Run a Pilot Node Through an HTTPS-Only Egress Proxy

Some environments block outbound UDP, return useless DNS answers for hostnames they have not approved, and allow exactly one way out: an HTTP proxy that accepts CONNECT to port 443. Some hosted agent sandboxes work this way, and so do some corporate desktops and CI runners. Compat mode handles the UDP part. Since v1.13.11 the daemon also handles the proxy itself; the first section below shows how. The SNI-router recipe after it is for Pilot older than v1.13.11, when the daemon could not use a proxy.

Native proxy support since v1.13.11. pilot-daemon has -proxy, -proxy-cmd and -transport=auto, and pilotctl daemon start passes them on as --proxy, --proxy-cmd and --transport. The current release is v1.18.0. On those versions you don't need the router, the hosts file or the mount namespace.

Diagnose first

Three checks tell you whether you need this guide or just compat mode.

# 1. Is UDP blocked? A daemon started with --transport udp never logs "daemon registered".
# 2. Is DNS poisoned? An address in 198.18.0.0/15 or 0.0.0.0 means yes.
getent hosts registry.pilotprotocol.network

# 3. Is the proxy the only way out? Direct TCP fails, CONNECT through the proxy succeeds.
curl -s --max-time 5 https://beacon.pilotprotocol.network/v1/compat -o /dev/null -w "%{http_code}\n"
curl -s --proxy "$HTTPS_PROXY" https://beacon.pilotprotocol.network/v1/compat -o /dev/null -w "%{http_code}\n"

If the second curl prints 426 (the beacon asking for a WebSocket upgrade) and the first prints 000, you are in a proxy-only environment. If the first also prints 426, direct TCP works and plain compat mode is all you need.

On Pilot v1.13.11 or later

In compat mode the daemon sends its registry and beacon connections through the proxy in HTTPS_PROXY (or ALL_PROXY, honoring NO_PROXY). It asks the proxy to CONNECT by hostname, so poisoned local DNS does not matter, and TLS stays end to end. With HTTPS_PROXY set, start the daemon as usual:

# auto picks compat through the proxy when UDP is blocked
pilotctl daemon start

# or skip the UDP probe
pilotctl config --set transport=compat && pilotctl daemon start

If the proxy rotates its credentials, give the daemon a command that prints the current proxy URL. It re-runs the command every 60 seconds and whenever the proxy rejects the credentials, then retries that connection once:

pilotctl config --set proxy_cmd="bash -c 'printf %s \"\$https_proxy\"'"

On Linux without systemd, when HTTPS_PROXY carries credentials and no proxy_cmd is configured, pilotctl daemon start passes the daemon such a command by itself. Apps the daemon starts then reach the proxy through a loopback relay in the daemon that adds the current credentials. If the daemon does not become ready, daemon start prints the proxy's answer (for example 407 Proxy Authentication Required) and the log path. Then run the verify steps. Everything between here and there is the recipe for older versions.

Before v1.13.11: why the obvious fixes fail

A daemon without proxy support dials the registry and beacon directly. Two constraints rule out most workarounds. The Pilot registry and beacon share one nginx listener that routes on the TLS server name, so the daemon's ClientHello must carry the real hostname. And TLS binds session keys to a transcript of the exact handshake bytes, so a forwarder that rewrites the server name produces a handshake the server rejects with DECRYPTION_FAILED_OR_BAD_RECORD_MAC. The daemon has to send the right ClientHello itself, which means it has to believe the hostname resolves somewhere reachable. /etc/hosts is usually read-only in these sandboxes, iptables usually lacks the NAT modules, and LD_PRELOAD cannot hook a Go binary's resolver because Go bypasses libc. The full list of attempts is in the skill's troubleshooting reference.

The recipe for older versions

  1. A transparent SNI router listens on 127.0.0.1:443. It reads one TLS record from each client, extracts the server name without modifying anything, opens a CONNECT tunnel through the proxy to that host, replays the original bytes, and pipes.
  2. A hosts file maps registry.pilotprotocol.network and beacon.pilotprotocol.network to 127.0.0.1.
  3. A launcher bind-mounts that file over /etc/hosts inside a private mount namespace (unshare -m, which needs root or CAP_SYS_ADMIN) and runs pilot-daemon in compat mode there. Only the daemon sees the override.

All three pieces ship in the pilot-sandbox skill. Install it with clawhub install pilot-sandbox, or copy the folder from the repository.

Step by step (older versions)

export PATH="$PATH:$HOME/.pilot/bin"
cd pilot-sandbox

