Skip to content

fix(backend): re-apply persisted SSH password on boot after OTA slot swap#158

Merged
andrescera merged 1 commit into
mainfrom
fix/ssh-password-ota-slot-sync
Jul 17, 2026
Merged

fix(backend): re-apply persisted SSH password on boot after OTA slot swap#158
andrescera merged 1 commit into
mainfrom
fix/ssh-password-ota-slot-sync

Conversation

@andrescera

Copy link
Copy Markdown
Member

What

Adds ensureSshPasswordSynced() to the backend and wires it into the boot sequence
(main.ts, via guardNonCritical("ssh-password-sync", …)) immediately before the
boot getSshStatus() probe. On boot it compares the persisted ssh_pass_hash
(from config.json) against the live /etc/shadow hash and, on a mismatch,
re-applies the existing persisted ssh_pass through the same stdin-only passwd
path resetSshPassword() already uses.

Touches four files, backend-only: apps/backend/src/modules/system/ssh.ts,
apps/backend/src/main.ts, a new apps/backend/src/tests/ssh-password-sync.test.ts,
and the backend AGENTS.md.

Why

An OTA A/B slot swap silently reverts the operator's SSH password to the
factory-baked default — confirmed on real Rock 5B+ hardware. config.json
(ssh_pass + ssh_pass_hash) is /data-persisted and survives the swap, but the
OS-level /etc/shadow entry is rootfs-local: baked fresh into each image and not
carried across slots. After an OTA the freshly-activated slot holds the build-time
password while config.json still remembers the operator's real one, locking SSH
until a manual UI reset — on every update. Nothing re-applied it.

This mirrors the host-key restore the image already does in
ceralive-ssh-firstboot.sh::ensure_host_keys(): compare the persisted identity
against the live one and re-apply (never regenerate) on a mismatch.

How to verify

  • cd apps/backend && bun test src/tests/ssh-password-sync.test.ts — covers
    same-slot no-op, fresh-slot re-apply (proves the existing plaintext is applied,
    not a new one), unset-password no-op, boot-safety (never throws on passwd or
    shadow-read failure), and the dev-mock seam.
  • cd apps/backend && bun test — full backend suite green (1936 pass / 0 fail).
  • cd apps/backend && bunx tsc --noEmit and bunx biome check — both clean.

Risks

Low, and contained to a boot-time reconciliation:

  • Boot fail-soft: ensureSshPasswordSynced() never throws (wrapped in
    guardNonCritical); a sync failure logs + broadcasts a notification and returns —
    it can never brick boot.
  • No-op on the common path: a clean no-op under mocks, when ssh_pass is unset
    (nothing persisted yet), and on the same-slot boot where the OS already matches.
  • No credential change: it re-applies the existing persisted password and never
    generates a new one or writes config — only the OS /etc/shadow entry catches up.
    resetSshPassword() and startStopSsh()'s generate-if-missing branch are untouched.
  • Effectful surface (readShadow / applyPassword) is injected via
    SshPasswordSyncDeps, so the test suite drives it without a real passwd//etc/shadow.

…swap

config.json (ssh_pass + ssh_pass_hash) is /data-persisted and survives an A/B
OTA slot swap, but the OS-level /etc/shadow entry is rootfs-local — baked fresh
into each image and not carried across slots. After an OTA the fresh slot's
shadow held the build-time password while config.json still remembered the
operator's real one, locking SSH until a manual UI reset on every update
(confirmed on real Rock 5B+ hardware).

Add ensureSshPasswordSynced(), wired into the boot sequence via
guardNonCritical() right before the getSshStatus() probe. It mirrors
image-building-pipeline's ceralive-ssh-firstboot.sh::ensure_host_keys() restore
pattern: compare the persisted ssh_pass_hash against the live /etc/shadow hash
and, on a mismatch, RE-APPLY the EXISTING persisted ssh_pass via the same
stdin-only passwd path resetSshPassword() uses — never generating a new password
and never rewriting config. Boot-fail-soft (never throws), a no-op under mocks,
when ssh_pass is unset, or when the OS already matches. resetSshPassword() and
startStopSsh()'s generate-if-missing branch are untouched.
@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 294ed164-d8aa-4055-b2ad-0bc437d62209

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/ssh-password-ota-slot-sync

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@andrescera
andrescera merged commit 4e0c109 into main Jul 17, 2026
12 checks passed
@andrescera
andrescera deleted the fix/ssh-password-ota-slot-sync branch July 17, 2026 17:57
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