13 KiB
Architecture Part 13 — Threat model
Status: draft (security review 2026-10-12,
audits/security-review-2026-10-12/). Marks as in01: [DESIGN] a decision taken; [FACT] an observation with a citation. Unmarked = not yet classified. Findings named here asR-…are register rows; for open ones the detail is held off-git.
1. Starting fact
[FACT] The controller, agent, felhom.eu and catalog repositories are readable by anyone without a login
(measured 2026-10-12; homelab-manifests is private). Assume the attacker has read all product source. A guard
that depends on the code being secret is not a guard here. Whether the repositories stay public is the operator's
decision (R-925).
2. Assets, most harmful loss first
- Household app data — photos, documents, passwords in the household's apps (the customer guest, its data drive, and the backups).
- Household logins — the dashboard password, family members, each app's admin.
- Box credentials — the box's hub API key, the local-API token, the tunnel token, the agent's Proxmox token, the vaulted break-glass root credential.
- Hub data — customer records and reports, escrowed (sealed) backup keys, Cloudflare tokens, the global key.
- Operator signing keys — they decide what a box accepts (R-924).
- Off-site backups and their keys — ciphertext on ep0 and the Storage Box.
- Gitea, the registry and the installer — they decide what code runs on every box (R-971, R-979, R-980).
3. Attackers and what they start with
| attacker | starts with | |
|---|---|---|
| a | a stranger on the internet | the public source; the tunnel addresses of every app and dashboard; the hub's public name (from an allowed country) |
| b | someone on the household LAN | the box's LAN address; the host's and the guest's LAN ports (measured: SSH, the Proxmox UI, DNS, rpcbind) |
| c | a household member with a family account | a family session |
| d | a bad catalog app (upstream image turned bad) | code in one container on the guest's shared Docker daemon |
| e | a compromised controller | root in the guest, the docker socket, the local-API token, the box's hub key |
| f | a compromised hub (or a copy of its database) | every box's hub key, sealed escrow blobs, Cloudflare tokens |
| g | a compromised Gitea / registry / build | push to the catalog, the images and the installer tag |
| h | a thief with the box | the disks |
| i | Cloudflare | the plaintext of tunnel traffic — accepted (R-904) |
| j | added 2026-10-23: a hacked homelab app pod on DooPlex (happened: Plex2Shell, HM-040) | the pod network with free egress: every cluster Service, the node's ports (NFS, kubelet, k3s API), the LAN masqueraded as DooPlex; no service-account token |
4. Entry points
How enumerated (re-runnable): none of the three uses a router library; each registers a few ServeMux patterns
and dispatches in switch blocks, so the cases were parsed from source, and auth was read by hand per handler. The
scripts are held with the review's working notes.
| component | routes | no credential | listeners |
|---|---|---|---|
| controller | 231 (+8 in setup mode) | 21, of which 7 change state, each behind its own password, code or token | port 8080 on the guest's Docker networks (not published); mail relay 2525/2465/2526 |
| hub | 121 (1 health, 53 API, 67 web — v0.152.0 added the two token routes; both route tables are now pinned to the source by tests, R-952) — the API table has 54 entries since /box-config/ (v0.161.0) and /preferences (v0.165.0); counted from hub/internal/api/routes.go, 2026-10-24 |
appliance register (rate-limited, by design), re-enroll (only while the operator has armed recovery mode), login, health, and the public self-bind page /bind/<token> (a capability link, added to this list 2026-10-24) |
one ingress, path /, geo filter (HU, IE, LAN) |
| agent | 41 local-API routes on main (counted 2026-10-24 in felhom-agent/internal/localapi/server.go; POST /host/poweroff added 2026-10-13, R-998; POST /host/line-test/run added 2026-10-24, R-1250, unreleased) |
0 | local API on the island bridge; WireGuard; poke (tunnel only); felhom-sshd (tunnel only, measured) |
Outbound fetches that bring code or config onto a box: the catalog (git, main), app images, the four infra
images, the controller image (by tag), controller.yaml from the hub, the agent binary and config bundle (signed),
OS packages (exact versions, apt-signed), the installer script (by tag, R-980).
5. The trust table of 01 §5, checked
| boundary | verdict | evidence |
|---|---|---|
| end-user ↔ apps | holds, with gaps | traefik routing and header stripping hold; homepage has the Docker socket and no gate (R-981). Re-check (2026-10-24, review round 2): the socket is gone from the homepage template (catalog templates/homepage/docker-compose.yml:21) and the box's allow-list is empty, so a socket bind is refused (felhom-controller/controller/internal/privpolicy/allowlist.json) |
| added 2026-10-14 (R-1033, P1): a visitor ↔ Cloudflare over plain http | holds on boxes with controller ≥ 0.312.0 | before: every public name answered http:// (login pages too), and Sonarr/Radarr/Prowlarr redirected https visitors to an absolute http:// login; a password could cross an untrusted network unencrypted (attacker: anyone on the visitor's network). Now traefik sends every http:// request to https (301) before an app sees it, HSTS one day; boxes below 0.312.0 (Tester 2 at 0.292.0) keep the gap until the floor delivers it. audits/https-only-2026-10-14/ |
| added 2026-10-15 (R-1045): a stranger ↔ a fresh Plex | holds on controller ≥ 0.313.0; on older ones only while the household does not press „Kész" before claiming | an unclaimed Plex answers everything to a private address with no token, and through the tunnel every request comes from one. The setup gate (catalog 9a23ee5) closes the public path; 0.313.0 opens it only when Plex itself says claimed. A status probe meant to cover older controllers OPENED an unclaimed Plex while it started (audits/media-build-2026-10-15/G7/) and was removed |
| customer ↔ controller UI | partly | bcrypt and session hold; small gaps (R-983); family sessions survive a reset (R-976). Re-check (2026-10-24, review round 2): both fixed — a password change or a claim/reset ends every dashboard, family and gate session (controller v0.324.0, internal/web/auth.go:352-366) |
| controller ↔ agent | partly | every route bound to the caller's guest; a compromised controller can still flush restore points (R-973), void the recovery code (R-978) (re-check 2026-10-24: both narrowed — a cadence cap on POST /backup since agent v0.160.0, internal/localapi/backup_cap.go; a new escrow package is uploaded only at the claim since agent v0.160.0, escrow_ceremony.go), and — since agent v0.155.0 — switch the whole machine off once per power-on, holding the hub's silence alarms for up to 14 days (R-998; mitigations and residual risk: 14-host-power-off.md §5) |
| added (R-1007): controller → an app's own job queue | holds — nothing new | the after_unclean_boot commands run inside the app's container through the docker socket the controller already holds, and read the app's database with a read-only session: no key into the household's account, nothing a compromised controller could not already do (exec in any app, read its database). The household's lost-file names stay on the box (app.yaml, the app page); the operator event carries counts only, and a failing command's output is never logged (security review of controller 8d862e4, fixed in 3b85a0c) |
| added: agent ↔ root on the host | holds by the sudoers | every root verb is one regex-anchored line or a root wrapper that re-checks its input (03 §3); agent v0.156.1 adds one delete: felhom-vzdump-partial-clean removes only a DEAD half-written vzdump of the named guest on a dir storage (no vzdump on the host, nothing holding it open) — a compromised agent gains the deletion of a leftover that is not a backup |
| agent ↔ hub | holds for destructive ops | signed jobs verified in the documented order; unsigned spec has no size bounds (R-984) |
| added 2026-10-15: operator script ↔ hub UI | holds from hub v0.152.0 | scripts used the operator password, which can do everything; now a scoped, expiring, hashed token (05 §17); a token never reaches a secret, a delete or /api/v1. No token sends, mints or reveals a household credential or link since v0.153.0 (09 §3 decision 252, R-1055 closed) — the off-site re-issues included (R-1059); the global API key is still one all-powerful bearer (R-1054) |
| controller ↔ hub | did not hold until 2026-10-12 | the global key was public (R-950, rotated) and three routes needed no key (R-951, fixed); per-box keys are bound to their box (R-955) |
| box ↔ PBS | holds as written | ciphertext only; the zero-knowledge wording is stronger than a live box allows (R-985) |
| guest ↔ Proxmox host | holds | the guest has no Proxmox credentials (measured) |
| hub ↔ Cloudflare API | holds for the zone check | tokens are stored unsealed in the hub database (R-975); R-137 open. Re-check (2026-10-24, review round 2): the tokens are sealed (enc:v1:) since hub 0.168.0, deployed 2026-10-24 (hub/internal/store/r975_config_token_seal.go) |
| added: app ↔ app (shared Docker daemon) | partly | separate containers and networks; any docker-socket app is root in the guest (R-981) |
| added: app ↔ controller | partly | apps share a network with the controller; dashboard routes still need a session |
| added: LAN ↔ box | partly | root password SSH on the host from the LAN (R-982). Re-check (2026-10-24, review round 2): the config bundle's key-only drop-in shipped in agent v0.163.0 (felhom-agent/configs/felhom-os-apply:230) |
| added: hub ↔ box (inbound) | does not hold | 01 §4 says the box exposes no control endpoint; a hub-key route can replace the box's settings (R-970). Re-check (2026-10-24, review round 2): the config routes are removed and no route takes the hub key — pinned by felhom-controller/controller/cmd/controller/r970_no_hub_key_door_test.go (01 §4 says the same) |
| added: catalog ↔ every box | design gap | a catalog push reaches every box with no check on the box (R-971). Re-check (2026-10-24, review round 2): the box checks every rendered compose against its allow-list before any write since controller v0.328.0 (internal/privpolicy/privpolicy.go) |
| added 2026-10-23 (R-1241): a hacked app pod on DooPlex ↔ Felhom's secrets | does not hold; not used in the Plex2Shell window | two no-login doors: the Longhorn NFS export (the hub's unencrypted nightly copies with the unsealed tokens of R-975; by a published file-handle escape the whole sda1 disk, the operator signing keys included — not exercised) and the Longhorn API (backup a volume to any target). Plex2Shell, 2026-10-17 → 10-23: NOT REACHED — 0 new NFS connections in the window, no off-schedule backup or volume; the hub, the k8s API and the kubelet refuse a caller with no credential (measured). The export was narrowed to the pod network on 2026-10-23; the pod side stays open (audits/plex2shell-felhom-impact-2026-10-23/). Felhom half, 2026-10-23 night: the last plain tokens sealed in hub 0.168.0 (deployed 2026-10-24 — corrected 2026-10-24; audits/hub-token-seal-2026-10-23/); the two plain Longhorn copies of the hub volume already in the folder are R-1243's decision. A household box's version of this attacker (a hacked app sending or mining) is what the upload watch (R-1244, released 2026-10-24 in controller v0.342.0 + hub v0.169.0 — corrected 2026-10-24) detects |
[DESIGN — undocumented in 01 §5, found 2026-10-24, review round 2 — confirmed through R-1265]: the file manager's one
sign-in (R-1100) — FileBrowser trusts its proxy header blindly, and every app container can reach filebrowser:80
directly; the only guard is that the header NAME is a per-box secret, which traefik's forwardAuth strips from every
client request and sets only for a browser holding the dashboard's files pass
(felhom-controller/controller/internal/infra/filebrowser_sso.go:15-22, internal/web/files_sso.go). The name file's
creation race was fixed by review round 2 (controller b01bb4b).
6. What we accept (do not re-report)
- Cloudflare can read tunnel traffic — R-904,
09§3 decision 184. - Signing keys on DooPlex with no passphrase until the first sale — R-924,
04§3.1 (operator ruling 1). - Public source — the operator's decision (R-925).
- A pasted Cloudflare token is checked for its zone only; the recipe guards the write side —
09§3 decision 226. - Per-name family sign-in lock —
01§5 (a stranger can lock one member out for 10 minutes; same shape as R-775).