carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package edge
2
3import (
4 "bytes"
5 "crypto/sha256"
6 "encoding/hex"
7 "log"
8 "net"
9 "net/http"
10 "net/http/httptest"
11 "os"
12 "strings"
13 "testing"
14 "time"
15
16 "github.com/carlosframework/platform/internal/registry"
17)
18
19// sha256Hex is the test-side mirror of what FleetChannelAuth stores and
20// SetDynamicAuth consumes: the sha256 hex of a bearer token, never the
21// plaintext.
22func sha256Hex(s string) string {
23 sum := sha256.Sum256([]byte(s))
24 return hex.EncodeToString(sum[:])
25}
26
27// tunnelReq builds a request that is well-formed EXCEPT for the credentials
28// under test: valid upgrade headers, so the auth cases below can only fail on
29// auth. Pass an empty token to send no Authorization header at all.
30func tunnelReq(name, token string) *http.Request {
31 req := httptest.NewRequest("GET", "/register", nil)
32 req.Header.Set("Connection", "Upgrade")
33 req.Header.Set("Upgrade", tunnelUpgradeProto)
34 req.Header.Set("Carlos-Tunnel-Name", name)
35 if token != "" {
36 req.Header.Set("Authorization", "Bearer "+token)
37 }
38 return req
39}
40
41func TestTunnelRegisterRejectsBadToken(t *testing.T) {
42 // TWO clients on purpose: with only one configured, no test here can tell
43 // "every token is bound to its own channel" apart from "any known token
44 // admits any known channel" — an implementation looping over h.clients
45 // would pass a single-client table unchanged.
46 hub := NewTunnelHub("tunnel.test", map[string]string{"pi-box": "sekrit", "hq": "other"})
47 for _, tc := range []struct{ desc, name, token string }{
48 {"no auth", "", ""},
49 {"wrong token", "pi-box", "nope"},
50 {"unknown name", "nosuch", "sekrit"},
51 {"empty name", "", "sekrit"},
52 // The binding property itself: a valid token may only ever claim ITS
53 // OWN channel name, in either direction.
54 {"cross-name", "hq", "sekrit"},
55 {"cross-name reversed", "pi-box", "other"},
56 } {
57 rec := httptest.NewRecorder()
58 hub.ServeHTTP(rec, tunnelReq(tc.name, tc.token))
59 if rec.Code != http.StatusUnauthorized {
60 t.Errorf("%s (name=%q token=%q): got %d, want 401", tc.desc, tc.name, tc.token, rec.Code)
61 }
62 }
63}
64
65// Scheme-less credential: "Authorization: sekrit" must not authenticate even
66// though "sekrit" is the correct configured token — TrimPrefix would have let
67// this through unchanged (nothing to strip), which is the bug this pins.
68func TestTunnelRegisterRejectsSchemelessAuthorization(t *testing.T) {
69 hub := NewTunnelHub("tunnel.test", map[string]string{"pi-box": "sekrit"})
70 req := tunnelReq("pi-box", "")
71 req.Header.Set("Authorization", "sekrit") // no "Bearer " prefix
72 rec := httptest.NewRecorder()
73 hub.ServeHTTP(rec, req)
74 if rec.Code != http.StatusUnauthorized {
75 t.Fatalf("scheme-less Authorization: got %d, want 401", rec.Code)
76 }
77}
78
79func TestTunnelRegisterAuthPrecedesUpgradeCheck(t *testing.T) {
80 hub := NewTunnelHub("tunnel.test", map[string]string{"pi-box": "sekrit"})
81 req := httptest.NewRequest("GET", "/register", nil) // no upgrade headers at all
82 req.Header.Set("Authorization", "Bearer nope")
83 req.Header.Set("Carlos-Tunnel-Name", "pi-box")
84 rec := httptest.NewRecorder()
85 hub.ServeHTTP(rec, req)
86 if rec.Code != http.StatusUnauthorized {
87 t.Fatalf("got %d, want 401 — an unauthenticated caller must not learn which upgrade protocols we speak", rec.Code)
88 }
89}
90
91func TestTunnelRegisterRejectsWrongUpgrade(t *testing.T) {
92 hub := NewTunnelHub("tunnel.test", map[string]string{"pi-box": "sekrit"})
93 for _, tc := range []struct{ desc, conn, upgrade string }{
94 {"no upgrade headers", "", ""},
95 {"missing Upgrade", "Upgrade", ""},
96 {"missing Connection", "", tunnelUpgradeProto},
97 {"wrong version", "Upgrade", "carlos-tunnel/2"},
98 {"other protocol", "Upgrade", "websocket"},
99 } {
100 req := httptest.NewRequest("GET", "/register", nil)
101 req.Header.Set("Authorization", "Bearer sekrit")
102 req.Header.Set("Carlos-Tunnel-Name", "pi-box")
103 if tc.conn != "" {
104 req.Header.Set("Connection", tc.conn)
105 }
106 if tc.upgrade != "" {
107 req.Header.Set("Upgrade", tc.upgrade)
108 }
109 rec := httptest.NewRecorder()
110 hub.ServeHTTP(rec, req)
111 if rec.Code != http.StatusBadRequest {
112 t.Errorf("%s: got %d, want 400", tc.desc, rec.Code)
113 continue
114 }
115 // A future carlos-tunnel/2 host must get a diagnosable refusal.
116 if !strings.Contains(rec.Body.String(), tunnelUpgradeProto) {
117 t.Errorf("%s: body %q should name the expected protocol", tc.desc, rec.Body.String())
118 }
119 }
120}
121
122func TestTunnelRegisterAcceptsTokenWiseConnectionHeader(t *testing.T) {
123 hub := NewTunnelHub("tunnel.test", map[string]string{"pi-box": "sekrit"})
124 req := httptest.NewRequest("GET", "/register", nil)
125 req.Header.Set("Authorization", "Bearer sekrit")
126 req.Header.Set("Carlos-Tunnel-Name", "pi-box")
127 req.Header.Set("Connection", "keep-alive, Upgrade") // token-wise, per RFC 7230
128 req.Header.Set("Upgrade", "Carlos-Tunnel/1") // protocol names are case-insensitive
129 rec := httptest.NewRecorder()
130 hub.ServeHTTP(rec, req)
131 // This request passes auth AND the upgrade gate, then dies at the hijack,
132 // which httptest.NewRecorder cannot do — so 500 (not 400) is how we know
133 // the gate let it through. The real 101 is Task 10's e2e.
134 if rec.Code != http.StatusInternalServerError {
135 t.Fatalf("got %d, want 500 (past the upgrade gate, stopped at the hijack)", rec.Code)
136 }
137}
138
139func TestTunnelRegisterRejectsUnknownPath(t *testing.T) {
140 hub := NewTunnelHub("tunnel.test", map[string]string{"pi-box": "sekrit"})
141 req := httptest.NewRequest("GET", "/anything-else", nil)
142 rec := httptest.NewRecorder()
143 hub.ServeHTTP(rec, req)
144 if rec.Code != http.StatusNotFound {
145 t.Fatalf("got %d, want 404", rec.Code)
146 }
147}
148
149// TestTunnelRegisterAcceptsDynamicToken proves the second credential source:
150// a channel name containing "/" (the "<fleet>/<label>" shape ValidTunnelName
151// would refuse) authenticates against a token hash set via SetDynamicAuth,
152// hashing the presented token and comparing against the stored hash rather
153// than the plaintext.
154func TestTunnelRegisterAcceptsDynamicToken(t *testing.T) {
155 hub := NewTunnelHub("tunnel.test", nil)
156 hub.SetDynamicAuth(map[string]string{"acme/pi-1": sha256Hex("tok")})
157 rec := httptest.NewRecorder()
158 hub.ServeHTTP(rec, tunnelReq("acme/pi-1", "tok"))
159 // Same tell as TestTunnelRegisterAcceptsTokenWiseConnectionHeader: past
160 // auth and the upgrade gate, stopped at the hijack httptest can't do.
161 if rec.Code != http.StatusInternalServerError {
162 t.Fatalf("got %d, want 500 (past auth, stopped at the hijack)", rec.Code)
163 }
164}
165
166func TestTunnelRegisterRejectsWrongDynamicToken(t *testing.T) {
167 hub := NewTunnelHub("tunnel.test", nil)
168 hub.SetDynamicAuth(map[string]string{"acme/pi-1": sha256Hex("tok")})
169 rec := httptest.NewRecorder()
170 hub.ServeHTTP(rec, tunnelReq("acme/pi-1", "wrong"))
171 if rec.Code != http.StatusUnauthorized {
172 t.Fatalf("got %d, want 401", rec.Code)
173 }
174}
175
176func TestTunnelRegisterRejectsUnknownDynamicChannel(t *testing.T) {
177 hub := NewTunnelHub("tunnel.test", nil)
178 hub.SetDynamicAuth(map[string]string{"acme/pi-1": sha256Hex("tok")})
179 rec := httptest.NewRecorder()
180 hub.ServeHTTP(rec, tunnelReq("acme/pi-2", "tok"))
181 if rec.Code != http.StatusUnauthorized {
182 t.Fatalf("got %d, want 401", rec.Code)
183 }
184}
185
186// TestStaticClientStillAuthenticatesAlongsideDynamicSet is the regression: a
187// static env client must keep authenticating unchanged even once a dynamic
188// set is also configured on the same hub (tito hq's plaintext-compare path
189// is untouched by fleet channels landing).
190func TestStaticClientStillAuthenticatesAlongsideDynamicSet(t *testing.T) {
191 hub := NewTunnelHub("tunnel.test", map[string]string{"hq": "sekrit"})
192 hub.SetDynamicAuth(map[string]string{"acme/pi-1": sha256Hex("tok")})
193 rec := httptest.NewRecorder()
194 hub.ServeHTTP(rec, tunnelReq("hq", "sekrit"))
195 if rec.Code != http.StatusInternalServerError {
196 t.Fatalf("static client with a dynamic set also configured: got %d, want 500", rec.Code)
197 }
198}
199
200// TestVerifyDynamic covers the extracted gate directly, without a handshake:
201// VerifyDynamic is the one place that answers "does (name, token) authenticate
202// as a fleet channel, and whose?" — ServeHTTP's dynamic branch is now just a
203// caller, and later fleet-API surfaces (which have no ResponseWriter to 401
204// with) need the same answer.
205func TestVerifyDynamic(t *testing.T) {
206 // The static client is configured on the SAME hub on purpose: a static
207 // name must never authenticate through this gate, even presenting the
208 // correct plaintext token h.clients holds for it. ServeHTTP's static
209 // compare is the only path that may admit it.
210 hub := NewTunnelHub("tunnel.test", map[string]string{"hq": "sekrit"})
211 hub.SetDynamicAuth(map[string]string{"pi/box-a": sha256Hex("tok")})
212 for _, tc := range []struct {
213 desc, name, token string
214 wantFleet string
215 wantOK bool
216 }{
217 {desc: "static name with its own correct plaintext token", name: "hq", token: "sekrit"},
218 {desc: "unknown dynamic name", name: "pi/box-z", token: "tok"},
219 {desc: "right name, wrong token", name: "pi/box-a", token: "wrong"},
220 {desc: "right name and token", name: "pi/box-a", token: "tok", wantFleet: "pi", wantOK: true},
221 } {
222 fleet, ok := hub.VerifyDynamic(tc.name, tc.token)
223 if ok != tc.wantOK || fleet != tc.wantFleet {
224 t.Errorf("%s: VerifyDynamic(%q, %q) = (%q, %v), want (%q, %v)",
225 tc.desc, tc.name, tc.token, fleet, ok, tc.wantFleet, tc.wantOK)
226 }
227 }
228 // A SetDynamicAuth swap that drops the name must refuse it the instant it
229 // returns — the same whole-map-replace property detach relies on.
230 hub.SetDynamicAuth(map[string]string{"pi/box-b": sha256Hex("tok")})
231 if fleet, ok := hub.VerifyDynamic("pi/box-a", "tok"); ok || fleet != "" {
232 t.Errorf("after a SetDynamicAuth swap dropping the name: got (%q, %v), want (\"\", false)", fleet, ok)
233 }
234}
235
236// TestVerifyDynamicRefusesSlashlessNameInDynamicAuth is the one case only the
237// "/" gate can refuse, and so the only test that actually pins it: the name is
238// PRESENT in dynamicAuth under a hash that matches the presented token, so
239// every other check in VerifyDynamic passes. Delete the gate and this test
240// fails; the table above would not, because "hq" simply misses that map.
241//
242// It makes the doc comment's claim real: no slash-less name authenticates
243// here, whatever dynamicAuth holds. Production never writes such a key
244// (FleetChannelAuth emits "<fleet>/<label>" only) — the point is that a future
245// writer that did could not silently turn a static channel name, or a name
246// with no fleet at all, into a fleet-authenticated one.
247func TestVerifyDynamicRefusesSlashlessNameInDynamicAuth(t *testing.T) {
248 hub := NewTunnelHub("tunnel.test", map[string]string{"hq": "sekrit"})
249 hub.SetDynamicAuth(map[string]string{
250 "hq": sha256Hex("sekrit"), // a static channel name, hash-correct
251 "/box-a": sha256Hex("tok"), // empty fleet segment, hash-correct
252 "noslash": sha256Hex("tok"),
253 })
254 for _, tc := range []struct{ desc, name, token string }{
255 {"static name, hash-correct, present in dynamicAuth", "hq", "sekrit"},
256 {"empty fleet segment", "/box-a", "tok"},
257 {"bare name with no separator", "noslash", "tok"},
258 } {
259 if fleet, ok := hub.VerifyDynamic(tc.name, tc.token); ok || fleet != "" {
260 t.Errorf("%s: VerifyDynamic(%q, %q) = (%q, %v), want (\"\", false)",
261 tc.desc, tc.name, tc.token, fleet, ok)
262 }
263 }
264}
265
266// TestCloseChannelClosesAndRemovesLiveConn: CloseChannel is the detach/rotate
267// primitive — force-close a live conn and drop it from the map. No real h2
268// handshake needed: CloseChannel only ever touches .raw.
269func TestCloseChannelClosesAndRemovesLiveConn(t *testing.T) {
270 hub := NewTunnelHub("tunnel.test", nil)
271 c1, c2 := net.Pipe()
272 defer c2.Close()
273 hub.mu.Lock()
274 hub.conns["acme/pi-1"] = &tunnelConn{raw: c1}
275 hub.mu.Unlock()
276
277 hub.CloseChannel("acme/pi-1")
278
279 hub.mu.Lock()
280 _, stillThere := hub.conns["acme/pi-1"]
281 hub.mu.Unlock()
282 if stillThere {
283 t.Fatal("CloseChannel left the channel registered")
284 }
285 if _, err := c1.Write([]byte("x")); err == nil {
286 t.Fatal("CloseChannel did not close the raw conn")
287 }
288}
289
290// TestCloseChannelOnUnknownNameIsNoop: closing a name with no live conn must
291// not panic or otherwise misbehave (Reload calls this for every vanished
292// registry entry, whether or not a channel was ever actually connected).
293func TestCloseChannelOnUnknownNameIsNoop(t *testing.T) {
294 hub := NewTunnelHub("tunnel.test", nil)
295 hub.CloseChannel("nosuch/label")
296}
297
298// TestSetDynamicAuthRotationClosesVanishedChannelAndRefusesOldToken pins the
299// brief's exact rotation contract: a token authenticates while its channel is
300// in the dynamic set; once SetDynamicAuth is called WITHOUT that entry (the
301// shape a rotate/detach registry poll produces), any live conn for it is
302// force-closed and the old token is refused on the very next dial — not left
303// to die on its own on the next failed ping.
304func TestSetDynamicAuthRotationClosesVanishedChannelAndRefusesOldToken(t *testing.T) {
305 hub := NewTunnelHub("tunnel.test", nil)
306 hub.SetDynamicAuth(map[string]string{"acme/pi-1": sha256Hex("tok")})
307
308 // Valid while present.
309 rec := httptest.NewRecorder()
310 hub.ServeHTTP(rec, tunnelReq("acme/pi-1", "tok"))
311 if rec.Code != http.StatusInternalServerError {
312 t.Fatalf("initial dynamic dial: got %d, want 500", rec.Code)
313 }
314
315 // A live established channel for that name.
316 c1, c2 := net.Pipe()
317 defer c2.Close()
318 hub.mu.Lock()
319 hub.conns["acme/pi-1"] = &tunnelConn{raw: c1}
320 hub.mu.Unlock()
321
322 // Rotate: the entry vanishes from the dynamic set entirely.
323 hub.SetDynamicAuth(map[string]string{})
324
325 hub.mu.Lock()
326 _, stillThere := hub.conns["acme/pi-1"]
327 hub.mu.Unlock()
328 if stillThere {
329 t.Fatal("SetDynamicAuth left the vanished channel's live conn registered")
330 }
331 if _, err := c1.Write([]byte("x")); err == nil {
332 t.Fatal("SetDynamicAuth did not close the vanished channel's raw conn")
333 }
334
335 rec2 := httptest.NewRecorder()
336 hub.ServeHTTP(rec2, tunnelReq("acme/pi-1", "tok"))
337 if rec2.Code != http.StatusUnauthorized {
338 t.Fatalf("old token after rotation: got %d, want 401", rec2.Code)
339 }
340}
341
342func TestParseTunnelClients(t *testing.T) {
343 got, err := parseTunnelClients("pi-box=sekrit,hq=other")
344 if err != nil || got["pi-box"] != "sekrit" || got["hq"] != "other" {
345 t.Fatalf("parse: %v %v", got, err)
346 }
347 if _, err := parseTunnelClients("malformed"); err == nil {
348 t.Fatal("want error on entry without '='")
349 }
350}
351
352// tunnelRoute upserts one route into a fresh registry (openReg, the package's
353// one registry harness — edge_test.go) and returns the registry.
354func tunnelRoute(t *testing.T, rt registry.Route) *registry.Registry {
355 t.Helper()
356 reg := openReg(t)
357 if err := reg.Upsert(rt); err != nil {
358 t.Fatalf("upsert: %v", err)
359 }
360 return reg
361}
362
363func TestReloadBuildsTunnelBackedRoute(t *testing.T) {
364 reg := tunnelRoute(t, registry.Route{
365 Host: "demo.example", Kind: "instance", Status: "ready", Tunnel: "pi-box",
366 })
367 e, err := New(reg, nil)
368 if err != nil {
369 t.Fatal(err)
370 }
371 // Before EnableTunnels the route has no backend at all: it must not be
372 // served, and must not burn an ACME issuance either.
373 if e.Allowed("demo.example") {
374 t.Fatal("tunnel-backed route served with no hub configured")
375 }
376 hub := NewTunnelHub("tunnel.test", map[string]string{"pi-box": "sekrit"})
377 e.EnableTunnels(hub)
378 if err := e.Reload(); err != nil {
379 t.Fatal(err)
380 }
381 if !e.Allowed("demo.example") {
382 t.Fatal("tunnel-backed route not served after EnableTunnels+Reload")
383 }
384 if !e.Allowed("tunnel.test") {
385 t.Fatal("reserved tunnel hostname must be Allowed (autocert HostPolicy)")
386 }
387 // Channel down -> 502, not a hang.
388 req := httptest.NewRequest("GET", "http://demo.example/", nil)
389 req.Host = "demo.example"
390 rec := httptest.NewRecorder()
391 e.ServeHTTP(rec, req)
392 if rec.Code != http.StatusBadGateway {
393 t.Fatalf("disconnected tunnel: got %d, want 502", rec.Code)
394 }
395}
396
397// The reserved hostname is answered by the hub itself, ahead of the route
398// table — so a registration attempt reaches the handshake (401 here, no
399// credentials) instead of the unknown-host 404.
400func TestEdgeInterceptsReservedTunnelHost(t *testing.T) {
401 e, err := New(openReg(t), nil)
402 if err != nil {
403 t.Fatal(err)
404 }
405 e.EnableTunnels(NewTunnelHub("tunnel.test", map[string]string{"pi-box": "sekrit"}))
406 if err := e.Reload(); err != nil {
407 t.Fatal(err)
408 }
409 req := tunnelReq("pi-box", "nope")
410 req.Host = "tunnel.test:443" // host:port must still match the reserved name
411 rec := httptest.NewRecorder()
412 e.ServeHTTP(rec, req)
413 if rec.Code != http.StatusUnauthorized {
414 t.Fatalf("got %d, want 401 — the reserved host must reach the hub, not the route table", rec.Code)
415 }
416}
417
418// `carlos route -tunnel` is a narrow UPDATE: it does not clear the route's
419// socket/addr. So a route can legitimately carry both, and which one wins is
420// a real decision, not a moot one:
421// - hub configured -> the tunnel wins (the newest, most explicit binding).
422// - no hub on this box -> the old addr keeps serving, rather than the host
423// dropping off the air on an edge that cannot honor the tunnel.
424func TestTunnelBindingWinsOverAddrOnlyWhenHubConfigured(t *testing.T) {
425 backend, _ := hostEchoBackend(t, "local addr backend")
426 reg := tunnelRoute(t, registry.Route{
427 Host: "both.example", Kind: "service", Status: "ready",
428 Addr: backend.Listener.Addr().String(), Tunnel: "pi-box",
429 })
430 e, err := New(reg, nil)
431 if err != nil {
432 t.Fatal(err)
433 }
434 srv := httptest.NewServer(e)
435 defer srv.Close()
436 code, body := getHost(t, srv.URL, "both.example")
437 if code != http.StatusOK || body != "local addr backend" {
438 t.Fatalf("no hub configured: got %d %q, want the addr backend to keep serving", code, body)
439 }
440 e.EnableTunnels(NewTunnelHub("tunnel.test", map[string]string{"pi-box": "sekrit"}))
441 if err := e.Reload(); err != nil {
442 t.Fatal(err)
443 }
444 if code, _ := getHost(t, srv.URL, "both.example"); code != http.StatusBadGateway {
445 t.Fatalf("hub configured: got %d, want 502 from the (disconnected) tunnel", code)
446 }
447}
448
449// The env wiring is the only place a channel name enters the process from
450// config, and a name `carlos route -tunnel` would refuse can never match a
451// route — so it must stop the boot, not connect and carry nothing.
452func TestEnableTunnelsFromEnv(t *testing.T) {
453 newEdge := func() *Edge {
454 e, err := New(openReg(t), nil)
455 if err != nil {
456 t.Fatal(err)
457 }
458 return e
459 }
460 t.Run("unset is not an error", func(t *testing.T) {
461 e := newEdge()
462 if err := enableTunnelsFromEnv(e); err != nil {
463 t.Fatalf("no CARLOS_TUNNEL_HOST: %v", err)
464 }
465 if e.tunnels != nil {
466 t.Fatal("hub armed with no CARLOS_TUNNEL_HOST")
467 }
468 })
469 // A host with no STATIC clients is valid: fleet channels (SetDynamicAuth,
470 // wired by Reload from the registry) can populate the hub with no static
471 // env config at all — the hub still has to exist for those to land on.
472 // I2 (final customer-fleets review): zero static clients is legitimate
473 // (fleet-channels-only), but it's also exactly what a typo'd
474 // CARLOS_TUNNEL_CLIENTS var name silently produces — an explicit warning
475 // must land in the log so the two are distinguishable there, not just
476 // inferable from a client count.
477 t.Run("host with no static clients is valid but warns", func(t *testing.T) {
478 t.Setenv("CARLOS_TUNNEL_HOST", "tunnel.test")
479 t.Setenv("CARLOS_TUNNEL_CLIENTS", "")
480 e := newEdge()
481 var buf bytes.Buffer
482 log.SetOutput(&buf)
483 t.Cleanup(func() { log.SetOutput(os.Stderr) })
484 if err := enableTunnelsFromEnv(e); err != nil {
485 t.Fatalf("CARLOS_TUNNEL_HOST with no static clients: %v", err)
486 }
487 if e.tunnels == nil {
488 t.Fatal("hub not armed with no static clients configured")
489 }
490 if !strings.Contains(buf.String(), "WARNING: no static tunnel clients configured") {
491 t.Fatalf("log = %q, want a WARNING about no static tunnel clients", buf.String())
492 }
493 })
494 t.Run("invalid channel name", func(t *testing.T) {
495 t.Setenv("CARLOS_TUNNEL_HOST", "tunnel.test")
496 t.Setenv("CARLOS_TUNNEL_CLIENTS", "pi-box=sekrit,Pi_Box=other")
497 err := enableTunnelsFromEnv(newEdge())
498 if err == nil || !strings.Contains(err.Error(), "Pi_Box") {
499 t.Fatalf("got %v, want an error naming the bad entry", err)
500 }
501 })
502 t.Run("armed", func(t *testing.T) {
503 t.Setenv("CARLOS_TUNNEL_HOST", "tunnel.test")
504 t.Setenv("CARLOS_TUNNEL_CLIENTS", "pi-box=sekrit")
505 e := newEdge()
506 if err := enableTunnelsFromEnv(e); err != nil {
507 t.Fatal(err)
508 }
509 if e.tunnels == nil || e.tunnels.Host() != "tunnel.test" || !e.Allowed("tunnel.test") {
510 t.Fatalf("hub not armed on the configured host")
511 }
512 })
513}
514
515// TestReloadWiresFleetChannelAuthIntoTunnelHub is Task 3's core integration
516// point: Edge.Reload reads Registry.FleetChannelAuth (Task 1) and pushes it
517// into the hub, with no new polling — the existing 2s Watch->Reload loop is
518// the whole convergence path.
519func TestReloadWiresFleetChannelAuthIntoTunnelHub(t *testing.T) {
520 reg := openReg(t)
521 if err := reg.UpsertFleet(registry.Fleet{Name: "acme", Account: "amadan", Bucket: "acme-data"}); err != nil {
522 t.Fatal(err)
523 }
524 if err := reg.AddFleetBox(registry.FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: sha256Hex("tok"), Status: "active"}); err != nil {
525 t.Fatal(err)
526 }
527 e, err := New(reg, nil)
528 if err != nil {
529 t.Fatal(err)
530 }
531 e.EnableTunnels(NewTunnelHub("tunnel.test", nil))
532 if err := e.Reload(); err != nil {
533 t.Fatal(err)
534 }
535 rec := httptest.NewRecorder()
536 e.tunnels.ServeHTTP(rec, tunnelReq("acme/pi-1", "tok"))
537 if rec.Code != http.StatusInternalServerError {
538 t.Fatalf("fleet channel auth not wired from the registry: got %d, want 500", rec.Code)
539 }
540}
541
542// TestReloadWithNilTunnelHubIsSafe: most boxes run with no CARLOS_TUNNEL_HOST
543// at all, so e.tunnels is nil on every Reload — the fleet-auth wiring must be
544// guarded, not assume a hub exists.
545func TestReloadWithNilTunnelHubIsSafe(t *testing.T) {
546 e, err := New(openReg(t), nil)
547 if err != nil {
548 t.Fatal(err)
549 }
550 if err := e.Reload(); err != nil {
551 t.Fatalf("Reload with no tunnel hub configured: %v", err)
552 }
553}
554
555// TestReloadDetachedFleetBoxClosesLiveChannel proves the rotate/detach
556// convergence bound end to end through Reload: a box detached in the
557// registry has its live conn force-closed on the very next reload, the same
558// poll Watch drives every ~2s.
559func TestReloadDetachedFleetBoxClosesLiveChannel(t *testing.T) {
560 reg := openReg(t)
561 if err := reg.UpsertFleet(registry.Fleet{Name: "acme", Account: "amadan", Bucket: "acme-data"}); err != nil {
562 t.Fatal(err)
563 }
564 if err := reg.AddFleetBox(registry.FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: sha256Hex("tok"), Status: "active"}); err != nil {
565 t.Fatal(err)
566 }
567 e, err := New(reg, nil)
568 if err != nil {
569 t.Fatal(err)
570 }
571 hub := NewTunnelHub("tunnel.test", nil)
572 e.EnableTunnels(hub)
573 if err := e.Reload(); err != nil {
574 t.Fatal(err)
575 }
576
577 // A live channel, as if the box had already dialed in successfully.
578 c1, c2 := net.Pipe()
579 defer c2.Close()
580 hub.mu.Lock()
581 hub.conns["acme/pi-1"] = &tunnelConn{raw: c1}
582 hub.mu.Unlock()
583
584 if err := reg.SetFleetBoxStatus("acme", "pi-1", "detached"); err != nil {
585 t.Fatal(err)
586 }
587 if err := e.Reload(); err != nil {
588 t.Fatal(err)
589 }
590
591 hub.mu.Lock()
592 _, stillThere := hub.conns["acme/pi-1"]
593 hub.mu.Unlock()
594 if stillThere {
595 t.Fatal("Reload did not close the detached fleet box's live channel")
596 }
597}
598
599// TestRotateVsDetachAsymmetry pins the deliberate difference between the two
600// registry-driven revocation paths, through the real Reload wiring:
601//
602// - Rotate (SetFleetBoxToken, same box still active): a NEW dial with the
603// OLD token is refused immediately, but an ALREADY-LIVE channel is left
604// alone — closing it would take the box's connection down for the gap
605// between the registry write and the box itself learning its new token.
606// - Detach (SetFleetBoxStatus "detached"): the hard revocation — an
607// ALREADY-LIVE channel is force-closed on the very next Reload.
608func TestRotateVsDetachAsymmetry(t *testing.T) {
609 reg := openReg(t)
610 if err := reg.UpsertFleet(registry.Fleet{Name: "acme", Account: "amadan", Bucket: "acme-data"}); err != nil {
611 t.Fatal(err)
612 }
613 oldHash := sha256Hex("old-tok")
614 if err := reg.AddFleetBox(registry.FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: oldHash, Status: "active"}); err != nil {
615 t.Fatal(err)
616 }
617 e, err := New(reg, nil)
618 if err != nil {
619 t.Fatal(err)
620 }
621 hub := NewTunnelHub("tunnel.test", nil)
622 e.EnableTunnels(hub)
623 if err := e.Reload(); err != nil {
624 t.Fatal(err)
625 }
626
627 // A live channel, as if pi-1 had already dialed in on the old token.
628 c1, c2 := net.Pipe()
629 defer c2.Close()
630 hub.mu.Lock()
631 hub.conns["acme/pi-1"] = &tunnelConn{raw: c1}
632 hub.mu.Unlock()
633
634 // --- Rotate ---
635 newHash := sha256Hex("new-tok")
636 if err := reg.SetFleetBoxToken("acme", "pi-1", newHash); err != nil {
637 t.Fatal(err)
638 }
639 if err := e.Reload(); err != nil {
640 t.Fatal(err)
641 }
642
643 // (a) A NEW dial with the OLD token is refused now.
644 rec := httptest.NewRecorder()
645 hub.ServeHTTP(rec, tunnelReq("acme/pi-1", "old-tok"))
646 if rec.Code != http.StatusUnauthorized {
647 t.Fatalf("rotate: new dial with old token: got %d, want 401", rec.Code)
648 }
649 // A NEW dial with the NEW token is accepted.
650 rec2 := httptest.NewRecorder()
651 hub.ServeHTTP(rec2, tunnelReq("acme/pi-1", "new-tok"))
652 if rec2.Code != http.StatusInternalServerError {
653 t.Fatalf("rotate: new dial with new token: got %d, want 500 (past auth)", rec2.Code)
654 }
655 // (b) The EXISTING live channel from before the rotation is untouched —
656 // that's the designed asymmetry, not a bug.
657 hub.mu.Lock()
658 c, stillLive := hub.conns["acme/pi-1"]
659 hub.mu.Unlock()
660 if !stillLive || c.raw != c1 {
661 t.Fatal("rotate: the pre-existing live channel was closed — rotation must not evict an established session")
662 }
663
664 // --- Detach ---
665 if err := reg.SetFleetBoxStatus("acme", "pi-1", "detached"); err != nil {
666 t.Fatal(err)
667 }
668 if err := e.Reload(); err != nil {
669 t.Fatal(err)
670 }
671 hub.mu.Lock()
672 _, stillThere := hub.conns["acme/pi-1"]
673 hub.mu.Unlock()
674 if stillThere {
675 t.Fatal("detach: the live channel from before detach was not closed — detach must be a hard revocation")
676 }
677}
678
679// A tunnel-served host is never activator-managed: the process lives on the
680// far side of the channel, so a wake here would start a second, local copy.
681// The retire pass must also drop it, killing the child a repointed route left
682// behind.
683func TestTunnelRouteIsNeverActivatorManaged(t *testing.T) {
684 reg := tunnelRoute(t, registry.Route{
685 Host: "moved.example", Kind: "instance", Status: "ready",
686 Tunnel: "pi-box", Hibernate: true,
687 })
688 act := &fakeActivator{}
689 e, err := New(reg, act)
690 if err != nil {
691 t.Fatal(err)
692 }
693 e.EnableTunnels(NewTunnelHub("tunnel.test", map[string]string{"pi-box": "sekrit"}))
694 if err := e.Reload(); err != nil {
695 t.Fatal(err)
696 }
697 if last := act.retired[len(act.retired)-1]; last["moved.example"] {
698 t.Fatalf("tunnel-served host kept in the activator's managed set: %v", last)
699 }
700 req := httptest.NewRequest("GET", "http://moved.example/", nil)
701 req.Host = "moved.example"
702 e.ServeHTTP(httptest.NewRecorder(), req)
703 if act.served != 0 {
704 t.Fatalf("activator woke a local process for a tunnel-served host (%d call(s))", act.served)
705 }
706}
707
708// TestPingCadence pins the cadence SELECTION rather than wall-clock ping
709// timing: a fleet (dynamic) channel's reap bound is the customer-visible
710// failover window, so it pings tighter, while the static platform channels
711// keep the wider cadence they have always had. Asserting the selection is
712// what lets this be a millisecond test instead of a 25-second one.
713func TestPingCadence(t *testing.T) {
714 if e, to := pingCadence(false); e != tunnelPingEvery || to != tunnelPingTimeout {
715 t.Fatalf("static cadence changed: got %s/%s, want %s/%s", e, to, tunnelPingEvery, tunnelPingTimeout)
716 }
717 // The static numbers themselves, spelled out: a future edit that widened
718 // or narrowed them would otherwise pass the comparison above unnoticed.
719 if tunnelPingEvery != 15*time.Second || tunnelPingTimeout != 10*time.Second {
720 t.Fatalf("static cadence constants moved: %s/%s, want 15s/10s", tunnelPingEvery, tunnelPingTimeout)
721 }
722 if e, to := pingCadence(true); e != fleetPingEvery || to != fleetPingTimeout {
723 t.Fatalf("fleet cadence wrong: got %s/%s, want %s/%s", e, to, fleetPingEvery, fleetPingTimeout)
724 }
725 if fleetPingEvery != 5*time.Second || fleetPingTimeout != 5*time.Second {
726 t.Fatalf("fleet cadence constants moved: %s/%s, want 5s/5s", fleetPingEvery, fleetPingTimeout)
727 }
728}
729
730// TestDynamicChannelMarkedDynamic is the other half of the cadence: knowing
731// WHICH cadence to use means the conn has to remember which credential source
732// admitted it, and only a real handshake (httptest.NewRecorder cannot hijack)
733// ever reaches the point where a conn is stored. Both kinds register against
734// ONE hub, so this also re-proves the two credential sources coexist.
735func TestDynamicChannelMarkedDynamic(t *testing.T) {
736 hub := NewTunnelHub("unused.test", map[string]string{"hq": "sekrit"})
737 hub.SetDynamicAuth(map[string]string{"pi/box-a": sha256Hex("tok")})
738 // The hub answers registration directly here: no route, no edge, no
739 // reserved-hostname routing — this test is about what ServeHTTP stores.
740 srv := httptest.NewServer(hub)
741 defer srv.Close()
742
743 ok := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
744 stopDyn := startFakeRemoteHost(t, srv.URL, "pi/box-a", "tok", ok)
745 defer stopDyn()
746 stopStatic := startFakeRemoteHost(t, srv.URL, "hq", "sekrit", ok)
747 defer stopStatic()
748 waitForTunnelState(t, hub, "pi/box-a", "connected", e2eReconnectBound)
749 waitForTunnelState(t, hub, "hq", "connected", e2eReconnectBound)
750
751 if !channelIsDynamic(t, hub, "pi/box-a") {
752 t.Fatal("a channel admitted by the dynamic (fleet) credential source was not marked dynamic — it would ping at the static cadence and its fleet's failover window would be ~25s")
753 }
754 if channelIsDynamic(t, hub, "hq") {
755 t.Fatal("a static env channel was marked dynamic — platform channels must keep the wider cadence")
756 }
757}
758
759func channelIsDynamic(t *testing.T, hub *TunnelHub, name string) bool {
760 t.Helper()
761 hub.mu.Lock()
762 defer hub.mu.Unlock()
763 c := hub.conns[name]
764 if c == nil {
765 t.Fatalf("channel %q is not registered", name)
766 }
767 return c.dynamic
768}
769