initial commit
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
.zig-cache/
|
||||
zig-out/
|
||||
*.o
|
||||
|
||||
.ignore/
|
||||
@@ -0,0 +1,122 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
nxdns is a DNS sinkhole written in Zig. It denies ads/trackers/malware by returning 0.0.0.0 for denied domains and forwards allowed queries to upstream DoH/DoT servers.
|
||||
|
||||
## Build Commands
|
||||
|
||||
```bash
|
||||
zig build # Build the project
|
||||
zig build run # Run nxdns
|
||||
zig build test # Run all tests
|
||||
zig fmt src/ # Format code
|
||||
```
|
||||
|
||||
Validate config without running:
|
||||
```bash
|
||||
NXDNS_CONFIG=./config.example.toml ./zig-out/bin/nxdns check
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/
|
||||
├── main.zig # Entry point, CLI, server orchestration
|
||||
├── events.zig # Event signaling (denylist reload via eventfd)
|
||||
├── util.zig # Utility functions
|
||||
├── dns/ # DNS protocol (RFC 1035)
|
||||
│ ├── packet.zig # Full packet parse/encode
|
||||
│ ├── 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
|
||||
│ └── connection_pool.zig # Connection pooling
|
||||
├── 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
|
||||
├── storage/
|
||||
│ ├── db.zig # SQLite wrapper
|
||||
│ ├── schema.zig # Tables and migrations
|
||||
│ └── logger.zig # Async batched query logging
|
||||
├── config/
|
||||
│ ├── config.zig # TOML config loading
|
||||
│ ├── toml.zig # TOML parser
|
||||
│ └── watcher.zig # inotify + signalfd for hot reload
|
||||
├── logging/
|
||||
│ └── logger.zig # Structured logging
|
||||
└── web/
|
||||
├── server.zig # HTTP server
|
||||
├── 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
|
||||
```
|
||||
|
||||
## Key Data Flow
|
||||
|
||||
1. Query arrives (UDP/TCP) → `handler.handle()`
|
||||
2. Check denylist (parent-walking: `ads.google.com` → `google.com` → `com`)
|
||||
3. If denied: return 0.0.0.0 response
|
||||
4. If allowed: check cache → query upstream (DoH/DoT) → cache response
|
||||
5. Log query to SQLite asynchronously
|
||||
|
||||
## Code Patterns
|
||||
|
||||
**Database transactions** - Use RAII pattern for safety:
|
||||
```zig
|
||||
var tx = try db.begin();
|
||||
defer tx.deinit(); // Auto-rollback if not committed
|
||||
// ... do work ...
|
||||
try tx.commit();
|
||||
```
|
||||
|
||||
## Design Principles
|
||||
|
||||
- **Lean**: No bloat, only what's needed
|
||||
- **Pragmatic**: Real-world patterns that work
|
||||
- **Simple**: Easy to understand and extend
|
||||
|
||||
## Implementation Status
|
||||
|
||||
See PLAN.md for the full spec. Current state:
|
||||
- ✅ DNS protocol parsing/encoding (with EDNS)
|
||||
- ✅ UDP/TCP servers
|
||||
- ✅ Denylist with parent-walking
|
||||
- ✅ DNS cache
|
||||
- ✅ DoH client
|
||||
- ✅ DoT client (with connection pooling)
|
||||
- ✅ Web UI with REST API
|
||||
- ✅ Config hot reload (inotify + signalfd)
|
||||
- ✅ Denylist fetching and auto-reload
|
||||
- ✅ Safe search enforcement
|
||||
- ✅ Log retention enforcement (startup + daily cleanup)
|
||||
- ✅ Integration tests
|
||||
|
||||
## Reference
|
||||
|
||||
Zig 0.15.2 stdlib source is at `.ignore/zig/` for API verification.
|
||||
@@ -0,0 +1,74 @@
|
||||
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_module = b.createModule(.{
|
||||
.root_source_file = b.path("src/main.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
}),
|
||||
});
|
||||
|
||||
// Link SQLite
|
||||
exe.linkSystemLibrary("sqlite3");
|
||||
exe.linkLibC();
|
||||
|
||||
b.installArtifact(exe);
|
||||
|
||||
// Run command
|
||||
const run_cmd = b.addRunArtifact(exe);
|
||||
run_cmd.step.dependOn(b.getInstallStep());
|
||||
|
||||
if (b.args) |args| {
|
||||
run_cmd.addArgs(args);
|
||||
}
|
||||
|
||||
const run_step = b.step("run", "Run nxdns");
|
||||
run_step.dependOn(&run_cmd.step);
|
||||
|
||||
// Unit tests for main (includes all modules via imports - runs all embedded tests)
|
||||
const main_tests = b.addTest(.{
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = b.path("src/main.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
}),
|
||||
});
|
||||
main_tests.linkSystemLibrary("sqlite3");
|
||||
main_tests.linkLibC();
|
||||
|
||||
const run_main_tests = b.addRunArtifact(main_tests);
|
||||
|
||||
// Unit tests for DNS packet module (self-contained)
|
||||
const dns_tests = b.addTest(.{
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = b.path("src/dns/packet.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
}),
|
||||
});
|
||||
|
||||
const run_dns_tests = b.addRunArtifact(dns_tests);
|
||||
|
||||
const test_step = b.step("test", "Run unit tests");
|
||||
test_step.dependOn(&run_main_tests.step);
|
||||
test_step.dependOn(&run_dns_tests.step);
|
||||
|
||||
// Memory test binary
|
||||
const memtest = b.addExecutable(.{
|
||||
.name = "memtest",
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = b.path("src/memtest.zig"),
|
||||
.target = target,
|
||||
.optimize = .ReleaseFast,
|
||||
}),
|
||||
});
|
||||
|
||||
const run_memtest = b.addRunArtifact(memtest);
|
||||
const memtest_step = b.step("memtest", "Run memory test with real blocklists");
|
||||
memtest_step.dependOn(&run_memtest.step);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
.{
|
||||
.name = .nxdns,
|
||||
.version = "0.1.0",
|
||||
.fingerprint = 0x3307b31156a24fc8,
|
||||
.minimum_zig_version = "0.14.0",
|
||||
.dependencies = .{},
|
||||
.paths = .{
|
||||
"build.zig",
|
||||
"build.zig.zon",
|
||||
"src",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
# nxdns configuration
|
||||
|
||||
# Database path
|
||||
database = "/etc/nxdns/nxdns.db"
|
||||
|
||||
[upstream]
|
||||
# Upstream DNS servers (DoH or DoT)
|
||||
servers = ["https://cloudflare-dns.com/dns-query", "https://dns.google/dns-query"]
|
||||
|
||||
[blocking]
|
||||
# Response for denied domains: "zero" (0.0.0.0) or "nxdomain"
|
||||
response = "zero"
|
||||
# Automatically fetch denylist updates every 5 minutes
|
||||
auto_update = true
|
||||
|
||||
[safe_search]
|
||||
# Enforce safe search on Google, Bing, YouTube, etc.
|
||||
enabled = true
|
||||
|
||||
[web]
|
||||
# Web interface settings
|
||||
port = 8080
|
||||
bind = "127.0.0.1"
|
||||
# password = "your-password-here"
|
||||
|
||||
[logging]
|
||||
# Query log retention (e.g., "7 days", "1 week", "3 months", "1 year", "forever")
|
||||
retention = "30 days"
|
||||
# Log level: debug, info, warn, error
|
||||
level = "info"
|
||||
# Output: stderr, syslog, or /path/to/file
|
||||
output = "stderr"
|
||||
|
||||
[dns]
|
||||
# DNS server settings
|
||||
port = 53
|
||||
bind = "0.0.0.0"
|
||||
cache_size = 10000
|
||||
Vendored
+349
@@ -0,0 +1,349 @@
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const types = @import("../dns/types.zig");
|
||||
const handler = @import("../server/handler.zig");
|
||||
|
||||
/// Cache entry with TTL and query type
|
||||
const CacheEntry = struct {
|
||||
response: []const u8,
|
||||
qtype: u16,
|
||||
expires_at: i64,
|
||||
created_at: i64,
|
||||
};
|
||||
|
||||
/// DNS response cache with TTL
|
||||
pub const DnsCache = struct {
|
||||
/// Cache entries: domain -> list of entries (for different qtypes)
|
||||
entries: std.StringHashMapUnmanaged(std.ArrayListUnmanaged(CacheEntry)),
|
||||
/// Total number of entries
|
||||
entry_count: usize,
|
||||
/// Maximum number of entries
|
||||
max_entries: usize,
|
||||
/// Minimum TTL (in seconds)
|
||||
min_ttl: u32,
|
||||
/// Maximum TTL (in seconds)
|
||||
max_ttl: u32,
|
||||
allocator: Allocator,
|
||||
mutex: std.Thread.Mutex,
|
||||
|
||||
pub const DEFAULT_MAX_ENTRIES: usize = 10000;
|
||||
pub const DEFAULT_MIN_TTL: u32 = 60;
|
||||
pub const DEFAULT_MAX_TTL: u32 = 86400;
|
||||
|
||||
pub fn init(allocator: Allocator) DnsCache {
|
||||
return initWithConfig(allocator, DEFAULT_MAX_ENTRIES, DEFAULT_MIN_TTL, DEFAULT_MAX_TTL);
|
||||
}
|
||||
|
||||
pub fn initWithConfig(allocator: Allocator, max_entries: usize, min_ttl: u32, max_ttl: u32) DnsCache {
|
||||
return DnsCache{
|
||||
.entries = std.StringHashMapUnmanaged(std.ArrayListUnmanaged(CacheEntry)){},
|
||||
.entry_count = 0,
|
||||
.max_entries = max_entries,
|
||||
.min_ttl = min_ttl,
|
||||
.max_ttl = max_ttl,
|
||||
.allocator = allocator,
|
||||
.mutex = std.Thread.Mutex{},
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *DnsCache) void {
|
||||
var iter = self.entries.iterator();
|
||||
while (iter.next()) |entry| {
|
||||
// Free domain key
|
||||
self.allocator.free(entry.key_ptr.*);
|
||||
|
||||
// Free all cached responses
|
||||
for (entry.value_ptr.items) |cache_entry| {
|
||||
self.allocator.free(cache_entry.response);
|
||||
}
|
||||
entry.value_ptr.deinit(self.allocator);
|
||||
}
|
||||
self.entries.deinit(self.allocator);
|
||||
}
|
||||
|
||||
/// Get a cached response and return a mutable copy (caller owns the memory)
|
||||
/// Filters by both domain and query type
|
||||
/// Returns error.OutOfMemory if allocation fails, null if not in cache
|
||||
pub fn getCopy(self: *DnsCache, domain: []const u8, qtype: types.QType) error{OutOfMemory}!?[]u8 {
|
||||
self.mutex.lock();
|
||||
defer self.mutex.unlock();
|
||||
|
||||
const qtype_val = @intFromEnum(qtype);
|
||||
const entries_list = self.entries.get(domain) orelse return null;
|
||||
const now = std.time.timestamp();
|
||||
|
||||
for (entries_list.items) |entry| {
|
||||
if (entry.qtype == qtype_val and entry.expires_at > now) {
|
||||
return try self.allocator.dupe(u8, entry.response);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Put a response in the cache
|
||||
/// Note: Responses with TTL=0 are not cached per RFC 2308 (negative caching)
|
||||
pub fn put(self: *DnsCache, domain: []const u8, qtype: types.QType, response: []const u8, ttl: u32) void {
|
||||
// Don't cache responses with TTL=0 - these indicate "do not cache" per RFC
|
||||
if (ttl == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.mutex.lock();
|
||||
defer self.mutex.unlock();
|
||||
|
||||
const qtype_val = @intFromEnum(qtype);
|
||||
|
||||
// Clamp TTL to configured bounds
|
||||
const clamped_ttl = @max(self.min_ttl, @min(ttl, self.max_ttl));
|
||||
|
||||
const now = std.time.timestamp();
|
||||
const expires_at = now + clamped_ttl;
|
||||
|
||||
// Evict until we have space
|
||||
while (self.entry_count >= self.max_entries) {
|
||||
self.evictExpiredLocked();
|
||||
if (self.entry_count >= self.max_entries) {
|
||||
self.evictOldestLocked();
|
||||
}
|
||||
}
|
||||
|
||||
// Get or create entry list for this domain
|
||||
const gop = self.entries.getOrPut(self.allocator, domain) catch |err| {
|
||||
std.log.warn("Cache: failed to create entry: {}", .{err});
|
||||
return;
|
||||
};
|
||||
|
||||
if (!gop.found_existing) {
|
||||
gop.key_ptr.* = self.allocator.dupe(u8, domain) catch |err| {
|
||||
std.log.warn("Cache: failed to allocate domain key: {}", .{err});
|
||||
return;
|
||||
};
|
||||
gop.value_ptr.* = std.ArrayListUnmanaged(CacheEntry){};
|
||||
}
|
||||
|
||||
// Check if we already have an entry for this qtype and update it
|
||||
for (gop.value_ptr.items) |*entry| {
|
||||
if (entry.qtype == qtype_val) {
|
||||
// Update existing entry
|
||||
self.allocator.free(entry.response);
|
||||
entry.response = self.allocator.dupe(u8, response) catch |err| {
|
||||
std.log.warn("Cache: failed to allocate response copy: {}", .{err});
|
||||
return;
|
||||
};
|
||||
entry.expires_at = expires_at;
|
||||
entry.created_at = now;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Copy response and add new entry
|
||||
const response_copy = self.allocator.dupe(u8, response) catch |err| {
|
||||
std.log.warn("Cache: failed to allocate response copy: {}", .{err});
|
||||
return;
|
||||
};
|
||||
|
||||
gop.value_ptr.append(self.allocator, CacheEntry{
|
||||
.response = response_copy,
|
||||
.qtype = qtype_val,
|
||||
.expires_at = expires_at,
|
||||
.created_at = now,
|
||||
}) catch |err| {
|
||||
std.log.warn("Cache: failed to append entry: {}", .{err});
|
||||
self.allocator.free(response_copy);
|
||||
return;
|
||||
};
|
||||
|
||||
self.entry_count += 1;
|
||||
}
|
||||
|
||||
/// Clear all cached entries
|
||||
pub fn clear(self: *DnsCache) void {
|
||||
self.mutex.lock();
|
||||
defer self.mutex.unlock();
|
||||
|
||||
var iter = self.entries.iterator();
|
||||
while (iter.next()) |entry| {
|
||||
self.allocator.free(entry.key_ptr.*);
|
||||
for (entry.value_ptr.items) |cache_entry| {
|
||||
self.allocator.free(cache_entry.response);
|
||||
}
|
||||
entry.value_ptr.deinit(self.allocator);
|
||||
}
|
||||
self.entries.clearRetainingCapacity();
|
||||
self.entry_count = 0;
|
||||
}
|
||||
|
||||
/// Evict expired entries
|
||||
fn evictExpiredLocked(self: *DnsCache) void {
|
||||
const now = std.time.timestamp();
|
||||
var to_remove = std.ArrayListUnmanaged([]const u8){};
|
||||
defer to_remove.deinit(self.allocator);
|
||||
|
||||
var iter = self.entries.iterator();
|
||||
while (iter.next()) |entry| {
|
||||
var i: usize = 0;
|
||||
while (i < entry.value_ptr.items.len) {
|
||||
if (entry.value_ptr.items[i].expires_at <= now) {
|
||||
self.allocator.free(entry.value_ptr.items[i].response);
|
||||
_ = entry.value_ptr.swapRemove(i);
|
||||
self.entry_count -= 1;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Mark empty domains for removal
|
||||
if (entry.value_ptr.items.len == 0) {
|
||||
to_remove.append(self.allocator, entry.key_ptr.*) catch continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove empty domains
|
||||
for (to_remove.items) |domain| {
|
||||
if (self.entries.fetchRemove(domain)) |kv| {
|
||||
self.allocator.free(kv.key);
|
||||
var value = kv.value;
|
||||
value.deinit(self.allocator);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Evict the oldest entry
|
||||
fn evictOldestLocked(self: *DnsCache) void {
|
||||
var oldest_domain: ?[]const u8 = null;
|
||||
var oldest_time: i64 = std.math.maxInt(i64);
|
||||
var oldest_idx: usize = 0;
|
||||
|
||||
var iter = self.entries.iterator();
|
||||
while (iter.next()) |entry| {
|
||||
for (entry.value_ptr.items, 0..) |cache_entry, i| {
|
||||
if (cache_entry.created_at < oldest_time) {
|
||||
oldest_time = cache_entry.created_at;
|
||||
oldest_domain = entry.key_ptr.*;
|
||||
oldest_idx = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (oldest_domain) |domain| {
|
||||
if (self.entries.getPtr(domain)) |list| {
|
||||
self.allocator.free(list.items[oldest_idx].response);
|
||||
_ = list.swapRemove(oldest_idx);
|
||||
self.entry_count -= 1;
|
||||
|
||||
// Remove domain if empty
|
||||
if (list.items.len == 0) {
|
||||
if (self.entries.fetchRemove(domain)) |kv| {
|
||||
self.allocator.free(kv.key);
|
||||
var value = kv.value;
|
||||
value.deinit(self.allocator);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get cache statistics
|
||||
pub fn getStats(self: *DnsCache) CacheStats {
|
||||
self.mutex.lock();
|
||||
defer self.mutex.unlock();
|
||||
|
||||
return CacheStats{
|
||||
.entry_count = self.entry_count,
|
||||
.domain_count = self.entries.count(),
|
||||
.max_entries = self.max_entries,
|
||||
};
|
||||
}
|
||||
|
||||
/// Convert to handler-compatible Cache interface
|
||||
pub fn toHandlerCache(self: *DnsCache) handler.Cache {
|
||||
return handler.Cache{
|
||||
.context = self,
|
||||
.getFn = getWrapper,
|
||||
.putFn = putWrapper,
|
||||
};
|
||||
}
|
||||
|
||||
fn getWrapper(ctx: *anyopaque, domain: []const u8, qtype: types.QType) ?[]u8 {
|
||||
const self: *DnsCache = @ptrCast(@alignCast(ctx));
|
||||
return self.getCopy(domain, qtype) catch |err| {
|
||||
std.log.warn("Cache lookup failed due to memory allocation error: {}", .{err});
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
fn putWrapper(ctx: *anyopaque, domain: []const u8, qtype: types.QType, response: []const u8, ttl: u32) void {
|
||||
const self: *DnsCache = @ptrCast(@alignCast(ctx));
|
||||
self.put(domain, qtype, response, ttl);
|
||||
}
|
||||
};
|
||||
|
||||
/// Cache statistics
|
||||
pub const CacheStats = struct {
|
||||
entry_count: usize,
|
||||
domain_count: usize,
|
||||
max_entries: usize,
|
||||
};
|
||||
|
||||
test "DnsCache basic operations" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var cache = DnsCache.init(allocator);
|
||||
defer cache.deinit();
|
||||
|
||||
const response = "test response data";
|
||||
cache.put("example.com", types.QType.A, response, 300);
|
||||
|
||||
const cached = try cache.getCopy("example.com", types.QType.A);
|
||||
defer if (cached) |c| allocator.free(c);
|
||||
|
||||
try testing.expect(cached != null);
|
||||
try testing.expectEqualStrings(response, cached.?);
|
||||
}
|
||||
|
||||
test "DnsCache expiration" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
// min_ttl=1 ensures entry doesn't expire immediately
|
||||
var cache = DnsCache.initWithConfig(allocator, 100, 1, 60);
|
||||
defer cache.deinit();
|
||||
|
||||
const response = "test response data";
|
||||
cache.put("example.com", types.QType.A, response, 1); // TTL of 1 second (clamped to min)
|
||||
|
||||
// Should be cached immediately (TTL is 1 second, so still valid)
|
||||
const cached1 = try cache.getCopy("example.com", types.QType.A);
|
||||
defer if (cached1) |c| allocator.free(c);
|
||||
try testing.expect(cached1 != null);
|
||||
|
||||
// Note: Can't easily test actual expiration without time manipulation
|
||||
}
|
||||
|
||||
test "DnsCache miss" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var cache = DnsCache.init(allocator);
|
||||
defer cache.deinit();
|
||||
|
||||
const cached = try cache.getCopy("nonexistent.com", types.QType.A);
|
||||
try testing.expect(cached == null);
|
||||
}
|
||||
|
||||
test "DnsCache stats" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var cache = DnsCache.init(allocator);
|
||||
defer cache.deinit();
|
||||
|
||||
cache.put("example1.com", types.QType.A, "response1", 300);
|
||||
cache.put("example2.com", types.QType.A, "response2", 300);
|
||||
|
||||
const stats = cache.getStats();
|
||||
try testing.expectEqual(@as(usize, 2), stats.entry_count);
|
||||
try testing.expectEqual(@as(usize, 2), stats.domain_count);
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const toml = @import("toml.zig");
|
||||
const fs = std.fs;
|
||||
|
||||
/// Application configuration
|
||||
pub const Config = struct {
|
||||
/// Upstream DNS servers
|
||||
upstream: UpstreamConfig,
|
||||
/// Blocking settings
|
||||
blocking: BlockingConfig,
|
||||
/// Safe search settings
|
||||
safe_search: SafeSearchConfig,
|
||||
/// Web interface settings
|
||||
web: WebConfig,
|
||||
/// Logging settings
|
||||
logging: LoggingConfig,
|
||||
/// DNS server settings
|
||||
dns: DnsConfig,
|
||||
/// Database path
|
||||
database_path: []const u8,
|
||||
|
||||
allocator: Allocator,
|
||||
|
||||
pub const UpstreamConfig = struct {
|
||||
servers: [][]const u8,
|
||||
};
|
||||
|
||||
pub const BlockingConfig = struct {
|
||||
response: BlockingResponse,
|
||||
auto_update: bool,
|
||||
|
||||
pub const BlockingResponse = enum {
|
||||
zero,
|
||||
nxdomain,
|
||||
};
|
||||
};
|
||||
|
||||
pub const SafeSearchConfig = struct {
|
||||
enabled: bool,
|
||||
};
|
||||
|
||||
pub const WebConfig = struct {
|
||||
port: u16,
|
||||
bind: []const u8,
|
||||
password: ?[]const u8,
|
||||
};
|
||||
|
||||
pub const LoggingConfig = struct {
|
||||
retention: []const u8,
|
||||
level: LogLevel,
|
||||
output: []const u8,
|
||||
|
||||
pub const LogLevel = enum {
|
||||
debug,
|
||||
info,
|
||||
warn,
|
||||
err,
|
||||
};
|
||||
|
||||
/// Get retention duration in seconds
|
||||
pub fn retentionSeconds(self: *const LoggingConfig) i64 {
|
||||
return parseDuration(self.retention) catch 30 * std.time.s_per_day;
|
||||
}
|
||||
};
|
||||
|
||||
/// Parse a human-readable duration string to seconds
|
||||
/// Supports: "30 days", "1 week", "6 hours", "3 months", "1 year", "forever"
|
||||
pub fn parseDuration(input: []const u8) !i64 {
|
||||
const trimmed = std.mem.trim(u8, input, " \t\n\r");
|
||||
|
||||
if (std.mem.eql(u8, trimmed, "forever")) {
|
||||
return std.math.maxInt(i64);
|
||||
}
|
||||
|
||||
// Find where digits end
|
||||
var num_end: usize = 0;
|
||||
for (trimmed) |c| {
|
||||
if (!std.ascii.isDigit(c)) break;
|
||||
num_end += 1;
|
||||
}
|
||||
if (num_end == 0) return error.InvalidDuration;
|
||||
|
||||
const value = std.fmt.parseInt(i64, trimmed[0..num_end], 10) catch return error.InvalidDuration;
|
||||
if (value < 0) return error.InvalidDuration;
|
||||
|
||||
const unit = std.mem.trim(u8, trimmed[num_end..], " ");
|
||||
|
||||
const multiplier: i64 = if (unit.len == 0)
|
||||
std.time.s_per_day // bare number = days
|
||||
else if (startsWith(unit, "sec"))
|
||||
1
|
||||
else if (startsWith(unit, "min"))
|
||||
std.time.s_per_min
|
||||
else if (startsWith(unit, "hour"))
|
||||
std.time.s_per_hour
|
||||
else if (startsWith(unit, "day"))
|
||||
std.time.s_per_day
|
||||
else if (startsWith(unit, "week"))
|
||||
std.time.s_per_week
|
||||
else if (startsWith(unit, "month"))
|
||||
30 * std.time.s_per_day
|
||||
else if (startsWith(unit, "year"))
|
||||
365 * std.time.s_per_day
|
||||
else
|
||||
return error.InvalidDuration;
|
||||
|
||||
return value * multiplier;
|
||||
}
|
||||
|
||||
fn startsWith(haystack: []const u8, needle: []const u8) bool {
|
||||
return std.mem.startsWith(u8, haystack, needle);
|
||||
}
|
||||
|
||||
pub const DnsConfig = struct {
|
||||
port: u16,
|
||||
bind: []const u8,
|
||||
cache_size: usize,
|
||||
workers: u32,
|
||||
};
|
||||
|
||||
/// Default configuration
|
||||
pub fn default(allocator: Allocator) !Config {
|
||||
const servers = try allocator.alloc([]const u8, 2);
|
||||
servers[0] = try allocator.dupe(u8, "https://cloudflare-dns.com/dns-query");
|
||||
servers[1] = try allocator.dupe(u8, "https://dns.google/dns-query");
|
||||
|
||||
return Config{
|
||||
.upstream = .{
|
||||
.servers = servers,
|
||||
},
|
||||
.blocking = .{
|
||||
.response = .zero,
|
||||
.auto_update = true,
|
||||
},
|
||||
.safe_search = .{
|
||||
.enabled = true,
|
||||
},
|
||||
.web = .{
|
||||
.port = 8080,
|
||||
.bind = try allocator.dupe(u8, "127.0.0.1"),
|
||||
.password = null,
|
||||
},
|
||||
.logging = .{
|
||||
.retention = try allocator.dupe(u8, "30 days"),
|
||||
.level = .info,
|
||||
.output = try allocator.dupe(u8, "stderr"),
|
||||
},
|
||||
.dns = .{
|
||||
.port = 53,
|
||||
.bind = try allocator.dupe(u8, "0.0.0.0"),
|
||||
.cache_size = 10000,
|
||||
.workers = 32,
|
||||
},
|
||||
.database_path = try allocator.dupe(u8, "/etc/nxdns/nxdns.db"),
|
||||
.allocator = allocator,
|
||||
};
|
||||
}
|
||||
|
||||
/// Load configuration from a file
|
||||
pub fn load(path: []const u8, allocator: Allocator) !Config {
|
||||
// Read file
|
||||
const file = fs.cwd().openFile(path, .{}) catch |err| {
|
||||
if (err == error.FileNotFound) {
|
||||
// Return default config if file doesn't exist
|
||||
return default(allocator);
|
||||
}
|
||||
return err;
|
||||
};
|
||||
defer file.close();
|
||||
|
||||
const content = try file.readToEndAlloc(allocator, 1024 * 1024);
|
||||
defer allocator.free(content);
|
||||
|
||||
return parseContent(content, allocator);
|
||||
}
|
||||
|
||||
/// Parse configuration from a string
|
||||
pub fn parseContent(content: []const u8, allocator: Allocator) !Config {
|
||||
var parser = toml.TomlParser.init(allocator);
|
||||
const toml_data = try parser.parse(content);
|
||||
defer toml.freeValue(allocator, .{ .table = toml_data });
|
||||
|
||||
var config = try default(allocator);
|
||||
errdefer config.deinit();
|
||||
|
||||
// Parse upstream section
|
||||
if (toml.getTable(toml_data, "upstream")) |upstream| {
|
||||
if (toml.getStringArray(upstream, "servers")) |servers_arr| {
|
||||
// Free default servers
|
||||
for (config.upstream.servers) |s| {
|
||||
config.allocator.free(s);
|
||||
}
|
||||
config.allocator.free(config.upstream.servers);
|
||||
|
||||
// Parse new servers
|
||||
var servers = try allocator.alloc([]const u8, servers_arr.len);
|
||||
for (servers_arr, 0..) |v, i| {
|
||||
switch (v) {
|
||||
.string => |s| servers[i] = try allocator.dupe(u8, s),
|
||||
else => servers[i] = try allocator.dupe(u8, ""),
|
||||
}
|
||||
}
|
||||
config.upstream.servers = servers;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse blocking section
|
||||
if (toml.getTable(toml_data, "blocking")) |blocking| {
|
||||
if (toml.getString(blocking, "response")) |resp| {
|
||||
if (std.mem.eql(u8, resp, "nxdomain")) {
|
||||
config.blocking.response = .nxdomain;
|
||||
} else {
|
||||
config.blocking.response = .zero;
|
||||
}
|
||||
}
|
||||
if (toml.getBool(blocking, "auto_update")) |auto_update| {
|
||||
config.blocking.auto_update = auto_update;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse safe_search section
|
||||
if (toml.getTable(toml_data, "safe_search")) |ss| {
|
||||
if (toml.getBool(ss, "enabled")) |enabled| {
|
||||
config.safe_search.enabled = enabled;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse web section
|
||||
if (toml.getTable(toml_data, "web")) |web| {
|
||||
if (toml.getInt(web, "port")) |port| {
|
||||
config.web.port = @intCast(port);
|
||||
}
|
||||
if (toml.getString(web, "bind")) |bind| {
|
||||
config.allocator.free(config.web.bind);
|
||||
config.web.bind = try allocator.dupe(u8, bind);
|
||||
}
|
||||
if (toml.getString(web, "password")) |password| {
|
||||
if (config.web.password) |p| {
|
||||
config.allocator.free(p);
|
||||
}
|
||||
config.web.password = try allocator.dupe(u8, password);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse logging section
|
||||
if (toml.getTable(toml_data, "logging")) |logging| {
|
||||
if (toml.getString(logging, "retention")) |retention| {
|
||||
config.allocator.free(config.logging.retention);
|
||||
config.logging.retention = try allocator.dupe(u8, retention);
|
||||
}
|
||||
if (toml.getString(logging, "level")) |level| {
|
||||
if (std.mem.eql(u8, level, "debug")) {
|
||||
config.logging.level = .debug;
|
||||
} else if (std.mem.eql(u8, level, "warn")) {
|
||||
config.logging.level = .warn;
|
||||
} else if (std.mem.eql(u8, level, "error")) {
|
||||
config.logging.level = .err;
|
||||
} else {
|
||||
config.logging.level = .info;
|
||||
}
|
||||
}
|
||||
if (toml.getString(logging, "output")) |output| {
|
||||
config.allocator.free(config.logging.output);
|
||||
config.logging.output = try allocator.dupe(u8, output);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse dns section
|
||||
if (toml.getTable(toml_data, "dns")) |dns| {
|
||||
if (toml.getInt(dns, "port")) |port| {
|
||||
config.dns.port = @intCast(port);
|
||||
}
|
||||
if (toml.getString(dns, "bind")) |bind| {
|
||||
config.allocator.free(config.dns.bind);
|
||||
config.dns.bind = try allocator.dupe(u8, bind);
|
||||
}
|
||||
if (toml.getInt(dns, "cache_size")) |size| {
|
||||
config.dns.cache_size = @intCast(size);
|
||||
}
|
||||
if (toml.getInt(dns, "workers")) |workers| {
|
||||
config.dns.workers = @intCast(workers);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse database path
|
||||
if (toml.getString(toml_data, "database")) |db_path| {
|
||||
config.allocator.free(config.database_path);
|
||||
config.database_path = try allocator.dupe(u8, db_path);
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/// Free configuration memory
|
||||
pub fn deinit(self: *Config) void {
|
||||
for (self.upstream.servers) |s| {
|
||||
self.allocator.free(s);
|
||||
}
|
||||
self.allocator.free(self.upstream.servers);
|
||||
self.allocator.free(self.web.bind);
|
||||
if (self.web.password) |p| {
|
||||
self.allocator.free(p);
|
||||
}
|
||||
self.allocator.free(self.logging.retention);
|
||||
self.allocator.free(self.logging.output);
|
||||
self.allocator.free(self.dns.bind);
|
||||
self.allocator.free(self.database_path);
|
||||
}
|
||||
|
||||
/// Validate configuration
|
||||
pub fn validate(self: *const Config) !void {
|
||||
// Upstream validation
|
||||
if (self.upstream.servers.len == 0) {
|
||||
return error.NoUpstreamServers;
|
||||
}
|
||||
for (self.upstream.servers) |server| {
|
||||
if (!isValidUpstreamServer(server)) {
|
||||
return error.InvalidUpstreamServer;
|
||||
}
|
||||
}
|
||||
|
||||
// DNS validation
|
||||
if (!isValidPort(self.dns.port)) {
|
||||
return error.InvalidDnsPort;
|
||||
}
|
||||
if (!isValidIpAddress(self.dns.bind)) {
|
||||
return error.InvalidDnsBindAddress;
|
||||
}
|
||||
|
||||
// Web validation
|
||||
if (!isValidPort(self.web.port)) {
|
||||
return error.InvalidWebPort;
|
||||
}
|
||||
if (!isValidIpAddress(self.web.bind)) {
|
||||
return error.InvalidWebBindAddress;
|
||||
}
|
||||
|
||||
// Port conflict check (only if binding to same interface)
|
||||
if (std.mem.eql(u8, self.dns.bind, self.web.bind) and self.dns.port == self.web.port) {
|
||||
return error.PortConflict;
|
||||
}
|
||||
}
|
||||
|
||||
fn isValidPort(port: u16) bool {
|
||||
return port >= 1 and port <= 65535;
|
||||
}
|
||||
|
||||
fn isValidIpAddress(addr: []const u8) bool {
|
||||
// Check for IPv4
|
||||
var parts = std.mem.splitScalar(u8, addr, '.');
|
||||
var count: usize = 0;
|
||||
while (parts.next()) |part| {
|
||||
if (count >= 4) return false;
|
||||
_ = std.fmt.parseInt(u8, part, 10) catch return false;
|
||||
count += 1;
|
||||
}
|
||||
if (count == 4) return true;
|
||||
|
||||
// Check for common IPv6 formats (::, ::1, etc)
|
||||
if (std.mem.indexOf(u8, addr, ":") != null) {
|
||||
return true; // Basic check - let the network layer validate fully
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
fn isValidUpstreamServer(server: []const u8) bool {
|
||||
// Must not be empty
|
||||
if (server.len == 0) return false;
|
||||
|
||||
// IPv4 address format (with optional port)
|
||||
if (isValidIpAddress(server) or isValidIpAddressWithPort(server)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// URL format (for future DoH support)
|
||||
if (std.mem.startsWith(u8, server, "https://") or std.mem.startsWith(u8, server, "tls://")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
fn isValidIpAddressWithPort(addr: []const u8) bool {
|
||||
const colon_idx = std.mem.lastIndexOf(u8, addr, ":") orelse return false;
|
||||
if (colon_idx == 0 or colon_idx >= addr.len - 1) return false;
|
||||
|
||||
const ip_part = addr[0..colon_idx];
|
||||
const port_part = addr[colon_idx + 1 ..];
|
||||
|
||||
if (!isValidIpAddress(ip_part)) return false;
|
||||
const port = std.fmt.parseInt(u16, port_part, 10) catch return false;
|
||||
return isValidPort(port);
|
||||
}
|
||||
};
|
||||
|
||||
/// Example configuration file content
|
||||
pub const EXAMPLE_CONFIG =
|
||||
\\# nxdns configuration
|
||||
\\
|
||||
\\# Database path
|
||||
\\database = "/etc/nxdns/nxdns.db"
|
||||
\\
|
||||
\\[upstream]
|
||||
\\# Upstream DNS servers (DoH or DoT)
|
||||
\\servers = [
|
||||
\\ "https://cloudflare-dns.com/dns-query",
|
||||
\\ "https://dns.google/dns-query"
|
||||
\\]
|
||||
\\
|
||||
\\[blocking]
|
||||
\\# Response for blocked domains: "zero" (0.0.0.0) or "nxdomain"
|
||||
\\response = "zero"
|
||||
\\# Automatically fetch blocklist updates every 5 minutes
|
||||
\\auto_update = true
|
||||
\\
|
||||
\\[safe_search]
|
||||
\\# Enforce safe search on Google, Bing, YouTube, etc.
|
||||
\\enabled = true
|
||||
\\
|
||||
\\[web]
|
||||
\\# Web interface settings
|
||||
\\port = 8080
|
||||
\\bind = "127.0.0.1"
|
||||
\\# password = "your-password-here" # Uncomment to enable auth
|
||||
\\
|
||||
\\[logging]
|
||||
\\# Query log retention (e.g., "7 days", "1 week", "3 months", "1 year", "forever")
|
||||
\\retention = "30 days"
|
||||
\\# Log level: debug, info, warn, error
|
||||
\\level = "info"
|
||||
\\# Output: stderr, syslog, or /path/to/file
|
||||
\\output = "stderr"
|
||||
\\
|
||||
\\[dns]
|
||||
\\# DNS server settings
|
||||
\\port = 53
|
||||
\\bind = "0.0.0.0"
|
||||
\\cache_size = 10000
|
||||
\\workers = 32
|
||||
;
|
||||
|
||||
test "Config default" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var config = try Config.default(allocator);
|
||||
defer config.deinit();
|
||||
|
||||
try testing.expectEqual(@as(u16, 53), config.dns.port);
|
||||
try testing.expectEqual(@as(u16, 8080), config.web.port);
|
||||
try testing.expect(config.safe_search.enabled);
|
||||
}
|
||||
|
||||
test "Config parse" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
const content =
|
||||
\\[dns]
|
||||
\\port = 5353
|
||||
\\
|
||||
\\[web]
|
||||
\\port = 9090
|
||||
\\
|
||||
\\[blocking]
|
||||
\\response = "nxdomain"
|
||||
;
|
||||
|
||||
var config = try Config.parseContent(content, allocator);
|
||||
defer config.deinit();
|
||||
|
||||
try testing.expectEqual(@as(u16, 5353), config.dns.port);
|
||||
try testing.expectEqual(@as(u16, 9090), config.web.port);
|
||||
try testing.expectEqual(Config.BlockingConfig.BlockingResponse.nxdomain, config.blocking.response);
|
||||
}
|
||||
|
||||
test "parseDuration" {
|
||||
const testing = std.testing;
|
||||
|
||||
// Basic units
|
||||
try testing.expectEqual(@as(i64, 60), Config.parseDuration("1 minute"));
|
||||
try testing.expectEqual(@as(i64, 3600), Config.parseDuration("1 hour"));
|
||||
try testing.expectEqual(@as(i64, 86400), Config.parseDuration("1 day"));
|
||||
try testing.expectEqual(@as(i64, 604800), Config.parseDuration("1 week"));
|
||||
|
||||
// Plurals work too
|
||||
try testing.expectEqual(@as(i64, 7 * 86400), Config.parseDuration("7 days"));
|
||||
try testing.expectEqual(@as(i64, 2 * 604800), Config.parseDuration("2 weeks"));
|
||||
|
||||
// Months and years (approximate)
|
||||
try testing.expectEqual(@as(i64, 30 * 86400), Config.parseDuration("1 month"));
|
||||
try testing.expectEqual(@as(i64, 365 * 86400), Config.parseDuration("1 year"));
|
||||
|
||||
// Bare number = days
|
||||
try testing.expectEqual(@as(i64, 30 * 86400), Config.parseDuration("30"));
|
||||
|
||||
// Forever
|
||||
try testing.expectEqual(std.math.maxInt(i64), Config.parseDuration("forever"));
|
||||
|
||||
// Invalid
|
||||
try testing.expectError(error.InvalidDuration, Config.parseDuration(""));
|
||||
try testing.expectError(error.InvalidDuration, Config.parseDuration("abc"));
|
||||
try testing.expectError(error.InvalidDuration, Config.parseDuration("1 parsec"));
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
/// Simple TOML parser for configuration files
|
||||
pub const TomlParser = struct {
|
||||
allocator: Allocator,
|
||||
|
||||
pub const Value = union(enum) {
|
||||
string: []const u8,
|
||||
integer: i64,
|
||||
float: f64,
|
||||
boolean: bool,
|
||||
array: []Value,
|
||||
table: std.StringHashMapUnmanaged(Value),
|
||||
};
|
||||
|
||||
pub const ParseError = error{
|
||||
InvalidSyntax,
|
||||
UnterminatedString,
|
||||
InvalidNumber,
|
||||
InvalidKey,
|
||||
OutOfMemory,
|
||||
};
|
||||
|
||||
pub fn init(allocator: Allocator) TomlParser {
|
||||
return TomlParser{
|
||||
.allocator = allocator,
|
||||
};
|
||||
}
|
||||
|
||||
/// Parse a TOML string
|
||||
pub fn parse(self: *TomlParser, content: []const u8) ParseError!std.StringHashMapUnmanaged(Value) {
|
||||
var result = std.StringHashMapUnmanaged(Value){};
|
||||
errdefer freeValue(self.allocator, .{ .table = result });
|
||||
|
||||
var current_table: *std.StringHashMapUnmanaged(Value) = &result;
|
||||
var lines = std.mem.splitScalar(u8, content, '\n');
|
||||
|
||||
while (lines.next()) |line| {
|
||||
const trimmed = std.mem.trim(u8, line, " \t\r");
|
||||
|
||||
// Skip empty lines and comments
|
||||
if (trimmed.len == 0 or trimmed[0] == '#') continue;
|
||||
|
||||
// Section header [section.name]
|
||||
if (trimmed[0] == '[') {
|
||||
if (std.mem.indexOfScalar(u8, trimmed, ']')) |end| {
|
||||
const section_name = std.mem.trim(u8, trimmed[1..end], " \t");
|
||||
current_table = try self.getOrCreateTable(&result, section_name);
|
||||
} else {
|
||||
return error.InvalidSyntax;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Key-value pair
|
||||
if (std.mem.indexOfScalar(u8, trimmed, '=')) |eq_pos| {
|
||||
const key = std.mem.trim(u8, trimmed[0..eq_pos], " \t");
|
||||
const value_str = std.mem.trim(u8, trimmed[eq_pos + 1 ..], " \t");
|
||||
|
||||
if (key.len == 0) return error.InvalidKey;
|
||||
|
||||
const key_copy = try self.allocator.dupe(u8, key);
|
||||
errdefer self.allocator.free(key_copy);
|
||||
|
||||
const value = try self.parseValue(value_str);
|
||||
try current_table.put(self.allocator, key_copy, value);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
fn getOrCreateTable(self: *TomlParser, root: *std.StringHashMapUnmanaged(Value), path: []const u8) ParseError!*std.StringHashMapUnmanaged(Value) {
|
||||
var current = root;
|
||||
var parts = std.mem.splitScalar(u8, path, '.');
|
||||
|
||||
while (parts.next()) |part| {
|
||||
const gop = try current.getOrPut(self.allocator, part);
|
||||
if (!gop.found_existing) {
|
||||
gop.key_ptr.* = try self.allocator.dupe(u8, part);
|
||||
gop.value_ptr.* = Value{ .table = std.StringHashMapUnmanaged(Value){} };
|
||||
}
|
||||
switch (gop.value_ptr.*) {
|
||||
.table => |*t| current = t,
|
||||
else => return error.InvalidSyntax,
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
fn parseValue(self: *TomlParser, value_str: []const u8) ParseError!Value {
|
||||
if (value_str.len == 0) return error.InvalidSyntax;
|
||||
|
||||
// String (quoted)
|
||||
if (value_str[0] == '"') {
|
||||
if (value_str.len < 2 or value_str[value_str.len - 1] != '"') {
|
||||
return error.UnterminatedString;
|
||||
}
|
||||
const str = try self.allocator.dupe(u8, value_str[1 .. value_str.len - 1]);
|
||||
return Value{ .string = str };
|
||||
}
|
||||
|
||||
// Boolean
|
||||
if (std.mem.eql(u8, value_str, "true")) {
|
||||
return Value{ .boolean = true };
|
||||
}
|
||||
if (std.mem.eql(u8, value_str, "false")) {
|
||||
return Value{ .boolean = false };
|
||||
}
|
||||
|
||||
// Array
|
||||
if (value_str[0] == '[') {
|
||||
return try self.parseArray(value_str);
|
||||
}
|
||||
|
||||
// Number (integer or float)
|
||||
if (std.mem.indexOfScalar(u8, value_str, '.')) |_| {
|
||||
const float = std.fmt.parseFloat(f64, value_str) catch return error.InvalidNumber;
|
||||
return Value{ .float = float };
|
||||
} else {
|
||||
const int = std.fmt.parseInt(i64, value_str, 10) catch return error.InvalidNumber;
|
||||
return Value{ .integer = int };
|
||||
}
|
||||
}
|
||||
|
||||
fn parseArray(self: *TomlParser, value_str: []const u8) ParseError!Value {
|
||||
if (value_str.len < 2 or value_str[0] != '[' or value_str[value_str.len - 1] != ']') {
|
||||
return error.InvalidSyntax;
|
||||
}
|
||||
|
||||
const content = std.mem.trim(u8, value_str[1 .. value_str.len - 1], " \t");
|
||||
if (content.len == 0) {
|
||||
return Value{ .array = try self.allocator.alloc(Value, 0) };
|
||||
}
|
||||
|
||||
var elements = std.ArrayListUnmanaged(Value){};
|
||||
errdefer {
|
||||
for (elements.items) |v| freeValue(self.allocator, v);
|
||||
elements.deinit(self.allocator);
|
||||
}
|
||||
|
||||
var iter = std.mem.splitScalar(u8, content, ',');
|
||||
while (iter.next()) |element| {
|
||||
const trimmed = std.mem.trim(u8, element, " \t\r\n");
|
||||
if (trimmed.len == 0) continue;
|
||||
const value = try self.parseValue(trimmed);
|
||||
try elements.append(self.allocator, value);
|
||||
}
|
||||
|
||||
return Value{ .array = try elements.toOwnedSlice(self.allocator) };
|
||||
}
|
||||
};
|
||||
|
||||
/// Free a TOML value and all nested values
|
||||
pub fn freeValue(allocator: Allocator, value: TomlParser.Value) void {
|
||||
switch (value) {
|
||||
.string => |s| allocator.free(s),
|
||||
.array => |arr| {
|
||||
for (arr) |v| freeValue(allocator, v);
|
||||
allocator.free(arr);
|
||||
},
|
||||
.table => |t| {
|
||||
var table = t;
|
||||
var iter = table.iterator();
|
||||
while (iter.next()) |entry| {
|
||||
allocator.free(entry.key_ptr.*);
|
||||
freeValue(allocator, entry.value_ptr.*);
|
||||
}
|
||||
table.deinit(allocator);
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a string value from a table
|
||||
pub fn getString(table: std.StringHashMapUnmanaged(TomlParser.Value), key: []const u8) ?[]const u8 {
|
||||
if (table.get(key)) |value| {
|
||||
return switch (value) {
|
||||
.string => |s| s,
|
||||
else => null,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get an integer value from a table
|
||||
pub fn getInt(table: std.StringHashMapUnmanaged(TomlParser.Value), key: []const u8) ?i64 {
|
||||
if (table.get(key)) |value| {
|
||||
return switch (value) {
|
||||
.integer => |i| i,
|
||||
else => null,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get a boolean value from a table
|
||||
pub fn getBool(table: std.StringHashMapUnmanaged(TomlParser.Value), key: []const u8) ?bool {
|
||||
if (table.get(key)) |value| {
|
||||
return switch (value) {
|
||||
.boolean => |b| b,
|
||||
else => null,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get a nested table
|
||||
pub fn getTable(table: std.StringHashMapUnmanaged(TomlParser.Value), key: []const u8) ?std.StringHashMapUnmanaged(TomlParser.Value) {
|
||||
if (table.get(key)) |value| {
|
||||
return switch (value) {
|
||||
.table => |t| t,
|
||||
else => null,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get an array of strings
|
||||
pub fn getStringArray(table: std.StringHashMapUnmanaged(TomlParser.Value), key: []const u8) ?[]TomlParser.Value {
|
||||
if (table.get(key)) |value| {
|
||||
return switch (value) {
|
||||
.array => |arr| arr,
|
||||
else => null,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
test "TOML parse simple values" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var parser = TomlParser.init(allocator);
|
||||
|
||||
const content =
|
||||
\\name = "test"
|
||||
\\port = 8080
|
||||
\\enabled = true
|
||||
\\ratio = 0.5
|
||||
;
|
||||
|
||||
const result = try parser.parse(content);
|
||||
defer freeValue(allocator, .{ .table = result });
|
||||
|
||||
try testing.expectEqualStrings("test", getString(result, "name").?);
|
||||
try testing.expectEqual(@as(i64, 8080), getInt(result, "port").?);
|
||||
try testing.expectEqual(true, getBool(result, "enabled").?);
|
||||
}
|
||||
|
||||
test "TOML parse sections" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var parser = TomlParser.init(allocator);
|
||||
|
||||
const content =
|
||||
\\[server]
|
||||
\\host = "localhost"
|
||||
\\port = 53
|
||||
\\
|
||||
\\[web]
|
||||
\\port = 8080
|
||||
;
|
||||
|
||||
const result = try parser.parse(content);
|
||||
defer freeValue(allocator, .{ .table = result });
|
||||
|
||||
const server = getTable(result, "server").?;
|
||||
try testing.expectEqualStrings("localhost", getString(server, "host").?);
|
||||
try testing.expectEqual(@as(i64, 53), getInt(server, "port").?);
|
||||
|
||||
const web = getTable(result, "web").?;
|
||||
try testing.expectEqual(@as(i64, 8080), getInt(web, "port").?);
|
||||
}
|
||||
|
||||
test "TOML parse array" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var parser = TomlParser.init(allocator);
|
||||
|
||||
const content =
|
||||
\\servers = ["server1", "server2", "server3"]
|
||||
;
|
||||
|
||||
const result = try parser.parse(content);
|
||||
defer freeValue(allocator, .{ .table = result });
|
||||
|
||||
const servers = getStringArray(result, "servers").?;
|
||||
try testing.expectEqual(@as(usize, 3), servers.len);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
const std = @import("std");
|
||||
const posix = std.posix;
|
||||
const Allocator = std.mem.Allocator;
|
||||
const Config = @import("config.zig").Config;
|
||||
const events = @import("../events.zig");
|
||||
const ShutdownCoordinator = @import("../server/shutdown.zig").ShutdownCoordinator;
|
||||
|
||||
/// Configuration file watcher using Linux inotify
|
||||
/// Efficiently monitors config file for changes and triggers reload callbacks
|
||||
/// Uses ShutdownCoordinator's eventfd for clean shutdown
|
||||
pub const ConfigWatcher = struct {
|
||||
config_path: []const u8,
|
||||
allocator: Allocator,
|
||||
inotify_fd: posix.fd_t,
|
||||
watch_fd: i32,
|
||||
coordinator: *ShutdownCoordinator,
|
||||
on_config_reload: ?*const fn () void,
|
||||
on_denylist_reload: ?*const fn () void,
|
||||
|
||||
pub const Error = error{
|
||||
InotifyInitFailed,
|
||||
WatchAddFailed,
|
||||
PathTooLong,
|
||||
};
|
||||
|
||||
// inotify init flags
|
||||
const IN_NONBLOCK: u32 = 0x800;
|
||||
const IN_CLOEXEC: u32 = 0x80000;
|
||||
|
||||
// inotify watch event masks
|
||||
const IN_CLOSE_WRITE: u32 = 0x00000008;
|
||||
const IN_MOVED_TO: u32 = 0x00000080;
|
||||
const IN_CREATE: u32 = 0x00000100;
|
||||
|
||||
/// Initialize the config watcher with inotify
|
||||
pub fn init(config_path: []const u8, allocator: Allocator, coordinator: *ShutdownCoordinator) Error!ConfigWatcher {
|
||||
// Initialize inotify with non-blocking and close-on-exec flags
|
||||
const inotify_fd = posix.inotify_init1(IN_NONBLOCK | IN_CLOEXEC) catch {
|
||||
return error.InotifyInitFailed;
|
||||
};
|
||||
errdefer posix.close(inotify_fd);
|
||||
|
||||
// We need to watch the directory, not the file, because editors often
|
||||
// create a new file and rename it (which deletes the old watch)
|
||||
var dir_path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||
const dir_path = getDirectoryPath(config_path, &dir_path_buf) catch {
|
||||
return error.PathTooLong;
|
||||
};
|
||||
|
||||
// Add watch for the config file's directory
|
||||
// Watch for CLOSE_WRITE (file saved), MOVED_TO (file renamed into place), CREATE
|
||||
const watch_mask = IN_CLOSE_WRITE | IN_MOVED_TO | IN_CREATE;
|
||||
const watch_fd = posix.inotify_add_watchZ(
|
||||
inotify_fd,
|
||||
dir_path,
|
||||
watch_mask,
|
||||
) catch {
|
||||
return error.WatchAddFailed;
|
||||
};
|
||||
|
||||
return ConfigWatcher{
|
||||
.config_path = config_path,
|
||||
.allocator = allocator,
|
||||
.inotify_fd = inotify_fd,
|
||||
.watch_fd = watch_fd,
|
||||
.coordinator = coordinator,
|
||||
.on_config_reload = null,
|
||||
.on_denylist_reload = null,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *ConfigWatcher) void {
|
||||
if (self.watch_fd >= 0) {
|
||||
_ = posix.inotify_rm_watch(self.inotify_fd, self.watch_fd);
|
||||
}
|
||||
posix.close(self.inotify_fd);
|
||||
}
|
||||
|
||||
/// Set callback for when config is reloaded
|
||||
pub fn setConfigReloadCallback(self: *ConfigWatcher, callback: *const fn () void) void {
|
||||
self.on_config_reload = callback;
|
||||
}
|
||||
|
||||
/// Set callback for when denylist needs reload
|
||||
pub fn setDenylistReloadCallback(self: *ConfigWatcher, callback: *const fn () void) void {
|
||||
self.on_denylist_reload = callback;
|
||||
}
|
||||
|
||||
/// Start watching (blocking) - returns when shutdown requested
|
||||
pub fn watch(self: *ConfigWatcher) void {
|
||||
var event_buf: [4096]u8 align(@alignOf(InotifyEvent)) = undefined;
|
||||
const target_filename = getFilename(self.config_path);
|
||||
const denylist_event_fd = events.getDenylistEventFd();
|
||||
const shutdown_event_fd = self.coordinator.getEventFd();
|
||||
|
||||
std.log.info("Config watcher started for: {s}", .{self.config_path});
|
||||
|
||||
while (!self.coordinator.isShutdownRequested()) {
|
||||
// Build poll fd array
|
||||
var fds: [3]posix.pollfd = undefined;
|
||||
var nfds: usize = 2;
|
||||
|
||||
fds[0] = .{ .fd = self.inotify_fd, .events = posix.POLL.IN, .revents = 0 };
|
||||
fds[1] = .{ .fd = shutdown_event_fd, .events = posix.POLL.IN, .revents = 0 };
|
||||
|
||||
if (denylist_event_fd >= 0) {
|
||||
fds[2] = .{ .fd = denylist_event_fd, .events = posix.POLL.IN, .revents = 0 };
|
||||
nfds = 3;
|
||||
}
|
||||
|
||||
// Infinite wait - only wakes on real events
|
||||
const poll_result = posix.poll(fds[0..nfds], -1) catch |err| {
|
||||
std.log.warn("Config watcher poll error: {}", .{err});
|
||||
continue;
|
||||
};
|
||||
|
||||
if (poll_result == 0) continue;
|
||||
|
||||
// Check for shutdown signal
|
||||
if (fds[1].revents & posix.POLL.IN != 0) {
|
||||
std.log.info("Shutdown requested, stopping watcher...", .{});
|
||||
break;
|
||||
}
|
||||
|
||||
// Check for denylist reload event
|
||||
if (nfds == 3 and fds[2].revents & posix.POLL.IN != 0) {
|
||||
if (events.consumeDenylistReload()) {
|
||||
std.log.info("Denylist update detected, triggering reload...", .{});
|
||||
if (self.on_denylist_reload) |callback| {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for config file changes
|
||||
if (fds[0].revents & posix.POLL.IN != 0) {
|
||||
// Read events
|
||||
const bytes_read = posix.read(self.inotify_fd, &event_buf) catch |err| {
|
||||
if (err == error.WouldBlock) continue;
|
||||
std.log.warn("Config watcher read error: {}", .{err});
|
||||
continue;
|
||||
};
|
||||
|
||||
if (bytes_read == 0) continue;
|
||||
|
||||
// Process events
|
||||
var offset: usize = 0;
|
||||
while (offset < bytes_read) {
|
||||
const event: *const InotifyEvent = @ptrCast(@alignCast(&event_buf[offset]));
|
||||
offset += @sizeOf(InotifyEvent) + event.len;
|
||||
|
||||
// Check if this event is for our config file
|
||||
if (event.len > 0) {
|
||||
const event_name = @as([*]const u8, @ptrCast(&event.name))[0 .. event.len - 1];
|
||||
if (std.mem.eql(u8, event_name, target_filename)) {
|
||||
std.log.info("Config file changed, triggering reload...", .{});
|
||||
self.triggerConfigReload();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std.log.info("Config watcher stopped", .{});
|
||||
}
|
||||
|
||||
/// Start watching in a background thread
|
||||
pub fn watchAsync(self: *ConfigWatcher) !std.Thread {
|
||||
return std.Thread.spawn(.{}, watch, .{self});
|
||||
}
|
||||
|
||||
fn triggerConfigReload(self: *ConfigWatcher) void {
|
||||
// Small delay to ensure file is fully written
|
||||
std.Thread.sleep(100 * std.time.ns_per_ms);
|
||||
|
||||
if (self.on_config_reload) |callback| {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract directory path from file path
|
||||
fn getDirectoryPath(file_path: []const u8, buf: []u8) ![:0]const u8 {
|
||||
if (std.mem.lastIndexOfScalar(u8, file_path, '/')) |idx| {
|
||||
if (idx + 1 > buf.len) return error.PathTooLong;
|
||||
@memcpy(buf[0..idx], file_path[0..idx]);
|
||||
buf[idx] = 0;
|
||||
return buf[0..idx :0];
|
||||
}
|
||||
// No directory separator, use current directory
|
||||
buf[0] = '.';
|
||||
buf[1] = 0;
|
||||
return buf[0..1 :0];
|
||||
}
|
||||
|
||||
/// Extract filename from path
|
||||
fn getFilename(file_path: []const u8) []const u8 {
|
||||
if (std.mem.lastIndexOfScalar(u8, file_path, '/')) |idx| {
|
||||
return file_path[idx + 1 ..];
|
||||
}
|
||||
return file_path;
|
||||
}
|
||||
};
|
||||
|
||||
/// Linux inotify_event structure
|
||||
const InotifyEvent = extern struct {
|
||||
wd: i32,
|
||||
mask: u32,
|
||||
cookie: u32,
|
||||
len: u32,
|
||||
name: [0]u8, // Variable length, access via pointer arithmetic
|
||||
};
|
||||
|
||||
test "ConfigWatcher getFilename" {
|
||||
const testing = std.testing;
|
||||
|
||||
try testing.expectEqualStrings("config.toml", ConfigWatcher.getFilename("/etc/nxdns/config.toml"));
|
||||
try testing.expectEqualStrings("config.toml", ConfigWatcher.getFilename("config.toml"));
|
||||
try testing.expectEqualStrings("test.toml", ConfigWatcher.getFilename("./test.toml"));
|
||||
}
|
||||
|
||||
test "ConfigWatcher getDirectoryPath" {
|
||||
const testing = std.testing;
|
||||
var buf: [256]u8 = undefined;
|
||||
|
||||
const dir1 = try ConfigWatcher.getDirectoryPath("/etc/nxdns/config.toml", &buf);
|
||||
try testing.expectEqualStrings("/etc/nxdns", dir1);
|
||||
|
||||
const dir2 = try ConfigWatcher.getDirectoryPath("config.toml", &buf);
|
||||
try testing.expectEqualStrings(".", dir2);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
const std = @import("std");
|
||||
const types = @import("types.zig");
|
||||
const Name = @import("name.zig").Name;
|
||||
const ResourceRecord = @import("record.zig").ResourceRecord;
|
||||
const RData = @import("record.zig").RData;
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
/// EDNS Option Codes
|
||||
pub const OptionCode = enum(u16) {
|
||||
LLQ = 1, // Long-Lived Queries
|
||||
UL = 2, // Update Lease
|
||||
NSID = 3, // Name Server Identifier
|
||||
DAU = 5, // DNSSEC Algorithm Understood
|
||||
DHU = 6, // DS Hash Understood
|
||||
N3U = 7, // NSEC3 Hash Understood
|
||||
ECS = 8, // Client Subnet
|
||||
EXPIRE = 9, // Expire
|
||||
COOKIE = 10, // DNS Cookie
|
||||
TCP_KEEPALIVE = 11, // TCP Keepalive
|
||||
PADDING = 12, // Padding
|
||||
CHAIN = 13, // CHAIN
|
||||
KEY_TAG = 14, // Key Tag
|
||||
EXTENDED_ERROR = 15, // Extended DNS Error
|
||||
CLIENT_TAG = 16, // Client Tag
|
||||
SERVER_TAG = 17, // Server Tag
|
||||
_,
|
||||
};
|
||||
|
||||
/// EDNS Option
|
||||
pub const EdnsOption = struct {
|
||||
code: OptionCode,
|
||||
data: []const u8,
|
||||
|
||||
pub fn deinit(self: *EdnsOption, allocator: Allocator) void {
|
||||
allocator.free(self.data);
|
||||
}
|
||||
};
|
||||
|
||||
/// EDNS (OPT Record) Information
|
||||
pub const Edns = struct {
|
||||
/// Requestor's UDP payload size
|
||||
udp_payload_size: u16,
|
||||
/// Extended RCODE (upper 8 bits)
|
||||
extended_rcode: u8,
|
||||
/// EDNS version
|
||||
version: u8,
|
||||
/// DNSSEC OK flag
|
||||
dnssec_ok: bool,
|
||||
/// Other flags (Z field, 15 bits)
|
||||
z: u15,
|
||||
/// EDNS options
|
||||
options: []EdnsOption,
|
||||
|
||||
allocator: ?Allocator,
|
||||
|
||||
pub fn init() Edns {
|
||||
return Edns{
|
||||
.udp_payload_size = types.EDNS_DEFAULT_SIZE,
|
||||
.extended_rcode = 0,
|
||||
.version = 0,
|
||||
.dnssec_ok = false,
|
||||
.z = 0,
|
||||
.options = &[_]EdnsOption{},
|
||||
.allocator = null,
|
||||
};
|
||||
}
|
||||
|
||||
/// Parse EDNS from an OPT resource record
|
||||
pub fn fromOptRecord(record: ResourceRecord, allocator: Allocator) !Edns {
|
||||
if (record.rtype != types.QType.OPT) {
|
||||
return error.NotOptRecord;
|
||||
}
|
||||
|
||||
// CLASS field contains UDP payload size
|
||||
const udp_payload_size = @intFromEnum(record.class);
|
||||
|
||||
// TTL field contains extended RCODE and flags
|
||||
const ttl = record.ttl;
|
||||
const extended_rcode: u8 = @truncate((ttl >> 24) & 0xFF);
|
||||
const version: u8 = @truncate((ttl >> 16) & 0xFF);
|
||||
const flags: u16 = @truncate(ttl & 0xFFFF);
|
||||
const dnssec_ok = (flags >> 15) & 1 == 1;
|
||||
const z: u15 = @truncate(flags & 0x7FFF);
|
||||
|
||||
// Parse options from RDATA
|
||||
var options = std.ArrayListUnmanaged(EdnsOption){};
|
||||
errdefer {
|
||||
for (options.items) |*opt| {
|
||||
opt.deinit(allocator);
|
||||
}
|
||||
options.deinit(allocator);
|
||||
}
|
||||
|
||||
const opt_data = switch (record.rdata) {
|
||||
.opt => |data| data,
|
||||
else => return error.InvalidOptRecord,
|
||||
};
|
||||
|
||||
var pos: usize = 0;
|
||||
while (pos + 4 <= opt_data.len) {
|
||||
const code = std.mem.readInt(u16, opt_data[pos..][0..2], .big);
|
||||
const length = std.mem.readInt(u16, opt_data[pos + 2 ..][0..2], .big);
|
||||
pos += 4;
|
||||
|
||||
if (pos + length > opt_data.len) break;
|
||||
|
||||
const data = try allocator.dupe(u8, opt_data[pos .. pos + length]);
|
||||
try options.append(allocator, EdnsOption{
|
||||
.code = @enumFromInt(code),
|
||||
.data = data,
|
||||
});
|
||||
pos += length;
|
||||
}
|
||||
|
||||
return Edns{
|
||||
.udp_payload_size = udp_payload_size,
|
||||
.extended_rcode = extended_rcode,
|
||||
.version = version,
|
||||
.dnssec_ok = dnssec_ok,
|
||||
.z = z,
|
||||
.options = try options.toOwnedSlice(allocator),
|
||||
.allocator = allocator,
|
||||
};
|
||||
}
|
||||
|
||||
/// Create an OPT resource record from this EDNS info
|
||||
pub fn toOptRecord(self: Edns, allocator: Allocator) !ResourceRecord {
|
||||
// Create root name (empty)
|
||||
var root_name = try Name.fromString("", allocator);
|
||||
errdefer root_name.deinit();
|
||||
|
||||
// Encode options to RDATA
|
||||
var rdata_size: usize = 0;
|
||||
for (self.options) |opt| {
|
||||
rdata_size += 4 + opt.data.len;
|
||||
}
|
||||
|
||||
var rdata = try allocator.alloc(u8, rdata_size);
|
||||
errdefer allocator.free(rdata);
|
||||
|
||||
var pos: usize = 0;
|
||||
for (self.options) |opt| {
|
||||
std.mem.writeInt(u16, rdata[pos..][0..2], @intFromEnum(opt.code), .big);
|
||||
std.mem.writeInt(u16, rdata[pos + 2 ..][0..2], @intCast(opt.data.len), .big);
|
||||
@memcpy(rdata[pos + 4 .. pos + 4 + opt.data.len], opt.data);
|
||||
pos += 4 + opt.data.len;
|
||||
}
|
||||
|
||||
// Build TTL from extended RCODE and flags
|
||||
var ttl: u32 = 0;
|
||||
ttl |= @as(u32, self.extended_rcode) << 24;
|
||||
ttl |= @as(u32, self.version) << 16;
|
||||
if (self.dnssec_ok) {
|
||||
ttl |= @as(u32, 1) << 15;
|
||||
}
|
||||
ttl |= @as(u32, self.z);
|
||||
|
||||
return ResourceRecord{
|
||||
.name = root_name,
|
||||
.rtype = types.QType.OPT,
|
||||
.class = @enumFromInt(self.udp_payload_size),
|
||||
.ttl = ttl,
|
||||
.rdata = RData{ .opt = rdata },
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Edns) void {
|
||||
if (self.allocator) |alloc| {
|
||||
for (self.options) |*opt| {
|
||||
var o = opt.*;
|
||||
o.deinit(alloc);
|
||||
}
|
||||
alloc.free(self.options);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if DNSSEC validation is requested
|
||||
pub fn wantsDnssec(self: Edns) bool {
|
||||
return self.dnssec_ok;
|
||||
}
|
||||
|
||||
/// Get the maximum UDP payload size the client can handle
|
||||
pub fn getMaxPayloadSize(self: Edns) u16 {
|
||||
return self.udp_payload_size;
|
||||
}
|
||||
};
|
||||
|
||||
/// Check if a packet has EDNS support by looking for OPT record in additional section
|
||||
pub fn hasEdns(records: []const ResourceRecord) bool {
|
||||
for (records) |record| {
|
||||
if (record.rtype == types.QType.OPT) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Find and extract EDNS info from additional records
|
||||
pub fn extractEdns(records: []const ResourceRecord, allocator: Allocator) !?Edns {
|
||||
for (records) |record| {
|
||||
if (record.rtype == types.QType.OPT) {
|
||||
return try Edns.fromOptRecord(record, allocator);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
test "EDNS init" {
|
||||
const testing = std.testing;
|
||||
|
||||
const edns = Edns.init();
|
||||
|
||||
try testing.expectEqual(@as(u16, types.EDNS_DEFAULT_SIZE), edns.udp_payload_size);
|
||||
try testing.expectEqual(@as(u8, 0), edns.version);
|
||||
try testing.expect(!edns.dnssec_ok);
|
||||
}
|
||||
|
||||
test "EDNS to/from OPT record roundtrip" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var original = Edns{
|
||||
.udp_payload_size = 1232,
|
||||
.extended_rcode = 0,
|
||||
.version = 0,
|
||||
.dnssec_ok = true,
|
||||
.z = 0,
|
||||
.options = &[_]EdnsOption{},
|
||||
.allocator = null,
|
||||
};
|
||||
|
||||
var opt_record = try original.toOptRecord(allocator);
|
||||
defer opt_record.deinit(allocator);
|
||||
|
||||
var parsed = try Edns.fromOptRecord(opt_record, allocator);
|
||||
defer parsed.deinit();
|
||||
|
||||
try testing.expectEqual(original.udp_payload_size, parsed.udp_payload_size);
|
||||
try testing.expectEqual(original.extended_rcode, parsed.extended_rcode);
|
||||
try testing.expectEqual(original.version, parsed.version);
|
||||
try testing.expectEqual(original.dnssec_ok, parsed.dnssec_ok);
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
const std = @import("std");
|
||||
const types = @import("types.zig");
|
||||
|
||||
/// DNS Header (12 bytes)
|
||||
/// Format:
|
||||
/// 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 |
|
||||
/// +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|
||||
pub const Header = struct {
|
||||
/// Transaction ID
|
||||
id: u16,
|
||||
/// Query/Response flag (0 = query, 1 = response)
|
||||
qr: bool,
|
||||
/// Operation code
|
||||
opcode: types.OpCode,
|
||||
/// Authoritative Answer flag
|
||||
aa: bool,
|
||||
/// Truncation flag
|
||||
tc: bool,
|
||||
/// Recursion Desired flag
|
||||
rd: bool,
|
||||
/// Recursion Available flag
|
||||
ra: bool,
|
||||
/// Reserved bit (must be zero)
|
||||
z: bool,
|
||||
/// Authenticated Data flag (DNSSEC - response was validated)
|
||||
ad: bool,
|
||||
/// Checking Disabled flag (DNSSEC - client doesn't want validation)
|
||||
cd: bool,
|
||||
/// Response code
|
||||
rcode: types.RCode,
|
||||
/// Number of questions
|
||||
qdcount: u16,
|
||||
/// Number of answers
|
||||
ancount: u16,
|
||||
/// Number of authority records
|
||||
nscount: u16,
|
||||
/// Number of additional records
|
||||
arcount: u16,
|
||||
|
||||
pub const SIZE: usize = 12;
|
||||
|
||||
pub const ParseError = error{
|
||||
BufferTooSmall,
|
||||
};
|
||||
|
||||
/// Parse a DNS header from a buffer
|
||||
pub fn parse(buffer: []const u8) ParseError!Header {
|
||||
if (buffer.len < SIZE) {
|
||||
return error.BufferTooSmall;
|
||||
}
|
||||
|
||||
const id = std.mem.readInt(u16, buffer[0..2], .big);
|
||||
const flags = std.mem.readInt(u16, buffer[2..4], .big);
|
||||
const qdcount = std.mem.readInt(u16, buffer[4..6], .big);
|
||||
const ancount = std.mem.readInt(u16, buffer[6..8], .big);
|
||||
const nscount = std.mem.readInt(u16, buffer[8..10], .big);
|
||||
const arcount = std.mem.readInt(u16, buffer[10..12], .big);
|
||||
|
||||
return Header{
|
||||
.id = id,
|
||||
.qr = (flags >> 15) & 1 == 1,
|
||||
.opcode = @enumFromInt(@as(u4, @truncate((flags >> 11) & 0xF))),
|
||||
.aa = (flags >> 10) & 1 == 1,
|
||||
.tc = (flags >> 9) & 1 == 1,
|
||||
.rd = (flags >> 8) & 1 == 1,
|
||||
.ra = (flags >> 7) & 1 == 1,
|
||||
.z = (flags >> 6) & 1 == 1,
|
||||
.ad = (flags >> 5) & 1 == 1,
|
||||
.cd = (flags >> 4) & 1 == 1,
|
||||
.rcode = @enumFromInt(@as(u4, @truncate(flags & 0xF))),
|
||||
.qdcount = qdcount,
|
||||
.ancount = ancount,
|
||||
.nscount = nscount,
|
||||
.arcount = arcount,
|
||||
};
|
||||
}
|
||||
|
||||
/// Encode the header into a buffer
|
||||
pub fn encode(self: Header, buffer: []u8) ParseError!void {
|
||||
if (buffer.len < SIZE) {
|
||||
return error.BufferTooSmall;
|
||||
}
|
||||
|
||||
std.mem.writeInt(u16, buffer[0..2], self.id, .big);
|
||||
|
||||
var flags: u16 = 0;
|
||||
flags |= @as(u16, @intFromBool(self.qr)) << 15;
|
||||
flags |= @as(u16, @intFromEnum(self.opcode)) << 11;
|
||||
flags |= @as(u16, @intFromBool(self.aa)) << 10;
|
||||
flags |= @as(u16, @intFromBool(self.tc)) << 9;
|
||||
flags |= @as(u16, @intFromBool(self.rd)) << 8;
|
||||
flags |= @as(u16, @intFromBool(self.ra)) << 7;
|
||||
flags |= @as(u16, @intFromBool(self.z)) << 6;
|
||||
flags |= @as(u16, @intFromBool(self.ad)) << 5;
|
||||
flags |= @as(u16, @intFromBool(self.cd)) << 4;
|
||||
flags |= @as(u16, @intFromEnum(self.rcode));
|
||||
|
||||
std.mem.writeInt(u16, buffer[2..4], flags, .big);
|
||||
std.mem.writeInt(u16, buffer[4..6], self.qdcount, .big);
|
||||
std.mem.writeInt(u16, buffer[6..8], self.ancount, .big);
|
||||
std.mem.writeInt(u16, buffer[8..10], self.nscount, .big);
|
||||
std.mem.writeInt(u16, buffer[10..12], self.arcount, .big);
|
||||
}
|
||||
|
||||
/// Create a response header from a query header
|
||||
pub fn createResponse(query: Header, rcode: types.RCode) Header {
|
||||
return Header{
|
||||
.id = query.id,
|
||||
.qr = true, // Response
|
||||
.opcode = query.opcode,
|
||||
.aa = false,
|
||||
.tc = false,
|
||||
.rd = query.rd,
|
||||
.ra = true, // We support recursion
|
||||
.z = false,
|
||||
.ad = false, // Set by DNSSEC validation
|
||||
.cd = query.cd, // Preserve client's CD flag
|
||||
.rcode = rcode,
|
||||
.qdcount = query.qdcount,
|
||||
.ancount = 0,
|
||||
.nscount = 0,
|
||||
.arcount = 0,
|
||||
};
|
||||
}
|
||||
|
||||
/// Check if the response has authenticated data (DNSSEC validated)
|
||||
pub fn isAuthenticated(self: Header) bool {
|
||||
return self.ad;
|
||||
}
|
||||
|
||||
/// Check if this is a query
|
||||
pub fn isQuery(self: Header) bool {
|
||||
return !self.qr;
|
||||
}
|
||||
|
||||
/// Check if this is a response
|
||||
pub fn isResponse(self: Header) bool {
|
||||
return self.qr;
|
||||
}
|
||||
};
|
||||
|
||||
test "parse simple header" {
|
||||
const testing = std.testing;
|
||||
|
||||
// Simple query header
|
||||
const buffer = [_]u8{
|
||||
0x00, 0x01, // ID = 1
|
||||
0x01, 0x00, // Standard query, RD=1
|
||||
0x00, 0x01, // QDCOUNT = 1
|
||||
0x00, 0x00, // ANCOUNT = 0
|
||||
0x00, 0x00, // NSCOUNT = 0
|
||||
0x00, 0x00, // ARCOUNT = 0
|
||||
};
|
||||
|
||||
const header = try Header.parse(&buffer);
|
||||
|
||||
try testing.expectEqual(@as(u16, 1), header.id);
|
||||
try testing.expect(!header.qr); // Query
|
||||
try testing.expectEqual(types.OpCode.Query, header.opcode);
|
||||
try testing.expect(!header.aa);
|
||||
try testing.expect(!header.tc);
|
||||
try testing.expect(header.rd); // Recursion desired
|
||||
try testing.expect(!header.ra);
|
||||
try testing.expect(!header.z);
|
||||
try testing.expect(!header.ad);
|
||||
try testing.expect(!header.cd);
|
||||
try testing.expectEqual(types.RCode.NoError, header.rcode);
|
||||
try testing.expectEqual(@as(u16, 1), header.qdcount);
|
||||
try testing.expectEqual(@as(u16, 0), header.ancount);
|
||||
try testing.expectEqual(@as(u16, 0), header.nscount);
|
||||
try testing.expectEqual(@as(u16, 0), header.arcount);
|
||||
}
|
||||
|
||||
test "encode and decode header roundtrip" {
|
||||
const testing = std.testing;
|
||||
|
||||
const original = Header{
|
||||
.id = 0xABCD,
|
||||
.qr = true,
|
||||
.opcode = types.OpCode.Query,
|
||||
.aa = true,
|
||||
.tc = false,
|
||||
.rd = true,
|
||||
.ra = true,
|
||||
.z = false,
|
||||
.ad = true,
|
||||
.cd = false,
|
||||
.rcode = types.RCode.NoError,
|
||||
.qdcount = 1,
|
||||
.ancount = 2,
|
||||
.nscount = 0,
|
||||
.arcount = 1,
|
||||
};
|
||||
|
||||
var buffer: [Header.SIZE]u8 = undefined;
|
||||
try original.encode(&buffer);
|
||||
|
||||
const decoded = try Header.parse(&buffer);
|
||||
|
||||
try testing.expectEqual(original.id, decoded.id);
|
||||
try testing.expectEqual(original.qr, decoded.qr);
|
||||
try testing.expectEqual(original.opcode, decoded.opcode);
|
||||
try testing.expectEqual(original.aa, decoded.aa);
|
||||
try testing.expectEqual(original.tc, decoded.tc);
|
||||
try testing.expectEqual(original.rd, decoded.rd);
|
||||
try testing.expectEqual(original.ra, decoded.ra);
|
||||
try testing.expectEqual(original.z, decoded.z);
|
||||
try testing.expectEqual(original.ad, decoded.ad);
|
||||
try testing.expectEqual(original.cd, decoded.cd);
|
||||
try testing.expectEqual(original.rcode, decoded.rcode);
|
||||
try testing.expectEqual(original.qdcount, decoded.qdcount);
|
||||
try testing.expectEqual(original.ancount, decoded.ancount);
|
||||
try testing.expectEqual(original.nscount, decoded.nscount);
|
||||
try testing.expectEqual(original.arcount, decoded.arcount);
|
||||
}
|
||||
|
||||
test "create response from query" {
|
||||
const testing = std.testing;
|
||||
|
||||
const query = Header{
|
||||
.id = 0x1234,
|
||||
.qr = false,
|
||||
.opcode = types.OpCode.Query,
|
||||
.aa = false,
|
||||
.tc = false,
|
||||
.rd = true,
|
||||
.ra = false,
|
||||
.z = false,
|
||||
.ad = false,
|
||||
.cd = false,
|
||||
.rcode = types.RCode.NoError,
|
||||
.qdcount = 1,
|
||||
.ancount = 0,
|
||||
.nscount = 0,
|
||||
.arcount = 0,
|
||||
};
|
||||
|
||||
const response = Header.createResponse(query, types.RCode.NoError);
|
||||
|
||||
try testing.expectEqual(query.id, response.id);
|
||||
try testing.expect(response.qr); // Is response
|
||||
try testing.expectEqual(query.opcode, response.opcode);
|
||||
try testing.expectEqual(query.rd, response.rd);
|
||||
try testing.expect(response.ra); // Recursion available
|
||||
try testing.expectEqual(types.RCode.NoError, response.rcode);
|
||||
}
|
||||
|
||||
test "buffer too small" {
|
||||
const testing = std.testing;
|
||||
|
||||
const buffer = [_]u8{ 0x00, 0x01 }; // Only 2 bytes
|
||||
try testing.expectError(error.BufferTooSmall, Header.parse(&buffer));
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
const std = @import("std");
|
||||
const types = @import("types.zig");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
/// DNS Name with support for compression
|
||||
pub const Name = struct {
|
||||
/// Raw labels (without compression, each label prefixed with length)
|
||||
labels: []const u8,
|
||||
/// Allocator used for labels (null if labels are a view into packet)
|
||||
allocator: ?Allocator,
|
||||
|
||||
pub const ParseError = error{
|
||||
InvalidLabel,
|
||||
NameTooLong,
|
||||
LabelTooLong,
|
||||
CompressionLoop,
|
||||
InvalidPointer,
|
||||
BufferTooSmall,
|
||||
OutOfMemory,
|
||||
};
|
||||
|
||||
pub const ParseResult = struct {
|
||||
name: Name,
|
||||
bytes_read: usize,
|
||||
};
|
||||
|
||||
/// Parse a DNS name from a buffer with compression support (iterative, no recursion)
|
||||
/// packet_start is the beginning of the entire DNS packet (for resolving pointers)
|
||||
pub fn parse(buffer: []const u8, packet_start: []const u8, allocator: Allocator) ParseError!ParseResult {
|
||||
// Pre-allocate to avoid multiple reallocations
|
||||
var labels = try std.ArrayListUnmanaged(u8).initCapacity(allocator, types.MAX_NAME_LENGTH + 1);
|
||||
errdefer labels.deinit(allocator);
|
||||
|
||||
var bytes_read: usize = 0;
|
||||
var final_bytes_read: ?usize = null;
|
||||
var total_length: usize = 0;
|
||||
var jumps: usize = 0;
|
||||
const max_jumps: usize = 128; // Prevent infinite compression loops
|
||||
|
||||
// Track visited offsets to detect loops more robustly
|
||||
// Use a bitset for packet offsets up to 16KB (covers typical DNS packets)
|
||||
// For offsets beyond 16KB, fall back to jump counter only
|
||||
var visited: [2048]u8 = [_]u8{0} ** 2048; // 16384 bits = 16KB coverage
|
||||
|
||||
// Current parsing position - can switch buffers on pointer jumps
|
||||
var current_buffer: []const u8 = buffer;
|
||||
var pos: usize = 0;
|
||||
|
||||
while (true) {
|
||||
if (pos >= current_buffer.len) {
|
||||
return error.BufferTooSmall;
|
||||
}
|
||||
|
||||
const len_byte = current_buffer[pos];
|
||||
|
||||
// Check for compression pointer (top 2 bits set)
|
||||
if ((len_byte & types.COMPRESSION_POINTER_MASK) == types.COMPRESSION_POINTER_MASK) {
|
||||
if (pos + 1 >= current_buffer.len) {
|
||||
return error.BufferTooSmall;
|
||||
}
|
||||
|
||||
// Store the bytes read before first pointer (only from original buffer)
|
||||
if (final_bytes_read == null) {
|
||||
final_bytes_read = bytes_read + 2;
|
||||
}
|
||||
|
||||
// Get pointer offset (14 bits)
|
||||
const ptr_high: u16 = @as(u16, len_byte & 0x3F) << 8;
|
||||
const ptr_low: u16 = current_buffer[pos + 1];
|
||||
const offset: usize = ptr_high | ptr_low;
|
||||
|
||||
// Validate pointer - must be within packet bounds
|
||||
if (offset >= packet_start.len) {
|
||||
return error.InvalidPointer;
|
||||
}
|
||||
|
||||
// Check for compression loops using visited bitset
|
||||
if (offset < 16384) {
|
||||
const byte_idx = offset / 8;
|
||||
const bit_idx: u3 = @intCast(offset % 8);
|
||||
if ((visited[byte_idx] & (@as(u8, 1) << bit_idx)) != 0) {
|
||||
return error.CompressionLoop;
|
||||
}
|
||||
visited[byte_idx] |= (@as(u8, 1) << bit_idx);
|
||||
}
|
||||
|
||||
// Also enforce jump counter as secondary protection
|
||||
jumps += 1;
|
||||
if (jumps > max_jumps) {
|
||||
return error.CompressionLoop;
|
||||
}
|
||||
|
||||
// Jump to pointed location (iteratively, no recursion)
|
||||
current_buffer = packet_start[offset..];
|
||||
pos = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Normal label
|
||||
const label_len = len_byte;
|
||||
|
||||
if (label_len == 0) {
|
||||
// End of name
|
||||
try labels.append(allocator, 0);
|
||||
// Only add to bytes_read if we haven't jumped
|
||||
if (final_bytes_read == null) {
|
||||
bytes_read += 1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (label_len > types.MAX_LABEL_LENGTH) {
|
||||
return error.LabelTooLong;
|
||||
}
|
||||
|
||||
if (pos + 1 + label_len > current_buffer.len) {
|
||||
return error.BufferTooSmall;
|
||||
}
|
||||
|
||||
total_length += label_len + 1; // +1 for dot
|
||||
if (total_length > types.MAX_NAME_LENGTH) {
|
||||
return error.NameTooLong;
|
||||
}
|
||||
|
||||
// Copy label length and data
|
||||
try labels.append(allocator, len_byte);
|
||||
try labels.appendSlice(allocator, current_buffer[pos + 1 .. pos + 1 + label_len]);
|
||||
|
||||
pos += 1 + label_len;
|
||||
// Only add to bytes_read if we haven't jumped
|
||||
if (final_bytes_read == null) {
|
||||
bytes_read += 1 + label_len;
|
||||
}
|
||||
}
|
||||
|
||||
return ParseResult{
|
||||
.name = Name{
|
||||
.labels = try labels.toOwnedSlice(allocator),
|
||||
.allocator = allocator,
|
||||
},
|
||||
.bytes_read = final_bytes_read orelse bytes_read,
|
||||
};
|
||||
}
|
||||
|
||||
/// Parse a name from a string (e.g., "www.google.com")
|
||||
pub fn fromString(str: []const u8, allocator: Allocator) ParseError!Name {
|
||||
if (str.len > types.MAX_NAME_LENGTH) {
|
||||
return error.NameTooLong;
|
||||
}
|
||||
|
||||
var labels = std.ArrayListUnmanaged(u8){};
|
||||
errdefer labels.deinit(allocator);
|
||||
|
||||
// Handle root domain
|
||||
if (str.len == 0 or (str.len == 1 and str[0] == '.')) {
|
||||
try labels.append(allocator, 0);
|
||||
return Name{
|
||||
.labels = try labels.toOwnedSlice(allocator),
|
||||
.allocator = allocator,
|
||||
};
|
||||
}
|
||||
|
||||
var iter = std.mem.splitScalar(u8, str, '.');
|
||||
while (iter.next()) |label| {
|
||||
if (label.len == 0) continue; // Skip empty labels (trailing dot)
|
||||
if (label.len > types.MAX_LABEL_LENGTH) {
|
||||
return error.LabelTooLong;
|
||||
}
|
||||
|
||||
try labels.append(allocator, @intCast(label.len));
|
||||
try labels.appendSlice(allocator, label);
|
||||
}
|
||||
|
||||
try labels.append(allocator, 0); // Null terminator
|
||||
|
||||
return Name{
|
||||
.labels = try labels.toOwnedSlice(allocator),
|
||||
.allocator = allocator,
|
||||
};
|
||||
}
|
||||
|
||||
/// Convert the name to a string (e.g., "www.google.com")
|
||||
pub fn toString(self: Name, allocator: Allocator) ![]u8 {
|
||||
var result = std.ArrayListUnmanaged(u8){};
|
||||
errdefer result.deinit(allocator);
|
||||
|
||||
var pos: usize = 0;
|
||||
var first = true;
|
||||
|
||||
while (pos < self.labels.len) {
|
||||
const label_len = self.labels[pos];
|
||||
if (label_len == 0) break;
|
||||
|
||||
if (!first) {
|
||||
try result.append(allocator, '.');
|
||||
}
|
||||
first = false;
|
||||
|
||||
const label_start = pos + 1;
|
||||
const label_end = label_start + label_len;
|
||||
if (label_end > self.labels.len) break;
|
||||
|
||||
try result.appendSlice(allocator, self.labels[label_start..label_end]);
|
||||
pos = label_end;
|
||||
}
|
||||
|
||||
return result.toOwnedSlice(allocator);
|
||||
}
|
||||
|
||||
/// Convert the name to a string using a pre-allocated buffer (no allocation)
|
||||
/// Returns null if buffer is too small
|
||||
pub fn toStringBuf(self: Name, buf: []u8) ?[]const u8 {
|
||||
var pos: usize = 0;
|
||||
var out_pos: usize = 0;
|
||||
var first = true;
|
||||
|
||||
while (pos < self.labels.len) {
|
||||
const label_len = self.labels[pos];
|
||||
if (label_len == 0) break;
|
||||
|
||||
if (!first) {
|
||||
if (out_pos >= buf.len) return null;
|
||||
buf[out_pos] = '.';
|
||||
out_pos += 1;
|
||||
}
|
||||
first = false;
|
||||
|
||||
const label_start = pos + 1;
|
||||
const label_end = label_start + label_len;
|
||||
if (label_end > self.labels.len) break;
|
||||
|
||||
if (out_pos + label_len > buf.len) return null;
|
||||
@memcpy(buf[out_pos..][0..label_len], self.labels[label_start..label_end]);
|
||||
out_pos += label_len;
|
||||
pos = label_end;
|
||||
}
|
||||
|
||||
return buf[0..out_pos];
|
||||
}
|
||||
|
||||
/// Encode the name into a buffer (without compression)
|
||||
pub fn encode(self: Name, buffer: []u8) ParseError!usize {
|
||||
if (buffer.len < self.labels.len) {
|
||||
return error.BufferTooSmall;
|
||||
}
|
||||
|
||||
@memcpy(buffer[0..self.labels.len], self.labels);
|
||||
return self.labels.len;
|
||||
}
|
||||
|
||||
/// Get the encoded length of the name
|
||||
pub fn encodedLen(self: Name) usize {
|
||||
return self.labels.len;
|
||||
}
|
||||
|
||||
/// Check if two names are equal (case-insensitive per DNS spec)
|
||||
pub fn eql(self: Name, other: Name) bool {
|
||||
if (self.labels.len != other.labels.len) return false;
|
||||
|
||||
var pos: usize = 0;
|
||||
while (pos < self.labels.len) {
|
||||
const len = self.labels[pos];
|
||||
if (len != other.labels[pos]) return false;
|
||||
|
||||
if (len == 0) break;
|
||||
|
||||
const start = pos + 1;
|
||||
const end = start + len;
|
||||
if (end > self.labels.len) return false;
|
||||
|
||||
for (self.labels[start..end], other.labels[start..end]) |a, b| {
|
||||
if (std.ascii.toLower(a) != std.ascii.toLower(b)) return false;
|
||||
}
|
||||
|
||||
pos = end;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Get the number of labels in the name
|
||||
pub fn labelCount(self: Name) usize {
|
||||
var count: usize = 0;
|
||||
var pos: usize = 0;
|
||||
|
||||
while (pos < self.labels.len) {
|
||||
const len = self.labels[pos];
|
||||
if (len == 0) break;
|
||||
count += 1;
|
||||
pos += 1 + len;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/// Check if this name is a subdomain of another
|
||||
/// E.g., "www.example.com" is a subdomain of "example.com"
|
||||
pub fn isSubdomainOf(self: Name, parent: Name) bool {
|
||||
const self_count = self.labelCount();
|
||||
const parent_count = parent.labelCount();
|
||||
|
||||
// Self must have more labels than parent to be a subdomain
|
||||
if (self_count <= parent_count) return false;
|
||||
|
||||
// Skip the first (self_count - parent_count) labels in self
|
||||
// Then the remaining labels should match parent exactly
|
||||
const labels_to_skip = self_count - parent_count;
|
||||
|
||||
var self_pos: usize = 0;
|
||||
var skipped: usize = 0;
|
||||
|
||||
// Skip labels in self
|
||||
while (skipped < labels_to_skip and self_pos < self.labels.len) {
|
||||
const len = self.labels[self_pos];
|
||||
if (len == 0) break;
|
||||
self_pos += 1 + len;
|
||||
skipped += 1;
|
||||
}
|
||||
|
||||
// Now compare remaining self labels with all parent labels
|
||||
var parent_pos: usize = 0;
|
||||
while (self_pos < self.labels.len and parent_pos < parent.labels.len) {
|
||||
const self_len = self.labels[self_pos];
|
||||
const parent_len = parent.labels[parent_pos];
|
||||
|
||||
// Both null terminators = match
|
||||
if (self_len == 0 and parent_len == 0) return true;
|
||||
|
||||
// Length mismatch
|
||||
if (self_len != parent_len) return false;
|
||||
|
||||
// Compare label content case-insensitively
|
||||
const self_end = self_pos + 1 + self_len;
|
||||
const parent_end = parent_pos + 1 + parent_len;
|
||||
|
||||
if (self_end > self.labels.len or parent_end > parent.labels.len) return false;
|
||||
|
||||
for (self.labels[self_pos + 1 .. self_end], parent.labels[parent_pos + 1 .. parent_end]) |a, b| {
|
||||
if (std.ascii.toLower(a) != std.ascii.toLower(b)) return false;
|
||||
}
|
||||
|
||||
self_pos = self_end;
|
||||
parent_pos = parent_end;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Free the name's memory
|
||||
pub fn deinit(self: *Name) void {
|
||||
if (self.allocator) |alloc| {
|
||||
alloc.free(self.labels);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/// Compression map for encoding names with compression
|
||||
pub const CompressionMap = struct {
|
||||
map: std.StringHashMapUnmanaged(u16),
|
||||
allocator: Allocator,
|
||||
|
||||
pub fn init(allocator: Allocator) CompressionMap {
|
||||
return CompressionMap{
|
||||
.map = std.StringHashMapUnmanaged(u16){},
|
||||
.allocator = allocator,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *CompressionMap) void {
|
||||
var iter = self.map.keyIterator();
|
||||
while (iter.next()) |key| {
|
||||
self.allocator.free(key.*);
|
||||
}
|
||||
self.map.deinit(self.allocator);
|
||||
}
|
||||
|
||||
pub fn lookup(self: *CompressionMap, name: []const u8) ?u16 {
|
||||
return self.map.get(name);
|
||||
}
|
||||
|
||||
pub fn insert(self: *CompressionMap, name: []const u8, offset: u16) !void {
|
||||
const key = try self.allocator.dupe(u8, name);
|
||||
errdefer self.allocator.free(key);
|
||||
try self.map.put(self.allocator, key, offset);
|
||||
}
|
||||
};
|
||||
|
||||
test "parse simple name" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
// www.google.com encoded as: \x03www\x06google\x03com\x00
|
||||
const buffer = [_]u8{ 0x03, 'w', 'o', 'w', 0x06, 'g', 'o', 'o', 'g', 'l', 'e', 0x03, 'c', 'o', 'm', 0x00 };
|
||||
|
||||
const result = try Name.parse(&buffer, &buffer, allocator);
|
||||
defer {
|
||||
var name = result.name;
|
||||
name.deinit();
|
||||
}
|
||||
|
||||
try testing.expectEqual(@as(usize, 16), result.bytes_read);
|
||||
|
||||
const str = try result.name.toString(allocator);
|
||||
defer allocator.free(str);
|
||||
|
||||
try testing.expectEqualStrings("wow.google.com", str);
|
||||
}
|
||||
|
||||
test "parse name with compression" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
// Packet with compression:
|
||||
// Offset 0: header (12 bytes, simulated with zeros)
|
||||
// Offset 12: \x03www\x06google\x03com\x00 (first name)
|
||||
// Offset 28: \x03api + pointer to offset 16 (google.com)
|
||||
var packet: [36]u8 = undefined;
|
||||
@memset(&packet, 0);
|
||||
|
||||
// First name at offset 12: www.google.com
|
||||
packet[12] = 0x03;
|
||||
packet[13] = 'w';
|
||||
packet[14] = 'w';
|
||||
packet[15] = 'w';
|
||||
packet[16] = 0x06;
|
||||
packet[17] = 'g';
|
||||
packet[18] = 'o';
|
||||
packet[19] = 'o';
|
||||
packet[20] = 'g';
|
||||
packet[21] = 'l';
|
||||
packet[22] = 'e';
|
||||
packet[23] = 0x03;
|
||||
packet[24] = 'c';
|
||||
packet[25] = 'o';
|
||||
packet[26] = 'm';
|
||||
packet[27] = 0x00;
|
||||
|
||||
// Second name at offset 28: api + pointer to google.com (offset 16)
|
||||
packet[28] = 0x03;
|
||||
packet[29] = 'a';
|
||||
packet[30] = 'p';
|
||||
packet[31] = 'i';
|
||||
packet[32] = 0xC0; // Compression pointer
|
||||
packet[33] = 16; // Points to offset 16 (google.com)
|
||||
|
||||
const result = try Name.parse(packet[28..], &packet, allocator);
|
||||
defer {
|
||||
var name = result.name;
|
||||
name.deinit();
|
||||
}
|
||||
|
||||
try testing.expectEqual(@as(usize, 6), result.bytes_read); // \x03api + 2 byte pointer
|
||||
|
||||
const str = try result.name.toString(allocator);
|
||||
defer allocator.free(str);
|
||||
|
||||
try testing.expectEqualStrings("api.google.com", str);
|
||||
}
|
||||
|
||||
test "name from string" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var name = try Name.fromString("www.example.com", allocator);
|
||||
defer name.deinit();
|
||||
|
||||
const str = try name.toString(allocator);
|
||||
defer allocator.free(str);
|
||||
|
||||
try testing.expectEqualStrings("www.example.com", str);
|
||||
try testing.expectEqual(@as(usize, 3), name.labelCount());
|
||||
}
|
||||
|
||||
test "name from string with trailing dot" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var name = try Name.fromString("www.example.com.", allocator);
|
||||
defer name.deinit();
|
||||
|
||||
const str = try name.toString(allocator);
|
||||
defer allocator.free(str);
|
||||
|
||||
try testing.expectEqualStrings("www.example.com", str);
|
||||
}
|
||||
|
||||
test "root name" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var name = try Name.fromString("", allocator);
|
||||
defer name.deinit();
|
||||
|
||||
const str = try name.toString(allocator);
|
||||
defer allocator.free(str);
|
||||
|
||||
try testing.expectEqualStrings("", str);
|
||||
try testing.expectEqual(@as(usize, 0), name.labelCount());
|
||||
}
|
||||
|
||||
test "name encode and decode roundtrip" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var original = try Name.fromString("test.example.org", allocator);
|
||||
defer original.deinit();
|
||||
|
||||
var buffer: [256]u8 = undefined;
|
||||
const encoded_len = try original.encode(&buffer);
|
||||
|
||||
const result = try Name.parse(buffer[0..encoded_len], buffer[0..encoded_len], allocator);
|
||||
defer {
|
||||
var name = result.name;
|
||||
name.deinit();
|
||||
}
|
||||
|
||||
try testing.expect(original.eql(result.name));
|
||||
}
|
||||
|
||||
test "name equality case insensitive" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var name1 = try Name.fromString("WWW.EXAMPLE.COM", allocator);
|
||||
defer name1.deinit();
|
||||
|
||||
var name2 = try Name.fromString("www.example.com", allocator);
|
||||
defer name2.deinit();
|
||||
|
||||
try testing.expect(name1.eql(name2));
|
||||
}
|
||||
|
||||
test "label too long" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
// Create a label with 64 characters (max is 63)
|
||||
const long_label = "a" ** 64 ++ ".example.com";
|
||||
try testing.expectError(error.LabelTooLong, Name.fromString(long_label, allocator));
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
pub const types = @import("types.zig");
|
||||
pub const Header = @import("header.zig").Header;
|
||||
pub const Name = @import("name.zig").Name;
|
||||
pub const CompressionMap = @import("name.zig").CompressionMap;
|
||||
pub const Question = @import("question.zig").Question;
|
||||
pub const ResourceRecord = @import("record.zig").ResourceRecord;
|
||||
pub const RData = @import("record.zig").RData;
|
||||
pub const createARecord = @import("record.zig").createARecord;
|
||||
pub const createAAAARecord = @import("record.zig").createAAAARecord;
|
||||
pub const createCnameRecord = @import("record.zig").createCnameRecord;
|
||||
pub const Edns = @import("edns.zig").Edns;
|
||||
pub const hasEdns = @import("edns.zig").hasEdns;
|
||||
pub const extractEdns = @import("edns.zig").extractEdns;
|
||||
|
||||
/// TTL for blocked/synthetic responses (in seconds)
|
||||
pub const DENIED_RESPONSE_TTL: u32 = 60;
|
||||
|
||||
/// Complete DNS Packet
|
||||
pub const Packet = struct {
|
||||
header: Header,
|
||||
questions: []Question,
|
||||
answers: []ResourceRecord,
|
||||
authority: []ResourceRecord,
|
||||
additional: []ResourceRecord,
|
||||
allocator: Allocator,
|
||||
|
||||
/// Raw bytes of the original packet (for forwarding)
|
||||
raw_bytes: ?[]const u8 = null,
|
||||
|
||||
pub const ParseError = error{
|
||||
BufferTooSmall,
|
||||
InvalidLabel,
|
||||
NameTooLong,
|
||||
LabelTooLong,
|
||||
CompressionLoop,
|
||||
InvalidPointer,
|
||||
OutOfMemory,
|
||||
InvalidRData,
|
||||
HeaderParseError,
|
||||
};
|
||||
|
||||
/// Parse a complete DNS packet from a buffer
|
||||
pub fn parse(buffer: []const u8, allocator: Allocator) ParseError!Packet {
|
||||
// Parse header
|
||||
const header = Header.parse(buffer) catch return error.HeaderParseError;
|
||||
|
||||
var offset: usize = Header.SIZE;
|
||||
|
||||
// Parse questions
|
||||
var questions = try allocator.alloc(Question, header.qdcount);
|
||||
errdefer {
|
||||
for (questions) |*q| {
|
||||
q.deinit();
|
||||
}
|
||||
allocator.free(questions);
|
||||
}
|
||||
|
||||
var q_idx: usize = 0;
|
||||
while (q_idx < header.qdcount) : (q_idx += 1) {
|
||||
const result = try Question.parse(buffer[offset..], buffer, allocator);
|
||||
questions[q_idx] = result.question;
|
||||
offset += result.bytes_read;
|
||||
}
|
||||
|
||||
// Parse answers
|
||||
var answers = try allocator.alloc(ResourceRecord, header.ancount);
|
||||
errdefer {
|
||||
for (answers[0..]) |*a| {
|
||||
a.deinit(allocator);
|
||||
}
|
||||
allocator.free(answers);
|
||||
}
|
||||
|
||||
var a_idx: usize = 0;
|
||||
while (a_idx < header.ancount) : (a_idx += 1) {
|
||||
const result = try ResourceRecord.parse(buffer[offset..], buffer, allocator);
|
||||
answers[a_idx] = result.record;
|
||||
offset += result.bytes_read;
|
||||
}
|
||||
|
||||
// Parse authority
|
||||
var authority = try allocator.alloc(ResourceRecord, header.nscount);
|
||||
errdefer {
|
||||
for (authority[0..]) |*ns| {
|
||||
ns.deinit(allocator);
|
||||
}
|
||||
allocator.free(authority);
|
||||
}
|
||||
|
||||
var ns_idx: usize = 0;
|
||||
while (ns_idx < header.nscount) : (ns_idx += 1) {
|
||||
const result = try ResourceRecord.parse(buffer[offset..], buffer, allocator);
|
||||
authority[ns_idx] = result.record;
|
||||
offset += result.bytes_read;
|
||||
}
|
||||
|
||||
// Parse additional
|
||||
var additional = try allocator.alloc(ResourceRecord, header.arcount);
|
||||
errdefer {
|
||||
for (additional[0..]) |*ar| {
|
||||
ar.deinit(allocator);
|
||||
}
|
||||
allocator.free(additional);
|
||||
}
|
||||
|
||||
var ar_idx: usize = 0;
|
||||
while (ar_idx < header.arcount) : (ar_idx += 1) {
|
||||
const result = try ResourceRecord.parse(buffer[offset..], buffer, allocator);
|
||||
additional[ar_idx] = result.record;
|
||||
offset += result.bytes_read;
|
||||
}
|
||||
|
||||
// Store raw bytes
|
||||
const raw = try allocator.dupe(u8, buffer);
|
||||
|
||||
return Packet{
|
||||
.header = header,
|
||||
.questions = questions,
|
||||
.answers = answers,
|
||||
.authority = authority,
|
||||
.additional = additional,
|
||||
.allocator = allocator,
|
||||
.raw_bytes = raw,
|
||||
};
|
||||
}
|
||||
|
||||
/// Encode the packet into a buffer
|
||||
pub fn encode(self: Packet, buffer: []u8) ParseError!usize {
|
||||
var offset: usize = 0;
|
||||
|
||||
// Update header counts
|
||||
var header = self.header;
|
||||
header.qdcount = @intCast(self.questions.len);
|
||||
header.ancount = @intCast(self.answers.len);
|
||||
header.nscount = @intCast(self.authority.len);
|
||||
header.arcount = @intCast(self.additional.len);
|
||||
|
||||
// Encode header
|
||||
header.encode(buffer[offset..]) catch return error.BufferTooSmall;
|
||||
offset += Header.SIZE;
|
||||
|
||||
// Encode questions
|
||||
for (self.questions) |q| {
|
||||
const len = try q.encode(buffer[offset..]);
|
||||
offset += len;
|
||||
}
|
||||
|
||||
// Encode answers
|
||||
for (self.answers) |a| {
|
||||
const len = try a.encode(buffer[offset..], self.allocator);
|
||||
offset += len;
|
||||
}
|
||||
|
||||
// Encode authority
|
||||
for (self.authority) |ns| {
|
||||
const len = try ns.encode(buffer[offset..], self.allocator);
|
||||
offset += len;
|
||||
}
|
||||
|
||||
// Encode additional
|
||||
for (self.additional) |ar| {
|
||||
const len = try ar.encode(buffer[offset..], self.allocator);
|
||||
offset += len;
|
||||
}
|
||||
|
||||
return offset;
|
||||
}
|
||||
|
||||
/// Free all packet memory
|
||||
pub fn deinit(self: *Packet) void {
|
||||
for (self.questions) |*q| {
|
||||
q.deinit();
|
||||
}
|
||||
self.allocator.free(self.questions);
|
||||
|
||||
for (self.answers) |*a| {
|
||||
a.deinit(self.allocator);
|
||||
}
|
||||
self.allocator.free(self.answers);
|
||||
|
||||
for (self.authority) |*ns| {
|
||||
ns.deinit(self.allocator);
|
||||
}
|
||||
self.allocator.free(self.authority);
|
||||
|
||||
for (self.additional) |*ar| {
|
||||
ar.deinit(self.allocator);
|
||||
}
|
||||
self.allocator.free(self.additional);
|
||||
|
||||
if (self.raw_bytes) |raw| {
|
||||
self.allocator.free(raw);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this packet is a query
|
||||
pub fn isQuery(self: Packet) bool {
|
||||
return self.header.isQuery();
|
||||
}
|
||||
|
||||
/// Check if this packet is a response
|
||||
pub fn isResponse(self: Packet) bool {
|
||||
return self.header.isResponse();
|
||||
}
|
||||
|
||||
/// Get the first question's domain name as a string
|
||||
pub fn getQueryName(self: Packet) ![]u8 {
|
||||
if (self.questions.len == 0) return error.NoQuestion;
|
||||
return self.questions[0].name.toString(self.allocator);
|
||||
}
|
||||
|
||||
pub const NoQuestionError = error{NoQuestion};
|
||||
|
||||
/// Get the first question's type
|
||||
pub fn getQueryType(self: Packet) ?types.QType {
|
||||
if (self.questions.len == 0) return null;
|
||||
return self.questions[0].qtype;
|
||||
}
|
||||
|
||||
/// Check if EDNS is present
|
||||
pub fn hasEdnsSupport(self: Packet) bool {
|
||||
return hasEdns(self.additional);
|
||||
}
|
||||
|
||||
/// Get EDNS information if present
|
||||
pub fn getEdns(self: Packet) !?Edns {
|
||||
return extractEdns(self.additional, self.allocator);
|
||||
}
|
||||
|
||||
/// Create a response packet from a query
|
||||
pub fn createResponse(query: *const Packet, rcode: types.RCode, allocator: Allocator) !Packet {
|
||||
const response_header = Header.createResponse(query.header, rcode);
|
||||
|
||||
// Clone questions
|
||||
var questions = try allocator.alloc(Question, query.questions.len);
|
||||
errdefer allocator.free(questions);
|
||||
|
||||
for (query.questions, 0..) |q, i| {
|
||||
questions[i] = try q.clone(allocator);
|
||||
}
|
||||
|
||||
return Packet{
|
||||
.header = response_header,
|
||||
.questions = questions,
|
||||
.answers = try allocator.alloc(ResourceRecord, 0),
|
||||
.authority = try allocator.alloc(ResourceRecord, 0),
|
||||
.additional = try allocator.alloc(ResourceRecord, 0),
|
||||
.allocator = allocator,
|
||||
};
|
||||
}
|
||||
|
||||
/// Create a denied response (returns 0.0.0.0 for A queries)
|
||||
pub fn createDeniedResponse(query: *const Packet, allocator: Allocator) !Packet {
|
||||
var response = try createResponse(query, types.RCode.NoError, allocator);
|
||||
errdefer response.deinit();
|
||||
|
||||
if (query.questions.len > 0) {
|
||||
const q = query.questions[0];
|
||||
|
||||
// Clone the name for the answer
|
||||
const labels = try allocator.dupe(u8, q.name.labels);
|
||||
const answer_name = Name{
|
||||
.labels = labels,
|
||||
.allocator = allocator,
|
||||
};
|
||||
|
||||
var answers = try allocator.alloc(ResourceRecord, 1);
|
||||
|
||||
if (q.qtype == types.QType.A) {
|
||||
answers[0] = createARecord(answer_name, DENIED_RESPONSE_TTL, [4]u8{ 0, 0, 0, 0 });
|
||||
} else if (q.qtype == types.QType.AAAA) {
|
||||
answers[0] = createAAAARecord(answer_name, DENIED_RESPONSE_TTL, [16]u8{ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 });
|
||||
} else {
|
||||
// For other types, just return NXDOMAIN
|
||||
answer_name.allocator.?.free(answer_name.labels);
|
||||
allocator.free(answers);
|
||||
response.header.rcode = types.RCode.NXDomain;
|
||||
return response;
|
||||
}
|
||||
|
||||
allocator.free(response.answers);
|
||||
response.answers = answers;
|
||||
response.header.ancount = 1;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/// Create an NXDOMAIN response
|
||||
pub fn createNxdomainResponse(query: *const Packet, allocator: Allocator) !Packet {
|
||||
return createResponse(query, types.RCode.NXDomain, allocator);
|
||||
}
|
||||
|
||||
/// Create a safe search CNAME response that redirects to the safe domain
|
||||
pub fn createSafeSearchResponse(query: *const Packet, safe_domain: []const u8, allocator: Allocator) !Packet {
|
||||
var response = try createResponse(query, types.RCode.NoError, allocator);
|
||||
errdefer response.deinit();
|
||||
|
||||
if (query.questions.len > 0) {
|
||||
const q = query.questions[0];
|
||||
|
||||
// Clone the name for the answer
|
||||
const labels = try allocator.dupe(u8, q.name.labels);
|
||||
const answer_name = Name{
|
||||
.labels = labels,
|
||||
.allocator = allocator,
|
||||
};
|
||||
|
||||
// Create the CNAME target
|
||||
const target = try Name.fromString(safe_domain, allocator);
|
||||
|
||||
var answers = try allocator.alloc(ResourceRecord, 1);
|
||||
answers[0] = createCnameRecord(answer_name, DENIED_RESPONSE_TTL, target);
|
||||
|
||||
allocator.free(response.answers);
|
||||
response.answers = answers;
|
||||
response.header.ancount = 1;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/// Create a SERVFAIL response
|
||||
pub fn createServfailResponse(query: *const Packet, allocator: Allocator) !Packet {
|
||||
return createResponse(query, types.RCode.ServFail, allocator);
|
||||
}
|
||||
|
||||
/// Create a FORMERR response
|
||||
pub fn createFormerrResponse(query: *const Packet, allocator: Allocator) !Packet {
|
||||
return createResponse(query, types.RCode.FormErr, allocator);
|
||||
}
|
||||
};
|
||||
|
||||
/// Parse only the header from a buffer (for quick inspection)
|
||||
pub fn parseHeaderOnly(buffer: []const u8) !Header {
|
||||
return Header.parse(buffer);
|
||||
}
|
||||
|
||||
/// Get the transaction ID from a buffer without full parsing
|
||||
pub fn getTransactionId(buffer: []const u8) ?u16 {
|
||||
if (buffer.len < 2) return null;
|
||||
return std.mem.readInt(u16, buffer[0..2], .big);
|
||||
}
|
||||
|
||||
test "parse simple query packet" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
// Simple A query for www.google.com
|
||||
const query = [_]u8{
|
||||
// Header
|
||||
0x00, 0x01, // ID = 1
|
||||
0x01, 0x00, // Standard query, RD=1
|
||||
0x00, 0x01, // QDCOUNT = 1
|
||||
0x00, 0x00, // ANCOUNT = 0
|
||||
0x00, 0x00, // NSCOUNT = 0
|
||||
0x00, 0x00, // ARCOUNT = 0
|
||||
// Question
|
||||
0x03, 'w', 'w', 'w', // www
|
||||
0x06, 'g', 'o', 'o', 'g', 'l', 'e', // google
|
||||
0x03, 'c', 'o', 'm', // com
|
||||
0x00, // null terminator
|
||||
0x00, 0x01, // TYPE = A
|
||||
0x00, 0x01, // CLASS = IN
|
||||
};
|
||||
|
||||
var packet = try Packet.parse(&query, allocator);
|
||||
defer packet.deinit();
|
||||
|
||||
try testing.expectEqual(@as(u16, 1), packet.header.id);
|
||||
try testing.expect(packet.isQuery());
|
||||
try testing.expect(!packet.isResponse());
|
||||
try testing.expectEqual(@as(usize, 1), packet.questions.len);
|
||||
try testing.expectEqual(@as(usize, 0), packet.answers.len);
|
||||
|
||||
const name = try packet.getQueryName();
|
||||
defer allocator.free(name);
|
||||
|
||||
try testing.expectEqualStrings("www.google.com", name);
|
||||
try testing.expectEqual(types.QType.A, packet.getQueryType().?);
|
||||
}
|
||||
|
||||
test "parse response packet with A record" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
// Response for example.com with A record 93.184.216.34
|
||||
const response = [_]u8{
|
||||
// Header
|
||||
0x00, 0x02, // ID = 2
|
||||
0x81, 0x80, // Response, RD=1, RA=1
|
||||
0x00, 0x01, // QDCOUNT = 1
|
||||
0x00, 0x01, // ANCOUNT = 1
|
||||
0x00, 0x00, // NSCOUNT = 0
|
||||
0x00, 0x00, // ARCOUNT = 0
|
||||
// Question
|
||||
0x07, 'e', 'x', 'a', 'm', 'p', 'l', 'e', // example
|
||||
0x03, 'c', 'o', 'm', // com
|
||||
0x00, // null terminator
|
||||
0x00, 0x01, // TYPE = A
|
||||
0x00, 0x01, // CLASS = IN
|
||||
// Answer
|
||||
0xC0, 0x0C, // Pointer to offset 12 (example.com)
|
||||
0x00, 0x01, // TYPE = A
|
||||
0x00, 0x01, // CLASS = IN
|
||||
0x00, 0x00, 0x01, 0x2C, // TTL = 300
|
||||
0x00, 0x04, // RDLENGTH = 4
|
||||
0x5D, 0xB8, 0xD8, 0x22, // 93.184.216.34
|
||||
};
|
||||
|
||||
var packet = try Packet.parse(&response, allocator);
|
||||
defer packet.deinit();
|
||||
|
||||
try testing.expectEqual(@as(u16, 2), packet.header.id);
|
||||
try testing.expect(packet.isResponse());
|
||||
try testing.expectEqual(@as(usize, 1), packet.questions.len);
|
||||
try testing.expectEqual(@as(usize, 1), packet.answers.len);
|
||||
|
||||
const answer = packet.answers[0];
|
||||
try testing.expectEqual(types.QType.A, answer.rtype);
|
||||
try testing.expectEqual(@as(u32, 300), answer.ttl);
|
||||
|
||||
const ip = answer.getIPv4().?;
|
||||
try testing.expectEqual([4]u8{ 93, 184, 216, 34 }, ip);
|
||||
}
|
||||
|
||||
test "create blocked response" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
// Create a query
|
||||
const query_bytes = [_]u8{
|
||||
// Header
|
||||
0x00, 0x03, // ID = 3
|
||||
0x01, 0x00, // Standard query, RD=1
|
||||
0x00, 0x01, // QDCOUNT = 1
|
||||
0x00, 0x00, // ANCOUNT = 0
|
||||
0x00, 0x00, // NSCOUNT = 0
|
||||
0x00, 0x00, // ARCOUNT = 0
|
||||
// Question
|
||||
0x09, 'd', 'o', 'u', 'b', 'l', 'e', 'c', 'l', 'k', // doubleclk
|
||||
0x03, 'n', 'e', 't', // net
|
||||
0x00, // null terminator
|
||||
0x00, 0x01, // TYPE = A
|
||||
0x00, 0x01, // CLASS = IN
|
||||
};
|
||||
|
||||
var query = try Packet.parse(&query_bytes, allocator);
|
||||
defer query.deinit();
|
||||
|
||||
var denied = try Packet.createDeniedResponse(&query, allocator);
|
||||
defer denied.deinit();
|
||||
|
||||
try testing.expect(denied.isResponse());
|
||||
try testing.expectEqual(@as(u16, 3), denied.header.id);
|
||||
try testing.expectEqual(@as(usize, 1), denied.answers.len);
|
||||
|
||||
const answer = denied.answers[0];
|
||||
try testing.expectEqual(types.QType.A, answer.rtype);
|
||||
try testing.expectEqual([4]u8{ 0, 0, 0, 0 }, answer.getIPv4().?);
|
||||
}
|
||||
|
||||
test "encode and decode packet roundtrip" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
// Simple query
|
||||
const original_bytes = [_]u8{
|
||||
// Header
|
||||
0xAB, 0xCD, // ID
|
||||
0x01, 0x00, // Standard query, RD=1
|
||||
0x00, 0x01, // QDCOUNT = 1
|
||||
0x00, 0x00, // ANCOUNT = 0
|
||||
0x00, 0x00, // NSCOUNT = 0
|
||||
0x00, 0x00, // ARCOUNT = 0
|
||||
// Question
|
||||
0x04, 't', 'e', 's', 't', // test
|
||||
0x03, 'c', 'o', 'm', // com
|
||||
0x00, // null terminator
|
||||
0x00, 0x01, // TYPE = A
|
||||
0x00, 0x01, // CLASS = IN
|
||||
};
|
||||
|
||||
var original = try Packet.parse(&original_bytes, allocator);
|
||||
defer original.deinit();
|
||||
|
||||
// Encode
|
||||
var buffer: [512]u8 = undefined;
|
||||
const encoded_len = try original.encode(&buffer);
|
||||
|
||||
// Decode
|
||||
var decoded = try Packet.parse(buffer[0..encoded_len], allocator);
|
||||
defer decoded.deinit();
|
||||
|
||||
try testing.expectEqual(original.header.id, decoded.header.id);
|
||||
try testing.expectEqual(original.questions.len, decoded.questions.len);
|
||||
|
||||
const orig_name = try original.getQueryName();
|
||||
defer allocator.free(orig_name);
|
||||
|
||||
const dec_name = try decoded.getQueryName();
|
||||
defer allocator.free(dec_name);
|
||||
|
||||
try testing.expectEqualStrings(orig_name, dec_name);
|
||||
}
|
||||
|
||||
test "get transaction ID" {
|
||||
const testing = std.testing;
|
||||
|
||||
const buffer = [_]u8{ 0x12, 0x34, 0x01, 0x00 };
|
||||
try testing.expectEqual(@as(u16, 0x1234), getTransactionId(&buffer).?);
|
||||
|
||||
const short = [_]u8{0x12};
|
||||
try testing.expect(getTransactionId(&short) == null);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
const std = @import("std");
|
||||
const types = @import("types.zig");
|
||||
const Name = @import("name.zig").Name;
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
/// DNS Question Section
|
||||
/// Format:
|
||||
/// +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|
||||
/// | QNAME |
|
||||
/// +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|
||||
/// | QTYPE |
|
||||
/// +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|
||||
/// | QCLASS |
|
||||
/// +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|
||||
pub const Question = struct {
|
||||
/// Query name
|
||||
name: Name,
|
||||
/// Query type
|
||||
qtype: types.QType,
|
||||
/// Query class
|
||||
qclass: types.QClass,
|
||||
|
||||
pub const ParseError = error{
|
||||
BufferTooSmall,
|
||||
InvalidLabel,
|
||||
NameTooLong,
|
||||
LabelTooLong,
|
||||
CompressionLoop,
|
||||
InvalidPointer,
|
||||
OutOfMemory,
|
||||
};
|
||||
|
||||
pub const ParseResult = struct {
|
||||
question: Question,
|
||||
bytes_read: usize,
|
||||
};
|
||||
|
||||
/// Parse a question from a buffer
|
||||
pub fn parse(buffer: []const u8, packet_start: []const u8, allocator: Allocator) ParseError!ParseResult {
|
||||
// Parse the name first
|
||||
const name_result = try Name.parse(buffer, packet_start, allocator);
|
||||
errdefer {
|
||||
var n = name_result.name;
|
||||
n.deinit();
|
||||
}
|
||||
|
||||
const remaining = buffer[name_result.bytes_read..];
|
||||
if (remaining.len < 4) {
|
||||
var n = name_result.name;
|
||||
n.deinit();
|
||||
return error.BufferTooSmall;
|
||||
}
|
||||
|
||||
const qtype = std.mem.readInt(u16, remaining[0..2], .big);
|
||||
const qclass = std.mem.readInt(u16, remaining[2..4], .big);
|
||||
|
||||
return ParseResult{
|
||||
.question = Question{
|
||||
.name = name_result.name,
|
||||
.qtype = @enumFromInt(qtype),
|
||||
.qclass = @enumFromInt(qclass),
|
||||
},
|
||||
.bytes_read = name_result.bytes_read + 4,
|
||||
};
|
||||
}
|
||||
|
||||
/// Encode the question into a buffer
|
||||
pub fn encode(self: Question, buffer: []u8) ParseError!usize {
|
||||
var offset: usize = 0;
|
||||
|
||||
// Encode name
|
||||
const name_len = try self.name.encode(buffer[offset..]);
|
||||
offset += name_len;
|
||||
|
||||
if (buffer.len < offset + 4) {
|
||||
return error.BufferTooSmall;
|
||||
}
|
||||
|
||||
// Encode type and class
|
||||
std.mem.writeInt(u16, buffer[offset..][0..2], @intFromEnum(self.qtype), .big);
|
||||
offset += 2;
|
||||
std.mem.writeInt(u16, buffer[offset..][0..2], @intFromEnum(self.qclass), .big);
|
||||
offset += 2;
|
||||
|
||||
return offset;
|
||||
}
|
||||
|
||||
/// Get the encoded length of the question
|
||||
pub fn encodedLen(self: Question) usize {
|
||||
return self.name.encodedLen() + 4; // name + qtype (2) + qclass (2)
|
||||
}
|
||||
|
||||
/// Free the question's memory
|
||||
pub fn deinit(self: *Question) void {
|
||||
self.name.deinit();
|
||||
}
|
||||
|
||||
/// Create a copy of the question
|
||||
pub fn clone(self: Question, allocator: Allocator) !Question {
|
||||
const labels = try allocator.dupe(u8, self.name.labels);
|
||||
return Question{
|
||||
.name = Name{
|
||||
.labels = labels,
|
||||
.allocator = allocator,
|
||||
},
|
||||
.qtype = self.qtype,
|
||||
.qclass = self.qclass,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
test "parse simple question" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
// www.google.com A IN
|
||||
const buffer = [_]u8{
|
||||
0x03, 'w', 'w', 'w', // www
|
||||
0x06, 'g', 'o', 'o', 'g', 'l', 'e', // google
|
||||
0x03, 'c', 'o', 'm', // com
|
||||
0x00, // null terminator
|
||||
0x00, 0x01, // TYPE = A
|
||||
0x00, 0x01, // CLASS = IN
|
||||
};
|
||||
|
||||
const result = try Question.parse(&buffer, &buffer, allocator);
|
||||
defer {
|
||||
var q = result.question;
|
||||
q.deinit();
|
||||
}
|
||||
|
||||
try testing.expectEqual(@as(usize, 20), result.bytes_read);
|
||||
try testing.expectEqual(types.QType.A, result.question.qtype);
|
||||
try testing.expectEqual(types.QClass.IN, result.question.qclass);
|
||||
|
||||
const name_str = try result.question.name.toString(allocator);
|
||||
defer allocator.free(name_str);
|
||||
try testing.expectEqualStrings("www.google.com", name_str);
|
||||
}
|
||||
|
||||
test "parse AAAA question" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
// example.com AAAA IN
|
||||
const buffer = [_]u8{
|
||||
0x07, 'e', 'x', 'a', 'm', 'p', 'l', 'e', // example
|
||||
0x03, 'c', 'o', 'm', // com
|
||||
0x00, // null terminator
|
||||
0x00, 0x1C, // TYPE = AAAA (28)
|
||||
0x00, 0x01, // CLASS = IN
|
||||
};
|
||||
|
||||
const result = try Question.parse(&buffer, &buffer, allocator);
|
||||
defer {
|
||||
var q = result.question;
|
||||
q.deinit();
|
||||
}
|
||||
|
||||
try testing.expectEqual(types.QType.AAAA, result.question.qtype);
|
||||
try testing.expectEqual(types.QClass.IN, result.question.qclass);
|
||||
}
|
||||
|
||||
test "encode and decode question roundtrip" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var name = try Name.fromString("test.example.org", allocator);
|
||||
errdefer name.deinit();
|
||||
|
||||
const original = Question{
|
||||
.name = name,
|
||||
.qtype = types.QType.MX,
|
||||
.qclass = types.QClass.IN,
|
||||
};
|
||||
defer {
|
||||
var q = @as(Question, original);
|
||||
q.deinit();
|
||||
}
|
||||
|
||||
var buffer: [256]u8 = undefined;
|
||||
const encoded_len = try original.encode(&buffer);
|
||||
|
||||
const result = try Question.parse(buffer[0..encoded_len], buffer[0..encoded_len], allocator);
|
||||
defer {
|
||||
var q = result.question;
|
||||
q.deinit();
|
||||
}
|
||||
|
||||
try testing.expectEqual(original.qtype, result.question.qtype);
|
||||
try testing.expectEqual(original.qclass, result.question.qclass);
|
||||
try testing.expect(original.name.eql(result.question.name));
|
||||
}
|
||||
|
||||
test "question encoded length" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var name = try Name.fromString("www.example.com", allocator);
|
||||
errdefer name.deinit();
|
||||
|
||||
const q = Question{
|
||||
.name = name,
|
||||
.qtype = types.QType.A,
|
||||
.qclass = types.QClass.IN,
|
||||
};
|
||||
defer {
|
||||
var question = @as(Question, q);
|
||||
question.deinit();
|
||||
}
|
||||
|
||||
// www(1+3) + example(1+7) + com(1+3) + null(1) + type(2) + class(2) = 21
|
||||
try testing.expectEqual(@as(usize, 21), q.encodedLen());
|
||||
}
|
||||
@@ -0,0 +1,983 @@
|
||||
const std = @import("std");
|
||||
const types = @import("types.zig");
|
||||
const Name = @import("name.zig").Name;
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
/// Resource Record Data (tagged union)
|
||||
pub const RData = union(enum) {
|
||||
/// A record - IPv4 address (4 bytes)
|
||||
a: [4]u8,
|
||||
/// AAAA record - IPv6 address (16 bytes)
|
||||
aaaa: [16]u8,
|
||||
/// CNAME record - canonical name
|
||||
cname: Name,
|
||||
/// NS record - name server
|
||||
ns: Name,
|
||||
/// PTR record - pointer
|
||||
ptr: Name,
|
||||
/// MX record - mail exchange
|
||||
mx: struct {
|
||||
preference: u16,
|
||||
exchange: Name,
|
||||
},
|
||||
/// TXT record - text strings
|
||||
txt: []const []const u8,
|
||||
/// SOA record - start of authority
|
||||
soa: struct {
|
||||
mname: Name,
|
||||
rname: Name,
|
||||
serial: u32,
|
||||
refresh: u32,
|
||||
retry: u32,
|
||||
expire: u32,
|
||||
minimum: u32,
|
||||
},
|
||||
/// SRV record - service locator
|
||||
srv: struct {
|
||||
priority: u16,
|
||||
weight: u16,
|
||||
port: u16,
|
||||
target: Name,
|
||||
},
|
||||
/// OPT record (EDNS) - stored as raw data
|
||||
opt: []const u8,
|
||||
|
||||
// DNSSEC record types
|
||||
|
||||
/// DNSKEY record - public key for DNSSEC
|
||||
dnskey: struct {
|
||||
flags: u16, // Zone key flag (bit 7), SEP flag (bit 15)
|
||||
protocol: u8, // Must be 3
|
||||
algorithm: u8, // DNSSEC algorithm number
|
||||
public_key: []const u8,
|
||||
},
|
||||
|
||||
/// DS record - Delegation Signer
|
||||
ds: struct {
|
||||
key_tag: u16,
|
||||
algorithm: u8,
|
||||
digest_type: u8,
|
||||
digest: []const u8,
|
||||
},
|
||||
|
||||
/// RRSIG record - signature over RRset
|
||||
rrsig: struct {
|
||||
type_covered: u16,
|
||||
algorithm: u8,
|
||||
labels: u8,
|
||||
original_ttl: u32,
|
||||
signature_expiration: u32,
|
||||
signature_inception: u32,
|
||||
key_tag: u16,
|
||||
signer_name: Name,
|
||||
signature: []const u8,
|
||||
},
|
||||
|
||||
/// NSEC record - authenticated denial of existence
|
||||
nsec: struct {
|
||||
next_domain: Name,
|
||||
type_bitmap: []const u8,
|
||||
},
|
||||
|
||||
/// NSEC3 record - hashed authenticated denial
|
||||
nsec3: struct {
|
||||
hash_algorithm: u8,
|
||||
flags: u8,
|
||||
iterations: u16,
|
||||
salt: []const u8,
|
||||
next_hashed_owner: []const u8,
|
||||
type_bitmap: []const u8,
|
||||
},
|
||||
|
||||
/// NSEC3PARAM record - NSEC3 parameters
|
||||
nsec3param: struct {
|
||||
hash_algorithm: u8,
|
||||
flags: u8,
|
||||
iterations: u16,
|
||||
salt: []const u8,
|
||||
},
|
||||
|
||||
/// Unknown/raw record data
|
||||
raw: []const u8,
|
||||
|
||||
pub fn deinit(self: *RData, allocator: Allocator) void {
|
||||
switch (self.*) {
|
||||
.cname => |*name| name.deinit(),
|
||||
.ns => |*name| name.deinit(),
|
||||
.ptr => |*name| name.deinit(),
|
||||
.mx => |*mx| mx.exchange.deinit(),
|
||||
.txt => |txt| {
|
||||
for (txt) |s| {
|
||||
allocator.free(s);
|
||||
}
|
||||
allocator.free(txt);
|
||||
},
|
||||
.soa => |*soa| {
|
||||
soa.mname.deinit();
|
||||
soa.rname.deinit();
|
||||
},
|
||||
.srv => |*srv| srv.target.deinit(),
|
||||
.opt => |data| allocator.free(data),
|
||||
// DNSSEC types
|
||||
.dnskey => |*dk| allocator.free(dk.public_key),
|
||||
.ds => |*ds| allocator.free(ds.digest),
|
||||
.rrsig => |*rrsig| {
|
||||
rrsig.signer_name.deinit();
|
||||
allocator.free(rrsig.signature);
|
||||
},
|
||||
.nsec => |*nsec| {
|
||||
nsec.next_domain.deinit();
|
||||
allocator.free(nsec.type_bitmap);
|
||||
},
|
||||
.nsec3 => |*nsec3| {
|
||||
allocator.free(nsec3.salt);
|
||||
allocator.free(nsec3.next_hashed_owner);
|
||||
allocator.free(nsec3.type_bitmap);
|
||||
},
|
||||
.nsec3param => |*np| allocator.free(np.salt),
|
||||
.raw => |data| allocator.free(data),
|
||||
.a, .aaaa => {},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/// DNS Resource Record
|
||||
/// Format:
|
||||
/// +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|
||||
/// | NAME |
|
||||
/// +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|
||||
/// | TYPE |
|
||||
/// +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|
||||
/// | CLASS |
|
||||
/// +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|
||||
/// | TTL |
|
||||
/// | |
|
||||
/// +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|
||||
/// | RDLENGTH |
|
||||
/// +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|
||||
/// | RDATA |
|
||||
/// +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|
||||
pub const ResourceRecord = struct {
|
||||
name: Name,
|
||||
rtype: types.QType,
|
||||
class: types.QClass,
|
||||
ttl: u32,
|
||||
rdata: RData,
|
||||
|
||||
pub const ParseError = error{
|
||||
BufferTooSmall,
|
||||
InvalidLabel,
|
||||
NameTooLong,
|
||||
LabelTooLong,
|
||||
CompressionLoop,
|
||||
InvalidPointer,
|
||||
OutOfMemory,
|
||||
InvalidRData,
|
||||
};
|
||||
|
||||
pub const ParseResult = struct {
|
||||
record: ResourceRecord,
|
||||
bytes_read: usize,
|
||||
};
|
||||
|
||||
/// Parse a resource record from a buffer
|
||||
pub fn parse(buffer: []const u8, packet_start: []const u8, allocator: Allocator) ParseError!ParseResult {
|
||||
// Parse the name
|
||||
const name_result = try Name.parse(buffer, packet_start, allocator);
|
||||
errdefer {
|
||||
var n = name_result.name;
|
||||
n.deinit();
|
||||
}
|
||||
|
||||
var offset = name_result.bytes_read;
|
||||
const remaining = buffer[offset..];
|
||||
|
||||
if (remaining.len < 10) {
|
||||
var n = name_result.name;
|
||||
n.deinit();
|
||||
return error.BufferTooSmall;
|
||||
}
|
||||
|
||||
const rtype_val = std.mem.readInt(u16, remaining[0..2], .big);
|
||||
const class_val = std.mem.readInt(u16, remaining[2..4], .big);
|
||||
const ttl = std.mem.readInt(u32, remaining[4..8], .big);
|
||||
const rdlength = std.mem.readInt(u16, remaining[8..10], .big);
|
||||
|
||||
offset += 10;
|
||||
|
||||
const rdata_buf = buffer[offset..];
|
||||
if (rdata_buf.len < rdlength) {
|
||||
var n = name_result.name;
|
||||
n.deinit();
|
||||
return error.BufferTooSmall;
|
||||
}
|
||||
|
||||
const rtype: types.QType = @enumFromInt(rtype_val);
|
||||
const rdata = try parseRData(rtype, rdata_buf[0..rdlength], packet_start, allocator);
|
||||
errdefer {
|
||||
var rd = rdata;
|
||||
rd.deinit(allocator);
|
||||
}
|
||||
|
||||
return ParseResult{
|
||||
.record = ResourceRecord{
|
||||
.name = name_result.name,
|
||||
.rtype = rtype,
|
||||
.class = @enumFromInt(class_val),
|
||||
.ttl = ttl,
|
||||
.rdata = rdata,
|
||||
},
|
||||
.bytes_read = offset + rdlength,
|
||||
};
|
||||
}
|
||||
|
||||
fn parseRData(rtype: types.QType, data: []const u8, packet_start: []const u8, allocator: Allocator) ParseError!RData {
|
||||
switch (rtype) {
|
||||
.A => {
|
||||
if (data.len != 4) return error.InvalidRData;
|
||||
return RData{ .a = data[0..4].* };
|
||||
},
|
||||
.AAAA => {
|
||||
if (data.len != 16) return error.InvalidRData;
|
||||
return RData{ .aaaa = data[0..16].* };
|
||||
},
|
||||
.CNAME => {
|
||||
const name_result = try Name.parse(data, packet_start, allocator);
|
||||
return RData{ .cname = name_result.name };
|
||||
},
|
||||
.NS => {
|
||||
const name_result = try Name.parse(data, packet_start, allocator);
|
||||
return RData{ .ns = name_result.name };
|
||||
},
|
||||
.PTR => {
|
||||
const name_result = try Name.parse(data, packet_start, allocator);
|
||||
return RData{ .ptr = name_result.name };
|
||||
},
|
||||
.MX => {
|
||||
if (data.len < 3) return error.InvalidRData;
|
||||
const preference = std.mem.readInt(u16, data[0..2], .big);
|
||||
const name_result = try Name.parse(data[2..], packet_start, allocator);
|
||||
return RData{
|
||||
.mx = .{
|
||||
.preference = preference,
|
||||
.exchange = name_result.name,
|
||||
},
|
||||
};
|
||||
},
|
||||
.TXT => {
|
||||
var strings = std.ArrayListUnmanaged([]const u8){};
|
||||
errdefer {
|
||||
for (strings.items) |s| {
|
||||
allocator.free(s);
|
||||
}
|
||||
strings.deinit(allocator);
|
||||
}
|
||||
|
||||
var pos: usize = 0;
|
||||
while (pos < data.len) {
|
||||
const str_len = data[pos];
|
||||
pos += 1;
|
||||
if (pos + str_len > data.len) return error.InvalidRData;
|
||||
const str = try allocator.dupe(u8, data[pos .. pos + str_len]);
|
||||
try strings.append(allocator, str);
|
||||
pos += str_len;
|
||||
}
|
||||
|
||||
return RData{ .txt = try strings.toOwnedSlice(allocator) };
|
||||
},
|
||||
.SOA => {
|
||||
var offset: usize = 0;
|
||||
|
||||
const mname_result = try Name.parse(data, packet_start, allocator);
|
||||
errdefer {
|
||||
var n = mname_result.name;
|
||||
n.deinit();
|
||||
}
|
||||
offset += mname_result.bytes_read;
|
||||
|
||||
const rname_result = try Name.parse(data[offset..], packet_start, allocator);
|
||||
errdefer {
|
||||
var n = rname_result.name;
|
||||
n.deinit();
|
||||
}
|
||||
offset += rname_result.bytes_read;
|
||||
|
||||
if (data.len < offset + 20) return error.InvalidRData;
|
||||
|
||||
const serial = std.mem.readInt(u32, data[offset..][0..4], .big);
|
||||
const refresh = std.mem.readInt(u32, data[offset + 4 ..][0..4], .big);
|
||||
const retry = std.mem.readInt(u32, data[offset + 8 ..][0..4], .big);
|
||||
const expire = std.mem.readInt(u32, data[offset + 12 ..][0..4], .big);
|
||||
const minimum = std.mem.readInt(u32, data[offset + 16 ..][0..4], .big);
|
||||
|
||||
return RData{
|
||||
.soa = .{
|
||||
.mname = mname_result.name,
|
||||
.rname = rname_result.name,
|
||||
.serial = serial,
|
||||
.refresh = refresh,
|
||||
.retry = retry,
|
||||
.expire = expire,
|
||||
.minimum = minimum,
|
||||
},
|
||||
};
|
||||
},
|
||||
.SRV => {
|
||||
if (data.len < 7) return error.InvalidRData;
|
||||
const priority = std.mem.readInt(u16, data[0..2], .big);
|
||||
const weight = std.mem.readInt(u16, data[2..4], .big);
|
||||
const port = std.mem.readInt(u16, data[4..6], .big);
|
||||
const target_result = try Name.parse(data[6..], packet_start, allocator);
|
||||
return RData{
|
||||
.srv = .{
|
||||
.priority = priority,
|
||||
.weight = weight,
|
||||
.port = port,
|
||||
.target = target_result.name,
|
||||
},
|
||||
};
|
||||
},
|
||||
.OPT => {
|
||||
const opt_data = try allocator.dupe(u8, data);
|
||||
return RData{ .opt = opt_data };
|
||||
},
|
||||
// DNSSEC record types
|
||||
.DNSKEY => {
|
||||
if (data.len < 4) return error.InvalidRData;
|
||||
const flags = std.mem.readInt(u16, data[0..2], .big);
|
||||
const protocol = data[2];
|
||||
const algorithm = data[3];
|
||||
const public_key = try allocator.dupe(u8, data[4..]);
|
||||
return RData{ .dnskey = .{
|
||||
.flags = flags,
|
||||
.protocol = protocol,
|
||||
.algorithm = algorithm,
|
||||
.public_key = public_key,
|
||||
} };
|
||||
},
|
||||
.DS => {
|
||||
if (data.len < 4) return error.InvalidRData;
|
||||
const key_tag = std.mem.readInt(u16, data[0..2], .big);
|
||||
const algorithm = data[2];
|
||||
const digest_type = data[3];
|
||||
const digest = try allocator.dupe(u8, data[4..]);
|
||||
return RData{ .ds = .{
|
||||
.key_tag = key_tag,
|
||||
.algorithm = algorithm,
|
||||
.digest_type = digest_type,
|
||||
.digest = digest,
|
||||
} };
|
||||
},
|
||||
.RRSIG => {
|
||||
// RRSIG format: type_covered(2) + algorithm(1) + labels(1) + original_ttl(4) +
|
||||
// sig_expiration(4) + sig_inception(4) + key_tag(2) + signer_name + signature
|
||||
if (data.len < 18) return error.InvalidRData;
|
||||
const type_covered = std.mem.readInt(u16, data[0..2], .big);
|
||||
const algorithm = data[2];
|
||||
const labels = data[3];
|
||||
const original_ttl = std.mem.readInt(u32, data[4..8], .big);
|
||||
const signature_expiration = std.mem.readInt(u32, data[8..12], .big);
|
||||
const signature_inception = std.mem.readInt(u32, data[12..16], .big);
|
||||
const key_tag = std.mem.readInt(u16, data[16..18], .big);
|
||||
|
||||
const name_result = try Name.parse(data[18..], packet_start, allocator);
|
||||
errdefer {
|
||||
var n = name_result.name;
|
||||
n.deinit();
|
||||
}
|
||||
|
||||
const sig_start = 18 + name_result.bytes_read;
|
||||
const signature = try allocator.dupe(u8, data[sig_start..]);
|
||||
|
||||
return RData{ .rrsig = .{
|
||||
.type_covered = type_covered,
|
||||
.algorithm = algorithm,
|
||||
.labels = labels,
|
||||
.original_ttl = original_ttl,
|
||||
.signature_expiration = signature_expiration,
|
||||
.signature_inception = signature_inception,
|
||||
.key_tag = key_tag,
|
||||
.signer_name = name_result.name,
|
||||
.signature = signature,
|
||||
} };
|
||||
},
|
||||
.NSEC => {
|
||||
const name_result = try Name.parse(data, packet_start, allocator);
|
||||
errdefer {
|
||||
var n = name_result.name;
|
||||
n.deinit();
|
||||
}
|
||||
const type_bitmap = try allocator.dupe(u8, data[name_result.bytes_read..]);
|
||||
return RData{ .nsec = .{
|
||||
.next_domain = name_result.name,
|
||||
.type_bitmap = type_bitmap,
|
||||
} };
|
||||
},
|
||||
.NSEC3 => {
|
||||
// NSEC3 format: hash_algorithm(1) + flags(1) + iterations(2) + salt_length(1) +
|
||||
// salt + hash_length(1) + hash + type_bitmap
|
||||
if (data.len < 5) return error.InvalidRData;
|
||||
const hash_algorithm = data[0];
|
||||
const flags = data[1];
|
||||
const iterations = std.mem.readInt(u16, data[2..4], .big);
|
||||
const salt_length = data[4];
|
||||
|
||||
var offset: usize = 5;
|
||||
if (data.len < offset + salt_length) return error.InvalidRData;
|
||||
const salt = try allocator.dupe(u8, data[offset .. offset + salt_length]);
|
||||
errdefer allocator.free(salt);
|
||||
offset += salt_length;
|
||||
|
||||
if (data.len < offset + 1) return error.InvalidRData;
|
||||
const hash_length = data[offset];
|
||||
offset += 1;
|
||||
|
||||
if (data.len < offset + hash_length) return error.InvalidRData;
|
||||
const next_hashed_owner = try allocator.dupe(u8, data[offset .. offset + hash_length]);
|
||||
errdefer allocator.free(next_hashed_owner);
|
||||
offset += hash_length;
|
||||
|
||||
const type_bitmap = try allocator.dupe(u8, data[offset..]);
|
||||
|
||||
return RData{ .nsec3 = .{
|
||||
.hash_algorithm = hash_algorithm,
|
||||
.flags = flags,
|
||||
.iterations = iterations,
|
||||
.salt = salt,
|
||||
.next_hashed_owner = next_hashed_owner,
|
||||
.type_bitmap = type_bitmap,
|
||||
} };
|
||||
},
|
||||
.NSEC3PARAM => {
|
||||
if (data.len < 5) return error.InvalidRData;
|
||||
const hash_algorithm = data[0];
|
||||
const flags = data[1];
|
||||
const iterations = std.mem.readInt(u16, data[2..4], .big);
|
||||
const salt_length = data[4];
|
||||
|
||||
if (data.len < 5 + salt_length) return error.InvalidRData;
|
||||
const salt = try allocator.dupe(u8, data[5 .. 5 + salt_length]);
|
||||
|
||||
return RData{ .nsec3param = .{
|
||||
.hash_algorithm = hash_algorithm,
|
||||
.flags = flags,
|
||||
.iterations = iterations,
|
||||
.salt = salt,
|
||||
} };
|
||||
},
|
||||
else => {
|
||||
const raw_data = try allocator.dupe(u8, data);
|
||||
return RData{ .raw = raw_data };
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode the resource record into a buffer
|
||||
pub fn encode(self: ResourceRecord, buffer: []u8, allocator: Allocator) ParseError!usize {
|
||||
var offset: usize = 0;
|
||||
|
||||
// Encode name
|
||||
const name_len = try self.name.encode(buffer[offset..]);
|
||||
offset += name_len;
|
||||
|
||||
if (buffer.len < offset + 10) {
|
||||
return error.BufferTooSmall;
|
||||
}
|
||||
|
||||
// Encode type, class, ttl
|
||||
std.mem.writeInt(u16, buffer[offset..][0..2], @intFromEnum(self.rtype), .big);
|
||||
offset += 2;
|
||||
std.mem.writeInt(u16, buffer[offset..][0..2], @intFromEnum(self.class), .big);
|
||||
offset += 2;
|
||||
std.mem.writeInt(u32, buffer[offset..][0..4], self.ttl, .big);
|
||||
offset += 4;
|
||||
|
||||
// Reserve space for rdlength
|
||||
const rdlength_offset = offset;
|
||||
offset += 2;
|
||||
|
||||
// Encode rdata
|
||||
const rdata_len = try self.encodeRData(buffer[offset..], allocator);
|
||||
offset += rdata_len;
|
||||
|
||||
// Write rdlength
|
||||
std.mem.writeInt(u16, buffer[rdlength_offset..][0..2], @intCast(rdata_len), .big);
|
||||
|
||||
return offset;
|
||||
}
|
||||
|
||||
fn encodeRData(self: ResourceRecord, buffer: []u8, allocator: Allocator) ParseError!usize {
|
||||
_ = allocator;
|
||||
switch (self.rdata) {
|
||||
.a => |addr| {
|
||||
if (buffer.len < 4) return error.BufferTooSmall;
|
||||
@memcpy(buffer[0..4], &addr);
|
||||
return 4;
|
||||
},
|
||||
.aaaa => |addr| {
|
||||
if (buffer.len < 16) return error.BufferTooSmall;
|
||||
@memcpy(buffer[0..16], &addr);
|
||||
return 16;
|
||||
},
|
||||
.cname => |name| {
|
||||
return name.encode(buffer);
|
||||
},
|
||||
.ns => |name| {
|
||||
return name.encode(buffer);
|
||||
},
|
||||
.ptr => |name| {
|
||||
return name.encode(buffer);
|
||||
},
|
||||
.mx => |mx| {
|
||||
if (buffer.len < 2) return error.BufferTooSmall;
|
||||
std.mem.writeInt(u16, buffer[0..2], mx.preference, .big);
|
||||
const name_len = try mx.exchange.encode(buffer[2..]);
|
||||
return 2 + name_len;
|
||||
},
|
||||
.txt => |strings| {
|
||||
var offset: usize = 0;
|
||||
for (strings) |s| {
|
||||
if (buffer.len < offset + 1 + s.len) return error.BufferTooSmall;
|
||||
buffer[offset] = @intCast(s.len);
|
||||
offset += 1;
|
||||
@memcpy(buffer[offset .. offset + s.len], s);
|
||||
offset += s.len;
|
||||
}
|
||||
return offset;
|
||||
},
|
||||
.soa => |soa| {
|
||||
var offset: usize = 0;
|
||||
offset += try soa.mname.encode(buffer[offset..]);
|
||||
offset += try soa.rname.encode(buffer[offset..]);
|
||||
if (buffer.len < offset + 20) return error.BufferTooSmall;
|
||||
std.mem.writeInt(u32, buffer[offset..][0..4], soa.serial, .big);
|
||||
offset += 4;
|
||||
std.mem.writeInt(u32, buffer[offset..][0..4], soa.refresh, .big);
|
||||
offset += 4;
|
||||
std.mem.writeInt(u32, buffer[offset..][0..4], soa.retry, .big);
|
||||
offset += 4;
|
||||
std.mem.writeInt(u32, buffer[offset..][0..4], soa.expire, .big);
|
||||
offset += 4;
|
||||
std.mem.writeInt(u32, buffer[offset..][0..4], soa.minimum, .big);
|
||||
offset += 4;
|
||||
return offset;
|
||||
},
|
||||
.srv => |srv| {
|
||||
if (buffer.len < 6) return error.BufferTooSmall;
|
||||
std.mem.writeInt(u16, buffer[0..2], srv.priority, .big);
|
||||
std.mem.writeInt(u16, buffer[2..4], srv.weight, .big);
|
||||
std.mem.writeInt(u16, buffer[4..6], srv.port, .big);
|
||||
const name_len = try srv.target.encode(buffer[6..]);
|
||||
return 6 + name_len;
|
||||
},
|
||||
.opt => |data| {
|
||||
if (buffer.len < data.len) return error.BufferTooSmall;
|
||||
@memcpy(buffer[0..data.len], data);
|
||||
return data.len;
|
||||
},
|
||||
// DNSSEC types
|
||||
.dnskey => |dk| {
|
||||
const min_len = 4 + dk.public_key.len;
|
||||
if (buffer.len < min_len) return error.BufferTooSmall;
|
||||
std.mem.writeInt(u16, buffer[0..2], dk.flags, .big);
|
||||
buffer[2] = dk.protocol;
|
||||
buffer[3] = dk.algorithm;
|
||||
@memcpy(buffer[4 .. 4 + dk.public_key.len], dk.public_key);
|
||||
return min_len;
|
||||
},
|
||||
.ds => |ds| {
|
||||
const min_len = 4 + ds.digest.len;
|
||||
if (buffer.len < min_len) return error.BufferTooSmall;
|
||||
std.mem.writeInt(u16, buffer[0..2], ds.key_tag, .big);
|
||||
buffer[2] = ds.algorithm;
|
||||
buffer[3] = ds.digest_type;
|
||||
@memcpy(buffer[4 .. 4 + ds.digest.len], ds.digest);
|
||||
return min_len;
|
||||
},
|
||||
.rrsig => |rrsig| {
|
||||
if (buffer.len < 18) return error.BufferTooSmall;
|
||||
std.mem.writeInt(u16, buffer[0..2], rrsig.type_covered, .big);
|
||||
buffer[2] = rrsig.algorithm;
|
||||
buffer[3] = rrsig.labels;
|
||||
std.mem.writeInt(u32, buffer[4..8], rrsig.original_ttl, .big);
|
||||
std.mem.writeInt(u32, buffer[8..12], rrsig.signature_expiration, .big);
|
||||
std.mem.writeInt(u32, buffer[12..16], rrsig.signature_inception, .big);
|
||||
std.mem.writeInt(u16, buffer[16..18], rrsig.key_tag, .big);
|
||||
const name_len = try rrsig.signer_name.encode(buffer[18..]);
|
||||
const sig_start = 18 + name_len;
|
||||
if (buffer.len < sig_start + rrsig.signature.len) return error.BufferTooSmall;
|
||||
@memcpy(buffer[sig_start .. sig_start + rrsig.signature.len], rrsig.signature);
|
||||
return sig_start + rrsig.signature.len;
|
||||
},
|
||||
.nsec => |nsec| {
|
||||
const name_len = try nsec.next_domain.encode(buffer);
|
||||
if (buffer.len < name_len + nsec.type_bitmap.len) return error.BufferTooSmall;
|
||||
@memcpy(buffer[name_len .. name_len + nsec.type_bitmap.len], nsec.type_bitmap);
|
||||
return name_len + nsec.type_bitmap.len;
|
||||
},
|
||||
.nsec3 => |nsec3| {
|
||||
const min_len = 5 + nsec3.salt.len + 1 + nsec3.next_hashed_owner.len + nsec3.type_bitmap.len;
|
||||
if (buffer.len < min_len) return error.BufferTooSmall;
|
||||
buffer[0] = nsec3.hash_algorithm;
|
||||
buffer[1] = nsec3.flags;
|
||||
std.mem.writeInt(u16, buffer[2..4], nsec3.iterations, .big);
|
||||
buffer[4] = @intCast(nsec3.salt.len);
|
||||
var offset: usize = 5;
|
||||
@memcpy(buffer[offset .. offset + nsec3.salt.len], nsec3.salt);
|
||||
offset += nsec3.salt.len;
|
||||
buffer[offset] = @intCast(nsec3.next_hashed_owner.len);
|
||||
offset += 1;
|
||||
@memcpy(buffer[offset .. offset + nsec3.next_hashed_owner.len], nsec3.next_hashed_owner);
|
||||
offset += nsec3.next_hashed_owner.len;
|
||||
@memcpy(buffer[offset .. offset + nsec3.type_bitmap.len], nsec3.type_bitmap);
|
||||
return offset + nsec3.type_bitmap.len;
|
||||
},
|
||||
.nsec3param => |np| {
|
||||
const min_len = 5 + np.salt.len;
|
||||
if (buffer.len < min_len) return error.BufferTooSmall;
|
||||
buffer[0] = np.hash_algorithm;
|
||||
buffer[1] = np.flags;
|
||||
std.mem.writeInt(u16, buffer[2..4], np.iterations, .big);
|
||||
buffer[4] = @intCast(np.salt.len);
|
||||
@memcpy(buffer[5 .. 5 + np.salt.len], np.salt);
|
||||
return min_len;
|
||||
},
|
||||
.raw => |data| {
|
||||
if (buffer.len < data.len) return error.BufferTooSmall;
|
||||
@memcpy(buffer[0..data.len], data);
|
||||
return data.len;
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Free the record's memory
|
||||
pub fn deinit(self: *ResourceRecord, allocator: Allocator) void {
|
||||
self.name.deinit();
|
||||
self.rdata.deinit(allocator);
|
||||
}
|
||||
|
||||
/// Get the IPv4 address if this is an A record
|
||||
pub fn getIPv4(self: ResourceRecord) ?[4]u8 {
|
||||
return switch (self.rdata) {
|
||||
.a => |addr| addr,
|
||||
else => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// Get the IPv6 address if this is an AAAA record
|
||||
pub fn getIPv6(self: ResourceRecord) ?[16]u8 {
|
||||
return switch (self.rdata) {
|
||||
.aaaa => |addr| addr,
|
||||
else => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// Get the CNAME target if this is a CNAME record
|
||||
pub fn getCname(self: ResourceRecord) ?Name {
|
||||
return switch (self.rdata) {
|
||||
.cname => |name| name,
|
||||
else => null,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/// Create an A record
|
||||
pub fn createARecord(name: Name, ttl: u32, ip: [4]u8) ResourceRecord {
|
||||
return ResourceRecord{
|
||||
.name = name,
|
||||
.rtype = types.QType.A,
|
||||
.class = types.QClass.IN,
|
||||
.ttl = ttl,
|
||||
.rdata = RData{ .a = ip },
|
||||
};
|
||||
}
|
||||
|
||||
/// Create an AAAA record
|
||||
pub fn createAAAARecord(name: Name, ttl: u32, ip: [16]u8) ResourceRecord {
|
||||
return ResourceRecord{
|
||||
.name = name,
|
||||
.rtype = types.QType.AAAA,
|
||||
.class = types.QClass.IN,
|
||||
.ttl = ttl,
|
||||
.rdata = RData{ .aaaa = ip },
|
||||
};
|
||||
}
|
||||
|
||||
/// Create a CNAME record
|
||||
pub fn createCnameRecord(name: Name, ttl: u32, target: Name) ResourceRecord {
|
||||
return ResourceRecord{
|
||||
.name = name,
|
||||
.rtype = types.QType.CNAME,
|
||||
.class = types.QClass.IN,
|
||||
.ttl = ttl,
|
||||
.rdata = RData{ .cname = target },
|
||||
};
|
||||
}
|
||||
|
||||
test "parse A record" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
// example.com A 1.2.3.4 TTL=300
|
||||
const buffer = [_]u8{
|
||||
0x07, 'e', 'x', 'a', 'm', 'p', 'l', 'e', // example
|
||||
0x03, 'c', 'o', 'm', // com
|
||||
0x00, // null terminator
|
||||
0x00, 0x01, // TYPE = A
|
||||
0x00, 0x01, // CLASS = IN
|
||||
0x00, 0x00, 0x01, 0x2C, // TTL = 300
|
||||
0x00, 0x04, // RDLENGTH = 4
|
||||
0x01, 0x02, 0x03, 0x04, // RDATA = 1.2.3.4
|
||||
};
|
||||
|
||||
const result = try ResourceRecord.parse(&buffer, &buffer, allocator);
|
||||
defer {
|
||||
var r = result.record;
|
||||
r.deinit(allocator);
|
||||
}
|
||||
|
||||
try testing.expectEqual(types.QType.A, result.record.rtype);
|
||||
try testing.expectEqual(types.QClass.IN, result.record.class);
|
||||
try testing.expectEqual(@as(u32, 300), result.record.ttl);
|
||||
|
||||
const ip = result.record.getIPv4().?;
|
||||
try testing.expectEqual([4]u8{ 1, 2, 3, 4 }, ip);
|
||||
}
|
||||
|
||||
test "parse AAAA record" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
// example.com AAAA 2001:db8::1 TTL=600
|
||||
const buffer = [_]u8{
|
||||
0x07, 'e', 'x', 'a', 'm', 'p', 'l', 'e', // example
|
||||
0x03, 'c', 'o', 'm', // com
|
||||
0x00, // null terminator
|
||||
0x00, 0x1C, // TYPE = AAAA (28)
|
||||
0x00, 0x01, // CLASS = IN
|
||||
0x00, 0x00, 0x02, 0x58, // TTL = 600
|
||||
0x00, 0x10, // RDLENGTH = 16
|
||||
0x20, 0x01, 0x0d, 0xb8, 0x00, 0x00, 0x00, 0x00, // 2001:0db8:0000:0000:
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, // 0000:0000:0000:0001
|
||||
};
|
||||
|
||||
const result = try ResourceRecord.parse(&buffer, &buffer, allocator);
|
||||
defer {
|
||||
var r = result.record;
|
||||
r.deinit(allocator);
|
||||
}
|
||||
|
||||
try testing.expectEqual(types.QType.AAAA, result.record.rtype);
|
||||
try testing.expectEqual(@as(u32, 600), result.record.ttl);
|
||||
|
||||
const ip = result.record.getIPv6().?;
|
||||
try testing.expectEqual([16]u8{ 0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 }, ip);
|
||||
}
|
||||
|
||||
test "parse CNAME record" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
// www.example.com CNAME example.com TTL=3600
|
||||
const buffer = [_]u8{
|
||||
0x03, 'w', 'w', 'w', // www
|
||||
0x07, 'e', 'x', 'a', 'm', 'p', 'l', 'e', // example
|
||||
0x03, 'c', 'o', 'm', // com
|
||||
0x00, // null terminator
|
||||
0x00, 0x05, // TYPE = CNAME
|
||||
0x00, 0x01, // CLASS = IN
|
||||
0x00, 0x00, 0x0E, 0x10, // TTL = 3600
|
||||
0x00, 0x0D, // RDLENGTH = 13
|
||||
0x07, 'e', 'x', 'a', 'm', 'p', 'l', 'e', // example
|
||||
0x03, 'c', 'o', 'm', // com
|
||||
0x00, // null terminator
|
||||
};
|
||||
|
||||
const result = try ResourceRecord.parse(&buffer, &buffer, allocator);
|
||||
defer {
|
||||
var r = result.record;
|
||||
r.deinit(allocator);
|
||||
}
|
||||
|
||||
try testing.expectEqual(types.QType.CNAME, result.record.rtype);
|
||||
try testing.expectEqual(@as(u32, 3600), result.record.ttl);
|
||||
|
||||
const cname = result.record.getCname().?;
|
||||
const cname_str = try cname.toString(allocator);
|
||||
defer allocator.free(cname_str);
|
||||
try testing.expectEqualStrings("example.com", cname_str);
|
||||
}
|
||||
|
||||
test "parse TXT record" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
// example.com TXT "v=spf1 -all" TTL=300
|
||||
const buffer = [_]u8{
|
||||
0x07, 'e', 'x', 'a', 'm', 'p', 'l', 'e', // example
|
||||
0x03, 'c', 'o', 'm', // com
|
||||
0x00, // null terminator
|
||||
0x00, 0x10, // TYPE = TXT (16)
|
||||
0x00, 0x01, // CLASS = IN
|
||||
0x00, 0x00, 0x01, 0x2C, // TTL = 300
|
||||
0x00, 0x0C, // RDLENGTH = 12
|
||||
0x0B, // String length = 11
|
||||
'v', '=', 's', 'p', 'f', '1', ' ', '-', 'a', 'l', 'l',
|
||||
};
|
||||
|
||||
const result = try ResourceRecord.parse(&buffer, &buffer, allocator);
|
||||
defer {
|
||||
var r = result.record;
|
||||
r.deinit(allocator);
|
||||
}
|
||||
|
||||
try testing.expectEqual(types.QType.TXT, result.record.rtype);
|
||||
|
||||
switch (result.record.rdata) {
|
||||
.txt => |strings| {
|
||||
try testing.expectEqual(@as(usize, 1), strings.len);
|
||||
try testing.expectEqualStrings("v=spf1 -all", strings[0]);
|
||||
},
|
||||
else => try testing.expect(false),
|
||||
}
|
||||
}
|
||||
|
||||
test "create and encode A record" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var name = try Name.fromString("test.example.com", allocator);
|
||||
defer name.deinit();
|
||||
|
||||
const record = createARecord(name, 300, [4]u8{ 192, 168, 1, 1 });
|
||||
|
||||
var buffer: [256]u8 = undefined;
|
||||
const encoded_len = try record.encode(&buffer, allocator);
|
||||
|
||||
// Parse it back
|
||||
const result = try ResourceRecord.parse(buffer[0..encoded_len], buffer[0..encoded_len], allocator);
|
||||
defer {
|
||||
var r = result.record;
|
||||
r.deinit(allocator);
|
||||
}
|
||||
|
||||
try testing.expectEqual(types.QType.A, result.record.rtype);
|
||||
try testing.expectEqual(@as(u32, 300), result.record.ttl);
|
||||
try testing.expectEqual([4]u8{ 192, 168, 1, 1 }, result.record.getIPv4().?);
|
||||
}
|
||||
|
||||
test "parse DNSKEY record" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
// example.com DNSKEY 256 3 8 <public_key> TTL=3600
|
||||
const buffer = [_]u8{
|
||||
0x07, 'e', 'x', 'a', 'm', 'p', 'l', 'e', // example
|
||||
0x03, 'c', 'o', 'm', // com
|
||||
0x00, // null terminator
|
||||
0x00, 0x30, // TYPE = DNSKEY (48)
|
||||
0x00, 0x01, // CLASS = IN
|
||||
0x00, 0x00, 0x0E, 0x10, // TTL = 3600
|
||||
0x00, 0x08, // RDLENGTH = 8
|
||||
0x01, 0x00, // flags = 256 (Zone Key)
|
||||
0x03, // protocol = 3
|
||||
0x08, // algorithm = 8 (RSASHA256)
|
||||
0xDE, 0xAD, 0xBE, 0xEF, // public_key (dummy 4 bytes)
|
||||
};
|
||||
|
||||
const result = try ResourceRecord.parse(&buffer, &buffer, allocator);
|
||||
defer {
|
||||
var r = result.record;
|
||||
r.deinit(allocator);
|
||||
}
|
||||
|
||||
try testing.expectEqual(types.QType.DNSKEY, result.record.rtype);
|
||||
try testing.expectEqual(@as(u32, 3600), result.record.ttl);
|
||||
|
||||
switch (result.record.rdata) {
|
||||
.dnskey => |dk| {
|
||||
try testing.expectEqual(@as(u16, 256), dk.flags);
|
||||
try testing.expectEqual(@as(u8, 3), dk.protocol);
|
||||
try testing.expectEqual(@as(u8, 8), dk.algorithm);
|
||||
try testing.expectEqual(@as(usize, 4), dk.public_key.len);
|
||||
},
|
||||
else => try testing.expect(false),
|
||||
}
|
||||
}
|
||||
|
||||
test "parse DS record" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
// example.com DS 12345 8 2 <digest> TTL=86400
|
||||
const buffer = [_]u8{
|
||||
0x07, 'e', 'x', 'a', 'm', 'p', 'l', 'e', // example
|
||||
0x03, 'c', 'o', 'm', // com
|
||||
0x00, // null terminator
|
||||
0x00, 0x2B, // TYPE = DS (43)
|
||||
0x00, 0x01, // CLASS = IN
|
||||
0x00, 0x01, 0x51, 0x80, // TTL = 86400
|
||||
0x00, 0x08, // RDLENGTH = 8
|
||||
0x30, 0x39, // key_tag = 12345
|
||||
0x08, // algorithm = 8
|
||||
0x02, // digest_type = 2 (SHA-256)
|
||||
0xAB, 0xCD, 0xEF, 0x01, // digest (dummy 4 bytes)
|
||||
};
|
||||
|
||||
const result = try ResourceRecord.parse(&buffer, &buffer, allocator);
|
||||
defer {
|
||||
var r = result.record;
|
||||
r.deinit(allocator);
|
||||
}
|
||||
|
||||
try testing.expectEqual(types.QType.DS, result.record.rtype);
|
||||
|
||||
switch (result.record.rdata) {
|
||||
.ds => |ds| {
|
||||
try testing.expectEqual(@as(u16, 12345), ds.key_tag);
|
||||
try testing.expectEqual(@as(u8, 8), ds.algorithm);
|
||||
try testing.expectEqual(@as(u8, 2), ds.digest_type);
|
||||
try testing.expectEqual(@as(usize, 4), ds.digest.len);
|
||||
},
|
||||
else => try testing.expect(false),
|
||||
}
|
||||
}
|
||||
|
||||
test "parse NSEC3PARAM record" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
// example.com NSEC3PARAM 1 0 10 <salt> TTL=0
|
||||
const buffer = [_]u8{
|
||||
0x07, 'e', 'x', 'a', 'm', 'p', 'l', 'e', // example
|
||||
0x03, 'c', 'o', 'm', // com
|
||||
0x00, // null terminator
|
||||
0x00, 0x33, // TYPE = NSEC3PARAM (51)
|
||||
0x00, 0x01, // CLASS = IN
|
||||
0x00, 0x00, 0x00, 0x00, // TTL = 0
|
||||
0x00, 0x07, // RDLENGTH = 7
|
||||
0x01, // hash_algorithm = 1 (SHA-1)
|
||||
0x00, // flags = 0
|
||||
0x00, 0x0A, // iterations = 10
|
||||
0x02, // salt_length = 2
|
||||
0xAB, 0xCD, // salt
|
||||
};
|
||||
|
||||
const result = try ResourceRecord.parse(&buffer, &buffer, allocator);
|
||||
defer {
|
||||
var r = result.record;
|
||||
r.deinit(allocator);
|
||||
}
|
||||
|
||||
try testing.expectEqual(types.QType.NSEC3PARAM, result.record.rtype);
|
||||
|
||||
switch (result.record.rdata) {
|
||||
.nsec3param => |np| {
|
||||
try testing.expectEqual(@as(u8, 1), np.hash_algorithm);
|
||||
try testing.expectEqual(@as(u8, 0), np.flags);
|
||||
try testing.expectEqual(@as(u16, 10), np.iterations);
|
||||
try testing.expectEqual(@as(usize, 2), np.salt.len);
|
||||
},
|
||||
else => try testing.expect(false),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
const std = @import("std");
|
||||
|
||||
/// DNS Query/Response types (RFC 1035 + extensions)
|
||||
pub const QType = enum(u16) {
|
||||
A = 1,
|
||||
NS = 2,
|
||||
MD = 3, // obsolete
|
||||
MF = 4, // obsolete
|
||||
CNAME = 5,
|
||||
SOA = 6,
|
||||
MB = 7,
|
||||
MG = 8,
|
||||
MR = 9,
|
||||
NULL = 10,
|
||||
WKS = 11,
|
||||
PTR = 12,
|
||||
HINFO = 13,
|
||||
MINFO = 14,
|
||||
MX = 15,
|
||||
TXT = 16,
|
||||
RP = 17,
|
||||
AFSDB = 18,
|
||||
X25 = 19,
|
||||
ISDN = 20,
|
||||
RT = 21,
|
||||
NSAP = 22,
|
||||
NSAP_PTR = 23,
|
||||
SIG = 24,
|
||||
KEY = 25,
|
||||
PX = 26,
|
||||
GPOS = 27,
|
||||
AAAA = 28,
|
||||
LOC = 29,
|
||||
NXT = 30,
|
||||
EID = 31,
|
||||
NIMLOC = 32,
|
||||
SRV = 33,
|
||||
ATMA = 34,
|
||||
NAPTR = 35,
|
||||
KX = 36,
|
||||
CERT = 37,
|
||||
A6 = 38,
|
||||
DNAME = 39,
|
||||
SINK = 40,
|
||||
OPT = 41, // EDNS
|
||||
APL = 42,
|
||||
DS = 43, // DNSSEC
|
||||
SSHFP = 44,
|
||||
IPSECKEY = 45,
|
||||
RRSIG = 46, // DNSSEC
|
||||
NSEC = 47, // DNSSEC
|
||||
DNSKEY = 48, // DNSSEC
|
||||
DHCID = 49,
|
||||
NSEC3 = 50,
|
||||
NSEC3PARAM = 51,
|
||||
TLSA = 52,
|
||||
SMIMEA = 53,
|
||||
HIP = 55,
|
||||
NINFO = 56,
|
||||
RKEY = 57,
|
||||
TALINK = 58,
|
||||
CDS = 59,
|
||||
CDNSKEY = 60,
|
||||
OPENPGPKEY = 61,
|
||||
CSYNC = 62,
|
||||
ZONEMD = 63,
|
||||
SVCB = 64,
|
||||
HTTPS = 65,
|
||||
SPF = 99,
|
||||
UINFO = 100,
|
||||
UID = 101,
|
||||
GID = 102,
|
||||
UNSPEC = 103,
|
||||
NID = 104,
|
||||
L32 = 105,
|
||||
L64 = 106,
|
||||
LP = 107,
|
||||
EUI48 = 108,
|
||||
EUI64 = 109,
|
||||
TKEY = 249,
|
||||
TSIG = 250,
|
||||
IXFR = 251,
|
||||
AXFR = 252,
|
||||
MAILB = 253,
|
||||
MAILA = 254,
|
||||
ANY = 255,
|
||||
URI = 256,
|
||||
CAA = 257,
|
||||
AVC = 258,
|
||||
DOA = 259,
|
||||
AMTRELAY = 260,
|
||||
TA = 32768,
|
||||
DLV = 32769,
|
||||
_,
|
||||
|
||||
pub fn toString(self: QType) []const u8 {
|
||||
return switch (self) {
|
||||
.A => "A",
|
||||
.NS => "NS",
|
||||
.CNAME => "CNAME",
|
||||
.SOA => "SOA",
|
||||
.PTR => "PTR",
|
||||
.MX => "MX",
|
||||
.TXT => "TXT",
|
||||
.AAAA => "AAAA",
|
||||
.SRV => "SRV",
|
||||
.OPT => "OPT",
|
||||
// DNSSEC types
|
||||
.DS => "DS",
|
||||
.RRSIG => "RRSIG",
|
||||
.NSEC => "NSEC",
|
||||
.DNSKEY => "DNSKEY",
|
||||
.NSEC3 => "NSEC3",
|
||||
.NSEC3PARAM => "NSEC3PARAM",
|
||||
.CDS => "CDS",
|
||||
.CDNSKEY => "CDNSKEY",
|
||||
.ANY => "ANY",
|
||||
.HTTPS => "HTTPS",
|
||||
.SVCB => "SVCB",
|
||||
else => "UNKNOWN",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/// DNS Query Class
|
||||
pub const QClass = enum(u16) {
|
||||
IN = 1, // Internet
|
||||
CS = 2, // CSNET (obsolete)
|
||||
CH = 3, // Chaos
|
||||
HS = 4, // Hesiod
|
||||
NONE = 254,
|
||||
ANY = 255,
|
||||
_,
|
||||
|
||||
pub fn toString(self: QClass) []const u8 {
|
||||
return switch (self) {
|
||||
.IN => "IN",
|
||||
.CH => "CH",
|
||||
.HS => "HS",
|
||||
.ANY => "ANY",
|
||||
else => "UNKNOWN",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/// DNS Response Code
|
||||
pub const RCode = enum(u4) {
|
||||
NoError = 0, // No error
|
||||
FormErr = 1, // Format error
|
||||
ServFail = 2, // Server failure
|
||||
NXDomain = 3, // Non-existent domain
|
||||
NotImp = 4, // Not implemented
|
||||
Refused = 5, // Query refused
|
||||
YXDomain = 6, // Name exists when it should not
|
||||
YXRRSet = 7, // RR set exists when it should not
|
||||
NXRRSet = 8, // RR set does not exist
|
||||
NotAuth = 9, // Not authorized
|
||||
NotZone = 10, // Name not in zone
|
||||
_,
|
||||
|
||||
pub fn toString(self: RCode) []const u8 {
|
||||
return switch (self) {
|
||||
.NoError => "NOERROR",
|
||||
.FormErr => "FORMERR",
|
||||
.ServFail => "SERVFAIL",
|
||||
.NXDomain => "NXDOMAIN",
|
||||
.NotImp => "NOTIMP",
|
||||
.Refused => "REFUSED",
|
||||
else => "UNKNOWN",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/// DNS Operation Code
|
||||
pub const OpCode = enum(u4) {
|
||||
Query = 0, // Standard query
|
||||
IQuery = 1, // Inverse query (obsolete)
|
||||
Status = 2, // Server status request
|
||||
Notify = 4, // Zone change notification
|
||||
Update = 5, // Dynamic update
|
||||
_,
|
||||
};
|
||||
|
||||
/// Maximum DNS name length (RFC 1035: 255 octets wire format, 253 chars in dot notation)
|
||||
pub const MAX_NAME_LENGTH: usize = 253;
|
||||
|
||||
/// Maximum DNS label length (RFC 1035: 63 octets, 6-bit length field)
|
||||
pub const MAX_LABEL_LENGTH: usize = 63;
|
||||
|
||||
/// Standard DNS UDP message size (RFC 1035 Section 4.2.1)
|
||||
pub const DNS_UDP_SIZE: usize = 512;
|
||||
|
||||
/// EDNS0 default UDP payload size (RFC 6891, commonly 4096 for modern resolvers)
|
||||
pub const EDNS_DEFAULT_SIZE: usize = 4096;
|
||||
|
||||
/// DNS header size in bytes (RFC 1035 Section 4.1.1: ID + flags + 4 counts)
|
||||
pub const DNS_HEADER_SIZE: usize = 12;
|
||||
|
||||
/// Compression pointer mask - top 2 bits set indicates pointer (RFC 1035 Section 4.1.4)
|
||||
pub const COMPRESSION_POINTER_MASK: u8 = 0xC0;
|
||||
|
||||
/// Maximum compression pointer offset (14-bit value, RFC 1035 Section 4.1.4)
|
||||
pub const MAX_COMPRESSION_OFFSET: u16 = 0x3FFF;
|
||||
|
||||
test "QType values" {
|
||||
const testing = std.testing;
|
||||
try testing.expectEqual(@as(u16, 1), @intFromEnum(QType.A));
|
||||
try testing.expectEqual(@as(u16, 28), @intFromEnum(QType.AAAA));
|
||||
try testing.expectEqual(@as(u16, 5), @intFromEnum(QType.CNAME));
|
||||
try testing.expectEqual(@as(u16, 41), @intFromEnum(QType.OPT));
|
||||
}
|
||||
|
||||
test "QClass values" {
|
||||
const testing = std.testing;
|
||||
try testing.expectEqual(@as(u16, 1), @intFromEnum(QClass.IN));
|
||||
try testing.expectEqual(@as(u16, 255), @intFromEnum(QClass.ANY));
|
||||
}
|
||||
|
||||
test "RCode values" {
|
||||
const testing = std.testing;
|
||||
try testing.expectEqual(@as(u4, 0), @intFromEnum(RCode.NoError));
|
||||
try testing.expectEqual(@as(u4, 3), @intFromEnum(RCode.NXDomain));
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
const std = @import("std");
|
||||
const posix = std.posix;
|
||||
|
||||
/// Event signaling using Linux eventfd
|
||||
/// Allows efficient cross-thread event notification without polling
|
||||
pub const EventFd = struct {
|
||||
fd: posix.fd_t,
|
||||
|
||||
pub const Error = error{
|
||||
EventFdCreateFailed,
|
||||
};
|
||||
|
||||
/// Create a new eventfd
|
||||
pub fn init() Error!EventFd {
|
||||
// EFD_NONBLOCK | EFD_CLOEXEC
|
||||
const flags: u32 = 0x800 | 0x80000;
|
||||
const fd = std.os.linux.eventfd(0, flags);
|
||||
if (@as(isize, @bitCast(fd)) < 0) {
|
||||
return error.EventFdCreateFailed;
|
||||
}
|
||||
return .{ .fd = @intCast(fd) };
|
||||
}
|
||||
|
||||
pub fn deinit(self: *EventFd) void {
|
||||
posix.close(self.fd);
|
||||
}
|
||||
|
||||
/// Signal the event (non-blocking)
|
||||
pub fn signal(self: *EventFd) void {
|
||||
const val: u64 = 1;
|
||||
_ = posix.write(self.fd, std.mem.asBytes(&val)) catch {};
|
||||
}
|
||||
|
||||
/// Consume the event (non-blocking, returns true if event was pending)
|
||||
pub fn consume(self: *EventFd) bool {
|
||||
var val: u64 = undefined;
|
||||
const n = posix.read(self.fd, std.mem.asBytes(&val)) catch return false;
|
||||
return n == 8;
|
||||
}
|
||||
|
||||
/// Get the file descriptor for polling
|
||||
pub fn getFd(self: *EventFd) posix.fd_t {
|
||||
return self.fd;
|
||||
}
|
||||
};
|
||||
|
||||
/// Global eventfd for denylist reload signaling
|
||||
var denylist_event: ?EventFd = null;
|
||||
|
||||
/// Initialize the global denylist event
|
||||
pub fn initDenylistEvent() !void {
|
||||
denylist_event = try EventFd.init();
|
||||
}
|
||||
|
||||
/// Deinitialize the global denylist event
|
||||
pub fn deinitDenylistEvent() void {
|
||||
if (denylist_event) |*ev| {
|
||||
ev.deinit();
|
||||
denylist_event = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Signal that denylist needs reload
|
||||
pub fn signalDenylistReload() void {
|
||||
if (denylist_event) |*ev| {
|
||||
ev.signal();
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the denylist event fd for polling (returns -1 if not initialized)
|
||||
pub fn getDenylistEventFd() posix.fd_t {
|
||||
if (denylist_event) |*ev| {
|
||||
return ev.getFd();
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/// Consume the denylist reload event
|
||||
pub fn consumeDenylistReload() bool {
|
||||
if (denylist_event) |*ev| {
|
||||
return ev.consume();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,666 @@
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const Database = @import("../storage/db.zig").Database;
|
||||
const handler = @import("../server/handler.zig");
|
||||
const types = @import("../dns/types.zig");
|
||||
const toLower = @import("../util.zig").toLower;
|
||||
|
||||
/// Maximum domain buffer size (RFC 1035: 253 chars + null terminator)
|
||||
const MAX_DOMAIN_BUF_SIZE = types.MAX_NAME_LENGTH + 1;
|
||||
|
||||
/// Domain info with attribution (Gap 2)
|
||||
const DomainInfo = struct {
|
||||
groups: u64, // Bitmask of groups that have this domain denied
|
||||
source_id: i64, // First source_id that contributed this domain
|
||||
};
|
||||
|
||||
/// Rule info with attribution (Gap 2)
|
||||
const RuleInfo = struct {
|
||||
groups: u64, // Bitmask of groups this rule applies to
|
||||
rule_id: i64, // The rule ID in the database
|
||||
};
|
||||
|
||||
/// Thread-safe denylist wrapper using RwLock for concurrent access
|
||||
/// Allows multiple readers during queries, exclusive access during reload
|
||||
pub const ThreadSafeDenylist = struct {
|
||||
inner: *Denylist,
|
||||
lock: std.Thread.RwLock,
|
||||
allocator: Allocator,
|
||||
|
||||
pub fn init(denylist: *Denylist, allocator: Allocator) ThreadSafeDenylist {
|
||||
return .{
|
||||
.inner = denylist,
|
||||
.lock = .{},
|
||||
.allocator = allocator,
|
||||
};
|
||||
}
|
||||
|
||||
/// Check if domain is denied with full attribution (acquires read lock)
|
||||
/// Gap 4: Takes groups bitmask for many-to-many support
|
||||
pub fn checkWithMask(self: *ThreadSafeDenylist, domain: []const u8, groups_mask: u64) handler.DenyResult {
|
||||
self.lock.lockShared();
|
||||
defer self.lock.unlockShared();
|
||||
return self.inner.checkWithMask(domain, groups_mask);
|
||||
}
|
||||
|
||||
/// Legacy: Check if domain is denied for a single group (acquires read lock)
|
||||
pub fn check(self: *ThreadSafeDenylist, domain: []const u8, group_id: u32) handler.DenyResult {
|
||||
self.lock.lockShared();
|
||||
defer self.lock.unlockShared();
|
||||
return self.inner.check(domain, group_id);
|
||||
}
|
||||
|
||||
/// Legacy: Check if domain is denied (acquires read lock)
|
||||
pub fn isDenied(self: *ThreadSafeDenylist, domain: []const u8, group_id: u32) bool {
|
||||
return self.check(domain, group_id).denied;
|
||||
}
|
||||
|
||||
/// Get all groups for a client as a bitmask (Gap 4: many-to-many)
|
||||
pub fn getGroupsForClient(self: *ThreadSafeDenylist, client_ip: []const u8) u64 {
|
||||
self.lock.lockShared();
|
||||
defer self.lock.unlockShared();
|
||||
return self.inner.getGroupsForClient(client_ip);
|
||||
}
|
||||
|
||||
/// Legacy: Get primary group for client (acquires read lock)
|
||||
pub fn getGroupForClient(self: *ThreadSafeDenylist, client_ip: []const u8) u32 {
|
||||
self.lock.lockShared();
|
||||
defer self.lock.unlockShared();
|
||||
return self.inner.getGroupForClient(client_ip);
|
||||
}
|
||||
|
||||
/// Atomically swap the inner denylist (acquires write lock)
|
||||
/// Returns the old denylist for cleanup by the caller
|
||||
pub fn swap(self: *ThreadSafeDenylist, new_denylist: Denylist) Denylist {
|
||||
self.lock.lock();
|
||||
defer self.lock.unlock();
|
||||
|
||||
const old = self.inner.*;
|
||||
self.inner.* = new_denylist;
|
||||
return old;
|
||||
}
|
||||
|
||||
/// Convert to handler-compatible interface
|
||||
pub fn toHandlerDenylist(self: *ThreadSafeDenylist) handler.Denylist {
|
||||
return handler.Denylist{
|
||||
.context = self,
|
||||
.checkFn = checkWithMaskWrapper,
|
||||
.getGroupsFn = getGroupsWrapper,
|
||||
};
|
||||
}
|
||||
|
||||
fn checkWithMaskWrapper(ctx: *anyopaque, domain: []const u8, groups_mask: u64) handler.DenyResult {
|
||||
const self: *ThreadSafeDenylist = @ptrCast(@alignCast(ctx));
|
||||
return self.checkWithMask(domain, groups_mask);
|
||||
}
|
||||
|
||||
fn getGroupsWrapper(ctx: *anyopaque, client_ip: []const u8) u64 {
|
||||
const self: *ThreadSafeDenylist = @ptrCast(@alignCast(ctx));
|
||||
return self.getGroupsForClient(client_ip);
|
||||
}
|
||||
};
|
||||
|
||||
/// Denylist with parent-walking lookup
|
||||
/// Uses arena allocator for fast bulk allocation and instant deallocation
|
||||
pub const Denylist = struct {
|
||||
/// Maps domain -> DomainInfo with groups and source attribution
|
||||
denied_domains: std.StringHashMapUnmanaged(DomainInfo),
|
||||
/// Maps client IP -> groups bitmask (Gap 4: supports many-to-many)
|
||||
client_groups: std.StringHashMapUnmanaged(u64),
|
||||
/// Custom allow rules per group: domain -> RuleInfo with groups and rule_id
|
||||
allow_rules: std.StringHashMapUnmanaged(RuleInfo),
|
||||
/// Custom deny rules per group: domain -> RuleInfo with groups and rule_id
|
||||
deny_rules: std.StringHashMapUnmanaged(RuleInfo),
|
||||
/// Allowlist domains from external sources (Gap 5): domain -> DomainInfo
|
||||
allowlist_domains: std.StringHashMapUnmanaged(DomainInfo),
|
||||
/// Arena for fast allocations - all memory freed at once on deinit
|
||||
arena: std.heap.ArenaAllocator,
|
||||
|
||||
pub fn init(backing_allocator: Allocator) Denylist {
|
||||
return Denylist{
|
||||
.denied_domains = std.StringHashMapUnmanaged(DomainInfo){},
|
||||
.client_groups = std.StringHashMapUnmanaged(u64){},
|
||||
.allow_rules = std.StringHashMapUnmanaged(RuleInfo){},
|
||||
.deny_rules = std.StringHashMapUnmanaged(RuleInfo){},
|
||||
.allowlist_domains = std.StringHashMapUnmanaged(DomainInfo){},
|
||||
.arena = std.heap.ArenaAllocator.init(backing_allocator),
|
||||
};
|
||||
}
|
||||
|
||||
fn groupMask(group_id: u32) u64 {
|
||||
if (group_id >= 64) return 0;
|
||||
return @as(u64, 1) << @intCast(group_id);
|
||||
}
|
||||
|
||||
fn allocator(self: *Denylist) Allocator {
|
||||
return self.arena.allocator();
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Denylist) void {
|
||||
// Arena frees everything at once - no need to iterate
|
||||
self.arena.deinit();
|
||||
}
|
||||
|
||||
/// Check if a domain is denied for given groups (bitmask), with full attribution
|
||||
/// Gap 4: groups_mask allows checking against multiple groups at once
|
||||
pub fn checkWithMask(self: *Denylist, domain: []const u8, groups_mask: u64) handler.DenyResult {
|
||||
// Normalize domain to lowercase
|
||||
var domain_lower_buf: [MAX_DOMAIN_BUF_SIZE]u8 = undefined;
|
||||
const domain_lower = toLower(domain, &domain_lower_buf) orelse return .{
|
||||
.denied = false,
|
||||
.list_id = null,
|
||||
.rule_id = null,
|
||||
.is_rule_allow = false,
|
||||
};
|
||||
|
||||
// Combine with default group (group 0 applies to everyone)
|
||||
const default_mask = groupMask(0);
|
||||
const combined_mask = groups_mask | default_mask;
|
||||
|
||||
// 1. Check custom allow rules first (exact match only for allow)
|
||||
if (self.allow_rules.get(domain_lower)) |rule_info| {
|
||||
if ((rule_info.groups & combined_mask) != 0) {
|
||||
return .{
|
||||
.denied = false,
|
||||
.list_id = null,
|
||||
.rule_id = rule_info.rule_id,
|
||||
.is_rule_allow = true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check allowlist domains from external sources (Gap 5)
|
||||
if (self.checkAllowlistWithMask(domain_lower, combined_mask)) |_| {
|
||||
return .{
|
||||
.denied = false,
|
||||
.list_id = null,
|
||||
.rule_id = null,
|
||||
.is_rule_allow = true, // Treated as an allow override
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Check custom deny rules (with parent walking)
|
||||
if (self.checkDenyRulesWithMask(domain_lower, combined_mask)) |rule_info| {
|
||||
return .{
|
||||
.denied = true,
|
||||
.list_id = null,
|
||||
.rule_id = rule_info.rule_id,
|
||||
.is_rule_allow = false,
|
||||
};
|
||||
}
|
||||
|
||||
// 4. Check denylists (with parent walking)
|
||||
if (self.checkDenylistWithMask(domain_lower, combined_mask)) |domain_info| {
|
||||
return .{
|
||||
.denied = true,
|
||||
.list_id = domain_info.source_id,
|
||||
.rule_id = null,
|
||||
.is_rule_allow = false,
|
||||
};
|
||||
}
|
||||
|
||||
return .{
|
||||
.denied = false,
|
||||
.list_id = null,
|
||||
.rule_id = null,
|
||||
.is_rule_allow = false,
|
||||
};
|
||||
}
|
||||
|
||||
/// Check if a domain is denied for a given group, with full attribution
|
||||
/// Legacy API - converts single group_id to bitmask
|
||||
pub fn check(self: *Denylist, domain: []const u8, group_id: u32) handler.DenyResult {
|
||||
return self.checkWithMask(domain, groupMask(group_id));
|
||||
}
|
||||
|
||||
/// Legacy: Check if a domain is denied for a given group
|
||||
pub fn isDenied(self: *Denylist, domain: []const u8, group_id: u32) bool {
|
||||
return self.check(domain, group_id).denied;
|
||||
}
|
||||
|
||||
fn checkDenyRulesWithMask(self: *Denylist, domain: []const u8, mask: u64) ?RuleInfo {
|
||||
var d: []const u8 = domain;
|
||||
while (true) {
|
||||
if (self.deny_rules.get(d)) |rule_info| {
|
||||
if ((rule_info.groups & mask) != 0) {
|
||||
return rule_info;
|
||||
}
|
||||
}
|
||||
|
||||
// Move to parent domain
|
||||
if (std.mem.indexOfScalar(u8, d, '.')) |idx| {
|
||||
d = d[idx + 1 ..];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn checkDenylistWithMask(self: *Denylist, domain: []const u8, mask: u64) ?DomainInfo {
|
||||
var d: []const u8 = domain;
|
||||
while (true) {
|
||||
if (self.denied_domains.get(d)) |domain_info| {
|
||||
if ((domain_info.groups & mask) != 0) {
|
||||
return domain_info;
|
||||
}
|
||||
}
|
||||
|
||||
// Move to parent domain
|
||||
if (std.mem.indexOfScalar(u8, d, '.')) |idx| {
|
||||
d = d[idx + 1 ..];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check allowlist domains from external sources (Gap 5)
|
||||
/// Uses exact match only (no parent walking - allowlists are typically specific)
|
||||
fn checkAllowlistWithMask(self: *Denylist, domain: []const u8, mask: u64) ?DomainInfo {
|
||||
if (self.allowlist_domains.get(domain)) |domain_info| {
|
||||
if ((domain_info.groups & mask) != 0) {
|
||||
return domain_info;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get all groups for a client IP as a bitmask (Gap 4: many-to-many)
|
||||
pub fn getGroupsForClient(self: *Denylist, client_ip: []const u8) u64 {
|
||||
return self.client_groups.get(client_ip) orelse groupMask(0);
|
||||
}
|
||||
|
||||
/// Legacy: Get the primary group ID for a client IP (returns first set bit)
|
||||
pub fn getGroupForClient(self: *Denylist, client_ip: []const u8) u32 {
|
||||
const mask = self.getGroupsForClient(client_ip);
|
||||
// Return the lowest set group bit (or 0 if only default group)
|
||||
if (mask == groupMask(0)) return 0;
|
||||
var bit: u6 = 0;
|
||||
while (bit < 64) : (bit += 1) {
|
||||
if ((mask & (@as(u64, 1) << bit)) != 0) return bit;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Add a denied domain for a group with source attribution
|
||||
pub fn addDeniedDomainWithSource(self: *Denylist, domain: []const u8, group_id: u32, source_id: i64) !void {
|
||||
var domain_lower_buf: [MAX_DOMAIN_BUF_SIZE]u8 = undefined;
|
||||
const domain_lower = toLower(domain, &domain_lower_buf) orelse return;
|
||||
|
||||
const alloc = self.allocator();
|
||||
const gop = try self.denied_domains.getOrPut(alloc, domain_lower);
|
||||
if (!gop.found_existing) {
|
||||
gop.key_ptr.* = try alloc.dupe(u8, domain_lower);
|
||||
gop.value_ptr.* = .{ .groups = 0, .source_id = source_id };
|
||||
}
|
||||
gop.value_ptr.groups |= groupMask(group_id);
|
||||
}
|
||||
|
||||
/// Add a denied domain for a group (legacy - no source attribution)
|
||||
pub fn addDeniedDomain(self: *Denylist, domain: []const u8, group_id: u32) !void {
|
||||
return self.addDeniedDomainWithSource(domain, group_id, 0);
|
||||
}
|
||||
|
||||
/// Add an allowlist domain for a group with source attribution (Gap 5)
|
||||
pub fn addAllowlistDomainWithSource(self: *Denylist, domain: []const u8, group_id: u32, source_id: i64) !void {
|
||||
var domain_lower_buf: [MAX_DOMAIN_BUF_SIZE]u8 = undefined;
|
||||
const domain_lower = toLower(domain, &domain_lower_buf) orelse return;
|
||||
|
||||
const alloc = self.allocator();
|
||||
const gop = try self.allowlist_domains.getOrPut(alloc, domain_lower);
|
||||
if (!gop.found_existing) {
|
||||
gop.key_ptr.* = try alloc.dupe(u8, domain_lower);
|
||||
gop.value_ptr.* = .{ .groups = 0, .source_id = source_id };
|
||||
}
|
||||
gop.value_ptr.groups |= groupMask(group_id);
|
||||
}
|
||||
|
||||
/// Add a group to a client's groups bitmask (Gap 4: many-to-many)
|
||||
pub fn addClientToGroup(self: *Denylist, client_ip: []const u8, group_id: u32) !void {
|
||||
const alloc = self.allocator();
|
||||
const gop = try self.client_groups.getOrPut(alloc, client_ip);
|
||||
if (!gop.found_existing) {
|
||||
gop.key_ptr.* = try alloc.dupe(u8, client_ip);
|
||||
gop.value_ptr.* = 0;
|
||||
}
|
||||
gop.value_ptr.* |= groupMask(group_id);
|
||||
}
|
||||
|
||||
/// Legacy: Set client's group (replaces all groups with single group)
|
||||
pub fn setClientGroup(self: *Denylist, client_ip: []const u8, group_id: u32) !void {
|
||||
const alloc = self.allocator();
|
||||
const gop = try self.client_groups.getOrPut(alloc, client_ip);
|
||||
if (!gop.found_existing) {
|
||||
gop.key_ptr.* = try alloc.dupe(u8, client_ip);
|
||||
}
|
||||
gop.value_ptr.* = groupMask(group_id);
|
||||
}
|
||||
|
||||
/// Add an allow rule with rule_id attribution
|
||||
pub fn addAllowRuleWithId(self: *Denylist, domain: []const u8, group_id: u32, rule_id: i64) !void {
|
||||
var domain_lower_buf: [MAX_DOMAIN_BUF_SIZE]u8 = undefined;
|
||||
const domain_lower = toLower(domain, &domain_lower_buf) orelse return;
|
||||
|
||||
const alloc = self.allocator();
|
||||
const gop = try self.allow_rules.getOrPut(alloc, domain_lower);
|
||||
if (!gop.found_existing) {
|
||||
gop.key_ptr.* = try alloc.dupe(u8, domain_lower);
|
||||
gop.value_ptr.* = .{ .groups = 0, .rule_id = rule_id };
|
||||
}
|
||||
gop.value_ptr.groups |= groupMask(group_id);
|
||||
}
|
||||
|
||||
/// Add an allow rule (legacy - no rule_id attribution)
|
||||
pub fn addAllowRule(self: *Denylist, domain: []const u8, group_id: u32) !void {
|
||||
return self.addAllowRuleWithId(domain, group_id, 0);
|
||||
}
|
||||
|
||||
/// Add a deny rule with rule_id attribution
|
||||
pub fn addDenyRuleWithId(self: *Denylist, domain: []const u8, group_id: u32, rule_id: i64) !void {
|
||||
var domain_lower_buf: [MAX_DOMAIN_BUF_SIZE]u8 = undefined;
|
||||
const domain_lower = toLower(domain, &domain_lower_buf) orelse return;
|
||||
|
||||
const alloc = self.allocator();
|
||||
const gop = try self.deny_rules.getOrPut(alloc, domain_lower);
|
||||
if (!gop.found_existing) {
|
||||
gop.key_ptr.* = try alloc.dupe(u8, domain_lower);
|
||||
gop.value_ptr.* = .{ .groups = 0, .rule_id = rule_id };
|
||||
}
|
||||
gop.value_ptr.groups |= groupMask(group_id);
|
||||
}
|
||||
|
||||
/// Add a deny rule (legacy - no rule_id attribution)
|
||||
pub fn addDenyRule(self: *Denylist, domain: []const u8, group_id: u32) !void {
|
||||
return self.addDenyRuleWithId(domain, group_id, 0);
|
||||
}
|
||||
|
||||
/// Convert to handler-compatible Denylist interface
|
||||
pub fn toHandlerDenylist(self: *Denylist) handler.Denylist {
|
||||
return handler.Denylist{
|
||||
.context = self,
|
||||
.checkFn = checkWrapper,
|
||||
.getGroupFn = getGroupWrapper,
|
||||
};
|
||||
}
|
||||
|
||||
fn checkWrapper(ctx: *anyopaque, domain: []const u8, group_id: u32) handler.DenyResult {
|
||||
const self: *Denylist = @ptrCast(@alignCast(ctx));
|
||||
return self.check(domain, group_id);
|
||||
}
|
||||
|
||||
fn getGroupWrapper(ctx: *anyopaque, client_ip: []const u8) u32 {
|
||||
const self: *Denylist = @ptrCast(@alignCast(ctx));
|
||||
return self.getGroupForClient(client_ip);
|
||||
}
|
||||
};
|
||||
|
||||
/// Load denylist from database
|
||||
pub fn loadFromDatabase(db: *Database, allocator: Allocator) !Denylist {
|
||||
var denylist = Denylist.init(allocator);
|
||||
errdefer denylist.deinit();
|
||||
|
||||
// Count domains first to preallocate HashMap (avoids repeated resizing)
|
||||
var count_stmt = try db.prepare(
|
||||
\\SELECT COUNT(DISTINCT bd.domain)
|
||||
\\FROM denylist_domains bd
|
||||
\\JOIN group_sources gs ON bd.source_id = gs.source_id
|
||||
\\JOIN denylist_sources bs ON bd.source_id = bs.id
|
||||
\\WHERE bs.enabled = 1
|
||||
);
|
||||
defer count_stmt.finalize();
|
||||
|
||||
var domain_count: u32 = 0;
|
||||
if (try count_stmt.step()) {
|
||||
domain_count = @intCast(count_stmt.getInt(0));
|
||||
}
|
||||
|
||||
// Preallocate capacity to avoid resizing during inserts
|
||||
if (domain_count > 0) {
|
||||
try denylist.denied_domains.ensureTotalCapacity(denylist.allocator(), domain_count);
|
||||
}
|
||||
|
||||
// Load denied domains grouped by group, with source attribution (type=0 denylists)
|
||||
var stmt = try db.prepare(
|
||||
\\SELECT bd.domain, gs.group_id, bd.source_id
|
||||
\\FROM denylist_domains bd
|
||||
\\JOIN group_sources gs ON bd.source_id = gs.source_id
|
||||
\\JOIN denylist_sources bs ON bd.source_id = bs.id
|
||||
\\WHERE bs.enabled = 1 AND (bs.type = 0 OR bs.type IS NULL)
|
||||
);
|
||||
defer stmt.finalize();
|
||||
|
||||
while (try stmt.step()) {
|
||||
const domain = stmt.getText(0) orelse continue;
|
||||
const group_id: u32 = @intCast(stmt.getInt(1));
|
||||
const source_id = stmt.getInt(2);
|
||||
try denylist.addDeniedDomainWithSource(domain, group_id, source_id);
|
||||
}
|
||||
|
||||
// Load allowlist domains from external sources (Gap 5: type=1 allowlists)
|
||||
var allowlist_stmt = try db.prepare(
|
||||
\\SELECT ad.domain, gs.group_id, ad.source_id
|
||||
\\FROM allowlist_domains ad
|
||||
\\JOIN group_sources gs ON ad.source_id = gs.source_id
|
||||
\\JOIN denylist_sources bs ON ad.source_id = bs.id
|
||||
\\WHERE bs.enabled = 1 AND bs.type = 1
|
||||
);
|
||||
defer allowlist_stmt.finalize();
|
||||
|
||||
while (try allowlist_stmt.step()) {
|
||||
const domain = allowlist_stmt.getText(0) orelse continue;
|
||||
const group_id: u32 = @intCast(allowlist_stmt.getInt(1));
|
||||
const source_id = allowlist_stmt.getInt(2);
|
||||
try denylist.addAllowlistDomainWithSource(domain, group_id, source_id);
|
||||
}
|
||||
|
||||
// Load client groups from legacy clients.group_id column
|
||||
var client_stmt = try db.prepare("SELECT ip, group_id FROM clients WHERE group_id IS NOT NULL");
|
||||
defer client_stmt.finalize();
|
||||
|
||||
while (try client_stmt.step()) {
|
||||
const ip = client_stmt.getText(0) orelse continue;
|
||||
const group_id: u32 = @intCast(client_stmt.getInt(1));
|
||||
try denylist.addClientToGroup(ip, group_id);
|
||||
}
|
||||
|
||||
// Load client groups from client_groups junction table (Gap 4: many-to-many)
|
||||
var junction_stmt = try db.prepare(
|
||||
\\SELECT c.ip, cg.group_id
|
||||
\\FROM client_groups cg
|
||||
\\JOIN clients c ON cg.client_id = c.id
|
||||
);
|
||||
defer junction_stmt.finalize();
|
||||
|
||||
while (try junction_stmt.step()) {
|
||||
const ip = junction_stmt.getText(0) orelse continue;
|
||||
const group_id: u32 = @intCast(junction_stmt.getInt(1));
|
||||
try denylist.addClientToGroup(ip, group_id);
|
||||
}
|
||||
|
||||
// Load custom rules with rule_id attribution
|
||||
var rule_stmt = try db.prepare("SELECT id, domain, group_id, action FROM rules");
|
||||
defer rule_stmt.finalize();
|
||||
|
||||
while (try rule_stmt.step()) {
|
||||
const rule_id = rule_stmt.getInt(0);
|
||||
const domain = rule_stmt.getText(1) orelse continue;
|
||||
const group_id: u32 = if (rule_stmt.isNull(2)) 0 else @intCast(rule_stmt.getInt(2));
|
||||
const action = rule_stmt.getText(3) orelse continue;
|
||||
|
||||
if (std.mem.eql(u8, action, "allow")) {
|
||||
try denylist.addAllowRuleWithId(domain, group_id, rule_id);
|
||||
} else if (std.mem.eql(u8, action, "deny")) {
|
||||
try denylist.addDenyRuleWithId(domain, group_id, rule_id);
|
||||
}
|
||||
}
|
||||
|
||||
return denylist;
|
||||
}
|
||||
|
||||
test "Denylist exact match" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var denylist = Denylist.init(allocator);
|
||||
defer denylist.deinit();
|
||||
|
||||
try denylist.addDeniedDomain("ads.example.com", 0);
|
||||
|
||||
try testing.expect(denylist.isDenied("ads.example.com", 0));
|
||||
try testing.expect(!denylist.isDenied("example.com", 0));
|
||||
try testing.expect(!denylist.isDenied("other.example.com", 0));
|
||||
}
|
||||
|
||||
test "Denylist parent walking" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var denylist = Denylist.init(allocator);
|
||||
defer denylist.deinit();
|
||||
|
||||
try denylist.addDeniedDomain("doubleclick.net", 0);
|
||||
|
||||
// Subdomain should be denied
|
||||
try testing.expect(denylist.isDenied("www.doubleclick.net", 0));
|
||||
try testing.expect(denylist.isDenied("ads.doubleclick.net", 0));
|
||||
try testing.expect(denylist.isDenied("sub.ads.doubleclick.net", 0));
|
||||
|
||||
// Parent should not be denied
|
||||
try testing.expect(!denylist.isDenied("net", 0));
|
||||
}
|
||||
|
||||
test "Denylist allow rules" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var denylist = Denylist.init(allocator);
|
||||
defer denylist.deinit();
|
||||
|
||||
try denylist.addDeniedDomain("example.com", 0);
|
||||
try denylist.addAllowRule("allowed.example.com", 0);
|
||||
|
||||
try testing.expect(denylist.isDenied("denied.example.com", 0));
|
||||
try testing.expect(!denylist.isDenied("allowed.example.com", 0));
|
||||
}
|
||||
|
||||
test "Denylist group isolation" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var denylist = Denylist.init(allocator);
|
||||
defer denylist.deinit();
|
||||
|
||||
try denylist.addDeniedDomain("denied.com", 1);
|
||||
|
||||
// Group 1 should see it denied
|
||||
try testing.expect(denylist.isDenied("denied.com", 1));
|
||||
|
||||
// Group 0 and 2 should not see it denied
|
||||
try testing.expect(!denylist.isDenied("denied.com", 0));
|
||||
try testing.expect(!denylist.isDenied("denied.com", 2));
|
||||
}
|
||||
|
||||
test "Denylist case insensitive" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var denylist = Denylist.init(allocator);
|
||||
defer denylist.deinit();
|
||||
|
||||
try denylist.addDeniedDomain("EXAMPLE.COM", 0);
|
||||
|
||||
try testing.expect(denylist.isDenied("example.com", 0));
|
||||
try testing.expect(denylist.isDenied("EXAMPLE.COM", 0));
|
||||
try testing.expect(denylist.isDenied("Example.Com", 0));
|
||||
}
|
||||
|
||||
test "Denylist scale to 1 million domains" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var denylist = Denylist.init(allocator);
|
||||
defer denylist.deinit();
|
||||
|
||||
// Preallocate for better performance
|
||||
try denylist.denied_domains.ensureTotalCapacity(denylist.allocator(), 1_000_000);
|
||||
|
||||
const start_load = std.time.milliTimestamp();
|
||||
|
||||
// Insert 1 million domains
|
||||
var domain_buf: [64]u8 = undefined;
|
||||
for (0..1_000_000) |i| {
|
||||
const domain = std.fmt.bufPrint(&domain_buf, "domain{d}.example.com", .{i}) catch continue;
|
||||
try denylist.addDeniedDomain(domain, 0);
|
||||
}
|
||||
|
||||
const load_time = std.time.milliTimestamp() - start_load;
|
||||
|
||||
// Verify count
|
||||
try testing.expectEqual(@as(usize, 1_000_000), denylist.denied_domains.count());
|
||||
|
||||
// Benchmark lookups (1000 random lookups)
|
||||
const start_lookup = std.time.milliTimestamp();
|
||||
var found: usize = 0;
|
||||
for (0..1000) |i| {
|
||||
const domain = std.fmt.bufPrint(&domain_buf, "domain{d}.example.com", .{i * 1000}) catch continue;
|
||||
if (denylist.isDenied(domain, 0)) {
|
||||
found += 1;
|
||||
}
|
||||
}
|
||||
const lookup_time = std.time.milliTimestamp() - start_lookup;
|
||||
|
||||
// All lookups should find denied domains
|
||||
try testing.expectEqual(@as(usize, 1000), found);
|
||||
|
||||
// Test parent-walking lookup (subdomain of denied domain)
|
||||
try denylist.addDeniedDomain("denied-parent.com", 0);
|
||||
try testing.expect(denylist.isDenied("sub.denied-parent.com", 0));
|
||||
try testing.expect(denylist.isDenied("deep.sub.denied-parent.com", 0));
|
||||
|
||||
// Test non-denied domain
|
||||
try testing.expect(!denylist.isDenied("not-denied.com", 0));
|
||||
|
||||
// Print performance metrics
|
||||
std.debug.print("\n[Denylist Scale Test]\n", .{});
|
||||
std.debug.print(" Domains: 1,000,000\n", .{});
|
||||
std.debug.print(" Load time: {d}ms\n", .{load_time});
|
||||
std.debug.print(" 1000 lookups: {d}ms ({d}us/lookup)\n", .{ lookup_time, lookup_time });
|
||||
}
|
||||
|
||||
test "Denylist load time scales linearly" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
const sizes = [_]usize{ 10_000, 50_000, 100_000, 200_000 };
|
||||
var times: [sizes.len]i64 = undefined;
|
||||
|
||||
for (sizes, 0..) |size, idx| {
|
||||
var denylist = Denylist.init(allocator);
|
||||
defer denylist.deinit();
|
||||
|
||||
try denylist.denied_domains.ensureTotalCapacity(denylist.allocator(), @intCast(size));
|
||||
|
||||
const start = std.time.milliTimestamp();
|
||||
|
||||
var domain_buf: [64]u8 = undefined;
|
||||
for (0..size) |i| {
|
||||
const domain = std.fmt.bufPrint(&domain_buf, "d{d}.test.com", .{i}) catch continue;
|
||||
try denylist.addDeniedDomain(domain, 0);
|
||||
}
|
||||
|
||||
times[idx] = std.time.milliTimestamp() - start;
|
||||
try testing.expectEqual(size, denylist.denied_domains.count());
|
||||
}
|
||||
|
||||
std.debug.print("\n[Denylist Linear Scaling]\n", .{});
|
||||
for (sizes, 0..) |size, idx| {
|
||||
const rate = if (times[idx] > 0) @divTrunc(@as(i64, @intCast(size)), times[idx]) else 0;
|
||||
std.debug.print(" {d}: {d}ms ({d}k domains/sec)\n", .{ size, times[idx], rate });
|
||||
}
|
||||
|
||||
// Verify roughly linear scaling (4x size should be ~4x time, allow 2x-8x range)
|
||||
if (times[1] > 0) {
|
||||
const ratio = @divTrunc(times[3] * 100, times[1]);
|
||||
try testing.expect(ratio >= 200 and ratio <= 800);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
const std = @import("std");
|
||||
const http = std.http;
|
||||
const Allocator = std.mem.Allocator;
|
||||
const Writer = std.Io.Writer;
|
||||
const Database = @import("../storage/db.zig").Database;
|
||||
const schema = @import("../storage/schema.zig");
|
||||
const events = @import("../events.zig");
|
||||
|
||||
const DenylistStatus = schema.DenylistStatus;
|
||||
|
||||
/// Maximum output size for denylist fetch (100MB)
|
||||
const MAX_DENYLIST_SIZE = 100 * 1024 * 1024;
|
||||
|
||||
/// Denylist format types
|
||||
pub const DenylistFormat = enum {
|
||||
hosts, // 0.0.0.0 domain.com or 127.0.0.1 domain.com
|
||||
domains, // one domain per line
|
||||
adblock, // ||domain.com^
|
||||
|
||||
/// Detect format from content sample
|
||||
pub fn detect(content: []const u8) DenylistFormat {
|
||||
var lines = std.mem.splitScalar(u8, content, '\n');
|
||||
var hosts_count: usize = 0;
|
||||
var adblock_count: usize = 0;
|
||||
var checked: usize = 0;
|
||||
|
||||
while (lines.next()) |line| {
|
||||
const trimmed = std.mem.trim(u8, line, " \t\r");
|
||||
if (trimmed.len == 0 or trimmed[0] == '#') continue;
|
||||
|
||||
if (std.mem.startsWith(u8, trimmed, "||") and std.mem.endsWith(u8, trimmed, "^")) {
|
||||
adblock_count += 1;
|
||||
} else if (std.mem.startsWith(u8, trimmed, "0.0.0.0 ") or
|
||||
std.mem.startsWith(u8, trimmed, "127.0.0.1 "))
|
||||
{
|
||||
hosts_count += 1;
|
||||
}
|
||||
|
||||
checked += 1;
|
||||
if (checked >= 50) break;
|
||||
}
|
||||
|
||||
if (adblock_count > hosts_count) return .adblock;
|
||||
if (hosts_count > 0) return .hosts;
|
||||
return .domains;
|
||||
}
|
||||
};
|
||||
|
||||
/// Result of parsing a denylist
|
||||
pub const ParseResult = struct {
|
||||
domains: std.ArrayListUnmanaged([]const u8),
|
||||
invalid_count: usize,
|
||||
};
|
||||
|
||||
/// Parse denylist content and return domains plus count of invalid lines
|
||||
pub fn parseDenylist(content: []const u8, format: DenylistFormat, allocator: Allocator) !ParseResult {
|
||||
var domains = std.ArrayListUnmanaged([]const u8){};
|
||||
errdefer {
|
||||
for (domains.items) |d| allocator.free(d);
|
||||
domains.deinit(allocator);
|
||||
}
|
||||
|
||||
var invalid_count: usize = 0;
|
||||
var lines = std.mem.splitScalar(u8, content, '\n');
|
||||
|
||||
while (lines.next()) |line| {
|
||||
const trimmed = std.mem.trim(u8, line, " \t\r");
|
||||
if (trimmed.len == 0 or trimmed[0] == '#' or trimmed[0] == '!') continue;
|
||||
|
||||
const domain = switch (format) {
|
||||
.hosts => parseHostsLine(trimmed),
|
||||
.domains => parseDomainLine(trimmed),
|
||||
.adblock => parseAdblockLine(trimmed),
|
||||
};
|
||||
|
||||
if (domain) |d| {
|
||||
if (isValidDomain(d)) {
|
||||
const copy = try allocator.dupe(u8, d);
|
||||
try domains.append(allocator, copy);
|
||||
} else {
|
||||
invalid_count += 1;
|
||||
}
|
||||
} else {
|
||||
// Line matched format but couldn't be parsed (e.g., wrong IP in hosts)
|
||||
invalid_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return .{ .domains = domains, .invalid_count = invalid_count };
|
||||
}
|
||||
|
||||
fn parseHostsLine(line: []const u8) ?[]const u8 {
|
||||
// Format: 0.0.0.0 domain.com or 127.0.0.1 domain.com
|
||||
var parts = std.mem.splitAny(u8, line, " \t");
|
||||
|
||||
const ip = parts.first();
|
||||
if (!std.mem.eql(u8, ip, "0.0.0.0") and !std.mem.eql(u8, ip, "127.0.0.1")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const domain = parts.next() orelse return null;
|
||||
|
||||
// Skip localhost entries
|
||||
if (std.mem.eql(u8, domain, "localhost") or
|
||||
std.mem.eql(u8, domain, "localhost.localdomain") or
|
||||
std.mem.eql(u8, domain, "local") or
|
||||
std.mem.eql(u8, domain, "broadcasthost"))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return domain;
|
||||
}
|
||||
|
||||
fn parseDomainLine(line: []const u8) ?[]const u8 {
|
||||
// Just the domain, skip comments
|
||||
if (line.len == 0) return null;
|
||||
|
||||
// Check for inline comments
|
||||
var domain = line;
|
||||
if (std.mem.indexOf(u8, line, "#")) |idx| {
|
||||
domain = std.mem.trim(u8, line[0..idx], " \t");
|
||||
}
|
||||
|
||||
if (domain.len == 0) return null;
|
||||
|
||||
// Handle wildcard format: *.domain.com -> domain.com
|
||||
if (std.mem.startsWith(u8, domain, "*.")) {
|
||||
domain = domain[2..];
|
||||
}
|
||||
|
||||
return if (domain.len > 0) domain else null;
|
||||
}
|
||||
|
||||
fn parseAdblockLine(line: []const u8) ?[]const u8 {
|
||||
// Format: ||domain.com^ or ||domain.com^$...
|
||||
if (!std.mem.startsWith(u8, line, "||")) return null;
|
||||
|
||||
var end = line.len;
|
||||
|
||||
// Find the end marker (^ or $)
|
||||
if (std.mem.indexOf(u8, line[2..], "^")) |idx| {
|
||||
end = idx + 2;
|
||||
} else if (std.mem.indexOf(u8, line[2..], "$")) |idx| {
|
||||
end = idx + 2;
|
||||
}
|
||||
|
||||
const domain = line[2..end];
|
||||
return if (domain.len > 0) domain else null;
|
||||
}
|
||||
|
||||
fn isValidDomain(domain: []const u8) bool {
|
||||
if (domain.len == 0 or domain.len > 253) return false;
|
||||
|
||||
// Must contain at least one dot (except for special cases)
|
||||
if (std.mem.indexOf(u8, domain, ".") == null) return false;
|
||||
|
||||
// Check for valid characters
|
||||
for (domain) |c| {
|
||||
if (!std.ascii.isAlphanumeric(c) and c != '.' and c != '-' and c != '_') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Don't start or end with hyphen or dot
|
||||
if (domain[0] == '-' or domain[0] == '.') return false;
|
||||
if (domain[domain.len - 1] == '-' or domain[domain.len - 1] == '.') return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Denylist fetcher for downloading and updating denylists
|
||||
pub const DenylistFetcher = struct {
|
||||
db: *Database,
|
||||
allocator: Allocator,
|
||||
|
||||
pub fn init(db: *Database, allocator: Allocator) DenylistFetcher {
|
||||
return DenylistFetcher{
|
||||
.db = db,
|
||||
.allocator = allocator,
|
||||
};
|
||||
}
|
||||
|
||||
/// Fetch and update a denylist from URL
|
||||
pub fn fetchAndUpdate(self: *DenylistFetcher, source_id: i64, url: []const u8) !usize {
|
||||
// Fetch content via HTTP
|
||||
const content = self.httpGet(url) catch |err| {
|
||||
// Fetch failed - check if we have cached data
|
||||
const has_cached = self.hasCachedDomains(source_id);
|
||||
const status: DenylistStatus = if (has_cached) .cached else .failed;
|
||||
self.updateStatus(source_id, status);
|
||||
return err;
|
||||
};
|
||||
defer self.allocator.free(content);
|
||||
|
||||
// Compute content hash
|
||||
var hasher = std.hash.XxHash3.init(0);
|
||||
hasher.update(content);
|
||||
const hash = hasher.final();
|
||||
var hash_str: [16]u8 = undefined;
|
||||
_ = std.fmt.bufPrint(&hash_str, "{x:0>16}", .{hash}) catch unreachable;
|
||||
|
||||
// Check if content changed
|
||||
const old_hash = self.getContentHash(source_id);
|
||||
if (old_hash) |h| {
|
||||
if (std.mem.eql(u8, &hash_str, h)) {
|
||||
// Content unchanged
|
||||
self.updateStatusAndHash(source_id, .unchanged, &hash_str);
|
||||
return self.getDomainCount(source_id);
|
||||
}
|
||||
}
|
||||
|
||||
// Detect format
|
||||
const format = DenylistFormat.detect(content);
|
||||
|
||||
// Parse domains
|
||||
var result = try parseDenylist(content, format, self.allocator);
|
||||
defer {
|
||||
for (result.domains.items) |d| self.allocator.free(d);
|
||||
result.domains.deinit(self.allocator);
|
||||
}
|
||||
|
||||
// Update database with new domains and invalid count
|
||||
const count = try self.updateDatabase(source_id, result.domains.items, result.invalid_count, &hash_str);
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
fn hasCachedDomains(self: *DenylistFetcher, source_id: i64) bool {
|
||||
var stmt = self.db.prepare("SELECT domain_count FROM denylist_sources WHERE id = ?") catch return false;
|
||||
defer stmt.finalize();
|
||||
stmt.bindInt(1, source_id) catch return false;
|
||||
if (stmt.step() catch false) {
|
||||
return stmt.getInt(0) > 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
fn getContentHash(self: *DenylistFetcher, source_id: i64) ?[]const u8 {
|
||||
var stmt = self.db.prepare("SELECT content_hash FROM denylist_sources WHERE id = ?") catch return null;
|
||||
defer stmt.finalize();
|
||||
stmt.bindInt(1, source_id) catch return null;
|
||||
if (stmt.step() catch false) {
|
||||
return stmt.getText(0);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
fn getDomainCount(self: *DenylistFetcher, source_id: i64) usize {
|
||||
var stmt = self.db.prepare("SELECT domain_count FROM denylist_sources WHERE id = ?") catch return 0;
|
||||
defer stmt.finalize();
|
||||
stmt.bindInt(1, source_id) catch return 0;
|
||||
if (stmt.step() catch false) {
|
||||
const count = stmt.getInt(0);
|
||||
return if (count >= 0) @intCast(count) else 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
fn updateStatus(self: *DenylistFetcher, source_id: i64, status: DenylistStatus) void {
|
||||
var stmt = self.db.prepare("UPDATE denylist_sources SET status = ? WHERE id = ?") catch return;
|
||||
defer stmt.finalize();
|
||||
stmt.bindInt(1, @intFromEnum(status)) catch return;
|
||||
stmt.bindInt(2, source_id) catch return;
|
||||
_ = stmt.step() catch {};
|
||||
}
|
||||
|
||||
fn updateStatusAndHash(self: *DenylistFetcher, source_id: i64, status: DenylistStatus, hash: []const u8) void {
|
||||
var stmt = self.db.prepare(
|
||||
"UPDATE denylist_sources SET status = ?, content_hash = ?, last_updated = strftime('%s', 'now') WHERE id = ?",
|
||||
) catch return;
|
||||
defer stmt.finalize();
|
||||
stmt.bindInt(1, @intFromEnum(status)) catch return;
|
||||
stmt.bindText(2, hash) catch return;
|
||||
stmt.bindInt(3, source_id) catch return;
|
||||
_ = stmt.step() catch {};
|
||||
}
|
||||
|
||||
fn updateDatabase(self: *DenylistFetcher, source_id: i64, domains: []const []const u8, invalid_count: usize, hash: []const u8) !usize {
|
||||
// Get source type to determine which table to use (Gap 5)
|
||||
const source_type = self.getSourceType(source_id);
|
||||
const is_allowlist = source_type == .allowlist;
|
||||
|
||||
// Start transaction with RAII - auto-rollback if not committed
|
||||
var tx = try self.db.begin();
|
||||
defer tx.deinit();
|
||||
|
||||
// Delete existing domains for this source from appropriate table
|
||||
if (is_allowlist) {
|
||||
var delete_stmt = try self.db.prepare("DELETE FROM allowlist_domains WHERE source_id = ?");
|
||||
defer delete_stmt.finalize();
|
||||
try delete_stmt.bindInt(1, source_id);
|
||||
_ = try delete_stmt.step();
|
||||
} else {
|
||||
var delete_stmt = try self.db.prepare("DELETE FROM denylist_domains WHERE source_id = ?");
|
||||
defer delete_stmt.finalize();
|
||||
try delete_stmt.bindInt(1, source_id);
|
||||
_ = try delete_stmt.step();
|
||||
}
|
||||
|
||||
// Insert new domains into appropriate table
|
||||
const insert_sql = if (is_allowlist)
|
||||
"INSERT OR IGNORE INTO allowlist_domains (domain, source_id) VALUES (?, ?)"
|
||||
else
|
||||
"INSERT OR IGNORE INTO denylist_domains (domain, source_id) VALUES (?, ?)";
|
||||
|
||||
var insert_stmt = try self.db.prepare(insert_sql);
|
||||
defer insert_stmt.finalize();
|
||||
|
||||
for (domains) |domain| {
|
||||
insert_stmt.reset();
|
||||
try insert_stmt.bindText(1, domain);
|
||||
try insert_stmt.bindInt(2, source_id);
|
||||
_ = try insert_stmt.step();
|
||||
}
|
||||
|
||||
// Update source metadata with status = updated, including invalid_domains count (Gap 6)
|
||||
var update_stmt = try self.db.prepare(
|
||||
\\UPDATE denylist_sources SET
|
||||
\\ domain_count = ?,
|
||||
\\ invalid_domains = ?,
|
||||
\\ status = ?,
|
||||
\\ content_hash = ?,
|
||||
\\ last_updated = strftime('%s', 'now'),
|
||||
\\ date_modified = strftime('%s', 'now')
|
||||
\\WHERE id = ?
|
||||
);
|
||||
defer update_stmt.finalize();
|
||||
try update_stmt.bindInt(1, @intCast(domains.len));
|
||||
try update_stmt.bindInt(2, @intCast(invalid_count));
|
||||
try update_stmt.bindInt(3, @intFromEnum(DenylistStatus.updated));
|
||||
try update_stmt.bindText(4, hash);
|
||||
try update_stmt.bindInt(5, source_id);
|
||||
_ = try update_stmt.step();
|
||||
|
||||
try tx.commit();
|
||||
|
||||
return domains.len;
|
||||
}
|
||||
|
||||
fn getSourceType(self: *DenylistFetcher, source_id: i64) schema.SourceType {
|
||||
var stmt = self.db.prepare("SELECT type FROM denylist_sources WHERE id = ?") catch return .denylist;
|
||||
defer stmt.finalize();
|
||||
stmt.bindInt(1, source_id) catch return .denylist;
|
||||
if (stmt.step() catch false) {
|
||||
return schema.SourceType.fromInt(stmt.getInt(0));
|
||||
}
|
||||
return .denylist;
|
||||
}
|
||||
|
||||
fn httpGet(self: *DenylistFetcher, url: []const u8) ![]const u8 {
|
||||
var client = http.Client{ .allocator = self.allocator };
|
||||
defer client.deinit();
|
||||
|
||||
var response_writer = Writer.Allocating.init(self.allocator);
|
||||
errdefer response_writer.deinit();
|
||||
|
||||
const result = client.fetch(.{
|
||||
.location = .{ .url = url },
|
||||
.response_writer = &response_writer.writer,
|
||||
}) catch |err| {
|
||||
std.log.warn("HTTP fetch failed for {s}: {}", .{ url, err });
|
||||
return error.FetchFailed;
|
||||
};
|
||||
|
||||
if (result.status != .ok) {
|
||||
std.log.warn("HTTP {d} response for {s}", .{ @intFromEnum(result.status), url });
|
||||
return error.FetchFailed; // errdefer handles cleanup
|
||||
}
|
||||
|
||||
return response_writer.toOwnedSlice() catch error.FetchFailed;
|
||||
}
|
||||
|
||||
/// Update all enabled denylists
|
||||
pub fn updateAll(self: *DenylistFetcher) !void {
|
||||
var stmt = try self.db.prepare("SELECT id, url, comment FROM denylist_sources WHERE enabled = 1");
|
||||
defer stmt.finalize();
|
||||
|
||||
while (try stmt.step()) {
|
||||
const id = stmt.getInt(0);
|
||||
const url = stmt.getText(1) orelse continue;
|
||||
const label = stmt.getText(2) orelse url;
|
||||
|
||||
std.log.info("Updating denylist: {s}", .{label});
|
||||
|
||||
const url_copy = try self.allocator.dupe(u8, url);
|
||||
defer self.allocator.free(url_copy);
|
||||
|
||||
const count = self.fetchAndUpdate(id, url_copy) catch |err| {
|
||||
std.log.warn("Failed to update denylist {s}: {}", .{ label, err });
|
||||
continue;
|
||||
};
|
||||
|
||||
std.log.info("Denylist {s}: {d} domains", .{ label, count });
|
||||
}
|
||||
|
||||
// Signal that denylist needs to be reloaded into memory
|
||||
events.signalDenylistReload();
|
||||
}
|
||||
|
||||
/// Fetch denylists that have 0 domains (incomplete/interrupted fetches)
|
||||
/// Called at startup to resume any denylists that were mid-fetch when server stopped
|
||||
pub fn fetchIncomplete(self: *DenylistFetcher) !usize {
|
||||
var stmt = try self.db.prepare(
|
||||
"SELECT id, url, comment FROM denylist_sources WHERE enabled = 1 AND domain_count = 0",
|
||||
);
|
||||
defer stmt.finalize();
|
||||
|
||||
var fetched: usize = 0;
|
||||
|
||||
while (try stmt.step()) {
|
||||
const id = stmt.getInt(0);
|
||||
const url = stmt.getText(1) orelse continue;
|
||||
const label = stmt.getText(2) orelse url;
|
||||
|
||||
std.log.info("Resuming incomplete denylist: {s}", .{label});
|
||||
|
||||
const url_copy = try self.allocator.dupe(u8, url);
|
||||
defer self.allocator.free(url_copy);
|
||||
|
||||
const count = self.fetchAndUpdate(id, url_copy) catch |err| {
|
||||
std.log.warn("Failed to fetch denylist {s}: {}", .{ label, err });
|
||||
continue;
|
||||
};
|
||||
|
||||
std.log.info("Denylist {s}: {d} domains", .{ label, count });
|
||||
fetched += 1;
|
||||
}
|
||||
|
||||
if (fetched > 0) {
|
||||
events.signalDenylistReload();
|
||||
}
|
||||
|
||||
return fetched;
|
||||
}
|
||||
};
|
||||
|
||||
test "detect hosts format" {
|
||||
const testing = std.testing;
|
||||
|
||||
const hosts_content =
|
||||
\\# Comment
|
||||
\\0.0.0.0 ads.example.com
|
||||
\\127.0.0.1 tracker.example.com
|
||||
\\0.0.0.0 malware.example.com
|
||||
;
|
||||
|
||||
const format = DenylistFormat.detect(hosts_content);
|
||||
try testing.expectEqual(DenylistFormat.hosts, format);
|
||||
}
|
||||
|
||||
test "detect adblock format" {
|
||||
const testing = std.testing;
|
||||
|
||||
const adblock_content =
|
||||
\\! AdBlock list
|
||||
\\||ads.example.com^
|
||||
\\||tracker.example.com^$third-party
|
||||
\\||malware.example.com^
|
||||
;
|
||||
|
||||
const format = DenylistFormat.detect(adblock_content);
|
||||
try testing.expectEqual(DenylistFormat.adblock, format);
|
||||
}
|
||||
|
||||
test "parse hosts format" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
const content =
|
||||
\\# Comment
|
||||
\\0.0.0.0 ads.example.com
|
||||
\\127.0.0.1 tracker.example.com
|
||||
\\0.0.0.0 localhost
|
||||
;
|
||||
|
||||
var result = try parseDenylist(content, .hosts, allocator);
|
||||
defer {
|
||||
for (result.domains.items) |d| allocator.free(d);
|
||||
result.domains.deinit(allocator);
|
||||
}
|
||||
|
||||
try testing.expectEqual(@as(usize, 2), result.domains.items.len);
|
||||
try testing.expectEqualStrings("ads.example.com", result.domains.items[0]);
|
||||
try testing.expectEqualStrings("tracker.example.com", result.domains.items[1]);
|
||||
}
|
||||
|
||||
test "parse adblock format" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
const content =
|
||||
\\! Comment
|
||||
\\||ads.example.com^
|
||||
\\||tracker.example.com^$third-party
|
||||
;
|
||||
|
||||
var result = try parseDenylist(content, .adblock, allocator);
|
||||
defer {
|
||||
for (result.domains.items) |d| allocator.free(d);
|
||||
result.domains.deinit(allocator);
|
||||
}
|
||||
|
||||
try testing.expectEqual(@as(usize, 2), result.domains.items.len);
|
||||
try testing.expectEqualStrings("ads.example.com", result.domains.items[0]);
|
||||
try testing.expectEqualStrings("tracker.example.com", result.domains.items[1]);
|
||||
}
|
||||
|
||||
test "parse domain list format" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
const content =
|
||||
\\# Comment
|
||||
\\ads.example.com
|
||||
\\tracker.example.com # inline comment
|
||||
\\malware.example.com
|
||||
;
|
||||
|
||||
var result = try parseDenylist(content, .domains, allocator);
|
||||
defer {
|
||||
for (result.domains.items) |d| allocator.free(d);
|
||||
result.domains.deinit(allocator);
|
||||
}
|
||||
|
||||
try testing.expectEqual(@as(usize, 3), result.domains.items.len);
|
||||
}
|
||||
|
||||
test "parse tracks invalid domains" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
const content =
|
||||
\\# Comment
|
||||
\\valid.example.com
|
||||
\\-invalid-start.com
|
||||
\\also.valid.org
|
||||
\\toolong12345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345.com
|
||||
;
|
||||
|
||||
var result = try parseDenylist(content, .domains, allocator);
|
||||
defer {
|
||||
for (result.domains.items) |d| allocator.free(d);
|
||||
result.domains.deinit(allocator);
|
||||
}
|
||||
|
||||
try testing.expectEqual(@as(usize, 2), result.domains.items.len);
|
||||
try testing.expectEqual(@as(usize, 2), result.invalid_count);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
const std = @import("std");
|
||||
const toLower = @import("../util.zig").toLower;
|
||||
|
||||
/// Safe search enforcement - rewrites queries to force safe search
|
||||
/// Based on PLAN.md specifications
|
||||
|
||||
/// Safe search rewrite rule
|
||||
const SafeSearchRewrite = struct {
|
||||
from: []const u8,
|
||||
to: []const u8,
|
||||
};
|
||||
|
||||
/// Google safe search rewrites
|
||||
const GOOGLE_REWRITES = [_]SafeSearchRewrite{
|
||||
.{ .from = "www.google.com", .to = "forcesafesearch.google.com" },
|
||||
.{ .from = "www.google.co.uk", .to = "forcesafesearch.google.com" },
|
||||
.{ .from = "www.google.ca", .to = "forcesafesearch.google.com" },
|
||||
.{ .from = "www.google.com.au", .to = "forcesafesearch.google.com" },
|
||||
.{ .from = "www.google.de", .to = "forcesafesearch.google.com" },
|
||||
.{ .from = "www.google.fr", .to = "forcesafesearch.google.com" },
|
||||
.{ .from = "www.google.es", .to = "forcesafesearch.google.com" },
|
||||
.{ .from = "www.google.it", .to = "forcesafesearch.google.com" },
|
||||
.{ .from = "www.google.nl", .to = "forcesafesearch.google.com" },
|
||||
.{ .from = "www.google.be", .to = "forcesafesearch.google.com" },
|
||||
.{ .from = "www.google.ch", .to = "forcesafesearch.google.com" },
|
||||
.{ .from = "www.google.at", .to = "forcesafesearch.google.com" },
|
||||
.{ .from = "www.google.pl", .to = "forcesafesearch.google.com" },
|
||||
.{ .from = "www.google.se", .to = "forcesafesearch.google.com" },
|
||||
.{ .from = "www.google.no", .to = "forcesafesearch.google.com" },
|
||||
.{ .from = "www.google.dk", .to = "forcesafesearch.google.com" },
|
||||
.{ .from = "www.google.fi", .to = "forcesafesearch.google.com" },
|
||||
.{ .from = "www.google.pt", .to = "forcesafesearch.google.com" },
|
||||
.{ .from = "www.google.ru", .to = "forcesafesearch.google.com" },
|
||||
.{ .from = "www.google.co.jp", .to = "forcesafesearch.google.com" },
|
||||
.{ .from = "www.google.co.kr", .to = "forcesafesearch.google.com" },
|
||||
.{ .from = "www.google.com.br", .to = "forcesafesearch.google.com" },
|
||||
.{ .from = "www.google.com.mx", .to = "forcesafesearch.google.com" },
|
||||
.{ .from = "www.google.co.in", .to = "forcesafesearch.google.com" },
|
||||
.{ .from = "www.google.co.nz", .to = "forcesafesearch.google.com" },
|
||||
.{ .from = "www.google.co.za", .to = "forcesafesearch.google.com" },
|
||||
};
|
||||
|
||||
/// Bing safe search rewrites
|
||||
const BING_REWRITES = [_]SafeSearchRewrite{
|
||||
.{ .from = "www.bing.com", .to = "strict.bing.com" },
|
||||
.{ .from = "bing.com", .to = "strict.bing.com" },
|
||||
};
|
||||
|
||||
/// YouTube safe search rewrites (restrictmoderate for moderate, restrict for strict)
|
||||
const YOUTUBE_REWRITES = [_]SafeSearchRewrite{
|
||||
.{ .from = "www.youtube.com", .to = "restrictmoderate.youtube.com" },
|
||||
.{ .from = "youtube.com", .to = "restrictmoderate.youtube.com" },
|
||||
.{ .from = "m.youtube.com", .to = "restrictmoderate.youtube.com" },
|
||||
.{ .from = "youtubei.googleapis.com", .to = "restrictmoderate.youtube.com" },
|
||||
.{ .from = "youtube.googleapis.com", .to = "restrictmoderate.youtube.com" },
|
||||
.{ .from = "www.youtube-nocookie.com", .to = "restrictmoderate.youtube.com" },
|
||||
};
|
||||
|
||||
/// DuckDuckGo safe search rewrites
|
||||
const DUCKDUCKGO_REWRITES = [_]SafeSearchRewrite{
|
||||
.{ .from = "duckduckgo.com", .to = "safe.duckduckgo.com" },
|
||||
.{ .from = "www.duckduckgo.com", .to = "safe.duckduckgo.com" },
|
||||
};
|
||||
|
||||
/// Pixabay safe search rewrites
|
||||
const PIXABAY_REWRITES = [_]SafeSearchRewrite{
|
||||
.{ .from = "pixabay.com", .to = "safesearch.pixabay.com" },
|
||||
};
|
||||
|
||||
/// Check if a domain should be rewritten for safe search
|
||||
/// Returns the safe search domain if a rewrite is needed, null otherwise
|
||||
pub fn applySafeSearch(domain: []const u8) ?[]const u8 {
|
||||
// Lowercase the domain for case-insensitive matching
|
||||
var lower_buf: [256]u8 = undefined;
|
||||
const lower = toLower(domain, &lower_buf) orelse return null;
|
||||
|
||||
// Check all rewrite rules
|
||||
inline for (GOOGLE_REWRITES) |rule| {
|
||||
if (std.mem.eql(u8, lower, rule.from)) return rule.to;
|
||||
}
|
||||
inline for (BING_REWRITES) |rule| {
|
||||
if (std.mem.eql(u8, lower, rule.from)) return rule.to;
|
||||
}
|
||||
inline for (YOUTUBE_REWRITES) |rule| {
|
||||
if (std.mem.eql(u8, lower, rule.from)) return rule.to;
|
||||
}
|
||||
inline for (DUCKDUCKGO_REWRITES) |rule| {
|
||||
if (std.mem.eql(u8, lower, rule.from)) return rule.to;
|
||||
}
|
||||
inline for (PIXABAY_REWRITES) |rule| {
|
||||
if (std.mem.eql(u8, lower, rule.from)) return rule.to;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get safe search CNAME target for a domain
|
||||
/// Used to create a CNAME response that redirects to safe search
|
||||
pub fn getSafeSearchCname(domain: []const u8) ?[]const u8 {
|
||||
return applySafeSearch(domain);
|
||||
}
|
||||
|
||||
/// Check if safe search is applicable to this domain (for logging)
|
||||
pub fn isSafeSearchDomain(domain: []const u8) bool {
|
||||
return applySafeSearch(domain) != null;
|
||||
}
|
||||
|
||||
test "safe search Google" {
|
||||
const testing = std.testing;
|
||||
|
||||
try testing.expectEqualStrings("forcesafesearch.google.com", applySafeSearch("www.google.com").?);
|
||||
try testing.expectEqualStrings("forcesafesearch.google.com", applySafeSearch("WWW.GOOGLE.COM").?);
|
||||
try testing.expectEqualStrings("forcesafesearch.google.com", applySafeSearch("www.google.co.uk").?);
|
||||
}
|
||||
|
||||
test "safe search Bing" {
|
||||
const testing = std.testing;
|
||||
|
||||
try testing.expectEqualStrings("strict.bing.com", applySafeSearch("www.bing.com").?);
|
||||
try testing.expectEqualStrings("strict.bing.com", applySafeSearch("bing.com").?);
|
||||
}
|
||||
|
||||
test "safe search YouTube" {
|
||||
const testing = std.testing;
|
||||
|
||||
try testing.expectEqualStrings("restrictmoderate.youtube.com", applySafeSearch("www.youtube.com").?);
|
||||
try testing.expectEqualStrings("restrictmoderate.youtube.com", applySafeSearch("m.youtube.com").?);
|
||||
}
|
||||
|
||||
test "safe search DuckDuckGo" {
|
||||
const testing = std.testing;
|
||||
|
||||
try testing.expectEqualStrings("safe.duckduckgo.com", applySafeSearch("duckduckgo.com").?);
|
||||
}
|
||||
|
||||
test "safe search non-matching" {
|
||||
const testing = std.testing;
|
||||
|
||||
try testing.expect(applySafeSearch("example.com") == null);
|
||||
try testing.expect(applySafeSearch("google.com") == null); // Note: no www
|
||||
try testing.expect(applySafeSearch("mail.google.com") == null);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
const std = @import("std");
|
||||
const posix = std.posix;
|
||||
const fs = std.fs;
|
||||
|
||||
/// Log output destination
|
||||
pub const LogOutput = union(enum) {
|
||||
stderr: void,
|
||||
syslog: void,
|
||||
file: fs.File,
|
||||
};
|
||||
|
||||
/// Global logger state
|
||||
pub const Logger = struct {
|
||||
output: LogOutput,
|
||||
level: std.log.Level,
|
||||
mutex: std.Thread.Mutex,
|
||||
|
||||
/// Global logger instance
|
||||
var global: ?*Logger = null;
|
||||
|
||||
pub fn init(output_str: []const u8, level: std.log.Level) !Logger {
|
||||
const output: LogOutput = if (std.mem.eql(u8, output_str, "stderr"))
|
||||
.stderr
|
||||
else if (std.mem.eql(u8, output_str, "syslog"))
|
||||
.syslog
|
||||
else blk: {
|
||||
// Treat as file path
|
||||
const file = try fs.cwd().createFile(output_str, .{
|
||||
.truncate = false,
|
||||
});
|
||||
// Seek to end for append - if this fails, we'll overwrite from start (not ideal but not fatal)
|
||||
file.seekFromEnd(0) catch {};
|
||||
break :blk LogOutput{ .file = file };
|
||||
};
|
||||
|
||||
return Logger{
|
||||
.output = output,
|
||||
.level = level,
|
||||
.mutex = std.Thread.Mutex{},
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Logger) void {
|
||||
switch (self.output) {
|
||||
.file => |f| f.close(),
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
|
||||
/// Set this logger as the global logger
|
||||
pub fn setGlobal(self: *Logger) void {
|
||||
global = self;
|
||||
}
|
||||
|
||||
/// Clear the global logger
|
||||
pub fn clearGlobal() void {
|
||||
global = null;
|
||||
}
|
||||
|
||||
/// Write a log message
|
||||
pub fn write(self: *Logger, level: std.log.Level, scope: []const u8, message: []const u8) void {
|
||||
self.mutex.lock();
|
||||
defer self.mutex.unlock();
|
||||
|
||||
switch (self.output) {
|
||||
.stderr => self.writeStderr(level, scope, message),
|
||||
.syslog => self.writeSyslog(level, message),
|
||||
.file => |f| self.writeFile(f, level, scope, message),
|
||||
}
|
||||
}
|
||||
|
||||
fn writeStderr(self: *Logger, level: std.log.Level, scope: []const u8, message: []const u8) void {
|
||||
_ = self;
|
||||
const level_str = switch (level) {
|
||||
.err => "error",
|
||||
.warn => "warning",
|
||||
.info => "info",
|
||||
.debug => "debug",
|
||||
};
|
||||
|
||||
// Get current time
|
||||
const ts = std.time.timestamp();
|
||||
const epoch_secs: std.time.epoch.EpochSeconds = .{ .secs = @intCast(ts) };
|
||||
const day_secs = epoch_secs.getDaySeconds();
|
||||
const hours = day_secs.getHoursIntoDay();
|
||||
const minutes = day_secs.getMinutesIntoHour();
|
||||
const seconds = day_secs.getSecondsIntoMinute();
|
||||
|
||||
var buf: [4096]u8 = undefined;
|
||||
const formatted = if (scope.len > 0 and !std.mem.eql(u8, scope, "default"))
|
||||
std.fmt.bufPrint(&buf, "{d:0>2}:{d:0>2}:{d:0>2} {s}({s}): {s}\n", .{ hours, minutes, seconds, level_str, scope, message }) catch return
|
||||
else
|
||||
std.fmt.bufPrint(&buf, "{d:0>2}:{d:0>2}:{d:0>2} {s}: {s}\n", .{ hours, minutes, seconds, level_str, message }) catch return;
|
||||
|
||||
// Intentionally silent - if stderr write fails, there's nowhere else to report it
|
||||
_ = posix.write(posix.STDERR_FILENO, formatted) catch {};
|
||||
}
|
||||
|
||||
fn writeFile(self: *Logger, file: fs.File, level: std.log.Level, scope: []const u8, message: []const u8) void {
|
||||
_ = self;
|
||||
const timestamp = std.time.timestamp();
|
||||
const level_str = switch (level) {
|
||||
.err => "ERROR",
|
||||
.warn => "WARN",
|
||||
.info => "INFO",
|
||||
.debug => "DEBUG",
|
||||
};
|
||||
|
||||
var buf: [4096]u8 = undefined;
|
||||
const formatted = if (scope.len > 0 and !std.mem.eql(u8, scope, "default"))
|
||||
std.fmt.bufPrint(&buf, "{d} [{s}] ({s}): {s}\n", .{ timestamp, level_str, scope, message }) catch return
|
||||
else
|
||||
std.fmt.bufPrint(&buf, "{d} [{s}]: {s}\n", .{ timestamp, level_str, message }) catch return;
|
||||
|
||||
// Intentionally silent - logging failures shouldn't crash the application
|
||||
// If file write fails repeatedly, the application will continue but logs will be lost
|
||||
_ = posix.write(file.handle, formatted) catch {};
|
||||
}
|
||||
|
||||
fn writeSyslog(self: *Logger, level: std.log.Level, message: []const u8) void {
|
||||
_ = self;
|
||||
// Syslog priority: facility (user = 1) * 8 + severity
|
||||
const severity: u8 = switch (level) {
|
||||
.err => 3, // error
|
||||
.warn => 4, // warning
|
||||
.info => 6, // informational
|
||||
.debug => 7, // debug
|
||||
};
|
||||
const priority = (1 << 3) | severity; // USER facility
|
||||
|
||||
// Create socket and send to /dev/log
|
||||
const sock = posix.socket(posix.AF.UNIX, posix.SOCK.DGRAM, 0) catch return;
|
||||
defer posix.close(sock);
|
||||
|
||||
var addr: posix.sockaddr.un = .{
|
||||
.family = posix.AF.UNIX,
|
||||
.path = undefined,
|
||||
};
|
||||
const path = "/dev/log";
|
||||
@memcpy(addr.path[0..path.len], path);
|
||||
addr.path[path.len] = 0;
|
||||
|
||||
// Format: <priority>message
|
||||
var buf: [1024]u8 = undefined;
|
||||
const formatted = std.fmt.bufPrint(&buf, "<{d}>nxdns: {s}", .{ priority, message }) catch return;
|
||||
|
||||
// Intentionally silent - syslog failures shouldn't crash the application
|
||||
_ = posix.sendto(
|
||||
sock,
|
||||
formatted,
|
||||
0,
|
||||
@ptrCast(&addr),
|
||||
@sizeOf(posix.sockaddr.un),
|
||||
) catch {};
|
||||
}
|
||||
|
||||
/// Custom log function compatible with std.options.logFn
|
||||
pub fn log(
|
||||
comptime level: std.log.Level,
|
||||
comptime scope: @TypeOf(.enum_literal),
|
||||
comptime format: []const u8,
|
||||
args: anytype,
|
||||
) void {
|
||||
const logger = global orelse {
|
||||
// Fall back to default logging
|
||||
std.log.defaultLog(level, scope, format, args);
|
||||
return;
|
||||
};
|
||||
|
||||
// Check log level
|
||||
if (@intFromEnum(level) > @intFromEnum(logger.level)) return;
|
||||
|
||||
// Format the message
|
||||
var buf: [4096]u8 = undefined;
|
||||
const message = std.fmt.bufPrint(&buf, format, args) catch return;
|
||||
|
||||
const scope_name = @tagName(scope);
|
||||
logger.write(level, scope_name, message);
|
||||
}
|
||||
};
|
||||
|
||||
/// Convert config log level to std.log.Level
|
||||
pub fn configLevelToStd(level: anytype) std.log.Level {
|
||||
return switch (level) {
|
||||
.debug => .debug,
|
||||
.info => .info,
|
||||
.warn => .warn,
|
||||
.err => .err,
|
||||
};
|
||||
}
|
||||
|
||||
test "Logger stderr" {
|
||||
var logger = try Logger.init("stderr", .info);
|
||||
defer logger.deinit();
|
||||
|
||||
logger.write(.info, "test", "Hello from test");
|
||||
}
|
||||
+533
@@ -0,0 +1,533 @@
|
||||
const std = @import("std");
|
||||
const net = std.net;
|
||||
const fs = std.fs;
|
||||
|
||||
// Logging module
|
||||
const app_logger = @import("logging/logger.zig");
|
||||
|
||||
// Override std.log to use our custom logger
|
||||
pub const std_options: std.Options = .{
|
||||
.logFn = app_logger.Logger.log,
|
||||
};
|
||||
|
||||
// DNS modules
|
||||
pub const dns = struct {
|
||||
pub const types = @import("dns/types.zig");
|
||||
pub const header = @import("dns/header.zig");
|
||||
pub const name = @import("dns/name.zig");
|
||||
pub const question = @import("dns/question.zig");
|
||||
pub const record = @import("dns/record.zig");
|
||||
pub const edns = @import("dns/edns.zig");
|
||||
pub const packet = @import("dns/packet.zig");
|
||||
};
|
||||
|
||||
// Server modules
|
||||
const udp_server = @import("server/udp.zig");
|
||||
const tcp_server = @import("server/tcp.zig");
|
||||
const handler_mod = @import("server/handler.zig");
|
||||
const shutdown = @import("server/shutdown.zig");
|
||||
const rate_limiter_mod = @import("server/rate_limiter.zig");
|
||||
|
||||
// Upstream modules
|
||||
const pool = @import("upstream/pool.zig");
|
||||
|
||||
// Storage modules
|
||||
const db_mod = @import("storage/db.zig");
|
||||
const schema = @import("storage/schema.zig");
|
||||
const logger_mod = @import("storage/logger.zig");
|
||||
|
||||
// Filter modules
|
||||
const denylist_mod = @import("filter/denylist.zig");
|
||||
const fetcher_mod = @import("filter/fetcher.zig");
|
||||
|
||||
// Cache module
|
||||
const cache_mod = @import("cache/dns_cache.zig");
|
||||
|
||||
// Config module
|
||||
const config_mod = @import("config/config.zig");
|
||||
const watcher_mod = @import("config/watcher.zig");
|
||||
|
||||
// Events module
|
||||
const events = @import("events.zig");
|
||||
|
||||
// Web module
|
||||
const web_server = @import("web/server.zig");
|
||||
|
||||
const VERSION = "0.1.0";
|
||||
|
||||
/// Context for config reload callback (needed because callback is a simple fn pointer)
|
||||
const ReloadContext = struct {
|
||||
db: *db_mod.Database,
|
||||
denylist: *denylist_mod.ThreadSafeDenylist,
|
||||
config_path: []const u8,
|
||||
allocator: std.mem.Allocator,
|
||||
};
|
||||
|
||||
/// Context for log cleanup thread
|
||||
const CleanupContext = struct {
|
||||
db: *db_mod.Database,
|
||||
retention_seconds: i64,
|
||||
coordinator: *shutdown.ShutdownCoordinator,
|
||||
};
|
||||
|
||||
/// Daily log cleanup thread - runs cleanup every 24 hours
|
||||
fn logCleanupThread(ctx: *CleanupContext) void {
|
||||
const day_ms: i32 = 24 * 60 * 60 * 1000;
|
||||
|
||||
while (!ctx.coordinator.isShutdownRequested()) {
|
||||
// Block until shutdown or 24 hours pass
|
||||
if (ctx.coordinator.wait(day_ms)) break;
|
||||
|
||||
std.log.info("Running daily log cleanup...", .{});
|
||||
logger_mod.cleanupOldLogs(ctx.db, ctx.retention_seconds) catch |err| {
|
||||
std.log.warn("Daily log cleanup failed: {}", .{err});
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Context for denylist auto-update thread
|
||||
const AutoUpdateContext = struct {
|
||||
db: *db_mod.Database,
|
||||
coordinator: *shutdown.ShutdownCoordinator,
|
||||
allocator: std.mem.Allocator,
|
||||
};
|
||||
|
||||
/// Denylist auto-update thread - fetches updates daily
|
||||
fn autoUpdateThread(ctx: *AutoUpdateContext) void {
|
||||
const update_interval_ms: i32 = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
while (!ctx.coordinator.isShutdownRequested()) {
|
||||
// Wait until shutdown or 24 hours pass
|
||||
if (ctx.coordinator.wait(update_interval_ms)) break;
|
||||
|
||||
std.log.info("Running scheduled denylist update...", .{});
|
||||
var fetcher = fetcher_mod.DenylistFetcher.init(ctx.db, ctx.allocator);
|
||||
fetcher.updateAll() catch |err| {
|
||||
std.log.warn("Scheduled denylist update failed: {}", .{err});
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Atomic storage for reload context (accessed from multiple threads)
|
||||
var config_reload_ctx: std.atomic.Value(?*ReloadContext) = .{ .raw = null };
|
||||
|
||||
/// Public function to reload denylist from database
|
||||
/// Called after denylist updates via web UI
|
||||
pub fn reloadDenylist() void {
|
||||
const ctx = config_reload_ctx.load(.acquire) orelse {
|
||||
std.log.warn("Denylist reload called but context is null", .{});
|
||||
return;
|
||||
};
|
||||
|
||||
std.log.info("Reloading denylist from database...", .{});
|
||||
|
||||
const new_denylist = denylist_mod.loadFromDatabase(ctx.db, ctx.allocator) catch |err| {
|
||||
std.log.err("Failed to reload denylist: {}", .{err});
|
||||
return;
|
||||
};
|
||||
|
||||
var old_denylist = ctx.denylist.swap(new_denylist);
|
||||
old_denylist.deinit();
|
||||
|
||||
std.log.info("Denylist reloaded successfully", .{});
|
||||
}
|
||||
|
||||
/// Callback triggered when config file changes
|
||||
fn configReloadCallback() void {
|
||||
const ctx = config_reload_ctx.load(.acquire) orelse {
|
||||
std.log.warn("Config reload callback called but context is null", .{});
|
||||
return;
|
||||
};
|
||||
|
||||
std.log.info("Reloading configuration...", .{});
|
||||
|
||||
// Reload config
|
||||
var new_config = config_mod.Config.load(ctx.config_path, ctx.allocator) catch |err| {
|
||||
std.log.err("Failed to reload config: {}", .{err});
|
||||
return;
|
||||
};
|
||||
defer new_config.deinit();
|
||||
|
||||
new_config.validate() catch |err| {
|
||||
std.log.err("New config is invalid: {}", .{err});
|
||||
return;
|
||||
};
|
||||
|
||||
// Reload denylist from database
|
||||
const new_denylist = denylist_mod.loadFromDatabase(ctx.db, ctx.allocator) catch |err| {
|
||||
std.log.err("Failed to reload denylist: {}", .{err});
|
||||
return;
|
||||
};
|
||||
|
||||
// Atomically swap denylists using RwLock (thread-safe)
|
||||
// The swap acquires a write lock, ensuring no readers are active
|
||||
var old_denylist = ctx.denylist.swap(new_denylist);
|
||||
|
||||
// Clean up old denylist after swap is complete
|
||||
// At this point, no readers can access the old denylist
|
||||
old_denylist.deinit();
|
||||
|
||||
std.log.info("Configuration reloaded successfully", .{});
|
||||
}
|
||||
|
||||
pub fn main() !void {
|
||||
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
|
||||
defer _ = gpa.deinit();
|
||||
const allocator = gpa.allocator();
|
||||
|
||||
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(allocator);
|
||||
} else if (std.mem.eql(u8, command, "check")) {
|
||||
try checkConfig(allocator);
|
||||
} else if (std.mem.eql(u8, command, "version")) {
|
||||
printVersion();
|
||||
} else if (std.mem.eql(u8, command, "help") or std.mem.eql(u8, command, "-h") or std.mem.eql(u8, command, "--help")) {
|
||||
printUsage();
|
||||
} else {
|
||||
std.log.err("Unknown command: {s}", .{command});
|
||||
printUsage();
|
||||
}
|
||||
}
|
||||
|
||||
fn printUsage() void {
|
||||
std.debug.print(
|
||||
\\nxdns - DNS sinkhole
|
||||
\\
|
||||
\\Usage: nxdns <command>
|
||||
\\
|
||||
\\Commands:
|
||||
\\ run Start the DNS server
|
||||
\\ check Validate configuration
|
||||
\\ version Show version information
|
||||
\\ help Show this help message
|
||||
\\
|
||||
\\Environment Variables:
|
||||
\\ NXDNS_CONFIG Path to configuration file
|
||||
\\
|
||||
, .{});
|
||||
}
|
||||
|
||||
fn printVersion() void {
|
||||
std.debug.print("nxdns version {s}\n", .{VERSION});
|
||||
}
|
||||
|
||||
fn getConfigPath() []const u8 {
|
||||
return std.posix.getenv("NXDNS_CONFIG") orelse "/etc/nxdns/config.toml";
|
||||
}
|
||||
|
||||
fn runServer(allocator: std.mem.Allocator) !void {
|
||||
std.debug.print("nxdns v{s} starting...\n", .{VERSION});
|
||||
|
||||
// Load configuration
|
||||
const config_path = getConfigPath();
|
||||
var config = try config_mod.Config.load(config_path, allocator);
|
||||
defer config.deinit();
|
||||
|
||||
try config.validate();
|
||||
std.debug.print("Configuration loaded from {s}\n", .{config_path});
|
||||
|
||||
// Initialize logging
|
||||
const log_level = app_logger.configLevelToStd(config.logging.level);
|
||||
var logger = app_logger.Logger.init(config.logging.output, log_level) catch |err| blk: {
|
||||
std.debug.print("Warning: Failed to initialize logger: {}, using stderr\n", .{err});
|
||||
break :blk app_logger.Logger.init("stderr", log_level) catch |e| {
|
||||
std.debug.print("Fatal: Cannot initialize logging to stderr: {}\n", .{e});
|
||||
return e;
|
||||
};
|
||||
};
|
||||
defer logger.deinit();
|
||||
logger.setGlobal();
|
||||
defer app_logger.Logger.clearGlobal();
|
||||
|
||||
// Open database
|
||||
var db = try db_mod.Database.open(config.database_path, allocator);
|
||||
defer db.close();
|
||||
|
||||
// Run migrations
|
||||
try schema.migrate(&db);
|
||||
std.debug.print("Database initialized at {s}\n", .{config.database_path});
|
||||
|
||||
// Cleanup old logs based on retention policy
|
||||
const retention_seconds = config.logging.retentionSeconds();
|
||||
logger_mod.cleanupOldLogs(&db, retention_seconds) catch |err| {
|
||||
std.log.warn("Failed to cleanup old logs: {}", .{err});
|
||||
};
|
||||
|
||||
// Initialize denylist with thread-safe wrapper
|
||||
var denylist = try denylist_mod.loadFromDatabase(&db, allocator);
|
||||
defer denylist.deinit();
|
||||
var safe_denylist = denylist_mod.ThreadSafeDenylist.init(&denylist, allocator);
|
||||
std.debug.print("Denylist loaded ({} domains)\n", .{denylist.denied_domains.count()});
|
||||
|
||||
// Resume any incomplete denylist fetches from previous run
|
||||
{
|
||||
var fetcher = fetcher_mod.DenylistFetcher.init(&db, allocator);
|
||||
const resumed = fetcher.fetchIncomplete() catch |err| blk: {
|
||||
std.log.warn("Failed to resume incomplete denylists: {}", .{err});
|
||||
break :blk 0;
|
||||
};
|
||||
if (resumed > 0) {
|
||||
// Reload denylist with newly fetched domains
|
||||
var new_denylist = denylist_mod.loadFromDatabase(&db, allocator) catch |err| {
|
||||
std.log.err("Failed to reload denylist after resume: {}", .{err});
|
||||
return err;
|
||||
};
|
||||
var old = safe_denylist.swap(new_denylist);
|
||||
old.deinit();
|
||||
std.debug.print("Denylist updated after resume ({} domains)\n", .{new_denylist.denied_domains.count()});
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize cache
|
||||
var cache = cache_mod.DnsCache.initWithConfig(
|
||||
allocator,
|
||||
config.dns.cache_size,
|
||||
cache_mod.DnsCache.DEFAULT_MIN_TTL,
|
||||
cache_mod.DnsCache.DEFAULT_MAX_TTL,
|
||||
);
|
||||
defer cache.deinit();
|
||||
std.debug.print("DNS cache initialized (max {} entries)\n", .{config.dns.cache_size});
|
||||
|
||||
// Initialize query logger
|
||||
var query_logger = logger_mod.QueryLogger.init(&db, allocator);
|
||||
defer query_logger.deinit();
|
||||
|
||||
// Initialize upstream pool
|
||||
const upstream_configs = try allocator.alloc(pool.UpstreamConfig, config.upstream.servers.len);
|
||||
defer allocator.free(upstream_configs);
|
||||
|
||||
for (config.upstream.servers, 0..) |server, i| {
|
||||
upstream_configs[i] = pool.UpstreamConfig{
|
||||
.address = server,
|
||||
};
|
||||
}
|
||||
|
||||
var upstream = try pool.UpstreamPool.init(upstream_configs, allocator);
|
||||
defer upstream.deinit();
|
||||
std.debug.print("Upstream DNS configured ({} servers)\n", .{config.upstream.servers.len});
|
||||
|
||||
// Initialize rate limiter
|
||||
var limiter = rate_limiter_mod.RateLimiter.initWithConfig(allocator, .{
|
||||
.max_qps = 20,
|
||||
.enabled = true,
|
||||
});
|
||||
defer limiter.deinit();
|
||||
std.debug.print("Rate limiting enabled (20 qps per client)\n", .{});
|
||||
|
||||
// Initialize handler
|
||||
var handler = handler_mod.Handler.init(allocator);
|
||||
handler.setDenylist(safe_denylist.toHandlerDenylist());
|
||||
handler.setCache(cache.toHandlerCache());
|
||||
handler.setUpstream(upstream.toHandlerUpstream());
|
||||
handler.setLogger(query_logger.toHandlerLogger());
|
||||
handler.setRateLimiter(&limiter);
|
||||
handler.config.blocking_response = switch (config.blocking.response) {
|
||||
.zero => .zero,
|
||||
.nxdomain => .nxdomain,
|
||||
};
|
||||
|
||||
// Start DNS servers
|
||||
const dns_addr = try net.Address.parseIp(config.dns.bind, config.dns.port);
|
||||
|
||||
var udp_handler = handler.toUdpHandler();
|
||||
var udp = try udp_server.UdpServer.initWithConfig(dns_addr, &udp_handler, allocator, .{
|
||||
.num_workers = config.dns.workers,
|
||||
});
|
||||
defer udp.deinit();
|
||||
|
||||
var tcp_handler = handler.toTcpHandler();
|
||||
var tcp = try tcp_server.TcpServer.initWithConfig(dns_addr, &tcp_handler, allocator, .{
|
||||
.num_workers = config.dns.workers,
|
||||
});
|
||||
defer tcp.deinit();
|
||||
|
||||
std.debug.print("DNS server listening on {}:{}\n", .{ dns_addr.in.sa.addr, config.dns.port });
|
||||
|
||||
// Start web server
|
||||
const web_addr = try net.Address.parseIp(config.web.bind, config.web.port);
|
||||
var web = try web_server.WebServer.init(web_addr, &db, allocator);
|
||||
defer web.deinit();
|
||||
std.debug.print("Web UI available at http://{}:{}\n", .{ web_addr.in.sa.addr, config.web.port });
|
||||
|
||||
// Wire up SSE subscriber for real-time query log streaming
|
||||
query_logger.setSubscriber(web.toLoggerSubscriber());
|
||||
|
||||
// Set up shutdown coordinator (owns signalfd for SIGINT/SIGTERM)
|
||||
var coordinator = shutdown.ShutdownCoordinator.init();
|
||||
defer coordinator.deinit();
|
||||
|
||||
// Initialize event signaling for denylist reloads
|
||||
try events.initDenylistEvent();
|
||||
defer events.deinitDenylistEvent();
|
||||
|
||||
// Start config file watcher for hot reload
|
||||
var config_watcher: ?watcher_mod.ConfigWatcher = null;
|
||||
|
||||
// Store pointers for the reload callback
|
||||
var reload_context = ReloadContext{
|
||||
.db = &db,
|
||||
.denylist = &safe_denylist,
|
||||
.config_path = config_path,
|
||||
.allocator = allocator,
|
||||
};
|
||||
config_reload_ctx.store(&reload_context, .release);
|
||||
|
||||
// Context for daily log cleanup
|
||||
var cleanup_context = CleanupContext{
|
||||
.db = &db,
|
||||
.retention_seconds = retention_seconds,
|
||||
.coordinator = &coordinator,
|
||||
};
|
||||
|
||||
// Context for denylist auto-updates (if enabled)
|
||||
var auto_update_context = AutoUpdateContext{
|
||||
.db = &db,
|
||||
.coordinator = &coordinator,
|
||||
.allocator = allocator,
|
||||
};
|
||||
|
||||
if (watcher_mod.ConfigWatcher.init(config_path, allocator, &coordinator)) |watcher| {
|
||||
config_watcher = watcher;
|
||||
config_watcher.?.setConfigReloadCallback(configReloadCallback);
|
||||
config_watcher.?.setDenylistReloadCallback(reloadDenylist);
|
||||
std.debug.print("Config watcher started (inotify)\n", .{});
|
||||
} else |err| {
|
||||
std.log.warn("Failed to start config watcher: {} - config hot reload disabled", .{err});
|
||||
}
|
||||
defer if (config_watcher) |*w| w.deinit();
|
||||
|
||||
std.debug.print("\nnxdns is running. Press Ctrl+C to stop.\n\n", .{});
|
||||
|
||||
// Start server threads
|
||||
const udp_thread = try std.Thread.spawn(.{}, udp_server.UdpServer.run, .{&udp});
|
||||
const tcp_thread = try std.Thread.spawn(.{}, tcp_server.TcpServer.run, .{&tcp});
|
||||
const web_thread = try std.Thread.spawn(.{}, web_server.WebServer.run, .{&web});
|
||||
const cleanup_thread = try std.Thread.spawn(.{}, logCleanupThread, .{&cleanup_context});
|
||||
|
||||
// Start auto-update thread if enabled
|
||||
var auto_update_thread: ?std.Thread = null;
|
||||
if (config.blocking.auto_update) {
|
||||
auto_update_thread = try std.Thread.spawn(.{}, autoUpdateThread, .{&auto_update_context});
|
||||
std.debug.print("Denylist auto-update enabled (daily)\n", .{});
|
||||
}
|
||||
|
||||
// Start config watcher in background thread
|
||||
var watcher_thread: ?std.Thread = null;
|
||||
if (config_watcher) |*w| {
|
||||
watcher_thread = try std.Thread.spawn(.{}, watcher_mod.ConfigWatcher.watch, .{w});
|
||||
}
|
||||
|
||||
// Main thread waits for shutdown signal (SIGINT/SIGTERM via signalfd)
|
||||
_ = coordinator.waitForSignal();
|
||||
|
||||
std.debug.print("\nShutting down...\n", .{});
|
||||
|
||||
// Stop all servers
|
||||
std.debug.print(" Stopping servers...\n", .{});
|
||||
udp.stop();
|
||||
tcp.stop();
|
||||
web.stop();
|
||||
|
||||
// Wait for connections to drain
|
||||
std.debug.print(" Waiting for connections...\n", .{});
|
||||
tcp.waitForConnections(2000);
|
||||
web.waitForConnections(2000);
|
||||
|
||||
// Flush query logs
|
||||
std.debug.print(" Flushing logs...\n", .{});
|
||||
query_logger.flush() catch |err| {
|
||||
std.log.warn("Failed to flush query logs during shutdown: {}", .{err});
|
||||
};
|
||||
|
||||
// Wait for threads to finish
|
||||
std.debug.print(" Joining UDP...\n", .{});
|
||||
udp_thread.join();
|
||||
std.debug.print(" Joining TCP...\n", .{});
|
||||
tcp_thread.join();
|
||||
std.debug.print(" Joining web...\n", .{});
|
||||
web_thread.join();
|
||||
std.debug.print(" Joining cleanup...\n", .{});
|
||||
cleanup_thread.join();
|
||||
if (auto_update_thread) |t| {
|
||||
std.debug.print(" Joining auto-update...\n", .{});
|
||||
t.join();
|
||||
}
|
||||
if (watcher_thread) |t| {
|
||||
std.debug.print(" Joining watcher...\n", .{});
|
||||
t.join();
|
||||
}
|
||||
|
||||
std.debug.print("Goodbye!\n", .{});
|
||||
}
|
||||
|
||||
fn checkConfig(allocator: std.mem.Allocator) !void {
|
||||
const config_path = getConfigPath();
|
||||
|
||||
std.debug.print("Checking configuration at {s}...\n", .{config_path});
|
||||
|
||||
var config = config_mod.Config.load(config_path, allocator) catch |err| {
|
||||
std.debug.print("ERROR: Failed to load config: {}\n", .{err});
|
||||
return;
|
||||
};
|
||||
defer config.deinit();
|
||||
|
||||
config.validate() catch |err| {
|
||||
std.debug.print("ERROR: Invalid configuration: {}\n", .{err});
|
||||
return;
|
||||
};
|
||||
|
||||
std.debug.print("Configuration is valid.\n", .{});
|
||||
std.debug.print("\nSettings:\n", .{});
|
||||
std.debug.print(" DNS port: {}\n", .{config.dns.port});
|
||||
std.debug.print(" DNS bind: {s}\n", .{config.dns.bind});
|
||||
std.debug.print(" Web port: {}\n", .{config.web.port});
|
||||
std.debug.print(" Web bind: {s}\n", .{config.web.bind});
|
||||
std.debug.print(" Upstream servers: {}\n", .{config.upstream.servers.len});
|
||||
std.debug.print(" Cache size: {}\n", .{config.dns.cache_size});
|
||||
std.debug.print(" Safe search: {}\n", .{config.safe_search.enabled});
|
||||
std.debug.print(" Deny response: {s}\n", .{@tagName(config.blocking.response)});
|
||||
std.debug.print(" Denylist auto-update: {}\n", .{config.blocking.auto_update});
|
||||
std.debug.print(" Log retention: {s}\n", .{config.logging.retention});
|
||||
}
|
||||
|
||||
// Tests
|
||||
test {
|
||||
// Run all tests from imported modules
|
||||
// DNS protocol
|
||||
_ = dns.types;
|
||||
_ = dns.header;
|
||||
_ = dns.name;
|
||||
_ = dns.question;
|
||||
_ = dns.record;
|
||||
_ = dns.edns;
|
||||
_ = dns.packet;
|
||||
|
||||
// Server components
|
||||
_ = handler_mod;
|
||||
_ = rate_limiter_mod;
|
||||
_ = udp_server;
|
||||
_ = tcp_server;
|
||||
|
||||
// Upstream
|
||||
_ = pool;
|
||||
_ = @import("upstream/doh.zig");
|
||||
_ = @import("upstream/dot.zig");
|
||||
|
||||
// Cache
|
||||
_ = cache_mod;
|
||||
|
||||
// Filter
|
||||
_ = denylist_mod;
|
||||
|
||||
// Config
|
||||
_ = config_mod;
|
||||
_ = watcher_mod;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
const std = @import("std");
|
||||
const fs = std.fs;
|
||||
const denylist_mod = @import("filter/denylist.zig");
|
||||
|
||||
fn getVmRSS() usize {
|
||||
const status_file = fs.openFileAbsolute("/proc/self/status", .{}) catch return 0;
|
||||
defer status_file.close();
|
||||
|
||||
var status_buf: [4096]u8 = undefined;
|
||||
const status_len = status_file.readAll(&status_buf) catch return 0;
|
||||
const status = status_buf[0..status_len];
|
||||
|
||||
var lines = std.mem.splitScalar(u8, status, '\n');
|
||||
while (lines.next()) |l| {
|
||||
if (std.mem.startsWith(u8, l, "VmRSS:")) {
|
||||
const rest = std.mem.trim(u8, l["VmRSS:".len..], " \t");
|
||||
var parts = std.mem.splitScalar(u8, rest, ' ');
|
||||
if (parts.next()) |kb_str| {
|
||||
return std.fmt.parseInt(usize, kb_str, 10) catch 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
pub fn main() !void {
|
||||
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
|
||||
defer _ = gpa.deinit();
|
||||
const allocator = gpa.allocator();
|
||||
|
||||
const mem_before = getVmRSS();
|
||||
|
||||
var denylist = denylist_mod.Denylist.init(allocator);
|
||||
defer denylist.deinit();
|
||||
|
||||
const denylist_dir = ".ignore/denylists";
|
||||
var dir = try fs.cwd().openDir(denylist_dir, .{ .iterate = true });
|
||||
defer dir.close();
|
||||
|
||||
var total_domains: usize = 0;
|
||||
var total_lines: usize = 0;
|
||||
|
||||
const start = std.time.milliTimestamp();
|
||||
|
||||
var iter = dir.iterate();
|
||||
while (try iter.next()) |entry| {
|
||||
if (entry.kind != .file) continue;
|
||||
|
||||
// Read entire file
|
||||
const content = dir.readFileAlloc(allocator, entry.name, 100 * 1024 * 1024) catch continue;
|
||||
defer allocator.free(content);
|
||||
|
||||
var lines = std.mem.splitScalar(u8, content, '\n');
|
||||
while (lines.next()) |line| {
|
||||
total_lines += 1;
|
||||
|
||||
// Parse Adblock format: ||domain^
|
||||
if (std.mem.startsWith(u8, line, "||")) {
|
||||
const rest = line[2..];
|
||||
if (std.mem.indexOfScalar(u8, rest, '^')) |end| {
|
||||
const domain = rest[0..end];
|
||||
if (domain.len > 0 and domain.len < 254) {
|
||||
denylist.addDeniedDomain(domain, 0) catch continue;
|
||||
total_domains += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const load_time = std.time.milliTimestamp() - start;
|
||||
const mem_after = getVmRSS();
|
||||
const mem_delta_kb = if (mem_after > mem_before) mem_after - mem_before else 0;
|
||||
const mem_delta_bytes = mem_delta_kb * 1024;
|
||||
const domain_count = denylist.denied_domains.count();
|
||||
const bytes_per_domain = if (domain_count > 0) mem_delta_bytes / domain_count else 0;
|
||||
|
||||
std.debug.print("\n[Memory Test - Real Denylists]\n", .{});
|
||||
std.debug.print(" Total lines: {d}\n", .{total_lines});
|
||||
std.debug.print(" Unique domains: {d}\n", .{domain_count});
|
||||
std.debug.print(" Load time: {d}ms\n", .{load_time});
|
||||
std.debug.print(" Memory before: {d} KB\n", .{mem_before});
|
||||
std.debug.print(" Memory after: {d} KB\n", .{mem_after});
|
||||
std.debug.print(" Memory delta: {d} KB ({d} MB)\n", .{ mem_delta_kb, mem_delta_kb / 1024 });
|
||||
std.debug.print(" Bytes per domain: {d}\n", .{bytes_per_domain});
|
||||
}
|
||||
@@ -0,0 +1,655 @@
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const packet = @import("../dns/packet.zig");
|
||||
const types = @import("../dns/types.zig");
|
||||
const safesearch = @import("../filter/safe_search.zig");
|
||||
const schema = @import("../storage/schema.zig");
|
||||
const udp = @import("udp.zig");
|
||||
const tcp = @import("tcp.zig");
|
||||
const rate_limiter = @import("rate_limiter.zig");
|
||||
|
||||
/// Query log entry with full status tracking
|
||||
pub const QueryLogEntry = struct {
|
||||
timestamp: i64,
|
||||
domain: []const u8,
|
||||
client_ip: []const u8,
|
||||
qtype: types.QType,
|
||||
// Status tracking (Gap 1)
|
||||
status: schema.QueryStatus,
|
||||
// Attribution tracking (Gap 2)
|
||||
list_id: ?i64,
|
||||
rule_id: ?i64,
|
||||
// Reply type tracking (Gap 3)
|
||||
reply_type: schema.ReplyType,
|
||||
// Protocol tracking (Gap 8)
|
||||
protocol: schema.ClientProtocol,
|
||||
// Performance
|
||||
response_time_us: u64,
|
||||
upstream: ?[]const u8,
|
||||
reason: ?[]const u8,
|
||||
/// DNSSEC validation status from upstream (AD bit)
|
||||
dnssec_validated: bool,
|
||||
|
||||
// Legacy compatibility - computed from status
|
||||
pub fn isDenied(self: QueryLogEntry) bool {
|
||||
return self.status.isDenied();
|
||||
}
|
||||
};
|
||||
|
||||
/// Handler configuration
|
||||
pub const HandlerConfig = struct {
|
||||
blocking_response: BlockingResponse = .zero,
|
||||
safe_search_enabled: bool = true,
|
||||
rate_limit_enabled: bool = true,
|
||||
rate_limit_qps: u32 = 20,
|
||||
|
||||
pub const BlockingResponse = enum {
|
||||
zero, // Return 0.0.0.0
|
||||
nxdomain, // Return NXDOMAIN
|
||||
};
|
||||
};
|
||||
|
||||
// Type-erased interfaces are used here to avoid circular imports:
|
||||
// handler.zig defines interfaces, concrete types implement them.
|
||||
// This allows handler to remain decoupled from specific implementations.
|
||||
|
||||
/// Result of denylist check with attribution
|
||||
pub const DenyResult = struct {
|
||||
denied: bool,
|
||||
list_id: ?i64, // Which denylist source caused the block
|
||||
rule_id: ?i64, // Which rule caused the block/allow
|
||||
is_rule_allow: bool, // True if an allow rule overrode a denylist match
|
||||
};
|
||||
|
||||
/// Interface for denylist checking
|
||||
/// Gap 4: Uses u64 groups bitmask to support many-to-many client-group relationships
|
||||
pub const Denylist = struct {
|
||||
context: *anyopaque,
|
||||
checkFn: *const fn (*anyopaque, []const u8, u64) DenyResult,
|
||||
getGroupsFn: *const fn (*anyopaque, []const u8) u64,
|
||||
|
||||
/// Check if domain is denied for groups (bitmask), with full attribution
|
||||
pub fn checkWithMask(self: Denylist, domain: []const u8, groups_mask: u64) DenyResult {
|
||||
return self.checkFn(self.context, domain, groups_mask);
|
||||
}
|
||||
|
||||
/// Legacy: Check if domain is denied for a single group
|
||||
pub fn check(self: Denylist, domain: []const u8, group_id: u32) DenyResult {
|
||||
const mask: u64 = if (group_id >= 64) 0 else (@as(u64, 1) << @intCast(group_id));
|
||||
return self.checkWithMask(domain, mask);
|
||||
}
|
||||
|
||||
/// Legacy compatibility - just returns boolean
|
||||
pub fn isDenied(self: Denylist, domain: []const u8, group_id: u32) bool {
|
||||
return self.check(domain, group_id).denied;
|
||||
}
|
||||
|
||||
/// Get all groups for a client as a bitmask (Gap 4: many-to-many)
|
||||
pub fn getGroupsForClient(self: Denylist, client_ip: []const u8) u64 {
|
||||
return self.getGroupsFn(self.context, client_ip);
|
||||
}
|
||||
|
||||
/// Legacy: Get primary group ID for a client
|
||||
pub fn getGroupForClient(self: Denylist, client_ip: []const u8) u32 {
|
||||
const mask = self.getGroupsForClient(client_ip);
|
||||
if (mask == 1) return 0; // Only default group
|
||||
var bit: u6 = 0;
|
||||
while (bit < 64) : (bit += 1) {
|
||||
if ((mask & (@as(u64, 1) << bit)) != 0) return bit;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
/// Interface for upstream DNS
|
||||
pub const Upstream = struct {
|
||||
context: *anyopaque,
|
||||
queryFn: *const fn (*anyopaque, []const u8, Allocator) ?[]const u8,
|
||||
|
||||
pub fn query(self: Upstream, dns_packet: []const u8, allocator: Allocator) ?[]const u8 {
|
||||
return self.queryFn(self.context, dns_packet, allocator);
|
||||
}
|
||||
};
|
||||
|
||||
/// Interface for DNS cache
|
||||
/// Note: getCopy returns a mutable slice that the caller owns and must free
|
||||
pub const Cache = struct {
|
||||
context: *anyopaque,
|
||||
getFn: *const fn (*anyopaque, []const u8, types.QType) ?[]u8,
|
||||
putFn: *const fn (*anyopaque, []const u8, types.QType, []const u8, u32) void,
|
||||
|
||||
/// Get a copy of a cached response. Caller owns the returned memory.
|
||||
pub fn getCopy(self: Cache, domain: []const u8, qtype: types.QType) ?[]u8 {
|
||||
return self.getFn(self.context, domain, qtype);
|
||||
}
|
||||
|
||||
pub fn put(self: Cache, domain: []const u8, qtype: types.QType, response: []const u8, ttl: u32) void {
|
||||
self.putFn(self.context, domain, qtype, response, ttl);
|
||||
}
|
||||
};
|
||||
|
||||
/// Interface for query logging
|
||||
pub const Logger = struct {
|
||||
context: *anyopaque,
|
||||
logFn: *const fn (*anyopaque, QueryLogEntry) void,
|
||||
|
||||
pub fn log(self: Logger, entry: QueryLogEntry) void {
|
||||
self.logFn(self.context, entry);
|
||||
}
|
||||
};
|
||||
|
||||
/// Main DNS request handler
|
||||
pub const Handler = struct {
|
||||
denylist: ?Denylist,
|
||||
cache: ?Cache,
|
||||
upstream: ?Upstream,
|
||||
logger: ?Logger,
|
||||
limiter: ?*rate_limiter.RateLimiter,
|
||||
config: HandlerConfig,
|
||||
allocator: Allocator,
|
||||
|
||||
pub fn init(allocator: Allocator) Handler {
|
||||
return Handler{
|
||||
.denylist = null,
|
||||
.cache = null,
|
||||
.upstream = null,
|
||||
.logger = null,
|
||||
.limiter = null,
|
||||
.config = HandlerConfig{},
|
||||
.allocator = allocator,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn setDenylist(self: *Handler, denylist: Denylist) void {
|
||||
self.denylist = denylist;
|
||||
}
|
||||
|
||||
pub fn setCache(self: *Handler, cache: Cache) void {
|
||||
self.cache = cache;
|
||||
}
|
||||
|
||||
pub fn setUpstream(self: *Handler, upstream: Upstream) void {
|
||||
self.upstream = upstream;
|
||||
}
|
||||
|
||||
pub fn setLogger(self: *Handler, logger: Logger) void {
|
||||
self.logger = logger;
|
||||
}
|
||||
|
||||
pub fn setRateLimiter(self: *Handler, limiter: *rate_limiter.RateLimiter) void {
|
||||
self.limiter = limiter;
|
||||
}
|
||||
|
||||
/// Handle a DNS query with protocol info
|
||||
pub fn handleWithProtocol(
|
||||
self: *Handler,
|
||||
query_bytes: []const u8,
|
||||
client_addr: std.net.Address,
|
||||
protocol: schema.ClientProtocol,
|
||||
allocator: Allocator,
|
||||
) ?[]const u8 {
|
||||
const start_time = std.time.microTimestamp();
|
||||
|
||||
// Parse the query
|
||||
var query = packet.Packet.parse(query_bytes, allocator) catch |err| {
|
||||
std.log.warn("Failed to parse DNS query: {}", .{err});
|
||||
return self.createErrorResponse(query_bytes, types.RCode.FormErr, allocator);
|
||||
};
|
||||
defer query.deinit();
|
||||
|
||||
// Must have at least one question
|
||||
if (query.questions.len == 0) {
|
||||
return self.createErrorResponse(query_bytes, types.RCode.FormErr, allocator);
|
||||
}
|
||||
|
||||
const question = query.questions[0];
|
||||
|
||||
// Get domain name as string (stack buffer, no allocation)
|
||||
var domain_buf: [254]u8 = undefined;
|
||||
const domain = question.name.toStringBuf(&domain_buf) orelse {
|
||||
return self.createErrorResponse(query_bytes, types.RCode.ServFail, allocator);
|
||||
};
|
||||
|
||||
// Get client IP as string
|
||||
var client_ip_buf: [45]u8 = undefined;
|
||||
const client_ip = formatAddress(client_addr, &client_ip_buf);
|
||||
|
||||
// Check rate limit
|
||||
if (self.limiter) |limiter| {
|
||||
if (!limiter.checkRequest(client_ip)) {
|
||||
std.log.debug("Rate limited client: {s}", .{client_ip});
|
||||
// Return REFUSED for rate-limited clients
|
||||
return self.createErrorResponse(query_bytes, types.RCode.Refused, allocator);
|
||||
}
|
||||
}
|
||||
|
||||
// Get client's groups (Gap 4: many-to-many - returns bitmask)
|
||||
var groups_mask: u64 = 1; // Default group (bit 0)
|
||||
if (self.denylist) |dl| {
|
||||
groups_mask = dl.getGroupsForClient(client_ip);
|
||||
}
|
||||
|
||||
// Check denylist with full attribution (Gap 4: uses groups bitmask)
|
||||
if (self.denylist) |dl| {
|
||||
const result = dl.checkWithMask(domain, groups_mask);
|
||||
|
||||
if (result.denied) {
|
||||
const response = self.createDeniedResponsePacket(&query, allocator) orelse return null;
|
||||
|
||||
// Determine status based on whether it was a denylist or rule
|
||||
const status: schema.QueryStatus = if (result.rule_id != null)
|
||||
.denied_rule
|
||||
else
|
||||
.denied_denylist;
|
||||
|
||||
self.logQuery(.{
|
||||
.timestamp = std.time.timestamp(),
|
||||
.domain = domain,
|
||||
.client_ip = client_ip,
|
||||
.qtype = question.qtype,
|
||||
.status = status,
|
||||
.list_id = result.list_id,
|
||||
.rule_id = result.rule_id,
|
||||
.reply_type = .ip, // Returns 0.0.0.0 or NXDOMAIN
|
||||
.protocol = protocol,
|
||||
.response_time_us = calcElapsedMicros(start_time),
|
||||
.upstream = null,
|
||||
.reason = "denylist",
|
||||
.dnssec_validated = false,
|
||||
});
|
||||
|
||||
return response;
|
||||
} else if (result.is_rule_allow and result.rule_id != null) {
|
||||
// Domain was on denylist but explicitly allowed by rule
|
||||
// Continue to forward, but log the allow rule
|
||||
}
|
||||
}
|
||||
|
||||
// Check safe search enforcement
|
||||
if (self.config.safe_search_enabled) {
|
||||
if (safesearch.applySafeSearch(domain)) |safe_domain| {
|
||||
const response = self.createSafeSearchResponsePacket(&query, safe_domain, allocator) orelse return null;
|
||||
|
||||
self.logQuery(.{
|
||||
.timestamp = std.time.timestamp(),
|
||||
.domain = domain,
|
||||
.client_ip = client_ip,
|
||||
.qtype = question.qtype,
|
||||
.status = .denied_upstream, // Safe search is a kind of upstream block
|
||||
.list_id = null,
|
||||
.rule_id = null,
|
||||
.reply_type = .cname,
|
||||
.protocol = protocol,
|
||||
.response_time_us = calcElapsedMicros(start_time),
|
||||
.upstream = null,
|
||||
.reason = "safesearch",
|
||||
.dnssec_validated = false,
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
// Check cache
|
||||
if (self.cache) |cache| {
|
||||
if (cache.getCopy(domain, question.qtype)) |cached| {
|
||||
// Update transaction ID in cached response
|
||||
// cached is a mutable copy owned by caller
|
||||
if (cached.len < 2) {
|
||||
allocator.free(cached);
|
||||
return null;
|
||||
}
|
||||
cached[0] = @intCast((query.header.id >> 8) & 0xFF);
|
||||
cached[1] = @intCast(query.header.id & 0xFF);
|
||||
|
||||
self.logQuery(.{
|
||||
.timestamp = std.time.timestamp(),
|
||||
.domain = domain,
|
||||
.client_ip = client_ip,
|
||||
.qtype = question.qtype,
|
||||
.status = .cached,
|
||||
.list_id = null,
|
||||
.rule_id = null,
|
||||
.reply_type = .ip, // Assume IP for cached
|
||||
.protocol = protocol,
|
||||
.response_time_us = calcElapsedMicros(start_time),
|
||||
.upstream = null,
|
||||
.reason = "cache",
|
||||
.dnssec_validated = false, // TODO: could store/retrieve from cache
|
||||
});
|
||||
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
// Forward to upstream
|
||||
if (self.upstream) |upstream| {
|
||||
const response = upstream.query(query_bytes, allocator) orelse {
|
||||
std.log.warn("Upstream query failed for {s}", .{domain});
|
||||
|
||||
self.logQuery(.{
|
||||
.timestamp = std.time.timestamp(),
|
||||
.domain = domain,
|
||||
.client_ip = client_ip,
|
||||
.qtype = question.qtype,
|
||||
.status = .upstream_error,
|
||||
.list_id = null,
|
||||
.rule_id = null,
|
||||
.reply_type = .servfail,
|
||||
.protocol = protocol,
|
||||
.response_time_us = calcElapsedMicros(start_time),
|
||||
.upstream = null,
|
||||
.reason = "upstream_failed",
|
||||
.dnssec_validated = false,
|
||||
});
|
||||
|
||||
return self.createErrorResponse(query_bytes, types.RCode.ServFail, allocator);
|
||||
};
|
||||
|
||||
// Validate response is a valid DNS packet before processing
|
||||
var response_pkt = packet.Packet.parse(response, allocator) catch |err| {
|
||||
std.log.warn("Invalid upstream response for {s}: {}", .{ domain, err });
|
||||
allocator.free(response);
|
||||
return self.createErrorResponse(query_bytes, types.RCode.ServFail, allocator);
|
||||
};
|
||||
|
||||
defer response_pkt.deinit();
|
||||
|
||||
// Determine reply type from response
|
||||
const reply_type = self.detectReplyType(&response_pkt);
|
||||
|
||||
// Check for CNAME uncloaking - detect denied domains hiding behind CNAMEs
|
||||
const dl = self.denylist orelse {
|
||||
// No denylist - cache response and return it
|
||||
if (self.cache) |cache| {
|
||||
if (response_pkt.answers.len > 0) {
|
||||
cache.put(domain, question.qtype, response, response_pkt.answers[0].ttl);
|
||||
}
|
||||
}
|
||||
|
||||
self.logQuery(.{
|
||||
.timestamp = std.time.timestamp(),
|
||||
.domain = domain,
|
||||
.client_ip = client_ip,
|
||||
.qtype = question.qtype,
|
||||
.status = .forwarded,
|
||||
.list_id = null,
|
||||
.rule_id = null,
|
||||
.reply_type = reply_type,
|
||||
.protocol = protocol,
|
||||
.response_time_us = calcElapsedMicros(start_time),
|
||||
.upstream = "upstream",
|
||||
.reason = null,
|
||||
.dnssec_validated = response_pkt.header.ad,
|
||||
});
|
||||
return response;
|
||||
};
|
||||
|
||||
// Check if any CNAME target is denied
|
||||
if (self.findDeniedCname(&response_pkt, dl, groups_mask)) |_| {
|
||||
allocator.free(response);
|
||||
const denied_response = self.createDeniedResponsePacket(&query, allocator) orelse return null;
|
||||
|
||||
self.logQuery(.{
|
||||
.timestamp = std.time.timestamp(),
|
||||
.domain = domain,
|
||||
.client_ip = client_ip,
|
||||
.qtype = question.qtype,
|
||||
.status = .denied_denylist,
|
||||
.list_id = null, // TODO: Could track which list blocked the CNAME
|
||||
.rule_id = null,
|
||||
.reply_type = .ip,
|
||||
.protocol = protocol,
|
||||
.response_time_us = calcElapsedMicros(start_time),
|
||||
.upstream = null,
|
||||
.reason = "cname_uncloaking",
|
||||
.dnssec_validated = false,
|
||||
});
|
||||
|
||||
return denied_response;
|
||||
}
|
||||
|
||||
// Cache the response
|
||||
if (self.cache) |cache| {
|
||||
if (response_pkt.answers.len > 0) {
|
||||
cache.put(domain, question.qtype, response, response_pkt.answers[0].ttl);
|
||||
}
|
||||
}
|
||||
|
||||
self.logQuery(.{
|
||||
.timestamp = std.time.timestamp(),
|
||||
.domain = domain,
|
||||
.client_ip = client_ip,
|
||||
.qtype = question.qtype,
|
||||
.status = .forwarded,
|
||||
.list_id = null,
|
||||
.rule_id = null,
|
||||
.reply_type = reply_type,
|
||||
.protocol = protocol,
|
||||
.response_time_us = calcElapsedMicros(start_time),
|
||||
.upstream = "upstream",
|
||||
.reason = null,
|
||||
.dnssec_validated = response_pkt.header.ad,
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// No upstream configured
|
||||
return self.createErrorResponse(query_bytes, types.RCode.ServFail, allocator);
|
||||
}
|
||||
|
||||
/// Handle a DNS query (legacy - defaults to UDP protocol)
|
||||
pub fn handle(
|
||||
self: *Handler,
|
||||
query_bytes: []const u8,
|
||||
client_addr: std.net.Address,
|
||||
allocator: Allocator,
|
||||
) ?[]const u8 {
|
||||
return self.handleWithProtocol(query_bytes, client_addr, .udp, allocator);
|
||||
}
|
||||
|
||||
/// Detect reply type from response packet
|
||||
fn detectReplyType(self: *Handler, response_pkt: *packet.Packet) schema.ReplyType {
|
||||
_ = self;
|
||||
|
||||
// Check RCODE first
|
||||
switch (response_pkt.header.rcode) {
|
||||
.ServFail => return .servfail,
|
||||
.NXDomain => return .nxdomain,
|
||||
.Refused => return .refused,
|
||||
else => {},
|
||||
}
|
||||
|
||||
// No answers = NODATA
|
||||
if (response_pkt.answers.len == 0) {
|
||||
return .nodata;
|
||||
}
|
||||
|
||||
// Check answer types
|
||||
for (response_pkt.answers) |answer| {
|
||||
if (answer.rtype == types.QType.A or answer.rtype == types.QType.AAAA) {
|
||||
return .ip;
|
||||
}
|
||||
if (answer.rtype == types.QType.CNAME) {
|
||||
return .cname;
|
||||
}
|
||||
}
|
||||
|
||||
return .unknown;
|
||||
}
|
||||
|
||||
/// Check if any CNAME target in the response is denied
|
||||
/// Checks ANSWER, AUTHORITY, and ADDITIONAL sections for completeness
|
||||
/// Returns true if a denied CNAME was found
|
||||
/// Gap 4: Takes groups bitmask for many-to-many support
|
||||
fn findDeniedCname(self: *Handler, response_pkt: *packet.Packet, dl: Denylist, groups_mask: u64) ?bool {
|
||||
_ = self;
|
||||
|
||||
// Check all sections where CNAMEs could appear
|
||||
const sections = [_][]const packet.ResourceRecord{
|
||||
response_pkt.answers,
|
||||
response_pkt.authority,
|
||||
response_pkt.additional,
|
||||
};
|
||||
|
||||
for (sections) |section| {
|
||||
for (section) |record| {
|
||||
if (record.rtype != types.QType.CNAME) continue;
|
||||
|
||||
const cname = record.getCname() orelse continue;
|
||||
var cname_buf: [254]u8 = undefined;
|
||||
const cname_str = cname.toStringBuf(&cname_buf) orelse continue;
|
||||
|
||||
if (dl.checkWithMask(cname_str, groups_mask).denied) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Calculate elapsed microseconds since start_time, handling potential negative values
|
||||
fn calcElapsedMicros(start_time: i64) u64 {
|
||||
const elapsed = std.time.microTimestamp() - start_time;
|
||||
return if (elapsed > 0) @intCast(elapsed) else 0;
|
||||
}
|
||||
|
||||
fn createDeniedResponsePacket(self: *Handler, query: *const packet.Packet, allocator: Allocator) ?[]const u8 {
|
||||
var response = switch (self.config.blocking_response) {
|
||||
.zero => packet.Packet.createDeniedResponse(query, allocator) catch return null,
|
||||
.nxdomain => packet.Packet.createNxdomainResponse(query, allocator) catch return null,
|
||||
};
|
||||
defer response.deinit();
|
||||
|
||||
var buffer: [types.EDNS_DEFAULT_SIZE]u8 = undefined;
|
||||
const len = response.encode(&buffer) catch return null;
|
||||
|
||||
return allocator.dupe(u8, buffer[0..len]) catch null;
|
||||
}
|
||||
|
||||
fn createSafeSearchResponsePacket(self: *Handler, query: *const packet.Packet, safe_domain: []const u8, allocator: Allocator) ?[]const u8 {
|
||||
_ = self;
|
||||
var response = packet.Packet.createSafeSearchResponse(query, safe_domain, allocator) catch return null;
|
||||
defer response.deinit();
|
||||
|
||||
var buffer: [types.EDNS_DEFAULT_SIZE]u8 = undefined;
|
||||
const len = response.encode(&buffer) catch return null;
|
||||
|
||||
return allocator.dupe(u8, buffer[0..len]) catch null;
|
||||
}
|
||||
|
||||
fn createErrorResponse(self: *Handler, query_bytes: []const u8, rcode: types.RCode, allocator: Allocator) ?[]const u8 {
|
||||
_ = self;
|
||||
if (query_bytes.len < types.DNS_HEADER_SIZE) return null;
|
||||
|
||||
// Create a minimal error response
|
||||
var response_buf: [types.DNS_HEADER_SIZE]u8 = undefined;
|
||||
@memcpy(&response_buf, query_bytes[0..types.DNS_HEADER_SIZE]);
|
||||
|
||||
// Set QR = 1 (response), RA = 1 (recursion available), and RCODE
|
||||
response_buf[2] |= 0x80; // QR = 1
|
||||
response_buf[3] = (response_buf[3] & 0xF0) | @intFromEnum(rcode);
|
||||
response_buf[3] |= 0x80; // RA = 1
|
||||
|
||||
// Set counts to 0 for answers, authority, additional
|
||||
response_buf[6] = 0;
|
||||
response_buf[7] = 0;
|
||||
response_buf[8] = 0;
|
||||
response_buf[9] = 0;
|
||||
response_buf[10] = 0;
|
||||
response_buf[11] = 0;
|
||||
|
||||
return allocator.dupe(u8, &response_buf) catch null;
|
||||
}
|
||||
|
||||
fn logQuery(self: *Handler, entry: QueryLogEntry) void {
|
||||
if (self.logger) |logger| {
|
||||
logger.log(entry);
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a UDP handler wrapper
|
||||
pub fn toUdpHandler(self: *Handler) udp.UdpServer.Handler {
|
||||
return udp.UdpServer.Handler{
|
||||
.context = self,
|
||||
.handleFn = handleUdp,
|
||||
};
|
||||
}
|
||||
|
||||
/// Create a TCP handler wrapper
|
||||
pub fn toTcpHandler(self: *Handler) tcp.TcpServer.Handler {
|
||||
return tcp.TcpServer.Handler{
|
||||
.context = self,
|
||||
.handleFn = handleTcp,
|
||||
};
|
||||
}
|
||||
|
||||
fn handleUdp(ctx: *anyopaque, query: []const u8, client_addr: std.net.Address, allocator: Allocator) ?[]const u8 {
|
||||
const self: *Handler = @ptrCast(@alignCast(ctx));
|
||||
return self.handleWithProtocol(query, client_addr, .udp, allocator);
|
||||
}
|
||||
|
||||
fn handleTcp(ctx: *anyopaque, query: []const u8, client_addr: std.net.Address, allocator: Allocator) ?[]const u8 {
|
||||
const self: *Handler = @ptrCast(@alignCast(ctx));
|
||||
return self.handleWithProtocol(query, client_addr, .tcp, allocator);
|
||||
}
|
||||
};
|
||||
|
||||
/// Format a network address to a string representation
|
||||
/// Returns "0.0.0.0" as fallback for unknown address families (safer than "unknown" for rate limiting)
|
||||
fn formatAddress(addr: std.net.Address, buf: []u8) []const u8 {
|
||||
if (addr.any.family == std.posix.AF.INET) {
|
||||
// IPv4 address
|
||||
const bytes = @as(*const [4]u8, @ptrCast(&addr.in.sa.addr));
|
||||
const result = std.fmt.bufPrint(buf, "{d}.{d}.{d}.{d}", .{
|
||||
bytes[0], bytes[1], bytes[2], bytes[3],
|
||||
}) catch return "0.0.0.0";
|
||||
return result;
|
||||
} else if (addr.any.family == std.posix.AF.INET6) {
|
||||
// IPv6 address - use compressed format for common cases
|
||||
const bytes = @as(*const [16]u8, @ptrCast(&addr.in6.sa.addr));
|
||||
const result = std.fmt.bufPrint(buf, "{x:0>2}{x:0>2}:{x:0>2}{x:0>2}:{x:0>2}{x:0>2}:{x:0>2}{x:0>2}:{x:0>2}{x:0>2}:{x:0>2}{x:0>2}:{x:0>2}{x:0>2}:{x:0>2}{x:0>2}", .{
|
||||
bytes[0], bytes[1], bytes[2], bytes[3],
|
||||
bytes[4], bytes[5], bytes[6], bytes[7],
|
||||
bytes[8], bytes[9], bytes[10], bytes[11],
|
||||
bytes[12], bytes[13], bytes[14], bytes[15],
|
||||
}) catch return "::";
|
||||
return result;
|
||||
}
|
||||
// Unknown address family - use safe fallback that won't break rate limiting
|
||||
std.log.warn("Unknown address family: {}", .{addr.any.family});
|
||||
return "0.0.0.0";
|
||||
}
|
||||
|
||||
test "Handler basic test" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var handler = Handler.init(allocator);
|
||||
|
||||
// Create a simple query
|
||||
const query_bytes = [_]u8{
|
||||
0x00, 0x01, // ID
|
||||
0x01, 0x00, // Standard query, RD=1
|
||||
0x00, 0x01, // QDCOUNT = 1
|
||||
0x00, 0x00, // ANCOUNT = 0
|
||||
0x00, 0x00, // NSCOUNT = 0
|
||||
0x00, 0x00, // ARCOUNT = 0
|
||||
0x07, 'e', 'x', 'a', 'm', 'p', 'l', 'e', // example
|
||||
0x03, 'c', 'o', 'm', // com
|
||||
0x00, // null
|
||||
0x00, 0x01, // TYPE = A
|
||||
0x00, 0x01, // CLASS = IN
|
||||
};
|
||||
|
||||
const addr = std.net.Address.initIp4([4]u8{ 127, 0, 0, 1 }, 12345);
|
||||
|
||||
// Without upstream, should return SERVFAIL
|
||||
const response = handler.handle(&query_bytes, addr, allocator);
|
||||
|
||||
if (response) |r| {
|
||||
defer allocator.free(r);
|
||||
try testing.expect(r.len >= types.DNS_HEADER_SIZE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
/// Rate limiter to prevent DNS amplification attacks
|
||||
/// Uses a sliding window algorithm with per-IP tracking
|
||||
pub const RateLimiter = struct {
|
||||
/// Per-IP request tracking
|
||||
clients: std.StringHashMapUnmanaged(ClientState),
|
||||
allocator: Allocator,
|
||||
mutex: std.Thread.Mutex,
|
||||
|
||||
/// Configuration
|
||||
config: Config,
|
||||
|
||||
/// Statistics
|
||||
stats: Stats,
|
||||
|
||||
pub const Config = struct {
|
||||
/// Maximum requests per second per IP
|
||||
max_qps: u32 = 20,
|
||||
/// Time window for rate limiting (in milliseconds)
|
||||
window_ms: u64 = 1000,
|
||||
/// Maximum number of IPs to track (LRU eviction beyond this)
|
||||
max_clients: usize = 10000,
|
||||
/// Enable rate limiting
|
||||
enabled: bool = true,
|
||||
};
|
||||
|
||||
const ClientState = struct {
|
||||
/// Request timestamps in the current window (circular buffer)
|
||||
timestamps: [32]i64,
|
||||
/// Current position in the buffer
|
||||
pos: usize,
|
||||
/// Number of requests in current window
|
||||
count: u32,
|
||||
/// Last activity timestamp (for LRU eviction)
|
||||
last_seen: i64,
|
||||
};
|
||||
|
||||
pub const Stats = struct {
|
||||
/// Total requests processed
|
||||
total_requests: u64 = 0,
|
||||
/// Requests that were rate limited
|
||||
rate_limited: u64 = 0,
|
||||
/// Currently tracked clients
|
||||
active_clients: usize = 0,
|
||||
};
|
||||
|
||||
pub fn init(allocator: Allocator) RateLimiter {
|
||||
return initWithConfig(allocator, Config{});
|
||||
}
|
||||
|
||||
pub fn initWithConfig(allocator: Allocator, config: Config) RateLimiter {
|
||||
return RateLimiter{
|
||||
.clients = std.StringHashMapUnmanaged(ClientState){},
|
||||
.allocator = allocator,
|
||||
.mutex = std.Thread.Mutex{},
|
||||
.config = config,
|
||||
.stats = Stats{},
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *RateLimiter) void {
|
||||
var iter = self.clients.iterator();
|
||||
while (iter.next()) |entry| {
|
||||
self.allocator.free(entry.key_ptr.*);
|
||||
}
|
||||
self.clients.deinit(self.allocator);
|
||||
}
|
||||
|
||||
/// Check if a request from this IP should be allowed
|
||||
/// Returns true if allowed, false if rate limited
|
||||
pub fn checkRequest(self: *RateLimiter, client_ip: []const u8) bool {
|
||||
if (!self.config.enabled) return true;
|
||||
|
||||
self.mutex.lock();
|
||||
defer self.mutex.unlock();
|
||||
|
||||
self.stats.total_requests += 1;
|
||||
|
||||
const now = std.time.milliTimestamp();
|
||||
const window_start = now - @as(i64, @intCast(self.config.window_ms));
|
||||
|
||||
// Get or create client state
|
||||
const gop = self.clients.getOrPut(self.allocator, client_ip) catch {
|
||||
// On allocation failure, allow the request
|
||||
return true;
|
||||
};
|
||||
|
||||
if (!gop.found_existing) {
|
||||
// New client - copy the key
|
||||
gop.key_ptr.* = self.allocator.dupe(u8, client_ip) catch {
|
||||
_ = self.clients.remove(client_ip);
|
||||
return true;
|
||||
};
|
||||
|
||||
// Initialize state
|
||||
gop.value_ptr.* = ClientState{
|
||||
.timestamps = [_]i64{0} ** 32,
|
||||
.pos = 0,
|
||||
.count = 0,
|
||||
.last_seen = now,
|
||||
};
|
||||
|
||||
// Evict oldest client if at capacity
|
||||
if (self.clients.count() > self.config.max_clients) {
|
||||
self.evictOldestLocked();
|
||||
}
|
||||
}
|
||||
|
||||
var state = gop.value_ptr;
|
||||
state.last_seen = now;
|
||||
|
||||
// Count requests in current window
|
||||
var count: u32 = 0;
|
||||
for (state.timestamps) |ts| {
|
||||
if (ts > window_start) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Check rate limit
|
||||
if (count >= self.config.max_qps) {
|
||||
self.stats.rate_limited += 1;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Record this request
|
||||
state.timestamps[state.pos] = now;
|
||||
state.pos = (state.pos + 1) % state.timestamps.len;
|
||||
state.count = count + 1;
|
||||
|
||||
self.stats.active_clients = self.clients.count();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Evict the oldest (least recently seen) client
|
||||
fn evictOldestLocked(self: *RateLimiter) void {
|
||||
var oldest_key: ?[]const u8 = null;
|
||||
var oldest_time: i64 = std.math.maxInt(i64);
|
||||
|
||||
var iter = self.clients.iterator();
|
||||
while (iter.next()) |entry| {
|
||||
if (entry.value_ptr.last_seen < oldest_time) {
|
||||
oldest_time = entry.value_ptr.last_seen;
|
||||
oldest_key = entry.key_ptr.*;
|
||||
}
|
||||
}
|
||||
|
||||
if (oldest_key) |key| {
|
||||
if (self.clients.fetchRemove(key)) |kv| {
|
||||
self.allocator.free(kv.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current statistics
|
||||
pub fn getStats(self: *RateLimiter) Stats {
|
||||
self.mutex.lock();
|
||||
defer self.mutex.unlock();
|
||||
return self.stats;
|
||||
}
|
||||
|
||||
/// Reset statistics
|
||||
pub fn resetStats(self: *RateLimiter) void {
|
||||
self.mutex.lock();
|
||||
defer self.mutex.unlock();
|
||||
self.stats = Stats{};
|
||||
}
|
||||
|
||||
/// Clear all tracked clients
|
||||
pub fn clear(self: *RateLimiter) void {
|
||||
self.mutex.lock();
|
||||
defer self.mutex.unlock();
|
||||
|
||||
var iter = self.clients.iterator();
|
||||
while (iter.next()) |entry| {
|
||||
self.allocator.free(entry.key_ptr.*);
|
||||
}
|
||||
self.clients.clearRetainingCapacity();
|
||||
self.stats.active_clients = 0;
|
||||
}
|
||||
};
|
||||
|
||||
test "RateLimiter basic operations" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var limiter = RateLimiter.initWithConfig(allocator, .{
|
||||
.max_qps = 5,
|
||||
.window_ms = 1000,
|
||||
});
|
||||
defer limiter.deinit();
|
||||
|
||||
// First 5 requests should be allowed
|
||||
for (0..5) |_| {
|
||||
try testing.expect(limiter.checkRequest("192.168.1.1"));
|
||||
}
|
||||
|
||||
// 6th request should be rate limited
|
||||
try testing.expect(!limiter.checkRequest("192.168.1.1"));
|
||||
|
||||
// Different IP should still be allowed
|
||||
try testing.expect(limiter.checkRequest("192.168.1.2"));
|
||||
}
|
||||
|
||||
test "RateLimiter disabled" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var limiter = RateLimiter.initWithConfig(allocator, .{
|
||||
.max_qps = 1,
|
||||
.enabled = false,
|
||||
});
|
||||
defer limiter.deinit();
|
||||
|
||||
// All requests should be allowed when disabled
|
||||
for (0..100) |_| {
|
||||
try testing.expect(limiter.checkRequest("192.168.1.1"));
|
||||
}
|
||||
}
|
||||
|
||||
test "RateLimiter statistics" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var limiter = RateLimiter.initWithConfig(allocator, .{
|
||||
.max_qps = 2,
|
||||
.window_ms = 1000,
|
||||
});
|
||||
defer limiter.deinit();
|
||||
|
||||
_ = limiter.checkRequest("192.168.1.1"); // allowed
|
||||
_ = limiter.checkRequest("192.168.1.1"); // allowed
|
||||
_ = limiter.checkRequest("192.168.1.1"); // rate limited
|
||||
|
||||
const stats = limiter.getStats();
|
||||
try testing.expectEqual(@as(u64, 3), stats.total_requests);
|
||||
try testing.expectEqual(@as(u64, 1), stats.rate_limited);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
const std = @import("std");
|
||||
const posix = std.posix;
|
||||
const linux = std.os.linux;
|
||||
|
||||
/// Unified shutdown coordinator using signalfd + eventfd
|
||||
/// - signalfd: catches SIGINT/SIGTERM (main thread waits on this)
|
||||
/// - eventfd: wakes all worker threads when shutdown requested
|
||||
pub const ShutdownCoordinator = struct {
|
||||
shutdown_requested: std.atomic.Value(bool),
|
||||
signal_fd: posix.fd_t,
|
||||
event_fd: posix.fd_t,
|
||||
|
||||
pub fn init() ShutdownCoordinator {
|
||||
// Block SIGINT/SIGTERM so they go to signalfd instead of default handler
|
||||
var mask = std.mem.zeroes(linux.sigset_t);
|
||||
linux.sigaddset(&mask, linux.SIG.INT);
|
||||
linux.sigaddset(&mask, linux.SIG.TERM);
|
||||
_ = linux.sigprocmask(linux.SIG.BLOCK, &mask, null);
|
||||
|
||||
// Create signalfd
|
||||
const sig_fd_raw = linux.signalfd(-1, &mask, linux.SFD.CLOEXEC);
|
||||
const sig_fd: posix.fd_t = if (@as(isize, @bitCast(sig_fd_raw)) < 0) -1 else @intCast(sig_fd_raw);
|
||||
|
||||
// Create eventfd for waking threads
|
||||
const evt_fd = posix.eventfd(0, linux.EFD.CLOEXEC) catch -1;
|
||||
|
||||
return ShutdownCoordinator{
|
||||
.shutdown_requested = std.atomic.Value(bool).init(false),
|
||||
.signal_fd = sig_fd,
|
||||
.event_fd = evt_fd,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *ShutdownCoordinator) void {
|
||||
if (self.signal_fd != -1) posix.close(self.signal_fd);
|
||||
if (self.event_fd != -1) posix.close(self.event_fd);
|
||||
}
|
||||
|
||||
/// Signal shutdown and wake all waiting threads
|
||||
pub fn requestShutdown(self: *ShutdownCoordinator) void {
|
||||
self.shutdown_requested.store(true, .release);
|
||||
|
||||
// Wake all threads waiting on eventfd
|
||||
if (self.event_fd != -1) {
|
||||
const val: u64 = 1;
|
||||
_ = posix.write(self.event_fd, std.mem.asBytes(&val)) catch {};
|
||||
}
|
||||
}
|
||||
|
||||
pub fn isShutdownRequested(self: *ShutdownCoordinator) bool {
|
||||
return self.shutdown_requested.load(.acquire);
|
||||
}
|
||||
|
||||
/// Get eventfd for polling (worker threads use this)
|
||||
pub fn getEventFd(self: *ShutdownCoordinator) posix.fd_t {
|
||||
return self.event_fd;
|
||||
}
|
||||
|
||||
/// Get signalfd for polling (main thread uses this)
|
||||
pub fn getSignalFd(self: *ShutdownCoordinator) posix.fd_t {
|
||||
return self.signal_fd;
|
||||
}
|
||||
|
||||
/// Block until shutdown signal received (main thread calls this)
|
||||
/// Returns true if signal was received, false on error
|
||||
pub fn waitForSignal(self: *ShutdownCoordinator) bool {
|
||||
if (self.signal_fd == -1) return false;
|
||||
|
||||
var fds = [1]posix.pollfd{
|
||||
.{ .fd = self.signal_fd, .events = posix.POLL.IN, .revents = 0 },
|
||||
};
|
||||
|
||||
_ = posix.poll(&fds, -1) catch return false;
|
||||
|
||||
if (fds[0].revents & posix.POLL.IN != 0) {
|
||||
// Consume the signal
|
||||
var siginfo: linux.signalfd_siginfo = undefined;
|
||||
_ = posix.read(self.signal_fd, std.mem.asBytes(&siginfo)) catch {};
|
||||
self.requestShutdown();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Block until shutdown is requested or timeout (worker threads use this)
|
||||
/// timeout_ms: -1 for infinite wait
|
||||
pub fn wait(self: *ShutdownCoordinator, timeout_ms: i32) bool {
|
||||
if (self.isShutdownRequested()) return true;
|
||||
if (self.event_fd == -1) return false;
|
||||
|
||||
var fds = [1]posix.pollfd{
|
||||
.{ .fd = self.event_fd, .events = posix.POLL.IN, .revents = 0 },
|
||||
};
|
||||
|
||||
_ = posix.poll(&fds, timeout_ms) catch return self.isShutdownRequested();
|
||||
return self.isShutdownRequested();
|
||||
}
|
||||
};
|
||||
|
||||
test "ShutdownCoordinator basic" {
|
||||
var coordinator = ShutdownCoordinator.init();
|
||||
defer coordinator.deinit();
|
||||
|
||||
try std.testing.expect(!coordinator.isShutdownRequested());
|
||||
coordinator.requestShutdown();
|
||||
try std.testing.expect(coordinator.isShutdownRequested());
|
||||
}
|
||||
|
||||
test "ShutdownCoordinator wait returns immediately after shutdown" {
|
||||
var coordinator = ShutdownCoordinator.init();
|
||||
defer coordinator.deinit();
|
||||
|
||||
coordinator.requestShutdown();
|
||||
const result = coordinator.wait(1000);
|
||||
try std.testing.expect(result);
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
const std = @import("std");
|
||||
const posix = std.posix;
|
||||
const net = std.net;
|
||||
const Allocator = std.mem.Allocator;
|
||||
const packet = @import("../dns/packet.zig");
|
||||
const types = @import("../dns/types.zig");
|
||||
|
||||
/// Connection task for the worker pool
|
||||
const ConnectionTask = struct {
|
||||
handle: posix.socket_t,
|
||||
address: net.Address,
|
||||
};
|
||||
|
||||
/// Simple bounded work queue for connection tasks
|
||||
const WorkQueue = struct {
|
||||
items: [QUEUE_SIZE]?ConnectionTask,
|
||||
head: usize,
|
||||
tail: usize,
|
||||
count: usize,
|
||||
mutex: std.Thread.Mutex,
|
||||
not_empty: std.Thread.Condition,
|
||||
not_full: std.Thread.Condition,
|
||||
|
||||
const QUEUE_SIZE = 64;
|
||||
|
||||
fn init() WorkQueue {
|
||||
return .{
|
||||
.items = [_]?ConnectionTask{null} ** QUEUE_SIZE,
|
||||
.head = 0,
|
||||
.tail = 0,
|
||||
.count = 0,
|
||||
.mutex = .{},
|
||||
.not_empty = .{},
|
||||
.not_full = .{},
|
||||
};
|
||||
}
|
||||
|
||||
/// Push a task, returns false if queue is full
|
||||
fn tryPush(self: *WorkQueue, task: ConnectionTask) bool {
|
||||
self.mutex.lock();
|
||||
defer self.mutex.unlock();
|
||||
|
||||
if (self.count >= QUEUE_SIZE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.items[self.tail] = task;
|
||||
self.tail = (self.tail + 1) % QUEUE_SIZE;
|
||||
self.count += 1;
|
||||
self.not_empty.signal();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Pop a task, blocks until available or timeout
|
||||
fn pop(self: *WorkQueue, running: *std.atomic.Value(bool)) ?ConnectionTask {
|
||||
self.mutex.lock();
|
||||
defer self.mutex.unlock();
|
||||
|
||||
while (self.count == 0) {
|
||||
if (!running.load(.acquire)) {
|
||||
return null;
|
||||
}
|
||||
// Wait with timeout to periodically check running flag
|
||||
self.not_empty.timedWait(&self.mutex, 100 * std.time.ns_per_ms) catch {};
|
||||
}
|
||||
|
||||
if (self.count == 0) return null;
|
||||
|
||||
const task = self.items[self.head];
|
||||
self.items[self.head] = null;
|
||||
self.head = (self.head + 1) % QUEUE_SIZE;
|
||||
self.count -= 1;
|
||||
self.not_full.signal();
|
||||
return task;
|
||||
}
|
||||
|
||||
fn wakeAll(self: *WorkQueue) void {
|
||||
self.mutex.lock();
|
||||
defer self.mutex.unlock();
|
||||
self.not_empty.broadcast();
|
||||
}
|
||||
};
|
||||
|
||||
pub const TcpServer = struct {
|
||||
listener: net.Server,
|
||||
allocator: Allocator,
|
||||
handler: *Handler,
|
||||
running: std.atomic.Value(bool),
|
||||
active_connections: std.atomic.Value(u32),
|
||||
max_connections: u32,
|
||||
connection_timeout_ms: u32,
|
||||
work_queue: WorkQueue,
|
||||
workers: []std.Thread,
|
||||
num_workers: u32,
|
||||
|
||||
/// Default maximum concurrent TCP connections
|
||||
pub const DEFAULT_MAX_CONNECTIONS: u32 = 100;
|
||||
|
||||
/// Default connection read/write timeout (30 seconds)
|
||||
pub const DEFAULT_CONNECTION_TIMEOUT_MS: u32 = 30000;
|
||||
|
||||
/// Default number of worker threads
|
||||
pub const DEFAULT_NUM_WORKERS: u32 = 8;
|
||||
|
||||
pub const Handler = struct {
|
||||
context: *anyopaque,
|
||||
handleFn: *const fn (*anyopaque, []const u8, std.net.Address, Allocator) ?[]const u8,
|
||||
|
||||
pub fn handle(self: Handler, query: []const u8, client_addr: std.net.Address, allocator: Allocator) ?[]const u8 {
|
||||
return self.handleFn(self.context, query, client_addr, allocator);
|
||||
}
|
||||
};
|
||||
|
||||
pub const InitError = error{
|
||||
ListenFailed,
|
||||
} || net.Address.ListenError;
|
||||
|
||||
/// Initialize the TCP server
|
||||
pub fn init(bind_addr: net.Address, handler: *Handler, allocator: Allocator) InitError!TcpServer {
|
||||
return initWithConfig(bind_addr, handler, allocator, .{});
|
||||
}
|
||||
|
||||
pub const Config = struct {
|
||||
max_connections: u32 = DEFAULT_MAX_CONNECTIONS,
|
||||
connection_timeout_ms: u32 = DEFAULT_CONNECTION_TIMEOUT_MS,
|
||||
num_workers: u32 = DEFAULT_NUM_WORKERS,
|
||||
};
|
||||
|
||||
/// Initialize the TCP server with custom configuration
|
||||
pub fn initWithConfig(bind_addr: net.Address, handler: *Handler, allocator: Allocator, config: Config) InitError!TcpServer {
|
||||
const listener = bind_addr.listen(.{
|
||||
.reuse_address = true,
|
||||
}) catch {
|
||||
return error.ListenFailed;
|
||||
};
|
||||
|
||||
return TcpServer{
|
||||
.listener = listener,
|
||||
.allocator = allocator,
|
||||
.handler = handler,
|
||||
.running = std.atomic.Value(bool).init(false),
|
||||
.active_connections = std.atomic.Value(u32).init(0),
|
||||
.max_connections = config.max_connections,
|
||||
.connection_timeout_ms = config.connection_timeout_ms,
|
||||
.work_queue = WorkQueue.init(),
|
||||
.workers = &[_]std.Thread{},
|
||||
.num_workers = config.num_workers,
|
||||
};
|
||||
}
|
||||
|
||||
/// Start the server loop
|
||||
pub fn run(self: *TcpServer) !void {
|
||||
self.running.store(true, .release);
|
||||
|
||||
// Start worker threads
|
||||
self.workers = self.allocator.alloc(std.Thread, self.num_workers) catch |err| {
|
||||
std.log.err("TCP: failed to allocate worker threads: {}", .{err});
|
||||
return error.OutOfMemory;
|
||||
};
|
||||
errdefer self.allocator.free(self.workers);
|
||||
|
||||
var started: u32 = 0;
|
||||
errdefer {
|
||||
self.running.store(false, .release);
|
||||
self.work_queue.wakeAll();
|
||||
for (self.workers[0..started]) |w| w.join();
|
||||
}
|
||||
|
||||
for (self.workers) |*worker| {
|
||||
worker.* = std.Thread.spawn(.{}, workerLoop, .{self}) catch |err| {
|
||||
std.log.err("TCP: failed to start worker thread: {}", .{err});
|
||||
return error.ThreadSpawnFailed;
|
||||
};
|
||||
started += 1;
|
||||
}
|
||||
|
||||
std.log.info("TCP: started {} worker threads", .{self.num_workers});
|
||||
|
||||
while (self.running.load(.acquire)) {
|
||||
// Use poll with timeout to allow checking running flag
|
||||
var fds = [1]posix.pollfd{
|
||||
.{
|
||||
.fd = self.listener.stream.handle,
|
||||
.events = posix.POLL.IN,
|
||||
.revents = 0,
|
||||
},
|
||||
};
|
||||
|
||||
const poll_result = posix.poll(&fds, 100) catch |err| {
|
||||
std.log.warn("TCP poll error: {}", .{err});
|
||||
continue;
|
||||
};
|
||||
|
||||
// Timeout - check running flag and continue
|
||||
if (poll_result == 0) continue;
|
||||
|
||||
// No connection pending
|
||||
if (fds[0].revents & posix.POLL.IN == 0) continue;
|
||||
|
||||
const conn = self.listener.accept() catch |err| {
|
||||
std.log.warn("TCP accept error: {}", .{err});
|
||||
continue;
|
||||
};
|
||||
|
||||
// Reserve a connection slot atomically (prevents TOCTOU race)
|
||||
// fetchAdd returns the OLD value, so new count is old + 1
|
||||
const old_count = self.active_connections.fetchAdd(1, .acq_rel);
|
||||
if (old_count >= self.max_connections) {
|
||||
// Over limit - rollback reservation and reject
|
||||
_ = self.active_connections.fetchSub(1, .release);
|
||||
std.log.warn("TCP: max connections ({}) reached, rejecting new connection", .{self.max_connections});
|
||||
posix.close(conn.stream.handle);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Submit to worker pool
|
||||
const task = ConnectionTask{
|
||||
.handle = conn.stream.handle,
|
||||
.address = conn.address,
|
||||
};
|
||||
|
||||
if (!self.work_queue.tryPush(task)) {
|
||||
// Queue full - rollback reservation and reject
|
||||
_ = self.active_connections.fetchSub(1, .release);
|
||||
std.log.warn("TCP: work queue full, rejecting connection", .{});
|
||||
posix.close(conn.stream.handle);
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown: wait for workers
|
||||
self.work_queue.wakeAll();
|
||||
for (self.workers) |w| w.join();
|
||||
self.allocator.free(self.workers);
|
||||
self.workers = &[_]std.Thread{};
|
||||
}
|
||||
|
||||
/// Worker thread loop
|
||||
fn workerLoop(self: *TcpServer) void {
|
||||
while (self.running.load(.acquire)) {
|
||||
if (self.work_queue.pop(&self.running)) |task| {
|
||||
self.handleConnectionTask(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximum DNS message size for TCP (RFC 1035: 2-byte length prefix allows up to 65535)
|
||||
const MAX_DNS_MESSAGE_SIZE: usize = 65535;
|
||||
|
||||
fn handleConnectionTask(self: *TcpServer, task: ConnectionTask) void {
|
||||
// Count was already incremented in accept loop when slot was reserved
|
||||
defer _ = self.active_connections.fetchSub(1, .release);
|
||||
defer posix.close(task.handle);
|
||||
|
||||
// Set read/write timeout on the connection to prevent slow clients from blocking
|
||||
const timeout = posix.timeval{
|
||||
.sec = @intCast(self.connection_timeout_ms / 1000),
|
||||
.usec = @intCast((self.connection_timeout_ms % 1000) * 1000),
|
||||
};
|
||||
posix.setsockopt(task.handle, posix.SOL.SOCKET, posix.SO.RCVTIMEO, std.mem.asBytes(&timeout)) catch {};
|
||||
posix.setsockopt(task.handle, posix.SOL.SOCKET, posix.SO.SNDTIMEO, std.mem.asBytes(&timeout)) catch {};
|
||||
|
||||
var buffer: [MAX_DNS_MESSAGE_SIZE]u8 = undefined;
|
||||
|
||||
while (self.running.load(.acquire)) {
|
||||
// Read 2-byte length prefix using posix
|
||||
var len_buf: [2]u8 = undefined;
|
||||
if (!readExact(task.handle, &len_buf)) break;
|
||||
|
||||
const length = std.mem.readInt(u16, &len_buf, .big);
|
||||
// Validate: must be at least a DNS header, and within buffer capacity
|
||||
if (length < types.DNS_HEADER_SIZE) {
|
||||
std.log.debug("TCP: message too small ({} bytes), dropping connection", .{length});
|
||||
break;
|
||||
}
|
||||
if (length > MAX_DNS_MESSAGE_SIZE) {
|
||||
std.log.warn("TCP: message too large ({} bytes), dropping connection", .{length});
|
||||
break;
|
||||
}
|
||||
|
||||
// Read DNS message using posix
|
||||
if (!readExact(task.handle, buffer[0..length])) break;
|
||||
|
||||
// Handle the query
|
||||
const response = self.handler.handle(
|
||||
buffer[0..length],
|
||||
task.address,
|
||||
self.allocator,
|
||||
) orelse continue;
|
||||
defer self.allocator.free(response);
|
||||
|
||||
// Validate response fits in TCP DNS message (u16 length prefix)
|
||||
if (response.len > MAX_DNS_MESSAGE_SIZE) {
|
||||
std.log.warn("TCP: response too large ({} bytes), dropping", .{response.len});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Write length-prefixed response
|
||||
var resp_len: [2]u8 = undefined;
|
||||
std.mem.writeInt(u16, &resp_len, @intCast(response.len), .big);
|
||||
|
||||
if (!writeAll(task.handle, &resp_len)) break;
|
||||
if (!writeAll(task.handle, response)) break;
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the server
|
||||
pub fn stop(self: *TcpServer) void {
|
||||
self.running.store(false, .release);
|
||||
self.work_queue.wakeAll();
|
||||
}
|
||||
|
||||
/// Wait for active connections to finish (with timeout)
|
||||
pub fn waitForConnections(self: *TcpServer, timeout_ms: u64) void {
|
||||
const start = std.time.milliTimestamp();
|
||||
while (self.active_connections.load(.acquire) > 0) {
|
||||
const elapsed: u64 = @intCast(std.time.milliTimestamp() - start);
|
||||
if (elapsed >= timeout_ms) {
|
||||
std.log.warn("TCP: {} connections still active after timeout", .{self.active_connections.load(.acquire)});
|
||||
break;
|
||||
}
|
||||
std.posix.nanosleep(0, 10 * std.time.ns_per_ms);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clean up resources
|
||||
pub fn deinit(self: *TcpServer) void {
|
||||
self.listener.deinit();
|
||||
}
|
||||
};
|
||||
|
||||
/// Read exactly the requested number of bytes using posix
|
||||
fn readExact(handle: posix.socket_t, buf: []u8) bool {
|
||||
var total_read: usize = 0;
|
||||
while (total_read < buf.len) {
|
||||
const n = posix.read(handle, buf[total_read..]) catch return false;
|
||||
if (n == 0) return false; // EOF
|
||||
total_read += n;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Write all bytes using posix
|
||||
fn writeAll(handle: posix.socket_t, buf: []const u8) bool {
|
||||
var total_written: usize = 0;
|
||||
while (total_written < buf.len) {
|
||||
const n = posix.write(handle, buf[total_written..]) catch return false;
|
||||
if (n == 0) return false;
|
||||
total_written += n;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
test "TcpServer init" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
const Handler = TcpServer.Handler;
|
||||
var handler = Handler{
|
||||
.context = undefined,
|
||||
.handleFn = struct {
|
||||
fn handle(_: *anyopaque, _: []const u8, _: std.net.Address, _: Allocator) ?[]const u8 {
|
||||
return null;
|
||||
}
|
||||
}.handle,
|
||||
};
|
||||
|
||||
const addr = net.Address.initIp4(.{ 127, 0, 0, 1 }, 0);
|
||||
var server = TcpServer.init(addr, &handler, allocator) catch |err| {
|
||||
std.debug.print("TcpServer init failed: {}\n", .{err});
|
||||
return error.TestFailed;
|
||||
};
|
||||
defer server.deinit();
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
const std = @import("std");
|
||||
const posix = std.posix;
|
||||
const Allocator = std.mem.Allocator;
|
||||
const packet = @import("../dns/packet.zig");
|
||||
const types = @import("../dns/types.zig");
|
||||
|
||||
/// UDP query task for worker pool
|
||||
const QueryTask = struct {
|
||||
data: [types.EDNS_DEFAULT_SIZE]u8,
|
||||
len: usize,
|
||||
src_addr: posix.sockaddr,
|
||||
addr_len: posix.socklen_t,
|
||||
};
|
||||
|
||||
/// Simple bounded work queue for UDP query tasks
|
||||
const WorkQueue = struct {
|
||||
items: [QUEUE_SIZE]?QueryTask,
|
||||
head: usize,
|
||||
tail: usize,
|
||||
count: usize,
|
||||
mutex: std.Thread.Mutex,
|
||||
not_empty: std.Thread.Condition,
|
||||
|
||||
const QUEUE_SIZE = 64;
|
||||
|
||||
fn init() WorkQueue {
|
||||
return .{
|
||||
.items = [_]?QueryTask{null} ** QUEUE_SIZE,
|
||||
.head = 0,
|
||||
.tail = 0,
|
||||
.count = 0,
|
||||
.mutex = .{},
|
||||
.not_empty = .{},
|
||||
};
|
||||
}
|
||||
|
||||
fn tryPush(self: *WorkQueue, task: QueryTask) bool {
|
||||
self.mutex.lock();
|
||||
defer self.mutex.unlock();
|
||||
|
||||
if (self.count >= QUEUE_SIZE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.items[self.tail] = task;
|
||||
self.tail = (self.tail + 1) % QUEUE_SIZE;
|
||||
self.count += 1;
|
||||
self.not_empty.signal();
|
||||
return true;
|
||||
}
|
||||
|
||||
fn pop(self: *WorkQueue, running: *std.atomic.Value(bool)) ?QueryTask {
|
||||
self.mutex.lock();
|
||||
defer self.mutex.unlock();
|
||||
|
||||
while (self.count == 0) {
|
||||
if (!running.load(.acquire)) {
|
||||
return null;
|
||||
}
|
||||
self.not_empty.timedWait(&self.mutex, 100 * std.time.ns_per_ms) catch {};
|
||||
}
|
||||
|
||||
if (self.count == 0) return null;
|
||||
|
||||
const task = self.items[self.head];
|
||||
self.items[self.head] = null;
|
||||
self.head = (self.head + 1) % QUEUE_SIZE;
|
||||
self.count -= 1;
|
||||
return task;
|
||||
}
|
||||
|
||||
fn wakeAll(self: *WorkQueue) void {
|
||||
self.mutex.lock();
|
||||
defer self.mutex.unlock();
|
||||
self.not_empty.broadcast();
|
||||
}
|
||||
};
|
||||
|
||||
pub const UdpServer = struct {
|
||||
socket: posix.socket_t,
|
||||
allocator: Allocator,
|
||||
handler: *Handler,
|
||||
running: std.atomic.Value(bool),
|
||||
work_queue: WorkQueue,
|
||||
workers: []std.Thread,
|
||||
num_workers: u32,
|
||||
dropped_queries: std.atomic.Value(u64),
|
||||
|
||||
/// Default number of worker threads
|
||||
pub const DEFAULT_NUM_WORKERS: u32 = 8;
|
||||
|
||||
pub const Handler = struct {
|
||||
context: *anyopaque,
|
||||
handleFn: *const fn (*anyopaque, []const u8, std.net.Address, Allocator) ?[]const u8,
|
||||
|
||||
pub fn handle(self: Handler, query: []const u8, client_addr: std.net.Address, allocator: Allocator) ?[]const u8 {
|
||||
return self.handleFn(self.context, query, client_addr, allocator);
|
||||
}
|
||||
};
|
||||
|
||||
pub const InitError = error{
|
||||
SocketCreationFailed,
|
||||
SetSockOptFailed,
|
||||
BindFailed,
|
||||
} || posix.SocketError || posix.SetSockOptError;
|
||||
|
||||
pub const Config = struct {
|
||||
num_workers: u32 = DEFAULT_NUM_WORKERS,
|
||||
};
|
||||
|
||||
/// Initialize the UDP server
|
||||
pub fn init(bind_addr: std.net.Address, handler: *Handler, allocator: Allocator) InitError!UdpServer {
|
||||
return initWithConfig(bind_addr, handler, allocator, .{});
|
||||
}
|
||||
|
||||
/// Initialize the UDP server with custom configuration
|
||||
pub fn initWithConfig(bind_addr: std.net.Address, handler: *Handler, allocator: Allocator, config: Config) InitError!UdpServer {
|
||||
// Create UDP socket
|
||||
const socket = try posix.socket(
|
||||
bind_addr.any.family,
|
||||
posix.SOCK.DGRAM,
|
||||
0,
|
||||
);
|
||||
errdefer posix.close(socket);
|
||||
|
||||
// Bind to address (no SO_REUSEADDR - we want bind to fail if another instance is running)
|
||||
posix.bind(socket, &bind_addr.any, bind_addr.getOsSockLen()) catch {
|
||||
return error.BindFailed;
|
||||
};
|
||||
|
||||
return UdpServer{
|
||||
.socket = socket,
|
||||
.allocator = allocator,
|
||||
.handler = handler,
|
||||
.running = std.atomic.Value(bool).init(false),
|
||||
.work_queue = WorkQueue.init(),
|
||||
.workers = &[_]std.Thread{},
|
||||
.num_workers = config.num_workers,
|
||||
.dropped_queries = std.atomic.Value(u64).init(0),
|
||||
};
|
||||
}
|
||||
|
||||
/// Get the count of dropped queries (for monitoring)
|
||||
pub fn getDroppedQueries(self: *UdpServer) u64 {
|
||||
return self.dropped_queries.load(.monotonic);
|
||||
}
|
||||
|
||||
/// Start the server loop
|
||||
pub fn run(self: *UdpServer) !void {
|
||||
self.running.store(true, .release);
|
||||
|
||||
// Start worker threads
|
||||
self.workers = self.allocator.alloc(std.Thread, self.num_workers) catch |err| {
|
||||
std.log.err("UDP: failed to allocate worker threads: {}", .{err});
|
||||
return error.OutOfMemory;
|
||||
};
|
||||
errdefer self.allocator.free(self.workers);
|
||||
|
||||
var started: u32 = 0;
|
||||
errdefer {
|
||||
self.running.store(false, .release);
|
||||
self.work_queue.wakeAll();
|
||||
for (self.workers[0..started]) |w| w.join();
|
||||
}
|
||||
|
||||
for (self.workers) |*worker| {
|
||||
worker.* = std.Thread.spawn(.{}, workerLoop, .{self}) catch |err| {
|
||||
std.log.err("UDP: failed to start worker thread: {}", .{err});
|
||||
return error.ThreadSpawnFailed;
|
||||
};
|
||||
started += 1;
|
||||
}
|
||||
|
||||
std.log.info("UDP: started {} worker threads", .{self.num_workers});
|
||||
|
||||
var buffer: [types.EDNS_DEFAULT_SIZE]u8 = undefined;
|
||||
|
||||
while (self.running.load(.acquire)) {
|
||||
// Use poll with timeout to allow checking running flag
|
||||
var fds = [1]posix.pollfd{
|
||||
.{
|
||||
.fd = self.socket,
|
||||
.events = posix.POLL.IN,
|
||||
.revents = 0,
|
||||
},
|
||||
};
|
||||
|
||||
const poll_result = posix.poll(&fds, 100) catch |err| {
|
||||
std.log.warn("UDP poll error: {}", .{err});
|
||||
continue;
|
||||
};
|
||||
|
||||
// Timeout - check running flag and continue
|
||||
if (poll_result == 0) continue;
|
||||
|
||||
// No data available
|
||||
if (fds[0].revents & posix.POLL.IN == 0) continue;
|
||||
|
||||
var src_addr: posix.sockaddr = undefined;
|
||||
var addr_len: posix.socklen_t = @sizeOf(posix.sockaddr);
|
||||
|
||||
// Receive query
|
||||
const recv_len = posix.recvfrom(
|
||||
self.socket,
|
||||
&buffer,
|
||||
0,
|
||||
&src_addr,
|
||||
&addr_len,
|
||||
) catch |err| {
|
||||
std.log.warn("UDP receive error: {}", .{err});
|
||||
continue;
|
||||
};
|
||||
|
||||
if (recv_len < types.DNS_HEADER_SIZE) {
|
||||
continue; // Too small to be valid DNS
|
||||
}
|
||||
|
||||
// Create task and submit to worker pool
|
||||
var task = QueryTask{
|
||||
.data = undefined,
|
||||
.len = recv_len,
|
||||
.src_addr = src_addr,
|
||||
.addr_len = addr_len,
|
||||
};
|
||||
@memcpy(task.data[0..recv_len], buffer[0..recv_len]);
|
||||
|
||||
if (!self.work_queue.tryPush(task)) {
|
||||
// Backpressure: send SERVFAIL instead of silent drop
|
||||
_ = self.dropped_queries.fetchAdd(1, .monotonic);
|
||||
self.sendServfail(task.data[0..task.len], &task.src_addr, task.addr_len);
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown: wait for workers
|
||||
self.work_queue.wakeAll();
|
||||
for (self.workers) |w| w.join();
|
||||
self.allocator.free(self.workers);
|
||||
self.workers = &[_]std.Thread{};
|
||||
}
|
||||
|
||||
/// Worker thread loop
|
||||
fn workerLoop(self: *UdpServer) void {
|
||||
while (self.running.load(.acquire)) {
|
||||
if (self.work_queue.pop(&self.running)) |task| {
|
||||
self.processQuery(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Process a single query
|
||||
fn processQuery(self: *UdpServer, task: QueryTask) void {
|
||||
const client_addr = std.net.Address{ .any = task.src_addr };
|
||||
|
||||
// Handle the query
|
||||
const response = self.handler.handle(
|
||||
task.data[0..task.len],
|
||||
client_addr,
|
||||
self.allocator,
|
||||
) orelse return;
|
||||
defer self.allocator.free(response);
|
||||
|
||||
// Determine max response size based on query EDNS support
|
||||
const max_response_size = getMaxResponseSize(task.data[0..task.len]);
|
||||
|
||||
// Send response (truncate if needed)
|
||||
if (response.len > max_response_size) {
|
||||
// Set TC (truncation) bit in response header
|
||||
var truncated_response: [types.EDNS_DEFAULT_SIZE]u8 = undefined;
|
||||
const safe_max = @min(max_response_size, types.EDNS_DEFAULT_SIZE);
|
||||
const truncated_len = @min(response.len, safe_max);
|
||||
@memcpy(truncated_response[0..truncated_len], response[0..truncated_len]);
|
||||
truncated_response[2] |= 0x02;
|
||||
|
||||
_ = posix.sendto(
|
||||
self.socket,
|
||||
truncated_response[0..truncated_len],
|
||||
0,
|
||||
&task.src_addr,
|
||||
task.addr_len,
|
||||
) catch |err| {
|
||||
std.log.warn("UDP send error: {}", .{err});
|
||||
};
|
||||
} else {
|
||||
_ = posix.sendto(
|
||||
self.socket,
|
||||
response,
|
||||
0,
|
||||
&task.src_addr,
|
||||
task.addr_len,
|
||||
) catch |err| {
|
||||
std.log.warn("UDP send error: {}", .{err});
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Determine maximum response size based on EDNS in query
|
||||
/// Returns 512 (RFC 1035 default) if no EDNS, otherwise client's advertised size
|
||||
fn getMaxResponseSize(query: []const u8) usize {
|
||||
// Need at least header + minimal question
|
||||
if (query.len < types.DNS_HEADER_SIZE) {
|
||||
return types.DNS_UDP_SIZE;
|
||||
}
|
||||
|
||||
// Check ARCOUNT (additional record count) - bytes 10-11
|
||||
const arcount = std.mem.readInt(u16, query[10..12], .big);
|
||||
if (arcount == 0) {
|
||||
return types.DNS_UDP_SIZE;
|
||||
}
|
||||
|
||||
// Quick scan for OPT record (type 41)
|
||||
// OPT records have root name (0x00), type 0x0029
|
||||
// This is a simplified scan - look for the pattern in additional section
|
||||
var i: usize = types.DNS_HEADER_SIZE;
|
||||
|
||||
// Skip questions
|
||||
const qdcount = std.mem.readInt(u16, query[4..6], .big);
|
||||
var q: u16 = 0;
|
||||
while (q < qdcount and i < query.len) : (q += 1) {
|
||||
// Skip name
|
||||
while (i < query.len) {
|
||||
const len = query[i];
|
||||
if (len == 0) {
|
||||
i += 1;
|
||||
break;
|
||||
} else if ((len & 0xC0) == 0xC0) {
|
||||
i += 2;
|
||||
break;
|
||||
} else {
|
||||
i += 1 + len;
|
||||
}
|
||||
}
|
||||
i += 4; // Skip QTYPE and QCLASS
|
||||
}
|
||||
|
||||
// Skip answers
|
||||
const ancount = std.mem.readInt(u16, query[6..8], .big);
|
||||
var a: u16 = 0;
|
||||
while (a < ancount and i < query.len) : (a += 1) {
|
||||
i = skipResourceRecord(query, i);
|
||||
}
|
||||
|
||||
// Skip authority
|
||||
const nscount = std.mem.readInt(u16, query[8..10], .big);
|
||||
var n: u16 = 0;
|
||||
while (n < nscount and i < query.len) : (n += 1) {
|
||||
i = skipResourceRecord(query, i);
|
||||
}
|
||||
|
||||
// Look for OPT in additional
|
||||
var ar: u16 = 0;
|
||||
while (ar < arcount and i + 11 <= query.len) : (ar += 1) {
|
||||
const name_start = i;
|
||||
// Skip name
|
||||
while (i < query.len) {
|
||||
const len = query[i];
|
||||
if (len == 0) {
|
||||
i += 1;
|
||||
break;
|
||||
} else if ((len & 0xC0) == 0xC0) {
|
||||
i += 2;
|
||||
break;
|
||||
} else {
|
||||
i += 1 + len;
|
||||
}
|
||||
}
|
||||
|
||||
if (i + 10 > query.len) break;
|
||||
|
||||
const rtype = std.mem.readInt(u16, query[i..][0..2], .big);
|
||||
if (rtype == 41 and query[name_start] == 0) {
|
||||
// Found OPT record - CLASS field contains UDP payload size
|
||||
const udp_size = std.mem.readInt(u16, query[i + 2 ..][0..2], .big);
|
||||
// Return client's size, capped at our max
|
||||
return @min(udp_size, types.EDNS_DEFAULT_SIZE);
|
||||
}
|
||||
|
||||
// Skip to next record
|
||||
const rdlength = std.mem.readInt(u16, query[i + 8 ..][0..2], .big);
|
||||
i += 10 + rdlength;
|
||||
}
|
||||
|
||||
return types.DNS_UDP_SIZE;
|
||||
}
|
||||
|
||||
/// Skip a resource record and return new position
|
||||
fn skipResourceRecord(data: []const u8, start: usize) usize {
|
||||
var i = start;
|
||||
|
||||
// Skip name
|
||||
while (i < data.len) {
|
||||
const len = data[i];
|
||||
if (len == 0) {
|
||||
i += 1;
|
||||
break;
|
||||
} else if ((len & 0xC0) == 0xC0) {
|
||||
i += 2;
|
||||
break;
|
||||
} else {
|
||||
i += 1 + len;
|
||||
}
|
||||
}
|
||||
|
||||
// Need TYPE(2) + CLASS(2) + TTL(4) + RDLENGTH(2)
|
||||
if (i + 10 > data.len) return data.len;
|
||||
|
||||
const rdlength = std.mem.readInt(u16, data[i + 8 ..][0..2], .big);
|
||||
const new_pos = i + 10 + rdlength;
|
||||
// Clamp to data.len to ensure callers don't need to handle overflow
|
||||
return @min(new_pos, data.len);
|
||||
}
|
||||
|
||||
/// Stop the server
|
||||
pub fn stop(self: *UdpServer) void {
|
||||
self.running.store(false, .release);
|
||||
self.work_queue.wakeAll();
|
||||
}
|
||||
|
||||
/// Close the server socket
|
||||
pub fn deinit(self: *UdpServer) void {
|
||||
posix.close(self.socket);
|
||||
}
|
||||
|
||||
/// Send a SERVFAIL response for backpressure
|
||||
fn sendServfail(self: *UdpServer, query: []const u8, addr: *const posix.sockaddr, addr_len: posix.socklen_t) void {
|
||||
if (query.len < types.DNS_HEADER_SIZE) return;
|
||||
|
||||
// Build minimal SERVFAIL response (12 bytes - header only)
|
||||
var response: [12]u8 = undefined;
|
||||
|
||||
// Copy transaction ID (bytes 0-1)
|
||||
response[0] = query[0];
|
||||
response[1] = query[1];
|
||||
|
||||
// Flags: QR=1 (response), OPCODE=copy, AA=0, TC=0, RD=copy, RA=1, Z=0, RCODE=2 (SERVFAIL)
|
||||
const opcode = query[2] & 0x78; // Extract OPCODE bits
|
||||
const rd = query[2] & 0x01; // Extract RD bit
|
||||
response[2] = 0x80 | opcode | rd; // QR=1, copy OPCODE and RD
|
||||
response[3] = 0x82; // RA=1, RCODE=2 (SERVFAIL)
|
||||
|
||||
// Counts: all zeros (no questions/answers in minimal response)
|
||||
response[4] = 0;
|
||||
response[5] = 0;
|
||||
response[6] = 0;
|
||||
response[7] = 0;
|
||||
response[8] = 0;
|
||||
response[9] = 0;
|
||||
response[10] = 0;
|
||||
response[11] = 0;
|
||||
|
||||
_ = posix.sendto(self.socket, &response, 0, addr, addr_len) catch {};
|
||||
}
|
||||
};
|
||||
|
||||
/// Create a simple echo handler for testing
|
||||
/// Caller must call destroyEchoHandler when done to free allocated context
|
||||
pub fn createEchoHandler(allocator: Allocator) !UdpServer.Handler {
|
||||
const EchoContext = struct {
|
||||
allocator: Allocator,
|
||||
|
||||
fn handle(ctx: *anyopaque, query: []const u8, _: std.net.Address, alloc: Allocator) ?[]const u8 {
|
||||
_ = ctx;
|
||||
// Parse query and create response
|
||||
var pkt = packet.Packet.parse(query, alloc) catch return null;
|
||||
defer pkt.deinit();
|
||||
|
||||
// Create simple response echoing the query
|
||||
var response = packet.Packet.createDeniedResponse(&pkt, alloc) catch return null;
|
||||
defer response.deinit();
|
||||
|
||||
var response_buffer: [types.EDNS_DEFAULT_SIZE]u8 = undefined;
|
||||
const response_len = response.encode(&response_buffer) catch return null;
|
||||
|
||||
return alloc.dupe(u8, response_buffer[0..response_len]) catch return null;
|
||||
}
|
||||
};
|
||||
|
||||
const ctx = try allocator.create(EchoContext);
|
||||
ctx.* = EchoContext{ .allocator = allocator };
|
||||
|
||||
return UdpServer.Handler{
|
||||
.context = ctx,
|
||||
.handleFn = EchoContext.handle,
|
||||
};
|
||||
}
|
||||
|
||||
/// Free the echo handler context allocated by createEchoHandler
|
||||
pub fn destroyEchoHandler(handler: *UdpServer.Handler, allocator: Allocator) void {
|
||||
const EchoContext = struct { allocator: Allocator };
|
||||
const ctx: *EchoContext = @ptrCast(@alignCast(handler.context));
|
||||
allocator.destroy(ctx);
|
||||
handler.context = undefined;
|
||||
}
|
||||
|
||||
test "UDP server creation" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var handler = try createEchoHandler(allocator);
|
||||
defer destroyEchoHandler(&handler, allocator);
|
||||
|
||||
// Try to create server on a high port to avoid permission issues
|
||||
const addr = std.net.Address.initIp4([4]u8{ 127, 0, 0, 1 }, 15353);
|
||||
|
||||
var server = UdpServer.init(addr, &handler, allocator) catch |err| {
|
||||
// Skip test if we can't bind (e.g., in CI)
|
||||
std.log.warn("Could not create UDP server: {}", .{err});
|
||||
return;
|
||||
};
|
||||
defer server.deinit();
|
||||
|
||||
try testing.expect(server.socket != 0);
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
const std = @import("std");
|
||||
const c = @cImport({
|
||||
@cInclude("sqlite3.h");
|
||||
});
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
/// SQLite Database wrapper
|
||||
pub const Database = struct {
|
||||
conn: *c.sqlite3,
|
||||
allocator: Allocator,
|
||||
/// Mutex to serialize transaction access (SQLite doesn't support nested transactions)
|
||||
tx_mutex: std.Thread.Mutex = .{},
|
||||
|
||||
pub const Error = error{
|
||||
OpenFailed,
|
||||
ExecuteFailed,
|
||||
PrepareFailed,
|
||||
BindFailed,
|
||||
StepFailed,
|
||||
ColumnError,
|
||||
OutOfMemory,
|
||||
};
|
||||
|
||||
/// Open a database file
|
||||
pub fn open(path: []const u8, allocator: Allocator) Error!Database {
|
||||
var conn: ?*c.sqlite3 = null;
|
||||
|
||||
// Create null-terminated path
|
||||
const path_z = allocator.dupeZ(u8, path) catch return error.OutOfMemory;
|
||||
defer allocator.free(path_z);
|
||||
|
||||
const result = c.sqlite3_open(path_z.ptr, &conn);
|
||||
if (result != c.SQLITE_OK) {
|
||||
if (conn) |cn| {
|
||||
_ = c.sqlite3_close(cn);
|
||||
}
|
||||
return error.OpenFailed;
|
||||
}
|
||||
|
||||
// Enable WAL mode for better concurrency
|
||||
var stmt: ?*c.sqlite3_stmt = null;
|
||||
_ = c.sqlite3_prepare_v2(conn, "PRAGMA journal_mode=WAL", -1, &stmt, null);
|
||||
if (stmt) |s| {
|
||||
_ = c.sqlite3_step(s);
|
||||
_ = c.sqlite3_finalize(s);
|
||||
}
|
||||
|
||||
// Enable foreign keys
|
||||
_ = c.sqlite3_prepare_v2(conn, "PRAGMA foreign_keys=ON", -1, &stmt, null);
|
||||
if (stmt) |s| {
|
||||
_ = c.sqlite3_step(s);
|
||||
_ = c.sqlite3_finalize(s);
|
||||
}
|
||||
|
||||
// Set busy timeout to handle concurrent write contention
|
||||
// SQLite will retry for up to 5 seconds before returning SQLITE_BUSY
|
||||
_ = c.sqlite3_prepare_v2(conn, "PRAGMA busy_timeout=5000", -1, &stmt, null);
|
||||
if (stmt) |s| {
|
||||
_ = c.sqlite3_step(s);
|
||||
_ = c.sqlite3_finalize(s);
|
||||
}
|
||||
|
||||
return Database{
|
||||
.conn = conn.?,
|
||||
.allocator = allocator,
|
||||
};
|
||||
}
|
||||
|
||||
/// Close the database
|
||||
pub fn close(self: *Database) void {
|
||||
_ = c.sqlite3_close(self.conn);
|
||||
}
|
||||
|
||||
/// Execute a SQL statement
|
||||
pub fn exec(self: *Database, sql: []const u8) Error!void {
|
||||
const sql_z = self.allocator.dupeZ(u8, sql) catch return error.OutOfMemory;
|
||||
defer self.allocator.free(sql_z);
|
||||
|
||||
var err_msg: [*c]u8 = null;
|
||||
const result = c.sqlite3_exec(self.conn, sql_z.ptr, null, null, &err_msg);
|
||||
|
||||
if (result != c.SQLITE_OK) {
|
||||
if (err_msg) |msg| {
|
||||
std.log.err("SQLite error: {s}", .{msg});
|
||||
c.sqlite3_free(msg);
|
||||
}
|
||||
return error.ExecuteFailed;
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute multiple SQL statements (for schema creation)
|
||||
pub fn execMulti(self: *Database, sql: []const u8) Error!void {
|
||||
var iter = std.mem.splitSequence(u8, sql, ";");
|
||||
while (iter.next()) |stmt_sql| {
|
||||
const trimmed = std.mem.trim(u8, stmt_sql, " \t\n\r");
|
||||
if (trimmed.len == 0) continue;
|
||||
|
||||
// Add back semicolon
|
||||
const full_stmt = std.fmt.allocPrint(self.allocator, "{s};", .{trimmed}) catch return error.OutOfMemory;
|
||||
defer self.allocator.free(full_stmt);
|
||||
|
||||
self.exec(full_stmt) catch |err| {
|
||||
std.log.err("Failed to execute: {s}", .{trimmed});
|
||||
return err;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Prepare a SQL statement
|
||||
pub fn prepare(self: *Database, sql: []const u8) Error!Statement {
|
||||
const sql_z = self.allocator.dupeZ(u8, sql) catch return error.OutOfMemory;
|
||||
defer self.allocator.free(sql_z);
|
||||
|
||||
var stmt: ?*c.sqlite3_stmt = null;
|
||||
const result = c.sqlite3_prepare_v2(self.conn, sql_z.ptr, @intCast(sql_z.len), &stmt, null);
|
||||
|
||||
if (result != c.SQLITE_OK or stmt == null) {
|
||||
const err = c.sqlite3_errmsg(self.conn);
|
||||
if (err) |msg| {
|
||||
std.log.err("SQLite prepare error: {s}", .{msg});
|
||||
}
|
||||
return error.PrepareFailed;
|
||||
}
|
||||
|
||||
return Statement{
|
||||
.stmt = stmt.?,
|
||||
.allocator = self.allocator,
|
||||
};
|
||||
}
|
||||
|
||||
/// Get the last insert rowid
|
||||
pub fn lastInsertRowId(self: *Database) i64 {
|
||||
return c.sqlite3_last_insert_rowid(self.conn);
|
||||
}
|
||||
|
||||
/// Get the number of rows changed by the last statement
|
||||
pub fn changes(self: *Database) i32 {
|
||||
return c.sqlite3_changes(self.conn);
|
||||
}
|
||||
|
||||
/// Begin a transaction with RAII semantics - auto-rollback on scope exit if not committed
|
||||
pub fn begin(self: *Database) Error!Transaction {
|
||||
self.tx_mutex.lock();
|
||||
errdefer self.tx_mutex.unlock();
|
||||
try self.exec("BEGIN IMMEDIATE");
|
||||
return Transaction{ .db = self };
|
||||
}
|
||||
|
||||
/// Legacy: Begin a transaction (acquires tx_mutex to prevent concurrent transactions)
|
||||
/// Prefer using begin() which returns a Transaction with RAII semantics
|
||||
pub fn beginTransaction(self: *Database) Error!void {
|
||||
self.tx_mutex.lock();
|
||||
errdefer self.tx_mutex.unlock();
|
||||
return self.exec("BEGIN IMMEDIATE");
|
||||
}
|
||||
|
||||
/// Legacy: Commit a transaction (releases tx_mutex)
|
||||
/// Prefer using Transaction.commit() for RAII safety
|
||||
pub fn commit(self: *Database) Error!void {
|
||||
defer self.tx_mutex.unlock();
|
||||
return self.exec("COMMIT");
|
||||
}
|
||||
|
||||
/// Legacy: Rollback a transaction (releases tx_mutex)
|
||||
/// Prefer using Transaction.deinit() for RAII safety
|
||||
pub fn rollback(self: *Database) Error!void {
|
||||
defer self.tx_mutex.unlock();
|
||||
return self.exec("ROLLBACK");
|
||||
}
|
||||
};
|
||||
|
||||
/// RAII Transaction - automatically rolls back if not committed
|
||||
pub const Transaction = struct {
|
||||
db: *Database,
|
||||
committed: bool = false,
|
||||
|
||||
/// Commit the transaction
|
||||
pub fn commit(self: *Transaction) Database.Error!void {
|
||||
try self.db.exec("COMMIT");
|
||||
self.committed = true;
|
||||
}
|
||||
|
||||
/// Cleanup: rollback if not committed, always release mutex
|
||||
pub fn deinit(self: *Transaction) void {
|
||||
if (!self.committed) {
|
||||
self.db.exec("ROLLBACK") catch |err| {
|
||||
std.log.err("Transaction rollback failed: {}", .{err});
|
||||
};
|
||||
}
|
||||
self.db.tx_mutex.unlock();
|
||||
}
|
||||
};
|
||||
|
||||
/// Prepared statement wrapper
|
||||
pub const Statement = struct {
|
||||
stmt: *c.sqlite3_stmt,
|
||||
allocator: Allocator,
|
||||
|
||||
/// Bind an integer value
|
||||
pub fn bindInt(self: *Statement, index: usize, value: i64) Database.Error!void {
|
||||
const result = c.sqlite3_bind_int64(self.stmt, @intCast(index), value);
|
||||
if (result != c.SQLITE_OK) {
|
||||
return error.BindFailed;
|
||||
}
|
||||
}
|
||||
|
||||
/// Bind an unsigned integer value (clamps to i64 max for SQLite compatibility)
|
||||
pub fn bindUint64(self: *Statement, index: usize, value: u64) Database.Error!void {
|
||||
const clamped: i64 = @intCast(@min(value, @as(u64, std.math.maxInt(i64))));
|
||||
return self.bindInt(index, clamped);
|
||||
}
|
||||
|
||||
/// Bind a text value
|
||||
pub fn bindText(self: *Statement, index: usize, value: []const u8) Database.Error!void {
|
||||
const value_z = self.allocator.dupeZ(u8, value) catch return error.OutOfMemory;
|
||||
defer self.allocator.free(value_z);
|
||||
|
||||
const result = c.sqlite3_bind_text(
|
||||
self.stmt,
|
||||
@intCast(index),
|
||||
value_z.ptr,
|
||||
@intCast(value_z.len), // dupeZ's .len excludes the null terminator
|
||||
c.SQLITE_TRANSIENT,
|
||||
);
|
||||
|
||||
if (result != c.SQLITE_OK) {
|
||||
return error.BindFailed;
|
||||
}
|
||||
}
|
||||
|
||||
/// Bind a null value
|
||||
pub fn bindNull(self: *Statement, index: usize) Database.Error!void {
|
||||
const result = c.sqlite3_bind_null(self.stmt, @intCast(index));
|
||||
if (result != c.SQLITE_OK) {
|
||||
return error.BindFailed;
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a step (returns true if row available)
|
||||
pub fn step(self: *Statement) Database.Error!bool {
|
||||
const result = c.sqlite3_step(self.stmt);
|
||||
if (result == c.SQLITE_ROW) {
|
||||
return true;
|
||||
} else if (result == c.SQLITE_DONE) {
|
||||
return false;
|
||||
} else {
|
||||
return error.StepFailed;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get an integer column value
|
||||
pub fn getInt(self: *Statement, col: usize) i64 {
|
||||
return c.sqlite3_column_int64(self.stmt, @intCast(col));
|
||||
}
|
||||
|
||||
/// Get a text column value
|
||||
pub fn getText(self: *Statement, col: usize) ?[]const u8 {
|
||||
const ptr = c.sqlite3_column_text(self.stmt, @intCast(col));
|
||||
if (ptr == null) return null;
|
||||
|
||||
const len = c.sqlite3_column_bytes(self.stmt, @intCast(col));
|
||||
if (len <= 0) return "";
|
||||
|
||||
return ptr[0..@intCast(len)];
|
||||
}
|
||||
|
||||
/// Check if column is null
|
||||
pub fn isNull(self: *Statement, col: usize) bool {
|
||||
return c.sqlite3_column_type(self.stmt, @intCast(col)) == c.SQLITE_NULL;
|
||||
}
|
||||
|
||||
/// Reset the statement for reuse
|
||||
pub fn reset(self: *Statement) void {
|
||||
_ = c.sqlite3_reset(self.stmt);
|
||||
_ = c.sqlite3_clear_bindings(self.stmt);
|
||||
}
|
||||
|
||||
/// Finalize (close) the statement
|
||||
pub fn finalize(self: *Statement) void {
|
||||
_ = c.sqlite3_finalize(self.stmt);
|
||||
}
|
||||
};
|
||||
|
||||
test "Database open and close" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var db = try Database.open(":memory:", allocator);
|
||||
defer db.close();
|
||||
|
||||
try db.exec("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)");
|
||||
try db.exec("INSERT INTO test (name) VALUES ('hello')");
|
||||
|
||||
var stmt = try db.prepare("SELECT id, name FROM test WHERE id = 1");
|
||||
defer stmt.finalize();
|
||||
|
||||
const has_row = try stmt.step();
|
||||
try testing.expect(has_row);
|
||||
|
||||
const id = stmt.getInt(0);
|
||||
try testing.expectEqual(@as(i64, 1), id);
|
||||
|
||||
const name = stmt.getText(1);
|
||||
try testing.expectEqualStrings("hello", name.?);
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const Database = @import("db.zig").Database;
|
||||
const handler = @import("../server/handler.zig");
|
||||
const schema = @import("schema.zig");
|
||||
|
||||
/// Query log entry for batching
|
||||
pub const LogEntry = struct {
|
||||
timestamp: i64,
|
||||
domain: []const u8,
|
||||
client_ip: []const u8,
|
||||
qtype: u16,
|
||||
// New status tracking (Gap 1)
|
||||
status: schema.QueryStatus,
|
||||
// Attribution tracking (Gap 2)
|
||||
list_id: ?i64,
|
||||
rule_id: ?i64,
|
||||
// Reply type tracking (Gap 3)
|
||||
reply_type: schema.ReplyType,
|
||||
// Protocol tracking (Gap 8)
|
||||
protocol: schema.ClientProtocol,
|
||||
response_time_us: u64,
|
||||
upstream: ?[]const u8,
|
||||
reason: ?[]const u8,
|
||||
dnssec_validated: bool,
|
||||
|
||||
// Legacy compatibility
|
||||
pub fn isDenied(self: LogEntry) bool {
|
||||
return self.status.isDenied();
|
||||
}
|
||||
};
|
||||
|
||||
/// Subscriber callback for real-time query notifications
|
||||
pub const Subscriber = struct {
|
||||
context: *anyopaque,
|
||||
notifyFn: *const fn (*anyopaque, LogEntry) void,
|
||||
|
||||
pub fn notify(self: Subscriber, entry: LogEntry) void {
|
||||
self.notifyFn(self.context, entry);
|
||||
}
|
||||
};
|
||||
|
||||
/// Batched query logger - batches entries for efficient SQLite writes
|
||||
pub const QueryLogger = struct {
|
||||
buffer: std.ArrayListUnmanaged(LogEntry),
|
||||
db: *Database,
|
||||
allocator: Allocator,
|
||||
mutex: std.Thread.Mutex,
|
||||
last_flush: i64,
|
||||
batch_size: usize,
|
||||
flush_interval_ms: i64,
|
||||
subscriber: ?Subscriber,
|
||||
dropped_entries: std.atomic.Value(u64),
|
||||
|
||||
const BATCH_SIZE: usize = 100;
|
||||
const FLUSH_INTERVAL_MS: i64 = 100;
|
||||
|
||||
pub fn init(db: *Database, allocator: Allocator) QueryLogger {
|
||||
return QueryLogger{
|
||||
.buffer = std.ArrayListUnmanaged(LogEntry){},
|
||||
.db = db,
|
||||
.allocator = allocator,
|
||||
.mutex = std.Thread.Mutex{},
|
||||
.last_flush = std.time.milliTimestamp(),
|
||||
.batch_size = BATCH_SIZE,
|
||||
.flush_interval_ms = FLUSH_INTERVAL_MS,
|
||||
.subscriber = null,
|
||||
.dropped_entries = std.atomic.Value(u64).init(0),
|
||||
};
|
||||
}
|
||||
|
||||
/// Get count of dropped log entries (for monitoring)
|
||||
pub fn getDroppedEntries(self: *QueryLogger) u64 {
|
||||
return self.dropped_entries.load(.monotonic);
|
||||
}
|
||||
|
||||
/// Set a subscriber for real-time query notifications
|
||||
pub fn setSubscriber(self: *QueryLogger, subscriber: Subscriber) void {
|
||||
self.subscriber = subscriber;
|
||||
}
|
||||
|
||||
pub fn deinit(self: *QueryLogger) void {
|
||||
// Best effort flush during shutdown - log but don't fail if it errors
|
||||
self.flush() catch |err| {
|
||||
std.log.warn("QueryLogger: flush failed during deinit: {}", .{err});
|
||||
};
|
||||
|
||||
// Free any remaining entries
|
||||
for (self.buffer.items) |entry| {
|
||||
self.allocator.free(entry.domain);
|
||||
self.allocator.free(entry.client_ip);
|
||||
if (entry.upstream) |u| self.allocator.free(u);
|
||||
if (entry.reason) |r| self.allocator.free(r);
|
||||
}
|
||||
self.buffer.deinit(self.allocator);
|
||||
}
|
||||
|
||||
/// Log a query (non-blocking)
|
||||
pub fn log(self: *QueryLogger, entry: handler.QueryLogEntry) void {
|
||||
self.mutex.lock();
|
||||
defer self.mutex.unlock();
|
||||
|
||||
// Copy strings since the original may be freed
|
||||
const domain_copy = self.allocator.dupe(u8, entry.domain) catch {
|
||||
_ = self.dropped_entries.fetchAdd(1, .monotonic);
|
||||
return;
|
||||
};
|
||||
errdefer self.allocator.free(domain_copy);
|
||||
|
||||
const client_ip_copy = self.allocator.dupe(u8, entry.client_ip) catch {
|
||||
_ = self.dropped_entries.fetchAdd(1, .monotonic);
|
||||
self.allocator.free(domain_copy);
|
||||
return;
|
||||
};
|
||||
errdefer self.allocator.free(client_ip_copy);
|
||||
|
||||
const upstream_copy: ?[]const u8 = if (entry.upstream) |u|
|
||||
self.allocator.dupe(u8, u) catch null
|
||||
else
|
||||
null;
|
||||
|
||||
const reason_copy: ?[]const u8 = if (entry.reason) |r|
|
||||
self.allocator.dupe(u8, r) catch null
|
||||
else
|
||||
null;
|
||||
|
||||
const log_entry = LogEntry{
|
||||
.timestamp = entry.timestamp,
|
||||
.domain = domain_copy,
|
||||
.client_ip = client_ip_copy,
|
||||
.qtype = @intFromEnum(entry.qtype),
|
||||
.status = entry.status,
|
||||
.list_id = entry.list_id,
|
||||
.rule_id = entry.rule_id,
|
||||
.reply_type = entry.reply_type,
|
||||
.protocol = entry.protocol,
|
||||
.response_time_us = entry.response_time_us,
|
||||
.upstream = upstream_copy,
|
||||
.reason = reason_copy,
|
||||
.dnssec_validated = entry.dnssec_validated,
|
||||
};
|
||||
|
||||
// Notify real-time subscriber (for SSE)
|
||||
if (self.subscriber) |sub| {
|
||||
sub.notify(log_entry);
|
||||
}
|
||||
|
||||
self.buffer.append(self.allocator, log_entry) catch {
|
||||
std.log.warn("QueryLogger: failed to append log entry", .{});
|
||||
self.allocator.free(domain_copy);
|
||||
self.allocator.free(client_ip_copy);
|
||||
if (upstream_copy) |u| self.allocator.free(u);
|
||||
if (reason_copy) |r| self.allocator.free(r);
|
||||
return;
|
||||
};
|
||||
|
||||
const now = std.time.milliTimestamp();
|
||||
if (self.buffer.items.len >= self.batch_size or
|
||||
now - self.last_flush >= self.flush_interval_ms)
|
||||
{
|
||||
self.flushLocked();
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush the buffer to the database
|
||||
pub fn flush(self: *QueryLogger) !void {
|
||||
self.mutex.lock();
|
||||
defer self.mutex.unlock();
|
||||
self.flushLocked();
|
||||
}
|
||||
|
||||
fn flushLocked(self: *QueryLogger) void {
|
||||
if (self.buffer.items.len == 0) return;
|
||||
|
||||
var tx = self.db.begin() catch |err| {
|
||||
std.log.warn("QueryLogger: failed to begin transaction: {}", .{err});
|
||||
return;
|
||||
};
|
||||
defer tx.deinit();
|
||||
|
||||
var insert_count: usize = 0;
|
||||
for (self.buffer.items) |entry| {
|
||||
self.insertEntry(entry) catch |err| {
|
||||
std.log.debug("QueryLogger: failed to insert entry: {}", .{err});
|
||||
continue;
|
||||
};
|
||||
insert_count += 1;
|
||||
}
|
||||
|
||||
tx.commit() catch |err| {
|
||||
std.log.warn("QueryLogger: commit failed, rolling back {} entries: {}", .{ insert_count, err });
|
||||
return;
|
||||
};
|
||||
|
||||
// Free and clear buffer
|
||||
for (self.buffer.items) |entry| {
|
||||
self.allocator.free(entry.domain);
|
||||
self.allocator.free(entry.client_ip);
|
||||
if (entry.upstream) |u| self.allocator.free(u);
|
||||
if (entry.reason) |r| self.allocator.free(r);
|
||||
}
|
||||
self.buffer.clearRetainingCapacity();
|
||||
self.last_flush = std.time.milliTimestamp();
|
||||
}
|
||||
|
||||
fn insertEntry(self: *QueryLogger, entry: LogEntry) !void {
|
||||
// Get or create domain ID
|
||||
const domain_id = try self.getOrCreateDomainId(entry.domain);
|
||||
|
||||
// Get or create client ID
|
||||
const client_id = try self.getOrCreateClientId(entry.client_ip);
|
||||
|
||||
// Insert log entry with all new columns
|
||||
var stmt = try self.db.prepare(
|
||||
\\INSERT INTO query_log (timestamp, domain_id, client_id, qtype, denied, status, list_id, rule_id, reply_type, protocol, response_time_us, upstream, reason, dnssec_validated)
|
||||
\\VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
|
||||
);
|
||||
defer stmt.finalize();
|
||||
|
||||
try stmt.bindInt(1, entry.timestamp);
|
||||
try stmt.bindInt(2, domain_id);
|
||||
try stmt.bindInt(3, client_id);
|
||||
try stmt.bindInt(4, entry.qtype);
|
||||
try stmt.bindInt(5, if (entry.isDenied()) 1 else 0); // Legacy denied column
|
||||
try stmt.bindInt(6, @intFromEnum(entry.status)); // New status column
|
||||
if (entry.list_id) |lid| {
|
||||
try stmt.bindInt(7, lid);
|
||||
} else {
|
||||
try stmt.bindNull(7);
|
||||
}
|
||||
if (entry.rule_id) |rid| {
|
||||
try stmt.bindInt(8, rid);
|
||||
} else {
|
||||
try stmt.bindNull(8);
|
||||
}
|
||||
try stmt.bindInt(9, @intFromEnum(entry.reply_type));
|
||||
try stmt.bindInt(10, @intFromEnum(entry.protocol));
|
||||
try stmt.bindUint64(11, entry.response_time_us);
|
||||
|
||||
if (entry.upstream) |u| {
|
||||
try stmt.bindText(12, u);
|
||||
} else {
|
||||
try stmt.bindNull(12);
|
||||
}
|
||||
|
||||
if (entry.reason) |r| {
|
||||
try stmt.bindText(13, r);
|
||||
} else {
|
||||
try stmt.bindNull(13);
|
||||
}
|
||||
|
||||
try stmt.bindInt(14, if (entry.dnssec_validated) 1 else 0);
|
||||
|
||||
_ = try stmt.step();
|
||||
}
|
||||
|
||||
fn getOrCreateDomainId(self: *QueryLogger, domain: []const u8) !i64 {
|
||||
// Try to get existing
|
||||
var select_stmt = try self.db.prepare("SELECT id FROM domains WHERE domain = ?1");
|
||||
defer select_stmt.finalize();
|
||||
|
||||
try select_stmt.bindText(1, domain);
|
||||
|
||||
if (try select_stmt.step()) {
|
||||
return select_stmt.getInt(0);
|
||||
}
|
||||
|
||||
// Insert new
|
||||
var insert_stmt = try self.db.prepare("INSERT INTO domains (domain) VALUES (?1)");
|
||||
defer insert_stmt.finalize();
|
||||
|
||||
try insert_stmt.bindText(1, domain);
|
||||
_ = try insert_stmt.step();
|
||||
|
||||
return self.db.lastInsertRowId();
|
||||
}
|
||||
|
||||
fn getOrCreateClientId(self: *QueryLogger, client_ip: []const u8) !i64 {
|
||||
// Try to get existing
|
||||
var select_stmt = try self.db.prepare("SELECT id FROM clients WHERE ip = ?1");
|
||||
defer select_stmt.finalize();
|
||||
|
||||
try select_stmt.bindText(1, client_ip);
|
||||
|
||||
if (try select_stmt.step()) {
|
||||
const client_id = select_stmt.getInt(0);
|
||||
|
||||
// Update last_seen
|
||||
var update_stmt = try self.db.prepare(
|
||||
\\UPDATE clients SET last_seen = strftime('%s', 'now') WHERE id = ?1
|
||||
);
|
||||
defer update_stmt.finalize();
|
||||
|
||||
try update_stmt.bindInt(1, client_id);
|
||||
_ = try update_stmt.step();
|
||||
|
||||
return client_id;
|
||||
}
|
||||
|
||||
// Insert new
|
||||
var insert_stmt = try self.db.prepare("INSERT INTO clients (ip) VALUES (?1)");
|
||||
defer insert_stmt.finalize();
|
||||
|
||||
try insert_stmt.bindText(1, client_ip);
|
||||
_ = try insert_stmt.step();
|
||||
|
||||
return self.db.lastInsertRowId();
|
||||
}
|
||||
|
||||
/// Convert to handler-compatible Logger interface
|
||||
pub fn toHandlerLogger(self: *QueryLogger) handler.Logger {
|
||||
return handler.Logger{
|
||||
.context = self,
|
||||
.logFn = logWrapper,
|
||||
};
|
||||
}
|
||||
|
||||
fn logWrapper(ctx: *anyopaque, entry: handler.QueryLogEntry) void {
|
||||
const self: *QueryLogger = @ptrCast(@alignCast(ctx));
|
||||
self.log(entry);
|
||||
}
|
||||
};
|
||||
|
||||
/// Clean up old query logs based on retention policy
|
||||
/// retention_seconds: duration in seconds (use config.logging.retentionSeconds())
|
||||
pub fn cleanupOldLogs(db: *Database, retention_seconds: i64) !void {
|
||||
const cutoff = std.time.timestamp() - retention_seconds;
|
||||
|
||||
var stmt = try db.prepare("DELETE FROM query_log WHERE timestamp < ?1");
|
||||
defer stmt.finalize();
|
||||
|
||||
try stmt.bindInt(1, cutoff);
|
||||
_ = try stmt.step();
|
||||
|
||||
const deleted = db.changes();
|
||||
if (deleted > 0) {
|
||||
std.log.info("Cleaned up {} old query log entries", .{deleted});
|
||||
}
|
||||
}
|
||||
|
||||
test "QueryLogger basic test" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
const types = @import("../dns/types.zig");
|
||||
|
||||
var db = try Database.open(":memory:", allocator);
|
||||
defer db.close();
|
||||
|
||||
try schema.createSchema(&db);
|
||||
|
||||
var logger = QueryLogger.init(&db, allocator);
|
||||
defer logger.deinit();
|
||||
|
||||
// Log a query with new fields
|
||||
logger.log(.{
|
||||
.timestamp = std.time.timestamp(),
|
||||
.domain = "example.com",
|
||||
.client_ip = "192.168.1.1",
|
||||
.qtype = types.QType.A,
|
||||
.status = .forwarded,
|
||||
.list_id = null,
|
||||
.rule_id = null,
|
||||
.reply_type = .ip,
|
||||
.protocol = .udp,
|
||||
.response_time_us = 1234,
|
||||
.upstream = "cloudflare",
|
||||
.reason = null,
|
||||
.dnssec_validated = true,
|
||||
});
|
||||
|
||||
// Flush
|
||||
try logger.flush();
|
||||
|
||||
// Verify it was logged
|
||||
var stmt = try db.prepare("SELECT COUNT(*) FROM query_log");
|
||||
defer stmt.finalize();
|
||||
|
||||
_ = try stmt.step();
|
||||
const count = stmt.getInt(0);
|
||||
try testing.expectEqual(@as(i64, 1), count);
|
||||
|
||||
// Verify status was logged correctly
|
||||
var status_stmt = try db.prepare("SELECT status FROM query_log LIMIT 1");
|
||||
defer status_stmt.finalize();
|
||||
_ = try status_stmt.step();
|
||||
const status = status_stmt.getInt(0);
|
||||
try testing.expectEqual(@as(i64, 1), status); // forwarded = 1
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
const std = @import("std");
|
||||
const Database = @import("db.zig").Database;
|
||||
|
||||
pub const SCHEMA_VERSION: i64 = 1;
|
||||
|
||||
// =============================================================================
|
||||
// Query Status - tracks exactly why a query was allowed/denied
|
||||
// =============================================================================
|
||||
|
||||
pub const QueryStatus = enum(u8) {
|
||||
unknown = 0, // Status not determined
|
||||
forwarded = 1, // Sent to upstream, got response
|
||||
cached = 2, // Answered from cache
|
||||
denied_denylist = 3, // Blocked by denylist (gravity)
|
||||
denied_rule = 4, // Blocked by user rule
|
||||
denied_upstream = 5, // Upstream returned block (e.g., safe search rewrite)
|
||||
allowed_rule = 6, // Explicitly allowed by rule, overriding denylist
|
||||
upstream_error = 7, // Upstream failed, SERVFAIL, etc.
|
||||
|
||||
pub fn toString(self: QueryStatus) []const u8 {
|
||||
return switch (self) {
|
||||
.unknown => "unknown",
|
||||
.forwarded => "forwarded",
|
||||
.cached => "cached",
|
||||
.denied_denylist => "denied_denylist",
|
||||
.denied_rule => "denied_rule",
|
||||
.denied_upstream => "denied_upstream",
|
||||
.allowed_rule => "allowed_rule",
|
||||
.upstream_error => "upstream_error",
|
||||
};
|
||||
}
|
||||
|
||||
pub fn fromInt(value: i64) QueryStatus {
|
||||
return switch (value) {
|
||||
1 => .forwarded,
|
||||
2 => .cached,
|
||||
3 => .denied_denylist,
|
||||
4 => .denied_rule,
|
||||
5 => .denied_upstream,
|
||||
6 => .allowed_rule,
|
||||
7 => .upstream_error,
|
||||
else => .unknown,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn isDenied(self: QueryStatus) bool {
|
||||
return switch (self) {
|
||||
.denied_denylist, .denied_rule, .denied_upstream => true,
|
||||
else => false,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Reply Type - tracks DNS response type
|
||||
// =============================================================================
|
||||
|
||||
pub const ReplyType = enum(u8) {
|
||||
unknown = 0,
|
||||
nodata = 1, // Empty response (no records)
|
||||
nxdomain = 2, // Domain doesn't exist
|
||||
cname = 3, // CNAME response
|
||||
ip = 4, // A/AAAA response
|
||||
servfail = 5, // Server failure
|
||||
refused = 6, // Query refused
|
||||
|
||||
pub fn toString(self: ReplyType) []const u8 {
|
||||
return switch (self) {
|
||||
.unknown => "unknown",
|
||||
.nodata => "nodata",
|
||||
.nxdomain => "nxdomain",
|
||||
.cname => "cname",
|
||||
.ip => "ip",
|
||||
.servfail => "servfail",
|
||||
.refused => "refused",
|
||||
};
|
||||
}
|
||||
|
||||
pub fn fromInt(value: i64) ReplyType {
|
||||
return switch (value) {
|
||||
1 => .nodata,
|
||||
2 => .nxdomain,
|
||||
3 => .cname,
|
||||
4 => .ip,
|
||||
5 => .servfail,
|
||||
6 => .refused,
|
||||
else => .unknown,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Source Type - denylist vs allowlist
|
||||
// =============================================================================
|
||||
|
||||
pub const SourceType = enum(u8) {
|
||||
denylist = 0,
|
||||
allowlist = 1,
|
||||
|
||||
pub fn toString(self: SourceType) []const u8 {
|
||||
return switch (self) {
|
||||
.denylist => "denylist",
|
||||
.allowlist => "allowlist",
|
||||
};
|
||||
}
|
||||
|
||||
pub fn fromInt(value: i64) SourceType {
|
||||
return switch (value) {
|
||||
1 => .allowlist,
|
||||
else => .denylist,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Client Protocol - UDP vs TCP
|
||||
// =============================================================================
|
||||
|
||||
pub const ClientProtocol = enum(u8) {
|
||||
udp = 0,
|
||||
tcp = 1,
|
||||
|
||||
pub fn toString(self: ClientProtocol) []const u8 {
|
||||
return switch (self) {
|
||||
.udp => "udp",
|
||||
.tcp => "tcp",
|
||||
};
|
||||
}
|
||||
|
||||
pub fn fromInt(value: i64) ClientProtocol {
|
||||
return switch (value) {
|
||||
1 => .tcp,
|
||||
else => .udp,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/// Denylist fetch status (follows Pi-hole conventions)
|
||||
pub const DenylistStatus = enum(u8) {
|
||||
pending = 0, // Never fetched / unknown
|
||||
updated = 1, // Successfully fetched new data
|
||||
unchanged = 2, // Content unchanged (same hash)
|
||||
cached = 3, // Fetch failed, using cached data
|
||||
failed = 4, // Fetch failed, no data available
|
||||
|
||||
pub fn toString(self: DenylistStatus) []const u8 {
|
||||
return switch (self) {
|
||||
.pending => "pending",
|
||||
.updated => "updated",
|
||||
.unchanged => "unchanged",
|
||||
.cached => "cached",
|
||||
.failed => "failed",
|
||||
};
|
||||
}
|
||||
|
||||
pub fn fromInt(value: i64) DenylistStatus {
|
||||
return switch (value) {
|
||||
1 => .updated,
|
||||
2 => .unchanged,
|
||||
3 => .cached,
|
||||
4 => .failed,
|
||||
else => .pending,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/// Create the initial database schema
|
||||
pub fn createSchema(db: *Database) !void {
|
||||
try db.execMulti(
|
||||
\\-- Schema version tracking
|
||||
\\CREATE TABLE IF NOT EXISTS schema_version (
|
||||
\\ version INTEGER PRIMARY KEY
|
||||
\\);
|
||||
\\
|
||||
\\-- Groups for client classification
|
||||
\\CREATE TABLE IF NOT EXISTS groups (
|
||||
\\ id INTEGER PRIMARY KEY,
|
||||
\\ name TEXT NOT NULL UNIQUE,
|
||||
\\ description TEXT,
|
||||
\\ created_at INTEGER DEFAULT (strftime('%s', 'now')),
|
||||
\\ date_modified INTEGER
|
||||
\\);
|
||||
\\
|
||||
\\-- Insert default group
|
||||
\\INSERT OR IGNORE INTO groups (id, name, description) VALUES (0, 'default', 'Default group for all clients');
|
||||
\\
|
||||
\\-- Clients (devices)
|
||||
\\CREATE TABLE IF NOT EXISTS clients (
|
||||
\\ id INTEGER PRIMARY KEY,
|
||||
\\ ip TEXT NOT NULL UNIQUE,
|
||||
\\ name TEXT,
|
||||
\\ group_id INTEGER DEFAULT 0 REFERENCES groups(id),
|
||||
\\ first_seen INTEGER DEFAULT (strftime('%s', 'now')),
|
||||
\\ last_seen INTEGER DEFAULT (strftime('%s', 'now')),
|
||||
\\ date_modified INTEGER
|
||||
\\);
|
||||
\\
|
||||
\\CREATE INDEX IF NOT EXISTS idx_clients_ip ON clients(ip);
|
||||
\\CREATE INDEX IF NOT EXISTS idx_clients_group ON clients(group_id);
|
||||
\\
|
||||
\\-- Denylist sources (type: 0=denylist, 1=allowlist)
|
||||
\\CREATE TABLE IF NOT EXISTS denylist_sources (
|
||||
\\ id INTEGER PRIMARY KEY,
|
||||
\\ url TEXT NOT NULL UNIQUE,
|
||||
\\ comment TEXT,
|
||||
\\ category TEXT DEFAULT 'ads',
|
||||
\\ enabled INTEGER DEFAULT 1,
|
||||
\\ status INTEGER DEFAULT 0,
|
||||
\\ last_updated INTEGER,
|
||||
\\ domain_count INTEGER DEFAULT 0,
|
||||
\\ invalid_domains INTEGER DEFAULT 0,
|
||||
\\ content_hash TEXT,
|
||||
\\ update_interval INTEGER DEFAULT 86400,
|
||||
\\ type INTEGER DEFAULT 0,
|
||||
\\ created_at INTEGER DEFAULT (strftime('%s', 'now')),
|
||||
\\ date_modified INTEGER
|
||||
\\);
|
||||
\\
|
||||
\\-- Domains from denylists
|
||||
\\CREATE TABLE IF NOT EXISTS denylist_domains (
|
||||
\\ id INTEGER PRIMARY KEY,
|
||||
\\ domain TEXT NOT NULL,
|
||||
\\ source_id INTEGER NOT NULL REFERENCES denylist_sources(id) ON DELETE CASCADE,
|
||||
\\ UNIQUE(domain, source_id)
|
||||
\\);
|
||||
\\
|
||||
\\CREATE INDEX IF NOT EXISTS idx_denylist_domains_domain ON denylist_domains(domain);
|
||||
\\CREATE INDEX IF NOT EXISTS idx_denylist_domains_source ON denylist_domains(source_id);
|
||||
\\
|
||||
\\-- Which groups use which denylist sources
|
||||
\\CREATE TABLE IF NOT EXISTS group_sources (
|
||||
\\ group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
\\ source_id INTEGER NOT NULL REFERENCES denylist_sources(id) ON DELETE CASCADE,
|
||||
\\ PRIMARY KEY (group_id, source_id)
|
||||
\\);
|
||||
\\
|
||||
\\-- Custom allow/deny rules (per-group)
|
||||
\\CREATE TABLE IF NOT EXISTS rules (
|
||||
\\ id INTEGER PRIMARY KEY,
|
||||
\\ domain TEXT NOT NULL,
|
||||
\\ group_id INTEGER REFERENCES groups(id) ON DELETE CASCADE,
|
||||
\\ action TEXT NOT NULL CHECK(action IN ('allow', 'deny')),
|
||||
\\ comment TEXT,
|
||||
\\ created_at INTEGER DEFAULT (strftime('%s', 'now')),
|
||||
\\ date_modified INTEGER,
|
||||
\\ UNIQUE(domain, group_id)
|
||||
\\);
|
||||
\\
|
||||
\\CREATE INDEX IF NOT EXISTS idx_rules_domain ON rules(domain);
|
||||
\\CREATE INDEX IF NOT EXISTS idx_rules_group ON rules(group_id);
|
||||
\\
|
||||
\\-- String interning for query log (domains)
|
||||
\\CREATE TABLE IF NOT EXISTS domains (
|
||||
\\ id INTEGER PRIMARY KEY,
|
||||
\\ domain TEXT NOT NULL UNIQUE
|
||||
\\);
|
||||
\\
|
||||
\\CREATE INDEX IF NOT EXISTS idx_domains_domain ON domains(domain);
|
||||
\\
|
||||
\\-- Query log (status: see QueryStatus enum, protocol: 0=udp, 1=tcp)
|
||||
\\CREATE TABLE IF NOT EXISTS 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,
|
||||
\\ denied INTEGER NOT NULL DEFAULT 0,
|
||||
\\ status INTEGER DEFAULT 0,
|
||||
\\ list_id INTEGER REFERENCES denylist_sources(id),
|
||||
\\ rule_id INTEGER REFERENCES rules(id),
|
||||
\\ reply_type INTEGER DEFAULT 0,
|
||||
\\ protocol INTEGER DEFAULT 0,
|
||||
\\ response_time_us INTEGER,
|
||||
\\ upstream TEXT,
|
||||
\\ reason TEXT,
|
||||
\\ dnssec_validated INTEGER NOT NULL DEFAULT 0
|
||||
\\);
|
||||
\\
|
||||
\\CREATE INDEX IF NOT EXISTS idx_query_log_timestamp ON query_log(timestamp);
|
||||
\\CREATE INDEX IF NOT EXISTS idx_query_log_client ON query_log(client_id);
|
||||
\\CREATE INDEX IF NOT EXISTS idx_query_log_domain ON query_log(domain_id);
|
||||
\\CREATE INDEX IF NOT EXISTS idx_query_log_denied ON query_log(denied);
|
||||
\\CREATE INDEX IF NOT EXISTS idx_query_log_status ON query_log(status);
|
||||
\\
|
||||
\\-- Client-group many-to-many junction table
|
||||
\\CREATE TABLE IF NOT EXISTS client_groups (
|
||||
\\ client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
|
||||
\\ group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
\\ PRIMARY KEY (client_id, group_id)
|
||||
\\);
|
||||
\\
|
||||
\\-- Allowlist domains (for allowlist sources)
|
||||
\\CREATE TABLE IF NOT EXISTS allowlist_domains (
|
||||
\\ id INTEGER PRIMARY KEY,
|
||||
\\ domain TEXT NOT NULL,
|
||||
\\ source_id INTEGER NOT NULL REFERENCES denylist_sources(id) ON DELETE CASCADE,
|
||||
\\ UNIQUE(domain, source_id)
|
||||
\\);
|
||||
\\CREATE INDEX IF NOT EXISTS idx_allowlist_domains_domain ON allowlist_domains(domain);
|
||||
\\CREATE INDEX IF NOT EXISTS idx_allowlist_domains_source ON allowlist_domains(source_id);
|
||||
\\
|
||||
\\-- Settings
|
||||
\\CREATE TABLE IF NOT EXISTS settings (
|
||||
\\ key TEXT PRIMARY KEY,
|
||||
\\ value TEXT NOT NULL
|
||||
\\);
|
||||
\\
|
||||
\\-- Default settings
|
||||
\\INSERT OR IGNORE INTO settings (key, value) VALUES ('blocking_response', 'zero');
|
||||
\\INSERT OR IGNORE INTO settings (key, value) VALUES ('safe_search_enabled', 'true');
|
||||
\\INSERT OR IGNORE INTO settings (key, value) VALUES ('log_retention', '30 days');
|
||||
);
|
||||
|
||||
try db.exec("INSERT OR REPLACE INTO schema_version (version) VALUES (1)");
|
||||
}
|
||||
|
||||
/// Get current schema version
|
||||
pub fn getSchemaVersion(db: *Database) !i64 {
|
||||
// Check if schema_version table exists
|
||||
var stmt = db.prepare(
|
||||
\\SELECT name FROM sqlite_master
|
||||
\\WHERE type='table' AND name='schema_version'
|
||||
) catch return 0;
|
||||
defer stmt.finalize();
|
||||
|
||||
if (!(try stmt.step())) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Get version
|
||||
var version_stmt = try db.prepare("SELECT version FROM schema_version LIMIT 1");
|
||||
defer version_stmt.finalize();
|
||||
|
||||
if (try version_stmt.step()) {
|
||||
return version_stmt.getInt(0);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
pub fn migrate(db: *Database) !void {
|
||||
const current_version = try getSchemaVersion(db);
|
||||
if (current_version == 0) {
|
||||
try createSchema(db);
|
||||
}
|
||||
}
|
||||
|
||||
/// Denylist categories
|
||||
pub const DenylistCategory = enum {
|
||||
ads,
|
||||
malware,
|
||||
adult,
|
||||
telemetry,
|
||||
gambling,
|
||||
social,
|
||||
|
||||
pub fn toString(self: DenylistCategory) []const u8 {
|
||||
return switch (self) {
|
||||
.ads => "ads",
|
||||
.malware => "malware",
|
||||
.adult => "adult",
|
||||
.telemetry => "telemetry",
|
||||
.gambling => "gambling",
|
||||
.social => "social",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/// Pre-configured denylist source
|
||||
const DefaultDenylist = struct {
|
||||
url: []const u8,
|
||||
comment: ?[]const u8,
|
||||
category: DenylistCategory,
|
||||
enabled_by_default: bool,
|
||||
};
|
||||
|
||||
/// Default denylist sources from PLAN.md
|
||||
pub const DEFAULT_DENYLISTS = [_]DefaultDenylist{
|
||||
// Ads & Trackers
|
||||
.{
|
||||
.url = "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts",
|
||||
.comment = "StevenBlack Unified",
|
||||
.category = .ads,
|
||||
.enabled_by_default = true,
|
||||
},
|
||||
.{
|
||||
.url = "https://big.oisd.nl/domainswild",
|
||||
.comment = "OISD Big (large list)",
|
||||
.category = .ads,
|
||||
.enabled_by_default = false,
|
||||
},
|
||||
.{
|
||||
.url = "https://adguardteam.github.io/AdGuardSDNSFilter/Filters/filter.txt",
|
||||
.comment = "AdGuard DNS Filter",
|
||||
.category = .ads,
|
||||
.enabled_by_default = true,
|
||||
},
|
||||
.{
|
||||
.url = "https://pgl.yoyo.org/adservers/serverlist.php?hostformat=hosts&showintro=0",
|
||||
.comment = "Peter Lowe's Ad Servers",
|
||||
.category = .ads,
|
||||
.enabled_by_default = true,
|
||||
},
|
||||
|
||||
// Malware & Phishing
|
||||
.{
|
||||
.url = "https://urlhaus.abuse.ch/downloads/hostfile/",
|
||||
.comment = "URLhaus Malware",
|
||||
.category = .malware,
|
||||
.enabled_by_default = true,
|
||||
},
|
||||
.{
|
||||
.url = "https://threatfox.abuse.ch/downloads/hostfile/",
|
||||
.comment = "ThreatFox IOCs",
|
||||
.category = .malware,
|
||||
.enabled_by_default = true,
|
||||
},
|
||||
.{
|
||||
.url = "https://malware-filter.gitlab.io/malware-filter/phishing-filter-hosts.txt",
|
||||
.comment = "Phishing Filter",
|
||||
.category = .malware,
|
||||
.enabled_by_default = true,
|
||||
},
|
||||
|
||||
// Adult Content
|
||||
.{
|
||||
.url = "https://nsfw.oisd.nl/domainswild",
|
||||
.comment = "OISD NSFW",
|
||||
.category = .adult,
|
||||
.enabled_by_default = false,
|
||||
},
|
||||
.{
|
||||
.url = "https://raw.githubusercontent.com/StevenBlack/hosts/master/alternates/porn/hosts",
|
||||
.comment = null,
|
||||
.category = .adult,
|
||||
.enabled_by_default = false,
|
||||
},
|
||||
|
||||
// Native Telemetry
|
||||
.{
|
||||
.url = "https://raw.githubusercontent.com/nickspaargaren/no-google/master/categories/proxies",
|
||||
.comment = null,
|
||||
.category = .telemetry,
|
||||
.enabled_by_default = false,
|
||||
},
|
||||
.{
|
||||
.url = "https://raw.githubusercontent.com/nickspaargaren/no-google/master/categories/analytics",
|
||||
.comment = null,
|
||||
.category = .telemetry,
|
||||
.enabled_by_default = false,
|
||||
},
|
||||
|
||||
// Gambling
|
||||
.{
|
||||
.url = "https://raw.githubusercontent.com/StevenBlack/hosts/master/alternates/gambling/hosts",
|
||||
.comment = null,
|
||||
.category = .gambling,
|
||||
.enabled_by_default = false,
|
||||
},
|
||||
|
||||
// Social Media
|
||||
.{
|
||||
.url = "https://raw.githubusercontent.com/StevenBlack/hosts/master/alternates/social/hosts",
|
||||
.comment = null,
|
||||
.category = .social,
|
||||
.enabled_by_default = false,
|
||||
},
|
||||
};
|
||||
|
||||
/// Insert default denylist sources
|
||||
pub fn insertDefaultDenylists(db: *Database) !void {
|
||||
for (DEFAULT_DENYLISTS) |list| {
|
||||
var stmt = try db.prepare(
|
||||
\\INSERT OR IGNORE INTO denylist_sources (url, comment, category, enabled)
|
||||
\\VALUES (?1, ?2, ?3, ?4)
|
||||
);
|
||||
defer stmt.finalize();
|
||||
|
||||
try stmt.bindText(1, list.url);
|
||||
if (list.comment) |comment| {
|
||||
try stmt.bindText(2, comment);
|
||||
} else {
|
||||
try stmt.bindNull(2);
|
||||
}
|
||||
try stmt.bindText(3, list.category.toString());
|
||||
try stmt.bindInt(4, if (list.enabled_by_default) 1 else 0);
|
||||
_ = try stmt.step();
|
||||
}
|
||||
|
||||
// Associate enabled denylists with default group
|
||||
try db.exec(
|
||||
\\INSERT OR IGNORE INTO group_sources (group_id, source_id)
|
||||
\\SELECT 0, id FROM denylist_sources WHERE enabled = 1
|
||||
);
|
||||
}
|
||||
|
||||
test "schema creation" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var db = try Database.open(":memory:", allocator);
|
||||
defer db.close();
|
||||
|
||||
try createSchema(&db);
|
||||
|
||||
// Verify tables exist
|
||||
var stmt = try db.prepare(
|
||||
\\SELECT name FROM sqlite_master
|
||||
\\WHERE type='table' ORDER BY name
|
||||
);
|
||||
defer stmt.finalize();
|
||||
|
||||
var tables = std.ArrayListUnmanaged([]const u8){};
|
||||
defer tables.deinit(allocator);
|
||||
|
||||
while (try stmt.step()) {
|
||||
if (stmt.getText(0)) |name| {
|
||||
try tables.append(allocator, try allocator.dupe(u8, name));
|
||||
}
|
||||
}
|
||||
defer {
|
||||
for (tables.items) |t| allocator.free(t);
|
||||
}
|
||||
|
||||
try testing.expect(tables.items.len > 0);
|
||||
}
|
||||
|
||||
test "migration from fresh db" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var db = try Database.open(":memory:", allocator);
|
||||
defer db.close();
|
||||
|
||||
try migrate(&db);
|
||||
|
||||
const version = try getSchemaVersion(&db);
|
||||
try testing.expectEqual(SCHEMA_VERSION, version);
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
const std = @import("std");
|
||||
const net = std.net;
|
||||
const posix = std.posix;
|
||||
const tls = std.crypto.tls;
|
||||
const Certificate = std.crypto.Certificate;
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
/// Persistent TLS connection for DNS-over-TLS
|
||||
/// Heap-allocates all TLS state to allow connection reuse across queries.
|
||||
/// This avoids expensive TLS handshakes for repeated queries to the same server.
|
||||
pub const PersistentDotConnection = struct {
|
||||
allocator: Allocator,
|
||||
host: []const u8,
|
||||
port: u16,
|
||||
state: ?*ConnectionState,
|
||||
timeout_ms: u32,
|
||||
|
||||
const ConnectionState = struct {
|
||||
stream: net.Stream,
|
||||
// Heap-allocated buffers for the TLS client
|
||||
stream_read_buffer: *[tls.Client.min_buffer_len]u8,
|
||||
stream_write_buffer: *[tls.Client.min_buffer_len]u8,
|
||||
tls_read_buffer: *[tls.Client.min_buffer_len]u8,
|
||||
tls_write_buffer: *[tls.Client.min_buffer_len]u8,
|
||||
// The stream reader/writer use pointers to buffers above
|
||||
stream_reader: net.Stream.Reader,
|
||||
stream_writer: net.Stream.Writer,
|
||||
// TLS client
|
||||
tls_client: tls.Client,
|
||||
// CA bundle must stay alive for the connection duration
|
||||
ca_bundle: Certificate.Bundle,
|
||||
|
||||
fn deinit(self: *ConnectionState, allocator: Allocator) void {
|
||||
self.ca_bundle.deinit(allocator);
|
||||
self.stream.close();
|
||||
allocator.destroy(self.stream_read_buffer);
|
||||
allocator.destroy(self.stream_write_buffer);
|
||||
allocator.destroy(self.tls_read_buffer);
|
||||
allocator.destroy(self.tls_write_buffer);
|
||||
}
|
||||
};
|
||||
|
||||
pub const Error = error{
|
||||
InvalidHost,
|
||||
ConnectionFailed,
|
||||
TlsHandshakeFailed,
|
||||
CertificateLoadFailed,
|
||||
SendFailed,
|
||||
ReceiveFailed,
|
||||
InvalidResponse,
|
||||
Timeout,
|
||||
OutOfMemory,
|
||||
};
|
||||
|
||||
pub fn init(host: []const u8, port: u16, allocator: Allocator) PersistentDotConnection {
|
||||
return .{
|
||||
.allocator = allocator,
|
||||
.host = host,
|
||||
.port = port,
|
||||
.state = null,
|
||||
.timeout_ms = 5000,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *PersistentDotConnection) void {
|
||||
self.close();
|
||||
}
|
||||
|
||||
pub fn setTimeout(self: *PersistentDotConnection, timeout_ms: u32) void {
|
||||
self.timeout_ms = timeout_ms;
|
||||
}
|
||||
|
||||
/// Close the persistent connection
|
||||
pub fn close(self: *PersistentDotConnection) void {
|
||||
if (self.state) |state| {
|
||||
state.deinit(self.allocator);
|
||||
self.allocator.destroy(state);
|
||||
self.state = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensure we have a valid connection, creating one if needed
|
||||
fn ensureConnected(self: *PersistentDotConnection) Error!*ConnectionState {
|
||||
if (self.state) |state| {
|
||||
// Check if connection is still alive using poll
|
||||
// POLLIN without data but no POLLHUP/POLLERR means connection is good
|
||||
var fds = [1]posix.pollfd{.{
|
||||
.fd = state.stream.handle,
|
||||
.events = posix.POLL.IN,
|
||||
.revents = 0,
|
||||
}};
|
||||
|
||||
// Non-blocking poll (timeout=0)
|
||||
const poll_result = posix.poll(&fds, 0) catch {
|
||||
self.close();
|
||||
return self.connect();
|
||||
};
|
||||
|
||||
// If poll returns 0, no events - connection is idle and good
|
||||
if (poll_result == 0) {
|
||||
return state;
|
||||
}
|
||||
|
||||
// Check for error conditions
|
||||
if (fds[0].revents & (posix.POLL.ERR | posix.POLL.HUP | posix.POLL.NVAL) != 0) {
|
||||
// Connection is dead
|
||||
self.close();
|
||||
return self.connect();
|
||||
}
|
||||
|
||||
// Connection has data or is ready - that's fine, return it
|
||||
return state;
|
||||
}
|
||||
|
||||
// Create new connection
|
||||
return self.connect();
|
||||
}
|
||||
|
||||
/// Establish a new TLS connection
|
||||
fn connect(self: *PersistentDotConnection) Error!*ConnectionState {
|
||||
// Allocate buffers on heap
|
||||
const stream_read_buffer = self.allocator.create([tls.Client.min_buffer_len]u8) catch return error.OutOfMemory;
|
||||
errdefer self.allocator.destroy(stream_read_buffer);
|
||||
|
||||
const stream_write_buffer = self.allocator.create([tls.Client.min_buffer_len]u8) catch return error.OutOfMemory;
|
||||
errdefer self.allocator.destroy(stream_write_buffer);
|
||||
|
||||
const tls_read_buffer = self.allocator.create([tls.Client.min_buffer_len]u8) catch return error.OutOfMemory;
|
||||
errdefer self.allocator.destroy(tls_read_buffer);
|
||||
|
||||
const tls_write_buffer = self.allocator.create([tls.Client.min_buffer_len]u8) catch return error.OutOfMemory;
|
||||
errdefer self.allocator.destroy(tls_write_buffer);
|
||||
|
||||
// Load system CA certificates
|
||||
var ca_bundle: Certificate.Bundle = .{};
|
||||
ca_bundle.rescan(self.allocator) catch |err| {
|
||||
std.log.err("Failed to load system CA certificates: {}", .{err});
|
||||
return error.CertificateLoadFailed;
|
||||
};
|
||||
errdefer ca_bundle.deinit(self.allocator);
|
||||
|
||||
// Connect to server via TCP
|
||||
const stream = net.tcpConnectToHost(self.allocator, self.host, self.port) catch |err| {
|
||||
std.log.warn("DoT connection to {s}:{d} failed: {}", .{ self.host, self.port, err });
|
||||
return error.ConnectionFailed;
|
||||
};
|
||||
errdefer stream.close();
|
||||
|
||||
// Apply socket timeouts
|
||||
const timeout = posix.timeval{
|
||||
.sec = @intCast(self.timeout_ms / 1000),
|
||||
.usec = @intCast((self.timeout_ms % 1000) * 1000),
|
||||
};
|
||||
posix.setsockopt(stream.handle, posix.SOL.SOCKET, posix.SO.RCVTIMEO, std.mem.asBytes(&timeout)) catch {};
|
||||
posix.setsockopt(stream.handle, posix.SOL.SOCKET, posix.SO.SNDTIMEO, std.mem.asBytes(&timeout)) catch {};
|
||||
|
||||
// Allocate connection state
|
||||
const state = self.allocator.create(ConnectionState) catch return error.OutOfMemory;
|
||||
errdefer self.allocator.destroy(state);
|
||||
|
||||
// Initialize stream reader/writer with heap buffers
|
||||
state.stream = stream;
|
||||
state.stream_read_buffer = stream_read_buffer;
|
||||
state.stream_write_buffer = stream_write_buffer;
|
||||
state.tls_read_buffer = tls_read_buffer;
|
||||
state.tls_write_buffer = tls_write_buffer;
|
||||
state.ca_bundle = ca_bundle;
|
||||
|
||||
state.stream_reader = net.Stream.Reader.init(stream, stream_read_buffer);
|
||||
state.stream_writer = net.Stream.Writer.init(stream, stream_write_buffer);
|
||||
|
||||
// Initialize TLS client with handshake
|
||||
state.tls_client = tls.Client.init(state.stream_reader.interface(), &state.stream_writer.interface, .{
|
||||
.host = .{ .explicit = self.host },
|
||||
.ca = .{ .bundle = state.ca_bundle },
|
||||
.write_buffer = tls_write_buffer,
|
||||
.read_buffer = tls_read_buffer,
|
||||
}) catch |err| {
|
||||
std.log.warn("DoT TLS handshake with {s} failed: {}", .{ self.host, err });
|
||||
// Clean up manually since errdefer won't handle partial state
|
||||
ca_bundle.deinit(self.allocator);
|
||||
stream.close();
|
||||
self.allocator.destroy(stream_read_buffer);
|
||||
self.allocator.destroy(stream_write_buffer);
|
||||
self.allocator.destroy(tls_read_buffer);
|
||||
self.allocator.destroy(tls_write_buffer);
|
||||
self.allocator.destroy(state);
|
||||
return error.TlsHandshakeFailed;
|
||||
};
|
||||
|
||||
self.state = state;
|
||||
return state;
|
||||
}
|
||||
|
||||
/// Send a DNS query and receive the response
|
||||
/// Reuses existing connection if available, creates new one if needed
|
||||
pub fn query(self: *PersistentDotConnection, dns_packet: []const u8) Error![]const u8 {
|
||||
const state = self.ensureConnected() catch |err| {
|
||||
return err;
|
||||
};
|
||||
|
||||
// DNS-over-TLS uses 2-byte length prefix (big-endian)
|
||||
var len_buf: [2]u8 = undefined;
|
||||
std.mem.writeInt(u16, &len_buf, @intCast(dns_packet.len), .big);
|
||||
|
||||
// Send length prefix + DNS packet via TLS
|
||||
state.tls_client.writer.writeAll(&len_buf) catch {
|
||||
self.close(); // Connection failed, close it
|
||||
return error.SendFailed;
|
||||
};
|
||||
state.tls_client.writer.writeAll(dns_packet) catch {
|
||||
self.close();
|
||||
return error.SendFailed;
|
||||
};
|
||||
state.tls_client.writer.flush() catch {
|
||||
self.close();
|
||||
return error.SendFailed;
|
||||
};
|
||||
|
||||
// Read response length via TLS
|
||||
var resp_len_buf: [2]u8 = undefined;
|
||||
state.tls_client.reader.readSliceAll(&resp_len_buf) catch {
|
||||
self.close();
|
||||
return error.ReceiveFailed;
|
||||
};
|
||||
|
||||
const resp_len = std.mem.readInt(u16, &resp_len_buf, .big);
|
||||
|
||||
// Validate response length
|
||||
if (resp_len < 12 or resp_len > 65535) {
|
||||
self.close();
|
||||
return error.InvalidResponse;
|
||||
}
|
||||
|
||||
// Read response
|
||||
const response = self.allocator.alloc(u8, resp_len) catch return error.OutOfMemory;
|
||||
errdefer self.allocator.free(response);
|
||||
|
||||
state.tls_client.reader.readSliceAll(response) catch {
|
||||
self.allocator.free(response);
|
||||
self.close();
|
||||
return error.ReceiveFailed;
|
||||
};
|
||||
|
||||
return response;
|
||||
}
|
||||
};
|
||||
|
||||
/// Connection pool for managing persistent connections to upstream DNS servers.
|
||||
/// Maintains one connection per host:port combination.
|
||||
pub const DotConnectionPool = struct {
|
||||
connections: std.StringHashMapUnmanaged(*PersistentDotConnection),
|
||||
mutex: std.Thread.Mutex,
|
||||
allocator: Allocator,
|
||||
|
||||
pub fn init(allocator: Allocator) DotConnectionPool {
|
||||
return .{
|
||||
.connections = .{},
|
||||
.mutex = .{},
|
||||
.allocator = allocator,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *DotConnectionPool) void {
|
||||
self.mutex.lock();
|
||||
defer self.mutex.unlock();
|
||||
|
||||
var iter = self.connections.iterator();
|
||||
while (iter.next()) |entry| {
|
||||
entry.value_ptr.*.deinit();
|
||||
self.allocator.destroy(entry.value_ptr.*);
|
||||
self.allocator.free(entry.key_ptr.*);
|
||||
}
|
||||
self.connections.deinit(self.allocator);
|
||||
}
|
||||
|
||||
/// Get or create a persistent connection for the given host:port
|
||||
pub fn getConnection(self: *DotConnectionPool, host: []const u8, port: u16) !*PersistentDotConnection {
|
||||
self.mutex.lock();
|
||||
defer self.mutex.unlock();
|
||||
|
||||
var key_buf: [280]u8 = undefined;
|
||||
const key = std.fmt.bufPrint(&key_buf, "{s}:{d}", .{ host, port }) catch return error.InvalidHost;
|
||||
|
||||
if (self.connections.get(key)) |conn| {
|
||||
return conn;
|
||||
}
|
||||
|
||||
// Create new persistent connection
|
||||
const conn = try self.allocator.create(PersistentDotConnection);
|
||||
conn.* = PersistentDotConnection.init(host, port, self.allocator);
|
||||
|
||||
const key_copy = try self.allocator.dupe(u8, key);
|
||||
errdefer self.allocator.free(key_copy);
|
||||
|
||||
try self.connections.put(self.allocator, key_copy, conn);
|
||||
return conn;
|
||||
}
|
||||
|
||||
/// Close a specific connection (e.g., after repeated failures)
|
||||
pub fn closeConnection(self: *DotConnectionPool, host: []const u8, port: u16) void {
|
||||
self.mutex.lock();
|
||||
defer self.mutex.unlock();
|
||||
|
||||
var key_buf: [280]u8 = undefined;
|
||||
const key = std.fmt.bufPrint(&key_buf, "{s}:{d}", .{ host, port }) catch return;
|
||||
|
||||
if (self.connections.get(key)) |conn| {
|
||||
conn.close();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
test "PersistentDotConnection basic" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var conn = PersistentDotConnection.init("cloudflare-dns.com", 853, allocator);
|
||||
defer conn.deinit();
|
||||
|
||||
// Just verify initialization works - actual connection test would need network
|
||||
try testing.expectEqual(@as(u16, 853), conn.port);
|
||||
try testing.expectEqualStrings("cloudflare-dns.com", conn.host);
|
||||
}
|
||||
|
||||
test "DotConnectionPool basic" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var pool = DotConnectionPool.init(allocator);
|
||||
defer pool.deinit();
|
||||
|
||||
// Get a connection (won't actually connect without network)
|
||||
const conn = try pool.getConnection("cloudflare-dns.com", 853);
|
||||
try testing.expectEqual(@as(u16, 853), conn.port);
|
||||
|
||||
// Getting same host:port should return same connection
|
||||
const conn2 = try pool.getConnection("cloudflare-dns.com", 853);
|
||||
try testing.expectEqual(conn, conn2);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
const std = @import("std");
|
||||
const http = std.http;
|
||||
const Allocator = std.mem.Allocator;
|
||||
const Writer = std.Io.Writer;
|
||||
|
||||
/// Number of parallel connection slots per URL
|
||||
const NUM_SLOTS = 8;
|
||||
|
||||
/// DNS-over-HTTPS connection pool using slot-based sharding.
|
||||
/// Maintains NUM_SLOTS independent HTTP clients per URL, with round-robin assignment.
|
||||
/// Each slot has its own mutex, allowing up to NUM_SLOTS parallel queries.
|
||||
pub const DohConnectionPool = struct {
|
||||
/// Per-URL pools
|
||||
url_pools: std.StringHashMapUnmanaged(*UrlPool),
|
||||
pools_mutex: std.Thread.Mutex,
|
||||
allocator: Allocator,
|
||||
|
||||
/// Pool for a single URL with NUM_SLOTS parallel slots
|
||||
const UrlPool = struct {
|
||||
slots: [NUM_SLOTS]Slot,
|
||||
next: std.atomic.Value(usize),
|
||||
url: []const u8,
|
||||
|
||||
const Slot = struct {
|
||||
mutex: std.Thread.Mutex,
|
||||
client: ?http.Client,
|
||||
};
|
||||
|
||||
fn init(url: []const u8) UrlPool {
|
||||
var pool = UrlPool{
|
||||
.slots = undefined,
|
||||
.next = std.atomic.Value(usize).init(0),
|
||||
.url = url,
|
||||
};
|
||||
for (&pool.slots) |*slot| {
|
||||
slot.* = Slot{
|
||||
.mutex = .{},
|
||||
.client = null,
|
||||
};
|
||||
}
|
||||
return pool;
|
||||
}
|
||||
|
||||
fn deinit(self: *UrlPool) void {
|
||||
for (&self.slots) |*slot| {
|
||||
if (slot.client) |*client| {
|
||||
client.deinit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn query(self: *UrlPool, dns_packet: []const u8, allocator: Allocator) ![]const u8 {
|
||||
// Round-robin slot selection
|
||||
const idx = self.next.fetchAdd(1, .monotonic) % NUM_SLOTS;
|
||||
const slot = &self.slots[idx];
|
||||
|
||||
slot.mutex.lock();
|
||||
defer slot.mutex.unlock();
|
||||
|
||||
// Lazily create client for this slot
|
||||
if (slot.client == null) {
|
||||
slot.client = http.Client{ .allocator = allocator };
|
||||
}
|
||||
|
||||
var response_writer = Writer.Allocating.init(allocator);
|
||||
errdefer response_writer.deinit();
|
||||
|
||||
const result = slot.client.?.fetch(.{
|
||||
.location = .{ .url = self.url },
|
||||
.method = .POST,
|
||||
.payload = dns_packet,
|
||||
.extra_headers = &[_]http.Header{
|
||||
.{ .name = "Content-Type", .value = "application/dns-message" },
|
||||
.{ .name = "Accept", .value = "application/dns-message" },
|
||||
},
|
||||
.response_writer = &response_writer.writer,
|
||||
}) catch |err| {
|
||||
std.log.warn("DoH request failed for {s}: {}", .{ self.url, err });
|
||||
// Reset client on failure - might be stale connection
|
||||
if (slot.client) |*client| {
|
||||
client.deinit();
|
||||
slot.client = null;
|
||||
}
|
||||
return error.ConnectionFailed;
|
||||
};
|
||||
|
||||
if (result.status != .ok) {
|
||||
std.log.warn("DoH HTTP {d} from {s}", .{ @intFromEnum(result.status), self.url });
|
||||
return error.HttpError;
|
||||
}
|
||||
|
||||
const data = response_writer.written();
|
||||
if (data.len < 12) {
|
||||
std.log.warn("DoH response too short ({d} bytes) from {s}", .{ data.len, self.url });
|
||||
return error.InvalidResponse;
|
||||
}
|
||||
|
||||
return response_writer.toOwnedSlice() catch return error.OutOfMemory;
|
||||
}
|
||||
};
|
||||
|
||||
pub const Error = error{
|
||||
ConnectionFailed,
|
||||
InvalidResponse,
|
||||
OutOfMemory,
|
||||
HttpError,
|
||||
};
|
||||
|
||||
pub fn init(allocator: Allocator) DohConnectionPool {
|
||||
return .{
|
||||
.url_pools = .{},
|
||||
.pools_mutex = .{},
|
||||
.allocator = allocator,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *DohConnectionPool) void {
|
||||
self.pools_mutex.lock();
|
||||
defer self.pools_mutex.unlock();
|
||||
|
||||
var iter = self.url_pools.iterator();
|
||||
while (iter.next()) |entry| {
|
||||
entry.value_ptr.*.deinit();
|
||||
self.allocator.destroy(entry.value_ptr.*);
|
||||
self.allocator.free(entry.key_ptr.*);
|
||||
}
|
||||
self.url_pools.deinit(self.allocator);
|
||||
}
|
||||
|
||||
/// Query a DoH server, using pooled connections for parallelism
|
||||
pub fn query(self: *DohConnectionPool, url: []const u8, dns_packet: []const u8) Error![]const u8 {
|
||||
const pool = self.getOrCreatePool(url) catch return error.OutOfMemory;
|
||||
return pool.query(dns_packet, self.allocator);
|
||||
}
|
||||
|
||||
fn getOrCreatePool(self: *DohConnectionPool, url: []const u8) !*UrlPool {
|
||||
self.pools_mutex.lock();
|
||||
defer self.pools_mutex.unlock();
|
||||
|
||||
if (self.url_pools.get(url)) |pool| {
|
||||
return pool;
|
||||
}
|
||||
|
||||
// Create new pool for this URL
|
||||
const url_copy = try self.allocator.dupe(u8, url);
|
||||
errdefer self.allocator.free(url_copy);
|
||||
|
||||
const pool = try self.allocator.create(UrlPool);
|
||||
pool.* = UrlPool.init(url_copy);
|
||||
|
||||
try self.url_pools.put(self.allocator, url_copy, pool);
|
||||
return pool;
|
||||
}
|
||||
};
|
||||
|
||||
// Legacy single-query client (no pooling) - kept for compatibility
|
||||
pub const DohClient = struct {
|
||||
url: []const u8,
|
||||
allocator: Allocator,
|
||||
|
||||
pub const Error = error{
|
||||
InvalidUrl,
|
||||
ConnectionFailed,
|
||||
InvalidResponse,
|
||||
OutOfMemory,
|
||||
HttpError,
|
||||
};
|
||||
|
||||
pub fn init(url: []const u8, allocator: Allocator) Error!DohClient {
|
||||
if (!std.mem.startsWith(u8, url, "https://")) {
|
||||
return error.InvalidUrl;
|
||||
}
|
||||
return DohClient{
|
||||
.url = url,
|
||||
.allocator = allocator,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn query(self: *DohClient, dns_packet: []const u8) Error![]const u8 {
|
||||
var client = http.Client{ .allocator = self.allocator };
|
||||
defer client.deinit();
|
||||
|
||||
var response_writer = Writer.Allocating.init(self.allocator);
|
||||
errdefer response_writer.deinit();
|
||||
|
||||
const result = client.fetch(.{
|
||||
.location = .{ .url = self.url },
|
||||
.method = .POST,
|
||||
.payload = dns_packet,
|
||||
.extra_headers = &[_]http.Header{
|
||||
.{ .name = "Content-Type", .value = "application/dns-message" },
|
||||
.{ .name = "Accept", .value = "application/dns-message" },
|
||||
},
|
||||
.response_writer = &response_writer.writer,
|
||||
}) catch |err| {
|
||||
std.log.warn("DoH request failed for {s}: {}", .{ self.url, err });
|
||||
return error.ConnectionFailed;
|
||||
};
|
||||
|
||||
if (result.status != .ok) {
|
||||
std.log.warn("DoH HTTP {d} from {s}", .{ @intFromEnum(result.status), self.url });
|
||||
return error.HttpError;
|
||||
}
|
||||
|
||||
const data = response_writer.written();
|
||||
if (data.len < 12) {
|
||||
std.log.warn("DoH response too short ({d} bytes) from {s}", .{ data.len, self.url });
|
||||
return error.InvalidResponse;
|
||||
}
|
||||
|
||||
return response_writer.toOwnedSlice() catch return error.OutOfMemory;
|
||||
}
|
||||
|
||||
pub fn setTimeout(self: *DohClient, timeout_secs: u32) void {
|
||||
_ = self;
|
||||
_ = timeout_secs;
|
||||
}
|
||||
};
|
||||
|
||||
test "DohConnectionPool basic" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var pool = DohConnectionPool.init(allocator);
|
||||
defer pool.deinit();
|
||||
|
||||
// Just test pool creation - actual queries need network
|
||||
const url_pool = try pool.getOrCreatePool("https://cloudflare-dns.com/dns-query");
|
||||
try testing.expectEqualStrings("https://cloudflare-dns.com/dns-query", url_pool.url);
|
||||
|
||||
// Same URL should return same pool
|
||||
const url_pool2 = try pool.getOrCreatePool("https://cloudflare-dns.com/dns-query");
|
||||
try testing.expectEqual(url_pool, url_pool2);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const net = std.net;
|
||||
const posix = std.posix;
|
||||
const tls = std.crypto.tls;
|
||||
const Io = std.Io;
|
||||
const Certificate = std.crypto.Certificate;
|
||||
|
||||
/// DNS-over-TLS client
|
||||
/// Sends DNS queries over TLS on port 853 using 2-byte length prefix format (RFC 7858)
|
||||
pub const DotClient = struct {
|
||||
host: []const u8,
|
||||
port: u16,
|
||||
allocator: Allocator,
|
||||
timeout_ms: u32,
|
||||
|
||||
pub const DEFAULT_PORT: u16 = 853;
|
||||
|
||||
pub const Error = error{
|
||||
InvalidHost,
|
||||
ConnectionFailed,
|
||||
TlsHandshakeFailed,
|
||||
CertificateLoadFailed,
|
||||
SendFailed,
|
||||
ReceiveFailed,
|
||||
InvalidResponse,
|
||||
Timeout,
|
||||
OutOfMemory,
|
||||
};
|
||||
|
||||
/// Initialize a DoT client
|
||||
/// host: hostname like "cloudflare-dns.com" or "1.1.1.1"
|
||||
/// port: typically 853
|
||||
pub fn init(host: []const u8, port: u16, allocator: Allocator) DotClient {
|
||||
return DotClient{
|
||||
.host = host,
|
||||
.port = port,
|
||||
.allocator = allocator,
|
||||
.timeout_ms = 5000, // 5 second default timeout
|
||||
};
|
||||
}
|
||||
|
||||
/// Parse a tls:// URL and create a DoT client
|
||||
/// e.g., "tls://cloudflare-dns.com" or "tls://1.1.1.1:853"
|
||||
pub fn fromUrl(url: []const u8, allocator: Allocator) Error!DotClient {
|
||||
// Strip tls:// prefix
|
||||
const prefix = "tls://";
|
||||
if (!std.mem.startsWith(u8, url, prefix)) {
|
||||
return error.InvalidHost;
|
||||
}
|
||||
|
||||
const host_port = url[prefix.len..];
|
||||
|
||||
// Check for port
|
||||
if (std.mem.lastIndexOfScalar(u8, host_port, ':')) |colon_idx| {
|
||||
const host = host_port[0..colon_idx];
|
||||
const port_str = host_port[colon_idx + 1 ..];
|
||||
const port = std.fmt.parseInt(u16, port_str, 10) catch return error.InvalidHost;
|
||||
return DotClient.init(host, port, allocator);
|
||||
}
|
||||
|
||||
return DotClient.init(host_port, DEFAULT_PORT, allocator);
|
||||
}
|
||||
|
||||
/// Query the DoT server with a DNS packet
|
||||
/// Returns the DNS response packet (caller owns the memory)
|
||||
pub fn query(self: *DotClient, dns_packet: []const u8) Error![]const u8 {
|
||||
// Load system CA certificates for TLS verification
|
||||
var ca_bundle: Certificate.Bundle = .{};
|
||||
ca_bundle.rescan(self.allocator) catch |err| {
|
||||
std.log.err("Failed to load system CA certificates: {}", .{err});
|
||||
return error.CertificateLoadFailed;
|
||||
};
|
||||
defer ca_bundle.deinit(self.allocator);
|
||||
|
||||
// Connect to server via TCP
|
||||
const stream = net.tcpConnectToHost(self.allocator, self.host, self.port) catch |err| {
|
||||
std.log.warn("DoT connection to {s}:{d} failed: {}", .{ self.host, self.port, err });
|
||||
return error.ConnectionFailed;
|
||||
};
|
||||
defer stream.close();
|
||||
|
||||
// Apply socket timeouts
|
||||
const timeout = posix.timeval{
|
||||
.sec = @intCast(self.timeout_ms / 1000),
|
||||
.usec = @intCast((self.timeout_ms % 1000) * 1000),
|
||||
};
|
||||
posix.setsockopt(stream.handle, posix.SOL.SOCKET, posix.SO.RCVTIMEO, std.mem.asBytes(&timeout)) catch |err| {
|
||||
std.log.warn("DoT: failed to set receive timeout: {}", .{err});
|
||||
};
|
||||
posix.setsockopt(stream.handle, posix.SOL.SOCKET, posix.SO.SNDTIMEO, std.mem.asBytes(&timeout)) catch |err| {
|
||||
std.log.warn("DoT: failed to set send timeout: {}", .{err});
|
||||
};
|
||||
|
||||
// Buffers for the underlying TCP stream reader/writer
|
||||
var stream_read_buffer: [tls.Client.min_buffer_len]u8 = undefined;
|
||||
var stream_write_buffer: [tls.Client.min_buffer_len]u8 = undefined;
|
||||
|
||||
// Create stream reader/writer
|
||||
var stream_reader = net.Stream.Reader.init(stream, &stream_read_buffer);
|
||||
var stream_writer = net.Stream.Writer.init(stream, &stream_write_buffer);
|
||||
|
||||
// Buffers for TLS layer
|
||||
var tls_read_buffer: [tls.Client.min_buffer_len]u8 = undefined;
|
||||
var tls_write_buffer: [tls.Client.min_buffer_len]u8 = undefined;
|
||||
|
||||
// Initialize TLS client with handshake using system CA bundle
|
||||
var tls_client = tls.Client.init(stream_reader.interface(), &stream_writer.interface, .{
|
||||
.host = .{ .explicit = self.host },
|
||||
.ca = .{ .bundle = ca_bundle },
|
||||
.write_buffer = &tls_write_buffer,
|
||||
.read_buffer = &tls_read_buffer,
|
||||
}) catch |err| {
|
||||
std.log.warn("DoT TLS handshake with {s} failed: {}", .{ self.host, err });
|
||||
return error.TlsHandshakeFailed;
|
||||
};
|
||||
|
||||
// DNS-over-TLS uses 2-byte length prefix (big-endian)
|
||||
var len_buf: [2]u8 = undefined;
|
||||
std.mem.writeInt(u16, &len_buf, @intCast(dns_packet.len), .big);
|
||||
|
||||
// Send length prefix + DNS packet via TLS
|
||||
tls_client.writer.writeAll(&len_buf) catch return error.SendFailed;
|
||||
tls_client.writer.writeAll(dns_packet) catch return error.SendFailed;
|
||||
tls_client.writer.flush() catch return error.SendFailed;
|
||||
|
||||
// Read response length via TLS
|
||||
var resp_len_buf: [2]u8 = undefined;
|
||||
tls_client.reader.readSliceAll(&resp_len_buf) catch return error.ReceiveFailed;
|
||||
|
||||
const resp_len = std.mem.readInt(u16, &resp_len_buf, .big);
|
||||
|
||||
// Validate response length
|
||||
if (resp_len < 12 or resp_len > 65535) return error.InvalidResponse;
|
||||
|
||||
// Read response
|
||||
const response = self.allocator.alloc(u8, resp_len) catch return error.OutOfMemory;
|
||||
errdefer self.allocator.free(response);
|
||||
|
||||
tls_client.reader.readSliceAll(response) catch {
|
||||
self.allocator.free(response);
|
||||
return error.ReceiveFailed;
|
||||
};
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/// Set the timeout in milliseconds
|
||||
pub fn setTimeout(self: *DotClient, timeout_ms: u32) void {
|
||||
self.timeout_ms = timeout_ms;
|
||||
}
|
||||
};
|
||||
|
||||
/// Query a DoT server (convenience function)
|
||||
pub fn queryDot(host: []const u8, port: u16, dns_packet: []const u8, allocator: Allocator) DotClient.Error![]const u8 {
|
||||
var client = DotClient.init(host, port, allocator);
|
||||
return client.query(dns_packet);
|
||||
}
|
||||
|
||||
/// Query a DoT server from a URL (e.g., "tls://cloudflare-dns.com")
|
||||
pub fn queryDotUrl(url: []const u8, dns_packet: []const u8, allocator: Allocator) DotClient.Error![]const u8 {
|
||||
var client = try DotClient.fromUrl(url, allocator);
|
||||
return client.query(dns_packet);
|
||||
}
|
||||
|
||||
test "DotClient URL parsing" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
// Valid TLS URLs
|
||||
const client1 = try DotClient.fromUrl("tls://cloudflare-dns.com", allocator);
|
||||
try testing.expectEqualStrings("cloudflare-dns.com", client1.host);
|
||||
try testing.expectEqual(DotClient.DEFAULT_PORT, client1.port);
|
||||
|
||||
const client2 = try DotClient.fromUrl("tls://1.1.1.1:853", allocator);
|
||||
try testing.expectEqualStrings("1.1.1.1", client2.host);
|
||||
try testing.expectEqual(@as(u16, 853), client2.port);
|
||||
|
||||
const client3 = try DotClient.fromUrl("tls://dns.google:8853", allocator);
|
||||
try testing.expectEqualStrings("dns.google", client3.host);
|
||||
try testing.expectEqual(@as(u16, 8853), client3.port);
|
||||
|
||||
// Invalid URLs should fail
|
||||
const invalid_result = DotClient.fromUrl("https://example.com", allocator);
|
||||
try testing.expectError(error.InvalidHost, invalid_result);
|
||||
|
||||
const invalid_result2 = DotClient.fromUrl("not-a-url", allocator);
|
||||
try testing.expectError(error.InvalidHost, invalid_result2);
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
const std = @import("std");
|
||||
const posix = std.posix;
|
||||
const net = std.net;
|
||||
const Allocator = std.mem.Allocator;
|
||||
const handler = @import("../server/handler.zig");
|
||||
const doh = @import("doh.zig");
|
||||
const dot = @import("dot.zig");
|
||||
const connection_pool = @import("connection_pool.zig");
|
||||
|
||||
/// Upstream protocol type
|
||||
pub const Protocol = enum {
|
||||
udp, // Plain UDP (IP:port)
|
||||
doh, // DNS-over-HTTPS (https://...)
|
||||
dot, // DNS-over-TLS (tls://...)
|
||||
};
|
||||
|
||||
/// Persistent UDP socket for upstream queries
|
||||
/// Uses mutex to prevent response mixing between concurrent queries
|
||||
const UdpUpstream = struct {
|
||||
socket: posix.socket_t,
|
||||
mutex: std.Thread.Mutex,
|
||||
timeout_ms: u32,
|
||||
|
||||
fn init(ip: [4]u8, port: u16, timeout_ms: u32) !UdpUpstream {
|
||||
const sock = try posix.socket(posix.AF.INET, posix.SOCK.DGRAM, 0);
|
||||
errdefer posix.close(sock);
|
||||
|
||||
const addr = net.Address.initIp4(ip, port);
|
||||
|
||||
// Connect the socket to the upstream - allows send/recv and ICMP errors
|
||||
try posix.connect(sock, &addr.any, addr.getOsSockLen());
|
||||
|
||||
return UdpUpstream{
|
||||
.socket = sock,
|
||||
.mutex = .{},
|
||||
.timeout_ms = timeout_ms,
|
||||
};
|
||||
}
|
||||
|
||||
fn deinit(self: *UdpUpstream) void {
|
||||
posix.close(self.socket);
|
||||
}
|
||||
|
||||
fn query(self: *UdpUpstream, dns_packet: []const u8, allocator: Allocator) ![]const u8 {
|
||||
self.mutex.lock();
|
||||
defer self.mutex.unlock();
|
||||
|
||||
// Set timeout for this query
|
||||
const timeout = posix.timeval{
|
||||
.sec = @intCast(self.timeout_ms / 1000),
|
||||
.usec = @intCast((self.timeout_ms % 1000) * 1000),
|
||||
};
|
||||
posix.setsockopt(self.socket, posix.SOL.SOCKET, posix.SO.RCVTIMEO, std.mem.asBytes(&timeout)) catch {};
|
||||
posix.setsockopt(self.socket, posix.SOL.SOCKET, posix.SO.SNDTIMEO, std.mem.asBytes(&timeout)) catch {};
|
||||
|
||||
// Send using connected socket (no address needed)
|
||||
_ = try posix.send(self.socket, dns_packet, 0);
|
||||
|
||||
// Receive response
|
||||
var response_buf: [4096]u8 = undefined;
|
||||
const n = try posix.recv(self.socket, &response_buf, 0);
|
||||
|
||||
if (n < 12) return error.InvalidResponse;
|
||||
|
||||
const response = try allocator.alloc(u8, n);
|
||||
@memcpy(response, response_buf[0..n]);
|
||||
return response;
|
||||
}
|
||||
};
|
||||
|
||||
/// Upstream DNS server configuration
|
||||
pub const UpstreamConfig = struct {
|
||||
/// Server address/URL
|
||||
/// - "8.8.8.8" or "8.8.8.8:53" for plain UDP
|
||||
/// - "https://cloudflare-dns.com/dns-query" for DoH
|
||||
/// - "tls://cloudflare-dns.com" for DoT
|
||||
address: []const u8,
|
||||
port: u16 = 53,
|
||||
enabled: bool = true,
|
||||
timeout_ms: u32 = 1000, // 1 second default (reduced from 2s for faster failover)
|
||||
|
||||
/// Detect protocol from address
|
||||
pub fn getProtocol(self: UpstreamConfig) Protocol {
|
||||
if (std.mem.startsWith(u8, self.address, "https://")) {
|
||||
return .doh;
|
||||
} else if (std.mem.startsWith(u8, self.address, "tls://")) {
|
||||
return .dot;
|
||||
} else {
|
||||
return .udp;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/// Health state for an upstream server
|
||||
const UpstreamHealth = struct {
|
||||
failures: u32 = 0,
|
||||
last_failure: i64 = 0,
|
||||
|
||||
const FAILURE_THRESHOLD: u32 = 3;
|
||||
const COOLDOWN_SECONDS: i64 = 30;
|
||||
|
||||
fn isHealthy(self: *const UpstreamHealth) bool {
|
||||
if (self.failures < FAILURE_THRESHOLD) return true;
|
||||
// Allow retry after cooldown
|
||||
const now = std.time.timestamp();
|
||||
return now - self.last_failure > COOLDOWN_SECONDS;
|
||||
}
|
||||
|
||||
fn recordFailure(self: *UpstreamHealth) void {
|
||||
self.failures +|= 1; // Saturating add
|
||||
self.last_failure = std.time.timestamp();
|
||||
}
|
||||
|
||||
fn recordSuccess(self: *UpstreamHealth) void {
|
||||
self.failures = 0;
|
||||
}
|
||||
};
|
||||
|
||||
/// Pool of upstream DNS servers with failover
|
||||
pub const UpstreamPool = struct {
|
||||
configs: []UpstreamConfig,
|
||||
allocator: Allocator,
|
||||
/// Connection pool for DoT - maintains persistent TLS connections
|
||||
dot_pool: connection_pool.DotConnectionPool,
|
||||
/// Connection pool for DoH - maintains persistent HTTP connections
|
||||
doh_pool: doh.DohConnectionPool,
|
||||
/// Persistent UDP sockets - one per UDP upstream config (null for non-UDP)
|
||||
udp_upstreams: []?UdpUpstream,
|
||||
/// Health state per upstream for fast failover
|
||||
health: []UpstreamHealth,
|
||||
|
||||
pub fn init(configs: []const UpstreamConfig, allocator: Allocator) !UpstreamPool {
|
||||
const configs_copy = try allocator.alloc(UpstreamConfig, configs.len);
|
||||
@memcpy(configs_copy, configs);
|
||||
|
||||
const udp_upstreams = try allocator.alloc(?UdpUpstream, configs.len);
|
||||
for (configs_copy, 0..) |config, i| {
|
||||
if (config.getProtocol() == .udp) {
|
||||
if (parseIpv4(config.address)) |ip| {
|
||||
udp_upstreams[i] = UdpUpstream.init(ip, config.port, config.timeout_ms) catch |err| {
|
||||
std.log.warn("Failed to create UDP socket for {s}:{d}: {} (will use per-query fallback)", .{ config.address, config.port, err });
|
||||
udp_upstreams[i] = null;
|
||||
continue;
|
||||
};
|
||||
} else {
|
||||
udp_upstreams[i] = null;
|
||||
}
|
||||
} else {
|
||||
udp_upstreams[i] = null;
|
||||
}
|
||||
}
|
||||
|
||||
const health = try allocator.alloc(UpstreamHealth, configs.len);
|
||||
for (health) |*h| {
|
||||
h.* = UpstreamHealth{};
|
||||
}
|
||||
|
||||
return UpstreamPool{
|
||||
.configs = configs_copy,
|
||||
.allocator = allocator,
|
||||
.dot_pool = connection_pool.DotConnectionPool.init(allocator),
|
||||
.doh_pool = doh.DohConnectionPool.init(allocator),
|
||||
.udp_upstreams = udp_upstreams,
|
||||
.health = health,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *UpstreamPool) void {
|
||||
for (self.udp_upstreams) |*maybe_udp| {
|
||||
if (maybe_udp.*) |*udp| {
|
||||
udp.deinit();
|
||||
}
|
||||
}
|
||||
self.allocator.free(self.udp_upstreams);
|
||||
self.allocator.free(self.health);
|
||||
self.dot_pool.deinit();
|
||||
self.doh_pool.deinit();
|
||||
self.allocator.free(self.configs);
|
||||
}
|
||||
|
||||
/// Convert to handler-compatible Upstream interface
|
||||
pub fn toHandlerUpstream(self: *UpstreamPool) handler.Upstream {
|
||||
return handler.Upstream{
|
||||
.context = self,
|
||||
.queryFn = queryWrapper,
|
||||
};
|
||||
}
|
||||
|
||||
fn queryWrapper(ctx: *anyopaque, dns_packet: []const u8, allocator: Allocator) ?[]const u8 {
|
||||
const self: *UpstreamPool = @ptrCast(@alignCast(ctx));
|
||||
return self.query(dns_packet, allocator);
|
||||
}
|
||||
|
||||
/// Query upstream DNS servers, trying each until one succeeds.
|
||||
/// Skips unhealthy upstreams (>3 consecutive failures) for 30 seconds.
|
||||
pub fn query(self: *UpstreamPool, dns_packet: []const u8, allocator: Allocator) ?[]const u8 {
|
||||
// First pass: try healthy upstreams only
|
||||
for (self.configs, 0..) |config, i| {
|
||||
if (!config.enabled) continue;
|
||||
if (!self.health[i].isHealthy()) continue;
|
||||
|
||||
const result = self.queryUpstream(config, i, dns_packet, allocator);
|
||||
if (result) |response| {
|
||||
self.health[i].recordSuccess();
|
||||
return response;
|
||||
}
|
||||
self.health[i].recordFailure();
|
||||
}
|
||||
|
||||
// Second pass: try unhealthy upstreams as last resort
|
||||
for (self.configs, 0..) |config, i| {
|
||||
if (!config.enabled) continue;
|
||||
if (self.health[i].isHealthy()) continue; // Already tried
|
||||
|
||||
const result = self.queryUpstream(config, i, dns_packet, allocator);
|
||||
if (result) |response| {
|
||||
self.health[i].recordSuccess();
|
||||
return response;
|
||||
}
|
||||
self.health[i].recordFailure();
|
||||
}
|
||||
|
||||
std.log.err("All upstream DNS servers failed", .{});
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Query a single upstream based on its protocol
|
||||
fn queryUpstream(self: *UpstreamPool, config: UpstreamConfig, index: usize, dns_packet: []const u8, allocator: Allocator) ?[]const u8 {
|
||||
const protocol = config.getProtocol();
|
||||
|
||||
switch (protocol) {
|
||||
.doh => {
|
||||
// Use connection pool with slot-based sharding for parallelism
|
||||
return self.doh_pool.query(config.address, dns_packet) catch |err| {
|
||||
std.log.warn("DoH query failed for {s}: {}", .{ config.address, err });
|
||||
return null;
|
||||
};
|
||||
},
|
||||
.dot => {
|
||||
// Use persistent connection pool for DoT
|
||||
const parsed = parseDotUrl(config.address) orelse {
|
||||
std.log.warn("DoT invalid URL: {s}", .{config.address});
|
||||
return null;
|
||||
};
|
||||
|
||||
const conn = self.dot_pool.getConnection(parsed.host, parsed.port) catch |err| {
|
||||
std.log.warn("DoT pool failed for {s}: {}", .{ config.address, err });
|
||||
return null;
|
||||
};
|
||||
conn.setTimeout(config.timeout_ms);
|
||||
|
||||
return conn.query(dns_packet) catch |err| {
|
||||
std.log.warn("DoT query failed for {s}: {}", .{ config.address, err });
|
||||
return null;
|
||||
};
|
||||
},
|
||||
.udp => {
|
||||
// Use pooled socket if available, fall back to per-query socket
|
||||
if (self.udp_upstreams[index]) |*udp| {
|
||||
return udp.query(dns_packet, allocator) catch |err| {
|
||||
std.log.warn("UDP upstream {s}:{d} failed: {}", .{ config.address, config.port, err });
|
||||
return null;
|
||||
};
|
||||
} else if (parseIpv4(config.address)) |ip| {
|
||||
// Fallback: per-query socket (socket creation failed at init)
|
||||
return queryUdp(ip, config.port, dns_packet, config.timeout_ms, allocator) catch |err| {
|
||||
std.log.warn("UDP upstream {s}:{d} failed: {}", .{ config.address, config.port, err });
|
||||
return null;
|
||||
};
|
||||
} else {
|
||||
std.log.warn("Invalid IPv4 address: {s}", .{config.address});
|
||||
return null;
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/// Parse a tls:// URL into host and port
|
||||
fn parseDotUrl(url: []const u8) ?struct { host: []const u8, port: u16 } {
|
||||
const prefix = "tls://";
|
||||
if (!std.mem.startsWith(u8, url, prefix)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const host_port = url[prefix.len..];
|
||||
|
||||
// Check for port
|
||||
if (std.mem.lastIndexOfScalar(u8, host_port, ':')) |colon_idx| {
|
||||
const host = host_port[0..colon_idx];
|
||||
const port_str = host_port[colon_idx + 1 ..];
|
||||
const port = std.fmt.parseInt(u16, port_str, 10) catch return null;
|
||||
return .{ .host = host, .port = port };
|
||||
}
|
||||
|
||||
return .{ .host = host_port, .port = 853 };
|
||||
}
|
||||
|
||||
/// Query a DNS server using plain UDP
|
||||
fn queryUdp(ip: [4]u8, port: u16, dns_packet: []const u8, timeout_ms: u32, allocator: Allocator) ![]const u8 {
|
||||
const sock = try posix.socket(posix.AF.INET, posix.SOCK.DGRAM, 0);
|
||||
defer posix.close(sock);
|
||||
|
||||
// Set receive timeout - this is critical to avoid indefinite blocking
|
||||
const timeout = posix.timeval{
|
||||
.sec = @intCast(timeout_ms / 1000),
|
||||
.usec = @intCast((timeout_ms % 1000) * 1000),
|
||||
};
|
||||
posix.setsockopt(sock, posix.SOL.SOCKET, posix.SO.RCVTIMEO, std.mem.asBytes(&timeout)) catch |err| {
|
||||
std.log.warn("Failed to set socket receive timeout (queries may hang): {}", .{err});
|
||||
// Continue anyway - the query might still work, just without timeout protection
|
||||
};
|
||||
|
||||
// Also set send timeout
|
||||
posix.setsockopt(sock, posix.SOL.SOCKET, posix.SO.SNDTIMEO, std.mem.asBytes(&timeout)) catch |err| {
|
||||
std.log.warn("Failed to set socket send timeout: {}", .{err});
|
||||
};
|
||||
|
||||
const addr = net.Address.initIp4(ip, port);
|
||||
_ = try posix.sendto(sock, dns_packet, 0, &addr.any, addr.getOsSockLen());
|
||||
|
||||
var response_buf: [4096]u8 = undefined;
|
||||
const n = try posix.recvfrom(sock, &response_buf, 0, null, null);
|
||||
|
||||
// DNS header is 12 bytes minimum
|
||||
if (n < 12) return error.InvalidResponse;
|
||||
|
||||
const response = try allocator.alloc(u8, n);
|
||||
@memcpy(response, response_buf[0..n]);
|
||||
return response;
|
||||
}
|
||||
|
||||
/// Parse an IPv4 address string like "8.8.8.8" into bytes
|
||||
fn parseIpv4(addr: []const u8) ?[4]u8 {
|
||||
// Strip any port suffix
|
||||
const host = if (std.mem.indexOf(u8, addr, ":")) |idx| addr[0..idx] else addr;
|
||||
|
||||
var result: [4]u8 = undefined;
|
||||
var parts = std.mem.splitScalar(u8, host, '.');
|
||||
var i: usize = 0;
|
||||
|
||||
while (parts.next()) |part| {
|
||||
if (i >= 4) return null;
|
||||
result[i] = std.fmt.parseInt(u8, part, 10) catch return null;
|
||||
i += 1;
|
||||
}
|
||||
|
||||
if (i != 4) return null;
|
||||
return result;
|
||||
}
|
||||
|
||||
test "parseIpv4" {
|
||||
const testing = std.testing;
|
||||
|
||||
try testing.expectEqual([4]u8{ 8, 8, 8, 8 }, parseIpv4("8.8.8.8").?);
|
||||
try testing.expectEqual([4]u8{ 1, 1, 1, 1 }, parseIpv4("1.1.1.1").?);
|
||||
try testing.expectEqual([4]u8{ 192, 168, 1, 1 }, parseIpv4("192.168.1.1:53").?);
|
||||
try testing.expect(parseIpv4("invalid") == null);
|
||||
try testing.expect(parseIpv4("256.0.0.1") == null);
|
||||
}
|
||||
|
||||
test "UpstreamConfig protocol detection" {
|
||||
const testing = std.testing;
|
||||
|
||||
const doh_config = UpstreamConfig{ .address = "https://cloudflare-dns.com/dns-query" };
|
||||
try testing.expectEqual(Protocol.doh, doh_config.getProtocol());
|
||||
|
||||
const dot_config = UpstreamConfig{ .address = "tls://cloudflare-dns.com" };
|
||||
try testing.expectEqual(Protocol.dot, dot_config.getProtocol());
|
||||
|
||||
const udp_config = UpstreamConfig{ .address = "8.8.8.8" };
|
||||
try testing.expectEqual(Protocol.udp, udp_config.getProtocol());
|
||||
|
||||
const udp_with_port = UpstreamConfig{ .address = "1.1.1.1:53" };
|
||||
try testing.expectEqual(Protocol.udp, udp_with_port.getProtocol());
|
||||
}
|
||||
|
||||
test "UpstreamPool initialization" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
const configs = [_]UpstreamConfig{
|
||||
.{ .address = "https://cloudflare-dns.com/dns-query" },
|
||||
.{ .address = "tls://1.1.1.1" },
|
||||
.{ .address = "8.8.8.8" },
|
||||
};
|
||||
|
||||
var pool = try UpstreamPool.init(&configs, allocator);
|
||||
defer pool.deinit();
|
||||
|
||||
try testing.expectEqual(@as(usize, 3), pool.configs.len);
|
||||
}
|
||||
|
||||
test "parseDotUrl" {
|
||||
const testing = std.testing;
|
||||
|
||||
// Valid URLs
|
||||
const r1 = parseDotUrl("tls://cloudflare-dns.com").?;
|
||||
try testing.expectEqualStrings("cloudflare-dns.com", r1.host);
|
||||
try testing.expectEqual(@as(u16, 853), r1.port);
|
||||
|
||||
const r2 = parseDotUrl("tls://1.1.1.1:853").?;
|
||||
try testing.expectEqualStrings("1.1.1.1", r2.host);
|
||||
try testing.expectEqual(@as(u16, 853), r2.port);
|
||||
|
||||
const r3 = parseDotUrl("tls://dns.google:8853").?;
|
||||
try testing.expectEqualStrings("dns.google", r3.host);
|
||||
try testing.expectEqual(@as(u16, 8853), r3.port);
|
||||
|
||||
// Invalid URLs
|
||||
try testing.expect(parseDotUrl("https://example.com") == null);
|
||||
try testing.expect(parseDotUrl("example.com") == null);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
const std = @import("std");
|
||||
|
||||
/// Convert a string to lowercase using a pre-allocated buffer.
|
||||
/// Returns null if the buffer is too small.
|
||||
pub fn toLower(s: []const u8, buf: []u8) ?[]const u8 {
|
||||
if (s.len > buf.len) return null;
|
||||
for (s, 0..) |c, i| {
|
||||
buf[i] = std.ascii.toLower(c);
|
||||
}
|
||||
return buf[0..s.len];
|
||||
}
|
||||
|
||||
test "toLower" {
|
||||
const testing = std.testing;
|
||||
var buf: [64]u8 = undefined;
|
||||
|
||||
try testing.expectEqualStrings("hello", toLower("HELLO", &buf).?);
|
||||
try testing.expectEqualStrings("hello", toLower("hello", &buf).?);
|
||||
try testing.expectEqualStrings("hello123", toLower("HeLLo123", &buf).?);
|
||||
|
||||
// Buffer too small
|
||||
var small_buf: [2]u8 = undefined;
|
||||
try testing.expect(toLower("hello", &small_buf) == null);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
const std = @import("std");
|
||||
const posix = std.posix;
|
||||
const Database = @import("../../storage/db.zig").Database;
|
||||
const json = @import("../json.zig");
|
||||
const response = @import("../response.zig");
|
||||
|
||||
pub fn list(db: *Database, handle: posix.socket_t, allocator: std.mem.Allocator) void {
|
||||
var stmt = db.prepare(
|
||||
\\SELECT c.id, c.ip, c.name, c.group_id, g.name, c.first_seen, c.last_seen
|
||||
\\FROM clients c
|
||||
\\LEFT JOIN groups g ON c.group_id = g.id
|
||||
\\ORDER BY c.last_seen DESC
|
||||
) catch {
|
||||
response.sendServerError(handle, "Database error");
|
||||
return;
|
||||
};
|
||||
defer stmt.finalize();
|
||||
|
||||
var w = json.JsonWriter.init(allocator);
|
||||
defer w.deinit();
|
||||
|
||||
w.beginArray() catch return;
|
||||
|
||||
while (true) {
|
||||
const has_row = stmt.step() catch break;
|
||||
if (!has_row) break;
|
||||
|
||||
w.beginObject() catch break;
|
||||
w.writeIntField("id", stmt.getInt(0)) catch break;
|
||||
w.writeStringField("ip", stmt.getText(1) orelse "unknown") catch break;
|
||||
w.writeOptionalStringField("name", stmt.getText(2)) catch break;
|
||||
w.writeIntField("group_id", stmt.getInt(3)) catch break;
|
||||
w.writeStringField("group_name", stmt.getText(4) orelse "default") catch break;
|
||||
w.writeIntField("first_seen", stmt.getInt(5)) catch break;
|
||||
w.writeIntField("last_seen", stmt.getInt(6)) catch break;
|
||||
w.endObject() catch break;
|
||||
}
|
||||
|
||||
w.endArray() catch return;
|
||||
|
||||
response.sendJson(handle, w.items());
|
||||
}
|
||||
|
||||
const UpdateRequest = struct {
|
||||
name: ?[]const u8 = null,
|
||||
group_id: ?i64 = null,
|
||||
};
|
||||
|
||||
pub fn update(db: *Database, handle: posix.socket_t, allocator: std.mem.Allocator, id: i64, body: []const u8) void {
|
||||
const parsed = std.json.parseFromSlice(UpdateRequest, allocator, body, .{
|
||||
.ignore_unknown_fields = true,
|
||||
}) catch {
|
||||
response.sendBadRequest(handle, "Invalid JSON");
|
||||
return;
|
||||
};
|
||||
defer parsed.deinit();
|
||||
|
||||
if (parsed.value.name) |name| {
|
||||
var stmt = db.prepare("UPDATE clients SET name = ? WHERE id = ?") catch {
|
||||
response.sendServerError(handle, "Database error");
|
||||
return;
|
||||
};
|
||||
defer stmt.finalize();
|
||||
stmt.bindText(1, name) catch return;
|
||||
stmt.bindInt(2, id) catch return;
|
||||
_ = stmt.step() catch |err| {
|
||||
std.log.warn("Failed to update client name: {}", .{err});
|
||||
};
|
||||
}
|
||||
|
||||
if (parsed.value.group_id) |group_id| {
|
||||
var stmt = db.prepare("UPDATE clients SET group_id = ? WHERE id = ?") catch {
|
||||
response.sendServerError(handle, "Database error");
|
||||
return;
|
||||
};
|
||||
defer stmt.finalize();
|
||||
stmt.bindInt(1, group_id) catch return;
|
||||
stmt.bindInt(2, id) catch return;
|
||||
_ = stmt.step() catch |err| {
|
||||
std.log.warn("Failed to update client group: {}", .{err});
|
||||
};
|
||||
}
|
||||
|
||||
response.sendOk(handle);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
const std = @import("std");
|
||||
const posix = std.posix;
|
||||
const Database = @import("../../storage/db.zig").Database;
|
||||
const schema = @import("../../storage/schema.zig");
|
||||
const json = @import("../json.zig");
|
||||
const response = @import("../response.zig");
|
||||
|
||||
/// Maximum lengths for validation
|
||||
const MAX_COMMENT_LENGTH = 256;
|
||||
const MAX_URL_LENGTH = 2048;
|
||||
|
||||
/// Validate a denylist URL
|
||||
fn isValidUrl(url: []const u8) bool {
|
||||
if (url.len == 0 or url.len > MAX_URL_LENGTH) return false;
|
||||
|
||||
// Must start with http:// or https://
|
||||
if (!std.mem.startsWith(u8, url, "http://") and !std.mem.startsWith(u8, url, "https://")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for invalid characters (basic URL validation)
|
||||
for (url) |c| {
|
||||
// URL-safe characters
|
||||
if (!std.ascii.isAlphanumeric(c) and
|
||||
c != ':' and c != '/' and c != '.' and c != '-' and c != '_' and
|
||||
c != '?' and c != '&' and c != '=' and c != '%' and c != '+' and
|
||||
c != '#' and c != '@' and c != '!' and c != '$' and c != '\'' and
|
||||
c != '(' and c != ')' and c != '*' and c != ',' and c != ';')
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
pub fn list(db: *Database, handle: posix.socket_t, allocator: std.mem.Allocator) void {
|
||||
var stmt = db.prepare(
|
||||
\\SELECT id, url, comment, enabled, status, domain_count FROM denylist_sources ORDER BY id
|
||||
) catch {
|
||||
response.sendServerError(handle, "Database error");
|
||||
return;
|
||||
};
|
||||
defer stmt.finalize();
|
||||
|
||||
var w = json.JsonWriter.init(allocator);
|
||||
defer w.deinit();
|
||||
|
||||
w.beginArray() catch return;
|
||||
|
||||
while (true) {
|
||||
const has_row = stmt.step() catch break;
|
||||
if (!has_row) break;
|
||||
|
||||
const status_int = stmt.getInt(4);
|
||||
const status = schema.DenylistStatus.fromInt(status_int);
|
||||
|
||||
w.beginObject() catch break;
|
||||
w.writeIntField("id", stmt.getInt(0)) catch break;
|
||||
w.writeStringField("url", stmt.getText(1) orelse "") catch break;
|
||||
if (stmt.getText(2)) |comment| {
|
||||
w.writeStringField("comment", comment) catch break;
|
||||
}
|
||||
w.writeBoolField("enabled", stmt.getInt(3) == 1) catch break;
|
||||
w.writeIntField("status", status_int) catch break;
|
||||
w.writeStringField("status_text", status.toString()) catch break;
|
||||
w.writeIntField("domain_count", stmt.getInt(5)) catch break;
|
||||
w.endObject() catch break;
|
||||
}
|
||||
|
||||
w.endArray() catch return;
|
||||
|
||||
response.sendJson(handle, w.items());
|
||||
}
|
||||
|
||||
const AddRequest = struct {
|
||||
url: []const u8,
|
||||
comment: ?[]const u8 = null,
|
||||
};
|
||||
|
||||
pub fn add(db: *Database, handle: posix.socket_t, allocator: std.mem.Allocator, body: []const u8) void {
|
||||
const parsed = std.json.parseFromSlice(AddRequest, allocator, body, .{
|
||||
.ignore_unknown_fields = true,
|
||||
}) catch {
|
||||
response.sendBadRequest(handle, "Invalid JSON");
|
||||
return;
|
||||
};
|
||||
defer parsed.deinit();
|
||||
|
||||
// Validate denylist URL
|
||||
if (!isValidUrl(parsed.value.url)) {
|
||||
response.sendBadRequest(handle, "Invalid URL (must be http:// or https://, max 2048 chars)");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate comment length if provided
|
||||
if (parsed.value.comment) |comment| {
|
||||
if (comment.len > MAX_COMMENT_LENGTH) {
|
||||
response.sendBadRequest(handle, "Comment too long (max 256 chars)");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var stmt = db.prepare("INSERT INTO denylist_sources (url, comment, enabled, domain_count) VALUES (?, ?, 1, 0)") catch {
|
||||
response.sendServerError(handle, "Database error");
|
||||
return;
|
||||
};
|
||||
defer stmt.finalize();
|
||||
|
||||
stmt.bindText(1, parsed.value.url) catch return;
|
||||
if (parsed.value.comment) |comment| {
|
||||
stmt.bindText(2, comment) catch return;
|
||||
} else {
|
||||
stmt.bindNull(2) catch return;
|
||||
}
|
||||
|
||||
_ = stmt.step() catch {
|
||||
response.sendServerError(handle, "Insert failed (URL may already exist)");
|
||||
return;
|
||||
};
|
||||
|
||||
const id = db.lastInsertRowId();
|
||||
|
||||
// Link to default group (0) so it's active immediately
|
||||
linkToDefaultGroup: {
|
||||
var group_stmt = db.prepare("INSERT INTO group_sources (group_id, source_id) VALUES (0, ?)") catch {
|
||||
std.log.warn("Failed to link denylist {d} to default group", .{id});
|
||||
break :linkToDefaultGroup;
|
||||
};
|
||||
defer group_stmt.finalize();
|
||||
group_stmt.bindInt(1, id) catch break :linkToDefaultGroup;
|
||||
_ = group_stmt.step() catch |err| {
|
||||
std.log.warn("Failed to insert group_sources for denylist {d}: {}", .{ id, err });
|
||||
};
|
||||
}
|
||||
|
||||
var w = json.JsonWriter.init(allocator);
|
||||
defer w.deinit();
|
||||
|
||||
w.beginObject() catch return;
|
||||
w.writeIntField("id", id) catch return;
|
||||
w.writeBoolField("success", true) catch return;
|
||||
w.endObject() catch return;
|
||||
|
||||
// Send 201 Created
|
||||
var header_buf: [256]u8 = undefined;
|
||||
const header = std.fmt.bufPrint(&header_buf, "HTTP/1.1 201 Created\r\n{s}Content-Type: application/json\r\nContent-Length: {d}\r\n\r\n", .{
|
||||
response.CORS_API,
|
||||
w.items().len,
|
||||
}) catch return;
|
||||
|
||||
_ = posix.write(handle, header) catch |err| {
|
||||
std.log.debug("Failed to write response header: {}", .{err});
|
||||
return;
|
||||
};
|
||||
_ = posix.write(handle, w.items()) catch |err| {
|
||||
std.log.debug("Failed to write response body: {}", .{err});
|
||||
};
|
||||
}
|
||||
|
||||
pub fn delete(db: *Database, handle: posix.socket_t, id: i64) void {
|
||||
// Delete associated domains first
|
||||
var del_domains = db.prepare("DELETE FROM denylist_domains WHERE source_id = ?") catch {
|
||||
response.sendServerError(handle, "Database error");
|
||||
return;
|
||||
};
|
||||
defer del_domains.finalize();
|
||||
del_domains.bindInt(1, id) catch return;
|
||||
_ = del_domains.step() catch |err| {
|
||||
std.log.warn("Failed to delete denylist domains for source {d}: {}", .{ id, err });
|
||||
// Continue - we still want to try deleting the source
|
||||
};
|
||||
|
||||
// Delete from group_sources
|
||||
var del_groups = db.prepare("DELETE FROM group_sources WHERE source_id = ?") catch {
|
||||
response.sendServerError(handle, "Database error");
|
||||
return;
|
||||
};
|
||||
defer del_groups.finalize();
|
||||
del_groups.bindInt(1, id) catch return;
|
||||
_ = del_groups.step() catch |err| {
|
||||
std.log.warn("Failed to delete group_sources for source {d}: {}", .{ id, err });
|
||||
// Continue - we still want to try deleting the source
|
||||
};
|
||||
|
||||
// Delete the source
|
||||
var stmt = db.prepare("DELETE FROM denylist_sources WHERE id = ?") catch {
|
||||
response.sendServerError(handle, "Database error");
|
||||
return;
|
||||
};
|
||||
defer stmt.finalize();
|
||||
stmt.bindInt(1, id) catch return;
|
||||
_ = stmt.step() catch {
|
||||
response.sendServerError(handle, "Delete failed");
|
||||
return;
|
||||
};
|
||||
|
||||
response.sendOk(handle);
|
||||
}
|
||||
|
||||
const ToggleRequest = struct {
|
||||
enabled: bool,
|
||||
};
|
||||
|
||||
pub fn toggle(db: *Database, handle: posix.socket_t, allocator: std.mem.Allocator, id: i64, body: []const u8) void {
|
||||
const parsed = std.json.parseFromSlice(ToggleRequest, allocator, body, .{
|
||||
.ignore_unknown_fields = true,
|
||||
}) catch {
|
||||
response.sendBadRequest(handle, "Invalid JSON");
|
||||
return;
|
||||
};
|
||||
defer parsed.deinit();
|
||||
|
||||
var stmt = db.prepare("UPDATE denylist_sources SET enabled = ? WHERE id = ?") catch {
|
||||
response.sendServerError(handle, "Database error");
|
||||
return;
|
||||
};
|
||||
defer stmt.finalize();
|
||||
|
||||
stmt.bindInt(1, if (parsed.value.enabled) 1 else 0) catch return;
|
||||
stmt.bindInt(2, id) catch return;
|
||||
|
||||
_ = stmt.step() catch {
|
||||
response.sendServerError(handle, "Update failed");
|
||||
return;
|
||||
};
|
||||
|
||||
response.sendOk(handle);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
const std = @import("std");
|
||||
const posix = std.posix;
|
||||
const Database = @import("../../storage/db.zig").Database;
|
||||
const json = @import("../json.zig");
|
||||
const response = @import("../response.zig");
|
||||
|
||||
/// Maximum length for group/client names
|
||||
const MAX_NAME_LENGTH = 64;
|
||||
|
||||
/// Validate a name (group or client name)
|
||||
fn isValidName(name: []const u8) bool {
|
||||
// Empty or too long
|
||||
if (name.len == 0 or name.len > MAX_NAME_LENGTH) return false;
|
||||
|
||||
// Must start with alphanumeric
|
||||
if (!std.ascii.isAlphanumeric(name[0])) return false;
|
||||
|
||||
// Can only contain alphanumeric, spaces, hyphens, and underscores
|
||||
for (name) |c| {
|
||||
if (!std.ascii.isAlphanumeric(c) and c != ' ' and c != '-' and c != '_') return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
pub fn list(db: *Database, handle: posix.socket_t, allocator: std.mem.Allocator) void {
|
||||
var stmt = db.prepare("SELECT id, name, description FROM groups ORDER BY id") catch {
|
||||
response.sendServerError(handle, "Database error");
|
||||
return;
|
||||
};
|
||||
defer stmt.finalize();
|
||||
|
||||
var w = json.JsonWriter.init(allocator);
|
||||
defer w.deinit();
|
||||
|
||||
w.beginArray() catch return;
|
||||
|
||||
while (true) {
|
||||
const has_row = stmt.step() catch break;
|
||||
if (!has_row) break;
|
||||
|
||||
w.beginObject() catch break;
|
||||
w.writeIntField("id", stmt.getInt(0)) catch break;
|
||||
w.writeStringField("name", stmt.getText(1) orelse "unknown") catch break;
|
||||
w.writeOptionalStringField("description", stmt.getText(2)) catch break;
|
||||
w.endObject() catch break;
|
||||
}
|
||||
|
||||
w.endArray() catch return;
|
||||
|
||||
response.sendJson(handle, w.items());
|
||||
}
|
||||
|
||||
const GroupRequest = struct {
|
||||
name: []const u8,
|
||||
description: ?[]const u8 = null,
|
||||
};
|
||||
|
||||
pub fn add(db: *Database, handle: posix.socket_t, allocator: std.mem.Allocator, body: []const u8) void {
|
||||
const parsed = std.json.parseFromSlice(GroupRequest, allocator, body, .{
|
||||
.ignore_unknown_fields = true,
|
||||
}) catch {
|
||||
response.sendBadRequest(handle, "Invalid JSON");
|
||||
return;
|
||||
};
|
||||
defer parsed.deinit();
|
||||
|
||||
// Validate group name
|
||||
if (!isValidName(parsed.value.name)) {
|
||||
response.sendBadRequest(handle, "Invalid group name (max 64 chars, alphanumeric start)");
|
||||
return;
|
||||
}
|
||||
|
||||
var stmt = db.prepare("INSERT INTO groups (name, description) VALUES (?, ?)") catch {
|
||||
response.sendServerError(handle, "Database error");
|
||||
return;
|
||||
};
|
||||
defer stmt.finalize();
|
||||
|
||||
stmt.bindText(1, parsed.value.name) catch return;
|
||||
if (parsed.value.description) |desc| {
|
||||
stmt.bindText(2, desc) catch return;
|
||||
} else {
|
||||
stmt.bindNull(2) catch return;
|
||||
}
|
||||
|
||||
_ = stmt.step() catch {
|
||||
response.sendServerError(handle, "Insert failed");
|
||||
return;
|
||||
};
|
||||
|
||||
const id = db.lastInsertRowId();
|
||||
|
||||
var w = json.JsonWriter.init(allocator);
|
||||
defer w.deinit();
|
||||
|
||||
w.beginObject() catch return;
|
||||
w.writeIntField("id", id) catch return;
|
||||
w.writeBoolField("success", true) catch return;
|
||||
w.endObject() catch return;
|
||||
|
||||
var header_buf: [256]u8 = undefined;
|
||||
const header = std.fmt.bufPrint(&header_buf, "HTTP/1.1 201 Created\r\n{s}Content-Type: application/json\r\nContent-Length: {d}\r\n\r\n", .{
|
||||
response.CORS_API,
|
||||
w.items().len,
|
||||
}) catch return;
|
||||
|
||||
_ = posix.write(handle, header) catch |err| {
|
||||
std.log.debug("Failed to write response header: {}", .{err});
|
||||
return;
|
||||
};
|
||||
_ = posix.write(handle, w.items()) catch |err| {
|
||||
std.log.debug("Failed to write response body: {}", .{err});
|
||||
};
|
||||
}
|
||||
|
||||
pub fn update(db: *Database, handle: posix.socket_t, allocator: std.mem.Allocator, id: i64, body: []const u8) void {
|
||||
if (id == 0) {
|
||||
response.sendBadRequest(handle, "Cannot modify default group");
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = std.json.parseFromSlice(GroupRequest, allocator, body, .{
|
||||
.ignore_unknown_fields = true,
|
||||
}) catch {
|
||||
response.sendBadRequest(handle, "Invalid JSON");
|
||||
return;
|
||||
};
|
||||
defer parsed.deinit();
|
||||
|
||||
var stmt = db.prepare("UPDATE groups SET name = ?, description = ? WHERE id = ?") catch {
|
||||
response.sendServerError(handle, "Database error");
|
||||
return;
|
||||
};
|
||||
defer stmt.finalize();
|
||||
|
||||
stmt.bindText(1, parsed.value.name) catch return;
|
||||
if (parsed.value.description) |desc| {
|
||||
stmt.bindText(2, desc) catch return;
|
||||
} else {
|
||||
stmt.bindNull(2) catch return;
|
||||
}
|
||||
stmt.bindInt(3, id) catch return;
|
||||
|
||||
_ = stmt.step() catch {
|
||||
response.sendServerError(handle, "Update failed");
|
||||
return;
|
||||
};
|
||||
|
||||
response.sendOk(handle);
|
||||
}
|
||||
|
||||
pub fn delete(db: *Database, handle: posix.socket_t, id: i64) void {
|
||||
if (id == 0) {
|
||||
response.sendBadRequest(handle, "Cannot delete default group");
|
||||
return;
|
||||
}
|
||||
|
||||
// Move clients in this group to default group first
|
||||
var move_stmt = db.prepare("UPDATE clients SET group_id = 0 WHERE group_id = ?") catch {
|
||||
response.sendServerError(handle, "Database error");
|
||||
return;
|
||||
};
|
||||
defer move_stmt.finalize();
|
||||
move_stmt.bindInt(1, id) catch return;
|
||||
_ = move_stmt.step() catch |err| {
|
||||
std.log.warn("Failed to move clients to default group: {}", .{err});
|
||||
};
|
||||
|
||||
var stmt = db.prepare("DELETE FROM groups WHERE id = ?") catch {
|
||||
response.sendServerError(handle, "Database error");
|
||||
return;
|
||||
};
|
||||
defer stmt.finalize();
|
||||
|
||||
stmt.bindInt(1, id) catch return;
|
||||
_ = stmt.step() catch {
|
||||
response.sendServerError(handle, "Delete failed");
|
||||
return;
|
||||
};
|
||||
|
||||
response.sendOk(handle);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
const std = @import("std");
|
||||
const posix = std.posix;
|
||||
const Database = @import("../../storage/db.zig").Database;
|
||||
const json = @import("../json.zig");
|
||||
const response = @import("../response.zig");
|
||||
|
||||
/// Maximum number of queries to return in the API response
|
||||
const DEFAULT_QUERY_LIMIT = 100;
|
||||
|
||||
pub fn sendQueries(db: *Database, handle: posix.socket_t, allocator: std.mem.Allocator) void {
|
||||
const query = std.fmt.comptimePrint(
|
||||
\\SELECT ql.timestamp, d.domain, COALESCE(c.name, c.ip), ql.qtype, ql.denied, ql.dnssec_validated
|
||||
\\FROM query_log ql
|
||||
\\JOIN domains d ON ql.domain_id = d.id
|
||||
\\JOIN clients c ON ql.client_id = c.id
|
||||
\\ORDER BY ql.timestamp DESC
|
||||
\\LIMIT {d}
|
||||
, .{DEFAULT_QUERY_LIMIT});
|
||||
var stmt = db.prepare(query) catch {
|
||||
response.sendServerError(handle, "Database error");
|
||||
return;
|
||||
};
|
||||
defer stmt.finalize();
|
||||
|
||||
var w = json.JsonWriter.init(allocator);
|
||||
defer w.deinit();
|
||||
|
||||
w.beginArray() catch return;
|
||||
|
||||
while (true) {
|
||||
const has_row = stmt.step() catch break;
|
||||
if (!has_row) break;
|
||||
|
||||
w.beginObject() catch break;
|
||||
w.writeIntField("timestamp", stmt.getInt(0)) catch break;
|
||||
w.writeStringField("domain", stmt.getText(1) orelse "unknown") catch break;
|
||||
w.writeStringField("client", stmt.getText(2) orelse "unknown") catch break;
|
||||
w.writeIntField("qtype", stmt.getInt(3)) catch break;
|
||||
w.writeBoolField("denied", stmt.getInt(4) == 1) catch break;
|
||||
w.writeBoolField("dnssec", stmt.getInt(5) == 1) catch break;
|
||||
w.endObject() catch break;
|
||||
}
|
||||
|
||||
w.endArray() catch return;
|
||||
|
||||
response.sendJson(handle, w.items());
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
const std = @import("std");
|
||||
const posix = std.posix;
|
||||
const Database = @import("../../storage/db.zig").Database;
|
||||
const json = @import("../json.zig");
|
||||
const response = @import("../response.zig");
|
||||
|
||||
/// Maximum length for a DNS domain name (RFC 1035)
|
||||
const MAX_DOMAIN_LENGTH = 253;
|
||||
|
||||
/// Validate a domain name according to RFC 1035 rules
|
||||
fn isValidDomain(domain: []const u8) bool {
|
||||
// Empty or too long
|
||||
if (domain.len == 0 or domain.len > MAX_DOMAIN_LENGTH) return false;
|
||||
|
||||
// Wildcard domains like *.example.com are allowed
|
||||
var check_domain = domain;
|
||||
if (std.mem.startsWith(u8, domain, "*.")) {
|
||||
check_domain = domain[2..];
|
||||
if (check_domain.len == 0) return false;
|
||||
}
|
||||
|
||||
// Check each label
|
||||
var labels = std.mem.splitScalar(u8, check_domain, '.');
|
||||
var label_count: usize = 0;
|
||||
while (labels.next()) |label| {
|
||||
// Label cannot be empty (except trailing dot creates empty last label)
|
||||
if (label.len == 0) {
|
||||
// Allow trailing dot
|
||||
if (labels.peek() == null) continue;
|
||||
return false;
|
||||
}
|
||||
// Label cannot exceed 63 characters
|
||||
if (label.len > 63) return false;
|
||||
|
||||
// Label must start with alphanumeric
|
||||
if (!std.ascii.isAlphanumeric(label[0])) return false;
|
||||
|
||||
// Label must end with alphanumeric
|
||||
if (!std.ascii.isAlphanumeric(label[label.len - 1])) return false;
|
||||
|
||||
// Label can only contain alphanumeric and hyphens
|
||||
for (label) |c| {
|
||||
if (!std.ascii.isAlphanumeric(c) and c != '-') return false;
|
||||
}
|
||||
|
||||
label_count += 1;
|
||||
}
|
||||
|
||||
// Must have at least one label
|
||||
return label_count > 0;
|
||||
}
|
||||
|
||||
pub fn list(db: *Database, handle: posix.socket_t, allocator: std.mem.Allocator) void {
|
||||
var stmt = db.prepare(
|
||||
\\SELECT r.id, r.domain, r.group_id, g.name, r.action, r.comment
|
||||
\\FROM rules r
|
||||
\\LEFT JOIN groups g ON r.group_id = g.id
|
||||
\\ORDER BY r.id DESC
|
||||
) catch {
|
||||
response.sendServerError(handle, "Database error");
|
||||
return;
|
||||
};
|
||||
defer stmt.finalize();
|
||||
|
||||
var w = json.JsonWriter.init(allocator);
|
||||
defer w.deinit();
|
||||
|
||||
w.beginArray() catch return;
|
||||
|
||||
while (true) {
|
||||
const has_row = stmt.step() catch break;
|
||||
if (!has_row) break;
|
||||
|
||||
w.beginObject() catch break;
|
||||
w.writeIntField("id", stmt.getInt(0)) catch break;
|
||||
w.writeStringField("domain", stmt.getText(1) orelse "") catch break;
|
||||
w.writeIntField("group_id", stmt.getInt(2)) catch break;
|
||||
w.writeStringField("group_name", stmt.getText(3) orelse "all") catch break;
|
||||
w.writeStringField("action", stmt.getText(4) orelse "deny") catch break;
|
||||
w.writeOptionalStringField("comment", stmt.getText(5)) catch break;
|
||||
w.endObject() catch break;
|
||||
}
|
||||
|
||||
w.endArray() catch return;
|
||||
|
||||
response.sendJson(handle, w.items());
|
||||
}
|
||||
|
||||
const AddRequest = struct {
|
||||
domain: []const u8,
|
||||
action: []const u8,
|
||||
group_id: ?i64 = null,
|
||||
comment: ?[]const u8 = null,
|
||||
};
|
||||
|
||||
pub fn add(db: *Database, handle: posix.socket_t, allocator: std.mem.Allocator, body: []const u8) void {
|
||||
const parsed = std.json.parseFromSlice(AddRequest, allocator, body, .{
|
||||
.ignore_unknown_fields = true,
|
||||
}) catch {
|
||||
response.sendBadRequest(handle, "Invalid JSON");
|
||||
return;
|
||||
};
|
||||
defer parsed.deinit();
|
||||
|
||||
// Validate action
|
||||
if (!std.mem.eql(u8, parsed.value.action, "allow") and !std.mem.eql(u8, parsed.value.action, "deny")) {
|
||||
response.sendBadRequest(handle, "Action must be 'allow' or 'deny'");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate domain
|
||||
if (!isValidDomain(parsed.value.domain)) {
|
||||
response.sendBadRequest(handle, "Invalid domain name");
|
||||
return;
|
||||
}
|
||||
|
||||
var stmt = db.prepare("INSERT INTO rules (domain, group_id, action, comment) VALUES (?, ?, ?, ?)") catch {
|
||||
response.sendServerError(handle, "Database error");
|
||||
return;
|
||||
};
|
||||
defer stmt.finalize();
|
||||
|
||||
stmt.bindText(1, parsed.value.domain) catch return;
|
||||
if (parsed.value.group_id) |gid| {
|
||||
stmt.bindInt(2, gid) catch return;
|
||||
} else {
|
||||
stmt.bindNull(2) catch return;
|
||||
}
|
||||
stmt.bindText(3, parsed.value.action) catch return;
|
||||
if (parsed.value.comment) |c| {
|
||||
stmt.bindText(4, c) catch return;
|
||||
} else {
|
||||
stmt.bindNull(4) catch return;
|
||||
}
|
||||
|
||||
_ = stmt.step() catch {
|
||||
response.sendServerError(handle, "Insert failed (duplicate?)");
|
||||
return;
|
||||
};
|
||||
|
||||
const id = db.lastInsertRowId();
|
||||
|
||||
var w = json.JsonWriter.init(allocator);
|
||||
defer w.deinit();
|
||||
|
||||
w.beginObject() catch return;
|
||||
w.writeIntField("id", id) catch return;
|
||||
w.writeBoolField("success", true) catch return;
|
||||
w.endObject() catch return;
|
||||
|
||||
var header_buf: [256]u8 = undefined;
|
||||
const header = std.fmt.bufPrint(&header_buf, "HTTP/1.1 201 Created\r\n{s}Content-Type: application/json\r\nContent-Length: {d}\r\n\r\n", .{
|
||||
response.CORS_API,
|
||||
w.items().len,
|
||||
}) catch return;
|
||||
|
||||
_ = posix.write(handle, header) catch |err| {
|
||||
std.log.debug("Failed to write response header: {}", .{err});
|
||||
return;
|
||||
};
|
||||
_ = posix.write(handle, w.items()) catch |err| {
|
||||
std.log.debug("Failed to write response body: {}", .{err});
|
||||
};
|
||||
}
|
||||
|
||||
pub fn delete(db: *Database, handle: posix.socket_t, id: i64) void {
|
||||
var stmt = db.prepare("DELETE FROM rules WHERE id = ?") catch {
|
||||
response.sendServerError(handle, "Database error");
|
||||
return;
|
||||
};
|
||||
defer stmt.finalize();
|
||||
|
||||
stmt.bindInt(1, id) catch return;
|
||||
_ = stmt.step() catch {
|
||||
response.sendServerError(handle, "Delete failed");
|
||||
return;
|
||||
};
|
||||
|
||||
response.sendOk(handle);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
const std = @import("std");
|
||||
const posix = std.posix;
|
||||
const Database = @import("../../storage/db.zig").Database;
|
||||
const json = @import("../json.zig");
|
||||
const response = @import("../response.zig");
|
||||
|
||||
pub fn list(db: *Database, handle: posix.socket_t, allocator: std.mem.Allocator) void {
|
||||
var stmt = db.prepare("SELECT key, value FROM settings") catch {
|
||||
response.sendServerError(handle, "Database error");
|
||||
return;
|
||||
};
|
||||
defer stmt.finalize();
|
||||
|
||||
var w = json.JsonWriter.init(allocator);
|
||||
defer w.deinit();
|
||||
|
||||
w.beginObject() catch return;
|
||||
|
||||
while (true) {
|
||||
const has_row = stmt.step() catch break;
|
||||
if (!has_row) break;
|
||||
|
||||
const key = stmt.getText(0) orelse continue;
|
||||
const value = stmt.getText(1) orelse "";
|
||||
|
||||
w.writeStringField(key, value) catch break;
|
||||
}
|
||||
|
||||
w.endObject() catch return;
|
||||
|
||||
response.sendJson(handle, w.items());
|
||||
}
|
||||
|
||||
const UpdateRequest = struct {
|
||||
blocking_response: ?[]const u8 = null,
|
||||
safe_search_enabled: ?[]const u8 = null,
|
||||
log_retention: ?[]const u8 = null,
|
||||
};
|
||||
|
||||
pub fn update(db: *Database, handle: posix.socket_t, allocator: std.mem.Allocator, body: []const u8) void {
|
||||
const parsed = std.json.parseFromSlice(UpdateRequest, allocator, body, .{
|
||||
.ignore_unknown_fields = true,
|
||||
}) catch {
|
||||
response.sendBadRequest(handle, "Invalid JSON");
|
||||
return;
|
||||
};
|
||||
defer parsed.deinit();
|
||||
|
||||
if (parsed.value.blocking_response) |val| {
|
||||
updateSetting(db, "blocking_response", val);
|
||||
}
|
||||
if (parsed.value.safe_search_enabled) |val| {
|
||||
updateSetting(db, "safe_search_enabled", val);
|
||||
}
|
||||
if (parsed.value.log_retention) |val| {
|
||||
updateSetting(db, "log_retention", val);
|
||||
}
|
||||
|
||||
response.sendOk(handle);
|
||||
}
|
||||
|
||||
fn updateSetting(db: *Database, key: []const u8, value: []const u8) void {
|
||||
var stmt = db.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)") catch return;
|
||||
defer stmt.finalize();
|
||||
|
||||
stmt.bindText(1, key) catch return;
|
||||
stmt.bindText(2, value) catch return;
|
||||
_ = stmt.step() catch |err| {
|
||||
std.log.warn("Failed to update setting '{s}': {}", .{ key, err });
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
const std = @import("std");
|
||||
const posix = std.posix;
|
||||
const Database = @import("../../storage/db.zig").Database;
|
||||
const json = @import("../json.zig");
|
||||
const response = @import("../response.zig");
|
||||
|
||||
pub fn sendStats(db: *Database, handle: posix.socket_t, allocator: std.mem.Allocator) void {
|
||||
var stmt = db.prepare("SELECT COUNT(*) FROM query_log") catch {
|
||||
response.sendServerError(handle, "Database error");
|
||||
return;
|
||||
};
|
||||
defer stmt.finalize();
|
||||
|
||||
const total_queries: i64 = blk: {
|
||||
const has_row = stmt.step() catch break :blk 0;
|
||||
break :blk if (has_row) stmt.getInt(0) else 0;
|
||||
};
|
||||
|
||||
var denied_stmt = db.prepare("SELECT COUNT(*) FROM query_log WHERE denied = 1") catch {
|
||||
response.sendServerError(handle, "Database error");
|
||||
return;
|
||||
};
|
||||
defer denied_stmt.finalize();
|
||||
|
||||
const denied_queries: i64 = blk: {
|
||||
const has_row = denied_stmt.step() catch break :blk 0;
|
||||
break :blk if (has_row) denied_stmt.getInt(0) else 0;
|
||||
};
|
||||
|
||||
const deny_percentage: f64 = if (total_queries > 0)
|
||||
@as(f64, @floatFromInt(denied_queries)) / @as(f64, @floatFromInt(total_queries)) * 100.0
|
||||
else
|
||||
0.0;
|
||||
|
||||
var w = json.JsonWriter.init(allocator);
|
||||
defer w.deinit();
|
||||
|
||||
w.beginObject() catch return;
|
||||
w.writeIntField("total_queries", total_queries) catch return;
|
||||
w.writeIntField("denied_queries", denied_queries) catch return;
|
||||
// Write percentage as string to control formatting
|
||||
w.writeKey("deny_percentage") catch return;
|
||||
var pct_buf: [16]u8 = undefined;
|
||||
const pct_str = std.fmt.bufPrint(&pct_buf, "{d:.1}", .{deny_percentage}) catch return;
|
||||
w.writeRaw(pct_str) catch return;
|
||||
w.endObject() catch return;
|
||||
|
||||
response.sendJson(handle, w.items());
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const argon2 = std.crypto.pwhash.argon2;
|
||||
|
||||
/// HTTP Basic Authentication handler using Argon2id
|
||||
///
|
||||
/// Uses OWASP-recommended Argon2id parameters for secure password hashing.
|
||||
/// PHC format string includes salt and params, so verification is self-contained.
|
||||
pub const BasicAuth = struct {
|
||||
username: []const u8,
|
||||
password_hash: [128]u8, // PHC format string
|
||||
hash_len: usize,
|
||||
enabled: bool,
|
||||
allocator: Allocator,
|
||||
|
||||
/// Create auth with plaintext password (hashes it with Argon2id)
|
||||
pub fn init(username: []const u8, password: []const u8, allocator: Allocator) BasicAuth {
|
||||
var hash_buf: [128]u8 = undefined;
|
||||
const hash = argon2.strHash(password, .{
|
||||
.allocator = allocator,
|
||||
.params = argon2.Params.owasp_2id,
|
||||
}, &hash_buf) catch {
|
||||
return BasicAuth.disabled(allocator);
|
||||
};
|
||||
|
||||
return BasicAuth{
|
||||
.username = username,
|
||||
.password_hash = hash_buf,
|
||||
.hash_len = hash.len,
|
||||
.enabled = true,
|
||||
.allocator = allocator,
|
||||
};
|
||||
}
|
||||
|
||||
/// Create disabled auth (no authentication required)
|
||||
pub fn disabled(allocator: Allocator) BasicAuth {
|
||||
return BasicAuth{
|
||||
.username = "",
|
||||
.password_hash = undefined,
|
||||
.hash_len = 0,
|
||||
.enabled = false,
|
||||
.allocator = allocator,
|
||||
};
|
||||
}
|
||||
|
||||
/// Check if authentication is required
|
||||
pub fn isEnabled(self: BasicAuth) bool {
|
||||
return self.enabled;
|
||||
}
|
||||
|
||||
/// Validate credentials from Authorization header
|
||||
/// Header format: "Basic base64(username:password)"
|
||||
pub fn validate(self: BasicAuth, auth_header: ?[]const u8) bool {
|
||||
if (!self.enabled) return true;
|
||||
|
||||
const header = auth_header orelse return false;
|
||||
|
||||
// Must start with "Basic "
|
||||
if (!std.mem.startsWith(u8, header, "Basic ")) return false;
|
||||
|
||||
const encoded = header[6..];
|
||||
|
||||
// Decode base64
|
||||
var decode_buf: [256]u8 = undefined;
|
||||
const decoded_len = std.base64.standard.Decoder.calcSizeForSlice(encoded) catch return false;
|
||||
if (decoded_len > decode_buf.len) return false;
|
||||
|
||||
std.base64.standard.Decoder.decode(&decode_buf, encoded) catch return false;
|
||||
const decoded = decode_buf[0..decoded_len];
|
||||
|
||||
// Find colon separator
|
||||
const colon_idx = std.mem.indexOfScalar(u8, decoded, ':') orelse return false;
|
||||
|
||||
const username = decoded[0..colon_idx];
|
||||
const password = decoded[colon_idx + 1 ..];
|
||||
|
||||
// Check username
|
||||
if (!std.mem.eql(u8, username, self.username)) return false;
|
||||
|
||||
// Verify password with Argon2id
|
||||
argon2.strVerify(self.password_hash[0..self.hash_len], password, .{
|
||||
.allocator = self.allocator,
|
||||
}) catch return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Generate WWW-Authenticate header for 401 response
|
||||
pub fn getWwwAuthenticateHeader() []const u8 {
|
||||
return "WWW-Authenticate: Basic realm=\"nxdns\"\r\n";
|
||||
}
|
||||
};
|
||||
|
||||
/// Extract Authorization header from HTTP request
|
||||
pub fn extractAuthHeader(request: []const u8) ?[]const u8 {
|
||||
var lines = std.mem.splitSequence(u8, request, "\r\n");
|
||||
|
||||
while (lines.next()) |line| {
|
||||
if (std.ascii.startsWithIgnoreCase(line, "authorization:")) {
|
||||
const value = std.mem.trim(u8, line[14..], " ");
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
test "BasicAuth validation" {
|
||||
const testing = std.testing;
|
||||
|
||||
var auth = BasicAuth.init("admin", "secret123", testing.allocator);
|
||||
|
||||
// Valid credentials (base64("admin:secret123") = "YWRtaW46c2VjcmV0MTIz")
|
||||
try testing.expect(auth.validate("Basic YWRtaW46c2VjcmV0MTIz"));
|
||||
|
||||
// Invalid credentials
|
||||
try testing.expect(!auth.validate("Basic YWRtaW46d3Jvbmc=")); // admin:wrong
|
||||
try testing.expect(!auth.validate("Basic dXNlcjpzZWNyZXQxMjM=")); // user:secret123
|
||||
try testing.expect(!auth.validate(null));
|
||||
try testing.expect(!auth.validate("Bearer token"));
|
||||
}
|
||||
|
||||
test "BasicAuth disabled" {
|
||||
const testing = std.testing;
|
||||
|
||||
const auth = BasicAuth.disabled(testing.allocator);
|
||||
|
||||
// Should always return true when disabled
|
||||
try testing.expect(auth.validate(null));
|
||||
try testing.expect(auth.validate("Basic anything"));
|
||||
}
|
||||
|
||||
test "extractAuthHeader" {
|
||||
const testing = std.testing;
|
||||
|
||||
const request = "GET / HTTP/1.1\r\nHost: localhost\r\nAuthorization: Basic YWRtaW46c2VjcmV0\r\n\r\n";
|
||||
const header = extractAuthHeader(request);
|
||||
|
||||
try testing.expect(header != null);
|
||||
try testing.expectEqualStrings("Basic YWRtaW46c2VjcmV0", header.?);
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>nxdns - DNS Sinkhole</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; }
|
||||
.container { max-width: 1400px; margin: 0 auto; padding: 20px; }
|
||||
header { display: flex; justify-content: space-between; align-items: center; padding: 20px 0; border-bottom: 1px solid #334155; margin-bottom: 30px; }
|
||||
h1 { font-size: 24px; font-weight: 600; }
|
||||
nav { display: flex; gap: 10px; }
|
||||
nav button { background: #334155; border: none; color: #94a3b8; padding: 8px 16px; border-radius: 6px; cursor: pointer; font-size: 14px; }
|
||||
nav button.active { background: #3b82f6; color: white; }
|
||||
nav button:hover { background: #475569; }
|
||||
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; margin-bottom: 30px; }
|
||||
.stat-card { background: #1e293b; border-radius: 12px; padding: 20px; }
|
||||
.stat-label { color: #94a3b8; font-size: 14px; margin-bottom: 8px; }
|
||||
.stat-value { font-size: 32px; font-weight: 700; }
|
||||
.stat-value.blocked { color: #ef4444; }
|
||||
.stat-value.total { color: #22c55e; }
|
||||
.section { background: #1e293b; border-radius: 12px; padding: 20px; margin-bottom: 20px; display: none; }
|
||||
.section.active { display: block; }
|
||||
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; }
|
||||
.section-title { font-size: 18px; font-weight: 600; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { padding: 12px; text-align: left; border-bottom: 1px solid #334155; }
|
||||
th { color: #94a3b8; font-weight: 500; font-size: 14px; }
|
||||
.badge { padding: 2px 8px; border-radius: 4px; font-size: 12px; }
|
||||
.badge-red { background: #ef4444; color: white; }
|
||||
.badge-green { background: #22c55e; color: white; }
|
||||
.badge-blue { background: #3b82f6; color: white; }
|
||||
.badge-yellow { background: #eab308; color: black; }
|
||||
.loading { text-align: center; padding: 40px; color: #94a3b8; }
|
||||
.form-row { display: flex; gap: 10px; margin-bottom: 15px; flex-wrap: wrap; }
|
||||
.form-row input, .form-row select { padding: 10px; border: 1px solid #334155; border-radius: 6px; background: #0f172a; color: #e2e8f0; font-size: 14px; }
|
||||
.form-row input { flex: 1; min-width: 150px; }
|
||||
.form-row select { min-width: 120px; }
|
||||
.form-row input::placeholder { color: #64748b; }
|
||||
.btn { padding: 10px 16px; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 500; }
|
||||
.btn-primary { background: #3b82f6; color: white; }
|
||||
.btn-primary:hover { background: #2563eb; }
|
||||
.btn-success { background: #22c55e; color: white; }
|
||||
.btn-success:hover { background: #16a34a; }
|
||||
.btn-danger { background: #ef4444; color: white; padding: 6px 12px; font-size: 12px; }
|
||||
.btn-danger:hover { background: #dc2626; }
|
||||
.btn-sm { padding: 6px 12px; font-size: 12px; }
|
||||
.actions { display: flex; gap: 6px; }
|
||||
.hidden { display: none !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>nxdns</h1>
|
||||
<nav>
|
||||
<button class="active" onclick="showTab('dashboard')">Dashboard</button>
|
||||
<button onclick="showTab('denylists')">Denylists</button>
|
||||
<button onclick="showTab('rules')">Rules</button>
|
||||
<button onclick="showTab('groups')">Groups</button>
|
||||
<button onclick="showTab('clients')">Clients</button>
|
||||
<button onclick="showTab('settings')">Settings</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Total Queries</div>
|
||||
<div class="stat-value total" id="total-queries">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Denied Queries</div>
|
||||
<div class="stat-value blocked" id="denied-queries">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Deny Rate</div>
|
||||
<div class="stat-value" id="deny-rate">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard -->
|
||||
<div class="section active" id="tab-dashboard">
|
||||
<div class="section-title">Recent Queries</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
<th>Domain</th>
|
||||
<th>Client</th>
|
||||
<th>Type</th>
|
||||
<th>Status</th>
|
||||
<th>DNSSEC</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="queries-table">
|
||||
<tr><td colspan="6" class="loading">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Denylists -->
|
||||
<div class="section" id="tab-denylists">
|
||||
<div class="section-header">
|
||||
<div class="section-title">Denylists</div>
|
||||
<button class="btn btn-success btn-sm" onclick="updateAllDenylists()">Update All</button>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<input type="text" id="denylist-url" placeholder="URL (e.g., https://raw.githubusercontent.com/...)" style="flex:2">
|
||||
<input type="text" id="denylist-comment" placeholder="Comment (optional)">
|
||||
<button class="btn btn-primary" onclick="addDenylist()">Add Denylist</button>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>URL</th>
|
||||
<th>Entries</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="denylists-table">
|
||||
<tr><td colspan="4" class="loading">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Rules -->
|
||||
<div class="section" id="tab-rules">
|
||||
<div class="section-title">Custom Rules</div>
|
||||
<div class="form-row">
|
||||
<input type="text" id="rule-domain" placeholder="Domain (e.g., example.com)">
|
||||
<select id="rule-action">
|
||||
<option value="deny">Deny</option>
|
||||
<option value="allow">Allow</option>
|
||||
</select>
|
||||
<select id="rule-group">
|
||||
<option value="">All Groups</option>
|
||||
</select>
|
||||
<input type="text" id="rule-comment" placeholder="Comment (optional)">
|
||||
<button class="btn btn-primary" onclick="addRule()">Add Rule</button>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Domain</th>
|
||||
<th>Action</th>
|
||||
<th>Group</th>
|
||||
<th>Comment</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="rules-table">
|
||||
<tr><td colspan="5" class="loading">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Groups -->
|
||||
<div class="section" id="tab-groups">
|
||||
<div class="section-title">Client Groups</div>
|
||||
<div class="form-row">
|
||||
<input type="text" id="group-name" placeholder="Group Name">
|
||||
<input type="text" id="group-description" placeholder="Description (optional)">
|
||||
<button class="btn btn-primary" onclick="addGroup()">Add Group</button>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="groups-table">
|
||||
<tr><td colspan="4" class="loading">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Clients -->
|
||||
<div class="section" id="tab-clients">
|
||||
<div class="section-title">Discovered Clients</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>IP Address</th>
|
||||
<th>Name</th>
|
||||
<th>Group</th>
|
||||
<th>First Seen</th>
|
||||
<th>Last Seen</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="clients-table">
|
||||
<tr><td colspan="6" class="loading">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Settings -->
|
||||
<div class="section" id="tab-settings">
|
||||
<div class="section-title">Settings</div>
|
||||
<div class="form-row">
|
||||
<label style="min-width: 200px; line-height: 40px;">Blocking Response:</label>
|
||||
<select id="setting-blocking-response" onchange="updateSetting('blocking_response', this.value)">
|
||||
<option value="zero">0.0.0.0 (Recommended)</option>
|
||||
<option value="nxdomain">NXDOMAIN</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label style="min-width: 200px; line-height: 40px;">Safe Search:</label>
|
||||
<select id="setting-safe-search" onchange="updateSetting('safe_search_enabled', this.value)">
|
||||
<option value="true">Enabled</option>
|
||||
<option value="false">Disabled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label style="min-width: 200px; line-height: 40px;">Log Retention:</label>
|
||||
<input type="text" id="setting-retention" value="30 days" style="width: 150px;" placeholder="e.g., 7 days, 1 week" onchange="updateSetting('log_retention', this.value)">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const qtypes = {1:'A',2:'NS',5:'CNAME',6:'SOA',12:'PTR',15:'MX',16:'TXT',28:'AAAA',33:'SRV',255:'ANY'};
|
||||
let groups = [];
|
||||
|
||||
function showTab(tab) {
|
||||
document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
|
||||
document.querySelectorAll('nav button').forEach(b => b.classList.remove('active'));
|
||||
document.getElementById('tab-' + tab).classList.add('active');
|
||||
event.target.classList.add('active');
|
||||
if (tab === 'denylists') loadDenylists();
|
||||
if (tab === 'rules') loadGroups().then(loadRules);
|
||||
if (tab === 'groups') loadGroups();
|
||||
if (tab === 'clients') { loadGroups(); loadClients(); }
|
||||
if (tab === 'settings') loadSettings();
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const res = await fetch('/api/stats');
|
||||
const data = await res.json();
|
||||
document.getElementById('total-queries').textContent = data.total_queries.toLocaleString();
|
||||
document.getElementById('denied-queries').textContent = data.denied_queries.toLocaleString();
|
||||
document.getElementById('deny-rate').textContent = data.deny_percentage.toFixed(1) + '%';
|
||||
} catch (e) { console.error('Stats error:', e); }
|
||||
}
|
||||
|
||||
async function loadQueries() {
|
||||
try {
|
||||
const res = await fetch('/api/queries');
|
||||
const data = await res.json();
|
||||
const tbody = document.getElementById('queries-table');
|
||||
tbody.innerHTML = data.map(q => `
|
||||
<tr>
|
||||
<td style="white-space:nowrap">${formatTimestamp(q.timestamp)}</td>
|
||||
<td>${q.domain}</td>
|
||||
<td>${q.client}</td>
|
||||
<td>${qtypes[q.qtype] || q.qtype}</td>
|
||||
<td>${q.denied ? '<span class="badge badge-red">Denied</span>' : '<span class="badge badge-green">Allowed</span>'}</td>
|
||||
<td>${q.dnssec ? '<span class="badge badge-blue">Secure</span>' : '<span style="color:#64748b">-</span>'}</td>
|
||||
</tr>
|
||||
`).join('') || '<tr><td colspan="6">No queries yet</td></tr>';
|
||||
} catch (e) { console.error('Queries error:', e); }
|
||||
}
|
||||
|
||||
async function loadDenylists() {
|
||||
try {
|
||||
const res = await fetch('/api/denylists');
|
||||
const data = await res.json();
|
||||
const tbody = document.getElementById('denylists-table');
|
||||
const hasFetching = data.some(b => b.domain_count === 0);
|
||||
tbody.innerHTML = data.map(b => `
|
||||
<tr>
|
||||
<td title="${b.url}">${truncateUrl(b.url)}${b.comment ? ' <em style="color:#94a3b8">(' + b.comment + ')</em>' : ''}</td>
|
||||
<td>${b.domain_count === 0 ? '<em style="color:#94a3b8">Fetching...</em>' : b.domain_count.toLocaleString()}</td>
|
||||
<td>${b.enabled ? '<span class="badge badge-green">Enabled</span>' : '<span class="badge badge-red">Disabled</span>'}</td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-sm" style="background:#475569;color:white" onclick="toggleDenylist(${b.id}, ${!b.enabled})">${b.enabled ? 'Disable' : 'Enable'}</button>
|
||||
<button class="btn btn-sm btn-primary" onclick="updateDenylist(${b.id})">Update</button>
|
||||
<button class="btn btn-danger" onclick="deleteDenylist(${b.id})">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('') || '<tr><td colspan="4">No denylists configured</td></tr>';
|
||||
// Auto-refresh while fetching
|
||||
if (hasFetching) setTimeout(loadDenylists, 2000);
|
||||
} catch (e) { console.error('Denylists error:', e); }
|
||||
}
|
||||
|
||||
function truncateUrl(url) {
|
||||
if (url.length <= 60) return url;
|
||||
return url.substring(0, 57) + '...';
|
||||
}
|
||||
|
||||
function formatTimestamp(ts) {
|
||||
const d = new Date(ts * 1000);
|
||||
const now = new Date();
|
||||
const time = d.toLocaleTimeString();
|
||||
// Show date only if not today
|
||||
if (d.toDateString() !== now.toDateString()) {
|
||||
return d.toLocaleDateString(undefined, {month:'short', day:'numeric'}) + ' ' + time;
|
||||
}
|
||||
return time;
|
||||
}
|
||||
|
||||
async function addDenylist() {
|
||||
const url = document.getElementById('denylist-url').value.trim();
|
||||
const comment = document.getElementById('denylist-comment').value.trim() || null;
|
||||
if (!url) return alert('Please enter a URL');
|
||||
try {
|
||||
const res = await fetch('/api/denylists', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({url, comment})});
|
||||
if (res.ok) { document.getElementById('denylist-url').value = ''; document.getElementById('denylist-comment').value = ''; loadDenylists(); }
|
||||
else alert('Error: ' + ((await res.json()).error || 'Failed'));
|
||||
} catch (e) { alert('Error adding denylist'); }
|
||||
}
|
||||
|
||||
async function toggleDenylist(id, enabled) {
|
||||
await fetch('/api/denylists/' + id, {method:'PUT', headers:{'Content-Type':'application/json'}, body:JSON.stringify({enabled})});
|
||||
loadDenylists();
|
||||
}
|
||||
|
||||
async function deleteDenylist(id) {
|
||||
if (!confirm('Delete this denylist?')) return;
|
||||
await fetch('/api/denylists/' + id, {method:'DELETE'});
|
||||
loadDenylists();
|
||||
}
|
||||
|
||||
async function updateDenylist(id) {
|
||||
try {
|
||||
const res = await fetch('/api/denylists/' + id + '/update', {method:'POST'});
|
||||
if (res.ok) {
|
||||
loadDenylists(); // Will show "Fetching..." and auto-refresh
|
||||
} else {
|
||||
const data = await res.json();
|
||||
alert('Error: ' + (data.error || 'Update failed'));
|
||||
}
|
||||
} catch (e) { alert('Error updating denylist'); }
|
||||
}
|
||||
|
||||
async function updateAllDenylists() {
|
||||
await fetch('/api/denylists/update', {method:'POST'});
|
||||
alert('Update started in background');
|
||||
}
|
||||
|
||||
async function loadGroups() {
|
||||
try {
|
||||
const res = await fetch('/api/groups');
|
||||
groups = await res.json();
|
||||
const tbody = document.getElementById('groups-table');
|
||||
tbody.innerHTML = groups.map(g => `
|
||||
<tr>
|
||||
<td>${g.id}</td>
|
||||
<td>${g.name}</td>
|
||||
<td>${g.description || '-'}</td>
|
||||
<td class="actions">
|
||||
${g.id === 0 ? '<span class="badge badge-blue">Default</span>' : `<button class="btn btn-danger" onclick="deleteGroup(${g.id})">Delete</button>`}
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
const select = document.getElementById('rule-group');
|
||||
select.innerHTML = '<option value="">All Groups</option>' + groups.map(g => `<option value="${g.id}">${g.name}</option>`).join('');
|
||||
} catch (e) { console.error('Groups error:', e); }
|
||||
}
|
||||
|
||||
async function addGroup() {
|
||||
const name = document.getElementById('group-name').value.trim();
|
||||
const description = document.getElementById('group-description').value.trim();
|
||||
if (!name) return alert('Please enter a group name');
|
||||
try {
|
||||
const res = await fetch('/api/groups', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({name, description: description || null})});
|
||||
if (res.ok) { document.getElementById('group-name').value = ''; document.getElementById('group-description').value = ''; loadGroups(); }
|
||||
else alert('Error: ' + ((await res.json()).error || 'Failed'));
|
||||
} catch (e) { alert('Error adding group'); }
|
||||
}
|
||||
|
||||
async function deleteGroup(id) {
|
||||
if (!confirm('Delete this group? Clients will be moved to default group.')) return;
|
||||
await fetch('/api/groups/' + id, {method:'DELETE'});
|
||||
loadGroups();
|
||||
}
|
||||
|
||||
async function loadRules() {
|
||||
try {
|
||||
const res = await fetch('/api/rules');
|
||||
const data = await res.json();
|
||||
const tbody = document.getElementById('rules-table');
|
||||
tbody.innerHTML = data.map(r => `
|
||||
<tr>
|
||||
<td>${r.domain}</td>
|
||||
<td>${r.action === 'allow' ? '<span class="badge badge-green">Allow</span>' : '<span class="badge badge-red">Deny</span>'}</td>
|
||||
<td>${r.group_name || 'All'}</td>
|
||||
<td>${r.comment || '-'}</td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-danger" onclick="deleteRule(${r.id})">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('') || '<tr><td colspan="5">No custom rules</td></tr>';
|
||||
} catch (e) { console.error('Rules error:', e); }
|
||||
}
|
||||
|
||||
async function addRule() {
|
||||
const domain = document.getElementById('rule-domain').value.trim();
|
||||
const action = document.getElementById('rule-action').value;
|
||||
const group_id = document.getElementById('rule-group').value || null;
|
||||
const comment = document.getElementById('rule-comment').value.trim() || null;
|
||||
if (!domain) return alert('Please enter a domain');
|
||||
try {
|
||||
const res = await fetch('/api/rules', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({domain, action, group_id: group_id ? parseInt(group_id) : null, comment})});
|
||||
if (res.ok) { document.getElementById('rule-domain').value = ''; document.getElementById('rule-comment').value = ''; loadRules(); }
|
||||
else alert('Error: ' + ((await res.json()).error || 'Failed'));
|
||||
} catch (e) { alert('Error adding rule'); }
|
||||
}
|
||||
|
||||
async function deleteRule(id) {
|
||||
if (!confirm('Delete this rule?')) return;
|
||||
await fetch('/api/rules/' + id, {method:'DELETE'});
|
||||
loadRules();
|
||||
}
|
||||
|
||||
async function loadClients() {
|
||||
try {
|
||||
const res = await fetch('/api/clients');
|
||||
const data = await res.json();
|
||||
const tbody = document.getElementById('clients-table');
|
||||
tbody.innerHTML = data.map(c => `
|
||||
<tr>
|
||||
<td>${c.ip}</td>
|
||||
<td>${c.name || '<em style="color:#64748b">unnamed</em>'}</td>
|
||||
<td>
|
||||
<select onchange="updateClient(${c.id}, 'group_id', this.value)">
|
||||
${groups.map(g => `<option value="${g.id}" ${g.id === c.group_id ? 'selected' : ''}>${g.name}</option>`).join('')}
|
||||
</select>
|
||||
</td>
|
||||
<td>${new Date(c.first_seen * 1000).toLocaleDateString()}</td>
|
||||
<td>${new Date(c.last_seen * 1000).toLocaleString()}</td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-sm" style="background:#475569;color:white" onclick="renameClient(${c.id}, '${c.name || ''}')">Rename</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('') || '<tr><td colspan="6">No clients discovered yet</td></tr>';
|
||||
} catch (e) { console.error('Clients error:', e); }
|
||||
}
|
||||
|
||||
async function updateClient(id, field, value) {
|
||||
const body = {};
|
||||
body[field] = field === 'group_id' ? parseInt(value) : value;
|
||||
await fetch('/api/clients/' + id, {method:'PUT', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
|
||||
}
|
||||
|
||||
function renameClient(id, currentName) {
|
||||
const name = prompt('Enter client name:', currentName);
|
||||
if (name !== null) updateClient(id, 'name', name).then(loadClients);
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const res = await fetch('/api/settings');
|
||||
const data = await res.json();
|
||||
document.getElementById('setting-blocking-response').value = data.blocking_response || 'zero';
|
||||
document.getElementById('setting-safe-search').value = data.safe_search_enabled || 'true';
|
||||
document.getElementById('setting-retention').value = data.log_retention || '30 days';
|
||||
} catch (e) { console.error('Settings error:', e); }
|
||||
}
|
||||
|
||||
async function updateSetting(key, value) {
|
||||
const body = {};
|
||||
body[key] = value;
|
||||
await fetch('/api/settings', {method:'PUT', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
|
||||
}
|
||||
|
||||
// Add single query to top of table (for SSE)
|
||||
function addQueryToTable(q) {
|
||||
const tbody = document.getElementById('queries-table');
|
||||
// Remove "Loading..." or "No queries yet" message
|
||||
if (tbody.querySelector('.loading') || tbody.innerHTML.includes('No queries yet')) {
|
||||
tbody.innerHTML = '';
|
||||
}
|
||||
const row = document.createElement('tr');
|
||||
row.innerHTML = `
|
||||
<td style="white-space:nowrap">${formatTimestamp(q.timestamp)}</td>
|
||||
<td>${q.domain}</td>
|
||||
<td>${q.client}</td>
|
||||
<td>${qtypes[q.qtype] || q.qtype}</td>
|
||||
<td>${q.denied ? '<span class="badge badge-red">Denied</span>' : '<span class="badge badge-green">Allowed</span>'}</td>
|
||||
<td>${q.dnssec ? '<span class="badge badge-blue">Secure</span>' : '<span style="color:#64748b">-</span>'}</td>
|
||||
`;
|
||||
tbody.insertBefore(row, tbody.firstChild);
|
||||
// Keep table size reasonable (remove old rows)
|
||||
while (tbody.children.length > 100) {
|
||||
tbody.removeChild(tbody.lastChild);
|
||||
}
|
||||
// Update stats incrementally
|
||||
const totalEl = document.getElementById('total-queries');
|
||||
const deniedEl = document.getElementById('denied-queries');
|
||||
const rateEl = document.getElementById('deny-rate');
|
||||
const total = parseInt(totalEl.textContent.replace(/,/g, '')) || 0;
|
||||
const denied = parseInt(deniedEl.textContent.replace(/,/g, '')) || 0;
|
||||
const newTotal = total + 1;
|
||||
const newDenied = denied + (q.denied ? 1 : 0);
|
||||
totalEl.textContent = newTotal.toLocaleString();
|
||||
deniedEl.textContent = newDenied.toLocaleString();
|
||||
rateEl.textContent = (newTotal > 0 ? (newDenied / newTotal * 100).toFixed(1) : 0) + '%';
|
||||
}
|
||||
|
||||
// Set up SSE for real-time query updates
|
||||
function setupSSE() {
|
||||
const events = new EventSource('/api/queries/live');
|
||||
events.onmessage = (e) => {
|
||||
try {
|
||||
const query = JSON.parse(e.data);
|
||||
addQueryToTable(query);
|
||||
} catch (err) {
|
||||
console.error('SSE parse error:', err);
|
||||
}
|
||||
};
|
||||
events.onerror = (e) => {
|
||||
console.log('SSE connection error, will reconnect...');
|
||||
};
|
||||
return events;
|
||||
}
|
||||
|
||||
// Initial load
|
||||
loadStats();
|
||||
loadQueries();
|
||||
loadDenylists();
|
||||
|
||||
// Set up real-time updates via SSE
|
||||
setupSSE();
|
||||
|
||||
// Refresh stats periodically (SSE handles queries)
|
||||
setInterval(loadStats, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,179 @@
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
/// JSON writer that builds JSON incrementally into a buffer
|
||||
pub const JsonWriter = struct {
|
||||
buffer: std.ArrayListUnmanaged(u8),
|
||||
allocator: Allocator,
|
||||
first_in_container: bool,
|
||||
|
||||
pub fn init(allocator: Allocator) JsonWriter {
|
||||
return .{
|
||||
.buffer = .{},
|
||||
.allocator = allocator,
|
||||
.first_in_container = true,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *JsonWriter) void {
|
||||
self.buffer.deinit(self.allocator);
|
||||
}
|
||||
|
||||
pub fn toOwnedSlice(self: *JsonWriter) []u8 {
|
||||
return self.buffer.toOwnedSlice(self.allocator) catch &[_]u8{};
|
||||
}
|
||||
|
||||
pub fn items(self: *JsonWriter) []const u8 {
|
||||
return self.buffer.items;
|
||||
}
|
||||
|
||||
pub fn beginArray(self: *JsonWriter) !void {
|
||||
try self.buffer.append(self.allocator, '[');
|
||||
self.first_in_container = true;
|
||||
}
|
||||
|
||||
pub fn endArray(self: *JsonWriter) !void {
|
||||
try self.buffer.append(self.allocator, ']');
|
||||
self.first_in_container = false;
|
||||
}
|
||||
|
||||
pub fn beginObject(self: *JsonWriter) !void {
|
||||
if (!self.first_in_container) {
|
||||
try self.buffer.append(self.allocator, ',');
|
||||
}
|
||||
try self.buffer.append(self.allocator, '{');
|
||||
self.first_in_container = true;
|
||||
}
|
||||
|
||||
pub fn endObject(self: *JsonWriter) !void {
|
||||
try self.buffer.append(self.allocator, '}');
|
||||
self.first_in_container = false;
|
||||
}
|
||||
|
||||
pub fn writeKey(self: *JsonWriter, key: []const u8) !void {
|
||||
if (!self.first_in_container) {
|
||||
try self.buffer.append(self.allocator, ',');
|
||||
}
|
||||
try self.buffer.append(self.allocator, '"');
|
||||
try self.buffer.appendSlice(self.allocator, key);
|
||||
try self.buffer.appendSlice(self.allocator, "\":");
|
||||
self.first_in_container = true;
|
||||
}
|
||||
|
||||
pub fn writeString(self: *JsonWriter, value: []const u8) !void {
|
||||
try self.buffer.append(self.allocator, '"');
|
||||
// Escape special characters
|
||||
for (value) |c| {
|
||||
switch (c) {
|
||||
'"' => try self.buffer.appendSlice(self.allocator, "\\\""),
|
||||
'\\' => try self.buffer.appendSlice(self.allocator, "\\\\"),
|
||||
'\n' => try self.buffer.appendSlice(self.allocator, "\\n"),
|
||||
'\r' => try self.buffer.appendSlice(self.allocator, "\\r"),
|
||||
'\t' => try self.buffer.appendSlice(self.allocator, "\\t"),
|
||||
else => try self.buffer.append(self.allocator, c),
|
||||
}
|
||||
}
|
||||
try self.buffer.append(self.allocator, '"');
|
||||
self.first_in_container = false;
|
||||
}
|
||||
|
||||
pub fn writeInt(self: *JsonWriter, value: i64) !void {
|
||||
var buf: [32]u8 = undefined;
|
||||
const str = std.fmt.bufPrint(&buf, "{d}", .{value}) catch return error.OutOfMemory;
|
||||
try self.buffer.appendSlice(self.allocator, str);
|
||||
self.first_in_container = false;
|
||||
}
|
||||
|
||||
pub fn writeBool(self: *JsonWriter, value: bool) !void {
|
||||
try self.buffer.appendSlice(self.allocator, if (value) "true" else "false");
|
||||
self.first_in_container = false;
|
||||
}
|
||||
|
||||
pub fn writeNull(self: *JsonWriter) !void {
|
||||
try self.buffer.appendSlice(self.allocator, "null");
|
||||
self.first_in_container = false;
|
||||
}
|
||||
|
||||
/// Write raw text (no escaping, for pre-formatted numbers)
|
||||
pub fn writeRaw(self: *JsonWriter, value: []const u8) !void {
|
||||
try self.buffer.appendSlice(self.allocator, value);
|
||||
self.first_in_container = false;
|
||||
}
|
||||
|
||||
/// Write a key-value pair with string value
|
||||
pub fn writeStringField(self: *JsonWriter, key: []const u8, value: []const u8) !void {
|
||||
try self.writeKey(key);
|
||||
try self.writeString(value);
|
||||
}
|
||||
|
||||
/// Write a key-value pair with optional string value
|
||||
pub fn writeOptionalStringField(self: *JsonWriter, key: []const u8, value: ?[]const u8) !void {
|
||||
try self.writeKey(key);
|
||||
if (value) |v| {
|
||||
try self.writeString(v);
|
||||
} else {
|
||||
try self.writeNull();
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a key-value pair with integer value
|
||||
pub fn writeIntField(self: *JsonWriter, key: []const u8, value: i64) !void {
|
||||
try self.writeKey(key);
|
||||
try self.writeInt(value);
|
||||
}
|
||||
|
||||
/// Write a key-value pair with boolean value
|
||||
pub fn writeBoolField(self: *JsonWriter, key: []const u8, value: bool) !void {
|
||||
try self.writeKey(key);
|
||||
try self.writeBool(value);
|
||||
}
|
||||
};
|
||||
|
||||
test "JsonWriter basic" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var w = JsonWriter.init(allocator);
|
||||
defer w.deinit();
|
||||
|
||||
try w.beginObject();
|
||||
try w.writeStringField("name", "test");
|
||||
try w.writeIntField("count", 42);
|
||||
try w.writeBoolField("active", true);
|
||||
try w.endObject();
|
||||
|
||||
try testing.expectEqualStrings("{\"name\":\"test\",\"count\":42,\"active\":true}", w.items());
|
||||
}
|
||||
|
||||
test "JsonWriter array" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var w = JsonWriter.init(allocator);
|
||||
defer w.deinit();
|
||||
|
||||
try w.beginArray();
|
||||
try w.beginObject();
|
||||
try w.writeIntField("id", 1);
|
||||
try w.endObject();
|
||||
try w.beginObject();
|
||||
try w.writeIntField("id", 2);
|
||||
try w.endObject();
|
||||
try w.endArray();
|
||||
|
||||
try testing.expectEqualStrings("[{\"id\":1},{\"id\":2}]", w.items());
|
||||
}
|
||||
|
||||
test "JsonWriter escaping" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var w = JsonWriter.init(allocator);
|
||||
defer w.deinit();
|
||||
|
||||
try w.beginObject();
|
||||
try w.writeStringField("text", "hello \"world\"\nline2");
|
||||
try w.endObject();
|
||||
|
||||
try testing.expectEqualStrings("{\"text\":\"hello \\\"world\\\"\\nline2\"}", w.items());
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
const std = @import("std");
|
||||
const posix = std.posix;
|
||||
|
||||
pub const CORS_HEADERS = "Access-Control-Allow-Origin: *\r\nAccess-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS\r\nAccess-Control-Allow-Headers: Content-Type, Authorization\r\n";
|
||||
pub const CORS_API = "Access-Control-Allow-Origin: *\r\n";
|
||||
|
||||
/// Write data to socket, logging on failure
|
||||
fn writeAll(handle: posix.socket_t, data: []const u8) void {
|
||||
_ = posix.write(handle, data) catch |err| {
|
||||
std.log.debug("HTTP write failed: {}", .{err});
|
||||
};
|
||||
}
|
||||
|
||||
/// Send a JSON response
|
||||
pub fn sendJson(handle: posix.socket_t, json: []const u8) void {
|
||||
var header_buf: [256]u8 = undefined;
|
||||
const header = std.fmt.bufPrint(&header_buf, "HTTP/1.1 200 OK\r\n{s}Content-Type: application/json\r\nContent-Length: {d}\r\n\r\n", .{
|
||||
CORS_API,
|
||||
json.len,
|
||||
}) catch return;
|
||||
|
||||
writeAll(handle, header);
|
||||
writeAll(handle, json);
|
||||
}
|
||||
|
||||
/// Send a JSON response with 201 Created status
|
||||
pub fn sendJsonCreated(handle: posix.socket_t, json: []const u8) void {
|
||||
var header_buf: [256]u8 = undefined;
|
||||
const header = std.fmt.bufPrint(&header_buf, "HTTP/1.1 201 Created\r\n{s}Content-Type: application/json\r\nContent-Length: {d}\r\n\r\n", .{
|
||||
CORS_API,
|
||||
json.len,
|
||||
}) catch return;
|
||||
|
||||
writeAll(handle, header);
|
||||
writeAll(handle, json);
|
||||
}
|
||||
|
||||
/// Send a success response with a message
|
||||
pub fn sendSuccess(handle: posix.socket_t, message: []const u8) void {
|
||||
var buf: [256]u8 = undefined;
|
||||
const body = std.fmt.bufPrint(&buf, "{{\"success\":true,\"message\":\"{s}\"}}", .{message}) catch return;
|
||||
sendJson(handle, body);
|
||||
}
|
||||
|
||||
/// Send a simple success response
|
||||
pub fn sendOk(handle: posix.socket_t) void {
|
||||
const body = "{\"success\":true}";
|
||||
sendJson(handle, body);
|
||||
}
|
||||
|
||||
/// Send an error response
|
||||
pub fn sendError(handle: posix.socket_t, status: u16, message: []const u8) void {
|
||||
const status_text = switch (status) {
|
||||
400 => "Bad Request",
|
||||
401 => "Unauthorized",
|
||||
404 => "Not Found",
|
||||
405 => "Method Not Allowed",
|
||||
500 => "Internal Server Error",
|
||||
else => "Error",
|
||||
};
|
||||
|
||||
var body_buf: [256]u8 = undefined;
|
||||
const body = std.fmt.bufPrint(&body_buf, "{{\"error\":\"{s}\"}}", .{message}) catch return;
|
||||
|
||||
var header_buf: [256]u8 = undefined;
|
||||
const header = std.fmt.bufPrint(&header_buf, "HTTP/1.1 {d} {s}\r\n{s}Content-Type: application/json\r\nContent-Length: {d}\r\n\r\n", .{
|
||||
status,
|
||||
status_text,
|
||||
CORS_API,
|
||||
body.len,
|
||||
}) catch return;
|
||||
|
||||
writeAll(handle, header);
|
||||
writeAll(handle, body);
|
||||
}
|
||||
|
||||
pub fn sendBadRequest(handle: posix.socket_t, message: []const u8) void {
|
||||
sendError(handle, 400, message);
|
||||
}
|
||||
|
||||
pub fn sendUnauthorized(handle: posix.socket_t) void {
|
||||
const body = "{\"error\":\"Unauthorized\"}";
|
||||
var buf: [256]u8 = undefined;
|
||||
const response = std.fmt.bufPrint(&buf, "HTTP/1.1 401 Unauthorized\r\n{s}WWW-Authenticate: Basic realm=\"nxdns\"\r\nContent-Type: application/json\r\nContent-Length: {d}\r\n\r\n{s}", .{
|
||||
CORS_API,
|
||||
body.len,
|
||||
body,
|
||||
}) catch return;
|
||||
writeAll(handle, response);
|
||||
}
|
||||
|
||||
pub fn sendNotFound(handle: posix.socket_t) void {
|
||||
sendError(handle, 404, "Not found");
|
||||
}
|
||||
|
||||
pub fn sendMethodNotAllowed(handle: posix.socket_t) void {
|
||||
sendError(handle, 405, "Method not allowed");
|
||||
}
|
||||
|
||||
pub fn sendServerError(handle: posix.socket_t, message: []const u8) void {
|
||||
sendError(handle, 500, message);
|
||||
}
|
||||
|
||||
/// Send HTML content
|
||||
pub fn sendHtml(handle: posix.socket_t, html: []const u8) void {
|
||||
var header_buf: [256]u8 = undefined;
|
||||
const header = std.fmt.bufPrint(&header_buf, "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {d}\r\n\r\n", .{html.len}) catch return;
|
||||
|
||||
writeAll(handle, header);
|
||||
writeAll(handle, html);
|
||||
}
|
||||
|
||||
/// Send OPTIONS response for CORS preflight
|
||||
pub fn sendOptions(handle: posix.socket_t) void {
|
||||
const response = "HTTP/1.1 204 No Content\r\n" ++ CORS_HEADERS ++ "Content-Length: 0\r\n\r\n";
|
||||
writeAll(handle, response);
|
||||
}
|
||||
@@ -0,0 +1,813 @@
|
||||
const std = @import("std");
|
||||
const net = std.net;
|
||||
const posix = std.posix;
|
||||
const Allocator = std.mem.Allocator;
|
||||
const Database = @import("../storage/db.zig").Database;
|
||||
const DenylistFetcher = @import("../filter/fetcher.zig").DenylistFetcher;
|
||||
const events = @import("../events.zig");
|
||||
const auth = @import("auth.zig");
|
||||
const logger_mod = @import("../storage/logger.zig");
|
||||
const response = @import("response.zig");
|
||||
const json = @import("json.zig");
|
||||
|
||||
// API handlers
|
||||
const stats_api = @import("api/stats.zig");
|
||||
const queries_api = @import("api/queries.zig");
|
||||
const denylists_api = @import("api/denylists.zig");
|
||||
const groups_api = @import("api/groups.zig");
|
||||
const clients_api = @import("api/clients.zig");
|
||||
const rules_api = @import("api/rules.zig");
|
||||
const settings_api = @import("api/settings.zig");
|
||||
|
||||
pub const WebServer = struct {
|
||||
listener: net.Server,
|
||||
db: *Database,
|
||||
allocator: Allocator,
|
||||
running: bool,
|
||||
basic_auth: auth.BasicAuth,
|
||||
active_connections: std.atomic.Value(u32),
|
||||
|
||||
// SSE (Server-Sent Events) client tracking
|
||||
sse_clients: std.ArrayListUnmanaged(posix.socket_t),
|
||||
sse_mutex: std.Thread.Mutex,
|
||||
|
||||
/// Maximum number of concurrent SSE clients to prevent resource exhaustion
|
||||
const MAX_SSE_CLIENTS: usize = 100;
|
||||
|
||||
/// Maximum number of concurrent HTTP connections
|
||||
const MAX_CONNECTIONS: u32 = 100;
|
||||
|
||||
/// Poll timeout for SSE keep-alive (milliseconds)
|
||||
const SSE_POLL_TIMEOUT_MS: i32 = 1000;
|
||||
|
||||
/// Poll timeout for accept loop (milliseconds)
|
||||
const ACCEPT_POLL_TIMEOUT_MS: i32 = 100;
|
||||
|
||||
/// HTTP request buffer size (limits header size)
|
||||
const HTTP_REQUEST_BUFFER_SIZE: usize = 8192;
|
||||
|
||||
/// Maximum HTTP body size (1MB)
|
||||
const MAX_BODY_SIZE: usize = 1024 * 1024;
|
||||
|
||||
pub fn init(bind_addr: net.Address, db: *Database, allocator: Allocator) !WebServer {
|
||||
const listener = try bind_addr.listen(.{
|
||||
.reuse_address = true,
|
||||
});
|
||||
|
||||
return WebServer{
|
||||
.listener = listener,
|
||||
.db = db,
|
||||
.allocator = allocator,
|
||||
.running = false,
|
||||
.basic_auth = auth.BasicAuth.disabled(allocator),
|
||||
.active_connections = std.atomic.Value(u32).init(0),
|
||||
.sse_clients = std.ArrayListUnmanaged(posix.socket_t){},
|
||||
.sse_mutex = std.Thread.Mutex{},
|
||||
};
|
||||
}
|
||||
|
||||
/// Create a logger subscriber for real-time SSE notifications
|
||||
pub fn toLoggerSubscriber(self: *WebServer) logger_mod.Subscriber {
|
||||
return logger_mod.Subscriber{
|
||||
.context = self,
|
||||
.notifyFn = broadcastQueryWrapper,
|
||||
};
|
||||
}
|
||||
|
||||
fn broadcastQueryWrapper(ctx: *anyopaque, entry: logger_mod.LogEntry) void {
|
||||
const self: *WebServer = @ptrCast(@alignCast(ctx));
|
||||
self.broadcastQuery(entry);
|
||||
}
|
||||
|
||||
/// Broadcast a query log entry to all SSE clients
|
||||
pub fn broadcastQuery(self: *WebServer, entry: logger_mod.LogEntry) void {
|
||||
// Look up client name from database (fall back to IP if not found)
|
||||
var client_name_buf: [128]u8 = undefined;
|
||||
var client_name: []const u8 = entry.client_ip;
|
||||
|
||||
var stmt = self.db.prepare("SELECT name FROM clients WHERE ip = ?") catch null;
|
||||
if (stmt) |*s| {
|
||||
defer s.finalize();
|
||||
s.bindText(1, entry.client_ip) catch {};
|
||||
if (s.step() catch false) {
|
||||
if (s.getText(0)) |name| {
|
||||
// Copy name to local buffer before statement is finalized
|
||||
const len = @min(name.len, client_name_buf.len);
|
||||
@memcpy(client_name_buf[0..len], name[0..len]);
|
||||
client_name = client_name_buf[0..len];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var buf: [1024]u8 = undefined;
|
||||
const json_data = std.fmt.bufPrint(&buf, "data: {{\"timestamp\":{d},\"domain\":\"{s}\",\"client\":\"{s}\",\"qtype\":{d},\"denied\":{s},\"status\":{d},\"reply_type\":{d},\"protocol\":{d},\"dnssec\":{s}}}\n\n", .{
|
||||
entry.timestamp,
|
||||
entry.domain,
|
||||
client_name,
|
||||
entry.qtype,
|
||||
if (entry.isDenied()) "true" else "false",
|
||||
@intFromEnum(entry.status),
|
||||
@intFromEnum(entry.reply_type),
|
||||
@intFromEnum(entry.protocol),
|
||||
if (entry.dnssec_validated) "true" else "false",
|
||||
}) catch return;
|
||||
|
||||
self.sse_mutex.lock();
|
||||
defer self.sse_mutex.unlock();
|
||||
|
||||
// Reverse iteration: safe with swapRemove (removed element is already processed)
|
||||
var i: usize = self.sse_clients.items.len;
|
||||
while (i > 0) {
|
||||
i -= 1;
|
||||
const written = posix.write(self.sse_clients.items[i], json_data) catch {
|
||||
_ = self.sse_clients.swapRemove(i);
|
||||
continue;
|
||||
};
|
||||
if (written == 0) {
|
||||
_ = self.sse_clients.swapRemove(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle SSE connection - keeps connection open for streaming
|
||||
fn handleSSE(self: *WebServer, handle: posix.socket_t) void {
|
||||
// Check client limit before accepting
|
||||
self.sse_mutex.lock();
|
||||
if (self.sse_clients.items.len >= MAX_SSE_CLIENTS) {
|
||||
self.sse_mutex.unlock();
|
||||
std.log.warn("SSE client limit reached ({d}), rejecting connection", .{MAX_SSE_CLIENTS});
|
||||
response.sendError(handle, 503, "Too many SSE clients");
|
||||
return;
|
||||
}
|
||||
self.sse_mutex.unlock();
|
||||
|
||||
const headers = "HTTP/1.1 200 OK\r\n" ++
|
||||
"Content-Type: text/event-stream\r\n" ++
|
||||
"Cache-Control: no-cache\r\n" ++
|
||||
"Connection: keep-alive\r\n" ++
|
||||
"Access-Control-Allow-Origin: *\r\n\r\n";
|
||||
_ = posix.write(handle, headers) catch |err| {
|
||||
std.log.debug("SSE connection write failed (client likely disconnected): {}", .{err});
|
||||
return;
|
||||
};
|
||||
|
||||
self.sse_mutex.lock();
|
||||
// Re-check after acquiring lock (could have changed)
|
||||
if (self.sse_clients.items.len >= MAX_SSE_CLIENTS) {
|
||||
self.sse_mutex.unlock();
|
||||
return;
|
||||
}
|
||||
self.sse_clients.append(self.allocator, handle) catch {
|
||||
self.sse_mutex.unlock();
|
||||
return;
|
||||
};
|
||||
self.sse_mutex.unlock();
|
||||
|
||||
// Keep connection alive - block until client disconnects
|
||||
var buf: [1]u8 = undefined;
|
||||
while (self.running) {
|
||||
var fds = [1]posix.pollfd{
|
||||
.{
|
||||
.fd = handle,
|
||||
.events = posix.POLL.IN,
|
||||
.revents = 0,
|
||||
},
|
||||
};
|
||||
const poll_result = posix.poll(&fds, SSE_POLL_TIMEOUT_MS) catch break;
|
||||
|
||||
if (poll_result > 0) {
|
||||
if (fds[0].revents & (posix.POLL.HUP | posix.POLL.ERR) != 0) break;
|
||||
if (fds[0].revents & posix.POLL.IN != 0) {
|
||||
const n = posix.read(handle, &buf) catch break;
|
||||
if (n == 0) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unregister client
|
||||
self.sse_mutex.lock();
|
||||
defer self.sse_mutex.unlock();
|
||||
|
||||
for (self.sse_clients.items, 0..) |client, i| {
|
||||
if (client == handle) {
|
||||
_ = self.sse_clients.swapRemove(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set HTTP Basic Auth credentials
|
||||
pub fn setAuth(self: *WebServer, username: []const u8, password: []const u8) void {
|
||||
self.basic_auth = auth.BasicAuth.init(username, password, self.allocator);
|
||||
}
|
||||
|
||||
pub fn run(self: *WebServer) !void {
|
||||
self.running = true;
|
||||
|
||||
while (self.running) {
|
||||
var fds = [1]posix.pollfd{
|
||||
.{
|
||||
.fd = self.listener.stream.handle,
|
||||
.events = posix.POLL.IN,
|
||||
.revents = 0,
|
||||
},
|
||||
};
|
||||
|
||||
const poll_result = posix.poll(&fds, ACCEPT_POLL_TIMEOUT_MS) catch |err| {
|
||||
std.log.warn("HTTP poll error: {}", .{err});
|
||||
continue;
|
||||
};
|
||||
|
||||
if (poll_result == 0) continue;
|
||||
if (fds[0].revents & posix.POLL.IN == 0) continue;
|
||||
|
||||
const conn = self.listener.accept() catch |err| {
|
||||
std.log.warn("HTTP accept error: {}", .{err});
|
||||
continue;
|
||||
};
|
||||
|
||||
// Check connection limit to prevent resource exhaustion
|
||||
if (self.active_connections.load(.acquire) >= MAX_CONNECTIONS) {
|
||||
response.sendError(conn.stream.handle, 503, "Server overloaded");
|
||||
posix.close(conn.stream.handle);
|
||||
continue;
|
||||
}
|
||||
|
||||
const thread = std.Thread.spawn(.{}, handleConnection, .{ self, conn }) catch |err| {
|
||||
std.log.warn("Failed to spawn thread: {}", .{err});
|
||||
posix.close(conn.stream.handle);
|
||||
continue;
|
||||
};
|
||||
thread.detach();
|
||||
}
|
||||
}
|
||||
|
||||
/// Read timeout for HTTP requests (5 seconds) - prevents slowloris attacks
|
||||
const REQUEST_TIMEOUT_SECS = 5;
|
||||
|
||||
fn handleConnection(self: *WebServer, conn: net.Server.Connection) void {
|
||||
_ = self.active_connections.fetchAdd(1, .monotonic);
|
||||
defer _ = self.active_connections.fetchSub(1, .monotonic);
|
||||
defer posix.close(conn.stream.handle);
|
||||
|
||||
// Set read timeout to prevent slowloris attacks
|
||||
const timeout = posix.timeval{ .sec = REQUEST_TIMEOUT_SECS, .usec = 0 };
|
||||
posix.setsockopt(conn.stream.handle, posix.SOL.SOCKET, posix.SO.RCVTIMEO, std.mem.asBytes(&timeout)) catch |err| {
|
||||
std.log.warn("Failed to set HTTP socket timeout: {}", .{err});
|
||||
};
|
||||
|
||||
var buffer: [HTTP_REQUEST_BUFFER_SIZE]u8 = undefined;
|
||||
const n = posix.read(conn.stream.handle, &buffer) catch return;
|
||||
if (n == 0) return;
|
||||
|
||||
const request = buffer[0..n];
|
||||
self.handleRequest(conn.stream.handle, request);
|
||||
}
|
||||
|
||||
fn handleRequest(self: *WebServer, handle: posix.socket_t, request: []const u8) void {
|
||||
var lines = std.mem.splitSequence(u8, request, "\r\n");
|
||||
const first_line = lines.first();
|
||||
|
||||
var parts = std.mem.splitScalar(u8, first_line, ' ');
|
||||
const method = parts.first();
|
||||
const path = parts.next() orelse "/";
|
||||
|
||||
// Find body (after \r\n\r\n)
|
||||
const body = if (std.mem.indexOf(u8, request, "\r\n\r\n")) |idx|
|
||||
request[idx + 4 ..]
|
||||
else
|
||||
"";
|
||||
|
||||
// Handle OPTIONS (CORS preflight)
|
||||
if (std.mem.eql(u8, method, "OPTIONS")) {
|
||||
response.sendOptions(handle);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check authentication for API routes
|
||||
if (self.basic_auth.isEnabled() and std.mem.startsWith(u8, path, "/api/")) {
|
||||
const auth_header = auth.extractAuthHeader(request);
|
||||
if (!self.basic_auth.validate(auth_header)) {
|
||||
response.sendUnauthorized(handle);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Route requests
|
||||
if (std.mem.eql(u8, path, "/") or std.mem.eql(u8, path, "/index.html")) {
|
||||
self.sendHtml(handle);
|
||||
} else if (std.mem.startsWith(u8, path, "/api/")) {
|
||||
self.handleApi(handle, method, path, body);
|
||||
} else {
|
||||
response.sendNotFound(handle);
|
||||
}
|
||||
}
|
||||
|
||||
fn handleApi(self: *WebServer, handle: posix.socket_t, method: []const u8, path: []const u8, body: []const u8) void {
|
||||
// Stats
|
||||
if (std.mem.eql(u8, path, "/api/stats")) {
|
||||
stats_api.sendStats(self.db, handle, self.allocator);
|
||||
return;
|
||||
}
|
||||
|
||||
// Queries
|
||||
if (std.mem.eql(u8, path, "/api/queries")) {
|
||||
queries_api.sendQueries(self.db, handle, self.allocator);
|
||||
return;
|
||||
}
|
||||
if (std.mem.eql(u8, path, "/api/queries/live")) {
|
||||
self.handleSSE(handle);
|
||||
return;
|
||||
}
|
||||
|
||||
// Denylists
|
||||
if (std.mem.eql(u8, path, "/api/denylists")) {
|
||||
if (std.mem.eql(u8, method, "GET")) {
|
||||
denylists_api.list(self.db, handle, self.allocator);
|
||||
} else if (std.mem.eql(u8, method, "POST")) {
|
||||
self.addDenylist(handle, body);
|
||||
} else {
|
||||
response.sendMethodNotAllowed(handle);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (std.mem.startsWith(u8, path, "/api/denylists/")) {
|
||||
const suffix = path[15..];
|
||||
// Check for /api/denylists/update endpoint
|
||||
if (std.mem.eql(u8, suffix, "update")) {
|
||||
if (std.mem.eql(u8, method, "POST")) {
|
||||
self.triggerDenylistUpdate(handle);
|
||||
} else {
|
||||
response.sendMethodNotAllowed(handle);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Check for /api/denylists/:id/update endpoint
|
||||
if (std.mem.endsWith(u8, suffix, "/update")) {
|
||||
const id_str = suffix[0 .. suffix.len - 7]; // Remove "/update"
|
||||
const id = std.fmt.parseInt(i64, id_str, 10) catch {
|
||||
response.sendBadRequest(handle, "Invalid denylist ID");
|
||||
return;
|
||||
};
|
||||
if (std.mem.eql(u8, method, "POST")) {
|
||||
self.updateSingleDenylist(handle, id);
|
||||
} else {
|
||||
response.sendMethodNotAllowed(handle);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Otherwise it's /api/denylists/:id
|
||||
const id = std.fmt.parseInt(i64, suffix, 10) catch {
|
||||
response.sendBadRequest(handle, "Invalid denylist ID");
|
||||
return;
|
||||
};
|
||||
if (std.mem.eql(u8, method, "DELETE")) {
|
||||
denylists_api.delete(self.db, handle, id);
|
||||
events.signalDenylistReload();
|
||||
} else if (std.mem.eql(u8, method, "PUT")) {
|
||||
denylists_api.toggle(self.db, handle, self.allocator, id, body);
|
||||
events.signalDenylistReload();
|
||||
} else {
|
||||
response.sendMethodNotAllowed(handle);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Groups
|
||||
if (std.mem.eql(u8, path, "/api/groups")) {
|
||||
if (std.mem.eql(u8, method, "GET")) {
|
||||
groups_api.list(self.db, handle, self.allocator);
|
||||
} else if (std.mem.eql(u8, method, "POST")) {
|
||||
groups_api.add(self.db, handle, self.allocator, body);
|
||||
} else {
|
||||
response.sendMethodNotAllowed(handle);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (std.mem.startsWith(u8, path, "/api/groups/")) {
|
||||
const id = std.fmt.parseInt(i64, path[12..], 10) catch {
|
||||
response.sendBadRequest(handle, "Invalid group ID");
|
||||
return;
|
||||
};
|
||||
if (std.mem.eql(u8, method, "PUT")) {
|
||||
groups_api.update(self.db, handle, self.allocator, id, body);
|
||||
} else if (std.mem.eql(u8, method, "DELETE")) {
|
||||
groups_api.delete(self.db, handle, id);
|
||||
} else {
|
||||
response.sendMethodNotAllowed(handle);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Clients
|
||||
if (std.mem.eql(u8, path, "/api/clients")) {
|
||||
if (std.mem.eql(u8, method, "GET")) {
|
||||
clients_api.list(self.db, handle, self.allocator);
|
||||
} else {
|
||||
response.sendMethodNotAllowed(handle);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (std.mem.startsWith(u8, path, "/api/clients/")) {
|
||||
const id = std.fmt.parseInt(i64, path[13..], 10) catch {
|
||||
response.sendBadRequest(handle, "Invalid client ID");
|
||||
return;
|
||||
};
|
||||
if (std.mem.eql(u8, method, "PUT")) {
|
||||
clients_api.update(self.db, handle, self.allocator, id, body);
|
||||
} else {
|
||||
response.sendMethodNotAllowed(handle);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Rules
|
||||
if (std.mem.eql(u8, path, "/api/rules")) {
|
||||
if (std.mem.eql(u8, method, "GET")) {
|
||||
rules_api.list(self.db, handle, self.allocator);
|
||||
} else if (std.mem.eql(u8, method, "POST")) {
|
||||
rules_api.add(self.db, handle, self.allocator, body);
|
||||
} else {
|
||||
response.sendMethodNotAllowed(handle);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (std.mem.startsWith(u8, path, "/api/rules/")) {
|
||||
const id = std.fmt.parseInt(i64, path[11..], 10) catch {
|
||||
response.sendBadRequest(handle, "Invalid rule ID");
|
||||
return;
|
||||
};
|
||||
if (std.mem.eql(u8, method, "DELETE")) {
|
||||
rules_api.delete(self.db, handle, id);
|
||||
} else {
|
||||
response.sendMethodNotAllowed(handle);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Settings
|
||||
if (std.mem.eql(u8, path, "/api/settings")) {
|
||||
if (std.mem.eql(u8, method, "GET")) {
|
||||
settings_api.list(self.db, handle, self.allocator);
|
||||
} else if (std.mem.eql(u8, method, "PUT")) {
|
||||
settings_api.update(self.db, handle, self.allocator, body);
|
||||
} else {
|
||||
response.sendMethodNotAllowed(handle);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Not found
|
||||
response.sendNotFound(handle);
|
||||
}
|
||||
|
||||
fn triggerDenylistUpdate(self: *WebServer, handle: posix.socket_t) void {
|
||||
// Heap-allocate the fetcher so it survives after this function returns
|
||||
const fetcher = self.allocator.create(DenylistFetcher) catch {
|
||||
response.sendServerError(handle, "Memory allocation failed");
|
||||
return;
|
||||
};
|
||||
fetcher.* = DenylistFetcher.init(self.db, self.allocator);
|
||||
|
||||
const ThreadContext = struct {
|
||||
fetcher: *DenylistFetcher,
|
||||
allocator: std.mem.Allocator,
|
||||
|
||||
fn run(ctx: *@This()) void {
|
||||
const alloc = ctx.allocator;
|
||||
const dlf = ctx.fetcher;
|
||||
defer alloc.destroy(ctx);
|
||||
defer alloc.destroy(dlf);
|
||||
|
||||
dlf.updateAll() catch |err| {
|
||||
std.log.warn("Denylist update failed: {}", .{err});
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const ctx = self.allocator.create(ThreadContext) catch {
|
||||
self.allocator.destroy(fetcher);
|
||||
response.sendServerError(handle, "Memory allocation failed");
|
||||
return;
|
||||
};
|
||||
ctx.* = .{ .fetcher = fetcher, .allocator = self.allocator };
|
||||
|
||||
const thread = std.Thread.spawn(.{}, ThreadContext.run, .{ctx}) catch {
|
||||
self.allocator.destroy(ctx);
|
||||
self.allocator.destroy(fetcher);
|
||||
response.sendServerError(handle, "Failed to start update");
|
||||
return;
|
||||
};
|
||||
thread.detach();
|
||||
|
||||
var w = json.JsonWriter.init(self.allocator);
|
||||
defer w.deinit();
|
||||
w.beginObject() catch return;
|
||||
w.writeBoolField("success", true) catch return;
|
||||
w.writeStringField("message", "Update started in background") catch return;
|
||||
w.endObject() catch return;
|
||||
|
||||
var header_buf: [256]u8 = undefined;
|
||||
const header = std.fmt.bufPrint(&header_buf, "HTTP/1.1 202 Accepted\r\n{s}Content-Type: application/json\r\nContent-Length: {d}\r\n\r\n", .{
|
||||
response.CORS_API,
|
||||
w.items().len,
|
||||
}) catch return;
|
||||
|
||||
_ = posix.write(handle, header) catch |err| {
|
||||
std.log.debug("Failed to write HTTP header: {}", .{err});
|
||||
return;
|
||||
};
|
||||
_ = posix.write(handle, w.items()) catch |err| {
|
||||
std.log.debug("Failed to write HTTP body: {}", .{err});
|
||||
};
|
||||
}
|
||||
|
||||
const AddDenylistRequest = struct {
|
||||
url: []const u8,
|
||||
comment: ?[]const u8 = null,
|
||||
};
|
||||
|
||||
/// Add a denylist and automatically fetch its domains
|
||||
fn addDenylist(self: *WebServer, handle: posix.socket_t, body: []const u8) void {
|
||||
const parsed = std.json.parseFromSlice(AddDenylistRequest, self.allocator, body, .{
|
||||
.ignore_unknown_fields = true,
|
||||
}) catch {
|
||||
response.sendBadRequest(handle, "Invalid JSON");
|
||||
return;
|
||||
};
|
||||
defer parsed.deinit();
|
||||
|
||||
const url = parsed.value.url;
|
||||
|
||||
// Basic URL validation
|
||||
if (url.len == 0 or url.len > 2048) {
|
||||
response.sendBadRequest(handle, "Invalid URL length");
|
||||
return;
|
||||
}
|
||||
if (!std.mem.startsWith(u8, url, "http://") and !std.mem.startsWith(u8, url, "https://")) {
|
||||
response.sendBadRequest(handle, "URL must start with http:// or https://");
|
||||
return;
|
||||
}
|
||||
|
||||
// Insert into database
|
||||
var stmt = self.db.prepare("INSERT INTO denylist_sources (url, comment, enabled, domain_count) VALUES (?, ?, 1, 0)") catch {
|
||||
response.sendServerError(handle, "Database error");
|
||||
return;
|
||||
};
|
||||
defer stmt.finalize();
|
||||
|
||||
stmt.bindText(1, url) catch return;
|
||||
if (parsed.value.comment) |comment| {
|
||||
stmt.bindText(2, comment) catch return;
|
||||
} else {
|
||||
stmt.bindNull(2) catch return;
|
||||
}
|
||||
|
||||
_ = stmt.step() catch {
|
||||
response.sendServerError(handle, "Insert failed (URL may already exist)");
|
||||
return;
|
||||
};
|
||||
|
||||
const id = self.db.lastInsertRowId();
|
||||
|
||||
// Link to default group
|
||||
linkGroup: {
|
||||
var group_stmt = self.db.prepare("INSERT INTO group_sources (group_id, source_id) VALUES (0, ?)") catch break :linkGroup;
|
||||
defer group_stmt.finalize();
|
||||
group_stmt.bindInt(1, id) catch break :linkGroup;
|
||||
_ = group_stmt.step() catch {};
|
||||
}
|
||||
|
||||
// Spawn background thread to fetch domains (don't block the API)
|
||||
const FetchContext = struct {
|
||||
fetcher: *DenylistFetcher,
|
||||
url: []const u8,
|
||||
id: i64,
|
||||
allocator: std.mem.Allocator,
|
||||
|
||||
fn run(ctx: *@This()) void {
|
||||
const alloc = ctx.allocator;
|
||||
const dlf = ctx.fetcher;
|
||||
const dl_url = ctx.url;
|
||||
const dl_id = ctx.id;
|
||||
defer alloc.destroy(ctx);
|
||||
defer alloc.destroy(dlf);
|
||||
defer alloc.free(dl_url);
|
||||
|
||||
const count = dlf.fetchAndUpdate(dl_id, dl_url) catch |err| {
|
||||
std.log.warn("Background fetch failed for denylist {d}: {}", .{ dl_id, err });
|
||||
return;
|
||||
};
|
||||
std.log.info("Denylist {d} fetched: {d} domains", .{ dl_id, count });
|
||||
events.signalDenylistReload();
|
||||
}
|
||||
};
|
||||
|
||||
const fetcher = self.allocator.create(DenylistFetcher) catch {
|
||||
// Denylist added, but can't start background fetch
|
||||
var w = json.JsonWriter.init(self.allocator);
|
||||
defer w.deinit();
|
||||
w.beginObject() catch return;
|
||||
w.writeIntField("id", id) catch return;
|
||||
w.writeBoolField("success", true) catch return;
|
||||
w.writeBoolField("fetching", false) catch return;
|
||||
w.endObject() catch return;
|
||||
response.sendJsonCreated(handle, w.items());
|
||||
return;
|
||||
};
|
||||
fetcher.* = DenylistFetcher.init(self.db, self.allocator);
|
||||
|
||||
const url_copy = self.allocator.dupe(u8, url) catch {
|
||||
self.allocator.destroy(fetcher);
|
||||
var w = json.JsonWriter.init(self.allocator);
|
||||
defer w.deinit();
|
||||
w.beginObject() catch return;
|
||||
w.writeIntField("id", id) catch return;
|
||||
w.writeBoolField("success", true) catch return;
|
||||
w.writeBoolField("fetching", false) catch return;
|
||||
w.endObject() catch return;
|
||||
response.sendJsonCreated(handle, w.items());
|
||||
return;
|
||||
};
|
||||
|
||||
const ctx = self.allocator.create(FetchContext) catch {
|
||||
self.allocator.free(url_copy);
|
||||
self.allocator.destroy(fetcher);
|
||||
var w = json.JsonWriter.init(self.allocator);
|
||||
defer w.deinit();
|
||||
w.beginObject() catch return;
|
||||
w.writeIntField("id", id) catch return;
|
||||
w.writeBoolField("success", true) catch return;
|
||||
w.writeBoolField("fetching", false) catch return;
|
||||
w.endObject() catch return;
|
||||
response.sendJsonCreated(handle, w.items());
|
||||
return;
|
||||
};
|
||||
ctx.* = .{ .fetcher = fetcher, .url = url_copy, .id = id, .allocator = self.allocator };
|
||||
|
||||
const thread = std.Thread.spawn(.{}, FetchContext.run, .{ctx}) catch {
|
||||
self.allocator.destroy(ctx);
|
||||
self.allocator.free(url_copy);
|
||||
self.allocator.destroy(fetcher);
|
||||
var w = json.JsonWriter.init(self.allocator);
|
||||
defer w.deinit();
|
||||
w.beginObject() catch return;
|
||||
w.writeIntField("id", id) catch return;
|
||||
w.writeBoolField("success", true) catch return;
|
||||
w.writeBoolField("fetching", false) catch return;
|
||||
w.endObject() catch return;
|
||||
response.sendJsonCreated(handle, w.items());
|
||||
return;
|
||||
};
|
||||
thread.detach();
|
||||
|
||||
// Return immediately - fetch happens in background
|
||||
var w = json.JsonWriter.init(self.allocator);
|
||||
defer w.deinit();
|
||||
w.beginObject() catch return;
|
||||
w.writeIntField("id", id) catch return;
|
||||
w.writeBoolField("success", true) catch return;
|
||||
w.writeBoolField("fetching", true) catch return;
|
||||
w.endObject() catch return;
|
||||
|
||||
response.sendJsonCreated(handle, w.items());
|
||||
}
|
||||
|
||||
fn updateSingleDenylist(self: *WebServer, handle: posix.socket_t, id: i64) void {
|
||||
var stmt = self.db.prepare("SELECT url FROM denylist_sources WHERE id = ?") catch {
|
||||
response.sendServerError(handle, "Database error");
|
||||
return;
|
||||
};
|
||||
defer stmt.finalize();
|
||||
|
||||
stmt.bindInt(1, id) catch return;
|
||||
|
||||
const has_row = stmt.step() catch {
|
||||
response.sendServerError(handle, "Database error");
|
||||
return;
|
||||
};
|
||||
if (!has_row) {
|
||||
response.sendError(handle, 404, "Denylist not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const url = stmt.getText(0) orelse {
|
||||
response.sendServerError(handle, "No URL for denylist");
|
||||
return;
|
||||
};
|
||||
|
||||
// Spawn background thread to fetch (don't block the API)
|
||||
const FetchContext = struct {
|
||||
fetcher: *DenylistFetcher,
|
||||
url: []const u8,
|
||||
id: i64,
|
||||
allocator: std.mem.Allocator,
|
||||
|
||||
fn run(ctx: *@This()) void {
|
||||
const alloc = ctx.allocator;
|
||||
const dlf = ctx.fetcher;
|
||||
const dl_url = ctx.url;
|
||||
const dl_id = ctx.id;
|
||||
defer alloc.destroy(ctx);
|
||||
defer alloc.destroy(dlf);
|
||||
defer alloc.free(dl_url);
|
||||
|
||||
const count = dlf.fetchAndUpdate(dl_id, dl_url) catch |err| {
|
||||
std.log.warn("Background update failed for denylist {d}: {}", .{ dl_id, err });
|
||||
return;
|
||||
};
|
||||
std.log.info("Denylist {d} updated: {d} domains", .{ dl_id, count });
|
||||
events.signalDenylistReload();
|
||||
}
|
||||
};
|
||||
|
||||
const fetcher = self.allocator.create(DenylistFetcher) catch {
|
||||
response.sendServerError(handle, "Memory allocation failed");
|
||||
return;
|
||||
};
|
||||
fetcher.* = DenylistFetcher.init(self.db, self.allocator);
|
||||
|
||||
const url_copy = self.allocator.dupe(u8, url) catch {
|
||||
self.allocator.destroy(fetcher);
|
||||
response.sendServerError(handle, "Memory allocation failed");
|
||||
return;
|
||||
};
|
||||
|
||||
const ctx = self.allocator.create(FetchContext) catch {
|
||||
self.allocator.free(url_copy);
|
||||
self.allocator.destroy(fetcher);
|
||||
response.sendServerError(handle, "Memory allocation failed");
|
||||
return;
|
||||
};
|
||||
ctx.* = .{ .fetcher = fetcher, .url = url_copy, .id = id, .allocator = self.allocator };
|
||||
|
||||
const thread = std.Thread.spawn(.{}, FetchContext.run, .{ctx}) catch {
|
||||
self.allocator.destroy(ctx);
|
||||
self.allocator.free(url_copy);
|
||||
self.allocator.destroy(fetcher);
|
||||
response.sendServerError(handle, "Failed to start update");
|
||||
return;
|
||||
};
|
||||
thread.detach();
|
||||
|
||||
// Return 202 Accepted - update happens in background
|
||||
var w = json.JsonWriter.init(self.allocator);
|
||||
defer w.deinit();
|
||||
w.beginObject() catch return;
|
||||
w.writeBoolField("success", true) catch return;
|
||||
w.writeStringField("message", "Update started") catch return;
|
||||
w.endObject() catch return;
|
||||
|
||||
var header_buf: [256]u8 = undefined;
|
||||
const header = std.fmt.bufPrint(&header_buf, "HTTP/1.1 202 Accepted\r\n{s}Content-Type: application/json\r\nContent-Length: {d}\r\n\r\n", .{
|
||||
response.CORS_API,
|
||||
w.items().len,
|
||||
}) catch return;
|
||||
|
||||
_ = posix.write(handle, header) catch return;
|
||||
_ = posix.write(handle, w.items()) catch {};
|
||||
}
|
||||
|
||||
fn sendHtml(self: *WebServer, handle: posix.socket_t) void {
|
||||
_ = self;
|
||||
const html = @embedFile("index.html");
|
||||
response.sendHtml(handle, html);
|
||||
}
|
||||
|
||||
pub fn stop(self: *WebServer) void {
|
||||
self.running = false;
|
||||
}
|
||||
|
||||
/// Wait for active connections to finish (with timeout)
|
||||
pub fn waitForConnections(self: *WebServer, timeout_ms: u64) void {
|
||||
const start = std.time.milliTimestamp();
|
||||
while (self.active_connections.load(.acquire) > 0) {
|
||||
const elapsed: u64 = @intCast(std.time.milliTimestamp() - start);
|
||||
if (elapsed >= timeout_ms) {
|
||||
std.log.warn("Web: {} connections still active after timeout", .{self.active_connections.load(.acquire)});
|
||||
break;
|
||||
}
|
||||
std.posix.nanosleep(0, 10 * std.time.ns_per_ms);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deinit(self: *WebServer) void {
|
||||
self.sse_clients.deinit(self.allocator);
|
||||
self.listener.deinit();
|
||||
}
|
||||
};
|
||||
|
||||
test "WebServer init" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var db = try Database.open(":memory:", allocator);
|
||||
defer db.close();
|
||||
|
||||
const addr = net.Address.initIp4(.{ 127, 0, 0, 1 }, 0);
|
||||
var server = WebServer.init(addr, &db, allocator) catch |err| {
|
||||
std.debug.print("WebServer init failed: {}\n", .{err});
|
||||
return error.TestFailed;
|
||||
};
|
||||
defer server.deinit();
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
const std = @import("std");
|
||||
const testing = std.testing;
|
||||
|
||||
const packet = @import("packet");
|
||||
const Header = @import("header").Header;
|
||||
const Name = @import("name").Name;
|
||||
const Question = @import("question").Question;
|
||||
const types = @import("types");
|
||||
|
||||
// ============================================================================
|
||||
// Real DNS packet captures for testing
|
||||
// ============================================================================
|
||||
|
||||
/// Standard A query for google.com
|
||||
/// Captured from: dig google.com A
|
||||
const GOOGLE_A_QUERY = [_]u8{
|
||||
// Header
|
||||
0xAB, 0xCD, // ID: 0xABCD
|
||||
0x01, 0x00, // Flags: standard query, RD=1
|
||||
0x00, 0x01, // QDCOUNT: 1
|
||||
0x00, 0x00, // ANCOUNT: 0
|
||||
0x00, 0x00, // NSCOUNT: 0
|
||||
0x00, 0x00, // ARCOUNT: 0
|
||||
// Question: google.com A IN
|
||||
0x06, 'g', 'o', 'o', 'g', 'l', 'e',
|
||||
0x03, 'c', 'o', 'm',
|
||||
0x00, // null terminator
|
||||
0x00, 0x01, // QTYPE: A (1)
|
||||
0x00, 0x01, // QCLASS: IN (1)
|
||||
};
|
||||
|
||||
/// AAAA query for example.org
|
||||
const EXAMPLE_AAAA_QUERY = [_]u8{
|
||||
// Header
|
||||
0x12, 0x34, // ID
|
||||
0x01, 0x00, // Flags: standard query, RD=1
|
||||
0x00, 0x01, // QDCOUNT: 1
|
||||
0x00, 0x00, // ANCOUNT: 0
|
||||
0x00, 0x00, // NSCOUNT: 0
|
||||
0x00, 0x00, // ARCOUNT: 0
|
||||
// Question: example.org AAAA IN
|
||||
0x07, 'e', 'x', 'a', 'm', 'p', 'l', 'e',
|
||||
0x03, 'o', 'r', 'g',
|
||||
0x00,
|
||||
0x00, 0x1C, // QTYPE: AAAA (28)
|
||||
0x00, 0x01, // QCLASS: IN
|
||||
};
|
||||
|
||||
/// Response with A record
|
||||
const SIMPLE_A_RESPONSE = [_]u8{
|
||||
// Header
|
||||
0xAB, 0xCD, // ID
|
||||
0x81, 0x80, // Flags: response, RD=1, RA=1
|
||||
0x00, 0x01, // QDCOUNT: 1
|
||||
0x00, 0x01, // ANCOUNT: 1
|
||||
0x00, 0x00, // NSCOUNT: 0
|
||||
0x00, 0x00, // ARCOUNT: 0
|
||||
// Question: google.com A IN (with compression)
|
||||
0x06, 'g', 'o', 'o', 'g', 'l', 'e',
|
||||
0x03, 'c', 'o', 'm',
|
||||
0x00,
|
||||
0x00, 0x01, // QTYPE: A
|
||||
0x00, 0x01, // QCLASS: IN
|
||||
// Answer: A record using compression pointer
|
||||
0xC0, 0x0C, // Name pointer to offset 12 (google.com)
|
||||
0x00, 0x01, // TYPE: A
|
||||
0x00, 0x01, // CLASS: IN
|
||||
0x00, 0x00, 0x01, 0x2C, // TTL: 300 seconds
|
||||
0x00, 0x04, // RDLENGTH: 4
|
||||
0xD8, 0x3A, 0xD3, 0x8E, // RDATA: 216.58.211.142
|
||||
};
|
||||
|
||||
/// Response with CNAME chain
|
||||
const CNAME_RESPONSE = [_]u8{
|
||||
// Header
|
||||
0x55, 0x66, // ID
|
||||
0x81, 0x80, // Flags: response, RD=1, RA=1
|
||||
0x00, 0x01, // QDCOUNT: 1
|
||||
0x00, 0x02, // ANCOUNT: 2 (CNAME + A)
|
||||
0x00, 0x00, // NSCOUNT: 0
|
||||
0x00, 0x00, // ARCOUNT: 0
|
||||
// Question: www.example.com A IN
|
||||
0x03, 'w', 'w', 'w',
|
||||
0x07, 'e', 'x', 'a', 'm', 'p', 'l', 'e',
|
||||
0x03, 'c', 'o', 'm',
|
||||
0x00,
|
||||
0x00, 0x01, // QTYPE: A
|
||||
0x00, 0x01, // QCLASS: IN
|
||||
// Answer 1: CNAME www.example.com -> example.com
|
||||
0xC0, 0x0C, // Name pointer to www.example.com
|
||||
0x00, 0x05, // TYPE: CNAME
|
||||
0x00, 0x01, // CLASS: IN
|
||||
0x00, 0x00, 0x0E, 0x10, // TTL: 3600
|
||||
0x00, 0x02, // RDLENGTH: 2 (compression pointer)
|
||||
0xC0, 0x10, // RDATA: pointer to example.com
|
||||
// Answer 2: A record for example.com
|
||||
0xC0, 0x10, // Name pointer to example.com
|
||||
0x00, 0x01, // TYPE: A
|
||||
0x00, 0x01, // CLASS: IN
|
||||
0x00, 0x00, 0x01, 0x2C, // TTL: 300
|
||||
0x00, 0x04, // RDLENGTH: 4
|
||||
0x5D, 0xB8, 0xD8, 0x22, // RDATA: 93.184.216.34
|
||||
};
|
||||
|
||||
/// Malformed packet - truncated header
|
||||
const MALFORMED_TRUNCATED = [_]u8{
|
||||
0x12, 0x34, // Only 2 bytes, header needs 12
|
||||
};
|
||||
|
||||
/// Malformed packet - invalid compression pointer (loop)
|
||||
const MALFORMED_COMPRESSION_LOOP = [_]u8{
|
||||
// Header
|
||||
0x00, 0x01,
|
||||
0x01, 0x00,
|
||||
0x00, 0x01,
|
||||
0x00, 0x00,
|
||||
0x00, 0x00,
|
||||
0x00, 0x00,
|
||||
// Question with self-referencing pointer
|
||||
0xC0, 0x0C, // Points to itself
|
||||
0x00, 0x01,
|
||||
0x00, 0x01,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Header Tests
|
||||
// ============================================================================
|
||||
|
||||
test "Header.parse - standard query" {
|
||||
const h = try Header.parse(&GOOGLE_A_QUERY);
|
||||
|
||||
try testing.expectEqual(@as(u16, 0xABCD), h.id);
|
||||
try testing.expect(!h.qr); // Query, not response
|
||||
try testing.expectEqual(types.OpCode.Query, h.opcode);
|
||||
try testing.expect(!h.aa); // Not authoritative
|
||||
try testing.expect(!h.tc); // Not truncated
|
||||
try testing.expect(h.rd); // Recursion desired
|
||||
try testing.expect(!h.ra); // Recursion not available (query)
|
||||
try testing.expectEqual(types.RCode.NoError, h.rcode);
|
||||
try testing.expectEqual(@as(u16, 1), h.qdcount);
|
||||
try testing.expectEqual(@as(u16, 0), h.ancount);
|
||||
try testing.expectEqual(@as(u16, 0), h.nscount);
|
||||
try testing.expectEqual(@as(u16, 0), h.arcount);
|
||||
}
|
||||
|
||||
test "Header.parse - standard response" {
|
||||
const h = try Header.parse(&SIMPLE_A_RESPONSE);
|
||||
|
||||
try testing.expectEqual(@as(u16, 0xABCD), h.id);
|
||||
try testing.expect(h.qr); // Response
|
||||
try testing.expect(h.rd); // RD copied from query
|
||||
try testing.expect(h.ra); // Recursion available
|
||||
try testing.expectEqual(@as(u16, 1), h.qdcount);
|
||||
try testing.expectEqual(@as(u16, 1), h.ancount);
|
||||
}
|
||||
|
||||
test "Header.parse - buffer too small" {
|
||||
const result = Header.parse(&MALFORMED_TRUNCATED);
|
||||
try testing.expectError(error.BufferTooSmall, result);
|
||||
}
|
||||
|
||||
test "Header.encode - roundtrip" {
|
||||
const original = try Header.parse(&GOOGLE_A_QUERY);
|
||||
|
||||
var buf: [12]u8 = undefined;
|
||||
original.encode(&buf);
|
||||
|
||||
const decoded = try Header.parse(&buf);
|
||||
try testing.expectEqual(original.id, decoded.id);
|
||||
try testing.expectEqual(original.qr, decoded.qr);
|
||||
try testing.expectEqual(original.opcode, decoded.opcode);
|
||||
try testing.expectEqual(original.rd, decoded.rd);
|
||||
try testing.expectEqual(original.qdcount, decoded.qdcount);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Name Tests
|
||||
// ============================================================================
|
||||
|
||||
test "Name.parse - simple domain" {
|
||||
const allocator = testing.allocator;
|
||||
const buffer = [_]u8{ 0x06, 'g', 'o', 'o', 'g', 'l', 'e', 0x03, 'c', 'o', 'm', 0x00 };
|
||||
|
||||
const result = try Name.parse(&buffer, &buffer, allocator);
|
||||
defer result.name.deinit();
|
||||
|
||||
try testing.expectEqual(@as(usize, 12), result.bytes_read);
|
||||
|
||||
var str_buf: [256]u8 = undefined;
|
||||
const str = result.name.toStringBuf(&str_buf).?;
|
||||
try testing.expectEqualStrings("google.com", str);
|
||||
}
|
||||
|
||||
test "Name.parse - root domain" {
|
||||
const allocator = testing.allocator;
|
||||
const buffer = [_]u8{0x00}; // Just null byte = root
|
||||
|
||||
const result = try Name.parse(&buffer, &buffer, allocator);
|
||||
defer result.name.deinit();
|
||||
|
||||
try testing.expectEqual(@as(usize, 1), result.bytes_read);
|
||||
|
||||
var str_buf: [256]u8 = undefined;
|
||||
const str = result.name.toStringBuf(&str_buf).?;
|
||||
try testing.expectEqualStrings("", str);
|
||||
}
|
||||
|
||||
test "Name.parse - compression pointer" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
// Full packet with question followed by answer using compression
|
||||
const result = try Name.parse(SIMPLE_A_RESPONSE[27..], &SIMPLE_A_RESPONSE, allocator);
|
||||
defer result.name.deinit();
|
||||
|
||||
var str_buf: [256]u8 = undefined;
|
||||
const str = result.name.toStringBuf(&str_buf).?;
|
||||
try testing.expectEqualStrings("google.com", str);
|
||||
}
|
||||
|
||||
test "Name.parse - max label length (63)" {
|
||||
const allocator = testing.allocator;
|
||||
var buffer: [67]u8 = undefined;
|
||||
buffer[0] = 63; // Label length = max
|
||||
for (1..64) |i| {
|
||||
buffer[i] = 'a';
|
||||
}
|
||||
buffer[64] = 0x03;
|
||||
buffer[65] = 'c';
|
||||
buffer[66] = 'o';
|
||||
// Would need more bytes for full domain, but testing max label
|
||||
|
||||
// This should work (63 is max label length)
|
||||
const result = Name.parse(buffer[0..67], buffer[0..67], allocator);
|
||||
if (result) |r| {
|
||||
r.name.deinit();
|
||||
} else |_| {
|
||||
// May fail due to incomplete buffer, which is fine
|
||||
}
|
||||
}
|
||||
|
||||
test "Name.parse - label too long (64+)" {
|
||||
const allocator = testing.allocator;
|
||||
var buffer: [68]u8 = undefined;
|
||||
buffer[0] = 64; // Label length > 63 is invalid (and not a pointer)
|
||||
@memset(buffer[1..65], 'a');
|
||||
buffer[65] = 0x00;
|
||||
|
||||
const result = Name.parse(&buffer, &buffer, allocator);
|
||||
try testing.expectError(error.InvalidLabel, result);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Question Tests
|
||||
// ============================================================================
|
||||
|
||||
test "Question.parse - A record query" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
const result = try Question.parse(GOOGLE_A_QUERY[12..], &GOOGLE_A_QUERY, allocator);
|
||||
defer result.question.deinit();
|
||||
|
||||
try testing.expectEqual(types.QType.A, result.question.qtype);
|
||||
try testing.expectEqual(types.QClass.IN, result.question.qclass);
|
||||
|
||||
var str_buf: [256]u8 = undefined;
|
||||
const domain = result.question.name.toStringBuf(&str_buf).?;
|
||||
try testing.expectEqualStrings("google.com", domain);
|
||||
}
|
||||
|
||||
test "Question.parse - AAAA record query" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
const result = try Question.parse(EXAMPLE_AAAA_QUERY[12..], &EXAMPLE_AAAA_QUERY, allocator);
|
||||
defer result.question.deinit();
|
||||
|
||||
try testing.expectEqual(types.QType.AAAA, result.question.qtype);
|
||||
try testing.expectEqual(types.QClass.IN, result.question.qclass);
|
||||
|
||||
var str_buf: [256]u8 = undefined;
|
||||
const domain = result.question.name.toStringBuf(&str_buf).?;
|
||||
try testing.expectEqualStrings("example.org", domain);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Packet Tests
|
||||
// ============================================================================
|
||||
|
||||
test "Packet.parse - simple query" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var pkt = try packet.Packet.parse(&GOOGLE_A_QUERY, allocator);
|
||||
defer pkt.deinit();
|
||||
|
||||
try testing.expectEqual(@as(u16, 0xABCD), pkt.header.id);
|
||||
try testing.expect(!pkt.header.qr);
|
||||
try testing.expectEqual(@as(usize, 1), pkt.questions.len);
|
||||
try testing.expectEqual(@as(usize, 0), pkt.answers.len);
|
||||
|
||||
var str_buf: [256]u8 = undefined;
|
||||
const domain = pkt.questions[0].name.toStringBuf(&str_buf).?;
|
||||
try testing.expectEqualStrings("google.com", domain);
|
||||
try testing.expectEqual(types.QType.A, pkt.questions[0].qtype);
|
||||
}
|
||||
|
||||
test "Packet.parse - response with A record" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var pkt = try packet.Packet.parse(&SIMPLE_A_RESPONSE, allocator);
|
||||
defer pkt.deinit();
|
||||
|
||||
try testing.expect(pkt.header.qr);
|
||||
try testing.expectEqual(@as(usize, 1), pkt.questions.len);
|
||||
try testing.expectEqual(@as(usize, 1), pkt.answers.len);
|
||||
|
||||
// Check the A record
|
||||
const answer = pkt.answers[0];
|
||||
try testing.expectEqual(types.QType.A, answer.rtype);
|
||||
try testing.expectEqual(@as(u32, 300), answer.ttl);
|
||||
|
||||
// Verify IP address
|
||||
const ip = answer.getA().?;
|
||||
try testing.expectEqual([4]u8{ 0xD8, 0x3A, 0xD3, 0x8E }, ip);
|
||||
}
|
||||
|
||||
test "Packet.parse - CNAME chain" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var pkt = try packet.Packet.parse(&CNAME_RESPONSE, allocator);
|
||||
defer pkt.deinit();
|
||||
|
||||
try testing.expectEqual(@as(usize, 2), pkt.answers.len);
|
||||
|
||||
// First answer should be CNAME
|
||||
try testing.expectEqual(types.QType.CNAME, pkt.answers[0].rtype);
|
||||
|
||||
// Second answer should be A record
|
||||
try testing.expectEqual(types.QType.A, pkt.answers[1].rtype);
|
||||
|
||||
const ip = pkt.answers[1].getA().?;
|
||||
try testing.expectEqual([4]u8{ 0x5D, 0xB8, 0xD8, 0x22 }, ip);
|
||||
}
|
||||
|
||||
test "Packet.parse - truncated packet" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
const result = packet.Packet.parse(&MALFORMED_TRUNCATED, allocator);
|
||||
try testing.expectError(error.HeaderParseError, result);
|
||||
}
|
||||
|
||||
test "Packet.encode - roundtrip" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var original = try packet.Packet.parse(&GOOGLE_A_QUERY, allocator);
|
||||
defer original.deinit();
|
||||
|
||||
var buf: [512]u8 = undefined;
|
||||
const encoded_len = try original.encode(&buf);
|
||||
|
||||
var decoded = try packet.Packet.parse(buf[0..encoded_len], allocator);
|
||||
defer decoded.deinit();
|
||||
|
||||
try testing.expectEqual(original.header.id, decoded.header.id);
|
||||
try testing.expectEqual(original.questions.len, decoded.questions.len);
|
||||
}
|
||||
|
||||
test "Packet.createBlockedResponse - creates valid response" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var query = try packet.Packet.parse(&GOOGLE_A_QUERY, allocator);
|
||||
defer query.deinit();
|
||||
|
||||
var response = try packet.Packet.createBlockedResponse(&query, allocator);
|
||||
defer response.deinit();
|
||||
|
||||
try testing.expect(response.header.qr); // Is response
|
||||
try testing.expectEqual(query.header.id, response.header.id);
|
||||
try testing.expectEqual(@as(usize, 1), response.answers.len);
|
||||
|
||||
// Should return 0.0.0.0 for blocked
|
||||
const ip = response.answers[0].getA().?;
|
||||
try testing.expectEqual([4]u8{ 0, 0, 0, 0 }, ip);
|
||||
}
|
||||
|
||||
test "Packet.createNxdomainResponse - creates valid NXDOMAIN" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var query = try packet.Packet.parse(&GOOGLE_A_QUERY, allocator);
|
||||
defer query.deinit();
|
||||
|
||||
var response = try packet.Packet.createNxdomainResponse(&query, allocator);
|
||||
defer response.deinit();
|
||||
|
||||
try testing.expect(response.header.qr);
|
||||
try testing.expectEqual(types.RCode.NXDomain, response.header.rcode);
|
||||
try testing.expectEqual(@as(usize, 0), response.answers.len);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Edge Cases
|
||||
// ============================================================================
|
||||
|
||||
test "DNS max name length (253 chars)" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
// Build a name with max length: 63.63.63.63 = 253 chars + labels
|
||||
var buffer: [512]u8 = undefined;
|
||||
var pos: usize = 12; // Skip header
|
||||
|
||||
// Add 4 labels of 63 chars each
|
||||
for (0..4) |_| {
|
||||
buffer[pos] = 63;
|
||||
pos += 1;
|
||||
@memset(buffer[pos .. pos + 63], 'a');
|
||||
pos += 63;
|
||||
}
|
||||
buffer[pos] = 0; // Null terminator
|
||||
pos += 1;
|
||||
|
||||
// Add qtype and qclass
|
||||
buffer[pos] = 0x00;
|
||||
buffer[pos + 1] = 0x01;
|
||||
buffer[pos + 2] = 0x00;
|
||||
buffer[pos + 3] = 0x01;
|
||||
pos += 4;
|
||||
|
||||
// Set up header
|
||||
@memset(buffer[0..12], 0);
|
||||
buffer[2] = 0x01; // RD=1
|
||||
buffer[5] = 0x01; // QDCOUNT=1
|
||||
|
||||
const pkt_result = packet.Packet.parse(buffer[0..pos], allocator);
|
||||
// This may fail due to name being too long (4*63=252 + dots)
|
||||
if (pkt_result) |*pkt| {
|
||||
pkt.deinit();
|
||||
} else |_| {}
|
||||
}
|
||||
|
||||
test "DNS various query types" {
|
||||
const allocator = testing.allocator;
|
||||
const query_types = [_]types.QType{ .A, .AAAA, .CNAME, .MX, .NS, .TXT, .SOA, .PTR, .SRV };
|
||||
|
||||
for (query_types) |qtype| {
|
||||
var buf: [64]u8 = undefined;
|
||||
@memset(buf[0..12], 0);
|
||||
buf[2] = 0x01; // RD
|
||||
buf[5] = 0x01; // QDCOUNT
|
||||
|
||||
// Simple question: a.b
|
||||
buf[12] = 0x01;
|
||||
buf[13] = 'a';
|
||||
buf[14] = 0x01;
|
||||
buf[15] = 'b';
|
||||
buf[16] = 0x00;
|
||||
|
||||
const qtype_val = @intFromEnum(qtype);
|
||||
buf[17] = @intCast((qtype_val >> 8) & 0xFF);
|
||||
buf[18] = @intCast(qtype_val & 0xFF);
|
||||
buf[19] = 0x00;
|
||||
buf[20] = 0x01; // CLASS IN
|
||||
|
||||
var pkt = try packet.Packet.parse(buf[0..21], allocator);
|
||||
defer pkt.deinit();
|
||||
|
||||
try testing.expectEqual(qtype, pkt.questions[0].qtype);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,917 @@
|
||||
const std = @import("std");
|
||||
const testing = std.testing;
|
||||
|
||||
const handler_mod = @import("handler");
|
||||
const rate_limiter = @import("rate_limiter");
|
||||
const cache = @import("cache");
|
||||
const blocklist_mod = @import("blocklist");
|
||||
const packet = @import("packet");
|
||||
const types = @import("types");
|
||||
const Name = @import("name").Name;
|
||||
|
||||
// ============================================================================
|
||||
// Test DNS Query/Response Packets
|
||||
// ============================================================================
|
||||
|
||||
/// Standard A query for example.com
|
||||
fn createTestQuery() [29]u8 {
|
||||
return [_]u8{
|
||||
// Header
|
||||
0x00, 0x01, // ID
|
||||
0x01, 0x00, // Flags: standard query, RD=1
|
||||
0x00, 0x01, // QDCOUNT: 1
|
||||
0x00, 0x00, // ANCOUNT: 0
|
||||
0x00, 0x00, // NSCOUNT: 0
|
||||
0x00, 0x00, // ARCOUNT: 0
|
||||
// Question: example.com A IN
|
||||
0x07, 'e', 'x', 'a', 'm', 'p', 'l', 'e',
|
||||
0x03, 'c', 'o', 'm',
|
||||
0x00, // null
|
||||
0x00, 0x01, // TYPE = A
|
||||
0x00, 0x01, // CLASS = IN
|
||||
};
|
||||
}
|
||||
|
||||
/// Create a query for a specific domain
|
||||
fn createQueryForDomain(domain: []const u8, buf: *[512]u8) usize {
|
||||
// Header
|
||||
buf[0] = 0x00;
|
||||
buf[1] = 0x02; // ID = 2
|
||||
buf[2] = 0x01;
|
||||
buf[3] = 0x00; // RD=1
|
||||
buf[4] = 0x00;
|
||||
buf[5] = 0x01; // QDCOUNT=1
|
||||
buf[6] = 0x00;
|
||||
buf[7] = 0x00;
|
||||
buf[8] = 0x00;
|
||||
buf[9] = 0x00;
|
||||
buf[10] = 0x00;
|
||||
buf[11] = 0x00;
|
||||
|
||||
// Question section - encode domain name
|
||||
var pos: usize = 12;
|
||||
|
||||
// Split domain by dots and encode labels
|
||||
var iter = std.mem.splitScalar(u8, domain, '.');
|
||||
while (iter.next()) |label| {
|
||||
if (label.len > 63 or label.len == 0) continue;
|
||||
buf[pos] = @intCast(label.len);
|
||||
pos += 1;
|
||||
@memcpy(buf[pos..][0..label.len], label);
|
||||
pos += label.len;
|
||||
}
|
||||
buf[pos] = 0; // Null terminator
|
||||
pos += 1;
|
||||
|
||||
// QTYPE = A
|
||||
buf[pos] = 0x00;
|
||||
buf[pos + 1] = 0x01;
|
||||
// QCLASS = IN
|
||||
buf[pos + 2] = 0x00;
|
||||
buf[pos + 3] = 0x01;
|
||||
pos += 4;
|
||||
|
||||
return pos;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mock Upstream for Testing
|
||||
// ============================================================================
|
||||
|
||||
const MockUpstream = struct {
|
||||
response: ?[]const u8,
|
||||
allocator: std.mem.Allocator,
|
||||
call_count: usize = 0,
|
||||
|
||||
pub fn init(response: ?[]const u8, allocator: std.mem.Allocator) MockUpstream {
|
||||
return .{
|
||||
.response = response,
|
||||
.allocator = allocator,
|
||||
.call_count = 0,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn toHandlerUpstream(self: *MockUpstream) handler_mod.Upstream {
|
||||
return .{
|
||||
.context = self,
|
||||
.queryFn = queryWrapper,
|
||||
};
|
||||
}
|
||||
|
||||
fn queryWrapper(ctx: *anyopaque, _: []const u8, allocator: std.mem.Allocator) ?[]const u8 {
|
||||
const self: *MockUpstream = @ptrCast(@alignCast(ctx));
|
||||
self.call_count += 1;
|
||||
if (self.response) |r| {
|
||||
return allocator.dupe(u8, r) catch null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Handler Tests
|
||||
// ============================================================================
|
||||
|
||||
test "Handler - returns SERVFAIL without upstream" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var handler = handler_mod.Handler.init(allocator);
|
||||
const query = createTestQuery();
|
||||
const addr = std.net.Address.initIp4([4]u8{ 127, 0, 0, 1 }, 12345);
|
||||
|
||||
const response = handler.handle(&query, addr, allocator);
|
||||
|
||||
if (response) |r| {
|
||||
defer allocator.free(r);
|
||||
// Should be at least header size
|
||||
try testing.expect(r.len >= types.DNS_HEADER_SIZE);
|
||||
// Check RCODE is SERVFAIL (2) - in flags byte
|
||||
const rcode = r[3] & 0x0F;
|
||||
try testing.expectEqual(@as(u8, 2), rcode);
|
||||
}
|
||||
}
|
||||
|
||||
test "Handler - cache integration" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var dns_cache = cache.DnsCache.init(allocator);
|
||||
defer dns_cache.deinit();
|
||||
|
||||
var handler = handler_mod.Handler.init(allocator);
|
||||
handler.setCache(dns_cache.toHandlerCache());
|
||||
|
||||
// Pre-populate cache
|
||||
const cached_response = [_]u8{
|
||||
0x00, 0x01, // ID (will be overwritten)
|
||||
0x81, 0x80, // Flags: response
|
||||
0x00, 0x01, // QDCOUNT
|
||||
0x00, 0x01, // ANCOUNT
|
||||
0x00, 0x00, // NSCOUNT
|
||||
0x00, 0x00, // ARCOUNT
|
||||
// Question
|
||||
0x07, 'e', 'x', 'a', 'm', 'p', 'l', 'e',
|
||||
0x03, 'c', 'o', 'm', 0x00,
|
||||
0x00, 0x01, 0x00, 0x01,
|
||||
// Answer
|
||||
0xC0, 0x0C, // Compression pointer
|
||||
0x00, 0x01, // TYPE A
|
||||
0x00, 0x01, // CLASS IN
|
||||
0x00, 0x00, 0x01, 0x2C, // TTL 300
|
||||
0x00, 0x04, // RDLENGTH
|
||||
0x01, 0x02, 0x03, 0x04, // IP: 1.2.3.4
|
||||
};
|
||||
|
||||
dns_cache.put("example.com", types.QType.A, &cached_response, 300);
|
||||
|
||||
const query = createTestQuery();
|
||||
const addr = std.net.Address.initIp4([4]u8{ 127, 0, 0, 1 }, 12345);
|
||||
|
||||
const response = handler.handle(&query, addr, allocator);
|
||||
try testing.expect(response != null);
|
||||
|
||||
if (response) |r| {
|
||||
defer allocator.free(r);
|
||||
// ID should be updated to match query
|
||||
try testing.expectEqual(@as(u8, 0x00), r[0]);
|
||||
try testing.expectEqual(@as(u8, 0x01), r[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Rate Limiter Tests
|
||||
// ============================================================================
|
||||
|
||||
test "RateLimiter - allows requests under limit" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var limiter = rate_limiter.RateLimiter.initWithConfig(allocator, .{
|
||||
.max_qps = 10,
|
||||
.window_ms = 1000,
|
||||
});
|
||||
defer limiter.deinit();
|
||||
|
||||
// Should allow first 10 requests
|
||||
for (0..10) |_| {
|
||||
try testing.expect(limiter.checkRequest("192.168.1.1"));
|
||||
}
|
||||
}
|
||||
|
||||
test "RateLimiter - blocks requests over limit" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var limiter = rate_limiter.RateLimiter.initWithConfig(allocator, .{
|
||||
.max_qps = 5,
|
||||
.window_ms = 1000,
|
||||
});
|
||||
defer limiter.deinit();
|
||||
|
||||
// First 5 should be allowed
|
||||
for (0..5) |_| {
|
||||
try testing.expect(limiter.checkRequest("10.0.0.1"));
|
||||
}
|
||||
|
||||
// 6th should be blocked
|
||||
try testing.expect(!limiter.checkRequest("10.0.0.1"));
|
||||
}
|
||||
|
||||
test "RateLimiter - tracks clients independently" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var limiter = rate_limiter.RateLimiter.initWithConfig(allocator, .{
|
||||
.max_qps = 2,
|
||||
.window_ms = 1000,
|
||||
});
|
||||
defer limiter.deinit();
|
||||
|
||||
// Client A uses quota
|
||||
try testing.expect(limiter.checkRequest("192.168.1.1"));
|
||||
try testing.expect(limiter.checkRequest("192.168.1.1"));
|
||||
try testing.expect(!limiter.checkRequest("192.168.1.1")); // Blocked
|
||||
|
||||
// Client B still has quota
|
||||
try testing.expect(limiter.checkRequest("192.168.1.2"));
|
||||
try testing.expect(limiter.checkRequest("192.168.1.2"));
|
||||
try testing.expect(!limiter.checkRequest("192.168.1.2")); // Blocked
|
||||
}
|
||||
|
||||
test "RateLimiter - can be disabled" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var limiter = rate_limiter.RateLimiter.initWithConfig(allocator, .{
|
||||
.max_qps = 1,
|
||||
.enabled = false,
|
||||
});
|
||||
defer limiter.deinit();
|
||||
|
||||
// All requests allowed when disabled
|
||||
for (0..100) |_| {
|
||||
try testing.expect(limiter.checkRequest("any.ip"));
|
||||
}
|
||||
}
|
||||
|
||||
test "RateLimiter - statistics tracking" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var limiter = rate_limiter.RateLimiter.initWithConfig(allocator, .{
|
||||
.max_qps = 3,
|
||||
.window_ms = 1000,
|
||||
});
|
||||
defer limiter.deinit();
|
||||
|
||||
_ = limiter.checkRequest("1.1.1.1"); // allowed
|
||||
_ = limiter.checkRequest("1.1.1.1"); // allowed
|
||||
_ = limiter.checkRequest("1.1.1.1"); // allowed
|
||||
_ = limiter.checkRequest("1.1.1.1"); // blocked
|
||||
_ = limiter.checkRequest("2.2.2.2"); // allowed (different client)
|
||||
|
||||
const stats = limiter.getStats();
|
||||
try testing.expectEqual(@as(u64, 5), stats.total_requests);
|
||||
try testing.expectEqual(@as(u64, 1), stats.rate_limited);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Cache Tests
|
||||
// ============================================================================
|
||||
|
||||
test "DnsCache - stores and retrieves entries" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var dns_cache = cache.DnsCache.init(allocator);
|
||||
defer dns_cache.deinit();
|
||||
|
||||
const response = "test response data";
|
||||
dns_cache.put("test.com", types.QType.A, response, 300);
|
||||
|
||||
const cached = try dns_cache.getCopy("test.com", types.QType.A);
|
||||
defer if (cached) |c| allocator.free(c);
|
||||
|
||||
try testing.expect(cached != null);
|
||||
try testing.expectEqualStrings(response, cached.?);
|
||||
}
|
||||
|
||||
test "DnsCache - returns null for missing entries" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var dns_cache = cache.DnsCache.init(allocator);
|
||||
defer dns_cache.deinit();
|
||||
|
||||
const cached = try dns_cache.getCopy("nonexistent.com", types.QType.A);
|
||||
try testing.expect(cached == null);
|
||||
}
|
||||
|
||||
test "DnsCache - separates entries by qtype" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var dns_cache = cache.DnsCache.init(allocator);
|
||||
defer dns_cache.deinit();
|
||||
|
||||
dns_cache.put("example.com", types.QType.A, "A record", 300);
|
||||
dns_cache.put("example.com", types.QType.AAAA, "AAAA record", 300);
|
||||
|
||||
const a_cached = try dns_cache.getCopy("example.com", types.QType.A);
|
||||
defer if (a_cached) |c| allocator.free(c);
|
||||
|
||||
const aaaa_cached = try dns_cache.getCopy("example.com", types.QType.AAAA);
|
||||
defer if (aaaa_cached) |c| allocator.free(c);
|
||||
|
||||
try testing.expectEqualStrings("A record", a_cached.?);
|
||||
try testing.expectEqualStrings("AAAA record", aaaa_cached.?);
|
||||
}
|
||||
|
||||
test "DnsCache - respects max entries limit" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var dns_cache = cache.DnsCache.initWithConfig(allocator, 3, 60, 86400);
|
||||
defer dns_cache.deinit();
|
||||
|
||||
// Add 4 entries, should evict oldest
|
||||
dns_cache.put("one.com", types.QType.A, "1", 300);
|
||||
dns_cache.put("two.com", types.QType.A, "2", 300);
|
||||
dns_cache.put("three.com", types.QType.A, "3", 300);
|
||||
dns_cache.put("four.com", types.QType.A, "4", 300);
|
||||
|
||||
const stats = dns_cache.getStats();
|
||||
try testing.expect(stats.entry_count <= 3);
|
||||
}
|
||||
|
||||
test "DnsCache - updates existing entries" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var dns_cache = cache.DnsCache.init(allocator);
|
||||
defer dns_cache.deinit();
|
||||
|
||||
dns_cache.put("update.com", types.QType.A, "original", 300);
|
||||
dns_cache.put("update.com", types.QType.A, "updated", 300);
|
||||
|
||||
const cached = try dns_cache.getCopy("update.com", types.QType.A);
|
||||
defer if (cached) |c| allocator.free(c);
|
||||
|
||||
try testing.expectEqualStrings("updated", cached.?);
|
||||
}
|
||||
|
||||
test "DnsCache - does not cache TTL=0 responses" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var dns_cache = cache.DnsCache.init(allocator);
|
||||
defer dns_cache.deinit();
|
||||
|
||||
// Try to cache with TTL=0 - should NOT be cached per RFC 2308
|
||||
dns_cache.put("nocache.com", types.QType.A, "should not cache", 0);
|
||||
|
||||
const cached = try dns_cache.getCopy("nocache.com", types.QType.A);
|
||||
try testing.expect(cached == null);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Handler with Rate Limiting Integration
|
||||
// ============================================================================
|
||||
|
||||
test "Handler - respects rate limiter" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var limiter = rate_limiter.RateLimiter.initWithConfig(allocator, .{
|
||||
.max_qps = 2,
|
||||
.window_ms = 1000,
|
||||
});
|
||||
defer limiter.deinit();
|
||||
|
||||
var handler = handler_mod.Handler.init(allocator);
|
||||
handler.setRateLimiter(&limiter);
|
||||
|
||||
const query = createTestQuery();
|
||||
const addr = std.net.Address.initIp4([4]u8{ 10, 0, 0, 1 }, 12345);
|
||||
|
||||
// First 2 requests succeed (return SERVFAIL due to no upstream)
|
||||
const r1 = handler.handle(&query, addr, allocator);
|
||||
const r2 = handler.handle(&query, addr, allocator);
|
||||
|
||||
if (r1) |r| allocator.free(r);
|
||||
if (r2) |r| allocator.free(r);
|
||||
|
||||
// 3rd request should be rate limited (REFUSED)
|
||||
const r3 = handler.handle(&query, addr, allocator);
|
||||
if (r3) |r| {
|
||||
defer allocator.free(r);
|
||||
// REFUSED = RCODE 5
|
||||
const rcode = r[3] & 0x0F;
|
||||
try testing.expectEqual(@as(u8, 5), rcode);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Blocklist Integration Tests
|
||||
// ============================================================================
|
||||
|
||||
test "Handler with blocklist - blocks matching domains" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var blocklist = blocklist_mod.Blocklist.init(allocator);
|
||||
defer blocklist.deinit();
|
||||
|
||||
try blocklist.addBlockedDomain("ads.example.com", 0);
|
||||
|
||||
var handler = handler_mod.Handler.init(allocator);
|
||||
handler.setBlocklist(blocklist.toHandlerBlocklist());
|
||||
|
||||
// Query for blocked domain
|
||||
var query_buf: [512]u8 = undefined;
|
||||
const query_len = createQueryForDomain("ads.example.com", &query_buf);
|
||||
const addr = std.net.Address.initIp4([4]u8{ 127, 0, 0, 1 }, 12345);
|
||||
|
||||
const response = handler.handle(query_buf[0..query_len], addr, allocator);
|
||||
try testing.expect(response != null);
|
||||
|
||||
if (response) |r| {
|
||||
defer allocator.free(r);
|
||||
// Should be a valid response with blocked content (0.0.0.0 or NXDOMAIN)
|
||||
try testing.expect(r.len >= types.DNS_HEADER_SIZE);
|
||||
// QR bit should be set (response)
|
||||
try testing.expect((r[2] & 0x80) != 0);
|
||||
}
|
||||
}
|
||||
|
||||
test "Handler with blocklist - allows non-blocked domains" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var blocklist = blocklist_mod.Blocklist.init(allocator);
|
||||
defer blocklist.deinit();
|
||||
|
||||
try blocklist.addBlockedDomain("blocked.com", 0);
|
||||
|
||||
// Create mock upstream that returns a valid response
|
||||
const mock_response = [_]u8{
|
||||
0x00, 0x02, 0x81, 0x80, // Header (response)
|
||||
0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,
|
||||
// Question: allowed.com A IN
|
||||
0x07, 'a', 'l', 'l', 'o', 'w', 'e', 'd',
|
||||
0x03, 'c', 'o', 'm', 0x00,
|
||||
0x00, 0x01, 0x00, 0x01,
|
||||
// Answer
|
||||
0xC0, 0x0C, 0x00, 0x01, 0x00, 0x01,
|
||||
0x00, 0x00, 0x01, 0x2C, // TTL 300
|
||||
0x00, 0x04, 0x08, 0x08, 0x08, 0x08, // IP 8.8.8.8
|
||||
};
|
||||
|
||||
var mock_upstream = MockUpstream.init(&mock_response, allocator);
|
||||
|
||||
var handler = handler_mod.Handler.init(allocator);
|
||||
handler.setBlocklist(blocklist.toHandlerBlocklist());
|
||||
handler.setUpstream(mock_upstream.toHandlerUpstream());
|
||||
|
||||
// Query for non-blocked domain
|
||||
var query_buf: [512]u8 = undefined;
|
||||
const query_len = createQueryForDomain("allowed.com", &query_buf);
|
||||
const addr = std.net.Address.initIp4([4]u8{ 127, 0, 0, 1 }, 12345);
|
||||
|
||||
const response = handler.handle(query_buf[0..query_len], addr, allocator);
|
||||
try testing.expect(response != null);
|
||||
|
||||
if (response) |r| {
|
||||
defer allocator.free(r);
|
||||
// Should forward to upstream and return its response
|
||||
try testing.expect(mock_upstream.call_count == 1);
|
||||
}
|
||||
}
|
||||
|
||||
test "Handler with blocklist - blocks subdomains" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var blocklist = blocklist_mod.Blocklist.init(allocator);
|
||||
defer blocklist.deinit();
|
||||
|
||||
// Block parent domain
|
||||
try blocklist.addBlockedDomain("doubleclick.net", 0);
|
||||
|
||||
var handler = handler_mod.Handler.init(allocator);
|
||||
handler.setBlocklist(blocklist.toHandlerBlocklist());
|
||||
|
||||
// Query for subdomain - should also be blocked
|
||||
var query_buf: [512]u8 = undefined;
|
||||
const query_len = createQueryForDomain("ads.doubleclick.net", &query_buf);
|
||||
const addr = std.net.Address.initIp4([4]u8{ 127, 0, 0, 1 }, 12345);
|
||||
|
||||
const response = handler.handle(query_buf[0..query_len], addr, allocator);
|
||||
try testing.expect(response != null);
|
||||
|
||||
if (response) |r| {
|
||||
defer allocator.free(r);
|
||||
// Should be blocked
|
||||
try testing.expect(r.len >= types.DNS_HEADER_SIZE);
|
||||
}
|
||||
}
|
||||
|
||||
test "Blocklist - allow rules override block rules" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var blocklist = blocklist_mod.Blocklist.init(allocator);
|
||||
defer blocklist.deinit();
|
||||
|
||||
try blocklist.addBlockedDomain("example.com", 0);
|
||||
try blocklist.addAllowRule("allowed.example.com", 0);
|
||||
|
||||
// Subdomain blocked
|
||||
try testing.expect(blocklist.isBlocked("blocked.example.com", 0));
|
||||
// But allowed.example.com is explicitly allowed
|
||||
try testing.expect(!blocklist.isBlocked("allowed.example.com", 0));
|
||||
}
|
||||
|
||||
test "Blocklist - group isolation" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var blocklist = blocklist_mod.Blocklist.init(allocator);
|
||||
defer blocklist.deinit();
|
||||
|
||||
// Block domain only for group 1
|
||||
try blocklist.addBlockedDomain("group1only.com", 1);
|
||||
|
||||
// Group 1 sees it blocked
|
||||
try testing.expect(blocklist.isBlocked("group1only.com", 1));
|
||||
// Group 0 and 2 do not see it blocked
|
||||
try testing.expect(!blocklist.isBlocked("group1only.com", 0));
|
||||
try testing.expect(!blocklist.isBlocked("group1only.com", 2));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Compression Loop Detection Tests
|
||||
// ============================================================================
|
||||
|
||||
test "Name parsing - detects compression loop" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
// Packet with self-referencing compression pointer
|
||||
const bad_packet = [_]u8{
|
||||
0x00, 0x01, 0x01, 0x00,
|
||||
0x00, 0x01, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
// Name with compression pointer pointing to itself (offset 12)
|
||||
0xC0, 0x0C,
|
||||
0x00, 0x01, 0x00, 0x01,
|
||||
};
|
||||
|
||||
// Parsing should detect the loop and fail
|
||||
const result = Name.parse(bad_packet[12..], &bad_packet, allocator);
|
||||
try testing.expectError(error.CompressionLoop, result);
|
||||
}
|
||||
|
||||
test "Name parsing - detects indirect compression loop" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
// Packet where pointer A -> pointer B -> pointer A
|
||||
const bad_packet = [_]u8{
|
||||
0x00, 0x01, 0x01, 0x00,
|
||||
0x00, 0x01, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
// Offset 12: pointer to offset 14
|
||||
0xC0, 0x0E,
|
||||
// Offset 14: pointer to offset 12
|
||||
0xC0, 0x0C,
|
||||
0x00, 0x01, 0x00, 0x01,
|
||||
};
|
||||
|
||||
const result = Name.parse(bad_packet[12..], &bad_packet, allocator);
|
||||
try testing.expectError(error.CompressionLoop, result);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Malformed Packet Handling Tests
|
||||
// ============================================================================
|
||||
|
||||
test "Handler - handles truncated packet" {
|
||||
const allocator = testing.allocator;
|
||||
var handler = handler_mod.Handler.init(allocator);
|
||||
|
||||
// Only 2 bytes - too short for DNS header
|
||||
const truncated = [_]u8{ 0x00, 0x01 };
|
||||
const addr = std.net.Address.initIp4([4]u8{ 127, 0, 0, 1 }, 12345);
|
||||
|
||||
const response = handler.handle(&truncated, addr, allocator);
|
||||
|
||||
if (response) |r| {
|
||||
defer allocator.free(r);
|
||||
// Should return FORMERR
|
||||
const rcode = r[3] & 0x0F;
|
||||
try testing.expectEqual(@as(u8, 1), rcode); // FORMERR
|
||||
}
|
||||
}
|
||||
|
||||
test "Handler - handles empty question section" {
|
||||
const allocator = testing.allocator;
|
||||
var handler = handler_mod.Handler.init(allocator);
|
||||
|
||||
// Valid header but QDCOUNT = 0
|
||||
const no_question = [_]u8{
|
||||
0x00, 0x01, 0x01, 0x00,
|
||||
0x00, 0x00, // QDCOUNT = 0
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
};
|
||||
const addr = std.net.Address.initIp4([4]u8{ 127, 0, 0, 1 }, 12345);
|
||||
|
||||
const response = handler.handle(&no_question, addr, allocator);
|
||||
|
||||
if (response) |r| {
|
||||
defer allocator.free(r);
|
||||
const rcode = r[3] & 0x0F;
|
||||
try testing.expectEqual(@as(u8, 1), rcode); // FORMERR
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Full Query Flow Integration Tests
|
||||
// ============================================================================
|
||||
|
||||
test "Full flow - query hits cache" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var dns_cache = cache.DnsCache.init(allocator);
|
||||
defer dns_cache.deinit();
|
||||
|
||||
var mock_upstream = MockUpstream.init(null, allocator);
|
||||
|
||||
var handler = handler_mod.Handler.init(allocator);
|
||||
handler.setCache(dns_cache.toHandlerCache());
|
||||
handler.setUpstream(mock_upstream.toHandlerUpstream());
|
||||
|
||||
// Pre-populate cache
|
||||
const cached_response = [_]u8{
|
||||
0x00, 0x01, 0x81, 0x80,
|
||||
0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,
|
||||
0x07, 'e', 'x', 'a', 'm', 'p', 'l', 'e',
|
||||
0x03, 'c', 'o', 'm', 0x00,
|
||||
0x00, 0x01, 0x00, 0x01,
|
||||
0xC0, 0x0C, 0x00, 0x01, 0x00, 0x01,
|
||||
0x00, 0x00, 0x01, 0x2C,
|
||||
0x00, 0x04, 0x01, 0x02, 0x03, 0x04,
|
||||
};
|
||||
dns_cache.put("example.com", types.QType.A, &cached_response, 300);
|
||||
|
||||
const query = createTestQuery();
|
||||
const addr = std.net.Address.initIp4([4]u8{ 127, 0, 0, 1 }, 12345);
|
||||
|
||||
const response = handler.handle(&query, addr, allocator);
|
||||
try testing.expect(response != null);
|
||||
|
||||
if (response) |r| {
|
||||
defer allocator.free(r);
|
||||
// Upstream should NOT have been called
|
||||
try testing.expectEqual(@as(usize, 0), mock_upstream.call_count);
|
||||
}
|
||||
}
|
||||
|
||||
test "Full flow - cache miss goes to upstream" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var dns_cache = cache.DnsCache.init(allocator);
|
||||
defer dns_cache.deinit();
|
||||
|
||||
const upstream_response = [_]u8{
|
||||
0x00, 0x01, 0x81, 0x80,
|
||||
0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,
|
||||
0x07, 'e', 'x', 'a', 'm', 'p', 'l', 'e',
|
||||
0x03, 'c', 'o', 'm', 0x00,
|
||||
0x00, 0x01, 0x00, 0x01,
|
||||
0xC0, 0x0C, 0x00, 0x01, 0x00, 0x01,
|
||||
0x00, 0x00, 0x01, 0x2C,
|
||||
0x00, 0x04, 0x08, 0x08, 0x08, 0x08,
|
||||
};
|
||||
|
||||
var mock_upstream = MockUpstream.init(&upstream_response, allocator);
|
||||
|
||||
var handler = handler_mod.Handler.init(allocator);
|
||||
handler.setCache(dns_cache.toHandlerCache());
|
||||
handler.setUpstream(mock_upstream.toHandlerUpstream());
|
||||
|
||||
const query = createTestQuery();
|
||||
const addr = std.net.Address.initIp4([4]u8{ 127, 0, 0, 1 }, 12345);
|
||||
|
||||
const response = handler.handle(&query, addr, allocator);
|
||||
try testing.expect(response != null);
|
||||
|
||||
if (response) |r| {
|
||||
defer allocator.free(r);
|
||||
// Upstream should have been called
|
||||
try testing.expectEqual(@as(usize, 1), mock_upstream.call_count);
|
||||
|
||||
// Result should now be cached
|
||||
const cached = try dns_cache.getCopy("example.com", types.QType.A);
|
||||
try testing.expect(cached != null);
|
||||
if (cached) |c| allocator.free(c);
|
||||
}
|
||||
}
|
||||
|
||||
test "Full flow - blocklist takes precedence over cache" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
var blocklist = blocklist_mod.Blocklist.init(allocator);
|
||||
defer blocklist.deinit();
|
||||
try blocklist.addBlockedDomain("blocked.com", 0);
|
||||
|
||||
var dns_cache = cache.DnsCache.init(allocator);
|
||||
defer dns_cache.deinit();
|
||||
|
||||
// Pre-populate cache with a response for blocked.com
|
||||
const cached_response = [_]u8{
|
||||
0x00, 0x01, 0x81, 0x80,
|
||||
0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,
|
||||
0x07, 'b', 'l', 'o', 'c', 'k', 'e', 'd',
|
||||
0x03, 'c', 'o', 'm', 0x00,
|
||||
0x00, 0x01, 0x00, 0x01,
|
||||
0xC0, 0x0C, 0x00, 0x01, 0x00, 0x01,
|
||||
0x00, 0x00, 0x01, 0x2C,
|
||||
0x00, 0x04, 0x08, 0x08, 0x08, 0x08, // 8.8.8.8
|
||||
};
|
||||
dns_cache.put("blocked.com", types.QType.A, &cached_response, 300);
|
||||
|
||||
var handler = handler_mod.Handler.init(allocator);
|
||||
handler.setBlocklist(blocklist.toHandlerBlocklist());
|
||||
handler.setCache(dns_cache.toHandlerCache());
|
||||
|
||||
var query_buf: [512]u8 = undefined;
|
||||
const query_len = createQueryForDomain("blocked.com", &query_buf);
|
||||
const addr = std.net.Address.initIp4([4]u8{ 127, 0, 0, 1 }, 12345);
|
||||
|
||||
const response = handler.handle(query_buf[0..query_len], addr, allocator);
|
||||
try testing.expect(response != null);
|
||||
|
||||
if (response) |r| {
|
||||
defer allocator.free(r);
|
||||
// Parse response to check answer
|
||||
var pkt = packet.Packet.parse(r, allocator) catch {
|
||||
try testing.expect(false);
|
||||
return;
|
||||
};
|
||||
defer pkt.deinit();
|
||||
|
||||
// Should be blocked (0.0.0.0), not cached (8.8.8.8)
|
||||
if (pkt.answers.len > 0) {
|
||||
const ip = pkt.answers[0].getA();
|
||||
if (ip) |addr_bytes| {
|
||||
try testing.expectEqual([4]u8{ 0, 0, 0, 0 }, addr_bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Packet Encoding/Decoding Roundtrip Tests
|
||||
// ============================================================================
|
||||
|
||||
test "Packet roundtrip - query" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
const original_query = createTestQuery();
|
||||
var pkt = try packet.Packet.parse(&original_query, allocator);
|
||||
defer pkt.deinit();
|
||||
|
||||
var buf: [512]u8 = undefined;
|
||||
const encoded_len = try pkt.encode(&buf);
|
||||
|
||||
var decoded = try packet.Packet.parse(buf[0..encoded_len], allocator);
|
||||
defer decoded.deinit();
|
||||
|
||||
try testing.expectEqual(pkt.header.id, decoded.header.id);
|
||||
try testing.expectEqual(pkt.header.qr, decoded.header.qr);
|
||||
try testing.expectEqual(pkt.questions.len, decoded.questions.len);
|
||||
}
|
||||
|
||||
test "Packet - createBlockedResponse has 0.0.0.0" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
const query = createTestQuery();
|
||||
var query_pkt = try packet.Packet.parse(&query, allocator);
|
||||
defer query_pkt.deinit();
|
||||
|
||||
var response = try packet.Packet.createBlockedResponse(&query_pkt, allocator);
|
||||
defer response.deinit();
|
||||
|
||||
try testing.expect(response.header.qr); // Is response
|
||||
try testing.expectEqual(@as(usize, 1), response.answers.len);
|
||||
|
||||
const ip = response.answers[0].getA();
|
||||
try testing.expect(ip != null);
|
||||
try testing.expectEqual([4]u8{ 0, 0, 0, 0 }, ip.?);
|
||||
}
|
||||
|
||||
test "Packet - createNxdomainResponse has NXDOMAIN RCODE" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
const query = createTestQuery();
|
||||
var query_pkt = try packet.Packet.parse(&query, allocator);
|
||||
defer query_pkt.deinit();
|
||||
|
||||
var response = try packet.Packet.createNxdomainResponse(&query_pkt, allocator);
|
||||
defer response.deinit();
|
||||
|
||||
try testing.expect(response.header.qr);
|
||||
try testing.expectEqual(types.RCode.NXDomain, response.header.rcode);
|
||||
try testing.expectEqual(@as(usize, 0), response.answers.len);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DoH/DoT URL Parsing Tests
|
||||
// ============================================================================
|
||||
|
||||
const dot = @import("dot");
|
||||
const doh = @import("doh");
|
||||
|
||||
test "DoT URL parsing - valid URLs" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
// Standard TLS URL
|
||||
const client1 = try dot.DotClient.fromUrl("tls://cloudflare-dns.com", allocator);
|
||||
try testing.expectEqualStrings("cloudflare-dns.com", client1.host);
|
||||
try testing.expectEqual(@as(u16, 853), client1.port);
|
||||
|
||||
// With explicit port
|
||||
const client2 = try dot.DotClient.fromUrl("tls://1.1.1.1:853", allocator);
|
||||
try testing.expectEqualStrings("1.1.1.1", client2.host);
|
||||
try testing.expectEqual(@as(u16, 853), client2.port);
|
||||
|
||||
// Custom port
|
||||
const client3 = try dot.DotClient.fromUrl("tls://dns.google:8853", allocator);
|
||||
try testing.expectEqualStrings("dns.google", client3.host);
|
||||
try testing.expectEqual(@as(u16, 8853), client3.port);
|
||||
}
|
||||
|
||||
test "DoT URL parsing - invalid URLs" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
// Wrong scheme
|
||||
try testing.expectError(error.InvalidHost, dot.DotClient.fromUrl("https://example.com", allocator));
|
||||
try testing.expectError(error.InvalidHost, dot.DotClient.fromUrl("not-a-url", allocator));
|
||||
}
|
||||
|
||||
test "DoH URL parsing - valid URLs" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
// Standard DoH URL
|
||||
const client1 = try doh.DohClient.init("https://cloudflare-dns.com/dns-query", allocator);
|
||||
try testing.expectEqualStrings("cloudflare-dns.com", client1.host);
|
||||
try testing.expectEqualStrings("/dns-query", client1.path);
|
||||
try testing.expectEqual(@as(u16, 443), client1.port);
|
||||
|
||||
// With custom port
|
||||
const client2 = try doh.DohClient.init("https://dns.quad9.net:8443/dns-query", allocator);
|
||||
try testing.expectEqualStrings("dns.quad9.net", client2.host);
|
||||
try testing.expectEqual(@as(u16, 8443), client2.port);
|
||||
|
||||
// No path (defaults to /dns-query)
|
||||
const client3 = try doh.DohClient.init("https://example.com", allocator);
|
||||
try testing.expectEqualStrings("example.com", client3.host);
|
||||
try testing.expectEqualStrings("/dns-query", client3.path);
|
||||
}
|
||||
|
||||
test "DoH URL parsing - invalid URLs" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
// Wrong scheme
|
||||
try testing.expectError(error.InvalidUrl, doh.DohClient.init("http://example.com/dns-query", allocator));
|
||||
try testing.expectError(error.InvalidUrl, doh.DohClient.init("not-a-url", allocator));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Edge Case Tests
|
||||
// ============================================================================
|
||||
|
||||
test "Handler - IPv6 client address" {
|
||||
const allocator = testing.allocator;
|
||||
var handler = handler_mod.Handler.init(allocator);
|
||||
|
||||
const query = createTestQuery();
|
||||
|
||||
// Create IPv6 address
|
||||
var addr: std.net.Address = undefined;
|
||||
addr.in6 = std.net.Ip6Address.init([16]u8{
|
||||
0x20, 0x01, 0x0d, 0xb8, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
|
||||
}, 12345);
|
||||
addr.any.family = std.posix.AF.INET6;
|
||||
|
||||
// Should handle IPv6 without crashing
|
||||
const response = handler.handle(&query, addr, allocator);
|
||||
|
||||
if (response) |r| {
|
||||
defer allocator.free(r);
|
||||
try testing.expect(r.len >= types.DNS_HEADER_SIZE);
|
||||
}
|
||||
}
|
||||
|
||||
test "Cache - TTL clamping" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
// Configure with min_ttl=60, max_ttl=3600
|
||||
var dns_cache = cache.DnsCache.initWithConfig(allocator, 100, 60, 3600);
|
||||
defer dns_cache.deinit();
|
||||
|
||||
// Put with TTL below min - should be clamped to 60
|
||||
dns_cache.put("test1.com", types.QType.A, "response", 10);
|
||||
const cached1 = try dns_cache.getCopy("test1.com", types.QType.A);
|
||||
try testing.expect(cached1 != null);
|
||||
if (cached1) |c| allocator.free(c);
|
||||
|
||||
// Put with TTL above max - should be clamped to 3600
|
||||
dns_cache.put("test2.com", types.QType.A, "response", 100000);
|
||||
const cached2 = try dns_cache.getCopy("test2.com", types.QType.A);
|
||||
try testing.expect(cached2 != null);
|
||||
if (cached2) |c| allocator.free(c);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user