diff options
| author | Doot Agent <agent@doot.terst.org> | 2026-03-25 04:03:13 +0000 |
|---|---|---|
| committer | Doot Agent <agent@doot.terst.org> | 2026-03-25 04:03:13 +0000 |
| commit | 2db5020047640361066510f29f908ca9fd1c99aa (patch) | |
| tree | d68b87204621ec8ab7bd7a7366a80357cd443366 /internal | |
| parent | 23c670442392af1c75b935b3296ae2fc4fd094ba (diff) | |
feat: gate Claudomator UI behind Doot session auth via reverse proxy
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/config/config.go | 6 | ||||
| -rw-r--r-- | internal/config/config_test.go | 41 | ||||
| -rw-r--r-- | internal/handlers/claudomator_proxy.go | 126 | ||||
| -rw-r--r-- | internal/handlers/claudomator_proxy_test.go | 175 |
4 files changed, 348 insertions, 0 deletions
diff --git a/internal/config/config.go b/internal/config/config.go index 86d0d5b..d3770f1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -39,6 +39,9 @@ type Config struct { // WebAuthn WebAuthnRPID string // Relying Party ID (domain, e.g., "doot.terst.org") WebAuthnOrigin string // Expected origin (e.g., "https://doot.terst.org") + + // Claudomator + ClaudomatorURL string // URL of Claudomator service } // Load reads configuration from environment variables @@ -75,6 +78,9 @@ func Load() (*Config, error) { // WebAuthn WebAuthnRPID: os.Getenv("WEBAUTHN_RP_ID"), WebAuthnOrigin: os.Getenv("WEBAUTHN_ORIGIN"), + + // Claudomator + ClaudomatorURL: getEnvWithDefault("CLAUDOMATOR_URL", "http://127.0.0.1:8484"), } // Validate required fields diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 41cd6e0..0722825 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -276,6 +276,47 @@ func TestLoad(t *testing.T) { } } +func TestConfig_ClaudomatorURL_Default(t *testing.T) { + os.Unsetenv("CLAUDOMATOR_URL") + os.Setenv("TODOIST_API_KEY", "test-todoist-key") + os.Setenv("TRELLO_API_KEY", "test-trello-key") + os.Setenv("TRELLO_TOKEN", "test-trello-token") + defer func() { + os.Unsetenv("TODOIST_API_KEY") + os.Unsetenv("TRELLO_API_KEY") + os.Unsetenv("TRELLO_TOKEN") + }() + + cfg, err := Load() + if err != nil { + t.Fatalf("Load failed: %v", err) + } + if cfg.ClaudomatorURL != "http://127.0.0.1:8484" { + t.Errorf("Expected default ClaudomatorURL 'http://127.0.0.1:8484', got '%s'", cfg.ClaudomatorURL) + } +} + +func TestConfig_ClaudomatorURL_EnvOverride(t *testing.T) { + os.Setenv("CLAUDOMATOR_URL", "http://1.2.3.4:9000") + os.Setenv("TODOIST_API_KEY", "test-todoist-key") + os.Setenv("TRELLO_API_KEY", "test-trello-key") + os.Setenv("TRELLO_TOKEN", "test-trello-token") + defer func() { + os.Unsetenv("CLAUDOMATOR_URL") + os.Unsetenv("TODOIST_API_KEY") + os.Unsetenv("TRELLO_API_KEY") + os.Unsetenv("TRELLO_TOKEN") + }() + + cfg, err := Load() + if err != nil { + t.Fatalf("Load failed: %v", err) + } + if cfg.ClaudomatorURL != "http://1.2.3.4:9000" { + t.Errorf("Expected ClaudomatorURL 'http://1.2.3.4:9000', got '%s'", cfg.ClaudomatorURL) + } +} + func TestLoad_ValidationError(t *testing.T) { // Clear required env vars to trigger validation error os.Unsetenv("TODOIST_API_KEY") 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 +} |
