| new file mode 100644 |
| index 000000000..5aa4ef5a4 |
| --- /dev/null |
| +++ b/internal/edge/tunnel_e2e_test.go |
| @@ -0,0 +1,218 @@ |
| +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) |
| +} |