initial commit
This commit is contained in:
@@ -0,0 +1,413 @@
|
||||
const std = @import("std");
|
||||
const posix = std.posix;
|
||||
const net = std.net;
|
||||
const Allocator = std.mem.Allocator;
|
||||
const handler = @import("../server/handler.zig");
|
||||
const doh = @import("doh.zig");
|
||||
const dot = @import("dot.zig");
|
||||
const connection_pool = @import("connection_pool.zig");
|
||||
|
||||
/// Upstream protocol type
|
||||
pub const Protocol = enum {
|
||||
udp, // Plain UDP (IP:port)
|
||||
doh, // DNS-over-HTTPS (https://...)
|
||||
dot, // DNS-over-TLS (tls://...)
|
||||
};
|
||||
|
||||
/// Persistent UDP socket for upstream queries
|
||||
/// Uses mutex to prevent response mixing between concurrent queries
|
||||
const UdpUpstream = struct {
|
||||
socket: posix.socket_t,
|
||||
mutex: std.Thread.Mutex,
|
||||
timeout_ms: u32,
|
||||
|
||||
fn init(ip: [4]u8, port: u16, timeout_ms: u32) !UdpUpstream {
|
||||
const sock = try posix.socket(posix.AF.INET, posix.SOCK.DGRAM, 0);
|
||||
errdefer posix.close(sock);
|
||||
|
||||
const addr = net.Address.initIp4(ip, port);
|
||||
|
||||
// Connect the socket to the upstream - allows send/recv and ICMP errors
|
||||
try posix.connect(sock, &addr.any, addr.getOsSockLen());
|
||||
|
||||
return UdpUpstream{
|
||||
.socket = sock,
|
||||
.mutex = .{},
|
||||
.timeout_ms = timeout_ms,
|
||||
};
|
||||
}
|
||||
|
||||
fn deinit(self: *UdpUpstream) void {
|
||||
posix.close(self.socket);
|
||||
}
|
||||
|
||||
fn query(self: *UdpUpstream, dns_packet: []const u8, allocator: Allocator) ![]const u8 {
|
||||
self.mutex.lock();
|
||||
defer self.mutex.unlock();
|
||||
|
||||
// Set timeout for this query
|
||||
const timeout = posix.timeval{
|
||||
.sec = @intCast(self.timeout_ms / 1000),
|
||||
.usec = @intCast((self.timeout_ms % 1000) * 1000),
|
||||
};
|
||||
posix.setsockopt(self.socket, posix.SOL.SOCKET, posix.SO.RCVTIMEO, std.mem.asBytes(&timeout)) catch {};
|
||||
posix.setsockopt(self.socket, posix.SOL.SOCKET, posix.SO.SNDTIMEO, std.mem.asBytes(&timeout)) catch {};
|
||||
|
||||
// Send using connected socket (no address needed)
|
||||
_ = try posix.send(self.socket, dns_packet, 0);
|
||||
|
||||
// Receive response
|
||||
var response_buf: [4096]u8 = undefined;
|
||||
const n = try posix.recv(self.socket, &response_buf, 0);
|
||||
|
||||
if (n < 12) return error.InvalidResponse;
|
||||
|
||||
const response = try allocator.alloc(u8, n);
|
||||
@memcpy(response, response_buf[0..n]);
|
||||
return response;
|
||||
}
|
||||
};
|
||||
|
||||
/// Upstream DNS server configuration
|
||||
pub const UpstreamConfig = struct {
|
||||
/// Server address/URL
|
||||
/// - "8.8.8.8" or "8.8.8.8:53" for plain UDP
|
||||
/// - "https://cloudflare-dns.com/dns-query" for DoH
|
||||
/// - "tls://cloudflare-dns.com" for DoT
|
||||
address: []const u8,
|
||||
port: u16 = 53,
|
||||
enabled: bool = true,
|
||||
timeout_ms: u32 = 1000, // 1 second default (reduced from 2s for faster failover)
|
||||
|
||||
/// Detect protocol from address
|
||||
pub fn getProtocol(self: UpstreamConfig) Protocol {
|
||||
if (std.mem.startsWith(u8, self.address, "https://")) {
|
||||
return .doh;
|
||||
} else if (std.mem.startsWith(u8, self.address, "tls://")) {
|
||||
return .dot;
|
||||
} else {
|
||||
return .udp;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/// Health state for an upstream server
|
||||
const UpstreamHealth = struct {
|
||||
failures: u32 = 0,
|
||||
last_failure: i64 = 0,
|
||||
|
||||
const FAILURE_THRESHOLD: u32 = 3;
|
||||
const COOLDOWN_SECONDS: i64 = 30;
|
||||
|
||||
fn isHealthy(self: *const UpstreamHealth) bool {
|
||||
if (self.failures < FAILURE_THRESHOLD) return true;
|
||||
// Allow retry after cooldown
|
||||
const now = std.time.timestamp();
|
||||
return now - self.last_failure > COOLDOWN_SECONDS;
|
||||
}
|
||||
|
||||
fn recordFailure(self: *UpstreamHealth) void {
|
||||
self.failures +|= 1; // Saturating add
|
||||
self.last_failure = std.time.timestamp();
|
||||
}
|
||||
|
||||
fn recordSuccess(self: *UpstreamHealth) void {
|
||||
self.failures = 0;
|
||||
}
|
||||
};
|
||||
|
||||
/// Pool of upstream DNS servers with failover
|
||||
pub const UpstreamPool = struct {
|
||||
configs: []UpstreamConfig,
|
||||
allocator: Allocator,
|
||||
/// Connection pool for DoT - maintains persistent TLS connections
|
||||
dot_pool: connection_pool.DotConnectionPool,
|
||||
/// Connection pool for DoH - maintains persistent HTTP connections
|
||||
doh_pool: doh.DohConnectionPool,
|
||||
/// Persistent UDP sockets - one per UDP upstream config (null for non-UDP)
|
||||
udp_upstreams: []?UdpUpstream,
|
||||
/// Health state per upstream for fast failover
|
||||
health: []UpstreamHealth,
|
||||
|
||||
pub fn init(configs: []const UpstreamConfig, allocator: Allocator) !UpstreamPool {
|
||||
const configs_copy = try allocator.alloc(UpstreamConfig, configs.len);
|
||||
@memcpy(configs_copy, configs);
|
||||
|
||||
const udp_upstreams = try allocator.alloc(?UdpUpstream, configs.len);
|
||||
for (configs_copy, 0..) |config, i| {
|
||||
if (config.getProtocol() == .udp) {
|
||||
if (parseIpv4(config.address)) |ip| {
|
||||
udp_upstreams[i] = UdpUpstream.init(ip, config.port, config.timeout_ms) catch |err| {
|
||||
std.log.warn("Failed to create UDP socket for {s}:{d}: {} (will use per-query fallback)", .{ config.address, config.port, err });
|
||||
udp_upstreams[i] = null;
|
||||
continue;
|
||||
};
|
||||
} else {
|
||||
udp_upstreams[i] = null;
|
||||
}
|
||||
} else {
|
||||
udp_upstreams[i] = null;
|
||||
}
|
||||
}
|
||||
|
||||
const health = try allocator.alloc(UpstreamHealth, configs.len);
|
||||
for (health) |*h| {
|
||||
h.* = UpstreamHealth{};
|
||||
}
|
||||
|
||||
return UpstreamPool{
|
||||
.configs = configs_copy,
|
||||
.allocator = allocator,
|
||||
.dot_pool = connection_pool.DotConnectionPool.init(allocator),
|
||||
.doh_pool = doh.DohConnectionPool.init(allocator),
|
||||
.udp_upstreams = udp_upstreams,
|
||||
.health = health,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *UpstreamPool) void {
|
||||
for (self.udp_upstreams) |*maybe_udp| {
|
||||
if (maybe_udp.*) |*udp| {
|
||||
udp.deinit();
|
||||
}
|
||||
}
|
||||
self.allocator.free(self.udp_upstreams);
|
||||
self.allocator.free(self.health);
|
||||
self.dot_pool.deinit();
|
||||
self.doh_pool.deinit();
|
||||
self.allocator.free(self.configs);
|
||||
}
|
||||
|
||||
/// Convert to handler-compatible Upstream interface
|
||||
pub fn toHandlerUpstream(self: *UpstreamPool) handler.Upstream {
|
||||
return handler.Upstream{
|
||||
.context = self,
|
||||
.queryFn = queryWrapper,
|
||||
};
|
||||
}
|
||||
|
||||
fn queryWrapper(ctx: *anyopaque, dns_packet: []const u8, allocator: Allocator) ?[]const u8 {
|
||||
const self: *UpstreamPool = @ptrCast(@alignCast(ctx));
|
||||
return self.query(dns_packet, allocator);
|
||||
}
|
||||
|
||||
/// Query upstream DNS servers, trying each until one succeeds.
|
||||
/// Skips unhealthy upstreams (>3 consecutive failures) for 30 seconds.
|
||||
pub fn query(self: *UpstreamPool, dns_packet: []const u8, allocator: Allocator) ?[]const u8 {
|
||||
// First pass: try healthy upstreams only
|
||||
for (self.configs, 0..) |config, i| {
|
||||
if (!config.enabled) continue;
|
||||
if (!self.health[i].isHealthy()) continue;
|
||||
|
||||
const result = self.queryUpstream(config, i, dns_packet, allocator);
|
||||
if (result) |response| {
|
||||
self.health[i].recordSuccess();
|
||||
return response;
|
||||
}
|
||||
self.health[i].recordFailure();
|
||||
}
|
||||
|
||||
// Second pass: try unhealthy upstreams as last resort
|
||||
for (self.configs, 0..) |config, i| {
|
||||
if (!config.enabled) continue;
|
||||
if (self.health[i].isHealthy()) continue; // Already tried
|
||||
|
||||
const result = self.queryUpstream(config, i, dns_packet, allocator);
|
||||
if (result) |response| {
|
||||
self.health[i].recordSuccess();
|
||||
return response;
|
||||
}
|
||||
self.health[i].recordFailure();
|
||||
}
|
||||
|
||||
std.log.err("All upstream DNS servers failed", .{});
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Query a single upstream based on its protocol
|
||||
fn queryUpstream(self: *UpstreamPool, config: UpstreamConfig, index: usize, dns_packet: []const u8, allocator: Allocator) ?[]const u8 {
|
||||
const protocol = config.getProtocol();
|
||||
|
||||
switch (protocol) {
|
||||
.doh => {
|
||||
// Use connection pool with slot-based sharding for parallelism
|
||||
return self.doh_pool.query(config.address, dns_packet) catch |err| {
|
||||
std.log.warn("DoH query failed for {s}: {}", .{ config.address, err });
|
||||
return null;
|
||||
};
|
||||
},
|
||||
.dot => {
|
||||
// Use persistent connection pool for DoT
|
||||
const parsed = parseDotUrl(config.address) orelse {
|
||||
std.log.warn("DoT invalid URL: {s}", .{config.address});
|
||||
return null;
|
||||
};
|
||||
|
||||
const conn = self.dot_pool.getConnection(parsed.host, parsed.port) catch |err| {
|
||||
std.log.warn("DoT pool failed for {s}: {}", .{ config.address, err });
|
||||
return null;
|
||||
};
|
||||
conn.setTimeout(config.timeout_ms);
|
||||
|
||||
return conn.query(dns_packet) catch |err| {
|
||||
std.log.warn("DoT query failed for {s}: {}", .{ config.address, err });
|
||||
return null;
|
||||
};
|
||||
},
|
||||
.udp => {
|
||||
// Use pooled socket if available, fall back to per-query socket
|
||||
if (self.udp_upstreams[index]) |*udp| {
|
||||
return udp.query(dns_packet, allocator) catch |err| {
|
||||
std.log.warn("UDP upstream {s}:{d} failed: {}", .{ config.address, config.port, err });
|
||||
return null;
|
||||
};
|
||||
} else if (parseIpv4(config.address)) |ip| {
|
||||
// Fallback: per-query socket (socket creation failed at init)
|
||||
return queryUdp(ip, config.port, dns_packet, config.timeout_ms, allocator) catch |err| {
|
||||
std.log.warn("UDP upstream {s}:{d} failed: {}", .{ config.address, config.port, err });
|
||||
return null;
|
||||
};
|
||||
} else {
|
||||
std.log.warn("Invalid IPv4 address: {s}", .{config.address});
|
||||
return null;
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/// Parse a tls:// URL into host and port
|
||||
fn parseDotUrl(url: []const u8) ?struct { host: []const u8, port: u16 } {
|
||||
const prefix = "tls://";
|
||||
if (!std.mem.startsWith(u8, url, prefix)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const host_port = url[prefix.len..];
|
||||
|
||||
// Check for port
|
||||
if (std.mem.lastIndexOfScalar(u8, host_port, ':')) |colon_idx| {
|
||||
const host = host_port[0..colon_idx];
|
||||
const port_str = host_port[colon_idx + 1 ..];
|
||||
const port = std.fmt.parseInt(u16, port_str, 10) catch return null;
|
||||
return .{ .host = host, .port = port };
|
||||
}
|
||||
|
||||
return .{ .host = host_port, .port = 853 };
|
||||
}
|
||||
|
||||
/// Query a DNS server using plain UDP
|
||||
fn queryUdp(ip: [4]u8, port: u16, dns_packet: []const u8, timeout_ms: u32, allocator: Allocator) ![]const u8 {
|
||||
const sock = try posix.socket(posix.AF.INET, posix.SOCK.DGRAM, 0);
|
||||
defer posix.close(sock);
|
||||
|
||||
// Set receive timeout - this is critical to avoid indefinite blocking
|
||||
const timeout = posix.timeval{
|
||||
.sec = @intCast(timeout_ms / 1000),
|
||||
.usec = @intCast((timeout_ms % 1000) * 1000),
|
||||
};
|
||||
posix.setsockopt(sock, posix.SOL.SOCKET, posix.SO.RCVTIMEO, std.mem.asBytes(&timeout)) catch |err| {
|
||||
std.log.warn("Failed to set socket receive timeout (queries may hang): {}", .{err});
|
||||
// Continue anyway - the query might still work, just without timeout protection
|
||||
};
|
||||
|
||||
// Also set send timeout
|
||||
posix.setsockopt(sock, posix.SOL.SOCKET, posix.SO.SNDTIMEO, std.mem.asBytes(&timeout)) catch |err| {
|
||||
std.log.warn("Failed to set socket send timeout: {}", .{err});
|
||||
};
|
||||
|
||||
const addr = net.Address.initIp4(ip, port);
|
||||
_ = try posix.sendto(sock, dns_packet, 0, &addr.any, addr.getOsSockLen());
|
||||
|
||||
var response_buf: [4096]u8 = undefined;
|
||||
const n = try posix.recvfrom(sock, &response_buf, 0, null, null);
|
||||
|
||||
// DNS header is 12 bytes minimum
|
||||
if (n < 12) return error.InvalidResponse;
|
||||
|
||||
const response = try allocator.alloc(u8, n);
|
||||
@memcpy(response, response_buf[0..n]);
|
||||
return response;
|
||||
}
|
||||
|
||||
/// Parse an IPv4 address string like "8.8.8.8" into bytes
|
||||
fn parseIpv4(addr: []const u8) ?[4]u8 {
|
||||
// Strip any port suffix
|
||||
const host = if (std.mem.indexOf(u8, addr, ":")) |idx| addr[0..idx] else addr;
|
||||
|
||||
var result: [4]u8 = undefined;
|
||||
var parts = std.mem.splitScalar(u8, host, '.');
|
||||
var i: usize = 0;
|
||||
|
||||
while (parts.next()) |part| {
|
||||
if (i >= 4) return null;
|
||||
result[i] = std.fmt.parseInt(u8, part, 10) catch return null;
|
||||
i += 1;
|
||||
}
|
||||
|
||||
if (i != 4) return null;
|
||||
return result;
|
||||
}
|
||||
|
||||
test "parseIpv4" {
|
||||
const testing = std.testing;
|
||||
|
||||
try testing.expectEqual([4]u8{ 8, 8, 8, 8 }, parseIpv4("8.8.8.8").?);
|
||||
try testing.expectEqual([4]u8{ 1, 1, 1, 1 }, parseIpv4("1.1.1.1").?);
|
||||
try testing.expectEqual([4]u8{ 192, 168, 1, 1 }, parseIpv4("192.168.1.1:53").?);
|
||||
try testing.expect(parseIpv4("invalid") == null);
|
||||
try testing.expect(parseIpv4("256.0.0.1") == null);
|
||||
}
|
||||
|
||||
test "UpstreamConfig protocol detection" {
|
||||
const testing = std.testing;
|
||||
|
||||
const doh_config = UpstreamConfig{ .address = "https://cloudflare-dns.com/dns-query" };
|
||||
try testing.expectEqual(Protocol.doh, doh_config.getProtocol());
|
||||
|
||||
const dot_config = UpstreamConfig{ .address = "tls://cloudflare-dns.com" };
|
||||
try testing.expectEqual(Protocol.dot, dot_config.getProtocol());
|
||||
|
||||
const udp_config = UpstreamConfig{ .address = "8.8.8.8" };
|
||||
try testing.expectEqual(Protocol.udp, udp_config.getProtocol());
|
||||
|
||||
const udp_with_port = UpstreamConfig{ .address = "1.1.1.1:53" };
|
||||
try testing.expectEqual(Protocol.udp, udp_with_port.getProtocol());
|
||||
}
|
||||
|
||||
test "UpstreamPool initialization" {
|
||||
const testing = std.testing;
|
||||
const allocator = testing.allocator;
|
||||
|
||||
const configs = [_]UpstreamConfig{
|
||||
.{ .address = "https://cloudflare-dns.com/dns-query" },
|
||||
.{ .address = "tls://1.1.1.1" },
|
||||
.{ .address = "8.8.8.8" },
|
||||
};
|
||||
|
||||
var pool = try UpstreamPool.init(&configs, allocator);
|
||||
defer pool.deinit();
|
||||
|
||||
try testing.expectEqual(@as(usize, 3), pool.configs.len);
|
||||
}
|
||||
|
||||
test "parseDotUrl" {
|
||||
const testing = std.testing;
|
||||
|
||||
// Valid URLs
|
||||
const r1 = parseDotUrl("tls://cloudflare-dns.com").?;
|
||||
try testing.expectEqualStrings("cloudflare-dns.com", r1.host);
|
||||
try testing.expectEqual(@as(u16, 853), r1.port);
|
||||
|
||||
const r2 = parseDotUrl("tls://1.1.1.1:853").?;
|
||||
try testing.expectEqualStrings("1.1.1.1", r2.host);
|
||||
try testing.expectEqual(@as(u16, 853), r2.port);
|
||||
|
||||
const r3 = parseDotUrl("tls://dns.google:8853").?;
|
||||
try testing.expectEqualStrings("dns.google", r3.host);
|
||||
try testing.expectEqual(@as(u16, 8853), r3.port);
|
||||
|
||||
// Invalid URLs
|
||||
try testing.expect(parseDotUrl("https://example.com") == null);
|
||||
try testing.expect(parseDotUrl("example.com") == null);
|
||||
}
|
||||
Reference in New Issue
Block a user