From c86abd29845fa6feb61b1fddb8e8bb41312d55a8 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Mon, 15 Jun 2026 09:14:14 +0000 Subject: feat: service gateway framework + playground demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Formalizes doot as an authenticated reverse proxy for arbitrary upstream services. Any local HTTP service can now be registered behind doot's existing auth + SSL layer with 3 lines of config. - Rename NewClaudomatorProxy → NewServiceProxy (generic) - Replace hard-coded claudomator proxy block with serviceMount slice loop - Add PlaygroundURL config (PLAYGROUND_URL env var) - Add playground/web/server.py: stdlib Python status page on port 9090 - Document pattern in .agent/design.md; update .env.example To add a new service: set its URL env var, add a config field, append one mount entry in main.go. Claudomator's GitHub webhook bypass is expressed as webhookPaths on its mount. Co-Authored-By: Claude Sonnet 4.6 --- internal/handlers/claudomator_proxy.go | 130 ------------------- internal/handlers/claudomator_proxy_test.go | 175 -------------------------- internal/handlers/proxy.go | 121 ++++++++++++++++++ internal/handlers/proxy_test.go | 186 ++++++++++++++++++++++++++++ 4 files changed, 307 insertions(+), 305 deletions(-) delete mode 100644 internal/handlers/claudomator_proxy.go delete mode 100644 internal/handlers/claudomator_proxy_test.go create mode 100644 internal/handlers/proxy.go create mode 100644 internal/handlers/proxy_test.go (limited to 'internal/handlers') diff --git a/internal/handlers/claudomator_proxy.go b/internal/handlers/claudomator_proxy.go deleted file mode 100644 index f3f0cd2..0000000 --- a/internal/handlers/claudomator_proxy.go +++ /dev/null @@ -1,130 +0,0 @@ -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, allowedOrigin 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") { - if allowedOrigin != "" && r.Header.Get("Origin") != allowedOrigin { - http.Error(w, "forbidden", http.StatusForbidden) - return - } - 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 deleted file mode 100644 index bb2842a..0000000 --- a/internal/handlers/claudomator_proxy_test.go +++ /dev/null @@ -1,175 +0,0 @@ -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 -} diff --git a/internal/handlers/proxy.go b/internal/handlers/proxy.go new file mode 100644 index 0000000..ce10a6a --- /dev/null +++ b/internal/handlers/proxy.go @@ -0,0 +1,121 @@ +package handlers + +import ( + "io" + "net" + "net/http" + "net/http/httputil" + "net/url" + "strings" +) + +// NewServiceProxy returns an http.Handler that reverse-proxies requests to +// targetURL, stripping pathPrefix from the request path before forwarding. +// WebSocket upgrade requests are handled via raw TCP hijacking to support +// long-lived connections. +func NewServiceProxy(targetURL, allowedOrigin, pathPrefix string) http.Handler { + target, err := url.Parse(targetURL) + if err != nil { + panic("service proxy: invalid target URL: " + err.Error()) + } + + stripPrefix := func(p string) string { + s := strings.TrimPrefix(p, pathPrefix) + if s == "" { + s = "/" + } + return s + } + + rp := &httputil.ReverseProxy{ + Director: func(req *http.Request) { + req.URL.Scheme = target.Scheme + req.URL.Host = target.Host + req.URL.Path = stripPrefix(req.URL.Path) + if req.URL.RawPath != "" { + req.URL.RawPath = stripPrefix(req.URL.RawPath) + } + }, + ModifyResponse: func(resp *http.Response) error { + 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") { + if allowedOrigin != "" && r.Header.Get("Origin") != allowedOrigin { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + proxyWebSocket(w, r, target, pathPrefix) + return + } + rp.ServeHTTP(w, r) + }) +} + +// proxyWebSocket handles WebSocket upgrade via raw TCP hijacking. +func proxyWebSocket(w http.ResponseWriter, r *http.Request, target *url.URL, pathPrefix string) { + 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() + + r.URL.Scheme = target.Scheme + r.URL.Host = target.Host + stripped := strings.TrimPrefix(r.URL.Path, pathPrefix) + if stripped == "" { + stripped = "/" + } + r.URL.Path = stripped + if r.URL.RawPath != "" { + rawStripped := strings.TrimPrefix(r.URL.RawPath, pathPrefix) + if rawStripped == "" { + rawStripped = "/" + } + r.URL.RawPath = rawStripped + } + r.RequestURI = r.URL.RequestURI() + + if err := r.Write(upstream); err != nil { + http.Error(w, "bad gateway", http.StatusBadGateway) + return + } + + 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() + + 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/proxy_test.go b/internal/handlers/proxy_test.go new file mode 100644 index 0000000..69d701d --- /dev/null +++ b/internal/handlers/proxy_test.go @@ -0,0 +1,186 @@ +package handlers + +import ( + "bufio" + "fmt" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestServiceProxy_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 := NewServiceProxy(backend.URL, "", "/claudomator") + + 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 TestServiceProxy_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 := NewServiceProxy(backend.URL, "", "/claudomator") + + 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 TestServiceProxy_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 := NewServiceProxy(backend.URL, "", "/claudomator") + + req := httptest.NewRequest("GET", "/claudomator/api/tasks%2Ffoo", nil) + rr := httptest.NewRecorder() + proxy.ServeHTTP(rr, req) + + if strings.HasPrefix(seenRawPath, "/claudomator") { + t.Errorf("upstream still sees /claudomator prefix in RawPath: %q", seenRawPath) + } +} + +func TestServiceProxy_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 := NewServiceProxy(backend.URL, "", "/claudomator") + + 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) + } +} + +func TestServiceProxy_DifferentPrefix(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 := NewServiceProxy(backend.URL, "", "/playground") + + req := httptest.NewRequest("GET", "/playground/status", nil) + rr := httptest.NewRecorder() + proxy.ServeHTTP(rr, req) + + if seenPath != "/status" { + t.Errorf("expected upstream to see /status, got %q", seenPath) + } +} + +// 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 TestServiceProxy_WebSocket(t *testing.T) { + 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() + + buf := make([]byte, 4096) + n, err := conn.Read(buf) + if err != nil || n == 0 { + return + } + + resp := "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n" + _, _ = conn.Write([]byte(resp)) + + msgBuf := make([]byte, 64) + n, _ = conn.Read(msgBuf) + if n > 0 { + _, _ = conn.Write(msgBuf[:n]) + } + }() + + targetURL := "http://" + listener.Addr().String() + + proxy := NewServiceProxy(targetURL, "", "/claudomator") + + 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) + }() + + _, err = clientSide.Write([]byte("ping")) + if err != nil { + t.Fatal(err) + } + + 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