diff --git a/.env.example b/.env.example index 24caeaa..46f9f50 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://tunnel.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..53b18bd 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., `tunnel.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., `tunnel`) +- 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`) | +| `/32` | Single IP (e.g., your home IP) | +| `,` | 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..ee18df0 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 @@ -160,15 +169,26 @@ 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 SSH. - // Tailscale SSH still works (bypasses the Lightsail firewall). - const sshFirewallCidrs = - sshCidrs?.toLowerCase() === "none" - ? ["192.0.2.1/32"] - : sshCidrs - ? sshCidrs.split(",").map((cidr) => cidr.trim()) - : ["0.0.0.0/0"] + /** 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 → 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): 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) + const mcpFirewallCidrs = parseCidrs(mcpPortCidrs) // GOTCHA: port_info is ForceNew in the Pulumi/Terraform provider. // Adding or removing entries triggers a REPLACEMENT, and the @@ -189,13 +209,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 +259,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 }, })