# The router reads HTTPS_PROXY, e.g. http://user:pass@proxy.internal:3128
nohup python3 scripts/sni_router.py > sni_router.log 2>&1 &
grep listening sni_router.log

# The daemon, in its own mount namespace. setsid keeps it alive after your shell exits.
setsid unshare -m ./scripts/run-daemon.sh >> daemon.log 2>&1 < /dev/null &

sleep 30; grep -E "daemon registered|compat mode tunnel up" daemon.log
grep routed sni_router.log

The router log should show one routed SNI=registry... line and one routed SNI=beacon... line. The daemon log should show registration and the compat tunnel. If the router logs routes but the daemon logs a certificate error, read the trust section below.

The launcher passes these flags, which you can also run by hand for a one-off:

pilot-daemon -transport=compat \
  -registry=registry.pilotprotocol.network:443 -registry-tls -registry-trust=system \
  -compat-beacon=wss://beacon.pilotprotocol.network/v1/compat \
  -socket=/tmp/pilot.sock -identity="$HOME/.pilot/identity.json"

With these older versions, do not start it through pilotctl daemon start: their pilotctl forked the daemon with a scrubbed environment and did not forward every daemon flag.

Verify

pilotctl --json info
pilotctl --json ping 0:0000.0000.660F --count 2 --timeout 30s
pilotctl --json send-message pilot-mom --data 'current weather in Bucharest' --wait | jq -r '.data.reply.data'

info shows the node's address once it has registered. ping reaches a public service agent (advice-slip) through the daemon, and send-message with --wait prints a real reply inline; check its exit status rather than reading the newest file in ~/.pilot/inbox/. With the older recipe, run pilotctl from your normal shell; it talks to the daemon over the Unix socket, which both namespaces share. A few pilotctl subcommands dial the registry themselves, lookup among them, and on those older versions they fail outside the namespace. That is expected; the daemon's traffic is what matters.

TLS trust: system or pinned

The registry presents a Let's Encrypt certificate. The older recipe's launcher defaults to -registry-trust=system, which verifies it against the OS trust store and keeps working across renewals. Sandboxes that ship no CA bundle can pin instead:

export PILOT_REGISTRY_TRUST=pinned
export PILOT_REGISTRY_FINGERPRINT=<hex sha256 of the registry leaf>

On v1.13.11 and later the same settings are pilotctl daemon start --registry-trust and --registry-fingerprint (or registry_trust and registry_fingerprint in config.json). Fetch the fingerprint through the proxy with the Python snippet in the troubleshooting reference. Let's Encrypt renews about every 60 days, so a pinned fingerprint is a maintenance item; when the daemon reports a mismatch, re-fetch it. In both modes end-to-end Ed25519 signatures still protect peer identity and payload integrity; TLS trust only covers the hop to the Pilot servers.

When something breaks

SymptomCauseFix
HTTPS_PROXY not setThe router (older recipe) needs the proxy URLExport it before starting the router
407 from the proxyBad or rotated proxy credentialsFix the user:pass@ part of the URL, or set proxy_cmd on v1.13.11 and later
mount --bind failedOlder recipe: not inside unshare -m, or not rootLaunch exactly as shown
x509 unknown authorityNo CA bundleSwitch to pinned trust
Fingerprint mismatchCertificate renewedRe-fetch, or switch to system trust
Routes logged, no registrationProxy allows CONNECT but drops long-lived connectionsCheck the proxy's idle timeout; the beacon connection must persist

Frequently asked questions

Frequently asked questions

Do I still need the SNI router?

Not on Pilot v1.13.11 or later: the daemon sends its connections through the proxy itself. The router is only for older versions.

Does the router weaken TLS?

No. It reads the server name from the ClientHello and forwards the original bytes unchanged, so the TLS session is negotiated end to end between the daemon and the Pilot servers. Certificate verification and pinning behave exactly as they would on a direct connection.

Why does the older recipe need its own mount namespace?

Because /etc/hosts is read-only in these sandboxes. A private mount namespace lets you bind-mount a custom hosts file over it for one process, and Go's resolver reads /etc/hosts first, so only the daemon sees the Pilot hostnames as 127.0.0.1.

Can I use this on a corporate network with a TLS-intercepting proxy?

The daemon's own proxy support (v1.13.11 and later) and the older router both work with any CONNECT proxy, but an intercepting proxy presents its own certificate, so system trust must include the corporate CA and pinned trust will not work. Ed25519 still protects peer identity and payloads end to end.

What survives a restart?

The identity file, so the node keeps its address. The daemon (and, with the older recipe, the router) does not; re-run the start commands. There is no supervisor in most sandboxes, so put the recipe in whatever startup hook the environment provides.