carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// Package-internal reverse-channel hub: remote hosts dial OUT to the edge's
2// existing 443 listener on a reserved hostname, authenticate with a bearer
3// token bound to their channel name, and the connection inverts — the edge
4// becomes an HTTP/2 client over the hijacked conn, the host an HTTP/2 server.
5// Nothing ever connects TO a remote host (issue #53's locked-down property);
6// this file is the edge half of that bargain. Transport judgment call
7// (spec 2026-08-04): reverse h2 rather than a websocket port of titogo's
8// tunnel — x/net/http2 already supplies mux, flow control, and pings.
9package edge
10
11import (
12 "context"
13 "crypto/sha256"
14 "crypto/subtle"
15 "encoding/hex"
16 "fmt"
17 "log"
18 "net"
19 "net/http"
20 "slices"
21 "strings"
22 "sync"
23 "time"
24
25 "golang.org/x/net/http/httpguts"
26 "golang.org/x/net/http2"
27)
28
29const (
30 tunnelRegisterPath = "/register"
31 // tunnelUpgradeProto is the wire contract the host dialer codes against:
32 //
33 // GET /register
34 // Authorization: Bearer <token>
35 // Carlos-Tunnel-Name: <name>
36 // Connection: Upgrade
37 // Upgrade: carlos-tunnel/1
38 //
39 // The dialer MUST also pin NextProtos to {"http/1.1"} on its TLS config.
40 // The edge's 443 listener advertises h2, and a dialer that lets ALPN pick
41 // it authenticates fine and then dies on "hijack unsupported": HTTP/2
42 // response writers are not http.Hijacker, and hijacking is how this
43 // handshake gets the raw conn. The h2 session begins only AFTER the 101,
44 // on that raw conn, with the roles inverted.
45 tunnelUpgradeProto = "carlos-tunnel/1"
46 // tunnelPingEvery / tunnelPingTimeout bound how long a half-dead channel
47 // can look connected: a failed or timed-out ping tears it down, the host
48 // notices its conn die and re-dials (seconds — the bound Task 10 asserts).
49 tunnelPingEvery = 15 * time.Second
50 tunnelPingTimeout = 10 * time.Second
51 // Fleet (dynamic) channels ping tighter (fleets-console spec D7): a
52 // customer fleet's failover is "requests fall back to a surviving box once
53 // the dead channel is reaped", so the reap bound IS the user-visible
54 // outage window — ≤10s (one 5s sleep + one 5s deadline) instead of the
55 // static channels' ~25s. Static platform channels (tito hq) keep the wider
56 // cadence: their routes have no fallback box, so a tighter ping only adds
57 // churn risk for no availability gain.
58 fleetPingEvery = 5 * time.Second
59 fleetPingTimeout = 5 * time.Second
60)
61
62// pingCadence picks a channel's liveness cadence from which credential source
63// admitted it. Split out from pingLoop so the choice is testable without
64// waiting on wall-clock pings.
65func pingCadence(dynamic bool) (every, timeout time.Duration) {
66 if dynamic {
67 return fleetPingEvery, fleetPingTimeout
68 }
69 return tunnelPingEvery, tunnelPingTimeout
70}
71
72type tunnelConn struct {
73 cc *http2.ClientConn
74 raw net.Conn
75 // dynamic records that this conn authenticated against the registry-backed
76 // (fleet) credential source rather than the static env map — the one fact
77 // ServeHTTP knows and pingLoop needs for pingCadence.
78 dynamic bool
79}
80
81type TunnelHub struct {
82 host string // reserved hostname this hub answers on
83 clients map[string]string // channel name -> bearer token (static env config, plaintext)
84 mu sync.Mutex
85 conns map[string]*tunnelConn
86 // dynamicAuth is the second, registry-backed credential source: channel
87 // "<fleet>/<label>" -> sha256 hex of its bearer token. Guarded by mu
88 // alongside conns since SetDynamicAuth's rotation path (below) touches
89 // both. Static clients keep authenticating by plaintext compare,
90 // unchanged; this is consulted only when a name misses h.clients, which
91 // is always true for a dynamic name — ValidTunnelName-checked static
92 // names never contain "/".
93 dynamicAuth map[string]string
94}
95
96func NewTunnelHub(host string, clients map[string]string) *TunnelHub {
97 return &TunnelHub{host: host, clients: clients, conns: make(map[string]*tunnelConn)}
98}
99
100func (h *TunnelHub) Host() string { return h.host }
101
102// parseTunnelClients parses CARLOS_TUNNEL_CLIENTS ("name=token[,name=token]").
103func parseTunnelClients(s string) (map[string]string, error) {
104 out := make(map[string]string)
105 for _, pair := range strings.Split(s, ",") {
106 pair = strings.TrimSpace(pair)
107 if pair == "" {
108 continue
109 }
110 name, token, ok := strings.Cut(pair, "=")
111 if !ok || name == "" || token == "" {
112 return nil, fmt.Errorf("tunnel clients: malformed entry %q (want name=token)", pair)
113 }
114 out[name] = token
115 }
116 return out, nil
117}
118
119// ServeHTTP handles ONLY the registration handshake; the Edge routes requests
120// here when r.Host equals the reserved hostname, so ordinary app traffic can
121// never reach it and this handler never sees ordinary traffic.
122func (h *TunnelHub) ServeHTTP(w http.ResponseWriter, r *http.Request) {
123 if r.URL.Path != tunnelRegisterPath {
124 http.NotFound(w, r)
125 return
126 }
127 name := r.Header.Get("Carlos-Tunnel-Name")
128 // CutPrefix's ok is load-bearing: TrimPrefix would return a scheme-less
129 // header (e.g. "Authorization: sekrit") unchanged, letting a credential
130 // that never claimed to be a bearer token compare equal to a configured
131 // one. No "Bearer " prefix is an outright 401, not a token of "".
132 token, hasBearer := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
133 want, ok := h.clients[name]
134 // Constant-time compare; the name lookup happening first is fine — the
135 // name is not the secret, the token is. A token is bound to exactly one
136 // channel name, so a leaked token cannot impersonate a different host.
137 // An empty configured token never authenticates anything: parseTunnelClients
138 // rejects one, and a hub built some other way must not turn a blank config
139 // value into a channel that answers to "Authorization: Bearer ".
140 authOK := name != "" && ok && want != "" && hasBearer &&
141 subtle.ConstantTimeCompare([]byte(token), []byte(want)) == 1
142 // Second credential source: a name that misses the static map (every
143 // "<fleet>/<label>" dynamic channel does, since ValidTunnelName-checked
144 // static names never contain "/") falls through to the registry-backed
145 // set SetDynamicAuth maintains. The presented token is never compared in
146 // the clear here — only its sha256 hex is, against the stored hash — so
147 // a leaked auth log or memory dump of this table never yields a usable
148 // bearer token. Folded into the SAME authOK the static path feeds, so
149 // there is exactly one 401 below regardless of which source (or neither)
150 // missed; a caller cannot distinguish "wrong static token" from "unknown
151 // dynamic channel" from "wrong dynamic token" by response shape.
152 // viaDynamic is that branch's other output: which source admitted this
153 // conn decides its ping cadence (pingCadence), and this is the only place
154 // that fact is known.
155 viaDynamic := false
156 if !authOK && name != "" && hasBearer {
157 _, viaDynamic = h.VerifyDynamic(name, token)
158 authOK = viaDynamic
159 }
160 if !authOK {
161 http.Error(w, "unauthorized", http.StatusUnauthorized)
162 return
163 }
164 // Wire-contract gate, deliberately AFTER auth: an unauthenticated caller
165 // gets 401 whatever it sends, so probing cannot discover which protocols
166 // this endpoint speaks. Checking it at all is what keeps a version skew
167 // diagnosable — without it we would answer "101 carlos-tunnel/1" to a
168 // client that never asked, and a future carlos-tunnel/2 host would see a
169 // mystery h2-preface failure instead of a refusal naming the version.
170 // Connection is matched token-wise and Upgrade case-insensitively, both
171 // per RFC 7230; the version suffix is what must match exactly.
172 if !httpguts.HeaderValuesContainsToken(r.Header["Connection"], "Upgrade") ||
173 !strings.EqualFold(strings.TrimSpace(r.Header.Get("Upgrade")), tunnelUpgradeProto) {
174 http.Error(w, "expected Connection: Upgrade with Upgrade: "+tunnelUpgradeProto, http.StatusBadRequest)
175 return
176 }
177 hj, ok := w.(http.Hijacker)
178 if !ok {
179 http.Error(w, "hijack unsupported", http.StatusInternalServerError)
180 return
181 }
182 conn, bufrw, err := hj.Hijack()
183 if err != nil {
184 log.Printf("edge: tunnel %s: hijack: %v", name, err)
185 return
186 }
187 // The h2 session runs on the RAW conn, so anything the host pipelined
188 // ahead of our 101 is sitting unreadable in bufrw and would corrupt the
189 // handshake. Fail loudly here rather than hang on a broken preface: the
190 // host-side dialer's contract is to write nothing until it reads the 101.
191 if n := bufrw.Reader.Buffered(); n > 0 {
192 log.Printf("edge: tunnel %s: %d bytes sent before 101 — dropping", name, n)
193 conn.Close()
194 return
195 }
196 // Manual 101 on the hijacked conn (the ResponseWriter is spent once hijacked).
197 fmt.Fprintf(bufrw, "HTTP/1.1 101 Switching Protocols\r\nUpgrade: %s\r\nConnection: Upgrade\r\n\r\n", tunnelUpgradeProto)
198 if err := bufrw.Flush(); err != nil {
199 conn.Close()
200 return
201 }
202 tr := &http2.Transport{}
203 cc, err := tr.NewClientConn(conn)
204 if err != nil {
205 log.Printf("edge: tunnel %s: h2 client conn: %v", name, err)
206 conn.Close()
207 return
208 }
209 h.mu.Lock()
210 if old := h.conns[name]; old != nil {
211 old.raw.Close() // a re-register replaces the previous channel
212 }
213 h.conns[name] = &tunnelConn{cc: cc, raw: conn, dynamic: viaDynamic}
214 h.mu.Unlock()
215 log.Printf("edge: tunnel %s: connected from %s", name, conn.RemoteAddr())
216 go h.pingLoop(name, cc, conn, viaDynamic)
217}
218
219// pingLoop is the edge-side liveness check: a dead ping closes the raw conn
220// and unregisters the channel, so requests fail fast to 502 instead of
221// hanging on a black-holed TCP session (the venue-Wi-Fi failure mode).
222// dynamic carries the credential source through from ServeHTTP: fleet
223// channels reap on a tighter bound because that bound is their failover
224// window (see pingCadence).
225func (h *TunnelHub) pingLoop(name string, cc *http2.ClientConn, raw net.Conn, dynamic bool) {
226 every, timeout := pingCadence(dynamic)
227 for {
228 time.Sleep(every)
229 ctx, cancel := context.WithTimeout(context.Background(), timeout)
230 err := cc.Ping(ctx)
231 cancel()
232 if err != nil {
233 log.Printf("edge: tunnel %s: ping failed (%v) — dropping channel", name, err)
234 raw.Close()
235 // Only unregister if WE are still the live channel: a re-register
236 // may already have replaced this entry (and closed our conn, which
237 // is what failed the ping), and deleting then would blank out a
238 // perfectly healthy successor.
239 h.mu.Lock()
240 if cur := h.conns[name]; cur != nil && cur.raw == raw {
241 delete(h.conns, name)
242 }
243 h.mu.Unlock()
244 return
245 }
246 }
247}
248
249// VerifyDynamic reports whether (name, token) authenticates as a dynamic
250// (registry-backed fleet) channel, and which fleet owns it. It is the single
251// answer to that question: ServeHTTP's dynamic branch is one caller, and
252// surfaces with no ResponseWriter to 401 with are the others.
253//
254// The "/" requirement is a gate, not a parse convenience: static channel names
255// are ValidTunnelName-checked and never contain "/", so refusing slash-less
256// names here means a static client's plaintext token can never be admitted by
257// this path, whatever h.clients (or a hand-built dynamicAuth) holds. An empty
258// fleet segment ("/box-a") is refused for the same reason: it would otherwise
259// authenticate into fleet "", a name no real fleet has, and every ownership
260// check downstream compares against the fleet this returns.
261//
262// The presented token is never compared in the clear — only its sha256 hex is,
263// against the stored hash — so a leaked dump of this table yields no usable
264// bearer token, and the compare is constant-time so a wrong token leaks no
265// prefix information by timing.
266func (h *TunnelHub) VerifyDynamic(name, token string) (fleet string, ok bool) {
267 fleet, _, hasSlash := strings.Cut(name, "/")
268 if !hasSlash || fleet == "" {
269 return "", false
270 }
271 h.mu.Lock()
272 wantHash, known := h.dynamicAuth[name]
273 h.mu.Unlock()
274 if !known || wantHash == "" {
275 return "", false
276 }
277 sum := sha256.Sum256([]byte(token))
278 gotHash := hex.EncodeToString(sum[:])
279 if subtle.ConstantTimeCompare([]byte(gotHash), []byte(wantHash)) != 1 {
280 return "", false
281 }
282 return fleet, true
283}
284
285// SetDynamicAuth replaces the entire registry-backed credential set —
286// channel "<fleet>/<label>" -> sha256 hex of its bearer token — with auth.
287// The whole-map REPLACE (not an incremental merge) is what makes a NEW dial
288// with a stale token get refused the instant this returns, for both
289// rotation and detach: the next ServeHTTP call either hashes the presented
290// token against a DIFFERENT stored hash (rotation: the name is still
291// present, just under a new hash) or misses the map entirely (detach: the
292// name is gone). Either way, a new dial with the old token fails now.
293//
294// ROTATE and DETACH are deliberately asymmetric on an ALREADY-LIVE session,
295// and that asymmetry is load-bearing, not an oversight:
296//
297// - Detach: the name vanishes from auth entirely, so it is in the OLD set
298// but missing from the new one — the loop below calls CloseChannel on
299// it, force-closing any live conn right now. Detach is the hard
300// revocation path; a box taken out of the fleet must not keep a
301// channel open on its old credential for even one more request.
302// - Rotate: the name STAYS present in auth, just under a new hash — the
303// loop below sees it as still-authorized (present in both old and new)
304// and does NOT close it. An already-established h2 session therefore
305// keeps carrying traffic on its OLD token's connection until the
306// legitimate box notices (its own ping/reconnect cycle) and re-dials
307// with the NEW token — not until this call returns. This is
308// deliberate: closing on every rotation would take the box's channel
309// down for the gap between the registry write and the box learning its
310// new token, and rotation (unlike detach) is not "this box must be cut
311// off now" — it is "this box's credential changed." A caller that wants
312// rotation to also force an immediate reconnect must close explicitly
313// (CloseChannel) rather than relying on SetDynamicAuth to do it.
314func (h *TunnelHub) SetDynamicAuth(auth map[string]string) {
315 h.mu.Lock()
316 old := h.dynamicAuth
317 h.dynamicAuth = auth
318 h.mu.Unlock()
319 for name := range old {
320 if _, stillAuthorized := auth[name]; !stillAuthorized {
321 h.CloseChannel(name)
322 }
323 }
324}
325
326// CloseChannel force-closes and unregisters name's live conn, if any — the
327// detach/rotate path (SetDynamicAuth above), also usable directly. Mirrors
328// pingLoop's delete-if-still-live guard: the raw conn is closed OUTSIDE the
329// lock (Close can block briefly on the wire), then a second lock re-checks
330// that this exact conn is STILL the one registered before deleting it — a
331// re-register that has already replaced the entry in between must not have
332// ITS fresh conn deleted out from under it.
333func (h *TunnelHub) CloseChannel(name string) {
334 h.mu.Lock()
335 cur := h.conns[name]
336 h.mu.Unlock()
337 if cur == nil {
338 return
339 }
340 cur.raw.Close()
341 h.mu.Lock()
342 if c := h.conns[name]; c != nil && c.raw == cur.raw {
343 delete(h.conns, name)
344 }
345 h.mu.Unlock()
346}
347
348func (h *TunnelHub) conn(name string) *http2.ClientConn {
349 h.mu.Lock()
350 defer h.mu.Unlock()
351 if c := h.conns[name]; c != nil {
352 return c.cc
353 }
354 return nil
355}
356
357// State reports "connected" or "". The heartbeat's tunnel-state field comes
358// from the host-side dial loop, not from here; today this is test-consumed
359// only — no production caller reads it.
360func (h *TunnelHub) State(name string) string {
361 if h.conn(name) != nil {
362 return "connected"
363 }
364 return ""
365}
366
367// ConnectedOfFleet returns every currently-connected channel name belonging
368// to fleet — the "<fleet>/<label>" names whose prefix matches — sorted. This
369// is what makes the edge's fleet-route fallback (edge.go's fleetProxy)
370// deterministic: "any connected box of this fleet" always picks the same one
371// for a given live set, first entry, rather than whatever order Go's map
372// iteration happens to produce.
373func (h *TunnelHub) ConnectedOfFleet(fleet string) []string {
374 prefix := fleet + "/"
375 h.mu.Lock()
376 defer h.mu.Unlock()
377 var out []string
378 for name := range h.conns {
379 if strings.HasPrefix(name, prefix) {
380 out = append(out, name)
381 }
382 }
383 slices.Sort(out)
384 return out
385}
386
387type roundTripperFunc func(*http.Request) (*http.Response, error)
388
389func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
390
391// transportFor resolves the channel PER REQUEST, so a proxy built before a
392// reconnect uses the fresh channel afterwards with no reload needed.
393func (h *TunnelHub) transportFor(name string) http.RoundTripper {
394 return roundTripperFunc(func(req *http.Request) (*http.Response, error) {
395 cc := h.conn(name)
396 if cc == nil {
397 return nil, fmt.Errorf("tunnel %q not connected", name)
398 }
399 return cc.RoundTrip(req)
400 })
401}
402