| new file mode 100644 |
| index 000000000..10566dd25 |
| --- /dev/null |
| +++ b/internal/edge/tunnel.go |
| @@ -0,0 +1,190 @@ |
| +// 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/subtle" |
| + "fmt" |
| + "log" |
| + "net" |
| + "net/http" |
| + "strings" |
| + "sync" |
| + "time" |
| + |
| + "golang.org/x/net/http2" |
| +) |
| + |
| +const ( |
| + tunnelRegisterPath = "/register" |
| + 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 |
| +) |
| + |
| +type tunnelConn struct { |
| + cc *http2.ClientConn |
| + raw net.Conn |
| +} |
| + |
| +type TunnelHub struct { |
| + host string // reserved hostname this hub answers on |
| + clients map[string]string // channel name -> bearer token |
| + mu sync.Mutex |
| + conns map[string]*tunnelConn |
| +} |
| + |
| +func NewTunnelHub(host string, clients map[string]string) *TunnelHub { |
| + return &TunnelHub{host: host, clients: clients, conns: make(map[string]*tunnelConn)} |
| +} |
| + |
| +func (h *TunnelHub) Host() string { return h.host } |
| + |
| +// parseTunnelClients parses CARLOS_TUNNEL_CLIENTS ("name=token[,name=token]"). |
| +func parseTunnelClients(s string) (map[string]string, error) { |
| + out := make(map[string]string) |
| + for _, pair := range strings.Split(s, ",") { |
| + pair = strings.TrimSpace(pair) |
| + if pair == "" { |
| + continue |
| + } |
| + name, token, ok := strings.Cut(pair, "=") |
| + if !ok || name == "" || token == "" { |
| + return nil, fmt.Errorf("tunnel clients: malformed entry %q (want name=token)", pair) |
| + } |
| + out[name] = token |
| + } |
| + return out, nil |
| +} |
| + |
| +// ServeHTTP handles ONLY the registration handshake; the Edge routes requests |
| +// here when r.Host equals the reserved hostname, so ordinary app traffic can |
| +// never reach it and this handler never sees ordinary traffic. |
| +func (h *TunnelHub) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| + if r.URL.Path != tunnelRegisterPath { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + name := r.Header.Get("Carlos-Tunnel-Name") |
| + token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") |
| + want, ok := h.clients[name] |
| + // Constant-time compare; the name lookup happening first is fine — the |
| + // name is not the secret, the token is. A token is bound to exactly one |
| + // channel name, so a leaked token cannot impersonate a different host. |
| + // An empty configured token never authenticates anything: parseTunnelClients |
| + // rejects one, and a hub built some other way must not turn a blank config |
| + // value into a channel that answers to "Authorization: Bearer ". |
| + if name == "" || !ok || want == "" || subtle.ConstantTimeCompare([]byte(token), []byte(want)) != 1 { |
| + http.Error(w, "unauthorized", http.StatusUnauthorized) |
| + return |
| + } |
| + hj, ok := w.(http.Hijacker) |
| + if !ok { |
| + http.Error(w, "hijack unsupported", http.StatusInternalServerError) |
| + return |
| + } |
| + conn, bufrw, err := hj.Hijack() |
| + if err != nil { |
| + log.Printf("edge: tunnel %s: hijack: %v", name, err) |
| + return |
| + } |
| + // The h2 session runs on the RAW conn, so anything the host pipelined |
| + // ahead of our 101 is sitting unreadable in bufrw and would corrupt the |
| + // handshake. Fail loudly here rather than hang on a broken preface: the |
| + // host-side dialer's contract is to write nothing until it reads the 101. |
| + if n := bufrw.Reader.Buffered(); n > 0 { |
| + log.Printf("edge: tunnel %s: %d bytes sent before 101 — dropping", name, n) |
| + conn.Close() |
| + return |
| + } |
| + // Manual 101 on the hijacked conn (the ResponseWriter is spent once hijacked). |
| + fmt.Fprintf(bufrw, "HTTP/1.1 101 Switching Protocols\r\nUpgrade: %s\r\nConnection: Upgrade\r\n\r\n", tunnelUpgradeProto) |
| + if err := bufrw.Flush(); err != nil { |
| + conn.Close() |
| + return |
| + } |
| + tr := &http2.Transport{} |
| + cc, err := tr.NewClientConn(conn) |
| + if err != nil { |
| + log.Printf("edge: tunnel %s: h2 client conn: %v", name, err) |
| + conn.Close() |
| + return |
| + } |
| + h.mu.Lock() |
| + if old := h.conns[name]; old != nil { |
| + old.raw.Close() // a re-register replaces the previous channel |
| + } |
| + h.conns[name] = &tunnelConn{cc: cc, raw: conn} |
| + h.mu.Unlock() |
| + log.Printf("edge: tunnel %s: connected from %s", name, conn.RemoteAddr()) |
| + go h.pingLoop(name, cc, conn) |
| +} |
| + |
| +// pingLoop is the edge-side liveness check: a dead ping closes the raw conn |
| +// and unregisters the channel, so requests fail fast to 502 instead of |
| +// hanging on a black-holed TCP session (the venue-Wi-Fi failure mode). |
| +func (h *TunnelHub) pingLoop(name string, cc *http2.ClientConn, raw net.Conn) { |
| + for { |
| + time.Sleep(tunnelPingEvery) |
| + ctx, cancel := context.WithTimeout(context.Background(), tunnelPingTimeout) |
| + err := cc.Ping(ctx) |
| + cancel() |
| + if err != nil { |
| + log.Printf("edge: tunnel %s: ping failed (%v) — dropping channel", name, err) |
| + raw.Close() |
| + // Only unregister if WE are still the live channel: a re-register |
| + // may already have replaced this entry (and closed our conn, which |
| + // is what failed the ping), and deleting then would blank out a |
| + // perfectly healthy successor. |
| + h.mu.Lock() |
| + if cur := h.conns[name]; cur != nil && cur.raw == raw { |
| + delete(h.conns, name) |
| + } |
| + h.mu.Unlock() |
| + return |
| + } |
| + } |
| +} |
| + |
| +func (h *TunnelHub) conn(name string) *http2.ClientConn { |
| + h.mu.Lock() |
| + defer h.mu.Unlock() |
| + if c := h.conns[name]; c != nil { |
| + return c.cc |
| + } |
| + return nil |
| +} |
| + |
| +// State reports "connected" or "" — consumed by fleet status rendering. |
| +func (h *TunnelHub) State(name string) string { |
| + if h.conn(name) != nil { |
| + return "connected" |
| + } |
| + return "" |
| +} |
| + |
| +type roundTripperFunc func(*http.Request) (*http.Response, error) |
| + |
| +func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } |
| + |
| +// transportFor resolves the channel PER REQUEST, so a proxy built before a |
| +// reconnect uses the fresh channel afterwards with no reload needed. |
| +func (h *TunnelHub) transportFor(name string) http.RoundTripper { |
| + return roundTripperFunc(func(req *http.Request) (*http.Response, error) { |
| + cc := h.conn(name) |
| + if cc == nil { |
| + return nil, fmt.Errorf("tunnel %q not connected", name) |
| + } |
| + return cc.RoundTrip(req) |
| + }) |
| +} |