docs/DOCTOR.md
Rama main · réplica de lectura del repositorio original.
# LobbyForge Doctor & Capacity
The Doctor subsystem answers the self-host admin's two questions: *"is my instance healthy?"* and *"how many users can I host?"*. This document is the implementation companion to [`projectdetails/19_OBSERVABILITY_DOCTOR_CAPACITY.md`](../projectdetails/19_OBSERVABILITY_DOCTOR_CAPACITY.md).
The Doctor code lives in two places:
| Layer | Location | What it does |
|---|---|---|
| **Domain primitive** | `packages/core/src/doctor.ts` | Types (`DoctorCheck`, `SystemStats`, `CapacityProfile`), `buildDoctorReport`, `recommendCapacityProfile`. Platform-agnostic — no `import 'node:os'`. |
| **App-specific checks** | `apps/web/lib/doctor.ts` | `buildChecksFromStats` (pure) + `collectSystemStats` (Node-only) + `collectDoctorReport` (HTTP probes). |
This split is deliberate: a future Tauri desktop host or CLI tool can reuse `packages/core` without pulling in Next.js.
## Data model
```ts
type DoctorCategory = 'system' | 'network' | 'services' | 'media';
type AlertLevel = 'info' | 'warning' | 'critical' | 'fatal';
interface DoctorCheck {
id: string; // e.g. "postgres", "disk_usage", "turn_configured"
category: DoctorCategory;
ok: boolean; // true ⇒ display as ✅, false ⇒ use level for the glyph
level: AlertLevel; // ignored when ok === true
message: string; // short, admin-facing sentence
detail?: Record<string, unknown>;
}
interface SystemStats {
cpuCount: number;
loadAverage1m: number;
totalMemoryBytes: number;
freeMemoryBytes: number;
totalDiskBytes: number;
freeDiskBytes: number;
diskUsageRatio: number; // 0..1
uptimeSeconds: number;
livekitReachable: boolean | null;
postgresReachable: boolean | null;
redisReachable: boolean | null;
httpsReachable: boolean | null;
udpLikelyOpen: boolean | null;
turnConfigured: boolean | null;
startedAt: Date;
}
```
The tri-state (`true | false | null`) for reachability is intentional: a `null` means "the probe has not run yet" and is shown as `ℹ️ pending`, **not** as a failure. This keeps the Doctor honest — Doctor runs continuously every 15 minutes (per spec §3), so the first report can never claim "everything is fine" if nothing has been probed.
## Capacity profile
`recommendCapacityProfile(stats)` returns one of three tiers plus a numeric profile:
| Tier | Voice cap / room | Camera cap / room | Screen share | Video default | Layout |
|---|---|---|---|---|---|
| LOW | 10 | 2 | 1 | off | active speaker |
| MEDIUM | 40 | 5 | 1 | opt-in | active speaker + thumbnails |
| HIGH | 100 | 9 | 2 | on | grid |
The function follows the spec's "conservative language" rule (§8): it never returns a hard promise. The returned `guidance` string is intentionally phrased as *"With these settings the safe guidance is up to X voice users per room. Re-measure under live load before raising the cap."* — copy-paste from the spec wording.
Tier selection algorithm:
1. Floor: `cpuCount = max(1, stats.cpuCount)`, `totalMemoryBytes = max(512 MB, stats.totalMemoryBytes)`. Even a constrained host gets a sensible answer.
2. Initial pick: `LOW` if ≤ 1 CPU or < 2 GB; `HIGH` if ≥ 4 CPU and ≥ 8 GB and disk < 70 %; otherwise `MEDIUM`.
3. Demote on disk pressure: `diskUsageRatio ≥ 0.95` → `LOW`; `≥ 0.9` and currently `HIGH` → `MEDIUM`.
4. Demote on heavy load: `loadPerCpu > 2` → demote one step; `> 4` (in `buildChecksFromStats`) → `CRITICAL` check.
The function is pure and exhaustive over the tier — Vitest covers all branches in `packages/core/src/__tests__/doctor.test.ts`.
## Checks produced by `buildChecksFromStats`
| ID | Category | Default | When it fails |
|---|---|---|---|
| `cpu_count` | system | ok | never (always ok) |
| `memory_free` | system | ok | < 10 % free ⇒ `CRITICAL`; < 20 % ⇒ `WARNING` |
| `disk_usage` | system | ok | ≥ 95 % ⇒ `FATAL`; ≥ 90 % ⇒ `CRITICAL`; ≥ 80 % ⇒ `WARNING` |
| `load_average` | system | ok | per-CPU load ≥ 2 ⇒ `WARNING`; ≥ 4 ⇒ `CRITICAL` |
| `https` | network | info | `httpsReachable === false` ⇒ `CRITICAL` |
| `udp_range` | network | info | `udpLikelyOpen === false` ⇒ `WARNING` |
| `postgres` | services | info | `postgresReachable === false` ⇒ `CRITICAL` |
| `redis` | services | info | `redisReachable === false` ⇒ `CRITICAL` |
| `livekit_signaling` | services | info | `livekitReachable === false` ⇒ `CRITICAL` |
| `turn_configured` | media | info | TURN missing **and** UDP looks blocked ⇒ `WARNING` |
| `trusted_proxy` | network | info | production with `LOBBYFORGE_TRUSTED_PROXY` unset, `none` or unrecognised ⇒ `WARNING` (every client shares one rate-limit bucket; the message carries the fix). Added by `collectDoctorReport` from the environment via `buildTrustedProxyCheck` |
| `secure_origin` | network | info | a declared public origin (`NEXT_PUBLIC_BASE_URL`, `LOBBYFORGE_APP_ORIGIN`) on plain `http://` with a non-localhost host ⇒ `CRITICAL` while ALTCHA is the active bot-protection provider (it needs Web Crypto; sign-ups fall back to a slow pure-JS solver), `WARNING` otherwise (microphone / camera need HTTPS or localhost too). Added by `collectDoctorReport` via `buildSecureOriginCheck` |
| `client_address` | network | info | security-review INFRA-V-01. With `LOBBYFORGE_TRUSTED_PROXY` set, the address of the request asking for the report — or at least half of the last 256 proxied requests (sampled in memory by `lib/client-address-sample.ts`, from 20 requests on) — is a gateway: one of the container's default gateways (`/proc/net/route`) or a private IPv4 ending in `.1` ⇒ `WARNING`. Behind Docker's userland proxy with no IPv6 on the bridge, every IPv6 visitor arrives as the bridge gateway and they share one rate-limit bucket; the message carries the fix ([DEPLOY_CLOUDFLARE.md](./DEPLOY_CLOUDFLARE.md#client-addresses-behind-docker)). Added by `collectDoctorReport` via `buildClientAddressCheck`; Admin → Health and `/api/doctor` pass the caller's resolved address |
| `image_storage` | system | info | security-review FILE-001. Avatars, banners and the logo are data URLs in Postgres; their total (`getInstanceStoredImageBytes`, `octet_length` only — no image is loaded) at or above 1 GiB, or `LOBBYFORGE_IMAGE_STORAGE_WARN_MB` ⇒ `WARNING`, with a breakdown (accounts, guests, server banners, logo). Left out when the database cannot be read (the `postgres` check reports that) |
| `captcha` / `captcha_*` | services | info | Bot protection ([CAPTCHA.md](./CAPTCHA.md) §8), added by `collectDoctorReport` via `lib/captcha/doctor.ts`: external provider without a key (`captcha_keys`), undecryptable secret (`captcha_secret`), siteverify bad secret / unreachable (`captcha_siteverify`), test keys in production (`captcha_test_keys`) ⇒ `WARNING`; Redis down in production, so ALTCHA cannot block replays (`captcha_replay_store`) ⇒ `CRITICAL`; unknown `LOBBYFORGE_CAPTCHA_PROVIDER` (`captcha_env`) or unreadable settings (`captcha_settings`) ⇒ `WARNING`. One info line `captcha` when all is well |
`null` reachability always renders as `ok: true, level: 'info'` with the message *"… has not been probed yet"*. This is what keeps the very first 60-second ping from being noisy.
## Web app integration
`apps/web/lib/doctor.ts` adds the Node-`os`-bound collector and HTTP probes:
- `collectSystemStats()` — pulls CPU / load / memory from `node:os`, reads `LOBBYFORGE_DISK_USAGE_RATIO` from the env (a future iteration can read `/proc` or call `df` via `child_process`), and seeds all reachability booleans to `null`.
- `collectDoctorReport()` — fills in reachability via `probeUrl()` (1.5 s timeout, accepts 2xx/3xx) against `LIVEKIT_URL`, `POSTGRES_URL`, `REDIS_URL`, and `NEXT_PUBLIC_BASE_URL`, then calls `buildChecksFromStats()` and `buildDoctorReport()`.
- `redactStatsForPublic()` — strips `startedAt` from the public response. The endpoint is admin-facing but unauthenticated in the current skeleton; a future identity layer can remove this redaction entirely.
The web app exposes two routes:
| Route | Auth | Method | Purpose |
|---|---|---|---|
| `GET /api/health` | public | GET | Returns `buildHealthStatus({ web, started }, startedAt)`. Cheap; 120 req/min. |
| `GET /api/doctor` | admin token in production | GET | Returns `{ report, stats }`. Moderately expensive (4 parallel HTTP probes); 12 req/min. |
| `GET /admin/health` | admin token in production | GET | Renders `DoctorReport` as a server component with summary badges + capacity card. |
All three pass through `withApiSecurity` (in `lib/security-headers.ts`), which applies:
- `X-Content-Type-Options: nosniff`
- `X-Frame-Options: DENY`
- `Referrer-Policy: strict-origin-when-cross-origin`
- `Permissions-Policy: camera=(), microphone=(), geolocation=()`
- A Redis-backed production rate limit (keyed by route + trusted caller IP).
- A 405 + `Allow` header for any method that is not in the allowlist.
CSRF and an identity layer are deferred until the first state-changing route lands (Phase 2 of the roadmap). The intent is documented as a TODO in `lib/security-headers.ts` style comments.
## What is not implemented yet
The spec calls for several things this skeleton deliberately leaves for later:
- **Real TCP probes for PostgreSQL / Redis.** Today these return `true` optimistically. A real implementation opens a `pg` / `ioredis` connection and pings; both are already in the dependency surface but not wired in (see `probePostgres`/`probeRedis` in `lib/doctor.ts`).
- **UDP reachability probe.** Always `null` today. A real probe sends a STUN binding request and checks the response; deferred because it adds a runtime dep (`stun` or `node-turn`).
- **Nginx config test.** Lives in the `infra/` layer (not the JS app).
- **Disk snapshot from the real filesystem.** Currently a single env var. A future iteration shells out to `df -P` on Linux or reads `Get-PSDrive` on Windows.
- **Admin auth on `/admin/health` and `/api/doctor`.** The route is reachable today; the next pass introduces the user/session model.
- **Webhook / email / banner alert delivery.** The spec describes three alert channels; this skeleton emits the report only. The contract is that anything reading `/api/doctor` can drive its own channel.
- **Continuous / periodic scheduling.** Doctor is on-demand today. A cron tick (60 s health / 15 min full report) is the next step — and the 15-minute debounce rule for duplicate alerts is also future work.
These are listed in `docs/CHANGELOG.md` under "Out of Scope (intentionally deferred)".
## Security Hardening Update
Current behavior after the security pass:
- `GET /api/health` remains public and minimal.
- `GET /api/doctor` is admin-facing. In production it requires
`LOBBYFORGE_ADMIN_TOKEN` through the `x-lobbyforge-admin-token` header.
- `GET /admin/health` is admin-facing. In production it reads the same token
from the `lf_admin_token` cookie.
- `LOBBYFORGE_ADMIN_TOKEN` must be at least 32 characters.
- Non-production remains open so local development and tests do not need a
manual admin-login setup.
- Full role-based instance-admin sessions are still a later product feature.