| 1 | package edge |
| 2 | |
| 3 | import ( |
| 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. |
| 22 | func 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. |
| 30 | func 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 | |
| 41 | func 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. |
| 68 | func 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 | |
| 79 | func 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 | |
| 91 | func 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 | |
| 122 | func 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 | |
| 139 | func 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. |
| 154 | func 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 | |
| 166 | func 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 | |
| 176 | func 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). |
| 190 | func 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. |
| 205 | func 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. |
| 247 | func 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. |
| 269 | func 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). |
| 293 | func 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. |
| 304 | func 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 | |
| 342 | func 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. |
| 354 | func 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 | |
| 363 | func 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. |
| 400 | func 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. |
| 424 | func 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. |
| 452 | func 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. |
| 519 | func 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. |
| 545 | func 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. |
| 559 | func 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. |
| 608 | func 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. |
| 683 | func 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. |
| 713 | func 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. |
| 735 | func 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 | |
| 759 | func 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 | |