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. Hosted agent sandboxes such as Meta Muse's VM work this way, and so do some corporate desktops and CI runners. Compat mode already handles the UDP part. This guide handles the rest: getting a Pilot node registered when the daemon cannot resolve or dial the Pilot hostnames directly.

Diagnose first

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

# 1. Is UDP blocked? A daemon on the default transport 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 pilot-daemon -transport=compat is all you need.

Why the obvious fixes fail

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 design

  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

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

# The router reads HTTPS_PROXY, e.g. http://user:[email protected]: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"

Do not start it through pilotctl daemon start: that path forks with a scrubbed environment and does not forward every daemon flag.

Verify

pilotctl --json info
pilotctl --json trusted list
pilotctl --json ping 0:0000.0000.660F --count 2 --timeout 30s

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 those fail outside the namespace. That is expected. The daemon's traffic is what matters, and trusted list and ping exercise both paths through it.

TLS trust: system or pinned

The registry presents a Let's Encrypt certificate. The 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>

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 needs the proxy URLExport it before starting the router
proxy refused CONNECT ... 407Bad proxy credentialsFix the user:pass@ part of the URL
mount --bind failedNot 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

About this article

Published by the Pilot Protocol team. Product claims are scoped to the availability labels and technical references linked in the article; deployment behavior can vary by version and environment.

How we publish · Suggest a correction · Technical references

Frequently asked questions

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 daemon 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 router works 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 router and daemon do not; re-run the start commands. There is no supervisor in most sandboxes, so put the recipe in whatever startup hook the environment provides.