//! 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, .sized(self.cfg.cache.size)) 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 // above, which reads only the copies taken at prepare. // // `retireDisplaced`, not `retire`: the teardown of a displaced // generation is also when its `upstream.exchange` episodes are // reconciled, and this path and a reader's release are the two ways // a generation reaches it. The `configuration.load` half above // stays here, because that code is shared with the boot collector // and its rule is scoped. if (self.retired_upstream) |old| self.state.upstreams.?.retireDisplaced(io, old); } 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(©, "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); }