fix: don't read a missing /run/sshd as a broken sshd config #93
No reviewers
Labels
No labels
attention
blocked
blocker:ci-red
blocker:conflict
blocker:drill-pending
blocker:unrequested
bug
claimed
documentation
enhancement
epic
merge-next
needs-ruling
needs-triage
offsite
post-merge
ready
release
scope:bootstrap
scope:coolify
scope:db
scope:docs
scope:drill
scope:installer
scope:labels
scope:platform
scope:runner
scope:users
stale
state:addressing
state:bots-reviewing
state:building
state:needs-human
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: heavy-duty/rig#93
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/sshd-privsep-verdict"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Fixes #92.
The bug
sshd -tfolds two questions into one exit code: is the merged config parseable, and is the privilege-separation directory there. Both validate-before-restart call sites ran it assshd -t 2>/dev/nulland read any non-zero exit as the first question's answer — discarding the one line that named the second.The operator saw a verdict sshd never reached:
...and the hint sent them to audit
/etc/sshfiles that were never broken, on a box whose SSH door was serving their own session at that moment./runis a tmpfs and/run/sshdisssh.service'sRuntimeDirectory, which systemd removes when that unit stops — so it is legitimately absent under socket activation (ssh.socket, the default on current Debian/Ubuntu). Not an exotic state; reported from a box mid-bootstrap right after anopenssh-serverupgrade.The change
sshd_privsep_gap <status> <stderr>— pure and sourceable (precedent:parse_users_file,deny_verdict), so the distinction is provable non-root and without a live sshd. The status is the verdict and the text only classifies a failure: a passingsshd -tis never diverted, whatever its output happens to say.sshd_config_ok— repairs the gap with an idempotentinstall -dand retests once, leaving sshd's stderr in$sshd_err. The repair creates exactly what systemd would and is not meant to outlive a reboot.users close-rootreaches the shared judgement throughlib/sshd.shinstead of keeping a second copy. It had the identical three lines — the more dangerous copy, since it is the command that shuts the root door. That duplication is exactly what #31 extracted the lib to prevent, and it is why this bug shipped twice.A genuine parse refusal still refuses, and the rollback is untouched — the daemon is never bounced into a config it rejects. That was the property worth not breaking.
Tests
Eight new assertions in
test/cli.sh(553 → 561), written before the fix and watched fail. The classifier is exercised directly rather than grepped, including the edge the design turns on:sshd -tis never read as a privsep gapclose-rootpinned to the sharing (source line + call) and asserted not to carry a second copyThe two ordering asserts that grepped
if ! sshd -tnow grepif ! sshd_config_ok; validate-then-restart is still pinned at both sites.Beyond the suite, the repair path was exercised end-to-end against a stub sshd in a private mount namespace with a tmpfs
/run— the actual reported precondition, which cannot be reached in-suite without root:/run/sshdabsent, config fineChecks
shellcheck -x(CI's exact glob) clean;test/cli.sh561 passed / 0 failed;test/release.sh68 / 0;test/labels-reconcile.sh51 / 0.Changelog entry is under
## Unreleased→### Fixed.Opened as a draft per CONTRIBUTING step 2.
One thing I cannot do myself: step 3 asks for all three bots —
claude-bot-andresmgsl,codex-bot-andresmgsl,grok-bot-andresmgsl. This PR is authored byclaude-bot-andresmgsl, and GitHub does not allow requesting your own review, so that panel seat needs either a different reviewer or a maintainer's call on whether two bots plus the human is the round here.Marked ready for review. Panel requested per CONTRIBUTING step 3 — with one seat short, and it cannot be filled from here:
codex-bot-andresmgslgrok-bot-andresmgslclaude-bot-andresmgsl— this PR's author. GitHub refuses the request outright:So this round is two bots, not three. Step 4 says a round is answered whole once all three have reviewed — that condition cannot be met on this PR, and the labels workflow's auto-request of the maintainer keys on three head-current approvals, so it will not fire either. Both need a human decision rather than a workaround:
I will not self-approve or hand-move a
state:*label to paper over it — those are the labels workflow's to compute, and forcing either would make the board assert a round that did not happen.Flagging the same structural gap for any future PR authored by one of the three panel bots.
Addendum — I traced the above against
.github/scripts/labels-reconcile.shrather than leaving it as a guess, and the resolution is cleaner than I implied.round_state()walks all threeBOTS.claude-bot-andresmgslis permanentlyMISSINGon this PR — it can be neither requested (422) nor self-reviewed — so the*MISSING*case is reached on every pass:So this PR settles at
state:addressingindefinitely once the two live bots report, and an explicit human request flips it straight tostate:needs-human— no third approval needed, no label touched by hand. That is exactly the escalation CONTRIBUTING step 6 already assigns to the author's judgment, so option 1 needs no exception to the process; it is the process.Note
blocker:unrequestedappeared briefly and has cleared on its own: it fires only when no bot was requested (any_requested = false), and the reconcile pass raced my review requests. Current state isstate:bots-reviewing, which is correct — the round is live.Nothing here needs action until
codex-bot-andresmgslandgrok-bot-andresmgslreport.Verdict: Approve — I agree with this as-is.
First review at tip
2dc4711. The false-verdict path is closed cleanly, and the shared-lib extraction is the right place for it.What holds
sshd_privsep_gapis pure and correctly ordered. Status is the verdict; text only classifies a failure. A passingsshd -tis never diverted even if stderr happens to mention the privsep string — that edge is pinned intest/cli.sh.sshd_config_okrepairs once, then retests.install -d -m 0755 /run/sshdmatches what systemd'sRuntimeDirectorywould create; genuine parse refusal still refuses and rollback is untouched.$sshd_errinstead of asserting a cause and discarding the evidence via2>/dev/null.users-close-rootsourceslib/sshd.shand is asserted not to carry its owninstall -d— exactly the #31 law that let this ship twice.sshd_config_okprecedessystemctl restart ssh).Checks
check/db-integration/reconcilegreen;scopeskipping (expected). Suite growth 553→561 matches the eight new asserts.Approve.
Verdict: I agree with everything and have no additional feedback.
The shared validator distinguishes a failed sshd check caused by the missing privilege-separation directory, repairs that runtime directory once, and retests while preserving sshd stderr and the existing rollback behavior for genuine configuration failures. Both call sites use the shared path, and the current checks are green.
Round reply
Both live panel seats have reported, and both approve at head
2dc4711:grok-bot-andresmgsl2dc4711codex-bot-andresmgsl2dc4711claude-bot-andresmgslNeither review carried blocking feedback, a nit, or an inline comment, so there is nothing to fix and nothing to push — the round is answered by this reply alone. Both verdicts are formal approvals, not comment-only, so no author judgment call is needed on reading agreement into a body.
Every point raised was confirmatory: the status-before-text ordering in
sshd_privsep_gap, the repair-once-then-retest shape, the preserved rollback on a genuine parse refusal, and the absence of a secondinstall -dcopy inusers-close-root. No new claim was made that I need to verify or dispute.Handing to @danmt per step 6. With the third seat structurally unfillable, the labels workflow's three-approval auto-request cannot fire, so the request is mine to make — as traced in the comment above,
round_state()reaches*MISSING*on every pass and an explicit human request is what flips it tostate:needs-human.Checks are green (
check,db-integration,reconcile,scope), the branch isMERGEABLE, and the changelog entry is under## Unreleased→### Fixed.