// Package tunnelclient is the host half of the reverse channel (issue #53 / // C6 Step 3): dial OUT to the edge, invert the connection, and serve a local // http.Handler over HTTP/2 on it. Reconnect fast — hq's dual-WAN failover and // a Pi's Wi-Fi blip are both "the conn died, dial again", and that latency is // user-visible. // // It is deliberately a LEAF package: stdlib plus golang.org/x/net only, no // CARLOS imports. internal/host imports internal/edge, so the edge package's // own tests cannot import internal/host — and the acceptance test for this // channel (internal/edge/tunnel_e2e_test.go) has to drive the REAL client // loop, backoff and all, or the reconnect bound it asserts would be measuring // a test double. Keeping dial/serve/backoff here is what lets both sides use // one implementation. internal/host/tunnel_client.go is now a thin wrapper: // it reads the env and stores the state this package reports. package tunnelclient import ( "bufio" "context" "crypto/tls" "fmt" "io" "log" "math/rand/v2" "net" "net/http" "net/url" "time" "golang.org/x/net/http2" ) // The two states Loop reports through Config.OnState. "" (never reported // here) means the loop is not running at all — probes must fail toward honest // values, so the caller's zero value has to be distinguishable from anything // this package says. const ( StateConnected = "connected" StateReconnecting = "reconnecting" ) const ( BackoffFloor = 250 * time.Millisecond BackoffCap = 2 * time.Second // StableConnected is how long a connection has to have stayed up before // its loss resets backoff to the floor. Below this, the disconnect is // treated like a dial failure and backoff keeps escalating: the edge's // re-register-replaces-the-previous-channel semantics mean two peers // sharing a channel name (a cloned Pi env, the agent started twice, a // promote/adopt/restart overlap window) would otherwise evict each other // in an unbounded zero-sleep loop. StableConnected = 5 * time.Second // HandshakeTimeout bounds dial+TLS (via net.Dialer / tls.Dialer) and, // separately, the /register write+ReadResponse round trip: an edge that // accepts the TCP conn and then never answers must not park Loop forever // with no retry and no visible state change. HandshakeTimeout = 10 * time.Second ) // Config is one channel's worth of dialing instructions. URL, Name, Token and // Handler are required — Loop does not police them, because the caller is the // one holding the config source and owes its user the specific complaint (see // internal/host's env check). A blank or unparseable URL simply fails every // dial and backs off to BackoffCap, which is a bad config loudly logged, not // a storm. type Config struct { URL string // the edge's registration endpoint, http:// or https:// Name string // this channel's name, as the edge has it configured Token string // the bearer token bound to Name Handler http.Handler // served over the inverted conn // OnState, when non-nil, is called with StateConnected / // StateReconnecting on every transition — synchronously, from Loop's // goroutine, so it must not block. OnState func(string) // Logf defaults to log.Printf. The agent passes one that prefixes // "agent: " so the journal lines stay exactly what operators grep for. Logf func(format string, args ...any) } // Loop dials cfg.URL and serves cfg.Handler over the inverted conn, forever, // with jittered capped backoff. It returns only when ctx is cancelled; a // cancelled ctx tears down the live conn rather than waiting for it to die on // its own. State is left at whatever the last transition reported — this // package never invents a "stopped" state, because the agent's heartbeat // probe reads "" as "the loop was never started", which is a different fact. func Loop(ctx context.Context, cfg Config) { logf := cfg.Logf if logf == nil { logf = log.Printf } setState := func(s string) { if cfg.OnState != nil { cfg.OnState(s) } } backoff := BackoffFloor for ctx.Err() == nil { // Set BEFORE dialing, not only on error: Dial can block for up to // HandshakeTimeout on a black-holed handshake, and a stale "" or // "connected" during that window would read to the agent's heartbeat // probe as either "never started" or "healthy" — both wrong while // we're actually hung waiting on a dead edge. setState(StateReconnecting) conn, err := Dial(ctx, cfg.URL, cfg.Name, cfg.Token) if err != nil { sleep := backoff + rand.N(backoff/2) logf("tunnel dial: %v (retry in %s)", err, sleep) if !wait(ctx, sleep) { return } if backoff *= 2; backoff > BackoffCap { backoff = BackoffCap } continue } setState(StateConnected) logf("tunnel connected as %q", cfg.Name) connectedAt := time.Now() serveUntilDead(ctx, conn, Mark(cfg.Handler)) // blocks until the conn dies setState(StateReconnecting) logf("tunnel lost — re-dialing") // A connection that stayed up a while earns a fast retry (the // dual-WAN-failover / Wi-Fi-blip case); one that dies instantly does // not — see StableConnected. Either way we always sleep some amount // before redialing: a zero-delay retry is exactly the storm this // guards against. if time.Since(connectedAt) >= StableConnected { backoff = BackoffFloor } else if backoff *= 2; backoff > BackoffCap { backoff = BackoffCap } sleep := backoff + rand.N(backoff/2) logf("tunnel retry in %s", sleep) if !wait(ctx, sleep) { return } } } // wait sleeps d, or returns false if ctx is cancelled first. Every sleep in // Loop goes through it: a loop that ignored cancellation would outlive the // test that started it and keep dialing a closed listener. func wait(ctx context.Context, d time.Duration) bool { t := time.NewTimer(d) defer t.Stop() select { case <-ctx.Done(): return false case <-t.C: return true } } // serveUntilDead is Serve plus cancellation: http2.Server.ServeConn blocks // until the conn dies and has no other stop signal, so closing the conn is // how a cancelled ctx gets Loop back. func serveUntilDead(ctx context.Context, conn net.Conn, handler http.Handler) { done := make(chan struct{}) go func() { select { case <-ctx.Done(): conn.Close() case <-done: } }() defer close(done) Serve(conn, handler) } // bufferedConn replays bytes the handshake's bufio.Reader over-read (the h2 // preface can arrive in the same packet as the 101) before the raw conn. type bufferedConn struct { r io.Reader net.Conn } func (b bufferedConn) Read(p []byte) (int, error) { return b.r.Read(p) } // Dial performs the registration handshake and returns the raw conn, ready to // serve h2 on. https URLs use real TLS (the edge's public cert — server auth // by PKI, client auth by bearer token); http is for tests. // // ctx bounds the TCP connect and the TLS handshake. The /register round trip // after that is bounded by HandshakeTimeout instead, not by ctx: a cancel // arriving mid-handshake is therefore observed within HandshakeTimeout rather // than immediately, which is the one place cancellation is not instant and is // deliberately left simple — the alternative is a watchdog goroutine per dial // to buy 10 seconds on a shutdown path. func Dial(ctx context.Context, rawURL, name, token string) (net.Conn, error) { u, err := url.Parse(rawURL) if err != nil { return nil, fmt.Errorf("tunnel url: %w", err) } addr := u.Host dialer := &net.Dialer{Timeout: HandshakeTimeout} var conn net.Conn switch u.Scheme { case "https": if u.Port() == "" { addr = net.JoinHostPort(u.Hostname(), "443") } // tls.Dialer applies dialer.Timeout to the TCP connect and the TLS // handshake as a whole (crypto/tls docs) — a black-holed TLS peer // fails the dial instead of hanging it — and ctx on top of that. td := &tls.Dialer{NetDialer: dialer, Config: &tls.Config{ ServerName: u.Hostname(), NextProtos: []string{"http/1.1"}, // the handshake is 1.1; h2 starts after the 101 }} conn, err = td.DialContext(ctx, "tcp", addr) case "http": conn, err = dialer.DialContext(ctx, "tcp", addr) default: return nil, fmt.Errorf("tunnel url: unsupported scheme %q", u.Scheme) } if err != nil { return nil, err } // The dial above only bounds TCP connect + TLS handshake; the /register // write+read is a separate hang risk (an edge that accepts the conn and // then never answers). Bound it too, and clear the deadline before // returning success — the live h2 session that follows must not inherit a // stale deadline from the handshake. if err := conn.SetDeadline(time.Now().Add(HandshakeTimeout)); err != nil { conn.Close() return nil, fmt.Errorf("tunnel: set handshake deadline: %w", err) } req := &http.Request{ Method: "GET", URL: &url.URL{Path: "/register"}, Host: u.Hostname(), Header: http.Header{ "Authorization": {"Bearer " + token}, "Carlos-Tunnel-Name": {name}, "Connection": {"Upgrade"}, "Upgrade": {"carlos-tunnel/1"}, }, } if err := req.Write(conn); err != nil { conn.Close() return nil, err } br := bufio.NewReader(conn) resp, err := http.ReadResponse(br, req) if err != nil { conn.Close() return nil, err } resp.Body.Close() if resp.StatusCode != http.StatusSwitchingProtocols { conn.Close() return nil, fmt.Errorf("tunnel register: %s", resp.Status) } if err := conn.SetDeadline(time.Time{}); err != nil { conn.Close() return nil, fmt.Errorf("tunnel: clear handshake deadline: %w", err) } if br.Buffered() > 0 { return bufferedConn{r: io.MultiReader(br, conn), Conn: conn}, nil } return conn, nil } type tunnelKey struct{} // FromTunnel reports whether a request was served over a tunnel this client // dialled: the hub computed its GeoIP answer, so the edge adopts the hub's // headers and does not observe the request for analytics (the hub already // did). Set on every tunnelled request whatever the URL's scheme (geoip spec // D2: on a plaintext tunnel an on-path attacker can already inject any // request, so withholding geo would protect nothing and would make the hub's // cache disagree with what the app saw). Nothing outside this package can // set it. func FromTunnel(ctx context.Context) bool { v, _ := ctx.Value(tunnelKey{}).(bool) return v } type markedHandler struct{ h http.Handler } func (m markedHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { m.h.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), tunnelKey{}, true))) } // Mark wraps h so every request it serves carries the tunnel mark. Loop // applies it; a caller using Serve directly applies it itself if it wants // the mark. func Mark(h http.Handler) http.Handler { return markedHandler{h} } // Serve serves handler over the inverted conn until it dies. func Serve(conn net.Conn, handler http.Handler) { (&http2.Server{}).ServeConn(conn, &http2.ServeConnOpts{Handler: handler}) }