Files
nxdns/PLAN.md
T
2025-12-26 18:42:04 +01:00

46 KiB

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)

// 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)

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/:

// 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)

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|
|      |
+--+--+
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)
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: <length>

<DNS wire format>

Response is also application/dns-message with DNS wire format body.

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:

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:

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)

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:

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:

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:

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)

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:

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)

-- 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:

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)

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)

[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:

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:

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)

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:

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:

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

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

import { AreaChart, Card, Title } from "@tremor/react";

function QueriesChart({ data }) {
  return (
    <Card>
      <Title>Queries over time</Title>
      <AreaChart
        data={data}
        index="time"
        categories={["allowed", "blocked"]}
        colors={["emerald", "red"]}
      />
    </Card>
  );
}

9.4 Build for Embedding

npm run build
# Output in dist/ will be embedded by Zig build

Phase 10: CLI

10.1 Main Entry Point (src/main.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)

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

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:

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:

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:

# 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.