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.mdin 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.0for 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:
Headerstruct with all fieldsHeader.parse(buffer: []const u8) !HeaderHeader.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:
Namestruct (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) !usizeName.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:
QuestionstructQuestion.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:
ResourceRecordstruct 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:
EdnsOptionstruct- 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-queryhttps://dns.google/dns-queryhttps://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
- DNS packet parsing (types, header, name, question, record)
- DNS packet encoding (reverse of above)
- Tests for parsing/encoding (use captured real packets)
- UDP server (receive query, echo back)
- Basic handler (parse, create response, send)
- TCP server
- DoH client
- DoT client
- Upstream pool with failover
- SQLite wrapper
- Database schema
- Denylist loading from SQLite
- Denylist HashMap with parent-walking
- Deny logic in handler
- CNAME uncloaking
- DNS cache
- Safe search
- Query logging (async batched)
- TOML config parser
- Config loading/saving
- Config file watcher
- EDNS support
- HTTP server
- REST API endpoints
- Static file serving
- Authentication
- WebSocket for live logs
- React frontend
- CLI commands (check, migrate, upgrade, version)
- Logging (stderr, syslog, file)
- Graceful shutdown
- Integration tests
- 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
digto test DNS resolution - Compare responses with real DNS servers
- Test blocking with known ad domains
- Verify safe search works
Test Domains
google.com— should resolvedoubleclick.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:
nxdns runstarts successfully- DNS queries resolve correctly (test with
dig @localhost google.com) - Blocked domains return 0.0.0.0 (test with
dig @localhost doubleclick.net) - Web UI loads at
http://localhost:8080 - Query log shows in web UI
- All tests pass (
zig build test) - Can add/remove denylists via web UI
- Safe search works
- CNAME uncloaking works
- Upstream failover works
Good luck. Build something great.