diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-06-15 09:14:14 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-06-15 09:14:14 +0000 |
| commit | c86abd29845fa6feb61b1fddb8e8bb41312d55a8 (patch) | |
| tree | 697fa3542035672a73ba9ddee8d47ca7044b0d5b /internal/handlers | |
| parent | eb089ef4c9416bd3d5642bb5a4437355ffa2fdd0 (diff) | |
feat: service gateway framework + playground demo
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 <noreply@anthropic.com>
Diffstat (limited to 'internal/handlers')
| -rw-r--r-- | internal/handlers/proxy.go (renamed from internal/handlers/claudomator_proxy.go) | 49 | ||||
| -rw-r--r-- | internal/handlers/proxy_test.go (renamed from internal/handlers/claudomator_proxy_test.go) | 47 |
2 files changed, 49 insertions, 47 deletions
diff --git a/internal/handlers/claudomator_proxy.go b/internal/handlers/proxy.go index f3f0cd2..ce10a6a 100644 --- a/internal/handlers/claudomator_proxy.go +++ b/internal/handlers/proxy.go @@ -9,38 +9,34 @@ import ( "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 { +// 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("claudomator: invalid target URL: " + err.Error()) + 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 - - // Strip /claudomator prefix - stripped := strings.TrimPrefix(req.URL.Path, "/claudomator") - if stripped == "" { - stripped = "/" - } - req.URL.Path = stripped - + req.URL.Path = stripPrefix(req.URL.Path) if req.URL.RawPath != "" { - rawStripped := strings.TrimPrefix(req.URL.RawPath, "/claudomator") - if rawStripped == "" { - rawStripped = "/" - } - req.URL.RawPath = rawStripped + req.URL.RawPath = stripPrefix(req.URL.RawPath) } }, 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) } @@ -54,7 +50,7 @@ func NewClaudomatorProxy(targetURL, allowedOrigin string) http.Handler { http.Error(w, "forbidden", http.StatusForbidden) return } - proxyWebSocket(w, r, target) + proxyWebSocket(w, r, target, pathPrefix) return } rp.ServeHTTP(w, r) @@ -62,8 +58,7 @@ func NewClaudomatorProxy(targetURL, allowedOrigin string) http.Handler { } // proxyWebSocket handles WebSocket upgrade via raw TCP hijacking. -func proxyWebSocket(w http.ResponseWriter, r *http.Request, target *url.URL) { - // Determine host:port for dialing +func proxyWebSocket(w http.ResponseWriter, r *http.Request, target *url.URL, pathPrefix string) { host := target.Host if target.Port() == "" { switch target.Scheme { @@ -81,16 +76,15 @@ func proxyWebSocket(w http.ResponseWriter, r *http.Request, target *url.URL) { } 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") + stripped := strings.TrimPrefix(r.URL.Path, pathPrefix) if stripped == "" { stripped = "/" } r.URL.Path = stripped if r.URL.RawPath != "" { - rawStripped := strings.TrimPrefix(r.URL.RawPath, "/claudomator") + rawStripped := strings.TrimPrefix(r.URL.RawPath, pathPrefix) if rawStripped == "" { rawStripped = "/" } @@ -98,13 +92,11 @@ func proxyWebSocket(w http.ResponseWriter, r *http.Request, target *url.URL) { } 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) @@ -116,7 +108,6 @@ func proxyWebSocket(w http.ResponseWriter, r *http.Request, target *url.URL) { } defer clientConn.Close() - // Bidirectional copy — no deadlines so long-lived WS connections survive done := make(chan struct{}, 2) go func() { _, _ = io.Copy(upstream, clientConn) diff --git a/internal/handlers/claudomator_proxy_test.go b/internal/handlers/proxy_test.go index bb2842a..69d701d 100644 --- a/internal/handlers/claudomator_proxy_test.go +++ b/internal/handlers/proxy_test.go @@ -10,13 +10,13 @@ import ( "testing" ) -func TestClaudomatorProxy_HTTP_Forward(t *testing.T) { +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 := NewClaudomatorProxy(backend.URL) + proxy := NewServiceProxy(backend.URL, "", "/claudomator") req := httptest.NewRequest("GET", "/claudomator/", nil) rr := httptest.NewRecorder() @@ -30,7 +30,7 @@ func TestClaudomatorProxy_HTTP_Forward(t *testing.T) { } } -func TestClaudomatorProxy_StripPrefix(t *testing.T) { +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 @@ -38,7 +38,7 @@ func TestClaudomatorProxy_StripPrefix(t *testing.T) { })) defer backend.Close() - proxy := NewClaudomatorProxy(backend.URL) + proxy := NewServiceProxy(backend.URL, "", "/claudomator") req := httptest.NewRequest("GET", "/claudomator/api/tasks", nil) rr := httptest.NewRecorder() @@ -49,7 +49,7 @@ func TestClaudomatorProxy_StripPrefix(t *testing.T) { } } -func TestClaudomatorProxy_RawPathStrip(t *testing.T) { +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 @@ -57,26 +57,25 @@ func TestClaudomatorProxy_RawPathStrip(t *testing.T) { })) defer backend.Close() - proxy := NewClaudomatorProxy(backend.URL) + proxy := NewServiceProxy(backend.URL, "", "/claudomator") 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) { +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 := NewClaudomatorProxy(backend.URL) + proxy := NewServiceProxy(backend.URL, "", "/claudomator") req := httptest.NewRequest("GET", "/claudomator/sw.js", nil) rr := httptest.NewRecorder() @@ -87,6 +86,25 @@ func TestClaudomatorProxy_ServiceWorkerHeader(t *testing.T) { } } +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 { @@ -99,8 +117,7 @@ func (h *responseHijacker) Hijack() (net.Conn, *bufio.ReadWriter, error) { return h.serverConn, rw, nil } -func TestClaudomatorProxy_WebSocket(t *testing.T) { - // Stand up a minimal WebSocket echo backend that speaks raw HTTP upgrade +func TestServiceProxy_WebSocket(t *testing.T) { listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) @@ -116,18 +133,15 @@ func TestClaudomatorProxy_WebSocket(t *testing.T) { } 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 { @@ -137,9 +151,8 @@ func TestClaudomatorProxy_WebSocket(t *testing.T) { targetURL := "http://" + listener.Addr().String() - proxy := NewClaudomatorProxy(targetURL) + proxy := NewServiceProxy(targetURL, "", "/claudomator") - // Build a client-side TCP pipe to simulate the browser side clientSide, serverSide := net.Pipe() defer clientSide.Close() @@ -156,13 +169,11 @@ func TestClaudomatorProxy_WebSocket(t *testing.T) { 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 { |
