diff --git a/cmd/control-plane/main.go b/cmd/control-plane/main.go index 9f84edc..40c3c48 100644 --- a/cmd/control-plane/main.go +++ b/cmd/control-plane/main.go @@ -98,9 +98,14 @@ func main() { sampler := api.NewMetricsSampler(queries, 10*time.Second) sampler.Start(ctx) + // Wrap the API handler with the sandbox proxy so that requests with + // {port}-{sandbox_id}.{domain} Host headers are routed to the sandbox's + // host agent. All other requests pass through to the normal API router. + proxyWrapper := api.NewSandboxProxyWrapper(srv.Handler(), queries, hostPool) + httpServer := &http.Server{ Addr: cfg.ListenAddr, - Handler: srv.Handler(), + Handler: proxyWrapper, } // Graceful shutdown on signal. diff --git a/internal/api/handler_sandbox_proxy.go b/internal/api/handler_sandbox_proxy.go new file mode 100644 index 0000000..019fd7f --- /dev/null +++ b/internal/api/handler_sandbox_proxy.go @@ -0,0 +1,149 @@ +package api + +import ( + "fmt" + "log/slog" + "net/http" + "net/http/httputil" + "net/url" + "regexp" + "strconv" + "strings" + + "git.omukk.dev/wrenn/sandbox/internal/auth" + "git.omukk.dev/wrenn/sandbox/internal/db" + "git.omukk.dev/wrenn/sandbox/internal/lifecycle" +) + +// sandboxHostPattern matches hostnames like "49999-sb-abcd1234.localhost" or +// "49999-sb-abcd1234.example.com". Captures: port, sandbox ID. +var sandboxHostPattern = regexp.MustCompile(`^(\d+)-(sb-[0-9a-f]+)\.`) + +// SandboxProxyWrapper wraps an existing HTTP handler and intercepts requests +// whose Host header matches the {port}-{sandbox_id}.{domain} pattern. Matching +// requests are reverse-proxied through the host agent that owns the sandbox. +// All other requests are passed through to the inner handler. +// +// Authentication is via X-API-Key header only (no JWT). The API key's team +// must own the sandbox. +type SandboxProxyWrapper struct { + inner http.Handler + db *db.Queries + pool *lifecycle.HostClientPool + transport http.RoundTripper +} + +// NewSandboxProxyWrapper creates a new proxy wrapper. +func NewSandboxProxyWrapper(inner http.Handler, queries *db.Queries, pool *lifecycle.HostClientPool) *SandboxProxyWrapper { + return &SandboxProxyWrapper{ + inner: inner, + db: queries, + pool: pool, + transport: http.DefaultTransport, + } +} + +func (h *SandboxProxyWrapper) ServeHTTP(w http.ResponseWriter, r *http.Request) { + host := r.Host + // Strip port from Host header (e.g. "49999-sb-abcd1234.localhost:8000" → "49999-sb-abcd1234.localhost") + if colonIdx := strings.LastIndex(host, ":"); colonIdx != -1 { + host = host[:colonIdx] + } + + matches := sandboxHostPattern.FindStringSubmatch(host) + if matches == nil { + h.inner.ServeHTTP(w, r) + return + } + + port := matches[1] + sandboxID := matches[2] + + // Validate port. + portNum, err := strconv.Atoi(port) + if err != nil || portNum < 1 || portNum > 65535 { + http.Error(w, "invalid port", http.StatusBadRequest) + return + } + + // Authenticate: require API key or JWT, extract team ID. + teamID, err := h.authenticateRequest(r) + if err != nil { + writeError(w, http.StatusUnauthorized, "unauthorized", err.Error()) + return + } + + ctx := r.Context() + + // Look up sandbox and verify ownership. + sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ + ID: sandboxID, + TeamID: teamID, + }) + if err != nil { + http.Error(w, "sandbox not found", http.StatusNotFound) + return + } + + if sb.Status != "running" { + http.Error(w, fmt.Sprintf("sandbox is not running (status: %s)", sb.Status), http.StatusConflict) + return + } + + agentHost, err := h.db.GetHost(ctx, sb.HostID) + if err != nil { + http.Error(w, "host agent not found", http.StatusServiceUnavailable) + return + } + + if !agentHost.Address.Valid || agentHost.Address.String == "" { + http.Error(w, "host agent has no address", http.StatusServiceUnavailable) + return + } + + agentAddr := lifecycle.EnsureScheme(agentHost.Address.String) + upstreamPath := fmt.Sprintf("/proxy/%s/%s%s", sandboxID, port, r.URL.Path) + + target, err := url.Parse(agentAddr) + if err != nil { + http.Error(w, "invalid host agent address", http.StatusInternalServerError) + return + } + + proxy := &httputil.ReverseProxy{ + Transport: h.transport, + Director: func(req *http.Request) { + req.URL.Scheme = target.Scheme + req.URL.Host = target.Host + req.URL.Path = upstreamPath + req.URL.RawQuery = r.URL.RawQuery + req.Host = target.Host + }, + ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { + slog.Debug("sandbox proxy error", + "sandbox_id", sandboxID, + "port", port, + "error", err, + ) + http.Error(w, "proxy error: "+err.Error(), http.StatusBadGateway) + }, + } + + proxy.ServeHTTP(w, r) +} + +// authenticateRequest validates the request's API key and returns the team ID. +// Only API key authentication is supported for sandbox proxy requests (not JWT). +func (h *SandboxProxyWrapper) authenticateRequest(r *http.Request) (string, error) { + key := r.Header.Get("X-API-Key") + if key == "" { + return "", fmt.Errorf("X-API-Key header required") + } + + hash := auth.HashAPIKey(key) + row, err := h.db.GetAPIKeyByHash(r.Context(), hash) + if err != nil { + return "", fmt.Errorf("invalid API key") + } + return row.TeamID, nil +} diff --git a/internal/lifecycle/hostpool.go b/internal/lifecycle/hostpool.go index 0caf5ec..c6e724b 100644 --- a/internal/lifecycle/hostpool.go +++ b/internal/lifecycle/hostpool.go @@ -45,7 +45,7 @@ func (p *HostClientPool) Get(hostID, address string) hostagentv1connect.HostAgen if c, ok = p.clients[hostID]; ok { return c } - c = hostagentv1connect.NewHostAgentServiceClient(p.httpClient, ensureScheme(address)) + c = hostagentv1connect.NewHostAgentServiceClient(p.httpClient, EnsureScheme(address)) p.clients[hostID] = c return c } @@ -68,8 +68,8 @@ func (p *HostClientPool) Evict(hostID string) { p.mu.Unlock() } -// ensureScheme adds "http://" if the address has no scheme. -func ensureScheme(addr string) string { +// EnsureScheme adds "http://" if the address has no scheme. +func EnsureScheme(addr string) string { if strings.HasPrefix(addr, "http://") || strings.HasPrefix(addr, "https://") { return addr }