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
115 changes: 115 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
name: CI

on:
push:
pull_request:

jobs:
firmware-build:
name: Build firmware (ESP32-S3)
runs-on: ubuntu-latest
container:
image: espressif/idf:release-v5.4
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Write placeholder Wi-Fi credentials
run: |
cat > main/wifi_credentials.h <<'EOF'
#pragma once
constexpr const char* WIFI_SSID = "ci-placeholder-ssid";
constexpr const char* WIFI_PASSWORD = "ci-placeholder-password";
EOF

- name: Write placeholder admin credentials
run: |
cat > main/admin_credentials.h <<'EOF'
#pragma once
constexpr const char* ADMIN_USER = "ci-placeholder-admin";
constexpr const char* ADMIN_PASS = "ci-placeholder-password";
EOF

- name: Build firmware
run: |
. "$IDF_PATH/export.sh"
idf.py set-target esp32s3
idf.py build

host-tests:
name: Host-side unit tests (dns_wire)
runs-on: ubuntu-latest
container:
image: espressif/idf:release-v5.4
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Build host_test (linux target)
working-directory: host_test
run: |
. "$IDF_PATH/export.sh"
idf.py --preview set-target linux build

- name: Run host tests
run: ./host_test/build/host_test.elf

release:
name: Publish release artifacts
needs: [firmware-build, host-tests]
if: startsWith(github.ref, 'refs/tags/v')
permissions:
contents: write
runs-on: ubuntu-latest
container:
image: espressif/idf:release-v5.4
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Write placeholder Wi-Fi credentials
run: |
cat > main/wifi_credentials.h <<'EOF'
#pragma once
constexpr const char* WIFI_SSID = "ci-placeholder-ssid";
constexpr const char* WIFI_PASSWORD = "ci-placeholder-password";
EOF

- name: Write placeholder admin credentials
run: |
cat > main/admin_credentials.h <<'EOF'
#pragma once
constexpr const char* ADMIN_USER = "ci-placeholder-admin";
constexpr const char* ADMIN_PASS = "ci-placeholder-password";
EOF

- name: Build firmware
run: |
. "$IDF_PATH/export.sh"
idf.py set-target esp32s3
idf.py build

- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
files: |
build/bootloader/bootloader.bin
build/partition_table/partition-table.bin
build/mini_dns.bin
body: |
Prebuilt binaries for `${{ github.ref_name }}`.

**These binaries use placeholder Wi-Fi and admin credentials and will
not connect to any real network.** To flash a working device:

