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.
5.2 KiB
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
(
ArchyVpnServiceroutesfd00::/8for 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
fips0ULA (committed oncompanion/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)
fips0is 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.- sshd IPv6 listening is unverified.
fips0is IPv6-only; a sshd pinned toListenAddress 0.0.0.0RSTs on the ULA identically. The image installs and enables openssh-server (image-recipe/archipelago-scripts/install-to-disk.shL177/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/(seeTransportPrefsCard.vuefor 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 incore/archipelago/src/api/rpc/dispatcher.rsalongside the existingfips.*arms at ~L544; handler inapi/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.nfton every daemon config install and on toggle change; when off, remove it. Reload stayssudo nft -f /etc/fips/fips.nft. Never touch80-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-infois 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.
- 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
- Preflights, surfaced in the card: sshd enabled + listening on IPv6
(
[::]:22or*:22viass -tln), and whetherPasswordAuthenticationis 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.nftgone. - 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.