Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ sequenceDiagram
Note over dns_task,httpd: All three keep running independently.<br/>No shared mutable state between them.
```

**As of Phase 7b, boot is a two-way branch, not the single path the diagram above shows.** `wifi_connect_sta()` returns `false` if it can't get an IP within 30s (no stored/seedable config, or a dead network); `app_main` then calls `wifi_provision_start()` instead of starting the DNS/HTTP/mDNS/OTA tasks and the factory-reset watcher — see `main/wifi_provision.cpp`. The two branches never run concurrently and share no state, so this doesn't change the "no shared mutable state between tasks" property below, it just means the diagram's `dns_task`/`httpd`/`mdns` participants only exist on the success path.

Four independent runtime pieces exist after boot, with **no synchronization between them**:

1. **Wi-Fi event handling** (`wifi_connect.cpp`) — registered event handlers keep running for the life of the device (auto-reconnect on disconnect, plus IPv6 link-local bring-up as of Phase 4), but `wifi_connect()` itself only blocks `app_main` during initial IPv4 bring-up.
Expand All @@ -47,9 +49,11 @@ Three pieces of state cross the DNS task / HTTP task boundary. Two do it without

| File | Responsibility |
|---|---|
| `main/main.cpp` | Boot sequence: logs detected PSRAM size → `wifi_connect()` → `dns_server_start()` → `http_server_start()` → `mdns_responder_start()`. |
| `main/wifi_connect.h/.cpp` | Blocking Wi-Fi station bring-up with hardcoded credentials. Retries forever on disconnect (2s backoff), no give-up state. As of Phase 4, also triggers IPv6 link-local address creation on `WIFI_EVENT_STA_CONNECTED` (non-blocking, doesn't gate boot readiness) so mDNS has an address to advertise as AAAA. |
| `main/wifi_credentials.h` | `WIFI_SSID`/`WIFI_PASSWORD` constants. **Gitignored** — does not exist on a fresh checkout, must be recreated. |
| `main/main.cpp` | Boot sequence: logs detected PSRAM size → `wifi_connect_sta()`; on success, `dns_server_start()` → `http_server_start()` → `mdns_responder_start()` → `ota_updater_start()` → `factory_reset_watch_start()`; on failure (no reachable network within 30s), `wifi_provision_start()` instead (Phase 7b). |
| `main/wifi_connect.h/.cpp` | Blocking Wi-Fi station bring-up. As of Phase 7b, `wifi_connect_sta()` reads config from esp_wifi's own NVS blob rather than a hardcoded pair, seeding from `wifi_credentials.h` only on first boot, and returns `false` (rather than blocking forever) if no IP arrives within 30s. Retries forever on disconnect *after* a first successful connect (2s backoff, unchanged from earlier phases). As of Phase 4, also triggers IPv6 link-local address creation on `WIFI_EVENT_STA_CONNECTED` (non-blocking, doesn't gate boot readiness) so mDNS has an address to advertise as AAAA. |
| `main/wifi_credentials.h` | `WIFI_SSID`/`WIFI_PASSWORD` constants. **Gitignored** — does not exist on a fresh checkout, must be recreated. As of Phase 7b, only a first-boot seed for esp_wifi's own NVS storage, not the ongoing source of truth. |
| `main/wifi_provision.h/.cpp` | SoftAP captive-portal provisioning (Phase 7b): brings up `edge-dns-setup` (open AP, `WIFI_MODE_APSTA`), a minimal captive DNS responder, and an `httpd` instance serving `/scan` (JSON list of nearby networks), `/provision` (validates and writes STA config, then reboots), and a wildcard portal page. Also owns `factory_reset_watch_start()` (GPIO0/BOOT hold → `esp_wifi_restore()` + reboot). No auth on any of its routes — the AP being open is the access control. |
| `main/provision_validate.h/.cpp` | Pure SSID/password validation against 802.11/WPA2-PSK byte-length limits (Phase 7b) — no IDF/FreeRTOS dependency, host-tested like `dns_wire.cpp`/`ota_version.cpp`. Used by `wifi_provision.cpp`'s `/provision` handler before touching `esp_wifi_set_config()`. |
| `main/admin_credentials.h` | `ADMIN_USER`/`ADMIN_PASS` constants for Basic auth on the mutating `/api/records` routes (Phase 5). **Gitignored**, same pattern as `wifi_credentials.h` — does not exist on a fresh checkout, must be recreated. |
| `main/dns_records.h` | The seed hostname→IPv4 table (`DNS_RECORDS_DEFAULTS`, a `constexpr std::array<dns_record_t, N>`), consulted only once by `DnsRecordStore::load_from_nvs()` on first boot (Phase 5) — the live table lives in NVS afterward. Pure data, no logic, no dependencies beyond `<array>`/`<cstdint>`. IPv4-only by design — AAAA for a seeded name is added later through the CRUD API (Phase 6). |
| `main/dns_record_store.h/.cpp` | Runtime-managed DNS record table (`DnsRecordStore`, Phase 5) — NVS-persisted, guarded by a `std::mutex` since it's the first cross-task state that's genuinely shared *and* mutable. Each entry holds an optional IPv4 *and* optional IPv6 address (Phase 6 dual-stack — never neither). DNS task calls `find()` (returns a copy of the whole entry, not a pointer); HTTP task calls `snapshot()`/`create()`/`update()`/`remove()`. |
Expand Down Expand Up @@ -133,13 +137,15 @@ These are the things most likely to confuse future-you or bite an extension:
- **`httpd_config_t::max_uri_handlers` is a small, easy-to-undercount budget** (Phase 5) — the same shape as the `CONFIG_LWIP_MAX_SOCKETS` trap below. The default (8) was an exact fit for the 4 original GET-only routes; Phase 5's 4 new `/api/records` methods (`POST`/`PUT`/`DELETE`/`OPTIONS`) made it an exact fit again, so it was bumped to 12 for headroom. The next endpoint added anywhere needs this re-checked, not just incremented by one.
- **Metrics counters are `uint32_t`, not `uint64_t`, on purpose** (Phase 3) — the Xtensa LX7 has no native 64-bit atomic instructions, so `std::atomic<uint64_t>` here would silently fall back to libatomic's lock-based implementation, reintroducing a lock into an otherwise deliberately lock-free cross-task read path. A 32-bit counter wrapping after ~4B events is fine for a LAN-scale device scraped by Prometheus, which already tolerates counter resets via `rate()`.
- **Automated tests exist for the wire-format layer only (Phase 6), everything else is still manual/hardware-in-the-loop.** `host_test/` builds `main/dns_wire.cpp` unmodified against ESP-IDF's `linux` target and Unity — `idf.py --preview set-target linux -C host_test build && ./host_test/build/host_test.elf` (no board, no QEMU, since that file has no FreeRTOS/lwIP dependency). The DNS/HTTP task logic, NVS persistence, and everything with a socket in it are still only verified by flashing and `dig`/`curl`/browser/serial-log inspection — that's a real gap if this code keeps growing, but extending it needs fakes/mocks for sockets and NVS that `dns_wire.cpp`'s pure functions don't.
- **The provisioning portal (Phase 7b) is deliberately unauthenticated** — `/scan`, `/provision`, and the wildcard portal page in `wifi_provision.cpp` carry no auth check, unlike every route in `http_server.cpp`. This is a considered choice, not an oversight: the AP is open and unprovisioned by definition, there is no credential yet in existence to gate it behind, and gating it behind `admin_credentials.h` would make a factory-fresh device unprovisionable. Provisioning mode and normal operation never run concurrently (see the boot-branch note above), so this doesn't weaken the auth story for `/api/records` et al. — physical/RF proximity to join `edge-dns-setup` is the actual access control here, same reasoning as an unlocked configuration port on other consumer hardware.
- **STA reconfiguration on an already-started Wi-Fi stack needs an explicit stop first.** `wifi_provision_start()` is only reached after `wifi_connect_sta()` already called `esp_wifi_set_mode(WIFI_MODE_STA)`/`esp_wifi_start()` and its disconnect handler is actively retrying in the background. Calling `esp_wifi_set_mode(WIFI_MODE_APSTA)`/`esp_wifi_start()` again without stopping first isn't a supported transition, so `wifi_provision_start()` calls `esp_wifi_stop()` up front (tolerating `ESP_ERR_WIFI_NOT_STARTED`) before reconfiguring — easy to miss if this bring-up sequence is ever refactored.
- **The `linux` target's FreeRTOS port never returns from `vTaskStartScheduler()`, so `app_main()` returning doesn't end the process** — `host_test/main/test_dns_wire.cpp`'s `app_main()` calls `exit(UNITY_END())` itself rather than just returning, both to actually terminate and to turn Unity's failure count into a real process exit code for CI. Worth remembering before adding a second host-test binary: without an explicit `exit()`, running it from a script will hang forever after printing results, not just exit slowly.

## Explicit non-goals (as scoped)

These were ruled out deliberately, not overlooked — don't reintroduce them without reopening the scoping conversation:

- Wi-Fi provisioning UI / captive portal
- ~~Wi-Fi provisioning UI / captive portal~~ — **promoted, done (Phase 7b)**: see Future scoping item 8 above.
- TLS/HTTPS — Basic auth (Phase 5) runs over plaintext HTTP; see the gotcha above
- NVS-stored/rotatable admin credentials, rate limiting on auth failures — see the Phase 5 design doc's Open threads
- True iterative/recursive DNS resolution (root-server walking) — forwarding + caching was built instead (Phase 1); see the design doc for the rationale
Expand All @@ -159,7 +165,7 @@ Roughly ordered by how naturally each extends the current design, not by priorit
5. ~~**Record management: NVS-backed persistence + a real add/edit/delete API + basic auth.**~~ **Done (Phase 5).** Scoped as one phase, not three, because the pieces are load-bearing for each other: `POST`/`PUT`/`DELETE` on `/api/records` is worthless without persistence behind it, and shipping either without auth would leave a mutating, unauthenticated endpoint exposed on the LAN — so none of the three shipped without the other two. Also the single biggest architectural jump so far: `DnsRecordStore` is the first cross-task state that's genuinely shared *and* mutable, guarded by a `std::mutex` — every prior phase (blocklist, metrics) avoided this by keeping cross-task state either immutable-after-boot or a single atomic word. Ships a JSON CRUD API only, no bundled frontend — the record-management UI is a separate project. See the concurrency-model section above and `docs/superpowers/specs/2026-07-21-edge-dns-phase5-record-management-design.md`.
6. ~~**Hardening & reliability (Phase 6): AAAA record support for local-table names + host-side unit tests for the wire-format functions + a secondary/failover upstream resolver.**~~ **Done (Phase 6).** Bundled because none of the three touch the concurrency model or add new attack surface — unlike Phase 5, this phase rounds out correctness and resilience of what Phases 1–4 already shipped, rather than adding a new capability surface. `DnsRecordEntry` now holds an optional IPv4 *and* optional IPv6 address (never neither); `find()` returns the whole entry so the DNS task can tell "name exists, wrong family" (NODATA) apart from "name doesn't exist" (NXDOMAIN) — forwarded/cached AAAA for *non*-local names already worked as of Phase 1 (the forwarder and cache are qtype-agnostic). The pure functions in `dns_wire.cpp` are covered by a Unity suite building against ESP-IDF's `linux` target (`host_test/`, no board/QEMU needed). A slot whose primary-upstream attempt times out is retried once against a secondary before SERVFAIL, reusing the same transaction ID/socket. See `docs/superpowers/specs/2026-07-21-edge-dns-phase6-hardening-design.md`.
7. ~~**Leaving the bench, part 1 (Phase 7a): dual OTA partitions + signed, self-updating firmware.**~~ **Done (Phase 7a).** `otadata`/`ota_0`/`ota_1` replace the single `factory` partition (see partitions.csv); a background task in `ota_updater.cpp` polls GitHub Releases every 6 hours (or on demand via `POST /api/ota/check`), downloads via `esp_https_ota()`, and only cancels the bootloader's rollback after a real health check (30s uptime + at least one DNS query answered, or 10 minutes uptime regardless of traffic) — not unconditionally at boot. Until that gate passes, `esp_ota_begin()` itself refuses new OTA attempts, so the update endpoint rejects with 409 rather than failing mid-download. Signed with `CONFIG_SECURE_SIGNED_APPS_NO_SECURE_BOOT` (software-checked signature) rather than full Secure Boot v2, which would burn eFuses irreversibly. `GET /api/ota` / `POST /api/ota/check` expose status and a manual trigger.
8. **Leaving the bench, part 2 (Phase 7b): Wi-Fi provisioning via SoftAP captive portal.** Still an explicit non-goal today (see above). Split from Phase 7a because the two share no dependency beyond the one-time partition-table rewrite Phase 7a already did.
8. ~~**Leaving the bench, part 2 (Phase 7b): Wi-Fi provisioning via SoftAP captive portal.**~~ **Done (Phase 7b).** `wifi_connect_sta()` reads Wi-Fi config from esp_wifi's own NVS blob (`nvs.net80211`) rather than a hand-rolled store — `esp_wifi_get_config()` empty is the "unprovisioned" check, seeding from `wifi_credentials.h` on first boot; the boot-time connect attempt now has a 30s timeout instead of blocking forever. On timeout, `wifi_provision_start()` (`main/wifi_provision.cpp`) brings the device up as its own open AP (`edge-dns-setup`, `WIFI_MODE_APSTA` so scanning still works), reconfigures the AP's DHCP server to hand out itself as DNS, and runs a minimal captive DNS responder (every A query answered with the AP's own IP, everything else NODATA) alongside a small `httpd` instance serving `/scan`, `/provision`, and a wildcard portal page — deliberately a separate, much smaller responder rather than a mode flag threaded through `dns_server.cpp`'s hot query path, since the two never run concurrently and AP mode has no upstream/cache/record-table state to share with it. `POST /provision` validates via the pure `provision_validate()` (`main/provision_validate.h/.cpp`, host-tested) before writing `esp_wifi_set_config()` and rebooting into STA mode; a failed STA connect falls back to the portal *without* erasing stored credentials, so a merely-rebooting router recovers on its own next cycle. Holding GPIO0 (BOOT) ~5s (`factory_reset_watch_start()`, STA mode only) calls `esp_wifi_restore()` and reboots — the recovery path for a wrong stored password with no cable handy. The provisioning routes carry no auth: the AP is open by definition, and physical proximity to join it is the only access control available at that point.
9. **Reliability & forensics (Phase 7c): coredump-to-flash, task watchdog, heap/uptime/reset-reason metrics.** Also split from 7a/7b for the same reason — independent of both beyond the partition table.

Lower-value, opportunistic — worth doing if a specific need arises, not currently phase-scoped:
Expand Down
Loading
Loading