feat(tenant): combined platform + canvas Docker image with reverse proxy
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) <noreply@anthropic.com>
This commit is contained in:
parent
111c59da68
commit
a363b56f25
@ -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<HTMLDivElement>(null);
|
||||
|
||||
@ -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<T>(
|
||||
method: string,
|
||||
|
||||
63
platform/Dockerfile.tenant
Normal file
63
platform/Dockerfile.tenant
Normal file
@ -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"]
|
||||
35
platform/entrypoint-tenant.sh
Normal file
35
platform/entrypoint-tenant.sh
Normal file
@ -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
|
||||
@ -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 <meta> 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()
|
||||
}
|
||||
}
|
||||
|
||||
47
platform/internal/router/canvas_proxy.go
Normal file
47
platform/internal/router/canvas_proxy.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user