// Package-internal reverse-channel hub: remote hosts dial OUT to the edge's // existing 443 listener on a reserved hostname, authenticate with a bearer // token bound to their channel name, and the connection inverts — the edge // becomes an HTTP/2 client over the hijacked conn, the host an HTTP/2 server. // Nothing ever connects TO a remote host (issue #53's locked-down property); // this file is the edge half of that bargain. Transport judgment call // (spec 2026-08-04): reverse h2 rather than a websocket port of titogo's // tunnel — x/net/http2 already supplies mux, flow control, and pings. package edge import ( "context" "crypto/sha256" "crypto/subtle" "encoding/hex" "fmt" "log" "net" "net/http" "slices" "strings" "sync" "time" "golang.org/x/net/http/httpguts" "golang.org/x/net/http2" ) const ( tunnelRegisterPath = "/register" // tunnelUpgradeProto is the wire contract the host dialer codes against: // // GET /register // Authorization: Bearer // Carlos-Tunnel-Name: // Connection: Upgrade // Upgrade: carlos-tunnel/1 // // The dialer MUST also pin NextProtos to {"http/1.1"} on its TLS config. // The edge's 443 listener advertises h2, and a dialer that lets ALPN pick // it authenticates fine and then dies on "hijack unsupported": HTTP/2 // response writers are not http.Hijacker, and hijacking is how this // handshake gets the raw conn. The h2 session begins only AFTER the 101, // on that raw conn, with the roles inverted. tunnelUpgradeProto = "carlos-tunnel/1" // tunnelPingEvery / tunnelPingTimeout bound how long a half-dead channel // can look connected: a failed or timed-out ping tears it down, the host // notices its conn die and re-dials (seconds — the bound Task 10 asserts). tunnelPingEvery = 15 * time.Second tunnelPingTimeout = 10 * time.Second // Fleet (dynamic) channels ping tighter (fleets-console spec D7): a // customer fleet's failover is "requests fall back to a surviving box once // the dead channel is reaped", so the reap bound IS the user-visible // outage window — ≤10s (one 5s sleep + one 5s deadline) instead of the // static channels' ~25s. Static platform channels (tito hq) keep the wider // cadence: their routes have no fallback box, so a tighter ping only adds // churn risk for no availability gain. fleetPingEvery = 5 * time.Second fleetPingTimeout = 5 * time.Second ) // pingCadence picks a channel's liveness cadence from which credential source // admitted it. Split out from pingLoop so the choice is testable without // waiting on wall-clock pings. func pingCadence(dynamic bool) (every, timeout time.Duration) { if dynamic { return fleetPingEvery, fleetPingTimeout } return tunnelPingEvery, tunnelPingTimeout } type tunnelConn struct { cc *http2.ClientConn raw net.Conn // dynamic records that this conn authenticated against the registry-backed // (fleet) credential source rather than the static env map — the one fact // ServeHTTP knows and pingLoop needs for pingCadence. dynamic bool } type TunnelHub struct { host string // reserved hostname this hub answers on clients map[string]string // channel name -> bearer token (static env config, plaintext) mu sync.Mutex conns map[string]*tunnelConn // dynamicAuth is the second, registry-backed credential source: channel // "/