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

1646 lines
46 KiB
Markdown

# 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: <length>
<DNS wire format>
```
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 (
<Card>
<Title>Queries over time</Title>
<AreaChart
data={data}
index="time"
categories={["allowed", "blocked"]}
colors={["emerald", "red"]}
/>
</Card>
);
}
```
### 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.