573 lines
32 KiB
Markdown
573 lines
32 KiB
Markdown
# Minimal Deployment
|
|
|
|
Last verified: 2026-08-27.
|
|
|
|
This page records production running exact deployed application/runtime revision
|
|
`267e07340ba9ec85c1082d58da89b289b12c3726`, release `267e073`, and the single
|
|
Browser `screener-v12` contract. Product direction and pending work are owned by
|
|
[project memory](./project-memory.md) and [the TODO ledger](./todo.md).
|
|
|
|
This section documents the accepted UDP-only deployment contract: one Node.js
|
|
process provides the built Web client, room API, and WebSocket signaling behind
|
|
Caddy or nginx; ordinary peer ICE advertises only STUN, while Browser LiveKit
|
|
PCs configure no external ICE server and retain LiveKit-signaled UDP candidates.
|
|
LiveKit supplies bounded SFU capacity. Normal media remains distributed
|
|
through direct or peer edges whenever those paths work. Production includes
|
|
Browser SFU ICE isolation and leaves representation construction and selection
|
|
to pinned LiveKit with Dynacast and server send-side BWE.
|
|
|
|
A deployment may additionally provide one dedicated single-node LiveKit process
|
|
as the current controller's automatic final media fallback. This capacity is
|
|
dormant unless the complete public URL, private API URL, key, secret, and capacity
|
|
tuple is configured. It does not replace the P2P path,
|
|
the peer-assisted experiment, or required STUN discovery. LiveKit remains
|
|
ICE/UDP only. Ordinary peer ICE remains STUN-only and production coturn uses the
|
|
tracked STUN-only configuration with TCP/TLS disabled. The source and production
|
|
configure no TURN, ICE/TCP, media TCP, or TLS-relayed media.
|
|
|
|
Production runs exact `267e07340ba9ec85c1082d58da89b289b12c3726`, release
|
|
`267e073`, from `/opt/screener/releases/267e073`. The immutable runtime tar
|
|
SHA-256 is
|
|
`6a04af6390fa44e0667e3d1a730c665c6a2401f51872f3168044d70133515481`.
|
|
Its 41-file path/size/hash manifest SHA-256 is
|
|
`14e0b509397f822394a53110f17993dc625e80df4eca654dfa2bc817854928c8`.
|
|
Local and public `/healthz` return 200; Screener, LiveKit, coturn, and nginx are
|
|
active with `NRestarts=0`. The public main Browser asset is
|
|
`assets/index-lKEEgpx8.js` with SHA-256
|
|
`5f9ec937138d66220e727c01913cff85a9e82b9a8a1816c5a4dbd3d508e37369`.
|
|
|
|
The release deploys the single Browser `screener-v12` wire and one internal,
|
|
content-independent H.264 sender gate with VP8 fallback across Browser direct,
|
|
browser-relay, and SFU video. It retains pinned LiveKit default screen-share
|
|
representations, Dynacast and server send-side BWE without an application layer
|
|
selector, video `contentHint = "motion"`, and a local share-locked
|
|
`VP8 | Auto | H264` Host selector without codec wire state. Random four-digit
|
|
rooms use a 24-hour dormant lease, one room-lived 22-character grant,
|
|
orthogonal `open | private` grant/code admission,
|
|
20-Viewer room admission, the uniform endpoint media-copy cap `2`, and the
|
|
one-controller exact-candidate route runtime. Production configures
|
|
`ROOM_DATABASE_PATH=/var/lib/screener/rooms.sqlite`; systemd owns that single
|
|
`0700` StateDirectory and the database is `0600`. Only room authority survives
|
|
an application restart; participants, routes and media rebuild from fresh
|
|
process state.
|
|
LiveKit is dedicated, has `room.auto_create: false` and
|
|
`max_participants: 21`, and the application derives deployment admission as
|
|
9,000 publication ingress plus `9000 * MAX_VIEWERS_PER_ROOM` subscription
|
|
egress handles. Coturn listens only on UDP 3478 for STUN;
|
|
LiveKit media listens on UDP 7882. The stable application firewall table
|
|
`inet bonfire_filter` has SHA-256
|
|
`afaf9d066e9f292d8bd19032b38c0978ccf60dd01c2fe7f210968ff751f90534`,
|
|
and `/etc/nftables.conf` has SHA-256
|
|
`d57cab70b455e6ddd5cd4688c354b9af5e1d4fb9e9b67c6b85ade0da347cb1d0`;
|
|
the whole live ruleset is not a stable identity because fail2ban owns a dynamic
|
|
address set.
|
|
These bounds are fail-safe admissions, not throughput or quality claims.
|
|
The retained local 20-Viewer Browser smoke and open real-network boundaries are
|
|
owned by [verification status](./verification-status.md).
|
|
|
|
## Topology and prerequisites
|
|
|
|
Use two DNS names, for example `share.example.com` and `stun.example.com`. They
|
|
may resolve to the same public IP: the Web ingress owns TCP 443 while the
|
|
self-hosted STUN listener owns UDP 3478.
|
|
|
|
```text
|
|
browser -- HTTPS/WSS --> Caddy or nginx :443 --> Node.js :8787
|
|
browser <------------ DTLS-SRTP P2P ------------> browser
|
|
browser -- ordinary-peer STUN/UDP --> coturn :3478
|
|
browser <---------- DTLS-SRTP/UDP ----------> LiveKit :7882
|
|
```
|
|
|
|
The minimum runtime is Node.js 24 LTS and coturn 4.17.2 or a newer patched
|
|
release. Provision a valid TLS certificate for the Web name. Enable operating system time
|
|
synchronization and keep the Node application port reachable only from its
|
|
reverse proxy. A single Node process is intentional. Participants, signaling,
|
|
routes and media remain in process memory; optional SQLite preserves only room
|
|
authority and does not restore live WebRTC connections.
|
|
|
|
Build and validate the exact revision on a build host before starting it:
|
|
|
|
```sh
|
|
npm ci
|
|
npm run check
|
|
npm start
|
|
```
|
|
|
|
Do not run the repository full check, typecheck, or build on the current 960 MiB
|
|
no-swap production host. An inactive-release check before the `261e980c` cutover
|
|
temporarily starved HTTP and SSH; exact `691863e` remained current and all four
|
|
services stayed at `NRestarts=0`. The retained production path uses a locally
|
|
verified dist artifact plus one `npm ci --omit=dev --ignore-scripts --no-audit --no-fund`
|
|
in a transient unit bounded to 30% CPU, 160 MiB memory high, 256 MiB memory max,
|
|
no swap, and 120 seconds. It then runs only runtime imports and the
|
|
artifact/inode/permission gates.
|
|
|
|
Each immutable release must own an independent dependency tree. Never hard-link
|
|
`node_modules` or another file that deployment may `chown`, `chmod`, replace, or
|
|
remove: metadata changes would also mutate another immutable release. A copy or
|
|
copy-on-write reflink is acceptable only after an inode audit confirms that the
|
|
old and new regular-file sets have zero shared inodes. Do not recursively change
|
|
permissions until that check passes.
|
|
|
|
Routine application releases use the tracked
|
|
[`package-app-release.mjs`](../scripts/package-app-release.mjs) build-host entry
|
|
and [`release-app.sh`](../deploy/release-app.sh) server entry. The packager accepts
|
|
only a clean exact Git revision and already-built `dist`, emits one normalized
|
|
`tar.gz`, an exact path/size/hash manifest, and a non-secret release descriptor,
|
|
then extracts and verifies its own result. The archive also carries the full Git
|
|
revision. The server entry accepts only that descriptor, verifies the uploaded
|
|
hashes and revision, extracts into a new release directory, compares the exact
|
|
regular-file set with the manifest, installs an independent runtime dependency
|
|
tree under the existing resource bounds, and performs the atomic cutover and
|
|
bounded recovery checks. Release-specific scripts must not be copied and edited.
|
|
|
|
Infrastructure, configuration, secrets, and persistent state are outside this
|
|
application wrapper. A release that changes one of those surfaces uses a separate
|
|
scoped transaction with its own pre-change value and recovery path; it must not
|
|
make the routine application primitive conditional on that one-off change.
|
|
|
|
A routine application-only cutover leaves infrastructure, configuration, secrets,
|
|
and persistent state untouched. Verify a new immutable artifact, switch to it
|
|
atomically, and guarantee the pre-cutover application release only through
|
|
bounded health and postflight checks. It has no retention contract afterward and
|
|
is not a maintained backup. When a task actually changes infrastructure,
|
|
configuration, secrets, persistent state, or an irreversible surface, define and
|
|
verify recovery only for the touched surfaces before changing them.
|
|
|
|
In a strict-shell deployment, expected service states are data, not command
|
|
failures. Do not call `systemctl is-active` bare under `set -e`/`ERR`: an
|
|
intentionally stopped service returns a nonzero status and can trigger a false failure path. Read
|
|
`ActiveState` with `systemctl show`, compare the returned string explicitly, and
|
|
keep stop, symlink switch, start, health check, and any release restoration as
|
|
separate steps. Preflight every inspection dependency before changing a
|
|
service. After a start, poll health for a short bounded window instead of treating
|
|
one request during startup as a
|
|
failed release; retain the last failure while still enforcing the deadline.
|
|
After starting coturn, likewise wait boundedly for its UDP 3478 socket before
|
|
asserting STUN readiness; `ActiveState=active` can precede socket readiness.
|
|
|
|
Run `npm start` under a service supervisor that injects the environment, restarts
|
|
on failure, and applies bounded logs. For a simple untracked environment file,
|
|
the equivalent direct launch is
|
|
`node --env-file=.env.production dist/server/server/index.js`. Do not expose port
|
|
8787 to the Internet. The application default is `0.0.0.0` for LAN development
|
|
and container compatibility, while the bare-metal production baseline below
|
|
explicitly sets `LISTEN_HOST=127.0.0.1` because only the same-host reverse proxy
|
|
should connect. A container can instead use `0.0.0.0` and enforce the intended
|
|
boundary with port publishing rules or a host firewall.
|
|
|
|
The optional same-host LiveKit layout needs no additional public hostname:
|
|
`LIVEKIT_URL=wss://share.example.com` and nginx proxies only current `/rtc/v1*`
|
|
requests to LiveKit. Retired `/rtc` and `/rtc/validate` endpoints return 404
|
|
without request-target logging. LiveKit's HTTP/WebSocket listener on TCP 7880 is
|
|
private to nginx and the Screener process uses
|
|
`LIVEKIT_API_URL=http://127.0.0.1:7880` for `RoomService`; WebRTC media reaches
|
|
LiveKit directly on UDP 7882. No unrelated application may create rooms on this
|
|
instance, and its tracked configuration sets `room.auto_create: false`.
|
|
Install
|
|
[`screener-livekit-readiness.conf.example`](../deploy/systemd/screener-livekit-readiness.conf.example)
|
|
as a `screener.service.d` drop-in on this layout. `After=` orders the services,
|
|
while the bounded `ExecStartPre` waits for TCP 7880 readiness; ActiveState alone
|
|
does not prove the LiveKit control listener is accepting connections.
|
|
|
|
## Candidate boundary and recovery
|
|
|
|
The accepted target for `share.bonfire.icu` remains distributed and automatic:
|
|
direct/peer UDP first, with one Host publication available to authorized SFU
|
|
subscriptions when a logical edge cannot use peer transport. SFU-fed and
|
|
peer-fed endpoints use the same provisional-child transaction, and the exact
|
|
candidate child's first newly decoded frame commits that edge. Server-assisted
|
|
subscriptions and allocations use independent deployment admission rather than
|
|
an endpoint-cap or fixed-root rule.
|
|
|
|
The repository requires STUN and ordinary peer connections receive STUN-only
|
|
ICE. An existing logical edge prefers direct/peer UDP; the only application
|
|
suffix is the Host publication/SFU path. LiveKit participants receive only
|
|
revision-bound `sfu-config` URL/token messages and negotiate within LiveKit's
|
|
separate UDP-only ICE domain. The accepted Browser publisher and subscriber
|
|
connect contract provides an explicit empty ICE-server list, retains the UDP
|
|
candidates delivered through LiveKit signaling, and lets standard ICE select a
|
|
nominated non-relay pair. Candidate type, count, address family, and Browser
|
|
socket allocation remain implementation observations. This does not remove
|
|
ordinary-peer STUN or the deployment STUN used by LiveKit to discover its own
|
|
public address.
|
|
|
|
`PEER_ASSISTED_MEDIA=true` is the process-wide topology/SFU switch. Every normal
|
|
room gets its own bounded controller state and ordinary peer connections remain
|
|
STUN-only. Run route or infrastructure candidates on an isolated
|
|
instance/hostname and keep the current release unchanged. If the candidate fails,
|
|
stop it and restore only the recovery boundary defined for its changed surfaces;
|
|
do not add a permanent dual-transport branch.
|
|
|
|
A memory-room release starts with empty process authority. Starting the candidate
|
|
invalidates every current production room, Host token, invitation, password
|
|
verifier, and route; open pages must refresh or explicitly create a replacement
|
|
room. No room state crosses a release boundary in either direction. Guarantee the
|
|
pre-cutover immutable application release only through the bounded postflight; it
|
|
has no retention contract afterward and is not a maintained backup. An
|
|
application-only cutover leaves the environment, unit, LiveKit, coturn, firewall,
|
|
and reverse proxy unchanged; any task that changes one of those surfaces must
|
|
establish its scoped recovery before the change.
|
|
|
|
A shared-public-IP instance can test candidate behavior, but it cannot prove the
|
|
clean-port boundary or approve broad migration. The room-1 smoke uses this
|
|
shape: nginx owns 443, LiveKit 1.13.5 owns UDP 7882, and LiveKit is fail-closed under a 192 MiB
|
|
high/256 MiB hard cgroup limit with restart disabled. Prefer a separate VM/IP
|
|
for the full gate and verify representative external networks and devices.
|
|
|
|
Coturn provides STUN binding only and must reject allocation requests.
|
|
HTTPS/WSS always remains TLS/TCP independently of the UDP-only media ladder.
|
|
|
|
## Production application environment
|
|
|
|
Keep the real values in the process secret store or an untracked, access-restricted
|
|
environment file. Node.js 24 can load such a file with `--env-file`; the tracked
|
|
`.env.example` is only a schema. Generate independent secrets with a password
|
|
manager or a cross-platform Node command such as:
|
|
|
|
```sh
|
|
node -e "console.log(require('node:crypto').randomBytes(32).toString('base64url'))"
|
|
```
|
|
|
|
Use this production baseline template:
|
|
|
|
```dotenv
|
|
NODE_ENV=production
|
|
LISTEN_HOST=127.0.0.1
|
|
PORT=8787
|
|
PUBLIC_BASE_URL=https://share.example.com
|
|
ALLOWED_ORIGINS=https://share.example.com
|
|
SITE_ACCESS_PASSWORD=<INDEPENDENT_8_TO_128_BYTE_ACCESS_KEY>
|
|
ROOM_LEASE_SECONDS=86400
|
|
ROOM_DATABASE_PATH=/var/lib/screener/rooms.sqlite
|
|
MAX_VIEWERS_PER_ROOM=20
|
|
ENDPOINT_MEDIA_COPY_CAPACITY=2
|
|
|
|
STUN_URLS=stun:stun.example.com:3478
|
|
```
|
|
|
|
To make automatic SFU fallback capacity available, add the complete tuple:
|
|
|
|
```dotenv
|
|
PEER_ASSISTED_MEDIA=true
|
|
LIVEKIT_URL=wss://share.example.com
|
|
LIVEKIT_API_URL=http://127.0.0.1:7880
|
|
LIVEKIT_API_KEY=<GENERATED_LIVEKIT_API_KEY>
|
|
LIVEKIT_API_SECRET=<INDEPENDENT_SECRET_OF_AT_LEAST_32_BYTES>
|
|
```
|
|
|
|
Supplying any stale `PEER_ICE_TURN_*` or `SELECTED_EDGE_TURN_*` key, even blank,
|
|
fails startup. Production contains none of those keys, uses the tracked
|
|
STUN-only coturn configuration, and keeps TCP 3478/5349 plus every relay range
|
|
closed. An application-only release leaves the environment, coturn configuration,
|
|
and firewall unchanged; a task that changes any of them must define recovery for
|
|
that exact set before modification.
|
|
|
|
`PEER_ASSISTED_ROOM_IDS` is retired. Supplying it, even blank, fails startup so
|
|
that a stale room-1 deployment cannot silently retain the old scope. With
|
|
`PEER_ASSISTED_MEDIA=true`, every normal room receives peer-assisted routing,
|
|
optional LiveKit fallback, and the same deployment resource/fanout/failure guards.
|
|
Every ordinary peer connection remains STUN-only. There is no browser control,
|
|
percentage rollout, or second router.
|
|
|
|
`ALLOWED_ORIGINS` must list exact `http` or `https` origins, never `*`.
|
|
`SITE_ACCESS_PASSWORD` is required in production and must contain 8 through
|
|
128 visible ASCII bytes (`0x21` through `0x7e`). It must not be reused for
|
|
LiveKit, TLS, or another service. It authorizes room creation, Host role,
|
|
and code-only Viewer attempts; it does not replace a private room grant or
|
|
password. Local development and tests may omit it. Supplying a removed access
|
|
key, even blank, fails startup.
|
|
`ROOM_LEASE_SECONDS` defaults to 86,400 seconds and owns the dormant room
|
|
lifetime. An actively connected Host prevents expiry; explicit stop or Host
|
|
disconnect starts the lease, and only the exact Host token renews it before
|
|
expiry. Viewer activity never renews ownership. Optional `ROOM_DATABASE_PATH`
|
|
enables the exact-schema SQLite stable room-authority mode; unset keeps the
|
|
lightweight memory mode. `ROOM_TTL_SECONDS` remains invalid.
|
|
Production accepts 1 through 20 and explicitly selects 20. This is an
|
|
admission limit, not evidence that every publisher, network, or quality profile
|
|
can sustain that many streams.
|
|
`ENDPOINT_MEDIA_COPY_CAPACITY` defaults to 2 and accepts only 1, 2, or 3. It is
|
|
the single server-authoritative steady outbound media-copy cap for Host and
|
|
Viewer endpoints; role, browser, UA, and visibility do not create another tier.
|
|
A peer child or the Host's single SFU publication consumes one copy and upstream
|
|
receive is free. Transition work
|
|
may reach only `min(C + 1, 3)`. A Host already sending three copies at `C=3`
|
|
must fail or wait before a fourth endpoint copy is issued.
|
|
Supplying the removed `MAX_PEER_RELAY_DOWNSTREAM_EDGES`, even blank, fails
|
|
startup.
|
|
|
|
Production release `267e073` runs the deployed server and Browser assets
|
|
atomically on `screener-v12`; every stale Browser or executable-sender wire fails
|
|
before room authority. Native senders and helpers are outside this release.
|
|
|
|
The four `LIVEKIT_*` values must either all be absent or all be present, and a
|
|
complete tuple requires `PEER_ASSISTED_MEDIA=true`. The four-digit code space
|
|
fixes ingress at 9,000 Host publications; egress is 9,000 multiplied by
|
|
`MAX_VIEWERS_PER_ROOM`. There is no separate SFU capacity configuration. An
|
|
empty tuple keeps the optional SDK and server path dormant. `LIVEKIT_URL` must be a
|
|
plain `ws:` or `wss:` origin with no `/rtc` suffix; production requires `wss:`.
|
|
`LIVEKIT_API_URL` must be a plain `http:` or `https:` origin with no path;
|
|
production permits plaintext only on loopback and otherwise requires `https:`.
|
|
`LIVEKIT_API_SECRET` must contain at least 32 bytes and must not reuse
|
|
`SITE_ACCESS_PASSWORD`. These credentials authorize short-lived LiveKit room
|
|
tokens and do not provide E2EE: the LiveKit operator can access ordinary SFU
|
|
media. The LiveKit instance is dedicated to this Screener process and has
|
|
`room.auto_create: false`. Screener first binds the configured application
|
|
listener exclusively; a competing process that cannot bind makes no LiveKit
|
|
call. While bound but not initialized it returns `503` and has no signaling
|
|
upgrade handler. It then rejects any foreign room name, deletes every stale
|
|
managed room, confirms the namespace empty, and begins serving. Each
|
|
exact-generation room is then created before its token is issued. Reserved,
|
|
committed, and draining generations remain charged until
|
|
`DeleteRoom` succeeds and a follow-up lookup proves absence. Run only one
|
|
application process until a shared atomic admission and lifecycle owner exists.
|
|
|
|
For the first candidate canary, use an isolated instance and one recorded room.
|
|
Restart the application, verify startup removes the now-stale managed LiveKit
|
|
room before serving traffic, and create a fresh application room after startup.
|
|
Hold the application port with another process and verify a competing
|
|
startup makes zero LiveKit calls. Verify that both the replacement room and a
|
|
second normal room contain
|
|
`mediaMode: "peer-assisted"`, with independent route revisions and no shared
|
|
room state or capacity bypass. Exercise join, offer and answer, stale-token
|
|
reconnect after abort, Host signaling loss with and without a live Host
|
|
participant, stop, restart, and room deletion in both rooms. Confirm each drain
|
|
keeps capacity charged until the room is absent. Ordinary Web clients remain
|
|
STUN-only. Roll back by stopping Screener, proving every managed LiveKit room
|
|
absent, and restoring the exact recorded application environment, application
|
|
release, LiveKit configuration, coturn configuration, and firewall snapshot as
|
|
one unit; or direct traffic to the unchanged old instance. During migration,
|
|
remove retired route configuration in the same coherent change; the process
|
|
flag explicitly enables the all-room controller.
|
|
|
|
Only `POST /api/site-access` accepts the site access secret in an
|
|
`Authorization: Bearer` header from an exact allowed Origin. Success returns a
|
|
12-hour stateless HMAC-SHA256 cookie with `HttpOnly`, `SameSite=Strict`,
|
|
`Path=/`, bounded `Max-Age`, and, under production HTTPS, `Secure` plus an
|
|
`__Host-` name. The cookie contains no account or server-side session ID. There
|
|
is no account database, JWT, session map, or logout endpoint.
|
|
|
|
Before an access-boundary release, set `CHROME_PATH` to a local Chrome or
|
|
Chromium executable and run `npm run gate:access-privacy`. The gate creates a
|
|
loopback in-memory room with an isolated browser profile, navigates the fragment
|
|
through CDP rather than a process argument, disposes that context, and emits only
|
|
fixed booleans and counts. It checks tracked request/nginx/application logs,
|
|
browser storage, fragment cleanup, absence of a persistent room file, and runs
|
|
the complete signaling suite containing the strong rotate/revoke teardown case.
|
|
It never connects to production or accepts a production credential; exact
|
|
production request/nginx/journal inspection remains separate.
|
|
|
|
nginx limits this exact endpoint per source at `5r/m` with `burst=5 nodelay` and
|
|
returns 429 when exhausted. The limiter uses nginx shared memory; do not add the
|
|
secret, Authorization header, request body, or a new source-address field to
|
|
logs. `POST /api/rooms` accepts only the site-access cookie and strict JSON
|
|
with an explicit code-entry policy and optional room password; a Bearer header
|
|
is not an alternate creation
|
|
path. `/signal` still admits a cookie-free browser after Origin and capacity
|
|
checks and records the cookie state at upgrade. Host requires it with the room
|
|
Host token. A Viewer may omit it only when presenting a valid, unexpired grant
|
|
for that exact room; otherwise the server rejects before evaluating code entry.
|
|
|
|
Every room receives an independent invitation
|
|
`/r/{code}#v={room-scoped-grant}`; the fragment does not enter HTTP or WebSocket
|
|
request targets. The page validates it, writes it only to that room's
|
|
`sessionStorage`, and immediately replaces the visible URL with `/r/{code}`.
|
|
The Host may update or revoke that grant without changing code-entry policy.
|
|
|
|
Room allocation and lifetime use one deliberately small model:
|
|
|
|
- random unallocated codes come only from `1000` through `9999`, never duplicate
|
|
an active code, use the fixed 9,000-room capacity, and return on room release;
|
|
- active Host sharing does not expire; stop, capture-track end, or Host
|
|
disconnect starts `ROOM_LEASE_SECONDS`;
|
|
- only the exact Host token resumes and renews before expiry, Viewer activity
|
|
cannot, and expiry invalidates the code, credentials, verifier, participants,
|
|
and routes; and
|
|
- lightweight-mode process restart has the same fail-closed effect; stable mode
|
|
restores only unexpired room authority and requires fresh sessions/routes.
|
|
|
|
The server keeps only digests/verifiers for Host tokens, Viewer grants, and
|
|
optional room passwords. In stable mode those room-authority values, policy,
|
|
authorization generation and lease use one exact SQLite schema; live members,
|
|
routes and SFU state remain memory-only. The same browser keeps its raw Host token,
|
|
creation profile and non-expiring preferred code locally. Production stable-mode
|
|
uses the service-owned `/var/lib/screener` StateDirectory. Any new activation or
|
|
path change requires a separately verified writable directory plus configuration
|
|
and database backup/restore boundaries before changing `ROOM_DATABASE_PATH`.
|
|
|
|
Production startup requires one to eight syntactically valid `stun:` URLs
|
|
before the server listens. This validates shape only; it does not prove DNS,
|
|
firewall, NAT mappings, or external STUN reachability. The current application
|
|
rejects `TURN_URLS`, `TURN_SHARED_SECRET`, or `TURN_CREDENTIAL_TTL_SECONDS` when
|
|
any key is present, including with an empty value. Remove those obsolete keys
|
|
from the candidate environment; startup fails instead of pretending they enable
|
|
compatibility.
|
|
|
|
The rejected `PEER_ICE_TURN_*` and `SELECTED_EDGE_TURN_*` candidate names must
|
|
remain absent from production and fail startup even when blank. Ordinary peer
|
|
edges remain STUN-only and the application has no TURN configuration surface.
|
|
|
|
## HTTPS and WSS ingress
|
|
|
|
Terminate public TLS at Caddy or nginx and proxy every application path to the
|
|
single Node process. `/signal` must preserve the WebSocket upgrade, `Host`, and
|
|
`Origin`, and its idle/read timeout must be longer than a normal sharing session.
|
|
Do not cache `/api/*` responses. Caddy's minimal same-host form is:
|
|
|
|
```caddyfile
|
|
share.example.com {
|
|
reverse_proxy 127.0.0.1:8787
|
|
}
|
|
```
|
|
|
|
An equivalent nginx location is:
|
|
|
|
```nginx
|
|
location / {
|
|
proxy_pass http://127.0.0.1:8787;
|
|
proxy_http_version 1.1;
|
|
proxy_set_header Host $host;
|
|
proxy_set_header Upgrade $http_upgrade;
|
|
proxy_set_header Connection "upgrade";
|
|
proxy_read_timeout 1h;
|
|
}
|
|
```
|
|
|
|
The tracked
|
|
[`share.bonfire.icu.conf.example`](../deploy/nginx/share.bonfire.icu.conf.example)
|
|
follows this simple model and listens directly on public TCP 443. It does not
|
|
need an SNI transport router. Exact `/rtc` and pinned-v0 `/rtc/validate` requests
|
|
return 404 with access and request-line error logging disabled; only `/rtc/v1*`
|
|
is proxied. Current versioned requests carry a short-lived LiveKit JWT in the
|
|
query string, so no `/rtc*` target may fall through to the site's ordinary logs.
|
|
|
|
When Certbot manages the Web certificate, install the tracked
|
|
[`reload-nginx.sh`](../deploy/certbot/reload-nginx.sh) as an executable under
|
|
`/etc/letsencrypt/renewal-hooks/deploy/`. Run `certbot renew --dry-run` after
|
|
the first certificate is installed. This hook reloads only the Web ingress.
|
|
|
|
Keep the proxy's access-log retention bounded and access controlled. Requests to
|
|
`/r/{code}` put the room code in the path, so access logs can contain room codes
|
|
as well as network metadata. They must not be treated as public artifacts. For
|
|
a private room, the grant remains in the fragment and is not part of that
|
|
request target.
|
|
|
|
For a process-level liveness probe, send `GET /healthz`. A running process
|
|
returns HTTP 200 with `{"status":"ok"}` and `Cache-Control: no-store`; other
|
|
methods return 405 with `Allow: GET`. The endpoint does not inspect room state,
|
|
STUN/SFU reachability, or any external dependency, so use it only to decide whether
|
|
the Node process can accept HTTP requests. It is not a deployment-readiness or
|
|
end-to-end media check.
|
|
|
|
## Optional single-node LiveKit fallback
|
|
|
|
Use the tracked
|
|
[`livekit.yaml.example`](../deploy/livekit/livekit.yaml.example) and
|
|
[`livekit.service.example`](../deploy/systemd/livekit.service.example) as the
|
|
minimal single-node baseline. Install a reviewed, pinned LiveKit Server release
|
|
from its official release assets, generate an independent key pair with
|
|
`livekit-server generate-keys`, and place only the YAML key mapping in
|
|
`/etc/livekit/keys.yaml`. Keep that file owned by the `livekit` service account
|
|
with mode `0600`; put the same values in Screener's untracked process secrets.
|
|
|
|
The example deliberately omits Redis and every recording, ingress, egress,
|
|
webhook, external-TURN, and embedded-TURN service. It explicitly sets
|
|
`tcp_port: 0` and `allow_tcp_fallback: false`, and points `stun_servers` at the
|
|
deployment's self-hosted STUN listener for server-side public-IP discovery and
|
|
to prevent pinned LiveKit from substituting a public default in its join
|
|
response. Current Screener source gives Browser publisher/subscriber connects an
|
|
explicit empty list, so those SFU PCs do not use either response after the
|
|
pending application cutover. The tracked `rtc.congestion_control` enables
|
|
`use_send_side_bwe` for LiveKit-owned per-subscriber simulcast selection. Do not
|
|
add Redis for this one-node workload.
|
|
The service journal is the diagnostic log; keep its retention finite and access
|
|
restricted. Pinned LiveKit 1.13.5 includes raw PublisherOffer SDP in info-level
|
|
join records, so the tracked production baseline uses `logging.level: warn` and
|
|
`pion_level: error`. Raise either only in an isolated, short-lived canary and
|
|
remove its journal afterward. Never persist JWTs, SDP, ICE candidates, API
|
|
secrets, or full `/rtc` and `/rtc/*` request targets.
|
|
|
|
Configure both the host firewall and the provider firewall independently:
|
|
|
|
| Destination | Protocol | Exposure | Purpose |
|
|
| --- | --- | --- | --- |
|
|
| `127.0.0.1:7880` | TCP | Private | nginx to LiveKit signaling/API |
|
|
| public host `:7882` | UDP | Public | WebRTC ICE/UDP mux |
|
|
|
|
LiveKit may listen beyond loopback while it discovers and advertises public
|
|
media addresses, so the public-firewall denial of TCP 7880 is mandatory; nginx
|
|
must be its only ingress. UDP 7882 is a direct media listener and must not be
|
|
placed behind the HTTP reverse proxy. If the host is behind NAT, forward it
|
|
without port translation and verify the advertised candidate from an external
|
|
network. UDP-blocked networks are expected to fail clearly in this candidate;
|
|
do not set `rtc.tcp_port` above zero, enable TCP fallback, or add TURN to make a
|
|
canary pass silently.
|
|
|
|
## Self-hosted STUN
|
|
|
|
This subsection describes the accepted and deployed STUN-only boundary.
|
|
Production uses the tracked configuration and keeps TCP 3478/5349 plus every
|
|
relay range closed.
|
|
|
|
Copy [`deploy/coturn/turnserver.conf.example`](../deploy/coturn/turnserver.conf.example)
|
|
to an untracked service-owned location and use the tracked
|
|
[`coturn.service.example`](../deploy/systemd/coturn.service.example). The config
|
|
uses coturn's documented `stun-only` mode, ignores every TURN allocation request,
|
|
and disables TCP and TLS client listeners. It has no auth secret, user database,
|
|
relay port range, certificate, quota, or TURN REST credential. Pinned coturn
|
|
4.17.2 deprecates `no-dtls`, so the tracked config does not use that obsolete
|
|
switch or claim a separate DTLS listener gate.
|
|
|
|
On a single-homed public host, automatic listener selection is sufficient. On a
|
|
multi-homed host, set `listening-ip` to the intended public interface. Open only
|
|
UDP 3478 and keep TCP 3478/5349 plus every old relay range closed. The same public
|
|
IP may serve HTTPS/WSS on TCP 443 and LiveKit media on UDP 7882.
|
|
|
|
The example writes informational logs to stdout. Apply finite supervisor
|
|
retention and restrict access because STUN sees client IP addresses. Do not
|
|
enable `verbose` or `log-binding` continuously. `stun-only` is a hard candidate
|
|
boundary, not a hidden unauthenticated TURN service.
|
|
|
|
Open only these public listeners:
|
|
|
|
| Destination | Protocol | Purpose |
|
|
| --- | --- | --- |
|
|
| `share.example.com:80` | TCP | HTTP redirect and ACME challenge |
|
|
| `share.example.com:443` | TCP | HTTPS and WSS |
|
|
| `stun.example.com:3478` | UDP | STUN binding only |
|
|
| host public address `:7882` | UDP | LiveKit WebRTC ICE/UDP mux |
|
|
|
|
## Verification
|
|
|
|
Run these checks from real external networks before closing route acceptance:
|
|
|
|
1. Open the official [Trickle ICE sample](https://webrtc.github.io/samples/src/content/peerconnection/trickle-ice/)
|
|
and test the configured STUN URL. It must produce an `srflx` candidate over
|
|
UDP and no `relay` candidate. Confirm TCP 3478/5349 and the old relay range
|
|
are closed externally.
|
|
2. Create a normal Screener room on two different networks. Confirm media flows
|
|
and the selected-pair stats report a non-relay path when direct ICE succeeds.
|
|
3. On a normal room, exhaust a peer route and verify exactly one Host
|
|
publication serves only the admitted SFU subscriptions over LiveKit UDP 7882.
|
|
Peer descendants stay on ordinary direct UDP and every non-server endpoint
|
|
obeys the configured `ENDPOINT_MEDIA_COPY_CAPACITY`. On each Browser SFU PC,
|
|
verify no local external STUN/TURN server is configured, a nominated
|
|
non-relay UDP pair reaches an authorized LiveKit-signaled SFU candidate, and
|
|
the exact Viewer presents a decoded frame. Do not gate on candidate type or count.
|
|
4. Block all UDP and verify bounded recovery ends clearly without ICE/TCP,
|
|
TURN, or a long pseudo-connected path.
|
|
5. Exercise root departure, reconnect, SFU unavailable, route prepare rollback,
|
|
stop, and source/profile changes. Unaffected subtrees must not migrate.
|
|
6. Repeat at 1, 3, 5, and 20 viewers across representative consumer networks.
|
|
Record selected protocol, RTT, bitrate, frame rate, packet loss, publisher
|
|
upload/encode load, LiveKit ingress/egress, and final decoded quality. A real
|
|
public-network 1:20 session remains unverified; the passed local synthetic
|
|
1:20 smoke does not replace this heterogeneous-network quality gate.
|
|
|
|
References: [coturn 4.17.2 release](https://github.com/coturn/coturn/releases/tag/4.17.2),
|
|
[pinned turnserver documentation](https://github.com/coturn/coturn/blob/4.17.2/README.turnserver),
|
|
the [pinned example configuration](https://github.com/coturn/coturn/blob/4.17.2/examples/etc/turnserver.conf),
|
|
[RFC6265bis](https://datatracker.ietf.org/doc/draft-ietf-httpbis-rfc6265bis/),
|
|
[LiveKit firewall guidance](https://docs.livekit.io/deploy/admin/firewall/), and
|
|
the [pinned LiveKit configuration sample](https://github.com/livekit/livekit/blob/v1.13.5/config-sample.yaml).
|