modelbeast/docs/VPS.md
MODELBEAST 514ec6cdcc Security hardening from adversarial review (5-agent workflow)
Fixed confirmed findings before public exposure:
HIGH:
- upload filename path traversal → store.safe_name() strips to basename
- login rate-limit XFF bypass → key on request.client.host + per-username bucket;
  auth.check_login() burns bcrypt time on unknown users (no enumeration)
- cross-user read access → per-user isolation: guests see/use/download/delete only
  their own assets & jobs (owner sees all); WS job events scoped per-user
MEDIUM:
- unbounded upload read → bounded chunked streaming to the 1GB cap
- asset member path check → Path.is_relative_to boundary + ownership gate
- WS token-in-query-string leak → session-cookie-only WS auth
LOW:
- retry_job bypassed the per-user job cap → cap now checked on retry
- wholesale API-key injection → env_for_operator injects a paid key only to
  operators that declare it (guest local jobs never receive fal/OpenRouter keys)
- session revocation → users.session_epoch, bumped on password change
- int() 500s → 400; net-lane defense-in-depth (guests blocked by requires_env AND
  resources==net, so a mis-tagged paid op is still blocked)
+ public /api/health for serve.sh & proxy; docs/VPS.md; mb MB_TOKEN bearer auth

tests/smoke.sh: 34 checks passing incl. all new hardening.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 12:25:13 +10:00

94 lines
4.0 KiB
Markdown

# Public hosting — MODELBEAST at modelbeast.digalot.fyi
Expose MODELBEAST to the internet **without exposing the M3 Ultra directly**: a spare
VPS is the only public surface and reverse-proxies to the Mac over the tailnet. If the
VPS is ever compromised you just remove one tailnet node; the Mac is never on the
public internet.
```
friend's browser ──https──▶ VPS (Caddy, public)
│ reverse_proxy over tailnet
M3 Ultra 100.89.131.57:8777 (auth enforced here)
```
**Prerequisite: auth must be live on the Mac first** (HANDOFF2 Phase 1 — done). Do not
open this to the internet until `mb`/the UI require a login. Every public request now
hits the login gate; guests are local-only by hard server-side rule.
## On the VPS (run these — you own that box; I can't reach it)
```bash
# 1) join the same tailnet as the M3 Ultra
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up
# 2) Caddy (automatic HTTPS via Let's Encrypt)
sudo apt update && sudo apt install -y caddy
```
Write `/etc/caddy/Caddyfile`:
```
modelbeast.digalot.fyi {
encode zstd gzip
request_body {
max_size 1GB # matches the app's upload cap
}
reverse_proxy 100.89.131.57:8777
}
# optional second domain, same backend:
# modelbeast.partyl.party {
# encode zstd gzip
# request_body { max_size 1GB }
# reverse_proxy 100.89.131.57:8777
# }
```
```bash
sudo systemctl reload caddy
```
**DNS:** add an `A` record `modelbeast` → the VPS's public IP on `digalot.fyi` (and
`partyl.party` if using it). Caddy fetches the TLS cert automatically on first request.
## Why these choices
- **Subdomain, not `digalot.fyi/modelbeast`.** A path prefix would force a Vite
`base` + FastAPI `root_path` rework across the SPA, the WebSocket, and the `mb` CLI
for zero benefit. `modelbeast.<domain>` needs no app changes.
- **Caddy proxies WebSockets automatically** — the live job-log stream works through
it. The session cookie is same-origin (browser talks to the subdomain for both HTTP
and WS), so WS auth via cookie just works.
- **Auth lives in the app, not the proxy.** Proxy basic-auth can't do per-user roles,
the guest local-only rule, or per-user job caps. All of that is enforced in FastAPI.
- **Rejected: Tailscale Funnel.** Zero-VPS and simplest, but no custom domain and it's
bandwidth-capped. Keep it in your back pocket as the fallback if the VPS dies:
`tailscale funnel 8777` publishes `https://<machine>.<tailnet>.ts.net` instantly.
## Hardening already in the app (verify after going live)
- `Secure` cookie flag is set when the request arrived via https (reads
`X-Forwarded-Proto` that Caddy sets) — check DevTools shows `mb_session` as Secure.
- Upload cap 1 GB in the app **and** in Caddy (`request_body max_size`).
- Login rate-limit (5/min) keyed on `X-Forwarded-For`.
- The Mac keeps binding `0.0.0.0` on the tailnet; auth now protects it there too
(agents/CLI use `MB_TOKEN`).
## Off-tailnet acceptance test (do this WITH me once Caddy is up)
From a phone on cellular (not on the tailnet):
1. `https://modelbeast.digalot.fyi` shows the login page over a valid cert.
2. A guest logs in, runs `flux_local`, watches the live log stream, opens the dashboard.
3. `MB_HOST=https://modelbeast.digalot.fyi MB_TOKEN=... ./mb ops` works.
4. Unauthenticated `curl https://modelbeast.digalot.fyi/api/operators` → 401.
5. A guest gets 403 on `/api/settings` and on a direct cloud-operator job POST.
## Runbook
- Server stays a single uvicorn worker (the in-process job queue + shared SQLite
connection require exactly one worker — do not add `--workers`).
- Restart the Mac server: `./scripts/serve.sh`. Caddy needs no restart for that.
- Rotate a leaked session-signing key: delete the `auth_secret` row from the settings
table (`sqlite3 data/modelbeast.db "DELETE FROM settings WHERE key='auth_secret'"`)
and restart — a new one is generated and all existing sessions are invalidated.