From 2db5020047640361066510f29f908ca9fd1c99aa Mon Sep 17 00:00:00 2001 From: Doot Agent Date: Wed, 25 Mar 2026 04:03:13 +0000 Subject: feat: gate Claudomator UI behind Doot session auth via reverse proxy Co-Authored-By: Claude Sonnet 4.6 --- internal/handlers/claudomator_proxy.go | 126 ++++++++++++++++++++ internal/handlers/claudomator_proxy_test.go | 175 ++++++++++++++++++++++++++++ 2 files changed, 301 insertions(+) create mode 100644 internal/handlers/claudomator_proxy.go create mode 100644 internal/handlers/claudomator_proxy_test.go (limited to 'internal/handlers') diff --git a/internal/handlers/claudomator_proxy.go b/internal/handlers/claudomator_proxy.go new file mode 100644 index 0000000..bfbbabc --- /dev/null +++ b/internal/handlers/claudomator_proxy.go @@ -0,0 +1,126 @@ +package handlers + +import ( + "io" + "net" + "net/http" + "net/http/httputil" + "net/url" + "strings" +) + +// NewClaudomatorProxy returns an http.Handler that reverse-proxies requests to +// targetURL, stripping the "/claudomator" prefix from the path. WebSocket +// upgrade requests are handled via raw TCP hijacking to support long-lived +// connections. +func NewClaudomatorProxy(targetURL string) http.Handler { + target, err := url.Parse(targetURL) + if err != nil { + panic("claudomator: invalid target URL: " + err.Error()) + } + + rp := &httputil.ReverseProxy{ + Director: func(req *http.Request) { + req.URL.Scheme = target.Scheme + req.URL.Host = target.Host + + // Strip /claudomator prefix + stripped := strings.TrimPrefix(req.URL.Path, "/claudomator") + if stripped == "" { + stripped = "/" + } + req.URL.Path = stripped + + if req.URL.RawPath != "" { + rawStripped := strings.TrimPrefix(req.URL.RawPath, "/claudomator") + if rawStripped == "" { + rawStripped = "/" + } + req.URL.RawPath = rawStripped + } + }, + ModifyResponse: func(resp *http.Response) error { + // Preserve Service-Worker-Allowed header + if swa := resp.Header.Get("Service-Worker-Allowed"); swa != "" { + resp.Header.Set("Service-Worker-Allowed", swa) + } + return nil + }, + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.EqualFold(r.Header.Get("Upgrade"), "websocket") { + proxyWebSocket(w, r, target) + return + } + rp.ServeHTTP(w, r) + }) +} + +// proxyWebSocket handles WebSocket upgrade via raw TCP hijacking. +func proxyWebSocket(w http.ResponseWriter, r *http.Request, target *url.URL) { + // Determine host:port for dialing + host := target.Host + if target.Port() == "" { + switch target.Scheme { + case "https": + host += ":443" + default: + host += ":80" + } + } + + upstream, err := net.Dial("tcp", host) + if err != nil { + http.Error(w, "bad gateway", http.StatusBadGateway) + return + } + defer upstream.Close() + + // Rewrite path on the request before forwarding + r.URL.Scheme = target.Scheme + r.URL.Host = target.Host + stripped := strings.TrimPrefix(r.URL.Path, "/claudomator") + if stripped == "" { + stripped = "/" + } + r.URL.Path = stripped + if r.URL.RawPath != "" { + rawStripped := strings.TrimPrefix(r.URL.RawPath, "/claudomator") + if rawStripped == "" { + rawStripped = "/" + } + r.URL.RawPath = rawStripped + } + r.RequestURI = r.URL.RequestURI() + + // Write the HTTP request to the upstream connection + if err := r.Write(upstream); err != nil { + http.Error(w, "bad gateway", http.StatusBadGateway) + return + } + + // Hijack the client connection + hijacker, ok := w.(http.Hijacker) + if !ok { + http.Error(w, "websocket not supported", http.StatusInternalServerError) + return + } + clientConn, _, err := hijacker.Hijack() + if err != nil { + return + } + defer clientConn.Close() + + // Bidirectional copy — no deadlines so long-lived WS connections survive + done := make(chan struct{}, 2) + go func() { + _, _ = io.Copy(upstream, clientConn) + done <- struct{}{} + }() + go func() { + _, _ = io.Copy(clientConn, upstream) + done <- struct{}{} + }() + <-done +} diff --git a/internal/handlers/claudomator_proxy_test.go b/internal/handlers/claudomator_proxy_test.go new file mode 100644 index 0000000..bb2842a --- /dev/null +++ b/internal/handlers/claudomator_proxy_test.go @@ -0,0 +1,175 @@ +package handlers + +import ( + "bufio" + "fmt" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestClaudomatorProxy_HTTP_Forward(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "hello") + })) + defer backend.Close() + + proxy := NewClaudomatorProxy(backend.URL) + + req := httptest.NewRequest("GET", "/claudomator/", nil) + rr := httptest.NewRecorder() + proxy.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("expected 200, got %d", rr.Code) + } + if body := rr.Body.String(); body != "hello" { + t.Errorf("expected 'hello', got %q", body) + } +} + +func TestClaudomatorProxy_StripPrefix(t *testing.T) { + var seenPath string + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenPath = r.URL.Path + w.WriteHeader(http.StatusOK) + })) + defer backend.Close() + + proxy := NewClaudomatorProxy(backend.URL) + + req := httptest.NewRequest("GET", "/claudomator/api/tasks", nil) + rr := httptest.NewRecorder() + proxy.ServeHTTP(rr, req) + + if seenPath != "/api/tasks" { + t.Errorf("expected upstream to see /api/tasks, got %q", seenPath) + } +} + +func TestClaudomatorProxy_RawPathStrip(t *testing.T) { + var seenRawPath string + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenRawPath = r.URL.RawPath + w.WriteHeader(http.StatusOK) + })) + defer backend.Close() + + proxy := NewClaudomatorProxy(backend.URL) + + req := httptest.NewRequest("GET", "/claudomator/api/tasks%2Ffoo", nil) + rr := httptest.NewRecorder() + proxy.ServeHTTP(rr, req) + + // RawPath should have /claudomator stripped + if strings.HasPrefix(seenRawPath, "/claudomator") { + t.Errorf("upstream still sees /claudomator prefix in RawPath: %q", seenRawPath) + } +} + +func TestClaudomatorProxy_ServiceWorkerHeader(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Service-Worker-Allowed", "/") + w.WriteHeader(http.StatusOK) + })) + defer backend.Close() + + proxy := NewClaudomatorProxy(backend.URL) + + req := httptest.NewRequest("GET", "/claudomator/sw.js", nil) + rr := httptest.NewRecorder() + proxy.ServeHTTP(rr, req) + + if got := rr.Header().Get("Service-Worker-Allowed"); got != "/" { + t.Errorf("expected Service-Worker-Allowed '/', got %q", got) + } +} + +// responseHijacker wraps httptest.ResponseRecorder and implements http.Hijacker +// so WebSocket hijack tests work without a real TCP connection. +type responseHijacker struct { + *httptest.ResponseRecorder + serverConn net.Conn +} + +func (h *responseHijacker) Hijack() (net.Conn, *bufio.ReadWriter, error) { + rw := bufio.NewReadWriter(bufio.NewReader(h.serverConn), bufio.NewWriter(h.serverConn)) + return h.serverConn, rw, nil +} + +func TestClaudomatorProxy_WebSocket(t *testing.T) { + // Stand up a minimal WebSocket echo backend that speaks raw HTTP upgrade + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + + done := make(chan struct{}) + go func() { + defer close(done) + conn, err := listener.Accept() + if err != nil { + return + } + defer conn.Close() + + // Read the HTTP upgrade request from the proxy + buf := make([]byte, 4096) + n, err := conn.Read(buf) + if err != nil || n == 0 { + return + } + + // Send a minimal HTTP 101 response so the proxy considers this a WS + resp := "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n" + _, _ = conn.Write([]byte(resp)) + + // Echo one message: read "ping", write "pong" + msgBuf := make([]byte, 64) + n, _ = conn.Read(msgBuf) + if n > 0 { + _, _ = conn.Write(msgBuf[:n]) + } + }() + + targetURL := "http://" + listener.Addr().String() + + proxy := NewClaudomatorProxy(targetURL) + + // Build a client-side TCP pipe to simulate the browser side + clientSide, serverSide := net.Pipe() + defer clientSide.Close() + + rr := httptest.NewRecorder() + rh := &responseHijacker{ResponseRecorder: rr, serverConn: serverSide} + + req := httptest.NewRequest("GET", "/claudomator/ws", nil) + req.Header.Set("Upgrade", "websocket") + req.Header.Set("Connection", "Upgrade") + + proxyDone := make(chan struct{}) + go func() { + defer close(proxyDone) + proxy.ServeHTTP(rh, req) + }() + + // Write a message from the "browser" side and read the echo + _, err = clientSide.Write([]byte("ping")) + if err != nil { + t.Fatal(err) + } + + // Read back — we expect "pong" from the echo server (or at least some data) + got := make([]byte, 64) + n, _ := clientSide.Read(got) + if n == 0 { + t.Error("expected data back from WebSocket echo") + } + + clientSide.Close() + <-proxyDone + <-done +} -- cgit v1.2.3