Skip to content
Open
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
52 changes: 40 additions & 12 deletions create-katalystwp/engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
});
}

Expand All @@ -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
}
Expand Down Expand Up @@ -305,6 +308,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',
Expand Down Expand Up @@ -386,7 +391,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);
Expand All @@ -399,6 +404,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;
Expand Down Expand Up @@ -457,9 +467,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');
}

Expand Down Expand Up @@ -526,6 +537,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\`
`);
}
Expand Down Expand Up @@ -632,6 +652,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)
Expand Down Expand Up @@ -769,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
Expand Down Expand Up @@ -841,6 +865,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,
Expand Down
4 changes: 3 additions & 1 deletion create-katalystwp/server/AGENT_USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@ preset). **Poll `GET /environments/<name>` 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://<host>: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"
Expand Down
16 changes: 13 additions & 3 deletions create-katalystwp/server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>` |
| `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 |
Expand Down Expand Up @@ -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

Expand Down
41 changes: 33 additions & 8 deletions create-katalystwp/server/deploy/Caddyfile.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,45 @@
# claude mcp add --transport http katalyst https://<your-ip>: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): 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 <your-ip> <host[,host...]> <lo>-<hi> > /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.
# 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.
#
# 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 <host,...> argument —
# both keep working, and DEVBOX_PUBLIC_HOST=<domain> 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: <your-ip>:4000, <your-domain>:4000 {
<your-ip>:4000 {
tls {
issuer acme {
profile shortlived
}
}
# JSON payloads (session transcripts, env lists) compress ~10x; Caddy
# skips compression for SSE streams automatically.
encode zstd gzip
Expand Down
68 changes: 68 additions & 0 deletions create-katalystwp/server/deploy/gen-env-sites.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
#!/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 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 <bind-ip> <host[,host...]> <port-spec>... > /etc/caddy/env-sites.caddy
#
# <bind-ip> The droplet's public IP — the interface Caddy listens on.
# <host,...> 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".
# <port-spec> 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 <bind-ip>` 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 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 [ $# -lt 3 ]; then
echo "usage: $0 <bind-ip> <host[,host...]> <port-spec>... (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 $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' "$ADDRS"
cat <<EOF
bind $IP
encode zstd gzip
reverse_proxy 127.0.0.1:{http.request.local.port}
}
EOF
9 changes: 8 additions & 1 deletion create-katalystwp/server/src/allocator.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ async function freeDiskGb(path) {
// provisioning wants published (e.g. [3000] for a Next.js dev server); each
// gets a unique HOST port from the same range as the WP port, recorded as
// record.appPorts = [{ host, container }].
export async function allocate(registry, config, { nameHint, pool = null, appPorts = [] } = {}) {
export async function allocate(registry, config, { nameHint, pool = null, appPorts = [], scheme = null } = {}) {
return registry.mutate(async (data) => {
const envs = Object.values(data.environments);
// Warm-pool builds must leave a reserve of free slots for on-demand creates,
Expand Down Expand Up @@ -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).
Expand Down
4 changes: 4 additions & 0 deletions create-katalystwp/server/src/clone.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
Loading