Skip to content
Merged
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
13 changes: 13 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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://<lightsail-ip>: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
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 10 additions & 3 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
123 changes: 122 additions & 1 deletion DEPLOY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<lightsail-ip>: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 <TUNNEL_TOKEN>
```

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://<subdomain>.<yourdomain>/healthz
# Should return 200 OK
```

**5. Close port 8000** — set `ORIGIN_URL` and `MCP_PORT_CIDRS=none`, then deploy:

```bash
ORIGIN_URL=https://<subdomain>.<yourdomain> 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://<lightsailIp>:8000/healthz

# API Gateway — should return 200 (routed through tunnel)
curl https://<api-gateway-url>/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`) |
| `<your-ip>/32` | Single IP (e.g., your home IP) |
| `<cidr1>,<cidr2>` | 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 <TUNNEL_TOKEN>
```
4. Verify the tunnel: `curl https://<subdomain>.<yourdomain>/healthz`
5. Re-harden:
```bash
SSH_CIDRS=none ORIGIN_URL=https://<subdomain>.<yourdomain> 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-<stage> \
--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 <lightsailIp>` hangs** — use `:8000`. The firewall only allows ports 22 and 8000 by default (port 22 may be closed if `SSH_CIDRS=none`).
- **`curl <lightsailIp>` 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@<IP> "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 <lightsailIp>` and retry.
67 changes: 49 additions & 18 deletions sst.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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,
},
],
},
Expand Down Expand Up @@ -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 },
})

Expand Down