From 2588000fcb5abb31f6f3ff2fc61df30e8fcca3af Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Mon, 18 May 2026 13:54:06 -0400 Subject: [PATCH 1/2] fix: prevent port 8000 wipe by avoiding ForceNew on InstancePublicPorts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit port_info is ForceNew in the Pulumi/Terraform AWS provider — adding or removing entries triggers a resource replacement. The default create-before-delete order causes the delete step to wipe ports set by the create (PutInstancePublicPorts is a replace-all API). This is a known issue (pulumi/pulumi-aws#1511). Two-layer fix: 1. Always keep both port entries — map SSH_CIDRS=none to a non-routable CIDR (192.0.2.1/32, RFC 5737 TEST-NET) instead of removing the entry. Only cidrs changes, which is NOT ForceNew. 2. Add deleteBeforeReplace as a safety net — if a replacement is ever triggered by other changes, delete-old runs first so create-new sets the final state correctly. Co-Authored-By: Claude Opus 4.6 (1M context) --- DEPLOY.md | 14 +++++----- sst.config.ts | 72 ++++++++++++++++++++++++++++++--------------------- 2 files changed, 49 insertions(+), 37 deletions(-) diff --git a/DEPLOY.md b/DEPLOY.md index c307d5d..5254e41 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -409,13 +409,13 @@ CI nodes are ephemeral (auto-removed after inactivity) thanks to the OAuth clien `SSH_CIDRS` accepts any of these values: -| Value | Effect | -| ------------------------------ | ------------------------------------------------------- | -| (unset) | `0.0.0.0/0` — open to all (default, backward-compat) | -| `none` | Port 22 removed from firewall entirely (Tailscale-only) | -| `100.64.0.0/10` | Tailscale CIDR only (belt-and-suspenders) | -| `203.0.113.42/32` | Single IP (e.g., home IP) | -| `100.64.0.0/10,203.0.113.0/24` | Multiple CIDRs (comma-separated) | +| Value | Effect | +| ------------------------------ | ---------------------------------------------------- | +| (unset) | `0.0.0.0/0` — open to all (default, backward-compat) | +| `none` | Port 22 set to non-routable CIDR (Tailscale-only) | +| `100.64.0.0/10` | Tailscale CIDR only (belt-and-suspenders) | +| `203.0.113.42/32` | Single IP (e.g., home IP) | +| `100.64.0.0/10,203.0.113.0/24` | Multiple CIDRs (comma-separated) | ### Fresh VM bootstrap (chicken-and-egg) diff --git a/sst.config.ts b/sst.config.ts index 37ff6ce..5d4b75a 100644 --- a/sst.config.ts +++ b/sst.config.ts @@ -30,7 +30,7 @@ export default $config({ const sshPubkeyPath = env("SSH_PUBKEY_PATH").asString() // SSH firewall CIDRs. Comma-separated. Default: open (backward-compat). - // Set to "none" to remove port 22 entirely (Tailscale-only SSH). + // Set to "none" to block public SSH (Tailscale-only). const sshCidrs = env("SSH_CIDRS").asString() const expandHome = (p: string): string => @@ -160,35 +160,47 @@ export default $config({ instanceName: instance.name, }) - // GOTCHA: InstancePublicPorts is DECLARATIVE — it replaces ALL - // existing rules on every deploy. If you omit port 22 here, - // you lock yourself out of SSH via the public IP (but Tailscale - // SSH still works — it bypasses the Lightsail firewall entirely). - new aws.lightsail.InstancePublicPorts("VaultCortexPorts", { - instanceName: instance.name, - portInfos: [ - // SSH: configurable via SSH_CIDRS env var. - // "none" removes port 22 from the public firewall (Tailscale-only). - // Comma-separated CIDRs restrict to specific IPs (e.g. "100.64.0.0/10"). - // Default (unset): 0.0.0.0/0 (backward-compat). - ...(sshCidrs?.toLowerCase() === "none" - ? [] - : [ - { - protocol: "tcp", - fromPort: 22, - toPort: 22, - cidrs: sshCidrs - ? sshCidrs.split(",").map((cidr) => cidr.trim()) - : ["0.0.0.0/0"], - }, - ]), - // API Gateway calls Lightsail on this port. Bearer token is - // enforced upstream by the Lambda authorizer, so 0.0.0.0/0 - // is acceptable — the token is the real security boundary. - { protocol: "tcp", fromPort: 8000, toPort: 8000, cidrs: ["0.0.0.0/0"] }, - ], - }) + // GOTCHA #1: InstancePublicPorts is DECLARATIVE — it replaces ALL + // existing rules on every deploy. + // GOTCHA #2: port_info is ForceNew in the Pulumi/Terraform provider. + // Adding or removing entries triggers a resource REPLACEMENT. The + // default create-before-delete order causes the "delete old" step + // to wipe the newly created ports (PutInstancePublicPorts is a + // replace-all API). deleteBeforeReplace reverses the order so the + // create happens last and sticks. See pulumi/pulumi-aws#1511. + // To avoid triggering replacements entirely, we always keep both + // port entries and map "none" to a non-routable CIDR. + const sshFirewallCidrs = + sshCidrs?.toLowerCase() === "none" + ? ["192.0.2.1/32"] // RFC 5737 TEST-NET — non-routable, effectively blocks all SSH + : sshCidrs + ? sshCidrs.split(",").map((cidr) => cidr.trim()) + : ["0.0.0.0/0"] + + new aws.lightsail.InstancePublicPorts( + "VaultCortexPorts", + { + instanceName: instance.name, + portInfos: [ + { + protocol: "tcp", + fromPort: 22, + toPort: 22, + cidrs: sshFirewallCidrs, + }, + // API Gateway calls Lightsail on this port. Bearer token is + // enforced upstream by the Lambda authorizer, so 0.0.0.0/0 + // is acceptable — the token is the real security boundary. + { + protocol: "tcp", + fromPort: 8000, + toPort: 8000, + cidrs: ["0.0.0.0/0"], + }, + ], + }, + { deleteBeforeReplace: true }, + ) // Stage throttle: 20 req/sec, 40 burst. GOTCHA: throttlingRateLimit // and throttlingBurstLimit must BOTH be set — partial config is From 099e28be0a08678e45a0d96645fefd1a375a831f Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Mon, 18 May 2026 13:59:47 -0400 Subject: [PATCH 2/2] refactor: distribute comments to their relevant code blocks Co-Authored-By: Claude Opus 4.6 (1M context) --- sst.config.ts | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/sst.config.ts b/sst.config.ts index 5d4b75a..e9b5544 100644 --- a/sst.config.ts +++ b/sst.config.ts @@ -160,23 +160,24 @@ export default $config({ instanceName: instance.name, }) - // GOTCHA #1: InstancePublicPorts is DECLARATIVE — it replaces ALL - // existing rules on every deploy. - // GOTCHA #2: port_info is ForceNew in the Pulumi/Terraform provider. - // Adding or removing entries triggers a resource REPLACEMENT. The - // default create-before-delete order causes the "delete old" step - // to wipe the newly created ports (PutInstancePublicPorts is a - // replace-all API). deleteBeforeReplace reverses the order so the - // create happens last and sticks. See pulumi/pulumi-aws#1511. - // To avoid triggering replacements entirely, we always keep both - // port entries and map "none" to a non-routable CIDR. + // "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" - ? ["192.0.2.1/32"] // RFC 5737 TEST-NET — non-routable, effectively blocks all SSH + ? ["192.0.2.1/32"] : sshCidrs ? sshCidrs.split(",").map((cidr) => cidr.trim()) : ["0.0.0.0/0"] + // GOTCHA: port_info is ForceNew in the Pulumi/Terraform provider. + // Adding or removing entries triggers a REPLACEMENT, and the + // default create-before-delete order wipes newly created ports + // (PutInstancePublicPorts is a replace-all API). pulumi/pulumi-aws#1511. + // Two defenses: + // 1. Always keep both entries — "none" changes cidrs only (not ForceNew). + // 2. deleteBeforeReplace — if replacement is ever triggered, + // delete runs first so create sets the final state. new aws.lightsail.InstancePublicPorts( "VaultCortexPorts", { @@ -188,9 +189,8 @@ export default $config({ toPort: 22, cidrs: sshFirewallCidrs, }, - // API Gateway calls Lightsail on this port. Bearer token is - // enforced upstream by the Lambda authorizer, so 0.0.0.0/0 - // is acceptable — the token is the real security boundary. + // Auth enforced at two layers (Lambda authorizer + Express + // middleware), so 0.0.0.0/0 is fine even on a direct hit. { protocol: "tcp", fromPort: 8000, @@ -199,6 +199,7 @@ export default $config({ }, ], }, + // Prevents create-before-delete from wiping ports. See GOTCHA above. { deleteBeforeReplace: true }, )