package tunnelclient import ( "context" "fmt" "io" "net/http" "net/http/httptest" "testing" "time" "golang.org/x/net/http2" ) // fakeTunnelEdge accepts one registration, answers 101, then drives ONE h2 // request over the inverted conn and returns the response body it read. func fakeTunnelEdge(t *testing.T, wantToken, wantName string) (*httptest.Server, chan string) { t.Helper() got := make(chan string, 1) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Authorization") != "Bearer "+wantToken || r.Header.Get("Carlos-Tunnel-Name") != wantName { http.Error(w, "unauthorized", http.StatusUnauthorized) return } conn, bufrw, err := w.(http.Hijacker).Hijack() if err != nil { t.Errorf("hijack: %v", err) return } fmt.Fprintf(bufrw, "HTTP/1.1 101 Switching Protocols\r\nUpgrade: carlos-tunnel/1\r\nConnection: Upgrade\r\n\r\n") bufrw.Flush() cc, err := (&http2.Transport{}).NewClientConn(conn) if err != nil { t.Errorf("h2 client: %v", err) return } req, _ := http.NewRequest("GET", "https://demo.example/hello", nil) resp, err := cc.RoundTrip(req) if err != nil { t.Errorf("roundtrip over tunnel: %v", err) got <- "" return } body, _ := io.ReadAll(resp.Body) got <- resp.Header.Get("X-From-Host") + ":" + string(body) })) return srv, got } func TestDialAndServeInvertsConnection(t *testing.T) { srv, got := fakeTunnelEdge(t, "sekrit", "pi-box") defer srv.Close() handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-From-Host", r.Host) fmt.Fprint(w, "pong") }) conn, err := Dial(context.Background(), srv.URL, "pi-box", "sekrit") if err != nil { t.Fatalf("Dial: %v", err) } go Serve(conn, handler) if v := <-got; v != "demo.example:pong" { t.Fatalf("over-tunnel response = %q, want demo.example:pong", v) } } func TestDialRejectsNon101(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "unauthorized", http.StatusUnauthorized) })) defer srv.Close() if _, err := Dial(context.Background(), srv.URL, "pi-box", "wrong"); err == nil { t.Fatal("want error on 401 handshake") } } // A cancelled ctx must stop the loop from BOTH of its blocking positions: // parked in backoff between dials (no edge at all), and parked in ServeConn on // a live channel that is perfectly healthy. The second one is the load-bearing // case — http2.Server.ServeConn has no stop signal of its own, so cancellation // has to reach it by closing the conn — and it is what lets a test tear the // fake host down deterministically instead of leaking a dialer for the rest of // the run. func TestLoopStopsOnContextCancel(t *testing.T) { t.Run("while backing off", func(t *testing.T) { // A server that is already gone: every dial is refused, so the loop // spends essentially all of its time parked in wait(). dead := httptest.NewServer(http.NotFoundHandler()) url := dead.URL dead.Close() assertLoopReturns(t, url, nil) }) t.Run("while connected", func(t *testing.T) { release := make(chan struct{}) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { conn, bufrw, err := w.(http.Hijacker).Hijack() if err != nil { t.Errorf("hijack: %v", err) return } fmt.Fprintf(bufrw, "HTTP/1.1 101 Switching Protocols\r\nUpgrade: carlos-tunnel/1\r\nConnection: Upgrade\r\n\r\n") bufrw.Flush() // Hold the channel open from this side: the edge never hangs up, // so the ONLY thing that can end ServeConn is cancellation // closing the conn — which is the property under test. <-release conn.Close() })) // LIFO: release the fake edge's handler first, then shut it down. defer srv.Close() defer close(release) assertLoopReturns(t, srv.URL, make(chan struct{}, 1)) }) } // assertLoopReturns runs Loop against url, waits for it to reach the state // gate (connected, when connected is non-nil), cancels, and requires a prompt // return. func assertLoopReturns(t *testing.T, url string, connected chan struct{}) { t.Helper() ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) go func() { defer close(done) Loop(ctx, Config{ URL: url, Name: "pi-box", Token: "sekrit", Handler: http.NewServeMux(), OnState: func(s string) { if s == StateConnected && connected != nil { select { case connected <- struct{}{}: default: } } }, Logf: func(string, ...any) {}, // the failing-dial case is noisy }) }() if connected != nil { select { case <-connected: case <-time.After(5 * time.Second): cancel() t.Fatal("loop never connected") } } cancel() select { case <-done: case <-time.After(5 * time.Second): t.Fatal("Loop did not return after its context was cancelled") } }