| audience | humans, contributors |
|---|---|
| stability | evolving |
| last-reviewed | 2026-10-09 |
TL;DR. Connect another host with phux host add, then attach to its
terminals directly. Start with the SSH-based setup below; use manual
enrollment, an overlay, or a relay when that route cannot work.
Before you start: install phux locally and on the remote macOS or Linux
host, using the same release on both. Confirm you can log in with
ssh me@mini and that ssh me@mini phux --version finds the remote binary.
Replace me@mini below with that working SSH destination. Enrollment uses
your existing SSH trust and changes the remote user's service configuration;
use an account whose terminals you are authorized to control.
You do not need to configure an overlay first if the SSH route already works. For installs and release compatibility, see the installation guide.
phux host add me@mini
phux attach miniExpected: enrollment prints its checks and saves a host named mini.
Attach opens that host's terminal session. Run hostname in its shell to
confirm you are on the intended machine. Press Ctrl-A, release both keys,
then d to detach; phux attach mini returns to the running remote session.
Detaching leaves work on the remote server; a remote server crash or reboot
does not preserve live jobs (continuity boundaries).
A later attach also starts the registered host's server if you stopped it
by hand; it does not recover the jobs that server used to run.
If enrollment fails, follow the named failing step below. If a saved host stops connecting, start with the diagnostic sequence; do not delete its credentials or disable a firewall to guess at a fix.
Enrollment uses SSH trust: an account that can run phux pair and read its
token already has this access
(ADR-0055,
ADR-0122).
phux --remote me@mini performs the same setup and then attaches.
Each step prints one line as it happens; each failure names the next command.
- Check the binary. Run
ssh me@mini phux --version. SSH failures suggest checkingssh me@mini. A missing binary prompts the install command (ssh me@mini 'curl -fsSL https://phux.sh/install | sh') or--remote-phux PATHif the binary is outside the non-interactive shell'sPATH. - Start and supervise the server.
phux service install --quicwrites and starts a per-user launchd or systemd--userunit. If a server already runs,--adoptleaves it alone and arms the unit for its next start. Without a service manager, enrollment usesphux server --ensureand warns that it will not survive reboot.--no-serviceselects this unsupervised mode explicitly. - Pair.
phux pair --jsonmints a token only after the server reports a bound remote listener. It returns the certificate fingerprint, detected overlay addresses, and listeners. No listener means an error and no token. An unversioned token store is migrated once, with a server restart so listeners re-read it. - Find a direct route. Probe candidates with the new credentials:
--endpointif supplied, overlay addresses, then SSH's resolved host (ssh -G). Register the first response asquic://HOST:PORT. - Register the host. Write
[[remote]]under the host's name (miniforme@mini, or--name), recording the certificate pin and SSH destination. Store the token owner-only under the state directory. If no direct route answered, registerssh://me@miniand retain the first candidate asdirect. Later attaches retry it and promote it once UDP is reachable.
Repeating enrollment leaves a working registration unchanged; if its route fails, enrollment runs again. Certificate renewal has separate rules below.
A host with a direct route also gets a workload client certificate
(workload-auth.md §8.1): the key is generated on
this machine, only its signing request crosses ssh, and the entry names the
key and certificate files (client-cert, client-key) under
<state-dir>/remotes/. Every direct dial presents them, which a server in
[policy] mode = "paired" requires.
Certificates last 90 days and do not renew automatically:
phux host renew minienrolls a fresh certificate over the entry's ssh destination and changes nothing else (no re-pairing, no service install). A certificate from a different workload CA than the one it replaces is refused, so a destination that now reaches another machine changes nothing.phux host add me@minion a registered host renews a certificate that expires within 14 days, has expired, or cannot be read, even when the route still answers.- A dial to such a remote (
phux attach mini,phux ls --remote mini, and the other remote verbs) warns once with thephux host renewcommand, andphux doctorreports it asclient-certs.
Replacement has two phases: enroll, check, and save the new certificate,
then revoke the old one on the host. Failure before saving retains the old
entry and revokes the unused new credential. Failed revocation prints the
ssh ... phux workload revoke sha256:... recovery command. If the host never
held the old credential, that is reported rather than counted as revoked.
An entry with only client-cert or client-key is re-enrolled the same way.
The credential named by its certificate is revoked; if none can be identified,
the command warns.
phux host add --json and phux host renew --json report the outcome
beside the "host" object:
"enrollment": {
"status": "enrolled",
"error": null,
"credential_id": "sha256:...",
"expires_at": 1767225600,
"previous_credential_id": "sha256:...",
"previous_revoked": true,
"warnings": []
}status is enrolled, kept (the previous certificate still works and is
not due), failed (with error; the entry keeps the previous certificate,
or none), or skipped (a satellite, --ssh-only, the manual form, or no
direct route). credential_id and expires_at (Unix seconds; the day
admission is expected to end) describe the certificate the entry names now,
null for none. previous_revoked is null when nothing was superseded.
warnings carries anything left to do by hand. Paths appear in "host";
key bytes never appear anywhere.
phux host ls # remotes and satellites together
phux host show mini # inspect its route and auth references
phux host attach mini # same repair-aware path as phux attach mini
phux host renew mini # replace its workload client certificate
phux host rename mini desk # rename the local label, keep credentials
phux host disable edge # pause a satellite without forgetting it
phux host enable edge # resume it
phux host rm desk # forget the entry; token file stays putls, show, rename, renew, enable, disable, and rm accept --json.
attach is interactive. show, rename, and rm accept
--role remote|satellite when the same name exists in both registries;
without it they refuse to guess. Enable/disable apply only to satellites.
Renaming changes the local registry label, not the machine's hostname, service,
session names, or the path to its token file. The original SSH destination is
kept so attach repair still reaches the same machine.
A running hub picks up satellite edits without a restart. Every satellite
add, rm, rename, enable, and disable rings the config-reload
doorbell (as phux config reload does). The hub then re-reads
[[satellites]]: it dials new entries, stops removed ones, redials changed
ones, and leaves every other link and pane alone. The command prints what
happened, and --json carries it as hub_reload: signalled, no_server,
or failed. A server started without --hub ignores the registry. When
host add --role satellite patches --hub into its service unit, that
server starts dialing after its next restart.
--role satellite uses the same steps to register a peer this hub dials for
its users instead of a server you attach to. --ssh-only registers an
ssh:// entry without contacting the host at all.
A deliberate phux kill --server on mini leaves its service stopped.
The next phux attach mini (or phux --remote mini) tries these recovery
steps:
- Dial the saved route. An
ssh://entry tries its retaineddirectroute first and promotes it if reachable. - If unreachable, start the server over
ssh me@miniand retry with the saved credentials. - If still refused, re-pair over SSH and rewrite the entry.
- If SSH fails, report both the dial and SSH errors with their remedies.
--no-enroll stops after the first dial. The headless verbs (ls --remote
and friends) never shell out from a --json call.
When the credentials were minted elsewhere — a phone paired from a QR, or a host you cannot ssh to — register exactly what you hold:
phux host add mini quic://100.64.0.2:8788 --token-file ~/.local/state/phux/remotes/mini.token --cert-fingerprint AB:CD:...
phux host add mini ssh://me@mini # ssh trust only; no credentialsNAME ENDPOINT, or an endpoint URI alone (--name to label it), is the
manual form; anything else is an ssh destination. Each form refuses the
other's flags by name.
Without SSH access, run phux pair on the host and pass its one-tap link:
phux --remote mini --code 'https://phux.sh/connect?url=wss://100.64.0.2:8787&quic=quic://100.64.0.2:8788&fp=...&token=...'phux pair --qr renders the same link for phones. When the live QUIC listener
is reachable from a device, the link adds quic=quic://HOST:PORT; url= stays
as the WSS fallback for old app builds and is what the CLI currently registers.
The mobile app prefers quic when present. --code also accepts
phux://connect?..., the spelling printed for older app builds. The link
registers the target's name; later attaches need no code.
PORT on a --remote target defaults to 8788, the port a server auto-binds
on its overlay address
(ADR-0081). The
user@ half is a label, not a wire identity: phux runs one server per user
and the QUIC preamble carries a bearer token, so which server you reach is
decided by the address and port
(ADR-0093).
The session verbs take the same --remote target, placed after the verb, so
you can create, list, rename, and kill sessions on another machine from a
local shell:
phux new --remote me@mini -s build --json -- make watch
phux ls --remote me@mini
phux rename --remote me@mini build ci
phux kill --remote me@mini ciphux ls --all (-a) queries this machine and every registered host
concurrently, with a three-second deadline per host. Results are grouped by
machine; unreachable hosts show their reason without failing the listing.
--json emits phux.hosts/v1, also used by the TUI sidebar
(ADR-0140).
ls, new, kill, rename, and detach accept --remote. They use the
same resolution and QUIC/WSS dial path as phux --remote, including SSH
setup for an unregistered host. Under --json, setup is refused with
instructions in the error's remedy field: a machine-readable call must
not narrate setup or prompt through SSH. Three limits apply:
--remoteand--socketcannot combine: one names a local socket, the other a network dial.phux kill --server --remote HOSTis refused. The server accepts its stop command on the local socket only, so runphux kill --serveron that host.- An
ssh://registry entry is refused. It carries an interactive attach overssh -tand nothing else;phux host add HOSTgives it a direct QUIC endpoint the session verbs can dial.
phux new --remote without --json creates the session and attaches to it,
like the local form. With no --cwd, a remote session starts in the far
server's default directory: a path on this machine names nothing there.
Cockpit's Connect to Host (cmd+shift+O) uses the same [[remote]] registry
and QUIC/WSS stack. It only dials saved hosts; enrollment and repair stay in
the terminal. An unregistered host is refused with the command to add it.
See Cockpit's remote hosts for the
phux-remote setting and relaunch behavior.
When you can ssh to a host that has no overlay and no phux service, attach through ssh the way mosh does:
phux attach --ssh me@box
phux attach work --ssh me@boxSSH authenticates with its usual host-key check and password or 2FA prompt,
then runs phux bootstrap on the host. Bootstrap starts your server if
needed and opens a QUIC listener admitting only this attach's token. The
port, certificate fingerprint, and token return over SSH. phux pins the
fingerprint, connects over QUIC, and closes SSH. The session can then survive
network changes, render locally with predictive echo, and remain on the
server after detach.
Nothing is registered on either side. The listener closes about two minutes
after its last connection, and each cold attach bootstraps again. The host
needs phux installed; a non-interactive ssh shell may not have Homebrew or Nix
on its PATH, so name the binary with --remote-phux /opt/homebrew/bin/phux.
--udp-ports 60000-61000 keeps the listener inside one firewall rule.
If the QUIC dial does not connect within a few seconds, or the host's phux
predates phux bootstrap, the attach falls back to ssh -t me@box phux attach
and says why. A registered ssh:// host takes the same path.
phux host add (default --role remote) attaches to another machine. To
have this machine dial another as a federation satellite, pass
--role satellite:
phux host add --role satellite miniWith phux installed and ssh mini working, the command:
- Confirms phux is on
miniand installs its per-user service (launchd on macOS, systemd--useron Linux) with a QUIC listener, so the satellite survives logout and reboot. - Mints a pairing token there and pins the certificate fingerprint.
- Registers
miniin this machine's[[satellites]]registry. The token is stored owner-only (0600) under the state dir; it never lands in argv,config.toml, or logs. - Ensures this machine's per-user service runs with
--hub. If a unit already exists,--hubis patched into its argv and existing--quic/--listen/--restore/--socketarguments stay. Reinstalling with onlyphux service install --hubwould drop them (ADR-0083).
Afterwards this machine is the hub: host-qualified operations reach
mini over the hub-and-spoke link. Join stays accountless QUIC on your
overlay; there is no phux-operated relay on this path.
If the local server is already running without --hub, the unit is
updated and hub mode starts the next time that server starts — the
running process is not restarted, so panes stay up. --no-service skips
installing the remote unit only; the local --hub ensure still runs.
Each machine's sidebar and phux ls --all list that machine's own
[[remote]] registry, dialed by its own hosts provider
(ADR-0140). Nothing
is shared or propagated: registering mini on the laptop makes mini appear
on the laptop, and nowhere else. A phone that sees every host has every host
in its own registry; that says nothing about what the hosts see.
So a mesh of N machines needs N x (N-1) registrations, one per direction.
For three machines that is six phux host add or --code runs. Pick the
route per direction with this ladder; the first rung that holds wins:
phux host add me@HOSTwhenssh me@HOSTworks non-interactively (key auth; password prompts never fire, the ssh is run in batch mode). Add--remote-phux /opt/homebrew/bin/phux(or the Nix store path) when the error says phux is not on the PATH the ssh shell sees.phux pairon the target,phux attach --remote NAME --code LINKon the dialer when ssh cannot work in that direction. Mint one link per dialer so each link is revocable on its own withphux pair revoke.
Cases met in practice:
- macOS target with Remote Login off. Port 22 is closed, so rung 1 is
out. Either enable Remote Login (System Settings > General > Sharing) and
use rung 1, or use rung 2. A laptop's listener already binds its overlay
address, so
phux pair --host 100.x.y.z:8787prints a usable link. - Linux target behind Tailscale SSH. The tailnet SSH policy, not sshd,
decides which users may log in; the symptom is
tailnet policy does not permit you to SSH as user "me"and no key helps. Either allowautogroup:nonrootin the tailnet ACL and use rung 1, or use rung 2. Do not enroll asroot: phux is one server per user, and a root server is not the one holding your sessions. - Homebrew or Nix phux. Non-interactive zsh on macOS does not source
the profile that puts
/opt/homebrew/binon the PATH.--remote-phuxnames the binary; nothing on the host needs to change. - Stale overlay names. A re-enrolled machine gets a
-1suffix intailscale status(jamess-mac-mini-1); the unsuffixed entry is the dead node. Dial the online one. - Laptops go away. A row for a machine that sleeps or roams shows as unreachable until it is back on the overlay. That is the row doing its job, not a broken registration.
An agent diagnosing "host X does not see host Y" should run phux host ls
on X first. An empty or Y-less registry is the whole answer; the fix is a
registration on X, never on Y and never on the phone.
TLS and pairing authenticate a connection; they do not make a server behind NAT or CGNAT reachable. A WireGuard-class overlay supplies a routable address (ADR-0037). phux dials it like a LAN address, with TLS and token authentication on non-loopback binds (ADR-0031).
Certificate pins identify the certificate, not the hostname, so overlay DNS names work unchanged. Headscale, raw WireGuard, and Defguard-managed WireGuard use the same phux transports as Tailscale. Defguard needs routed reachability through its gateway; it is not assumed to be a peer mesh. See overlay reachability for the trust model and environment settings.
Every path below shares the same server-side setup, done once. First the server needs a remote listener. On the default profile a host on an overlay network already has one: the server binds its overlay address on 8787 (wss) and 8788 (QUIC) at startup (ADR-0081). Otherwise start it on a non-loopback bind — TLS and token auth engage automatically:
phux server --listen 0.0.0.0:8787 # TLS WebSocket (= PHUX_WS_ADDR)
# or, for QUIC:
phux server --quic 0.0.0.0:8788 # (= PHUX_QUIC_ADDR)Then pair, on the server host:
phux pairphux pair first asks the running server (the default socket, or
--socket PATH) which remote listeners it has bound, and mints nothing when
none would accept the credential: no server running, or a server with no
remote listener, is an error that names the socket and the fix
(ADR-0141). The server
re-reads the credential store when it changes, so the token works at the next
connection attempt with no restart, and revocation applies just as promptly.
Legacy anonymous token lines require a one-time explicit
phux pair --migrate-legacy. phux pair provisions the server
certificate if none exists yet, issued by the server's workload CA
(ADR-0153), so the fingerprints
it prints are the ones the server presents.
Its output looks like this (the overlay-address block appears when an
explicit PHUX_OVERLAY_ADDRS list or a tailnet/CGNAT address is selected):
Credential ID (use with `phux pair rotate|revoke`):
<credential-id>
Pairing token (a secret — give it to the device once):
<64-hex token>
Server certificate SHA-256 (verify on the device to defeat MITM):
<64-hex fingerprint>
Server certificate authority (pinned by the device; it survives leaf renewal):
sha256:<64-hex fingerprint>
Overlay network addresses (dial one of these from the device):
100.x.y.z
Token written to <state-dir>/remote-tokens
Record the token and the fingerprint; every phux attach below uses both. The
fingerprint is SHA-256, 64 hex digits, optionally colon-separated. The
authority line appears when the certificate was issued by the workload CA
(a server provisioned before ADR-0153 keeps its self-signed certificate and
prints none); phux host add and --code pin it, and a client holding
only the leaf fingerprint pins it on its first connection
(operations).
Keep the non-secret credential ID for lifecycle operations. Rotation prints a
new bearer once and keeps the previous generation valid for at most five
minutes by default (--overlap-seconds 0 cuts over immediately); an existing
absolute expiry is preserved, and an expired credential cannot be rotated.
Revocation and the end of a rotation overlap also disconnect established
sessions using that credential
(remote consumer trust model).
These, ls, and prune only edit the store and need no running server:
phux pair rotate <credential-id> --overlap-seconds 300
phux pair revoke <credential-id>For a phone or tablet phux pair also prints a one-tap
https://phux.sh/connect?url=…&quic=…&fp=…[&ca=…][&enroll=…]&token=… Universal Link (an https
link so only the app owning the domain receives the bearer token), a
phux://connect?… spelling for older app builds, and with --qr a terminal
QR of the same link. Treat all three like the token itself. url names the
address the server's WSS listener is bound to (the overlay address for a
0.0.0.0/:: bind), or --host HOST:PORT (or a full ws:///wss:// URL)
when the device reaches the server some other way, such as a MagicDNS name or
a port forward; it remains present for deployed apps. When the running server
also reports a device-dialable QUIC listener, quic names that live endpoint
and current mobile apps prefer it. An unspecified QUIC bind uses a detected
overlay address; a loopback bind, or an unspecified bind without an overlay,
is not advertised. The app passes one selected endpoint to RemoteClient;
the shared runtime alone owns dialing and reconnecting, with no bridge-side
fallback race. ca is the sha256: fingerprint of the CA the server's
certificate chains to; an app pins it beside fp, and one that predates it
ignores it. phux pair --enroll adds enroll, a single-use ticket with which
the device enrolls a workload certificate for a key it generates and keeps
(on a phone, in its keystore): what a server in paired mode requires
(ADR-0154).
The ticket expires in ten minutes and enrolls one key; phux --remote NAME --code LINK enrolls with it too.
--host still needs a bound WSS listener behind it. A WSS listener bound only
to loopback, or none at all, gives no link, and --qr then refuses before
minting. --name labels the server in the device's list.
A relay link from phux pair --relay-route ROUTE
(Path D) has a different shape:
quic is the relay, sni is the route the dial offers as its TLS server
name, fp pins the relay, ca pins the server (verified end to end inside
the relayed stream, so the relay forwards only ciphertext; ADR-0154), and
there is no url. The relay has no WebSocket leg; --enroll works through
it too. An app or CLI that predates sni refuses the link because
url is missing, instead of dialing the relay unrouted
(ADR-0149).
# Credentials + a scannable one-tap QR for the device:
phux pair --qr --name studio-miniPrefer QUIC where UDP is open — it handles roaming and connection migration
better. Use --ws wss:// when UDP is blocked by a network or firewall.
phux only ever sees an IP, so every overlay is dialed the same way once both peers are on it:
phux attach --quic HOST:8788 --token HEX --cert-fingerprint FP # preferred when UDP is open
phux attach --ws wss://HOST:8787 --token HEX --cert-fingerprint FP # when UDP is blockedRoutable hosts require --cert-fingerprint (only loopback trusts the dev
cert).
Bracket IPv6 literals in WebSocket URLs, for example
--ws 'wss://[fd00::1]:8787'. The brackets belong to the URL authority;
TCP resolution and the default TLS certificate identity use the bare address.
-
Path A: Tailscale. Install it on both ends and run
tailscale up;tailscale statuslists both peers with their100.xIP and MagicDNS name (myhost.tailnet-name.ts.net), which are interchangeable for the pin. Trust extends to Tailscale's coordination plane, mitigated by phux's own TLS + token. -
Path B: Headscale, the self-hostable OSS control plane for the same data plane. Run a Headscale server,
headscale users create NAME,headscale preauthkeys create --user NAME, then on each nodetailscale up --login-server https://headscale.example.com --authkey KEY. Dial the assigned100.xaddress. -
Path C: raw WireGuard. Generate a keypair on each end (
wg genkey | tee privatekey | wg pubkey > publickey), write/etc/wireguard/wg0.confon each,wg-quick up wg0, and checkwg showfor a recent handshake. There is no MagicDNS; dial the tunnel IP. Server side:[Interface] Address = 10.8.0.1/24 ListenPort = 51820 PrivateKey = <server privatekey> [Peer] PublicKey = <client publickey> AllowedIPs = 10.8.0.2/32
Client side (the
Endpointgoes on whichever side can see the other's public address):[Interface] Address = 10.8.0.2/24 PrivateKey = <client privatekey> [Peer] PublicKey = <server publickey> AllowedIPs = 10.8.0.1/32 Endpoint = server.example.com:51820 PersistentKeepalive = 25
Use Defguard's official deployment and enrollment guides for the VPN. Once the host has a stable tunnel IP and the gateway permits the required route, select that IP explicitly; no Tailscale CLI or Defguard SDK is needed:
# On the server host, after 10.77.0.2 is assigned to its VPN interface:
export PHUX_OVERLAY_ADDRS=10.77.0.2
# On an intended default-profile server, auto-listen uses that IP.
# For a deliberately configured server instead:
phux server --quic 10.77.0.2:8788 --listen 10.77.0.2:8787
# In another shell with the same environment and profile:
phux pair --qr --name studioDo not start a second server over an existing service; configure its concrete
listener addresses or environment instead. Service configuration is distinct
from shell exports. Prefer concrete binds over 0.0.0.0; phux binds only
addresses this machine owns, not the gateway's public IP or another peer's IP.
For IPv6 use --quic '[fd77::2]:8788' --listen '[fd77::2]:8787'.
The operator/client must also have an active VPN route to this host. Permit
UDP 8788 for QUIC and TCP 8787 for WSS only from intended VPN peers. A VPN
handshake alone does not prove client-to-client forwarding or a return path.
Use the existing manual host registration or host add --role satellite
flows above, retaining the token and certificate pin; do not treat VPN
membership as phux authorization. Human MFA sessions and unattended machine
tunnels have different lifecycle requirements.
The Defguard federation masterplan records the network topology, self-hosted license baseline, deployment acceptance tests, and capabilities not yet validated in a real VPN lab.
An overlay gives the client a route to the server. A relay instead accepts outbound connections from both, so the server's network needs no inbound listener. The relay terminates TLS on both connections and sees phux traffic in plaintext. Host it on a machine you trust.
Set up the route end to end:
-
On the relay host, run
phux relay pair --route ROUTE, save its printed tunnel token out of band, then startphux relay run --listen 0.0.0.0:4433. -
On the server host, put that tunnel token in a mode-
0600file and add:[[connector]] relay = "RELAY_HOST:4433" token-file = "/home/me/.local/state/phux/relay-route.token" cert-fingerprint = "RELAY_FP"
-
Start or restart
phux server. It supervises every configured connector;--connect RELAY_HOST:4433selects one exact entry for diagnosis. -
On the server host, mint the consumer credential for the route. The server must be running, but it needs no listener:
phux pair --relay-route ROUTE
It prints the server's token and a connect link,
https://phux.sh/connect?quic=quic://RELAY_HOST:4433&sni=ROUTE&fp=RELAY_FP&token=....--qrrenders the link. With several[[connector]]entries, add--relay RELAY_HOST:4433to pick one. -
Register and attach the consumer from the link:
phux attach --remote mini --code '<link>'Or register it by hand with the route as the entry's TLS server name:
phux host add mini quic://RELAY_HOST:4433 --tls-server-name ROUTE \ --cert-fingerprint RELAY_FP --token-file /path/to/SERVER_TOKEN
After either one,
phux attach mini,phux --remote mini, and the headless verbs (phux ls --remote mini) go through the relay. A one-off attach that skips the registry still works:phux attach --quic RELAY_HOST:4433 --tls-server-name ROUTE --cert-fingerprint RELAY_FP --token SERVER_TOKEN.
RELAY_FP pins the relay's certificate on both network legs.
SERVER_TOKEN crosses the relay opaquely and is verified by the server;
the tunnel token only authorizes the connector to claim its enrolled route.
The connector re-reads its token file on every redial, so rotation is
phux relay pair --route ROUTE, replace the file, then restart either side
when immediate cutover is required.
An unknown route fails the TLS handshake. An enrolled route with no live
tunnel closes as route-offline. A bad tunnel token or certificate pin leaves
the local server running and produces an outbound connector lost; scheduling redial diagnostic. A bad SERVER_TOKEN resets only that consumer stream;
the tunnel and other consumers remain live. Full relay state-file,
revocation, and trust-boundary details are in
relay operations; the design is
ADR-0057, building on
ADR-0051 and
ADR-0052.
Work from the server outward. A timeout alone cannot distinguish a stopped server, wrong address, blocked port, or broken network route.
- Check the host and server. Can you still
ssh me@mini? On that host, runphux statusandphux doctor. If the server is stopped, the normalphux attach minipath can repair it over SSH. If startup fails, inspectphux logs --serverthere before retrying. - Check the saved route. Locally, run
phux host show mini; compare its endpoint with the remote listener and address reported by the host. Anssh://fallback is usable, not proof that enrollment failed. - Check reachability for that route. Only if you use Tailscale/Headscale,
inspect
tailscale statusandtailscale ping <host>; on WireGuard, inspectwg showfor a recent handshake. Confirm the listener binds an address your client can reach and the intended port is allowed. QUIC needs UDP end to end. QUIC timing out while WebSocket works suggests a UDP-specific problem; use an already configured WebSocket route while investigating rather than removing authentication. - Check host firewall admission. A connection that opens but stalls can
be a firewall stealth-drop, but it is not conclusive. On macOS, run
phux doctoron the server host and follow itsremote-reachableremedy: allow the exact phux binary through the Application Firewall. Homebrew's Cellar path changes after upgrades, so an old allowlist entry may no longer apply. Keep the firewall enabled; an overlay does not replace host policy. - Read the actual refusal. Authentication and certificate errors need the remedies below, not more network retries.
-
Auth failure (HTTP 401 / unauthorized on the WebSocket upgrade; QUIC token rejection). The responding endpoint is reachable, but it rejected the credential. Confirm it is the intended host and that the saved token is present and not revoked. If needed, mint a new token with
phux pairon the trusted server and update the registration. New tokens are live at the next connection attempt; no server restart is required. -
Insecure credential store. The default store and any path selected by
PHUX_WS_TOKENSmust be a regular, non-symlink file owned by the effective user with no group or world permissions. Restore owner-only permissions (normallychmod 600 <path>); authentication fails closed until repaired. -
Fingerprint mismatch. The certificate the server presented does not match
--cert-fingerprint. Either the pinned value is stale (the server state dir was recreated, regeneratingremote-cert.pem), an operator certificate was substituted viaPHUX_WS_TLS_CERT/PHUX_WS_TLS_KEY, or you are dialing the wrong host. Re-runphux pairon the server host — it prints the persisted certificate's fingerprint beside a fresh credential (phux pair revokeit if you do not need it) — and compare. Do not "fix" a mismatch by dropping the flag: the pin is what closes the trust-on-first-use MITM window. -
Certificate authority changed. The server presented a certificate issued by another CA than the one this client pinned, and the dial stops before the token is sent; the message names both fingerprints. If the server's operator ran
phux workload authority --rotate, re-pair (phux host add NAME, or a freshphux pair --qrfor a phone). If nobody did, treat it as the wrong host or an interception and do not re-pair (operations). -
Certificate name mismatch (
IP address mismatch,NotValidForName,ERR_CERT_COMMON_NAME_INVALID) from a client that validates the server name —curl --cacert, a browser with the certificate trusted,openssl s_client -verify_ip.phux attachand the mobile app never hit this: they pin the fingerprint and ignore the name. The certificate's subjectAltName is fixed when it is generated (ADR-0091), so one minted before phux learned to name the overlay address claims only loopback and always will.phux doctorreports it asremote-certand prints the remedy. Regeneration creates a new certificate and fingerprint, invalidating every device's pin. Plan to re-pair them all:rm ~/.local/state/phux/remote-cert.pem ~/.local/state/phux/remote-key.pem phux pair # regenerates, naming the address it advertises phux upgrade # restarts the server in place so it presents it
then re-pair every device against the new fingerprint. A certificate the workload CA issued is regenerated under the same CA, so a client that pins the CA accepts the new one with no re-pair; only devices pinning the old leaf alone need it.
-
MagicDNS name does not resolve. MagicDNS may be disabled on the tailnet, or the client OS resolver is not wired up; fall back to the
100.xIP fromtailscale status. The pin is on the fingerprint, not the hostname, so switching between name and IP needs no re-pairing.
Overlay links are higher-latency than a LAN; remote consumers get better behavior by requesting state-sync output — see remote output modes.
ssh HOST phux stdio-bridge remains a valid manual path where SSH is already
the trust boundary — no token or pin is involved on that transport. Hosted
relay infrastructure, rendezvous servers, STUN/TURN, and reverse tunnels
remain deliberately out of scope for the self-host repo; the self-hosted
reference relay (Path D above) is the one carve-out, per ADR-0057. See
ADR-0037. For the full attach
and pair CLI surface, see the reference TUI.