918 lines
30 KiB
Zig
918 lines
30 KiB
Zig
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);
|
|
}
|
|
|