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