1646 lines
46 KiB
Markdown
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.
|