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/proxy.go | 121 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 internal/handlers/proxy.go (limited to 'internal/handlers/proxy.go') 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 +} -- cgit v1.2.3