diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6d3374a..4f4d230 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -64,6 +64,20 @@ jobs: ssh -i ~/.ssh/deploy_key admin@nex.jonathandeamer.com \ 'sudo install -m 644 /tmp/buffetcar.service /etc/systemd/system/buffetcar.service && rm /tmp/buffetcar.service && sudo systemctl daemon-reload' + - name: Install host firewall + run: | + scp -i ~/.ssh/deploy_key \ + contrib/buffetcar.nftables \ + admin@nex.jonathandeamer.com:/tmp/buffetcar.nftables + ssh -i ~/.ssh/deploy_key admin@nex.jonathandeamer.com ' + set -e + sudo apt-get install -y nftables + sudo nft -c -f /tmp/buffetcar.nftables + sudo install -m 644 /tmp/buffetcar.nftables /etc/nftables.conf + sudo systemctl enable --now nftables + sudo nft -f /etc/nftables.conf + rm /tmp/buffetcar.nftables' + - name: Restart service run: | ssh -i ~/.ssh/deploy_key admin@nex.jonathandeamer.com \ diff --git a/CLAUDE.md b/CLAUDE.md index fa603a0..3a49902 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,7 +74,8 @@ Optional reference suite (NOT in `make check`): Production runs on a single AWS Lightsail box (Debian), serving `/srv/nex` on port 1900. Deployment is **committed and automatic** — no manual step: -- **`.github/workflows/deploy.yml`** triggers on a successful **CI** run on `main`. It cross-compiles the `x86_64-unknown-linux-musl` binary, `scp`s it into `/usr/local/bin/buffetcar`, rsyncs the repo into the served tree (`/srv/nex/buffetcar/`, excluding `target/`, `.git/`, and `contrib/`), installs the systemd unit, `daemon-reload`s, and restarts the service. Any merge to `main` therefore redeploys. +- **`.github/workflows/deploy.yml`** triggers on a successful **CI** run on `main`. It cross-compiles the `x86_64-unknown-linux-musl` binary, `scp`s it into `/usr/local/bin/buffetcar`, rsyncs the repo into the served tree (`/srv/nex/buffetcar/`, excluding `target/`, `.git/`, and `contrib/`), installs the systemd unit, installs the host firewall (`contrib/buffetcar.nftables` → `/etc/nftables.conf`, validated with `nft -c` before it is applied), `daemon-reload`s, and restarts the service. Any merge to `main` therefore redeploys. +- **`contrib/buffetcar.nftables`** is the version-controlled host firewall the workflow installs. It is *surgical*, not a perimeter firewall: the input policy stays `accept` (so it can never lock out SSH or block other services — the Lightsail cloud firewall does port filtering), and its only rules cap **per-source-IP** concurrent connections and new-connection rate on tcp/1900. This is the firewall layer that `SECURITY.md` scopes rate limiting to, and it closes the [#29](https://github.com/jonathandeamer/buffetcar/issues/29) worker-exhaustion gap for the single-source case. **Edit it in `contrib/`, not on the box** (a redeploy overwrites `/etc/nftables.conf`). - **`contrib/buffetcar.service`** is the version-controlled, hardened unit the workflow installs. It runs the server **sandboxed as `DynamicUser=yes`** (never a login user, never root — the same no-root invariant the code enforces) with `ProtectSystem=strict`, `NoNewPrivileges`, and the read-only restrictions. **Keep it hardened, and edit it in `contrib/`, not on the box** (a redeploy overwrites the box copy). buffetcar serves read-only on the unprivileged port 1900, so it needs no capabilities, no `ReadWritePaths`, and no privileged user — don't add them. ## Production-readiness roadmap @@ -82,7 +83,7 @@ Production runs on a single AWS Lightsail box (Debian), serving `/srv/nex` on po The server is functionally complete and secure; the remaining gaps to "robust production server" are operational. Tracked as open issues: - **[#28 — Observability: structured server-side logging](https://github.com/jonathandeamer/buffetcar/issues/28)** — the headline gap. The server is silent today: per-connection `Err`s and worker panics are dropped (`let _ =` in `src/server.rs`). Design-first; respects no-leakage, signal-safety, and the OpenBSD `stdio` pledge. -- **[#29 — Abuse resistance: per-IP connection/rate limits](https://github.com/jonathandeamer/buffetcar/issues/29)** — timeouts bound a slow client to ~35s of worker-hold, but there is no per-IP cap, so ~128 slow clients can saturate the pool. Design-first, **human-merge** (threat model). +- **[#29 — Abuse resistance: per-IP connection/rate limits](https://github.com/jonathandeamer/buffetcar/issues/29)** — timeouts bound a slow client to ~35s of worker-hold, but there is no per-IP cap, so ~128 slow clients can saturate the pool. The **host-firewall layer now exists** (`contrib/buffetcar.nftables`, per-source cap on tcp/1900), closing the single-source case at the right layer with zero app complexity. What remains open is the *in-app* per-IP concurrent cap, justified narrowly as making the binary safe-by-default when deployed **without** a firewall — design-first, **human-merge** (threat model). (**[#27 — TCP listen backlog](https://github.com/jonathandeamer/buffetcar/issues/27)** is **done** — `server::bind_with_backlog` sets an explicit backlog via `rustix` `net` instead of the OS default; landed in #40/#41.) diff --git a/contrib/buffetcar.nftables b/contrib/buffetcar.nftables new file mode 100644 index 0000000..4667ca1 --- /dev/null +++ b/contrib/buffetcar.nftables @@ -0,0 +1,48 @@ +#!/usr/sbin/nft -f +# +# /etc/nftables.conf +# +# Host firewall for the buffetcar Nex server. Installed by the Deploy workflow +# (.github/workflows/deploy.yml). This is a *surgical* ruleset, not a full +# perimeter firewall: the Lightsail cloud firewall already allows/denies inbound +# ports, so port filtering is not this layer's job. Its only job is the thing +# the cloud firewall structurally cannot do — a per-source-IP cap on connections +# to the Nex port, closing the worker-exhaustion / slowloris gap tracked in +# issue #29. +# +# Because the input policy stays `accept`, this ruleset can never lock you out of +# SSH (port 22) or interfere with any other service: it only ever *drops* new +# connections to tcp/1900 that exceed a single source IP's limits. A throttled +# client is dropped silently (no RST) — the connection simply times out, which +# preserves buffetcar's no-leakage contract. +# +# The Deploy workflow validates this file with `nft -c -f` before installing it, +# so a syntax error fails the deploy rather than half-applying a ruleset. + +flush ruleset + +table inet buffetcar { + chain input { + # policy accept: the cloud firewall handles port filtering, and an + # accept policy means we can only ever add drops for tcp/1900 — + # never accidentally block SSH or established traffic. + type filter hook input priority filter; policy accept; + + # Per-source concurrent-connection cap. More than 16 simultaneous + # connections from one IP to the Nex port -> drop the excess. This + # is the resource cap (sibling of the server's --workers pool cap), + # keyed per source address so no single IP can monopolise workers. + tcp dport 1900 ct state new \ + meter nex_conns { ip saddr ct count over 16 } \ + drop + + # Per-source new-connection rate limit. Up to 30 new connections per + # minute per IP (small burst allowance); anything under the rate is + # accepted, anything over falls through to the drop below. This is + # the "rate limiting" the design scopes to the firewall layer. + tcp dport 1900 ct state new \ + meter nex_rate { ip saddr limit rate 30/minute burst 20 packets } \ + accept + tcp dport 1900 ct state new drop + } +}