carlos / platform Public

edge: TunnelHub — authenticated reverse-channel registration (reverse h2 over hijacked 443)

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.

Reverse h2 rather than a websocket port of titogo's tunnel — x/net/http2
already supplies mux, flow control, and pings. A ping loop bounds how long a
half-dead channel can look connected, and transportFor resolves the channel
per request so a proxy built before a reconnect uses the fresh one.

Promotes golang.org/x/net to a direct require. Wiring into the Edge's
ServeHTTP/Reload is the next task; this is the hub and its unit tests only.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 5acaed3ec305260473a97f5ba7216eeaa7dfbf68 parent b46b4cd
3 files changed, +237 −1
  • go.mod +1 −1
  • internal/edge/tunnel.go +190 −0
  • internal/edge/tunnel_test.go +46 −0
diff --git a/go.mod b/go.mod
index 642039770..b1bfa10f6 100644
--- a/go.mod
+++ b/go.mod
@@ -9,6 +9,7 @@ require (
github.com/aws/smithy-go v1.27.3
github.com/benbjohnson/litestream v0.5.14
golang.org/x/crypto v0.52.0
+ golang.org/x/net v0.55.0
modernc.org/sqlite v1.55.0
)
@@ -53,7 +54,6 @@ require (
github.com/superfly/ltx v0.5.1 // indirect
github.com/tetratelabs/wazero v1.2.1 // indirect
github.com/wasilibs/go-re2 v1.3.0 // indirect
- golang.org/x/net v0.55.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.37.0 // indirect
diff --git a/internal/edge/tunnel.go b/internal/edge/tunnel.go
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)
+ })
+}
diff --git a/internal/edge/tunnel_test.go b/internal/edge/tunnel_test.go
new file mode 100644
index 000000000..554dfe36b
--- /dev/null
+++ b/internal/edge/tunnel_test.go
@@ -0,0 +1,46 @@
+package edge
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestTunnelRegisterRejectsBadToken(t *testing.T) {
+ hub := NewTunnelHub("tunnel.test", map[string]string{"pi-box": "sekrit"})
+ for name, hdr := range map[string]http.Header{
+ "no auth": {},
+ "wrong token": {"Authorization": {"Bearer nope"}, "Carlos-Tunnel-Name": {"pi-box"}},
+ "unknown name": {"Authorization": {"Bearer sekrit"}, "Carlos-Tunnel-Name": {"other"}},
+ // A valid token may only claim ITS OWN channel name.
+ "cross-name": {"Authorization": {"Bearer sekrit"}, "Carlos-Tunnel-Name": {""}},
+ } {
+ req := httptest.NewRequest("GET", "/register", nil)
+ req.Header = hdr
+ rec := httptest.NewRecorder()
+ hub.ServeHTTP(rec, req)
+ if rec.Code != http.StatusUnauthorized {
+ t.Errorf("%s: got %d, want 401", name, rec.Code)
+ }
+ }
+}
+
+func TestTunnelRegisterRejectsUnknownPath(t *testing.T) {
+ hub := NewTunnelHub("tunnel.test", map[string]string{"pi-box": "sekrit"})
+ req := httptest.NewRequest("GET", "/anything-else", nil)
+ rec := httptest.NewRecorder()
+ hub.ServeHTTP(rec, req)
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("got %d, want 404", rec.Code)
+ }
+}
+
+func TestParseTunnelClients(t *testing.T) {
+ got, err := parseTunnelClients("pi-box=sekrit,hq=other")
+ if err != nil || got["pi-box"] != "sekrit" || got["hq"] != "other" {
+ t.Fatalf("parse: %v %v", got, err)
+ }
+ if _, err := parseTunnelClients("malformed"); err == nil {
+ t.Fatal("want error on entry without '='")
+ }
+}