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
14 changes: 14 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,15 +74,16 @@ 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

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.)

Expand Down
48 changes: 48 additions & 0 deletions contrib/buffetcar.nftables
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading