Skip to content

fix: dead-updater detection, slug allowlist, SSH-off gate on factory-reset poweroff - #52

Merged
kapoorankush merged 1 commit into
masterfrom
fix/636-batch-and-handoff-ssh
Aug 12, 2026
Merged

fix: dead-updater detection, slug allowlist, SSH-off gate on factory-reset poweroff#52
kapoorankush merged 1 commit into
masterfrom
fix/636-batch-and-handoff-ssh

Conversation

@kapoorankush

Copy link
Copy Markdown
Owner

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

  • Updates tab, dead-updater detection: /api/update/status exposes unit_busy on running payloads; the poll loop stops after 5 consecutive idle-unit readings (~10s) with honest terminal copy instead of polling a corpse status file forever (the running file 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.
  • download_images: anchored character allowlist on the derived repo slug plus rejection of pure-dot repo segments — 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.
  • first-boot: five doubled || return || return deduped.
  • setup_server: SSID_FORBIDDEN gains 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 ss verification) is extracted into disable_ssh_for_handoff() and now runs on BOTH handoff paths: gift mode and the poweroff factory reset. The --reboot and no-flag paths leave SSH alone — the device stays the owner's.

Safety properties carried over: --poweroff implies --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.

…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
kapoorankush merged commit 18658d5 into master Aug 12, 2026
5 checks passed
@kapoorankush
kapoorankush deleted the fix/636-batch-and-handoff-ssh branch August 12, 2026 19:46
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant