Files
archy/docs/HANDOFF-2026-08-31-ssh-over-mesh.md
T
Dorian 8cf45377a2 docs: handoff — SSH over the FIPS mesh (node-side toggle) for the node agent
Today's field test: Termux over the companion's split tunnel reaches the
node's fips0 ULA and gets RST — the mesh path works end to end, port 22
is refused by the node (fips0 default-deny, no 22 in the fips.d drop-ins;
sshd IPv6 listening unverified). The interim manual unblock (a
source-restricted 90-ssh.nft drop-in) is documented, but the real ask is
a first-class 'SSH over mesh' settings toggle in the node UI, with the
drop-in lifecycle owned by the daemon, a source-scope decision (paired
phones vs any mesh peer), sshd preflights, and an acceptance checklist.
Companion side is done (device-wide split tunnel + the node ULA now
displayed/copyable in the hub Nodes page) — the node agent is downstream.
2026-08-31 14:54:28 +01:00

5.2 KiB
Raw Blame History

HANDOFF — SSH over the FIPS mesh (node-side toggle), 2026-08-31

For: the node OS agent. From the companion agent, mid-0.5.28 testing. The user wants to SSH their node from Termux over the phone's FIPS mesh instead of keeping Tailscale around for it — the phone side is done and verified; the remaining work is all node-side, and it wants to be a first-class settings toggle, not a hand-edited firewall rule.

What already works (do not rebuild this)

  • The companion's embedded mesh is a device-wide split tunnel (ArchyVpnService routes fd00::/8 for the whole phone, no per-app filter, allowBypass). Termux — or any app — reaches mesh addresses with zero setup while the tunnel is up, on-LAN and away (anchor path).
  • The hub's Nodes page now displays and copies each FIPS node's fips0 ULA (committed on companion/0.5.28).
  • Verified live today: ssh user@<node-ULA> from Termux answers RST — the path works end-to-end; something on the node is doing the refusing.

The diagnosis (from today's field test + code read)

  1. fips0 is default-deny inbound. The hardening baseline (/etc/fips/fips.nft, provisioned out-of-band) rejects un-allowlisted ports with RST — the exact symptom the web-UI drop-in's comment documents on :80 (core/archipelago/src/fips/config.rs ~L237). The daemon's own drop-ins (/etc/fips/fips.d/80-web-ui.nft: 80/8443/5679, 85-app-ports.nft: app launch ports) do not include 22.
  2. sshd IPv6 listening is unverified. fips0 is IPv6-only; a sshd pinned to ListenAddress 0.0.0.0 RSTs on the ULA identically. The image installs and enables openssh-server (image-recipe/archipelago-scripts/install-to-disk.sh L177/L210) with default config (binds ::), but a preflight in the toggle should confirm rather than assume.

Interim manual unblock (what the user can do today, keep valid): /etc/fips/fips.d/90-ssh.nft containing ip6 saddr <phone-ULA> tcp dport 22 accept, then sudo nft -f /etc/fips/fips.nft. A daemon-owned toggle must own that file name/lifecycle so a hand-added rule and the feature don't fight over the same slot.

The ask: a "SSH over mesh" toggle

The user's instinct (seconded here): a setting in the FIPS/network area of the node UI, default off. Sketch:

  • UI: a small settings card in the pattern of neode-ui/src/views/settings/ (see TransportPrefsCard.vue for a segmented-pref card + vitest). Toggle + a source-scope selector + preflight status rows.
  • RPC: fips.ssh-over-mesh.get / fips.ssh-over-mesh.set (dispatch arm in core/archipelago/src/api/rpc/dispatcher.rs alongside the existing fips.* arms at ~L544; handler in api/rpc/fips.rs). Persisted with the other fips daemon-config state.
  • Enforcement: mirror the existing drop-in lifecycle in core/archipelago/src/fips/config.rs (~L243–320): when the toggle is on, write /etc/fips/fips.d/90-ssh.nft on every daemon config install and on toggle change; when off, remove it. Reload stays sudo nft -f /etc/fips/fips.nft. Never touch 80-web-ui.nft / 85-app-ports.nft.
  • Source scope (the design decision worth an issue thread):
    • Paired phones only — restricts to the phone ULAs/npubs the node has actually paired with. Open question: does the node durably know which inbound peers are "its" phones? FIPS accepts inbound peers without prior registration, so this may need a small persisted "trusted peers" list (seeded when fips.pair-info is issued, or on first successful dial). Recommended default if the data can be made reliable.
    • Custom source list — raw ULA list, per-rule ip6 saddr <ula> … entries. Escape hatch; fine to ship alongside.
    • Any mesh peer — what the user literally asked for, but flag it honestly in the UI: with no registration requirement, this faces port 22 at every peer that can route to the node over the mesh. If offered at all, gate it behind the same "I understand" confirmation pattern as other danger-zone settings.
  • Preflights, surfaced in the card: sshd enabled + listening on IPv6 ([::]:22 or *:22 via ss -tln), and whether PasswordAuthentication is on — if it is, show a keys-only recommendation (the firewall restriction is the belt; this is the suspenders).

Acceptance (on-device)

  • Toggle on, phone on LAN: ssh user@<node-ULA> from Termux connects.
  • Phone away from LAN (anchor path): same result.
  • Toggle off: connection refused again; 90-ssh.nft gone.
  • Daemon config install (upgrade/restart) preserves the on-state and the rule; nothing duplicated.
  • Non-default source scope actually restricts (try from a second mesh peer, or a wrong ULA).
  • Settings UI survives a page reload; RPC has a vitest like TransportPrefsCard.test.ts.

Working rules

Same as the queue handoffs: small commits, tracker issue for this feature (ssh-over-mesh), and the companion agent is downstream-only here — no companion changes are required (the phone already routes and displays the ULA). Optional nicety later, NOT part of this issue: the companion's FIPS hub page could one day surface the toggle state — only worth it if the fips.ssh-over-mesh.get RPC is trivial to add to the existing status call.