package edge // The acceptance test issue #53 names: edge + fake remote host over a REAL // reverse channel on loopback. Round-trip, header survival, kill-mid-flight, // re-dial, and the reconnect bound (asserted, not narrated). // // The fake remote host is not a hand-rolled dialer: it runs // internal/tunnelclient.Loop, the exact loop `carlos agent` runs on a Pi // (internal/host/tunnel_client.go is a thin env-reading wrapper around it). // That is the whole point of the extraction — a reconnect bound measured // against a test double would prove nothing about the fleet. import ( "context" "fmt" "io" "net/http" "net/http/httptest" "net/url" "strings" "sync" "testing" "time" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/tunnelclient" ) // e2eReconnectBound is the promise: a channel that dies is back and serving // within this. The client sleeps one jittered backoff step (250-375ms on a // first loss, 500-750ms once it has doubled) before re-dialing, so the real // figure is well under a second — the bound is deliberately loose enough that // a loaded CI box cannot flake it, and tight enough that a regression to the // 2s cap compounding, or to no reconnect at all, fails here. const e2eReconnectBound = 5 * time.Second func TestTunnelEndToEnd(t *testing.T) { reg := tunnelRoute(t, registry.Route{ Host: "demo.example", Kind: "instance", Status: "ready", Tunnel: "pi-box", }) e, err := New(reg, nil) if err != nil { t.Fatal(err) } // The edge listens on loopback; both app traffic and the tunnel // registration ride this ONE listener — the no-new-port property. srv := httptest.NewServer(e) defer srv.Close() // The hub's reserved hostname has to be the name the host actually dials, // because the dialer sends the URL's host as its Host header and the edge // routes registration on that. On a loopback edge that name is the // listener's own address; in production it is the operator's // CARLOS_TUNNEL_HOST, resolved by DNS to the same 443. u, err := url.Parse(srv.URL) if err != nil { t.Fatal(err) } hub := NewTunnelHub(u.Hostname(), map[string]string{"pi-box": "sekrit"}) e.EnableTunnels(hub) if err := e.Reload(); err != nil { t.Fatal(err) } // Fake remote host: its "local edge" answers with a header + body, and // echoes Host and X-Forwarded-For so the test can prove header discipline // held all the way across the channel. /slow parks until released — that // is what makes the kill below genuinely mid-flight rather than a sleep // and a hope. inflight := make(chan struct{}, 1) release := make(chan struct{}) defer close(release) appHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/slow" { select { case inflight <- struct{}{}: default: } // Either the test lets it finish, or the stream dies under it — // which is what a real handler sees when the channel drops, and // what keeps this goroutine from outliving the connection. select { case <-release: case <-r.Context().Done(): } } w.Header().Set("X-App-Header", "survived") w.Header().Set("Carlos-Purge", "/should-be-stripped") // edge must strip fmt.Fprintf(w, "host=%s xff=%s", r.Host, r.Header.Get("X-Forwarded-For")) }) stop := startFakeRemoteHost(t, srv.URL, "pi-box", "sekrit", appHandler) defer stop() waitForTunnelState(t, hub, "pi-box", "connected", e2eReconnectBound) do := func(path string) (*http.Response, string) { t.Helper() req, err := http.NewRequest("GET", srv.URL+path, nil) if err != nil { t.Fatal(err) } req.Host = "demo.example" resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("request through tunnel: %v", err) } body, _ := io.ReadAll(resp.Body) resp.Body.Close() return resp, string(body) } resp, body := do("/") if resp.StatusCode != 200 || resp.Header.Get("X-App-Header") != "survived" { t.Fatalf("round-trip: %d %v", resp.StatusCode, resp.Header) } if resp.Header.Get("Carlos-Purge") != "" { t.Fatal("Carlos-Purge leaked to the client — purge discipline broken over tunnel") } if !strings.Contains(body, "host=demo.example") || !strings.Contains(body, "xff=127.") { t.Fatalf("host/XFF discipline broken: %q", body) } // Kill the channel out from under the host with a request PARKED inside // the remote handler, then assert three things: the in-flight request // fails fast rather than hanging, the host re-dials, and the next request // succeeds — all inside the bound. slowDone := make(chan int, 1) go func() { req, err := http.NewRequest("GET", srv.URL+"/slow", nil) if err != nil { slowDone <- -1 return } req.Host = "demo.example" resp, err := http.DefaultClient.Do(req) if err != nil { slowDone <- 0 // the client saw the failure directly return } resp.Body.Close() slowDone <- resp.StatusCode }() select { case <-inflight: case <-time.After(e2eReconnectBound): t.Fatal("/slow never reached the remote host's handler") } // Unregister BEFORE closing, so the ordering is deterministic rather than // a race with the host's re-registration: once the entry is gone, State is // "" until the NEW channel lands, and a re-register can never be clobbered // by this teardown. hub.mu.Lock() dead := hub.conns["pi-box"] delete(hub.conns, "pi-box") hub.mu.Unlock() if dead == nil { t.Fatal("no live channel to kill — the hub lost it before the test did") } start := time.Now() dead.raw.Close() select { case code := <-slowDone: if code < 0 { t.Fatal("could not build the in-flight request") } if code == http.StatusOK { t.Fatal("the in-flight request succeeded after its channel died — the edge served bytes over a dead channel") } case <-time.After(e2eReconnectBound): t.Fatal("in-flight request neither completed nor failed after its channel died — it hung") } waitForTunnelState(t, hub, "pi-box", "connected", e2eReconnectBound) if resp, _ := do("/"); resp.StatusCode != 200 { t.Fatalf("post-reconnect request: %d", resp.StatusCode) } if d := time.Since(start); d > e2eReconnectBound { t.Fatalf("reconnect took %s, bound is %s", d, e2eReconnectBound) } else { t.Logf("reconnected and served in %s (bound %s)", d, e2eReconnectBound) } } // startFakeRemoteHost runs the production host-side loop against edgeURL and // returns a stop func that cancels it and WAITS for it to exit — a loop still // dialing after its test returned would log into the next test's output and // hold a goroutine for the rest of the run. func startFakeRemoteHost(t *testing.T, edgeURL, name, token string, handler http.Handler) func() { t.Helper() ctx, cancel := context.WithCancel(context.Background()) var wg sync.WaitGroup wg.Add(1) go func() { defer wg.Done() tunnelclient.Loop(ctx, tunnelclient.Config{ URL: edgeURL, Name: name, Token: token, Handler: handler, Logf: func(format string, args ...any) { t.Logf("fake host: "+format, args...) }, }) }() return func() { cancel() wg.Wait() // no t.Logf after the test returns } } func waitForTunnelState(t *testing.T, hub *TunnelHub, name, want string, within time.Duration) { t.Helper() deadline := time.Now().Add(within) for time.Now().Before(deadline) { if hub.State(name) == want { return } time.Sleep(20 * time.Millisecond) } t.Fatalf("tunnel %s never reached state %q within %s", name, want, within) }