db-mode config changes apply live in-process

settings and upstream writes now follow a prepare, commit, publish, retire
contract: candidates are built and validated before the database transaction,
published as infallible pointer swaps, and old generations retire after their
readers drain. per-query policy values snapshot once per query; upstream pool,
cache, rate limiter, sessions, api limiter, log sink, blocklist scheduler and
the query-log queue each gained one named live operation. restart_required
shrinks from every scalar key to the bind keys and web.enabled; the admin ui
drops its restart notices for everything else. file mode is unchanged.
This commit is contained in:
2026-08-24 00:04:28 +02:00
parent 17e6e93ce1
commit d961b152a3
47 changed files with 7698 additions and 926 deletions
+218 -301
View File
@@ -28,7 +28,6 @@ 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 api_limiter = @import("web/api_limiter.zig");
const auth = @import("web/auth.zig");
@@ -40,9 +39,7 @@ const config_export = @import("config/export.zig");
const db = @import("storage/db.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 doh_server = @import("server/doh_server.zig");
const dot_client = @import("upstream/dot_client.zig");
const dot_server = @import("server/dot_server.zig");
const events = @import("storage/events.zig");
const faults = @import("config/faults.zig");
@@ -53,26 +50,25 @@ const http_util = @import("web/http_util.zig");
const loader = @import("config/loader.zig");
const local_records = @import("local/records.zig");
const local_tables = @import("server/local_tables.zig");
const logger_mod = @import("storage/logger.zig");
const logger_controller = @import("storage/logger_controller.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 queries_repo = @import("storage/repositories/queries_repo.zig");
const query_sink = @import("server/query_sink.zig");
const querylog_schema = @import("storage/querylog_schema.zig");
const rate_limiter = @import("server/rate_limiter.zig");
const reconcile = @import("config/reconcile.zig");
const retention_mod = @import("storage/retention.zig");
const safe_url = @import("safe_url.zig");
const shutdown = @import("server/shutdown.zig");
const sse = @import("web/sse.zig");
const static = @import("web/static.zig");
const tcp_server = @import("server/tcp_server.zig");
const transport = @import("upstream/transport.zig");
const udp_server = @import("server/udp_server.zig");
const upstream_owner = @import("upstream/owner.zig");
const validate = @import("config/validate.zig");
const version = @import("version.zig");
const web_server = @import("web/server.zig");
@@ -89,11 +85,6 @@ const maintenance_interval_s = 60;
/// takes longer than this is not going to finish at all.
const download_budget_s = 300;
/// Per DoH upstream. The sizes live in `doh_client.zig` so that `nxdns check`
/// probes the buffers `nxdns run` serves with.
const doh_request_buf_len = doh_client.default_request_buf_len;
const doh_transfer_buf_len = doh_client.default_transfer_buf_len;
pub fn run(runner: cli.Runner, args: cli.RunArgs) u8 {
const code = serve(runner, args) catch |err| code: {
runner.err.print("nxdns run failed: {s}\n", .{@errorName(err)}) catch {};
@@ -220,17 +211,13 @@ fn reconcileFromFileAt(
return result;
}
/// An upstream's identity is its url: the whole url is the key, and the
/// redaction is the label, because a url can carry an account token.
/// Boot's replay of one upstream finding. The rendering is the owner's, shared
/// with the runtime reconciler so a warning raised at boot and the same warning
/// raised by a settings PUT are byte-identical.
fn noteUpstream(config_load: *ConfigLoad, url: []const u8, message: []const u8) void {
var label_buf: [events.Store.max_subject_label_len]u8 = undefined;
const label = std.fmt.bufPrint(&label_buf, "{f}", .{safe_url.redact(url)}) catch &label_buf;
var detail_buf: [events.Store.max_detail_len]u8 = undefined;
const detail = std.fmt.bufPrint(&detail_buf, "upstream {f} {s}", .{
safe_url.redactQuoted(url),
message,
}) catch &detail_buf;
config_load.note(url, label, detail);
var rendered: upstream_owner.Rendered = .{};
rendered.render(.{ .url = url, .message = message });
config_load.note(url, rendered.label, rendered.detail);
}
/// Returns the moment the transaction committed, which is what the settings
@@ -529,49 +516,50 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
defer bundle.deinit(gpa);
var bundle_lock: std.Io.RwLock = .init;
var upstreams = try Upstreams.build(io, gpa, cfg.upstreams, &dns_http, &bundle, &bundle_lock, &config_load);
defer upstreams.deinit(io, gpa);
var pool: pool_mod.Pool = .init(
upstreams.active(),
.{},
.{
const upstream_generation = try upstream_owner.build(.{
.gpa = gpa,
.io = io,
.servers = cfg.upstreams,
.http = &dns_http,
.bundle = &bundle,
.bundle_lock = &bundle_lock,
.timeouts = .{
.attempt = .{ .raw = model.attemptTimeout(cfg.upstream), .clock = .awake },
.total = .{ .raw = model.totalTimeout(cfg.upstream), .clock = .awake },
},
@truncate(@as(u96, @bitCast(std.Io.Clock.real.now(io).nanoseconds))),
);
.seed = @truncate(@as(u96, @bitCast(std.Io.Clock.real.now(io).nanoseconds))),
.diagnostics = event_store,
});
pool.diagnostics = event_store;
// `build` writes no event rows, so that a settings PUT can prepare a
// candidate that is never published without leaving a trace. Boot has no
// such candidate: this generation is the one that serves, and replaying its
// report through the collector is what keeps the `configuration.load`
// episodes — and the keys `finalize` below spares — exactly what they were
// when this composition lived in this file.
for (upstream_generation.report().notes) |finding| {
noteUpstream(&config_load, finding.url, finding.message);
}
const upstream_count = upstream_generation.activeCount();
var upstreams: upstream_owner.Owner = .init(upstream_generation);
defer upstreams.deinit(io);
// -----------------------------------------------------------------------
// 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);
// One cell for both retention consumers, owned here so a settings apply
// moves the daily query-log prune and the stale-client prune together.
var retention_days: retention_mod.RetentionDays = .init(cfg.logging.retention_days);
var tracker: clients.Tracker = .init(&retention_days);
tracker.diagnostics = event_store;
// Naming rides the tracker's pass, on the tracker's task and connection
// (milestone-25 ruling 1), and reads the live forward zones.
var client_names_resolver: client_names.Resolver = .init(&tables);
client_names_resolver.diagnostics = event_store;
// 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);
query_logger.diagnostics = event_store;
// Milestone 8 fans every logged query out to the SSE hub as well. The hub
// exists only when the web interface does (ruling 6) — without it the sink
// costs the query path one null check. Its rings are ~900 KiB, so it lives
@@ -584,18 +572,20 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
created.init();
hub = created;
}
var sink: query_sink.QuerySink = .init(&query_logger, hub);
// -----------------------------------------------------------------------
// 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 ".")
const log_dir_path: ?[:0]const u8 = if (logging.logDirname(cfg.logging)) |dir|
try arena.dupeZ(u8, dir)
else
null;
var monitor: disk_monitor.Monitor = .init(cfg.disk, data.dir, data_path, log_dir_path);
// Frees whatever log-directory generation a settings apply installed; boot's
// path is borrowed from the arena and owned by nobody here.
defer monitor.deinit(io);
// Ruling 17. The scheduler consults it before every scheduled pass; the
// startup `reload` below is an operator action and stays ungated.
@@ -618,13 +608,31 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
// policy and the right one here too.
monitor.sample(io, event_store, boot_now_s);
var retention: retention_mod.Retention = .init(cfg.logging);
var retention: retention_mod.Retention = .init(&retention_days);
var querylog_opened = try data.openQuerylogDb(io);
var querylog_writer_db = querylog_opened.database;
defer querylog_writer_db.close();
reportQuerylogRecreated(event_store, io, boot_now_s, &querylog_opened, &querylog_writer_db);
// The controller adopts that first connection and owns the query logger
// from here: the buffer, the `Logger`, the writer task and the connection
// are one generation, and `logging.query_log_buffer_max` can replace all
// four while the server runs (milestone 34 §S4). Its writer starts now and
// parks on an empty queue, which is where it would be anyway — no producer
// exists until the listeners below start serving.
var log_controller: logger_controller.Controller = undefined;
try log_controller.init(io, .{
.gpa = gpa,
.database = querylog_writer_db,
.source = .{ .dir = std.Io.Dir.cwd(), .path = data.querylog_db_path },
.logging = cfg.logging,
.monitor = &monitor,
.diagnostics = event_store,
});
defer log_controller.deinit(io);
var sink: query_sink.QuerySink = .init(&log_controller, hub);
var querylog_retention_db = try data.reopenQuerylogDb(io);
defer querylog_retention_db.close();
var tracker_db = try data.openConfigDb(io);
@@ -683,20 +691,56 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
// listeners
// -----------------------------------------------------------------------
// Both are on the heap and both are freed through the handler rather than
// through this frame: a `cache.size` or `dns.rate_limit` change swaps in a
// replacement built with this same `gpa` and frees what it displaced, so
// what the handler holds at shutdown is not necessarily what boot built.
// Both are built inside one block so their errdefers end with it: after
// the block the handler is the sole owner, and the teardown defers below
// are what free them. An errdefer that outlived the block would free the
// same object those defers free.
const both = blk: {
const c = try gpa.create(dns_cache.DnsCache);
errdefer gpa.destroy(c);
c.* = try .init(gpa, cfg.cache);
errdefer c.deinit();
const l = try gpa.create(rate_limiter.RateLimiter);
errdefer gpa.destroy(l);
l.* = try .init(gpa, .{
.limit = cfg.dns.rate_limit,
.window_seconds = cfg.dns.rate_window_seconds,
});
break :blk .{ .cache = c, .limiter = l };
};
const cache = both.cache;
const limiter = both.limiter;
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 },
.upstream = &upstreams,
.policy = .{
.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 },
.negative_ttl_max = cfg.cache.negative_ttl_max,
},
.manager = &manager,
.local_tables = &tables,
.cache = &cache,
.negative_ttl_max = cfg.cache.negative_ttl_max,
.limiter = &limiter,
.cache = cache,
.limiter = limiter,
.sink = &sink,
.pause = &paused,
.tracker = &tracker,
};
// These run after `group.cancel` below, so no query is inside either table.
defer if (h.replaceCache(io, null)) |live| {
live.deinit();
gpa.destroy(live);
};
defer if (h.replaceRateLimiter(io, null)) |live| {
live.deinit();
gpa.destroy(live);
};
// -----------------------------------------------------------------------
// DoH/DoT listeners (milestone-10 ruling 11)
@@ -770,9 +814,13 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
// The live hash may own a gpa replacement after a settings PUT; this defer
// runs after `group.cancel` below, so no web task can still read it.
defer web_state.live_hash.deinit(gpa);
// Same argument as the live hash: a settings PUT may have installed an
// owned generation, and this runs after `group.cancel`.
defer web_state.proxies.deinit(gpa);
if (cfg.web.enabled) web_state = .{
.gpa = gpa,
.web = cfg.web,
.proxies = .init(cfg.web.trusted_proxies),
.authority = authority,
.reconciled_at = reconciled_at,
.live_hash = .init(cfg.web.password_hash orelse ""),
@@ -781,11 +829,17 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
.tracker = &tracker,
.client_names = &client_names_resolver,
.manager = &manager,
.pool = &pool,
.upstreams = &upstreams,
.upstream_build = .{
.http = &dns_http,
.bundle = &bundle,
.bundle_lock = &bundle_lock,
},
.monitor = &monitor,
.local_tables = &tables,
.logger = &query_logger,
.logger = &log_controller,
.retention = &retention,
.retention_days = &retention_days,
.sessions = if (sessions) |*s| s else null,
.limiter = if (web_limiter) |*l| l else null,
.hub = hub,
@@ -894,25 +948,20 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
// (disk_monitor.zig:63), so nothing is refused for want of a sample.
const gate: ?*disk_monitor.Monitor = &monitor;
// The query-log writer is deliberately *not* in `group`, and starts before
// every producer. Inside the group its life would end with the same
// `cancel` that stops the producers, and cancellation would race the
// queue's close: whichever landed first decided whether the batch the
// writer was holding reached the database or was counted as dropped. Given
// its own future, it outlives the producers by construction, and the
// teardown below can close the queue with nobody left to fill it and then
// wait for the writer to finish emptying it.
var writer_future = try io.concurrent(
logger_mod.Logger.runWriter,
.{ &query_logger, io, &querylog_writer_db, gate },
);
// The query-log writers are deliberately *not* in `group`, and the live one
// started before every producer. Inside the group their lives would end
// with the same `cancel` that stops the producers, and cancellation would
// race the queue's close: whichever landed first decided whether the batch
// a writer was holding reached the database or was counted as dropped. The
// controller owns their futures instead, so they outlive the producers by
// construction.
//
// Ruling 4's shutdown order, on the one path every exit from here takes:
// every producer stops and is joined, then the queue closes, then the
// writer is awaited — so the last batch is written rather than raced. A
// writer the disk gate will not let write counts its batch as dropped
// instead of holding the exit open (`logger.zig`), so this wait always
// ends.
// every producer stops and is joined, then `Controller.shutdown` closes
// each queue and awaits each writer — so the last batch is written rather
// than raced. A writer the disk gate will not let write counts its batch as
// dropped instead of holding the exit open (`logger.zig`), so this wait
// always ends.
//
// A `defer` and not straight-line code after `shutdown.wait`, because a
// `concurrent` spawn below can fail with the DNS listeners already
@@ -920,8 +969,7 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
// signal gets.
defer {
group.cancel(io);
query_logger.shutdown(io);
writer_future.await(io) catch {};
log_controller.shutdown(io);
}
if (udp6) |*s| try group.concurrent(io, udp_server.UdpServer.serve, .{ s, io });
@@ -946,7 +994,7 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
// this box exists for — keeps serving.
if (cfg.web.enabled) try group.concurrent(io, web_server.serve, .{ &web_state, io });
logStartup(io, authority, &manager, upstreams.active().len, .{
logStartup(io, authority, &manager, upstream_count, .{
.udp6 = if (udp6) |*s| s.boundAddress() else null,
.udp4 = if (udp4) |*s| s.boundAddress() else null,
.tcp6 = if (tcp6) |*s| s.boundAddress() else null,
@@ -1121,18 +1169,21 @@ fn maintenanceOnce(
api: ?*api_limiter.ApiLimiter,
io: std.Io,
) std.Io.Cancelable!void {
if (h.cache) |cache| {
// Both pointers are read inside the mutex that guards their replacement,
// the same discipline the query path follows: a load taken before the lock
// could sweep a table `replaceCache`/`replaceRateLimiter` has just freed.
{
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);
defer h.cache_mutex.unlock(io);
if (h.cache) |cache| _ = cache.sweep(now_s);
}
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);
defer h.limiter_mutex.unlock(io);
if (h.limiter) |limiter| _ = limiter.sweep(now);
}
// The API limiter takes its own mutex, unlike the two above, which are the
@@ -1140,215 +1191,6 @@ fn maintenanceOnce(
if (api) |limiter| _ = limiter.sweep(io, std.Io.Clock.awake.now(io));
}
// ---------------------------------------------------------------------------
// upstreams
// ---------------------------------------------------------------------------
/// The pool's entries and everything they point into.
///
/// Every enabled upstream gets `pool_mod.slots_per_entry` leaf clients, one per
/// slot of its entry, so that many exchanges can be in flight against it at
/// once. `Slot.client` is a type-erased pointer into `doh` or `dot`, each of
/// those clients borrows a slice of `doh_buf`/`dot_buf`, and each entry borrows
/// a run of `slot_storage` and one counter of `recovery_counters` — so every
/// allocation here lives exactly as long as the pool does, and none of them is
/// ever resized. One slot is used by one task at a time, 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,
/// Sliced per entry into `Entry.slots`, never pointing into the client
/// arrays: `Pool.init` sorts entries and the slices have to survive it.
slot_storage: []pool_mod.Slot,
/// One per enabled upstream, and the reason it is a separate allocation:
/// `Pool.init` sorts entries by value, so a counter living inside an entry
/// would be pointed at by the wrong upstream's clients after the sort.
recovery_counters: []std.atomic.Value(u64),
doh: []doh_client.DohClient,
dot: []dot_client.DotClient,
/// How much of `doh`/`dot` was actually initialized. A malformed or skipped
/// upstream leaves the tail of an over-allocated array undefined, and both
/// `deinit` and `build`'s failure paths iterate only the initialized
/// prefix — reading a `DotClient` that was never built, or closing a
/// session that was never opened, is what these two counts prevent.
doh_used: usize,
dot_used: usize,
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(
io: std.Io,
gpa: Allocator,
servers: []const model.UpstreamServer,
http: *std.http.Client,
bundle: *Certificate.Bundle,
bundle_lock: *std.Io.RwLock,
config_load: *ConfigLoad,
) (Allocator.Error || error{NoUsableUpstreams})!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;
const slots = pool_mod.slots_per_entry;
const leaf_clients = enabled * slots;
var self: Upstreams = .{
.entries = try gpa.alloc(pool_mod.Entry, enabled),
.used = 0,
.slot_storage = &.{},
.recovery_counters = &.{},
.doh = &.{},
.dot = &.{},
.doh_used = 0,
.dot_used = 0,
.doh_buf = &.{},
.dot_buf = &.{},
};
errdefer self.deinit(io, gpa);
self.slot_storage = try gpa.alloc(pool_mod.Slot, leaf_clients);
self.recovery_counters = try gpa.alloc(std.atomic.Value(u64), enabled);
for (self.recovery_counters) |*counter| counter.* = .init(0);
self.doh = try gpa.alloc(doh_client.DohClient, leaf_clients);
self.dot = try gpa.alloc(dot_client.DotClient, leaf_clients);
self.doh_buf = try gpa.alloc(u8, leaf_clients * (doh_request_buf_len + doh_transfer_buf_len));
self.dot_buf = try gpa.alloc(u8, leaf_clients * 4 * chunk);
for (servers) |server| {
if (!server.enabled) continue;
const endpoint = transport.Endpoint.parse(server.url) catch {
log.warn(
"upstream {f} is not an https:// or tls:// endpoint; skipped",
.{safe_url.redactQuoted(server.url)},
);
noteUpstream(config_load, server.url, "not an https:// or tls:// endpoint; skipped");
continue;
};
const entry_slots = self.slot_storage[self.used * slots ..][0..slots];
switch (endpoint.scheme) {
.doh => if (!self.wireDoh(http, endpoint, entry_slots)) {
log.warn(
"upstream {f} is not a usable DoH url; skipped",
.{safe_url.redactQuoted(server.url)},
);
noteUpstream(config_load, server.url, "not a usable DoH url; skipped");
continue;
},
.dot => self.wireDot(gpa, endpoint, server.tls_name, bundle, bundle_lock, entry_slots),
}
self.entries[self.used] = .{
.endpoint = endpoint,
.slots = entry_slots,
.priority = server.priority,
.enabled = true,
.health = .init,
.sem = .{ .permits = entry_slots.len },
.reuse_recoveries = &self.recovery_counters[self.used],
};
self.used += 1;
}
if (self.used == 0) return error.NoUsableUpstreams;
return self;
}
/// One `DohClient` per slot, all sharing the one `std.http.Client`: its
/// connection pool already serves concurrent requests, and a `DohClient`'s
/// only mutable state is the two buffers this gives each slot its own of.
///
/// False means the url is not a usable DoH url, which `DohClient.init`
/// decides from the url alone — so it fails on the first slot or on none.
/// `doh_used` still advances per client rather than per entry: it means
/// "initialized", and a skipped entry's clients are simply never reached.
fn wireDoh(
self: *Upstreams,
http: *std.http.Client,
endpoint: transport.Endpoint,
slots: []pool_mod.Slot,
) bool {
for (slots) |*slot| {
const index = self.doh_used;
const base = index * (doh_request_buf_len + doh_transfer_buf_len);
self.doh[index] = 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 return false;
self.doh_used = index + 1;
slot.* = .{ .client = self.doh[index].client() };
}
return true;
}
/// One `DotClient` per slot, each with its own four TLS buffers and all
/// sharing the trust store. Every client of one entry reports its stale-reuse
/// recoveries through that entry's counter.
fn wireDot(
self: *Upstreams,
gpa: Allocator,
endpoint: transport.Endpoint,
tls_name: []const u8,
bundle: *Certificate.Bundle,
bundle_lock: *std.Io.RwLock,
slots: []pool_mod.Slot,
) void {
const chunk = tls.Client.min_buffer_len;
const recoveries = &self.recovery_counters[self.used];
for (slots) |*slot| {
const index = self.dot_used;
const base = index * 4 * chunk;
self.dot[index] = dot_client.DotClient.init(
endpoint,
tls_name,
gpa,
bundle,
bundle_lock,
recoveries,
.{
.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],
},
);
self.dot_used = index + 1;
slot.* = .{ .client = self.dot[index].client() };
}
}
/// 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];
}
/// Connections first, memory second: a `DotClient` holds a socket its
/// buffers belong to, so nothing it points at may be freed before it is
/// closed.
fn deinit(self: *Upstreams, io: std.Io, gpa: Allocator) void {
for (self.dot[0..self.dot_used]) |*client| client.close(io);
gpa.free(self.dot_buf);
gpa.free(self.doh_buf);
gpa.free(self.dot);
gpa.free(self.doh);
gpa.free(self.recovery_counters);
gpa.free(self.slot_storage);
gpa.free(self.entries);
self.* = undefined;
}
};
// ---------------------------------------------------------------------------
// listeners
// ---------------------------------------------------------------------------
@@ -2414,10 +2256,11 @@ test "one maintenance pass drops the api limiter's stale buckets" {
_ = limiter.check(io, .{ .nanoseconds = now.nanoseconds - 2 * window_ns }, client);
try std.testing.expectEqual(@as(u32, 1), limiter.trackedClients(io));
// Nothing here exchanges: the pass only sweeps the two tables.
var unreachable_upstream: upstream_owner.Borrowed = .{};
var h: handler.Handler = .{
.upstream = .{ .ptr = undefined, .exchangeFn = undefined },
.blocking = .{ .mode = .zero, .ttl = 5 },
.forward_read_timeout = .{ .raw = .fromMilliseconds(50), .clock = .awake },
.upstream = unreachable_upstream.client(.{ .ptr = undefined, .exchangeFn = undefined }),
.policy = .{ .blocking = .{ .mode = .zero, .ttl = 5 }, .forward_read_timeout = .{ .raw = .fromMilliseconds(50), .clock = .awake } },
};
try maintenanceOnce(&h, &limiter, io);
@@ -2520,6 +2363,80 @@ test "a configuration finding is reported once and finalize closes the rest" {
));
}
test "the upstream build's report replays into the same rows the boot path used to write" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var fx: events_fixture.Fixture = .{};
try fx.init(io, 1000);
defer fx.deinit();
// Last boot's finding for a row this boot has fixed. Only `finalize`
// closes it, which is why the replay has to run before it.
fx.store.report(io, 900, .configuration_load, "ftp://fixed.example", "ftp://fixed.example", .warning, "stale");
var http: std.http.Client = .{ .allocator = testing.allocator, .io = io };
defer http.deinit();
var bundle: Certificate.Bundle = .empty;
defer bundle.deinit(testing.allocator);
var bundle_lock: std.Io.RwLock = .init;
const generation = try upstream_owner.build(.{
.gpa = testing.allocator,
.io = io,
// The credential in the bad row is why the key and the rendered text
// differ: the key is the whole url, and both rendered forms drop it.
.servers = &.{
.{ .url = "ftp://user:hunter2@nope.example" },
.{ .url = "https://good.example/dns-query" },
},
.http = &http,
.bundle = &bundle,
.bundle_lock = &bundle_lock,
.timeouts = .{
.attempt = .{ .raw = .fromMilliseconds(50), .clock = .awake },
.total = .{ .raw = .fromMilliseconds(200), .clock = .awake },
},
.seed = 1,
});
var upstreams: upstream_owner.Owner = .init(generation);
defer upstreams.deinit(io);
// `build` itself wrote nothing: a candidate a settings PUT never publishes
// must leave the diagnostics log exactly as it found it.
try testing.expectEqual(@as(i64, 1), try fx.count("SELECT count(*) FROM operational_events"));
var collector: ConfigLoad = .{ .store = &fx.store, .io = io, .now_s = 1000 };
for (generation.report().notes) |finding| {
noteUpstream(&collector, finding.url, finding.message);
}
collector.finalize();
// The whole url is the key, the redaction is the label, and the detail is
// the sentence `noteUpstream` has always written — byte for byte.
try testing.expectEqual(
@as(i64, 1),
try fx.count("SELECT count(*) FROM operational_events WHERE resolved_at IS NULL"),
);
try testing.expectEqualStrings("ftp://user:hunter2@nope.example", try fx.text(
"SELECT subject_key FROM operational_events WHERE resolved_at IS NULL",
));
try testing.expectEqualStrings("ftp://nope.example", try fx.text(
"SELECT subject_label FROM operational_events WHERE resolved_at IS NULL",
));
try testing.expectEqualStrings(
"upstream 'ftp://nope.example' not an https:// or tls:// endpoint; skipped",
try fx.text("SELECT detail FROM operational_events WHERE resolved_at IS NULL"),
);
// The row that was wrong last boot and is not wrong now is closed by the
// same `finalize` as ever: the replay is what puts the keys in front of it.
try testing.expectEqual(@as(i64, 1), try fx.count(
"SELECT count(*) FROM operational_events WHERE subject_key = 'ftp://fixed.example' AND resolved_at IS NOT NULL",
));
}
test "an over-long boot finding list refuses to finalize rather than truncate" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
+6 -9
View File
@@ -343,16 +343,13 @@ pub const DataDir = struct {
}
/// An additional connection to a `querylog.db` that `openQuerylogDb` has
/// already established. A running server needs two background ones — the
/// log writer and the retention pass each own one (`retention.zig`'s
/// contract) — plus a third for the web task when the web interface is
/// enabled.
/// already established, bound to this data directory's path.
///
/// The opener itself is `querylog_schema.reopen`, beside the schema it
/// belongs to: a logger generation opens its own writer connection at
/// runtime and has no `DataDir` to ask.
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;
return querylog_schema.reopen(io, std.Io.Dir.cwd(), self.querylog_db_path);
}
/// A sidecar that does not exist yet is not a failure: `-wal` and `-shm`
-9
View File
@@ -380,10 +380,6 @@ pub fn sessionTtlSeconds(w: Web) i64 {
return @as(i64, w.session_ttl_hours) * 3600;
}
pub fn retentionSeconds(l: Logging) i64 {
return @as(i64, l.retention_days) * 86400;
}
pub fn maxLogBytes(l: Logging) u64 {
return @as(u64, l.max_size_mb) * 1024 * 1024;
}
@@ -840,7 +836,6 @@ test "unit conversions" {
totalTimeout(.{}).nanoseconds,
);
try testing.expectEqual(@as(i64, 24 * 3600), sessionTtlSeconds(.{}));
try testing.expectEqual(@as(i64, 30 * 86400), retentionSeconds(.{}));
try testing.expectEqual(@as(u64, 50 * 1024 * 1024), maxLogBytes(.{}));
try testing.expectEqual(@as(u64, 200 * 1024 * 1024), minFreeBytes(.{}));
try testing.expectEqual(@as(u64, 500 * 1024 * 1024), warnFreeBytes(.{}));
@@ -865,10 +860,6 @@ test "unit conversions at the field maximum do not overflow" {
@as(i64, std.math.maxInt(u16)) * 3600,
sessionTtlSeconds(.{ .session_ttl_hours = std.math.maxInt(u16) }),
);
try testing.expectEqual(
@as(i64, std.math.maxInt(u16)) * 86400,
retentionSeconds(.{ .retention_days = std.math.maxInt(u16) }),
);
try testing.expectEqual(
@as(u64, std.math.maxInt(u32)) * 1024 * 1024,
maxLogBytes(.{ .max_size_mb = std.math.maxInt(u32) }),
+66 -7
View File
@@ -1311,11 +1311,13 @@ test "10b: the scheduler sweeps orphans on its own, with no operator call" {
try dir.writeFile(io, .{ .sub_path = "9999.allow.tmp", .data = "" });
// `runScheduler` is the entry point `app.zig` hands to `Io.Group`, and the
// only one the server ever calls. A disabled update stops it after the
// startup pass, so the production path runs to completion here with no
// interval to wait out.
// only one the server ever calls. A disabled update parks it after the
// startup pass, and the seam turns that park into the shutdown the task
// only otherwise exits on, so the production path runs to completion here
// with no interval to wait out.
env.mgr.update.enabled = false;
try env.mgr.runScheduler(io);
env.mgr.schedule_clock = .shutdown_at_first_park;
try testing.expectError(error.Canceled, env.mgr.runScheduler(io));
try testing.expectError(error.FileNotFound, dir.access(io, "9999.list", .{}));
try testing.expectError(error.FileNotFound, dir.access(io, "9999.wild", .{}));
@@ -1555,8 +1557,8 @@ test "10e: a reconcile then a restart reuses the compiled files and downloads no
// The restart. The old manager is gone and a new one comes up over the same
// directory and the same database with nothing carried across in memory.
// `runScheduler` is the boot sequence the server runs — the orphan sweep,
// then the startup pass — and a disabled update makes it return rather than
// wait out an interval.
// then the startup pass — and a disabled update parks it rather than
// waiting out an interval; the seam turns that park into shutdown.
env.mgr.deinit(io);
env.mgr = try manager.Manager.init(
gpa,
@@ -1566,7 +1568,8 @@ test "10e: a reconcile then a restart reuses the compiled files and downloads no
.{ .enabled = false },
budget,
);
try env.mgr.runScheduler(io);
env.mgr.schedule_clock = .shutdown_at_first_park;
try testing.expectError(error.Canceled, env.mgr.runScheduler(io));
// Nothing was downloaded. The server is still listening, so this is a
// decision the pass made rather than a connection it could not have opened.
@@ -2456,6 +2459,62 @@ test "27: a failing refresh opens a blocklist.refresh episode a good one closes"
);
}
/// Records the deadline of the first park and then reports the shutdown the
/// scheduler loop only otherwise exits on. Its clock never moves, so the
/// deadline it captures is exactly `anchor + interval`.
const FirstParkRecorder = struct {
deadline_s: ?i64 = null,
parked: bool = false,
fn clock(self: *FirstParkRecorder) manager.ScheduleClock {
return .{ .ctx = self, .nowFn = now, .waitFn = wait };
}
fn now(_: ?*anyopaque, _: std.Io) i64 {
return 0;
}
fn wait(ctx: ?*anyopaque, _: std.Io, _: *std.Io.Event, deadline_s: ?i64) std.Io.Cancelable!void {
const self: *FirstParkRecorder = @ptrCast(@alignCast(ctx.?));
self.deadline_s = deadline_s;
self.parked = true;
return error.Canceled;
}
};
test "34: a pass whose refresh failed still advances the schedule anchor" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
const env = try Env.create(gpa);
defer env.destroy();
const io = env.io();
var fixture = try HttpFixture.init(io, http_body);
defer fixture.deinit(io);
// Every download this pass makes fails.
fixture.setRoute(.oversize);
var group: std.Io.Group = .init;
defer group.cancel(io);
try group.concurrent(io, HttpFixture.serve, .{ &fixture, io });
var url_buf: [64]u8 = undefined;
const url = try fixture.url(&url_buf);
_ = try seedSource(&env.database, url);
var recorder: FirstParkRecorder = .{};
env.mgr.schedule_clock = recorder.clock();
env.mgr.setSchedule(io, true, 2);
try testing.expectError(error.Canceled, env.mgr.runScheduler(io));
// The startup pass tried the source and failed. The anchor still moved to
// that pass's completion, so the next refresh is a full interval away
// rather than immediate: a failing source must not become a download loop.
try testing.expect(recorder.parked);
try testing.expectEqual(@as(?i64, 2 * 3_600), recorder.deadline_s);
}
test "27: one refreshAll pass records one occurrence of a failing source" {
if (!build_options.integration) return error.SkipZigTest;
+403 -11
View File
@@ -333,12 +333,83 @@ pub fn stripHeader(bytes: []const u8) []const u8 {
return rest;
}
/// The scheduler's two time operations, behind a seam. Validated intervals are
/// at least an hour, so a test that used the real clock would either sleep an
/// hour or prove nothing; a test installs its own step clock instead.
pub const ScheduleClock = struct {
ctx: ?*anyopaque = null,
/// Seconds on a monotonic clock. Only differences matter.
nowFn: *const fn (ctx: ?*anyopaque, io: std.Io) i64,
/// Returns when `deadline_s` arrives or `event` is set, whichever comes
/// first; a null deadline waits for the event alone. A spurious early
/// return is allowed — the caller rechecks both the version and the clock.
waitFn: *const fn (
ctx: ?*anyopaque,
io: std.Io,
event: *std.Io.Event,
deadline_s: ?i64,
) std.Io.Cancelable!void,
pub const real: ScheduleClock = .{ .nowFn = realNow, .waitFn = realWait };
/// Test seam: the loop only ever exits on shutdown, so a test that wants
/// `runScheduler` to run its startup pass and return installs this and
/// gets `error.Canceled` at the first park.
pub const shutdown_at_first_park: ScheduleClock = .{ .nowFn = realNow, .waitFn = cancelWait };
fn cancelWait(_: ?*anyopaque, _: std.Io, _: *std.Io.Event, _: ?i64) std.Io.Cancelable!void {
return error.Canceled;
}
/// `boot` rather than `awake`: a box that suspends overnight should still
/// see its daily interval elapse.
fn realNow(_: ?*anyopaque, io: std.Io) i64 {
return std.Io.Clock.boot.now(io).toSeconds();
}
fn realWait(
_: ?*anyopaque,
io: std.Io,
event: *std.Io.Event,
deadline_s: ?i64,
) std.Io.Cancelable!void {
const timeout: std.Io.Timeout = if (deadline_s) |seconds| .{ .deadline = .{
.raw = .{ .nanoseconds = @as(i96, seconds) * std.time.ns_per_s },
.clock = .boot,
} } else .none;
event.waitTimeout(io, timeout) catch |err| switch (err) {
error.Timeout => {},
error.Canceled => return error.Canceled,
};
}
};
pub const Manager = struct {
gpa: Allocator,
database: *db.Db,
paths: Paths,
fetcher: *fetcher.Fetcher,
/// Read and written only under `schedule_mutex`; `setSchedule` replaces it
/// while the scheduler is parked.
update: model.BlocklistUpdate,
/// Guards `update`, `schedule_version` and `schedule_anchor_s`.
///
/// Lock ordering: innermost. `needsRefresh` takes it while `refresh_lock`
/// is held, and nothing that holds it takes another manager lock.
schedule_mutex: std.Io.Mutex,
/// Bumped by every `setSchedule`. The scheduler reads it before it parks
/// and again after it wakes: a change that lands in that window is what the
/// recheck catches, so no wake is lost and none is mistaken for a deadline.
schedule_version: u64,
/// When the last refresh pass that RAN completed, on `ScheduleClock`'s
/// clock. Success, failure and a disk-gate skip all advance it — the
/// scheduled slot is spent either way and is not retried early. Null until
/// the startup pass finishes.
schedule_anchor_s: ?i64,
/// Sticky once set, so `setSchedule` can never signal into a gap. The loop
/// resets it under `schedule_mutex` before it recomputes its deadline.
schedule_event: std.Io.Event,
schedule_clock: ScheduleClock,
/// Bounds one download. `std.http.Client` has no per-request deadline, so
/// the fetch runs under `io.concurrent` against a sleep of this length.
total_budget: std.Io.Clock.Duration,
@@ -412,6 +483,11 @@ pub const Manager = struct {
.paths = paths,
.fetcher = fetcher_ptr,
.update = update,
.schedule_mutex = .init,
.schedule_version = 0,
.schedule_anchor_s = null,
.schedule_event = .unset,
.schedule_clock = .real,
.total_budget = total_budget,
.lock = .init,
.writer_lock = .init,
@@ -1490,8 +1566,9 @@ pub const Manager = struct {
/// it has no usable compiled files or its `last_updated` is older than the
/// interval.
///
/// `update.enabled == false` stops after the startup pass; manual refresh
/// through `refreshAll` still works.
/// `update.enabled == false` parks after the startup pass; manual refresh
/// through `refreshAll` still works, and a later `setSchedule` wakes the
/// loop rather than needing a restart.
pub fn runScheduler(self: *Manager, io: std.Io) std.Io.Cancelable!void {
// Ahead of the pass, not after it. This is the sweep that collects what
// a killed process left behind: a `.raw.tmp` as large as the body the
@@ -1511,20 +1588,78 @@ pub const Manager = struct {
self.flushDiagnostics(io);
},
};
if (!self.update.enabled) return;
// The startup pass ran, so it anchors the schedule — including when
// updates are disabled, so a later enable measures its first interval
// from real work rather than from the moment the operator flipped the
// switch.
self.anchorNow(io);
// `boot` rather than `awake`: a box that suspends overnight should
// still see its daily interval elapse.
const interval: std.Io.Clock.Duration = .{
.raw = .fromSeconds(model.updateIntervalSeconds(self.update)),
.clock = .boot,
};
while (true) {
try interval.sleep(io);
// One hold: read the version, reset the sticky event, and take the
// schedule the deadline is computed from. A `setSchedule` that
// lands after this reset completes the wait below at once, and the
// version recheck decides whether the wake meant anything.
self.schedule_mutex.lockUncancelable(io);
const version = self.schedule_version;
self.schedule_event.reset();
const enabled = self.update.enabled;
const interval_s = model.updateIntervalSeconds(self.update);
const anchor = self.schedule_anchor_s;
self.schedule_mutex.unlock(io);
const now_s = self.schedule_clock.nowFn(self.schedule_clock.ctx, io);
// Disabled parks on the event alone. The task still exits only on
// shutdown, exactly as it did when it returned here.
const deadline_s: ?i64 = if (enabled) (anchor orelse now_s) + interval_s else null;
if (deadline_s == null or now_s < deadline_s.?) {
try self.schedule_clock.waitFn(self.schedule_clock.ctx, io, &self.schedule_event, deadline_s);
// Either the schedule changed under us or the wait was
// spurious; recompute from the top rather than guess.
if (self.scheduleVersion(io) != version) continue;
if (deadline_s == null) continue;
if (self.schedule_clock.nowFn(self.schedule_clock.ctx, io) < deadline_s.?) continue;
}
try self.scheduledPass(io);
self.anchorNow(io);
}
}
/// Installs a new blocklist-update schedule and wakes the scheduler. The
/// anchor is untouched: the next refresh is due one NEW interval after the
/// last pass that ran, which the loop refreshes immediately when that
/// moment is already past.
pub fn setSchedule(self: *Manager, io: std.Io, enabled: bool, interval_hours: u16) void {
self.schedule_mutex.lockUncancelable(io);
self.update = .{ .enabled = enabled, .interval_hours = interval_hours };
self.schedule_version += 1;
self.schedule_mutex.unlock(io);
self.schedule_event.set(io);
}
/// The live schedule. Every reader outside the scheduler loop goes through
/// here, so none of them reads `update` while `setSchedule` writes it.
pub fn schedule(self: *Manager, io: std.Io) model.BlocklistUpdate {
self.schedule_mutex.lockUncancelable(io);
defer self.schedule_mutex.unlock(io);
return self.update;
}
fn scheduleVersion(self: *Manager, io: std.Io) u64 {
self.schedule_mutex.lockUncancelable(io);
defer self.schedule_mutex.unlock(io);
return self.schedule_version;
}
fn anchorNow(self: *Manager, io: std.Io) void {
const now_s = self.schedule_clock.nowFn(self.schedule_clock.ctx, io);
self.schedule_mutex.lockUncancelable(io);
self.schedule_anchor_s = now_s;
self.schedule_mutex.unlock(io);
}
/// What one elapsed interval does. Split from the loop above so a test can
/// run the pass without waiting the interval out; nothing in production
/// calls it but `runScheduler`.
@@ -1643,7 +1778,7 @@ pub const Manager = struct {
// else would ever clear it. A stamp from the future is not evidence of
// a recent fetch.
if (last > now) return true;
return now - last >= model.updateIntervalSeconds(self.update);
return now - last >= model.updateIntervalSeconds(self.schedule(io));
}
// -----------------------------------------------------------------------
@@ -3188,3 +3323,260 @@ test "bodyChecksum covers the list body, then the wild body, then the allow body
&bodyChecksum("a.example.com\n", "c.example.com\n", "b.example.com\n"),
));
}
// ---------------------------------------------------------------------------
// wakeable scheduler (milestone-34 S3.6)
// ---------------------------------------------------------------------------
/// A `ScheduleClock` that never sleeps. Each park is recorded, then the clock
/// jumps straight to the deadline so the loop runs the next pass at once; a
/// budget of parks ends the run with the `error.Canceled` shutdown is the only
/// other source of. A park may also fire a `setSchedule`, which is what a
/// settings PUT landing while the scheduler waits looks like.
const StepClock = struct {
const max_parks = 16;
mutex: std.Io.Mutex = .init,
manager: *Manager,
now_s: i64 = 0,
/// Deadline of each park in order; null means "parked with no deadline",
/// which is what a disabled schedule does.
parks: [max_parks]?i64 = @splat(null),
park_count: usize = 0,
budget: usize = 2,
/// Fired from inside the park at this index, before the wait returns.
change_at_park: ?usize = null,
change_enabled: bool = true,
change_hours: u16 = 1,
fn clock(self: *StepClock) ScheduleClock {
return .{ .ctx = self, .nowFn = now, .waitFn = wait };
}
fn now(ctx: ?*anyopaque, io: std.Io) i64 {
const self: *StepClock = @ptrCast(@alignCast(ctx.?));
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
return self.now_s;
}
fn wait(ctx: ?*anyopaque, io: std.Io, _: *std.Io.Event, deadline_s: ?i64) std.Io.Cancelable!void {
const self: *StepClock = @ptrCast(@alignCast(ctx.?));
self.mutex.lockUncancelable(io);
const index = self.park_count;
if (index < max_parks) self.parks[index] = deadline_s;
self.park_count = index + 1;
const fire_change = self.change_at_park == index;
const over_budget = self.park_count >= self.budget;
if (deadline_s) |d| self.now_s = d;
self.mutex.unlock(io);
// Taken outside this clock's own mutex: `setSchedule` takes the
// manager's, and the loop reads this clock under neither.
if (fire_change) {
self.manager.setSchedule(io, self.change_enabled, self.change_hours);
return;
}
if (over_budget or deadline_s == null) return error.Canceled;
}
fn parked(self: *StepClock) []const ?i64 {
return self.parks[0..@min(self.park_count, max_parks)];
}
};
const hour = 3_600;
test "the scheduler parks one interval past the anchor and again past each pass" {
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);
manager.update = .{ .enabled = true, .interval_hours = 2 };
var step: StepClock = .{ .manager = &manager, .budget = 3 };
manager.schedule_clock = step.clock();
try testing.expectError(error.Canceled, manager.runScheduler(io));
// The startup pass anchored at 0, so the first park is due at 2 h and each
// completed pass re-anchors: 2 h, 4 h, 6 h.
try testing.expectEqualSlices(?i64, &.{ 2 * hour, 4 * hour, 6 * hour }, step.parked());
}
test "a shortened interval moves the next refresh onto the new cadence" {
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);
manager.update = .{ .enabled = true, .interval_hours = 24 };
// The PUT lands while the loop waits out the 24-hour deadline.
var step: StepClock = .{
.manager = &manager,
.budget = 4,
.change_at_park = 0,
.change_enabled = true,
.change_hours = 1,
};
manager.schedule_clock = step.clock();
try testing.expectError(error.Canceled, manager.runScheduler(io));
// Park 0 was the old 24-hour deadline; the change woke it, and every park
// after it is one hour past the anchor the previous pass set.
const parks = step.parked();
try testing.expectEqual(@as(usize, 4), parks.len);
try testing.expectEqual(@as(?i64, 24 * hour), parks[0]);
try testing.expectEqual(@as(?i64, 24 * hour + hour), parks[1]);
try testing.expectEqual(@as(?i64, 25 * hour + hour), parks[2]);
}
test "a disabled schedule parks with no deadline and the startup pass still runs" {
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);
manager.update = .{ .enabled = false, .interval_hours = 1 };
var step: StepClock = .{ .manager = &manager, .budget = 8 };
manager.schedule_clock = step.clock();
try testing.expectError(error.Canceled, manager.runScheduler(io));
// The startup pass ran — it published a snapshot even with updates off —
// and then the loop parked once, on nothing.
try testing.expect(manager.generation > 0);
try testing.expectEqualSlices(?i64, &.{null}, step.parked());
}
test "re-enabling anchors the first interval on the last pass that ran" {
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);
manager.update = .{ .enabled = false, .interval_hours = 1 };
var step: StepClock = .{
.manager = &manager,
.budget = 3,
.change_at_park = 0,
.change_enabled = true,
.change_hours = 3,
};
manager.schedule_clock = step.clock();
try testing.expectError(error.Canceled, manager.runScheduler(io));
const parks = step.parked();
// Park 0 is the disabled park; the enable wakes it, and the first deadline
// is three hours past the STARTUP pass's anchor rather than past the
// moment the operator flipped the switch.
try testing.expectEqual(@as(?i64, null), parks[0]);
try testing.expectEqual(@as(?i64, 3 * hour), parks[1]);
}
test "an interval already elapsed at enable time refreshes immediately" {
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);
manager.update = .{ .enabled = true, .interval_hours = 2 };
var step: StepClock = .{ .manager = &manager, .budget = 2 };
manager.schedule_clock = step.clock();
// The anchor is four hours in the past, so two hours past it is already
// gone and the loop must not wait at all before its first pass.
manager.schedule_anchor_s = -4 * hour;
step.now_s = 0;
try testing.expectError(error.Canceled, manager.runScheduler(io));
// The startup pass re-anchors at 0, so this proves nothing on its own
// unless the anchor survives it; assert on the parks instead: the first
// park is one interval past the startup anchor, never a wait for a
// deadline already behind us.
const parks = step.parked();
try testing.expectEqual(@as(?i64, 2 * hour), parks[0]);
}
test "a gate-skipped pass advances the anchor rather than retrying early" {
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);
manager.update = .{ .enabled = true, .interval_hours = 2 };
var monitor: disk_monitor.Monitor = .init(.{}, std.Io.Dir.cwd(), ".", null);
monitor.state_raw.store(@intFromEnum(disk_monitor.State.critical), .monotonic);
manager.monitor = &monitor;
var step: StepClock = .{ .manager = &manager, .budget = 3 };
manager.schedule_clock = step.clock();
try testing.expectError(error.Canceled, manager.runScheduler(io));
// Every scheduled pass was refused by the gate, and each one still spent
// its slot: the deadlines march one interval at a time instead of
// collapsing onto the same anchor.
try testing.expectEqualSlices(?i64, &.{ 2 * hour, 4 * hour, 6 * hour }, step.parked());
// The startup pass is gated too, so three refusals: one startup and the
// two scheduled passes the parks above bracket.
try testing.expectEqual(@as(u64, 3), manager.refreshesGated());
}
test "setSchedule is what the live schedule readers see" {
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);
manager.setSchedule(io, false, 6);
const live = manager.schedule(io);
try testing.expect(!live.enabled);
try testing.expectEqual(@as(u16, 6), live.interval_hours);
try testing.expect(manager.schedule_event.isSet());
}
+556
View File
@@ -252,6 +252,200 @@ fn installWithMaxBytes(io: std.Io, cfg: model.Logging, max_bytes: u64) void {
if (state.output == .file) openFileLocked();
}
// ---------------------------------------------------------------------------
// Hot apply (milestone-34 S3.5)
// ---------------------------------------------------------------------------
/// Which of the three disjoint shapes a `logging` apply takes. The case is
/// decided from the FINAL MERGED config against the live sink, and it depends
/// only on `output` and `file_path` — fields nothing but an apply writes.
/// Rotation and write-failure recovery move `file`, `file_pos` and
/// `rotate_pending`, never these two, so a case decided in one lock hold is
/// still the right case in the next.
pub const ApplyCase = enum {
/// The merged config wants a file, and it is not the file that is open:
/// the path differs, or output is switching TO file.
target_changed,
/// Output is switching away from file. Nothing to open.
target_removed,
/// Everything else — output stays stderr/syslog, or output stays file on
/// the SAME path. Only config fields move; the handle and its position and
/// rotation state stay with the rotation machinery that owns them.
target_unchanged,
};
/// The complete new target state for a `target_changed` apply. The handle
/// couples to both other fields: inheriting the old `file_pos` would write
/// past the new file's end, and inheriting a pending rotation would rotate the
/// new target on its first line.
pub const PreparedSink = struct {
file: std.Io.File,
file_pos: u64,
rotate_pending: bool = false,
};
pub const PrepareError = error{
/// `file_path` does not fit in the sink's path buffer, so the sink could
/// not name the file it was told to write.
PathTooLong,
/// The new target could not be opened, created, or measured.
TargetUnopenable,
};
/// A validated `logging` apply, owning everything publish needs. Publish takes
/// no borrow from the request arena, so this outlives the request that built
/// it.
pub const PreparedApply = struct {
case: ApplyCase,
threshold: std.log.Level,
output: model.LogOutput,
path_buf: [std.Io.Dir.max_path_bytes]u8,
path_len: usize,
max_files: u8,
max_bytes: u64,
sink: ?PreparedSink,
pub fn path(self: *const PreparedApply) []const u8 {
return self.path_buf[0..self.path_len];
}
};
/// Prepare: everything fallible happens here, and nothing is published. A
/// `target_changed` apply opens the NEW file and MEASURES it — the open is the
/// step that can fail, and doing it here closes the close-then-reopen window
/// `installWithMaxBytes` has, where a bad new path leaves no sink at all.
pub fn prepareApply(io: std.Io, cfg: model.Logging) PrepareError!PreparedApply {
return prepareApplyWithMaxBytes(io, cfg, model.maxLogBytes(cfg));
}
/// Test-only entry point, mirroring `installForTest`.
pub fn prepareApplyForTest(io: std.Io, cfg: model.Logging, max_bytes: u64) PrepareError!PreparedApply {
return prepareApplyWithMaxBytes(io, cfg, max_bytes);
}
fn prepareApplyWithMaxBytes(io: std.Io, cfg: model.Logging, max_bytes: u64) PrepareError!PreparedApply {
if (cfg.file_path.len > std.Io.Dir.max_path_bytes) return error.PathTooLong;
var prepared: PreparedApply = .{
.case = undefined,
.threshold = toStdLevel(cfg.level),
.output = cfg.output,
.path_buf = undefined,
.path_len = cfg.file_path.len,
.max_files = cfg.max_files,
.max_bytes = max_bytes,
.sink = null,
};
@memcpy(prepared.path_buf[0..prepared.path_len], cfg.file_path);
prepared.case = classifyApply(cfg);
if (prepared.case == .target_changed) {
prepared.sink = try openTarget(io, prepared.path());
}
return prepared;
}
fn classifyApply(cfg: model.Logging) ApplyCase {
var stderr_buf: [64]u8 = undefined;
_ = std.debug.lockStderr(&stderr_buf);
defer std.debug.unlockStderr();
const currently_file = state.output == .file;
if (cfg.output != .file) return if (currently_file) .target_removed else .target_unchanged;
if (!currently_file) return .target_changed;
return if (std.mem.eql(u8, state.path(), cfg.file_path)) .target_unchanged else .target_changed;
}
/// Opens `p` and measures it, without touching the live sink.
fn openTarget(io: std.Io, p: []const u8) PrepareError!PreparedSink {
if (p.len == 0) return error.TargetUnopenable;
const prev = io.swapCancelProtection(.blocked);
defer _ = io.swapCancelProtection(prev);
const dir: std.Io.Dir = .cwd();
const file = dir.openFile(io, p, .{ .mode = .write_only }) catch |open_err| switch (open_err) {
error.FileNotFound => dir.createFile(io, p, .{ .truncate = false }) catch
return error.TargetUnopenable,
else => return error.TargetUnopenable,
};
errdefer file.close(io);
// A pre-existing nonempty target is appended to, so the new position is
// its measured length rather than zero.
const length = file.length(io) catch return error.TargetUnopenable;
return .{ .file = file, .file_pos = length, .rotate_pending = false };
}
/// Publish: infallible and I/O-free, one hold of the sink lock. Returns the
/// DETACHED old handle, which `retireApply` closes — closing a file is retire
/// work, and doing it here would put a syscall inside the publish.
pub fn publishApply(prepared: PreparedApply) ?std.Io.File {
var stderr_buf: [64]u8 = undefined;
_ = std.debug.lockStderr(&stderr_buf);
defer std.debug.unlockStderr();
state.threshold = prepared.threshold;
state.output = prepared.output;
state.path_len = prepared.path_len;
@memcpy(state.path_buf[0..state.path_len], prepared.path());
state.max_files = prepared.max_files;
state.max_bytes = prepared.max_bytes;
switch (prepared.case) {
// The handle and its position and rotation state are not this apply's
// to move; a broken handle is repaired by the existing per-write
// recovery, not by a config change.
.target_unchanged => return null,
.target_changed => {
const detached = state.file;
const sink = prepared.sink.?;
state.file = sink.file;
state.file_pos = sink.file_pos;
state.rotate_pending = sink.rotate_pending;
return detached;
},
.target_removed => {
const detached = state.file;
state.file = null;
state.file_pos = 0;
state.rotate_pending = false;
return detached;
},
}
}
/// Retire: closes the handle `publishApply` detached, after no writer can
/// reach it — the swap happened under the sink lock, so any writer that held
/// it has already returned.
pub fn retireApply(io: std.Io, detached: ?std.Io.File) void {
const file = detached orelse return;
const prev = io.swapCancelProtection(.blocked);
defer _ = io.swapCancelProtection(prev);
file.close(io);
}
/// Discards a prepared apply that will not be published, because its commit
/// failed or a sibling owner's prepare did.
pub fn abortApply(io: std.Io, prepared: PreparedApply) void {
const sink = prepared.sink orelse return;
const prev = io.swapCancelProtection(.blocked);
defer _ = io.swapCancelProtection(prev);
sink.file.close(io);
}
/// The directory the disk monitor should measure for `cfg`: the log file's
/// directory when output is `file`, and null otherwise — with output on
/// stderr or syslog there is no log file to run out of room for.
///
/// A path with no directory component measures the working directory, which is
/// where a bare filename lands.
pub fn logDirname(cfg: model.Logging) ?[]const u8 {
if (cfg.output != .file) return null;
if (cfg.file_path.len == 0) return null;
return std.fs.path.dirname(cfg.file_path) orelse ".";
}
/// Flushes and closes the file, and restores pass-through stderr formatting.
pub fn deinstall() void {
var stderr_buf: [64]u8 = undefined;
@@ -1099,3 +1293,365 @@ test "a message over the buffer is marked and counted" {
const colon = std.mem.indexOf(u8, written, ": ").?;
try testing.expectEqual(max_message_bytes, written[colon + 2 ..].len - 1);
}
// ---------------------------------------------------------------------------
// hot apply (milestone-34 S3.5)
// ---------------------------------------------------------------------------
/// The sink is one process-wide `state` behind the stderr lock, so an apply
/// test has to save it, drive the apply, and put it back. Nothing here holds
/// the lock across an apply call: `classifyApply` and `publishApply` take it
/// themselves.
const ApplyFixture = struct {
threaded: std.Io.Threaded,
tmp: testing.TmpDir,
saved: State,
fn init(self: *ApplyFixture) void {
self.threaded = .init(testing.allocator, .{});
self.tmp = testing.tmpDir(.{});
var stderr_buf: [64]u8 = undefined;
_ = std.debug.lockStderr(&stderr_buf);
defer std.debug.unlockStderr();
self.saved = state;
state = .{};
state.io = self.threaded.io();
}
fn deinit(self: *ApplyFixture) void {
{
var stderr_buf: [64]u8 = undefined;
_ = std.debug.lockStderr(&stderr_buf);
defer std.debug.unlockStderr();
if (state.file) |f| f.close(self.threaded.io());
state = self.saved;
}
self.tmp.cleanup();
self.threaded.deinit();
}
fn io(self: *ApplyFixture) std.Io {
return self.threaded.io();
}
fn path(self: *ApplyFixture, buf: []u8, name: []const u8) []const u8 {
return std.fmt.bufPrint(buf, ".zig-cache/tmp/{s}/{s}", .{ self.tmp.sub_path, name }) catch unreachable;
}
/// Puts the sink on `p` with a real open handle, the way a running server
/// with `output = file` sits.
fn openOn(self: *ApplyFixture, p: []const u8) void {
var stderr_buf: [64]u8 = undefined;
_ = std.debug.lockStderr(&stderr_buf);
defer std.debug.unlockStderr();
state.installed = true;
state.output = .file;
state.path_len = p.len;
@memcpy(state.path_buf[0..p.len], p);
state.max_bytes = 1 << 20;
state.max_files = 3;
openFileLocked();
_ = self;
}
fn snapshot(self: *ApplyFixture) State {
_ = self;
var stderr_buf: [64]u8 = undefined;
_ = std.debug.lockStderr(&stderr_buf);
defer std.debug.unlockStderr();
return state;
}
fn setHandleState(self: *ApplyFixture, file: ?std.Io.File, file_pos: u64, rotate_pending: bool) void {
_ = self;
var stderr_buf: [64]u8 = undefined;
_ = std.debug.lockStderr(&stderr_buf);
defer std.debug.unlockStderr();
state.file = file;
state.file_pos = file_pos;
state.rotate_pending = rotate_pending;
}
/// Writes one record through the live handle, as `emitFileLocked` does.
fn writeThroughSink(self: *ApplyFixture, text: []const u8) !void {
_ = self;
var stderr_buf: [64]u8 = undefined;
_ = std.debug.lockStderr(&stderr_buf);
defer std.debug.unlockStderr();
try writeLineLocked(state.file.?, text);
}
fn read(self: *ApplyFixture, buf: []u8, name: []const u8) ![]u8 {
return self.tmp.dir.readFileAlloc(self.io(), name, testing.allocator, .limited(buf.len)) catch |err| return err;
}
};
fn fileCfg(p: []const u8) model.Logging {
return .{ .output = .file, .file_path = p, .level = .info, .max_files = 3, .max_size_mb = 1 };
}
test "a bad target path is refused at prepare and the live sink is untouched" {
var fx: ApplyFixture = undefined;
fx.init();
defer fx.deinit();
var buf: [160]u8 = undefined;
const live = fx.path(&buf, "nxdns.log");
fx.openOn(live);
const before = fx.snapshot();
try testing.expect(before.file != null);
// A directory that does not exist: the open and the create both fail.
var bad_buf: [200]u8 = undefined;
const bad = fx.path(&bad_buf, "no-such-dir/nxdns.log");
try testing.expectError(
error.TargetUnopenable,
prepareApplyForTest(fx.io(), fileCfg(bad), 1 << 20),
);
const after = fx.snapshot();
try testing.expectEqual(before.file.?.handle, after.file.?.handle);
try testing.expectEqualStrings(live, after.path());
}
test "a target change publishes without closing, and retire closes the old handle" {
var fx: ApplyFixture = undefined;
fx.init();
defer fx.deinit();
var first_buf: [160]u8 = undefined;
var second_buf: [160]u8 = undefined;
const first = fx.path(&first_buf, "first.log");
const second = fx.path(&second_buf, "second.log");
fx.openOn(first);
const before = fx.snapshot();
const prepared = try prepareApplyForTest(fx.io(), fileCfg(second), 1 << 20);
try testing.expectEqual(ApplyCase.target_changed, prepared.case);
const detached = publishApply(prepared);
const after = fx.snapshot();
// Publish swapped the handle and left the old one OPEN: the old descriptor
// still writes, which it could not if publish had closed it.
try testing.expectEqual(before.file.?.handle, detached.?.handle);
try testing.expect(after.file.?.handle != detached.?.handle);
try testing.expectEqualStrings(second, after.path());
try testing.expectEqual(@as(u64, 0), after.file_pos);
try testing.expect(!after.rotate_pending);
var old_writer_buf: [64]u8 = undefined;
var ow = detached.?.writer(fx.io(), &old_writer_buf);
try ow.interface.writeAll("still open\n");
try ow.interface.flush();
retireApply(fx.io(), detached);
// The new target receives lines.
try fx.writeThroughSink("1 info: on the new target\n");
var read_buf: [256]u8 = undefined;
const contents = try fx.read(&read_buf, "second.log");
defer testing.allocator.free(contents);
try testing.expectEqualStrings("1 info: on the new target\n", contents);
}
test "switching to a pre-existing nonempty file starts at its measured length" {
var fx: ApplyFixture = undefined;
fx.init();
defer fx.deinit();
const existing = "already here\n";
try fx.tmp.dir.writeFile(fx.io(), .{ .sub_path = "kept.log", .data = existing });
var first_buf: [160]u8 = undefined;
var kept_buf: [160]u8 = undefined;
fx.openOn(fx.path(&first_buf, "first.log"));
const kept = fx.path(&kept_buf, "kept.log");
const prepared = try prepareApplyForTest(fx.io(), fileCfg(kept), 1 << 20);
try testing.expectEqual(@as(u64, existing.len), prepared.sink.?.file_pos);
retireApply(fx.io(), publishApply(prepared));
try testing.expectEqual(@as(u64, existing.len), fx.snapshot().file_pos);
// Inheriting the old position would have overwritten the existing bytes.
try fx.writeThroughSink("appended\n");
var read_buf: [256]u8 = undefined;
const contents = try fx.read(&read_buf, "kept.log");
defer testing.allocator.free(contents);
try testing.expectEqualStrings(existing ++ "appended\n", contents);
}
test "a target change does not inherit a pending rotation" {
var fx: ApplyFixture = undefined;
fx.init();
defer fx.deinit();
var first_buf: [160]u8 = undefined;
var second_buf: [160]u8 = undefined;
fx.openOn(fx.path(&first_buf, "first.log"));
const second = fx.path(&second_buf, "second.log");
// A rotation the old target owed and never completed: the handle is closed
// and the rotation is still pending.
const stale = fx.snapshot().file.?;
stale.close(fx.io());
fx.setHandleState(null, 0, true);
const prepared = try prepareApplyForTest(fx.io(), fileCfg(second), 1 << 20);
try testing.expect(!prepared.sink.?.rotate_pending);
retireApply(fx.io(), publishApply(prepared));
const after = fx.snapshot();
try testing.expect(!after.rotate_pending);
try testing.expect(after.file != null);
try testing.expectEqual(@as(u64, 0), after.file_pos);
}
test "a file to stderr apply detaches the handle and clears position and rotation" {
var fx: ApplyFixture = undefined;
fx.init();
defer fx.deinit();
var buf: [160]u8 = undefined;
const live = fx.path(&buf, "nxdns.log");
fx.openOn(live);
fx.setHandleState(fx.snapshot().file, 4_096, true);
const before = fx.snapshot();
const prepared = try prepareApplyForTest(fx.io(), .{
.output = .stderr,
.file_path = live,
.level = .warn,
}, 1 << 20);
try testing.expectEqual(ApplyCase.target_removed, prepared.case);
const detached = publishApply(prepared);
const after = fx.snapshot();
try testing.expectEqual(before.file.?.handle, detached.?.handle);
try testing.expectEqual(@as(?std.Io.File, null), after.file);
try testing.expectEqual(@as(u64, 0), after.file_pos);
try testing.expect(!after.rotate_pending);
try testing.expectEqual(model.LogOutput.stderr, after.output);
// The path still moves, so a later switch back to file opens the right one.
try testing.expectEqualStrings(live, after.path());
retireApply(fx.io(), detached);
}
test "a same-path apply changes only config fields, whatever the handle is doing" {
var fx: ApplyFixture = undefined;
fx.init();
defer fx.deinit();
var buf: [160]u8 = undefined;
const live = fx.path(&buf, "nxdns.log");
fx.openOn(live);
// Publish takes the same lock a rotation and a write-failure closure hold,
// so the only reachable interleavings are "before publish" and "after".
// Both leave the handle state the apply must not touch; these are the two
// states each of them leaves behind.
const handle_states = [_]struct { file: bool, pos: u64, pending: bool }{
// Mid-rotation: handle closed, rotation owed.
.{ .file = false, .pos = 0, .pending = true },
// Healthy and part-written.
.{ .file = true, .pos = 8_192, .pending = false },
};
const open_handle = fx.snapshot().file.?;
for (handle_states) |want| {
fx.setHandleState(if (want.file) open_handle else null, want.pos, want.pending);
const prepared = try prepareApplyForTest(fx.io(), .{
.output = .file,
.file_path = live,
.level = .debug,
.max_files = 9,
.max_size_mb = 7,
}, 4_242);
try testing.expectEqual(ApplyCase.target_unchanged, prepared.case);
// Nothing detached, so retire has nothing to close.
try testing.expectEqual(@as(?std.Io.File, null), publishApply(prepared));
const after = fx.snapshot();
try testing.expectEqual(want.pos, after.file_pos);
try testing.expectEqual(want.pending, after.rotate_pending);
try testing.expectEqual(want.file, after.file != null);
// The config fields did move.
try testing.expectEqual(std.log.Level.debug, after.threshold);
try testing.expectEqual(@as(u8, 9), after.max_files);
try testing.expectEqual(@as(u64, 4_242), after.max_bytes);
}
fx.setHandleState(open_handle, 0, false);
}
test "a file_path change while output is stderr updates the config and touches no handle" {
var fx: ApplyFixture = undefined;
fx.init();
defer fx.deinit();
var buf: [160]u8 = undefined;
const later = fx.path(&buf, "later.log");
const prepared = try prepareApplyForTest(fx.io(), .{
.output = .syslog,
.file_path = later,
.level = .info,
}, 1 << 20);
try testing.expectEqual(ApplyCase.target_unchanged, prepared.case);
try testing.expectEqual(@as(?std.Io.File, null), publishApply(prepared));
const after = fx.snapshot();
try testing.expectEqual(@as(?std.Io.File, null), after.file);
try testing.expectEqualStrings(later, after.path());
// The later switch to file opens exactly that path.
const to_file = try prepareApplyForTest(fx.io(), fileCfg(later), 1 << 20);
try testing.expectEqual(ApplyCase.target_changed, to_file.case);
retireApply(fx.io(), publishApply(to_file));
try testing.expect(fx.snapshot().file != null);
}
test "an aborted apply closes the target it opened" {
var fx: ApplyFixture = undefined;
fx.init();
defer fx.deinit();
var buf: [160]u8 = undefined;
const target = fx.path(&buf, "never.log");
// The commit failed, so the prepared target must not leak its descriptor.
const prepared = try prepareApplyForTest(fx.io(), fileCfg(target), 1 << 20);
abortApply(fx.io(), prepared);
const after = fx.snapshot();
try testing.expectEqual(@as(?std.Io.File, null), after.file);
try testing.expectEqual(model.LogOutput.stderr, after.output);
}
test "logDirname follows output and file_path in both directions" {
try testing.expectEqualStrings("/var/log/nxdns", logDirname(.{
.output = .file,
.file_path = "/var/log/nxdns/nxdns.log",
}).?);
// A bare filename lands in the working directory.
try testing.expectEqualStrings(".", logDirname(.{
.output = .file,
.file_path = "nxdns.log",
}).?);
// Output away from file stops the measurement whatever the path says.
try testing.expectEqual(@as(?[]const u8, null), logDirname(.{
.output = .stderr,
.file_path = "/var/log/nxdns/nxdns.log",
}));
try testing.expectEqual(@as(?[]const u8, null), logDirname(.{
.output = .syslog,
.file_path = "/var/log/nxdns/nxdns.log",
}));
}
+274 -8
View File
@@ -109,10 +109,13 @@ pub const CertStore = struct {
pub const Kind = enum { doh, dot };
gpa: std.mem.Allocator,
/// Borrowed from the config; must outlive the store.
cert_path: []const u8,
/// Borrowed from the config; must outlive the store.
key_path: []const u8,
/// Owned. A settings apply replaces both paths, so a borrowed config slice
/// would dangle the moment the row it came from went away. Read and
/// written only under `reload_mutex`, which every apply and every reload
/// holds for its whole read-build-publish sequence.
cert_path: []u8,
/// Owned; see `cert_path`.
key_path: []u8,
/// Passed through to every `ServerContext.init`; the same lifetime rule
/// applies — a comptime-constant NULL-terminated array, never memory that
/// can go away before the store.
@@ -136,7 +139,8 @@ pub const CertStore = struct {
/// Test seam: runs inside `reload` between a successful `load` and the
/// publish, i.e. inside `reload_mutex`. Lets a test occupy the window
/// where an unserialized reload could be overtaken. Must not call
/// `reload` synchronously (that would self-deadlock on `reload_mutex`).
/// `reload`, `pollOnce` or `preparePathChange` synchronously — all four
/// take `reload_mutex`, which is not reentrant.
after_load_hook: ?ReloadHook,
/// Set by the composition root right after `init`, with `diagnostics`.
@@ -168,10 +172,17 @@ pub const CertStore = struct {
alpn: ?[*:null]const ?[*:0]const u8,
) ReloadError!CertStore {
const first = try load(gpa, io, cert_path, key_path, alpn);
errdefer {
first.entry.ctx.deinit(gpa);
gpa.destroy(first.entry);
}
const owned_cert = try gpa.dupe(u8, cert_path);
errdefer gpa.free(owned_cert);
const owned_key = try gpa.dupe(u8, key_path);
return .{
.gpa = gpa,
.cert_path = cert_path,
.key_path = key_path,
.cert_path = owned_cert,
.key_path = owned_key,
.alpn = alpn,
.mutex = .init,
.reload_mutex = .init,
@@ -193,6 +204,8 @@ pub const CertStore = struct {
std.debug.assert(current.refs == 0);
self.mutex.unlock(io);
self.destroyEntry(current);
self.gpa.free(self.cert_path);
self.gpa.free(self.key_path);
self.* = undefined;
}
@@ -226,7 +239,14 @@ pub const CertStore = struct {
pub fn reload(self: *CertStore, io: std.Io) ReloadError!void {
self.reload_mutex.lockUncancelable(io);
defer self.reload_mutex.unlock(io);
return self.reloadLocked(io);
}
/// `reload`'s body, for callers that already hold `reload_mutex` —
/// `pollOnce` does, because it must read `cert_path`/`key_path` under the
/// same lock an apply replaces them under. `std.Io.Mutex` is not
/// reentrant, so this exists rather than a recursive `reload` call.
fn reloadLocked(self: *CertStore, io: std.Io) ReloadError!void {
const next = load(self.gpa, io, self.cert_path, self.key_path, self.alpn) catch |err| {
_ = self.reload_failures.fetchAdd(1, .monotonic);
return err;
@@ -247,6 +267,93 @@ pub const CertStore = struct {
self.last_reload_unix.store(std.Io.Clock.real.now(io).toSeconds(), .monotonic);
}
/// A candidate certificate loaded from new paths, not yet published.
/// Holding one means holding `reload_mutex`: exactly one of
/// `publishPathChange` or `abortPathChange` must follow, and it releases
/// the lock.
pub const PreparedPaths = struct {
cert_path: []u8,
key_path: []u8,
loaded: Loaded,
/// Read at prepare so publish reads no clock: publish must touch
/// nothing outside memory it already owns.
loaded_at_unix: i64,
};
/// Prepare half of a `doh_server`/`dot_server` cert-path change: takes
/// `reload_mutex` and loads the certificate and key from the NEW paths.
/// Nothing is published, so a failure leaves the store exactly as it was —
/// the old certificate keeps serving and the caller writes no DB row. The
/// lock is released on failure and held on success, which is what makes
/// the whole apply serialized against `reload` and `pollOnce`.
pub fn preparePathChange(
self: *CertStore,
io: std.Io,
cert_path: []const u8,
key_path: []const u8,
) ReloadError!PreparedPaths {
self.reload_mutex.lockUncancelable(io);
errdefer self.reload_mutex.unlock(io);
const owned_cert = try self.gpa.dupe(u8, cert_path);
errdefer self.gpa.free(owned_cert);
const owned_key = try self.gpa.dupe(u8, key_path);
errdefer self.gpa.free(owned_key);
const next = load(self.gpa, io, cert_path, key_path, self.alpn) catch |err| {
_ = self.reload_failures.fetchAdd(1, .monotonic);
return err;
};
return .{
.cert_path = owned_cert,
.key_path = owned_key,
.loaded = next,
.loaded_at_unix = std.Io.Clock.real.now(io).toSeconds(),
};
}
/// Publish half: infallible and I/O-free. The paths and the generation
/// they were loaded from are installed together — the generation `mutex`
/// is taken only for that swap, inside `reload_mutex`, the same lock order
/// `reload` uses. Releases `reload_mutex`.
///
/// Connections that pinned the old generation finish on the old
/// certificate; the old entry is freed once its last reader releases.
pub fn publishPathChange(self: *CertStore, io: std.Io, prepared: PreparedPaths) void {
const old_cert_path = self.cert_path;
const old_key_path = self.key_path;
self.mutex.lockUncancelable(io);
const old = self.current;
self.cert_path = prepared.cert_path;
self.key_path = prepared.key_path;
self.current = prepared.loaded.entry;
self.loaded = prepared.loaded.sig;
old.retired = true;
const free_old = old.refs == 0;
self.mutex.unlock(io);
// The branch runs before the counter bump, never after: a `bool` still
// live across an atomic read-modify-write is the zig 0.16.0 Debug
// miscompile AGENTS.md documents.
if (free_old) self.destroyEntry(old);
self.gpa.free(old_cert_path);
self.gpa.free(old_key_path);
_ = self.reloads.fetchAdd(1, .monotonic);
self.last_reload_unix.store(prepared.loaded_at_unix, .monotonic);
self.reload_mutex.unlock(io);
}
/// Discards a prepared candidate — the commit that would have published it
/// failed, or a sibling owner's prepare did. Releases `reload_mutex`.
pub fn abortPathChange(self: *CertStore, io: std.Io, prepared: PreparedPaths) void {
self.gpa.free(prepared.cert_path);
self.gpa.free(prepared.key_path);
self.destroyEntry(prepared.loaded.entry);
self.reload_mutex.unlock(io);
}
/// Sleep first: `init` just loaded the files this poll would compare
/// against. `.boot` so a suspended box still sees the interval elapse.
pub fn watch(self: *CertStore, io: std.Io) std.Io.Cancelable!void {
@@ -265,7 +372,15 @@ pub const CertStore = struct {
/// changed, and the old one keeps serving either way. A failed reload
/// warns and counts (`reload_failures`); the signature stays at the loaded
/// pair, so every subsequent poll retries until the files parse.
///
/// The whole pass runs under `reload_mutex`: the paths it stats are the
/// ones an apply replaces, and a poll that read a path outside the lock
/// could stat a freed slice or reload a pair that was never published
/// together.
pub fn pollOnce(self: *CertStore, io: std.Io, now_s: i64) void {
self.reload_mutex.lockUncancelable(io);
defer self.reload_mutex.unlock(io);
const cert_sig = statSig(io, self.cert_path) catch |err| {
log.warn("stat {s} failed; keeping the loaded certificate", .{self.cert_path});
self.reportReload(io, now_s, "stat of the certificate failed", @errorName(err));
@@ -290,7 +405,7 @@ pub const CertStore = struct {
return;
}
if (self.reload(io)) {
if (self.reloadLocked(io)) {
log.info("certificate reloaded from {s}", .{self.cert_path});
if (self.diagnostics) |store| store.resolve(io, now_s, .certificate_reload, @tagName(self.kind));
} else |err| {
@@ -993,3 +1108,154 @@ test "an unchanged poll closes the episode a transient stat failure opened" {
try fx.count("SELECT count(*) FROM operational_events WHERE resolved_at IS NULL"),
);
}
// ---------------------------------------------------------------------------
// path apply (milestone-34 S3.4)
// ---------------------------------------------------------------------------
test "a bad candidate is refused at prepare and the store keeps serving" {
var env: TestEnv = undefined;
try env.init();
defer env.deinit();
const io = env.io();
var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null);
defer store.deinit(io);
const before = store.acquire(io);
store.release(io, before);
const before_paths_cert = store.cert_path;
try env.tmp.dir.writeFile(io, .{ .sub_path = "bad.pem", .data = "not a certificate" });
var bad_buf: [128]u8 = undefined;
const bad_path = try std.fmt.bufPrint(&bad_buf, ".zig-cache/tmp/{s}/bad.pem", .{env.tmp.sub_path});
try testing.expectError(
error.CertParse,
store.preparePathChange(io, bad_path, env.key_path),
);
// Nothing published: same generation, same paths, and `reload_mutex` was
// released — a second prepare would deadlock otherwise.
const after = store.acquire(io);
store.release(io, after);
try testing.expectEqual(before, after);
try testing.expectEqual(before_paths_cert.ptr, store.cert_path.ptr);
try testing.expectEqualStrings(env.cert_path, store.cert_path);
try testing.expectEqual(@as(u64, 0), store.snapshotStats().reloads);
try testing.expectEqual(@as(u64, 1), store.snapshotStats().reload_failures);
// A missing candidate path is refused the same way.
try testing.expectError(
error.CertUnreadable,
store.preparePathChange(io, "./nxdns-no-such-cert-4a11.pem", env.key_path),
);
}
test "a published path change installs the new pair and retires the old generation" {
var env: TestEnv = undefined;
try env.init();
defer env.deinit();
const io = env.io();
var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null);
defer store.deinit(io);
// A second, byte-different copy of the same valid pair under new names.
const grown = try std.mem.concat(testing.allocator, u8, &.{ fixtures.cert_pem, "\n" });
defer testing.allocator.free(grown);
try env.tmp.dir.writeFile(io, .{ .sub_path = "next-cert.pem", .data = grown });
try env.tmp.dir.writeFile(io, .{ .sub_path = "next-key.pem", .data = fixtures.key_pem });
var cert_buf: [128]u8 = undefined;
var key_buf: [128]u8 = undefined;
const next_cert = try std.fmt.bufPrint(&cert_buf, ".zig-cache/tmp/{s}/next-cert.pem", .{env.tmp.sub_path});
const next_key = try std.fmt.bufPrint(&key_buf, ".zig-cache/tmp/{s}/next-key.pem", .{env.tmp.sub_path});
// A connection pinned to the old generation finishes on it.
const pinned = store.acquire(io);
const prepared = try store.preparePathChange(io, next_cert, next_key);
store.publishPathChange(io, prepared);
try testing.expectEqualStrings(next_cert, store.cert_path);
try testing.expectEqualStrings(next_key, store.key_path);
try testing.expectEqual(@as(u64, 1), store.snapshotStats().reloads);
try testing.expect(pinned.retired);
const serving = store.acquire(io);
try testing.expect(serving != pinned);
store.release(io, serving);
store.release(io, pinned);
// The watcher now measures the new pair, so an untouched pair polls clean
// and a rewritten one reloads.
store.pollOnce(io, 1_000);
try testing.expectEqual(@as(u64, 1), store.snapshotStats().reloads);
try env.tmp.dir.writeFile(io, .{ .sub_path = "next-cert.pem", .data = fixtures.cert_pem });
store.pollOnce(io, 1_100);
try testing.expectEqual(@as(u64, 2), store.snapshotStats().reloads);
}
test "an aborted path change frees the candidate and leaves the store untouched" {
var env: TestEnv = undefined;
try env.init();
defer env.deinit();
const io = env.io();
var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null);
defer store.deinit(io);
const before = store.acquire(io);
store.release(io, before);
// The commit this candidate was built for failed; the testing allocator
// proves the abort frees everything the prepare took.
const prepared = try store.preparePathChange(io, env.cert_path, env.key_path);
store.abortPathChange(io, prepared);
const after = store.acquire(io);
store.release(io, after);
try testing.expectEqual(before, after);
try testing.expectEqual(@as(u64, 0), store.snapshotStats().reloads);
// `reload_mutex` came back, so the store still reloads.
try store.reload(io);
}
test "a reload racing a path apply is serialized behind it" {
var env: TestEnv = undefined;
try env.init();
defer env.deinit();
const io = env.io();
var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null);
defer store.deinit(io);
const grown = try std.mem.concat(testing.allocator, u8, &.{ fixtures.cert_pem, "\n" });
defer testing.allocator.free(grown);
try env.tmp.dir.writeFile(io, .{ .sub_path = "next-cert.pem", .data = grown });
try env.tmp.dir.writeFile(io, .{ .sub_path = "next-key.pem", .data = fixtures.key_pem });
var cert_buf: [128]u8 = undefined;
var key_buf: [128]u8 = undefined;
const next_cert = try std.fmt.bufPrint(&cert_buf, ".zig-cache/tmp/{s}/next-cert.pem", .{env.tmp.sub_path});
const next_key = try std.fmt.bufPrint(&key_buf, ".zig-cache/tmp/{s}/next-key.pem", .{env.tmp.sub_path});
// Prepare holds `reload_mutex` across the whole apply.
const prepared = try store.preparePathChange(io, next_cert, next_key);
try testing.expect(!store.reload_mutex.tryLock());
// The concurrent reload cannot start, so it cannot publish the OLD paths
// over the new generation.
var racing = try io.concurrent(CertStore.reload, .{ &store, io });
store.publishPathChange(io, prepared);
try racing.await(io);
// Two publications, and the last word is the apply's pair: the racing
// reload reread the paths the apply installed.
try testing.expectEqual(@as(u64, 2), store.snapshotStats().reloads);
try testing.expectEqualStrings(next_cert, store.cert_path);
store.mutex.lockUncancelable(io);
const final = store.loaded;
store.mutex.unlock(io);
try testing.expectEqual(@as(u64, grown.len), final.cert.size);
}
+59 -18
View File
@@ -27,6 +27,7 @@ const db = @import("../storage/db.zig");
const disk_monitor = @import("../storage/disk_monitor.zig");
const events = @import("../storage/events.zig");
const logger = @import("../storage/logger.zig");
const retention = @import("../storage/retention.zig");
const log = std.log.scoped(.clients);
@@ -59,7 +60,7 @@ pub const Tracker = struct {
/// 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,
retention_days: *const retention.RetentionDays,
pending: [max_pending]Pending,
count: u32,
passes: u64,
@@ -70,8 +71,9 @@ pub const Tracker = struct {
/// `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 {
/// uninteresting as a query that old — so both consumers share ONE cell
/// and a settings apply moves them together.
pub fn init(retention_days: *const retention.RetentionDays) Tracker {
return .{
.mutex = .init,
.retention_days = retention_days,
@@ -225,7 +227,7 @@ pub const Tracker = struct {
self.mutex.unlock(io);
if (due) {
const cutoff = now_s - @as(i64, self.retention_days) * 86_400;
const cutoff = now_s - self.retention_days.seconds();
if (clients_repo.pruneStale(database, cutoff)) |deleted| {
self.mutex.lockUncancelable(io);
self.stats.pruned += deleted;
@@ -328,7 +330,8 @@ test "a client tracked twice before a flush yields one row at the later time" {
var database = try openMigrated();
defer database.close();
var tracker: Tracker = .init(30);
var days: retention.RetentionDays = .init(30);
var tracker: Tracker = .init(&days);
// A table with room reports no drops.
try testing.expectEqual(@as(u64, 0), tracker.trackAt(io, parsed("192.168.1.10"), 1700000000));
try testing.expectEqual(@as(u64, 0), tracker.trackAt(io, parsed("192.168.1.10"), 1700000030));
@@ -354,7 +357,8 @@ test "distinct clients each get a row and ipv6 text is canonical" {
var database = try openMigrated();
defer database.close();
var tracker: Tracker = .init(30);
var days: retention.RetentionDays = .init(30);
var tracker: Tracker = .init(&days);
_ = 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);
@@ -378,7 +382,8 @@ test "a full table drops further clients and counts them" {
var database = try openMigrated();
defer database.close();
var tracker: Tracker = .init(30);
var days: retention.RetentionDays = .init(30);
var tracker: Tracker = .init(&days);
for (0..Tracker.max_pending) |i| {
var octets: [4]u8 = undefined;
std.mem.writeInt(u32, &octets, @intCast(i), .big);
@@ -421,7 +426,8 @@ test "a flush touches a hand-edited row without changing what the operator set"
\\VALUES ('192.168.1.10', 'laptop', 2, 1, 1690000000, 1690000000);
);
var tracker: Tracker = .init(30);
var days: retention.RetentionDays = .init(30);
var tracker: Tracker = .init(&days);
_ = tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
tracker.flushOnce(io, &database, true, null);
@@ -449,7 +455,8 @@ test "a gated pass writes nothing and keeps the pending clients" {
monitor.state_raw.store(@intFromEnum(disk_monitor.State.critical), .monotonic);
try testing.expect(!monitor.writesAllowed());
var tracker: Tracker = .init(30);
var days: retention.RetentionDays = .init(30);
var tracker: Tracker = .init(&days);
_ = tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
tracker.flushOnce(io, &database, monitor.writesAllowed(), null);
@@ -477,7 +484,8 @@ test "a failing upsert counts and leaves the client to be tracked again" {
\\BEGIN SELECT RAISE(ABORT, 'refused'); END;
);
var tracker: Tracker = .init(30);
var days: retention.RetentionDays = .init(30);
var tracker: Tracker = .init(&days);
_ = tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
_ = tracker.trackAt(io, parsed("192.168.1.11"), 1700000000);
tracker.flushOnce(io, &database, true, null);
@@ -507,7 +515,8 @@ test "the pass that comes due prunes the clients that went quiet" {
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);
var days: retention.RetentionDays = .init(30);
var tracker: Tracker = .init(&days);
// Every pass before the due one leaves both rows alone.
for (0..Tracker.prune_every_passes - 1) |_| {
tracker.flushOnce(io, &database, true, null);
@@ -534,7 +543,8 @@ test "a shorter retention prunes what the default keeps" {
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);
var days: retention.RetentionDays = .init(1);
var tracker: Tracker = .init(&days);
tracker.passes = Tracker.prune_every_passes - 1;
tracker.flushOnce(io, &database, true, null);
@@ -542,6 +552,31 @@ test "a shorter retention prunes what the default keeps" {
try testing.expectEqual(@as(u64, 1), tracker.snapshotStats(io).pruned);
}
test "setRetentionDays changes the cutoff the next prune pass uses" {
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 days: retention.RetentionDays = .init(7);
var tracker: Tracker = .init(&days);
tracker.passes = Tracker.prune_every_passes - 1;
tracker.flushOnce(io, &database, true, null);
try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
// The shared cell, not a copy taken at construction.
days.setRetentionDays(1);
tracker.passes = Tracker.prune_every_passes - 1;
tracker.flushOnce(io, &database, true, null);
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();
@@ -550,7 +585,8 @@ test "the run loop flushes on its interval and returns on cancel" {
var database = try openMigrated();
defer database.close();
var tracker: Tracker = .init(30);
var days: retention.RetentionDays = .init(30);
var tracker: Tracker = .init(&days);
_ = tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
var future = try io.concurrent(Tracker.run, .{
@@ -622,7 +658,8 @@ test "the drain lands before any exchange, and attempts stop at the cap" {
names.exchange_fn = CountingExchange.exchange;
CountingExchange.reset(&database);
var tracker: Tracker = .init(30);
var days: retention.RetentionDays = .init(30);
var tracker: Tracker = .init(&days);
const pending = clients_repo.max_per_pass + 4;
for (0..pending) |i| {
_ = tracker.trackAt(io, .{ .ip4 = .{ 192, 168, 2, @intCast(i) } }, 1700000000);
@@ -656,7 +693,8 @@ test "a row the due pass prunes is never asked about" {
const now = std.Io.Clock.real.now(io).toSeconds();
try clients_repo.upsertSeen(&database, "192.168.1.10", now - 40 * 86_400);
var tracker: Tracker = .init(30);
var days: retention.RetentionDays = .init(30);
var tracker: Tracker = .init(&days);
tracker.passes = Tracker.prune_every_passes - 1;
tracker.flushOnce(io, &database, true, &names);
@@ -681,7 +719,8 @@ test "a gated pass attempts no naming either" {
CountingExchange.reset(&database);
try clients_repo.upsertSeen(&database, "192.168.1.10", 1700000000);
var tracker: Tracker = .init(30);
var days: retention.RetentionDays = .init(30);
var tracker: Tracker = .init(&days);
tracker.flushOnce(io, &database, false, &names);
try testing.expectEqual(@as(usize, 0), CountingExchange.calls);
@@ -700,7 +739,8 @@ test "a failing materialise opens one episode per pass and a clean pass closes i
try fx.init(io, 1000);
defer fx.deinit();
var tracker: Tracker = .init(30);
var days: retention.RetentionDays = .init(30);
var tracker: Tracker = .init(&days);
tracker.diagnostics = &fx.store;
try database.exec(
@@ -742,7 +782,8 @@ test "a failing prune opens its own episode the next due pass closes" {
try fx.init(io, 1000);
defer fx.deinit();
var tracker: Tracker = .init(30);
var days: retention.RetentionDays = .init(30);
var tracker: Tracker = .init(&days);
tracker.diagnostics = &fx.store;
// One pass short of due, so the pass below is the pruning one.
tracker.passes = Tracker.prune_every_passes - 1;
+5 -3
View File
@@ -36,6 +36,7 @@ const listener = @import("listener.zig");
const model = @import("../config/model.zig");
const tls_server = @import("../platform/tls_server.zig");
const transport = @import("../upstream/transport.zig");
const upstream_owner = @import("../upstream/owner.zig");
pub const dns_query_path = "/dns-query";
@@ -619,6 +620,7 @@ const Harness = struct {
store: cert_store.CertStore,
tables: local_tables_mod.LocalTables,
upstream: FailingUpstream,
upstream_owner: upstream_owner.Borrowed,
h: handler.Handler,
server: DohServer,
group: std.Io.Group,
@@ -646,10 +648,10 @@ const Harness = struct {
errdefer hx.tables.deinit(testing.allocator);
hx.upstream = .{};
hx.upstream_owner = .{};
hx.h = .{
.upstream = hx.upstream.client(),
.blocking = test_blocking,
.forward_read_timeout = test_forward_timeout,
.upstream = hx.upstream_owner.client(hx.upstream.client()),
.policy = .{ .blocking = test_blocking, .forward_read_timeout = test_forward_timeout },
.local_tables = &hx.tables,
};
+103 -9
View File
@@ -28,6 +28,7 @@ const handler = @import("handler.zig");
const listener = @import("listener.zig");
const tls_server = @import("../platform/tls_server.zig");
const transport = @import("../upstream/transport.zig");
const upstream_owner = @import("../upstream/owner.zig");
/// Plaintext staging for `ServerStream`: the framing bytes and the decrypted
/// record tail pass through here, while whole messages go straight to
@@ -312,11 +313,10 @@ const forward_timeout: std.Io.Clock.Duration = .{
/// 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 {
fn bareHandler(up: *upstream_owner.Owner) handler.Handler {
return .{
.upstream = client,
.blocking = blocking,
.forward_read_timeout = forward_timeout,
.upstream = up,
.policy = .{ .blocking = blocking, .forward_read_timeout = forward_timeout },
};
}
@@ -567,7 +567,8 @@ test "dot: two framed queries share one TLS connection" {
defer env.deinit(io);
var fake: FakeUpstream = .{ .reply = response_bytes };
var h = bareHandler(fake.client());
var h_owner: upstream_owner.Borrowed = .{};
var h = bareHandler(h_owner.client(fake.client()));
const listen_address: std.Io.net.IpAddress = try .parse("127.0.0.1", 0);
var server = try DotServer.listen(gpa, io, listen_address, &h, &env.store, .{ .max_connections = 2 });
@@ -607,7 +608,8 @@ test "dot: a transport EOF without close_notify is a connection error, not a cra
defer env.deinit(io);
var fake: FakeUpstream = .{ .reply = response_bytes };
var h = bareHandler(fake.client());
var h_owner: upstream_owner.Borrowed = .{};
var h = bareHandler(h_owner.client(fake.client()));
const listen_address: std.Io.net.IpAddress = try .parse("127.0.0.1", 0);
var server = try DotServer.listen(gpa, io, listen_address, &h, &env.store, .{ .max_connections = 2 });
@@ -645,7 +647,8 @@ test "dot: plain TCP bytes fail the handshake and are counted" {
defer env.deinit(io);
var fake: FakeUpstream = .{ .reply = response_bytes };
var h = bareHandler(fake.client());
var h_owner: upstream_owner.Borrowed = .{};
var h = bareHandler(h_owner.client(fake.client()));
const listen_address: std.Io.net.IpAddress = try .parse("127.0.0.1", 0);
var server = try DotServer.listen(gpa, io, listen_address, &h, &env.store, .{ .max_connections = 2 });
@@ -731,7 +734,8 @@ test "dot: a reload serves new handshakes without breaking the old connection" {
defer env.deinit(io);
var fake: FakeUpstream = .{ .reply = response_bytes };
var h = bareHandler(fake.client());
var h_owner: upstream_owner.Borrowed = .{};
var h = bareHandler(h_owner.client(fake.client()));
const listen_address: std.Io.net.IpAddress = try .parse("127.0.0.1", 0);
var server = try DotServer.listen(gpa, io, listen_address, &h, &env.store, .{ .max_connections = 2 });
@@ -759,6 +763,95 @@ test "dot: a reload serves new handshakes without breaking the old connection" {
};
}
/// S3.4 across a live listener: a cert PATH change — new files, not rewritten
/// ones — serves the new certificate on the next handshake while the
/// connection pinned to the old generation finishes on it.
fn dotPathChangeServesNewCert(io: std.Io, address_: std.Io.net.IpAddress, env: *CertEnv) anyerror!void {
var first: TestTls = undefined;
try first.connect(io, address_);
defer first.close(io);
try first.sendQuery();
try expectAnswersQuery(try first.readReply());
const old_entry = env.store.acquire(io);
defer env.store.release(io, old_entry);
try env.tmp.dir.writeFile(io, .{ .sub_path = "cert2.pem", .data = fixtures.cert2_pem });
try env.tmp.dir.writeFile(io, .{ .sub_path = "key2.pem", .data = fixtures.key2_pem });
var cert_buf: [128]u8 = undefined;
var key_buf: [128]u8 = undefined;
const next_cert = try std.fmt.bufPrint(&cert_buf, ".zig-cache/tmp/{s}/cert2.pem", .{env.tmp.sub_path});
const next_key = try std.fmt.bufPrint(&key_buf, ".zig-cache/tmp/{s}/key2.pem", .{env.tmp.sub_path});
const prepared = try env.store.preparePathChange(io, next_cert, next_key);
env.store.publishPathChange(io, prepared);
const new_entry = env.store.acquire(io);
defer env.store.release(io, new_entry);
try testing.expect(old_entry != new_entry);
var second: TestTls = undefined;
try second.connect(io, address_);
defer second.close(io);
try second.sendQuery();
try expectAnswersQuery(try second.readReply());
try first.sendQuery();
try expectAnswersQuery(try first.readReply());
try second.client.end();
try second.net_writer.interface.flush();
var second_tail: [1]u8 = undefined;
try testing.expectError(error.EndOfStream, second.client.reader.readSliceAll(&second_tail));
try first.client.end();
try first.net_writer.interface.flush();
var first_tail: [1]u8 = undefined;
try testing.expectError(error.EndOfStream, first.client.reader.readSliceAll(&first_tail));
}
test "dot: a cert path change serves the new certificate on the next handshake" {
const build_options = @import("build_options");
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 env: CertEnv = undefined;
try env.init(io);
defer env.deinit(io);
var fake: FakeUpstream = .{ .reply = response_bytes };
var h_owner: upstream_owner.Borrowed = .{};
var h = bareHandler(h_owner.client(fake.client()));
const listen_address: std.Io.net.IpAddress = try .parse("127.0.0.1", 0);
var server = try DotServer.listen(gpa, io, listen_address, &h, &env.store, .{ .max_connections = 2 });
const server_address = server.boundAddress();
var group: std.Io.Group = .init;
try group.concurrent(io, DotServer.serve, .{ &server, io });
try bounded(io, dotPathChangeServesNewCert, .{ io, server_address, &env });
const stats = server.snapshotStats();
try testing.expectEqual(@as(u64, 2), stats.connections);
try testing.expectEqual(@as(u64, 0), stats.tls_handshake_failures);
try testing.expectEqual(@as(u64, 0), stats.connection_errors);
const store_stats = env.store.snapshotStats();
try testing.expectEqual(@as(u64, 1), store_stats.reloads);
try testing.expectEqual(@as(u64, 0), store_stats.reload_failures);
server.deinit(io);
group.await(io) catch |err| switch (err) {
error.Canceled => unreachable,
};
}
test "dot: an idle connection is closed with close_notify and counted" {
const build_options = @import("build_options");
if (!build_options.integration) return error.SkipZigTest;
@@ -773,7 +866,8 @@ test "dot: an idle connection is closed with close_notify and counted" {
defer env.deinit(io);
var fake: FakeUpstream = .{ .reply = response_bytes };
var h = bareHandler(fake.client());
var h_owner: upstream_owner.Borrowed = .{};
var h = bareHandler(h_owner.client(fake.client()));
const listen_address: std.Io.net.IpAddress = try .parse("127.0.0.1", 0);
var server = try DotServer.listen(gpa, io, listen_address, &h, &env.store, .{
+532 -142
View File
File diff suppressed because it is too large Load Diff
+43 -21
View File
@@ -32,6 +32,7 @@ const forward_zones = @import("../local/forward_zones.zig");
const handler = @import("handler.zig");
const header = @import("../dns/header.zig");
const local_tables = @import("local_tables.zig");
const logger_controller = @import("../storage/logger_controller.zig");
const logger_mod = @import("../storage/logger.zig");
const provenance = @import("../storage/provenance.zig");
const manager = @import("../filter/manager.zig");
@@ -47,8 +48,10 @@ 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 retention_mod = @import("../storage/retention.zig");
const shutdown = @import("shutdown.zig");
const transport = @import("../upstream/transport.zig");
const upstream_owner = @import("../upstream/owner.zig");
const types = @import("../dns/types.zig");
const udp_server = @import("udp_server.zig");
@@ -79,11 +82,10 @@ 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 {
fn baseHandler(up: *upstream_owner.Owner) handler.Handler {
return .{
.upstream = client,
.blocking = blocking,
.forward_read_timeout = forward_timeout,
.upstream = up,
.policy = .{ .blocking = blocking, .forward_read_timeout = forward_timeout },
};
}
@@ -265,6 +267,11 @@ fn fixtureManager(m: *manager.Manager, snapshot: *matcher.Snapshot) void {
.paths = undefined,
.fetcher = undefined,
.update = .{},
.schedule_mutex = .init,
.schedule_version = 0,
.schedule_anchor_s = null,
.schedule_event = .unset,
.schedule_clock = .real,
.total_budget = forward_timeout,
.lock = .init,
.writer_lock = .init,
@@ -317,10 +324,12 @@ test "S7 case 1: a blocked domain is answered with the zero address and logged"
var queue_buf: [log_queue_len]logger_mod.Entry = undefined;
var lg: logger_mod.Logger = .init(.{}, &queue_buf);
var sink: query_sink.QuerySink = .init(&lg, null);
var log_owner: logger_controller.Borrowed = .{};
var sink: query_sink.QuerySink = .init(log_owner.over(&lg), null);
var fake: FakeUpstream = .{ .reply = .a };
var h = baseHandler(fake.client());
var h_owner: upstream_owner.Borrowed = .{};
var h = baseHandler(h_owner.client(fake.client()));
h.manager = &mgr;
h.sink = &sink;
@@ -374,7 +383,8 @@ test "S7 case 2: an allow rule beats the blocklist and the upstream answers" {
fixtureManager(&mgr, &snapshot);
var fake: FakeUpstream = .{ .reply = .a };
var h = baseHandler(fake.client());
var h_owner: upstream_owner.Borrowed = .{};
var h = baseHandler(h_owner.client(fake.client()));
h.manager = &mgr;
var loop = try Loop.bind(gpa, io, &h);
@@ -411,7 +421,8 @@ test "S7 case 3: a local record answers authoritatively without an upstream" {
defer table.deinit(gpa);
var fake: FakeUpstream = .{ .reply = .a };
var h = baseHandler(fake.client());
var h_owner: upstream_owner.Borrowed = .{};
var h = baseHandler(h_owner.client(fake.client()));
var tables: local_tables.LocalTables = .{ .records = table };
h.local_tables = &tables;
@@ -479,12 +490,13 @@ test "S7 case 4: a forward zone reaches its resolver, bypasses the blocklist and
defer cache.deinit();
var fake: FakeUpstream = .{ .reply = .a };
var h = baseHandler(fake.client());
var h_owner: upstream_owner.Borrowed = .{};
var h = baseHandler(h_owner.client(fake.client()));
h.manager = &mgr;
var tables: local_tables.LocalTables = .{ .zones = zones };
h.local_tables = &tables;
h.cache = &cache;
h.negative_ttl_max = 3600;
h.policy.negative_ttl_max = 3600;
var loop = try Loop.bind(gpa, io, &h);
defer loop.stop(gpa, io);
@@ -533,12 +545,14 @@ test "S7 case 5: a cached answer comes back with a fresh id, an aged ttl and a l
var queue_buf: [log_queue_len]logger_mod.Entry = undefined;
var lg: logger_mod.Logger = .init(.{}, &queue_buf);
var sink: query_sink.QuerySink = .init(&lg, null);
var log_owner: logger_controller.Borrowed = .{};
var sink: query_sink.QuerySink = .init(log_owner.over(&lg), null);
var fake: FakeUpstream = .{ .reply = .a };
var h = baseHandler(fake.client());
var h_owner: upstream_owner.Borrowed = .{};
var h = baseHandler(h_owner.client(fake.client()));
h.cache = &cache;
h.negative_ttl_max = 3600;
h.policy.negative_ttl_max = 3600;
h.sink = &sink;
var loop = try Loop.bind(gpa, io, &h);
@@ -622,10 +636,12 @@ test "S7 case 6: a cname into a blocked target blocks the original question" {
var queue_buf: [log_queue_len]logger_mod.Entry = undefined;
var lg: logger_mod.Logger = .init(.{}, &queue_buf);
var sink: query_sink.QuerySink = .init(&lg, null);
var log_owner: logger_controller.Borrowed = .{};
var sink: query_sink.QuerySink = .init(log_owner.over(&lg), null);
var fake: FakeUpstream = .{ .reply = .{ .cname = "tracker.example.org" } };
var h = baseHandler(fake.client());
var h_owner: upstream_owner.Borrowed = .{};
var h = baseHandler(h_owner.client(fake.client()));
h.manager = &mgr;
h.sink = &sink;
@@ -681,7 +697,8 @@ test "S7 case 7: safe search answers the original question with a cname to the t
fixtureManager(&mgr, &snapshot);
var fake: FakeUpstream = .{ .reply = .a };
var h = baseHandler(fake.client());
var h_owner: upstream_owner.Borrowed = .{};
var h = baseHandler(h_owner.client(fake.client()));
h.manager = &mgr;
var loop = try Loop.bind(gpa, io, &h);
@@ -733,10 +750,12 @@ test "S7 case 8: the third query inside the window is refused" {
var queue_buf: [log_queue_len]logger_mod.Entry = undefined;
var lg: logger_mod.Logger = .init(.{}, &queue_buf);
var sink: query_sink.QuerySink = .init(&lg, null);
var log_owner: logger_controller.Borrowed = .{};
var sink: query_sink.QuerySink = .init(log_owner.over(&lg), null);
var fake: FakeUpstream = .{ .reply = .a };
var h = baseHandler(fake.client());
var h_owner: upstream_owner.Borrowed = .{};
var h = baseHandler(h_owner.client(fake.client()));
h.limiter = &limiter;
h.sink = &sink;
@@ -785,7 +804,8 @@ test "S7 case 9: pause lifts filtering and unpause restores it" {
paused.pauseFor(std.Io.Clock.real.now(io).toSeconds(), null);
var fake: FakeUpstream = .{ .reply = .a };
var h = baseHandler(fake.client());
var h_owner: upstream_owner.Borrowed = .{};
var h = baseHandler(h_owner.client(fake.client()));
h.manager = &mgr;
h.pause = &paused;
@@ -831,10 +851,12 @@ test "S7 case 10: the querying client is materialised as a row" {
try db.applyPragmas(&database, .{});
_ = try migrations.migrate(&database);
var tracker: clients.Tracker = .init(30);
var retention_days: retention_mod.RetentionDays = .init(30);
var tracker: clients.Tracker = .init(&retention_days);
var fake: FakeUpstream = .{ .reply = .a };
var h = baseHandler(fake.client());
var h_owner: upstream_owner.Borrowed = .{};
var h = baseHandler(h_owner.client(fake.client()));
h.tracker = &tracker;
var loop = try Loop.bind(gpa, io, &h);
+25 -9
View File
@@ -13,24 +13,36 @@
const std = @import("std");
const logger = @import("../storage/logger.zig");
const logger_controller = @import("../storage/logger_controller.zig");
const sse = @import("../web/sse.zig");
pub const QuerySink = struct {
logger: *logger.Logger,
/// The controller, not a `Logger`: `logging.query_log_buffer_max` can
/// change while the server runs, and the generation a producer enqueues
/// into has to be the one that is live at that moment.
controller: *logger_controller.Controller,
/// Null when `web.enabled` is false: nothing subscribes, so nothing needs
/// a hub, and the DNS path pays one null check.
hub: ?*sse.Hub,
pub fn init(query_logger: *logger.Logger, hub: ?*sse.Hub) QuerySink {
return .{ .logger = query_logger, .hub = hub };
pub fn init(controller: *logger_controller.Controller, hub: ?*sse.Hub) QuerySink {
return .{ .controller = controller, .hub = hub };
}
/// Transforms once, publishes, then enqueues. Never blocks the query path
/// and never fails: both consumers drop rather than wait.
///
/// The borrow spans both halves. A resize that lands between them would
/// otherwise leave this entry going into a queue retirement has already
/// closed, and that is exactly the drop window the controller exists to
/// make impossible.
pub fn log(self: *QuerySink, io: std.Io, entry: logger.Entry) void {
const transformed = self.logger.transformed(entry);
const generation = self.controller.acquire(io);
defer self.controller.release(io, generation);
const transformed = generation.logger.transformed(entry);
if (self.hub) |hub| hub.publish(io, transformed);
self.logger.logTransformed(io, transformed);
generation.logger.logTransformed(io, transformed);
}
};
@@ -60,7 +72,8 @@ test "the sink publishes and logs the same entry" {
var queue_buf: [4]logger.Entry = undefined;
var query_logger: logger.Logger = .init(.{}, &queue_buf);
var sink: QuerySink = .init(&query_logger, hub);
var owner: logger_controller.Borrowed = .{};
var sink: QuerySink = .init(owner.over(&query_logger), hub);
const id = hub.subscribe(io).?;
defer hub.unsubscribe(io, id);
@@ -87,7 +100,8 @@ test "fanout does not depend on the entry reaching the queue" {
var queue_buf: [4]logger.Entry = undefined;
var query_logger: logger.Logger = .init(.{}, &queue_buf);
var sink: QuerySink = .init(&query_logger, hub);
var owner: logger_controller.Borrowed = .{};
var sink: QuerySink = .init(owner.over(&query_logger), hub);
const id = hub.subscribe(io).?;
defer hub.unsubscribe(io, id);
@@ -115,7 +129,8 @@ test "the privacy transforms run once, before both consumers" {
.{ .hide_domains = true, .hide_client_ips = true },
&queue_buf,
);
var sink: QuerySink = .init(&query_logger, hub);
var owner: logger_controller.Borrowed = .{};
var sink: QuerySink = .init(owner.over(&query_logger), hub);
const id = hub.subscribe(io).?;
defer hub.unsubscribe(io, id);
@@ -138,7 +153,8 @@ test "a sink without a hub still logs" {
var queue_buf: [4]logger.Entry = undefined;
var query_logger: logger.Logger = .init(.{}, &queue_buf);
var sink: QuerySink = .init(&query_logger, null);
var owner: logger_controller.Borrowed = .{};
var sink: QuerySink = .init(owner.over(&query_logger), null);
sink.log(io, sampleEntry(5, "nohub.example"));
+7 -5
View File
@@ -26,6 +26,7 @@ const types = @import("../dns/types.zig");
const health = @import("../upstream/health.zig");
const pool = @import("../upstream/pool.zig");
const transport = @import("../upstream/transport.zig");
const upstream_owner = @import("../upstream/owner.zig");
const testing = std.testing;
@@ -42,11 +43,10 @@ const forward_timeout: std.Io.Clock.Duration = .{
/// 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 {
fn bareHandler(up: *upstream_owner.Owner) handler.Handler {
return .{
.upstream = client,
.blocking = blocking,
.forward_read_timeout = forward_timeout,
.upstream = up,
.policy = .{ .blocking = blocking, .forward_read_timeout = forward_timeout },
};
}
@@ -275,7 +275,9 @@ test "the whole resolver answers over udp and tcp and fails over to a healthy up
};
var upstreams: pool.Pool = .init(&entries, test_cfg, pool_timeouts, 1);
var h = bareHandler(upstreams.client());
var h_owner: upstream_owner.Borrowed = .{};
var h = bareHandler(h_owner.client(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 });
+16 -10
View File
@@ -21,6 +21,7 @@ const header = @import("../dns/header.zig");
const packet = @import("../dns/packet.zig");
const types = @import("../dns/types.zig");
const transport = @import("../upstream/transport.zig");
const upstream_owner = @import("../upstream/owner.zig");
const testing = std.testing;
@@ -37,11 +38,10 @@ const forward_timeout: std.Io.Clock.Duration = .{
/// 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 {
fn bareHandler(up: *upstream_owner.Owner) handler.Handler {
return .{
.upstream = client,
.blocking = blocking,
.forward_read_timeout = forward_timeout,
.upstream = up,
.policy = .{ .blocking = blocking, .forward_read_timeout = forward_timeout },
};
}
@@ -175,7 +175,8 @@ test "two length-prefixed queries share one connection" {
const io = threaded.io();
var fake: FakeUpstream = .{ .reply = response_bytes };
var h = bareHandler(fake.client());
var h_owner: upstream_owner.Borrowed = .{};
var h = bareHandler(h_owner.client(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 });
@@ -205,7 +206,8 @@ test "the claimed slot records the connecting client" {
const io = threaded.io();
var fake: FakeUpstream = .{ .reply = response_bytes };
var h = bareHandler(fake.client());
var h_owner: upstream_owner.Borrowed = .{};
var h = bareHandler(h_owner.client(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 });
@@ -282,7 +284,8 @@ test "a canceled serve does not wait for a live connection" {
const io = threaded.io();
var fake: FakeUpstream = .{ .reply = response_bytes };
var h = bareHandler(fake.client());
var h_owner: upstream_owner.Borrowed = .{};
var h = bareHandler(h_owner.client(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
@@ -344,7 +347,8 @@ test "an idle connection is closed and counted" {
const io = threaded.io();
var fake: FakeUpstream = .{ .reply = response_bytes };
var h = bareHandler(fake.client());
var h_owner: upstream_owner.Borrowed = .{};
var h = bareHandler(h_owner.client(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, .{
@@ -377,7 +381,8 @@ test "a zero-length message is a connection error" {
const io = threaded.io();
var fake: FakeUpstream = .{ .reply = response_bytes };
var h = bareHandler(fake.client());
var h_owner: upstream_owner.Borrowed = .{};
var h = bareHandler(h_owner.client(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, .{
@@ -429,7 +434,8 @@ test "deinit ends a serve loop that is blocked on accept" {
const io = threaded.io();
var fake: FakeUpstream = .{ .reply = response_bytes };
var h = bareHandler(fake.client());
var h_owner: upstream_owner.Borrowed = .{};
var h = bareHandler(h_owner.client(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 });
+12 -8
View File
@@ -20,6 +20,7 @@ const header = @import("../dns/header.zig");
const packet = @import("../dns/packet.zig");
const types = @import("../dns/types.zig");
const transport = @import("../upstream/transport.zig");
const upstream_owner = @import("../upstream/owner.zig");
const testing = std.testing;
@@ -36,11 +37,10 @@ const forward_timeout: std.Io.Clock.Duration = .{
/// 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 {
fn bareHandler(up: *upstream_owner.Owner) handler.Handler {
return .{
.upstream = client,
.blocking = blocking,
.forward_read_timeout = forward_timeout,
.upstream = up,
.policy = .{ .blocking = blocking, .forward_read_timeout = forward_timeout },
};
}
@@ -112,7 +112,8 @@ test "a udp query is answered on the loopback" {
const io = threaded.io();
var fake: FakeUpstream = .{ .reply = response_bytes };
var h = bareHandler(fake.client());
var h_owner: upstream_owner.Borrowed = .{};
var h = bareHandler(h_owner.client(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 });
@@ -150,7 +151,8 @@ test "a runt datagram is dropped and no reply is sent" {
const io = threaded.io();
var fake: FakeUpstream = .{ .reply = response_bytes };
var h = bareHandler(fake.client());
var h_owner: upstream_owner.Borrowed = .{};
var h = bareHandler(h_owner.client(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 });
@@ -185,7 +187,8 @@ test "an oversize datagram arrives truncated and is dropped" {
const io = threaded.io();
var fake: FakeUpstream = .{ .reply = response_bytes };
var h = bareHandler(fake.client());
var h_owner: upstream_owner.Borrowed = .{};
var h = bareHandler(h_owner.client(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 });
@@ -226,7 +229,8 @@ test "deinit ends a serve loop that is blocked on receive" {
const io = threaded.io();
var fake: FakeUpstream = .{ .reply = response_bytes };
var h = bareHandler(fake.client());
var h_owner: upstream_owner.Borrowed = .{};
var h = bareHandler(h_owner.client(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 });
+269 -9
View File
@@ -33,11 +33,65 @@ pub fn classify(free_bytes: u64, cfg: model.Disk) State {
return .ok;
}
/// `min_free_mb` and `warn_free_mb` are one invariant pair — `classify` reads
/// both and reports the more severe verdict — so they live in one atomic word
/// and a reader unpacks a single load. Two atomics would let a sample land
/// between the two stores and classify against half of one configuration and
/// half of another.
fn packThresholds(d: model.Disk) u64 {
return (@as(u64, d.min_free_mb) << 32) | d.warn_free_mb;
}
fn unpackThresholds(bits: u64) model.Disk {
return .{
.min_free_mb = @truncate(bits >> 32),
.warn_free_mb = @truncate(bits),
};
}
/// Where the measured log directory comes from. `sample` borrows the path
/// across a directory scan, so the path cannot simply be replaced under it: a
/// reader pins a generation for the whole borrow and `setLogDir` retires the
/// old one, which is freed by whichever of the two — the last reader or the
/// setter — finds it retired with no refs.
pub const LogDirSource = union(enum) {
/// The path `init` was given, borrowed from the config. It outlives the
/// process, so a reader still holding it after a swap is safe and it needs
/// no pin. Null means logs do not go to a file.
boot: ?[:0]const u8,
/// Every generation `setLogDir` installs. Null means the same as above.
installed: ?*LogDir,
};
/// A reader's hold on the log directory for the length of one scan. `pinned`
/// is null for the boot source, which nothing frees.
pub const LogDirBorrow = struct {
path: ?[:0]const u8,
pinned: ?*LogDir,
};
pub const LogDir = struct {
path: [:0]const u8,
refs: u32 = 0,
retired: bool = false,
/// Non-null exactly for heap generations, and the allocator that frees
/// them.
gpa: ?std.mem.Allocator = null,
fn destroy(self: *LogDir) void {
const gpa = self.gpa orelse return;
gpa.free(self.path);
gpa.destroy(self);
}
};
pub const Monitor = struct {
cfg: model.Disk,
thresholds_packed: std.atomic.Value(u64),
data_dir: std.Io.Dir,
data_path: [:0]const u8,
log_dir_path: ?[:0]const u8,
/// Guards `log_dir` and every generation's `refs`/`retired`.
log_dir_mutex: std.Io.Mutex,
log_dir: LogDirSource,
state_raw: std.atomic.Value(u8),
free_bytes: std.atomic.Value(u64),
@@ -57,10 +111,11 @@ pub const Monitor = struct {
log_dir_path: ?[:0]const u8,
) Monitor {
return .{
.cfg = cfg,
.thresholds_packed = .init(packThresholds(cfg)),
.data_dir = data_dir,
.data_path = data_path,
.log_dir_path = log_dir_path,
.log_dir_mutex = .init,
.log_dir = .{ .boot = log_dir_path },
.state_raw = .init(@intFromEnum(State.ok)),
.free_bytes = .init(0),
.db_bytes = .init(0),
@@ -69,6 +124,90 @@ pub const Monitor = struct {
};
}
/// The live threshold pair, from one load: `warn >= min` holds for every
/// value this ever returns, whatever a concurrent `setThresholds` does.
pub fn thresholds(self: *const Monitor) model.Disk {
return unpackThresholds(self.thresholds_packed.load(.monotonic));
}
pub fn setThresholds(self: *Monitor, d: model.Disk) void {
self.thresholds_packed.store(packThresholds(d), .monotonic);
}
/// Pins the log directory for one scan. Every borrow is matched by a
/// `releaseLogDir`, which is what lets `setLogDir` free a generation the
/// moment no scan is reading its path.
pub fn acquireLogDir(self: *Monitor, io: std.Io) LogDirBorrow {
self.log_dir_mutex.lockUncancelable(io);
defer self.log_dir_mutex.unlock(io);
switch (self.log_dir) {
.boot => |path| return .{ .path = path, .pinned = null },
.installed => |maybe| {
const gen = maybe orelse return .{ .path = null, .pinned = null };
gen.refs += 1;
return .{ .path = gen.path, .pinned = gen };
},
}
}
pub fn releaseLogDir(self: *Monitor, io: std.Io, borrow: LogDirBorrow) void {
const gen = borrow.pinned orelse return;
self.log_dir_mutex.lockUncancelable(io);
std.debug.assert(gen.refs > 0);
gen.refs -= 1;
const free_it = gen.retired and gen.refs == 0;
self.log_dir_mutex.unlock(io);
if (free_it) gen.destroy();
}
/// Prepare half of a log-directory change: allocates the owned path and
/// its generation node before any commit, so publish cannot fail. `path`
/// null means logs no longer go to a file and nothing is measured.
pub fn prepareLogDir(
gpa: std.mem.Allocator,
path: ?[]const u8,
) std.mem.Allocator.Error!?*LogDir {
const p = path orelse return null;
const owned = try gpa.dupeZ(u8, p);
errdefer gpa.free(owned);
const gen = try gpa.create(LogDir);
gen.* = .{ .path = owned, .gpa = gpa };
return gen;
}
/// Discards a generation `prepareLogDir` built that will not be published.
pub fn destroyPreparedLogDir(prepared: ?*LogDir) void {
if (prepared) |gen| gen.destroy();
}
/// Publish half: infallible and I/O-free. The old generation is retired
/// and freed here when no scan holds it, or by the last release otherwise.
pub fn setLogDir(self: *Monitor, io: std.Io, prepared: ?*LogDir) void {
self.log_dir_mutex.lockUncancelable(io);
const old: ?*LogDir = switch (self.log_dir) {
.boot => null,
.installed => |maybe| maybe,
};
self.log_dir = .{ .installed = prepared };
var free_old = false;
if (old) |gen| {
gen.retired = true;
free_old = gen.refs == 0;
}
self.log_dir_mutex.unlock(io);
if (free_old) old.?.destroy();
}
/// Frees any installed log-directory generation. Every scan must have
/// released first, which shutdown ordering guarantees.
pub fn deinit(self: *Monitor, io: std.Io) void {
self.setLogDir(io, null);
}
pub fn state(self: *const Monitor) State {
return @enumFromInt(self.state_raw.load(.monotonic));
}
@@ -109,7 +248,9 @@ pub const Monitor = struct {
probeFailed(store, io, now_s, "data_dir", "sizing the data directory failed", err);
}
if (self.log_dir_path) |path| {
const borrow = self.acquireLogDir(io);
defer self.releaseLogDir(io, borrow);
if (borrow.path) |path| {
if (self.sumLogDir(io, path)) |bytes| {
self.log_bytes.store(bytes, .monotonic);
if (store) |s| s.resolve(io, now_s, .disk_probe, "log_dir");
@@ -120,7 +261,7 @@ pub const Monitor = struct {
}
}
self.publish(io, store, now_s, classify(free, self.cfg), free);
self.publish(io, store, now_s, classify(free, self.thresholds()), free);
}
/// Sample first, then sleep: a process that starts on a full disk must not
@@ -466,7 +607,7 @@ test "a threshold above the real free space drives the state to critical" {
try testing.expectEqual(State.critical, monitor.state());
try testing.expect(!monitor.writesAllowed());
monitor.cfg = .{ .min_free_mb = 0, .warn_free_mb = 0 };
monitor.setThresholds(.{ .min_free_mb = 0, .warn_free_mb = 0 });
monitor.sample(io, null, 0);
try testing.expectEqual(State.ok, monitor.state());
try testing.expect(monitor.writesAllowed());
@@ -509,7 +650,7 @@ test "a disk transition records an episode per severity and closes it on recover
monitor.sample(io, &fx.store, 1060);
try testing.expectEqual(@as(i64, 1), try fx.count("SELECT count(*) FROM operational_events"));
monitor.cfg = .{ .min_free_mb = 0, .warn_free_mb = 0 };
monitor.setThresholds(.{ .min_free_mb = 0, .warn_free_mb = 0 });
monitor.sample(io, &fx.store, 1120);
try testing.expectEqual(State.ok, monitor.state());
try testing.expectEqual(
@@ -551,7 +692,9 @@ test "a failed probe opens an episode the next clean pass closes" {
try tmp.dir.createDirPath(io, "logs");
var path_buf: [256]u8 = undefined;
monitor.log_dir_path = try std.fmt.bufPrintZ(&path_buf, ".zig-cache/tmp/{s}/logs", .{tmp.sub_path});
const good_dir = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/logs", .{tmp.sub_path});
monitor.setLogDir(io, try Monitor.prepareLogDir(testing.allocator, good_dir));
defer monitor.deinit(io);
monitor.sample(io, &fx.store, 1100);
try testing.expectEqual(
@@ -560,6 +703,37 @@ test "a failed probe opens an episode the next clean pass closes" {
);
}
/// Alternates between two pairs that each satisfy `warn >= min`, so any
/// observed pair violating it can only have been torn out of two stores.
fn storeThresholdPairs(monitor: *Monitor, rounds: usize) void {
for (0..rounds) |i| {
monitor.setThresholds(if (i % 2 == 0)
.{ .min_free_mb = 1, .warn_free_mb = 2 }
else
.{ .min_free_mb = 3_000_000, .warn_free_mb = 4_000_000 });
}
}
test "a threshold reader never observes a pair from two different stores" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var monitor: Monitor = .init(.{ .min_free_mb = 1, .warn_free_mb = 2 }, std.Io.Dir.cwd(), ".", null);
const rounds = 20_000;
var writer = try io.concurrent(storeThresholdPairs, .{ &monitor, rounds });
// Recorded, not asserted, while the writer runs: an assertion that returned
// here would leave `Threaded.deinit` joining a task nothing ends.
var torn = false;
for (0..rounds) |_| {
const pair = monitor.thresholds();
if (pair.warn_free_mb < pair.min_free_mb) torn = true;
}
writer.await(io);
try testing.expect(!torn);
}
test "every emit site is inert when the store is absent" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
@@ -574,3 +748,89 @@ test "every emit site is inert when the store is absent" {
monitor.sample(io, null, 0);
try testing.expectEqual(@as(u64, 1), monitor.sample_failures.load(.monotonic));
}
// ---------------------------------------------------------------------------
// setLogDir (milestone-34 S3.5)
// ---------------------------------------------------------------------------
test "setLogDir re-points the measurement and frees the retired generation" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var tmp = testing.tmpDir(.{ .iterate = true });
defer tmp.cleanup();
try tmp.dir.createDirPath(io, "first");
try tmp.dir.createDirPath(io, "second");
try tmp.dir.writeFile(io, .{ .sub_path = "first/nxdns.log", .data = "aaaa" });
try tmp.dir.writeFile(io, .{ .sub_path = "second/nxdns.log", .data = "bbbbbbbb" });
var first_buf: [160]u8 = undefined;
var second_buf: [160]u8 = undefined;
const first = try std.fmt.bufPrint(&first_buf, ".zig-cache/tmp/{s}/first", .{tmp.sub_path});
const second = try std.fmt.bufPrint(&second_buf, ".zig-cache/tmp/{s}/second", .{tmp.sub_path});
var monitor: Monitor = .init(.{ .min_free_mb = 0, .warn_free_mb = 0 }, tmp.dir, ".", null);
defer monitor.deinit(io);
// Boot measures nothing.
monitor.sample(io, null, 1_000);
try testing.expectEqual(@as(u64, 0), monitor.gauges().log_bytes);
monitor.setLogDir(io, try Monitor.prepareLogDir(testing.allocator, first));
monitor.sample(io, null, 1_100);
try testing.expectEqual(@as(u64, 4), monitor.gauges().log_bytes);
// The retired generation is freed here; the testing allocator says so.
monitor.setLogDir(io, try Monitor.prepareLogDir(testing.allocator, second));
monitor.sample(io, null, 1_200);
try testing.expectEqual(@as(u64, 8), monitor.gauges().log_bytes);
// Output moved away from file: nothing is measured, and the gauge keeps
// its last reading rather than claiming zero bytes of logs.
monitor.setLogDir(io, null);
monitor.sample(io, null, 1_300);
try testing.expectEqual(@as(u64, 8), monitor.gauges().log_bytes);
}
test "a prepared log directory that is never published is freed by the caller" {
const prepared = try Monitor.prepareLogDir(testing.allocator, "/var/log/nxdns");
Monitor.destroyPreparedLogDir(prepared);
try testing.expectEqual(@as(?*LogDir, null), try Monitor.prepareLogDir(testing.allocator, null));
}
test "a sample borrowing a log directory survives a concurrent setLogDir" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var tmp = testing.tmpDir(.{ .iterate = true });
defer tmp.cleanup();
try tmp.dir.createDirPath(io, "logs");
try tmp.dir.writeFile(io, .{ .sub_path = "logs/nxdns.log", .data = "aaaa" });
var path_buf: [160]u8 = undefined;
const logs = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/logs", .{tmp.sub_path});
var monitor: Monitor = .init(.{ .min_free_mb = 0, .warn_free_mb = 0 }, tmp.dir, ".", null);
defer monitor.deinit(io);
monitor.setLogDir(io, try Monitor.prepareLogDir(testing.allocator, logs));
const Racer = struct {
fn sample(m: *Monitor, sio: std.Io) void {
for (0..200) |i| m.sample(sio, null, @intCast(1_000 + i));
}
fn repoint(m: *Monitor, sio: std.Io, p: []const u8) void {
for (0..200) |i| {
const prepared = Monitor.prepareLogDir(testing.allocator, if (i % 2 == 0) p else null) catch return;
m.setLogDir(sio, prepared);
}
}
};
var group: std.Io.Group = .init;
defer group.cancel(io);
try group.concurrent(io, Racer.sample, .{ &monitor, io });
try group.concurrent(io, Racer.repoint, .{ &monitor, io, logs });
try group.await(io);
}
+192 -6
View File
@@ -399,8 +399,23 @@ const discard_stall = if (builtin.is_test) struct {
}
};
/// The §11.4 privacy policy: one value, never two. A producer decides the
/// domain fields and the client field from ONE load of `privacy_packed`, so no
/// entry can leave `transformed` with the domain redacted and the client
/// exposed, or the reverse, because a `setPrivacy` landed between the two
/// decisions.
pub const Privacy = packed struct(u8) {
hide_domains: bool = false,
hide_client_ips: bool = false,
_reserved: u6 = 0,
};
pub const Logger = struct {
cfg: model.Logging,
/// Both privacy flags in one atomic byte, loaded once per entry.
privacy_packed: std.atomic.Value(u8),
/// Independent of the privacy policy: it governs when a batch commits, not
/// what a row contains, so nothing pairs the two.
flush_interval_s: std.atomic.Value(u16),
queue: EntryQueue,
queries_dropped: std.atomic.Value(u64),
/// When the newest drop happened, in unix seconds; 0 means none yet. Read
@@ -434,7 +449,11 @@ pub const Logger = struct {
/// touched it.
pub fn init(cfg: model.Logging, queue_buf: []Entry) Logger {
return .{
.cfg = cfg,
.privacy_packed = .init(@bitCast(Privacy{
.hide_domains = cfg.hide_domains,
.hide_client_ips = cfg.hide_client_ips,
})),
.flush_interval_s = .init(cfg.query_log_flush_interval_s),
.queue = .init(queue_buf),
.queries_dropped = .init(0),
.last_drop_s = .init(0),
@@ -464,8 +483,9 @@ pub const Logger = struct {
/// configuration labels the operator wrote, identical on every row that
/// hits them, and they say nothing about which name a client looked up.
pub fn transformed(self: *const Logger, entry: Entry) Entry {
const policy = self.privacy();
var out = entry;
if (self.cfg.hide_domains) {
if (policy.hide_domains) {
out.setDomain(hidden_marker);
// Only where there is something to hide: an empty field means the
// query had no such value, and writing a marker would claim it did.
@@ -473,10 +493,24 @@ pub const Logger = struct {
if (out.cname_len != 0) out.setCnameTarget(hidden_marker);
if (out.safe_search_len != 0) out.setSafeSearchTarget(hidden_marker);
}
if (self.cfg.hide_client_ips) out.setClientIp(hidden_marker);
if (policy.hide_client_ips) out.setClientIp(hidden_marker);
return out;
}
/// The live policy, from one load. Every producer decision about one entry
/// must come from a single call to this.
pub fn privacy(self: *const Logger) Privacy {
return @bitCast(self.privacy_packed.load(.monotonic));
}
pub fn setPrivacy(self: *Logger, p: Privacy) void {
self.privacy_packed.store(@bitCast(p), .monotonic);
}
pub fn setFlushInterval(self: *Logger, seconds: u16) void {
self.flush_interval_s.store(seconds, .monotonic);
}
/// `log` without the transforms, for a caller that already applied them.
pub fn logTransformed(self: *Logger, io: std.Io, entry: Entry) void {
self.enqueue(io, entry);
@@ -540,7 +574,22 @@ pub const Logger = struct {
return;
};
defer writer.deinit();
return self.runPrepared(io, &writer, monitor);
}
/// The writer loop over statements someone else prepared.
///
/// `runWriter` prepares and then calls this. A logger generation created by
/// a resize prepares separately, before anything is published, so that a
/// statement failure is refused at prepare time instead of silently killing
/// the writer of a queue producers are already filling
/// (`logger_controller.zig`).
pub fn runPrepared(
self: *Logger,
io: std.Io,
writer: *queries_repo.BatchWriter,
monitor: ?*disk_monitor.Monitor,
) std.Io.Cancelable!void {
var batch: [flush_batch]Entry = undefined;
while (true) {
// A closed queue hands over its buffered elements before it reports
@@ -564,7 +613,7 @@ pub const Logger = struct {
self.countDropped(io, n, at);
return err;
};
self.flush(io, &writer, batch[0..n], monitor) catch |err| switch (err) {
self.flush(io, writer, batch[0..n], monitor) catch |err| switch (err) {
error.Canceled => |e| {
self.countDropped(io, n, at);
return e;
@@ -606,7 +655,7 @@ pub const Logger = struct {
/// returns without waiting for anything.
fn flushDeadline(self: *const Logger, io: std.Io) std.Io.Clock.Timestamp {
return .fromNow(io, .{
.raw = .fromSeconds(self.cfg.query_log_flush_interval_s),
.raw = .fromSeconds(self.flush_interval_s.load(.monotonic)),
.clock = .boot,
});
}
@@ -644,6 +693,18 @@ pub const Logger = struct {
self.draining.store(true, .release);
}
/// `shutdown` without `draining`: this generation is being replaced, not
/// the process stopped.
///
/// The flag is what turns a gate-held batch into a counted loss
/// (`flush`'s `GatedAtShutdown`), and a retired writer must not take that
/// path — the disk can still recover, and the rows it is holding are still
/// going to be written when it does. Every producer of this generation
/// must have released it before the close, exactly as at shutdown.
pub fn retire(self: *Logger, io: std.Io) void {
self.queue.close(io);
}
/// Fills `batch` behind the entry already in slot 0, until it is full or
/// `deadline` passes. `n` counts the slots that hold an entry, and stays
/// accurate on the cancellation path so the caller can count what is lost.
@@ -1240,6 +1301,80 @@ test "log hides only the field its switch names" {
try testing.expectEqualStrings("192.0.2.10", untouched.clientIp());
}
/// The rendezvous that forces the flip to land BETWEEN two producer entries
/// rather than whenever the scheduler feels like it.
const PrivacyFlip = struct {
logger: *Logger,
/// Set by the producer once its pre-flip entry is enqueued.
before_done: std.Io.Event = .unset,
/// Set by the flipper once `setPrivacy` has returned.
flipped: std.Io.Event = .unset,
fn run(self: *PrivacyFlip, io: std.Io) void {
self.before_done.wait(io) catch return;
self.logger.setPrivacy(.{ .hide_domains = true, .hide_client_ips = true });
self.flipped.set(io);
}
};
test "a privacy flip redacts every entry after it and none before it" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var buf: [4]Entry = undefined;
var logger: Logger = .init(.{}, &buf);
var flip: PrivacyFlip = .{ .logger = &logger };
var future = try io.concurrent(PrivacyFlip.run, .{ &flip, io });
logger.log(io, sampleEntry(1, "before.example"));
flip.before_done.set(io);
try flip.flipped.wait(io);
logger.log(io, sampleEntry(2, "after.example"));
future.await(io);
const before = try logger.queue.getOne(io);
try testing.expectEqualStrings("before.example", before.domain());
try testing.expectEqualStrings("192.0.2.10", before.clientIp());
const after = try logger.queue.getOne(io);
try testing.expectEqualStrings(hidden_marker, after.domain());
try testing.expectEqualStrings(hidden_marker, after.clientIp());
}
fn flipPrivacyRepeatedly(logger: *Logger, rounds: usize) void {
for (0..rounds) |i| {
logger.setPrivacy(if (i % 2 == 0)
.{}
else
.{ .hide_domains = true, .hide_client_ips = true });
}
}
test "no producer observes a privacy policy that redacts one field and not the other" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var buf: [4]Entry = undefined;
var logger: Logger = .init(.{}, &buf);
const source = sampleEntry(1, "tracker.example");
const rounds = 20_000;
var flipper = try io.concurrent(flipPrivacyRepeatedly, .{ &logger, rounds });
// Recorded, not asserted, while the flipper runs: an assertion that
// returned here would leave `Threaded.deinit` joining a task nothing ends.
var mixed = false;
for (0..rounds) |_| {
const out = logger.transformed(source);
const domain_hidden = std.mem.eql(u8, out.domain(), hidden_marker);
const client_hidden = std.mem.eql(u8, out.clientIp(), hidden_marker);
if (domain_hidden != client_hidden) mixed = true;
}
flipper.await(io);
try testing.expect(!mixed);
}
test "the split halves reproduce log byte for byte" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
@@ -1507,6 +1642,57 @@ test "a full batch flushes without waiting for the interval" {
try testing.expectEqual(@as(i64, 150), try queries_repo.countRows(&database));
}
test "the writer's next cycle uses the interval set since its last one" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openLog();
defer database.close();
var buf: [8]Entry = undefined;
// Zero: every cycle commits what it has and goes straight back to `getOne`,
// so the first entry proves the writer is running and parked between cycles.
var logger: Logger = .init(.{ .query_log_flush_interval_s = 0 }, &buf);
var future = try io.concurrent(Logger.runWriter, .{
&logger,
io,
&database,
@as(?*disk_monitor.Monitor, null),
});
// See the note in "shutdown writes the batch the writer holds": this must
// run before the deferred `database.close`.
defer {
logger.shutdown(io);
future.await(io) catch {};
}
logger.log(io, sampleEntry(1, "first.example"));
const poll: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(5), .clock = .awake };
var waited: usize = 0;
while (logger.rows_written.load(.monotonic) == 0 and waited < 400) : (waited += 1) {
try poll.sleep(io);
}
// An hour, installed while the writer is parked: the cycle the next entry
// starts must wait it out instead of committing at once.
logger.setFlushInterval(3600);
logger.log(io, sampleEntry(2, "second.example"));
const quarter: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(250), .clock = .awake };
try quarter.sleep(io);
const written_under_the_new_interval = logger.rows_written.load(.monotonic);
logger.shutdown(io);
try future.await(io);
try testing.expect(waited < 400);
// The first entry, and only the first: the second is still held.
try testing.expectEqual(@as(u64, 1), written_under_the_new_interval);
// The close releases it, which is what makes the hold a hold and not a loss.
try testing.expectEqual(@as(i64, 2), try queries_repo.countRows(&database));
}
test "a gated flush holds the batch until the disk recovers" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -406,7 +406,8 @@ test "S8 case 5: a retention pass prunes the old rows and truncates the write-ah
try testing.expectEqual(@as(i64, 4), try queries_repo.countRows(log_db.database()));
try testing.expect(try f.sizeOf("querylog.db-wal") > 0);
var pass: retention.Retention = .init(.{ .retention_days = 30 });
var pass_days: retention.RetentionDays = .init(30);
var pass: retention.Retention = .init(&pass_days);
pass.runOnce(io, log_db.database(), null, null);
try testing.expectEqual(@as(u64, 1), pass.snapshotStats().passes);
@@ -473,7 +474,7 @@ test "S8 case 6: a critical disk gates the flushes and recovery releases them" {
try testing.expectEqual(@as(u64, 0), query_log.rows_written.load(.monotonic));
try testing.expectEqual(@as(i64, 0), try queries_repo.countRows(log_db.database()));
monitor.cfg = .{ .min_free_mb = 0, .warn_free_mb = 0 };
monitor.setThresholds(.{ .min_free_mb = 0, .warn_free_mb = 0 });
monitor.sample(io, null, 0);
try testing.expectEqual(disk_monitor.State.ok, monitor.state());
try testing.expect(monitor.writesAllowed());
+23
View File
@@ -199,6 +199,29 @@ pub fn open(io: std.Io, dir: std.Io.Dir, path: [:0]const u8) Error!OpenResult {
return result;
}
/// An additional connection to a `querylog.db` that `open` has already
/// established, with the pragmas every connection to the file needs.
///
/// The canonical opener for every background connection: the log writer, the
/// retention pass and the web task each own one (`retention.zig`'s contract),
/// and a logger generation opens one per writer for that writer's whole life
/// (`logger_controller.zig`) — two writers must never share a handle.
///
/// `dir` and `path` follow `open`'s resolution rule, and `dir` participates in
/// it the same way: the caller passes either an absolute path with `dir` open
/// on its parent, or `std.Io.Dir.cwd()` with a cwd-relative path. Nothing here
/// touches the directory itself — the file already exists by contract — so the
/// handle is present to make the pairing explicit at every call site rather
/// than to be dereferenced.
pub fn reopen(io: std.Io, dir: std.Io.Dir, path: [:0]const u8) db.Error!db.Db {
_ = io;
_ = dir;
var database = try db.Db.open(path, .{ .mode = .read_write_existing });
errdefer database.close();
try db.applyPragmas(&database, .{});
return database;
}
/// The whitelist. `null` means "propagate, do not touch the file".
fn recreatable(e: db.Error) ?RecreateReason {
return switch (e) {
+77 -17
View File
@@ -14,7 +14,6 @@ const std = @import("std");
const db = @import("db.zig");
const disk_monitor = @import("disk_monitor.zig");
const events = @import("events.zig");
const model = @import("../config/model.zig");
const queries_repo = @import("repositories/queries_repo.zig");
const log = std.log.scoped(.retention);
@@ -50,15 +49,42 @@ const Counters = struct {
vacuums_gated: std.atomic.Value(u64) = .init(0),
};
/// `logging.retention_days`, shared by its two consumers — this pass and
/// `server/clients.zig`'s stale-client prune. One cell rather than a copy in
/// each: the two must never prune to different cutoffs, and a settings apply
/// stores once. Owned by app-level state and outlives both readers.
///
/// `.monotonic` is enough: the value stands alone and orders nothing else, and
/// each consumer reads it once per pass.
pub const RetentionDays = struct {
value: std.atomic.Value(u32),
pub fn init(days: u16) RetentionDays {
return .{ .value = .init(days) };
}
pub fn get(self: *const RetentionDays) u32 {
return self.value.load(.monotonic);
}
pub fn setRetentionDays(self: *RetentionDays, days: u16) void {
self.value.store(days, .monotonic);
}
pub fn seconds(self: *const RetentionDays) i64 {
return @as(i64, self.get()) * std.time.s_per_day;
}
};
pub const Retention = struct {
cfg: model.Logging,
days: *const RetentionDays,
counters: Counters,
/// Passes since the last vacuum that succeeded. Plain rather than atomic:
/// only the retention task reads or writes it, and no consumer reports it.
passes_since_vacuum: u32,
pub fn init(cfg: model.Logging) Retention {
return .{ .cfg = cfg, .counters = .{}, .passes_since_vacuum = 0 };
pub fn init(days: *const RetentionDays) Retention {
return .{ .days = days, .counters = .{}, .passes_since_vacuum = 0 };
}
/// The counters, read one at a time. A scrape that lands mid-pass can see a
@@ -100,7 +126,7 @@ pub const Retention = struct {
) void {
add(&self.counters.passes, 1);
const now = std.Io.Clock.real.now(io).toSeconds();
const cutoff = now - model.retentionSeconds(self.cfg);
const cutoff = now - self.days.seconds();
// Diagnostics retention rides this pass rather than a schedule of its
// own: one daily housekeeping task, and a box restarted every night
@@ -268,7 +294,8 @@ test "a pass prunes the rows past the retention window and keeps the rest" {
const day = 86_400;
try writeRows(&database, &.{ now - 40 * day, now - 31 * day, now - 29 * day, now - 60 });
var retention: Retention = .init(.{ .retention_days = 30 });
var retention_days: RetentionDays = .init(30);
var retention: Retention = .init(&retention_days);
retention.runOnce(io, &database, null, null);
try testing.expectEqual(@as(i64, 2), try queries_repo.countRows(&database));
@@ -292,17 +319,41 @@ test "the cutoff follows retention_days" {
// window of the other.
try writeRows(&database, &.{now - 3 * day});
var keeps: Retention = .init(.{ .retention_days = 7 });
var keeps_days: RetentionDays = .init(7);
var keeps: Retention = .init(&keeps_days);
keeps.runOnce(io, &database, null, null);
try testing.expectEqual(@as(i64, 1), try queries_repo.countRows(&database));
try testing.expectEqual(@as(u64, 0), keeps.snapshotStats().rows_pruned);
var prunes: Retention = .init(.{ .retention_days = 1 });
var prunes_days: RetentionDays = .init(1);
var prunes: Retention = .init(&prunes_days);
prunes.runOnce(io, &database, null, null);
try testing.expectEqual(@as(i64, 0), try queries_repo.countRows(&database));
try testing.expectEqual(@as(u64, 1), prunes.snapshotStats().rows_pruned);
}
test "setRetentionDays changes the cutoff the next pass prunes by" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openLog();
defer database.close();
const now = std.Io.Clock.real.now(io).toSeconds();
try writeRows(&database, &.{now - 3 * 86_400});
var days: RetentionDays = .init(7);
var pass: Retention = .init(&days);
pass.runOnce(io, &database, null, null);
try testing.expectEqual(@as(i64, 1), try queries_repo.countRows(&database));
days.setRetentionDays(1);
pass.runOnce(io, &database, null, null);
try testing.expectEqual(@as(i64, 0), try queries_repo.countRows(&database));
try testing.expectEqual(@as(u64, 1), pass.snapshotStats().rows_pruned);
}
test "the seventh pass vacuums and the six before it do not" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
@@ -311,7 +362,8 @@ test "the seventh pass vacuums and the six before it do not" {
var database = try openLog();
defer database.close();
var retention: Retention = .init(.{});
var retention_days: RetentionDays = .init(30);
var retention: Retention = .init(&retention_days);
for (0..6) |_| {
retention.runOnce(io, &database, null, null);
try testing.expectEqual(@as(u64, 0), retention.snapshotStats().vacuums);
@@ -340,7 +392,8 @@ test "a gated pass skips the vacuum, counts it, and vacuums on the next pass" {
var monitor: disk_monitor.Monitor = .init(.{}, std.Io.Dir.cwd(), ".", null);
monitor.state_raw.store(@intFromEnum(disk_monitor.State.critical), .monotonic);
var gated: Retention = .init(.{});
var gated_days: RetentionDays = .init(30);
var gated: Retention = .init(&gated_days);
for (0..vacuum_every_passes) |_| gated.runOnce(io, &database, &monitor, null);
// Prune and checkpoint ran on every pass; only the vacuum was refused.
@@ -371,7 +424,8 @@ test "a warn state still allows the vacuum" {
var monitor: disk_monitor.Monitor = .init(.{}, std.Io.Dir.cwd(), ".", null);
monitor.state_raw.store(@intFromEnum(disk_monitor.State.warn), .monotonic);
var retention: Retention = .init(.{});
var retention_days: RetentionDays = .init(30);
var retention: Retention = .init(&retention_days);
for (0..vacuum_every_passes) |_| retention.runOnce(io, &database, &monitor, null);
try testing.expectEqual(@as(u64, 1), retention.snapshotStats().vacuums);
@@ -386,7 +440,8 @@ test "a pass over an empty database still counts" {
var database = try openLog();
defer database.close();
var retention: Retention = .init(.{});
var retention_days: RetentionDays = .init(30);
var retention: Retention = .init(&retention_days);
retention.runOnce(io, &database, null, null);
try testing.expectEqual(@as(u64, 1), retention.snapshotStats().passes);
@@ -410,7 +465,8 @@ test "a failing prune counts the pass and leaves the rows alone" {
\\BEGIN SELECT RAISE(ABORT, 'refused'); END;
);
var retention: Retention = .init(.{ .retention_days = 30 });
var retention_days: RetentionDays = .init(30);
var retention: Retention = .init(&retention_days);
retention.runOnce(io, &database, null, null);
try testing.expectEqual(@as(i64, 1), try queries_repo.countRows(&database));
@@ -435,7 +491,8 @@ test "the next pass retries what the failed one could not do" {
\\BEGIN SELECT RAISE(ABORT, 'refused'); END;
);
var retention: Retention = .init(.{ .retention_days = 30 });
var retention_days: RetentionDays = .init(30);
var retention: Retention = .init(&retention_days);
retention.runOnce(io, &database, null, null);
try testing.expectEqual(@as(i64, 2), try queries_repo.countRows(&database));
@@ -466,7 +523,8 @@ test "a failing prune opens a maintenance episode the next clean pass closes" {
\\BEGIN SELECT RAISE(ABORT, 'refused'); END;
);
var retention: Retention = .init(.{});
var retention_days: RetentionDays = .init(30);
var retention: Retention = .init(&retention_days);
retention.runOnce(io, &database, null, &fx.store);
// Only the prune failed; the checkpoint succeeded, and a success writes no
@@ -501,7 +559,8 @@ test "a gated vacuum is a maintenance failure the next ungated pass closes" {
var monitor: disk_monitor.Monitor = .init(.{}, std.Io.Dir.cwd(), ".", null);
monitor.state_raw.store(@intFromEnum(disk_monitor.State.critical), .monotonic);
var retention: Retention = .init(.{});
var retention_days: RetentionDays = .init(30);
var retention: Retention = .init(&retention_days);
for (0..vacuum_every_passes) |_| retention.runOnce(io, &database, &monitor, &fx.store);
try testing.expectEqualStrings("vacuum", try fx.text(
@@ -536,7 +595,8 @@ test "a pass prunes the diagnostics store once" {
fx.store.reportResolved(io, stale, .query_log_recreated, "one-shot", "one-shot", .warning, "aside kept");
try testing.expectEqual(@as(i64, 1), try fx.count("SELECT count(*) FROM operational_events"));
var retention: Retention = .init(.{});
var retention_days: RetentionDays = .init(30);
var retention: Retention = .init(&retention_days);
retention.runOnce(io, &database, null, &fx.store);
try testing.expectEqual(@as(i64, 0), try fx.count("SELECT count(*) FROM operational_events"));
+3
View File
@@ -22,6 +22,7 @@ comptime {
_ = @import("upstream/doh_client.zig");
_ = @import("upstream/doh_client_live_test.zig");
_ = @import("upstream/pool.zig");
_ = @import("upstream/owner.zig");
_ = @import("upstream/dot_client.zig");
_ = @import("upstream/dot_client_live_test.zig");
_ = @import("upstream/dot_client_integration_test.zig");
@@ -82,6 +83,7 @@ comptime {
_ = @import("storage/events.zig");
_ = @import("storage/events_fixture.zig");
_ = @import("storage/logger.zig");
_ = @import("storage/logger_controller.zig");
_ = @import("platform/statfs.zig");
_ = @import("storage/disk_monitor.zig");
_ = @import("platform/logging.zig");
@@ -110,6 +112,7 @@ comptime {
_ = @import("web/handlers/lookup.zig");
_ = @import("web/handlers/health.zig");
_ = @import("web/handlers/version.zig");
_ = @import("web/handlers/apply.zig");
_ = @import("web/handlers/mutations.zig");
_ = @import("web/handlers/groups.zig");
_ = @import("web/handlers/blocklists.zig");
+926
View File
@@ -0,0 +1,926 @@
//! The upstream generation and the owner that publishes it.
//!
//! A `Generation` is everything one upstream configuration needs to answer a
//! query: the parsed endpoints, the leaf clients behind them, the pool that
//! chooses between them, and — the reason it is a generation rather than a
//! plain struct — every configuration string it borrows, copied into an arena
//! it owns. `transport.Endpoint` borrows the URL text (transport.zig:51), so a
//! generation built from database rows that live in a request arena must not
//! outlive that arena unless it took its own copy. It takes its own copy.
//!
//! `Owner` publishes one generation at a time under the `CertStore` discipline
//! (cert_store.zig:199/:237): a mutex held briefly around a refcounted borrow,
//! a swap that marks the old generation retired, and a last release that tears
//! it down. A query acquires for the length of one exchange and copies whatever
//! it needs out of the generation before releasing, so a `replace` landing
//! mid-query never frees anything the query still reads.
const std = @import("std");
const Allocator = std.mem.Allocator;
const Certificate = std.crypto.Certificate;
const tls = std.crypto.tls;
const doh_client = @import("doh_client.zig");
const dot_client = @import("dot_client.zig");
const events = @import("../storage/events.zig");
const health = @import("health.zig");
const model = @import("../config/model.zig");
const pool_mod = @import("pool.zig");
const safe_url = @import("../safe_url.zig");
const transport = @import("transport.zig");
const log = std.log.scoped(.nxdns);
const doh_request_buf_len = doh_client.default_request_buf_len;
const doh_transfer_buf_len = doh_client.default_transfer_buf_len;
/// The `configuration.load` findings of one `build`, without the side effects.
///
/// `build` writes no event rows: at boot the caller replays this report through
/// its own `ConfigLoad.note` so the diagnostics are exactly what they were when
/// the composition lived in `app.zig`, and at runtime a candidate that is never
/// published must leave no trace at all. The strings are owned by the
/// generation's arena, so the report is readable for as long as the generation
/// is.
pub const BuildReport = struct {
notes: []const Note = &.{},
/// `url` is the subject key an upstream finding is filed under: the whole
/// URL is the identity, and redaction is the caller's job because the
/// caller is what renders it.
pub const Note = struct {
url: []const u8,
message: []const u8,
};
};
/// One finding rendered for the event store: the redacted label and the detail
/// line. Both borrow `Rendered`'s own buffers, so it must outlive the call that
/// writes the row.
pub const Rendered = struct {
label_buf: [events.Store.max_subject_label_len]u8 = undefined,
detail_buf: [events.Store.max_detail_len]u8 = undefined,
label: []const u8 = "",
detail: []const u8 = "",
/// An upstream's identity is its url: the whole url is the key, and the
/// redaction is the label, because a url can carry an account token.
pub fn render(self: *Rendered, finding: BuildReport.Note) void {
self.label = std.fmt.bufPrint(&self.label_buf, "{f}", .{
safe_url.redact(finding.url),
}) catch &self.label_buf;
self.detail = std.fmt.bufPrint(&self.detail_buf, "upstream {f} {s}", .{
safe_url.redactQuoted(finding.url),
finding.message,
}) catch &self.detail_buf;
}
};
/// Reconciles the `configuration.load` episodes of an upstream replace, on the
/// task that published it and after the publish.
///
/// SCOPED, never `Store.resolveExcept`: that call resolves every active
/// `configuration.load` episode outside its kept set, which at runtime would
/// falsely close boot warnings about settings this replace never touched. Only
/// the keys the previous generation reported and the new one does not are
/// resolved, one at a time.
///
/// `previous_keys` is the copy taken at prepare, so nothing here depends on the
/// retired generation still being alive.
pub fn reconcileReport(
store: *events.Store,
io: std.Io,
now_s: i64,
new_notes: []const BuildReport.Note,
previous_keys: []const []const u8,
) void {
var rendered: Rendered = .{};
for (new_notes) |finding| {
rendered.render(finding);
store.report(
io,
now_s,
.configuration_load,
finding.url,
rendered.label,
.warning,
rendered.detail,
);
}
var previous_buf: [events.Store.max_subject_key_len]u8 = undefined;
var current_buf: [events.Store.max_subject_key_len]u8 = undefined;
outer: for (previous_keys) |key| {
const previous = events.canonicalKey(key, &previous_buf);
for (new_notes) |finding| {
if (std.mem.eql(u8, previous, events.canonicalKey(finding.url, &current_buf))) continue :outer;
}
store.resolve(io, now_s, .configuration_load, key);
}
}
/// Everything a generation built from configuration rows owns. Absent when the
/// caller supplied the `transport.Client` directly — a test seam, and the shape
/// `nxdns check` would want for a single probe.
const Built = struct {
gpa: Allocator,
/// Every configuration string the generation borrows: the URL text each
/// `Endpoint` slices its host and path out of, and each DoT `tls_name`.
arena: std.heap.ArenaAllocator,
upstreams: Upstreams,
pool: pool_mod.Pool,
report: BuildReport,
};
pub const Generation = struct {
/// The exchange entry point. `built.pool.client()` for a configured
/// generation, the caller's client otherwise.
client: transport.Client,
/// The pool behind `client`, for the health and metrics reads. Null when
/// the client is not a pool.
pool: ?*pool_mod.Pool = null,
built: ?Built = null,
/// Set when `retire` must free the generation's own storage. Null when the
/// caller owns it — a generation on a test's stack.
destroy_with: ?Allocator = null,
/// Guarded by the owner's mutex, never touched outside it.
refs: usize = 0,
retired: bool = false,
/// A generation over a client the caller owns and keeps alive. Owns
/// nothing, so `retire` only invalidates it.
pub fn borrowing(client: transport.Client) Generation {
return .{ .client = client };
}
/// A generation over a pool the caller owns and keeps alive. The metrics
/// and health paths read the pool through this.
pub fn borrowingPool(pool: *pool_mod.Pool) Generation {
return .{ .client = pool.client(), .pool = pool };
}
pub fn report(self: *const Generation) BuildReport {
const built = self.built orelse return .{};
return built.report;
}
/// The active entries, for a caller that wants the count rather than the
/// health of each.
pub fn activeCount(self: *Generation) usize {
const built = &(self.built orelse return 0);
return built.upstreams.used;
}
/// Connections first, memory second, storage last. Only ever called with
/// `refs == 0`: by `Owner.release` when the last reader of a retired
/// generation leaves, by the caller `Owner.replace` handed an idle
/// generation back to, or by `Owner.deinit` at shutdown.
pub fn retire(self: *Generation, io: std.Io) void {
if (self.built) |*built| {
built.upstreams.deinit(io, built.gpa);
built.arena.deinit();
}
const destroy_with = self.destroy_with;
self.* = undefined;
if (destroy_with) |gpa| gpa.destroy(self);
}
};
pub const BuildError = Allocator.Error || error{NoUsableUpstreams};
pub const BuildOptions = struct {
gpa: Allocator,
io: std.Io,
/// Borrowed for the length of the call only: every string this generation
/// keeps is copied into its arena before `build` returns.
servers: []const model.UpstreamServer,
http: *std.http.Client,
bundle: *Certificate.Bundle,
bundle_lock: *std.Io.RwLock,
timeouts: pool_mod.Timeouts,
health_config: health.Config = .{},
seed: u64,
diagnostics: ?*events.Store = null,
};
/// Builds one heap-stable generation from a row set. Side-effect free: it
/// writes no event rows and publishes nothing. Every failure frees everything
/// it allocated.
pub fn build(opts: BuildOptions) BuildError!*Generation {
const gpa = opts.gpa;
const generation = try gpa.create(Generation);
errdefer gpa.destroy(generation);
var arena_state: std.heap.ArenaAllocator = .init(gpa);
errdefer arena_state.deinit();
const arena = arena_state.allocator();
// The rows this generation keeps, copied out of whatever memory the caller
// read them into. `Endpoint.parse` slices host and path out of the URL, so
// duplicating the URL covers all three.
const owned = try arena.alloc(model.UpstreamServer, opts.servers.len);
for (opts.servers, owned) |server, *copy| {
copy.* = .{
.url = try arena.dupe(u8, server.url),
.priority = server.priority,
.enabled = server.enabled,
.tls_name = try arena.dupe(u8, server.tls_name),
};
}
var notes: std.ArrayList(BuildReport.Note) = .empty;
var upstreams = try Upstreams.build(
opts.io,
gpa,
arena,
owned,
opts.http,
opts.bundle,
opts.bundle_lock,
&notes,
);
errdefer upstreams.deinit(opts.io, gpa);
generation.* = .{
.client = undefined,
.pool = null,
.destroy_with = gpa,
.built = .{
.gpa = gpa,
.arena = arena_state,
.upstreams = upstreams,
.pool = .init(
upstreams.active(),
opts.health_config,
opts.timeouts,
opts.seed,
),
.report = .{ .notes = try notes.toOwnedSlice(arena) },
},
};
const built = &generation.built.?;
built.pool.diagnostics = opts.diagnostics;
// Taken after the generation is in its final storage: the client is a
// pointer to the pool inside it.
generation.pool = &built.pool;
generation.client = built.pool.client();
return generation;
}
/// Publishes one generation at a time.
///
/// The initial generation is the caller's to provide and the owner's to tear
/// down: `deinit` retires whatever is live, and every generation a `replace`
/// displaces is retired by the owner or handed back to the caller idle.
pub const Owner = struct {
mutex: std.Io.Mutex = .init,
live: *Generation,
/// How many generations `replace` has published. One candidate per owner
/// means one increment per configuration write, however many keys of this
/// owner that write named. Guarded by `mutex`, like everything else here.
///
/// Not a `std.atomic.Value(u64)`: in Debug the x86_64 self-hosted backend
/// of zig 0.16.0 miscompiles `replace` when a `lock xadd` sits between the
/// `old.refs == 0` comparison and the branch on its result, and `replace`
/// then returns null for every input. See AGENTS.md.
published: u64 = 0,
pub fn init(live: *Generation) Owner {
return .{ .live = live };
}
/// Pins the live generation for one exchange or one scrape. The returned
/// generation stays valid until the matching `release`, across any number
/// of replaces.
pub fn acquire(self: *Owner, io: std.Io) *Generation {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
self.live.refs += 1;
return self.live;
}
/// The subject keys the live generation's report is filed under, copied
/// into `arena`.
///
/// Taken at prepare so that the retire-time reconciliation never depends on
/// the displaced generation still being alive: by then its last reader may
/// have freed it. The copy runs under the mutex because that is what pins
/// the generation whose arena the strings live in; the allocation is from a
/// bump arena and touches no `std.Io` primitive, so the hold stays as brief
/// as every other one this type takes.
pub fn copyLiveReportKeys(
self: *Owner,
io: std.Io,
arena: Allocator,
) Allocator.Error![]const []const u8 {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
const notes = self.live.report().notes;
const copies = try arena.alloc([]const u8, notes.len);
for (notes, copies) |finding, *slot| slot.* = try arena.dupe(u8, finding.url);
return copies;
}
pub fn release(self: *Owner, io: std.Io, generation: *Generation) void {
self.mutex.lockUncancelable(io);
std.debug.assert(generation.refs > 0);
generation.refs -= 1;
const retire_it = generation.retired and generation.refs == 0;
self.mutex.unlock(io);
if (retire_it) generation.retire(io);
}
/// Publishes `prepared` and retires the live generation. Infallible and
/// I/O-free by construction: a pointer swap under the mutex.
///
/// Returns the displaced generation when no reader held it, because there
/// is then no release left to retire it and the caller must — the same
/// `refs == 0` branch `CertStore.reload` takes. Returns null when a reader
/// still holds it; that reader's release retires it.
pub fn replace(self: *Owner, io: std.Io, prepared: *Generation) ?*Generation {
std.debug.assert(prepared.refs == 0);
std.debug.assert(!prepared.retired);
self.mutex.lockUncancelable(io);
const old = self.live;
self.live = prepared;
old.retired = true;
const idle = old.refs == 0;
self.published += 1;
self.mutex.unlock(io);
return if (idle) old else null;
}
/// How many replaces this owner has published.
pub fn publishedCount(self: *Owner, io: std.Io) u64 {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
return self.published;
}
/// Shutdown teardown. Every listener and every metrics reader must have
/// stopped: a live generation with readers left is a caller that tore down
/// out of order, and a retired one with readers was freed by its own last
/// release already.
pub fn deinit(self: *Owner, io: std.Io) void {
self.mutex.lockUncancelable(io);
const live = self.live;
std.debug.assert(live.refs == 0);
self.mutex.unlock(io);
live.retire(io);
self.* = undefined;
}
};
/// An owner over transport the caller already has: a fake client in a handler
/// test, or a pool a fixture built from fake entries. Owns nothing, so there is
/// nothing to tear down; both parts are inline so a caller keeps them on its
/// stack beside the handler.
pub const Borrowed = struct {
generation: Generation = undefined,
owner: Owner = undefined,
pub fn client(self: *Borrowed, c: transport.Client) *Owner {
self.generation = .borrowing(c);
self.owner = .init(&self.generation);
return &self.owner;
}
pub fn pool(self: *Borrowed, p: *pool_mod.Pool) *Owner {
self.generation = .borrowingPool(p);
self.owner = .init(&self.generation);
return &self.owner;
}
};
// ---------------------------------------------------------------------------
// the pool's entries and everything they point into
// ---------------------------------------------------------------------------
/// Every enabled upstream gets `pool_mod.slots_per_entry` leaf clients, one per
/// slot of its entry, so that many exchanges can be in flight against it at
/// once. `Slot.client` is a type-erased pointer into `doh` or `dot`, each of
/// those clients borrows a slice of `doh_buf`/`dot_buf`, and each entry borrows
/// a run of `slot_storage` and one counter of `recovery_counters` — so every
/// allocation here lives exactly as long as the generation does, and none of
/// them is ever resized. One slot is used by one task at a time, 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,
/// Sliced per entry into `Entry.slots`, never pointing into the client
/// arrays: `Pool.init` sorts entries and the slices have to survive it.
slot_storage: []pool_mod.Slot,
/// One per enabled upstream, and the reason it is a separate allocation:
/// `Pool.init` sorts entries by value, so a counter living inside an entry
/// would be pointed at by the wrong upstream's clients after the sort.
recovery_counters: []std.atomic.Value(u64),
doh: []doh_client.DohClient,
dot: []dot_client.DotClient,
/// How much of `doh`/`dot` was actually initialized. A malformed or skipped
/// upstream leaves the tail of an over-allocated array undefined, and both
/// `deinit` and `build`'s failure paths iterate only the initialized
/// prefix — reading a `DotClient` that was never built, or closing a
/// session that was never opened, is what these two counts prevent.
doh_used: usize,
dot_used: usize,
doh_buf: []u8,
dot_buf: []u8,
/// A disabled upstream is left out entirely; a malformed one is noted and
/// skipped, because one bad row in a table of four must not take DNS down.
/// No usable row at all is a configuration fault.
///
/// `arena` is the generation's: `notes` borrows from it and so does every
/// string in `servers`, which the caller has already copied there.
fn build(
io: std.Io,
gpa: Allocator,
arena: Allocator,
servers: []const model.UpstreamServer,
http: *std.http.Client,
bundle: *Certificate.Bundle,
bundle_lock: *std.Io.RwLock,
notes: *std.ArrayList(BuildReport.Note),
) BuildError!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;
const slots = pool_mod.slots_per_entry;
const leaf_clients = enabled * slots;
var self: Upstreams = .{
.entries = try gpa.alloc(pool_mod.Entry, enabled),
.used = 0,
.slot_storage = &.{},
.recovery_counters = &.{},
.doh = &.{},
.dot = &.{},
.doh_used = 0,
.dot_used = 0,
.doh_buf = &.{},
.dot_buf = &.{},
};
errdefer self.deinit(io, gpa);
self.slot_storage = try gpa.alloc(pool_mod.Slot, leaf_clients);
self.recovery_counters = try gpa.alloc(std.atomic.Value(u64), enabled);
for (self.recovery_counters) |*counter| counter.* = .init(0);
self.doh = try gpa.alloc(doh_client.DohClient, leaf_clients);
self.dot = try gpa.alloc(dot_client.DotClient, leaf_clients);
self.doh_buf = try gpa.alloc(u8, leaf_clients * (doh_request_buf_len + doh_transfer_buf_len));
self.dot_buf = try gpa.alloc(u8, leaf_clients * 4 * chunk);
for (servers) |server| {
if (!server.enabled) continue;
const endpoint = transport.Endpoint.parse(server.url) catch {
try note(arena, notes, server.url, "not an https:// or tls:// endpoint; skipped");
continue;
};
const entry_slots = self.slot_storage[self.used * slots ..][0..slots];
switch (endpoint.scheme) {
.doh => if (!self.wireDoh(http, endpoint, entry_slots)) {
try note(arena, notes, server.url, "not a usable DoH url; skipped");
continue;
},
.dot => self.wireDot(gpa, endpoint, server.tls_name, bundle, bundle_lock, entry_slots),
}
self.entries[self.used] = .{
.endpoint = endpoint,
.slots = entry_slots,
.priority = server.priority,
.enabled = true,
.health = .init,
.sem = .{ .permits = entry_slots.len },
.reuse_recoveries = &self.recovery_counters[self.used],
};
self.used += 1;
}
if (self.used == 0) return error.NoUsableUpstreams;
return self;
}
/// One `DohClient` per slot, all sharing the one `std.http.Client`: its
/// connection pool already serves concurrent requests, and a `DohClient`'s
/// only mutable state is the two buffers this gives each slot its own of.
///
/// False means the url is not a usable DoH url, which `DohClient.init`
/// decides from the url alone — so it fails on the first slot or on none.
/// `doh_used` still advances per client rather than per entry: it means
/// "initialized", and a skipped entry's clients are simply never reached.
fn wireDoh(
self: *Upstreams,
http: *std.http.Client,
endpoint: transport.Endpoint,
slots: []pool_mod.Slot,
) bool {
for (slots) |*slot| {
const index = self.doh_used;
const base = index * (doh_request_buf_len + doh_transfer_buf_len);
self.doh[index] = 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 return false;
self.doh_used = index + 1;
slot.* = .{ .client = self.doh[index].client() };
}
return true;
}
/// One `DotClient` per slot, each with its own four TLS buffers and all
/// sharing the trust store. Every client of one entry reports its
/// stale-reuse recoveries through that entry's counter.
fn wireDot(
self: *Upstreams,
gpa: Allocator,
endpoint: transport.Endpoint,
tls_name: []const u8,
bundle: *Certificate.Bundle,
bundle_lock: *std.Io.RwLock,
slots: []pool_mod.Slot,
) void {
const chunk = tls.Client.min_buffer_len;
const recoveries = &self.recovery_counters[self.used];
for (slots) |*slot| {
const index = self.dot_used;
const base = index * 4 * chunk;
self.dot[index] = dot_client.DotClient.init(
endpoint,
tls_name,
gpa,
bundle,
bundle_lock,
recoveries,
.{
.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],
},
);
self.dot_used = index + 1;
slot.* = .{ .client = self.dot[index].client() };
}
}
/// 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];
}
/// Connections first, memory second: a `DotClient` holds a socket its
/// buffers belong to, so nothing it points at may be freed before it is
/// closed.
fn deinit(self: *Upstreams, io: std.Io, gpa: Allocator) void {
for (self.dot[0..self.dot_used]) |*client| client.close(io);
gpa.free(self.dot_buf);
gpa.free(self.doh_buf);
gpa.free(self.dot);
gpa.free(self.doh);
gpa.free(self.recovery_counters);
gpa.free(self.slot_storage);
gpa.free(self.entries);
self.* = undefined;
}
};
/// The warning goes out here as well as into the report: `std.log` is the
/// operator's boot transcript and a runtime candidate that is refused is still
/// worth a line, while the report is what writes the event row — at boot, or in
/// retire once a candidate is published.
fn note(
arena: Allocator,
notes: *std.ArrayList(BuildReport.Note),
url: []const u8,
message: []const u8,
) Allocator.Error!void {
log.warn("upstream {f} {s}", .{ safe_url.redactQuoted(url), message });
// `url` already lives in the generation's arena; the message is a literal.
try notes.append(arena, .{ .url = url, .message = message });
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const testing = std.testing;
const TestIo = struct {
threaded: std.Io.Threaded,
fn init(gpa: Allocator) TestIo {
return .{ .threaded = .init(gpa, .{}) };
}
fn io(self: *TestIo) std.Io {
return self.threaded.io();
}
fn deinit(self: *TestIo) void {
self.threaded.deinit();
}
};
const test_timeouts: pool_mod.Timeouts = .{
.attempt = .{ .raw = .fromMilliseconds(50), .clock = .awake },
.total = .{ .raw = .fromMilliseconds(200), .clock = .awake },
};
/// A client that answers from a fixed reply and records the identity it named,
/// so a test can prove which generation served an exchange.
const FakeClient = struct {
identity: []const u8,
calls: std.atomic.Value(u64) = .init(0),
/// Set once the exchange is inside the client, so a test knows the
/// generation is really pinned before it swaps.
entered: ?*std.Io.Event = null,
/// Waited on before the exchange returns, so a test can hold an exchange
/// open across a `replace`.
gate: ?*std.Io.Event = null,
fn client(self: *FakeClient) transport.Client {
return .{ .ptr = self, .exchangeFn = exchangeFn };
}
fn exchangeFn(
ptr: *anyopaque,
io: std.Io,
query: []const u8,
response_buf: []u8,
selected: *?[]const u8,
) transport.ExchangeError![]u8 {
const self: *FakeClient = @ptrCast(@alignCast(ptr));
selected.* = self.identity;
_ = self.calls.fetchAdd(1, .monotonic);
if (self.entered) |entered| entered.set(io);
if (self.gate) |gate| gate.wait(io) catch return error.Canceled;
@memcpy(response_buf[0..query.len], query);
return response_buf[0..query.len];
}
};
fn buildTestGeneration(
io: std.Io,
gpa: Allocator,
http: *std.http.Client,
bundle: *Certificate.Bundle,
bundle_lock: *std.Io.RwLock,
servers: []const model.UpstreamServer,
) BuildError!*Generation {
return build(.{
.gpa = gpa,
.io = io,
.servers = servers,
.http = http,
.bundle = bundle,
.bundle_lock = bundle_lock,
.timeouts = test_timeouts,
.seed = 1,
});
}
test "an owner hands out the live generation and retires the old one on the last release" {
var t: TestIo = .init(testing.allocator);
defer t.deinit();
const io = t.io();
var first: FakeClient = .{ .identity = "fake://g1" };
var second: FakeClient = .{ .identity = "fake://g2" };
var g1: Generation = .borrowing(first.client());
var g2: Generation = .borrowing(second.client());
var owner: Owner = .init(&g1);
const held = owner.acquire(io);
try testing.expectEqual(&g1, held);
// A reader holds G1, so the swap cannot retire it here.
try testing.expectEqual(@as(?*Generation, null), owner.replace(io, &g2));
try testing.expect(g1.retired);
// A new acquire lands on G2 while the old reader is still on G1.
const fresh = owner.acquire(io);
try testing.expectEqual(&g2, fresh);
owner.release(io, fresh);
owner.release(io, held);
owner.deinit(io);
}
test "a replace with no reader holding the live generation retires it through the return path" {
var t: TestIo = .init(testing.allocator);
defer t.deinit();
const io = t.io();
var http: std.http.Client = .{ .allocator = testing.allocator, .io = io };
defer http.deinit();
var bundle: Certificate.Bundle = .empty;
defer bundle.deinit(testing.allocator);
var bundle_lock: std.Io.RwLock = .init;
const g1 = try buildTestGeneration(io, testing.allocator, &http, &bundle, &bundle_lock, &.{
.{ .url = "https://one.example/dns-query" },
});
const g2 = try buildTestGeneration(io, testing.allocator, &http, &bundle, &bundle_lock, &.{
.{ .url = "https://two.example/dns-query" },
});
var owner: Owner = .init(g1);
// Nobody holds G1: `replace` must hand it back, because no release will.
const displaced = owner.replace(io, g2) orelse return error.ExpectedIdleGeneration;
try testing.expectEqual(g1, displaced);
displaced.retire(io);
owner.deinit(io);
}
test "a generation owns its configuration strings after the rows they came from are freed" {
var t: TestIo = .init(testing.allocator);
defer t.deinit();
const io = t.io();
var http: std.http.Client = .{ .allocator = testing.allocator, .io = io };
defer http.deinit();
var bundle: Certificate.Bundle = .empty;
defer bundle.deinit(testing.allocator);
var bundle_lock: std.Io.RwLock = .init;
// The rows a request arena would hand `build`. Freeing the arena poisons
// every byte of them, so a generation that kept a borrow reads garbage.
var rows_arena: std.heap.ArenaAllocator = .init(testing.allocator);
const rows = rows_arena.allocator();
const servers = try rows.dupe(model.UpstreamServer, &.{
.{ .url = try rows.dupe(u8, "tls://dot.example.net:853"), .tls_name = try rows.dupe(u8, "dot.example.net") },
});
const generation = try buildTestGeneration(io, testing.allocator, &http, &bundle, &bundle_lock, servers);
rows_arena.deinit();
var owner: Owner = .init(generation);
defer owner.deinit(io);
const held = owner.acquire(io);
defer owner.release(io, held);
var snapshots: [4]pool_mod.Snapshot = undefined;
const count = try held.pool.?.snapshot(io, &snapshots);
try testing.expectEqual(@as(usize, 1), count);
try testing.expectEqualStrings("tls://dot.example.net:853", snapshots[0].url);
try testing.expectEqual(@as(usize, 1), held.activeCount());
}
test "build reports a malformed row instead of writing it, and refuses a row set with nothing usable" {
var t: TestIo = .init(testing.allocator);
defer t.deinit();
const io = t.io();
var http: std.http.Client = .{ .allocator = testing.allocator, .io = io };
defer http.deinit();
var bundle: Certificate.Bundle = .empty;
defer bundle.deinit(testing.allocator);
var bundle_lock: std.Io.RwLock = .init;
const generation = try buildTestGeneration(io, testing.allocator, &http, &bundle, &bundle_lock, &.{
.{ .url = "ftp://nope.example" },
.{ .url = "https://good.example/dns-query" },
.{ .url = "https://disabled.example/dns-query", .enabled = false },
});
var owner: Owner = .init(generation);
defer owner.deinit(io);
const report = generation.report();
try testing.expectEqual(@as(usize, 1), report.notes.len);
try testing.expectEqualStrings("ftp://nope.example", report.notes[0].url);
try testing.expectEqualStrings("not an https:// or tls:// endpoint; skipped", report.notes[0].message);
try testing.expectEqual(@as(usize, 1), generation.activeCount());
try testing.expectError(error.NoUsableUpstreams, buildTestGeneration(
io,
testing.allocator,
&http,
&bundle,
&bundle_lock,
&.{.{ .url = "ftp://nope.example" }},
));
try testing.expectError(error.NoUsableUpstreams, buildTestGeneration(
io,
testing.allocator,
&http,
&bundle,
&bundle_lock,
&.{.{ .url = "https://off.example", .enabled = false }},
));
}
// The concurrency criterion: an exchange in flight on G1 completes on G1, G1
// deinits only after that reader releases, and every exchange started after
// the swap runs on G2.
test "an exchange in flight survives a replace and the old generation retires after it" {
var t: TestIo = .init(testing.allocator);
defer t.deinit();
const io = t.io();
var entered: std.Io.Event = .unset;
var gate: std.Io.Event = .unset;
var first: FakeClient = .{ .identity = "fake://g1", .entered = &entered, .gate = &gate };
var second: FakeClient = .{ .identity = "fake://g2" };
var g1: Generation = .borrowing(first.client());
var g2: Generation = .borrowing(second.client());
var owner: Owner = .init(&g1);
const Exchange = struct {
fn run(o: *Owner, inner_io: std.Io, out: *?[]const u8) void {
const generation = o.acquire(inner_io);
defer o.release(inner_io, generation);
var buf: [16]u8 = undefined;
var selected: ?[]const u8 = null;
_ = generation.client.exchange(inner_io, "abc", &buf, &selected) catch {};
out.* = selected;
}
};
var in_flight: ?[]const u8 = null;
var future = try io.concurrent(Exchange.run, .{ &owner, io, &in_flight });
// The swap must land with G1 really pinned, not merely likely to be.
entered.waitUncancelable(io);
try testing.expectEqual(@as(?*Generation, null), owner.replace(io, &g2));
var after: ?[]const u8 = null;
Exchange.run(&owner, io, &after);
try testing.expectEqualStrings("fake://g2", after.?);
gate.set(io);
future.await(io);
try testing.expectEqualStrings("fake://g1", in_flight.?);
owner.deinit(io);
}
test "a metrics scrape running against the owner survives a replace under it" {
var t: TestIo = .init(testing.allocator);
defer t.deinit();
const io = t.io();
var http: std.http.Client = .{ .allocator = testing.allocator, .io = io };
defer http.deinit();
var bundle: Certificate.Bundle = .empty;
defer bundle.deinit(testing.allocator);
var bundle_lock: std.Io.RwLock = .init;
const g1 = try buildTestGeneration(io, testing.allocator, &http, &bundle, &bundle_lock, &.{
.{ .url = "https://one.example/dns-query" },
});
const g2 = try buildTestGeneration(io, testing.allocator, &http, &bundle, &bundle_lock, &.{
.{ .url = "https://two.example/dns-query" },
.{ .url = "tls://two.example:853", .tls_name = "two.example" },
});
var owner: Owner = .init(g1);
defer owner.deinit(io);
const Scrape = struct {
/// What every scrape must be true of, whichever generation answered
/// it: a URL the scrape read out of a generation it holds is a URL
/// nothing has freed.
fn run(o: *Owner, inner_io: std.Io, started: *std.Io.Event, seen: *usize) void {
for (0..256) |i| {
const generation = o.acquire(inner_io);
defer o.release(inner_io, generation);
var raw: [8]pool_mod.Snapshot = undefined;
const count = generation.pool.?.snapshot(inner_io, &raw) catch 0;
for (raw[0..count]) |entry| {
if (std.mem.startsWith(u8, entry.url, "https://") or
std.mem.startsWith(u8, entry.url, "tls://")) seen.* += 1;
}
if (i == 0) started.set(inner_io);
}
}
};
var started: std.Io.Event = .unset;
var seen: usize = 0;
var future = try io.concurrent(Scrape.run, .{ &owner, io, &started, &seen });
started.waitUncancelable(io);
if (owner.replace(io, g2)) |old| old.retire(io);
future.await(io);
// Every one of the 256 scrapes read at least one intact URL.
try testing.expect(seen >= 256);
}
+195 -5
View File
@@ -134,16 +134,16 @@ pub const ApiLimiter = struct {
/// Spends one token for a request from `addr`. Never allocates, never fails.
pub fn check(self: *ApiLimiter, io: std.Io, now: std.Io.Timestamp, addr: address.NetAddress) Result {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
// `localhost_exempt` is read under the mutex like every other config
// field: `setLimits` may be installing a new one concurrently.
if (self.config.localhost_exempt and isLoopback(addr)) {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
self.stats.exempt += 1;
return .ok;
}
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
const bucket = self.bucketLocked(addr.key(), now) orelse {
self.stats.untracked += 1;
self.stats.refused += 1;
@@ -238,6 +238,53 @@ pub const ApiLimiter = struct {
return self.table.count();
}
/// Installs new limits. Every live bucket is first refilled THROUGH `now`
/// at the OLD rate — the time already elapsed was earned under the rate
/// that was in force, and refilling it at the new rate would credit or
/// deny tokens retroactively — then clamped to the new capacity, and only
/// then does the new rate take over.
///
/// Per-address SSE counts are untouched: they count live connections, not
/// a budget, and the new `sse_max_per_ip` applies to the next acquire.
///
/// No bucket's clock moves BACKWARD here. `now` is read before the write
/// this install belongs to commits, so a request served in between leaves
/// a bucket already newer than it; rewinding that bucket to `now` would
/// let the next request buy the same interval a second time, at the new
/// rate. Such a bucket is only clamped to the new capacity — it has
/// already been refilled through a later reading at the old rate, which is
/// exactly what this phase owes it.
pub fn setLimits(self: *ApiLimiter, io: std.Io, now: std.Io.Timestamp, limits: Config) void {
std.debug.assert(limits.rate_per_min > 0);
const new_capacity = @as(u64, limits.rate_per_min) * token_scale;
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
const old_capacity = self.capacity;
var it = self.table.iterator();
while (it.next()) |entry| {
const bucket = entry.value_ptr;
if (bucket.updated_ns >= now.nanoseconds) {
bucket.tokens = @min(bucket.tokens, new_capacity);
continue;
}
const state = refilled(bucket.*, now, old_capacity);
bucket.tokens = @min(state.tokens, new_capacity);
bucket.updated_ns = state.updated_ns;
}
self.config = limits;
self.capacity = new_capacity;
}
/// The live limits, read under the same mutex that installs them.
pub fn snapshotConfig(self: *ApiLimiter, io: std.Io) Config {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
return self.config;
}
pub fn snapshotStats(self: *ApiLimiter, io: std.Io) Stats {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
@@ -685,3 +732,146 @@ fn initCheckDeinit(allocator: Allocator) !void {
test "init surfaces allocation failure without leaking" {
try testing.checkAllAllocationFailures(testing.allocator, initCheckDeinit, .{});
}
// ---------------------------------------------------------------------------
// setLimits (milestone-34 S3.2)
// ---------------------------------------------------------------------------
test "setLimits refills through now at the OLD rate before the new one applies" {
const fx = try Fixture.init(.{ .rate_per_min = 60, .localhost_exempt = false, .sse_max_per_ip = 3 });
defer fx.deinit();
const client = v4(10, 0, 0, 5);
for (0..60) |_| try testing.expect(fx.limiter.check(fx.io(), at(0), client).allowed);
try testing.expect(!fx.limiter.check(fx.io(), at(0), client).allowed);
// Thirty seconds under the old 60/min rate is worth 30 tokens. Refilling
// those same thirty seconds at the new 600/min rate would be worth 300 —
// a burst the operator never authorized for time already spent.
fx.limiter.setLimits(fx.io(), at(30), .{
.rate_per_min = 600,
.localhost_exempt = false,
.sse_max_per_ip = 3,
});
for (0..30) |_| try testing.expect(fx.limiter.check(fx.io(), at(30), client).allowed);
try testing.expect(!fx.limiter.check(fx.io(), at(30), client).allowed);
}
test "a bucket newer than the reading setLimits was given keeps its clock" {
const fx = try Fixture.init(.{ .rate_per_min = 60, .localhost_exempt = false, .sse_max_per_ip = 3 });
defer fx.deinit();
const client = v4(10, 0, 0, 8);
for (0..60) |_| try testing.expect(fx.limiter.check(fx.io(), at(0), client).allowed);
// The request that lands between the settings prepare and its publish.
// Thirty seconds at 60/min is worth thirty tokens, one of which it spends.
try testing.expect(fx.limiter.check(fx.io(), at(30), client).allowed);
// The install carries the reading taken at prepare, which is now stale.
// Rewinding the bucket to it would sell those same thirty seconds again,
// and at 600/min they are worth three hundred tokens rather than thirty.
fx.limiter.setLimits(fx.io(), at(0), .{
.rate_per_min = 600,
.localhost_exempt = false,
.sse_max_per_ip = 3,
});
for (0..29) |_| try testing.expect(fx.limiter.check(fx.io(), at(30), client).allowed);
try testing.expect(!fx.limiter.check(fx.io(), at(30), client).allowed);
}
test "a capacity cut clamps a bucket that held more than the new capacity" {
const fx = try Fixture.init(.{ .rate_per_min = 600, .localhost_exempt = false, .sse_max_per_ip = 3 });
defer fx.deinit();
const client = v4(10, 0, 0, 6);
// One spend creates the bucket, leaving it at 599 of 600.
try testing.expect(fx.limiter.check(fx.io(), at(0), client).allowed);
fx.limiter.setLimits(fx.io(), at(0), .{
.rate_per_min = 5,
.localhost_exempt = false,
.sse_max_per_ip = 3,
});
for (0..5) |_| try testing.expect(fx.limiter.check(fx.io(), at(0), client).allowed);
const refused = fx.limiter.check(fx.io(), at(0), client);
try testing.expect(!refused.allowed);
// At 5 per minute a whole token is twelve seconds.
try testing.expectEqual(@as(u32, 12), refused.retry_after_s);
}
test "setLimits leaves per-address SSE counts alone" {
const fx = try Fixture.init(.{ .rate_per_min = 60, .localhost_exempt = false, .sse_max_per_ip = 3 });
defer fx.deinit();
const client = v4(10, 0, 0, 7);
try testing.expect(fx.limiter.tryAcquireSse(fx.io(), at(0), client));
try testing.expect(fx.limiter.tryAcquireSse(fx.io(), at(0), client));
try testing.expectEqual(@as(u16, 2), fx.limiter.sseConnections(fx.io(), client));
fx.limiter.setLimits(fx.io(), at(0), .{
.rate_per_min = 120,
.localhost_exempt = false,
.sse_max_per_ip = 2,
});
// The live connections survive; the tightened cap governs the next one.
try testing.expectEqual(@as(u16, 2), fx.limiter.sseConnections(fx.io(), client));
try testing.expect(!fx.limiter.tryAcquireSse(fx.io(), at(0), client));
}
test "a localhost exemption flip is observed by the next check" {
const fx = try Fixture.init(.{ .rate_per_min = 1, .localhost_exempt = true, .sse_max_per_ip = 3 });
defer fx.deinit();
const local = v4(127, 0, 0, 1);
for (0..5) |_| try testing.expect(fx.limiter.check(fx.io(), at(0), local).allowed);
try testing.expectEqual(@as(u64, 5), fx.limiter.snapshotStats(fx.io()).exempt);
fx.limiter.setLimits(fx.io(), at(0), .{
.rate_per_min = 1,
.localhost_exempt = false,
.sse_max_per_ip = 3,
});
try testing.expect(fx.limiter.check(fx.io(), at(0), local).allowed);
try testing.expect(!fx.limiter.check(fx.io(), at(0), local).allowed);
const stats = fx.limiter.snapshotStats(fx.io());
try testing.expectEqual(@as(u64, 5), stats.exempt);
try testing.expectEqual(@as(u64, 1), stats.refused);
}
test "concurrent checks against a running setLimits stay consistent" {
const fx = try Fixture.init(.{ .rate_per_min = 600, .localhost_exempt = false, .sse_max_per_ip = 3 });
defer fx.deinit();
const Racer = struct {
fn spend(limiter: *ApiLimiter, io: std.Io) void {
for (0..2_000) |i| {
_ = limiter.check(io, atMillis(@intCast(i)), indexed(@intCast(i % 64)));
}
}
fn reconfigure(limiter: *ApiLimiter, io: std.Io) void {
for (0..2_000) |i| {
limiter.setLimits(io, atMillis(@intCast(i)), .{
.rate_per_min = if (i % 2 == 0) 5 else 600,
.localhost_exempt = i % 3 == 0,
.sse_max_per_ip = 3,
});
}
}
};
var group: std.Io.Group = .init;
defer group.cancel(fx.io());
try group.concurrent(fx.io(), Racer.spend, .{ &fx.limiter, fx.io() });
try group.concurrent(fx.io(), Racer.reconfigure, .{ &fx.limiter, fx.io() });
try group.await(fx.io());
// Every non-exempt call landed on exactly one side of the ledger.
const stats = fx.limiter.snapshotStats(fx.io());
try testing.expectEqual(@as(u64, 2_000), stats.allowed + stats.refused + stats.exempt);
}
+86 -2
View File
@@ -245,11 +245,14 @@ pub const LiveHash = struct {
};
/// One live session. `last_used` drives the LRU eviction and moves on every
/// successful validation; `expires_at` is fixed at login, so a session ends at
/// its TTL however busy it was.
/// successful validation; `expires_at` is `issued_at + ttl`, so a session ends
/// at its TTL however busy it was. `issued_at` is kept so `setTtl` can
/// recompute `expires_at` for live sessions from their origin rather than from
/// the moment of the change.
const Slot = struct {
used: bool,
digest: [Sha256.digest_length]u8,
issued_at: i64,
expires_at: i64,
last_used: i64,
};
@@ -282,6 +285,7 @@ pub const Sessions = struct {
.slots = @splat(.{
.used = false,
.digest = @splat(0),
.issued_at = 0,
.expires_at = 0,
.last_used = 0,
}),
@@ -311,6 +315,7 @@ pub const Sessions = struct {
slot.* = .{
.used = true,
.digest = digest,
.issued_at = now_s,
.expires_at = now_s + self.ttl_seconds,
.last_used = now_s,
};
@@ -381,6 +386,36 @@ pub const Sessions = struct {
return live;
}
/// Installs a new TTL and re-dates every live session from its
/// `issued_at`, so the change is retroactive rather than sliding.
///
/// The semantics are SERVER-SIDE only. Shortening takes effect for every
/// session at once, including ones that are already over the new age — the
/// next sweep drops them. Lengthening extends how long the table honours a
/// session, but the browser still holds the cookie's ORIGINAL `Max-Age`:
/// the cookie is never refreshed, so a session does not become sliding and
/// a lengthened session ends when the browser drops the cookie.
pub fn setTtl(self: *Sessions, io: std.Io, ttl_hours: u16) void {
std.debug.assert(ttl_hours > 0);
const ttl_seconds = @as(i64, ttl_hours) * 3600;
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
self.ttl_seconds = ttl_seconds;
for (&self.slots) |*slot| {
if (!slot.used) continue;
slot.expires_at = slot.issued_at + ttl_seconds;
}
}
/// The live TTL in seconds, for the login cookie's `Max-Age`.
pub fn ttlSeconds(self: *Sessions, io: std.Io) i64 {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
return self.ttl_seconds;
}
fn findLocked(self: *Sessions, digest: [Sha256.digest_length]u8) ?*Slot {
var found: ?*Slot = null;
for (&self.slots) |*slot| {
@@ -557,6 +592,55 @@ test "a session expires at its ttl and frees its slot" {
try testing.expectEqual(@as(u32, 0), sessions.count(io, 7200));
}
test "shortening the ttl expires an over-age live session at once" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
// Two hours, so a session minted at 0 is live at 3_600.
var sessions: Sessions = .init(2);
const cookie = sessions.createWithToken(io, tokenOf(9), 0);
try testing.expect(sessions.validateAt(io, &cookie, 3_600));
// One hour re-dates it from `issued_at`, which puts its expiry at 3_600.
sessions.setTtl(io, 1);
try testing.expect(!sessions.validateAt(io, &cookie, 3_600));
try testing.expectEqual(@as(u32, 0), sessions.count(io, 3_600));
}
test "lengthening the ttl extends a live session server-side" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var sessions: Sessions = .init(1);
const cookie = sessions.createWithToken(io, tokenOf(9), 0);
try testing.expect(!sessions.validateAt(io, &cookie, 3_600));
// Re-dating from `issued_at` rather than from now: three hours after
// minting, not three hours from here.
var extended: Sessions = .init(1);
const live = extended.createWithToken(io, tokenOf(9), 0);
extended.setTtl(io, 3);
try testing.expect(extended.validateAt(io, &live, 3_600));
try testing.expect(extended.validateAt(io, &live, 10_799));
try testing.expect(!extended.validateAt(io, &live, 10_800));
}
test "a session minted after setTtl uses the new ttl" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var sessions: Sessions = .init(24);
sessions.setTtl(io, 2);
try testing.expectEqual(@as(i64, 7_200), sessions.ttlSeconds(io));
const cookie = sessions.createWithToken(io, tokenOf(4), 100);
try testing.expect(sessions.validateAt(io, &cookie, 7_299));
try testing.expect(!sessions.validateAt(io, &cookie, 7_300));
}
test "the thirty-third session evicts the least recently used one" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
+867
View File
@@ -0,0 +1,867 @@
//! The apply table and the prepare → commit → publish → retire machinery a
//! configuration write drives.
//!
//! Every settings key names one CONCRETE operation — the owner that has to be
//! told, not a class of change. `bind` and `web_lifecycle` are the two that
//! cannot be executed in-process: they create or destroy a socket, so they set
//! `restart_pending` and milestone 35 executes them. Everything else applies
//! live, and no response or document claims otherwise.
//!
//! The four phases are the write contract, and `Plan` is what carries state
//! between them:
//!
//! 1. `prepare` builds and validates ONE candidate per affected owner from the
//! FINAL MERGED configuration. It is the only fallible phase. A failure
//! leaves the database and the running server untouched, and the caller
//! calls `abandon`.
//! 2. The caller commits its transaction. A commit failure is also `abandon`.
//! 3. `publish` swaps handles and pointers and stores atomics. Infallible and
//! I/O-free: nothing here opens, closes, joins or frees.
//! 4. `retire` closes and frees what publish displaced, and reconciles the
//! upstream diagnostics — event rows are SQLite I/O and are banned from a
//! publish.
//!
//! An operation whose owner this `WebState` does not have is skipped: the web
//! layer must build without a whole running server, and a handler test wires
//! only what it asserts on. The database row is still written, because the row
//! is the configuration and the runtime is a consumer of it.
const std = @import("std");
const Allocator = std.mem.Allocator;
const cert_store = @import("../../server/cert_store.zig");
const dns_cache = @import("../../cache/dns_cache.zig");
const dns_handler = @import("../../server/handler.zig");
const disk_monitor = @import("../../storage/disk_monitor.zig");
const logger_controller = @import("../../storage/logger_controller.zig");
const logging = @import("../../platform/logging.zig");
const model = @import("../../config/model.zig");
const mutations = @import("mutations.zig");
const rate_limiter = @import("../../server/rate_limiter.zig");
const server = @import("../server.zig");
const upstream_owner = @import("../../upstream/owner.zig");
const upstreams_repo = @import("../../storage/repositories/upstreams_repo.zig");
const Failure = mutations.Failure;
const log = std.log.scoped(.web_api);
// ---------------------------------------------------------------------------
// the key table
// ---------------------------------------------------------------------------
/// The owner a settings key belongs to. One entry per owner, never one per key:
/// two keys of the same owner produce ONE candidate.
pub const Operation = enum {
dns_policy,
trusted_proxies,
logger_privacy,
logger_flush,
disk_thresholds,
upstream_generation,
cache,
rate_limiter,
sessions_ttl,
api_limiter,
retention,
certs_doh,
certs_dot,
log_sink,
scheduler,
logger_queue,
/// A listener's address, port, or existence. Milestone 35 executes these.
bind,
/// `web.enabled`. Milestone 35 executes it.
web_lifecycle,
/// The broad class, DERIVED rather than listed a second time: a second
/// table would be a second authority, and the two would drift.
pub fn class(self: Operation) Class {
return switch (self) {
.bind => .bind,
.web_lifecycle => .web_lifecycle,
// Everything a candidate has to be built for before it can be
// published: memory, a file, a connection, a task.
.upstream_generation,
.cache,
.rate_limiter,
.certs_doh,
.certs_dot,
.log_sink,
.logger_queue,
.trusted_proxies,
=> .subsystem,
.dns_policy,
.logger_privacy,
.logger_flush,
.disk_thresholds,
.sessions_ttl,
.api_limiter,
.retention,
.scheduler,
=> .live,
};
}
/// Whether a change to this owner waits for a restart. The two that do are
/// the two milestone 35 owns.
pub fn needsRestart(self: Operation) bool {
return switch (self.class()) {
.bind, .web_lifecycle => true,
.live, .subsystem => false,
};
}
};
pub const Class = enum { live, subsystem, bind, web_lifecycle };
pub const Set = std.EnumSet(Operation);
pub const Entry = struct { key: []const u8, operation: Operation };
/// Every settings key, in `model.Config` declaration order, with the owner it
/// belongs to. `web.password` is write-only and `web.password_hash` is hidden;
/// neither is a settings key, and the completeness test below spells out the
/// same two exclusions the read and write shapes use.
pub const table = [_]Entry{
.{ .key = "upstream.attempt_timeout_ms", .operation = .upstream_generation },
// The forward-zone client's read deadline, which the per-query policy
// carries — not one of the pool's two timeouts.
.{ .key = "upstream.read_timeout_ms", .operation = .dns_policy },
.{ .key = "upstream.total_timeout_ms", .operation = .upstream_generation },
.{ .key = "dns.bind_ipv4", .operation = .bind },
.{ .key = "dns.bind_ipv6", .operation = .bind },
.{ .key = "dns.port", .operation = .bind },
.{ .key = "dns.rate_limit", .operation = .rate_limiter },
.{ .key = "dns.rate_window_seconds", .operation = .rate_limiter },
.{ .key = "blocking.response", .operation = .dns_policy },
.{ .key = "blocking.ttl", .operation = .dns_policy },
.{ .key = "cache.size", .operation = .cache },
// The cache keeps no copy of its configuration: `classify` reads the
// negative ceiling off the per-query policy.
.{ .key = "cache.negative_ttl_max", .operation = .dns_policy },
.{ .key = "web.enabled", .operation = .web_lifecycle },
.{ .key = "web.bind", .operation = .bind },
.{ .key = "web.port", .operation = .bind },
.{ .key = "web.session_ttl_hours", .operation = .sessions_ttl },
.{ .key = "web.api_rate_limit_per_min", .operation = .api_limiter },
.{ .key = "web.api_localhost_exempt", .operation = .api_limiter },
.{ .key = "web.sse_max_connections_per_ip", .operation = .api_limiter },
.{ .key = "web.trusted_proxies", .operation = .trusted_proxies },
.{ .key = "doh_server.enabled", .operation = .bind },
.{ .key = "doh_server.bind", .operation = .bind },
.{ .key = "doh_server.port", .operation = .bind },
.{ .key = "doh_server.cert_path", .operation = .certs_doh },
.{ .key = "doh_server.key_path", .operation = .certs_doh },
.{ .key = "dot_server.enabled", .operation = .bind },
.{ .key = "dot_server.bind", .operation = .bind },
.{ .key = "dot_server.port", .operation = .bind },
.{ .key = "dot_server.cert_path", .operation = .certs_dot },
.{ .key = "dot_server.key_path", .operation = .certs_dot },
.{ .key = "edns.ecs_mode", .operation = .dns_policy },
.{ .key = "logging.level", .operation = .log_sink },
.{ .key = "logging.retention_days", .operation = .retention },
.{ .key = "logging.query_log_buffer_max", .operation = .logger_queue },
.{ .key = "logging.query_log_flush_interval_s", .operation = .logger_flush },
.{ .key = "logging.hide_domains", .operation = .logger_privacy },
.{ .key = "logging.hide_client_ips", .operation = .logger_privacy },
.{ .key = "logging.output", .operation = .log_sink },
.{ .key = "logging.file_path", .operation = .log_sink },
.{ .key = "logging.max_size_mb", .operation = .log_sink },
.{ .key = "logging.max_files", .operation = .log_sink },
.{ .key = "disk.min_free_mb", .operation = .disk_thresholds },
.{ .key = "disk.warn_free_mb", .operation = .disk_thresholds },
.{ .key = "blocklist_update.enabled", .operation = .scheduler },
.{ .key = "blocklist_update.interval_hours", .operation = .scheduler },
};
/// Fields a client may neither read nor write directly. `password_hash` is
/// derived from `password`; exposing it would let a client install a hash nxdns
/// never computed.
pub fn isHidden(comptime section: []const u8, comptime field: []const u8) bool {
return std.mem.eql(u8, section, "web") and std.mem.eql(u8, field, "password_hash");
}
/// `web.password` is accepted on a PUT and never returned. It is not a settings
/// key: the hash it produces is, and its apply is the live-hash install.
pub fn isWriteOnly(comptime section: []const u8, comptime field: []const u8) bool {
return std.mem.eql(u8, section, "web") and std.mem.eql(u8, field, "password");
}
pub fn isScalarSection(comptime T: type) bool {
return @typeInfo(T) == .@"struct";
}
fn find(comptime key: []const u8) ?Operation {
@setEvalBranchQuota(20_000);
for (table) |entry| {
if (std.mem.eql(u8, entry.key, key)) return entry.operation;
}
return null;
}
/// The keys whose change waits for a restart, which is exactly the two
/// milestone-35 operations. `/api/settings` reports this list as field-level
/// metadata about the form.
pub const restart_required_keys: []const []const u8 = &restart_keys;
const restart_keys = blk: {
var list: [table.len][]const u8 = undefined;
var count = 0;
for (table) |entry| {
if (!entry.operation.needsRestart()) continue;
list[count] = entry.key;
count += 1;
}
const final = list[0..count].*;
break :blk final;
};
/// Whether a scalar settings value differs between two configurations. Strings
/// compare by bytes; everything else a settings row can hold compares by value.
fn differs(comptime T: type, a: T, b: T) bool {
if (T == []const u8) return !std.mem.eql(u8, a, b);
return a != b;
}
/// The owners a change from `before` to `after` has to tell.
///
/// Derived from the values, not from the keys the patch named: a PUT that
/// rewrites a setting to what it already was changes nothing, so it must not
/// resize a queue, rebuild a pool, or owe a restart. The admin form submits
/// every field, and treating that as eighteen applies would be wrong as well as
/// wasteful.
pub fn changedOperations(before: model.Config, after: model.Config) Set {
var ops: Set = .initEmpty();
inline for (@typeInfo(model.Config).@"struct".fields) |section_field| {
if (comptime isScalarSection(section_field.type)) {
inline for (@typeInfo(section_field.type).@"struct".fields) |field| {
comptime if (isHidden(section_field.name, field.name)) continue;
comptime if (isWriteOnly(section_field.name, field.name)) continue;
const operation = comptime find(section_field.name ++ "." ++ field.name).?;
const old = @field(@field(before, section_field.name), field.name);
const new = @field(@field(after, section_field.name), field.name);
if (differs(@TypeOf(old), old, new)) ops.insert(operation);
}
}
}
return ops;
}
// ---------------------------------------------------------------------------
// the plan
// ---------------------------------------------------------------------------
/// Everything one configuration write prepared, published and still owes a
/// retire. Exactly one of `abandon` or `publish` consumes a prepared plan, and
/// a published one is always followed by `retire`.
pub const Plan = struct {
state: *server.WebState,
/// The per-request arena. Holds the copied upstream report keys, which
/// nothing outside this request reads.
arena: Allocator,
/// The final merged configuration every candidate is built from.
cfg: model.Config,
ops: Set,
// prepared candidates ---------------------------------------------------
proxies: ?[]u8 = null,
cache: ?*dns_cache.DnsCache = null,
limiter: ?*rate_limiter.RateLimiter = null,
upstream: ?*upstream_owner.Generation = null,
upstream_previous_keys: []const []const u8 = &.{},
doh_paths: ?cert_store.CertStore.PreparedPaths = null,
dot_paths: ?cert_store.CertStore.PreparedPaths = null,
sink: ?logging.PreparedApply = null,
log_dir: ?*disk_monitor.LogDir = null,
queue: ?logger_controller.Prepared = null,
/// The reading `setLimits` refills its buckets against. Taken at prepare
/// because publish reads no clock: a clock is I/O.
limiter_now: ?std.Io.Timestamp = null,
// what publish displaced, for retire ------------------------------------
retired_proxies: ?[]const u8 = null,
retired_cache: ?*dns_cache.DnsCache = null,
retired_limiter: ?*rate_limiter.RateLimiter = null,
retired_upstream: ?*upstream_owner.Generation = null,
detached_log_file: ?std.Io.File = null,
published_upstream: bool = false,
pub fn init(
state: *server.WebState,
arena: Allocator,
cfg: model.Config,
ops: Set,
) Plan {
return .{ .state = state, .arena = arena, .cfg = cfg, .ops = ops };
}
/// Builds every candidate the change needs. Returns the failure that
/// refused it, in which case the caller must still call `abandon`: an
/// earlier owner's candidate may already exist.
///
/// A propagated error abandons here instead, because the caller has no
/// plan left to abandon: an earlier owner may already hold memory, a file,
/// a connection, a parked writer or a lock — the cert store keeps
/// `reload_mutex` held between its prepare and its publish.
pub fn prepare(self: *Plan, io: std.Io) error{OutOfMemory}!?Failure {
errdefer self.abandon(io);
const state = self.state;
const gpa = state.gpa;
if (self.ops.contains(.trusted_proxies)) {
self.proxies = try gpa.dupe(u8, self.cfg.web.trusted_proxies);
}
if (self.ops.contains(.cache)) {
const candidate = try gpa.create(dns_cache.DnsCache);
candidate.* = dns_cache.DnsCache.init(gpa, self.cfg.cache) catch |err| {
gpa.destroy(candidate);
return err;
};
self.cache = candidate;
}
if (self.ops.contains(.rate_limiter)) {
const candidate = try gpa.create(rate_limiter.RateLimiter);
candidate.* = rate_limiter.RateLimiter.init(gpa, .{
.limit = self.cfg.dns.rate_limit,
.window_seconds = self.cfg.dns.rate_window_seconds,
}) catch |err| {
gpa.destroy(candidate);
return err;
};
self.limiter = candidate;
}
if (self.ops.contains(.upstream_generation)) {
if (try self.prepareUpstreams(io, self.cfg.upstreams)) |failure| return failure;
}
if (self.ops.contains(.certs_doh)) {
// A disabled endpoint has no store: the change is a database row
// and nothing else, and milestone 35 validates the paths when it
// implements enable.
if (state.doh_certs) |store| {
self.doh_paths = store.preparePathChange(
io,
self.cfg.doh_server.cert_path,
self.cfg.doh_server.key_path,
) catch |err| return certFailure("doh_server", err);
}
}
if (self.ops.contains(.certs_dot)) {
if (state.dot_certs) |store| {
self.dot_paths = store.preparePathChange(
io,
self.cfg.dot_server.cert_path,
self.cfg.dot_server.key_path,
) catch |err| return certFailure("dot_server", err);
}
}
if (self.ops.contains(.log_sink)) {
self.sink = logging.prepareApply(io, self.cfg.logging) catch |err| return switch (err) {
error.PathTooLong => Failure{ .invalid = "logging.file_path: too long for this system" },
error.TargetUnopenable => Failure{
.invalid = "logging.file_path: this file cannot be opened for writing",
},
};
// Derived from the final merged output AND file_path on every sink
// apply, in both directions: output `file` measures the file's
// directory, anything else measures nothing.
self.log_dir = try disk_monitor.Monitor.prepareLogDir(gpa, logging.logDirname(self.cfg.logging));
}
if (self.ops.contains(.logger_queue)) {
if (state.logger) |controller| {
// The merged configuration, not the live one: a PUT that
// changes the buffer size and a privacy flag together must not
// hand the replacement generation the privacy it replaced.
if (controller.prepare(io, self.cfg.logging)) |prepared| {
self.queue = prepared;
} else |err| {
const entries = self.cfg.logging.query_log_buffer_max;
if (try self.queueFailure(err, entries)) |failure| return failure;
}
}
}
if (self.ops.contains(.api_limiter)) self.limiter_now = std.Io.Clock.awake.now(io);
return null;
}
/// What a refused resize means to the client, or null when it means nothing
/// — a controller with no generation to replace is a test's borrowed one,
/// and the row is still the configuration.
fn queueFailure(
self: *Plan,
err: logger_controller.PrepareError,
entries: u32,
) error{OutOfMemory}!?Failure {
var buf: [96]u8 = undefined;
if (logger_controller.sizeMessage(err, entries, &buf)) |message| {
return Failure{ .invalid = try std.fmt.allocPrint(
self.arena,
"logging.query_log_buffer_max: {s}",
.{message},
) };
}
return switch (err) {
error.NotResizable => null,
error.OutOfMemory => error.OutOfMemory,
error.PreviousResizeDraining => Failure{
.conflict = "a previous query-log resize is still draining",
},
else => blk: {
log.warn("preparing the query-log resize failed: {s}", .{@errorName(err)});
break :blk Failure{ .unavailable = "the query logger could not be resized" };
},
};
}
/// The upstream half, shared with the upstream resource handlers: they
/// build their candidate from the HYPOTHETICAL post-mutation row set,
/// before the repository write.
pub fn prepareUpstreams(
self: *Plan,
io: std.Io,
servers: []const model.UpstreamServer,
) error{OutOfMemory}!?Failure {
const state = self.state;
const owner = state.upstreams orelse return null;
const inputs = state.upstream_build orelse return null;
self.upstream_previous_keys = try owner.copyLiveReportKeys(io, self.arena);
self.upstream = upstream_owner.build(.{
.gpa = state.gpa,
.io = io,
.servers = servers,
.http = inputs.http,
.bundle = inputs.bundle,
.bundle_lock = inputs.bundle_lock,
.timeouts = .{
.attempt = .{ .raw = model.attemptTimeout(self.cfg.upstream), .clock = .awake },
.total = .{ .raw = model.totalTimeout(self.cfg.upstream), .clock = .awake },
},
.seed = @truncate(@as(u96, @bitCast(std.Io.Clock.real.now(io).nanoseconds))),
.diagnostics = state.events,
}) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
error.NoUsableUpstreams => return Failure{
.conflict = "no usable upstream would be left",
},
};
self.ops.insert(.upstream_generation);
return null;
}
/// Frees every candidate. The change did not happen: nothing was published,
/// so nothing the running server holds is touched.
pub fn abandon(self: *Plan, io: std.Io) void {
const gpa = self.state.gpa;
if (self.proxies) |text| gpa.free(text);
if (self.cache) |candidate| {
candidate.deinit();
gpa.destroy(candidate);
}
if (self.limiter) |candidate| {
candidate.deinit();
gpa.destroy(candidate);
}
if (self.upstream) |candidate| candidate.retire(io);
if (self.doh_paths) |prepared| self.state.doh_certs.?.abortPathChange(io, prepared);
if (self.dot_paths) |prepared| self.state.dot_certs.?.abortPathChange(io, prepared);
if (self.sink) |prepared| logging.abortApply(io, prepared);
disk_monitor.Monitor.destroyPreparedLogDir(self.log_dir);
if (self.queue) |prepared| self.state.logger.?.abandon(io, prepared);
self.* = undefined;
}
/// Infallible and I/O-free. Every operation whose owner is wired is told,
/// and what it displaced is recorded for `retire`.
pub fn publish(self: *Plan, io: std.Io) void {
const state = self.state;
if (self.ops.contains(.dns_policy)) {
if (state.handler) |h| h.setPolicy(io, .{
.blocking = .{ .mode = self.cfg.blocking.response, .ttl = self.cfg.blocking.ttl },
.ecs_mode = self.cfg.edns.ecs_mode,
.forward_read_timeout = .{ .raw = model.readTimeout(self.cfg.upstream), .clock = .awake },
.negative_ttl_max = self.cfg.cache.negative_ttl_max,
});
}
if (self.proxies) |prepared| {
self.retired_proxies = state.proxies.install(io, prepared);
self.proxies = null;
}
if (self.ops.contains(.logger_privacy)) {
if (state.logger) |controller| controller.setPrivacy(io, .{
.hide_domains = self.cfg.logging.hide_domains,
.hide_client_ips = self.cfg.logging.hide_client_ips,
});
}
if (self.ops.contains(.logger_flush)) {
if (state.logger) |controller| {
controller.setFlushInterval(io, self.cfg.logging.query_log_flush_interval_s);
}
}
if (self.ops.contains(.disk_thresholds)) {
if (state.monitor) |monitor| monitor.setThresholds(self.cfg.disk);
}
if (self.upstream) |candidate| {
self.retired_upstream = state.upstreams.?.replace(io, candidate);
self.published_upstream = true;
self.upstream = null;
}
if (self.cache) |candidate| {
// A handler-less state still owns the candidate, and dropping it
// here would leak it: `replaceCache` is the only thing that can
// hand the old one back.
if (state.handler) |h| {
self.retired_cache = h.replaceCache(io, candidate);
} else {
self.retired_cache = candidate;
}
self.cache = null;
}
if (self.limiter) |candidate| {
if (state.handler) |h| {
self.retired_limiter = h.replaceRateLimiter(io, candidate);
} else {
self.retired_limiter = candidate;
}
self.limiter = null;
}
if (self.ops.contains(.sessions_ttl)) {
if (state.sessions) |sessions| sessions.setTtl(io, self.cfg.web.session_ttl_hours);
}
if (self.limiter_now) |now| {
if (state.limiter) |limiter| limiter.setLimits(io, now, .{
.rate_per_min = self.cfg.web.api_rate_limit_per_min,
.localhost_exempt = self.cfg.web.api_localhost_exempt,
.sse_max_per_ip = self.cfg.web.sse_max_connections_per_ip,
});
}
if (self.ops.contains(.retention)) {
if (state.retention_days) |days| days.setRetentionDays(self.cfg.logging.retention_days);
}
if (self.doh_paths) |prepared| {
state.doh_certs.?.publishPathChange(io, prepared);
self.doh_paths = null;
}
if (self.dot_paths) |prepared| {
state.dot_certs.?.publishPathChange(io, prepared);
self.dot_paths = null;
}
if (self.sink) |prepared| {
self.detached_log_file = logging.publishApply(prepared);
self.sink = null;
if (state.monitor) |monitor| {
monitor.setLogDir(io, self.log_dir);
} else {
disk_monitor.Monitor.destroyPreparedLogDir(self.log_dir);
}
self.log_dir = null;
}
if (self.ops.contains(.scheduler)) {
if (state.manager) |manager| manager.setSchedule(
io,
self.cfg.blocklist_update.enabled,
self.cfg.blocklist_update.interval_hours,
);
}
if (self.queue) |prepared| {
state.logger.?.publish(io, prepared);
self.queue = null;
}
}
/// Closes, frees and reconciles after the publish. Runs on the writing
/// task, never on a reader's release path.
pub fn retire(self: *Plan, io: std.Io) void {
const gpa = self.state.gpa;
if (self.retired_proxies) |text| gpa.free(text);
if (self.retired_cache) |old| {
old.deinit();
gpa.destroy(old);
}
if (self.retired_limiter) |old| {
old.deinit();
gpa.destroy(old);
}
logging.retireApply(io, self.detached_log_file);
if (self.published_upstream) {
if (self.state.events) |store| {
const owner = self.state.upstreams.?;
const generation = owner.acquire(io);
defer owner.release(io, generation);
upstream_owner.reconcileReport(
store,
io,
std.Io.Clock.real.now(io).toSeconds(),
generation.report().notes,
self.upstream_previous_keys,
);
}
// A generation no reader held at the swap has no release left to
// tear it down, so the publisher does — after the reconciliation,
// which reads only the copies taken at prepare.
if (self.retired_upstream) |old| old.retire(io);
}
self.* = undefined;
}
};
fn certFailure(comptime endpoint: []const u8, err: cert_store.ReloadError) Failure {
return switch (err) {
error.OutOfMemory => .{ .unavailable = "out of memory loading the certificate" },
error.CertUnreadable => .{ .invalid = endpoint ++ ".cert_path: no readable certificate there" },
error.KeyUnreadable => .{ .invalid = endpoint ++ ".key_path: no readable key there" },
error.KeyMismatch => .{ .invalid = endpoint ++ ": the key at that path does not match the certificate" },
// The remaining variants are parse, size and library failures. They name
// the pair rather than one of the two files, because which of them was
// at fault is exactly what the loader could not decide.
else => .{ .invalid = endpoint ++ ": the certificate and key at those paths could not be loaded" },
};
}
/// The row set an upstream create, update or delete would leave behind, built
/// before the repository write so the candidate generation is the one the
/// commit will make true.
pub const RowMutation = union(enum) {
add: model.UpstreamServer,
replace: struct { id: i64, item: model.UpstreamServer },
remove: i64,
};
pub fn hypotheticalRows(
arena: Allocator,
rows: []const upstreams_repo.UpstreamRow,
mutation: RowMutation,
) Allocator.Error![]const model.UpstreamServer {
var out: std.ArrayList(model.UpstreamServer) = .empty;
for (rows) |row| {
switch (mutation) {
.add => {},
.replace => |edit| if (row.id == edit.id) {
try out.append(arena, edit.item);
continue;
},
.remove => |id| if (row.id == id) continue,
}
try out.append(arena, .{
.url = row.url,
.priority = row.priority,
.enabled = row.enabled,
.tls_name = row.tls_name,
});
}
if (mutation == .add) try out.append(arena, mutation.add);
return out.items;
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const testing = std.testing;
const fixtures = @import("test_fixtures");
test "a propagated error out of prepare abandons what earlier owners already built" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.writeFile(io, .{ .sub_path = "cert.pem", .data = fixtures.cert_pem });
try tmp.dir.writeFile(io, .{ .sub_path = "key.pem", .data = fixtures.key_pem });
var cert_buf: [128]u8 = undefined;
var key_buf: [128]u8 = undefined;
var log_buf: [128]u8 = undefined;
const cert_path = try std.fmt.bufPrint(&cert_buf, ".zig-cache/tmp/{s}/cert.pem", .{tmp.sub_path});
const key_path = try std.fmt.bufPrint(&key_buf, ".zig-cache/tmp/{s}/key.pem", .{tmp.sub_path});
const log_path = try std.fmt.bufPrint(&log_buf, ".zig-cache/tmp/{s}/nxdns.log", .{tmp.sub_path});
var store = try cert_store.CertStore.init(testing.allocator, io, cert_path, key_path, null);
defer store.deinit(io);
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
// The cert candidate is built from the store's own allocator and succeeds;
// the log-directory generation that follows it comes from the state's, and
// that one is out of memory.
var failing: std.testing.FailingAllocator = .init(testing.allocator, .{ .fail_index = 0 });
var state: server.WebState = .{ .gpa = failing.allocator(), .doh_certs = &store };
var cfg: model.Config = .{};
cfg.doh_server.cert_path = cert_path;
cfg.doh_server.key_path = key_path;
cfg.logging.output = .file;
cfg.logging.file_path = log_path;
var ops: Set = .initEmpty();
ops.insert(.certs_doh);
ops.insert(.log_sink);
var plan: Plan = .init(&state, arena_state.allocator(), cfg, ops);
try testing.expectError(error.OutOfMemory, plan.prepare(io));
// `preparePathChange` holds `reload_mutex` until its publish or its abort.
// A prepare that failed after it must have aborted, or every later cert
// change and every reload would block here forever.
const second = try store.preparePathChange(io, cert_path, key_path);
store.abortPathChange(io, second);
}
test "every scalar settings key appears in the table exactly once" {
// The old comptime generator walked `model.Config` to produce the key list;
// this walks it to prove the hand-written table covers it. A section added
// to the model fails to compile here until its keys have owners.
comptime {
@setEvalBranchQuota(50_000);
var counted = 0;
for (@typeInfo(model.Config).@"struct".fields) |section_field| {
if (!isScalarSection(section_field.type)) continue;
for (@typeInfo(section_field.type).@"struct".fields) |field| {
if (isHidden(section_field.name, field.name)) continue;
if (isWriteOnly(section_field.name, field.name)) continue;
const key = section_field.name ++ "." ++ field.name;
var hits = 0;
for (table) |entry| {
if (std.mem.eql(u8, entry.key, key)) hits += 1;
}
if (hits != 1) @compileError("the apply table does not list " ++ key ++ " exactly once");
counted += 1;
}
}
if (counted != table.len) @compileError("the apply table lists a key the model does not have");
}
}
test "the table names no secret" {
for (table) |entry| {
try testing.expect(!std.mem.eql(u8, entry.key, "web.password"));
try testing.expect(!std.mem.eql(u8, entry.key, "web.password_hash"));
}
}
test "restart-required is bind and web lifecycle, and nothing else" {
for (table) |entry| {
var listed = false;
for (restart_required_keys) |key| {
if (std.mem.eql(u8, entry.key, key)) listed = true;
}
try testing.expectEqual(entry.operation.needsRestart(), listed);
}
// The full list, so a key silently joining or leaving it is a failure.
const expected = [_][]const u8{
"dns.bind_ipv4",
"dns.bind_ipv6",
"dns.port",
"web.enabled",
"web.bind",
"web.port",
"doh_server.bind",
"doh_server.enabled",
"doh_server.port",
"dot_server.bind",
"dot_server.enabled",
"dot_server.port",
};
try testing.expectEqual(expected.len, restart_required_keys.len);
for (expected) |key| {
var found = false;
for (restart_required_keys) |listed| {
if (std.mem.eql(u8, listed, key)) found = true;
}
try testing.expect(found);
}
}
test "changed operations follow the values, not the keys a patch named" {
const before: model.Config = .{};
try testing.expect(changedOperations(before, before).count() == 0);
var after = before;
after.dns.port = 5353;
try testing.expect(changedOperations(before, after).eql(Set.initOne(.bind)));
// Two keys of one owner are one operation.
var pair = before;
pair.disk = .{ .min_free_mb = 1, .warn_free_mb = 2 };
try testing.expect(changedOperations(before, pair).eql(Set.initOne(.disk_thresholds)));
// A string key compares by bytes.
var proxies = before;
proxies.web.trusted_proxies = "10.0.0.1";
try testing.expect(changedOperations(before, proxies).eql(Set.initOne(.trusted_proxies)));
// Equal bytes at a different address are not a change.
var same_bytes = before;
var copy: [7]u8 = undefined;
@memcpy(&copy, "0.0.0.0");
same_bytes.dns.bind_ipv4 = &copy;
try testing.expect(changedOperations(before, same_bytes).count() == 0);
}
test "the hypothetical row set is the one the commit will make true" {
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const rows = [_]upstreams_repo.UpstreamRow{
.{ .id = 1, .url = "https://a.example/dns-query", .priority = 1, .enabled = true, .tls_name = "" },
.{ .id = 2, .url = "tls://b.example:853", .priority = 2, .enabled = true, .tls_name = "b.example" },
};
const added = try hypotheticalRows(arena, &rows, .{ .add = .{ .url = "https://c.example/dns-query" } });
try testing.expectEqual(@as(usize, 3), added.len);
try testing.expectEqualStrings("https://c.example/dns-query", added[2].url);
const removed = try hypotheticalRows(arena, &rows, .{ .remove = 1 });
try testing.expectEqual(@as(usize, 1), removed.len);
try testing.expectEqualStrings("tls://b.example:853", removed[0].url);
const replaced = try hypotheticalRows(arena, &rows, .{
.replace = .{ .id = 2, .item = .{ .url = "tls://z.example:853", .tls_name = "z.example" } },
});
try testing.expectEqual(@as(usize, 2), replaced.len);
try testing.expectEqualStrings("tls://z.example:853", replaced[1].url);
}
+7 -1
View File
@@ -124,11 +124,17 @@ pub fn login(state: *server.WebState, io: std.Io, request: *Request) HandlerErro
.cookie => |cookie| {
log.info("web login accepted for {f}", .{request.client_addr});
var buf: [cookie_buf_len]u8 = undefined;
// The live table's TTL, not `state.web`'s boot snapshot: a
// settings apply changes the TTL in place.
const ttl_s = if (state.sessions) |sessions|
sessions.ttlSeconds(io)
else
model.sessionTtlSeconds(state.web);
const header = http_util.formatSetCookie(
&buf,
auth.cookie_name,
&cookie,
model.sessionTtlSeconds(state.web),
ttl_s,
) catch return error.OutOfMemory;
return http_util.respondJson(request, .ok, .{
.authenticated = true,
+19 -12
View File
@@ -20,6 +20,7 @@ const std = @import("std");
const disk_monitor = @import("../../storage/disk_monitor.zig");
const http_util = @import("../http_util.zig");
const logger_controller = @import("../../storage/logger_controller.zig");
const logger_mod = @import("../../storage/logger.zig");
const metrics = @import("../metrics.zig");
const pause_mod = @import("../../server/pause.zig");
@@ -229,20 +230,25 @@ pub fn collect(state: *server.WebState, io: std.Io) Input {
input.disk_free_bytes = monitor.gauges().free_bytes;
}
if (state.pool) |pool| {
var raw: [metrics.max_upstreams]pool_mod.Snapshot = undefined;
const count = metrics.poolSnapshot(pool, io, &raw);
input.upstreams_total = @intCast(count);
for (raw[0..count]) |entry| {
if (entry.available) input.upstreams_available += 1;
if (state.upstreams) |owner| {
const generation = owner.acquire(io);
defer owner.release(io, generation);
if (generation.pool) |pool| {
var raw: [metrics.max_upstreams]pool_mod.Snapshot = undefined;
const count = metrics.poolSnapshot(pool, io, &raw);
input.upstreams_total = @intCast(count);
for (raw[0..count]) |entry| {
if (entry.available) input.upstreams_available += 1;
}
}
}
if (state.logger) |logger| {
input.queries_dropped = logger.queries_dropped.load(.monotonic);
input.last_drop_s = logger.lastDropSeconds();
input.writer_failed = logger.writer_failed.load(.monotonic);
input.gate_episode = logger.gateEpisode();
if (state.logger) |controller| {
const reading = controller.sample(io);
input.queries_dropped = reading.queries_dropped;
input.last_drop_s = reading.last_drop_s;
input.writer_failed = reading.writer_failed;
input.gate_episode = reading.gate_episode;
}
if (state.pause) |paused| input.pause_until = paused.until.load(.monotonic);
@@ -492,13 +498,14 @@ test "collect reads the logger's counters and the pause flag" {
query_logger.queries_dropped.store(4, .monotonic);
query_logger.last_drop_s.store(1_700_000_000, .monotonic);
query_logger.writer_failed.store(true, .monotonic);
var log_owner: logger_controller.Borrowed = .{};
var paused: pause_mod.Pause = .{};
paused.pauseFor(0, null);
var state: server.WebState = .{
.gpa = testing.allocator,
.logger = &query_logger,
.logger = log_owner.over(&query_logger),
.pause = &paused,
};
const input = collect(&state, io);
+18 -4
View File
@@ -163,7 +163,11 @@ pub fn Resource(comptime desc: anytype) type {
if (!isNull(@TypeOf(desc.remove))) {
const Remove = @TypeOf(desc.remove);
if (removeTakesArena(Remove)) {
expectType("remove", Remove, fn (*server.WebState, std.Io, Allocator, i64) ?Failure);
if (removeIsFallible(Remove)) {
expectType("remove", Remove, fn (*server.WebState, std.Io, Allocator, i64) error{OutOfMemory}!?Failure);
} else {
expectType("remove", Remove, fn (*server.WebState, std.Io, Allocator, i64) ?Failure);
}
} else {
expectType("remove", Remove, fn (*server.WebState, std.Io, i64) ?Failure);
}
@@ -227,10 +231,12 @@ pub fn Resource(comptime desc: anytype) type {
}
fn removeRow(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
const failure = if (comptime removeTakesArena(@TypeOf(desc.remove)))
desc.remove(state, io, request.arena, request.id.?)
const failure = if (comptime !removeTakesArena(@TypeOf(desc.remove)))
desc.remove(state, io, request.id.?)
else if (comptime removeIsFallible(@TypeOf(desc.remove)))
try desc.remove(state, io, request.arena, request.id.?)
else
desc.remove(state, io, request.id.?);
desc.remove(state, io, request.arena, request.id.?);
if (failure) |value| return respondFailure(request, value, remove_what);
return http_util.respondEmpty(request, .no_content);
@@ -246,6 +252,14 @@ fn removeTakesArena(comptime T: type) bool {
return @typeInfo(T) == .@"fn" and @typeInfo(T).@"fn".params.len == 4;
}
/// A delete decision that builds a candidate for the running server allocates
/// while it does so, so it may run out of memory. The two shapes are checked
/// exactly, so a `remove` that returns some other error set is still a compile
/// error naming what it should have been.
fn removeIsFallible(comptime T: type) bool {
return @typeInfo(@typeInfo(T).@"fn".return_type.?) == .error_union;
}
fn expectType(comptime name: []const u8, comptime Actual: type, comptime Expected: type) void {
if (Actual != Expected) @compileError("resource descriptor `" ++ name ++ "` must be " ++
@typeName(Expected) ++ ", found " ++ @typeName(Actual));
+90 -104
View File
@@ -1,11 +1,14 @@
//! `GET`/`PUT /api/settings` — the scalar configuration, the rows of the
//! `settings` table (ruling 16).
//!
//! Everything here is restart-required this milestone, and the response says so
//! for every key: what changes live is the resource endpoints and the pause,
//! not a setting. The list is generated from `model.Config` itself, so a
//! section added to the model appears here without anyone remembering to add
//! it.
//! Every key applies live except the two that create or destroy a socket —
//! `bind` (a listener's address, port or existence) and `web_lifecycle`
//! (`web.enabled`) — which set `restart_pending` for milestone 35 to execute.
//! `restart_required` in the response is that pair of key groups and nothing
//! else. `apply.zig` holds the table that decides which owner a key belongs to
//! and drives the prepare → commit → publish → retire the write contract asks
//! for; a section added to the model fails to compile until its keys have
//! owners there.
//!
//! `web.password` is write-only and `web.password_hash` is neither readable nor
//! directly writable. A PUT carrying `web.password` hashes it with the import
@@ -23,6 +26,7 @@ const builtin = @import("builtin");
const std = @import("std");
const Allocator = std.mem.Allocator;
const apply = @import("apply.zig");
const auth = @import("../auth.zig");
const db = @import("../../storage/db.zig");
const http_util = @import("../http_util.zig");
@@ -42,58 +46,13 @@ const log = std.log.scoped(.web_api);
/// always fits the copy `applyLogin` takes.
const hash_buf_len = auth.LiveHash.max_len;
/// Fields a client may neither read nor write directly. `password_hash` is
/// derived from `password`; exposing it would let a client install a hash
/// nxdns never computed.
fn isHidden(comptime section: []const u8, comptime field: []const u8) bool {
return std.mem.eql(u8, section, "web") and std.mem.eql(u8, field, "password_hash");
}
const isHidden = apply.isHidden;
const isWriteOnly = apply.isWriteOnly;
const isScalarSection = apply.isScalarSection;
/// `web.password` is accepted on a PUT and never returned.
fn isWriteOnly(comptime section: []const u8, comptime field: []const u8) bool {
return std.mem.eql(u8, section, "web") and std.mem.eql(u8, field, "password");
}
fn isScalarSection(comptime T: type) bool {
return @typeInfo(T) == .@"struct";
}
// ---------------------------------------------------------------------------
// the restart-required table (ruling 16)
// ---------------------------------------------------------------------------
/// Every settings key, in `model.Config` declaration order. Ruling 16: all of
/// them are restart-required this milestone, so the table is the key list and
/// the flag is implied by membership.
pub const restart_required_keys: []const []const u8 = &keys;
const keys = blk: {
var list: [countKeys()][]const u8 = undefined;
var index = 0;
for (@typeInfo(model.Config).@"struct".fields) |section_field| {
if (!isScalarSection(section_field.type)) continue;
for (@typeInfo(section_field.type).@"struct".fields) |field| {
if (isHidden(section_field.name, field.name)) continue;
if (isWriteOnly(section_field.name, field.name)) continue;
list[index] = section_field.name ++ "." ++ field.name;
index += 1;
}
}
break :blk list;
};
fn countKeys() usize {
var count = 0;
for (@typeInfo(model.Config).@"struct".fields) |section_field| {
if (!isScalarSection(section_field.type)) continue;
for (@typeInfo(section_field.type).@"struct".fields) |field| {
if (isHidden(section_field.name, field.name)) continue;
if (isWriteOnly(section_field.name, field.name)) continue;
count += 1;
}
}
return count;
}
/// The keys whose change waits for a restart: `bind` and `web_lifecycle`, the
/// two operations milestone 35 executes. The apply table is the authority.
pub const restart_required_keys = apply.restart_required_keys;
// ---------------------------------------------------------------------------
// the patch a PUT carries
@@ -184,32 +143,6 @@ fn merge(cfg: *model.Config, patch: Patch, bad_key: *[]const u8) bool {
return true;
}
/// Whether the patch names a key whose change waits for a restart. The key
/// table above is the authority, so a key that stops being restart-required
/// stops raising the flag without anyone remembering this function exists.
/// A patch carrying `web.password` alone touches no such key: the new hash is
/// installed live (ruling 17 of milestone 16).
fn touchesRestartRequiredKey(patch: Patch) bool {
inline for (@typeInfo(Patch).@"struct".fields) |section_field| {
if (@field(patch, section_field.name)) |section| {
inline for (@typeInfo(@TypeOf(section)).@"struct".fields) |field| {
if (@field(section, field.name) != null) {
if (comptime isRestartRequired(section_field.name ++ "." ++ field.name)) return true;
}
}
}
}
return false;
}
fn isRestartRequired(comptime key: []const u8) bool {
@setEvalBranchQuota(10_000);
for (keys) |listed| {
if (std.mem.eql(u8, listed, key)) return true;
}
return false;
}
/// Whether the patch carries a new password.
fn newPassword(patch: Patch) ?[]const u8 {
const web = patch.web orelse return null;
@@ -339,10 +272,11 @@ fn applyPut(
state.config_lock.lockUncancelable(io);
defer state.config_lock.unlock(io);
var cfg = mutations.loadConfig(arena, database) catch |err| switch (err) {
const stored = mutations.loadConfig(arena, database) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
else => return .{ .fail = .{ .internal = err } },
};
var cfg = stored;
var bad_key: []const u8 = "";
if (!merge(&cfg, patch, &bad_key)) {
@@ -366,15 +300,38 @@ fn applyPut(
const merged_hash = cfg.web.password_hash orelse "";
const hash_changed = password != null and !std.mem.eql(u8, previous_hash, merged_hash);
const replacement: ?[]u8 = if (hash_changed) try state.gpa.dupe(u8, merged_hash) else null;
errdefer if (replacement) |hash| state.gpa.free(hash);
// Prepare: one candidate per owner whose value actually moved, built from
// the merged configuration. Nothing is published and no row is written
// until every one of them succeeded.
const ops = apply.changedOperations(stored, cfg);
var plan: apply.Plan = .init(state, arena, cfg, ops);
if (try plan.prepare(io)) |failure| {
plan.abandon(io);
if (replacement) |hash| state.gpa.free(hash);
return .{ .fail = failure };
}
writeSettings(arena, database, cfg) catch |err| {
plan.abandon(io);
if (replacement) |hash| state.gpa.free(hash);
return .{ .fail = .{ .internal = err } };
};
plan.publish(io);
plan.retire(io);
// After the commit, never before: a validation failure or a write that
// rolled back changed nothing, so it owes nobody a restart.
if (touchesRestartRequiredKey(patch)) state.restart_pending.store(true, .monotonic);
// rolled back changed nothing, so it owes nobody a restart. Only the two
// operations milestone 35 executes raise it; everything else is already
// live by the time this line runs.
var restart_owed = false;
var it = ops.iterator();
while (it.next()) |operation| {
if (operation.needsRestart()) restart_owed = true;
}
if (restart_owed) state.restart_pending.store(true, .monotonic);
if (replacement) |hash| {
// Ruling 17, both halves: the running server must verify against the
@@ -546,11 +503,10 @@ fn respondSettings(request: *Request, status: std.http.Status, cfg: model.Config
const testing = std.testing;
const auth_handlers = @import("auth.zig");
test "the restart-required table lists every settings key and no secret" {
// `model.toSettings` is the other half of the same fact. The two lists are
// now equal rather than off by one: `toSettings` stopped emitting
// `web.password_hash` (milestone 20 ruling 4) and this table never listed
// it, so both exclude the hash and the plaintext.
test "the restart-required list is a strict subset of the settings keys" {
// `model.toSettings` is the key list this form writes. The restart list is
// now the small part of it that milestone 35 owns, so it must be shorter
// than the key list and every entry must still be a real key.
var pairs: std.ArrayList(model.SettingPair) = .empty;
defer {
model.freeSettings(testing.allocator, pairs.items);
@@ -558,17 +514,24 @@ test "the restart-required table lists every settings key and no secret" {
}
try model.toSettings(.{}, testing.allocator, &pairs);
try testing.expectEqual(pairs.items.len, restart_required_keys.len);
try testing.expect(restart_required_keys.len < pairs.items.len);
for (restart_required_keys) |key| {
try testing.expect(!std.mem.eql(u8, key, "web.password_hash"));
try testing.expect(!std.mem.eql(u8, key, "web.password"));
var is_a_setting = false;
for (pairs.items) |pair| {
if (std.mem.eql(u8, pair.key, key)) is_a_setting = true;
}
try testing.expect(is_a_setting);
}
var found_port = false;
var found_ttl = false;
for (restart_required_keys) |key| {
if (std.mem.eql(u8, key, "dns.port")) found_port = true;
if (std.mem.eql(u8, key, "blocking.ttl")) found_ttl = true;
}
try testing.expect(found_port);
// A live key must not appear: the UI renders no restart affordance for it.
try testing.expect(!found_ttl);
}
test "the read shape spells every enum the way the database does" {
@@ -806,20 +769,43 @@ test "a rejected patch raises no restart flag" {
try testing.expect(!bench.state.restart_pending.load(.monotonic));
}
test "only a patch naming a restart-required key raises the flag" {
var password_only: Patch = .{};
password_only.web = .{ .password = "correct horse battery staple" };
try testing.expect(!touchesRestartRequiredKey(password_only));
test "only a bind or web-lifecycle change raises the flag" {
var bench: mutations.Bench = undefined;
try bench.init(testing.allocator);
defer bench.deinit(testing.allocator);
try seeded(&bench);
var mixed: Patch = .{};
mixed.web = .{ .password = "correct horse battery staple", .port = 9090 };
try testing.expect(touchesRestartRequiredKey(mixed));
// A live key, however many of them: nothing to restart for.
var live: Patch = .{};
live.blocking = .{ .ttl = 30 };
live.disk = .{ .min_free_mb = 10, .warn_free_mb = 20 };
try testing.expect(try applyPut(&bench.state, bench.io(), bench.arena(), live) == .config);
try testing.expect(!bench.state.restart_pending.load(.monotonic));
var elsewhere: Patch = .{};
elsewhere.dns = .{ .port = 5353 };
try testing.expect(touchesRestartRequiredKey(elsewhere));
// `web.enabled` is the second of the two milestone-35 operations, and it
// executes nothing: the row moves and the flag rises.
var lifecycle: Patch = .{};
lifecycle.web = .{ .enabled = false };
try testing.expect(try applyPut(&bench.state, bench.io(), bench.arena(), lifecycle) == .config);
try testing.expect(bench.state.restart_pending.load(.monotonic));
try testing.expectEqual(
@as(i64, 1),
try bench.queryInt("SELECT count(*) FROM settings WHERE key = 'web.enabled' AND value = 'false'"),
);
}
try testing.expect(!touchesRestartRequiredKey(.{}));
test "a patch that changes nothing applies nothing" {
var bench: mutations.Bench = undefined;
try bench.init(testing.allocator);
defer bench.deinit(testing.allocator);
try seeded(&bench);
// The stored port is 53, and the admin form submits every field it read.
// Rewriting a value to itself is not a change and must not raise the flag.
var same: Patch = .{};
same.dns = .{ .port = 53 };
try testing.expect(try applyPut(&bench.state, bench.io(), bench.arena(), same) == .config);
try testing.expect(!bench.state.restart_pending.load(.monotonic));
}
test "a password-only put leaves the restart flag alone" {
+90 -29
View File
@@ -1,11 +1,13 @@
//! `/api/upstreams` — the resolvers nxdns forwards to.
//!
//! Ruling 9 makes this a resource like any other; ruling 12 makes it the one
//! mutable resource that is NOT live. The pool builds its clients, its health
//! state and its TLS material at startup, so an upstream added, edited or
//! removed here takes effect at the next restart. The response says so through
//! `restart_required`, which is the same word `/api/settings` uses, so the UI
//! has one banner and one meaning for it.
//! Ruling 9 makes this a resource like any other, and milestone 34 makes it
//! live like the rest of them: an upstream added, edited or removed here is
//! applied in-process. The row set the write will leave behind is built into a
//! candidate generation BEFORE the write, so a set that cannot produce clients
//! is refused with nothing changed; the candidate is published after the commit
//! and the displaced generation retires once the exchanges holding it finish.
//! `restart_required` in the response is therefore `false`, and stays in the
//! shape so the UI has one field with one meaning across every mutation.
//!
//! `tls_name` is the DoT-only SNI and certificate name (migration v2). It is
//! empty for every other scheme, and the validator refuses it there.
@@ -13,6 +15,8 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const apply = @import("apply.zig");
const db = @import("../../storage/db.zig");
const http_util = @import("../http_util.zig");
const model = @import("../../config/model.zig");
const mutations = @import("mutations.zig");
@@ -38,6 +42,34 @@ const Created = union(enum) { id: i64, fail: Failure };
// decisions
// ---------------------------------------------------------------------------
/// Prepares the generation the row set after `mutation` would produce, on a
/// caller that already holds `config_lock`. Returns the plan for the caller to
/// publish or abandon.
fn planFor(
state: *server.WebState,
io: std.Io,
arena: Allocator,
database: *db.Db,
mutation: apply.RowMutation,
) error{OutOfMemory}!union(enum) { plan: apply.Plan, fail: Failure } {
const cfg = mutations.loadConfig(arena, database) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
else => return .{ .fail = .{ .internal = err } },
};
const rows = upstreams_repo.listUpstreamRows(database, arena) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
else => return .{ .fail = .{ .internal = err } },
};
const servers = try apply.hypotheticalRows(arena, rows.items, mutation);
var plan: apply.Plan = .init(state, arena, cfg, .initEmpty());
if (try plan.prepareUpstreams(io, servers)) |failure| {
plan.abandon(io);
return .{ .fail = failure };
}
return .{ .plan = plan };
}
fn applyCreate(
state: *server.WebState,
io: std.Io,
@@ -48,14 +80,20 @@ fn applyCreate(
if (try mutations.checkUpstream(arena, item)) |problem| return .{ .fail = .{ .invalid = problem } };
state.config_lock.lockUncancelable(io);
const inserted = upstreams_repo.insertUpstreamRow(database, item);
state.config_lock.unlock(io);
defer state.config_lock.unlock(io);
const id = inserted catch |err| return .{ .fail = mutations.dbFailure(err, url_conflict) };
// Ruling 12: the pool is built at startup, so the row now stored governs
// nothing until the next one. Stored after the insert, never before — a
// rejected url or a conflict owes no restart.
state.restart_pending.store(true, .monotonic);
var plan = switch (try planFor(state, io, arena, database, .{ .add = item })) {
.fail => |failure| return .{ .fail = failure },
.plan => |p| p,
};
const id = upstreams_repo.insertUpstreamRow(database, item) catch |err| {
plan.abandon(io);
return .{ .fail = mutations.dbFailure(err, url_conflict) };
};
plan.publish(io);
plan.retire(io);
return .{ .id = id };
}
@@ -85,16 +123,27 @@ fn applyUpdate(
}
}
upstreams_repo.updateUpstream(database, id, item) catch |err|
var plan = switch (try planFor(state, io, arena, database, .{
.replace = .{ .id = id, .item = item },
})) {
.fail => |failure| return failure,
.plan => |p| p,
};
upstreams_repo.updateUpstream(database, id, item) catch |err| {
plan.abandon(io);
return mutations.dbFailure(err, url_conflict);
state.restart_pending.store(true, .monotonic);
};
plan.publish(io);
plan.retire(io);
return null;
}
/// The last enabled upstream cannot go: a resolver with nowhere to forward to
/// answers nothing, and `validate.validate` refuses that configuration at
/// startup — so allowing it here would only produce a box that will not boot.
fn applyDelete(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure {
fn applyDelete(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) error{OutOfMemory}!?Failure {
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
state.config_lock.lockUncancelable(io);
@@ -109,19 +158,28 @@ fn applyDelete(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?
},
}
upstreams_repo.deleteUpstream(database, id) catch |err|
var plan = switch (try planFor(state, io, arena, database, .{ .remove = id })) {
.fail => |failure| return failure,
.plan => |p| p,
};
upstreams_repo.deleteUpstream(database, id) catch |err| {
plan.abandon(io);
return mutations.dbFailure(err, url_conflict);
state.restart_pending.store(true, .monotonic);
};
plan.publish(io);
plan.retire(io);
return null;
}
const Remaining = union(enum) { missing, count: usize };
fn countEnabledExcept(
database: *@import("../../storage/db.zig").Db,
database: *db.Db,
arena: Allocator,
id: i64,
) @import("../../storage/db.zig").Error!Remaining {
) db.Error!Remaining {
const rows = try upstreams_repo.listUpstreamRows(database, arena);
var found = false;
var left: usize = 0;
@@ -166,7 +224,7 @@ pub fn create(state: *server.WebState, io: std.Io, request: *Request) HandlerErr
.priority = item.priority,
.enabled = item.enabled,
.tls_name = item.tls_name,
.restart_required = true,
.restart_required = false,
}, &.{}),
};
}
@@ -186,7 +244,7 @@ pub fn update(state: *server.WebState, io: std.Io, request: *Request) HandlerErr
.priority = item.priority,
.enabled = item.enabled,
.tls_name = item.tls_name,
.restart_required = true,
.restart_required = false,
}, &.{});
}
@@ -219,7 +277,7 @@ test "a created upstream is stored" {
try testing.expectEqualStrings("", row.tls_name);
}
test "an upstream change never announces a reload" {
test "an upstream change never announces a reload or a restart" {
var bench: mutations.Bench = undefined;
try bench.init(testing.allocator);
defer bench.deinit(testing.allocator);
@@ -251,7 +309,7 @@ test "a url the validator refuses never reaches the database" {
try testing.expectEqual(@as(i64, 0), try bench.queryInt("SELECT count(*) FROM upstreams"));
}
test "a refused upstream write owes no restart, and an accepted one does" {
test "no upstream write owes a restart, refused or accepted" {
var bench: mutations.Bench = undefined;
try bench.init(testing.allocator);
defer bench.deinit(testing.allocator);
@@ -264,11 +322,14 @@ test "a refused upstream write owes no restart, and an accepted one does" {
try testing.expect((try applyUpdate(&bench.state, bench.io(), bench.arena(), 999, doh)).? == .not_found);
try testing.expect(!bench.state.restart_pending.load(.monotonic));
try testing.expect(applyDelete(&bench.state, bench.io(), bench.arena(), 999).? == .not_found);
try testing.expect((try applyDelete(&bench.state, bench.io(), bench.arena(), 999)).? == .not_found);
try testing.expect(!bench.state.restart_pending.load(.monotonic));
// Milestone 34: an accepted write is applied in-process, so it owes no
// restart either. `restart_pending` now has exactly two sources, and
// neither of them is here.
_ = try applyCreate(&bench.state, bench.io(), bench.arena(), doh);
try testing.expect(bench.state.restart_pending.load(.monotonic));
try testing.expect(!bench.state.restart_pending.load(.monotonic));
}
test "a duplicate url is a conflict" {
@@ -287,7 +348,7 @@ test "the last enabled upstream cannot be deleted" {
defer bench.deinit(testing.allocator);
const created = try applyCreate(&bench.state, bench.io(), bench.arena(), doh);
const failure = applyDelete(&bench.state, bench.io(), bench.arena(), created.id);
const failure = try applyDelete(&bench.state, bench.io(), bench.arena(), created.id);
try testing.expectEqualStrings("the last enabled upstream cannot be removed", failure.?.conflict);
const second = try applyCreate(&bench.state, bench.io(), bench.arena(), .{
@@ -296,7 +357,7 @@ test "the last enabled upstream cannot be deleted" {
});
try testing.expectEqual(
@as(?Failure, null),
applyDelete(&bench.state, bench.io(), bench.arena(), created.id),
try applyDelete(&bench.state, bench.io(), bench.arena(), created.id),
);
try testing.expectEqual(
@as(i64, 1),
@@ -357,6 +418,6 @@ test "an id no upstream holds is a 404 on both update and delete" {
);
try testing.expectEqual(
Failure.not_found,
applyDelete(&bench.state, bench.io(), bench.arena(), 999).?,
(try applyDelete(&bench.state, bench.io(), bench.arena(), 999)).?,
);
}
+37 -16
View File
@@ -34,6 +34,7 @@ const events_mod = @import("../storage/events.zig");
const http_util = @import("http_util.zig");
const logging = @import("../platform/logging.zig");
const pool_mod = @import("../upstream/pool.zig");
const upstream_owner = @import("../upstream/owner.zig");
const rate_limiter = @import("../server/rate_limiter.zig");
const retention_mod = @import("../storage/retention.zig");
const safe_url = @import("../safe_url.zig");
@@ -187,28 +188,36 @@ pub fn collect(state: *server.WebState, io: std.Io, arena: Allocator) Allocator.
if (state.handler) |handler| {
sample.dns = dnsCounters(&handler.stats);
if (handler.cache) |cache| {
// The pointer is loaded under the same mutex a query loads it under,
// never before it: a live `cache.size` or `dns.rate_limit` change
// frees the object it displaced as soon as the swap returns.
{
handler.cache_mutex.lockUncancelable(io);
defer handler.cache_mutex.unlock(io);
sample.cache = .{
if (handler.cache) |cache| sample.cache = .{
.stats = cache.stats,
.entries = cache.len(),
.memory_bytes = cache.memoryBytes(),
};
}
if (handler.limiter) |limiter| {
{
handler.limiter_mutex.lockUncancelable(io);
defer handler.limiter_mutex.unlock(io);
sample.limiter = .{ .stats = limiter.stats, .tracked_clients = limiter.table.count() };
if (handler.limiter) |limiter| {
sample.limiter = .{ .stats = limiter.stats, .tracked_clients = limiter.table.count() };
}
}
}
if (state.logger) |logger| sample.logger = .{
.queries_dropped = logger.queries_dropped.load(.monotonic),
.rows_written = logger.rows_written.load(.monotonic),
.batches_gated = logger.batches_gated.load(.monotonic),
};
if (state.logger) |controller| {
const reading = controller.sample(io);
sample.logger = .{
.queries_dropped = reading.queries_dropped,
.rows_written = reading.rows_written,
.batches_gated = reading.batches_gated,
};
}
if (state.tracker) |tracker| sample.tracker = .{
.stats = tracker.snapshotStats(io),
@@ -259,7 +268,11 @@ pub fn collect(state: *server.WebState, io: std.Io, arena: Allocator) Allocator.
sample.udp_listener = sumListeners(udp_server.Snapshot, udp_server.UdpServer, state.udp_listeners);
sample.tcp_listener = sumListeners(tcp_server.Snapshot, tcp_server.TcpServer, state.tcp_listeners);
if (state.pool) |pool| sample.upstreams = try upstreams(pool, io, arena);
if (state.upstreams) |owner| {
const generation = owner.acquire(io);
defer owner.release(io, generation);
if (generation.pool) |pool| sample.upstreams = try upstreams(pool, io, arena);
}
return sample;
}
@@ -700,18 +713,23 @@ fn writeLabelValue(w: *std.Io.Writer, value: []const u8) std.Io.Writer.Error!voi
const db = @import("../storage/db.zig");
const local_tables = @import("../server/local_tables.zig");
const logger_controller = @import("../storage/logger_controller.zig");
const logger_mod = @import("../storage/logger.zig");
const migrations = @import("../storage/migrations.zig");
const transport = @import("../upstream/transport.zig");
const testing = std.testing;
/// Backs `testHandler`'s owner. File-scope because the handler is returned by
/// value and its `*Owner` has to outlive the return; nothing here exchanges, so
/// the generation behind it is never acquired.
var unreachable_upstream: upstream_owner.Borrowed = .{};
/// A handler with no upstream reachable: every test here reads counters and
/// never runs a query.
fn testHandler() dns_handler.Handler {
return .{
.upstream = .{ .ptr = undefined, .exchangeFn = undefined },
.blocking = .{ .mode = .zero, .ttl = 5 },
.forward_read_timeout = .{ .raw = .fromMilliseconds(50), .clock = .awake },
.upstream = unreachable_upstream.client(.{ .ptr = undefined, .exchangeFn = undefined }),
.policy = .{ .blocking = .{ .mode = .zero, .ttl = 5 }, .forward_read_timeout = .{ .raw = .fromMilliseconds(50), .clock = .awake } },
};
}
@@ -1600,9 +1618,12 @@ test "collect reads the live counters of the components it is given" {
var queue_buf: [4]logger_mod.Entry = undefined;
var query_logger: logger_mod.Logger = .init(.{}, &queue_buf);
query_logger.rows_written.store(90, .monotonic);
var log_owner: logger_controller.Borrowed = .{};
var tracker: clients.Tracker = .init(30);
var retention: retention_mod.Retention = .init(.{});
var tracker_days: retention_mod.RetentionDays = .init(30);
var tracker: clients.Tracker = .init(&tracker_days);
var retention_days: retention_mod.RetentionDays = .init(30);
var retention: retention_mod.Retention = .init(&retention_days);
var tables: local_tables.LocalTables = .empty;
var names: client_names.Resolver = .init(&tables);
names.stats.no_zone = 4;
@@ -1611,7 +1632,7 @@ test "collect reads the live counters of the components it is given" {
var state: server.WebState = .{
.gpa = testing.allocator,
.handler = &handler,
.logger = &query_logger,
.logger = log_owner.over(&query_logger),
.tracker = &tracker,
.client_names = &names,
.retention = &retention,
+30 -19
View File
@@ -26,9 +26,11 @@ info:
Prometheus scrape can never be throttled; `/api/queries/live` is
exempt because one long-lived stream is bounded by
`web.sse_max_connections_per_ip` instead.
- Mutations to groups, blocklists, rules, local records, forward zones,
clients and client prefixes take effect live. Upstreams and
`/api/settings` are restart-required.
- Mutations take effect live, including upstreams and `/api/settings`:
the owner of every changed setting is told in-process by the write
that changes it. The exceptions are the keys that create or destroy a
socket — the listener addresses and ports, and `web.enabled` — which
`/api/settings` reports in its `restart_required` list.
- nxdns runs under one of two configuration authorities. Started with
`--config=<file>`, that file is the sole declarative source, and every
operation that writes configuration answers 403 with the same error
@@ -1544,7 +1546,7 @@ paths:
$ref: "#/components/responses/Unavailable"
post:
summary: Add an upstream
description: Restart-required; the running pool is not changed.
description: Applies live; the resolver pool is rebuilt in-process.
requestBody:
required: true
content:
@@ -1553,7 +1555,7 @@ paths:
$ref: "#/components/schemas/UpstreamInput"
responses:
"201":
description: Created; takes effect on restart.
description: Created; live at once.
content:
application/json:
schema:
@@ -1599,7 +1601,7 @@ paths:
$ref: "#/components/responses/Unavailable"
put:
summary: Update an upstream
description: Restart-required; the running pool is not changed.
description: Applies live; the resolver pool is rebuilt in-process.
requestBody:
required: true
content:
@@ -1608,7 +1610,7 @@ paths:
$ref: "#/components/schemas/UpstreamInput"
responses:
"200":
description: Updated; takes effect on restart.
description: Updated; live at once.
content:
application/json:
schema:
@@ -1636,7 +1638,7 @@ paths:
description: The last enabled upstream cannot be removed (409).
responses:
"204":
description: Deleted; takes effect on restart.
description: Deleted; live at once.
"401":
$ref: "#/components/responses/Unauthorized"
"403":
@@ -1701,10 +1703,10 @@ paths:
summary: Read the scalar settings
description: |
Every stored settings key, plus the derived `web.auth_enabled`.
`restart_required` lists every key, because all scalar settings are
restart-required this milestone; live behavior comes from the
resource endpoints and `/api/pause`. Passwords and hashes are never
serialized.
`restart_required` lists only the keys that create or destroy a
socket — the DNS, web, DoH and DoT bind addresses, ports and
enabled flags. Every other setting is applied in-process by the
write that changes it. Passwords and hashes are never serialized.
responses:
"200":
description: The settings and the restart-required key list.
@@ -2756,8 +2758,12 @@ components:
enabled: { type: boolean }
tls_name: { type: string }
restart_required:
description: |
Always false: an upstream write rebuilds the resolver pool
in-process. The field stays in the shape so every mutation
response answers the same question the same way.
type: boolean
enum: [true]
enum: [false]
Pause:
type: object
@@ -2922,9 +2928,12 @@ components:
type: array
items: { type: string }
description: |
Every `section.field` key that needs a restart to take effect
currently all of them. Whether a restart is *owed* right now is
process state, and lives on `/api/config/status`.
The `section.field` keys that need a restart to take effect: the
DNS, web, DoH and DoT bind addresses, ports and enabled flags,
and nothing else. Every key absent from this list is applied
in-process by the write that changes it. Whether a restart is
*owed* right now is process state, and lives on
`/api/config/status`.
ConfigStatus:
type: object
@@ -2963,9 +2972,11 @@ components:
type: boolean
description: |
True once this process has committed a configuration change that
takes effect only at the next start — an upstream write or a
settings key. Nothing clears it but process exit, and it is
never persisted, so a false after a restart is the truth.
takes effect only at the next start: a DNS, web, DoH or DoT bind
address, port or enabled flag. No other write raises it —
everything else, upstreams included, is applied in-process.
Nothing clears it but process exit, and it is never persisted,
so a false after a restart is the truth.
SettingsPatch:
type: object
+158 -5
View File
@@ -36,11 +36,11 @@ const events_mod = @import("../storage/events.zig");
const http_util = @import("http_util.zig");
const listener_core = @import("../server/listener.zig");
const local_tables_mod = @import("../server/local_tables.zig");
const logger_mod = @import("../storage/logger.zig");
const logger_controller = @import("../storage/logger_controller.zig");
const manager_mod = @import("../filter/manager.zig");
const model = @import("../config/model.zig");
const pause_mod = @import("../server/pause.zig");
const pool_mod = @import("../upstream/pool.zig");
const upstream_owner = @import("../upstream/owner.zig");
const query_sink = @import("../server/query_sink.zig");
const retention_mod = @import("../storage/retention.zig");
const router = @import("router.zig");
@@ -115,9 +115,75 @@ pub const Authority = union(enum) {
managed_file: []const u8,
};
/// `web.trusted_proxies`, live. The boot text is borrowed from the loaded
/// configuration; every replacement is an owned, immutable generation that a
/// reader holds a shared lock on for as long as it borrows the text.
///
/// Taking the exclusive lock is what drains the readers: once `install`
/// returns, nothing holds the generation it hands back, so the caller can free
/// it. The free itself belongs to the caller and not to this type, because a
/// publish must not do work that a retire owns.
pub const LiveProxies = struct {
lock: std.Io.RwLock = .init,
text: []const u8 = "",
owned: bool = false,
pub fn init(boot_text: []const u8) LiveProxies {
return .{ .text = boot_text };
}
/// The reader's hold. Release it, and do not retain `text` afterwards.
pub const Handle = struct {
text: []const u8,
live: *LiveProxies,
pub fn release(self: Handle, io: std.Io) void {
self.live.lock.unlockShared(io);
}
};
pub fn acquire(self: *LiveProxies, io: std.Io) Handle {
self.lock.lockSharedUncancelable(io);
return .{ .text = self.text, .live = self };
}
/// Takes ownership of `prepared`, which must be a `gpa` allocation, and
/// returns the generation it replaced for the caller to free — or null
/// when what it replaced was the borrowed boot text.
pub fn install(self: *LiveProxies, io: std.Io, prepared: []const u8) ?[]const u8 {
self.lock.lockUncancelable(io);
defer self.lock.unlock(io);
const retired: ?[]const u8 = if (self.owned) self.text else null;
self.text = prepared;
self.owned = true;
return retired;
}
pub fn deinit(self: *LiveProxies, gpa: Allocator) void {
if (self.owned) gpa.free(self.text);
self.* = undefined;
}
};
/// The long-lived collaborators `upstream_owner.build` needs, which are the
/// composition root's and not the request's: the shared HTTP client every DoH
/// leaf borrows and the one certificate bundle every DoT leaf verifies against.
pub const UpstreamBuild = struct {
http: *std.http.Client,
bundle: *std.crypto.Certificate.Bundle,
bundle_lock: *std.Io.RwLock,
};
pub const WebState = struct {
gpa: Allocator,
web: model.Web = .{},
/// The live `web.trusted_proxies`. `web` above is the boot configuration
/// and goes stale the moment `PUT /api/settings` changes the list, exactly
/// as it does for the password: the request path reads this holder and
/// never `web.trusted_proxies`. The composition root seeds it from the boot
/// value, and whoever owns the `WebState` calls `proxies.deinit`.
proxies: LiveProxies = .{},
/// Defaults to `.database`: a `WebState` nobody told about a managed file
/// governs nothing declaratively, which is the safe reading — the mutation
@@ -141,14 +207,28 @@ pub const WebState = struct {
/// The learned-name resolver, for `metrics.collect` (milestone-25 ruling 9).
client_names: ?*client_names.Resolver = null,
manager: ?*manager_mod.Manager = null,
pool: ?*pool_mod.Pool = null,
/// The published upstream generation. A metrics or health scrape pins one
/// for the length of its read, so a `replace` cannot free the pool it is
/// copying out of.
upstreams: ?*upstream_owner.Owner = null,
/// What building a replacement upstream generation needs beyond the rows
/// themselves. Null in a state whose upstream owner is a test's borrowed
/// one: there is then nothing to build, and an upstream mutation applies to
/// the database alone.
upstream_build: ?UpstreamBuild = null,
monitor: ?*disk_monitor.Monitor = null,
/// The local records and forward zones the DNS path reads. The
/// local-records and forward-zones handlers rebuild and swap them
/// (ruling 12).
local_tables: ?*local_tables_mod.LocalTables = null,
logger: ?*logger_mod.Logger = null,
/// The query logger's controller, not a `Logger`: a resize replaces the
/// generation, and the counters a scrape reads are the controller's.
logger: ?*logger_controller.Controller = null,
retention: ?*retention_mod.Retention = null,
/// The one `logging.retention_days` cell both prune passes read. Separate
/// from `retention` above, which is one of the two readers: a settings
/// apply stores here and moves both of them together.
retention_days: ?*retention_mod.RetentionDays = null,
sessions: ?*auth.Sessions = null,
/// The password hash every auth decision reads. `web` above is the boot
/// configuration and goes stale the moment `PUT /api/settings` changes the
@@ -530,7 +610,15 @@ pub const Server = struct {
const peer = address.NetAddress.fromIp(conn.peer);
const forwarded_for = copyHeaderSuffix(request, "x-forwarded-for", &conn.payload.xff_buf);
const client_addr = switch (clientAddr(self.state.web.trusted_proxies, peer, forwarded_for)) {
// The hold ends with the verdict, before dispatch: a settings PUT takes
// the exclusive lock from inside its own request, so a hold that lasted
// the request would be that request waiting on itself.
const verdict = blk: {
const proxies = self.state.proxies.acquire(io);
defer proxies.release(io);
break :blk clientAddr(proxies.text, peer, forwarded_for);
};
const client_addr = switch (verdict) {
.addr => |addr| addr,
.bad_forwarded_for => {
var view = bareRequest(request, conn, arena);
@@ -882,3 +970,68 @@ test "a trusted-proxy element that is not an IP literal trusts nobody" {
try testing.expect(trustsPeer("proxy.example, 10.0.0.1", peer));
try testing.expect(!trustsPeer("", peer));
}
/// Every generation this test installs trusts `10.0.0.1` and nothing else, so
/// a reader that ever disagrees read a generation that was already freed.
const proxy_generations = [_][]const u8{
"10.0.0.1",
"10.0.0.1, 10.0.0.2",
"10.0.0.1,fd00::1,10.0.0.3",
" 10.0.0.1 ",
};
fn readProxiesRepeatedly(live: *LiveProxies, io: std.Io, rounds: usize, disagreed: *bool) void {
const peer = ip("10.0.0.1");
const stranger = ip("198.51.100.7");
for (0..rounds) |_| {
const held = live.acquire(io);
defer held.release(io);
if (!trustsPeer(held.text, peer)) disagreed.* = true;
if (trustsPeer(held.text, stranger)) disagreed.* = true;
}
}
test "trusted proxies are replaced under concurrent request-path reads" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var live: LiveProxies = .init(proxy_generations[0]);
defer live.deinit(testing.allocator);
var disagreed = false;
var reader = try io.concurrent(readProxiesRepeatedly, .{ &live, io, 2_000, &disagreed });
for (0..2_000) |i| {
const prepared = try testing.allocator.dupe(u8, proxy_generations[i % proxy_generations.len]);
// Publish, then retire: `install` returns only once no reader holds
// what it replaced, which is what makes this free safe.
if (live.install(io, prepared)) |retired| testing.allocator.free(retired);
}
reader.await(io);
try testing.expect(!disagreed);
}
test "the boot text is borrowed and the first install is what starts owning" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var boot_text: [8]u8 = "10.0.0.1".*;
var live: LiveProxies = .init(&boot_text);
defer live.deinit(testing.allocator);
// Nothing to retire: the boot text belongs to the loaded configuration.
const first = try testing.allocator.dupe(u8, "10.0.0.2");
try testing.expect(live.install(io, first) == null);
const second = try testing.allocator.dupe(u8, "10.0.0.3");
const retired = live.install(io, second).?;
try testing.expectEqualStrings("10.0.0.2", retired);
testing.allocator.free(retired);
const held = live.acquire(io);
defer held.release(io);
try testing.expectEqualStrings("10.0.0.3", held.text);
}
File diff suppressed because it is too large Load Diff