1. Clone the repo at this tag: `git checkout ${{ github.ref_name }}`
2. Create `main/wifi_credentials.h` and `main/admin_credentials.h` with
your real values (see README.md's Setup section)
3. `idf.py set-target esp32s3 && idf.py build`
4. `idf.py -p <PORT> flash monitor`

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
```
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
main/wifi_credentials.h
main/admin_credentials.h

build/
managed_components/
cmake-build-debug/
cmake-build-debug-esp-idf/
.idea/
Expand Down
116 changes: 71 additions & 45 deletions ARCHITECTURE.md

Large diffs are not rendered by default.

79 changes: 71 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# mini_dns

A minimal ESP32-S3 firmware that connects to Wi-Fi, resolves a small hardcoded set of hostnames over DNS (UDP/53), and serves an HTTP page + JSON API showing that same record table.
A minimal ESP32-S3 firmware that connects to Wi-Fi, resolves a runtime-managed set of hostnames over DNS (UDP/53), forwards everything else to an upstream resolver with TTL caching, sinkholes ad/tracker domains, advertises itself via mDNS, and serves an HTTP page + a JSON CRUD API + Prometheus metrics.

Proof-of-concept — no persistence, no provisioning UI, no OTA, no runtime record editing, no auth, no upstream DNS forwarding. See [`ARCHITECTURE.md`](ARCHITECTURE.md) for design details, gotchas, and future scoping.
Proof-of-concept moving toward a marketable "edge DNS" appliance — no provisioning UI, no OTA, no TLS. Records are NVS-persisted and editable via a Basic-auth-gated CRUD API as of Phase 5, dual-stack (A+AAAA) as of Phase 6, with a secondary-upstream retry on forward timeout and a host-side Unity test suite for the wire-format layer (see below). See [`ARCHITECTURE.md`](ARCHITECTURE.md) for design details, gotchas, and future scoping.

## Prerequisites

Expand All @@ -25,14 +25,21 @@ Proof-of-concept — no persistence, no provisioning UI, no OTA, no runtime reco
constexpr const char* WIFI_PASSWORD = "your-password";
```

3. **Edit `main/dns_records.h`** with your own hostname → IP mappings. The table committed to this repo reflects the original author's test network — replace it with entries meaningful to your own LAN:
3. **Create `main/admin_credentials.h`** — gitignored, same pattern as above. This is the Basic-auth credential checked on the mutating `POST`/`PUT`/`DELETE /api/records` routes (Phase 5):
```cpp
constexpr std::array<dns_record_t, N> DNS_RECORDS = {{
#pragma once
constexpr const char* ADMIN_USER = "admin";
constexpr const char* ADMIN_PASS = "your-password";
```

4. **Edit `main/dns_records.h`** with your own hostname → IPv4 mappings. As of Phase 5 this is only the **first-boot seed** — `DNS_RECORDS_DEFAULTS` is loaded into NVS once, then the live table lives there and is managed via the CRUD API below, not by reflashing:
```cpp
constexpr std::array<dns_record_t, N> DNS_RECORDS_DEFAULTS = {{
{"myhost.loc", {192, 168, 1, 100}},
// ...
}};
```
Avoid the `.local` TLD for anything you intend to reach from a phone/laptop browser — see the mDNS gotcha below.
Avoid the `.local` TLD for anything you intend to reach from a phone/laptop browser — see the mDNS gotcha below. (The device itself is always reachable at `edge-dns.local` regardless of what TLD your records use — see Phase 4.) This seed table is IPv4-only; an AAAA address for a seeded name can be added afterward through the CRUD API (Phase 6, below) once the device has booted.

## Building and flashing

Expand All @@ -54,12 +61,68 @@ On boot you should see log lines for Wi-Fi connecting (with the assigned IP), th
Replace `<esp32-ip>` with the IP logged on boot:

```
dig @<esp32-ip> <your-hostname> # DNS resolution
dig @<esp32-ip> <your-hostname> # DNS resolution — record store, cache, or forwarded upstream
dig @<esp32-ip> doubleclick.net # sinkholed (0.0.0.0 / NXDOMAIN) if on the ad-block list
curl http://<esp32-ip>/ # HTML dashboard
curl http://<esp32-ip>/api/records # JSON record list
curl http://<esp32-ip>/api/blocklist # JSON blocklist status + running block count
curl http://<esp32-ip>/metrics # Prometheus plaintext metrics
curl http://edge-dns.local/ # same dashboard, resolved via mDNS instead of raw IP

# Record management (Phase 5) — POST/PUT/DELETE require Basic auth
curl -u admin:<your-password> -X POST -d '{"host":"foo.loc","ip":"192.168.1.99"}' \
http://<esp32-ip>/api/records
curl -u admin:<your-password> -X PUT -d '{"host":"foo.loc","ip":"192.168.1.100"}' \
http://<esp32-ip>/api/records
curl -u admin:<your-password> -X DELETE -d '{"host":"foo.loc"}' \
http://<esp32-ip>/api/records

# Dual-stack records (Phase 6) — "ip" and "ipv6" are each optional, but a
# create/update needs at least one; either or both together are fine
curl -u admin:<your-password> -X POST \
-d '{"host":"dual.loc","ip":"192.168.1.99","ipv6":"2001:db8::1"}' \
http://<esp32-ip>/api/records
dig @<esp32-ip> AAAA dual.loc # answered locally, not forwarded
dig @<esp32-ip> AAAA foo.loc # v4-only record: NOERROR, no answer (NODATA) — not NXDOMAIN
```

## Running host tests

The pure DNS wire-format functions (`main/dns_wire.h/.cpp`) have no FreeRTOS/lwIP
dependency, so they're covered by a Unity test suite that builds and runs on the
host — no board, no QEMU (Phase 6):

```
source $IDF_PATH/export.sh
idf.py --preview set-target linux -C host_test build
./host_test/build/host_test.elf
```

Exits 0 with `24 Tests 0 Failures` on success — a nonzero exit is Unity's failure
count, so this is CI-friendly as-is.

## Continuous Integration

Every push and pull request runs two independent GitHub Actions jobs
(`.github/workflows/ci.yml`): `firmware-build` (ESP32-S3 target, same `idf.py
build` as above) and `host-tests` (the `host_test/` Unity suite, same commands as
above). Both jobs run inside the `espressif/idf:release-v5.4` Docker image, so
they match the ESP-IDF version this README documents.

Pushing a version tag (`git tag v0.6.0 && git push --tags`) additionally
triggers a `release` job that builds the firmware and attaches
`bootloader.bin`/`partition-table.bin`/`mini_dns.bin` to a GitHub Release.
**These release binaries are built with placeholder Wi-Fi/admin credentials and
will not join any real network** — CI has no access to your real
`wifi_credentials.h`/`admin_credentials.h` (both gitignored, by design — see
Setup above). Anyone deploying to a real device still needs to create those two
files locally and rebuild, exactly as in Setup steps 2–3; the release binaries
exist as a CI-verified reference build, not a flash-and-go artifact.

## Known gotchas (see ARCHITECTURE.md for full detail)

- **`.local` hostnames won't resolve from a phone/laptop browser.** `.local` is reserved for mDNS (RFC 6762); client OS resolvers intercept it before it ever reaches this device's DNS server. `dig`/`nslookup` work fine since they bypass that OS-level special-casing. Use a different TLD (e.g. `.loc`, `.test`) for anything you need a real browser to resolve.
- **No upstream DNS forwarding.** This is a leaf resolver — anything not in `DNS_RECORDS` gets NXDOMAIN. If you point a device's DNS settings at this appliance, that device loses normal internet DNS resolution until you point it back.
- **`.local` hostnames won't resolve from a phone/laptop browser.** `.local` is reserved for mDNS (RFC 6762); client OS resolvers intercept it before it ever reaches this device's DNS server. `dig`/`nslookup` work fine since they bypass that OS-level special-casing. Use a different TLD (e.g. `.loc`, `.test`) for anything you need a real browser to resolve. The device itself is always reachable at `edge-dns.local` via a real mDNS responder (Phase 4) — that's a separate mechanism from your own records.
- **Forwarding, not true recursion.** Anything not in the record store or the ad-block list is forwarded to an upstream resolver (`1.1.1.1` by default) and cached — not resolved by walking the root servers. As of Phase 6, a timed-out query is retried once against a secondary (`1.0.0.1`) before giving up; only if both time out does the client get SERVFAIL (after up to ~4s total).
- **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.
20 changes: 20 additions & 0 deletions dependencies.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
dependencies:
espressif/mdns:
component_hash: e81ca7a7f53ea34e78274df054da692c272e1315572876b237b1748e267c013b
dependencies:
- name: idf
require: private
version: '>=5.0'
source:
registry_url: https://components.espressif.com/
type: service
version: 1.11.3
idf:
source:
type: idf
version: 5.4.4
direct_dependencies:
- espressif/mdns
manifest_hash: e15e65256e8748561648ce5638e302cc0f03a8440a181aafeb8fee729e83be32
target: esp32s3
version: 2.0.0
133 changes: 133 additions & 0 deletions docs/superpowers/specs/2026-07-21-edge-dns-phase2-adblock-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
---
tags: [decision, feature, arch]
status: active
supersedes: ""
related: []
complexity: medium
---

# Edge DNS — Phase 2: Ad-block

## What

Adds a domain blocklist to the resolver built in Phase 1. A query for a blocked
domain (or any subdomain of one) is sinkholed — `0.0.0.0` for A queries, NXDOMAIN
for everything else — instead of reaching the cache or upstream forwarder. The
blocklist is seeded from a small baked-in default set on first boot, persisted to
NVS, and exposed read-only over HTTP alongside a running block counter.

This is Phase 2 of the "edge DNS" vision scoped in `ARCHITECTURE.md`. Phase 3
(metrics) is expected to read the block counter this phase introduces.

## Why

- **The problem:** the Phase-1 resolver forwards everything it doesn't recognize
locally, including ad/tracker domains — there is no way to stop resolving a name
the operator doesn't want resolved.
- **Where it sits in the resolution order:** local table → **blocklist** → cache →
forward. It must run before the cache/forwarder (a cached or freshly-forwarded
answer for a blocked name would defeat the block), and after the local table (the
static table's existing "always wins" invariant from Phase 1 must not regress —
an operator-configured local record should never be silently sinkholed).
- **Suffix matching, not exact-match:** real ad/tracker infrastructure lives on
subdomains (`ads.doubleclick.net`, `pixel.doubleclick.net`, …). Exact-match-only,
while textually what `ARCHITECTURE.md` first sketched, would miss the common
case and provide little real blocking value. Suffix matching on label boundaries
(not raw string suffix, to avoid `notdoubleclick.net` false-matching
`doubleclick.net`) is the standard hosts-list/Pi-hole semantic and is what this
phase implements instead.
- **NVS storage, read-only HTTP:** runtime persistence (survives reboot) without
yet building a write API. This keeps the phase's blast radius small — the
blocklist is populated once at boot (from NVS if present, else from the baked-in
defaults, which are then persisted) and never mutated afterward. That in turn
means the Phase-1 "no shared mutable state across tasks, no mutex" property
extends cleanly: both the DNS task and the HTTP task only *read* the set after
boot. The one piece of state that does change at runtime — the block counter —
is a single `std::atomic`, not a structure needing a lock. A write/edit HTTP
endpoint is deferred; adding one later is a small, additive change (mutate the
set, call the existing `save_to_nvs()`), not a redesign.

## How (key parts)

### Resolution order
1. Local static table (`find_dns_record`, unchanged) — still authoritative, still
first.
2. **Blocklist match** (new) — sinkhole and return before touching the cache.
3. Cache hit → serve (unchanged).
4. Miss → forward to upstream (unchanged).

### Matching: suffix walk over an unordered_set
`DnsBlocklist::is_blocked(qname_lower)` tests the full lowercased name, then
repeatedly strips the leftmost label and retests, stopping at the root:
`a.b.doubleclick.net` → `b.doubleclick.net` → `doubleclick.net` → `net`. Each
step is an O(1) `unordered_set` lookup; stripping on label (`.`) boundaries rather
than raw string suffix gives correct semantics for free (`notdoubleclick.net` never
tests as `doubleclick.net`). Blocking a domain implicitly blocks every subdomain of
it — the standard ad-block-list convention.

### Storage: NVS blob, seeded from a baked-in default list
- `main/dns_blocklist_defaults.h` — header-only `constexpr` array of ~15–30
curated ad/tracker domains, in the same style as `dns_records.h`.
- `DnsBlocklist::load_from_nvs()` — opens NVS namespace `blocklist`, reads a single
newline-separated blob under key `list`. If the key doesn't exist (first boot),
populates the in-memory set from the defaults header and calls
`save_to_nvs()` to persist it, so every subsequent boot loads from NVS instead of
the compiled-in list. A single blob (rather than one NVS key per domain) sidesteps
NVS's 15-character key-name limit and keeps the read/write path to one call each.
- `DnsBlocklist::save_to_nvs()` — serializes the current set back to the same blob
key. Exists now so the load-time seeding path has something to call; a future
HTTP mutate endpoint calls the same function.

### Counter and cross-task read access
`std::atomic<uint32_t> blocks_total_` increments on every sinkholed query
(`record_block()`, relaxed ordering — an approximate counter for operational
visibility, not a consistency-critical value). `blocks_total()`, `size()`, and a
`const std::unordered_set<std::string>& domains()` accessor are read by the new
`GET /api/blocklist` HTTP handler. This is a deliberate, narrow deviation from
Phase 1's "state lives inside the DNS task" pattern — justified because the set
itself is immutable after boot (only ever read from both tasks) and the counter
is atomic, so no mutex or queue is introduced.

### HTTP: status-only
`GET /api/blocklist` mirrors the existing read-only `GET /api/records` handler
(`http_server.cpp`) — same cJSON pattern, no new response infrastructure:
```json
{ "count": 23, "blocked_total": 104, "domains": ["doubleclick.net", ...] }
```

### Wiring
`blocklist().load_from_nvs()` is called inside `dns_server_start()`, before the
DNS task is created — NVS itself is already initialized earlier by
`wifi_connect()` (`nvs_flash_init()` in `wifi_connect.cpp`), so by the time either
the DNS task or the HTTP server (started after `dns_server_start()` in
`main.cpp`) can touch the blocklist, it's already loaded.

## Gotchas

- **Load-before-serve ordering matters twice over:** the blocklist must be loaded
before the DNS task starts (or the first queries would race an empty set) *and*
before `http_server_start()` (or an early `GET /api/blocklist` could observe zero
domains). Both are satisfied by loading synchronously inside `dns_server_start()`
ahead of `xTaskCreate`, since `main.cpp` calls `dns_server_start()` before
`http_server_start()`.
- **Suffix matching must walk label boundaries, not raw string suffixes** — a naive
`ends_with(qname, blocked)` check would wrongly block `notdoubleclick.net` for a
blocklist entry of `doubleclick.net`. The label-strip-and-retest approach avoids
this by construction.
- **Local table precedence must not regress** (same invariant Phase 1 called out
for cache/forwarder) — the blocklist check sits after `find_dns_record`, so an
operator's own static record for a name always wins even if that name happens to
also appear on the blocklist.
- **NVS blob size:** a single blob under one key is simplest, but every save
rewrites the whole list; fine at the curated-list sizes this phase targets
(dozens of entries), would need reconsidering if a future phase imports large
(10k+) third-party lists.

## Open threads

- HTTP write/mutate endpoint (`POST`/`DELETE /api/blocklist`) — deferred; the NVS
`save_to_nvs()` path exists so this is additive, not a redesign.
- Large third-party list import (SPIFFS/LittleFS-backed, per `ARCHITECTURE.md`'s
future-scoping) — a different storage tier than NVS, out of scope here.
- Phase 3 (`/metrics`) is expected to expose `blocks_total()` alongside cache/
forwarder counters once that phase starts.
Loading
Loading