milestone 7: serving pipeline, client tracking, pause and lifecycle
This commit is contained in:
+642
@@ -0,0 +1,642 @@
|
||||
//! The composition root: everything `nxdns run` owns, in the order it is built.
|
||||
//!
|
||||
//! Nothing else in the program constructs a collaborator. Each module takes its
|
||||
//! dependencies as parameters and knows nothing about who supplies them, so
|
||||
//! this file is the only place where the object graph exists — and the only
|
||||
//! place a lifetime spans the whole process.
|
||||
//!
|
||||
//! Two rules shape it:
|
||||
//!
|
||||
//! 1. **Nothing that is pointed at may move.** `Pool.Entry.client` erases a
|
||||
//! pointer into a DoH or DoT client, the `Logger`'s queue holds waiting
|
||||
//! tasks in intrusive lists, and the `Manager` is addressed through `self`.
|
||||
//! Every such value therefore lives in `serve`'s frame or in an owned heap
|
||||
//! allocation, never in a temporary that is copied afterwards.
|
||||
//! 2. **Every background loop is a task in one `std.Io.Group`.** Shutdown is
|
||||
//! `group.cancel`, which requests cancellation and joins, so no loop can
|
||||
//! still be running when the resources it borrows are released.
|
||||
//!
|
||||
//! Failure policy: a startup failure prints one line to the runner's error
|
||||
//! writer and exits. A configuration the operator can fix exits 2, so that
|
||||
//! `nxdns check` is the obvious next step; anything else exits 1. Once serving
|
||||
//! starts, nothing is fatal — the pipeline answers queries with a snapshot or
|
||||
//! without one (ruling 4), and every loop logs its own failures and keeps
|
||||
//! going.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const Certificate = std.crypto.Certificate;
|
||||
const Writer = std.Io.Writer;
|
||||
const net = std.Io.net;
|
||||
const tls = std.crypto.tls;
|
||||
|
||||
const bootstrap = @import("config/bootstrap.zig");
|
||||
const cli = @import("cli.zig");
|
||||
const clients = @import("server/clients.zig");
|
||||
const config_export = @import("config/export.zig");
|
||||
const disk_monitor = @import("storage/disk_monitor.zig");
|
||||
const dns_cache = @import("cache/dns_cache.zig");
|
||||
const doh_client = @import("upstream/doh_client.zig");
|
||||
const dot_client = @import("upstream/dot_client.zig");
|
||||
const fetcher = @import("filter/fetcher.zig");
|
||||
const forward_zones = @import("local/forward_zones.zig");
|
||||
const handler = @import("server/handler.zig");
|
||||
const local_records = @import("local/records.zig");
|
||||
const logger_mod = @import("storage/logger.zig");
|
||||
const logging = @import("platform/logging.zig");
|
||||
const manager_mod = @import("filter/manager.zig");
|
||||
const migrations = @import("storage/migrations.zig");
|
||||
const model = @import("config/model.zig");
|
||||
const pause = @import("server/pause.zig");
|
||||
const pool_mod = @import("upstream/pool.zig");
|
||||
const rate_limiter = @import("server/rate_limiter.zig");
|
||||
const retention_mod = @import("storage/retention.zig");
|
||||
const shutdown = @import("server/shutdown.zig");
|
||||
const tcp_server = @import("server/tcp_server.zig");
|
||||
const transport = @import("upstream/transport.zig");
|
||||
const udp_server = @import("server/udp_server.zig");
|
||||
const validate = @import("config/validate.zig");
|
||||
const version = @import("version.zig");
|
||||
|
||||
const log = std.log.scoped(.nxdns);
|
||||
|
||||
/// How long the cache and rate-limiter sweeps wait between passes. Both tables
|
||||
/// evict lazily on lookup as well; this is what keeps memory bounded for a
|
||||
/// client that asked once and never came back.
|
||||
const maintenance_interval_s = 60;
|
||||
|
||||
/// Bounds one blocklist download (`Manager.total_budget`). Generous, because a
|
||||
/// megabyte-scale list over a household uplink is normal, and a download that
|
||||
/// takes longer than this is not going to finish at all.
|
||||
const download_budget_s = 300;
|
||||
|
||||
/// Per DoH upstream. `min_request_buf` is 512; the extra room costs nothing and
|
||||
/// keeps a maximum-length name with a large OPT record comfortable.
|
||||
const doh_request_buf_len = 1024;
|
||||
const doh_transfer_buf_len = 4096;
|
||||
|
||||
/// A configuration fault the operator can fix, as opposed to a runtime one.
|
||||
/// These are the only failures this file raises itself; everything else comes
|
||||
/// out of a collaborator.
|
||||
const ConfigError = error{
|
||||
NoUsableUpstreams,
|
||||
BadBindAddress,
|
||||
BadRateLimit,
|
||||
};
|
||||
|
||||
pub fn run(runner: cli.Runner, paths: cli.Paths) u8 {
|
||||
const code = serve(runner, paths) catch |err| code: {
|
||||
runner.err.print("nxdns run failed: {s}\n", .{@errorName(err)}) catch {};
|
||||
if (isConfigFault(err)) {
|
||||
runner.err.writeAll("run `nxdns check` to see the configuration in full\n") catch {};
|
||||
break :code cli.exit_check;
|
||||
}
|
||||
break :code cli.exit_runtime;
|
||||
};
|
||||
|
||||
// Output the operator never received is not output, so a failed flush
|
||||
// outranks a successful run — the same rule `cli.finish` applies.
|
||||
runner.out.flush() catch return cli.exit_runtime;
|
||||
runner.err.flush() catch return cli.exit_runtime;
|
||||
return code;
|
||||
}
|
||||
|
||||
fn isConfigFault(err: anyerror) bool {
|
||||
return switch (err) {
|
||||
error.NoUsableUpstreams, error.BadBindAddress, error.BadRateLimit => true,
|
||||
else => false,
|
||||
};
|
||||
}
|
||||
|
||||
fn serve(r: cli.Runner, paths: cli.Paths) !u8 {
|
||||
const io = r.io;
|
||||
const gpa = r.gpa;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// storage and configuration
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
var data = try cli.DataDir.open(io, gpa, paths.data_dir, true);
|
||||
defer data.close(io, gpa);
|
||||
|
||||
var config_db = try data.openConfigDb(io);
|
||||
defer config_db.close();
|
||||
_ = try migrations.migrate(&config_db);
|
||||
|
||||
{
|
||||
// First run only: the file seeds an empty database and is ignored
|
||||
// forever after. Its diagnostics are the operator's one chance to see
|
||||
// why a config file was rejected, so they are printed like `check`
|
||||
// prints them.
|
||||
var diags: validate.Diagnostics = .init(gpa);
|
||||
defer diags.deinit();
|
||||
_ = bootstrap.bootstrap(io, gpa, &config_db, std.Io.Dir.cwd(), paths.config, &diags) catch |err| {
|
||||
diags.writeAll(r.err) catch {};
|
||||
return err;
|
||||
};
|
||||
}
|
||||
|
||||
// Every string in `cfg` points into this arena, and the pool's endpoints,
|
||||
// the handler's records and the monitor's paths all keep such strings. It
|
||||
// therefore outlives all of them, which is why it is declared here and not
|
||||
// inside a narrower scope.
|
||||
var arena_state: std.heap.ArenaAllocator = .init(gpa);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
|
||||
const cfg = try config_export.readConfig(&config_db, arena);
|
||||
|
||||
// From here on `std.log` goes wherever the operator asked. Before this call
|
||||
// the sink passes through to stderr, which is where a failure above needs
|
||||
// to appear anyway.
|
||||
logging.install(io, cfg.logging);
|
||||
defer logging.deinstall();
|
||||
|
||||
if (cfg.dns.rate_limit == 0 or cfg.dns.rate_window_seconds == 0) return error.BadRateLimit;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// local answers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
var records = try local_records.Records.build(gpa, cfg.local_records);
|
||||
defer records.deinit(gpa);
|
||||
|
||||
var zones = try forward_zones.Zones.build(gpa, cfg.forward_zones);
|
||||
defer zones.deinit(gpa);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// blocklists
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// Two HTTP clients on purpose. A blocklist download streams tens of
|
||||
// megabytes and holds its connection for the whole of it; DoH queries must
|
||||
// not queue behind that, and the two have nothing to share but a type.
|
||||
var fetch_http: std.http.Client = .{ .allocator = gpa, .io = io };
|
||||
defer fetch_http.deinit();
|
||||
var dns_http: std.http.Client = .{ .allocator = gpa, .io = io };
|
||||
defer dns_http.deinit();
|
||||
|
||||
const fetch_transfer = try gpa.alloc(u8, fetcher.min_transfer_buf);
|
||||
defer gpa.free(fetch_transfer);
|
||||
const fetch_redirect = try gpa.alloc(u8, fetcher.redirect_buffer_len);
|
||||
defer gpa.free(fetch_redirect);
|
||||
var fetch: fetcher.Fetcher = .{
|
||||
.http = &fetch_http,
|
||||
.transfer_buf = fetch_transfer,
|
||||
.redirect_buf = fetch_redirect,
|
||||
};
|
||||
|
||||
var manager: manager_mod.Manager = try .init(
|
||||
gpa,
|
||||
&config_db,
|
||||
.{ .dir = data.dir },
|
||||
&fetch,
|
||||
cfg.blocklist_update,
|
||||
.{ .raw = .fromSeconds(download_budget_s), .clock = .awake },
|
||||
);
|
||||
defer manager.deinit(io);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// upstream pool
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// Shared by every DoT client: the trust store does not vary per upstream
|
||||
// and the scan is expensive. Both must outlive the clients that hold them.
|
||||
var bundle: Certificate.Bundle = .empty;
|
||||
defer bundle.deinit(gpa);
|
||||
var bundle_lock: std.Io.RwLock = .init;
|
||||
|
||||
var upstreams = try Upstreams.build(gpa, cfg.upstreams, &dns_http, &bundle, &bundle_lock);
|
||||
defer upstreams.deinit(gpa);
|
||||
|
||||
var pool: pool_mod.Pool = .init(
|
||||
upstreams.active(),
|
||||
.{},
|
||||
.{ .raw = model.totalTimeout(cfg.upstream), .clock = .awake },
|
||||
@truncate(@as(u96, @bitCast(std.Io.Clock.real.now(io).nanoseconds))),
|
||||
);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// per-query state
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
var cache: dns_cache.DnsCache = try .init(gpa, cfg.cache);
|
||||
defer cache.deinit();
|
||||
|
||||
var limiter: rate_limiter.RateLimiter = try .init(gpa, .{
|
||||
.limit = cfg.dns.rate_limit,
|
||||
.window_seconds = cfg.dns.rate_window_seconds,
|
||||
});
|
||||
defer limiter.deinit();
|
||||
|
||||
var paused: pause.Pause = .{};
|
||||
var tracker: clients.Tracker = .init(cfg.logging.retention_days);
|
||||
|
||||
// The queue holds waiting tasks in intrusive lists, so neither the buffer
|
||||
// nor the `Logger` may move once a task has touched either.
|
||||
const queue_buf = try gpa.alloc(logger_mod.Entry, cfg.logging.query_log_buffer_max);
|
||||
defer gpa.free(queue_buf);
|
||||
var query_logger: logger_mod.Logger = .init(cfg.logging, queue_buf);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// disk, retention and the remaining connections (ruling 21)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
const data_path = try arena.dupeZ(u8, paths.data_dir);
|
||||
const log_dir_path: ?[:0]const u8 = if (cfg.logging.output == .file)
|
||||
try arena.dupeZ(u8, std.fs.path.dirname(cfg.logging.file_path) orelse ".")
|
||||
else
|
||||
null;
|
||||
var monitor: disk_monitor.Monitor = .init(cfg.disk, data.dir, data_path, log_dir_path);
|
||||
|
||||
// Ruling 17. The scheduler consults it before every scheduled pass; the
|
||||
// startup `reload` below is an operator action and stays ungated.
|
||||
manager.monitor = &monitor;
|
||||
|
||||
// One synchronous sample before anything can consult the gate. `Monitor`
|
||||
// initializes to `.ok`, and `Monitor.run` takes its first sample inside the
|
||||
// task — so without this call the log writer, the tracker and the blocklist
|
||||
// scheduler would all see "writes allowed" during the boot window and could
|
||||
// write to a critically full disk before the gate ever reflected it.
|
||||
// `run`'s own first sample repeats this one, which costs one extra statvfs
|
||||
// and directory scan at startup and keeps `run`'s "sample first, then
|
||||
// sleep" contract intact.
|
||||
//
|
||||
// `sample` returns nothing: every failure warns and counts inside the
|
||||
// monitor, and leaves the previous reading in place. A first sample that
|
||||
// fails therefore leaves the state at `.ok` — an unreadable filesystem is
|
||||
// not evidence that the disk is full, which is the monitor's documented
|
||||
// policy and the right one here too.
|
||||
monitor.sample(io);
|
||||
|
||||
var retention: retention_mod.Retention = .init(cfg.logging);
|
||||
|
||||
var querylog_writer_db = try data.openQuerylogDb(io);
|
||||
defer querylog_writer_db.close();
|
||||
var querylog_retention_db = try data.reopenQuerylogDb(io);
|
||||
defer querylog_retention_db.close();
|
||||
var tracker_db = try data.openConfigDb(io);
|
||||
defer tracker_db.close();
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// first snapshot
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// Ruling 4: serving starts either way. A household loses more from a DNS
|
||||
// server that refuses to start than from one that answers unfiltered until
|
||||
// the scheduler's first pass succeeds.
|
||||
manager.reload(io) catch |err| {
|
||||
log.warn(
|
||||
"loading the blocklist snapshot failed ({s}); serving unfiltered until the next refresh",
|
||||
.{@errorName(err)},
|
||||
);
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// listeners
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
var h: handler.Handler = .{
|
||||
.upstream = pool.client(),
|
||||
.blocking = .{ .mode = cfg.blocking.response, .ttl = cfg.blocking.ttl },
|
||||
.ecs_mode = cfg.edns.ecs_mode,
|
||||
.forward_read_timeout = .{ .raw = model.readTimeout(cfg.upstream), .clock = .awake },
|
||||
.manager = &manager,
|
||||
.records = &records,
|
||||
.zones = &zones,
|
||||
.cache = &cache,
|
||||
.negative_ttl_max = cfg.cache.negative_ttl_max,
|
||||
.limiter = &limiter,
|
||||
.logger = &query_logger,
|
||||
.pause = &paused,
|
||||
.tracker = &tracker,
|
||||
};
|
||||
|
||||
const v6_bind = parseBind(r, cfg.dns.bind_ipv6, cfg.dns.port, "dns.bind_ipv6") catch |err| return err;
|
||||
const v4_bind = parseBind(r, cfg.dns.bind_ipv4, cfg.dns.port, "dns.bind_ipv4") catch |err| return err;
|
||||
|
||||
// IPv6 first, and the order is load-bearing — see `Listeners`.
|
||||
var udp6: ?udp_server.UdpServer = udp_server.UdpServer.bind(gpa, io, v6_bind, &h, .{}) catch |err| bound: {
|
||||
if (!ipv6Unavailable(err)) return reportBind(r, "udp", v6_bind, err);
|
||||
log.warn("this system has no IPv6; serving IPv4 only", .{});
|
||||
break :bound null;
|
||||
};
|
||||
defer if (udp6) |*s| s.deinit(gpa, io);
|
||||
|
||||
const v6_is_wildcard = udp6 != null and isWildcard(v6_bind);
|
||||
|
||||
var udp4: ?udp_server.UdpServer = udp_server.UdpServer.bind(gpa, io, v4_bind, &h, .{}) catch |err| bound: {
|
||||
if (err != error.AddressInUse or !v6_is_wildcard) return reportBind(r, "udp", v4_bind, err);
|
||||
log.info("the IPv6 UDP listener is dual-stack and already serves IPv4", .{});
|
||||
break :bound null;
|
||||
};
|
||||
defer if (udp4) |*s| s.deinit(gpa, io);
|
||||
|
||||
var tcp6: ?tcp_server.TcpServer = tcp_server.TcpServer.listen(gpa, io, v6_bind, &h, .{}) catch |err| bound: {
|
||||
if (!ipv6Unavailable(err)) return reportBind(r, "tcp", v6_bind, err);
|
||||
break :bound null;
|
||||
};
|
||||
defer if (tcp6) |*s| s.deinit(gpa, io);
|
||||
|
||||
var tcp4: ?tcp_server.TcpServer = tcp_server.TcpServer.listen(gpa, io, v4_bind, &h, .{}) catch |err| bound: {
|
||||
if (err != error.AddressInUse or !(tcp6 != null and isWildcard(v6_bind))) {
|
||||
return reportBind(r, "tcp", v4_bind, err);
|
||||
}
|
||||
log.info("the IPv6 TCP listener is dual-stack and already serves IPv4", .{});
|
||||
break :bound null;
|
||||
};
|
||||
defer if (tcp4) |*s| s.deinit(gpa, io);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// run
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
shutdown.install(io);
|
||||
|
||||
// Declared after everything it borrows, so its `cancel` — which both
|
||||
// requests cancellation and joins — is the first thing that runs on the way
|
||||
// out (ruling 22). Nothing below this line may be released while a task
|
||||
// could still touch it.
|
||||
var group: std.Io.Group = .init;
|
||||
defer group.cancel(io);
|
||||
|
||||
if (udp6) |*s| try group.concurrent(io, udp_server.UdpServer.serve, .{ s, io });
|
||||
if (udp4) |*s| try group.concurrent(io, udp_server.UdpServer.serve, .{ s, io });
|
||||
if (tcp6) |*s| try group.concurrent(io, tcp_server.TcpServer.serve, .{ s, io });
|
||||
if (tcp4) |*s| try group.concurrent(io, tcp_server.TcpServer.serve, .{ s, io });
|
||||
|
||||
const gate: ?*disk_monitor.Monitor = &monitor;
|
||||
try group.concurrent(io, logger_mod.Logger.runWriter, .{ &query_logger, io, &querylog_writer_db, gate });
|
||||
try group.concurrent(io, retention_mod.Retention.run, .{ &retention, io, &querylog_retention_db });
|
||||
try group.concurrent(io, disk_monitor.Monitor.run, .{ &monitor, io });
|
||||
try group.concurrent(io, manager_mod.Manager.runScheduler, .{ &manager, io });
|
||||
try group.concurrent(io, clients.Tracker.run, .{ &tracker, io, &tracker_db, gate });
|
||||
try group.concurrent(io, runMaintenance, .{ &h, io });
|
||||
|
||||
logStartup(io, &manager, upstreams.active().len, .{
|
||||
.udp6 = if (udp6) |*s| s.boundAddress() else null,
|
||||
.udp4 = if (udp4) |*s| s.boundAddress() else null,
|
||||
.tcp6 = if (tcp6) |*s| s.boundAddress() else null,
|
||||
.tcp4 = if (tcp4) |*s| s.boundAddress() else null,
|
||||
});
|
||||
|
||||
// A canceled wait is a shutdown request too: whoever canceled this task
|
||||
// wants the process to stop, and the teardown below is how it stops.
|
||||
shutdown.wait(io) catch {};
|
||||
log.info("shutting down", .{});
|
||||
|
||||
// Before the group is canceled, so the writer sees a closed queue and
|
||||
// drains what it holds rather than losing it to cancellation (ruling 22).
|
||||
query_logger.shutdown(io);
|
||||
|
||||
return cli.exit_ok;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// background maintenance
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Sweeps the cache and the rate-limiter table. Both are guarded by mutexes the
|
||||
/// handler owns, because the handler is what contends for them; the sweeps live
|
||||
/// here because walking a whole table is not work a query should pay for.
|
||||
///
|
||||
/// The locks are taken cancelably: unlike `handle`, this loop has an error
|
||||
/// union to carry `error.Canceled` out of, and a shutdown that arrives while
|
||||
/// the query path holds a lock should not wait for it.
|
||||
fn runMaintenance(h: *handler.Handler, io: std.Io) std.Io.Cancelable!void {
|
||||
const interval: std.Io.Clock.Duration = .{
|
||||
.raw = .fromSeconds(maintenance_interval_s),
|
||||
.clock = .boot,
|
||||
};
|
||||
while (true) {
|
||||
try interval.sleep(io);
|
||||
|
||||
if (h.cache) |cache| {
|
||||
const now_s = std.Io.Clock.real.now(io).toSeconds();
|
||||
try h.cache_mutex.lock(io);
|
||||
_ = cache.sweep(now_s);
|
||||
h.cache_mutex.unlock(io);
|
||||
}
|
||||
|
||||
if (h.limiter) |limiter| {
|
||||
const now = std.Io.Clock.awake.now(io);
|
||||
try h.limiter_mutex.lock(io);
|
||||
_ = limiter.sweep(now);
|
||||
h.limiter_mutex.unlock(io);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// upstreams
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The pool's entries and everything they point into.
|
||||
///
|
||||
/// `Pool.Entry.client` is a type-erased pointer into `doh` or `dot`, and each
|
||||
/// client borrows a slice of `doh_buf`/`dot_buf`, so all five allocations live
|
||||
/// exactly as long as the pool does. One entry is used by one task at a time
|
||||
/// (`Entry.busy`), which is why the buffers are per client and not shared the
|
||||
/// way `cli.probeUpstreams` shares them.
|
||||
const Upstreams = struct {
|
||||
entries: []pool_mod.Entry,
|
||||
used: usize,
|
||||
doh: []doh_client.DohClient,
|
||||
dot: []dot_client.DotClient,
|
||||
doh_buf: []u8,
|
||||
dot_buf: []u8,
|
||||
|
||||
/// A disabled upstream is left out entirely; a malformed one warns and is
|
||||
/// skipped, because one bad row in a table of four must not take DNS down.
|
||||
/// No usable row at all is a configuration fault.
|
||||
fn build(
|
||||
gpa: Allocator,
|
||||
servers: []const model.UpstreamServer,
|
||||
http: *std.http.Client,
|
||||
bundle: *Certificate.Bundle,
|
||||
bundle_lock: *std.Io.RwLock,
|
||||
) (Allocator.Error || ConfigError)!Upstreams {
|
||||
var enabled: usize = 0;
|
||||
for (servers) |server| {
|
||||
if (server.enabled) enabled += 1;
|
||||
}
|
||||
if (enabled == 0) return error.NoUsableUpstreams;
|
||||
|
||||
const chunk = tls.Client.min_buffer_len;
|
||||
|
||||
var self: Upstreams = .{
|
||||
.entries = try gpa.alloc(pool_mod.Entry, enabled),
|
||||
.used = 0,
|
||||
.doh = &.{},
|
||||
.dot = &.{},
|
||||
.doh_buf = &.{},
|
||||
.dot_buf = &.{},
|
||||
};
|
||||
errdefer self.deinit(gpa);
|
||||
|
||||
self.doh = try gpa.alloc(doh_client.DohClient, enabled);
|
||||
self.dot = try gpa.alloc(dot_client.DotClient, enabled);
|
||||
self.doh_buf = try gpa.alloc(u8, enabled * (doh_request_buf_len + doh_transfer_buf_len));
|
||||
self.dot_buf = try gpa.alloc(u8, enabled * 4 * chunk);
|
||||
|
||||
var doh_count: usize = 0;
|
||||
var dot_count: usize = 0;
|
||||
|
||||
for (servers) |server| {
|
||||
if (!server.enabled) continue;
|
||||
|
||||
const endpoint = transport.Endpoint.parse(server.url) catch {
|
||||
log.warn("upstream '{s}' is not an https:// or tls:// endpoint; skipped", .{server.url});
|
||||
continue;
|
||||
};
|
||||
|
||||
const client: transport.Client = switch (endpoint.scheme) {
|
||||
.doh => doh: {
|
||||
const base = doh_count * (doh_request_buf_len + doh_transfer_buf_len);
|
||||
const slot = &self.doh[doh_count];
|
||||
slot.* = doh_client.DohClient.init(
|
||||
http,
|
||||
endpoint,
|
||||
self.doh_buf[base..][0..doh_request_buf_len],
|
||||
self.doh_buf[base + doh_request_buf_len ..][0..doh_transfer_buf_len],
|
||||
) catch {
|
||||
log.warn("upstream '{s}' is not a usable DoH url; skipped", .{server.url});
|
||||
continue;
|
||||
};
|
||||
doh_count += 1;
|
||||
break :doh slot.client();
|
||||
},
|
||||
.dot => dot: {
|
||||
const base = dot_count * 4 * chunk;
|
||||
const slot = &self.dot[dot_count];
|
||||
slot.* = dot_client.DotClient.init(endpoint, server.tls_name, gpa, bundle, bundle_lock, .{
|
||||
.tls_read = self.dot_buf[base..][0..chunk],
|
||||
.tls_write = self.dot_buf[base + chunk ..][0..chunk],
|
||||
.stream_read = self.dot_buf[base + 2 * chunk ..][0..chunk],
|
||||
.stream_write = self.dot_buf[base + 3 * chunk ..][0..chunk],
|
||||
});
|
||||
dot_count += 1;
|
||||
break :dot slot.client();
|
||||
},
|
||||
};
|
||||
|
||||
self.entries[self.used] = .{
|
||||
.endpoint = endpoint,
|
||||
.client = client,
|
||||
.priority = server.priority,
|
||||
.enabled = true,
|
||||
.health = .init,
|
||||
};
|
||||
self.used += 1;
|
||||
}
|
||||
|
||||
if (self.used == 0) return error.NoUsableUpstreams;
|
||||
return self;
|
||||
}
|
||||
|
||||
/// The prefix `Pool.init` is given. The rest of `entries` is allocated but
|
||||
/// never filled, which is what keeps `deinit` able to free the whole block.
|
||||
fn active(self: *Upstreams) []pool_mod.Entry {
|
||||
return self.entries[0..self.used];
|
||||
}
|
||||
|
||||
fn deinit(self: *Upstreams, gpa: Allocator) void {
|
||||
gpa.free(self.dot_buf);
|
||||
gpa.free(self.doh_buf);
|
||||
gpa.free(self.dot);
|
||||
gpa.free(self.doh);
|
||||
gpa.free(self.entries);
|
||||
self.* = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// listeners
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The addresses actually bound, for the startup line. A null is a family this
|
||||
/// process does not listen on separately — either because the system has no
|
||||
/// IPv6, or because the IPv6 socket is dual-stack and already serves IPv4.
|
||||
const Listeners = struct {
|
||||
udp6: ?net.IpAddress,
|
||||
udp4: ?net.IpAddress,
|
||||
tcp6: ?net.IpAddress,
|
||||
tcp4: ?net.IpAddress,
|
||||
};
|
||||
|
||||
fn parseBind(r: cli.Runner, text: []const u8, port: u16, field: []const u8) !net.IpAddress {
|
||||
return net.IpAddress.parse(text, port) catch {
|
||||
r.err.print("{s}: '{s}' is not an IP address\n", .{ field, text }) catch {};
|
||||
return error.BadBindAddress;
|
||||
};
|
||||
}
|
||||
|
||||
fn reportBind(r: cli.Runner, which: []const u8, addr: net.IpAddress, err: anyerror) anyerror {
|
||||
r.err.print("cannot bind {s} {f}: {s}\n", .{ which, addr, @errorName(err) }) catch {};
|
||||
return err;
|
||||
}
|
||||
|
||||
/// The kernel has no IPv6 at all. Refusing to start would be the wrong answer
|
||||
/// for the default configuration, which asks for both families.
|
||||
fn ipv6Unavailable(err: anyerror) bool {
|
||||
return switch (err) {
|
||||
error.AddressFamilyUnsupported,
|
||||
error.ProtocolUnsupportedBySystem,
|
||||
error.ProtocolUnsupportedByAddressFamily,
|
||||
=> true,
|
||||
else => false,
|
||||
};
|
||||
}
|
||||
|
||||
fn isWildcard(addr: net.IpAddress) bool {
|
||||
switch (addr) {
|
||||
.ip4 => |a| {
|
||||
for (a.bytes) |b| if (b != 0) return false;
|
||||
},
|
||||
.ip6 => |a| {
|
||||
for (a.bytes) |b| if (b != 0) return false;
|
||||
},
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// startup line
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// One line, at info, naming what an operator needs to see in `journalctl`
|
||||
/// right after a restart: where it listens, how many upstreams it has, and
|
||||
/// whether filtering is live.
|
||||
fn logStartup(io: std.Io, manager: *manager_mod.Manager, upstream_count: usize, bound: Listeners) void {
|
||||
var buf: [256]u8 = undefined;
|
||||
var w: Writer = .fixed(&buf);
|
||||
appendBind(&w, "udp", bound.udp6);
|
||||
appendBind(&w, "udp", bound.udp4);
|
||||
appendBind(&w, "tcp", bound.tcp6);
|
||||
appendBind(&w, "tcp", bound.tcp4);
|
||||
|
||||
// The generation is read under the snapshot handle's shared lock, which is
|
||||
// the same lock the reload took to publish it.
|
||||
if (manager.acquire(io)) |snapshot| {
|
||||
defer snapshot.release(io);
|
||||
log.info("nxdns {s} serving on{s}; {d} upstream(s); blocklist generation {d}", .{
|
||||
version.string,
|
||||
w.buffered(),
|
||||
upstream_count,
|
||||
manager.generation,
|
||||
});
|
||||
} else {
|
||||
log.info("nxdns {s} serving on{s}; {d} upstream(s); unfiltered (no blocklist snapshot)", .{
|
||||
version.string,
|
||||
w.buffered(),
|
||||
upstream_count,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Silent on overflow: a truncated startup line is not worth a failure path,
|
||||
/// and 256 bytes hold four addresses.
|
||||
fn appendBind(w: *Writer, which: []const u8, addr: ?net.IpAddress) void {
|
||||
const value = addr orelse return;
|
||||
w.print(" {s} {f}", .{ which, value }) catch {};
|
||||
}
|
||||
+59
-13
@@ -18,12 +18,14 @@ const Writer = std.Io.Writer;
|
||||
const Certificate = std.crypto.Certificate;
|
||||
const tls = std.crypto.tls;
|
||||
|
||||
const app = @import("app.zig");
|
||||
const config_export = @import("config/export.zig");
|
||||
const import = @import("config/import.zig");
|
||||
const model = @import("config/model.zig");
|
||||
const validate = @import("config/validate.zig");
|
||||
const db = @import("storage/db.zig");
|
||||
const migrations = @import("storage/migrations.zig");
|
||||
const querylog_schema = @import("storage/querylog_schema.zig");
|
||||
const doh_client = @import("upstream/doh_client.zig");
|
||||
const dot_client = @import("upstream/dot_client.zig");
|
||||
const pool = @import("upstream/pool.zig");
|
||||
@@ -204,7 +206,9 @@ pub const DataDir = struct {
|
||||
_ = try std.Io.Dir.cwd().createDirPathStatus(io, data_dir, .fromMode(0o700));
|
||||
}
|
||||
|
||||
var dir = try std.Io.Dir.cwd().openDir(io, data_dir, .{});
|
||||
// `.iterate`: the disk monitor sizes the databases by scanning this
|
||||
// directory, and an fd opened without it cannot be read as a directory.
|
||||
var dir = try std.Io.Dir.cwd().openDir(io, data_dir, .{ .iterate = true });
|
||||
errdefer dir.close(io);
|
||||
|
||||
// SQLite opens by path, not by directory handle, so both paths are
|
||||
@@ -243,6 +247,54 @@ pub const DataDir = struct {
|
||||
try db.applyPragmas(&database, .{});
|
||||
return database;
|
||||
}
|
||||
|
||||
/// The first connection to `querylog.db`: `querylog_schema.open` creates the
|
||||
/// file when it is missing and recreates it when it is unusable, so this is
|
||||
/// the call that establishes the schema. `reopenQuerylogDb` is for the
|
||||
/// connections that follow.
|
||||
///
|
||||
/// The 0600 chmod cannot come first the way `openConfigDb` does it — the
|
||||
/// file may not exist yet, and a recreate replaces it — so the main file and
|
||||
/// both WAL sidecars are locked down afterwards instead. A query log holds
|
||||
/// every domain every client asked for, which is as sensitive as anything in
|
||||
/// `config.db`.
|
||||
///
|
||||
/// `querylog_schema.open` resolves the path through SQLite's VFS as well as
|
||||
/// through the directory handle, so it is given `cwd` and the joined path
|
||||
/// rather than `self.dir` and a name (see its doc comment).
|
||||
pub fn openQuerylogDb(self: *const DataDir, io: std.Io) !db.Db {
|
||||
const opened = try querylog_schema.open(io, std.Io.Dir.cwd(), self.querylog_db_path);
|
||||
var database = opened.database;
|
||||
errdefer database.close();
|
||||
try self.restrictQuerylogPermissions(io);
|
||||
return database;
|
||||
}
|
||||
|
||||
/// An additional connection to a `querylog.db` that `openQuerylogDb` has
|
||||
/// already established. Phase 7 needs two — the log writer and the retention
|
||||
/// pass each own one (`retention.zig`'s contract).
|
||||
pub fn reopenQuerylogDb(self: *const DataDir, io: std.Io) !db.Db {
|
||||
_ = io;
|
||||
var database = try db.Db.open(self.querylog_db_path, .{ .mode = .read_write_existing });
|
||||
errdefer database.close();
|
||||
try db.applyPragmas(&database, .{});
|
||||
return database;
|
||||
}
|
||||
|
||||
/// A sidecar that does not exist yet is not a failure: `-wal` and `-shm`
|
||||
/// appear when SQLite first writes, and the next call catches them.
|
||||
fn restrictQuerylogPermissions(self: *const DataDir, io: std.Io) !void {
|
||||
for ([_][]const u8{
|
||||
querylog_db_name,
|
||||
querylog_db_name ++ "-wal",
|
||||
querylog_db_name ++ "-shm",
|
||||
}) |entry| {
|
||||
self.dir.setFilePermissions(io, entry, .fromMode(0o600), .{}) catch |e| switch (e) {
|
||||
error.FileNotFound => {},
|
||||
else => |other| return other,
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -320,12 +372,11 @@ pub fn runVersion(r: Runner) u8 {
|
||||
return finish(r, exit_ok);
|
||||
}
|
||||
|
||||
/// Unchanged from milestone 1. Wiring the configuration into the servers is
|
||||
/// Phase 7; there is deliberately no half-built serving path here.
|
||||
/// Serves DNS until SIGINT or SIGTERM. The whole of it lives in `app.zig`,
|
||||
/// which is where the composition root belongs; this stays the entry point so
|
||||
/// that `main` dispatches every command the same way.
|
||||
pub fn runRun(r: Runner, paths: Paths) u8 {
|
||||
_ = paths;
|
||||
r.out.writeAll("not implemented\n") catch return finish(r, exit_runtime);
|
||||
return finish(r, exit_check);
|
||||
return app.run(r, paths);
|
||||
}
|
||||
|
||||
pub fn runExport(r: Runner, args: ExportArgs) u8 {
|
||||
@@ -879,13 +930,8 @@ test "runVersion prints the milestone-1 version lines and exits 0" {
|
||||
try testing.expectEqual(@as(usize, 2), countLines(captured.out.written()));
|
||||
}
|
||||
|
||||
test "runRun still reports that serving is not implemented" {
|
||||
var captured: Captured = .init(testing.allocator);
|
||||
defer captured.deinit();
|
||||
|
||||
try testing.expectEqual(exit_check, runRun(captured.runner(), .{}));
|
||||
try testing.expectEqualStrings("not implemented\n", captured.out.written());
|
||||
}
|
||||
// `runRun` now binds sockets and serves until a signal arrives, so it has no
|
||||
// unit test: booting it is `src/server/phase7_integration_test.zig`'s case 11.
|
||||
|
||||
test "runUsageError names the fault and prints the usage text" {
|
||||
var captured: Captured = .init(testing.allocator);
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
const std = @import("std");
|
||||
const types = @import("types.zig");
|
||||
const record = @import("record.zig");
|
||||
const question = @import("question.zig");
|
||||
const packet_mod = @import("packet.zig");
|
||||
const Writer = std.Io.Writer;
|
||||
|
||||
/// The EDNS Client Subnet option code (RFC 7871 §6).
|
||||
@@ -159,6 +161,111 @@ fn ttlFrom(opt: OptRecord) u32 {
|
||||
(@as(u32, @intFromBool(opt.do_bit)) << 15);
|
||||
}
|
||||
|
||||
pub const StripResult = union(enum) {
|
||||
/// The query needs no rewrite and nothing was written to `out`.
|
||||
unchanged,
|
||||
/// The rewritten query, a prefix of `out`.
|
||||
rewritten: []u8,
|
||||
};
|
||||
|
||||
/// Byte offset of RDLENGTH inside an OPT record as `encodeOpt` writes it: a
|
||||
/// one-byte root owner name, TYPE, CLASS and TTL come first.
|
||||
const opt_rdlength_offset = 1 + 2 + 2 + 4;
|
||||
|
||||
/// Rebuilds `query` into `out` without its ECS option, so that a client's
|
||||
/// subnet never reaches the upstream resolver (PLAN §6.1). Every byte outside
|
||||
/// the OPT record is copied verbatim — ID, flags, all four section counts, the
|
||||
/// question and any record beside the OPT — and the OPT keeps its payload size,
|
||||
/// its flags and every option other than code 8.
|
||||
///
|
||||
/// `pkt` and `opt` are the caller's already-parsed views of `query`, and `out`
|
||||
/// must not overlap `query`.
|
||||
///
|
||||
/// One byte pattern does not survive the rewrite: a compression pointer in a
|
||||
/// record that follows the OPT and targets a byte inside the OPT's option list.
|
||||
/// Removing an option shifts those bytes, so the pointer then decodes to
|
||||
/// something else. RFC 1035 §4.1.4 only ever points a name at an earlier *name*,
|
||||
/// so such a pointer is malformed to begin with, and the upstream resolver
|
||||
/// answers the rewritten query with FORMERR instead of the original's answer.
|
||||
pub fn stripEcs(
|
||||
query: []const u8,
|
||||
pkt: packet_mod.Packet,
|
||||
opt: OptRecord,
|
||||
out: []u8,
|
||||
) error{ BadOption, Overflow }!StripResult {
|
||||
std.debug.assert(query.ptr == pkt.bytes.ptr);
|
||||
std.debug.assert(query.len == pkt.bytes.len);
|
||||
|
||||
// The rebuild reads `query` while it writes `out`, so an overlap would let
|
||||
// it consume bytes it has already overwritten. The two buffers must be
|
||||
// disjoint, and the caller finds that out here rather than in the packet it
|
||||
// sends upstream.
|
||||
std.debug.assert(@intFromPtr(out.ptr) + out.len <= @intFromPtr(query.ptr) or
|
||||
@intFromPtr(query.ptr) + query.len <= @intFromPtr(out.ptr));
|
||||
|
||||
const opt_start = (try optRecordStart(pkt, opt)) orelse return .unchanged;
|
||||
if ((try findOption(query, opt, ecs_option_code)) == null) return .unchanged;
|
||||
|
||||
var w = Writer.fixed(out);
|
||||
w.writeAll(query[0..opt_start]) catch return error.Overflow;
|
||||
encodeOpt(opt, &.{}, &w) catch |err| switch (err) {
|
||||
error.OptionsTooLong => unreachable, // the list written here is empty
|
||||
error.WriteFailed => return error.Overflow,
|
||||
};
|
||||
|
||||
// RDLENGTH is only known once the list has been filtered, and the surviving
|
||||
// options are not contiguous in `query`, so `encodeOpt` writes a placeholder
|
||||
// and the options stream in behind it.
|
||||
var kept_len: usize = 0;
|
||||
var it = options(query, opt);
|
||||
while (try it.next()) |o| {
|
||||
if (o.code == ecs_option_code) continue;
|
||||
var option_header: [4]u8 = undefined;
|
||||
std.mem.writeInt(u16, option_header[0..2], o.code, .big);
|
||||
std.mem.writeInt(u16, option_header[2..4], @intCast(o.data.len), .big);
|
||||
w.writeAll(&option_header) catch return error.Overflow;
|
||||
w.writeAll(o.data) catch return error.Overflow;
|
||||
kept_len += option_header.len + o.data.len;
|
||||
}
|
||||
|
||||
w.writeAll(query[opt.options.offset + opt.options.len ..]) catch return error.Overflow;
|
||||
|
||||
const message = w.buffered();
|
||||
std.mem.writeInt(u16, message[opt_start + opt_rdlength_offset ..][0..2], @intCast(kept_len), .big);
|
||||
return .{ .rewritten = message };
|
||||
}
|
||||
|
||||
/// Where the record holding `opt` begins, or null when `pkt` has no such
|
||||
/// record. A record's start offset is not part of a parsed view — only a walk
|
||||
/// of every section before it reaches that byte — so this repeats the walk
|
||||
/// `packet.parse` already did.
|
||||
///
|
||||
/// The match is on the whole RDATA span, which keeps a hand-built `OptRecord`
|
||||
/// from pointing this function at a different record than the one it describes.
|
||||
/// A section that will not walk lands on `error.BadOption` for the same reason:
|
||||
/// a `Packet` from `packet.parse` always walks, so only a `Packet` assembled by
|
||||
/// hand around unvalidated bytes gets here.
|
||||
fn optRecordStart(pkt: packet_mod.Packet, opt: OptRecord) error{BadOption}!?usize {
|
||||
var pos: usize = types.header_len;
|
||||
var q: u16 = 0;
|
||||
while (q < pkt.header.qdcount) : (q += 1) {
|
||||
const parsed = question.parse(pkt.bytes, pos) catch return error.BadOption;
|
||||
pos = parsed.end;
|
||||
}
|
||||
|
||||
const record_count = @as(u32, pkt.header.ancount) +
|
||||
@as(u32, pkt.header.nscount) + @as(u32, pkt.header.arcount);
|
||||
var r: u32 = 0;
|
||||
while (r < record_count) : (r += 1) {
|
||||
const parsed = record.parse(pkt.bytes, pos) catch return error.BadOption;
|
||||
if (parsed.record.rtype == .opt and
|
||||
parsed.record.rdata.offset == opt.options.offset and
|
||||
parsed.record.rdata.len == opt.options.len) return pos;
|
||||
pos = parsed.end;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// The full 12-bit RCODE (RFC 6891 §6.1.3): the OPT record supplies the upper
|
||||
/// eight bits, the header the lower four. Without an OPT record the value is
|
||||
/// just the header's four bits.
|
||||
@@ -406,6 +513,172 @@ test "encodeOpt reports a short buffer" {
|
||||
try testing.expectError(error.WriteFailed, encodeOpt(opt, "", &w));
|
||||
}
|
||||
|
||||
/// A query for example.com A whose OPT record carries an ECS option for
|
||||
/// 192.0.2.0/24 followed by a two-byte padding option, with DO set and a
|
||||
/// 4096-byte payload size.
|
||||
const ecs_query =
|
||||
"\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x01" ++ // header
|
||||
"\x07example\x03com\x00\x00\x01\x00\x01" ++ // question
|
||||
"\x00\x00\x29\x10\x00\x00\x00\x80\x00\x00\x11" ++ // OPT, 17 rdata bytes
|
||||
"\x00\x08\x00\x07\x00\x01\x18\x00\xc0\x00\x02" ++ // ECS
|
||||
"\x00\x0c\x00\x02\x00\x00"; // padding
|
||||
|
||||
/// `ecs_query` as `stripEcs` must rebuild it.
|
||||
const ecs_query_stripped =
|
||||
"\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x01" ++
|
||||
"\x07example\x03com\x00\x00\x01\x00\x01" ++
|
||||
"\x00\x00\x29\x10\x00\x00\x00\x80\x00\x00\x06" ++
|
||||
"\x00\x0c\x00\x02\x00\x00";
|
||||
|
||||
const ParsedQuery = struct { pkt: packet_mod.Packet, opt: OptRecord };
|
||||
|
||||
fn parseQuery(bytes: []const u8) !ParsedQuery {
|
||||
const p = try packet_mod.parse(bytes);
|
||||
return .{ .pkt = p, .opt = try parseOpt(bytes, packet_mod.findOptRecord(p).?) };
|
||||
}
|
||||
|
||||
fn expectUnchanged(result: StripResult) !void {
|
||||
switch (result) {
|
||||
.unchanged => {},
|
||||
.rewritten => return error.TestExpectedUnchanged,
|
||||
}
|
||||
}
|
||||
|
||||
test "stripEcs rebuilds the query without the ECS option" {
|
||||
const parsed = try parseQuery(ecs_query);
|
||||
var out: [512]u8 = undefined;
|
||||
const result = try stripEcs(ecs_query, parsed.pkt, parsed.opt, &out);
|
||||
const rewritten = result.rewritten;
|
||||
try testing.expectEqualSlices(u8, ecs_query_stripped, rewritten);
|
||||
|
||||
const p = try packet_mod.parse(rewritten);
|
||||
try testing.expectEqual(@as(u16, 0x1234), p.header.id);
|
||||
try testing.expectEqual(@as(u16, 1), p.header.qdcount);
|
||||
try testing.expectEqual(@as(u16, 1), p.header.arcount);
|
||||
|
||||
const opt = try parseOpt(rewritten, packet_mod.findOptRecord(p).?);
|
||||
try testing.expectEqual(@as(u16, 4096), opt.udp_payload_size);
|
||||
try testing.expectEqual(true, opt.do_bit);
|
||||
try testing.expectEqual(@as(?Option, null), try findOption(rewritten, opt, ecs_option_code));
|
||||
try testing.expectEqualSlices(u8, "\x00\x00", (try findOption(rewritten, opt, 12)).?.data);
|
||||
}
|
||||
|
||||
test "stripEcs reports a query with nothing to strip as unchanged" {
|
||||
const parsed = try parseQuery(ecs_query_stripped);
|
||||
var out: [512]u8 = undefined;
|
||||
try expectUnchanged(try stripEcs(ecs_query_stripped, parsed.pkt, parsed.opt, &out));
|
||||
|
||||
// No OPT record at all: the caller still holds an `OptRecord`, so the
|
||||
// absence has to be found by the walk rather than assumed.
|
||||
const no_opt = "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00" ++
|
||||
"\x07example\x03com\x00\x00\x01\x00\x01";
|
||||
const p = try packet_mod.parse(no_opt);
|
||||
try testing.expectEqual(@as(?record.Record, null), packet_mod.findOptRecord(p));
|
||||
const absent: OptRecord = .{
|
||||
.udp_payload_size = 4096,
|
||||
.extended_rcode = 0,
|
||||
.version = 0,
|
||||
.do_bit = false,
|
||||
.options = .{ .offset = 0, .len = 0 },
|
||||
};
|
||||
try expectUnchanged(try stripEcs(no_opt, p, absent, &out));
|
||||
}
|
||||
|
||||
test "stripEcs keeps a record that follows the OPT" {
|
||||
const head = "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x02" ++
|
||||
"\x07example\x03com\x00\x00\x01\x00\x01";
|
||||
const trailing = "\x00\x00\x01\x00\x01\x00\x00\x00\x0a\x00\x04\x01\x02\x03\x04";
|
||||
const query = head ++
|
||||
"\x00\x00\x29\x10\x00\x00\x00\x80\x00\x00\x0b" ++
|
||||
"\x00\x08\x00\x07\x00\x01\x18\x00\xc0\x00\x02" ++
|
||||
trailing;
|
||||
|
||||
const parsed = try parseQuery(query);
|
||||
var out: [512]u8 = undefined;
|
||||
const rewritten = (try stripEcs(query, parsed.pkt, parsed.opt, &out)).rewritten;
|
||||
try testing.expectEqualSlices(
|
||||
u8,
|
||||
head ++ "\x00\x00\x29\x10\x00\x00\x00\x80\x00\x00\x00" ++ trailing,
|
||||
rewritten,
|
||||
);
|
||||
try testing.expectEqual(@as(u16, 2), (try packet_mod.parse(rewritten)).header.arcount);
|
||||
}
|
||||
|
||||
test "stripEcs removes a repeated ECS option" {
|
||||
const ecs = "\x00\x08\x00\x07\x00\x01\x18\x00\xc0\x00\x02";
|
||||
const query = "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x01" ++
|
||||
"\x07example\x03com\x00\x00\x01\x00\x01" ++
|
||||
"\x00\x00\x29\x10\x00\x00\x00\x80\x00\x00\x1c" ++
|
||||
ecs ++ "\x00\x0c\x00\x02\x00\x00" ++ ecs;
|
||||
|
||||
const parsed = try parseQuery(query);
|
||||
var out: [512]u8 = undefined;
|
||||
const rewritten = (try stripEcs(query, parsed.pkt, parsed.opt, &out)).rewritten;
|
||||
try testing.expectEqualSlices(u8, ecs_query_stripped, rewritten);
|
||||
}
|
||||
|
||||
test "stripEcs reports an output buffer one byte short" {
|
||||
const parsed = try parseQuery(ecs_query);
|
||||
|
||||
var exact: [ecs_query_stripped.len]u8 = undefined;
|
||||
_ = (try stripEcs(ecs_query, parsed.pkt, parsed.opt, &exact)).rewritten;
|
||||
|
||||
var short: [ecs_query_stripped.len - 1]u8 = undefined;
|
||||
try testing.expectError(error.Overflow, stripEcs(ecs_query, parsed.pkt, parsed.opt, &short));
|
||||
|
||||
// Every shorter buffer fails the same way, including one that cannot even
|
||||
// hold the copied header.
|
||||
var i: usize = 0;
|
||||
while (i < short.len) : (i += 1) {
|
||||
try testing.expectError(
|
||||
error.Overflow,
|
||||
stripEcs(ecs_query, parsed.pkt, parsed.opt, short[0..i]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
test "stripEcs rejects a malformed option list" {
|
||||
// The record is well-formed; only its option list overruns, so `parseOpt`
|
||||
// is the one thing that rejects this packet.
|
||||
const query = "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x01" ++
|
||||
"\x07example\x03com\x00\x00\x01\x00\x01" ++
|
||||
"\x00\x00\x29\x10\x00\x00\x00\x00\x00\x00\x08" ++
|
||||
"\x00\x08\x00\x08\x00\x01\x18\x00";
|
||||
|
||||
const p = try packet_mod.parse(query);
|
||||
const rec = packet_mod.findOptRecord(p).?;
|
||||
try testing.expectError(error.BadOption, parseOpt(query, rec));
|
||||
|
||||
const hand_built: OptRecord = .{
|
||||
.udp_payload_size = rec.class,
|
||||
.extended_rcode = 0,
|
||||
.version = 0,
|
||||
.do_bit = false,
|
||||
.options = rec.rdata,
|
||||
};
|
||||
|
||||
var out: [512]u8 = undefined;
|
||||
try testing.expectError(error.BadOption, stripEcs(query, p, hand_built, &out));
|
||||
}
|
||||
|
||||
test "encodeOpt writes RDLENGTH where stripEcs patches it" {
|
||||
const opt: OptRecord = .{
|
||||
.udp_payload_size = 4096,
|
||||
.extended_rcode = 0,
|
||||
.version = 0,
|
||||
.do_bit = true,
|
||||
.options = .{ .offset = 0, .len = 0 },
|
||||
};
|
||||
var buf: [32]u8 = undefined;
|
||||
var w = Writer.fixed(&buf);
|
||||
try encodeOpt(opt, "\x00\x0c\x00\x02\x00\x00", &w);
|
||||
const bytes = w.buffered();
|
||||
try testing.expectEqual(
|
||||
@as(u16, 6),
|
||||
std.mem.readInt(u16, bytes[opt_rdlength_offset..][0..2], .big),
|
||||
);
|
||||
}
|
||||
|
||||
test "extendedRcode composes the twelve bits" {
|
||||
try testing.expectEqual(@as(u12, 3), extendedRcode(.nx_domain, null));
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ const clients_repo = @import("../storage/repositories/clients_repo.zig");
|
||||
const groups_repo = @import("../storage/repositories/groups_repo.zig");
|
||||
const rules_repo = @import("../storage/repositories/rules_repo.zig");
|
||||
const sources_repo = @import("../storage/repositories/sources_repo.zig");
|
||||
const disk_monitor = @import("../storage/disk_monitor.zig");
|
||||
const compiler = @import("compiler.zig");
|
||||
const fetcher = @import("fetcher.zig");
|
||||
const matcher = @import("matcher.zig");
|
||||
@@ -221,6 +222,15 @@ pub const Manager = struct {
|
||||
/// Owns the `statuses` table. The entries themselves borrow nothing.
|
||||
status_arena: std.heap.ArenaAllocator,
|
||||
|
||||
/// The §11.6 disk gate (ruling 17). Set by the composition root after
|
||||
/// `init` and before `runScheduler` starts; null disables gating, which is
|
||||
/// what every test and `nxdns check` want. Only the scheduler consults it —
|
||||
/// see `refreshGated`.
|
||||
monitor: ?*disk_monitor.Monitor = null,
|
||||
/// Scheduled refresh passes skipped by the disk gate. Phase 8's health
|
||||
/// rollup reads it through `refreshesGated`.
|
||||
refreshes_gated: std.atomic.Value(u64) = .init(0),
|
||||
|
||||
pub const Error = error{
|
||||
OutOfMemory,
|
||||
Canceled,
|
||||
@@ -958,6 +968,7 @@ pub const Manager = struct {
|
||||
};
|
||||
while (true) {
|
||||
try interval.sleep(io);
|
||||
if (self.refreshGated()) continue;
|
||||
self.refreshAll(io) catch |err| switch (err) {
|
||||
error.Canceled => return error.Canceled,
|
||||
else => log.warn("blocklist refresh pass failed: {s}", .{@errorName(err)}),
|
||||
@@ -965,12 +976,42 @@ pub const Manager = struct {
|
||||
}
|
||||
}
|
||||
|
||||
/// The §11.6 gate, consulted by scheduled passes only (ruling 17). A
|
||||
/// download writes tens of megabytes into the blocklist directory and the
|
||||
/// compile writes as much again, which is exactly the "non-essential write"
|
||||
/// a critically full disk must not take.
|
||||
///
|
||||
/// `reload` and `refreshAll` are deliberately not gated: both are operator
|
||||
/// actions (the composition root's startup load, Phase 8's manual refresh),
|
||||
/// and an operator who asks for a refresh on a full disk has asked for it.
|
||||
///
|
||||
/// Counting happens here, so a caller cannot skip a pass without recording
|
||||
/// it. One `warn` line per skipped pass — at a 24-hour interval that is one
|
||||
/// line a day, and the disk monitor already logs the state change itself.
|
||||
fn refreshGated(self: *Manager) bool {
|
||||
const monitor = self.monitor orelse return false;
|
||||
if (monitor.writesAllowed()) return false;
|
||||
_ = self.refreshes_gated.fetchAdd(1, .monotonic);
|
||||
log.warn("free space is critical; skipping the scheduled blocklist refresh", .{});
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Scheduled refresh passes the disk gate has skipped.
|
||||
pub fn refreshesGated(self: *const Manager) u64 {
|
||||
return self.refreshes_gated.load(.monotonic);
|
||||
}
|
||||
|
||||
fn startupPass(self: *Manager, io: std.Io) Error!void {
|
||||
self.writer_lock.lockUncancelable(io);
|
||||
defer self.writer_lock.unlock(io);
|
||||
|
||||
// Ahead of the gate on purpose: loading the compiled files that already
|
||||
// exist is a read. A full disk must not cost the household its
|
||||
// filtering as well as its downloads.
|
||||
try self.reloadLocked(io);
|
||||
|
||||
if (self.refreshGated()) return;
|
||||
|
||||
var rows = try sources_repo.listSourceRows(self.database, self.gpa);
|
||||
defer rows.deinit(self.gpa);
|
||||
defer sources_repo.freeSourceRows(self.gpa, rows.items);
|
||||
@@ -1439,6 +1480,42 @@ test "acquire before any reload returns null and holds no lock" {
|
||||
manager.lock.unlock(io);
|
||||
}
|
||||
|
||||
test "the disk gate skips a scheduled refresh only while writes are critical" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
var f: fetcher.Fetcher = undefined;
|
||||
var manager = try testManager(&database, &f);
|
||||
defer manager.deinit(io);
|
||||
|
||||
// No monitor: every pass runs, which is what the tests and `check` rely on.
|
||||
try testing.expect(!manager.refreshGated());
|
||||
try testing.expectEqual(@as(u64, 0), manager.refreshesGated());
|
||||
|
||||
var monitor: disk_monitor.Monitor = .init(.{}, std.Io.Dir.cwd(), ".", null);
|
||||
manager.monitor = &monitor;
|
||||
|
||||
// `.ok` and `.warn` both allow writes: only `critical` stops them.
|
||||
try testing.expect(!manager.refreshGated());
|
||||
monitor.state_raw.store(@intFromEnum(disk_monitor.State.warn), .monotonic);
|
||||
try testing.expect(!manager.refreshGated());
|
||||
try testing.expectEqual(@as(u64, 0), manager.refreshesGated());
|
||||
|
||||
monitor.state_raw.store(@intFromEnum(disk_monitor.State.critical), .monotonic);
|
||||
try testing.expect(manager.refreshGated());
|
||||
try testing.expect(manager.refreshGated());
|
||||
try testing.expectEqual(@as(u64, 2), manager.refreshesGated());
|
||||
|
||||
// Free space recovers and the schedule resumes; the counter keeps its total.
|
||||
monitor.state_raw.store(@intFromEnum(disk_monitor.State.ok), .monotonic);
|
||||
try testing.expect(!manager.refreshGated());
|
||||
try testing.expectEqual(@as(u64, 2), manager.refreshesGated());
|
||||
}
|
||||
|
||||
test "statusSnapshot on an empty manager copies nothing" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
|
||||
@@ -0,0 +1,502 @@
|
||||
//! Client auto-materialisation (PLAN §7.2): every address that asks a question
|
||||
//! ends up as a row in `clients`, so the operator can name it and assign it a
|
||||
//! group without typing an address by hand.
|
||||
//!
|
||||
//! A DNS query must never wait on a database write, so `track` only records the
|
||||
//! address in a fixed-capacity table in memory. A background loop drains that
|
||||
//! table every `flush_interval_s` and writes one row per distinct client, and
|
||||
//! prunes the rows of devices that went quiet on every
|
||||
//! `prune_every_passes`-th pass.
|
||||
//!
|
||||
//! The table is bounded at `max_pending`. A full table drops the address and
|
||||
//! counts it under `dropped_full`: a burst of spoofed source addresses must not
|
||||
//! be able to grow this allocation, and a dropped address costs nothing, since
|
||||
//! the next query from that client tracks it again.
|
||||
//!
|
||||
//! A materialised row does not reach the matcher until the next manager reload.
|
||||
//! Nothing depends on it: `groupForClient` already falls back to the prefix
|
||||
//! rules and then to the default group, so the row exists for the operator's
|
||||
//! benefit, not for resolution.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const address = @import("../platform/address.zig");
|
||||
const clients_repo = @import("../storage/repositories/clients_repo.zig");
|
||||
const db = @import("../storage/db.zig");
|
||||
const disk_monitor = @import("../storage/disk_monitor.zig");
|
||||
|
||||
const log = std.log.scoped(.clients);
|
||||
|
||||
/// RFC 5952 text of any IPv6 address. `format` never writes more than this, so
|
||||
/// the formatting in `flushOnce` cannot fail.
|
||||
const max_ip_text = 45;
|
||||
|
||||
/// One address waiting for its row, with the wall-clock second of its most
|
||||
/// recent query.
|
||||
const Pending = struct {
|
||||
addr: address.NetAddress,
|
||||
last_seen: i64,
|
||||
};
|
||||
|
||||
pub const Tracker = struct {
|
||||
/// Distinct clients one flush interval can carry. A household LAN holds two
|
||||
/// orders of magnitude fewer; the headroom is for the spoofing case.
|
||||
pub const max_pending = 512;
|
||||
pub const flush_interval_s = 60;
|
||||
/// One day at `flush_interval_s` seconds per pass.
|
||||
pub const prune_every_passes = 1440;
|
||||
|
||||
/// `tracked` counts the `track` calls that landed in the table, whether
|
||||
/// they created an entry or refreshed one, so `tracked + dropped_full` is
|
||||
/// the number of `track` calls.
|
||||
pub const Stats = struct {
|
||||
tracked: u64 = 0,
|
||||
flushed: u64 = 0,
|
||||
dropped_full: u64 = 0,
|
||||
pruned: u64 = 0,
|
||||
flush_failures: u64 = 0,
|
||||
};
|
||||
|
||||
/// Guards `pending`, `count`, `passes` and `stats`. Every field below is
|
||||
/// written under it, so a reader takes it too; see `snapshotStats`.
|
||||
mutex: std.Io.Mutex,
|
||||
retention_days: u16,
|
||||
pending: [max_pending]Pending,
|
||||
count: u32,
|
||||
passes: u64,
|
||||
stats: Stats,
|
||||
|
||||
/// `retention_days` is `logging.retention_days`, the same knob the query log
|
||||
/// prunes by (milestone-7 ruling 16). A client silent for that long is as
|
||||
/// uninteresting as a query that old.
|
||||
pub fn init(retention_days: u16) Tracker {
|
||||
return .{
|
||||
.mutex = .init,
|
||||
.retention_days = retention_days,
|
||||
.pending = undefined,
|
||||
.count = 0,
|
||||
.passes = 0,
|
||||
.stats = .{},
|
||||
};
|
||||
}
|
||||
|
||||
/// Records `addr` as seen now. Called from the query path, so it writes no
|
||||
/// database and returns no error: a full table drops the address.
|
||||
///
|
||||
/// `lockUncancelable` rather than `lock`: the caller is `Handler.handle`,
|
||||
/// which has no error union to carry `error.Canceled` out of. The critical
|
||||
/// section is a scan of at most `max_pending` addresses and holds no I/O.
|
||||
pub fn track(self: *Tracker, io: std.Io, addr: address.NetAddress) void {
|
||||
self.trackAt(io, addr, std.Io.Clock.real.now(io).toSeconds());
|
||||
}
|
||||
|
||||
/// `track` with the timestamp supplied, so a test does not depend on the
|
||||
/// wall clock.
|
||||
pub fn trackAt(self: *Tracker, io: std.Io, addr: address.NetAddress, now_s: i64) void {
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
|
||||
for (self.pending[0..self.count]) |*entry| {
|
||||
if (!entry.addr.eql(addr)) continue;
|
||||
entry.last_seen = now_s;
|
||||
self.stats.tracked += 1;
|
||||
return;
|
||||
}
|
||||
if (self.count == max_pending) {
|
||||
self.stats.dropped_full += 1;
|
||||
return;
|
||||
}
|
||||
self.pending[self.count] = .{ .addr = addr, .last_seen = now_s };
|
||||
self.count += 1;
|
||||
self.stats.tracked += 1;
|
||||
}
|
||||
|
||||
/// Flush loop, first flush one interval in: an empty table at startup has
|
||||
/// nothing to write.
|
||||
///
|
||||
/// `boot` rather than `awake`, so a box that suspends still sees its day
|
||||
/// elapse and prunes on schedule.
|
||||
///
|
||||
/// `database` must be a connection dedicated to this loop: no other task may
|
||||
/// use the same handle while it runs. `FULLMUTEX` (`db.zig:218`) serializes
|
||||
/// one SQLite call against another, but a transaction is connection state,
|
||||
/// not call state, so a flush that lands between another writer's BEGIN and
|
||||
/// COMMIT would commit or roll back with that writer's batch. Milestone-7
|
||||
/// ruling 21 gives this loop its own `config.db` connection; the tracker
|
||||
/// opens nothing itself.
|
||||
///
|
||||
/// `monitor` gates the pass: while free space is critical the tracker writes
|
||||
/// nothing, and the addresses it would have written stay dropped.
|
||||
pub fn run(
|
||||
self: *Tracker,
|
||||
io: std.Io,
|
||||
database: *db.Db,
|
||||
monitor: ?*disk_monitor.Monitor,
|
||||
) std.Io.Cancelable!void {
|
||||
const interval: std.Io.Clock.Duration = .{
|
||||
.raw = .fromSeconds(flush_interval_s),
|
||||
.clock = .boot,
|
||||
};
|
||||
while (true) {
|
||||
try interval.sleep(io);
|
||||
const writes_allowed = if (monitor) |m| m.writesAllowed() else true;
|
||||
self.flushOnce(io, database, writes_allowed);
|
||||
}
|
||||
}
|
||||
|
||||
/// One pass: drain the table, write a row per client, and prune on every
|
||||
/// `prune_every_passes`-th pass.
|
||||
///
|
||||
/// A gated pass does nothing at all, not even count: the work it skipped is
|
||||
/// still owed, and the pending addresses it leaves behind are re-tracked by
|
||||
/// the next query from each client.
|
||||
///
|
||||
/// Every database failure logs one line at `warn` and counts. Nothing
|
||||
/// retries within a pass: the dropped addresses come back on their own, and
|
||||
/// a failed prune repeats the same work a day later against the same rows.
|
||||
///
|
||||
/// Only `run` may call this concurrently with itself — the drain buffer is
|
||||
/// this call's stack, but the pass counter and the prune schedule assume a
|
||||
/// single caller.
|
||||
pub fn flushOnce(self: *Tracker, io: std.Io, database: *db.Db, writes_allowed: bool) void {
|
||||
if (!writes_allowed) return;
|
||||
|
||||
var drained: [max_pending]Pending = undefined;
|
||||
const batch = self.drain(io, &drained);
|
||||
|
||||
var flushed: u64 = 0;
|
||||
var failures: u64 = 0;
|
||||
for (batch) |entry| {
|
||||
var buf: [max_ip_text]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
entry.addr.format(&w) catch unreachable;
|
||||
|
||||
if (clients_repo.upsertSeen(database, w.buffered(), entry.last_seen)) {
|
||||
flushed += 1;
|
||||
} else |err| {
|
||||
if (failures == 0) {
|
||||
log.warn("materialising client {s} failed: {s}", .{ w.buffered(), @errorName(err) });
|
||||
}
|
||||
failures += 1;
|
||||
}
|
||||
}
|
||||
|
||||
self.mutex.lockUncancelable(io);
|
||||
self.passes += 1;
|
||||
self.stats.flushed += flushed;
|
||||
self.stats.flush_failures += failures;
|
||||
const due = self.passes % prune_every_passes == 0;
|
||||
self.mutex.unlock(io);
|
||||
|
||||
if (!due) return;
|
||||
const cutoff = std.Io.Clock.real.now(io).toSeconds() - @as(i64, self.retention_days) * 86_400;
|
||||
if (clients_repo.pruneStale(database, cutoff)) |deleted| {
|
||||
self.mutex.lockUncancelable(io);
|
||||
self.stats.pruned += deleted;
|
||||
self.mutex.unlock(io);
|
||||
} else |err| {
|
||||
log.warn("pruning clients before {d} failed: {s}", .{ cutoff, @errorName(err) });
|
||||
self.mutex.lockUncancelable(io);
|
||||
self.stats.flush_failures += 1;
|
||||
self.mutex.unlock(io);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn snapshotStats(self: *Tracker, io: std.Io) Stats {
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
return self.stats;
|
||||
}
|
||||
|
||||
/// Clients waiting for their row. Reaching `max_pending` is what turns
|
||||
/// further addresses into `dropped_full`.
|
||||
pub fn pendingClients(self: *Tracker, io: std.Io) u32 {
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
return self.count;
|
||||
}
|
||||
|
||||
/// Empties the table into `out` and returns what it copied. The lock is
|
||||
/// released before any database call, so the query path never waits on
|
||||
/// SQLite.
|
||||
fn drain(self: *Tracker, io: std.Io, out: *[max_pending]Pending) []const Pending {
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
|
||||
const n = self.count;
|
||||
@memcpy(out[0..n], self.pending[0..n]);
|
||||
self.count = 0;
|
||||
return out[0..n];
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const migrations = @import("../storage/migrations.zig");
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
fn openMigrated() !db.Db {
|
||||
var database = try db.Db.open(":memory:", .{ .mode = .memory });
|
||||
errdefer database.close();
|
||||
try db.applyPragmas(&database, .{});
|
||||
_ = try migrations.migrate(&database);
|
||||
return database;
|
||||
}
|
||||
|
||||
fn lastSeen(database: *db.Db, ip: []const u8) !i64 {
|
||||
var stmt = try database.prepare("SELECT last_seen FROM clients WHERE ip = ?1");
|
||||
defer stmt.deinit();
|
||||
try stmt.bindText(1, ip);
|
||||
try testing.expect(try stmt.step());
|
||||
return stmt.columnInt(0);
|
||||
}
|
||||
|
||||
fn parsed(text: []const u8) address.NetAddress {
|
||||
return address.NetAddress.parse(text) catch unreachable;
|
||||
}
|
||||
|
||||
test "a client tracked twice before a flush yields one row at the later time" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
var tracker: Tracker = .init(30);
|
||||
tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
|
||||
tracker.trackAt(io, parsed("192.168.1.10"), 1700000030);
|
||||
try testing.expectEqual(@as(u32, 1), tracker.pendingClients(io));
|
||||
|
||||
tracker.flushOnce(io, &database, true);
|
||||
|
||||
try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
|
||||
try testing.expectEqual(@as(i64, 1700000030), try lastSeen(&database, "192.168.1.10"));
|
||||
|
||||
const stats = tracker.snapshotStats(io);
|
||||
try testing.expectEqual(@as(u64, 2), stats.tracked);
|
||||
try testing.expectEqual(@as(u64, 1), stats.flushed);
|
||||
try testing.expectEqual(@as(u64, 0), stats.flush_failures);
|
||||
try testing.expectEqual(@as(u32, 0), tracker.pendingClients(io));
|
||||
}
|
||||
|
||||
test "distinct clients each get a row and ipv6 text is canonical" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
var tracker: Tracker = .init(30);
|
||||
tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
|
||||
tracker.trackAt(io, parsed("192.168.1.11"), 1700000001);
|
||||
tracker.trackAt(io, parsed("fd00:0:0:0:0:0:0:1"), 1700000002);
|
||||
// An IPv4-mapped literal is the same client as its plain form.
|
||||
tracker.trackAt(io, address.NetAddress.fromIp(try std.Io.net.IpAddress.parse("::ffff:192.168.1.10", 53)), 1700000003);
|
||||
try testing.expectEqual(@as(u32, 3), tracker.pendingClients(io));
|
||||
|
||||
tracker.flushOnce(io, &database, true);
|
||||
|
||||
try testing.expectEqual(@as(i64, 3), try clients_repo.countClients(&database));
|
||||
try testing.expectEqual(@as(i64, 1700000003), try lastSeen(&database, "192.168.1.10"));
|
||||
try testing.expectEqual(@as(i64, 1700000002), try lastSeen(&database, "fd00::1"));
|
||||
try testing.expectEqual(@as(u64, 3), tracker.snapshotStats(io).flushed);
|
||||
}
|
||||
|
||||
test "a full table drops further clients and counts them" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
var tracker: Tracker = .init(30);
|
||||
for (0..Tracker.max_pending) |i| {
|
||||
var octets: [4]u8 = undefined;
|
||||
std.mem.writeInt(u32, &octets, @intCast(i), .big);
|
||||
tracker.trackAt(io, .{ .ip4 = octets }, 1700000000);
|
||||
}
|
||||
try testing.expectEqual(@as(u32, Tracker.max_pending), tracker.pendingClients(io));
|
||||
try testing.expectEqual(@as(u64, 0), tracker.snapshotStats(io).dropped_full);
|
||||
|
||||
tracker.trackAt(io, parsed("203.0.113.7"), 1700000000);
|
||||
tracker.trackAt(io, parsed("203.0.113.8"), 1700000000);
|
||||
try testing.expectEqual(@as(u32, Tracker.max_pending), tracker.pendingClients(io));
|
||||
|
||||
const stats = tracker.snapshotStats(io);
|
||||
try testing.expectEqual(@as(u64, 2), stats.dropped_full);
|
||||
try testing.expectEqual(@as(u64, Tracker.max_pending), stats.tracked);
|
||||
|
||||
// A tracked client still refreshes while the table is full, and the flush
|
||||
// makes room for the next newcomer.
|
||||
tracker.trackAt(io, .{ .ip4 = .{ 0, 0, 0, 0 } }, 1700000060);
|
||||
tracker.flushOnce(io, &database, true);
|
||||
try testing.expectEqual(@as(i64, Tracker.max_pending), try clients_repo.countClients(&database));
|
||||
try testing.expectEqual(@as(i64, 1700000060), try lastSeen(&database, "0.0.0.0"));
|
||||
|
||||
tracker.trackAt(io, parsed("203.0.113.7"), 1700000060);
|
||||
try testing.expectEqual(@as(u32, 1), tracker.pendingClients(io));
|
||||
}
|
||||
|
||||
test "a flush touches a hand-edited row without changing what the operator set" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
try database.exec("INSERT INTO groups (id, name) VALUES (2, 'kids');");
|
||||
try database.exec(
|
||||
\\INSERT INTO clients (ip, name, group_id, hand_edited, first_seen, last_seen)
|
||||
\\VALUES ('192.168.1.10', 'laptop', 2, 1, 1690000000, 1690000000);
|
||||
);
|
||||
|
||||
var tracker: Tracker = .init(30);
|
||||
tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
|
||||
tracker.flushOnce(io, &database, true);
|
||||
|
||||
try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
|
||||
try testing.expectEqual(@as(i64, 1700000000), try lastSeen(&database, "192.168.1.10"));
|
||||
try testing.expectEqual(
|
||||
@as(i64, 1),
|
||||
try database.queryInt(
|
||||
\\SELECT count(*) FROM clients
|
||||
\\ WHERE ip = '192.168.1.10' AND name = 'laptop' AND group_id = 2
|
||||
\\ AND hand_edited = 1 AND first_seen = 1690000000
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
test "a gated pass writes nothing and keeps the pending clients" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
var monitor: disk_monitor.Monitor = .init(.{}, std.Io.Dir.cwd(), ".", null);
|
||||
monitor.state_raw.store(@intFromEnum(disk_monitor.State.critical), .monotonic);
|
||||
try testing.expect(!monitor.writesAllowed());
|
||||
|
||||
var tracker: Tracker = .init(30);
|
||||
tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
|
||||
tracker.flushOnce(io, &database, monitor.writesAllowed());
|
||||
|
||||
try testing.expectEqual(@as(i64, 0), try clients_repo.countClients(&database));
|
||||
try testing.expectEqual(@as(u32, 1), tracker.pendingClients(io));
|
||||
try testing.expectEqual(@as(u64, 0), tracker.snapshotStats(io).flushed);
|
||||
try testing.expectEqual(@as(u64, 0), tracker.passes);
|
||||
|
||||
// Free space recovers and the same pending client lands.
|
||||
monitor.state_raw.store(@intFromEnum(disk_monitor.State.warn), .monotonic);
|
||||
tracker.flushOnce(io, &database, monitor.writesAllowed());
|
||||
try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
|
||||
try testing.expectEqual(@as(u64, 1), tracker.passes);
|
||||
}
|
||||
|
||||
test "a failing upsert counts and leaves the client to be tracked again" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
try database.exec(
|
||||
\\CREATE TRIGGER refuse_insert BEFORE INSERT ON clients
|
||||
\\BEGIN SELECT RAISE(ABORT, 'refused'); END;
|
||||
);
|
||||
|
||||
var tracker: Tracker = .init(30);
|
||||
tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
|
||||
tracker.trackAt(io, parsed("192.168.1.11"), 1700000000);
|
||||
tracker.flushOnce(io, &database, true);
|
||||
|
||||
try testing.expectEqual(@as(i64, 0), try clients_repo.countClients(&database));
|
||||
const stats = tracker.snapshotStats(io);
|
||||
try testing.expectEqual(@as(u64, 0), stats.flushed);
|
||||
try testing.expectEqual(@as(u64, 2), stats.flush_failures);
|
||||
try testing.expectEqual(@as(u32, 0), tracker.pendingClients(io));
|
||||
|
||||
try database.exec("DROP TRIGGER refuse_insert;");
|
||||
tracker.trackAt(io, parsed("192.168.1.10"), 1700000060);
|
||||
tracker.flushOnce(io, &database, true);
|
||||
try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
|
||||
}
|
||||
|
||||
test "the pass that comes due prunes the clients that went quiet" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
const now = std.Io.Clock.real.now(io).toSeconds();
|
||||
const day = 86_400;
|
||||
try clients_repo.upsertSeen(&database, "10.0.0.1", now - 40 * day);
|
||||
try clients_repo.upsertSeen(&database, "10.0.0.2", now - 29 * day);
|
||||
|
||||
var tracker: Tracker = .init(30);
|
||||
// Every pass before the due one leaves both rows alone.
|
||||
for (0..Tracker.prune_every_passes - 1) |_| {
|
||||
tracker.flushOnce(io, &database, true);
|
||||
}
|
||||
try testing.expectEqual(@as(i64, 2), try clients_repo.countClients(&database));
|
||||
try testing.expectEqual(@as(u64, 0), tracker.snapshotStats(io).pruned);
|
||||
|
||||
tracker.flushOnce(io, &database, true);
|
||||
|
||||
try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
|
||||
try testing.expectEqual(@as(i64, now - 29 * day), try lastSeen(&database, "10.0.0.2"));
|
||||
try testing.expectEqual(@as(u64, 1), tracker.snapshotStats(io).pruned);
|
||||
try testing.expectEqual(@as(u64, Tracker.prune_every_passes), tracker.passes);
|
||||
}
|
||||
|
||||
test "a shorter retention prunes what the default keeps" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
const now = std.Io.Clock.real.now(io).toSeconds();
|
||||
try clients_repo.upsertSeen(&database, "10.0.0.1", now - 3 * 86_400);
|
||||
|
||||
var tracker: Tracker = .init(1);
|
||||
tracker.passes = Tracker.prune_every_passes - 1;
|
||||
tracker.flushOnce(io, &database, true);
|
||||
|
||||
try testing.expectEqual(@as(i64, 0), try clients_repo.countClients(&database));
|
||||
try testing.expectEqual(@as(u64, 1), tracker.snapshotStats(io).pruned);
|
||||
}
|
||||
|
||||
test "the run loop flushes on its interval and returns on cancel" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
var tracker: Tracker = .init(30);
|
||||
tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
|
||||
|
||||
var future = try io.concurrent(Tracker.run, .{
|
||||
&tracker,
|
||||
io,
|
||||
&database,
|
||||
@as(?*disk_monitor.Monitor, null),
|
||||
});
|
||||
// The first flush is one interval away, so cancelling immediately proves the
|
||||
// loop starts by sleeping rather than by writing.
|
||||
try testing.expectError(error.Canceled, future.cancel(io));
|
||||
try testing.expectEqual(@as(i64, 0), try clients_repo.countClients(&database));
|
||||
try testing.expectEqual(@as(u32, 1), tracker.pendingClients(io));
|
||||
}
|
||||
+1951
-83
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,93 @@
|
||||
//! Global pause of filtering (PLAN §13.1). One 64-bit flag the query path
|
||||
//! reads and the API writes. Pure: no allocation, no `std.Io`, no clock — the
|
||||
//! caller supplies the time, because the handler already has it.
|
||||
//!
|
||||
//! Pausing suspends FILTERING only: local records, forward zones, the cache,
|
||||
//! the upstream and the query log all keep running (milestone-7 ruling 18).
|
||||
//!
|
||||
//! The state is in memory and is deliberately not persisted: a restart resumes
|
||||
//! filtering, which is the safe default for a household.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
pub const Pause = struct {
|
||||
/// 0 = filtering active. -1 = paused until someone resumes. Any other value
|
||||
/// is the unix second filtering resumes at, so the expiry is a comparison
|
||||
/// on the query path rather than a timer task.
|
||||
until: std.atomic.Value(i64) = .init(0),
|
||||
|
||||
/// `.monotonic` throughout: the flag guards no other data, so nothing has
|
||||
/// to be ordered against it.
|
||||
pub fn isPaused(self: *const Pause, now_s: i64) bool {
|
||||
const until = self.until.load(.monotonic);
|
||||
if (until == 0) return false;
|
||||
if (until < 0) return true;
|
||||
return now_s < until;
|
||||
}
|
||||
|
||||
/// `null` pauses until `unpause`. A pause already in force is replaced, so
|
||||
/// the last call wins whether it lengthens or shortens the pause.
|
||||
pub fn pauseFor(self: *Pause, now_s: i64, duration_s: ?u32) void {
|
||||
const value: i64 = if (duration_s) |seconds| now_s +| @as(i64, seconds) else -1;
|
||||
self.until.store(value, .monotonic);
|
||||
}
|
||||
|
||||
pub fn unpause(self: *Pause) void {
|
||||
self.until.store(0, .monotonic);
|
||||
}
|
||||
};
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "a fresh pause is not paused" {
|
||||
const p: Pause = .{};
|
||||
try testing.expect(!p.isPaused(0));
|
||||
try testing.expect(!p.isPaused(1_700_000_000));
|
||||
}
|
||||
|
||||
test "an indefinite pause holds at every time" {
|
||||
var p: Pause = .{};
|
||||
p.pauseFor(1_700_000_000, null);
|
||||
try testing.expect(p.isPaused(1_700_000_000));
|
||||
try testing.expect(p.isPaused(1_700_000_000 + 86_400 * 365));
|
||||
try testing.expectEqual(@as(i64, -1), p.until.load(.monotonic));
|
||||
}
|
||||
|
||||
test "a timed pause expires at its own second" {
|
||||
var p: Pause = .{};
|
||||
p.pauseFor(1_000, 60);
|
||||
try testing.expect(p.isPaused(1_000));
|
||||
try testing.expect(p.isPaused(1_059));
|
||||
// The stored second is when filtering is back on, so it is not paused.
|
||||
try testing.expect(!p.isPaused(1_060));
|
||||
try testing.expect(!p.isPaused(1_061));
|
||||
}
|
||||
|
||||
test "unpause resumes both kinds of pause" {
|
||||
var p: Pause = .{};
|
||||
p.pauseFor(1_000, null);
|
||||
p.unpause();
|
||||
try testing.expect(!p.isPaused(1_000));
|
||||
|
||||
p.pauseFor(1_000, 60);
|
||||
p.unpause();
|
||||
try testing.expect(!p.isPaused(1_000));
|
||||
}
|
||||
|
||||
test "pauseFor overwrites a pause already in force" {
|
||||
var p: Pause = .{};
|
||||
p.pauseFor(1_000, 3_600);
|
||||
p.pauseFor(1_000, 10);
|
||||
try testing.expect(!p.isPaused(1_010));
|
||||
|
||||
// And in the other direction: indefinite replaces a timed pause.
|
||||
p.pauseFor(1_000, 10);
|
||||
p.pauseFor(1_000, null);
|
||||
try testing.expect(p.isPaused(1_010));
|
||||
}
|
||||
|
||||
test "a duration that would overflow saturates instead of wrapping" {
|
||||
var p: Pause = .{};
|
||||
p.pauseFor(std.math.maxInt(i64), std.math.maxInt(u32));
|
||||
try testing.expect(p.isPaused(std.math.maxInt(i64) - 1));
|
||||
}
|
||||
@@ -0,0 +1,996 @@
|
||||
//! Milestone-7 integration tests (spec S7): the serving pipeline end to end,
|
||||
//! over real sockets.
|
||||
//!
|
||||
//! This lives in its own file because it needs `@import("build_options")`, which
|
||||
//! only exists when the compilation is driven by `build.zig`. The body compiles
|
||||
//! on every `zig build test` run, so it cannot rot, and every case skips at run
|
||||
//! time unless `-Dintegration` is passed.
|
||||
//!
|
||||
//! What separates these cases from `handler.zig`'s own tests is the socket. The
|
||||
//! handler tests call `handle` directly; here every query travels through a real
|
||||
//! `UdpServer` on 127.0.0.1, through the real handler with its real cache,
|
||||
//! limiter, tracker and query log, and the reply is read back off the wire. The
|
||||
//! upstream is a `transport.Client` fixture, except in the forward-zone case,
|
||||
//! where the zone resolver has to be a real UDP socket because `ForwardClient`
|
||||
//! speaks wire DNS to an address.
|
||||
//!
|
||||
//! Hermetic: every socket is bound to 127.0.0.1, every database is in memory or
|
||||
//! inside a `std.testing.tmpDir`, and every wait carries a budget.
|
||||
|
||||
const std = @import("std");
|
||||
const build_options = @import("build_options");
|
||||
const net = std.Io.net;
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const app = @import("../app.zig");
|
||||
const cli = @import("../cli.zig");
|
||||
const clients = @import("clients.zig");
|
||||
const clients_repo = @import("../storage/repositories/clients_repo.zig");
|
||||
const db = @import("../storage/db.zig");
|
||||
const dns_cache = @import("../cache/dns_cache.zig");
|
||||
const forward_zones = @import("../local/forward_zones.zig");
|
||||
const handler = @import("handler.zig");
|
||||
const header = @import("../dns/header.zig");
|
||||
const logger_mod = @import("../storage/logger.zig");
|
||||
const manager = @import("../filter/manager.zig");
|
||||
const matcher = @import("../filter/matcher.zig");
|
||||
const migrations = @import("../storage/migrations.zig");
|
||||
const model = @import("../config/model.zig");
|
||||
const name = @import("../dns/name.zig");
|
||||
const packet = @import("../dns/packet.zig");
|
||||
const pause = @import("pause.zig");
|
||||
const question = @import("../dns/question.zig");
|
||||
const rate_limiter = @import("rate_limiter.zig");
|
||||
const record = @import("../dns/record.zig");
|
||||
const records = @import("../local/records.zig");
|
||||
const response = @import("../filter/response.zig");
|
||||
const shutdown = @import("shutdown.zig");
|
||||
const transport = @import("../upstream/transport.zig");
|
||||
const types = @import("../dns/types.zig");
|
||||
const udp_server = @import("udp_server.zig");
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// shared fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Long enough that a loopback round trip cannot lose to scheduling, short
|
||||
/// enough that a broken server fails the run instead of hanging it.
|
||||
const budget: std.Io.Timeout = .{ .duration = .{ .raw = .fromSeconds(5), .clock = .awake } };
|
||||
|
||||
const empty_records: records.Records = .empty;
|
||||
const empty_zones: forward_zones.Zones = .empty;
|
||||
|
||||
/// A five-second TTL makes the blocking answer's TTL unmistakable next to the
|
||||
/// upstream's 300.
|
||||
const blocking: response.Options = .{ .mode = .zero, .ttl = 5 };
|
||||
|
||||
const forward_timeout: std.Io.Clock.Duration = .{ .raw = .fromSeconds(2), .clock = .awake };
|
||||
|
||||
/// What every fake upstream answers with, and the TTL it carries.
|
||||
const upstream_rdata = [4]u8{ 93, 184, 216, 34 };
|
||||
const upstream_ttl: u32 = 300;
|
||||
|
||||
/// The zone resolver's answer, distinct from the pool's so a case can tell
|
||||
/// which of the two replied.
|
||||
const zone_rdata = [4]u8{ 10, 0, 0, 7 };
|
||||
const zone_ttl: u32 = 120;
|
||||
|
||||
/// The handler every case starts from: an upstream, the blocking options and
|
||||
/// the empty local tables. Each case wires in the collaborators it exercises.
|
||||
fn baseHandler(client: transport.Client) handler.Handler {
|
||||
return .{
|
||||
.upstream = client,
|
||||
.blocking = blocking,
|
||||
.forward_read_timeout = forward_timeout,
|
||||
.records = &empty_records,
|
||||
.zones = &empty_zones,
|
||||
};
|
||||
}
|
||||
|
||||
/// A real listener, a real client socket and the task that serves them.
|
||||
///
|
||||
/// Two phases: `bind` produces the value, `start` spawns the serve task against
|
||||
/// its final address. Nothing may copy a `Loop` after `start`, because the task
|
||||
/// holds a pointer into it.
|
||||
const Loop = struct {
|
||||
server: udp_server.UdpServer,
|
||||
group: std.Io.Group,
|
||||
client: net.Socket,
|
||||
server_address: net.IpAddress,
|
||||
|
||||
fn bind(gpa: Allocator, io: std.Io, h: *handler.Handler) !Loop {
|
||||
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var server = try udp_server.UdpServer.bind(gpa, io, listen_address, h, .{ .max_in_flight = 4 });
|
||||
errdefer server.deinit(gpa, io);
|
||||
|
||||
const client_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
const client = try client_address.bind(io, .{ .mode = .dgram });
|
||||
|
||||
return .{
|
||||
.server = server,
|
||||
.group = .init,
|
||||
.client = client,
|
||||
.server_address = server.boundAddress(),
|
||||
};
|
||||
}
|
||||
|
||||
fn start(self: *Loop, io: std.Io) !void {
|
||||
try self.group.concurrent(io, udp_server.UdpServer.serve, .{ &self.server, io });
|
||||
}
|
||||
|
||||
/// One query, one reply. The reply is a prefix of `buf`.
|
||||
fn ask(self: *Loop, io: std.Io, query: []const u8, buf: []u8) ![]u8 {
|
||||
try self.client.send(io, &self.server_address, query);
|
||||
const msg = try self.client.receiveTimeout(io, buf, budget);
|
||||
return msg.data;
|
||||
}
|
||||
|
||||
fn stop(self: *Loop, gpa: Allocator, io: std.Io) void {
|
||||
self.server.deinit(gpa, io);
|
||||
self.group.cancel(io);
|
||||
self.client.close(io);
|
||||
}
|
||||
};
|
||||
|
||||
/// A query for `domain`, RD set, one question, no OPT.
|
||||
fn queryFor(buf: []u8, id: u16, domain: []const u8, qtype: types.Type) []const u8 {
|
||||
var w: std.Io.Writer = .fixed(buf);
|
||||
var encoded: [types.header_len]u8 = undefined;
|
||||
header.encode(.{
|
||||
.id = id,
|
||||
.flags = .{
|
||||
.rcode = .no_error,
|
||||
.z = 0,
|
||||
.ra = false,
|
||||
.rd = true,
|
||||
.tc = false,
|
||||
.aa = false,
|
||||
.opcode = .query,
|
||||
.qr = false,
|
||||
},
|
||||
.qdcount = 1,
|
||||
.ancount = 0,
|
||||
.nscount = 0,
|
||||
.arcount = 0,
|
||||
}, &encoded);
|
||||
w.writeAll(&encoded) catch unreachable;
|
||||
question.encode(.{
|
||||
.name = name.fromText(domain) catch unreachable,
|
||||
.qtype = qtype,
|
||||
.qclass = .in,
|
||||
}, &w) catch unreachable;
|
||||
return w.buffered();
|
||||
}
|
||||
|
||||
/// The pool stand-in. It answers the question it is given rather than a fixed
|
||||
/// byte string, because the safe-search and uncloaking cases both change the
|
||||
/// question on the way out.
|
||||
///
|
||||
/// `calls` is atomic: the listener task runs on another thread than the one
|
||||
/// asserting.
|
||||
const FakeUpstream = struct {
|
||||
reply: Reply,
|
||||
calls: std.atomic.Value(u64) = .init(0),
|
||||
|
||||
const Reply = union(enum) {
|
||||
/// One A record for the queried name.
|
||||
a,
|
||||
/// One CNAME record for the queried name, pointing at this target.
|
||||
cname: []const u8,
|
||||
};
|
||||
|
||||
fn exchangeFn(
|
||||
ptr: *anyopaque,
|
||||
io: std.Io,
|
||||
query: []const u8,
|
||||
response_buf: []u8,
|
||||
) transport.ExchangeError![]u8 {
|
||||
_ = io;
|
||||
const self: *FakeUpstream = @ptrCast(@alignCast(ptr));
|
||||
_ = self.calls.fetchAdd(1, .monotonic);
|
||||
|
||||
const request = packet.parse(query) catch return error.BadResponse;
|
||||
const q = packet.firstQuestion(request) orelse return error.BadResponse;
|
||||
|
||||
var b = packet.ResponseBuilder.init(response_buf, request.header, q) catch
|
||||
return error.ResponseTooLarge;
|
||||
switch (self.reply) {
|
||||
.a => b.addAnswer(q.name, .a, .in, upstream_ttl, &upstream_rdata) catch
|
||||
return error.ResponseTooLarge,
|
||||
.cname => |target| {
|
||||
const t = name.fromText(target) catch return error.BadResponse;
|
||||
b.addAnswer(q.name, .cname, .in, upstream_ttl, t.wire()) catch
|
||||
return error.ResponseTooLarge;
|
||||
},
|
||||
}
|
||||
return b.finish();
|
||||
}
|
||||
|
||||
fn client(self: *FakeUpstream) transport.Client {
|
||||
return .{ .ptr = self, .exchangeFn = exchangeFn };
|
||||
}
|
||||
};
|
||||
|
||||
/// The forward zone's resolver: a real UDP socket, because `ForwardClient`
|
||||
/// speaks wire DNS to an address and nothing smaller would prove it did.
|
||||
///
|
||||
/// The loop ends when the receive is canceled, which is what `group.cancel`
|
||||
/// does at the end of the case.
|
||||
fn zoneResolver(io: std.Io, socket: *const net.Socket, calls: *std.atomic.Value(u64)) void {
|
||||
var buf: [udp_server.max_datagram]u8 = undefined;
|
||||
while (true) {
|
||||
const msg = socket.receive(io, &buf) catch return;
|
||||
_ = calls.fetchAdd(1, .monotonic);
|
||||
|
||||
const request = packet.parse(msg.data) catch continue;
|
||||
const q = packet.firstQuestion(request) orelse continue;
|
||||
|
||||
var reply_buf: [512]u8 = undefined;
|
||||
var b = packet.ResponseBuilder.init(&reply_buf, request.header, q) catch continue;
|
||||
b.addAnswer(q.name, .a, .in, zone_ttl, &zone_rdata) catch continue;
|
||||
socket.send(io, &msg.from, b.finish()) catch return;
|
||||
}
|
||||
}
|
||||
|
||||
const SnapshotFixture = struct {
|
||||
groups: []const model.Group = &.{.{ .name = "default" }},
|
||||
rules: []const model.Rule = &.{},
|
||||
};
|
||||
|
||||
fn buildSnapshot(gpa: Allocator, fixture: SnapshotFixture) !matcher.Snapshot {
|
||||
return matcher.Snapshot.build(gpa, .{
|
||||
.groups = fixture.groups,
|
||||
.group_ids = &.{1},
|
||||
.group_sources = &.{},
|
||||
.sources = &.{},
|
||||
.source_ids = &.{},
|
||||
.rules = fixture.rules,
|
||||
.clients = &.{},
|
||||
.prefixes = &.{},
|
||||
.compiled = &.{},
|
||||
.seed = 0x5eed,
|
||||
.generation = 1,
|
||||
});
|
||||
}
|
||||
|
||||
/// `Manager.acquire` reads the manager's lock and its current snapshot and
|
||||
/// nothing else, so a manager that publishes one hand-built snapshot needs
|
||||
/// none of the database, fetcher or blocklist directory the real one owns.
|
||||
fn fixtureManager(m: *manager.Manager, snapshot: *matcher.Snapshot) void {
|
||||
m.* = .{
|
||||
.gpa = testing.allocator,
|
||||
.database = undefined,
|
||||
.paths = undefined,
|
||||
.fetcher = undefined,
|
||||
.update = .{},
|
||||
.total_budget = forward_timeout,
|
||||
.lock = .init,
|
||||
.writer_lock = .init,
|
||||
.current = snapshot,
|
||||
.generation = 1,
|
||||
.statuses = &.{},
|
||||
.status_arena = .init(testing.allocator),
|
||||
};
|
||||
}
|
||||
|
||||
fn blockRule(pattern: []const u8) model.Rule {
|
||||
return .{ .group = "default", .pattern = pattern, .kind = .exact, .action = .block };
|
||||
}
|
||||
|
||||
fn allowRule(pattern: []const u8) model.Rule {
|
||||
return .{ .group = "default", .pattern = pattern, .kind = .exact, .action = .allow };
|
||||
}
|
||||
|
||||
fn firstAnswer(p: packet.Packet) !record.Record {
|
||||
var it = packet.answers(p);
|
||||
return (try it.next()) orelse error.TestExpectedAnswer;
|
||||
}
|
||||
|
||||
fn drainLog(lg: *logger_mod.Logger, io: std.Io, out: []logger_mod.Entry) []logger_mod.Entry {
|
||||
const n = lg.queue.getUncancelable(io, out, 0) catch 0;
|
||||
return out[0..n];
|
||||
}
|
||||
|
||||
/// Every case that asserts on the query log wants the same shape: a queue big
|
||||
/// enough to hold the whole case, drained once at the end.
|
||||
const log_queue_len = 8;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// case 1: blocked domain
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test "S7 case 1: a blocked domain is answered with the zero address and logged" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
var threaded: std.Io.Threaded = .init(gpa, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var snapshot = try buildSnapshot(gpa, .{ .rules = &.{blockRule("ads.example.com")} });
|
||||
defer snapshot.deinit();
|
||||
var mgr: manager.Manager = undefined;
|
||||
fixtureManager(&mgr, &snapshot);
|
||||
|
||||
var queue_buf: [log_queue_len]logger_mod.Entry = undefined;
|
||||
var lg: logger_mod.Logger = .init(.{}, &queue_buf);
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = .a };
|
||||
var h = baseHandler(fake.client());
|
||||
h.manager = &mgr;
|
||||
h.logger = ≶
|
||||
|
||||
var loop = try Loop.bind(gpa, io, &h);
|
||||
defer loop.stop(gpa, io);
|
||||
try loop.start(io);
|
||||
|
||||
var query_buf: [512]u8 = undefined;
|
||||
var reply_buf: [udp_server.max_datagram]u8 = undefined;
|
||||
const reply = try loop.ask(io, queryFor(&query_buf, 0x1111, "ads.example.com", .a), &reply_buf);
|
||||
|
||||
const p = try packet.parse(reply);
|
||||
try testing.expectEqual(@as(u16, 0x1111), p.header.id);
|
||||
try testing.expectEqual(types.Rcode.no_error, p.header.flags.rcode);
|
||||
try testing.expectEqual(@as(u16, 1), p.header.ancount);
|
||||
|
||||
const answer = try firstAnswer(p);
|
||||
try testing.expectEqual(@as(u32, blocking.ttl), answer.ttl);
|
||||
try testing.expectEqual([4]u8{ 0, 0, 0, 0 }, try record.rdataA(p.bytes, answer));
|
||||
|
||||
try testing.expectEqual(@as(u64, 0), fake.calls.load(.monotonic));
|
||||
try testing.expectEqual(@as(u64, 1), h.stats.blocked.load(.monotonic));
|
||||
|
||||
var entries: [log_queue_len]logger_mod.Entry = undefined;
|
||||
const logged = drainLog(&lg, io, &entries);
|
||||
try testing.expectEqual(@as(usize, 1), logged.len);
|
||||
try testing.expectEqual(true, logged[0].blocked);
|
||||
try testing.expectEqualStrings("ads.example.com", logged[0].domain());
|
||||
try testing.expectEqualStrings("rule_block_exact", logged[0].blockReason());
|
||||
try testing.expectEqualStrings("127.0.0.1", logged[0].clientIp());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// case 2: allow over block
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test "S7 case 2: an allow rule beats the blocklist and the upstream answers" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
var threaded: std.Io.Threaded = .init(gpa, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var snapshot = try buildSnapshot(gpa, .{
|
||||
.rules = &.{ blockRule("com"), allowRule("example.com") },
|
||||
});
|
||||
defer snapshot.deinit();
|
||||
var mgr: manager.Manager = undefined;
|
||||
fixtureManager(&mgr, &snapshot);
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = .a };
|
||||
var h = baseHandler(fake.client());
|
||||
h.manager = &mgr;
|
||||
|
||||
var loop = try Loop.bind(gpa, io, &h);
|
||||
defer loop.stop(gpa, io);
|
||||
try loop.start(io);
|
||||
|
||||
var query_buf: [512]u8 = undefined;
|
||||
var reply_buf: [udp_server.max_datagram]u8 = undefined;
|
||||
const reply = try loop.ask(io, queryFor(&query_buf, 0x2222, "example.com", .a), &reply_buf);
|
||||
|
||||
const p = try packet.parse(reply);
|
||||
const answer = try firstAnswer(p);
|
||||
try testing.expectEqual(upstream_rdata, try record.rdataA(p.bytes, answer));
|
||||
try testing.expectEqual(upstream_ttl, answer.ttl);
|
||||
try testing.expectEqual(@as(u64, 1), fake.calls.load(.monotonic));
|
||||
try testing.expectEqual(@as(u64, 0), h.stats.blocked.load(.monotonic));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// case 3: local records
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test "S7 case 3: a local record answers authoritatively without an upstream" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
var threaded: std.Io.Threaded = .init(gpa, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var table = try records.Records.build(gpa, &.{
|
||||
.{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.10", .ttl = 60 },
|
||||
});
|
||||
defer table.deinit(gpa);
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = .a };
|
||||
var h = baseHandler(fake.client());
|
||||
h.records = &table;
|
||||
|
||||
var loop = try Loop.bind(gpa, io, &h);
|
||||
defer loop.stop(gpa, io);
|
||||
try loop.start(io);
|
||||
|
||||
var query_buf: [512]u8 = undefined;
|
||||
var reply_buf: [udp_server.max_datagram]u8 = undefined;
|
||||
const reply = try loop.ask(io, queryFor(&query_buf, 0x3333, "nas.lan", .a), &reply_buf);
|
||||
|
||||
const p = try packet.parse(reply);
|
||||
try testing.expectEqual(true, p.header.flags.aa);
|
||||
try testing.expectEqual(@as(u16, 1), p.header.ancount);
|
||||
|
||||
const answer = try firstAnswer(p);
|
||||
try testing.expectEqual(@as(u32, 60), answer.ttl);
|
||||
try testing.expectEqual([4]u8{ 192, 168, 1, 10 }, try record.rdataA(p.bytes, answer));
|
||||
|
||||
try testing.expectEqual(@as(u64, 0), fake.calls.load(.monotonic));
|
||||
try testing.expectEqual(@as(u64, 1), h.stats.local_answers.load(.monotonic));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// case 4: forward zones
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test "S7 case 4: a forward zone reaches its resolver, bypasses the blocklist and caches" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
var threaded: std.Io.Threaded = .init(gpa, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
// The zone resolver is a socket of its own, so the case can tell a query
|
||||
// that reached it from one the pool answered.
|
||||
const resolver_bind: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
const resolver_socket = try resolver_bind.bind(io, .{ .mode = .dgram });
|
||||
defer resolver_socket.close(io);
|
||||
|
||||
var resolver_calls: std.atomic.Value(u64) = .init(0);
|
||||
var resolver_group: std.Io.Group = .init;
|
||||
defer resolver_group.cancel(io);
|
||||
try resolver_group.concurrent(io, zoneResolver, .{ io, &resolver_socket, &resolver_calls });
|
||||
|
||||
var resolver_text: [64]u8 = undefined;
|
||||
const resolver_url = try std.fmt.bufPrint(&resolver_text, "udp://127.0.0.1:{d}", .{
|
||||
resolver_socket.address.ip4.port,
|
||||
});
|
||||
|
||||
var zones = try forward_zones.Zones.build(gpa, &.{
|
||||
.{ .zone = "lan.home", .resolver = resolver_url },
|
||||
});
|
||||
defer zones.deinit(gpa);
|
||||
|
||||
// The name is blocklisted, so an answer from the zone resolver is proof the
|
||||
// bypass (ruling 7) holds over the wire.
|
||||
var snapshot = try buildSnapshot(gpa, .{ .rules = &.{blockRule("nas.lan.home")} });
|
||||
defer snapshot.deinit();
|
||||
var mgr: manager.Manager = undefined;
|
||||
fixtureManager(&mgr, &snapshot);
|
||||
|
||||
var cache: dns_cache.DnsCache = try .init(gpa, .{ .size = 8, .negative_ttl_max = 3600 });
|
||||
defer cache.deinit();
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = .a };
|
||||
var h = baseHandler(fake.client());
|
||||
h.manager = &mgr;
|
||||
h.zones = &zones;
|
||||
h.cache = &cache;
|
||||
h.negative_ttl_max = 3600;
|
||||
|
||||
var loop = try Loop.bind(gpa, io, &h);
|
||||
defer loop.stop(gpa, io);
|
||||
try loop.start(io);
|
||||
|
||||
var query_buf: [512]u8 = undefined;
|
||||
var reply_buf: [udp_server.max_datagram]u8 = undefined;
|
||||
const query = queryFor(&query_buf, 0x4444, "nas.lan.home", .a);
|
||||
|
||||
const first = try loop.ask(io, query, &reply_buf);
|
||||
const p = try packet.parse(first);
|
||||
try testing.expectEqual(types.Rcode.no_error, p.header.flags.rcode);
|
||||
try testing.expectEqual(zone_rdata, try record.rdataA(p.bytes, try firstAnswer(p)));
|
||||
try testing.expectEqual(@as(u64, 1), resolver_calls.load(.monotonic));
|
||||
try testing.expectEqual(@as(u64, 0), fake.calls.load(.monotonic));
|
||||
try testing.expectEqual(@as(u64, 0), h.stats.blocked.load(.monotonic));
|
||||
try testing.expectEqual(@as(u32, 1), cache.len());
|
||||
|
||||
// The second query is answered from the cache: the resolver socket sees
|
||||
// nothing more (PLAN §6.5).
|
||||
var second_buf: [512]u8 = undefined;
|
||||
const second_query = queryFor(&second_buf, 0x4455, "nas.lan.home", .a);
|
||||
const second = try loop.ask(io, second_query, &reply_buf);
|
||||
|
||||
const second_p = try packet.parse(second);
|
||||
try testing.expectEqual(@as(u16, 0x4455), second_p.header.id);
|
||||
try testing.expectEqual(zone_rdata, try record.rdataA(second_p.bytes, try firstAnswer(second_p)));
|
||||
try testing.expectEqual(@as(u64, 1), resolver_calls.load(.monotonic));
|
||||
try testing.expectEqual(@as(u64, 1), h.stats.cache_hits.load(.monotonic));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// case 5: cache
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test "S7 case 5: a cached answer comes back with a fresh id, an aged ttl and a logged hit" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
var threaded: std.Io.Threaded = .init(gpa, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var cache: dns_cache.DnsCache = try .init(gpa, .{ .size = 8, .negative_ttl_max = 3600 });
|
||||
defer cache.deinit();
|
||||
|
||||
var queue_buf: [log_queue_len]logger_mod.Entry = undefined;
|
||||
var lg: logger_mod.Logger = .init(.{}, &queue_buf);
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = .a };
|
||||
var h = baseHandler(fake.client());
|
||||
h.cache = &cache;
|
||||
h.negative_ttl_max = 3600;
|
||||
h.logger = ≶
|
||||
|
||||
var loop = try Loop.bind(gpa, io, &h);
|
||||
defer loop.stop(gpa, io);
|
||||
try loop.start(io);
|
||||
|
||||
var reply_buf: [udp_server.max_datagram]u8 = undefined;
|
||||
|
||||
// Miss, then hit under a different transaction ID.
|
||||
var miss_buf: [512]u8 = undefined;
|
||||
_ = try loop.ask(io, queryFor(&miss_buf, 0x5501, "example.com", .a), &reply_buf);
|
||||
try testing.expectEqual(@as(u32, 1), cache.len());
|
||||
try testing.expectEqual(@as(u64, 1), fake.calls.load(.monotonic));
|
||||
|
||||
var hit_buf: [512]u8 = undefined;
|
||||
const hit = try loop.ask(io, queryFor(&hit_buf, 0x5502, "example.com", .a), &reply_buf);
|
||||
const p = try packet.parse(hit);
|
||||
try testing.expectEqual(@as(u16, 0x5502), p.header.id);
|
||||
try testing.expectEqual(upstream_rdata, try record.rdataA(p.bytes, try firstAnswer(p)));
|
||||
try testing.expectEqual(@as(u64, 1), fake.calls.load(.monotonic));
|
||||
try testing.expectEqual(@as(u64, 1), h.stats.cache_hits.load(.monotonic));
|
||||
|
||||
// Ageing needs elapsed time, and a test cannot wait 10 seconds for it. The
|
||||
// entry is therefore planted with a stored-at stamp 10 seconds in the past,
|
||||
// under exactly the key the handler builds for this query.
|
||||
var aged_query_buf: [512]u8 = undefined;
|
||||
const aged_query = queryFor(&aged_query_buf, 0x5503, "aged.example.com", .a);
|
||||
var stored_buf: [512]u8 = undefined;
|
||||
const aged_p = try packet.parse(aged_query);
|
||||
var b = try packet.ResponseBuilder.init(&stored_buf, aged_p.header, packet.firstQuestion(aged_p).?);
|
||||
try b.addAnswer(try name.fromText("aged.example.com"), .a, .in, upstream_ttl, &upstream_rdata);
|
||||
|
||||
var key_buf: [dns_cache.max_key_len]u8 = undefined;
|
||||
const key = dns_cache.buildKey(
|
||||
&key_buf,
|
||||
"aged.example.com",
|
||||
@intFromEnum(types.Type.a),
|
||||
@intFromEnum(types.Class.in),
|
||||
false,
|
||||
null,
|
||||
);
|
||||
const aged_by = 10;
|
||||
try cache.put(
|
||||
std.Io.Clock.real.now(io).toSeconds() - aged_by,
|
||||
key,
|
||||
b.finish(),
|
||||
.{ .ttl_seconds = upstream_ttl, .negative = false },
|
||||
);
|
||||
|
||||
const aged = try loop.ask(io, aged_query, &reply_buf);
|
||||
const aged_reply = try packet.parse(aged);
|
||||
try testing.expectEqual(upstream_ttl - aged_by, (try firstAnswer(aged_reply)).ttl);
|
||||
try testing.expectEqual(@as(u64, 1), fake.calls.load(.monotonic));
|
||||
|
||||
var entries: [log_queue_len]logger_mod.Entry = undefined;
|
||||
const logged = drainLog(&lg, io, &entries);
|
||||
try testing.expectEqual(@as(usize, 3), logged.len);
|
||||
try testing.expectEqual(@as(?bool, false), logged[0].cache_hit);
|
||||
try testing.expectEqualStrings("pool", logged[0].upstream());
|
||||
try testing.expectEqual(@as(?bool, true), logged[1].cache_hit);
|
||||
try testing.expectEqualStrings("", logged[1].upstream());
|
||||
try testing.expectEqual(@as(?bool, true), logged[2].cache_hit);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// case 6: CNAME uncloaking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test "S7 case 6: a cname into a blocked target blocks the original question" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
var threaded: std.Io.Threaded = .init(gpa, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var snapshot = try buildSnapshot(gpa, .{ .rules = &.{blockRule("tracker.example.org")} });
|
||||
defer snapshot.deinit();
|
||||
var mgr: manager.Manager = undefined;
|
||||
fixtureManager(&mgr, &snapshot);
|
||||
|
||||
var queue_buf: [log_queue_len]logger_mod.Entry = undefined;
|
||||
var lg: logger_mod.Logger = .init(.{}, &queue_buf);
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = .{ .cname = "tracker.example.org" } };
|
||||
var h = baseHandler(fake.client());
|
||||
h.manager = &mgr;
|
||||
h.logger = ≶
|
||||
|
||||
var loop = try Loop.bind(gpa, io, &h);
|
||||
defer loop.stop(gpa, io);
|
||||
try loop.start(io);
|
||||
|
||||
var query_buf: [512]u8 = undefined;
|
||||
var reply_buf: [udp_server.max_datagram]u8 = undefined;
|
||||
const reply = try loop.ask(io, queryFor(&query_buf, 0x6666, "cdn.example.com", .a), &reply_buf);
|
||||
|
||||
const p = try packet.parse(reply);
|
||||
try testing.expectEqual(@as(u16, 1), p.header.ancount);
|
||||
|
||||
// The answer is about the name the client asked for, not the target.
|
||||
const answer = try firstAnswer(p);
|
||||
try testing.expectEqual(types.Type.a, answer.rtype);
|
||||
try testing.expectEqualSlices(
|
||||
u8,
|
||||
(try name.fromText("cdn.example.com")).wire(),
|
||||
answer.name.wire(),
|
||||
);
|
||||
try testing.expectEqual([4]u8{ 0, 0, 0, 0 }, try record.rdataA(p.bytes, answer));
|
||||
try testing.expectEqual(@as(u64, 1), h.stats.uncloak_blocked.load(.monotonic));
|
||||
|
||||
var entries: [log_queue_len]logger_mod.Entry = undefined;
|
||||
const logged = drainLog(&lg, io, &entries);
|
||||
try testing.expectEqual(@as(usize, 1), logged.len);
|
||||
try testing.expectEqual(true, logged[0].blocked);
|
||||
try testing.expectEqualStrings("cname:rule_block_exact", logged[0].blockReason());
|
||||
try testing.expectEqualStrings("cdn.example.com", logged[0].domain());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// case 7: safe search
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test "S7 case 7: safe search answers the original question with a cname to the target" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
var threaded: std.Io.Threaded = .init(gpa, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var snapshot = try buildSnapshot(gpa, .{
|
||||
.groups = &.{.{ .name = "default", .safe_search = true }},
|
||||
});
|
||||
defer snapshot.deinit();
|
||||
var mgr: manager.Manager = undefined;
|
||||
fixtureManager(&mgr, &snapshot);
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = .a };
|
||||
var h = baseHandler(fake.client());
|
||||
h.manager = &mgr;
|
||||
|
||||
var loop = try Loop.bind(gpa, io, &h);
|
||||
defer loop.stop(gpa, io);
|
||||
try loop.start(io);
|
||||
|
||||
var query_buf: [512]u8 = undefined;
|
||||
var reply_buf: [udp_server.max_datagram]u8 = undefined;
|
||||
const reply = try loop.ask(io, queryFor(&query_buf, 0x7777, "www.google.com", .a), &reply_buf);
|
||||
|
||||
const p = try packet.parse(reply);
|
||||
const target = try name.fromText("forcesafesearch.google.com");
|
||||
|
||||
// The reply keeps the question the client asked.
|
||||
try testing.expectEqualSlices(
|
||||
u8,
|
||||
(try name.fromText("www.google.com")).wire(),
|
||||
packet.firstQuestion(p).?.name.wire(),
|
||||
);
|
||||
try testing.expectEqual(@as(u16, 2), p.header.ancount);
|
||||
|
||||
var it = packet.answers(p);
|
||||
const cname = (try it.next()).?;
|
||||
try testing.expectEqual(types.Type.cname, cname.rtype);
|
||||
try testing.expectEqualSlices(u8, target.wire(), (try record.rdataCname(p.bytes, cname)).wire());
|
||||
|
||||
const a = (try it.next()).?;
|
||||
try testing.expectEqual(types.Type.a, a.rtype);
|
||||
try testing.expectEqualSlices(u8, target.wire(), a.name.wire());
|
||||
try testing.expectEqual(upstream_rdata, try record.rdataA(p.bytes, a));
|
||||
|
||||
try testing.expectEqual(@as(u64, 1), h.stats.safesearch_rewrites.load(.monotonic));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// case 8: rate limit
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test "S7 case 8: the third query inside the window is refused" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
var threaded: std.Io.Threaded = .init(gpa, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var limiter: rate_limiter.RateLimiter = try .init(gpa, .{ .limit = 2, .window_seconds = 60 });
|
||||
defer limiter.deinit();
|
||||
|
||||
var queue_buf: [log_queue_len]logger_mod.Entry = undefined;
|
||||
var lg: logger_mod.Logger = .init(.{}, &queue_buf);
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = .a };
|
||||
var h = baseHandler(fake.client());
|
||||
h.limiter = &limiter;
|
||||
h.logger = ≶
|
||||
|
||||
var loop = try Loop.bind(gpa, io, &h);
|
||||
defer loop.stop(gpa, io);
|
||||
try loop.start(io);
|
||||
|
||||
var reply_buf: [udp_server.max_datagram]u8 = undefined;
|
||||
for ([_]u16{ 0x8801, 0x8802 }) |id| {
|
||||
var query_buf: [512]u8 = undefined;
|
||||
const reply = try loop.ask(io, queryFor(&query_buf, id, "example.com", .a), &reply_buf);
|
||||
try testing.expectEqual(types.Rcode.no_error, (try packet.parse(reply)).header.flags.rcode);
|
||||
}
|
||||
|
||||
var third_buf: [512]u8 = undefined;
|
||||
const refused = try loop.ask(io, queryFor(&third_buf, 0x8803, "example.com", .a), &reply_buf);
|
||||
const p = try packet.parse(refused);
|
||||
try testing.expectEqual(types.Rcode.refused, p.header.flags.rcode);
|
||||
try testing.expectEqual(@as(u16, 0x8803), p.header.id);
|
||||
try testing.expectEqual(@as(u64, 1), h.stats.refused.load(.monotonic));
|
||||
try testing.expectEqual(@as(u64, 2), fake.calls.load(.monotonic));
|
||||
|
||||
// Ruling 8: a refused query is never query-logged.
|
||||
var entries: [log_queue_len]logger_mod.Entry = undefined;
|
||||
try testing.expectEqual(@as(usize, 2), drainLog(&lg, io, &entries).len);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// case 9: pause
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test "S7 case 9: pause lifts filtering and unpause restores it" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
var threaded: std.Io.Threaded = .init(gpa, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var snapshot = try buildSnapshot(gpa, .{ .rules = &.{blockRule("ads.example.com")} });
|
||||
defer snapshot.deinit();
|
||||
var mgr: manager.Manager = undefined;
|
||||
fixtureManager(&mgr, &snapshot);
|
||||
|
||||
var paused: pause.Pause = .{};
|
||||
paused.pauseFor(std.Io.Clock.real.now(io).toSeconds(), null);
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = .a };
|
||||
var h = baseHandler(fake.client());
|
||||
h.manager = &mgr;
|
||||
h.pause = &paused;
|
||||
|
||||
var loop = try Loop.bind(gpa, io, &h);
|
||||
defer loop.stop(gpa, io);
|
||||
try loop.start(io);
|
||||
|
||||
var reply_buf: [udp_server.max_datagram]u8 = undefined;
|
||||
|
||||
var paused_buf: [512]u8 = undefined;
|
||||
const while_paused = try loop.ask(io, queryFor(&paused_buf, 0x9901, "ads.example.com", .a), &reply_buf);
|
||||
const p = try packet.parse(while_paused);
|
||||
try testing.expectEqual(upstream_rdata, try record.rdataA(p.bytes, try firstAnswer(p)));
|
||||
try testing.expectEqual(@as(u64, 1), h.stats.paused_queries.load(.monotonic));
|
||||
try testing.expectEqual(@as(u64, 0), h.stats.blocked.load(.monotonic));
|
||||
|
||||
// Resuming puts the block back without a restart (ruling 18).
|
||||
paused.unpause();
|
||||
|
||||
var resumed_buf: [512]u8 = undefined;
|
||||
const after = try loop.ask(io, queryFor(&resumed_buf, 0x9902, "ads.example.com", .a), &reply_buf);
|
||||
const after_p = try packet.parse(after);
|
||||
try testing.expectEqual([4]u8{ 0, 0, 0, 0 }, try record.rdataA(after_p.bytes, try firstAnswer(after_p)));
|
||||
try testing.expectEqual(@as(u64, 1), h.stats.blocked.load(.monotonic));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// case 10: client tracking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test "S7 case 10: the querying client is materialised as a row" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
var threaded: std.Io.Threaded = .init(gpa, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
// The tracker's own connection (ruling 21), in memory here: the flush is
|
||||
// what this case asserts on, not where the file lives.
|
||||
var database = try db.Db.open(":memory:", .{ .mode = .memory });
|
||||
defer database.close();
|
||||
try db.applyPragmas(&database, .{});
|
||||
_ = try migrations.migrate(&database);
|
||||
|
||||
var tracker: clients.Tracker = .init(30);
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = .a };
|
||||
var h = baseHandler(fake.client());
|
||||
h.tracker = &tracker;
|
||||
|
||||
var loop = try Loop.bind(gpa, io, &h);
|
||||
defer loop.stop(gpa, io);
|
||||
try loop.start(io);
|
||||
|
||||
var reply_buf: [udp_server.max_datagram]u8 = undefined;
|
||||
for ([_]u16{ 0xa001, 0xa002 }) |id| {
|
||||
var query_buf: [512]u8 = undefined;
|
||||
_ = try loop.ask(io, queryFor(&query_buf, id, "example.com", .a), &reply_buf);
|
||||
}
|
||||
|
||||
// Two queries from one client are one pending entry, and the forced pass
|
||||
// stands in for the 60-second flush interval (S4 As-built seam).
|
||||
try testing.expectEqual(@as(u32, 1), tracker.pendingClients(io));
|
||||
tracker.flushOnce(io, &database, true);
|
||||
|
||||
try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
|
||||
try testing.expectEqual(@as(u32, 0), tracker.pendingClients(io));
|
||||
try testing.expectEqual(@as(u64, 1), tracker.snapshotStats(io).flushed);
|
||||
|
||||
var stmt = try database.prepare("SELECT ip, hand_edited FROM clients");
|
||||
defer stmt.deinit();
|
||||
try testing.expect(try stmt.step());
|
||||
try testing.expectEqualStrings("127.0.0.1", stmt.columnText(0));
|
||||
try testing.expectEqual(@as(i64, 0), stmt.columnInt(1));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// case 11: the whole application
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `std.testing.tmpDir` creates its directory against `std.testing.io`, so the
|
||||
/// application under test runs on the same `Io` instance the fixture used. The
|
||||
/// other cases build an `Io.Threaded` of their own, the way the listener tests
|
||||
/// do; this one cannot, because the temporary directory is already bound to
|
||||
/// this instance.
|
||||
const test_io = testing.io;
|
||||
|
||||
/// Where `std.testing.tmpDir` puts its directories (`lib/std/testing.zig:634`).
|
||||
const tmp_prefix = ".zig-cache/tmp/";
|
||||
const sub_path_len = @typeInfo(@FieldType(testing.TmpDir, "sub_path")).array.len;
|
||||
|
||||
/// High enough to need no privilege, and not the 15353/15354 pair the milestone
|
||||
/// smoke test used, so a stray smoke process cannot make this case pass.
|
||||
const app_port = 15455;
|
||||
|
||||
/// The unreachable upstream the seed configuration names. Nothing in this case
|
||||
/// needs it: the query it resolves is a local record, and a dead upstream is
|
||||
/// what proves the fail-open design still serves.
|
||||
const dead_upstream = "https://127.0.0.1:9/dns-query";
|
||||
|
||||
const app_config =
|
||||
\\.{
|
||||
\\ .dns = .{
|
||||
\\ .bind_ipv4 = "127.0.0.1",
|
||||
\\ .bind_ipv6 = "::1",
|
||||
\\ .port = 15455,
|
||||
\\ .rate_limit = 1000,
|
||||
\\ .rate_window_seconds = 60,
|
||||
\\ },
|
||||
\\ .logging = .{ .level = .info, .output = .stderr },
|
||||
\\ .web = .{ .enabled = false },
|
||||
\\ .groups = .{ .{ .name = "default" } },
|
||||
\\ .upstreams = .{ .{ .url = "https://127.0.0.1:9/dns-query" } },
|
||||
\\ .local_records = .{
|
||||
\\ .{ .name = "boot.test", .rtype = .a, .value = "10.9.8.7", .ttl = 60 },
|
||||
\\ },
|
||||
\\}
|
||||
\\
|
||||
;
|
||||
|
||||
comptime {
|
||||
// The port and the upstream appear in the configuration text as literals,
|
||||
// because a `.zon` file is data and not a format string.
|
||||
std.debug.assert(std.mem.containsAtLeast(u8, app_config, 1, std.fmt.comptimePrint("{d}", .{app_port})));
|
||||
std.debug.assert(std.mem.containsAtLeast(u8, app_config, 1, dead_upstream));
|
||||
}
|
||||
|
||||
/// How long one attempt at reaching the booting server waits, and how many
|
||||
/// attempts it gets. The product is the time the application has to bind.
|
||||
const boot_attempt: std.Io.Timeout = .{ .duration = .{ .raw = .fromMilliseconds(200), .clock = .awake } };
|
||||
const boot_attempts = 100;
|
||||
|
||||
/// Queries the booting server until it answers. A server that has not bound yet
|
||||
/// either swallows the datagram or answers it with an ICMP rejection, and both
|
||||
/// arrive here as an error worth retrying.
|
||||
fn askUntilAnswered(
|
||||
socket: *const net.Socket,
|
||||
dest: net.IpAddress,
|
||||
query: []const u8,
|
||||
buf: []u8,
|
||||
) ![]u8 {
|
||||
var attempt: usize = 0;
|
||||
while (attempt < boot_attempts) : (attempt += 1) {
|
||||
socket.send(test_io, &dest, query) catch continue;
|
||||
const msg = socket.receiveTimeout(test_io, buf, boot_attempt) catch continue;
|
||||
return msg.data;
|
||||
}
|
||||
return error.TestAppNeverAnswered;
|
||||
}
|
||||
|
||||
test "S7 case 11: the app boots, serves a query and exits zero on shutdown" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
|
||||
var tmp = testing.tmpDir(.{ .iterate = true });
|
||||
defer tmp.cleanup();
|
||||
try tmp.dir.writeFile(test_io, .{ .sub_path = "config.zon", .data = app_config });
|
||||
|
||||
var root_buf: [tmp_prefix.len + sub_path_len]u8 = undefined;
|
||||
@memcpy(root_buf[0..tmp_prefix.len], tmp_prefix);
|
||||
@memcpy(root_buf[tmp_prefix.len..], &tmp.sub_path);
|
||||
const root: []const u8 = &root_buf;
|
||||
|
||||
var config_buf: [root_buf.len + "/config.zon".len]u8 = undefined;
|
||||
const config_path = try std.fmt.bufPrint(&config_buf, "{s}/config.zon", .{root});
|
||||
|
||||
var out: std.Io.Writer.Allocating = .init(gpa);
|
||||
defer out.deinit();
|
||||
var err: std.Io.Writer.Allocating = .init(gpa);
|
||||
defer err.deinit();
|
||||
|
||||
const runner: cli.Runner = .{
|
||||
.io = test_io,
|
||||
.gpa = gpa,
|
||||
.out = &out.writer,
|
||||
.err = &err.writer,
|
||||
};
|
||||
|
||||
// The shutdown event is process-global, and another case in this binary may
|
||||
// have left it set.
|
||||
shutdown.reset();
|
||||
defer shutdown.reset();
|
||||
|
||||
var future = try test_io.concurrent(app.run, .{ runner, cli.Paths{
|
||||
.data_dir = root,
|
||||
.config = config_path,
|
||||
} });
|
||||
|
||||
const client_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
const client = try client_address.bind(test_io, .{ .mode = .dgram });
|
||||
defer client.close(test_io);
|
||||
|
||||
const server_address: net.IpAddress = try .parse("127.0.0.1", app_port);
|
||||
|
||||
var query_buf: [512]u8 = undefined;
|
||||
const query = queryFor(&query_buf, 0xb001, "boot.test", .a);
|
||||
var reply_buf: [udp_server.max_datagram]u8 = undefined;
|
||||
|
||||
const reply = askUntilAnswered(&client, server_address, query, &reply_buf) catch |e| {
|
||||
shutdown.trigger(test_io);
|
||||
_ = future.await(test_io);
|
||||
return e;
|
||||
};
|
||||
|
||||
const p = try packet.parse(reply);
|
||||
try testing.expectEqual(@as(u16, 0xb001), p.header.id);
|
||||
try testing.expectEqual(types.Rcode.no_error, p.header.flags.rcode);
|
||||
try testing.expectEqual(true, p.header.flags.aa);
|
||||
try testing.expectEqual([4]u8{ 10, 9, 8, 7 }, try record.rdataA(p.bytes, try firstAnswer(p)));
|
||||
|
||||
shutdown.trigger(test_io);
|
||||
try testing.expectEqual(cli.exit_ok, future.await(test_io));
|
||||
|
||||
// The lifecycle proof is the exit code, and a clean exit prints nothing.
|
||||
try testing.expectEqualStrings("", err.written());
|
||||
}
|
||||
@@ -18,6 +18,10 @@ const net = std.Io.net;
|
||||
const handler = @import("handler.zig");
|
||||
const tcp_server = @import("tcp_server.zig");
|
||||
const udp_server = @import("udp_server.zig");
|
||||
const model = @import("../config/model.zig");
|
||||
const response = @import("../filter/response.zig");
|
||||
const forward_zones = @import("../local/forward_zones.zig");
|
||||
const records = @import("../local/records.zig");
|
||||
const packet = @import("../dns/packet.zig");
|
||||
const record = @import("../dns/record.zig");
|
||||
const types = @import("../dns/types.zig");
|
||||
@@ -27,6 +31,31 @@ const transport = @import("../upstream/transport.zig");
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
const empty_records: records.Records = .empty;
|
||||
const empty_zones: forward_zones.Zones = .empty;
|
||||
const blocking_defaults: model.Blocking = .{};
|
||||
const blocking: response.Options = .{
|
||||
.mode = blocking_defaults.response,
|
||||
.ttl = blocking_defaults.ttl,
|
||||
};
|
||||
const forward_timeout: std.Io.Clock.Duration = .{
|
||||
.raw = model.readTimeout(.{}),
|
||||
.clock = .awake,
|
||||
};
|
||||
|
||||
/// An upstream and nothing else optional: no filtering, no cache, no log. The
|
||||
/// listeners and the pool are what this test exercises, so the handler is the
|
||||
/// same bare one its own tests use.
|
||||
fn bareHandler(client: transport.Client) handler.Handler {
|
||||
return .{
|
||||
.upstream = client,
|
||||
.blocking = blocking,
|
||||
.forward_read_timeout = forward_timeout,
|
||||
.records = &empty_records,
|
||||
.zones = &empty_zones,
|
||||
};
|
||||
}
|
||||
|
||||
/// Long enough that a loopback round trip cannot lose to scheduling, short
|
||||
/// enough that a broken server fails the run instead of hanging it.
|
||||
const budget: std.Io.Timeout = .{ .duration = .{ .raw = .fromSeconds(5), .clock = .awake } };
|
||||
@@ -226,7 +255,7 @@ test "the whole resolver answers over udp and tcp and fails over to a healthy up
|
||||
};
|
||||
var upstreams: pool.Pool = .init(&entries, test_cfg, attempt_timeout, 1);
|
||||
|
||||
var h: handler.Handler = .{ .upstream = upstreams.client() };
|
||||
var h = bareHandler(upstreams.client());
|
||||
|
||||
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var udp = try udp_server.UdpServer.bind(gpa, io, listen_address, &h, .{ .max_in_flight = 4 });
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
//! SIGINT and SIGTERM, turned into one `std.Io.Event`.
|
||||
//!
|
||||
//! No signalfd, no self-pipe, no epoll: the handler does exactly one thing, and
|
||||
//! `std.Io.Event.set` is async-signal-safe on the Threaded Linux backend — a
|
||||
//! raw `futex` wake with no allocation and no lock (`Io.zig:1855` →
|
||||
//! `Threaded.futexWake`). The `.mask`/`.flags` shape is the one Threaded uses
|
||||
//! for its own `SIG.IO`/`SIG.PIPE` handlers (`Threaded.zig:1653`): an empty
|
||||
//! mask and no `SA_RESTART`, so a blocking syscall returns `EINTR` and the
|
||||
//! backend's retry loop re-reads the cancellation state.
|
||||
//!
|
||||
//! Everything a shutdown actually has to do — drain the query log, cancel the
|
||||
//! task group, close the databases — happens on the task blocked in `wait`.
|
||||
//!
|
||||
//! The previous handlers are not restored. The process is leaving, and a
|
||||
//! second SIGTERM during teardown should still terminate it the default way
|
||||
//! only if the operator sends it before this module is armed.
|
||||
|
||||
const std = @import("std");
|
||||
const posix = std.posix;
|
||||
|
||||
var event: std.Io.Event = .unset;
|
||||
|
||||
/// Read by the signal handler, written by `install` before the handler exists.
|
||||
/// A `std.Io` is two pointers and cannot be stored atomically, so ordering is
|
||||
/// what makes the read safe: the store precedes the `sigaction` syscall that
|
||||
/// arms the handler, and no signal can reach the handler before that call
|
||||
/// returns.
|
||||
var handler_io: ?std.Io = null;
|
||||
|
||||
var installed: bool = false;
|
||||
|
||||
/// Arms the handlers for INT and TERM. Calling it again is a no-op: the process
|
||||
/// has one event and one pair of handlers, and a second boot inside one process
|
||||
/// (which only a test does) must not re-arm anything.
|
||||
pub fn install(io: std.Io) void {
|
||||
if (installed) return;
|
||||
handler_io = io;
|
||||
installed = true;
|
||||
|
||||
const act: posix.Sigaction = .{
|
||||
.handler = .{ .handler = onSignal },
|
||||
.mask = posix.sigemptyset(),
|
||||
.flags = 0,
|
||||
};
|
||||
posix.sigaction(.INT, &act, null);
|
||||
posix.sigaction(.TERM, &act, null);
|
||||
}
|
||||
|
||||
fn onSignal(_: posix.SIG) callconv(.c) void {
|
||||
const io = handler_io orelse return;
|
||||
event.set(io);
|
||||
}
|
||||
|
||||
/// Blocks until a shutdown is requested. A canceled wait is the caller's cue to
|
||||
/// tear down as well, which is why `app.run` treats both results the same.
|
||||
pub fn wait(io: std.Io) std.Io.Cancelable!void {
|
||||
return event.wait(io);
|
||||
}
|
||||
|
||||
/// The programmatic equivalent of the signal: what a test uses to shut the app
|
||||
/// down, and what a Phase 8 restart endpoint would call.
|
||||
pub fn trigger(io: std.Io) void {
|
||||
event.set(io);
|
||||
}
|
||||
|
||||
pub fn isRequested() bool {
|
||||
return event.isSet();
|
||||
}
|
||||
|
||||
/// Clears the request so the next `wait` blocks again. Only a test that boots
|
||||
/// the app more than once in one process needs this; a served process shuts
|
||||
/// down once.
|
||||
pub fn reset() void {
|
||||
event.reset();
|
||||
}
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "trigger releases a waiter and isRequested reports it" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
reset();
|
||||
try testing.expect(!isRequested());
|
||||
|
||||
trigger(io);
|
||||
try testing.expect(isRequested());
|
||||
// Already set, so this returns without blocking.
|
||||
try wait(io);
|
||||
|
||||
reset();
|
||||
try testing.expect(!isRequested());
|
||||
}
|
||||
|
||||
test "a waiting task is released by a later trigger" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
reset();
|
||||
|
||||
var group: std.Io.Group = .init;
|
||||
try group.concurrent(io, waitThenSet, .{ io, &done });
|
||||
trigger(io);
|
||||
try group.await(io);
|
||||
|
||||
try testing.expect(done.isSet());
|
||||
reset();
|
||||
done.reset();
|
||||
}
|
||||
|
||||
var done: std.Io.Event = .unset;
|
||||
|
||||
fn waitThenSet(io: std.Io, flag: *std.Io.Event) std.Io.Cancelable!void {
|
||||
try wait(io);
|
||||
flag.set(io);
|
||||
}
|
||||
+80
-17
@@ -12,8 +12,25 @@
|
||||
//! No stream read or write in 0.16.0 accepts a timeout, so every per-connection
|
||||
//! operation is raced against `Options.idle_timeout` through `std.Io.Select` and
|
||||
//! the loser is canceled.
|
||||
//!
|
||||
//! Shutdown takes one of two paths, and they end the live connections
|
||||
//! differently on purpose:
|
||||
//!
|
||||
//! - `deinit` shuts every active stream down first, so the connections unblock
|
||||
//! and finish by themselves. `serve` then drains them, and a reply that was
|
||||
//! half written still goes out whole.
|
||||
//! - A canceled `serve` cannot drain. `deinit` is what would shut the streams
|
||||
//! down, and it cannot run until `serve` returns — the composition root
|
||||
//! cancels its task group before it releases anything (app.zig). Meanwhile
|
||||
//! RFC 7766 §6.2.1.1 lets a client hold a connection open indefinitely by
|
||||
//! asking again inside the idle budget, so draining would let one chatty
|
||||
//! client stall the whole process's shutdown. The connections are canceled
|
||||
//! instead, at the cost of the one reply that was mid-write.
|
||||
//!
|
||||
//! Either way `serve` returns only once no task can still touch a slot.
|
||||
|
||||
const std = @import("std");
|
||||
const address = @import("../platform/address.zig");
|
||||
const handler = @import("handler.zig");
|
||||
const transport = @import("../upstream/transport.zig");
|
||||
|
||||
@@ -53,6 +70,16 @@ const State = enum(u32) { idle, serving, closing };
|
||||
/// before the close, and `deinit` only touches `.active` slots.
|
||||
const ConnState = enum { free, active, closing };
|
||||
|
||||
/// Why the accept loop stopped, which decides what happens to the connections
|
||||
/// still in flight.
|
||||
const Stop = enum {
|
||||
/// `deinit` published `.closing`. It has already shut every live connection
|
||||
/// down, so each one is unblocked and finishing on its own.
|
||||
closing,
|
||||
/// This task is being canceled. Nothing has touched the connections.
|
||||
canceled,
|
||||
};
|
||||
|
||||
/// What the accept loop does with a stream it has just accepted.
|
||||
const Claim = union(enum) {
|
||||
/// The stream owns `conns[index]`.
|
||||
@@ -77,7 +104,7 @@ pub const TcpServer = struct {
|
||||
state: std.atomic.Value(State),
|
||||
stopped: std.Io.Event,
|
||||
|
||||
/// One slot is ~131 KiB, so the default 64 connections cost ~8.4 MiB, which
|
||||
/// One slot is ~137 KiB, so the default 64 connections cost ~8.8 MiB, which
|
||||
/// is inside the PLAN §18 budget. The two message buffers cannot be shared
|
||||
/// or shrunk: the handler holds the query while the reply is built, and
|
||||
/// both ceilings are the 65535 bytes the length prefix can express.
|
||||
@@ -86,7 +113,15 @@ pub const TcpServer = struct {
|
||||
reply: [transport.max_message_len]u8,
|
||||
read_buf: [stream_buffer_len]u8,
|
||||
write_buf: [stream_buffer_len]u8,
|
||||
/// The handler's per-query working memory. It belongs to the slot so
|
||||
/// that answering a message allocates nothing, and a connection is
|
||||
/// answered serially, so one query uses it at a time.
|
||||
scratch: handler.Scratch,
|
||||
stream: std.Io.net.Stream,
|
||||
/// The client, read off the accepted socket once at claim time: every
|
||||
/// message on this connection comes from the same peer, and the handler
|
||||
/// needs it for rate limiting, groups and the query log.
|
||||
peer: std.Io.net.IpAddress,
|
||||
/// Guarded by `TcpServer.mutex`.
|
||||
state: ConnState,
|
||||
};
|
||||
@@ -96,7 +131,7 @@ pub const TcpServer = struct {
|
||||
pub fn listen(
|
||||
gpa: std.mem.Allocator,
|
||||
io: std.Io,
|
||||
address: std.Io.net.IpAddress,
|
||||
listen_address: std.Io.net.IpAddress,
|
||||
h: *handler.Handler,
|
||||
options: Options,
|
||||
) ListenError!TcpServer {
|
||||
@@ -106,7 +141,7 @@ pub const TcpServer = struct {
|
||||
errdefer gpa.free(conns);
|
||||
for (conns) |*conn| conn.state = .free;
|
||||
|
||||
const local = address;
|
||||
const local = listen_address;
|
||||
const server = try local.listen(io, .{ .reuse_address = true });
|
||||
|
||||
return .{
|
||||
@@ -132,15 +167,28 @@ pub const TcpServer = struct {
|
||||
if (self.state.cmpxchgStrong(.idle, .serving, .acq_rel, .acquire) != null) return;
|
||||
|
||||
var group: std.Io.Group = .init;
|
||||
self.acceptLoop(io, &group);
|
||||
|
||||
// A reply that is half written is worse than no reply, so the live
|
||||
// connections are awaited even when this task is being canceled.
|
||||
const prev = io.swapCancelProtection(.blocked);
|
||||
group.await(io) catch |err| switch (err) {
|
||||
error.Canceled => unreachable,
|
||||
};
|
||||
_ = io.swapCancelProtection(prev);
|
||||
switch (self.acceptLoop(io, &group)) {
|
||||
// `deinit` shut every live connection down before it published
|
||||
// `.closing`, so each one is already unblocked and ending on its
|
||||
// own. Awaiting them means a half-written reply still goes out
|
||||
// whole, and the wait is bounded by the shutdown, not the client.
|
||||
.closing => {
|
||||
const prev = io.swapCancelProtection(.blocked);
|
||||
group.await(io) catch |err| switch (err) {
|
||||
error.Canceled => unreachable,
|
||||
};
|
||||
_ = io.swapCancelProtection(prev);
|
||||
},
|
||||
// Nothing has shut these connections down: `deinit` cannot run
|
||||
// until this task returns, and RFC 7766 lets a client hold a
|
||||
// connection open forever by asking again inside the idle budget.
|
||||
// Draining here would therefore let one client stall the whole
|
||||
// process's shutdown for as long as it likes. `cancel` requests
|
||||
// cancellation and joins, so the slots are still quiet — and the
|
||||
// buffers still unreferenced — by the time `serve` returns; the
|
||||
// price is the one reply that was mid-write.
|
||||
.canceled => group.cancel(io),
|
||||
}
|
||||
|
||||
self.stopped.set(io);
|
||||
}
|
||||
@@ -167,14 +215,17 @@ pub const TcpServer = struct {
|
||||
self.* = undefined;
|
||||
}
|
||||
|
||||
fn acceptLoop(self: *TcpServer, io: std.Io, group: *std.Io.Group) void {
|
||||
fn acceptLoop(self: *TcpServer, io: std.Io, group: *std.Io.Group) Stop {
|
||||
while (self.state.load(.acquire) == .serving) {
|
||||
const stream = self.server.accept(io) catch |err| switch (err) {
|
||||
error.Canceled, error.SocketNotListening => return,
|
||||
error.Canceled => return .canceled,
|
||||
// `deinit` shuts the listening socket down to unblock exactly
|
||||
// this call, so it is the shutdown path arriving early.
|
||||
error.SocketNotListening => return .closing,
|
||||
else => {
|
||||
bump(&self.stats.accept_errors);
|
||||
log.debug("tcp accept failed: {t}", .{err});
|
||||
retry_delay.sleep(io) catch return;
|
||||
retry_delay.sleep(io) catch return .canceled;
|
||||
continue;
|
||||
},
|
||||
};
|
||||
@@ -192,7 +243,7 @@ pub const TcpServer = struct {
|
||||
.shutting_down => {
|
||||
bump(&self.stats.rejected_at_shutdown);
|
||||
stream.close(io);
|
||||
return;
|
||||
return .closing;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -206,6 +257,9 @@ pub const TcpServer = struct {
|
||||
|
||||
bump(&self.stats.accepted);
|
||||
}
|
||||
|
||||
// The loop condition failed, which only `deinit` can cause.
|
||||
return .closing;
|
||||
}
|
||||
|
||||
fn serveConn(self: *TcpServer, io: std.Io, index: usize) void {
|
||||
@@ -258,7 +312,15 @@ pub const TcpServer = struct {
|
||||
},
|
||||
}
|
||||
|
||||
const bytes = switch (self.handler.handle(io, .tcp, conn.query[0..len], &conn.reply)) {
|
||||
const outcome = self.handler.handle(
|
||||
io,
|
||||
.tcp,
|
||||
address.NetAddress.fromIp(conn.peer),
|
||||
conn.query[0..len],
|
||||
&conn.reply,
|
||||
&conn.scratch,
|
||||
);
|
||||
const bytes = switch (outcome) {
|
||||
// There is no framing for "no answer", so the connection ends.
|
||||
.drop => return,
|
||||
.reply => |b| b,
|
||||
@@ -286,6 +348,7 @@ pub const TcpServer = struct {
|
||||
switch (outcome) {
|
||||
.slot => |index| {
|
||||
self.conns[index].stream = stream;
|
||||
self.conns[index].peer = stream.socket.address;
|
||||
self.conns[index].state = .active;
|
||||
},
|
||||
.at_capacity, .shutting_down => {},
|
||||
|
||||
@@ -15,6 +15,10 @@ const net = std.Io.net;
|
||||
|
||||
const handler = @import("handler.zig");
|
||||
const tcp_server = @import("tcp_server.zig");
|
||||
const model = @import("../config/model.zig");
|
||||
const response = @import("../filter/response.zig");
|
||||
const forward_zones = @import("../local/forward_zones.zig");
|
||||
const records = @import("../local/records.zig");
|
||||
const header = @import("../dns/header.zig");
|
||||
const packet = @import("../dns/packet.zig");
|
||||
const types = @import("../dns/types.zig");
|
||||
@@ -22,6 +26,31 @@ const transport = @import("../upstream/transport.zig");
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
const empty_records: records.Records = .empty;
|
||||
const empty_zones: forward_zones.Zones = .empty;
|
||||
const blocking_defaults: model.Blocking = .{};
|
||||
const blocking: response.Options = .{
|
||||
.mode = blocking_defaults.response,
|
||||
.ttl = blocking_defaults.ttl,
|
||||
};
|
||||
const forward_timeout: std.Io.Clock.Duration = .{
|
||||
.raw = model.readTimeout(.{}),
|
||||
.clock = .awake,
|
||||
};
|
||||
|
||||
/// An upstream and nothing else optional: no filtering, no cache, no log. The
|
||||
/// listener is what these tests exercise, so the handler is the same bare one
|
||||
/// its own tests use.
|
||||
fn bareHandler(client: transport.Client) handler.Handler {
|
||||
return .{
|
||||
.upstream = client,
|
||||
.blocking = blocking,
|
||||
.forward_read_timeout = forward_timeout,
|
||||
.records = &empty_records,
|
||||
.zones = &empty_zones,
|
||||
};
|
||||
}
|
||||
|
||||
const budget: std.Io.Clock.Duration = .{ .raw = .fromSeconds(5), .clock = .awake };
|
||||
|
||||
/// Short enough to keep the idle-timeout test quick, long enough that a
|
||||
@@ -150,7 +179,7 @@ test "two length-prefixed queries share one connection" {
|
||||
const io = threaded.io();
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h: handler.Handler = .{ .upstream = fake.client() };
|
||||
var h = bareHandler(fake.client());
|
||||
|
||||
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{ .max_connections = 2 });
|
||||
@@ -171,6 +200,145 @@ test "two length-prefixed queries share one connection" {
|
||||
};
|
||||
}
|
||||
|
||||
test "the claimed slot records the connecting client" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
var threaded: std.Io.Threaded = .init(gpa, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h = bareHandler(fake.client());
|
||||
|
||||
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{ .max_connections = 2 });
|
||||
const server_address = server.boundAddress();
|
||||
|
||||
var group: std.Io.Group = .init;
|
||||
try group.concurrent(io, tcp_server.TcpServer.serve, .{ &server, io });
|
||||
|
||||
try bounded(io, twoQueriesOnOneConnection, .{ io, server_address });
|
||||
|
||||
// The only connection took slot 0, and the reply the client already read
|
||||
// was written after `claim` filled the slot in, so this read races nothing.
|
||||
// Without a real peer the handler would rate-limit, group and log every TCP
|
||||
// client under whatever the uninitialized slot happened to hold.
|
||||
const peer = server.conns[0].peer;
|
||||
try testing.expectEqual(net.IpAddress.ip4, std.meta.activeTag(peer));
|
||||
try testing.expectEqualSlices(u8, &[_]u8{ 127, 0, 0, 1 }, &peer.ip4.bytes);
|
||||
try testing.expect(peer.ip4.port != 0);
|
||||
|
||||
server.deinit(gpa, io);
|
||||
group.await(io) catch |err| switch (err) {
|
||||
error.Canceled => unreachable,
|
||||
};
|
||||
}
|
||||
|
||||
/// `std.Io.Group` takes only `Cancelable!void`, so the result is reported out
|
||||
/// of band. `answered` is set either way — a client that fails early must not
|
||||
/// leave the test blocked waiting for a reply that will never come, and `set`
|
||||
/// is documented to have no effect the second time.
|
||||
fn holdConnection(
|
||||
io: std.Io,
|
||||
address: net.IpAddress,
|
||||
answered: *std.Io.Event,
|
||||
result: *anyerror!void,
|
||||
) void {
|
||||
result.* = holdConnectionOpen(io, address, answered);
|
||||
answered.set(io);
|
||||
}
|
||||
|
||||
/// Holds a connection open the way RFC 7766 lets a real client hold one: one
|
||||
/// query answered, then nothing, so the server sits blocked on the read for the
|
||||
/// next message. Returns once the server ends the connection, however it ends
|
||||
/// it — a canceled server closes the socket, which the client sees either as
|
||||
/// end of stream or as a reset.
|
||||
fn holdConnectionOpen(io: std.Io, address: net.IpAddress, answered: *std.Io.Event) anyerror!void {
|
||||
const remote = address;
|
||||
var stream = try remote.connect(io, .{ .mode = .stream });
|
||||
defer stream.close(io);
|
||||
|
||||
var read_buf: [1024]u8 = undefined;
|
||||
var write_buf: [1024]u8 = undefined;
|
||||
var reader = stream.reader(io, &read_buf);
|
||||
var writer = stream.writer(io, &write_buf);
|
||||
|
||||
try writer.interface.writeAll(&transport.framePrefix(@intCast(query_bytes.len)));
|
||||
try writer.interface.writeAll(query_bytes);
|
||||
try writer.interface.flush();
|
||||
|
||||
const len = transport.parsePrefix((try reader.interface.takeArray(transport.prefix_len)).*);
|
||||
try expectAnswersQuery(try reader.interface.take(len));
|
||||
|
||||
answered.set(io);
|
||||
|
||||
var sink: [64]u8 = undefined;
|
||||
_ = reader.interface.readSliceShort(&sink) catch {};
|
||||
}
|
||||
|
||||
test "a canceled serve does not wait for a live connection" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
var threaded: std.Io.Threaded = .init(gpa, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h = bareHandler(fake.client());
|
||||
|
||||
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
// The idle budget is the whole time a drain would have to wait out, so it
|
||||
// is set far beyond any patience this test run has: if `serve` waits for
|
||||
// the connection instead of canceling it, the wait never ends.
|
||||
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{
|
||||
.max_connections = 2,
|
||||
.idle_timeout = .{ .raw = .fromSeconds(600), .clock = .awake },
|
||||
});
|
||||
const server_address = server.boundAddress();
|
||||
|
||||
var serving: std.Io.Group = .init;
|
||||
try serving.concurrent(io, tcp_server.TcpServer.serve, .{ &server, io });
|
||||
|
||||
var answered: std.Io.Event = .unset;
|
||||
var client_result: anyerror!void = {};
|
||||
var client_group: std.Io.Group = .init;
|
||||
try client_group.concurrent(io, holdConnection, .{ io, server_address, &answered, &client_result });
|
||||
|
||||
// The reply proves the connection is claimed and served, so the connection
|
||||
// task is now blocked reading the message that never comes. That is the
|
||||
// state a drain hangs in.
|
||||
answered.waitUncancelable(io);
|
||||
|
||||
// This is the composition root's shutdown: cancel the task group before
|
||||
// anything it borrows is released, so no `deinit` has shut the connection
|
||||
// down. It must still return. A regression here does not fail the test, it
|
||||
// hangs the run — there is no way to bound a join that does not finish.
|
||||
serving.cancel(io);
|
||||
|
||||
// Cancellation must not skip the per-connection cleanup: the slot is
|
||||
// released and the socket closed by `serveConn`'s defer, which runs on the
|
||||
// canceled path like any other.
|
||||
try testing.expectEqual(@as(?usize, 0), firstFreeSlot(&server));
|
||||
|
||||
client_group.cancel(io);
|
||||
server.deinit(gpa, io);
|
||||
|
||||
// Checked last: the connection had to be answered for the test to mean
|
||||
// anything, and the server is torn down before a failure is reported.
|
||||
try client_result;
|
||||
}
|
||||
|
||||
/// The first slot the server would hand out, read after `serve` has returned so
|
||||
/// nothing can be writing it.
|
||||
fn firstFreeSlot(server: *const tcp_server.TcpServer) ?usize {
|
||||
for (server.conns, 0..) |*conn, index| {
|
||||
if (conn.state == .free) return index;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
test "an idle connection is closed and counted" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
@@ -180,7 +348,7 @@ test "an idle connection is closed and counted" {
|
||||
const io = threaded.io();
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h: handler.Handler = .{ .upstream = fake.client() };
|
||||
var h = bareHandler(fake.client());
|
||||
|
||||
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{
|
||||
@@ -213,7 +381,7 @@ test "a zero-length message is a connection error" {
|
||||
const io = threaded.io();
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h: handler.Handler = .{ .upstream = fake.client() };
|
||||
var h = bareHandler(fake.client());
|
||||
|
||||
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{
|
||||
@@ -265,7 +433,7 @@ test "deinit ends a serve loop that is blocked on accept" {
|
||||
const io = threaded.io();
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h: handler.Handler = .{ .upstream = fake.client() };
|
||||
var h = bareHandler(fake.client());
|
||||
|
||||
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{ .max_connections = 2 });
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
//! allocates nothing. The pool is sized once in `bind` and never grows.
|
||||
|
||||
const std = @import("std");
|
||||
const address = @import("../platform/address.zig");
|
||||
const handler = @import("handler.zig");
|
||||
const transport = @import("../upstream/transport.zig");
|
||||
|
||||
@@ -75,10 +76,14 @@ pub const UdpServer = struct {
|
||||
/// The datagram actually sent stays bounded by `udpLimit` inside the
|
||||
/// handler, so nothing larger than 4096 bytes leaves this socket.
|
||||
///
|
||||
/// Cost: 4096 + 65535 ≈ 68 KiB per slot, so the default 64 slots hold
|
||||
/// ≈ 4.3 MiB. The PLAN §18 budget is 100 MB with ~1M blocked domains,
|
||||
/// so this pool takes about 4% of it.
|
||||
/// Cost: 4096 + 65535 + the scratch below ≈ 74 KiB per slot, so the
|
||||
/// default 64 slots hold ≈ 4.6 MiB. The PLAN §18 budget is 100 MB with
|
||||
/// ~1M blocked domains, so this pool takes about 5% of it.
|
||||
reply: [transport.max_message_len]u8,
|
||||
/// The handler's per-query working memory. It belongs to the slot so
|
||||
/// that answering a datagram still allocates nothing, and one slot
|
||||
/// serves one query at a time.
|
||||
scratch: handler.Scratch,
|
||||
from: std.Io.net.IpAddress,
|
||||
len: usize,
|
||||
/// Guarded by `UdpServer.mutex`.
|
||||
@@ -90,7 +95,7 @@ pub const UdpServer = struct {
|
||||
pub fn bind(
|
||||
gpa: std.mem.Allocator,
|
||||
io: std.Io,
|
||||
address: std.Io.net.IpAddress,
|
||||
bind_address: std.Io.net.IpAddress,
|
||||
h: *handler.Handler,
|
||||
options: Options,
|
||||
) BindError!UdpServer {
|
||||
@@ -100,7 +105,7 @@ pub const UdpServer = struct {
|
||||
errdefer gpa.free(slots);
|
||||
for (slots) |*slot| slot.in_use = false;
|
||||
|
||||
const local = address;
|
||||
const local = bind_address;
|
||||
const socket = try local.bind(io, .{ .mode = .dgram });
|
||||
|
||||
return .{
|
||||
@@ -204,7 +209,15 @@ pub const UdpServer = struct {
|
||||
const slot = &self.slots[index];
|
||||
defer self.release(io, index);
|
||||
|
||||
switch (self.handler.handle(io, .udp, slot.query[0..slot.len], &slot.reply)) {
|
||||
const outcome = self.handler.handle(
|
||||
io,
|
||||
.udp,
|
||||
address.NetAddress.fromIp(slot.from),
|
||||
slot.query[0..slot.len],
|
||||
&slot.reply,
|
||||
&slot.scratch,
|
||||
);
|
||||
switch (outcome) {
|
||||
.drop => bump(&self.stats.dropped_handler),
|
||||
.reply => |bytes| self.socket.send(io, &slot.from, bytes) catch |err| {
|
||||
bump(&self.stats.send_errors);
|
||||
@@ -283,8 +296,8 @@ test "a slot's reply buffer holds a whole DNS message" {
|
||||
}
|
||||
|
||||
test "the default slot pool stays inside the memory budget" {
|
||||
// 4096 + 65535 ≈ 68 KiB per slot; 64 slots ≈ 4.3 MiB, against the 100 MB
|
||||
// of PLAN §18.
|
||||
// 4096 + 65535 + scratch ≈ 74 KiB per slot; 64 slots ≈ 4.6 MiB, against the
|
||||
// 100 MB of PLAN §18.
|
||||
const options: Options = .{};
|
||||
const pool_bytes = @sizeOf(UdpServer.Slot) * @as(usize, options.max_in_flight);
|
||||
try testing.expect(pool_bytes < 8 * 1024 * 1024);
|
||||
|
||||
@@ -14,6 +14,10 @@ const net = std.Io.net;
|
||||
|
||||
const handler = @import("handler.zig");
|
||||
const udp_server = @import("udp_server.zig");
|
||||
const model = @import("../config/model.zig");
|
||||
const response = @import("../filter/response.zig");
|
||||
const forward_zones = @import("../local/forward_zones.zig");
|
||||
const records = @import("../local/records.zig");
|
||||
const header = @import("../dns/header.zig");
|
||||
const packet = @import("../dns/packet.zig");
|
||||
const types = @import("../dns/types.zig");
|
||||
@@ -21,6 +25,31 @@ const transport = @import("../upstream/transport.zig");
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
const empty_records: records.Records = .empty;
|
||||
const empty_zones: forward_zones.Zones = .empty;
|
||||
const blocking_defaults: model.Blocking = .{};
|
||||
const blocking: response.Options = .{
|
||||
.mode = blocking_defaults.response,
|
||||
.ttl = blocking_defaults.ttl,
|
||||
};
|
||||
const forward_timeout: std.Io.Clock.Duration = .{
|
||||
.raw = model.readTimeout(.{}),
|
||||
.clock = .awake,
|
||||
};
|
||||
|
||||
/// An upstream and nothing else optional: no filtering, no cache, no log. The
|
||||
/// listener is what these tests exercise, so the handler is the same bare one
|
||||
/// its own tests use.
|
||||
fn bareHandler(client: transport.Client) handler.Handler {
|
||||
return .{
|
||||
.upstream = client,
|
||||
.blocking = blocking,
|
||||
.forward_read_timeout = forward_timeout,
|
||||
.records = &empty_records,
|
||||
.zones = &empty_zones,
|
||||
};
|
||||
}
|
||||
|
||||
/// Long enough that a loopback round trip cannot lose to scheduling, short
|
||||
/// enough that a broken server fails the run instead of hanging it.
|
||||
const budget: std.Io.Timeout = .{ .duration = .{ .raw = .fromSeconds(5), .clock = .awake } };
|
||||
@@ -87,7 +116,7 @@ test "a udp query is answered on the loopback" {
|
||||
const io = threaded.io();
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h: handler.Handler = .{ .upstream = fake.client() };
|
||||
var h = bareHandler(fake.client());
|
||||
|
||||
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var server = try udp_server.UdpServer.bind(gpa, io, listen_address, &h, .{ .max_in_flight = 4 });
|
||||
@@ -125,7 +154,7 @@ test "a runt datagram is dropped and no reply is sent" {
|
||||
const io = threaded.io();
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h: handler.Handler = .{ .upstream = fake.client() };
|
||||
var h = bareHandler(fake.client());
|
||||
|
||||
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var server = try udp_server.UdpServer.bind(gpa, io, listen_address, &h, .{ .max_in_flight = 4 });
|
||||
@@ -160,7 +189,7 @@ test "an oversize datagram arrives truncated and is dropped" {
|
||||
const io = threaded.io();
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h: handler.Handler = .{ .upstream = fake.client() };
|
||||
var h = bareHandler(fake.client());
|
||||
|
||||
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var server = try udp_server.UdpServer.bind(gpa, io, listen_address, &h, .{ .max_in_flight = 4 });
|
||||
@@ -201,7 +230,7 @@ test "deinit ends a serve loop that is blocked on receive" {
|
||||
const io = threaded.io();
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h: handler.Handler = .{ .upstream = fake.client() };
|
||||
var h = bareHandler(fake.client());
|
||||
|
||||
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var server = try udp_server.UdpServer.bind(gpa, io, listen_address, &h, .{ .max_in_flight = 4 });
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
//! not appear in an export. `countClients` counts **all** rows, because S5's
|
||||
//! "has this database ever been configured" predicate needs the true count.
|
||||
//!
|
||||
//! Only list / insert / deleteAll / count exist.
|
||||
//! Only list / insert / deleteAll / count, plus the two runtime calls
|
||||
//! `upsertSeen` and `pruneStale` that the Phase 7 client tracker owns.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
@@ -81,6 +82,51 @@ pub fn insertClient(database: *db.Db, item: model.Client, ctx: InsertContext) db
|
||||
try stmt.exec();
|
||||
}
|
||||
|
||||
const upsert_seen_sql =
|
||||
\\INSERT INTO clients (ip, name, group_id, hand_edited, first_seen, last_seen)
|
||||
\\VALUES (?1, NULL, (SELECT id FROM groups WHERE name = 'default'), 0, ?2, ?2)
|
||||
\\ON CONFLICT(ip) DO UPDATE SET last_seen = excluded.last_seen
|
||||
;
|
||||
|
||||
/// Materialises a client seen in live traffic (PLAN §7.2), or touches
|
||||
/// `last_seen` on the row that already holds `ip`.
|
||||
///
|
||||
/// The conflict target is `clients.ip`, which the schema declares UNIQUE. Only
|
||||
/// `last_seen` is updated: `name`, `group_id` and `hand_edited` are the
|
||||
/// operator's, and a device that keeps querying must not overwrite them. A
|
||||
/// hand-edited row is touched too, so the operator sees liveness for the
|
||||
/// clients they named.
|
||||
///
|
||||
/// A new row lands in the `default` group. `groupForClient` resolves the real
|
||||
/// group from the prefix rules at query time, so the column here only decides
|
||||
/// what the operator sees before they assign the device themselves.
|
||||
///
|
||||
/// A database with no group named `default` fails the insert with
|
||||
/// `error.Constraint` rather than writing a dangling row. `validate.zig`
|
||||
/// rejects such a configuration long before a server serves from it.
|
||||
pub fn upsertSeen(database: *db.Db, ip: []const u8, now_s: i64) db.Error!void {
|
||||
var stmt = try database.prepare(upsert_seen_sql);
|
||||
defer stmt.deinit();
|
||||
try stmt.bindText(1, ip);
|
||||
try stmt.bindInt(2, now_s);
|
||||
try stmt.exec();
|
||||
}
|
||||
|
||||
/// Deletes the auto-materialised clients whose last query predates `cutoff_s`,
|
||||
/// and returns how many rows went. `hand_edited = 1` rows are configuration and
|
||||
/// survive any silence.
|
||||
///
|
||||
/// `last_seen` is the only signal, because §3.6 forbids joining `config.db`
|
||||
/// against the query log.
|
||||
pub fn pruneStale(database: *db.Db, cutoff_s: i64) db.Error!u32 {
|
||||
var stmt = try database.prepare("DELETE FROM clients WHERE hand_edited = 0 AND last_seen < ?1");
|
||||
defer stmt.deinit();
|
||||
try stmt.bindInt(1, cutoff_s);
|
||||
try stmt.exec();
|
||||
const deleted = database.changes();
|
||||
return @intCast(@min(deleted, std.math.maxInt(u32)));
|
||||
}
|
||||
|
||||
pub fn deleteAllClients(database: *db.Db) db.Error!void {
|
||||
return database.exec("DELETE FROM clients;");
|
||||
}
|
||||
@@ -281,6 +327,124 @@ test "listClients is leak-safe under allocation failure" {
|
||||
try testing.checkAllAllocationFailures(testing.allocator, listClientsUnderFailure, .{&ids});
|
||||
}
|
||||
|
||||
fn seenRow(database: *db.Db, ip: []const u8) !struct { hand_edited: i64, first_seen: i64, last_seen: i64, group_id: i64 } {
|
||||
var stmt = try database.prepare(
|
||||
"SELECT hand_edited, first_seen, last_seen, group_id FROM clients WHERE ip = ?1",
|
||||
);
|
||||
defer stmt.deinit();
|
||||
try stmt.bindText(1, ip);
|
||||
try testing.expect(try stmt.step());
|
||||
return .{
|
||||
.hand_edited = stmt.columnInt(0),
|
||||
.first_seen = stmt.columnInt(1),
|
||||
.last_seen = stmt.columnInt(2),
|
||||
.group_id = stmt.columnInt(3),
|
||||
};
|
||||
}
|
||||
|
||||
test "upsertSeen materialises an unseen client in the default group" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
try upsertSeen(&database, "192.168.1.50", 1700000000);
|
||||
|
||||
try testing.expectEqual(@as(i64, 1), try countClients(&database));
|
||||
const row = try seenRow(&database, "192.168.1.50");
|
||||
try testing.expectEqual(@as(i64, 0), row.hand_edited);
|
||||
try testing.expectEqual(@as(i64, 1700000000), row.first_seen);
|
||||
try testing.expectEqual(@as(i64, 1700000000), row.last_seen);
|
||||
try testing.expectEqual(@as(i64, 1), row.group_id);
|
||||
|
||||
// Materialised clients are runtime state, so an export must not see them.
|
||||
var items = try listClients(&database, testing.allocator);
|
||||
defer items.deinit(testing.allocator);
|
||||
defer freeClients(testing.allocator, items.items);
|
||||
try testing.expectEqual(@as(usize, 0), items.items.len);
|
||||
}
|
||||
|
||||
test "upsertSeen touches last_seen and leaves first_seen alone" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
try upsertSeen(&database, "192.168.1.50", 1700000000);
|
||||
try upsertSeen(&database, "192.168.1.50", 1700000600);
|
||||
|
||||
try testing.expectEqual(@as(i64, 1), try countClients(&database));
|
||||
const row = try seenRow(&database, "192.168.1.50");
|
||||
try testing.expectEqual(@as(i64, 1700000000), row.first_seen);
|
||||
try testing.expectEqual(@as(i64, 1700000600), row.last_seen);
|
||||
}
|
||||
|
||||
test "upsertSeen keeps a hand-edited row's name, group and flag" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
var ids = try seedGroups(&database);
|
||||
defer ids.deinit(testing.allocator);
|
||||
try seedClients(&database, &ids);
|
||||
|
||||
try upsertSeen(&database, "192.168.1.20", 1700009999);
|
||||
|
||||
try testing.expectEqual(@as(i64, 3), try countClients(&database));
|
||||
const row = try seenRow(&database, "192.168.1.20");
|
||||
try testing.expectEqual(@as(i64, 1), row.hand_edited);
|
||||
try testing.expectEqual(@as(i64, 2), row.group_id);
|
||||
try testing.expectEqual(@as(i64, 1700000000), row.first_seen);
|
||||
try testing.expectEqual(@as(i64, 1700009999), row.last_seen);
|
||||
|
||||
var items = try listClients(&database, testing.allocator);
|
||||
defer items.deinit(testing.allocator);
|
||||
defer freeClients(testing.allocator, items.items);
|
||||
try testing.expectEqual(@as(usize, 3), items.items.len);
|
||||
try testing.expectEqualStrings("laptop", items.items[1].name);
|
||||
try testing.expectEqualStrings("kids", items.items[1].group);
|
||||
}
|
||||
|
||||
test "upsertSeen reports a database with no default group" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
try database.exec("UPDATE groups SET name = 'renamed' WHERE id = 1;");
|
||||
|
||||
try testing.expectError(error.Constraint, upsertSeen(&database, "192.168.1.50", 1700000000));
|
||||
try testing.expectEqual(@as(i64, 0), try countClients(&database));
|
||||
}
|
||||
|
||||
test "pruneStale removes only stale auto-materialised rows" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
var ids = try seedGroups(&database);
|
||||
defer ids.deinit(testing.allocator);
|
||||
// A hand-edited row far older than the cutoff.
|
||||
try seedClients(&database, &ids);
|
||||
|
||||
try upsertSeen(&database, "10.0.0.1", 1700000000);
|
||||
try upsertSeen(&database, "10.0.0.2", 1700000199);
|
||||
// Exactly at the cutoff: the comparison is strict, so it stays.
|
||||
try upsertSeen(&database, "10.0.0.3", 1700000200);
|
||||
try upsertSeen(&database, "10.0.0.4", 1700000300);
|
||||
|
||||
try testing.expectEqual(@as(u32, 2), try pruneStale(&database, 1700000200));
|
||||
try testing.expectEqual(@as(i64, 5), try countClients(&database));
|
||||
try testing.expectEqual(
|
||||
@as(i64, 0),
|
||||
try database.queryInt("SELECT count(*) FROM clients WHERE ip IN ('10.0.0.1', '10.0.0.2')"),
|
||||
);
|
||||
try testing.expectEqual(@as(i64, 3), try database.queryInt("SELECT count(*) FROM clients WHERE hand_edited = 1"));
|
||||
|
||||
// A second pass over the same cutoff finds nothing left to do.
|
||||
try testing.expectEqual(@as(u32, 0), try pruneStale(&database, 1700000200));
|
||||
}
|
||||
|
||||
test "pruneStale spares hand-edited rows however stale" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
var ids = try seedGroups(&database);
|
||||
defer ids.deinit(testing.allocator);
|
||||
try seedClients(&database, &ids);
|
||||
|
||||
try testing.expectEqual(@as(u32, 0), try pruneStale(&database, 1800000000));
|
||||
try testing.expectEqual(@as(i64, 3), try countClients(&database));
|
||||
}
|
||||
|
||||
test "client_prefixes round-trip in prefix order with group names resolved" {
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
@@ -72,6 +72,10 @@ comptime {
|
||||
_ = @import("platform/logging.zig");
|
||||
_ = @import("storage/retention.zig");
|
||||
_ = @import("storage/phase6_integration_test.zig");
|
||||
_ = @import("server/pause.zig");
|
||||
_ = @import("server/clients.zig");
|
||||
_ = @import("server/shutdown.zig");
|
||||
_ = @import("server/phase7_integration_test.zig");
|
||||
}
|
||||
|
||||
extern fn sqlite3_libversion() [*:0]const u8;
|
||||
|
||||
Reference in New Issue
Block a user