Architecture - fulviofreitas/eero-ui GitHub Wiki
ποΈ Architecture
System Overview
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Svelte Frontend (SPA) β
β βββββββββββ βββββββββββ βββββββββββ βββββββββββ β
β β Views β βComponts β β Stores β β API β β
β ββββββ¬βββββ ββββββ¬βββββ ββββββ¬βββββ ββββββ¬βββββ β
β ββββββββββββββ΄βββββββββββββ΄βββββββββββββ β
βββββββββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββ
β HTTP/REST (/api/*)
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β FastAPI Backend β
β routes/ ββ deps.py ββ EeroClient (eero-api SDK) β
β services/collector.py ββwriteβββΊ VictoriaMetrics βββreadββ β
β services/victoria.py β
βββββββββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββ
β HTTPS
βΌ
ββββββββββββββββββββ
β Eero Cloud API β
ββββββββββββββββββββ
Why a Backend Proxy?
| Benefit | Description |
|---|---|
| π Security | Eero session tokens stay server-side, never exposed to browser |
| β»οΈ Reuse | Leverages existing eero-api Python SDK |
| β‘ Caching | Backend caches API responses (60s TTL) |
| π‘οΈ Rate Limiting | Protects against accidental API abuse |
Container Shape
The Docker image runs two processes, supervised by container-start.sh as PID 1:
[FastAPI :8000, published] ββwritesβββΊ [VictoriaMetrics :8428, loopback only, storage only]
β² β
βββββββββββββββββ reads ββββββββββββββββββ
- VictoriaMetrics is started with a storage path, retention and
-httpListenAddr=127.0.0.1:8428only. It has no-promscrape.config; nothing is scraped. - FastAPI hosts the REST API, serves the built SPA, and runs the metrics collector.
container-start.shwaits up to 30 s for VictoriaMetrics to answer/health(a timeout is a warning, not fatal), forwardsSIGTERMto both children ondocker stop, and exits non-zero if either child dies so Docker's restart policy applies.
The eero-prometheus-exporter is not part of the image, the Python environment or the runtime. See [[Metrics]] for running it as an optional, wholly separate container.
Metrics Pipeline
MetricsCollector (asyncio task in the FastAPI lifespan)
β every EERO_DASHBOARD_COLLECTION_INTERVAL seconds
β reads through the shared EeroClient + transformers.py
βΌ
VictoriaMetricsClient.write() ββPOST /api/v1/import (NDJSON)βββΊ VictoriaMetrics
β
routes/metrics.py βββ VictoriaMetricsClient.query_range() ββββββββββββββ
β
βΌ
SpeedtestChart / ClientCountChart / signal history
The collector reads every network on the account, normalises with the same transformers.py the routes use, and writes one batch per cycle with a single timestamp. VictoriaMetricsClient holds one shared httpx.AsyncClient that is closed on shutdown. Failures are logged, counted into eero_collector_errors_total{reason} and reported by GET /api/metrics/health; they never reach the request path.
The metric contract β eight metric names with exact label sets, preserved from the exporter so history stays continuous β is documented in [[Metrics]].
Authentication
sequenceDiagram
participant User
participant Dashboard
participant Backend
participant Eero Cloud
User->>Dashboard: Enter email/phone
Dashboard->>Backend: POST /api/auth/login
Backend->>Eero Cloud: Request OTP
Eero Cloud-->>User: Send OTP via SMS/email
User->>Dashboard: Enter OTP
Dashboard->>Backend: POST /api/auth/verify
Backend->>Eero Cloud: Validate OTP
Eero Cloud-->>Backend: Session token
Backend-->>Backend: SDK writes credential file (schema 2, 0600, atomic)
Backend-->>Dashboard: Set httpOnly cookie
- The eero session token is sent to the eero cloud as the
X-User-Tokenrequest header by the SDK. The legacys=cookie transport is off unlessEERO_DASHBOARD_SDK_LEGACY_COOKIE=true. - One
EeroClientsingleton (deps.py) is shared by every route and the collector. It is built withuse_keyring=False(pinned: single-file storage, no keyring fallback chain),send_legacy_cookieandget_retriesfrom settings. require_authis the cheap gate: "a token exists on disk".GET /api/auth/statusis the truthful one: it probesget_account()and reportsreason: "expired"(clearing the token) when the cloud rejects the session.
Exception Layer
main.py registers one FastAPI handler per eero-api exception class, so routes no longer wrap SDK errors into a blanket 500. The SDK message is never surfaced; detail is a static string.
| SDK exception | HTTP | Body extras |
|---|---|---|
EeroAuthenticationException |
401 | reason: "expired", token cleared, WWW-Authenticate: Bearer |
EeroNotFoundException |
404 | |
EeroAccessDeniedException |
403 | |
EeroPremiumRequiredException |
402 | type: "premium_required" |
EeroFeatureUnavailableException |
409 | type: "feature_unavailable", error_code |
EeroClientBlockedException |
503 | |
EeroRateLimitException |
429 | Retry-After: 60 |
EeroValidationException |
422 | detail: {field, message} |
EeroNetworkException, EeroTimeoutException |
503 | |
EeroAPIException |
502 | error_code/status_code logged; envelope only at DEBUG |
EeroException |
500 | generic |
Two eero-ui-specific exceptions use the same shape: ExperimentalWriteDisabledError β 403 {"type": "experimental_disabled"} and AccountIdentityWriteDisabledError β 403 {"type": "account_identity_disabled"}.
Request Guards
- CSRF β a middleware rejects every
POST/PUT/PATCH/DELETEunder/api/that does not carryX-Requested-With: eero-uiwith403 {"type": "csrf"}. A cross-site form orno-corsfetch cannot set that header.GET/HEAD/OPTIONSare exempt. - Path identifiers β every
{network_id},{eero_id},{profile_id}and{device_id}is validated by a router-level dependency (validate_request_path_ids) against the SDK's identifier grammar before any SDK call or PromQL selector is built; a bad value is a static400. - Write gates β
require_experimental_writesandrequire_account_identity_writes(deps.py) implementEERO_DASHBOARD_EXPERIMENTAL_WRITESandEERO_DASHBOARD_ACCOUNT_IDENTITY_WRITES. Settings-class routes innetworks.pyeach depend on their own module-level gate constant (_SQM_GATE,_DHCP_GATE, β¦) so a family can be opened independently once it is live-verified. - Rate limits β slowapi, keyed by remote address:
5/minuteon login and verify; shared scopes such assettings_writes(2/minute),experimental_writes(10/minute),speedtest(2/minute). Shared scopes are used deliberately: a per-path limit is bypassable by varying the path parameter. - Passthrough hygiene β routes that return an undocumented raw payload from the SDK pass it through
strip_sensitive_keys, which removes any key that looks like a password, PSK, token, secret, PIN or verification code.
Code Structure
eero-ui/
βββ backend/
β βββ app/
β β βββ main.py # FastAPI app, lifespan, exception handlers, CSRF guard
β β βββ config.py # Settings (EERO_DASHBOARD_*)
β β βββ deps.py # EeroClient singleton, auth and write gates
β β βββ transformers.py # Raw API β normalised models, identifier validation
β β βββ routes/ # auth, networks, devices, eeros, profiles, metrics, account
β β βββ services/
β β βββ collector.py # MetricsCollector
β β βββ victoria.py # VictoriaMetricsClient (write + read)
β βββ pyproject.toml
βββ container-start.sh # two-process supervisor
βββ scripts/
β βββ smoke-test-image.sh # Β§ 8.4 image smoke test
β βββ check-history-continuity.sh # metric continuity across the upgrade
β βββ live-verify.py # read-only snapshot/diff for live verification
βββ frontend/
βββ src/
β βββ lib/
β β βββ api/ # API client (writes use retries: 0)
β β βββ stores/ # Svelte stores; settingsLock.ts holds the per-network applying lock
β β βββ charts/ # shared Chart.js defaults and theming
β β βββ components/ # common/ primitives + feature components
β βββ routes/ # Pages
βββ package.json