summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--cmd/dashboard/main.go22
-rw-r--r--internal/config/config.go6
-rw-r--r--internal/config/config_test.go41
-rw-r--r--internal/handlers/claudomator_proxy.go126
-rw-r--r--internal/handlers/claudomator_proxy_test.go175
5 files changed, 368 insertions, 2 deletions
diff --git a/cmd/dashboard/main.go b/cmd/dashboard/main.go
index 68d3484..437420d 100644
--- a/cmd/dashboard/main.go
+++ b/cmd/dashboard/main.go
@@ -156,8 +156,10 @@ func main() {
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(config.RequestTimeout))
r.Use(appmiddleware.SecurityHeaders(cfg.Debug)) // Security headers
- r.Use(sessionManager.LoadAndSave) // Session middleware must be applied globally
- r.Use(authHandlers.Middleware().CSRFProtect) // CSRF protection
+ r.Use(sessionManager.LoadAndSave) // Session middleware must be applied globally
+
+ // Initialize Claudomator reverse proxy
+ claudomatorProxy := handlers.NewClaudomatorProxy(cfg.ClaudomatorURL)
// Rate limiter for auth endpoints
authRateLimiter := appmiddleware.NewRateLimiter(config.AuthRateLimitRequests, config.AuthRateLimitWindow)
@@ -218,8 +220,24 @@ func main() {
})
})
+ // Claudomator proxy routes
+ // /claudomator (no trailing slash) -> 301 redirect
+ r.Get("/claudomator", func(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, "/claudomator/", http.StatusMovedPermanently)
+ })
+
+ // GitHub webhook: no auth (GitHub POSTs with HMAC, no session)
+ r.Post("/claudomator/api/webhooks/github", claudomatorProxy.ServeHTTP)
+
+ // All other Claudomator routes: RequireAuth only, no CSRF
+ r.Group(func(r chi.Router) {
+ r.Use(authHandlers.Middleware().RequireAuth)
+ r.Handle("/claudomator/*", claudomatorProxy)
+ })
+
// Protected routes (auth required)
r.Group(func(r chi.Router) {
+ r.Use(authHandlers.Middleware().CSRFProtect)
r.Use(authHandlers.Middleware().RequireAuth)
// Dashboard
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
+}