Files
nxdns/specs/milestone-1.md

299 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Milestone 1: Build Baseline + Platform Layer
Goal: `zig build test` green natively; static musl executables for x86_64-linux and aarch64-linux linking pinned sqlite3 + mbedTLS; platform modules (address, tls_client, tls_server) implemented and tested; Gitea CI green. No DNS logic in this milestone.
Read first: `AGENTS.md` (values), `specs/research/zig-0.16-api-notes.md` (verified stdlib facts — pre-0.16 API knowledge is stale and MUST NOT be used). The Zig source of truth is `/home/mokhtar/app/zig` at tag `0.16.0`.
## Sessions
Five sessions. S1 runs first, alone. S2, S3, S4, S5 run in parallel after S1 is verified. The orchestrator (not any session) wires `src/tests.zig` imports and any `build.zig` additions afterward.
```
S1 ──> { S2, S3, S4, S5 } ──> orchestrator integration
```
---
## Session S1: Build Baseline
### S1.1 build.zig.zon
- `.name = .nxdns`, `.version = "0.1.0"`, `.minimum_zig_version = "0.16.0"`, `.paths = .{""}`, `.fingerprint` (compiler suggests the value on first build — accept it).
- Dependencies added with `zig fetch --save=<name> <url>` so the content hash is pinned:
- `sqlite`: SQLite 3.53.4 amalgamation zip (https://sqlite.org/2026/sqlite-amalgamation-3530400.zip).
- `mbedtls`: mbedTLS 3.6.7 LTS via the GitHub **source tag** tarball (refs/tags/mbedtls-3.6.7.tar.gz). The release asset is .tar.bz2, which `zig fetch` cannot decompress; the source tarball was diffed against it — identical library + 3rdparty content (3rdparty is in-tree in the 3.6 line).
- As built: `.gitignore` also covers `zig-pkg/` (0.16 unpacks deps there); build.zig adds `-Dgit-commit` (default "unknown") and a `run` step; mbedTLS compiles the 103 `library/*.c` files plus the five 3rdparty objects named by the everest/p256-m Makefile.inc fragments; `installHeadersDirectory` exposes mbedtls/psa headers to any module linking the lib (S4's shim needs no extra include wiring).
### S1.2 build.zig
- Comptime guard: `@import("builtin").zig_version` major==0 and minor==16, else `@compileError`.
- Options: `-Dintegration` (bool, default false) exposed to tests via a `build_options` module (`b.addOptions()`); also embed version string + git commit (`b.option([]const u8, "version-string", ...)` defaulting to "0.1.0-dev") for `src/version.zig`.
- C static libs, one per dependency, built with `b.addLibrary(.{ .linkage = .static, ... })` (NOT addStaticLibrary — it does not exist in 0.16):
- `sqlite3`: compile `sqlite3.c` from the sqlite dependency via `lib.root_module.addCSourceFile`; `addIncludePath` the dep root. Flags: `-DSQLITE_ENABLE_FTS5`, `-DSQLITE_THREADSAFE=1`, `-DSQLITE_DEFAULT_WAL_SYNCHRONOUS=1`, `-DSQLITE_OMIT_LOAD_EXTENSION`.
- `mbedtls`: compile every `library/*.c` file via `root_module.addCSourceFiles(.{ .root = dep.path("library"), .files = ... })` (paths must be relative — absolute paths panic) plus the `3rdparty/everest` and `3rdparty/p256m` sources; `addIncludePath` for `include`, `library`, and both 3rdparty include dirs. Default `mbedtls_config.h`, no custom config in this milestone.
- Executable `nxdns`: root module `src/main.zig`, `link_libc = true`, links both libs. Native artifact + installed.
- Test step `test`: `b.addTest` on a module rooted at `src/tests.zig`, importing `build_options`, linking both C libs.
- Step `cross`: for `x86_64-linux-musl` and `aarch64-linux-musl` (via `b.resolveTargetQuery`), build the exe with `exe.linkage = .static`, install to `zig-out/cross/<triple>/nxdns`.
- `.gitignore`: `.zig-cache/`, `zig-out/`.
### S1.3 src/main.zig, src/version.zig, src/tests.zig
- `src/version.zig`: `pub const string` / `pub const zig_version_string` pulled from `build_options`.
- `src/main.zig`: parse first CLI arg. `version` → print version + zig version, exit 0. `run`, `check`, `export`, `import` → print `not implemented`, exit 2. No arg / unknown → usage on stderr, exit 64. Args come from `std.process.Init` (`main(init: std.process.Init)`) — `std.process.args` does not exist in 0.16; see the API notes.
- `src/tests.zig`: `comptime { _ = @import("main.zig"); _ = @import("version.zig"); }` plus `test` block asserting the sqlite3 and mbedTLS C headers link: call `sqlite3_libversion()` and `mbedtls_version_get_string_full()` via `@cImport`-free extern declarations (declare the two extern fns manually) and check non-empty results.
### S1.4 Acceptance Criteria
- [ ] `zig build test` exits 0 natively; the C-link test prints/asserts both library version strings.
- [ ] `zig build cross` exits 0; `file zig-out/cross/x86_64-linux-musl/nxdns` and the aarch64 one both report "statically linked".
- [ ] `./zig-out/bin/nxdns version` prints the version and Zig 0.16.0, exit 0.
- [ ] `./zig-out/bin/nxdns` (no args) exits 64 with usage on stderr.
- [ ] `git status` clean of build artifacts (gitignore works).
---
## Session S2: platform/address.zig
Pure module. No `std.Io` operations — only type conversions to/from `std.Io.net.IpAddress` values. Unit tests in-file.
### S2.1 Types + API
```zig
pub const NetAddress = union(enum) {
ip4: [4]u8,
ip6: [16]u8,
pub const Key = [17]u8; // tag byte (4 or 6) + address bytes, zero-padded for ip4
pub fn parse(text: []const u8) error{InvalidAddress}!NetAddress; // "1.2.3.4", "fd00::1"; no port, no brackets
pub fn format(self: NetAddress, w: *std.Io.Writer) std.Io.Writer.Error!void; // v4 dotted; v6 RFC 5952 lowercase, :: compression
pub fn key(self: NetAddress) Key;
pub fn fromIp(addr: std.Io.net.IpAddress) NetAddress; // drops port; IPv4-mapped IPv6 (::ffff:a.b.c.d) normalizes to .ip4
pub fn toIp(self: NetAddress, port: u16) std.Io.net.IpAddress;
pub fn eql(a: NetAddress, b: NetAddress) bool;
};
pub const Prefix = struct {
addr: NetAddress, // host bits zeroed on parse
bits: u8, // <= 32 for ip4, <= 128 for ip6
pub fn parse(text: []const u8) error{InvalidPrefix}!Prefix; // "192.168.1.0/24", "fd00:abcd::/48"
pub fn contains(self: Prefix, addr: NetAddress) bool; // family mismatch => false
pub fn format(self: Prefix, w: *std.Io.Writer) std.Io.Writer.Error!void;
};
/// Longest-prefix winner; ties broken by lower `priority` value. Returns null when nothing matches.
pub fn matchLongest(comptime T: type, entries: []const T, addr: NetAddress) ?*const T;
// T must have fields: prefix: Prefix, priority: i64
```
Implement parse/format by hand or delegate to `std.Io.net.Ip4Address/Ip6Address` parsing where it fits — but the RFC 5952 output rules (lowercase hex, longest zero-run compressed, no compression of a single group) must hold and be tested either way.
### S2.2 Acceptance Criteria
- [ ] Round-trip tests: parse→format is identity for canonical inputs (`"192.168.1.1"`, `"fd00::1"`, `"::"`, `"2001:db8::8:800:200c:417a"`).
- [ ] RFC 5952 tests: `"2001:0DB8:0:0:1::1"` formats as `"2001:db8::1:0:0:0:1"`-style rules — specifically: longest run compressed, single zero group NOT compressed, lowercase.
- [ ] `fromIp` on an IPv4-mapped IPv6 address yields `.ip4`.
- [ ] `Prefix.contains`: `192.168.1.0/24` contains `.1.5`, not `.2.5`; `/0` contains everything of its family; family mismatch false.
- [ ] `matchLongest`: `/24` beats `/16`; equal bits → lower priority value wins.
- [ ] All tests pass via `zig test src/platform/address.zig` (orchestrator wires them into `zig build test` later).
---
## Session S3: platform/tls_client.zig
Wrapper over `std.crypto.tls.Client` for upstream DoT (and reused by anything needing client TLS over a `std.Io.net.Stream`). See the API notes §std.crypto.tls.Client for exact init requirements.
### S3.1 API
```zig
pub const ErrorClass = enum { handshake, certificate, io, protocol };
pub fn classify(err: anyerror) ErrorClass;
pub const TlsStream = struct {
// all fields private in practice; struct is pinned: MUST NOT move after init
// (tls.Client holds its reader/writer by value)
pub const Options = struct {
host: []const u8, // SNI + verification name (always sent as SNI)
// insecure_skip_verify skips CA/expiry verification but keeps SNI and leaf
// hostname matching (stdlib couples SNI to .host = .explicit)
ca: enum { system, insecure_skip_verify },
// buffers supplied by caller; read_buffer.len >= std.crypto.tls.Client.min_buffer_len
read_buffer: []u8,
write_buffer: []u8,
stream_read_buffer: []u8,
stream_write_buffer: []u8,
};
/// In-place init (pinned struct). `bundle` is scanned lazily for .system via
/// std.crypto.Certificate.Bundle.rescan when empty; caller owns bundle + lock lifetime.
pub fn init(
self: *TlsStream,
io: std.Io,
stream: *std.Io.net.Stream,
bundle: *std.crypto.Certificate.Bundle,
bundle_lock: *std.Io.RwLock,
gpa: std.mem.Allocator,
options: Options,
) InitError!void;
pub fn reader(self: *TlsStream) *std.Io.Reader; // plaintext
pub fn writer(self: *TlsStream) *std.Io.Writer; // plaintext
pub fn close(self: *TlsStream) void; // close_notify via Client.end, errors swallowed to log-level
};
```
- Entropy: 240 bytes via `io.random`; `realtime_now` via the Io clock (`io.now(.real)` — check the exact 0.16 name in `/home/mokhtar/app/zig/lib/std/Io.zig` before use).
- `classify` maps the stdlib error sets: cert/trust errors → `.certificate`; handshake alerts/negotiation → `.handshake`; ReadFailed/WriteFailed/connection errors → `.io`; everything else → `.protocol`. Write the mapping exhaustively over `std.crypto.tls.Client.InitError` — a `switch` with explicit arms, no `else => .protocol` catch-all for that set.
### S3.2 Tests
- Unit: `classify` mapping table (pick 6+ representative errors across the four classes).
- Integration: lives in `src/platform/tls_client_integration_test.zig` (separate file — conditional imports are impossible in 0.16; see API notes). Runtime guard `if (!build_options.integration) return error.SkipZigTest;` — the body stays type-checked in every build, runs only under `-Dintegration`. Connects to `1.1.1.1:853` (IPv4 literal — this host's IPv6 egress is dead and the anycast address is documented) with `.host = "cloudflare-dns.com"` so SNI + hostname verification run against the real name; `.ca = .system`; clean close. NOT part of default `zig build test`, NOT part of blocking CI.
- As built: `InitError = tls.Client.InitError || error{CertificateBundleLoadFailure}` (bundle rescan folded in, std.http.Client style). `classify` reaches exhaustiveness via `inline for` over `@typeInfo(tls.Client.InitError).error_set.?` — one mapping site, new stdlib errors break the build there.
### S3.3 Acceptance Criteria
- [ ] `zig test src/platform/tls_client.zig` passes (unit tests only).
- [ ] `classify` covers `InitError` exhaustively (compiles with explicit arms — adding a new stdlib error breaks the build here, by design).
- [ ] Integration test compiles under `-Dintegration` (orchestrator runs it after wiring; a live-network failure is an environment finding, not a session failure).
---
## Session S4: platform/tls_server.zig
mbedTLS-backed server-side TLS termination exposing `std.Io.Reader`/`std.Io.Writer`, so `std.http.Server` and the DoT server can sit on top of any accepted TCP stream.
### S4.1 mbedTLS extern layer
Declare the needed mbedTLS API as extern fns/opaque types in this file (no `@cImport` — keep translate-c out of the build). Needed surface: `mbedtls_ssl_context`, `mbedtls_ssl_config`, `mbedtls_x509_crt`, `mbedtls_pk_context`, `mbedtls_entropy_context`, `mbedtls_ctr_drbg_context` + their init/free/setup/parse functions, `mbedtls_ssl_handshake`, `mbedtls_ssl_read`, `mbedtls_ssl_write`, `mbedtls_ssl_close_notify`, `mbedtls_ssl_set_bio`, `mbedtls_strerror`. Sizes: allocate contexts with the C sizes via opaque + `extern` allocation pattern — simplest correct approach: define `extern struct` mirrors is NOT acceptable (fragile); instead heap-allocate via wrapper C-callable `malloc(sizeof)` is also not available — so: declare the context structs as `opaque` and allocate them with `gpa.alignedAlloc(u8, .of(usize), mbedtls_ssl_context_size)` where the sizes come from a tiny C shim file `src/platform/mbedtls_shim.c` exporting `size_t nx_sizeof_ssl_context(void)` etc. The shim is owned by this session and added to build by the orchestrator (S4 must NOT edit build.zig — note the shim path in the completion report).
### S4.2 API
```zig
pub const ServerContext = struct {
// holds parsed cert chain + key + ssl_config + drbg; one per listener, reused across connections
pub fn init(gpa: std.mem.Allocator, cert_pem: [:0]const u8, key_pem: [:0]const u8) InitError!ServerContext;
pub fn deinit(self: *ServerContext, gpa: std.mem.Allocator) void;
};
pub const ServerStream = struct {
// pinned after accept(); owns the ssl_context for one connection
/// Performs the TLS handshake over an accepted TCP stream.
/// BIO callbacks bridge mbedtls_ssl_read/write to stream.reader/writer interfaces.
pub fn accept(
self: *ServerStream,
gpa: std.mem.Allocator,
ctx: *ServerContext,
io: std.Io,
stream: *std.Io.net.Stream,
read_buffer: []u8,
write_buffer: []u8,
) AcceptError!void;
pub fn reader(self: *ServerStream) *std.Io.Reader; // plaintext, implemented via Io.Reader vtable over mbedtls_ssl_read
pub fn writer(self: *ServerStream) *std.Io.Writer; // plaintext, via mbedtls_ssl_write
pub fn close(self: *ServerStream, gpa: std.mem.Allocator) void; // close_notify + free ssl_context
};
```
- Error mapping: negative mbedTLS return codes → Zig error set with named errors for the common cases (`CertParse`, `KeyParse`, `KeyMismatch`, `HandshakeFailed`); include the raw code in a log via `mbedtls_strerror`.
- `MBEDTLS_ERR_SSL_WANT_READ/WANT_WRITE` loop inside read/write — never surfaces to callers. ONLY those two codes retry; the `*_IN_PROGRESS` codes are misconfiguration (no async crypto configured) and surface as errors.
- As built (review round 1): only an authenticated close_notify reads as clean `error.EndOfStream`; transport EOF without close_notify → `error.ReadFailed` with `read_err = .TlsConnectionTruncated` (no opt-out). `ServerContext.init` calls `mbedtls_pk_check_pair` (via the `nx_x509_crt_pk` shim accessor) → `error.KeyMismatch` on a mismatched pair, covered by the `tests/fixtures/mismatched_key.pem` fixture. mbedTLS compiles with `MBEDTLS_THREADING_C` + `MBEDTLS_THREADING_PTHREAD` (build.zig `addMbedtlsThreadingMacros`, applied to every module that sees mbedTLS headers — context sizes change with threading).
### S4.3 Test fixture + loopback test
- Generate once and commit: `tests/fixtures/self_signed_cert.pem` + `self_signed_key.pem` (openssl, EC P-256, CN=localhost, SAN DNS:localhost + IP:127.0.0.1, 100-year validity — a fixture, not a secret; note "test fixture, private key intentionally committed" in a `tests/fixtures/README.md`).
- Loopback test (compiled only under `build_options.integration`): thread A: `IpAddress.listen` on 127.0.0.1:0 → accept → `ServerStream.accept` with the fixture → echo one message read back to the writer. Thread B (client): `std.crypto.tls.Client` with `.host = .no_verification`, `.ca = .no_verification` → write message → read echo → assert equality → clean close both sides. Drive both with `io.concurrent` on one `Threaded` instance.
### S4.4 Acceptance Criteria
- [ ] `ServerContext.init` with the fixture cert+key succeeds; with truncated PEM returns `error.CertParse`/`error.KeyParse` (unit tests, no network).
- [ ] Loopback echo test passes under `-Dintegration` (orchestrator wires + runs).
- [ ] No `@cImport` anywhere; extern decls + C shim only.
- [ ] `close` sends close_notify (verified in the loopback test by the client reading EOF without error after `end`).
---
## Session S5: Gitea CI
### S5.1 .gitea/workflows/ci.yml
House style: `runs-on: ubuntu-24.04`, `actions/checkout@v4`, top-level `env:` for versions (model: `~/app/phoenix_inertia_react_starter/.gitea/workflows/ci.yml`). Runner: x86_64, dind, full egress, `uses:` resolves against github.com. Cache is ephemeral (runner restarts daily) — `actions/cache@v4` allowed as best-effort, never load-bearing.
Jobs:
1. `test`: checkout → `mlugg/setup-zig@v2` with `version: 0.16.0``zig build test``zig build test -Dintegration` is NOT run here (loopback integration runs are wired by the orchestrator in a later pass once tests.zig includes them; leave a commented job stub with a TODO referencing milestone-1 integration wiring).
2. `cross`: checkout → setup-zig → `zig build cross` → assert both output binaries exist and `file` reports statically linked.
### S5.2 .gitea/workflows/live-tls.yml
`workflow_dispatch` only. Runs `zig build test -Dintegration` (which includes the live DoT handshake test once wired). Non-blocking by construction.
### S5.3 Acceptance Criteria
- [ ] `yamllint`-clean (or at minimum `python3 -c "import yaml,sys; yaml.safe_load(open('.gitea/workflows/ci.yml'))"` passes for both files).
- [ ] Workflow YAML uses only actions available from github.com (`actions/checkout@v4`, `mlugg/setup-zig@v2`, `actions/cache@v4`).
- [ ] No job depends on cache hits for correctness.
---
## Module Layout
```
AGENTS.md values + aim (exists)
PLAN.md source of truth (exists)
specs/milestone-1.md this file
specs/research/zig-0.16-api-notes.md stdlib ground truth (exists)
build.zig S1
build.zig.zon S1
.gitignore S1
src/main.zig S1 CLI dispatch stub
src/version.zig S1 build_options plumbing
src/tests.zig S1 test aggregator (orchestrator extends)
src/platform/address.zig S2 NetAddress/Prefix/matchLongest
src/platform/tls_client.zig S3 stdlib TLS client wrapper
src/platform/tls_server.zig S4 mbedTLS server wrapper
src/platform/mbedtls_shim.c S4 sizeof shims
tests/fixtures/self_signed_cert.pem S4
tests/fixtures/self_signed_key.pem S4
tests/fixtures/mismatched_key.pem S4 (review round 1: KeyMismatch fixture)
tests/fixtures/fixtures.zig S4 (@embedFile module root for fixtures)
tests/fixtures/README.md S4
.gitea/workflows/ci.yml S5
.gitea/workflows/live-tls.yml S5
```
## File Ownership
| Files | Owner | Notes |
|---|---|---|
| build.zig, build.zig.zon, .gitignore, src/main.zig, src/version.zig, src/tests.zig | S1 | frozen after S1; orchestrator edits afterward |
| src/platform/address.zig | S2 | |
| src/platform/tls_client.zig | S3 | |
| src/platform/tls_server.zig, src/platform/mbedtls_shim.c, tests/fixtures/* | S4 | shim build-wiring done by orchestrator |
| .gitea/workflows/* | S5 | |
S2S5 MUST NOT edit build.zig, build.zig.zon, or src/tests.zig. If a session needs a build change, it reports the exact needed change in its completion report; the orchestrator applies it.
## Acceptance Criteria (Milestone 1 Complete)
- [ ] `zig build test` exits 0 (aggregator includes address, tls_client unit, tls_server unit tests).
- [ ] `zig build test -Dintegration` exits 0 locally (hermetic loopback TLS echo; deterministic, PR-blocking in CI).
- [ ] `zig build test -Dintegration -Dlive` exits 0 locally (adds the live DoT handshake; manual workflow only — a live-network failure is an environment finding, not a gate).
- [ ] `zig build cross` produces two statically linked executables that print `nxdns version` output under qemu-user or on-target (checked manually for aarch64 if qemu absent).
- [ ] CI workflows valid YAML; `test` + `cross` jobs green on the Gitea runner.
- [ ] All files committed with GPG-signed, lowercase-message commits.
## Anti-Requirements
- No DNS packet code, no sockets beyond the tests, no SQLite usage beyond the link check — that is milestone 2+.
- No custom mbedtls_config.h, no cipher tuning, no session tickets.
- No `@cImport`/translate-c anywhere.
- No extra CLI behavior beyond the specified stubs.
- No Docker/systemd packaging yet.
- No third-party Zig packages.
- Do not "fix" or extend files another session owns — report, don't touch.