Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ REDIS_URL=redis://redis:6379

# Platform
PORT=8080
# ADMIN_PORT=8081 # Internal-only port for admin routes (fix/issue-684). NOT published to host
# # in docker-compose.yml — only reachable within molecule-monorepo-net.
# # Workspace containers use PLATFORM_URL (→ PORT) and cannot reach admin routes.
# # Operators access via: docker exec <platform-container> curl localhost:8081/...
SECRETS_ENCRYPTION_KEY= # 32-byte key (raw or base64). Leave empty for plaintext (dev only).
CONFIGS_DIR= # Path to workspace-configs-templates/ (auto-discovered if empty)
PLUGINS_DIR= # Path to plugins/ directory (default: /plugins in container)
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ go run ./cmd/server # Run server (requires Postgres + Redis running)
go build -o molecli ./cmd/cli # Build TUI dashboard
./molecli # Run TUI dashboard (requires platform running)
```
Must run from `platform/` directory (not repo root). Env vars: `DATABASE_URL`, `REDIS_URL`, `PORT`, `PLATFORM_URL` (default `http://host.docker.internal:PORT` — passed to agent containers so they can reach the platform), `SECRETS_ENCRYPTION_KEY` (optional AES-256, 32 bytes), `CONFIGS_DIR` (auto-discovered), `PLUGINS_DIR` (deprecated — plugins are now installed per-workspace via API; the `plugins/` registry at repo root is auto-discovered), `ACTIVITY_RETENTION_DAYS` (default `7`), `ACTIVITY_CLEANUP_INTERVAL_HOURS` (default `6`), `CORS_ORIGINS` (comma-separated, default `http://localhost:3000,http://localhost:3001`), `RATE_LIMIT` (requests/min, default `600`), `WORKSPACE_DIR` (optional — global fallback host path for `/workspace` bind-mount; overridden by per-workspace `workspace_dir` column in DB; if neither is set, each workspace gets an isolated Docker named volume), `AWARENESS_URL` (optional — if set, injected into workspace containers along with a deterministic `AWARENESS_NAMESPACE` derived from workspace ID), `MOLECULE_IN_DOCKER` (optional — set to `1` when the platform itself runs inside Docker so the A2A proxy rewrites `127.0.0.1:<port>` URLs to container hostnames; auto-detected via `/.dockerenv`), `MOLECULE_ENV` (optional — set to `production` to hide the `/admin/workspaces/:id/test-token` E2E helper endpoint; unset or any other value leaves it enabled), `MOLECULE_ENABLE_TEST_TOKENS` (optional — set to `1` to force-enable the test-token endpoint even when `MOLECULE_ENV=production`; intended for staging runs only), `MOLECULE_ORG_ID` (optional — the public repo's only SaaS hook. When set to a UUID, every non-allowlisted request must carry a matching `X-Molecule-Org-Id` header or gets a 404; when unset, the guard is a passthrough so self-hosted / dev / CI are unaffected. Set only by the private `molecule-controlplane` provisioner on Fly Machines tenant instances — never by self-hosters).
Must run from `platform/` directory (not repo root). Env vars: `DATABASE_URL`, `REDIS_URL`, `PORT`, `ADMIN_PORT` (default `8081` — internal-only port for all AdminAuth-gated routes; intentionally **not published to host** in docker-compose so workspace containers and external callers cannot reach admin endpoints even if AdminAuth middleware regresses; fix/issue-684), `PLATFORM_URL` (default `http://host.docker.internal:PORT` — passed to agent containers so they can reach the platform), `SECRETS_ENCRYPTION_KEY` (optional AES-256, 32 bytes), `CONFIGS_DIR` (auto-discovered), `PLUGINS_DIR` (deprecated — plugins are now installed per-workspace via API; the `plugins/` registry at repo root is auto-discovered), `ACTIVITY_RETENTION_DAYS` (default `7`), `ACTIVITY_CLEANUP_INTERVAL_HOURS` (default `6`), `CORS_ORIGINS` (comma-separated, default `http://localhost:3000,http://localhost:3001`), `RATE_LIMIT` (requests/min, default `600`), `WORKSPACE_DIR` (optional — global fallback host path for `/workspace` bind-mount; overridden by per-workspace `workspace_dir` column in DB; if neither is set, each workspace gets an isolated Docker named volume), `AWARENESS_URL` (optional — if set, injected into workspace containers along with a deterministic `AWARENESS_NAMESPACE` derived from workspace ID), `MOLECULE_IN_DOCKER` (optional — set to `1` when the platform itself runs inside Docker so the A2A proxy rewrites `127.0.0.1:<port>` URLs to container hostnames; auto-detected via `/.dockerenv`), `MOLECULE_ENV` (optional — set to `production` to hide the `/admin/workspaces/:id/test-token` E2E helper endpoint; unset or any other value leaves it enabled), `MOLECULE_ENABLE_TEST_TOKENS` (optional — set to `1` to force-enable the test-token endpoint even when `MOLECULE_ENV=production`; intended for staging runs only), `MOLECULE_ORG_ID` (optional — the public repo's only SaaS hook. When set to a UUID, every non-allowlisted request must carry a matching `X-Molecule-Org-Id` header or gets a 404; when unset, the guard is a passthrough so self-hosted / dev / CI are unaffected. Set only by the private `molecule-controlplane` provisioner on Fly Machines tenant instances — never by self-hosters).

**Workspace tier resource limits** (issue #14 — override the per-tier memory/CPU caps in `provisioner.ApplyTierConfig`; CPU_SHARES follows Docker's 1024 = 1 CPU convention, translated to NanoCPUs for a hard cap):
- `TIER2_MEMORY_MB` / `TIER2_CPU_SHARES` — Standard tier (defaults `512` / `1024`)
Expand Down
6 changes: 6 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,12 @@ services:
DATABASE_URL: postgres://${POSTGRES_USER:-dev}:${POSTGRES_PASSWORD:-dev}@postgres:5432/${POSTGRES_DB:-molecule}?sslmode=disable
REDIS_URL: redis://redis:6379
PORT: "${PLATFORM_PORT:-8080}"
# ADMIN_PORT: internal-only port for admin routes (fix/issue-684-admin-network-isolation).
# Intentionally NOT included in the `ports:` mapping below — the port is
# reachable only within molecule-monorepo-net, not from the host interface.
# Workspace containers use PLATFORM_URL (→ public port) and therefore cannot
# reach admin endpoints even if AdminAuth middleware has a regression.
ADMIN_PORT: "${ADMIN_PORT:-8081}"
PLATFORM_URL: "http://platform:${PLATFORM_PORT:-8080}"
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:${CANVAS_PUBLISH_PORT:-3000},http://127.0.0.1:${CANVAS_PUBLISH_PORT:-3000},http://localhost:3001}
RATE_LIMIT: "${RATE_LIMIT:-1000}"
Expand Down
38 changes: 34 additions & 4 deletions platform/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,11 @@ func main() {
}

port := envOr("PORT", "8080")
// ADMIN_PORT: internal-only port for admin routes (fix/issue-684).
// docker-compose.yml intentionally does NOT publish this port to the host,
// so workspace containers (PLATFORM_URL → public port) and external callers
// cannot reach admin endpoints even if AdminAuth middleware regresses.
adminPort := envOr("ADMIN_PORT", "8081")
platformURL := envOr("PLATFORM_URL", fmt.Sprintf("http://host.docker.internal:%s", port))
configsDir := envOr("CONFIGS_DIR", findConfigsDir())

Expand Down Expand Up @@ -189,23 +194,45 @@ func main() {
channelMgr := channels.NewManager(wh, broadcaster)
go supervised.RunWithRecover(ctx, "channel-manager", channelMgr.Start)

// Router
// Public router — workspace agents, registry, canvas, health
r := router.Setup(hub, broadcaster, prov, platformURL, configsDir, wh, channelMgr)

// HTTP server with graceful shutdown
// Admin router — operators only (fix/issue-684-admin-network-isolation).
// Bound to ADMIN_PORT (default :8081). docker-compose.yml does NOT publish
// this port to the host, so workspace containers (configured with PLATFORM_URL
// → public port) and external callers cannot reach admin endpoints even if
// the AdminAuth middleware has a regression. This is the network-layer half of
// defence-in-depth; AdminAuth remains the application-layer half.
adminR := router.SetupAdmin(hub, broadcaster, prov, platformURL, configsDir, wh, channelMgr)

// Public HTTP server
srv := &http.Server{
Addr: fmt.Sprintf(":%s", port),
Handler: r,
}

// Start server in goroutine
// Admin HTTP server — internal-only
adminSrv := &http.Server{
Addr: fmt.Sprintf(":%s", adminPort),
Handler: adminR,
}

// Start public server
go func() {
log.Printf("Platform starting on :%s", port)
log.Printf("Platform public API starting on :%s", port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Server failed: %v", err)
}
}()

// Start admin server
go func() {
log.Printf("Platform admin API starting on :%s (not published to host — fix/issue-684)", adminPort)
if err := adminSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Admin server failed: %v", err)
}
}()

// Wait for interrupt signal
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
Expand All @@ -221,6 +248,9 @@ func main() {
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("Server forced shutdown: %v", err)
}
if err := adminSrv.Shutdown(shutdownCtx); err != nil {
log.Printf("Admin server forced shutdown: %v", err)
}

// Close WebSocket hub
hub.Close()
Expand Down
Loading