From 215b05e76f3ffbbe0eb206bed7aad5f55989d116 Mon Sep 17 00:00:00 2001 From: Louis Reingold Date: Sat, 29 Aug 2026 03:37:57 +0000 Subject: [PATCH 1/4] feat: serve env WordPress sites over HTTPS (TLS phase 2, fresh-env path) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The control plane got TLS in PR #72; this extends it to the per-env sites (issue #73). Key enabler: a Let's Encrypt IP certificate is valid for any port, so Caddy terminates TLS for the whole env port range with the cert it already holds — no domain needed. Scaffolder: - --bind=IP publishes ports (WP + app ports) on one host interface only, via a new WP_BIND prefix in .env interpolated into docker-compose.yml ("127.0.0.1:" when a proxy owns the public side; empty = unchanged local-dev behavior). - --public-scheme=http|https lands in .env as PUBLIC_SCHEME and reaches setup scripts as SANDBOX_PUBLIC_SCHEME (run-setup-script.sh). - wp-config forwarded-proto shim: X-Forwarded-Proto: https sets $_SERVER['HTTPS']='on' so the existing per-request WP_HOME/WP_SITEURL scheme detection (and is_ssl()) yields https behind the proxy. - `update` carries WP_BIND/PUBLIC_SCHEME through from the project's .env. Server: - DEVBOX_PUBLIC_SCHEME=https makes new envs scaffold with --bind=127.0.0.1 --public-scheme=https. - The scheme is recorded PER ENV at allocation (record.scheme): envs created before a switch keep working http URLs (and their public port binds) until migrated; a warm env claimed after the switch reports the scheme it was built with; a duplicate inherits its source's scheme (the copy is the source's dir verbatim). - wpUrl / admin-login loginUrl / UI links all follow the record's scheme (ops.js, status.js, ui/app.js). clone.js handles the new bind-aware app-port line shape. - Built-in FutureLayer preset URLs use SANDBOX_PUBLIC_SCHEME. Deploy: - deploy/gen-env-sites.sh generates ONE Caddy site block listing every range port as an address, proxying port-for-port via the {http.request.local.port} placeholder. The explicit `bind ` is load-bearing: it keeps loopback free for docker's publishes and the allocator's port probe (Caddy would otherwise bind 0.0.0.0). - Caddyfile.example + READMEs document the switch; warm pools must be rebuilt after it (they bake the scaffold at build time). Migration of pre-existing envs is deliberately NOT in this change — they continue exactly as before. Co-Authored-By: Claude Fable 5 --- create-katalystwp/engine.js | 29 ++++++++++-- create-katalystwp/server/AGENT_USAGE.md | 4 +- create-katalystwp/server/README.md | 16 +++++-- .../server/deploy/Caddyfile.example | 21 +++++++-- .../server/deploy/gen-env-sites.sh | 47 +++++++++++++++++++ create-katalystwp/server/src/allocator.js | 9 +++- create-katalystwp/server/src/clone.js | 4 ++ create-katalystwp/server/src/config.js | 11 +++++ create-katalystwp/server/src/manager.js | 7 +++ create-katalystwp/server/src/ops.js | 14 +++--- create-katalystwp/server/src/presets.js | 4 +- create-katalystwp/server/src/status.js | 10 ++-- create-katalystwp/server/ui/app.js | 19 +++++--- .../templates/docker-compose.yml | 11 ++++- create-katalystwp/templates/env.example | 11 +++++ .../templates/scripts/run-setup-script.sh | 6 ++- 16 files changed, 190 insertions(+), 33 deletions(-) create mode 100755 create-katalystwp/server/deploy/gen-env-sites.sh diff --git a/create-katalystwp/engine.js b/create-katalystwp/engine.js index 72555f9..18e2c7d 100644 --- a/create-katalystwp/engine.js +++ b/create-katalystwp/engine.js @@ -305,6 +305,8 @@ async function updateProject({ yes = false } = {}) { agentNpmPkgs: agents.map((k) => AGENTS[k].pkg).filter(Boolean).map((p) => p + ' ').join(''), port: env.WP_PORT || '8080', publicHost: env.PUBLIC_HOST || 'localhost', + publicScheme: env.PUBLIC_SCHEME || 'http', + wpBind: env.WP_BIND || '', appPortsBlock: renderAppPortsBlock(appPorts), wpAdminUser: env.WP_ADMIN_USER || 'admin', wpAdminPassword: env.WP_ADMIN_PASSWORD || 'password', @@ -386,7 +388,7 @@ function applyAgentSections(content, agents) { } function parseArgs(argv) { - const out = { dir: null, port: '8080', portExplicit: false, setup: true, setupScript: null, defines: null, activate: [], devScript: null, devCommand: null, appPorts: [], publicHost: 'localhost', agentsRaw: null, pluginsRaw: null, yes: false }; + const out = { dir: null, port: '8080', portExplicit: false, setup: true, setupScript: null, defines: null, activate: [], devScript: null, devCommand: null, appPorts: [], publicHost: 'localhost', publicScheme: 'http', bindHost: '', agentsRaw: null, pluginsRaw: null, yes: false }; for (const a of argv) { if (a.startsWith('--port=')) { out.port = a.slice('--port='.length); out.portExplicit = true; } else if (a.startsWith('--agents=')) out.agentsRaw = a.slice('--agents='.length); @@ -399,6 +401,11 @@ function parseArgs(argv) { else if (a.startsWith('--defines=')) out.defines = a.slice('--defines='.length); else if (a.startsWith('--app-ports=')) out.appPorts = parseAppPorts(a.slice('--app-ports='.length)); else if (a.startsWith('--public-host=')) out.publicHost = a.slice('--public-host='.length).trim() || 'localhost'; + else if (a.startsWith('--public-scheme=')) { + const s = a.slice('--public-scheme='.length).trim(); + if (s !== 'http' && s !== 'https') throw new Error(`--public-scheme must be http or https, got "${s}"`); + out.publicScheme = s; + } else if (a.startsWith('--bind=')) out.bindHost = a.slice('--bind='.length).trim(); else if (a.startsWith('--activate=')) { out.activate = a.slice('--activate='.length).split(',').map((s) => s.trim()).filter(Boolean); } else if (a === '--scaffold-only') out.setup = false; @@ -457,9 +464,10 @@ function renderAppPortsBlock(appPorts) { ' # container port (started here or by the dev script — the dev container', ' # shares this network namespace) are reachable on the host port. Published', ' # ports bind 0.0.0.0 and BYPASS ufw-style host firewalls — on an', - ' # internet-facing host, restrict them upstream (cloud firewall/VPN).', + ' # internet-facing host, restrict them upstream (cloud firewall/VPN),', + ' # or set WP_BIND=127.0.0.1: in .env to bind loopback only.', ' ports:', - ...appPorts.map((p) => ` - "${p.host}:${p.container}"`), + ...appPorts.map((p) => ` - "\${WP_BIND:-}${p.host}:${p.container}"`), ].join('\n'); } @@ -526,6 +534,15 @@ Options: --public-host=HOST Hostname/IP browsers use to reach this Docker host (default: localhost). Written to .env as PUBLIC_HOST and exposed to setup scripts as SANDBOX_PUBLIC_HOST. + --public-scheme=SCHEME + http (default) or https — the scheme browsers use to + reach the site (https when a TLS proxy fronts the + published ports). Written to .env as PUBLIC_SCHEME and + exposed to setup scripts as SANDBOX_PUBLIC_SCHEME. + --bind=IP Bind published ports (WP + app ports) to this host + interface only, e.g. --bind=127.0.0.1 to keep them off + the network when a reverse proxy fronts them. Default: + all interfaces. Written to .env as WP_BIND=IP:. --scaffold-only Only write files; skip the automatic \`npm run setup\` `); } @@ -632,6 +649,8 @@ async function copyTemplates(srcDir, destDir, vars, skip = new Set()) { .replaceAll('__PROJECT_NAME__', vars.projectName) .replaceAll('__WP_PORT__', vars.port) .replaceAll('__PUBLIC_HOST__', vars.publicHost) + .replaceAll('__PUBLIC_SCHEME__', vars.publicScheme ?? 'http') + .replaceAll('__WP_BIND__', vars.wpBind ?? '') .replaceAll('__APP_PORTS__', vars.appPortsBlock) .replaceAll('__AGENT_NPM_PKGS__', vars.agentNpmPkgs) .replaceAll('__KATALYST_VERSION__', ENGINE_VERSION) @@ -841,6 +860,10 @@ export async function create({ preset = {}, argv = process.argv.slice(2) } = {}) agentNpmPkgs: agents.map((k) => AGENTS[k].pkg).filter(Boolean).map((p) => p + ' ').join(''), port: String(args.port), publicHost: args.publicHost, + publicScheme: args.publicScheme, + // Interface prefix for published ports, colon included (e.g. "127.0.0.1:"). + // Empty = all interfaces (docker default) — the local-dev behavior. + wpBind: args.bindHost ? `${args.bindHost.replace(/:$/, '')}:` : '', appPortsBlock: renderAppPortsBlock(appPorts), wpAdminUser: adminUser, wpAdminPassword: adminPass, diff --git a/create-katalystwp/server/AGENT_USAGE.md b/create-katalystwp/server/AGENT_USAGE.md index 6ed2601..9c12889 100644 --- a/create-katalystwp/server/AGENT_USAGE.md +++ b/create-katalystwp/server/AGENT_USAGE.md @@ -53,7 +53,9 @@ preset). **Poll `GET /environments/` until `status` is `running`.** curl -s -H "Authorization: Bearer $TOK" -X POST "$BASE/environments" \ -d '{"name":"my-devbox"}' # → {"id":"env_…","name":"my-devbox","port":9000,"wpUrl":"http://:9000","status":"scaffolding"} -# (wpUrl and admin-login URLs use the server's DEVBOX_PUBLIC_HOST — directly openable) +# (wpUrl and admin-login URLs use the server's DEVBOX_PUBLIC_HOST — directly +# openable; they're https:// when the operator has TLS in front of the env +# ports. Always use the URLs as returned — don't assume a scheme.) # 2. Poll until running (or failed). Repeat every ~10s. curl -s -H "Authorization: Bearer $TOK" "$BASE/environments/my-devbox" diff --git a/create-katalystwp/server/README.md b/create-katalystwp/server/README.md index 477aae7..84f913f 100644 --- a/create-katalystwp/server/README.md +++ b/create-katalystwp/server/README.md @@ -32,6 +32,7 @@ The server is a **thin orchestrator over the scaffolded project's own scripts**: | `DEVBOX_API_TOKEN` | — | if set, all routes require `Authorization: Bearer ` | | `WP_PORT_RANGE` | `9000-9999` | host ports to allocate from (the env's WP port **and** its app ports) | | `DEVBOX_PUBLIC_HOST` | `localhost` | hostname/IP browsers use to reach this Docker host (your server's public IP / DNS name) — used in every returned URL (`wpUrl`, admin-login `loginUrl`) and passed to the scaffolder as `--public-host` so setup scripts can build browser-valid URLs | +| `DEVBOX_PUBLIC_SCHEME` | `http` | `https` when a TLS proxy fronts the env port range (see **HTTPS on a bare IP**) — new envs then bind published ports to loopback and get `https://` URLs; recorded per env, so pre-switch envs keep http | | `SANDBOX_SETUP_ENV_*` | — | setup secrets forwarded to every env's setup script with the prefix stripped (see above) | | `MAX_ENVIRONMENTS` | `25` | hard cap on environments | | `BUILD_CONCURRENCY` | `2` | simultaneous `docker build`/setup runs | @@ -143,9 +144,18 @@ renews them. Bind the server to loopback (`DEVBOX_BIND=127.0.0.1`, reachable for ACME validation. SSE session streams work through the proxy unchanged. -This covers the control plane (API/MCP/UI/token). The per-env WordPress sites -on their own ports remain plain HTTP until they're proxied too (requires -compose port rebinding + WP siteurl scheme changes — tracked separately). +This covers the control plane (API/MCP/UI/token). To serve the **per-env +WordPress sites** over HTTPS too, the same IP certificate applies (it's valid +for any port): generate one Caddy site block for the whole env port range with +[`deploy/gen-env-sites.sh`](deploy/gen-env-sites.sh) (TLS on every range port, +proxied port-for-port to loopback) and set `DEVBOX_PUBLIC_SCHEME=https`. New +envs are then scaffolded with published ports bound to `127.0.0.1` (Caddy owns +the public side), a forwarded-proto shim in wp-config, and `https://` wpUrl / +admin-login links. The scheme is recorded **per env** at creation, so envs +built before the switch keep their working plain-http URLs (and their public +`0.0.0.0` port binds) until migrated — pick a proxy range slice that doesn't +overlap their ports. Warm pools bake the scaffold at build time: rebuild them +after switching. ## Web UI diff --git a/create-katalystwp/server/deploy/Caddyfile.example b/create-katalystwp/server/deploy/Caddyfile.example index 1087b90..3df1f05 100644 --- a/create-katalystwp/server/deploy/Caddyfile.example +++ b/create-katalystwp/server/deploy/Caddyfile.example @@ -17,14 +17,29 @@ # claude mcp add --transport http katalyst https://:4000/mcp \ # --header "Authorization: Bearer $DEVBOX_API_TOKEN" # -# Note: this secures the CONTROL PLANE (API token, MCP, session streams, UI). -# The per-env WordPress sites on their own ports remain plain HTTP until they -# are proxied too (bigger change: compose port rebinding + WP siteurl scheme). +# This secures the CONTROL PLANE (API token, MCP, session streams, UI). +# +# To ALSO serve the per-env WordPress sites over HTTPS (TLS phase 2): the same +# IP certificate is valid for any port, so Caddy can terminate TLS for the +# whole env port range and proxy port-for-port to loopback. Three steps: +# +# 1. gen-env-sites.sh > /etc/caddy/env-sites.caddy +# (match the server's WP_PORT_RANGE), and uncomment the import below. +# 2. In the server env: DEVBOX_PUBLIC_SCHEME=https — new envs then bind +# their published ports to 127.0.0.1 (Caddy owns the public side) and +# get https wpUrl / admin-login links. +# 3. Rebuild any warm pools (they bake the scaffold at build time). +# +# Envs created BEFORE the switch keep publishing their ports on 0.0.0.0 with +# http URLs (their record says so) — pick a range slice for the proxy that +# doesn't overlap ports docker already binds publicly, or migrate those envs. { email you@example.com } +# import /etc/caddy/env-sites.caddy + :4000 { tls { issuer acme { diff --git a/create-katalystwp/server/deploy/gen-env-sites.sh b/create-katalystwp/server/deploy/gen-env-sites.sh new file mode 100755 index 0000000..1c86b6e --- /dev/null +++ b/create-katalystwp/server/deploy/gen-env-sites.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Generate the Caddy site block that serves every env WordPress site over +# HTTPS (TLS phase 2, issue #73): one block listing every port in the env +# range as an address, terminating TLS with the same short-lived Let's Encrypt +# IP certificate the control plane uses, and proxying port-for-port to +# loopback (where https-era envs bind their published ports). +# +# gen-env-sites.sh > /etc/caddy/env-sites.caddy +# +# Then add to /etc/caddy/Caddyfile: import /etc/caddy/env-sites.caddy +# and reload: caddy validate --config /etc/caddy/Caddyfile && systemctl reload caddy +# +# The explicit `bind ` is LOAD-BEARING: without it Caddy listens on +# 0.0.0.0 for each port, which would collide with docker's 127.0.0.1 publishes +# on the same ports (and with the server allocator's loopback port probe). +# Bound to the public IP only, Caddy and docker share each port cleanly. +# +# Keep the range in sync with the server's WP_PORT_RANGE (env sites AND their +# app ports allocate from it). Regenerate + reload after widening the range. +set -euo pipefail + +if [ $# -ne 3 ]; then + echo "usage: $0 " >&2 + exit 1 +fi +IP="$1"; LO="$2"; HI="$3" +if ! [ "$LO" -ge 1 ] || ! [ "$HI" -ge "$LO" ] || ! [ "$HI" -le 65535 ]; then + echo "invalid port range $LO-$HI" >&2 + exit 1 +fi + +echo "# Generated by gen-env-sites.sh $IP $LO $HI — do not edit by hand." +echo "# Katalyst env WordPress sites: TLS for ports $LO-$HI, proxied port-for-port to loopback." +# Caddyfile grammar: the opening brace must sit on the same line as the (last) +# site address. +printf '%s {\n' "$(seq "$LO" "$HI" | sed "s/^/$IP:/" | paste -sd, - | sed 's/,/, /g')" +cat < { const envs = Object.values(data.environments); // Warm-pool builds must leave a reserve of free slots for on-demand creates, @@ -141,6 +141,13 @@ export async function allocate(registry, config, { nameHint, pool = null, appPor // Host-published dev-server ports ({ host, container }), allocated from // the same range as `port`. Empty for envs that don't publish any. appPorts: allocatedAppPorts, + // How browsers reach THIS env: https means it was scaffolded loopback- + // bound behind the TLS proxy. Per-env (not view-time config) because a + // DEVBOX_PUBLIC_SCHEME switch must not lie about envs built before it — + // they stay http until migrated. Records from before this field default + // to http everywhere. A duplicate passes its source's scheme (the copy + // inherits the source's compose/bind on disk). + scheme: scheme || config.publicScheme, // No wpUrl stored: public URLs are built from DEVBOX_PUBLIC_HOST + port // at view time (status.js publicView / ops.js), so a host config change // never leaves stale URLs behind (issue #74). diff --git a/create-katalystwp/server/src/clone.js b/create-katalystwp/server/src/clone.js index 448eea5..cd9d129 100644 --- a/create-katalystwp/server/src/clone.js +++ b/create-katalystwp/server/src/clone.js @@ -44,7 +44,11 @@ export async function rewriteCloneIdentity(dir, { oldName, newName, oldPort, new throw new Error('docker-compose.yml: compose project name line not found — refusing to boot a copy that would clobber the source project'); } for (const { container, oldHost, newHost } of portMap) { + // Two vintages of app-port lines: bare `- "9101:3000"` and bind-aware + // `- "${WP_BIND:-}9101:3000"` (https-era template). Both anchors keep a + // bare number from matching inside an unrelated longer port. out = out.replaceAll(`"${oldHost}:${container}"`, `"${newHost}:${container}"`); + out = out.replaceAll(`:-}${oldHost}:${container}"`, `:-}${newHost}:${container}"`); } return out; }, { required: true }); diff --git a/create-katalystwp/server/src/config.js b/create-katalystwp/server/src/config.js index d3480b4..aa00416 100644 --- a/create-katalystwp/server/src/config.js +++ b/create-katalystwp/server/src/config.js @@ -60,6 +60,13 @@ export function loadConfig(env = process.env) { // scaffolder as --public-host so setup scripts can build browser-valid URLs // (SANDBOX_PUBLIC_HOST). On a remote box set the public IP or a DNS name. publicHost: env.DEVBOX_PUBLIC_HOST || 'localhost', + // Scheme browsers use to reach the ENV SITES (wpUrl, admin-login links). + // 'https' means a TLS-terminating proxy (e.g. Caddy, see deploy/) owns the + // public side of the whole WP_PORT_RANGE — new envs are then scaffolded + // with their ports bound to loopback (--bind=127.0.0.1) so only the proxy + // reaches them. Recorded per env at allocation (record.scheme), so envs + // created before a switch keep working plain-http URLs until migrated. + publicScheme: env.DEVBOX_PUBLIC_SCHEME || 'http', // Allocation / limits portRange: parseRange(env.WP_PORT_RANGE, '9000-9999'), @@ -116,6 +123,10 @@ export function loadConfig(env = process.env) { // Exposing this API to the network means exposing root-equivalent control of // the Docker host. Refuse to bind a non-loopback address without a bearer // token — otherwise anyone who can reach the port can create/destroy envs. + if (!['http', 'https'].includes(config.publicScheme)) { + throw new Error(`Invalid DEVBOX_PUBLIC_SCHEME "${config.publicScheme}" — must be http or https`); + } + const loopback = new Set(['127.0.0.1', '::1', 'localhost']); if (!loopback.has(config.bind) && !config.apiToken) { throw new Error( diff --git a/create-katalystwp/server/src/manager.js b/create-katalystwp/server/src/manager.js index 40585b3..0e4e555 100644 --- a/create-katalystwp/server/src/manager.js +++ b/create-katalystwp/server/src/manager.js @@ -170,6 +170,10 @@ export class Manager { // the scaffolder's default is Claude-only. '--agents=all', `--public-host=${config.publicHost}`, + // https env: the TLS proxy owns the public side of the port range — + // scaffold the published ports loopback-only and tell setup scripts + // (SANDBOX_PUBLIC_SCHEME) to build https URLs. + ...(record.scheme === 'https' ? ['--bind=127.0.0.1', '--public-scheme=https'] : []), ...(record.appPorts?.length ? [`--app-ports=${record.appPorts.map((p) => `${p.host}:${p.container}`).join(',')}`] : []), ...(provisionPlan ? provisionPlan.args : []), ]; @@ -243,6 +247,9 @@ export class Manager { const record = await allocate(this.registry, this.config, { nameHint: name, appPorts: (source.appPorts ?? []).map((p) => p.container), + // The copy is the source's dir verbatim (same compose bind, same shim), + // so it serves whatever scheme the source did — not the current config. + scheme: source.scheme || 'http', }); if (source.preset) await this.registry.update(record.id, { preset: source.preset }); this.jobs.set(record.id, 'setting-up'); diff --git a/create-katalystwp/server/src/ops.js b/create-katalystwp/server/src/ops.js index cff012b..1b11b77 100644 --- a/create-katalystwp/server/src/ops.js +++ b/create-katalystwp/server/src/ops.js @@ -162,8 +162,8 @@ export function buildOps(config, registry, manager, sessions, presets) { // agent-connector ability already installed in every env. WP emits the URL on // its own (in-container) home host, so rebase it to the public host + the // env's published port to make it directly openable (issue #74) — redemption - // uses the request host, so the token is host-agnostic. Plain http until env - // sites are proxied too (TLS phase 2, issue #73). + // uses the request host, so the token is host-agnostic. Scheme follows the + // env record (https for proxy-fronted envs, issue #73). const mintAdminLogin = async (env) => { await assertUsable(env); let res; @@ -177,7 +177,7 @@ export function buildOps(config, registry, manager, sessions, presets) { throw httpErr(502, `admin login link unavailable: ${String(res.stderr || url || '').trim().slice(0, 200)}`); } const u = new URL(url); - return { loginUrl: `http://${config.publicHost}:${env.port}${u.pathname}${u.search}` }; + return { loginUrl: `${env.scheme === 'https' ? 'https' : 'http'}://${config.publicHost}:${env.port}${u.pathname}${u.search}` }; }; // Read a session's event log. @@ -226,9 +226,9 @@ export function buildOps(config, registry, manager, sessions, presets) { const model = typeof body.model === 'string' && body.model.trim() ? body.model.trim() : undefined; const agent = AGENTS[body.agent] ? body.agent : undefined; // first-prompt session agent; else default - // wpUrl from the public host, not the stored record (issue #74) — same as - // publicView in status.js. - const wpUrl = (rec) => `http://${config.publicHost}:${rec.port}`; + // wpUrl: host from config (issue #74), scheme from the record (a warm env + // claimed after an https switch was still built http) — same as publicView. + const wpUrl = (rec) => `${rec.scheme === 'https' ? 'https' : 'http'}://${config.publicHost}:${rec.port}`; if (presetIds.length === 1 && !custom) { const claimed = await manager.claimAndStart(presetIds[0], { name: body.name, prompt: prompt || undefined, model, agent }); @@ -255,7 +255,7 @@ export function buildOps(config, registry, manager, sessions, presets) { name: record.name, port: record.port, appPorts: record.appPorts ?? [], - wpUrl: `http://${config.publicHost}:${record.port}`, + wpUrl: `${record.scheme === 'https' ? 'https' : 'http'}://${config.publicHost}:${record.port}`, status: record.status, duplicatedFrom: source.name, }; diff --git a/create-katalystwp/server/src/presets.js b/create-katalystwp/server/src/presets.js index da6c4f5..79460a3 100644 --- a/create-katalystwp/server/src/presets.js +++ b/create-katalystwp/server/src/presets.js @@ -78,7 +78,7 @@ const FUTURELAYER_SETUP_SCRIPT = `${BREAKDANCE_SETUP_SCRIPT} # ---- FutureLayer app (app-dot-futurelayer) ---- wp option update futurelayer_app_url_override_backend "http://workspace:3000" if [ -n "\${SANDBOX_APP_PORT_3000:-}" ]; then - wp option update futurelayer_app_url_override_browser "http://\${SANDBOX_PUBLIC_HOST:-localhost}:\${SANDBOX_APP_PORT_3000}" + wp option update futurelayer_app_url_override_browser "\${SANDBOX_PUBLIC_SCHEME:-http}://\${SANDBOX_PUBLIC_HOST:-localhost}:\${SANDBOX_APP_PORT_3000}" fi if [ -n "\${LOCAL_DEV_APP_DOT_FUTURELAYER_DOT_ENV_FILE_CONTENTS_BASE64:-}" ]; then @@ -95,7 +95,7 @@ MU_SRC=/home/node/breakdance/.devcontainer/mu-plugin-canonical-upload-urls.php if [ -f "\$MU_SRC" ]; then mkdir -p /home/node/wp/wp-content/mu-plugins cp -f "\$MU_SRC" /home/node/wp/wp-content/mu-plugins/canonical-upload-urls.php - wp config set FUTURELAYER_DEV_CANONICAL_URL "http://\${SANDBOX_PUBLIC_HOST:-localhost}:\${SANDBOX_WP_PORT:-80}" --type=constant + wp config set FUTURELAYER_DEV_CANONICAL_URL "\${SANDBOX_PUBLIC_SCHEME:-http}://\${SANDBOX_PUBLIC_HOST:-localhost}:\${SANDBOX_WP_PORT:-80}" --type=constant fi `; diff --git a/create-katalystwp/server/src/status.js b/create-katalystwp/server/src/status.js index 91e0ec6..6de4a0c 100644 --- a/create-katalystwp/server/src/status.js +++ b/create-katalystwp/server/src/status.js @@ -58,11 +58,13 @@ export function publicView(record, { status, publicHost }) { // Host-published dev-server ports ({ host, container }) — the UI links them // like `port`, rebased on the browser's hostname. appPorts: record.appPorts ?? [], - // Built from DEVBOX_PUBLIC_HOST at view time (not the stored record, which + // Host from DEVBOX_PUBLIC_HOST at view time (not the stored record, which // predates any host config change) so remote clients get a directly - // openable URL, not http://localhost: (issue #74). Plain http until - // env sites are proxied too (TLS phase 2, issue #73). - wpUrl: `http://${publicHost || 'localhost'}:${record.port}`, + // openable URL, not http://localhost: (issue #74). Scheme from the + // RECORD: it reflects how this env was scaffolded (loopback-bound behind + // the TLS proxy, or plain http) — envs from before the https switch keep + // http URLs until migrated (issue #73). + wpUrl: `${record.scheme === 'https' ? 'https' : 'http'}://${publicHost || 'localhost'}:${record.port}`, status, preset: record.preset || null, createdAt: record.createdAt, diff --git a/create-katalystwp/server/ui/app.js b/create-katalystwp/server/ui/app.js index 8434a0f..54c005f 100644 --- a/create-katalystwp/server/ui/app.js +++ b/create-katalystwp/server/ui/app.js @@ -27,10 +27,15 @@ const streamUrl = (id) => { const t = token.get(); return `/sessions/${id}/stream${t ? `?access_token=${encodeURIComponent(t)}` : ''}`; }; -// Env sites (WP + app ports) are plain HTTP even when the control plane is -// served over TLS — always link them http:// until they're proxied too -// (TLS phase 2, issue #73). location.protocol would mint dead https links. -const envSiteUrl = (port, query = '') => `http://${location.hostname}:${port}/${query}`; +// Links to an env's site (WP + app ports): hostname rebased on how THIS +// browser reached the UI (works from a phone/laptop even when the server's +// configured host differs), scheme taken from the env's own wpUrl — per env, +// because envs from before an https switch stay plain http until migrated +// (TLS phase 2, issue #73). location.protocol would mint dead links for them. +const envSiteUrl = (env, port, query = '') => { + const scheme = String(env.wpUrl || '').startsWith('https:') ? 'https' : 'http'; + return `${scheme}://${location.hostname}:${port}/${query}`; +}; // ---- stream-json → transcript items -------------------------------------- function reduce(items, partialRef, evt) { @@ -92,14 +97,14 @@ function EnvRow({ env, onAction }) { // Link to the WP site on the SAME host the UI was loaded from (not the // server's localhost wpUrl) — so it works from a phone/laptop hitting the // server's IP, and still works from inside the devbox via localhost. - const wpUrl = envSiteUrl(env.port); + const wpUrl = envSiteUrl(env, env.port); return html`
<${StatusDot} status=${env.status} /> ${env.displayName || env.name} ${env.preset && html`${env.preset}`} e.stopPropagation()}>:${env.port} - ${(env.appPorts || []).map((p) => html` e.stopPropagation()}>:${p.host}→${p.container}`)} + ${(env.appPorts || []).map((p) => html` e.stopPropagation()}>:${p.host}→${p.container}`)} ${up && html``}
@@ -1213,7 +1218,7 @@ function App() { try { const { loginUrl } = await api(`/environments/${env.id}/admin-login`, { method: 'POST' }); const u = new URL(loginUrl); - const dest = envSiteUrl(env.port, `?${u.searchParams.toString()}`); + const dest = envSiteUrl(env, env.port, `?${u.searchParams.toString()}`); if (w) w.location = dest; else window.open(dest, '_blank', 'noopener'); } catch (e) { if (w) w.close(); alert(`Admin login failed: ${e.message}`); } return; diff --git a/create-katalystwp/templates/docker-compose.yml b/create-katalystwp/templates/docker-compose.yml index 42b846d..0b0c48e 100644 --- a/create-katalystwp/templates/docker-compose.yml +++ b/create-katalystwp/templates/docker-compose.yml @@ -41,6 +41,12 @@ services: # the Docker network (http://wordpress — used by the Playwright browser). # ($$ is escaped to a literal $ for the generated wp-config.php.) WORDPRESS_CONFIG_EXTRA: | + // Behind a TLS-terminating reverse proxy (PUBLIC_SCHEME=https in .env), + // Apache sees plain HTTP — trust the proxy's X-Forwarded-Proto so the + // scheme detection below (and is_ssl()) yields https URLs. + if ( isset( $$_SERVER['HTTP_X_FORWARDED_PROTO'] ) && 'https' === $$_SERVER['HTTP_X_FORWARDED_PROTO'] ) { + $$_SERVER['HTTPS'] = 'on'; + } if ( ! empty( $$_SERVER['HTTP_HOST'] ) ) { $$scheme = ( ! empty( $$_SERVER['HTTPS'] ) && $$_SERVER['HTTPS'] !== 'off' ) ? 'https' : 'http'; define( 'WP_HOME', $$scheme . '://' . $$_SERVER['HTTP_HOST'] ); @@ -54,7 +60,10 @@ services: // sandbox, and the web container can read/write the bind-mounted tree. define( 'FS_METHOD', 'direct' ); ports: - - "${WP_PORT}:80" + # WP_BIND ("127.0.0.1:" or empty, from .env) restricts the published port + # to one host interface — loopback-only when a reverse proxy owns the + # public side. + - "${WP_BIND:-}${WP_PORT}:80" volumes: - ./workspace/wp:/var/www/html - ./php/php.ini:/usr/local/etc/php/conf.d/php.ini:ro # custom PHP overrides (upload limits, etc.) diff --git a/create-katalystwp/templates/env.example b/create-katalystwp/templates/env.example index 600a36e..f47ebae 100644 --- a/create-katalystwp/templates/env.example +++ b/create-katalystwp/templates/env.example @@ -11,6 +11,17 @@ WP_PORT=__WP_PORT__ # browser). Purely informational — nothing binds to it. PUBLIC_HOST=__PUBLIC_HOST__ +# Scheme browsers use to reach the site: http, or https when a TLS-terminating +# reverse proxy fronts the published ports (set via --public-scheme). Exposed +# to setup scripts as SANDBOX_PUBLIC_SCHEME. Purely informational, like +# PUBLIC_HOST — the containers themselves always speak plain HTTP. +PUBLIC_SCHEME=__PUBLIC_SCHEME__ + +# Host-interface prefix for the published ports (WP + app ports), COLON +# INCLUDED: "127.0.0.1:" binds loopback only (a reverse proxy owns the public +# side); empty binds all interfaces (plain local dev). Set via --bind. +WP_BIND=__WP_BIND__ + # WordPress site title (Settings → General). Defaults to this project's name; # edit to taste, then `npm run reset` to apply. WP_SITE_TITLE="__PROJECT_NAME__" diff --git a/create-katalystwp/templates/scripts/run-setup-script.sh b/create-katalystwp/templates/scripts/run-setup-script.sh index 69f3f7a..0cba1c8 100644 --- a/create-katalystwp/templates/scripts/run-setup-script.sh +++ b/create-katalystwp/templates/scripts/run-setup-script.sh @@ -51,12 +51,16 @@ done # Tell the setup script where this environment lives, so it can build URLs that # are valid outside the Docker network (dev-app browser URLs, canonical hosts): # SANDBOX_PUBLIC_HOST — PUBLIC_HOST from .env (--public-host) +# SANDBOX_PUBLIC_SCHEME — PUBLIC_SCHEME from .env (--public-scheme); +# http, or https behind a TLS proxy # SANDBOX_WP_PORT — the site's published host port # SANDBOX_APP_PORT_ — host port for each --app-ports entry # All are exported by name (-e NAME), so values stay off the command line. export SANDBOX_PUBLIC_HOST="$(grep -E '^PUBLIC_HOST=' .env | head -1 | cut -d= -f2-)" +SANDBOX_PUBLIC_SCHEME="$(grep -E '^PUBLIC_SCHEME=' .env | head -1 | cut -d= -f2-)" +export SANDBOX_PUBLIC_SCHEME="${SANDBOX_PUBLIC_SCHEME:-http}" export SANDBOX_WP_PORT="$(grep -E '^WP_PORT=' .env | head -1 | cut -d= -f2-)" -exec_args+=(-e SANDBOX_PUBLIC_HOST -e SANDBOX_WP_PORT) +exec_args+=(-e SANDBOX_PUBLIC_HOST -e SANDBOX_PUBLIC_SCHEME -e SANDBOX_WP_PORT) while IFS= read -r pair; do [ -n "$pair" ] || continue export "SANDBOX_APP_PORT_${pair%%=*}=${pair#*=}" From db1711594cb420df969e6d48cbb4c3bed8a8cf1e Mon Sep 17 00:00:00 2001 From: Louis Reingold Date: Sat, 29 Aug 2026 03:47:04 +0000 Subject: [PATCH 2/4] fix(server/deploy): env-sites block must not redeclare the tls automation policy --- create-katalystwp/server/deploy/gen-env-sites.sh | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/create-katalystwp/server/deploy/gen-env-sites.sh b/create-katalystwp/server/deploy/gen-env-sites.sh index 1c86b6e..9b9ca85 100755 --- a/create-katalystwp/server/deploy/gen-env-sites.sh +++ b/create-katalystwp/server/deploy/gen-env-sites.sh @@ -15,6 +15,13 @@ # on the same ports (and with the server allocator's loopback port probe). # Bound to the public IP only, Caddy and docker share each port cleanly. # +# No `tls` directive here ON PURPOSE: the control-plane site block +# (Caddyfile.example) already declares the shortlived-issuer automation policy +# for this same IP, and Caddy rejects a second policy for one hostname +# ("appears in more than one automation policy"). The env sites share the +# control plane's certificate. This import therefore REQUIRES the control-plane +# block in the same Caddyfile. +# # Keep the range in sync with the server's WP_PORT_RANGE (env sites AND their # app ports allocate from it). Regenerate + reload after widening the range. set -euo pipefail @@ -36,11 +43,6 @@ echo "# Katalyst env WordPress sites: TLS for ports $LO-$HI, proxied port-for-po printf '%s {\n' "$(seq "$LO" "$HI" | sed "s/^/$IP:/" | paste -sd, - | sed 's/,/, /g')" cat < Date: Sat, 29 Aug 2026 03:53:33 +0000 Subject: [PATCH 3/4] fix(engine): port-free check follows --bind interface (proxy holds the public side) --- create-katalystwp/engine.js | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/create-katalystwp/engine.js b/create-katalystwp/engine.js index 18e2c7d..e0b3aee 100644 --- a/create-katalystwp/engine.js +++ b/create-katalystwp/engine.js @@ -143,13 +143,16 @@ async function recordEnvironment(env) { await saveState(state); } -// Can we listen on this port? (Docker-published ports bind 0.0.0.0, so they — -// and any other host listener — make this return false.) -function portFree(port) { +// Can we listen on this port? Checks the interface the scaffolded stack will +// actually publish on: 0.0.0.0 by default (docker-published ports bind +// wildcard, so any host listener makes this false), or the --bind interface — +// with --bind=127.0.0.1 behind a reverse proxy, the proxy's own listener on +// the PUBLIC address of this port must not count as a conflict. +function portFree(port, host = '0.0.0.0') { return new Promise((res) => { const srv = createServer(); srv.once('error', () => res(false)); - srv.listen({ port, host: '0.0.0.0', exclusive: true }, () => srv.close(() => res(true))); + srv.listen({ port, host, exclusive: true }, () => srv.close(() => res(true))); }); } @@ -167,10 +170,10 @@ function portInUse(port) { // First port >= `start` that is free on the host AND not claimed by a // registered environment (which may just be stopped right now). -async function findFreePort(start, claimed) { +async function findFreePort(start, claimed, host = '0.0.0.0') { for (let p = start; p < start + 1000; p++) { if (claimed.has(p)) continue; - if (await portFree(p)) return p; + if (await portFree(p, host)) return p; } return start; // pathological — let Docker surface the error } @@ -788,18 +791,20 @@ export async function create({ preset = {}, argv = process.argv.slice(2) } = {}) // environment (which may just be stopped right now). const state = await loadState(); const claimed = new Set(state.environments.map((e) => Number(e.port))); + // Check the interface the stack will publish on (see portFree). + const bindIface = args.bindHost ? args.bindHost.replace(/:$/, '') : '0.0.0.0'; let port; if (args.portExplicit) { port = String(args.port); const n = parseInt(port, 10); - if (!(await portFree(n))) { + if (!(await portFree(n, bindIface))) { const holder = state.environments.find((e) => Number(e.port) === n); console.error(`\n✖ Port ${n} is already in use${holder ? ` by your "${holder.name}" environment (${holder.dir})` : ''}.`); - console.error(` Free alternative: --port=${await findFreePort(n + 1, claimed)}${holder ? `, or stop that environment: cd ${holder.dir} && npm run stop` : ''}\n`); + console.error(` Free alternative: --port=${await findFreePort(n + 1, claimed, bindIface)}${holder ? `, or stop that environment: cd ${holder.dir} && npm run stop` : ''}\n`); process.exit(1); } } else { - port = String(await findFreePort(parseInt(args.port, 10) || 8080, claimed)); + port = String(await findFreePort(parseInt(args.port, 10) || 8080, claimed, bindIface)); } // Only two questions: directory and agents. Port is auto-picked (visible in From ed36eed6f28610161286854b32f1e58c6aedfcdd Mon Sep 17 00:00:00 2001 From: Louis Reingold Date: Sat, 29 Aug 2026 04:58:34 +0000 Subject: [PATCH 4/4] feat(server/deploy): domain support for env-sites TLS (multi-host generator, global cert_issuer) --- .../server/deploy/Caddyfile.example | 30 ++++++--- .../server/deploy/gen-env-sites.sh | 63 ++++++++++++------- 2 files changed, 61 insertions(+), 32 deletions(-) diff --git a/create-katalystwp/server/deploy/Caddyfile.example b/create-katalystwp/server/deploy/Caddyfile.example index 3df1f05..180423e 100644 --- a/create-katalystwp/server/deploy/Caddyfile.example +++ b/create-katalystwp/server/deploy/Caddyfile.example @@ -19,12 +19,13 @@ # # This secures the CONTROL PLANE (API token, MCP, session streams, UI). # -# To ALSO serve the per-env WordPress sites over HTTPS (TLS phase 2): the same -# IP certificate is valid for any port, so Caddy can terminate TLS for the -# whole env port range and proxy port-for-port to loopback. Three steps: +# To ALSO serve the per-env WordPress sites over HTTPS (TLS phase 2): a +# certificate is valid for any port, so Caddy can terminate TLS for the whole +# env port range and proxy port-for-port to loopback. Three steps: # -# 1. gen-env-sites.sh > /etc/caddy/env-sites.caddy -# (match the server's WP_PORT_RANGE), and uncomment the import below. +# 1. gen-env-sites.sh - > /etc/caddy/env-sites.caddy +# (match the server's WP_PORT_RANGE; hosts = the bare IP, and/or a domain +# pointed at it), and uncomment the import below. # 2. In the server env: DEVBOX_PUBLIC_SCHEME=https — new envs then bind # their published ports to 127.0.0.1 (Caddy owns the public side) and # get https wpUrl / admin-login links. @@ -33,19 +34,28 @@ # Envs created BEFORE the switch keep publishing their ports on 0.0.0.0 with # http URLs (their record says so) — pick a range slice for the proxy that # doesn't overlap ports docker already binds publicly, or migrate those envs. +# +# With a DOMAIN (A records for @ and * → this box), list it alongside the IP +# in the site addresses below and in gen-env-sites.sh's argument — +# both keep working, and DEVBOX_PUBLIC_HOST= puts it in returned URLs. { email you@example.com + # Let's Encrypt "shortlived" ACME profile (~6-day certs, Caddy auto-renews). + # Global (cert_issuer) rather than per-site tls blocks: the bare-IP cert + # REQUIRES this profile, and per-site tls directives make Caddy reject the + # config when the same hostname spans site blocks with different host sets + # ("appears in more than one automation policy"). Domain certs are simply + # short-lived too. + cert_issuer acme { + profile shortlived + } } # import /etc/caddy/env-sites.caddy +# Control plane. With a domain, list both: :4000, :4000 { :4000 { - tls { - issuer acme { - profile shortlived - } - } # JSON payloads (session transcripts, env lists) compress ~10x; Caddy # skips compression for SSE streams automatically. encode zstd gzip diff --git a/create-katalystwp/server/deploy/gen-env-sites.sh b/create-katalystwp/server/deploy/gen-env-sites.sh index 9b9ca85..495130c 100755 --- a/create-katalystwp/server/deploy/gen-env-sites.sh +++ b/create-katalystwp/server/deploy/gen-env-sites.sh @@ -1,46 +1,65 @@ #!/usr/bin/env bash # Generate the Caddy site block that serves every env WordPress site over -# HTTPS (TLS phase 2, issue #73): one block listing every port in the env -# range as an address, terminating TLS with the same short-lived Let's Encrypt -# IP certificate the control plane uses, and proxying port-for-port to -# loopback (where https-era envs bind their published ports). +# HTTPS (TLS phase 2, issue #73): one block listing every host:port combination +# as an address, terminating TLS with certificates from the control-plane +# block's automation policy, and proxying port-for-port to loopback (where +# https-era envs bind their published ports). # -# gen-env-sites.sh > /etc/caddy/env-sites.caddy +# gen-env-sites.sh ... > /etc/caddy/env-sites.caddy +# +# The droplet's public IP — the interface Caddy listens on. +# Hostname(s) browsers use: a domain, the bare IP, or both +# (comma-separated) to keep old IP bookmarks working, e.g. +# "6047box.com,174.138.43.144". +# A range LO-HI or a single PORT; repeat to cover the allocator +# range PLUS individually migrated legacy envs whose old ports sit +# outside it, e.g.: gen-env-sites.sh 1.2.3.4 example.com,1.2.3.4 9100-9299 9047 # # Then add to /etc/caddy/Caddyfile: import /etc/caddy/env-sites.caddy # and reload: caddy validate --config /etc/caddy/Caddyfile && systemctl reload caddy # -# The explicit `bind ` is LOAD-BEARING: without it Caddy listens on +# The explicit `bind ` is LOAD-BEARING: without it Caddy listens on # 0.0.0.0 for each port, which would collide with docker's 127.0.0.1 publishes # on the same ports (and with the server allocator's loopback port probe). # Bound to the public IP only, Caddy and docker share each port cleanly. # -# No `tls` directive here ON PURPOSE: the control-plane site block -# (Caddyfile.example) already declares the shortlived-issuer automation policy -# for this same IP, and Caddy rejects a second policy for one hostname -# ("appears in more than one automation policy"). The env sites share the -# control plane's certificate. This import therefore REQUIRES the control-plane -# block in the same Caddyfile. +# No `tls` directive here ON PURPOSE: the Caddyfile's GLOBAL options block +# (Caddyfile.example) sets `cert_issuer acme { profile shortlived }`, which +# every site inherits. Per-site tls directives are a trap: Caddy rejects the +# config when a hostname spans site blocks with different host sets +# ("appears in more than one automation policy"). This import therefore +# REQUIRES that global cert_issuer in the importing Caddyfile. # # Keep the range in sync with the server's WP_PORT_RANGE (env sites AND their # app ports allocate from it). Regenerate + reload after widening the range. set -euo pipefail -if [ $# -ne 3 ]; then - echo "usage: $0 " >&2 - exit 1 -fi -IP="$1"; LO="$2"; HI="$3" -if ! [ "$LO" -ge 1 ] || ! [ "$HI" -ge "$LO" ] || ! [ "$HI" -le 65535 ]; then - echo "invalid port range $LO-$HI" >&2 +if [ $# -lt 3 ]; then + echo "usage: $0 ... (port-spec: LO-HI or PORT)" >&2 exit 1 fi +IP="$1"; HOSTS="$2"; shift 2 +PORTS="" +for spec in "$@"; do + case "$spec" in + *-*) LO="${spec%-*}"; HI="${spec#*-}" ;; + *) LO="$spec"; HI="$spec" ;; + esac + if ! [ "$LO" -ge 1 ] || ! [ "$HI" -ge "$LO" ] || ! [ "$HI" -le 65535 ]; then + echo "invalid port spec \"$spec\"" >&2 + exit 1 + fi + PORTS="$PORTS$(seq "$LO" "$HI")"$'\n' +done +PORTS="$(printf '%s' "$PORTS" | sort -un)" + +ADDRS="$(for h in ${HOSTS//,/ }; do printf '%s\n' "$PORTS" | sed "s/^/$h:/"; done | paste -sd, - | sed 's/,/, /g')" -echo "# Generated by gen-env-sites.sh $IP $LO $HI — do not edit by hand." -echo "# Katalyst env WordPress sites: TLS for ports $LO-$HI, proxied port-for-port to loopback." +echo "# Generated by gen-env-sites.sh $IP $HOSTS $* — do not edit by hand." +echo "# Katalyst env WordPress sites: TLS for ports $* on $HOSTS, proxied port-for-port to loopback." # Caddyfile grammar: the opening brace must sit on the same line as the (last) # site address. -printf '%s {\n' "$(seq "$LO" "$HI" | sed "s/^/$IP:/" | paste -sd, - | sed 's/,/, /g')" +printf '%s {\n' "$ADDRS" cat <