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
+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 = ©
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);
}