Verified against the fips crate source: the mesh ULA is a pure function of the PUBLIC key — fd || sha256(x-only pubkey)[0..15] — so the npub is the durable address and needs no resolver. Android/tools/fipssh wraps ssh for Termux: 'fipssh user@npub1…' derives the ULA (pure-python bech32 + sha256, checksum-validated, typo protection) and execs ssh over the companion's split tunnel; --resolve prints the ULA alone. The derivation is pinned by a new Rust test (npub_derives_the_same_mesh_ula_as_the_fips_identity, 3 seeds against fips::Identity) and the helper's output was verified byte-identical against a live fips identity pair. SSH-over-mesh handover updated with an addendum: node docs/UI can advertise npub-based addressing, no node-side DNS needed for this case.
6.3 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.
Addendum (2026-08-31, same day): the npub IS the address
While wiring this up we confirmed the mesh ULA is a pure function of the
public key — fd ‖ sha256(x-only pubkey)[0..15] (fips/src/identity/node_addr.rs
from_pubkey → identity/address.rs from_node_addr,
FIPS_ADDRESS_PREFIX = 0xfd). The daemon's DNS resolver (fips/dial.rs) just
answers what anyone can compute. Consequences for the node side:
- Docs/UI can advertise
ssh <user>@npub1…-style addressing: Termux'sAndroid/tools/fipssh(shipped with the companion work) derives the ULA from the npub with zero infrastructure, verified byte-identical against the fips crate (archy-fips-coretestnpub_derives_the_same_mesh_ula_as_the_fips_identity). - If the settings toggle from this handover ever grows a "copy command"
affordance,
fipssh <user>@<npub>is the natural shape (npub, not ULA — it is the durable identity; the ULA follows from it). - No node-side DNS surface is required for the SSH case; the resolver stays what it is today (the node's own peer dials).
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.