eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1package tunnel
2
3import (
4 "context"
5 "fmt"
6 "io"
7 "net/http"
8 "net/http/httptest"
9 "net/url"
10 "strings"
11 "sync"
12 "testing"
13 "time"
14
15 "github.com/coder/websocket"
16)
17
18// The whole loop, no router required: a local "instance" (plain HTTP +
19// a WebSocket echo), a gateway, and a client tunnelling between them. Verifies
20// ordinary requests, header passthrough, and — the part that matters for the
21// chat — a live WebSocket upgraded end-to-end through the tunnel.
22func TestTunnelEndToEnd(t *testing.T) {
23 backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
24 switch r.URL.Path {
25 case "/api/version":
26 w.Header().Set("Content-Type", "application/json")
27 io.WriteString(w, `{"build":"test","version":"test-1"}`)
28 case "/ws":
29 c, err := websocket.Accept(w, r, nil)
30 if err != nil {
31 return
32 }
33 defer c.CloseNow()
34 ctx := r.Context()
35 for {
36 typ, data, err := c.Read(ctx)
37 if err != nil {
38 return
39 }
40 if err := c.Write(ctx, typ, append([]byte("echo:"), data...)); err != nil {
41 return
42 }
43 }
44 default:
45 w.Header().Set("X-Seen-Host", r.Host)
46 io.WriteString(w, "hello through the tunnel")
47 }
48 }))
49 defer backend.Close()
50
51 gw := &Gateway{Domain: "test.local", DialTimeout: 5 * time.Second}
52 registered := make(chan string, 1)
53 gw.OnRegister = func(host string) error { registered <- host; return nil }
54 closed := make(chan string, 1)
55 gw.OnClose = func(host string) { closed <- host }
56
57 gwSrv := httptest.NewServer(gw)
58 defer gwSrv.Close()
59 u, _ := url.Parse(gwSrv.URL)
60 gw.ControlHost = u.Hostname() // ServeHTTP compares hosts port-stripped
61
62 ctx, cancel := context.WithCancel(context.Background())
63 defer cancel()
64 cl := &Client{Gateway: gwSrv.URL, LocalAddr: strings.TrimPrefix(backend.URL, "http://")}
65 ready := make(chan string, 1)
66 clientDone := make(chan error, 1)
67 go func() { clientDone <- cl.Run(ctx, func(h string) { ready <- h }) }()
68
69 var host string
70 select {
71 case host = <-ready:
72 case err := <-clientDone:
73 t.Fatalf("client exited early: %v", err)
74 case <-time.After(5 * time.Second):
75 t.Fatal("no ready")
76 }
77 if !strings.HasPrefix(host, "try-") || !strings.HasSuffix(host, ".test.local") {
78 t.Fatalf("unexpected host %q", host)
79 }
80 if got := <-registered; got != host {
81 t.Fatalf("OnRegister got %q, ready got %q", got, host)
82 }
83
84 // A plain request, addressed to the slug host through the gateway.
85 hostRewrite := func(rt http.RoundTripper) http.RoundTripper {
86 return roundTripperFunc(func(r *http.Request) (*http.Response, error) {
87 r.Host = host
88 return rt.RoundTrip(r)
89 })
90 }
91 httpc := &http.Client{Transport: hostRewrite(http.DefaultTransport.(*http.Transport).Clone())}
92 resp, err := httpc.Get(gwSrv.URL + "/anything")
93 if err != nil {
94 t.Fatalf("GET: %v", err)
95 }
96 body, _ := io.ReadAll(resp.Body)
97 resp.Body.Close()
98 if string(body) != "hello through the tunnel" {
99 t.Fatalf("body = %q", body)
100 }
101 if got := resp.Header.Get("X-Seen-Host"); got != host {
102 t.Fatalf("backend saw Host %q, want %q (public hostname must reach the instance)", got, host)
103 }
104
105 // A WebSocket, upgraded end-to-end through the tunnel.
106 wsCtx, wsCancel := context.WithTimeout(context.Background(), 5*time.Second)
107 defer wsCancel()
108 c, _, err := websocket.Dial(wsCtx, "ws://"+u.Host+"/ws", &websocket.DialOptions{
109 HTTPClient: &http.Client{Transport: hostRewrite(http.DefaultTransport.(*http.Transport).Clone())},
110 })
111 if err != nil {
112 t.Fatalf("ws dial: %v", err)
113 }
114 defer c.CloseNow()
115 if err := c.Write(wsCtx, websocket.MessageText, []byte("ping")); err != nil {
116 t.Fatalf("ws write: %v", err)
117 }
118 _, data, err := c.Read(wsCtx)
119 if err != nil {
120 t.Fatalf("ws read: %v", err)
121 }
122 if string(data) != "echo:ping" {
123 t.Fatalf("ws echo = %q", data)
124 }
125
126 // Tearing the client down retires the route.
127 cancel()
128 select {
129 case got := <-closed:
130 if got != host {
131 t.Fatalf("OnClose got %q", got)
132 }
133 case <-time.After(5 * time.Second):
134 t.Fatal("route never closed")
135 }
136}
137
138// The per-IP limiter refuses the sixth tunnel in an hour from one address.
139func TestTunnelRateLimit(t *testing.T) {
140 gw := &Gateway{Domain: "test.local"}
141 for range 5 {
142 if err := gw.admit("203.0.113.9"); err != nil {
143 t.Fatalf("admit: %v", err)
144 }
145 gw.byIP["203.0.113.9"] = append(gw.byIP["203.0.113.9"], time.Now())
146 }
147 if err := gw.admit("203.0.113.9"); err == nil {
148 t.Fatal("sixth tunnel admitted")
149 }
150 if err := gw.admit("203.0.113.10"); err != nil {
151 t.Fatalf("other address blocked: %v", err)
152 }
153}
154
155// A backend that is not an Eleven instance is refused before its hostname is
156// ever published.
157func TestTunnelRejectsNonEleven(t *testing.T) {
158 backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
159 io.WriteString(w, "just a web server")
160 }))
161 defer backend.Close()
162
163 gw := &Gateway{Domain: "test.local", DialTimeout: 5 * time.Second}
164 registered := false
165 gw.OnRegister = func(string) error { registered = true; return nil }
166 gwSrv := httptest.NewServer(gw)
167 defer gwSrv.Close()
168 u, _ := url.Parse(gwSrv.URL)
169 gw.ControlHost = u.Hostname()
170
171 cl := &Client{Gateway: gwSrv.URL, LocalAddr: strings.TrimPrefix(backend.URL, "http://")}
172 err := cl.Run(context.Background(), func(string) {})
173 if err == nil || !strings.Contains(err.Error(), "only Eleven instances") {
174 t.Fatalf("expected probe refusal, got %v", err)
175 }
176 if registered {
177 t.Fatal("route was published for a non-Eleven backend")
178 }
179}
180
181// The per-tunnel connection cap refuses dial-backs once live + in-flight
182// connections hit the limit.
183func TestTunnelConnCap(t *testing.T) {
184 g := &Gateway{MaxConns: 2}
185 tun := &liveTunnel{closed: make(chan struct{})}
186 tun.conns.Store(2)
187 if _, err := g.dialBack(context.Background(), tun); err == nil ||
188 !strings.Contains(err.Error(), "connection cap") {
189 t.Fatalf("expected cap refusal, got %v", err)
190 }
191 if got := tun.conns.Load(); got != 2 {
192 t.Fatalf("refused dial leaked a slot: %d", got)
193 }
194}
195
196// startTunnelPair boots a backend serving handler, the given gateway, and a
197// client tunnelling between them, returning the gateway server and the public
198// slug host once it's live. Everything tears down with the test.
199func startTunnelPair(t *testing.T, gw *Gateway, handler http.HandlerFunc) (gwSrv *httptest.Server, host string) {
200 t.Helper()
201 backend := httptest.NewServer(handler)
202 t.Cleanup(backend.Close)
203 gwSrv = httptest.NewServer(gw)
204 t.Cleanup(gwSrv.Close)
205 u, _ := url.Parse(gwSrv.URL)
206 gw.ControlHost = u.Hostname()
207
208 ctx, cancel := context.WithCancel(context.Background())
209 t.Cleanup(cancel)
210 cl := &Client{Gateway: gwSrv.URL, LocalAddr: strings.TrimPrefix(backend.URL, "http://")}
211 ready := make(chan string, 1)
212 clientDone := make(chan error, 1)
213 go func() { clientDone <- cl.Run(ctx, func(h string) { ready <- h }) }()
214 select {
215 case host = <-ready:
216 case err := <-clientDone:
217 t.Fatalf("client exited early: %v", err)
218 case <-time.After(5 * time.Second):
219 t.Fatal("no ready")
220 }
221 return gwSrv, host
222}
223
224// hostRewriteTransport addresses requests to the slug host through the
225// gateway's test listener.
226func hostRewriteTransport(host string) http.RoundTripper {
227 rt := http.DefaultTransport.(*http.Transport).Clone()
228 return roundTripperFunc(func(r *http.Request) (*http.Response, error) {
229 r.Host = host
230 return rt.RoundTrip(r)
231 })
232}
233
234// versionThen serves the probe's /api/version and hands everything else to
235// next — the minimum for a backend to pass the Eleven door check.
236func versionThen(next http.HandlerFunc) http.HandlerFunc {
237 return func(w http.ResponseWriter, r *http.Request) {
238 if r.URL.Path == "/api/version" {
239 w.Header().Set("Content-Type", "application/json")
240 io.WriteString(w, `{"build":"test","version":"test-1"}`)
241 return
242 }
243 next(w, r)
244 }
245}
246
247// A first-load burst wider than the per-tunnel connection cap queues for a
248// free connection instead of failing. The no-build shell fetches 40+ modules
249// in one burst over HTTP/2, and a single 502 permanently fails the page's ES
250// module graph — this is what made `lchat try` unusable in a browser.
251func TestTunnelBurstQueuesBeyondConnCap(t *testing.T) {
252 gw := &Gateway{Domain: "test.local", DialTimeout: 5 * time.Second, MaxConns: 4}
253 gwSrv, host := startTunnelPair(t, gw, versionThen(func(w http.ResponseWriter, r *http.Request) {
254 time.Sleep(20 * time.Millisecond) // hold connections open so the burst overlaps
255 io.WriteString(w, "module")
256 }))
257
258 httpc := &http.Client{Transport: hostRewriteTransport(host)}
259 var wg sync.WaitGroup
260 codes := make([]int, 24)
261 for i := range codes {
262 wg.Add(1)
263 go func() {
264 defer wg.Done()
265 resp, err := httpc.Get(fmt.Sprintf("%s/static/m%d.js", gwSrv.URL, i))
266 if err != nil {
267 codes[i] = -1
268 return
269 }
270 io.Copy(io.Discard, resp.Body)
271 resp.Body.Close()
272 codes[i] = resp.StatusCode
273 }()
274 }
275 wg.Wait()
276 for i, c := range codes {
277 if c != http.StatusOK {
278 t.Fatalf("request %d: status %d — a burst above the cap must queue, not fail", i, c)
279 }
280 }
281}
282
283// A visitor abandoning a request mid-dial (reload, closed tab) fails that one
284// dial only — it must never drop the tunnel for everyone else.
285func TestTunnelSurvivesAbandonedRequest(t *testing.T) {
286 closed := make(chan string, 1)
287 gw := &Gateway{Domain: "test.local", DialTimeout: 5 * time.Second}
288 gw.OnClose = func(host string) { closed <- host }
289 gwSrv, host := startTunnelPair(t, gw, versionThen(func(w http.ResponseWriter, r *http.Request) {
290 io.WriteString(w, "ok")
291 }))
292
293 gw.mu.Lock()
294 tun := gw.tunnels[host]
295 gw.mu.Unlock()
296 if tun == nil {
297 t.Fatal("no live tunnel")
298 }
299
300 abandoned, cancel := context.WithCancel(context.Background())
301 cancel()
302 if conn, err := gw.dialBack(abandoned, tun); err == nil {
303 conn.Close() // the dial-back can win the race; the tunnel must survive either way
304 }
305
306 select {
307 case h := <-closed:
308 t.Fatalf("abandoned request dropped the whole tunnel (%s)", h)
309 default:
310 }
311 httpc := &http.Client{Transport: hostRewriteTransport(host)}
312 resp, err := httpc.Get(gwSrv.URL + "/after")
313 if err != nil {
314 t.Fatalf("tunnel unusable after an abandoned request: %v", err)
315 }
316 defer resp.Body.Close()
317 if resp.StatusCode != http.StatusOK {
318 t.Fatalf("tunnel unusable after an abandoned request: HTTP %d", resp.StatusCode)
319 }
320}
321
322type roundTripperFunc func(*http.Request) (*http.Response, error)
323
324func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
325