# Architecture ## Core model `crates/core` is transport-agnostic, matching the shape of srt-router's `crosspoint-core`: it knows nothing about HTTP, mDNS, or how a device is actually reached. - **`Device`** — pure metadata: id, name, host, tags, credentials, and whether it was discovered or added manually. Play is decode-only (NDI/SRT source → HDMI out), so there is no per-device encode/decode mode. - **`Registry`** — an `Arc`-shareable, JSON-file-persisted map of devices. Groups are *derived*, not stored: `Registry::groups()` scans every device's `tags` and buckets ids by tag. That's what gives "one device, N groups" for free — there's no group entity to keep in sync. It's also what batch edit targets: applying a settings patch to a group just means applying it to every device id that tag currently maps to. - **`DeviceClient`** (trait) — everything flock can ask a device to do: read/write its Status, Network, Decode, and System settings, plus reboot. This is the seam between "what flock knows about a device" and "how flock actually talks to it." Two implementations exist: `flock-device-mock` (the simulated Play, default) and `flock-device-http` (a real HTTP client, confirmed against actual hardware — see below). - **`DeviceClientProvider`** (trait) — resolves a `Device` to its `DeviceClient`. The binary picks the provider at startup from `Config::provider` (`"mock"` or `"http"`). ## Credentials are encrypted at rest, transparently Every `Device.credentials.password` is plaintext *in memory* — that's what `DeviceClient` implementations and the API layer's `Device::redacted()` (for responses) both expect, and changing that would ripple everywhere. What changed is what `Registry` writes to disk: `crates/core/src/crypto.rs` encrypts each password with AES-256-GCM right at the `save()`/`load_or_new()` boundary, so `registry.json` itself never holds a plaintext password, whether that file ends up in a backup, a synced folder, or a support bundle. - The key is generated on first run and stored in `credentials.key`, a sibling of `registry.json` (so `docker-compose`'s existing registry volume mount covers it with no config changes) - chmod `600` on unix. Losing this file makes existing stored passwords undecryptable; the registry itself still loads fine; the operator just has to re-enter passwords. - Each encrypted value is stored as `flock-enc-v1::`. That explicit prefix is what makes a **legacy plaintext `registry.json` written before this existed** load correctly with zero migration step: `decrypt_or_pass_through` just returns anything without the prefix unchanged, and the very next save encrypts it going forward. - This only covers **credentials at rest** — the existing API-layer redaction (`Device::redacted()`, still showing `"********"` to the frontend) and flock's own auth (below) are unrelated, separate concerns. ## flock's own auth is optional, off by default Every concern above is about a *device's* BirdUI password. This is the separate question of who can reach *flock itself* — historically nobody-gated at all, matching BirdUI's own trusted-LAN model (whoever's on the LAN has full control). `admin_password` in `config/flock.toml` turns on a single shared login for flock's own web UI/API, for anyone who wants more than bare network trust without standing up real multi-user auth (out of scope for a single-operator LAN tool — see Phase 3 in docs/roadmap.md). - **Off by default** (`Config::admin_password: Option` is `None`) — every route behaves exactly as before. Setting it is the only opt-in step. - **One shared password, one session cookie.** `crates/web/src/auth.rs`: `POST /api/login` compares the given password against `admin_password` (constant-time, hand-rolled — cheap enough to not need a dedicated crate) and, on success, hands out a `flock_session` cookie (`HttpOnly`, `SameSite=Strict`) backed by a random token in an in-memory `HashSet`. `POST /api/logout` clears both. There's no per-user account model, no password reset flow, no persistence for sessions — a process restart logs everyone out, which is an acceptable tradeoff for what this is. - **Middleware, not per-handler checks.** `crates/web/src/lib.rs::app()` splits routes into two sub-routers: `public` (static frontend, `/health`, `/api/login`, `/api/logout`) and `protected` (everything else — every `/api/*` device/registry/settings route, and `/ws`), with `axum::middleware::from_fn_with_state(state, auth::require_auth)` applied only to `protected` via `route_layer` (which, unlike a blanket `.layer()`, only covers routes defined directly on that router — not ones merged in afterward). `require_auth` is a no-op pass-through whenever `admin_password` is `None`. - **Frontend gate is a single 401 check, not a separate "is auth enabled" endpoint.** `app.js`'s `init()` does a bare `GET /api/state`: a 401 means a session is required (show the login screen), anything else means there's no gate at all (start the app immediately) — the same request that would happen anyway doubles as the auth probe. - **Login is rate-limited, process-wide, not per-client.** `LoginGuard` (in `auth.rs`, held on `AppState` like everything else) locks out further `POST /api/login` attempts for 30s after 5 failures — even a *correct* password is rejected with `429` while locked, confirmed live. It's process-wide rather than per-IP deliberately: there's only one password to guess in the first place, so there's no useful notion of "which caller" to scope a limit to. A success resets the counter. - **`Config`'s hand-rolled `Debug` impl redacts `admin_password`** — it's logged at startup (`tracing::info!(?config, ...)`) alongside every other setting, and a derived `Debug` would have put the plaintext password in the log. ## Confirmed against real hardware Everything in this section was verified by logging into an actual BirdDog PLAY unit (firmware `BirdDog PLAY 1.0.18`) rather than inferred from secondhand documentation. It supersedes the guesses this project started with. - **BirdUI is server-rendered HTML, not a JSON API.** Real pages: `/dashboard`, `/network`, `/settings`, `/videoset` (AV Setup), `/logout`. Login is `POST /login` with a single `auth_password` field; the session lives in a `BirdDogSession` cookie. `flock-device-http` logs in lazily, detects an expired/missing session by checking for the login page's `auth_form` marker in a fetched page, and retries once. - **Every write is read-modify-write.** There's no JSON PATCH — flock GETs the current page, scrapes every input/select's current value (`crates/device-http/src/form.rs::scrape_form_fields`), overrides only the fields it manages, and POSTs the *entire* field map back as `multipart/form-data` (matching the real `
` encoding). This preserves whatever flock doesn't model — e.g. the shared template's hidden Encode fields, HDMI OSD timeout, genlock source — instead of silently clearing them. - **`/videoset` genuinely has both Encode and Decode markup**, with the Encode forms present in the DOM but `display:none` — confirms the shared firmware template covers encode-capable siblings too, and that dropping the Encode tab for Play was correct. - **Real field names are messier than flock's own model** (by design — see `crates/core/src/settings.rs`'s doc comment): e.g. `net_method`/`net_address`/ `net_avahi` for network, `Txpm`/`Rxpm` for transmit/receive preferred method (four options: `TCP`/`UDP`/`Multicast`/`RUDP` — no hyphen, unlike BirdUI's own prose which calls it "R-UDP"), `decode_ColorSpace` (`YUV`/`RGB`), `decode_NDIAudio` (`NDIAudioEn`/`NDIAudioDis`), `decode_ScreenSaverMode` (`CaptureSS`/`BlackSS`/`BirdDogSS`), `decode_TallyMode` (`TallyOn`/`TallyOff`/`VideoMode`). `flock-device-http` maps between these and flock's own settings shapes. - **Setting the decode source is not just a text field — verified live, including a full write.** The visible "Decode Source Name" text input (`dec0_source_name`) is itself hidden (`display:none`) in the real UI and posting to it alone silently no-ops. The actual mechanism, reverse engineered from the page's own JS and confirmed by driving it end-to-end through flock against real hardware: 1. The browser's source-picker dropdown is populated not from BirdUI's HTML at all, but from a **separate JSON API the device runs on port 8080**: `GET http://:8080/List` returns `{"source name": "ip:port", ...}` for every NDI source it currently sees. `flock-device-http::fetch_source_list` calls this at write time to resolve a chosen name to its ip (see "NDI source routing model" below for why flock no longer also queries this per-device just to populate a picker). 2. Applying a source requires POSTing `dec0_source_name`, `dec0_source_ip` (the resolved `ip:port`), **and** `dec0_change_source_button=dec0_change_source` together to `/videoset`. That button field is not cosmetic — omitting it (confirmed by testing) causes the server to silently ignore the source fields entirely, even though other fields in the same POST (colour space, tally, screensaver) do take effect. `set_decode_settings` always includes it. 3. `screensaver_mode`'s true current value isn't marked via the normal `selected` attribute on its dropdown - BirdUI's own JS reads it from a separate hidden `