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:8428 only. It has no -promscrape.config; nothing is scraped.
  • FastAPI hosts the REST API, serves the built SPA, and runs the metrics collector.
  • container-start.sh waits up to 30 s for VictoriaMetrics to answer /health (a timeout is a warning, not fatal), forwards SIGTERM to both children on docker 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-Token request header by the SDK. The legacy s= cookie transport is off unless EERO_DASHBOARD_SDK_LEGACY_COOKIE=true.
  • One EeroClient singleton (deps.py) is shared by every route and the collector. It is built with use_keyring=False (pinned: single-file storage, no keyring fallback chain), send_legacy_cookie and get_retries from settings.
  • require_auth is the cheap gate: "a token exists on disk". GET /api/auth/status is the truthful one: it probes get_account() and reports reason: "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/DELETE under /api/ that does not carry X-Requested-With: eero-ui with 403 {"type": "csrf"}. A cross-site form or no-cors fetch cannot set that header. GET/HEAD/OPTIONS are 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 static 400.
  • Write gates β€” require_experimental_writes and require_account_identity_writes (deps.py) implement EERO_DASHBOARD_EXPERIMENTAL_WRITES and EERO_DASHBOARD_ACCOUNT_IDENTITY_WRITES. Settings-class routes in networks.py each 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/minute on login and verify; shared scopes such as settings_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