initial commit
This commit is contained in:
@@ -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