Remote access over an overlay network
Run phux --remote user@host and you are done: it resolves an already-registered host, or pairs one first (from a pasted connect code, or over your existing...
Full source summary
Run phux --remote user@host and you are done: it resolves an already-registered host, or pairs one first (from a pasted connect code, or over your existing ssh trust) and remembers it, so every later attach is a direct QUIC dial with no ssh in the path. phux host enroll HOST is the same setup plus a service unit on the far end. The manual path — put both ends on a WireGuard-class overlay, mint credentials with phux pair, attach to the overlay address over QUIC or TLS WebSocket — is documented below for Tailscale, Headscale, and raw WireGuard, plus troubleshooting for routing, auth, and fingerprint failures. A fourth path uses the self-hosted reference relay when the server cannot accept an inbound connection.
Why an overlay
phux already ships everything a remote attach needs except reachability: wss://
(TLS 1.3) and QUIC transports, phux pair to mint a bearer token plus a
certificate fingerprint, and a non-loopback bind that engages TLS and token
auth automatically
(ADR-0031). What remains
is purely packet reachability — a self-hosted server behind NAT or CGNAT has no
inbound-reachable address. The sanctioned answer is a WireGuard-class overlay
network (ADR-0037): an L3
substrate that hands the client a routable address (a 100.x IP or a MagicDNS
*.ts.net name) which phux dials exactly like a LAN address, with zero new
code. Cert pinning is on the fingerprint, not the hostname, so overlay DNS
names work unchanged. phux is overlay-agnostic, and the fully-OSS
Headscale/WireGuard path is first-class, not a downgrade. Hosted relays,
rendezvous servers, and hole-punching are deliberately out of scope. The trust
model and environment knobs live in
operations.md;
this page owns the step-by-step task.
The short way: phux --remote
One command, and it reads like the one you already type:
phux --remote me@mini
The first time, mini is not a registered host, so phux pairs it before
attaching. It walks four rungs, cheapest first
(ADR-0093):
- A registered host — a
[[remote]]entry supplies the endpoint, the certificate pin, and the token, and the dial is a direct QUIC connection. This is the steady state and the only rung that runs once a host is known. - A pasted connect code —
--code, below. No ssh, no shell on the far end. - A one-time ssh pairing — runs
phux pair --jsonover your existing ssh trust, registers what it mints, and dials. Once per host; rung 1 catches everything after. - A refusal naming both remedies, when ssh cannot help.
PORT defaults to 8788, the port a server auto-binds on its overlay
address (ADR-0081).
Pass [USER@]HOST:PORT to say otherwise; the port applies to that dial and
does not rewrite the registry.
The user@ half is a label, not a wire identity: phux runs one server per
user and the QUIC preamble carries a bearer token, not a username, so which
server you reach is decided by the address and port. user@ names the ssh
destination for rung 3 and the registry key that remembers the result.
Pairing without ssh at all
If the host has no ssh you can use — or you would rather not shell into it —
run phux pair there, copy the one-tap link it prints, and hand it over:
phux --remote mini --code 'https://phux.phall.io/connect?url=wss://100.64.0.2:8787&fp=...&token=...'
That is the same link phux pair --qr renders for a phone, so a laptop and a
phone pair through one artifact. --code also accepts the link’s
phux://connect?... spelling, which phux pair prints on a second line for
older app builds. The link is registered under the target’s
name, and later attaches need no code.
--no-enroll refuses the ssh rung outright: an unregistered host is reported
with its remedies named rather than paired.
The related way: phux host enroll
--remote pairs a host; it deliberately does not install anything there.
When you want the far end to keep a server running across logout and reboot,
use the verb whose subject is that host:
phux host enroll mini
(Before the phux host namespace this verb was spelled phux enroll. See
ADR-0066.)
It confirms phux is installed on mini, installs the host’s service unit so
the server survives reboot, mints a pairing token there, reads back the
certificate fingerprint and the overlay address, writes the token locally
0600, and registers a [[remote]] entry. Afterwards both spellings work with
no flags:
phux attach mini
phux --remote mini
No token, no fingerprint, no address typed by hand. This grants nothing ssh
did not already grant — whoever can ssh mini can run phux pair there and
read the token themselves (ADR-0055).
A host with no overlay address, or one whose certificate could not be read,
has nothing dialable; enrollment says so and registers an ssh:// entry
instead. That still gives you phux attach mini against a server whose
sessions outlive the connection — it just tunnels through ssh rather than
dialing QUIC. Re-run phux host enroll once the overlay is up to upgrade
the transport.
The rest of this page is the manual path: what enroll automates, and what
to do when it cannot reach the host.
Common steps: pair, then listen
Every path below shares the same server-side setup, done once. Pairing order
does not matter: the server re-reads the credential store when it changes, so a
token minted against an already-running listener works at the next connection
attempt, and credential revocation applies just as promptly. Legacy anonymous
token lines require a one-time explicit phux pair --migrate-legacy. phux pair
never contacts a running server, and it provisions the self-signed certificate
if none exists yet, so the fingerprint it prints is the one the server will
present.
# On the server host, before starting the listener:
phux pair
Its output looks like this (the overlay-address block appears only when a tailnet or CGNAT-routed address is detected on the host):
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>
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.
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 can shorten that overlap. An already-expired
credential cannot be rotated and produces no replacement token. Revocation
affects new connections immediately, while already-established sessions
continue until they disconnect:
phux pair rotate <credential-id> --overlap-seconds 300
phux pair revoke <credential-id>
For a phone or tablet, skip the transcription entirely: when the server
address is known — pass --host HOST:PORT (or a full ws:///wss:// URL),
or let it fall back to a detected overlay address plus the PHUX_WS_ADDR
port — phux pair also prints a one-tap
https://phux.phall.io/connect?url=…&fp=…&token=… link carrying the URL,
fingerprint, and token together, and phux pair --qr renders that same link
as a scannable terminal QR. It is an https Universal Link rather than a
custom phux:// scheme so that only the app which owns the domain can
receive it — a custom scheme is not exclusive on iOS, and the link carries a
bearer token. The same link is printed a second time as phux://connect?…
for app builds that predate Universal Link support. Treat the link, the QR,
and the second spelling like the token itself: they carry the credential.
--name labels the server in the device’s list.
# Credentials + a scannable one-tap QR for the device:
phux pair --qr --host 100.x.y.z:8787 --name studio-mini
Then start the listener 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)
Prefer QUIC where UDP is open — it handles roaming and connection migration
better. Use --ws wss:// when UDP is blocked by a network or firewall.
Path A: Tailscale
Tailscale is the frictionless on-ramp.
- Install Tailscale on both the server host and the client device.
- Run
tailscale upon each. - Confirm both peers appear in
tailscale status. - Find the server’s address:
tailscale statusprints both the100.x.y.zIP and the MagicDNS name (likemyhost.tailnet-name.ts.net).
Then dial from the client:
# QUIC (preferred when UDP is open):
phux attach --quic myhost.tailnet-name.ts.net:8788 --token HEX --cert-fingerprint FP
# TLS WebSocket fallback (when UDP is blocked):
phux attach --ws wss://myhost.tailnet-name.ts.net:8787 --token HEX --cert-fingerprint FP
Routable hosts require --cert-fingerprint (only loopback trusts the dev
cert). The pin is fingerprint-based, so the MagicDNS name and the 100.x IP
are interchangeable — no re-pairing when you switch between them. The honest
tradeoff: trust extends to Tailscale’s coordination plane, mitigated by phux’s
own TLS + token riding on top.
Path B: Headscale
Headscale is a self-hostable, fully-OSS control plane for the same data plane, for operators who will not depend on a third-party coordinator. The client tooling is identical.
- Run a Headscale server.
- Create a user and a preauth key:
headscale users create NAME, thenheadscale preauthkeys create --user NAME. - Join each node:
tailscale up --login-server https://headscale.example.com --authkey KEY. - Verify both peers with
tailscale status.
Dial exactly as in Path A, using the Headscale-assigned 100.x address (or
its DNS name if configured):
phux attach --quic 100.64.0.2:8788 --token HEX --cert-fingerprint FP
# or
phux attach --ws wss://100.64.0.2:8787 --token HEX --cert-fingerprint FP
Path C: Raw WireGuard
A hand-rolled WireGuard overlay works the same way — all three paths look identical to phux, which only ever sees an IP.
-
Generate a keypair on both ends:
wg genkey | tee privatekey | wg pubkey > publickey. -
Write a minimal
/etc/wireguard/wg0.confon each end. Server side:[Interface] Address = 10.8.0.1/24 ListenPort = 51820 PrivateKey = <server privatekey> [Peer] PublicKey = <client publickey> AllowedIPs = 10.8.0.2/32Client 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 -
Bring the tunnel up on both ends:
wg-quick up wg0. -
Verify a recent handshake with
wg show.
Dial the peer’s tunnel address:
phux attach --quic 10.8.0.1:8788 --token HEX --cert-fingerprint FP
# or
phux attach --ws wss://10.8.0.1:8787 --token HEX --cert-fingerprint FP
With raw WireGuard there is no MagicDNS; use the tunnel IP or your own DNS.
Path D: via a reference relay
Paths A-C put both ends on one overlay so the client can reach the server’s address. A relay inverts the direction: the server dials out to a relay you host, and consumers dial the relay — nothing on the server’s network needs to accept an inbound connection. The tradeoff is stated plainly: the relay terminates TLS on both legs and sees phux traffic in plaintext. Self-hosting the relay on a trusted machine is the mitigation.
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. -
Attach the consumer, using the route as TLS SNI and the server’s ordinary
phux pairtoken as the consumer credential: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
operations.md; the design is
ADR-0057, building on
ADR-0051 and
ADR-0052.
Troubleshooting
Failures fall into a few classes, and the symptom tells you which one you have.
-
No route / connection timed out / connection refused. An overlay problem, not a phux problem. Check
tailscale status(both peers listed and notoffline) andtailscale ping <host>on Tailscale/Headscale, orwg showfor a recent handshake on raw WireGuard. Confirm the server binds an address the overlay routes (0.0.0.0:8787or the overlay IP itself) and that no host firewall drops the port. QUIC needs UDP end to end — if QUIC times out but wss:// works, UDP is blocked; stay on--ws. -
Auth failure (HTTP 401 / unauthorized on the WebSocket upgrade; QUIC token rejection). The link is fine; the bearer token is missing, mistyped, or was revoked. Mint one with
phux pair; it is live at the next connection attempt, with no restart. The 401 is returned before any phux frame is read, so a 401 proves reachability. -
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 re-prints the persisted certificate’s fingerprint without contacting the running server — and compare. Do not “fix” a mismatch by dropping the flag: the pin is what closes the trust-on-first-use MITM window. -
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. Widening it means a new certificate and a new fingerprint, which un-pairs every paired device; do it deliberately or not at all:rm ~/.local/state/phux/remote-cert.pem ~/.local/state/phux/remote-key.pem phux pair # regenerates, naming the address it advertisesthen re-pair every device against the new fingerprint.
-
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 operations.md.
Scope and alternatives
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.
Claude Code integration
The first-party phux Claude Code plugin exposes the authoritative phux mcp tool server and publishes lifecycle identity and attention events for Claude...
phux reference
Generated reference documentation, rendered from the compiled binary and byte-pinned by a freshness test so it cannot drift from the code.