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
3// The acceptance test issue #53 names: edge + fake remote host over a REAL
4// reverse channel on loopback. Round-trip, header survival, kill-mid-flight,
5// re-dial, and the reconnect bound (asserted, not narrated).
6//
7// The fake remote host is not a hand-rolled dialer: it runs
8// internal/tunnelclient.Loop, the exact loop `carlos agent` runs on a Pi
9// (internal/host/tunnel_client.go is a thin env-reading wrapper around it).
10// That is the whole point of the extraction — a reconnect bound measured
11// against a test double would prove nothing about the fleet.
12
13import (
14 "context"
15 "fmt"
16 "io"
17 "net/http"
18 "net/http/httptest"
19 "net/url"
20 "strings"
21 "sync"
22 "testing"
23 "time"
24
25 "github.com/carlosframework/platform/internal/registry"
26 "github.com/carlosframework/platform/internal/tunnelclient"
27)
28
29// e2eReconnectBound is the promise: a channel that dies is back and serving
30// within this. The client sleeps one jittered backoff step (250-375ms on a
31// first loss, 500-750ms once it has doubled) before re-dialing, so the real
32// figure is well under a second — the bound is deliberately loose enough that
33// a loaded CI box cannot flake it, and tight enough that a regression to the
34// 2s cap compounding, or to no reconnect at all, fails here.
35const e2eReconnectBound = 5 * time.Second
36
37func TestTunnelEndToEnd(t *testing.T) {
38 reg := tunnelRoute(t, registry.Route{
39 Host: "demo.example", Kind: "instance", Status: "ready", Tunnel: "pi-box",
40 })
41 e, err := New(reg, nil)
42 if err != nil {
43 t.Fatal(err)
44 }
45 // The edge listens on loopback; both app traffic and the tunnel
46 // registration ride this ONE listener — the no-new-port property.
47 srv := httptest.NewServer(e)
48 defer srv.Close()
49
50 // The hub's reserved hostname has to be the name the host actually dials,
51 // because the dialer sends the URL's host as its Host header and the edge
52 // routes registration on that. On a loopback edge that name is the
53 // listener's own address; in production it is the operator's
54 // CARLOS_TUNNEL_HOST, resolved by DNS to the same 443.
55 u, err := url.Parse(srv.URL)
56 if err != nil {
57 t.Fatal(err)
58 }
59 hub := NewTunnelHub(u.Hostname(), map[string]string{"pi-box": "sekrit"})
60 e.EnableTunnels(hub)
61 if err := e.Reload(); err != nil {
62 t.Fatal(err)
63 }
64
65 // Fake remote host: its "local edge" answers with a header + body, and
66 // echoes Host and X-Forwarded-For so the test can prove header discipline
67 // held all the way across the channel. /slow parks until released — that
68 // is what makes the kill below genuinely mid-flight rather than a sleep
69 // and a hope.
70 inflight := make(chan struct{}, 1)
71 release := make(chan struct{})
72 defer close(release)
73 appHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
74 if r.URL.Path == "/slow" {
75 select {
76 case inflight <- struct{}{}:
77 default:
78 }
79 // Either the test lets it finish, or the stream dies under it —
80 // which is what a real handler sees when the channel drops, and
81 // what keeps this goroutine from outliving the connection.
82 select {
83 case <-release:
84 case <-r.Context().Done():
85 }
86 }
87 w.Header().Set("X-App-Header", "survived")
88 w.Header().Set("Carlos-Purge", "/should-be-stripped") // edge must strip
89 fmt.Fprintf(w, "host=%s xff=%s", r.Host, r.Header.Get("X-Forwarded-For"))
90 })
91 stop := startFakeRemoteHost(t, srv.URL, "pi-box", "sekrit", appHandler)
92 defer stop()
93
94 waitForTunnelState(t, hub, "pi-box", "connected", e2eReconnectBound)
95
96 do := func(path string) (*http.Response, string) {
97 t.Helper()
98 req, err := http.NewRequest("GET", srv.URL+path, nil)
99 if err != nil {
100 t.Fatal(err)
101 }
102 req.Host = "demo.example"
103 resp, err := http.DefaultClient.Do(req)
104 if err != nil {
105 t.Fatalf("request through tunnel: %v", err)
106 }
107 body, _ := io.ReadAll(resp.Body)
108 resp.Body.Close()
109 return resp, string(body)
110 }
111
112 resp, body := do("/")
113 if resp.StatusCode != 200 || resp.Header.Get("X-App-Header") != "survived" {
114 t.Fatalf("round-trip: %d %v", resp.StatusCode, resp.Header)
115 }
116 if resp.Header.Get("Carlos-Purge") != "" {
117 t.Fatal("Carlos-Purge leaked to the client — purge discipline broken over tunnel")
118 }
119 if !strings.Contains(body, "host=demo.example") || !strings.Contains(body, "xff=127.") {
120 t.Fatalf("host/XFF discipline broken: %q", body)
121 }
122
123 // Kill the channel out from under the host with a request PARKED inside
124 // the remote handler, then assert three things: the in-flight request
125 // fails fast rather than hanging, the host re-dials, and the next request
126 // succeeds — all inside the bound.
127 slowDone := make(chan int, 1)
128 go func() {
129 req, err := http.NewRequest("GET", srv.URL+"/slow", nil)
130 if err != nil {
131 slowDone <- -1
132 return
133 }
134 req.Host = "demo.example"
135 resp, err := http.DefaultClient.Do(req)
136 if err != nil {
137 slowDone <- 0 // the client saw the failure directly
138 return
139 }
140 resp.Body.Close()
141 slowDone <- resp.StatusCode
142 }()
143 select {
144 case <-inflight:
145 case <-time.After(e2eReconnectBound):
146 t.Fatal("/slow never reached the remote host's handler")
147 }
148
149 // Unregister BEFORE closing, so the ordering is deterministic rather than
150 // a race with the host's re-registration: once the entry is gone, State is
151 // "" until the NEW channel lands, and a re-register can never be clobbered
152 // by this teardown.
153 hub.mu.Lock()
154 dead := hub.conns["pi-box"]
155 delete(hub.conns, "pi-box")
156 hub.mu.Unlock()
157 if dead == nil {
158 t.Fatal("no live channel to kill — the hub lost it before the test did")
159 }
160 start := time.Now()
161 dead.raw.Close()
162
163 select {
164 case code := <-slowDone:
165 if code < 0 {
166 t.Fatal("could not build the in-flight request")
167 }
168 if code == http.StatusOK {
169 t.Fatal("the in-flight request succeeded after its channel died — the edge served bytes over a dead channel")
170 }
171 case <-time.After(e2eReconnectBound):
172 t.Fatal("in-flight request neither completed nor failed after its channel died — it hung")
173 }
174
175 waitForTunnelState(t, hub, "pi-box", "connected", e2eReconnectBound)
176 if resp, _ := do("/"); resp.StatusCode != 200 {
177 t.Fatalf("post-reconnect request: %d", resp.StatusCode)
178 }
179 if d := time.Since(start); d > e2eReconnectBound {
180 t.Fatalf("reconnect took %s, bound is %s", d, e2eReconnectBound)
181 } else {
182 t.Logf("reconnected and served in %s (bound %s)", d, e2eReconnectBound)
183 }
184}
185
186// startFakeRemoteHost runs the production host-side loop against edgeURL and
187// returns a stop func that cancels it and WAITS for it to exit — a loop still
188// dialing after its test returned would log into the next test's output and
189// hold a goroutine for the rest of the run.
190func startFakeRemoteHost(t *testing.T, edgeURL, name, token string, handler http.Handler) func() {
191 t.Helper()
192 ctx, cancel := context.WithCancel(context.Background())
193 var wg sync.WaitGroup
194 wg.Add(1)
195 go func() {
196 defer wg.Done()
197 tunnelclient.Loop(ctx, tunnelclient.Config{
198 URL: edgeURL, Name: name, Token: token, Handler: handler,
199 Logf: func(format string, args ...any) { t.Logf("fake host: "+format, args...) },
200 })
201 }()
202 return func() {
203 cancel()
204 wg.Wait() // no t.Logf after the test returns
205 }
206}
207
208func waitForTunnelState(t *testing.T, hub *TunnelHub, name, want string, within time.Duration) {
209 t.Helper()
210 deadline := time.Now().Add(within)
211 for time.Now().Before(deadline) {
212 if hub.State(name) == want {
213 return
214 }
215 time.Sleep(20 * time.Millisecond)
216 }
217 t.Fatalf("tunnel %s never reached state %q within %s", name, want, within)
218}
219