diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a45ab7..0b92cfe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,8 +2,14 @@ name: CI on: push: + branches: [main] + tags: ['v*'] pull_request: +concurrency: + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: firmware-build: name: Build firmware (ESP32-S3) @@ -30,6 +36,24 @@ jobs: constexpr const char* ADMIN_PASS = "ci-placeholder-password"; EOF + - name: Write OTA signing key + env: + OTA_SIGNING_KEY_PEM: ${{ secrets.OTA_SIGNING_KEY_PEM }} + run: | + if [ -z "$OTA_SIGNING_KEY_PEM" ]; then + echo "::error::OTA_SIGNING_KEY_PEM secret is empty or unset. Generate a key with" \ + "'espsecure.py generate_signing_key --version 2 --scheme ecdsa256 secure_boot_signing_key.pem'" \ + "and paste its full contents into the repo's OTA_SIGNING_KEY_PEM secret (Settings > Secrets and variables > Actions)." + exit 1 + fi + echo "$OTA_SIGNING_KEY_PEM" > secure_boot_signing_key.pem + if ! grep -q "BEGIN EC PRIVATE KEY" secure_boot_signing_key.pem; then + echo "::error::secure_boot_signing_key.pem does not look like an ECDSA key (missing 'BEGIN EC PRIVATE KEY')." \ + "Regenerate with --scheme ecdsa256 — this project's sdkconfig expects ECDSA, not the RSA key" \ + "espsecure.py generates by default without that flag." + exit 1 + fi + - name: Build firmware run: | . "$IDF_PATH/export.sh" @@ -83,6 +107,24 @@ jobs: constexpr const char* ADMIN_PASS = "ci-placeholder-password"; EOF + - name: Write OTA signing key + env: + OTA_SIGNING_KEY_PEM: ${{ secrets.OTA_SIGNING_KEY_PEM }} + run: | + if [ -z "$OTA_SIGNING_KEY_PEM" ]; then + echo "::error::OTA_SIGNING_KEY_PEM secret is empty or unset. Generate a key with" \ + "'espsecure.py generate_signing_key --version 2 --scheme ecdsa256 secure_boot_signing_key.pem'" \ + "and paste its full contents into the repo's OTA_SIGNING_KEY_PEM secret (Settings > Secrets and variables > Actions)." + exit 1 + fi + echo "$OTA_SIGNING_KEY_PEM" > secure_boot_signing_key.pem + if ! grep -q "BEGIN EC PRIVATE KEY" secure_boot_signing_key.pem; then + echo "::error::secure_boot_signing_key.pem does not look like an ECDSA key (missing 'BEGIN EC PRIVATE KEY')." \ + "Regenerate with --scheme ecdsa256 — this project's sdkconfig expects ECDSA, not the RSA key" \ + "espsecure.py generates by default without that flag." + exit 1 + fi + - name: Build firmware run: | . "$IDF_PATH/export.sh" @@ -96,6 +138,7 @@ jobs: build/bootloader/bootloader.bin build/partition_table/partition-table.bin build/mini_dns.bin + build/ota_data_initial.bin body: | Prebuilt binaries for `${{ github.ref_name }}`. @@ -108,8 +151,16 @@ jobs: 3. `idf.py set-target esp32s3 && idf.py build` 4. `idf.py -p flash monitor` + These binaries are signed with this repository's CI signing key. A + device will only accept a signed OTA image whose signature matches + the public key embedded in its *currently running* firmware — + since the Setup instructions above have you generate your own + signing key, a locally-built device will reject these CI-signed + release assets over OTA. Fresh-flash via the esptool command below + works regardless of signing. + Flash command for these prebuilt binaries (placeholder credentials — device will boot but will not join your Wi-Fi): ``` - python -m esptool --chip esp32s3 -b 460800 --before default_reset --after hard_reset write_flash --flash_mode dio --flash_size 16MB --flash_freq 80m 0x0 bootloader.bin 0x8000 partition-table.bin 0x20000 mini_dns.bin + python -m esptool --chip esp32s3 -b 460800 --before default_reset --after hard_reset write_flash --flash_mode dio --flash_size 16MB --flash_freq 80m 0x0 bootloader.bin 0x8000 partition-table.bin 0x19000 ota_data_initial.bin 0x30000 mini_dns.bin ``` diff --git a/.gitignore b/.gitignore index 9d4e3c2..62a3c68 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ main/wifi_credentials.h main/admin_credentials.h +secure_boot_signing_key.pem build/ managed_components/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 86bdac4..3a56def 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -4,7 +4,7 @@ `mini_dns` is ESP32-S3 firmware moving from proof-of-concept toward a marketable "edge DNS" appliance: a single device that connects to Wi-Fi, serves a runtime-managed set of hostnames authoritatively (A and/or AAAA), forwards everything else to an upstream recursive resolver with TTL caching and secondary-upstream failover, sinkholes ad/tracker domains, exposes Prometheus metrics, advertises itself via mDNS, and serves an HTTP page + a JSON CRUD API for managing that record table. It was built incrementally — Wi-Fi → raw UDP → DNS parsing → single-record response → multi-record + NXDOMAIN → HTTP server → JSON API → page wired to the API → **forwarding resolver + cache (Phase 1)** → **NVS-backed ad-block (Phase 2)** → **Prometheus `/metrics` (Phase 3)** → **mDNS responder (Phase 4)** → **record management: persistence + CRUD API + auth (Phase 5)** → **hardening: dual-stack records + secondary upstream + host tests (Phase 6)** — each step flashed and confirmed on real hardware before moving on. -**As of Phase 5, records are runtime-managed** — persisted in NVS, editable via `POST`/`PUT`/`DELETE /api/records` (Basic-auth-gated), no longer a reflash-only compile-time table. **As of Phase 6, a record can hold an A, an AAAA, or both**, and a forwarded query that times out against the primary upstream gets one retry against a secondary before SERVFAIL. There is still no provisioning UI, no OTA, no TLS. If you're extending this, read the Non-Goals section before adding anything that smells like a "real" feature. +**As of Phase 5, records are runtime-managed** — persisted in NVS, editable via `POST`/`PUT`/`DELETE /api/records` (Basic-auth-gated), no longer a reflash-only compile-time table. **As of Phase 6, a record can hold an A, an AAAA, or both**, and a forwarded query that times out against the primary upstream gets one retry against a secondary before SERVFAIL. There is still no provisioning UI, no TLS. If you're extending this, read the Non-Goals section before adding anything that smells like a "real" feature. ## Target hardware / toolchain @@ -140,7 +140,6 @@ These are the things most likely to confuse future-you or bite an extension: These were ruled out deliberately, not overlooked — don't reintroduce them without reopening the scoping conversation: - Wi-Fi provisioning UI / captive portal -- OTA updates - 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 @@ -159,7 +158,9 @@ Roughly ordered by how naturally each extends the current design, not by priorit 4. ~~**mDNS responder.**~~ **Done (Phase 4).** Device self-advertisement as `edge-dns.local` (A + AAAA) plus an `_http._tcp` service via ESP-IDF's `mdns` managed component — a genuinely different mechanism from the unicast resolver, running alongside it rather than replacing it. Does not delegate `DNS_RECORDS` as `.local` hosts (see the `.local` gotcha above and the design doc's Open threads). See `docs/superpowers/specs/2026-07-21-edge-dns-phase4-mdns-design.md`. 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 dev bench (Phase 7): Wi-Fi provisioning (BLE/captive portal) + OTA updates.** Both explicit non-goals today. Sized as its own phase rather than folded into Phase 5 or 6 — this is the step that turns the device from "flash it from a laptop on the desk" into something that could plausibly leave the bench, and it doesn't share a dependency with either of the other two phases. +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. +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: - **Multiple questions per query (`qdcount > 1`)** — currently only the first question is parsed; real resolvers essentially never send more than one. diff --git a/README.md b/README.md index c0b81c4..6573c2a 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,25 @@ dig @ AAAA dual.loc # answered locally, not forwarded dig @ AAAA foo.loc # v4-only record: NOERROR, no answer (NODATA) — not NXDOMAIN ``` +## Monitoring + +A Prometheus + Grafana stack for the `/metrics` endpoint lives in +[`monitoring/`](monitoring/) (docker-compose, scrape config, provisioned +Grafana dashboard) — see `monitoring/README.md` for setup. + +![Grafana dashboard showing query breakdown, upstream health, latency percentiles, and saturation](docs/grafana-dashboard.png) + +## OTA updates + +The device checks GitHub Releases for a newer tagged version every 6 hours, and can also be triggered on demand, then updates itself over HTTPS (Phase 7a): + +``` +curl http:///api/ota # current status +curl -u admin: -X POST http:///api/ota/check # trigger a check now +``` + +A newly-flashed or newly-updated image stays in "pending_verify" until it's been up ~30s and answered at least one DNS query, or until 10 minutes have passed regardless of traffic (so an idle-but-healthy device isn't stuck forever) — only then does it cancel the bootloader's rollback, and only then will it accept another OTA check (an attempt while still pending_verify is rejected with 409, matching how `esp_ota_begin()` itself refuses to start an update on an unverified image). If it crashes before the gate passes, the bootloader reverts to the previous image on next reset. + ## Running host tests The pure DNS wire-format functions (`main/dns_wire.h/.cpp`) have no FreeRTOS/lwIP @@ -126,3 +145,5 @@ exist as a CI-verified reference build, not a flash-and-go artifact. - **Metrics run for the life of the device.** `/metrics` counters reset only on reboot — there's no zero/reset endpoint. - **Basic auth runs over plaintext HTTP.** There's no TLS on this device, so credentials for the mutating `/api/records` routes are base64-encoded, not encrypted. Fine on a trusted LAN, not a real security boundary. - **CORS is effectively open.** The mutating routes reflect back whatever `Origin` a request sends (browsers disallow a wildcard alongside credentialed requests) — protection comes entirely from the Basic-auth check, not from origin filtering. +- **OTA updates require a signing key you generate once.** `secure_boot_signing_key.pem` is gitignored like `wifi_credentials.h`; generate it with `espsecure.py generate_signing_key --version 2 --scheme ecdsa256 secure_boot_signing_key.pem` before your first build after Phase 7a — the `--scheme ecdsa256` flag matters, since `espsecure.py` defaults to an RSA key otherwise, which this project's ECDSA-based sdkconfig can't sign with. CI has its own copy in a repository secret (`OTA_SIGNING_KEY_PEM`) — see `.github/workflows/ci.yml`. +- **Repartitioning (Phase 7a) requires `idf.py erase-flash`.** This wipes the NVS record store and blocklist — reflash and re-seed from `dns_records.h`/`dns_blocklist_defaults.h`, or re-add records via the CRUD API, after upgrading from a pre-Phase-7a build. diff --git a/docs/grafana-dashboard.png b/docs/grafana-dashboard.png new file mode 100644 index 0000000..dc92eff Binary files /dev/null and b/docs/grafana-dashboard.png differ diff --git a/host_test/main/CMakeLists.txt b/host_test/main/CMakeLists.txt index f655291..e12f852 100644 --- a/host_test/main/CMakeLists.txt +++ b/host_test/main/CMakeLists.txt @@ -1,6 +1,7 @@ # dns_wire.cpp is pulled in directly from the firmware's main/ component # (not linked as a library) so this test always exercises the exact same # source that ships — see the header comment on ../CMakeLists.txt. -idf_component_register(SRCS "test_dns_wire.cpp" "../../main/dns_wire.cpp" +idf_component_register(SRCS "test_dns_wire.cpp" "test_ota_version.cpp" + "../../main/dns_wire.cpp" "../../main/ota_version.cpp" INCLUDE_DIRS "." "../../main" REQUIRES unity) diff --git a/host_test/main/test_dns_wire.cpp b/host_test/main/test_dns_wire.cpp index a63cd59..e8a6e23 100644 --- a/host_test/main/test_dns_wire.cpp +++ b/host_test/main/test_dns_wire.cpp @@ -11,8 +11,26 @@ #include #include "dns_wire.h" +#include "ota_version.h" #include "unity.h" +// test_ota_version.cpp's tests (Phase 7a) — plain (non-anonymous-namespace) +// functions there so they're callable here; this file owns the single +// merged app_main (see below), so its RUN_TEST calls need these declared. +void test_parse_valid_with_v_prefix(); +void test_parse_valid_without_v_prefix(); +void test_parse_tolerates_git_describe_suffix(); +void test_parse_rejects_empty(); +void test_parse_rejects_garbage(); +void test_parse_rejects_missing_minor(); +void test_is_newer_minor_bump(); +void test_is_newer_false_when_older(); +void test_is_newer_false_when_equal(); +void test_is_newer_patch_bump(); +void test_is_newer_major_beats_minor_and_patch(); +void test_is_newer_false_on_unparseable_remote(); +void test_is_newer_false_on_unparseable_current(); + namespace { // Encodes a dotted name ("test.loc") as length-prefixed labels terminated @@ -380,6 +398,22 @@ extern "C" void app_main(void) { UNITY_BEGIN(); unity_run_all_tests(); + // ota_version tests use plain RUN_TEST rather than TEST_CASE + // auto-registration (see test_ota_version.cpp), so they run here + // explicitly rather than via unity_run_all_tests() above. + RUN_TEST(test_parse_valid_with_v_prefix); + RUN_TEST(test_parse_valid_without_v_prefix); + RUN_TEST(test_parse_tolerates_git_describe_suffix); + RUN_TEST(test_parse_rejects_empty); + RUN_TEST(test_parse_rejects_garbage); + RUN_TEST(test_parse_rejects_missing_minor); + RUN_TEST(test_is_newer_minor_bump); + RUN_TEST(test_is_newer_false_when_older); + RUN_TEST(test_is_newer_false_when_equal); + RUN_TEST(test_is_newer_patch_bump); + RUN_TEST(test_is_newer_major_beats_minor_and_patch); + RUN_TEST(test_is_newer_false_on_unparseable_remote); + RUN_TEST(test_is_newer_false_on_unparseable_current); // ESP-IDF's linux-target port starts the FreeRTOS scheduler before // calling app_main and never tears it down when app_main returns — // the process would otherwise hang forever after printing results. diff --git a/host_test/main/test_ota_version.cpp b/host_test/main/test_ota_version.cpp new file mode 100644 index 0000000..a60ee13 --- /dev/null +++ b/host_test/main/test_ota_version.cpp @@ -0,0 +1,88 @@ +// Host-side unit tests for main/ota_version.h/.cpp (Phase 7a) — same +// no-FreeRTOS-dependency, linux-target pattern as test_dns_wire.cpp: +// +// idf.py --preview set-target linux -C host_test build +// ./host_test/build/host_test.elf + +#include "ota_version.h" +#include "unity.h" + +// Not in an anonymous namespace, unlike test_dns_wire.cpp's helpers: these +// need external linkage so test_dns_wire.cpp's app_main (the single merged +// Unity entry point — see its file comment) can RUN_TEST() them via the +// forward declarations there. + +void test_parse_valid_with_v_prefix() +{ + auto v = ota_version_parse("v1.2.3"); + TEST_ASSERT_TRUE(v.has_value()); + TEST_ASSERT_EQUAL_UINT(1, v->major); + TEST_ASSERT_EQUAL_UINT(2, v->minor); + TEST_ASSERT_EQUAL_UINT(3, v->patch); +} + +void test_parse_valid_without_v_prefix() +{ + auto v = ota_version_parse("0.6.0"); + TEST_ASSERT_TRUE(v.has_value()); + TEST_ASSERT_EQUAL_UINT(0, v->major); + TEST_ASSERT_EQUAL_UINT(6, v->minor); + TEST_ASSERT_EQUAL_UINT(0, v->patch); +} + +void test_parse_tolerates_git_describe_suffix() +{ + auto v = ota_version_parse("v0.6.0-3-gabc1234-dirty"); + TEST_ASSERT_TRUE(v.has_value()); + TEST_ASSERT_EQUAL_UINT(0, v->patch); +} + +void test_parse_rejects_empty() +{ + TEST_ASSERT_FALSE(ota_version_parse("").has_value()); +} + +void test_parse_rejects_garbage() +{ + TEST_ASSERT_FALSE(ota_version_parse("notaversion").has_value()); +} + +void test_parse_rejects_missing_minor() +{ + TEST_ASSERT_FALSE(ota_version_parse("v1").has_value()); +} + +void test_is_newer_minor_bump() +{ + TEST_ASSERT_TRUE(ota_version_is_newer("v0.6.0", "v0.7.0")); +} + +void test_is_newer_false_when_older() +{ + TEST_ASSERT_FALSE(ota_version_is_newer("v0.7.0", "v0.6.0")); +} + +void test_is_newer_false_when_equal() +{ + TEST_ASSERT_FALSE(ota_version_is_newer("v0.6.0", "v0.6.0")); +} + +void test_is_newer_patch_bump() +{ + TEST_ASSERT_TRUE(ota_version_is_newer("v0.6.0", "v0.6.1")); +} + +void test_is_newer_major_beats_minor_and_patch() +{ + TEST_ASSERT_TRUE(ota_version_is_newer("v0.9.9", "v1.0.0")); +} + +void test_is_newer_false_on_unparseable_remote() +{ + TEST_ASSERT_FALSE(ota_version_is_newer("v0.6.0", "garbage")); +} + +void test_is_newer_false_on_unparseable_current() +{ + TEST_ASSERT_FALSE(ota_version_is_newer("garbage", "v1.0.0")); +} diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 9c58acc..b5356fe 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -4,7 +4,8 @@ idf_component_register(SRCS "main.cpp" "wifi_connect.cpp" "dns_server.cpp" "dns_wire.cpp" "dns_cache.cpp" "dns_forwarder.cpp" "dns_blocklist.cpp" "dns_metrics.cpp" "dns_record_store.cpp" "http_server.cpp" - "mdns_responder.cpp" + "mdns_responder.cpp" "ota_version.cpp" "ota_updater.cpp" INCLUDE_DIRS "." REQUIRES esp_wifi esp_netif esp_event nvs_flash lwip esp_http_server json - esp_psram esp_timer mdns mbedtls) + esp_psram esp_timer mdns mbedtls app_update esp_https_ota + esp_http_client) diff --git a/main/http_server.cpp b/main/http_server.cpp index 1c31625..7188b76 100644 --- a/main/http_server.cpp +++ b/main/http_server.cpp @@ -14,11 +14,13 @@ #include "dns_forwarder.h" #include "dns_metrics.h" #include "dns_record_store.h" +#include "esp_app_desc.h" #include "esp_http_server.h" #include "esp_log.h" #include "lwip/inet.h" #include "lwip/sockets.h" #include "mbedtls/base64.h" +#include "ota_updater.h" namespace { @@ -642,6 +644,77 @@ constexpr httpd_uri_t METRICS_URI = { .user_ctx = nullptr, }; +esp_err_t ota_get_handler(httpd_req_t *req) +{ + cJSON *root = cJSON_CreateObject(); + if (root == nullptr) { + ESP_LOGE(TAG, "Failed to allocate JSON object"); + return httpd_resp_send_500(req); + } + + cJSON_AddStringToObject(root, "running_version", esp_app_get_description()->version); + cJSON_AddStringToObject(root, "health_state", + ota_updater_health_state() == OtaHealthState::kValid + ? "valid" + : "pending_verify"); + + OtaCheckStatus status = ota_updater_last_check(); + cJSON_AddBoolToObject(root, "checked", status.checked); + cJSON_AddBoolToObject(root, "check_in_progress", status.in_progress); + cJSON_AddBoolToObject(root, "update_available", status.update_available); + cJSON_AddStringToObject(root, "latest_version", status.latest_version.c_str()); + cJSON_AddStringToObject(root, "last_error", status.last_error.c_str()); + + char *json_str = cJSON_PrintUnformatted(root); + if (json_str == nullptr) { + ESP_LOGE(TAG, "Failed to serialize JSON"); + cJSON_Delete(root); + return httpd_resp_send_500(req); + } + + httpd_resp_set_type(req, "application/json"); + esp_err_t ret = httpd_resp_send(req, json_str, HTTPD_RESP_USE_STRLEN); + + cJSON_free(json_str); + cJSON_Delete(root); + return ret; +} + +constexpr httpd_uri_t OTA_GET_URI = { + .uri = "/api/ota", + .method = HTTP_GET, + .handler = ota_get_handler, + .user_ctx = nullptr, +}; + +// Auth-gated like the /api/records mutating routes (check_auth, +// http_server.cpp:192) — an unauthenticated caller shouldn't be able to +// trigger a multi-MB download and reboot cycle. +esp_err_t ota_check_post_handler(httpd_req_t *req) +{ + if (!check_auth(req)) { + return ESP_OK; + } + if (!ota_updater_request_check()) { + // No HTTPD_409_CONFLICT in this esp_http_server version's status enum + // (only through 431) — set the status line directly, same pattern as + // check_auth()'s 401 above. + httpd_resp_set_status(req, "409 Conflict"); + return httpd_resp_send( + req, "check not allowed right now (already in progress, image still pending_verify, or in failure cooldown)", + HTTPD_RESP_USE_STRLEN); + } + httpd_resp_set_status(req, "202 Accepted"); + return httpd_resp_send(req, nullptr, 0); +} + +constexpr httpd_uri_t OTA_CHECK_POST_URI = { + .uri = "/api/ota/check", + .method = HTTP_POST, + .handler = ota_check_post_handler, + .user_ctx = nullptr, +}; + } // namespace void http_server_start() @@ -653,7 +726,7 @@ void http_server_start() // bump with real headroom rather than the exact new count — the same // "leave headroom, don't just +1" lesson as the Phase 1 socket-budget // trap (see ARCHITECTURE.md). - config.max_uri_handlers = 12; + config.max_uri_handlers = 14; // was 12; +2 for /api/ota, /api/ota/check ESP_ERROR_CHECK(httpd_start(&server, &config)); ESP_ERROR_CHECK(httpd_register_uri_handler(server, &ROOT_URI)); @@ -664,6 +737,8 @@ void http_server_start() ESP_ERROR_CHECK(httpd_register_uri_handler(server, &RECORDS_OPTIONS_URI)); ESP_ERROR_CHECK(httpd_register_uri_handler(server, &BLOCKLIST_URI)); ESP_ERROR_CHECK(httpd_register_uri_handler(server, &METRICS_URI)); + ESP_ERROR_CHECK(httpd_register_uri_handler(server, &OTA_GET_URI)); + ESP_ERROR_CHECK(httpd_register_uri_handler(server, &OTA_CHECK_POST_URI)); ESP_LOGI(TAG, "HTTP server listening on port %d", config.server_port); } diff --git a/main/main.cpp b/main/main.cpp index ea5e4e3..dbd1e4b 100644 --- a/main/main.cpp +++ b/main/main.cpp @@ -3,6 +3,7 @@ #include "esp_psram.h" #include "http_server.h" #include "mdns_responder.h" +#include "ota_updater.h" #include "wifi_connect.h" namespace { @@ -29,4 +30,5 @@ extern "C" void app_main(void) dns_server_start(); http_server_start(); mdns_responder_start(); + ota_updater_start(); } diff --git a/main/ota_updater.cpp b/main/ota_updater.cpp new file mode 100644 index 0000000..31f2b32 --- /dev/null +++ b/main/ota_updater.cpp @@ -0,0 +1,325 @@ +#include "ota_updater.h" + +#include +#include +#include + +#include "cJSON.h" +#include "dns_metrics.h" +#include "esp_app_desc.h" +#include "esp_crt_bundle.h" +#include "esp_http_client.h" +#include "esp_https_ota.h" +#include "esp_log.h" +#include "esp_ota_ops.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "ota_version.h" + +namespace { + +constexpr const char *TAG = "ota_updater"; + +// GitHub requires a User-Agent on every API request or it 403s — the repo +// name doubles as a plausible one. No auth token: this hits the +// unauthenticated rate limit (60 req/hr per IP), which a device checking +// every OTA_CHECK_INTERVAL_US (6h) never approaches. +constexpr const char *GITHUB_RELEASES_URL = + "https://api.github.com/repos/RndmCodeGuy20/mini_dns/releases/latest"; +constexpr const char *USER_AGENT = "mini_dns-ota-updater"; + +// A slot must survive this long, with Wi-Fi already up (guaranteed by +// boot order — see ota_updater.h) and at least one DNS query actually +// answered, before it's trusted enough to cancel rollback. Long enough to +// rule out a crash-loop that only manifests once traffic arrives; short +// enough that a healthy device doesn't sit "pending verify" for long. +constexpr int64_t HEALTH_GATE_MIN_UPTIME_US = 30LL * 1000 * 1000; + +// Absolute-time escape from the query-count requirement above: an idle +// device (no DNS traffic yet) that has nonetheless run this long without +// crashing is trusted even with zero queries — the alternative is an +// unrecoverable rollback of a genuinely healthy image just because nothing +// happened to query it yet. +constexpr int64_t HEALTH_GATE_MAX_UPTIME_US = 10LL * 60 * 1000 * 1000; + +// How often the background task polls GitHub Releases on its own, absent +// a manual POST /api/ota/check — matches the rate-limit comment above. +constexpr int64_t OTA_CHECK_INTERVAL_US = 6LL * 3600 * 1000 * 1000; + +// A failed check (rate-limited, DNS broken, no asset, etc.) is usually not +// transient on a device-restart timescale — without a cooldown, a naive +// dashboard retry loop can trigger a fresh check every ~5s forever. One +// fixed window, no backoff: this only needs to stop hammering, not be +// clever about it. +constexpr int64_t OTA_FAILURE_COOLDOWN_US = 30LL * 1000 * 1000; + +// Response bodies from the GitHub API are capped here — generous headroom +// for a release with a handful of asset entries (tag_name appears near +// the top of the JSON regardless), not a real capacity need. +// ponytail: fixed cap, not a streaming JSON parser — raise this or switch +// to a streaming parse if a release ever grows enough assets to truncate +// the mini_dns.bin asset entry out of the buffer. +constexpr size_t MAX_RESPONSE_BYTES = 16384; + +std::atomic s_valid_marked{false}; +std::atomic s_check_requested{false}; +std::atomic s_check_in_progress{false}; +// esp_timer_get_time() of the last failed check cycle, 0 if none yet — +// gates ota_updater_request_check() during the cooldown window. +std::atomic s_last_failure_us{0}; + +// Written only by the background task, read via ota_updater_last_check() +// from the HTTP task. Guarded by s_last_check_mutex — unlike +// DnsMetricsSnapshot's plain fields, OtaCheckStatus holds std::string +// members, and a copy-assignment racing a concurrent read can free the +// source string's heap buffer mid-copy (use-after-free), not just return a +// stale value. +OtaCheckStatus s_last_check; +std::mutex s_last_check_mutex; + +struct HttpResponseBuffer { + std::string data; +}; + +esp_err_t http_event_handler(esp_http_client_event_t *evt) +{ + if (evt->event_id == HTTP_EVENT_ON_DATA) { + auto *buf = static_cast(evt->user_data); + if (buf->data.size() + evt->data_len <= MAX_RESPONSE_BYTES) { + buf->data.append(static_cast(evt->data), + static_cast(evt->data_len)); + } + } + return ESP_OK; +} + +// Fetches and parses GitHub's "latest release" JSON, extracting tag_name +// and the mini_dns.bin asset's download URL. Returns false with +// out_error set on any failure (network, HTTP status, JSON shape). +bool fetch_latest_release(std::string &out_tag, std::string &out_asset_url, + std::string &out_error) +{ + HttpResponseBuffer response; + esp_http_client_config_t config = {}; + config.url = GITHUB_RELEASES_URL; + config.event_handler = http_event_handler; + config.user_data = &response; + config.crt_bundle_attach = esp_crt_bundle_attach; + config.timeout_ms = 10000; + + esp_http_client_handle_t client = esp_http_client_init(&config); + esp_http_client_set_header(client, "User-Agent", USER_AGENT); + esp_http_client_set_header(client, "Accept", "application/vnd.github+json"); + + esp_err_t err = esp_http_client_perform(client); + int status = esp_http_client_get_status_code(client); + esp_http_client_cleanup(client); + + if (err != ESP_OK) { + out_error = std::string("http request failed: ") + esp_err_to_name(err); + return false; + } + if (status != 200) { + out_error = "GitHub API returned HTTP " + std::to_string(status); + return false; + } + + cJSON *root = cJSON_ParseWithLength(response.data.c_str(), response.data.size()); + if (root == nullptr) { + out_error = "failed to parse release JSON"; + return false; + } + + cJSON *tag_item = cJSON_GetObjectItemCaseSensitive(root, "tag_name"); + if (!cJSON_IsString(tag_item)) { + cJSON_Delete(root); + out_error = "release JSON missing tag_name"; + return false; + } + out_tag = tag_item->valuestring; + + cJSON *assets = cJSON_GetObjectItemCaseSensitive(root, "assets"); + cJSON *asset = nullptr; + cJSON_ArrayForEach(asset, assets) + { + cJSON *name_item = cJSON_GetObjectItemCaseSensitive(asset, "name"); + if (cJSON_IsString(name_item) && std::strcmp(name_item->valuestring, "mini_dns.bin") == 0) { + cJSON *url_item = cJSON_GetObjectItemCaseSensitive(asset, "browser_download_url"); + if (cJSON_IsString(url_item)) { + out_asset_url = url_item->valuestring; + } + break; + } + } + cJSON_Delete(root); + + if (out_asset_url.empty()) { + out_error = "release has no mini_dns.bin asset"; + return false; + } + return true; +} + +// Runs one full check-and-maybe-update cycle, updating s_last_check. +void run_check_cycle() +{ + s_check_in_progress = true; + OtaCheckStatus status; + status.checked = true; + + std::string tag, asset_url, error; + if (!fetch_latest_release(tag, asset_url, error)) { + // tag may already be valid even though this call failed overall — + // e.g. tag_name parsed fine but no mini_dns.bin asset was found. + // Surface it anyway so /api/ota shows the real latest tag alongside + // the error instead of an empty string indistinguishable from + // "never checked". + status.latest_version = tag; + status.last_error = error; + { + std::lock_guard lock(s_last_check_mutex); + s_last_check = status; + } + s_last_failure_us = esp_timer_get_time(); + s_check_in_progress = false; + ESP_LOGW(TAG, "release check failed: %s", error.c_str()); + return; + } + + status.latest_version = tag; + const std::string current_version = esp_app_get_description()->version; + status.update_available = ota_version_is_newer(current_version, tag); + + if (!status.update_available) { + ESP_LOGI(TAG, "running %s, latest is %s — no update", current_version.c_str(), + tag.c_str()); + { + std::lock_guard lock(s_last_check_mutex); + s_last_check = status; + } + s_check_in_progress = false; + return; + } + + ESP_LOGI(TAG, "update available: %s -> %s, downloading from %s", current_version.c_str(), + tag.c_str(), asset_url.c_str()); + + esp_http_client_config_t http_config = {}; + http_config.url = asset_url.c_str(); + http_config.crt_bundle_attach = esp_crt_bundle_attach; + http_config.timeout_ms = 30000; + http_config.keep_alive_enable = true; + + esp_https_ota_config_t ota_config = {}; + ota_config.http_config = &http_config; + + esp_err_t err = esp_https_ota(&ota_config); + if (err == ESP_OK) { + ESP_LOGI(TAG, "OTA succeeded, rebooting into %s", tag.c_str()); + { + std::lock_guard lock(s_last_check_mutex); + s_last_check = status; + } + s_check_in_progress = false; + esp_restart(); + } + + status.last_error = std::string("esp_https_ota failed: ") + esp_err_to_name(err); + ESP_LOGE(TAG, "%s", status.last_error.c_str()); + { + std::lock_guard lock(s_last_check_mutex); + s_last_check = status; + } + s_last_failure_us = esp_timer_get_time(); + s_check_in_progress = false; +} + +void ota_task(void *) +{ + const int64_t boot_time_us = esp_timer_get_time(); + int64_t last_periodic_check_us = boot_time_us; + bool health_gate_passed = false; + + while (true) { + if (!health_gate_passed) { + int64_t uptime_us = esp_timer_get_time() - boot_time_us; + // Queries >= 1 is the normal path; the absolute-time fallback + // (uptime past HEALTH_GATE_MAX_UPTIME_US) covers a healthy but + // idle device that would otherwise never leave pending_verify + // and get rolled back on its next reset despite being fine. + if (uptime_us >= HEALTH_GATE_MIN_UPTIME_US && + (metrics().snapshot().queries >= 1 || uptime_us >= HEALTH_GATE_MAX_UPTIME_US)) { + esp_err_t err = esp_ota_mark_app_valid_cancel_rollback(); + if (err == ESP_OK) { + s_valid_marked = true; + health_gate_passed = true; + ESP_LOGI(TAG, "health gate passed, cancelled rollback"); + } else { + ESP_LOGE(TAG, "esp_ota_mark_app_valid_cancel_rollback failed: %s", + esp_err_to_name(err)); + } + } + } + + if (s_check_requested.exchange(false)) { + run_check_cycle(); + } else if (health_gate_passed && + esp_timer_get_time() - last_periodic_check_us >= OTA_CHECK_INTERVAL_US) { + last_periodic_check_us = esp_timer_get_time(); + run_check_cycle(); + } + + vTaskDelay(pdMS_TO_TICKS(5000)); + } +} + +} // namespace + +void ota_updater_start() +{ + xTaskCreate(ota_task, "ota_updater", 8192, nullptr, tskIDLE_PRIORITY + 1, nullptr); +} + +OtaHealthState ota_updater_health_state() +{ + return s_valid_marked ? OtaHealthState::kValid : OtaHealthState::kPendingVerify; +} + +OtaCheckStatus ota_updater_last_check() +{ + // in_progress lives in its own atomic (s_check_in_progress), not inside + // s_last_check, since it changes independently of a completed check's + // result — stitched in here rather than stored redundantly in two places. + OtaCheckStatus status; + { + std::lock_guard lock(s_last_check_mutex); + status = s_last_check; + } + status.in_progress = s_check_in_progress; + return status; +} + +bool ota_updater_request_check() +{ + if (s_check_in_progress) { + return false; + } + if (!s_valid_marked) { + // esp_ota_begin() rejects a new OTA attempt while the running image + // is still pending_verify (ESP_ERR_OTA_ROLLBACK_INVALID_STATE) — fail + // fast instead of letting a doomed attempt trip the failure cooldown. + return false; + } + // s_last_failure_us == 0 means "never failed" — esp_timer_get_time() + // itself returns values near 0 for roughly the first 30s after boot, so + // without this guard the very first legitimate check request after + // flashing/booting would be spuriously rejected as "within cooldown" + // even though nothing has ever failed. + if (s_last_failure_us != 0 && + esp_timer_get_time() - s_last_failure_us < OTA_FAILURE_COOLDOWN_US) { + return false; + } + s_check_requested = true; + return true; +} diff --git a/main/ota_updater.h b/main/ota_updater.h new file mode 100644 index 0000000..315efe4 --- /dev/null +++ b/main/ota_updater.h @@ -0,0 +1,45 @@ +#pragma once + +#include + +// Background OTA task (Phase 7a): gates marking the running app image +// valid behind a real health check instead of doing it unconditionally at +// boot, and checks GitHub Releases for a newer tagged version — see +// ARCHITECTURE.md's OTA section for the rollback rationale. +// +// Call once from app_main(), after dns_server_start() (the health gate +// reads metrics().snapshot().queries, which is only meaningful once the +// DNS task is running) and http_server_start() (so /api/ota can report +// status immediately). +void ota_updater_start(); + +enum class OtaHealthState { + kPendingVerify, // running image not yet marked valid; a reset now rolls back + kValid, // marked valid via esp_ota_mark_app_valid_cancel_rollback() +}; +OtaHealthState ota_updater_health_state(); + +struct OtaCheckStatus { + bool checked = false; // false until the first check cycle has run + bool in_progress = false; // a check-and-update cycle is currently running + bool update_available = false; + // The tag parsed during the most recent check cycle, if that cycle got + // that far — run_check_cycle() starts from a fresh, empty OtaCheckStatus + // every cycle, so this is NOT "the most recent tag ever parsed": a + // network/HTTP/JSON-shape failure that happens before the tag_name parse + // step clears it back to empty, even if a prior cycle had populated it. + // May be populated even when last_error is non-empty — e.g. the release + // parsed fine but had no mini_dns.bin asset. + std::string latest_version; + std::string last_error; // empty on success +}; +OtaCheckStatus ota_updater_last_check(); + +// Auth-gated HTTP trigger (POST /api/ota/check in http_server.cpp): asks +// the background task to run a check-and-update cycle now rather than +// waiting for its periodic interval. Returns false (no-op, caller should +// report 409 Conflict) if a cycle is already in progress, if the running +// image hasn't passed its health gate yet (esp_ota_begin() rejects OTA +// while pending_verify), or if a prior cycle failed within the cooldown +// window. +bool ota_updater_request_check(); diff --git a/main/ota_version.cpp b/main/ota_version.cpp new file mode 100644 index 0000000..c8c63da --- /dev/null +++ b/main/ota_version.cpp @@ -0,0 +1,73 @@ +#include "ota_version.h" + +#include + +namespace { + +// Parses a run of ASCII digits starting at `pos`, advancing `pos` past +// them. Returns nullopt if there isn't at least one digit at `pos`. +std::optional parse_uint_component(const std::string &s, size_t &pos) +{ + if (pos >= s.size() || !std::isdigit(static_cast(s[pos]))) { + return std::nullopt; + } + unsigned value = 0; + while (pos < s.size() && std::isdigit(static_cast(s[pos]))) { + value = value * 10 + static_cast(s[pos] - '0'); + ++pos; + } + return value; +} + +} // namespace + +std::optional ota_version_parse(const std::string &text) +{ + size_t pos = 0; + if (pos < text.size() && (text[pos] == 'v' || text[pos] == 'V')) { + ++pos; + } + + auto major = parse_uint_component(text, pos); + if (!major || pos >= text.size() || text[pos] != '.') { + return std::nullopt; + } + ++pos; + + auto minor = parse_uint_component(text, pos); + if (!minor) { + return std::nullopt; + } + + // Patch is optional-but-expected: "1.2" with no patch component at all + // is treated as patch=0 rather than rejected, since a missing patch + // number is still an unambiguous version. A "." with no digits after + // it (e.g. "1.2.") is rejected as malformed. + unsigned patch = 0; + if (pos < text.size() && text[pos] == '.') { + ++pos; + auto parsed_patch = parse_uint_component(text, pos); + if (!parsed_patch) { + return std::nullopt; + } + patch = *parsed_patch; + } + + return OtaVersion{*major, *minor, patch}; +} + +bool ota_version_is_newer(const std::string ¤t, const std::string &remote) +{ + auto cur = ota_version_parse(current); + auto rem = ota_version_parse(remote); + if (!cur || !rem) { + return false; + } + if (rem->major != cur->major) { + return rem->major > cur->major; + } + if (rem->minor != cur->minor) { + return rem->minor > cur->minor; + } + return rem->patch > cur->patch; +} diff --git a/main/ota_version.h b/main/ota_version.h new file mode 100644 index 0000000..2130472 --- /dev/null +++ b/main/ota_version.h @@ -0,0 +1,27 @@ +#pragma once + +#include +#include + +// Parsed "MAJOR.MINOR.PATCH" — a leading 'v'/'V' is stripped by +// ota_version_parse(), matching this repo's `git tag v0.6.0` convention +// (see .github/workflows/ci.yml's release job and esp_app_get_description() +// via PROJECT_VER, which git-describe derives from the same tags). +struct OtaVersion { + unsigned major = 0; + unsigned minor = 0; + unsigned patch = 0; +}; + +// Strict on major/minor, tolerant on patch: a trailing non-digit suffix +// (e.g. the "-3-gabc1234-dirty" git-describe appends between exact tags) +// is ignored once the leading digits of patch are consumed. Returns +// nullopt for anything that isn't at least "N.N" with digit prefixes, +// e.g. "" or "notaversion". +std::optional ota_version_parse(const std::string &text); + +// True only if `remote` parses AND is strictly greater than `current` +// under lexicographic (major, minor, patch) comparison. Fails closed: +// if either side doesn't parse, returns false — never OTA onto something +// this comparator couldn't understand. +bool ota_version_is_newer(const std::string ¤t, const std::string &remote); diff --git a/monitoring/README.md b/monitoring/README.md new file mode 100644 index 0000000..241d75c --- /dev/null +++ b/monitoring/README.md @@ -0,0 +1,28 @@ +# mini_dns monitoring stack + +Prometheus + Grafana for the device's `/metrics` endpoint. Runs on your dev +machine, not the ESP32 — the ESP32 is just a scrape target. + +## Setup + +1. Find the device's LAN IP from its boot log (or `edge-dns.local` if your + local resolver forwards mDNS, which most don't by default). +2. Edit `prometheus/prometheus.yml`, replace `` with that IP. +3. From this directory: + ``` + docker compose up -d + ``` +4. Prometheus: http://localhost:9090 — check Status > Targets, `mini_dns` + job should be `UP`. +5. Grafana: http://localhost:3000 — login `admin`/`admin`, you'll be + prompted to change it on first login. Dashboard "mini_dns" is + auto-provisioned under the `mini_dns` folder. + +## Notes + +- If the device's IP changes (DHCP lease renewal), re-edit + `prometheus/prometheus.yml` and `docker compose restart prometheus`. + A static DHCP reservation on your router avoids this. +- Grafana's datasource and dashboard are provisioned from files + (`grafana/provisioning/`) — don't hand-edit them in the UI, changes + won't persist across container recreation. diff --git a/monitoring/docker-compose.yml b/monitoring/docker-compose.yml new file mode 100644 index 0000000..27cec21 --- /dev/null +++ b/monitoring/docker-compose.yml @@ -0,0 +1,26 @@ +services: + prometheus: + image: prom/prometheus:latest + container_name: mini_dns_prometheus + volumes: + - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prometheus-data:/prometheus + ports: + - "9090:9090" + restart: unless-stopped + + grafana: + image: grafana/grafana:latest + container_name: mini_dns_grafana + depends_on: + - prometheus + volumes: + - ./grafana/provisioning:/etc/grafana/provisioning:ro + - grafana-data:/var/lib/grafana + ports: + - "3000:3000" + restart: unless-stopped + +volumes: + grafana-data: + prometheus-data: diff --git a/monitoring/grafana/provisioning/dashboards/dashboards.yml b/monitoring/grafana/provisioning/dashboards/dashboards.yml new file mode 100644 index 0000000..0410f93 --- /dev/null +++ b/monitoring/grafana/provisioning/dashboards/dashboards.yml @@ -0,0 +1,9 @@ +apiVersion: 1 + +providers: + - name: mini_dns + folder: mini_dns + type: file + updateIntervalSeconds: 30 + options: + path: /etc/grafana/provisioning/dashboards/dashboards diff --git a/monitoring/grafana/provisioning/dashboards/dashboards/mini-dns.json b/monitoring/grafana/provisioning/dashboards/dashboards/mini-dns.json new file mode 100644 index 0000000..314c6e0 --- /dev/null +++ b/monitoring/grafana/provisioning/dashboards/dashboards/mini-dns.json @@ -0,0 +1,95 @@ +{ + "title": "mini_dns", + "uid": "mini-dns", + "timezone": "browser", + "refresh": "15s", + "time": { "from": "now-1h", "to": "now" }, + "panels": [ + { + "id": 1, + "title": "Query breakdown (rate/5m)", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 }, + "targets": [ + { "expr": "rate(dns_queries_total[5m])", "legendFormat": "queries" }, + { "expr": "rate(dns_local_hits_total[5m])", "legendFormat": "local_hits" }, + { "expr": "rate(dns_cache_hits_total[5m])", "legendFormat": "cache_hits" }, + { "expr": "rate(dns_cache_misses_total[5m])", "legendFormat": "cache_misses" }, + { "expr": "rate(dns_forwarded_total[5m])", "legendFormat": "forwarded" }, + { "expr": "rate(dns_blocked_total[5m])", "legendFormat": "blocked" } + ] + }, + { + "id": 2, + "title": "Upstream health (rate/5m)", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 }, + "targets": [ + { "expr": "rate(dns_upstream_replies_total[5m])", "legendFormat": "replies" }, + { "expr": "rate(dns_upstream_timeouts_total[5m])", "legendFormat": "timeouts" }, + { "expr": "rate(dns_upstream_retries_total[5m])", "legendFormat": "retries" }, + { "expr": "rate(dns_servfail_total[5m])", "legendFormat": "servfail" } + ] + }, + { + "id": 3, + "title": "Upstream latency (p50/p95/p99)", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 }, + "fieldConfig": { "defaults": { "unit": "ms" }, "overrides": [] }, + "targets": [ + { + "expr": "histogram_quantile(0.50, rate(dns_upstream_latency_ms_bucket[5m]))", + "legendFormat": "p50" + }, + { + "expr": "histogram_quantile(0.95, rate(dns_upstream_latency_ms_bucket[5m]))", + "legendFormat": "p95" + }, + { + "expr": "histogram_quantile(0.99, rate(dns_upstream_latency_ms_bucket[5m]))", + "legendFormat": "p99" + } + ] + }, + { + "id": 4, + "title": "Saturation", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 }, + "fieldConfig": { "defaults": { "unit": "percentunit", "max": 1, "min": 0 }, "overrides": [] }, + "targets": [ + { + "expr": "dns_cache_entries / dns_cache_capacity", + "legendFormat": "cache" + }, + { + "expr": "dns_inflight_queries / dns_inflight_capacity", + "legendFormat": "inflight" + } + ] + }, + { + "id": 5, + "title": "Blocklist domains", + "type": "stat", + "gridPos": { "h": 4, "w": 6, "x": 0, "y": 16 }, + "targets": [{ "expr": "dns_blocklist_domains", "legendFormat": "domains" }] + }, + { + "id": 6, + "title": "Upstream timeout rate", + "type": "timeseries", + "gridPos": { "h": 8, "w": 18, "x": 6, "y": 16 }, + "fieldConfig": { "defaults": { "unit": "percentunit", "min": 0 }, "overrides": [] }, + "targets": [ + { + "expr": "rate(dns_upstream_timeouts_total[5m]) / rate(dns_forwarded_total[5m])", + "legendFormat": "timeout rate" + } + ] + } + ], + "schemaVersion": 39, + "version": 2 +} diff --git a/monitoring/grafana/provisioning/datasources/prometheus.yml b/monitoring/grafana/provisioning/datasources/prometheus.yml new file mode 100644 index 0000000..bb009bb --- /dev/null +++ b/monitoring/grafana/provisioning/datasources/prometheus.yml @@ -0,0 +1,9 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: false diff --git a/monitoring/prometheus/prometheus.yml b/monitoring/prometheus/prometheus.yml new file mode 100644 index 0000000..252158a --- /dev/null +++ b/monitoring/prometheus/prometheus.yml @@ -0,0 +1,9 @@ +global: + scrape_interval: 15s + +scrape_configs: + - job_name: mini_dns + metrics_path: /metrics + static_configs: + - targets: + - ":80" diff --git a/partitions.csv b/partitions.csv index 6dd35b4..4c90b11 100644 --- a/partitions.csv +++ b/partitions.csv @@ -1,10 +1,14 @@ -# Name, Type, SubType, Offset, Size, Flags +# Name, Type, SubType, Offset, Size, Flags # Note: if you have increased the bootloader size, make sure to update the offsets to avoid overlap # -# Custom table (16MB flash): default IDF single-app table (24K nvs, 1M app) -# was sized for small flash chips and left the app partition at 98% full -# after Phase 2 (ad-block). Sized generously here so Phases 3/4 don't repeat -# this — see docs/superpowers/specs/2026-07-21-edge-dns-phase2-adblock-design.md. -nvs, data, nvs, 0x9000, 0x10000, -phy_init, data, phy, , 0x1000, -factory, app, factory, , 0x400000, +# Dual OTA slots (Phase 7a) replace the single "factory" partition — see +# ARCHITECTURE.md's OTA section. otadata tracks which slot is active/ +# pending-verify; coredump is reserved for Phase 7c's crash-dump capture. +# Repartitioning wipes NVS: `idf.py erase-flash` is required after this +# change, which also wipes the record store and blocklist (see README). +nvs, data, nvs, 0x9000, 0x10000, +otadata, data, ota, , 0x2000, +phy_init, data, phy, , 0x1000, +coredump, data, coredump, , 0x10000, +ota_0, app, ota_0, , 0x400000, +ota_1, app, ota_1, , 0x400000, diff --git a/sdkconfig.defaults b/sdkconfig.defaults index dbaa85a..fd997b8 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -23,3 +23,21 @@ CONFIG_SPIRAM_IGNORE_NOTFOUND=y # httpd_start() abort/reboot loop the first time this shipped. Set with # real headroom, not just "old total + 2", since the cost is negligible. CONFIG_LWIP_MAX_SOCKETS=16 + +# Phase 7a: dual ota_0/ota_1 slots (see partitions.csv) + bootloader +# rollback support — a slot that fails its post-update health check +# reverts to the previous slot on next reset instead of bricking. +CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y + +# App-image signature check without full Secure Boot v2 — deliberately +# NOT enabling secure boot itself (burns eFuses irreversibly, ends +# iteration on this dev board; see ARCHITECTURE.md non-goals). This is a +# software-only signature check the bootloader runs on OTA update, using +# a key that isn't burned into hardware. +CONFIG_SECURE_SIGNED_APPS_NO_SECURE_BOOT=y +CONFIG_SECURE_SIGNED_ON_UPDATE_NO_SECURE_BOOT=y +CONFIG_SECURE_BOOT_SIGNING_KEY="secure_boot_signing_key.pem" + +# GitHub's API and release-asset CDN are both signed by public CAs already +# in ESP-IDF's bundled root store — no custom CA needed for OTA fetches. +CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y