summaryrefslogtreecommitdiff
path: root/internal/handlers/proxy.go
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-06-15 09:14:14 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-06-15 09:14:14 +0000
commitc86abd29845fa6feb61b1fddb8e8bb41312d55a8 (patch)
tree697fa3542035672a73ba9ddee8d47ca7044b0d5b /internal/handlers/proxy.go
parenteb089ef4c9416bd3d5642bb5a4437355ffa2fdd0 (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/proxy.go')
-rw-r--r--internal/handlers/proxy.go121
1 files changed, 121 insertions, 0 deletions
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
+}