feat(netproxy): refresh rotating proxy credentials (407 -> refresh -> retry once) - #51
Merged
Merged
Conversation
… retry once)
Some egress proxies (Meta Muse) rotate the credentials embedded in
HTTPS_PROXY every few minutes. A long-running process keeps its
launch-time copy and gets 407 on every new CONNECT while its open tunnels
stay up ("node online, all apps broken"). netproxy snapshotted the
environment once, so a daemon broke within minutes.
Resolver
- Settings live in an atomically swapped state; ModeAuto re-reads the
environment, and a refresh source (WithRefreshCommand: "sh -c", 10 s
timeout, output and stderr never logged; or WithRefreshFunc) supplies
the current proxy URL. EnvRefreshCommand documents PILOT_PROXY_CMD as
the convention callers wire.
- Timed refresh on lookup (DefaultRefreshInterval 60 s,
WithRefreshInterval), Refresh(ctx) to force one, singleflight so
concurrent callers share one run, last good settings kept on failure,
WithRefreshErrorHandler told once per run of failures.
- NewResolver(spec, opts...) is Parse with options; existing
constructors keep their signatures and behaviour (auto additionally
follows in-place environment changes).
Dialer
- On 407 (or an unparseable CONNECT response, how Muse's rejections
surfaced) refresh and retry once on a new connection, only if the
refreshed URL differs. A refresh that already ran after the pick is not
repeated, so identical credentials never loop.
RefreshingTransport(base, r)
- Clone of base with Proxy = r.ProxyForRequest and a chained
OnProxyConnectResponse that turns non-200 CONNECT answers into
*ConnectError. Verified empirically: net/http reports a refused CONNECT
only as the proxy's reason phrase ("Proxy Authentication Required",
"unknown status code", or arbitrary proxy text) with no status code.
- Retries once for GET/HEAD/OPTIONS/TRACE (or Idempotency-Key) and, after
a 407, any request with GetBody; a POST without GetBody is not retried
but the refresh still happens for the next request.
Tests use an in-process CONNECT proxy whose accepted password rotates and
a refresh command that counts its runs: long-running dialer across 4
rotations with earlier tunnels still echoing; 407 -> refresh -> success;
unchanged credentials -> no retry; failing command -> last good kept and
error reported once; 50 concurrent dials hitting a rotation -> exactly one
command run; timeout; output never leaked; GET retried, POST without
GetBody not retried.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
- F1: a timed refresh no longer blocks the lookup that notices it is due.
Resolver.current starts the refresh in the background and answers from
the settings in hand (the last good credentials), so a slow or hung
refresh command cannot fail dials whose budget (e.g. the registry
client's 5 s) is shorter than the 10 s refresh timeout. Only a rejected
credential (reauth after a 407) waits for a refresh.
- F2: on Unix the refresh command runs in its own process group; a
timeout SIGKILLs the whole group (exec.Cmd.Cancel), and so does a
command that exits leaving a child holding its stdout, so hung
children no longer pile up across refreshes.
- F3: RefreshingTransport treats an unparseable response as a proxy
rejection only while the tunnel is being set up (no GotConn since the
last GetConn, via httptrace). A server sending a malformed response
through the tunnel gets net/http's error untouched and never triggers
the refresh command. The proxy URL a refused CONNECT used is captured
from the Proxy call itself.
- F4: net/http keys its pool by proxy URL including credentials, so once
a refresh changes the proxy settings, the first request afterwards
closes the stranded idle connections. A refresh that reads the same
settings again leaves the pool alone. Doc corrected.
- F5: an unparseable CONNECT response is reported by what was wrong with
it ("malformed HTTP status code (response text withheld)"), never by
its text, from both Dialer and RefreshingTransport; the Dialer keeps
wrapping only genuine read errors (EOF, net.Error).
Each fix has a regression test that fails on the previous commit.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Contributor
Author
Review fixes (70607b9)All five findings were confirmed and are fixed. Each one has a regression test, and each test fails on d6417bc.
Behaviour changes to note:
Checks run locally:
🤖 Generated with Claude Code |
TeoSlayer
added a commit
to pilot-protocol/pilotprotocol
that referenced
this pull request
Sep 24, 2026
common v0.5.15 (pilot-protocol/common#51, #52): idempotent registry Client.Close (fixes the 'close of closed channel' panic in every nightly since 07-26), pool reconnects keep their cause, log at Debug with a periodic summary, back off with jitter and retry on a healthy conn (113,804 'reconnected' INFO lines were 57% of one daemon.log), and the proxy credential refresh used by the upcoming Muse support. forceReconnectRegistry now declines once Stop has begun and closes the conn it installed if Stop won the race, so a heartbeat or rx-watchdog reconnect during shutdown no longer leaks a registry pool. Co-authored-by: Teodor Calin <teodor@vulturelabs.io> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Meta Muse's egress proxy rotates the credentials embedded in
HTTPS_PROXYevery few minutes. A fresh shell sees the current value (bash -c 'printf %s "$https_proxy"'); a long-running process keeps its launch-time copy and gets407on every new CONNECT while its existing tunnels stay up — "node online, all apps broken" (see https://pilotprotocol.network/blog/rotating-egress-proxy-credentials).netproxy.FromEnvironment()snapshotted the env once, so a daemon (whose registry client redials constantly) broke within minutes.This PR adds credential refresh inside
netproxyonly, soregistry/client(viaDialer) and HTTP callers (viaProxyForRequest/RefreshingTransport) get it without changes.What
Resolver: refreshable settings
os.Getenv) on refresh, so processes whose env is updated in place follow it. Without a command, behaviour is otherwise unchanged.WithRefreshCommand(cmd)runssh -c cmd(10 s timeout, stdin/stderr discarded,WaitDelayso a stray child cannot hang it, output capped at 64 KiB). Stdout, trimmed, must be onehttp(s)://proxy URL. The output is never logged or put in errors.WithRefreshFunc(fn)is the Go-function form.EnvRefreshCommand = "PILOT_PROXY_CMD"documents the convention; netproxy never reads it on its own. web4 wires it:PILOT_PROXY_CMD='bash -c '\''printf %s "$https_proxy"'\'''.)DefaultRefreshInterval(60 s,WithRefreshInterval).Refresh(ctx)forces one.WithRefreshErrorHandlerhears about it once per run of consecutive failures.NewResolver(spec, opts...),Option,WithRefreshCommand,WithRefreshFunc,WithRefreshInterval,WithRefreshErrorHandler,Resolver.Refresh,RefreshingTransport,EnvRefreshCommand,DefaultRefreshInterval.Parse,Explicit,FromEnvironmentandOffkeep their signatures.Dialer: 407 → refresh → retry once
407, the Dialer force-refreshes and retries the CONNECT once on a fresh connection, and only if the refreshed URL differs. Open tunnels are never touched.http.ReadResponsecannot parse is also treated as a possible rejection. Muse reported the 407 surfacing asmalformed HTTP status code. The refresh-and-compare rule still bounds this to one refresh and at most one retry.RefreshingTransport(base *http.Transport, r *Resolver) http.RoundTripperVerified empirically (pinned in
TestNetHTTPReportsRefusedCONNECTWithoutStatusCode): for a refused CONNECT, net/http returns a bareerrors.errorStringholding only the proxy's reason phrase:Proxy Authentication Required;unknown status codewhen there is no phrase;malformed HTTP status code "407Proxy"for an unparseable line.There is no status code in any of these, so matching on the text is unreliable.
The transport therefore reads the status from a chained
OnProxyConnectResponse(on a clone ofbase, withProxy = r.ProxyForRequest). It turns every non-200 into a*ConnectError, whose message never includes proxy text.On 407 it refreshes, then retries once for:
Idempotency-Keyheader);GetBody, since the proxy refused the tunnel and the server never saw the first attempt.A POST without GetBody is not retried, but the refresh still happens, so the next request works. The malformed case retries idempotent methods only.
Implements
CloseIdleConnections.Tests (
GOWORK=off go test -race ./... -count=1green; vet, gofmt, staticcheck and gitleaks clean; netproxy coverage 95.3%; refresh/transport tests stable over-count=15 -race)The tests use an in-process CONNECT proxy whose accepted password rotates, plus a refresh command (
sh -c) that logs its runs:osenv viaFromEnvironment, and a retry that goes direct when the refreshed NO_PROXY exempts the target;407 … (proxy credential refresh failed: … exit status 3); the command recovers afterwards;RefreshingTransport:Follow-ups (not in this PR)
PILOT_PROXY_CMDintoNewResolver. Wrap daemon HTTP clients andDefaultTransportinRefreshingTransportwhere the loopback exemption allows.proxyconf.RequestProxyalready benefits from the timed refresh.🤖 Generated with Claude Code