mirror of
https://github.com/Twigpine/node.git
synced 2026-11-08 07:38:18 -10:00
* feat(node): add a persistent libp2p identity key file
Add p2p_key_path (--p2p-key-path / GITLAWB_P2P_KEY, default
~/.gitlawb/p2p.key) with a resolver mirroring resolved_key_path, and
load_or_create_p2p_keypair, which generates an Ed25519 keypair on first
start, persists it 0600, and loads it thereafter. Mirrors the existing
load_or_create_keypair idiom for the node identity PEM.
A corrupt or unreadable key file is a hard error naming the path rather
than a silent regeneration, so a disk problem cannot quietly rotate the
node's network identity.
Not yet wired into p2p::start; that follows.
* fix(node): load the libp2p identity from the persistent key file
p2p::start now takes the Ed25519 keypair loaded by
load_or_create_p2p_keypair instead of computing one from the node DID,
so a node's network identity is generated once from the OS RNG and kept
on disk rather than recomputed from a public value on every start.
The node DID parameter is gone from start; the call site loads the key
first and continues without p2p if the key file cannot be read, matching
how a swarm-start failure is already handled.
The gossipsub message_id_fn is untouched and keeps its own hasher.
* fix(node): create the p2p key with 0600 at creation and reject a loose one
Open the key file with create_new and the mode set at creation, then
fsync, instead of writing it and narrowing the mode afterwards. The
secret is never on disk under a wider mode, an interrupted start cannot
leave it readable, and the exclusive open also refuses a pre-existing
entry at the path and makes a concurrent start take the key that landed
rather than clobber it.
Refuse to load a key file whose mode grants group or other access, and
name the observed mode so the operator can fix it. Report an empty key
file as empty rather than surfacing a protobuf decode error that blames
a missing rsa feature.
Pin GITLAWB_P2P_KEY onto the mounted volume in the Docker and fly
configs and document it, so the key does not depend on home-directory
resolution to land on persistent storage.
* fix(node): publish the p2p key atomically and pin its directory
Write the key to a scratch file in the same directory and hard-link it
onto the final path. The bytes are durable before any name points at
them, so a crash cannot leave a partial key that fails to load on the
next start and takes the node off the network until someone reads the
logs. A concurrent reader can no longer observe a half-written file
either, since the final name appears complete or not at all.
hard_link rather than rename: rename replaces its destination silently,
so refusing to clobber an existing key would depend on a check followed
by a separate rename, and a concurrent start can land in that gap.
hard_link is atomic and refuses an occupied path, including a symlink,
which it does not follow.
Create the key directory 0700 and tighten it when an existing one grants
group or other access. A 0600 key under a writable directory can still be
replaced or unlinked. Tightening rather than refusing to start, because
existing installs already have 0755 there and refusing would take p2p
down on all of them through a path that only warns.
Formatting on the branch is swept up here; it was already failing
cargo fmt --check before this change.
* style(node): comma instead of a dash in the key-load warning
House style avoids em dashes in text we write. The swarm-failure warning
beside it predates this branch and is left alone.
* fix(node): require the p2p key path to name a directory
A bare filename in GITLAWB_P2P_KEY put the key in whatever directory the
process started from, and the directory guard was skipped entirely on that
path: Path::parent returns Some("") for a bare filename, which the caller
filtered out before ever reaching ensure_key_dir. The key file was created
0600 inside a directory that kept whatever mode it already had.
Config::validate now rejects a p2p key path that names no directory, so the
node says so at boot instead of starting with a key it cannot protect. That
placement is the point: an error raised in the p2p start path is logged and
stepped over, leaving the node running without p2p and reporting healthy.
The check is lexical on the tilde-resolved path. canonicalize would fail on a
parent that does not exist yet, which is the shipped ~/.gitlawb default and
every container's first boot, and comparing against the working directory
would reject /data/p2p.key under the image's WORKDIR, an absolute directory
the operator did name.
Three sites answered the parent question differently, which is how the gap
arose: one filtered the empty case out, one already normalized it, and one
opened "" and silently skipped its fsync. They now share key_parent, and
Config::validate calls it rather than adding a fourth answer.
load_or_create_p2p_keypair also refuses a path naming no directory. That is a
backstop behind the config gate, not the gate, so a later caller that skips
validation cannot quietly restore the old behaviour.
* test(node): run the key-permission probe in its own process
The probe zeroes the umask so the assertion means something: under a
restrictive ambient umask the bits are masked to 0600 regardless of whether
the code pins the mode, and the check passes either way.
Zeroing it in the shared test process is the problem. umask is process-global
and cargo runs these tests on threads, so any test creating a file in that
window inherits 000. Measured before this change: an unrelated concurrent
test's file was created 0666.
The probe now runs in a child process, where the zeroed umask cannot reach a
sibling and dies with the child. The parent is an ordinary test that runs
concurrently with everything else. Double-gated with #[ignore] plus an env
check so a bare --ignored sweep does not zero the umask in the shared process
after all.
The parent asserts the child ran exactly one test and that it passed, not
just that it exited 0. A libtest filter matching nothing runs zero tests and
still exits 0, so without that assertion a renamed fixture would read as a
green permission check while asserting nothing. Verified by pointing the
filter at a name that does not exist and watching the parent fail.
* docs(node): scope the p2p key permission claim and note the rotation
The old wording said the key file is "created with owner-only permissions"
without qualification, which is only true on Unix: every permission path in
p2p/mod.rs is cfg(unix), so on other platforms the file inherits whatever the
directory gives it and nothing is enforced. Say what is actually enforced and
where.
Also document what operators now have to do rather than leaving them to
discover it:
- GITLAWB_P2P_KEY must name a directory, since a bare filename is refused at
startup.
- The PeerId rotates once on the first start after upgrading, so a
GITLAWB_P2P_BOOTSTRAP multiaddr pinning a peer's old id with a /p2p/<PeerId>
suffix needs updating or dropping. Suffix-less addresses and the HTTP seed
list are unaffected.
- If the node reports tightening a loose key directory, the key that was in it
should be treated as possibly exposed and deleted so a fresh one is
generated.
* fix(review): close a `..` escape from the p2p key-path check
Two reviewers found the same hole independently: the check rejected a path
naming no directory, but a relative parent that walks back out through `..`
named one and still landed in the working directory. `a/../p2p.key` and
`./keys/../p2p.key` resolve to the cwd itself and `../p2p.key` resolves above
it, so all three put the key exactly where the check exists to keep it out of,
and had ensure_key_dir chmod that directory to 0700 on the way. Verified by
running the paths through the predicate and printing where each parent lands.
The rule is now that a relative key path must name a directory and must not
walk back out: at least one Normal component, no ParentDir. `..` inside an
absolute path stays accepted, since it cannot depend on where the process
started. The predicate moves into names_no_usable_directory next to key_parent,
and the config gate and the load_or_create_p2p_keypair backstop both call it, so
they cannot drift apart.
Also fixes two smaller gaps found in the same pass:
- The permission fixture could report "1 passed" while asserting nothing. Its
env gate returns early, and an early return is a passing test, so a renamed
variable would look green. It now prints a sentinel after its assertions and
the parent requires it. Confirmed by pointing the child at a different
variable and watching the parent fail.
- A GITLAWB_P2P_KEY starting with `~/` is refused when no home directory
resolves, instead of creating a literal `~` directory relative to wherever
the node happened to start.
The backstop had no test, so it has one now, along with a both-directions test
for the predicate. That test cleans up after itself: with the guard removed it
really does write a key next to the source, which broke a later run once.
* fix(review): reject `..` in an absolute p2p key path too
The previous commit closed this for relative paths and exempted absolute ones,
reasoning that an absolute path cannot depend on the working directory. That is
true and it is not the hazard. `key_parent` hands `ensure_key_dir` the lexical
parent, so `/data/keys/../p2p.key` chmods `/data` rather than the `keys`
directory the path appears to name, and `/data/../p2p.key` run as root would
try to tighten `/` to 0700. The exemption also had a test asserting the first of
those was fine, so the gap was written down as intended behaviour.
`..` is now rejected wherever it appears. An absolute path's root counts as
naming a directory, so `/p2p.key` still validates and `/data/keys/p2p.key` is
unaffected.
Found by a second-model review pass after the in-process reviewers had cleared
the relative half.
* fix(node): scrub the serialized p2p private key from memory
Two buffers held the private key in its protobuf form and dropped without
scrubbing: the encoding produced when a new identity is generated, and the file
contents read back on every subsequent start. Both are now Zeroizing, matching
what gitlawb-core already does for its own key material.
Scope worth being honest about: this scrubs our copies of the serialized form,
not the libp2p Keypair itself, which owns the secret for the process lifetime
and exposes no way to zeroize it. The gain is that the encoded bytes do not
outlive the write and the read.
zeroize was already in the tree through gitlawb-core, so this promotes it to a
direct dependency of gitlawb-node and adds no packages; the lockfile change is
the one line recording that.
* fix(node): validate the p2p key path before securing its parent
Reject filesystem-root parents, directory-valued paths, and symlinks before
any directory is created or chmodded. Create missing ancestors at the ambient
mode and pin only the nominated key directory to 0700. Refuse symlink reads
with O_NOFOLLOW, propagate directory durability sync failures, and document
the loose-key rejection path for operators.
* fix(node): satisfy clippy single-element-loop on p2p path test
* fix(node): tighten the p2p key storage contract before mutation
Anchor parent-directory checks on symlink_metadata, reject ~/ escapes and
non-regular existing keys with a bounded read, treat directory AlreadyExists
as a first-boot race, and skip key-path validation when P2P is disabled.
* fix(node): refuse foreign-owned p2p key paths and cap reads on all platforms
Check directory and key ownership before chmod or load, walk ancestors for
foreign control, bound non-Unix reads to MAX_P2P_KEY_BYTES, and gate unix-only
fixture tests without panicking on other targets.
* fix(node): hold the p2p key directory open across its trust checks
Resolve the key path once, then do every check and mutation through an
O_DIRECTORY|O_NOFOLLOW handle on the parent (openat/fstat/fchmod/linkat),
so nothing can be swapped between the ownership check, the tighten, and
the key open. Key reads go through one bounded, non-blocking loader on
every platform; new keys are generated in an unlinked scratch file and
published atomically, with concurrent first boots converging on the
winner's identity. The storage contract is indexed by two matrix tests:
filesystem shapes in p2p (each refusal proven side-effect free against a
recursive snapshot) and path spellings plus the p2p-disabled guarantee
in config.
* fix(node): walk the p2p key ancestor chain descriptor-anchored
Replace the path-following metadata walk (foreign_ancestor_error) and the
post-walk create_dir_all gap with a single descriptor-anchored walk that
verifies and creates the chain together. Missing components are created at
0700 relative to the verified parent and re-verified; AlreadyExists is a race
the winner must pass. Non-unix builds keep create_dir_all.
* fix(node): refuse group-writable and non-directory p2p key ancestors
The ancestor walk now refuses any component with group or world write bits
unless sticky, and any intermediate component that is a symlink or another
object type. Tests drive the walk through real boots: 0770/0775 ancestors
refused before any key IO, 0755 and 1777-sticky ancestors accepted with stable
reload, and an intermediate symlink refused with its target untouched. Both
guards proven load-bearing by neuter revert-checks.
* fix(node): verify the cwd anchor for relative p2p key paths
A bare-relative key path is now walked from a verified working directory:
the cwd is opened as '.' with O_NOFOLLOW|O_DIRECTORY, fstat-verified for
ownership and write bits before any relative component is touched, and a
writable or foreign-owned cwd is refused before anything is created. The
refusal names the working directory path. Child-process fixtures prove both
directions without mutating the shared process cwd.
* test(node): stop rendering uids in p2p assert-message strings
CodeQL flags the uid-interpolating assert messages as cleartext logging to a
console sink. Every assert message in the p2p test module now avoids
interpolating uid values or the uid-bearing error text, while the substantive
assertions (the refusal names both uids, the ownership check fires before the
chmod) stay intact and are verified by the same assertions.
* test(node): add walk rows to the p2p key-storage contract matrix
The matrix now proves the ancestor-walk refusals table-driven: a
group-writable (0775) ancestor, a world-writable non-sticky (0777) ancestor,
and an intermediate symlink component are each refused with the no-mutation
snapshot discipline, while the existing boot rows still reload to a stable
PeerId. The fchmod-on-create path pins newly created ancestors to 0700
regardless of the ambient umask.
* docs(node): note the verified working directory for relative p2p key paths
README, .env.example, and the clap help for GITLAWB_P2P_KEY now state that a
relative key path is anchored at a verified working directory, and that an
unsafe cwd or ancestor is refused with p2p off while HTTP stays up. Also
normalizes two pre-existing em dashes in the README settings table.
* fix(node): pin and verify the mode of every key object this process creates
POSIX applies the process umask to every requested creation mode, so the
0700 passed to mkdirat and the 0600 passed to openat were requests rather
than results. Under a mask that strips owner bits the created directory
landed 0000 and the no-follow reopen failed EACCES before the repairing
fchmod was reached, so that half failed loudly. The scratch key was worse:
it was published unreadable, the boot that created it succeeded on its
already-open descriptor, and only the next boot lost p2p, behind a health
check that still reported healthy.
Every object this process creates now goes through one rule: create, pin
the mode on the object just created, reopen, and verify the achieved mode
by fstat. The directory pin is issued by name off the verified parent
before the reopen, because a directory that landed 0000 cannot be opened
at all, so a pin that waits for the reopen is unreachable in exactly the
case it exists for. A race winner is verified as-is and never chmodded,
which is the rule the ancestor walk already applied.
Pinned's field is private to the pin module and no constructor is
exported, so a descriptor that skipped the pin cannot be turned into a
key-directory handle or reach publication. That is what keeps the rule
from depending on review.
Two adjacent corrections the same defect exposed. The leaf mode predicate
was supersets-only: mode & 0o077 != 0 asks whether anything is granted
beyond the owner, so 0000, 0100 and 0400 all passed it while being
unusable, and an inherited setgid bit was judged on the wrong bit width. A
loose directory is still tightened; an over-closed one is now refused with
its remedy rather than widened, matching how an over-closed key file is
already handled. And an unreadable key or key directory now names its own
cause: every open failure previously reported a refused symlink, which is
what an operator saw for a key masked at creation.
The matrix runs first boot and fresh-process reload across umask 0000,
0022 and 0777, crossed with missing and existing ancestors and leaf, with
concurrent creators and an injected write failure. It runs in child
processes because umask is process global, and it requires at least one
successful concurrent creator so a row where every creator fails cannot
pass. All 21 rows were RED under umask 0777 before this change.
* fix(node): create and publish the node identity key through the pinned path
The node identity PEM had its own storage flow: create_dir_all with no
mode, then write, then set_permissions. That is the exists-then-write-then-
chmod sequence INV-23 prohibits, and it sits in the same ~/.gitlawb the p2p
key uses, so the umask defect the previous commit fixed for one key was
still live for the other.
Measured on the current code, as a non-root uid, all three RED:
umask 0000 directory landed 0777, world-writable, holding the key
umask 0022 directory landed 0755, world-traversable
umask 0777 directory landed 0000 and the write failed with EACCES
The third is the one that mattered most: load_or_create_keypair runs before
the listener binds, so the node exited there and no p2p code was reached at
all. The umask-independence guarantee could not be demonstrated on the
shipped default while this stood.
The directory is now created through the same pin helper, at a verified
0700, and the PEM is published through the same scratch-then-link path at a
verified 0600. An existing directory that grants access beyond the owner is
tightened, which closes the other half of the same gap: a 0600 key inside a
0755 directory is still replaceable by anyone who can write that directory.
An existing key is loaded untouched and never chmodded.
Deliberately not the full ensure_key_dir. That carries the ancestor trust
walk, and importing its refusals onto a path that never had them would turn
an unsafe but currently booting deployment into a boot failure on upgrade.
Only the immediate parent goes through the pin helper, which verifies the
grandparent it is about to chmod a child of; ancestors above that keep the
existing create_dir_all behavior. The one new refusal is a group or world
writable non-sticky grandparent, which is the case where another local user
can replace the node's identity outright.
* fix(node): split p2p key config validation from live storage validation
The key-path validator answered two different questions and its callers
disagreed about what to do with the answer. Bare names, `..`, a trailing
separator and the filesystem root are properties of the configured value,
and Config::validate refuses them before the listener binds. The same
function also stat'd the key path and its parent, so a symlinked parent, a
non-directory parent or an unreadable parent exited the node as invalid
configuration, while the identical class of fault found one layer later in
load_or_create_p2p_keypair only logged and left HTTP serving.
That split was invisible from the outside and the docs described only one
half of it. Measured against the binary before this change: a symlinked
parent, a regular-file parent and an unreadable parent all exited 1 before
bind, which is the opposite of what README and .env.example promise.
Validation is now lexical only, behind a P2pKeyConfigError so the two
domains cannot be confused at a call site. Every live storage fact is left
to the load path, which already re-establishes each one on a descriptor it
opened rather than a pathname it stat'd: O_NOFOLLOW on the key open, a
regular-file check by fstat, and ELOOP or ENOTDIR at the leaf. The verdicts
are unchanged; what changes is that one policy now decides all of them.
Two supporting changes. The whole p2p port gate moves ahead of the database
connect, both arms together, because connect_db_with_retry retries forever
and left the disabled arm unreachable without a database; only p2p::start
still needs the pool. And a failed key load is now logged at error with a
stable event name and mirrored into a gauge, because the policy this commit
settles on is to keep serving HTTP with a green health check while the node
is off the p2p network, and that is only defensible if the outage is
visible to something other than a human reading startup logs.
A new integration test drives the real binary with no database and proves
both domains at the process boundary: nine lexical spellings exit before
binding, ten storage faults serve HTTP with the failure logged, and
GITLAWB_P2P_PORT=0 reaches its disabled log without touching the key tree.
Every row asserts a before/after snapshot, because a refusal that mutates
storage on its way out is its own defect and a returned error cannot show
it. Three of those rows were RED before this change.
A symlinked key directory also reports as a symlink again. Linux returns
ENOTDIR rather than ELOOP when O_NOFOLLOW meets O_DIRECTORY, so the errno
alone cannot separate a symlink from a regular file and the message had
regressed to naming the wrong cause.
* docs(node): state the two-domain key-path policy and how to see a degrade
README and .env.example promised that p2p stays off while HTTP keeps
serving when the key cannot be loaded, including for an unsafe ancestor or
working directory. Measured against the binary, that was false for a
symlinked parent, a non-directory parent and an unreadable parent, each of
which exited before binding. The preceding commit made the code match the
promise; this makes the promise precise.
All three operator surfaces now name the split the same way. A value that
cannot name a securable key file is refused before the node binds. Anything
wrong with the storage itself degrades, and the sentence says how to notice:
the node logs p2p_identity_key_load_failed at error and sets the matching
metric, /health stays green in that state, and fixing the storage and
restarting restores the same PeerId. A node quietly off the network is the
cost this policy accepts, so the docs point at the signal rather than
leaving an operator to find it in startup logs.
Two corrections while here. The key directory is tightened only when it
grants access beyond its owner; one closed too far is refused with a
chmod 700 remedy rather than widened, so the sentence no longer implies the
node will open up a directory an operator locked down. And the existing
advice to delete a key after a tightening warning is now scoped to the
loosening case it was written for: an over-closed directory is refused, not
tightened, and nothing in it was ever exposed, so following that advice
would have meant deleting a safe key and taking a second PeerId rotation
that breaks every pinned bootstrap multiaddr.
* test(node): pin the degrade signal and wait for both startup lines
Two gaps in the boundary test, both found by extending the mutation spec to
cover this commit's own guards.
The degrade rows asserted only the prose message, so renaming the event
field or dropping the level would have passed while every alert built on
the operator docs broke and the node still looked healthy. The rows now
pin the error level and the p2p_identity_key_load_failed name the docs tell
operators to watch.
The rows also waited on a single log line, which is a race: the degraded
server logs "ready" from a spawned task while the p2p gate logs its verdict
from the main task, so whichever the reader stopped at first left the other
uncaptured. It passed when the test binary ran directly and when the row ran
alone, and failed only once other work shifted the scheduling. Reads now
wait for every needle before returning.
The fix stays on the test side on purpose. Logging the p2p verdict before
the degraded server is spawned would also make the order deterministic, but
that is production sequencing changed for a test's convenience.
* fix(node): keep key publication transactional and path-policy split
Bare GITLAWB_KEY filenames publish into the working directory without
chmodding it. A directory this process created is removed if pin or
verify fails. Ancestor walks use search descriptors so a safe 0111
component is traversable. Scratch unlink is part of publish success and
is fsynced. Rotation advice is limited to writable or readable exposure,
not ordinary 0755 tightening.
* fix(node): refuse a writable cwd for a bare identity key
Publishing identity.pem into cwd must not chmod cwd, but a 0600 key is still replaceable if that directory is group or world writable. Apply the same trusted-parent check used for nominated key directories before the handle is returned.
* fix(node): load identity without following pathnames
Create already published through a directory handle. Load still used exists-then-read, which followed a symlink at the key and opened the grandparent by pathname. Open both no-follow, treat /identity.pem like a cwd publish so it reaches the filesystem, and open a search-only grandparent with the walk flags.
* fix(node): parse key paths, leave identity parents, repair special modes
A terminal `.` in GITLAWB_P2P_KEY was accepted then retargeted by Path onto
the parent directory. Identity creation imported p2p's 0700 pin onto any
existing named parent. A setgid-only 2700 key directory skipped repair and
disabled P2P. Darwin ancestor walks still required directory-list permission.
* fix(node): type-check the non-unix p2p scratch writer
The shared scratch-then-link path names pin::Pinned on every target, but
the pin module was unix-only, so gitlawb-node did not compile off Unix.
* fix(node): refuse identity path spellings that Path would retarget
GITLAWB_KEY had the same terminal-dot hole as GITLAWB_P2P_KEY, and its
tilde expansion did not refuse a suffix that escaped home. Bare filenames
stay legal.
* fix(node): create every missing identity parent on one pinned walk
The identity key path split one creation transition across two policies.
`create_dir_all` built every ancestor above the immediate parent at the
ambient umask, then `create_dir_pinned_at` ran `verify_trusted_parent` on
the last of those and refused it. Under umask 0002 that ancestor lands
0775, so the phase that made it handed it to the phase that rejects it:
the node exits before binding and the failed boot leaves the directory
behind at 0775, so the next attempt starts from state the first one made.
Replace the split with one descriptor-anchored walk. Pass 1 walks up from
the key's parent until a component opens; that descriptor is the anchor, and
it is the same fd the next step creates into. Pass 2 walks back down calling
`create_dir_pinned_at` per component, each one pinned 0700 and verified at
its achieved mode before it becomes the parent of the next, so from the
anchor down no pathname is resolved twice. Pre-existing ancestors are
adopted as-is and never chmodded, since GITLAWB_KEY is a file path rather
than a dedicated-directory setting; write-authority is still required on the
directory each component is created in.
A boot that fails past the walk now removes exactly what it created,
deepest first, through unlinkat with AT_REMOVEDIR against the descriptor
each entry was made in. A race-adopted directory is never in that list, and
AT_REMOVEDIR refuses a non-empty directory, so a leaf another boot has
published into survives. The accumulator is an out-parameter so a mid-walk
failure and a post-walk failure reach the same single rollback arm.
Two consequences worth naming. A symlink at the deepest existing ancestor is
refused when the missing suffix reaches it, where `create_dir_all` followed
it. Ancestors ABOVE that anchor are still resolved by pathname and their
symlinks still followed, unchanged from before this walk, because O_NOFOLLOW
binds only the final component of a path-based open; closing that would mean
an openat chain from the root and the full ancestor policy that GITLAWB_KEY
paths deliberately do not get. The doc comment now says so rather than
claiming more than the code does. And a losing concurrent first boot can
remove an intermediate another boot adopted but has not yet written into,
turning that boot's start into an ENOENT failure; both still fail closed.
Also replace the root-adjacent test. It proved a pathname property by
running the full mutating path against the real filesystem root and
assuming the runner lacks permission. Under euid 0, common in build
containers, `/` is accepted (root-owned, 0755, no group or other write
bit), the production path returns Ok, the test's panic arm fires before its
cleanup assertion, and a real private key is left at /identity.pem, after
which the leading exists() check makes every later run a silent skip. The
routing is now asserted lexically through a named predicate, with no
filesystem access and no dependence on euid.
The matrix covers one, two and three missing levels under umasks 0000,
0002, 0022 and 0777, a pre-existing 0755 ancestor that must stay 0755, a
relative multi-level path, a symlinked deepest ancestor, refusal without
partial mutation, and a populated leaf whose removal must be refused and
reported. Created-directory modes are asserted by exact equality, and each
success row reloads in a fresh process and requires the same DID.
* test(node): race real concurrent first boots of the identity key
The rollback added with the pinned walk can remove an intermediate that a
concurrent boot has already adopted, and until now that was reasoned about
rather than executed. Nothing in the suite started two real first boots
against the same key path.
Add a `concurrent-first-boot` layout to the self-exec fixture and a driver
that races several children through `load_or_create_keypair_at` on one
missing multi-level path. A two-phase file rendezvous puts every child
inside a spin loop before any of them is released, so the spread across the
racing call is one loop iteration rather than process-startup jitter. Three
arms: unaided boots, one injected write failure against one unaided boot,
and four injected failures against one, which is what actually opens the
window.
The assertions are properties of every legal outcome, never one
interleaving, so the test is deterministic even though the race is not. A
boot that claims success has a parseable 0600 key on disk, all successes
agree on the DID, surviving directories are exactly 0700 with no scratch
residue, and if no key was published then nothing claimed success and a
retry must still work. Observation counters are printed, not asserted, so a
run that never hits the window says so rather than passing as proof.
Two things the race showed that reading could not. The documented window is
real: a boot was refused with "No such file or directory" on its publish
after a loser rolled back the leaf it had adopted. But the more common shape
is one hop earlier, the loser removing an adopted intermediate so the ENOENT
lands on the next component's creation instead. And under enough concurrent
rollbacks every boot can fail, not merely the losers, so several nodes
starting against a shared first-boot path can all need a restart. Two-child
racing never opened the window at all in 500 iterations.
* fix(node): resolve identity key path components one at a time
`O_NOFOLLOW` binds only the final component of a path-based `open(2)`, which
is all the kernel offers for a multi-component pathname. Three sites resolved
a whole configured path in one open and so guarded only the last position:
the load path, the existing-named-parent fast path, and the walk that creates
missing parents. Every interior symlink was followed, and `verify_trusted_parent`
then attested the directory that was reached rather than the path taken to it.
That is enough to substitute the node's whole identity, not merely misplace
its key. Load and create resolve the configured pathname identically, so
repointing one interior link redirects both. Point it at a directory holding
another PEM and the node boots as that DID while its real key sits untouched
on disk and nothing is logged as wrong. Point it at an empty directory and
the node mints a fresh identity and reports a first boot. Neither needs the
attacker to read anything: ownership and mode checks still pass, because they
run on the object at the end of the path.
Resolve each component with `openat` and `O_NOFOLLOW` against the descriptor
of the one before it, from `/` for an absolute path or the working directory
for a relative one, so a symlink is refused at every position. Interior
components open search-only and the final component keeps the caller's flags,
so a legitimate 0111 ancestor still resolves and the leaf descriptor is still
the one that gets fsynced and chmodded.
Components are adopted with no ownership or mode judgment. The only new
refusal is a symlink on the key path: a shared 0775 volume, a foreign-owned
ancestor and `/etc` all keep working, because `GITLAWB_KEY` is a file path
rather than a dedicated-directory setting. `verify_trusted_parent` stays
exactly where it was, on the descriptor a child is created in. The p2p key
path is untouched; it already walked component by component.
This is pre-existing rather than a regression: the same probe against the
branch point produces the same result. It is fixed here because closing it
replaces the walk this branch just added, so splitting it would mean
reviewing that code twice.
Covered by rows for an interior symlink with the next level present, an
absolute target escaping the base, a two-link chain, the fast path, a
relative configured path, and both load-path variants asserting on the DID
the node ends up presenting rather than on a mode or an error string. The
cases that were already refused are pinned too, including a symlink at the
immediate parent and a dangling link, neither of which had a test before.
---------
Co-authored-by: Kevin Codex <[email protected]>
98 lines
4.6 KiB
Docker
98 lines
4.6 KiB
Docker
# syntax=docker/dockerfile:1.6
|
|
#
|
|
# gitlawb-node — production image for operators.
|
|
# Multi-arch (linux/amd64, linux/arm64), non-root runtime, HEALTHCHECK.
|
|
|
|
# ── Build stage ─────────────────────────────────────────────────────────────
|
|
FROM rust:1.91-bookworm AS builder
|
|
|
|
WORKDIR /build
|
|
|
|
# Cache dependencies first for faster rebuilds.
|
|
# EVERY workspace member's manifest must be copied here, not just the ones this
|
|
# image ships: cargo loads the whole workspace before it resolves anything, so a
|
|
# missing member's Cargo.toml (or a path dependency pointing at one) aborts the
|
|
# layer before a single crate is fetched.
|
|
COPY Cargo.toml Cargo.lock ./
|
|
COPY crates/gitlawb-core/Cargo.toml crates/gitlawb-core/
|
|
COPY crates/gitlawb-node/Cargo.toml crates/gitlawb-node/
|
|
COPY crates/gl/Cargo.toml crates/gl/
|
|
COPY crates/git-remote-gitlawb/Cargo.toml crates/git-remote-gitlawb/
|
|
COPY crates/gitlawb-attest/Cargo.toml crates/gitlawb-attest/
|
|
COPY crates/icaptcha-client/Cargo.toml crates/icaptcha-client/
|
|
|
|
# Fetch deps (this layer caches until Cargo.{toml,lock} change).
|
|
# No `|| true`: this layer is expected to succeed, and swallowing its exit code
|
|
# is what let it sit dead from the moment gl gained a path dependency on
|
|
# icaptcha-client. A failure here should stop the build, not silently turn every
|
|
# image build into a cold one.
|
|
RUN mkdir -p crates/gitlawb-core/src crates/gitlawb-node/src crates/gl/src \
|
|
crates/git-remote-gitlawb/src crates/gitlawb-attest/src crates/icaptcha-client/src && \
|
|
echo 'fn main() {}' > crates/gitlawb-node/src/main.rs && \
|
|
echo 'fn main() {}' > crates/gl/src/main.rs && \
|
|
echo 'fn main() {}' > crates/git-remote-gitlawb/src/main.rs && \
|
|
echo '' > crates/gitlawb-core/src/lib.rs && \
|
|
echo '' > crates/gitlawb-attest/src/lib.rs && \
|
|
echo '' > crates/icaptcha-client/src/lib.rs && \
|
|
cargo build --release --locked -p gitlawb-node -p gl -p git-remote-gitlawb
|
|
|
|
# Now copy real sources and build for real.
|
|
# Force-bump mtimes so cargo's fingerprint check rebuilds — without this,
|
|
# cargo can keep the dummy `fn main() {}` binaries from the cache layer above
|
|
# and the runtime container exits immediately with code 0.
|
|
COPY crates/ crates/
|
|
COPY bootstrap-peers.json ./
|
|
RUN find crates -name "*.rs" -exec touch {} + && \
|
|
rm -f target/release/gitlawb-node target/release/gl target/release/git-remote-gitlawb && \
|
|
rm -rf target/release/.fingerprint/gitlawb-node-* \
|
|
target/release/.fingerprint/gl-* \
|
|
target/release/.fingerprint/git-remote-gitlawb-* && \
|
|
cargo build --release --locked -p gitlawb-node -p gl -p git-remote-gitlawb && \
|
|
strip target/release/gitlawb-node target/release/gl target/release/git-remote-gitlawb
|
|
|
|
# ── Runtime stage ───────────────────────────────────────────────────────────
|
|
FROM debian:bookworm-slim
|
|
|
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
git \
|
|
ca-certificates \
|
|
curl \
|
|
tini \
|
|
&& rm -rf /var/lib/apt/lists/*
|
|
|
|
# Non-root user for runtime
|
|
RUN groupadd -r gitlawb --gid=1000 \
|
|
&& useradd -r -g gitlawb --uid=1000 --home-dir=/data --shell=/sbin/nologin gitlawb \
|
|
&& mkdir -p /data/repos /data/keys \
|
|
&& chown -R gitlawb:gitlawb /data
|
|
|
|
COPY --from=builder /build/target/release/gitlawb-node /usr/local/bin/
|
|
COPY --from=builder /build/target/release/gl /usr/local/bin/
|
|
COPY --from=builder /build/target/release/git-remote-gitlawb /usr/local/bin/
|
|
|
|
USER gitlawb
|
|
WORKDIR /data
|
|
|
|
ENV GITLAWB_REPOS_DIR=/data/repos \
|
|
GITLAWB_KEY=/data/keys/identity.pem \
|
|
GITLAWB_P2P_KEY=/data/keys/p2p.key \
|
|
GITLAWB_HOST=0.0.0.0 \
|
|
GITLAWB_PORT=7545 \
|
|
GITLAWB_P2P_PORT=7546
|
|
|
|
EXPOSE 7545 7546/udp
|
|
|
|
VOLUME ["/data"]
|
|
|
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
|
CMD curl -fsS http://127.0.0.1:${GITLAWB_PORT}/health || exit 1
|
|
|
|
# Run under tini so the node is never PID 1. As PID 1 a process must reap
|
|
# reparented orphans itself; the node does not, so git's reparented grandchildren
|
|
# (e.g. pack-objects orphaned when a served upload-pack dies on a client
|
|
# disconnect) would accumulate as zombies until fork() fails with EAGAIN (#53).
|
|
# tini reaps them and forwards SIGTERM/SIGINT to the node, which runs its own
|
|
# graceful shutdown. (No `-g`: the node manages its own children, so group-wide
|
|
# signalling would only disturb in-flight git helpers during shutdown.)
|
|
ENTRYPOINT ["tini", "--", "gitlawb-node"]
|