From a363b56f251d737896a3ecda66f6c5fe4f04a795 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Thu, 16 Apr 2026 02:46:47 -0700 Subject: [PATCH] feat(tenant): combined platform + canvas Docker image with reverse proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single-container tenant architecture: Go platform (:8080) + Canvas Node.js (:3000) in one Fly machine, with Go's NoRoute handler reverse- proxying non-API routes to the canvas. Browser only talks to :8080. Changes: platform/Dockerfile.tenant — multi-stage build (Go + Node + runtime). Bakes workspace-configs-templates/ + org-templates/ into the image. Build context: repo root. platform/entrypoint-tenant.sh — starts both processes, kills both if either exits. Fly health check on :8080 covers the Go binary; canvas health is implicit (proxy returns 502 if canvas is down). platform/internal/router/canvas_proxy.go — httputil.ReverseProxy that forwards unmatched routes to CANVAS_PROXY_URL (http://localhost:3000). Activated by NoRoute when CANVAS_PROXY_URL env is set. platform/internal/router/router.go — wire NoRoute → canvasProxy when CANVAS_PROXY_URL is present; no-op otherwise (local dev unchanged). platform/internal/middleware/securityheaders.go — relaxed CSP to allow Next.js inline scripts/styles/eval + WebSocket + data: URIs. The strict `default-src 'self'` was blocking all canvas rendering. canvas/src/lib/api.ts — changed `||` to `??` for NEXT_PUBLIC_PLATFORM_URL so empty string means "same-origin" (combined image) instead of falling back to localhost:8080. canvas/src/components/tabs/TerminalTab.tsx — same `??` fix for WS URL. Verified: tenant machine boots, canvas renders, 8 runtime templates + 4 org templates visible, API routes work through the same port. Co-Authored-By: Claude Opus 4.6 (1M context) --- canvas/src/components/tabs/TerminalTab.tsx | 2 +- canvas/src/lib/api.ts | 7 ++- platform/Dockerfile.tenant | 63 +++++++++++++++++++ platform/entrypoint-tenant.sh | 35 +++++++++++ .../internal/middleware/securityheaders.go | 25 +++++++- platform/internal/router/canvas_proxy.go | 47 ++++++++++++++ platform/internal/router/router.go | 13 ++++ 7 files changed, 189 insertions(+), 3 deletions(-) create mode 100644 platform/Dockerfile.tenant create mode 100644 platform/entrypoint-tenant.sh create mode 100644 platform/internal/router/canvas_proxy.go diff --git a/canvas/src/components/tabs/TerminalTab.tsx b/canvas/src/components/tabs/TerminalTab.tsx index 669df931..6278d377 100644 --- a/canvas/src/components/tabs/TerminalTab.tsx +++ b/canvas/src/components/tabs/TerminalTab.tsx @@ -6,7 +6,7 @@ interface Props { workspaceId: string; } -const WS_URL = process.env.NEXT_PUBLIC_WS_URL?.replace("/ws", "") || "ws://localhost:8080"; +const WS_URL = (process.env.NEXT_PUBLIC_WS_URL ?? "ws://localhost:8080").replace("/ws", ""); export function TerminalTab({ workspaceId }: Props) { const containerRef = useRef(null); diff --git a/canvas/src/lib/api.ts b/canvas/src/lib/api.ts index 10e77e52..6bb091b1 100644 --- a/canvas/src/lib/api.ts +++ b/canvas/src/lib/api.ts @@ -1,7 +1,12 @@ import { getTenantSlug } from "./tenant"; +// When NEXT_PUBLIC_PLATFORM_URL is set to "" (empty string), the canvas +// uses relative paths — correct for the combined tenant image where Go +// platform + canvas run on the same port via reverse proxy. The `??` +// operator preserves "" as a valid value; `||` would fall through to +// the localhost default. export const PLATFORM_URL = - process.env.NEXT_PUBLIC_PLATFORM_URL || "http://localhost:8080"; + process.env.NEXT_PUBLIC_PLATFORM_URL ?? "http://localhost:8080"; async function request( method: string, diff --git a/platform/Dockerfile.tenant b/platform/Dockerfile.tenant new file mode 100644 index 00000000..d8738d8a --- /dev/null +++ b/platform/Dockerfile.tenant @@ -0,0 +1,63 @@ +# Dockerfile.tenant — combined platform (Go) + canvas (Next.js) image. +# +# Serves both the API (Go on :8080) and the UI (Node.js on :3000) in a +# single container. Fly listens on :8080 for health checks; an entrypoint +# script starts both processes. The Go router is the external-facing port; +# it reverse-proxies unknown routes to the canvas Node server so a single +# port handles everything. +# +# Build context: repo root (same as platform/Dockerfile). +# +# docker buildx build --platform linux/amd64 \ +# -f platform/Dockerfile.tenant \ +# --build-arg NEXT_PUBLIC_PLATFORM_URL="" \ +# --build-arg NEXT_PUBLIC_WS_URL="" \ +# -t registry.fly.io/molecule-tenant:latest \ +# --push . + +# ── Stage 1: Go platform binary ────────────────────────────────────── +FROM golang:1.25-alpine AS go-builder +WORKDIR /app +COPY platform/go.mod platform/go.sum ./ +RUN go mod download +COPY platform/ . +RUN CGO_ENABLED=0 GOOS=linux go build -o /platform ./cmd/server + +# ── Stage 2: Canvas Next.js standalone ──────────────────────────────── +FROM node:20-alpine AS canvas-builder +WORKDIR /canvas +COPY canvas/package.json canvas/package-lock.json* ./ +RUN npm install +COPY canvas/ . +# Platform URL is relative (same container) — empty string = same-origin. +# WebSocket URL uses relative path so the browser connects to the same host. +ARG NEXT_PUBLIC_PLATFORM_URL="" +ARG NEXT_PUBLIC_WS_URL="" +ENV NEXT_PUBLIC_PLATFORM_URL=$NEXT_PUBLIC_PLATFORM_URL +ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL +RUN npm run build + +# ── Stage 3: Runtime ────────────────────────────────────────────────── +FROM node:20-alpine +RUN apk add --no-cache ca-certificates git tzdata + +# Go platform binary +COPY --from=go-builder /platform /platform +COPY platform/migrations /migrations +COPY workspace-configs-templates /workspace-configs-templates +COPY org-templates /org-templates + +# Canvas standalone (Next.js server.js + static assets) +WORKDIR /canvas +COPY --from=canvas-builder /canvas/.next/standalone ./ +COPY --from=canvas-builder /canvas/.next/static ./.next/static +COPY --from=canvas-builder /canvas/public ./public + +# Entrypoint starts both processes. Go on :8080, Canvas on :3000. +# The Go platform's router proxies unknown routes to :3000 so Fly +# only needs to expose :8080. +COPY platform/entrypoint-tenant.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +EXPOSE 8080 +CMD ["/entrypoint.sh"] diff --git a/platform/entrypoint-tenant.sh b/platform/entrypoint-tenant.sh new file mode 100644 index 00000000..473efae4 --- /dev/null +++ b/platform/entrypoint-tenant.sh @@ -0,0 +1,35 @@ +#!/bin/sh +# Tenant entrypoint — starts both Go platform (API) and Canvas (UI). +# +# Go platform listens on :8080 (Fly health checks hit this port). +# Canvas Node.js listens on :3000 (internal only). +# The Go platform's fallback handler proxies non-API routes to :3000 +# so the browser only ever talks to :8080. +# +# If either process dies, we kill the other and exit non-zero so Fly +# restarts the machine. + +set -e + +# Start Canvas in background +cd /canvas +PORT=3000 HOSTNAME=0.0.0.0 node server.js & +CANVAS_PID=$! + +# Start Go platform in foreground-ish (we trap signals) +cd / +/platform & +PLATFORM_PID=$! + +# If either process exits, kill the other +cleanup() { + kill $CANVAS_PID 2>/dev/null || true + kill $PLATFORM_PID 2>/dev/null || true +} +trap cleanup EXIT SIGTERM SIGINT + +# Wait for either to exit — whichever exits first triggers cleanup +wait -n $CANVAS_PID $PLATFORM_PID +EXIT_CODE=$? +cleanup +exit $EXIT_CODE diff --git a/platform/internal/middleware/securityheaders.go b/platform/internal/middleware/securityheaders.go index ca98ef10..d16707ea 100644 --- a/platform/internal/middleware/securityheaders.go +++ b/platform/internal/middleware/securityheaders.go @@ -15,7 +15,6 @@ func SecurityHeaders() gin.HandlerFunc { return func(c *gin.Context) { c.Header("X-Content-Type-Options", "nosniff") c.Header("X-Frame-Options", "DENY") - c.Header("Content-Security-Policy", "default-src 'self'") c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains") // #282: these two were documented in CLAUDE.md but missing from // the middleware. Referrer-Policy prevents browsers from leaking @@ -25,6 +24,30 @@ func SecurityHeaders() gin.HandlerFunc { // canvas embeds iframes for Langfuse traces. c.Header("Referrer-Policy", "strict-origin-when-cross-origin") c.Header("Permissions-Policy", "camera=(), microphone=(), geolocation=()") + + // CSP: only apply to API responses. Canvas-proxied routes + // (NoRoute → reverse-proxy to Next.js) serve HTML with inline + // scripts + styles that `default-src 'self'` blocks. Next.js + // sets its own CSP via tags. The Go middleware should + // not override it for proxied HTML responses. + // + // Detection: API routes are registered explicitly in the router; + // canvas-proxied routes hit NoRoute. We can't detect NoRoute + // before c.Next() fires, so instead we check the response + // Content-Type after Next() — but that's too late for headers. + // + // Simpler: apply a permissive CSP that allows Next.js to work. + // 'unsafe-inline' + 'unsafe-eval' are needed for Next.js dev + // hydration; in production Next.js uses nonces but we don't + // propagate them through the proxy. This is acceptable because + // the canvas is our own code, not user-generated content. + c.Header("Content-Security-Policy", + "default-src 'self'; "+ + "script-src 'self' 'unsafe-inline' 'unsafe-eval'; "+ + "style-src 'self' 'unsafe-inline'; "+ + "img-src 'self' data: blob:; "+ + "connect-src 'self' ws: wss:; "+ + "font-src 'self' data:") c.Next() } } diff --git a/platform/internal/router/canvas_proxy.go b/platform/internal/router/canvas_proxy.go new file mode 100644 index 00000000..36b6ecff --- /dev/null +++ b/platform/internal/router/canvas_proxy.go @@ -0,0 +1,47 @@ +package router + +import ( + "log" + "net/http" + "net/http/httputil" + "net/url" + + "github.com/gin-gonic/gin" +) + +// newCanvasProxy returns a Gin handler that reverse-proxies all unmatched +// routes to the canvas Next.js server. Used in the combined tenant image +// (Dockerfile.tenant) where Go platform (:8080) and canvas (:3000) run in +// the same container. +// +// The proxy forwards the request path, query, and headers as-is. The Host +// header is rewritten to the canvas upstream so Next.js doesn't reject it +// (Next.js checks Host in dev mode). Response headers from canvas flow back +// to the client unchanged. +// +// Why NoRoute + proxy instead of nginx: one fewer process, one fewer config +// file, and the Go router already knows which routes are API routes. Any +// path not registered as an API route is a canvas page by elimination. +func newCanvasProxy(targetURL string) gin.HandlerFunc { + target, err := url.Parse(targetURL) + if err != nil { + log.Fatalf("canvas_proxy: invalid CANVAS_PROXY_URL %q: %v", targetURL, err) + } + + proxy := &httputil.ReverseProxy{ + Director: func(req *http.Request) { + req.URL.Scheme = target.Scheme + req.URL.Host = target.Host + req.Host = target.Host + }, + ErrorHandler: func(w http.ResponseWriter, _ *http.Request, err error) { + log.Printf("canvas_proxy: %v", err) + w.WriteHeader(http.StatusBadGateway) + w.Write([]byte("canvas unavailable")) + }, + } + + return func(c *gin.Context) { + proxy.ServeHTTP(c.Writer, c.Request) + } +} diff --git a/platform/internal/router/router.go b/platform/internal/router/router.go index 63c95e41..24573dc3 100644 --- a/platform/internal/router/router.go +++ b/platform/internal/router/router.go @@ -406,6 +406,19 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi sh := handlers.NewSocketHandler(hub) r.GET("/ws", sh.HandleConnect) + // Canvas reverse proxy — when running as a combined tenant image + // (Dockerfile.tenant), the Next.js canvas server runs on :3000 inside + // the same container. Any route not matched by the API handlers above + // gets proxied to the canvas so the browser only ever talks to :8080. + // + // When CANVAS_PROXY_URL is empty (self-hosted / local dev), this is a + // no-op and Gin returns its default 404. The canvas dev server runs + // separately on :3000 in that setup. + if canvasURL := os.Getenv("CANVAS_PROXY_URL"); canvasURL != "" { + canvasProxy := newCanvasProxy(canvasURL) + r.NoRoute(canvasProxy) + } + return r }