0055 — Always-on server and ssh-bootstrapped enrollment
0055 — Always-on server and ssh-bootstrapped enrollment
TL;DR. phux generates its own service unit (launchd on macOS,
systemd user unit on Linux) so a self-hosted server survives logout and
reboot. A client-side [[remote]] registry names a server the way
[[satellite]] names a hub peer, and phux enroll HOST mints the whole
credential over the operator’s existing ssh trust. The human never
transcribes a hex string.
Status: Proposed Date: 2026-07-25
Context
The remote path works and is unusable. phux pair mints a token and
prints a fingerprint, ADR-0031 makes a routable bind fail closed without
both, ADR-0037 hands the operator an overlay address — and then the
operator copies two 64-hex strings by hand into a command line they must
retype on every attach. phux pair --qr solved this for phones by
encoding a phux://connect deep link; laptops got nothing, because a
laptop has no camera pointed at the server’s terminal.
Two gaps sit under that. First, nothing keeps the server running: the
socket auto-spawn in the naked phux path covers “socket missing,” not
“host rebooted,” so a self-hosted mini loses its server on every restart
and the operator must ssh in to resurrect it. Second, there is no
client-side notion of a named server. [[satellite]] (ADR-0038) names a
peer a hub dials; the consumer side has only positional flags, so
phux attach cannot resolve mini to an endpoint, a pin, and a token.
The unexploited asset is ssh. An operator self-hosting a phux server already holds ssh trust to that host — they used it to install phux. That channel is authenticated, confidential, and already accepted by the operator’s threat model, which makes it exactly the right carrier for a one-time credential bootstrap.
Decision
- phux generates and owns a service unit.
phux service <install|uninstall|status|logs>writes alaunchdLaunchAgent on macOS and a systemd user unit on Linux, with restart-on-exit and start-at-boot, the server’s env (PHUX_QUIC_ADDR,PHUX_WS_ADDR,PHUX_WS_TOKENS,PHUX_WS_TLS_CERT) materialized into the unit, and logs under the existing state dir. Install is idempotent: rerunning it reconciles a changed unit rather than erroring. User-scope only — a system-wide unit implies a multi-user server, which ADR-0003’s one-server-per-user model does not have. - Reboot preserves the server, never the panes. A restarted service
is a new server with no terminals; every PTY died with the host. The
opt-in
--restoreflag wiresphux workspace saveon stop andrestoreon start, which brings back session names, layout, and cwd — not running processes. It is opt-in because a session list repopulated with fresh shells is a surprise, not a feature, unless asked for. - A client-side
[[remote]]registry names servers. Each entry:name,endpoint(quic://,wss://, orssh://),token-file(0o600, never inline),cert-fingerprint.phux attach NAMEresolves an entry to aDialwith no flags; explicit--quic/--wsflags still win. The schema deliberately mirrors[[satellite]]field for field and inherits ADR-0038’s fail-closed pin posture, but stays a separate table: the trust direction is opposite (consumer to server, not hub to peer), and collapsing them would let a hub’s peer list and a laptop’s server list drift into one another’s lifecycle. - A registered remote name beats the local-session reading of the same
word.
phux attach minichecks the registry first and dials the remote if it hits; only an unregistered name means “local session.” An entry exists because the operator ranenrollorremote addfor it, which is a deliberate statement about what that word means on this machine.--socketis explicit local intent and suppresses the lookup. Registry names may not start with a selector sigil (@ # . =) or contain:or/, so the registry can never shadow the selector grammar — only a bare session name. ssh://endpoints re-exec ssh instead of dialing. There is no consumer-side ssh transport (Dialis QUIC or WebSocket) and there need not be:ssh -t HOST phux attachputs the session on the remote server, where it outlives the connection. This is the zero-ceremony rung of the ladder and the honest fallback when a host has nothing dialable.phux enroll HOSTbootstraps over ssh. It runsphux pair --jsonon the far end through ssh (honoring the$PHUX_SSHseam the hub dialer already uses), optionally installs the service there, selects an overlay address from the pair output, and writes the local[[remote]]entry plus its token file.--jsonis added tophux pairfor this; the human-readable output is unchanged.- Remote names are local labels, not routes. A
[[remote]]name is a lookup key in one operator’s config. It is not ADR-0052’s SNI route name, which is a relay-side identity bound at relay enrollment. A laterendpoint = "relay://..."may carry a route name as data; the two namespaces stay distinct.
Why
- ssh is the only bootstrap channel that costs the operator nothing. QR
needs a camera, manual transcription needs two 64-hex strings, and a
rendezvous service needs infrastructure ADR-0037 already rejected. The
trust argument is airtight in the direction that matters: whoever can
ssh HOSTcan already runphux pairthere and read the token, so enrollment grants no authority ssh did not already grant. - Generating the unit beats documenting it because the failure mode of a
documented unit is a subtly wrong one — wrong binary path, missing env,
system scope instead of user scope — that surfaces as “my sessions
vanished” days later. The env wiring is the whole point; a hand-written
plist that omits
PHUX_WS_TOKENSsilently disables remote auth. - A separate
[[remote]]table costs one schema and buys an invariant: nothing a consumer writes can enable a hub route. Reusing[[satellite]]would putphux enrollin the business of editing the federation topology. - Fingerprint pinning already defeats MITM independent of how the pin arrived, so bootstrapping the pin over ssh adds no new trust assumption — it removes a transcription error class.
Tradeoffs
- Two init systems to generate for, and their reload semantics differ
(
launchctl bootstrap/bootoutvssystemctl --user). Third platforms and non-systemd Linux get a printed unit and a manual instruction, not an error. phux enrollshells out to ssh and inherits every ssh failure mode (agent not loaded, host key prompt,phuxnot on the remotePATH) into a phux error surface. Mitigated by naming the failing step, never a bare exit code.--restorerestores shape without state, which is a genuinely half-magic result. Accepted over the alternatives: restoring nothing is worse, and restoring processes is not possible.- Storing tokens as files referenced from config means enrollment writes
two places and the operator can desynchronize them by hand. The 0o600
file is still strictly better than an inline secret in
config.toml. - The registry is per-machine config with no sync story. An operator with three laptops enrolls three times.
- Registry-first name resolution means a local session sharing a name with
a registered remote becomes unreachable by name. Accepted: the collision
requires the operator to have deliberately registered that word, and
--socketstill reaches the local server. The alternative — a sigil or a--remoteflag — costs thephux miniergonomics that motivate the whole feature.
Alternatives
- Document a launchd plist in
docs/and ship no code — rejected: the env wiring is exactly what operators get wrong, and a wrong unit fails silently and late. - Reuse
[[satellite]]for consumer remotes — rejected: opposite trust direction, and it would couple a laptop’s server list to hub federation topology. - Extend
phux://connectdeep links to the desktop with a URL handler — rejected for now: it needs OS-level scheme registration on every platform to save a step ssh already saves for free. Still the right answer for phones, which is why--qrstays. - A phux-hosted rendezvous that brokers credentials — rejected: ADR-0037 already ruled out hosted relays and rendezvous for reachability; the same reasoning applies to credentials, with a strictly worse trust story.
- System-wide service unit — rejected: implies a multi-user server, which ADR-0003 does not have.