From 481e7f661350464ee3f32d6a4e0b417a67fb76d9 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Mon, 18 May 2026 16:57:02 -0400 Subject: [PATCH 1/6] feat: close port 8000 with ORIGIN_URL and MCP_PORT_CIDRS env vars Add two new env vars to sst.config.ts for optional port 8000 hardening: - ORIGIN_URL: when set, API Gateway routes through this URL (tunnel, Caddy, or any HTTPS frontend) instead of directly to the Lightsail IP. - MCP_PORT_CIDRS: controls port 8000 on the Lightsail firewall, same format as SSH_CIDRS. "none" maps to a non-routable CIDR (RFC 5737). Together they close the plaintext HTTP exposure on port 8000. Forkers who don't configure either get identical behavior to today. Extracted parseCidrs helper shared by SSH and MCP firewall logic. Updated deploy pipeline, DEPLOY.md (full Cloudflare Tunnel walkthrough with VM recovery procedure), ARCHITECTURE.md, and .env.example. Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 13 ++++ .github/workflows/deploy.yml | 2 + ARCHITECTURE.md | 13 +++- DEPLOY.md | 123 ++++++++++++++++++++++++++++++++++- sst.config.ts | 58 ++++++++++++----- 5 files changed, 189 insertions(+), 20 deletions(-) diff --git a/.env.example b/.env.example index 24caeaa..aabdb9e 100644 --- a/.env.example +++ b/.env.example @@ -47,6 +47,19 @@ TZ=UTC # AWS region — only used by SST deploys (sst.config.ts). Ignored by Docker containers. # AWS_REGION=us-east-1 +# ORIGIN_URL — Optional. When set, API Gateway routes through this URL +# instead of directly to the Lightsail IP on port 8000. Use with a +# Cloudflare Tunnel, Caddy reverse proxy, or any HTTPS frontend. +# Example: https://o1.yourdomain.dev +# Default (unset): http://:8000 +# ORIGIN_URL= + +# MCP_PORT_CIDRS — Controls port 8000 on the Lightsail firewall. +# "none" = non-routable CIDR (blocks all public access, use with ORIGIN_URL) +# Comma-separated CIDRs = restrict to specific IPs +# Default (unset): 0.0.0.0/0 (open to all) +# MCP_PORT_CIDRS=none + # vault-cortex configuration # Memory folder name in your vault (default: "About Me") MEMORY_DIR=About Me diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0e5e1a7..6101970 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -40,6 +40,8 @@ jobs: env: SSH_PUBKEY: ${{ secrets.SSH_PUBKEY }} SSH_CIDRS: ${{ vars.SSH_CIDRS }} + ORIGIN_URL: ${{ vars.ORIGIN_URL }} + MCP_PORT_CIDRS: ${{ vars.MCP_PORT_CIDRS }} run: npx sst deploy --stage "$SST_STAGE" - name: Connect to Tailscale diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e673987..5f2226f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -308,9 +308,16 @@ generator extracts the real client IP from API Gateway's `Forwarded` header (express-rate-limit's built-in validators are disabled — they assume direct-to-server traffic, not reverse-proxy deployments). -**Why both layers:** Lightsail port 8000 is publicly bound. If the API Gateway -authorizer is misconfigured, or someone hits the public IP directly, Express -still rejects. `/healthz` bypasses auth for docker-compose healthchecks. +**Why both layers:** Lightsail port 8000 is publicly bound by default. If the +API Gateway authorizer is misconfigured, or someone hits the public IP +directly, Express still rejects. `/healthz` bypasses auth for docker-compose +healthchecks. + +**Optional: close port 8000.** Set `ORIGIN_URL` to route API Gateway through +a tunnel or reverse proxy (e.g., Cloudflare Tunnel), then set +`MCP_PORT_CIDRS=none` to block direct access. With this configuration, bearer +tokens never travel in plaintext on any network segment — all traffic is +HTTPS end-to-end. See [`DEPLOY.md`](./DEPLOY.md#port-8000-hardening-optional). **Rotation:** Update the SST secret AND the Lightsail `.env`, then redeploy both. Existing JWTs signed with the old key become invalid immediately. diff --git a/DEPLOY.md b/DEPLOY.md index 5254e41..3162f15 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -445,11 +445,132 @@ aws lightsail put-instance-public-ports \ --- +## Port 8000 Hardening (Optional) + +By default, port 8000 is open to all IPs on the Lightsail firewall. API Gateway provides TLS for MCP client traffic, but port 8000 itself is plain HTTP — anyone who discovers the Lightsail IP (via scanning, Shodan, or historical records) can reach it directly. With `ORIGIN_URL` and `MCP_PORT_CIDRS`, you can route API Gateway through a tunnel or reverse proxy and close port 8000 entirely. + +### How it works + +`ORIGIN_URL` tells API Gateway where to route MCP traffic. When set, API Gateway sends requests to this URL instead of `http://:8000`. The URL can be a Cloudflare Tunnel, Caddy reverse proxy, Tailscale Funnel, or any HTTPS frontend that proxies to `localhost:8000` on the Lightsail instance. + +`MCP_PORT_CIDRS` controls port 8000 on the Lightsail firewall — same format as `SSH_CIDRS`. Set to `none` to block all direct access (traffic flows through the tunnel/proxy instead). + +Together: `ORIGIN_URL` provides the alternative path, `MCP_PORT_CIDRS=none` closes the direct path. + +### Example: Cloudflare Tunnel + +Cloudflare Tunnel (`cloudflared`) establishes an outbound-only connection from the Lightsail host to Cloudflare's edge. No inbound ports required — port 8000 is removed from the firewall entirely. The tunnel hostname serves as the HTTPS endpoint. + +**Prerequisites:** A free [Cloudflare account](https://dash.cloudflare.com/sign-up) with at least one domain using Cloudflare's nameservers. The tunnel routes through a subdomain on that domain (e.g., `o1.yourdomain.dev`). + +**1. Create a tunnel** in the Cloudflare dashboard: + +Go to [Zero Trust](https://one.dash.cloudflare.com/) → Networks → Tunnels → Create a tunnel → Cloudflared → name it (e.g., `vault-cortex`). Cloudflare generates a tunnel token. + +**2. Install `cloudflared` on the Lightsail VM** (one-time, via SSH): + +```bash +curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg \ + | sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null + +echo "deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] \ + https://pkg.cloudflare.com/cloudflared $(lsb_release -cs) main" \ + | sudo tee /etc/apt/sources.list.d/cloudflared.list + +sudo apt-get update && sudo apt-get install -y cloudflared +sudo cloudflared service install +``` + +After install, `cloudflared` runs as a systemd service, survives reboots, and is invisible to the Docker stack. + +**3. Configure the tunnel route** in the Cloudflare dashboard: + +In the tunnel's Public Hostname tab, add a route: + +- Subdomain: your chosen subdomain (e.g., `o1`) +- Domain: select your Cloudflare-managed domain +- Service: `http://localhost:8000` + +**4. Verify the tunnel** before closing port 8000: + +```bash +curl https://./healthz +# Should return 200 OK +``` + +**5. Close port 8000** — set `ORIGIN_URL` and `MCP_PORT_CIDRS=none`, then deploy: + +```bash +ORIGIN_URL=https://. MCP_PORT_CIDRS=none npx sst deploy +``` + +**6. Verify port 8000 is closed:** + +```bash +# Direct access — should timeout (port closed on firewall) +curl --connect-timeout 5 http://:8000/healthz + +# API Gateway — should return 200 (routed through tunnel) +curl https:///healthz +``` + +### MCP_PORT_CIDRS reference + +`MCP_PORT_CIDRS` accepts any of these values: + +| Value | Effect | +| ------------------------------ | ---------------------------------------------------------- | +| (unset) | `0.0.0.0/0` — open to all (default, backward-compat) | +| `none` | Port 8000 set to non-routable CIDR (use with `ORIGIN_URL`) | +| `203.0.113.42/32` | Single IP | +| `203.0.113.0/24,198.51.100/24` | Multiple CIDRs (comma-separated) | + +### Fresh VM bootstrap + +If the VM is replaced (bundle upgrade for Phase 2, key rotation) and `MCP_PORT_CIDRS=none`, port 8000 is closed — but `cloudflared` isn't running on the new VM yet. Recovery: + +1. Temporarily open both ports and disable tunnel routing: + ```bash + SSH_CIDRS=0.0.0.0/0 ORIGIN_URL= MCP_PORT_CIDRS=0.0.0.0/0 npx sst deploy + ``` +2. SSH in via public IP, install Tailscale (see [SSH Hardening](#ssh-hardening-with-tailscale-optional)) +3. Install `cloudflared` and register with the existing tunnel token: + ```bash + sudo apt-get update && sudo apt-get install -y cloudflared + sudo cloudflared service install + ``` +4. Verify the tunnel: `curl https://./healthz` +5. Re-harden: + ```bash + SSH_CIDRS=none ORIGIN_URL=https://. MCP_PORT_CIDRS=none npx sst deploy + ``` + +The tunnel token doesn't change when the VM is replaced — it's tied to the Cloudflare tunnel resource, not the host. + +### Rollback + +To revert to direct port 8000 access at any time: + +```bash +# Remove ORIGIN_URL and re-open port 8000 (no SSH needed — runs from your laptop) +ORIGIN_URL= MCP_PORT_CIDRS=0.0.0.0/0 npx sst deploy +``` + +Or via AWS CLI for immediate firewall change (overwritten on next SST deploy): + +```bash +aws lightsail put-instance-public-ports \ + --instance-name vault-cortex- \ + --port-infos '[{"protocol":"tcp","fromPort":22,"toPort":22,"cidrs":["0.0.0.0/0"]},{"protocol":"tcp","fromPort":8000,"toPort":8000,"cidrs":["0.0.0.0/0"]}]' +``` + +--- + ## Troubleshooting - **`npm run build` fails with `Property 'McpAuthToken' does not exist`** — `sst-env.d.ts` hasn't been generated. Run `npx sst deploy` (or `sst dev`) once for your stage. - **`sst dev` errors with `SecretMissingError`** — set the three secrets first (one-time setup step 2). -- **`curl ` hangs** — use `:8000`. The firewall only allows ports 22 and 8000 by default (port 22 may be closed if `SSH_CIDRS=none`). +- **`curl ` hangs** — use `:8000`. The firewall only allows ports 22 and 8000 by default (port 22 may be closed if `SSH_CIDRS=none`, port 8000 may be closed if `MCP_PORT_CIDRS=none`). - **`scp` / `ssh` fails with `Permission denied (publickey)`** — your local SSH key doesn't match what SST deployed to the Lightsail KeyPair. Verify `~/.ssh/vault-cortex` exists (generate with `ssh-keygen -t ed25519 -f ~/.ssh/vault-cortex -C vault-cortex-deploy -N ""`), then redeploy. To also use your personal key, add it post-provision: `ssh -i ~/.ssh/vault-cortex ubuntu@ "cat >> ~/.ssh/authorized_keys" < ~/.ssh/id_ed25519.pub`. - **`docker: command not found` on `lightsail:up`** — cloud-init hasn't finished installing Docker. The script waits up to 120s automatically; if it still times out, SSH in and check `tail /var/log/cloud-init-output.log`. - **Host key changed warning** — the Lightsail instance was replaced (e.g. `userData` changed in `sst.config.ts`). The deploy key convention prevents key-change replacements, but other properties can still trigger it. Run `ssh-keygen -R ` and retry. diff --git a/sst.config.ts b/sst.config.ts index e9b5544..507ff20 100644 --- a/sst.config.ts +++ b/sst.config.ts @@ -33,6 +33,15 @@ export default $config({ // Set to "none" to block public SSH (Tailscale-only). const sshCidrs = env("SSH_CIDRS").asString() + // MCP port firewall CIDRs. Same format as SSH_CIDRS. + // Set to "none" to block direct access to port 8000 (use with ORIGIN_URL). + const mcpPortCidrs = env("MCP_PORT_CIDRS").asString() + + // When set, API Gateway routes through this URL instead of directly to + // the Lightsail IP on port 8000. Use with a Cloudflare Tunnel, Caddy + // reverse proxy, or any HTTPS frontend that proxies to localhost:8000. + const originUrl = env("ORIGIN_URL").asString() + const expandHome = (p: string): string => p.startsWith("~/") ? `${homedir()}${p.slice(1)}` : p @@ -161,14 +170,20 @@ export default $config({ }) // "none" maps to a non-routable CIDR (RFC 5737 TEST-NET) so no - // real source IP ever matches — effectively blocks all public SSH. - // Tailscale SSH still works (bypasses the Lightsail firewall). - const sshFirewallCidrs = - sshCidrs?.toLowerCase() === "none" + // real source IP ever matches — effectively blocks all public access. + // Tailscale/cloudflared still work (bypass the Lightsail firewall). + const parseCidrs = ( + raw: string | undefined, + fallback: string[], + ): string[] => + raw?.toLowerCase() === "none" ? ["192.0.2.1/32"] - : sshCidrs - ? sshCidrs.split(",").map((cidr) => cidr.trim()) - : ["0.0.0.0/0"] + : raw + ? raw.split(",").map((cidr) => cidr.trim()) + : fallback + + const sshFirewallCidrs = parseCidrs(sshCidrs, ["0.0.0.0/0"]) + const mcpFirewallCidrs = parseCidrs(mcpPortCidrs, ["0.0.0.0/0"]) // GOTCHA: port_info is ForceNew in the Pulumi/Terraform provider. // Adding or removing entries triggers a REPLACEMENT, and the @@ -189,13 +204,15 @@ export default $config({ toPort: 22, cidrs: sshFirewallCidrs, }, - // Auth enforced at two layers (Lambda authorizer + Express - // middleware), so 0.0.0.0/0 is fine even on a direct hit. + // MCP_PORT_CIDRS controls who can reach port 8000 directly. + // With ORIGIN_URL set (tunnel/proxy), set MCP_PORT_CIDRS=none + // to block direct access — traffic flows through the tunnel. + // Without ORIGIN_URL, 0.0.0.0/0 is the default (API GW needs it). { protocol: "tcp", fromPort: 8000, toPort: 8000, - cidrs: ["0.0.0.0/0"], + cidrs: mcpFirewallCidrs, }, ], }, @@ -237,12 +254,21 @@ export default $config({ // GOTCHA: {proxy+} matches one-or-more path segments but NOT // the bare root "/". You need both routes. - api.routeUrl( - "ANY /{proxy+}", - $interpolate`http://${staticIp.ipAddress}:8000/{proxy}`, - { auth: { lambda: authorizer.id } }, - ) - api.routeUrl("ANY /", $interpolate`http://${staticIp.ipAddress}:8000`, { + // + // ORIGIN_URL: when set, API GW routes through a tunnel/proxy (HTTPS) + // instead of directly to the Lightsail IP (plaintext HTTP). Pair with + // MCP_PORT_CIDRS=none to close port 8000 on the firewall. + const proxyTarget = originUrl + ? `${originUrl}/{proxy}` + : $interpolate`http://${staticIp.ipAddress}:8000/{proxy}` + const rootTarget = originUrl + ? originUrl + : $interpolate`http://${staticIp.ipAddress}:8000` + + api.routeUrl("ANY /{proxy+}", proxyTarget, { + auth: { lambda: authorizer.id }, + }) + api.routeUrl("ANY /", rootTarget, { auth: { lambda: authorizer.id }, }) From d70008077043266ead977171f723f732f6273907 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Mon, 18 May 2026 16:57:28 -0400 Subject: [PATCH 2/6] docs: use generic subdomain in tunnel examples Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 2 +- DEPLOY.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index aabdb9e..46f9f50 100644 --- a/.env.example +++ b/.env.example @@ -50,7 +50,7 @@ TZ=UTC # ORIGIN_URL — Optional. When set, API Gateway routes through this URL # instead of directly to the Lightsail IP on port 8000. Use with a # Cloudflare Tunnel, Caddy reverse proxy, or any HTTPS frontend. -# Example: https://o1.yourdomain.dev +# Example: https://tunnel.yourdomain.dev # Default (unset): http://:8000 # ORIGIN_URL= diff --git a/DEPLOY.md b/DEPLOY.md index 3162f15..d2e9d54 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -461,7 +461,7 @@ Together: `ORIGIN_URL` provides the alternative path, `MCP_PORT_CIDRS=none` clos Cloudflare Tunnel (`cloudflared`) establishes an outbound-only connection from the Lightsail host to Cloudflare's edge. No inbound ports required — port 8000 is removed from the firewall entirely. The tunnel hostname serves as the HTTPS endpoint. -**Prerequisites:** A free [Cloudflare account](https://dash.cloudflare.com/sign-up) with at least one domain using Cloudflare's nameservers. The tunnel routes through a subdomain on that domain (e.g., `o1.yourdomain.dev`). +**Prerequisites:** A free [Cloudflare account](https://dash.cloudflare.com/sign-up) with at least one domain using Cloudflare's nameservers. The tunnel routes through a subdomain on that domain (e.g., `tunnel.yourdomain.dev`). **1. Create a tunnel** in the Cloudflare dashboard: @@ -487,7 +487,7 @@ After install, `cloudflared` runs as a systemd service, survives reboots, and is In the tunnel's Public Hostname tab, add a route: -- Subdomain: your chosen subdomain (e.g., `o1`) +- Subdomain: your chosen subdomain (e.g., `tunnel`) - Domain: select your Cloudflare-managed domain - Service: `http://localhost:8000` From 9447ae7ccd6668f911e6b53b36cf0e4537f01476 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Mon, 18 May 2026 17:11:36 -0400 Subject: [PATCH 3/6] docs: clarify MCP_PORT_CIDRS examples are placeholders Co-Authored-By: Claude Opus 4.6 (1M context) --- DEPLOY.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/DEPLOY.md b/DEPLOY.md index d2e9d54..53b18bd 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -518,12 +518,12 @@ curl https:///healthz `MCP_PORT_CIDRS` accepts any of these values: -| Value | Effect | -| ------------------------------ | ---------------------------------------------------------- | -| (unset) | `0.0.0.0/0` — open to all (default, backward-compat) | -| `none` | Port 8000 set to non-routable CIDR (use with `ORIGIN_URL`) | -| `203.0.113.42/32` | Single IP | -| `203.0.113.0/24,198.51.100/24` | Multiple CIDRs (comma-separated) | +| Value | Effect | +| ----------------- | ---------------------------------------------------------- | +| (unset) | `0.0.0.0/0` — open to all (default, backward-compat) | +| `none` | Port 8000 set to non-routable CIDR (use with `ORIGIN_URL`) | +| `/32` | Single IP (e.g., your home IP) | +| `,` | Multiple CIDRs (comma-separated) | ### Fresh VM bootstrap From 6d38327e1b2bc55799cf37a93059adc880af6261 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Mon, 18 May 2026 17:13:35 -0400 Subject: [PATCH 4/6] docs: use vendor-agnostic language in firewall comment Co-Authored-By: Claude Opus 4.6 (1M context) --- sst.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sst.config.ts b/sst.config.ts index 507ff20..bbbfb5a 100644 --- a/sst.config.ts +++ b/sst.config.ts @@ -171,7 +171,7 @@ export default $config({ // "none" maps to a non-routable CIDR (RFC 5737 TEST-NET) so no // real source IP ever matches — effectively blocks all public access. - // Tailscale/cloudflared still work (bypass the Lightsail firewall). + // Host-level services (tunnels, VPNs) bypass the Lightsail firewall. const parseCidrs = ( raw: string | undefined, fallback: string[], From 33d9330412dcc54e70b8c4f4fff55bcc6422defc Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Mon, 18 May 2026 17:17:37 -0400 Subject: [PATCH 5/6] refactor: rewrite parseCidrs with early returns and doc comment Co-Authored-By: Claude Opus 4.6 (1M context) --- sst.config.ts | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/sst.config.ts b/sst.config.ts index bbbfb5a..2f30f61 100644 --- a/sst.config.ts +++ b/sst.config.ts @@ -169,18 +169,22 @@ export default $config({ instanceName: instance.name, }) - // "none" maps to a non-routable CIDR (RFC 5737 TEST-NET) so no - // real source IP ever matches — effectively blocks all public access. - // Host-level services (tunnels, VPNs) bypass the Lightsail firewall. + /** + * Parse a CIDR env var into a firewall allowlist. + * - undefined → fallback (typically 0.0.0.0/0, open to all) + * - "none" → non-routable CIDR (RFC 5737 TEST-NET, blocks all public access) + * - "a/b,c/d" → split into individual CIDRs + * + * Host-level services (tunnels, VPNs) bypass the Lightsail firewall. + */ const parseCidrs = ( raw: string | undefined, fallback: string[], - ): string[] => - raw?.toLowerCase() === "none" - ? ["192.0.2.1/32"] - : raw - ? raw.split(",").map((cidr) => cidr.trim()) - : fallback + ): string[] => { + if (!raw) return fallback + if (raw.toLowerCase() === "none") return ["192.0.2.1/32"] + return raw.split(",").map((cidr) => cidr.trim()) + } const sshFirewallCidrs = parseCidrs(sshCidrs, ["0.0.0.0/0"]) const mcpFirewallCidrs = parseCidrs(mcpPortCidrs, ["0.0.0.0/0"]) From 6920de84b29d5e22b4684f5ab59e38f342191bea Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Mon, 18 May 2026 17:20:29 -0400 Subject: [PATCH 6/6] refactor: extract constants, remove fallback param from parseCidrs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NON_ROUTABLE_CIDR and OPEN_TO_ALL are now named constants. The fallback parameter is removed — undefined always means open to all, eliminating the risk of an empty-array fallback. Co-Authored-By: Claude Opus 4.6 (1M context) --- sst.config.ts | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/sst.config.ts b/sst.config.ts index 2f30f61..ee18df0 100644 --- a/sst.config.ts +++ b/sst.config.ts @@ -169,25 +169,26 @@ export default $config({ instanceName: instance.name, }) + /** RFC 5737 TEST-NET — no real source IP matches this CIDR. */ + const NON_ROUTABLE_CIDR = "192.0.2.1/32" + const OPEN_TO_ALL = ["0.0.0.0/0"] + /** * Parse a CIDR env var into a firewall allowlist. - * - undefined → fallback (typically 0.0.0.0/0, open to all) - * - "none" → non-routable CIDR (RFC 5737 TEST-NET, blocks all public access) + * - undefined → open to all + * - "none" → non-routable CIDR (blocks all public access) * - "a/b,c/d" → split into individual CIDRs * * Host-level services (tunnels, VPNs) bypass the Lightsail firewall. */ - const parseCidrs = ( - raw: string | undefined, - fallback: string[], - ): string[] => { - if (!raw) return fallback - if (raw.toLowerCase() === "none") return ["192.0.2.1/32"] + const parseCidrs = (raw: string | undefined): string[] => { + if (!raw) return OPEN_TO_ALL + if (raw.toLowerCase() === "none") return [NON_ROUTABLE_CIDR] return raw.split(",").map((cidr) => cidr.trim()) } - const sshFirewallCidrs = parseCidrs(sshCidrs, ["0.0.0.0/0"]) - const mcpFirewallCidrs = parseCidrs(mcpPortCidrs, ["0.0.0.0/0"]) + const sshFirewallCidrs = parseCidrs(sshCidrs) + const mcpFirewallCidrs = parseCidrs(mcpPortCidrs) // GOTCHA: port_info is ForceNew in the Pulumi/Terraform provider. // Adding or removing entries triggers a REPLACEMENT, and the