fix: dead-updater detection, slug allowlist, SSH-off gate on factory-reset poweroff - #52
Merged
Merged
Conversation
…vers non-gift factory reset Ported from litclock-dev (requalified, byte-verified): - updates: /api/update/status exposes unit_busy on running payloads; the poll loop stops on a SUSTAINED dead-updater signal (5 consecutive idle-unit readings, ~10s) with honest terminal copy instead of polling a corpse status file forever. Not a reload (the running file lies until reboot clears tmpfs, so a reload would loop) and not a route-side verdict (one sample can be the 2s memo lagging the dispatch window; a false 'dead' mid-run would resurrect litclock-dev#607). The cold-load probe no longer swaps the truthful card for a reading list on a dead candidate's word; a confirmed-busy poll promotes it. - download_images: anchored character allowlist on the derived repo slug (mirrors build-image.yml's ref_ok) — a crafted origin URL can no longer reshape the api.github.com request path/query. - sudoers 020: the litclock-dev#510 comment names the reset unit's ACTUAL ExecStart (--wipe-wifi --strict-env-wipe --poweroff --yes, litclock-dev#627) instead of the pre-#627 --reboot form. - first-boot: five doubled '|| return || return' deduped. - setup_server: SSID_FORBIDDEN now includes U+2028/U+2029 so the reject-before-journal comment stops overclaiming. Public-only (litclock-dev#636 headline, maintainer decision): - reset-setup: the #528 SSH-off + port-22 verification gate is extracted into disable_ssh_for_handoff() and now runs on BOTH handoff paths — gift mode AND the non-gift factory-reset poweroff, whose PWA copy invites 'move or pass the clock on'. An owner who ever enabled SSH no longer hands over a device with SSH + default creds listening. The --reboot and no-flag paths leave SSH alone (the device stays the owner's). Ordering safety carries over: --poweroff implies --strict-env-wipe, so a failed wipe aborts before the gate and SSH stays available for recovery. The development repo keeps SSH on for bench access; this divergence is deliberate and documented on litclock-dev#636.
kapoorankush
added a commit
that referenced
this pull request
Aug 12, 2026
…s (post-merge /review) (#53) Follow-up from the full /review pipeline on the merged #636 port (#52). The code was sound (Codex structured + security + performance + maintain- ability all clean), but the specialists found real gaps: - reset-setup: the SSH-off gate was only pattern-matched, never EXECUTED. A security gate you never run is unverified. Added behavioral tests that extract disable_ssh_for_handoff() and run it under bash with stubbed systemctl/raspi-config/ss: port 22 open -> exit 1 (no poweroff); only :2222/:220 -> proceeds (pins grep -qx 22); no ports -> proceeds. And a fail-open fix: Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port Process u_str ESTAB 0 0 * 1137776 * 1135318 u_dgr ESTAB 0 0 * 1137016 * 5169 u_str ESTAB 0 0 /run/systemd/journal/stdout 10524 * 8661 u_dgr ESTAB 0 0 /run/systemd/journal/dev-log 5167 * 0 u_dgr ESTAB 0 0 /run/systemd/journal/socket 5169 * 0 u_str ESTAB 0 0 /run/dbus/system_bus_socket 1135278 * 1136321 u_str ESTAB 0 0 /run/systemd/journal/stdout 12309 * 11902 u_dgr ESTAB 0 0 * 1137024 * 1137023 u_str ESTAB 0 0 /run/dbus/system_bus_socket 1136439 * 1136949 u_dgr ESTAB 0 0 * 1137802 * 1137801 u_str ESTAB 0 0 /run/dbus/system_bus_socket 1136413 * 1137796 u_dgr ESTAB 0 0 * 1137022 * 1137021 u_dgr ESTAB 0 0 * 1136456 * 5169 u_str ESTAB 0 0 * 8633 * 10471 u_dgr ESTAB 0 0 * 8635 * 5167 u_dgr ESTAB 0 0 * 1137023 * 1137024 u_dgr ESTAB 0 0 * 1137937 * 5169 u_dgr ESTAB 0 0 * 1136445 * 6231 u_dgr ESTAB 0 0 * 1137800 * 1137799 u_str ESTAB 0 0 * 8797 * 8796 u_str ESTAB 0 0 * 1140315 * 1140902 u_str ESTAB 0 0 * 1137001 * 1137873 u_str ESTAB 0 0 * 11902 * 12309 u_str ESTAB 0 0 * 8661 * 10524 u_str ESTAB 0 0 * 1140328 * 1143611 u_dgr ESTAB 0 0 * 1137021 * 1137022 u_dgr ESTAB 0 0 * 1137790 * 5169 u_str ESTAB 0 0 /run/systemd/journal/stdout 10471 * 8633 u_str ESTAB 0 0 /run/systemd/journal/stdout 1135318 * 1137776 u_str ESTAB 0 0 /run/systemd/journal/stdout 1140902 * 1140315 u_str ESTAB 0 0 /run/systemd/journal/stdout 1137164 * 1137932 u_str ESTAB 0 0 /run/systemd/journal/stdout 1137873 * 1137001 u_str ESTAB 0 0 /run/dbus/system_bus_socket 1137026 * 1137025 u_dgr ESTAB 0 0 * 1137801 * 1137802 u_dgr ESTAB 0 0 * 1136922 * 5169 u_dgr ESTAB 0 0 * 1137799 * 1137800 u_str ESTAB 0 0 * 8796 * 8797 u_str ESTAB 0 0 /run/systemd/journal/stdout 1143611 * 1140328 u_dgr ESTAB 0 0 * 1137941 * 1137942 u_str ESTAB 0 0 * 1137223 * 1137224 u_str ESTAB 0 0 /run/user/1000/bus 1136951 * 1136950 u_str ESTAB 0 0 /run/systemd/journal/stdout 1137224 * 1137223 u_str ESTAB 0 0 /run/dbus/system_bus_socket 1140426 * 1140425 u_str ESTAB 0 0 /run/systemd/journal/stdout 1141076 * 1141896 u_str ESTAB 0 0 /run/systemd/journal/stdout 1141084 * 1140439 u_dgr ESTAB 0 0 * 1137942 * 1137941 u_dgr ESTAB 0 0 * 1140441 * 5167 u_str ESTAB 0 0 /run/systemd/journal/stdout 10292 * 7437 u_dgr ESTAB 0 0 * 1140427 * 5167 u_dgr ESTAB 0 0 * 1138703 * 5169 u_dgr ESTAB 0 0 * 10337 * 5169 u_str ESTAB 0 0 * 1564014 * 1564015 u_str ESTAB 0 0 /run/systemd/journal/stdout 1424373 * 1420951 u_str ESTAB 0 0 * 1420951 * 1424373 u_str ESTAB 0 0 /run/dbus/system_bus_socket 10371 * 10359 u_str ESTAB 0 0 * 1141852 * 1140348 u_str ESTAB 0 0 * 1564015 * 1564014 u_dgr ESTAB 0 0 * 1426239 * 5167 u_dgr ESTAB 0 0 * 1141816 * 5167 u_str ESTAB 0 0 /run/dbus/system_bus_socket 1137242 * 1138710 u_str ESTAB 0 0 /run/systemd/journal/stdout 5078 * 8457 u_str ESTAB 0 0 /run/systemd/journal/stdout 1140348 * 1141852 u_str ESTAB 0 0 * 7437 * 10292 u_dgr ESTAB 0 0 * 8564 * 5167 u_str ESTAB 0 0 * 1140439 * 1141084 u_str ESTAB 0 0 /run/systemd/journal/stdout 1141004 * 1141866 u_str ESTAB 0 0 * 1141866 * 1141004 u_str ESTAB 0 0 * 1140425 * 1140426 u_str ESTAB 0 0 * 1143599 * 1143600 u_str ESTAB 0 0 * 8457 * 5078 u_str ESTAB 0 0 * 1143600 * 1143599 u_str ESTAB 0 0 * 1141896 * 1141076 u_dgr ESTAB 0 0 * 1140452 * 5167 u_str ESTAB 0 0 /run/systemd/journal/stdout 10599 * 10598 u_str ESTAB 0 0 * 10384 * 10385 u_str ESTAB 0 0 /run/systemd/journal/stdout 10385 * 10384 u_dgr ESTAB 0 0 * 10402 * 5167 u_dgr ESTAB 0 0 * 10609 * 5167 u_str ESTAB 0 0 /run/dbus/system_bus_socket 1141095 * 1143681 u_str ESTAB 0 0 /run/dbus/system_bus_socket 7487 * 10403 u_str ESTAB 0 0 * 10403 * 7487 u_str ESTAB 0 0 * 10598 * 10599 u_str ESTAB 0 0 * 1143681 * 1141095 u_dgr ESTAB 0 0 * 14482 * 5167 u_str ESTAB 0 0 /run/dbus/system_bus_socket 1143652 * 1141003 u_dgr ESTAB 0 0 * 6232 * 6233 u_str ESTAB 0 0 /run/dbus/system_bus_socket 8794 * 7954 u_str ESTAB 0 0 /run/dbus/system_bus_socket 1141024 * 1141023 u_str ESTAB 0 0 * 1141003 * 1143652 u_str ESTAB 0 0 * 7954 * 8794 u_str ESTAB 0 0 * 10280 * 10279 u_str ESTAB 0 0 * 1140963 * 1140343 u_str ESTAB 0 0 * 10279 * 10280 u_str ESTAB 0 0 * 1137932 * 1137164 u_str ESTAB 0 0 /run/systemd/journal/stdout 14469 * 11193 u_str ESTAB 0 0 * 9380 * 10328 u_str ESTAB 0 0 * 14484 * 14483 u_str ESTAB 0 0 /run/systemd/journal/stdout 1140343 * 1140963 u_str ESTAB 0 0 * 1140924 * 1141818 u_str ESTAB 0 0 * 14483 * 14484 u_str ESTAB 0 0 /run/dbus/system_bus_socket 1141818 * 1140924 u_str ESTAB 0 0 * 11193 * 14469 u_dgr ESTAB 0 0 * 7930 * 7931 u_str ESTAB 0 0 /run/systemd/journal/stdout 10328 * 9380 u_dgr ESTAB 0 0 * 7931 * 7930 u_dgr ESTAB 0 0 * 10278 * 5167 u_str ESTAB 0 0 * 1141023 * 1141024 u_dgr ESTAB 0 0 /run/systemd/notify 6231 * 0 u_dgr ESTAB 0 0 * 6233 * 6232 u_str ESTAB 0 0 @b73e375f172f205b/bus/systemd/bus-api-user 1136950 * 1136951 u_str ESTAB 0 0 @531ac269544b3aac/bus/systemd/bus-api-system 1136321 * 1135278 u_str ESTAB 0 0 @18dc0229ee6613a4/bus/systemd-network/bus-api-network 1137796 * 1136413 u_str ESTAB 0 0 @328d5fbd5f548868/bus/systemd-resolve/bus-api-resolve 1138710 * 1137242 u_str ESTAB 0 0 @ace6c9d74fd0a3dd/bus/systemd-timesyn/bus-api-timesync 1137025 * 1137026 u_str ESTAB 0 0 @5e8f494281e13045/bus/systemd/bus-system 1136949 * 1136439 u_str ESTAB 0 0 @63a321078db32580/bus/systemd-logind/system 10359 * 10371 ??? UNCONN 0 0 0.0.0.0:ipproto-255 0.0.0.0:* icmp6 UNCONN 0 0 *%ens18:ipv6-icmp *:* tcp ESTAB 0 0 192.168.3.100:58984 160.79.104.10:https tcp ESTAB 0 0 192.168.3.100:39044 160.79.104.10:https tcp ESTAB 0 0 192.168.3.100:44338 160.79.104.10:https tcp ESTAB 0 0 192.168.3.100:47722 160.79.104.10:https tcp ESTAB 0 0 192.168.3.100:35902 160.79.104.10:https tcp ESTAB 0 0 192.168.3.100:47884 34.149.66.165:https tcp ESTAB 0 0 192.168.3.100:44494 160.79.104.10:https tcp ESTAB 0 0 192.168.3.100:53982 160.79.104.10:https tcp ESTAB 0 0 192.168.3.100:55882 160.79.104.10:https tcp ESTAB 0 0 192.168.3.100:33990 160.79.104.10:https that ERRORS (nonzero, empty output) was read as 'verified closed' by the old pipe-to-grep — it now captures ss's rc separately and warns+proceeds like an absent ss, never a silent pass. - updates.js: both fireApply arms claim the run through one claimOptimisticRun() helper (was duplicated), with the deadUpdaterPolls reset invariant stated at the threshold declaration. The reconnect-resume path resets the corpse counter unconditionally but only enters the live reading list on unit_busy!==false (a corpse that outlived the Phase-7 restart hands back to the poll loop instead of showing a live view). - JS tests: the dialog-open promotion guard, verdict row-freeze, reconnect reset, and fireApply catch-path reset were untested — each now covered and verified to fail when its guard line is removed. - route test: a stale status file carries no unit_busy key even when busy. - download_images test: '&' + bare '.' repo segment added to the slug hostile vectors. - wifi test: SSID rejection pins U+2028/U+2029 + C1 NEL. - ref hygiene: the 7 bare #528 references (a dev issue) are now litclock-dev#528, matching the public convention. No behavior change reaches the fleet beyond the ss fail-open hardening and the reconnect-resume refinement; both are correctness improvements to paths that only run during an update or a factory reset.
kapoorankush
added a commit
that referenced
this pull request
Aug 16, 2026
…fixes (#56) Ports the two pre-RC blockers fixed upstream in litclock-dev#668 and #669. Both were found by review of the port PRs that brought #620 and #645 here, so this closes the loop on that train. litclock-dev#660 — the PWA "Factory reset" (litclock-reset.service runs `--wipe-wifi --strict-env-wipe --poweroff --yes`) did not rotate the setup-WiFi key that #620 had just made permanent. Because that path wipes WiFi, the next power-on raises the setup hotspot broadcasting LitClock-Setup with the PREVIOUS owner's key, permanently, surviving every reset the new owner later performs — while the card promises "Erases everything". v0.223.0 had no such leak, so this was a regression #620 introduced. The discriminator is `--wipe-wifi` AND the power-off, not either alone: a hand-run `--poweroff` never raises a hotspot, and `--wipe-wifi --reboot` is #620's same-owner WiFi reset, which must preserve. litclock-dev#667 — #645's link-local blanking removed the address from the same-IP short-circuit's coverage, so every dhcp4-change on a clock that cannot get a lease fell through to a full e-ink render (3 renders measured for 3 identical events). Review found the same hole for a genuinely empty address, which NM's defaults make at least as likely. Coalescing now keys on the observed address in its own tmpfs marker, while the blanked value stays the only thing diagnostics sees. Port notes: - `scripts/reset-setup.sh` HAND-MERGED. This repo has the litclock-dev#528/#636 SSH-off gate, which does not exist upstream at all — it was authored here in #52/#53 — so upstream's `--poweroff` arm has nothing to order against. The rotation call is placed BEFORE `disable_ssh_for_handoff`, matching the gift arm and for the same reason: the rotation fails closed and can exit 1, leaving the device with its current owner, who then still needs SSH to diagnose a read-only card. Verified after merging: the gate body is byte-identical to master and all four call sites survive. - `CLAUDE.md` deliberately NOT ported. The upstream half rewrites a #620 QA block this repo does not have (`grep -c 620 CLAUDE.md` → 0), so applying it would invent that block here. Same phantom-conflict trap as the #641/#648 half of the previous train. - `tests/test_reset_setup_sh.py` hand-merged per hunk rather than wholesale: the conflict replaced the old `_extract_block` helper with the new harness, but a whole-file resolution would have discarded this repo's SSH-gate tests. All twelve survive. - 4 bare dev#NNN references requalified. - Verified nothing was dropped: of every line the two upstream commits added, none is missing here. `test_gift_mode_rotates_hotspot_password_before_disabling_ssh` — added when the two features first met in #54 — is now parametrised over BOTH handoff arms, since #660 gave the `--poweroff` arm the same pairing and nothing covered it. Its anchor is also fixed: it matched the function name in the COMMENT above the call, so inverting the order in the new arm kept it green. Mutation-verified in both arms after the fix. Testing: - ruff + shellcheck clean - pytest: 3392 passed, 62 skipped - npm run test:js: 191 passed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Port of the litclock-dev#636 review-findings batch (litclock-dev PR #637, requalified and byte-verified), plus the public-only piece of that issue's headline: the factory-reset poweroff now enforces the SSH-off handoff gate.
Ported fixes
/api/update/statusexposesunit_busyonrunningpayloads; the poll loop stops after 5 consecutive idle-unit readings (~10s) with honest terminal copy instead of polling a corpse status file forever (therunningfile lies until reboot clears tmpfs, so this is deliberately a stop, not a reload — a reload would loop). The route never issues the verdict itself: one idle sample can be the 2s unit memo lagging the dispatch window, and a false "dead" mid-run would resurrect the litclock-dev#607 stale-card bug. The cold-load probe keeps the truthful server-rendered card rather than trusting a corpse file, promotes into the reading list once the unit is confirmed busy (with a dialog-open guard mirroring the litclock-dev#354 deferral), resets the corpse counter on every fresh running intent (pinned by a red-verified vitest case), and freezes the in-flight phase row when the verdict lands.|| return || returndeduped.SSID_FORBIDDENgains U+2028/U+2029.Public-only: SSH off on the non-gift factory reset
The factory-reset copy invites "move or pass the clock on", but the #528 SSH-off scrub only ran in gift mode — an owner who ever enabled SSH handed over a device with SSH + default creds listening the moment it joined the next network. The #528 gate (socket + service + raspi-config posture + boot-partition flags, then the port-22
ssverification) is extracted intodisable_ssh_for_handoff()and now runs on BOTH handoff paths: gift mode and the poweroff factory reset. The--rebootand no-flag paths leave SSH alone — the device stays the owner's.Safety properties carried over:
--poweroffimplies--strict-env-wipe, so a failed config wipe aborts long before the SSH gate and SSH stays available for recovery; if the port-22 verification itself fails (pathological — root-run disables failing), the script refuses to power off, same fail-closed posture gift mode has always had, with the diagnostics in journald. The development repo keeps SSH on (bench access); the divergence is deliberate and documented on litclock-dev#636.Gates: ruff clean; pytest 3,258 with the freetype-gated tests executed locally (zero relevant skips); vitest 188. Reviewed before opening (Claude adversarial + Codex adversarial; both independently found the corpse-counter leak, fixed in-branch with a regression test verified red without the fix).
Rides the v0.224.0 train.