875 lines
35 KiB
Zig
875 lines
35 KiB
Zig
//! 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);
|
|
}
|