# nxdns — Complete Implementation Plan ## Instructions for Claude Code You are implementing **nxdns**, a self-hosted DNS sinkhole written in Zig. This document contains everything you need to build it from scratch. Work autonomously, implementing one component at a time, testing as you go. **Autonomy guidelines:** - If something is ambiguous or underspecified, make a reasonable decision and document it - If you genuinely need human input (architectural decision, external service credentials, etc.), create a file called `QUESTIONS.md` in the project root with your questions, then continue working on other components - Run tests frequently — after each module is complete - Commit logically (one feature/fix per commit) with descriptive messages - If a dependency or approach doesn't work, try alternatives before asking **Do not stop until the project is complete and all tests pass.** --- ## Project Overview **nxdns** is a DNS server that: - Listens on UDP/TCP port 53 - Blocks ads, trackers, and malware by returning `0.0.0.0` for blocked domains - Forwards allowed queries to upstream DNS-over-HTTPS (DoH) or DNS-over-TLS (DoT) servers - Provides a web UI for configuration and monitoring - Stores query logs in SQLite - Uses TOML for configuration Target platform: Linux (primarily Raspberry Pi 5, but any Linux works) --- ## Technology Stack | Component | Technology | |-----------|------------| | Language | Zig (latest stable) | | Database | SQLite3 | | Config | TOML | | Frontend | React + React Router (static build) + Tremor (charts) | | HTTP Server | Zig (custom or std.http.Server) | | DNS | Custom implementation (this is the core of the project) | --- ## Project Structure ``` nxdns/ ├── src/ │ ├── main.zig # Entry point, CLI, server orchestration │ ├── events.zig # Event signaling (denylist reload via eventfd) │ ├── util.zig # Utility functions │ ├── dns/ │ │ ├── packet.zig # Full DNS packet parsing/encoding │ │ ├── header.zig # 12-byte DNS header │ │ ├── name.zig # Domain name with compression │ │ ├── question.zig # Query section │ │ ├── record.zig # Resource records (A, AAAA, CNAME, etc.) │ │ ├── edns.zig # EDNS (OPT record) support │ │ └── types.zig # Enums: QType, QClass, RCode │ ├── server/ │ │ ├── udp.zig # UDP listener (port 53) │ │ ├── tcp.zig # TCP listener (length-prefixed) │ │ ├── handler.zig # Core logic: deny check → cache → upstream │ │ ├── shutdown.zig # Graceful shutdown coordination │ │ └── rate_limiter.zig # Per-client rate limiting │ ├── upstream/ │ │ ├── doh.zig # DNS-over-HTTPS client │ │ ├── dot.zig # DNS-over-TLS client │ │ ├── pool.zig # Failover across upstreams with health tracking │ │ └── connection_pool.zig # Persistent connection pooling for DoT/DoH │ ├── filter/ │ │ ├── denylist.zig # HashMap with parent-walking lookup │ │ ├── fetcher.zig # Download and parse denylist URLs │ │ └── safe_search.zig # Rewrite domains to force safe search │ ├── cache/ │ │ └── dns_cache.zig # TTL-based response cache with eviction │ ├── storage/ │ │ ├── db.zig # SQLite wrapper with RAII Transaction │ │ ├── schema.zig # Tables and migrations │ │ └── logger.zig # Async batched query logging │ ├── config/ │ │ ├── config.zig # TOML config loading with validation │ │ ├── toml.zig # TOML parser │ │ └── watcher.zig # inotify + signalfd for hot reload │ ├── logging/ │ │ └── logger.zig # Structured logging │ └── web/ │ ├── server.zig # HTTP server with SSE for live logs │ ├── response.zig # HTTP response helpers │ ├── json.zig # JSON serialization │ ├── auth.zig # Session-based authentication │ └── api/ # REST API handlers │ ├── stats.zig │ ├── queries.zig │ ├── clients.zig │ ├── groups.zig │ ├── denylists.zig │ ├── rules.zig │ └── settings.zig ├── web/ # React frontend (separate build) │ └── ... ├── tests/ │ ├── integration_tests.zig # End-to-end server tests │ ├── integration/ # Integration test helpers │ └── dns/ │ └── protocol_tests.zig # DNS protocol conformance tests ├── build.zig ├── build.zig.zon ├── config.example.toml ├── README.md └── LICENSE ``` **Note:** Unit tests are inline in each module using Zig's `test` blocks. Integration and protocol tests are in the `tests/` directory. --- ## Phase 1: DNS Protocol Implementation This is the foundation. Implement RFC 1035 with extensions. ### 1.1 DNS Types and Constants (`src/dns/types.zig`) ```zig // Query/Response types pub const QType = enum(u16) { A = 1, NS = 2, CNAME = 5, SOA = 6, PTR = 12, MX = 15, TXT = 16, AAAA = 28, SRV = 33, OPT = 41, // EDNS DS = 43, // DNSSEC RRSIG = 46, // DNSSEC DNSKEY = 48, // DNSSEC ANY = 255, _, // Allow unknown types }; pub const QClass = enum(u16) { IN = 1, // Internet CH = 3, // Chaos HS = 4, // Hesiod ANY = 255, _, }; pub const RCode = enum(u4) { NoError = 0, FormErr = 1, ServFail = 2, NXDomain = 3, NotImp = 4, Refused = 5, // ... others as needed }; pub const OpCode = enum(u4) { Query = 0, IQuery = 1, // Inverse query (obsolete) Status = 2, // ... others as needed }; ``` ### 1.2 DNS Header (`src/dns/header.zig`) DNS header is exactly 12 bytes: ``` 1 1 1 1 1 1 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ | ID | +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ |QR| Opcode |AA|TC|RD|RA| Z | RCODE | +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ | QDCOUNT | +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ | ANCOUNT | +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ | NSCOUNT | +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ | ARCOUNT | +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ ``` Implement: - `Header` struct with all fields - `Header.parse(buffer: []const u8) !Header` - `Header.encode(self: Header, buffer: []u8) void` ### 1.3 Domain Name Encoding (`src/dns/name.zig`) DNS names use label format: `\x03www\x06google\x03com\x00` **Label compression**: A pointer (2 bytes starting with `0xC0`) can reference an earlier name in the packet to save space. ``` Pointer format: 11PPPPPP PPPPPPPP - First 2 bits are 1,1 (0xC0 mask) - Remaining 14 bits are offset from start of packet ``` Implement: - `Name` struct (store as slice of labels or as string) - `Name.parse(buffer: []const u8, packet_start: []const u8) !struct { name: Name, bytes_read: usize }` - `Name.encode(self: Name, buffer: []u8, compression_map: *CompressionMap) !usize` - `Name.toString(self: Name, allocator: Allocator) ![]const u8` - Handle compression on both read and write ### 1.4 Questions (`src/dns/question.zig`) ``` +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ | QNAME | +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ | QTYPE | +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ | QCLASS | +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ ``` Implement: - `Question` struct - `Question.parse(buffer: []const u8, packet_start: []const u8) !struct { question: Question, bytes_read: usize }` - `Question.encode(...) !usize` ### 1.5 Resource Records (`src/dns/record.zig`) ``` +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ | NAME | +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ | TYPE | +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ | CLASS | +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ | TTL | | | +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ | RDLENGTH | +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ | RDATA | +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ ``` RDATA format depends on TYPE: - **A**: 4 bytes (IPv4 address) - **AAAA**: 16 bytes (IPv6 address) - **CNAME**: Compressed domain name - **MX**: 2 bytes preference + domain name - **TXT**: Length-prefixed strings - **SOA**: Multiple fields (see RFC 1035) - **NS**: Domain name - **PTR**: Domain name - **SRV**: Priority, weight, port, target Implement: - `ResourceRecord` struct with tagged union for RDATA - Parse and encode methods ### 1.6 EDNS (`src/dns/edns.zig`) EDNS uses OPT pseudo-record in Additional section: - NAME: 0 (root) - TYPE: 41 (OPT) - CLASS: Requestor's UDP payload size - TTL: Extended RCODE and flags (including DO bit for DNSSEC) - RDATA: Attribute-value pairs (options) Key options: - DNSSEC OK (DO) bit in flags Implement: - `EdnsOption` struct - Parse/encode OPT records - Helper to check if query supports DNSSEC ### 1.7 Complete Packet (`src/dns/packet.zig`) ```zig pub const Packet = struct { header: Header, questions: []Question, answers: []ResourceRecord, authority: []ResourceRecord, additional: []ResourceRecord, pub fn parse(buffer: []const u8, allocator: Allocator) !Packet { ... } pub fn encode(self: Packet, buffer: []u8) !usize { ... } pub fn deinit(self: *Packet, allocator: Allocator) void { ... } }; ``` ### 1.8 Testing DNS Implementation Create comprehensive tests in `tests/dns/`: ```zig // Test with real captured DNS packets // Use Wireshark to capture real queries/responses // Store as hex strings and verify parsing test "parse simple A query" { const query = "\x00\x01\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00" ++ "\x03www\x06google\x03com\x00" ++ "\x00\x01\x00\x01"; const packet = try Packet.parse(query, testing.allocator); defer packet.deinit(testing.allocator); try testing.expectEqual(packet.header.id, 1); try testing.expectEqual(packet.questions.len, 1); try testing.expectEqualStrings(packet.questions[0].name.toString(), "www.google.com"); } ``` Test cases to cover: - Simple queries (A, AAAA) - Responses with multiple answers - Label compression (pointer in middle of name) - EDNS queries - CNAME chains - Malformed packets (should return errors, not crash) - Maximum length names (253 chars) - Maximum label length (63 chars) --- ## Phase 2: DNS Server ### 2.1 UDP Server (`src/server/udp.zig`) ```zig pub const UdpServer = struct { socket: std.posix.socket_t, handler: *Handler, pub fn init(bind_addr: std.net.Address, handler: *Handler) !UdpServer { ... } pub fn run(self: *UdpServer) !void { var buffer: [512]u8 = undefined; // Standard DNS UDP size while (true) { const result = try std.posix.recvfrom(self.socket, &buffer, ...); // Spawn task or handle inline self.handler.handle(buffer[0..result.len], result.src_addr); } } }; ``` Notes: - UDP DNS messages are typically max 512 bytes (or larger with EDNS) - Each query is independent, no connection state - Consider using async or thread pool for high throughput ### 2.2 TCP Server (`src/server/tcp.zig`) TCP DNS uses 2-byte length prefix before each message: ``` +--+--+ |Length| (2 bytes, big-endian) +--+--+ | | | DNS | |Packet| | | +--+--+ ``` ```zig pub const TcpServer = struct { listener: std.net.Server, handler: *Handler, pub fn run(self: *TcpServer) !void { while (true) { const conn = try self.listener.accept(); // Spawn handler for this connection try self.handleConnection(conn); } } fn handleConnection(self: *TcpServer, conn: std.net.Server.Connection) !void { defer conn.stream.close(); while (true) { // Read 2-byte length var len_buf: [2]u8 = undefined; _ = try conn.stream.readAll(&len_buf); const length = std.mem.readInt(u16, &len_buf, .big); // Read message var buffer: [65535]u8 = undefined; _ = try conn.stream.readAll(buffer[0..length]); // Handle and respond const response = try self.handler.handle(buffer[0..length]); // Write length-prefixed response var resp_len: [2]u8 = undefined; std.mem.writeInt(u16, &resp_len, @intCast(response.len), .big); try conn.stream.writeAll(&resp_len); try conn.stream.writeAll(response); } } }; ``` ### 2.3 Request Handler (`src/server/handler.zig`) Core logic flow: ``` 1. Parse incoming packet 2. Extract query name and type 3. Check denylist (with parent-walking: ads.google.com → google.com → com) 4. If denied: return 0.0.0.0 (or NXDOMAIN based on config) 5. If allowed: a. Check cache b. If cache hit: return cached response (with TTL adjustment) c. If cache miss: query upstream (with health-aware failover) d. Cache response e. Return response 6. Log query asynchronously (batched writes) ``` ```zig pub const Handler = struct { blocklist: *Blocklist, cache: *DnsCache, upstream: *UpstreamPool, logger: *QueryLogger, config: *Config, pub fn handle(self: *Handler, query_bytes: []const u8, client_addr: std.net.Address) ![]const u8 { const query = try Packet.parse(query_bytes, self.allocator); defer query.deinit(self.allocator); if (query.questions.len == 0) return error.NoQuestion; const question = query.questions[0]; const domain = question.name.toString(); // Get client's group const group = self.getClientGroup(client_addr); // Check blocklist if (self.blocklist.isBlocked(domain, group)) { // Log and return blocked response self.logger.log(.{ .domain = domain, .client = client_addr, .blocked = true, }); return self.createBlockedResponse(query); } // Check cache if (self.cache.get(domain, question.qtype)) |cached| { return cached; } // Forward to upstream const response = try self.upstream.query(query_bytes); // CNAME uncloaking: check if any CNAME in response is blocked const parsed_response = try Packet.parse(response, self.allocator); for (parsed_response.answers) |answer| { if (answer.type == .CNAME) { const cname_target = answer.rdata.cname.toString(); if (self.blocklist.isBlocked(cname_target, group)) { self.logger.log(.{ .domain = domain, .client = client_addr, .blocked = true, .reason = "CNAME uncloaking", }); return self.createBlockedResponse(query); } } } // Cache and return self.cache.put(domain, question.qtype, response, parsed_response.answers[0].ttl); self.logger.log(.{ .domain = domain, .client = client_addr, .blocked = false, }); return response; } fn createBlockedResponse(self: *Handler, query: Packet) ![]const u8 { // Create response with same ID, set QR=1, RA=1 // Add A record with 0.0.0.0 (or return NXDOMAIN based on config) ... } }; ``` --- ## Phase 3: Upstream DNS Clients ### 3.1 DoH Client (`src/upstream/doh.zig`) DNS-over-HTTPS sends DNS wire format via HTTP POST: ``` POST /dns-query HTTP/1.1 Host: cloudflare-dns.com Content-Type: application/dns-message Content-Length: ``` Response is also `application/dns-message` with DNS wire format body. ```zig pub const DohClient = struct { url: []const u8, // e.g., "https://cloudflare-dns.com/dns-query" http_client: std.http.Client, pub fn query(self: *DohClient, dns_packet: []const u8) ![]const u8 { var request = try self.http_client.request(.POST, self.url, ...); request.headers.append("Content-Type", "application/dns-message"); request.headers.append("Accept", "application/dns-message"); try request.writer().writeAll(dns_packet); try request.finish(); try request.wait(); const response = try request.reader().readAllAlloc(self.allocator, 65535); return response; } }; ``` Upstream servers to support: - `https://cloudflare-dns.com/dns-query` - `https://dns.google/dns-query` - `https://dns.quad9.net/dns-query` ### 3.2 DoT Client (`src/upstream/dot.zig`) DNS-over-TLS is regular DNS over TLS on port 853, using same 2-byte length prefix as TCP: ```zig pub const DotClient = struct { host: []const u8, port: u16 = 853, pub fn query(self: *DotClient, dns_packet: []const u8) ![]const u8 { // Establish TLS connection var tls_stream = try std.crypto.tls.Client.init( std.net.tcpConnectToHost(self.host, self.port), self.host, ); defer tls_stream.close(); // Send length-prefixed query var len_buf: [2]u8 = undefined; std.mem.writeInt(u16, &len_buf, @intCast(dns_packet.len), .big); try tls_stream.writeAll(&len_buf); try tls_stream.writeAll(dns_packet); // Read length-prefixed response _ = try tls_stream.readAll(&len_buf); const resp_len = std.mem.readInt(u16, &len_buf, .big); var response = try self.allocator.alloc(u8, resp_len); _ = try tls_stream.readAll(response); return response; } }; ``` ### 3.3 Upstream Pool (`src/upstream/pool.zig`) Manages multiple upstreams with priority-order failover: ```zig pub const UpstreamPool = struct { upstreams: []Upstream, // Ordered by priority pub fn query(self: *UpstreamPool, dns_packet: []const u8) ![]const u8 { for (self.upstreams) |upstream| { const result = upstream.query(dns_packet) catch |err| { log.warn("Upstream {} failed: {}", .{upstream.url, err}); continue; // Try next upstream }; return result; } return error.AllUpstreamsFailed; } }; ``` --- ## Phase 4: Filtering ### 4.1 Denylist HashMap (`src/filter/denylist.zig`) ```zig pub const Blocklist = struct { /// Maps group_id -> set of blocked domains groups: std.AutoHashMap(u32, std.StringHashMap(void)), allocator: Allocator, pub fn isBlocked(self: *Blocklist, domain: []const u8, group_id: u32) bool { const group_blocklist = self.groups.get(group_id) orelse return false; // Parent-walking: check domain and all parent domains var d = domain; while (true) { if (group_blocklist.contains(d)) return true; // Move to parent domain if (std.mem.indexOfScalar(u8, d, '.')) |idx| { d = d[idx + 1..]; } else { return false; } } } pub fn reload(self: *Blocklist, db: *Database) !void { // Clear existing // Load from SQLite // Group by group_id } }; ``` ### 4.2 Denylist Fetcher (`src/filter/fetcher.zig`) Load domains from SQLite into HashMap: ```zig pub fn loadBlocklist(db: *Database, allocator: Allocator) !Blocklist { var blocklist = Blocklist.init(allocator); // Load all domains grouped by their group associations const stmt = try db.prepare( \\SELECT d.domain, gl.group_id \\FROM domains d \\JOIN group_lists gl ON d.list_id = gl.list_id \\WHERE gl.enabled = 1 ); while (try stmt.step()) { const domain = stmt.getText(0); const group_id = stmt.getInt(1); try blocklist.addDomain(domain, group_id); } return blocklist; } ``` ### 4.3 Safe Search (`src/filter/safesearch.zig`) Rewrite queries to force safe search: ```zig const safe_search_rewrites = .{ // Google .{ "www.google.com", "forcesafesearch.google.com" }, .{ "www.google.co.uk", "forcesafesearch.google.com" }, // ... other Google TLDs // Bing .{ "www.bing.com", "strict.bing.com" }, // YouTube .{ "www.youtube.com", "restrictmoderate.youtube.com" }, .{ "m.youtube.com", "restrictmoderate.youtube.com" }, .{ "youtubei.googleapis.com", "restrictmoderate.youtube.com" }, .{ "youtube.googleapis.com", "restrictmoderate.youtube.com" }, .{ "www.youtube-nocookie.com", "restrictmoderate.youtube.com" }, // DuckDuckGo .{ "duckduckgo.com", "safe.duckduckgo.com" }, }; pub fn applySafeSearch(domain: []const u8) ?[]const u8 { inline for (safe_search_rewrites) |rewrite| { if (std.mem.eql(u8, domain, rewrite[0])) { return rewrite[1]; } } return null; } ``` ### 4.4 CNAME Uncloaking (`src/filter/cname.zig`) Check CNAME targets in responses against blocklist: ```zig pub fn checkCnameChain(response: *Packet, blocklist: *Blocklist, group_id: u32) bool { for (response.answers) |answer| { if (answer.type == .CNAME) { const target = answer.rdata.cname.toString(); if (blocklist.isBlocked(target, group_id)) { return true; // Blocked via CNAME } } } return false; } ``` --- ## Phase 5: DNS Cache ### 5.1 Cache Implementation (`src/cache/dns_cache.zig`) ```zig const CacheEntry = struct { response: []const u8, expires_at: i64, qtype: QType, }; pub const DnsCache = struct { entries: std.StringHashMap(std.ArrayList(CacheEntry)), allocator: Allocator, max_entries: usize, pub fn get(self: *DnsCache, domain: []const u8, qtype: QType) ?[]const u8 { const entries = self.entries.get(domain) orelse return null; const now = std.time.timestamp(); for (entries.items) |entry| { if (entry.qtype == qtype and entry.expires_at > now) { return entry.response; } } return null; } pub fn put(self: *DnsCache, domain: []const u8, qtype: QType, response: []const u8, ttl: u32) !void { const expires_at = std.time.timestamp() + ttl; // Evict if at capacity if (self.entries.count() >= self.max_entries) { self.evictOldest(); } // Store const entry = CacheEntry{ .response = try self.allocator.dupe(u8, response), .expires_at = expires_at, .qtype = qtype, }; // ... add to entries } fn evictOldest(self: *DnsCache) void { // LRU or random eviction } }; ``` --- ## Phase 6: Storage ### 6.1 SQLite Wrapper (`src/storage/db.zig`) Use Zig's SQLite bindings or call C API directly: ```zig pub const Database = struct { conn: *c.sqlite3, pub fn open(path: []const u8) !Database { ... } pub fn close(self: *Database) void { ... } pub fn exec(self: *Database, sql: []const u8) !void { ... } pub fn prepare(self: *Database, sql: []const u8) !Statement { ... } }; pub const Statement = struct { stmt: *c.sqlite3_stmt, pub fn bind(self: *Statement, index: usize, value: anytype) !void { ... } pub fn step(self: *Statement) !bool { ... } pub fn getText(self: *Statement, col: usize) []const u8 { ... } pub fn getInt(self: *Statement, col: usize) i64 { ... } pub fn reset(self: *Statement) void { ... } }; ``` ### 6.2 Schema (`src/storage/schema.zig`) ```sql -- String interning for query log CREATE TABLE domains ( id INTEGER PRIMARY KEY, domain TEXT NOT NULL UNIQUE ); CREATE TABLE clients ( id INTEGER PRIMARY KEY, ip TEXT NOT NULL UNIQUE, name TEXT, group_id INTEGER REFERENCES groups(id) ); CREATE TABLE groups ( id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE ); -- Blocklist sources CREATE TABLE blocklist_sources ( id INTEGER PRIMARY KEY, url TEXT NOT NULL, name TEXT, category TEXT, -- 'ads', 'malware', 'adult', etc. enabled INTEGER DEFAULT 1, last_updated INTEGER, domain_count INTEGER DEFAULT 0 ); -- Domains from blocklists CREATE TABLE blocklist_domains ( id INTEGER PRIMARY KEY, domain TEXT NOT NULL, source_id INTEGER REFERENCES blocklist_sources(id), UNIQUE(domain, source_id) ); CREATE INDEX idx_blocklist_domains_domain ON blocklist_domains(domain); -- Which groups use which blocklist sources CREATE TABLE group_sources ( group_id INTEGER REFERENCES groups(id), source_id INTEGER REFERENCES blocklist_sources(id), PRIMARY KEY (group_id, source_id) ); -- Custom rules (per-group allow/block) CREATE TABLE rules ( id INTEGER PRIMARY KEY, domain TEXT NOT NULL, group_id INTEGER REFERENCES groups(id), action TEXT NOT NULL, -- 'allow' or 'block' created_at INTEGER ); -- Query log CREATE TABLE query_log ( id INTEGER PRIMARY KEY, timestamp INTEGER NOT NULL, domain_id INTEGER NOT NULL REFERENCES domains(id), client_id INTEGER NOT NULL REFERENCES clients(id), qtype INTEGER, blocked INTEGER NOT NULL, response_time_us INTEGER, upstream TEXT ); CREATE INDEX idx_query_log_timestamp ON query_log(timestamp); CREATE INDEX idx_query_log_client ON query_log(client_id); -- Settings CREATE TABLE settings ( key TEXT PRIMARY KEY, value TEXT ); ``` ### 6.3 Async Query Logger (`src/storage/logger.zig`) Batch writes to avoid blocking DNS resolution: ```zig pub const QueryLogger = struct { buffer: std.ArrayList(QueryLogEntry), db: *Database, mutex: std.Thread.Mutex, last_flush: i64, const BATCH_SIZE = 100; const FLUSH_INTERVAL_MS = 100; pub fn log(self: *QueryLogger, entry: QueryLogEntry) void { self.mutex.lock(); defer self.mutex.unlock(); self.buffer.append(entry) catch return; const now = std.time.milliTimestamp(); if (self.buffer.items.len >= BATCH_SIZE or now - self.last_flush >= FLUSH_INTERVAL_MS) { self.flushLocked(); } } fn flushLocked(self: *QueryLogger) void { if (self.buffer.items.len == 0) return; self.db.exec("BEGIN IMMEDIATE") catch return; for (self.buffer.items) |entry| { // Insert domain if new, get ID // Insert client if new, get ID // Insert log entry } self.db.exec("COMMIT") catch { self.db.exec("ROLLBACK") catch {}; return; }; self.buffer.clearRetainingCapacity(); self.last_flush = std.time.milliTimestamp(); } }; ``` --- ## Phase 7: Configuration ### 7.1 TOML Parser Either use an existing Zig TOML library or implement a simple one. The config is not complex. ### 7.2 Config Structure (`src/config/config.zig`) ```zig pub const Config = struct { upstream: struct { servers: []const []const u8, }, blocking: struct { response: enum { zero, nxdomain } = .zero, }, safe_search: struct { enabled: bool = true, }, web: struct { port: u16 = 8080, bind: []const u8 = "127.0.0.1", password: ?[]const u8 = null, }, logging: struct { retention: []const u8 = "30 days", level: LogLevel = .info, output: []const u8 = "stderr", }, dns: struct { port: u16 = 53, bind: []const u8 = "0.0.0.0", cache_size: usize = 10000, }, pub fn load(path: []const u8) !Config { ... } pub fn save(self: Config, path: []const u8) !void { ... } }; ``` ### 7.3 Config File Example (`config.example.toml`) ```toml [upstream] servers = [ "https://cloudflare-dns.com/dns-query", "https://dns.google/dns-query" ] [blocking] response = "zero" # or "nxdomain" [safe_search] enabled = true [web] port = 8080 bind = "127.0.0.1" # password = "your-password-here" # Uncomment to enable auth [logging] retention = "30 days" # e.g., "7 days", "1 week", "3 months", "1 year", "forever" level = "info" # debug, info, warn, error output = "stderr" # stderr, syslog, or /path/to/file [dns] port = 53 bind = "0.0.0.0" cache_size = 10000 ``` ### 7.4 File Watcher (`src/config/watcher.zig`) Use inotify to watch config file for changes: ```zig pub const ConfigWatcher = struct { inotify_fd: i32, watch_fd: i32, config_path: []const u8, on_change: *const fn() void, pub fn init(path: []const u8, callback: *const fn() void) !ConfigWatcher { const fd = try std.posix.inotify_init1(0); const wd = try std.posix.inotify_add_watch(fd, path, std.posix.IN.MODIFY); return .{ .inotify_fd = fd, .watch_fd = wd, .config_path = path, .on_change = callback, }; } pub fn poll(self: *ConfigWatcher) !void { var buf: [4096]u8 = undefined; const len = try std.posix.read(self.inotify_fd, &buf); if (len > 0) { self.on_change(); } } }; ``` --- ## Phase 8: Web Interface ### 8.1 HTTP Server (`src/web/server.zig`) Use Zig's std.http.Server or implement a simple one: ```zig pub const WebServer = struct { server: std.net.Server, router: Router, static_files: StaticFiles, pub fn run(self: *WebServer) !void { while (true) { const conn = try self.server.accept(); try self.handleRequest(conn); } } fn handleRequest(self: *WebServer, conn: std.net.Server.Connection) !void { var buffer: [8192]u8 = undefined; var server = std.http.Server.init(conn, &buffer); const request = try server.receiveHead(); // Try API routes first if (std.mem.startsWith(u8, request.target, "/api/")) { try self.router.handle(request, &server); return; } // Serve static files try self.static_files.serve(request, &server); } }; ``` ### 8.2 API Routes (`src/web/api.zig`) ```zig pub fn handleStats(request: *Request, response: *Response) !void { const stats = getStats(); try response.json(.{ .queries_today = stats.queries_today, .blocked_today = stats.blocked_today, .percent_blocked = stats.percent_blocked, .top_blocked = stats.top_blocked, .top_clients = stats.top_clients, }); } pub fn handleQueries(request: *Request, response: *Response) !void { const limit = request.queryParam("limit") orelse "100"; const offset = request.queryParam("offset") orelse "0"; const client = request.queryParam("client"); const blocked = request.queryParam("blocked"); const queries = db.getQueries(.{ .limit = std.fmt.parseInt(usize, limit, 10) catch 100, .offset = std.fmt.parseInt(usize, offset, 10) catch 0, .client = client, .blocked = blocked, }); try response.json(queries); } // ... implement all endpoints from API spec ``` ### 8.3 Server-Sent Events for Live Logs Live query logs use SSE (Server-Sent Events) instead of WebSocket for simpler implementation. The HTTP server maintains a list of SSE subscribers that receive real-time query notifications via the QueryLogger's subscriber callback mechanism. Endpoint: `GET /api/queries/live`. ### 8.4 Static File Server (`src/web/static.zig`) Embed frontend files at compile time: ```zig const index_html = @embedFile("../../web/dist/index.html"); const app_js = @embedFile("../../web/dist/assets/app.js"); const app_css = @embedFile("../../web/dist/assets/app.css"); pub const StaticFiles = struct { pub fn serve(self: *StaticFiles, request: *Request, response: *Response) !void { const path = if (std.mem.eql(u8, request.target, "/")) "/index.html" else request.target; const content = self.getFile(path) orelse { // SPA fallback: serve index.html for unknown routes response.status = .ok; response.headers.append("Content-Type", "text/html"); try response.send(index_html); return; }; response.headers.append("Content-Type", self.getMimeType(path)); try response.send(content); } }; ``` ### 8.5 Authentication (`src/web/auth.zig`) Simple session-based auth: ```zig pub const Auth = struct { password_hash: ?[]const u8, sessions: std.StringHashMap(i64), // token -> expires_at pub fn isEnabled(self: *Auth) bool { return self.password_hash != null; } pub fn login(self: *Auth, password: []const u8) ?[]const u8 { if (!self.verifyPassword(password)) return null; const token = generateToken(); const expires = std.time.timestamp() + 86400; // 24 hours self.sessions.put(token, expires) catch return null; return token; } pub fn validateRequest(self: *Auth, request: *Request) bool { if (!self.isEnabled()) return true; const cookie = request.headers.get("Cookie") orelse return false; const token = parseCookie(cookie, "session") orelse return false; const expires = self.sessions.get(token) orelse return false; return expires > std.time.timestamp(); } }; ``` --- ## Phase 9: Frontend (React) ### 9.1 Setup ```bash cd web npm create vite@latest . -- --template react-ts npm install react-router-dom @tremor/react tailwindcss ``` ### 9.2 Key Components **Dashboard.tsx** — Stats cards, charts for queries over time, top blocked domains **QueryLog.tsx** — Table with filters, pagination, links to live view **LiveLog.tsx** — WebSocket-connected real-time log **Clients.tsx** — List clients, assign to groups **Groups.tsx** — Manage groups **Denylists.tsx** — Add/remove denylist sources, view by category **Rules.tsx** — Custom allow/block rules **Settings.tsx** — Config editor ### 9.3 Tremor Charts ```tsx import { AreaChart, Card, Title } from "@tremor/react"; function QueriesChart({ data }) { return ( Queries over time ); } ``` ### 9.4 Build for Embedding ```bash npm run build # Output in dist/ will be embedded by Zig build ``` --- ## Phase 10: CLI ### 10.1 Main Entry Point (`src/main.zig`) ```zig pub fn main() !void { var args = std.process.args(); _ = args.next(); // Skip program name const command = args.next() orelse { printUsage(); return; }; if (std.mem.eql(u8, command, "run")) { try runServer(); } else if (std.mem.eql(u8, command, "check")) { try checkConfig(); } else if (std.mem.eql(u8, command, "migrate")) { try runMigrations(); } else if (std.mem.eql(u8, command, "upgrade")) { try selfUpgrade(); } else if (std.mem.eql(u8, command, "version")) { printVersion(); } else { printUsage(); } } fn runServer() !void { const config = try Config.load("/etc/nxdns/config.toml"); // Initialize components var db = try Database.open("/etc/nxdns/nxdns.db"); try db.migrate(); var blocklist = try Blocklist.load(&db); var cache = DnsCache.init(config.dns.cache_size); var upstream = try UpstreamPool.init(config.upstream.servers); var logger = QueryLogger.init(&db); var handler = Handler{ .blocklist = &blocklist, .cache = &cache, .upstream = &upstream, .logger = &logger, .config = &config, }; // Start servers var udp = try UdpServer.init(config.dns.port, &handler); var tcp = try TcpServer.init(config.dns.port, &handler); var web = try WebServer.init(config.web.port, &handler, &db); // Start config watcher var watcher = try ConfigWatcher.init("/etc/nxdns/config.toml", reload); log.info("nxdns started", .{}); log.info("DNS server listening on :{}", .{config.dns.port}); log.info("Web UI at http://localhost:{}", .{config.web.port}); // Run (spawn threads or use async) const threads = [_]std.Thread{ try std.Thread.spawn(.{}, UdpServer.run, .{&udp}), try std.Thread.spawn(.{}, TcpServer.run, .{&tcp}), try std.Thread.spawn(.{}, WebServer.run, .{&web}), try std.Thread.spawn(.{}, ConfigWatcher.poll, .{&watcher}), }; for (threads) |t| t.join(); } fn selfUpgrade() !void { // 1. Fetch latest release from GitHub API // 2. Download binary for current architecture // 3. Verify checksum // 4. Replace current binary // 5. Print instructions to restart } ``` --- ## Phase 11: Logging ### 11.1 Logger (`src/util/log.zig`) ```zig pub const LogLevel = enum { debug, info, warn, err }; pub const Logger = struct { level: LogLevel, output: Output, const Output = union(enum) { stderr, syslog, file: std.fs.File, }; pub fn log(self: *Logger, level: LogLevel, comptime fmt: []const u8, args: anytype) void { if (@intFromEnum(level) < @intFromEnum(self.level)) return; const timestamp = formatTimestamp(); const level_str = switch (level) { .debug => "DEBUG", .info => "INFO ", .warn => "WARN ", .err => "ERROR", }; switch (self.output) { .stderr => { std.io.getStdErr().writer().print( "{s} {s} " ++ fmt ++ "\n", .{timestamp, level_str} ++ args, ) catch {}; }, .syslog => { // Use syslog(3) }, .file => |f| { f.writer().print(...) catch {}; }, } } pub fn info(self: *Logger, comptime fmt: []const u8, args: anytype) void { self.log(.info, fmt, args); } // ... debug, warn, err }; ``` --- ## Phase 12: Build System ### 12.1 build.zig ```zig const std = @import("std"); pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); const exe = b.addExecutable(.{ .name = "nxdns", .root_source_file = b.path("src/main.zig"), .target = target, .optimize = optimize, }); // Link SQLite exe.linkSystemLibrary("sqlite3"); exe.linkLibC(); // Embed static files exe.addAnonymousModule("static", .{ .root_source_file = b.path("web/dist/embed.zig"), }); b.installArtifact(exe); // Run command const run_cmd = b.addRunArtifact(exe); run_cmd.step.dependOn(b.getInstallStep()); const run_step = b.step("run", "Run nxdns"); run_step.dependOn(&run_cmd.step); // Tests const unit_tests = b.addTest(.{ .root_source_file = b.path("src/main.zig"), .target = target, .optimize = optimize, }); const run_unit_tests = b.addRunArtifact(unit_tests); const test_step = b.step("test", "Run unit tests"); test_step.dependOn(&run_unit_tests.step); } ``` --- ## Implementation Order 1. **DNS packet parsing** (types, header, name, question, record) 2. **DNS packet encoding** (reverse of above) 3. **Tests for parsing/encoding** (use captured real packets) 4. **UDP server** (receive query, echo back) 5. **Basic handler** (parse, create response, send) 6. **TCP server** 7. **DoH client** 8. **DoT client** 9. **Upstream pool** with failover 10. **SQLite wrapper** 11. **Database schema** 12. **Denylist loading** from SQLite 13. **Denylist HashMap** with parent-walking 14. **Deny logic** in handler 15. **CNAME uncloaking** 16. **DNS cache** 17. **Safe search** 18. **Query logging** (async batched) 19. **TOML config** parser 20. **Config loading/saving** 21. **Config file watcher** 22. **EDNS support** 23. **HTTP server** 24. **REST API** endpoints 25. **Static file serving** 26. **Authentication** 27. **WebSocket** for live logs 28. **React frontend** 29. **CLI commands** (check, migrate, upgrade, version) 30. **Logging** (stderr, syslog, file) 31. **Graceful shutdown** 32. **Integration tests** 33. **Documentation** --- ## Testing Strategy ### Unit Tests - Every DNS parsing function - Denylist lookups (exact match, parent walking) - Cache operations - Config parsing ### Integration Tests - Full query flow (UDP → handler → upstream → response) - Blocking behavior - CNAME uncloaking - API endpoints ### Manual Testing - Use `dig` to test DNS resolution - Compare responses with real DNS servers - Test blocking with known ad domains - Verify safe search works ### Test Domains - `google.com` — should resolve - `doubleclick.net` — should be blocked (if in denylist) - `tracker.example.com` → CNAME → `blocked-tracker.net` — test uncloaking --- ## Blocklist Sources to Include Pre-configured categories with suggested lists: ### Ads & Trackers - OISD: `https://big.oisd.nl/domainswild` - StevenBlack: `https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts` - AdGuard DNS: `https://adguardteam.github.io/AdGuardSDNSFilter/Filters/filter.txt` ### Malware & Phishing - URLhaus: `https://urlhaus.abuse.ch/downloads/hostfile/` - PhishTank: (requires parsing) - abuse.ch: `https://threatfox.abuse.ch/downloads/hostfile/` ### Adult Content - OISD NSFW: `https://nsfw.oisd.nl/domainswild` ### Native Telemetry - NextDNS native tracking: `https://raw.githubusercontent.com/nextdns/native-tracking-domains/main/domains/*` ### Gambling - Various community lists ### Social Media - Block Facebook, Twitter, TikTok, etc. --- ## Performance Targets - Handle 100 queries/second sustained - < 1ms for denylist lookup - < 5ms total latency for cached responses - < 100MB memory with 1M blocked domains - < 50MB database per month of logs (with retention) --- ## Security Considerations - Bind to specific interface (not 0.0.0.0 by default for web) - Rate limiting to prevent DNS amplification - Validate all input (DNS packets, API requests) - SQL injection prevention (use prepared statements) - Password hashing (argon2 or bcrypt) for web auth --- ## Architecture Patterns ### RAII Database Transactions Database transactions use RAII pattern for automatic rollback on scope exit: ```zig var tx = try db.begin(); defer tx.deinit(); // Auto-rollback if not committed // ... do work ... try tx.commit(); // Only on success ``` ### Graceful Shutdown with signalfd Instead of traditional signal handlers (which have async-signal-safety constraints), use Linux signalfd to handle SIGINT/SIGTERM as regular file descriptor events. This allows safe cleanup without signal handler restrictions. ### Upstream Health Tracking Upstreams track consecutive failures with 30-second cooldown. Healthy upstreams are tried first, unhealthy ones only as last resort. This provides fast failover without hammering failing servers. ### Connection Pooling DoT and DoH use persistent connection pools to avoid TLS handshake overhead. UDP uses pre-connected sockets with mutex protection for thread safety. ### Backpressure via SERVFAIL When UDP queue is full, send SERVFAIL response instead of silent drop. This provides feedback to clients for proper retry behavior. ### Atomic Connection Limits TCP connection limits use atomic fetch-add before accepting to prevent TOCTOU races: ```zig const old = count.fetchAdd(1, .acq_rel); if (old >= max) { _ = count.fetchSub(1, .release); // reject } ``` --- ## Default Configuration On first run, create: - `/etc/nxdns/config.toml` — from example - `/etc/nxdns/nxdns.db` — empty database with schema - Default group "default" - One denylist source (OISD or StevenBlack) --- ## Error Handling - DNS parse errors: Return FORMERR - Upstream timeout: Try next upstream, eventually return SERVFAIL - SQLite errors: Log and continue (don't crash) - Config errors: Log and use defaults where possible --- ## Questions File If you need clarification on anything, create `QUESTIONS.md`: ```markdown # Questions for Human Review ## Question 1: [Topic] [Your question here] **Context:** [Why you need this answered] **Your current assumption:** [What you're doing in the meantime] --- ## Question 2: ... ``` Continue working on other parts while waiting for answers. --- ## Success Criteria The project is complete when: 1. `nxdns run` starts successfully 2. DNS queries resolve correctly (test with `dig @localhost google.com`) 3. Blocked domains return 0.0.0.0 (test with `dig @localhost doubleclick.net`) 4. Web UI loads at `http://localhost:8080` 5. Query log shows in web UI 6. All tests pass (`zig build test`) 7. Can add/remove denylists via web UI 8. Safe search works 9. CNAME uncloaking works 10. Upstream failover works --- Good luck. Build something great.