db-mode config changes apply live in-process
Gates / frontend (push) Successful in 1m43s
Gates / test (push) Successful in 2m14s
Gates / test-aarch64 (push) Successful in 8m3s
Gates / package (push) Successful in 5m42s
Gates / container (push) Successful in 54s
CI / gates (push) Successful in 50m24s
Gates / frontend (push) Successful in 1m43s
Gates / test (push) Successful in 2m14s
Gates / test-aarch64 (push) Successful in 8m3s
Gates / package (push) Successful in 5m42s
Gates / container (push) Successful in 54s
CI / gates (push) Successful in 50m24s
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:
+218
-301
@@ -28,7 +28,6 @@ const Allocator = std.mem.Allocator;
|
||||
const Certificate = std.crypto.Certificate;
|
||||
const Writer = std.Io.Writer;
|
||||
const net = std.Io.net;
|
||||
const tls = std.crypto.tls;
|
||||
|
||||
const api_limiter = @import("web/api_limiter.zig");
|
||||
const auth = @import("web/auth.zig");
|
||||
@@ -40,9 +39,7 @@ const config_export = @import("config/export.zig");
|
||||
const db = @import("storage/db.zig");
|
||||
const disk_monitor = @import("storage/disk_monitor.zig");
|
||||
const dns_cache = @import("cache/dns_cache.zig");
|
||||
const doh_client = @import("upstream/doh_client.zig");
|
||||
const doh_server = @import("server/doh_server.zig");
|
||||
const dot_client = @import("upstream/dot_client.zig");
|
||||
const dot_server = @import("server/dot_server.zig");
|
||||
const events = @import("storage/events.zig");
|
||||
const faults = @import("config/faults.zig");
|
||||
@@ -53,26 +50,25 @@ const http_util = @import("web/http_util.zig");
|
||||
const loader = @import("config/loader.zig");
|
||||
const local_records = @import("local/records.zig");
|
||||
const local_tables = @import("server/local_tables.zig");
|
||||
const logger_mod = @import("storage/logger.zig");
|
||||
const logger_controller = @import("storage/logger_controller.zig");
|
||||
const logging = @import("platform/logging.zig");
|
||||
const manager_mod = @import("filter/manager.zig");
|
||||
const migrations = @import("storage/migrations.zig");
|
||||
const model = @import("config/model.zig");
|
||||
const pause = @import("server/pause.zig");
|
||||
const pool_mod = @import("upstream/pool.zig");
|
||||
const queries_repo = @import("storage/repositories/queries_repo.zig");
|
||||
const query_sink = @import("server/query_sink.zig");
|
||||
const querylog_schema = @import("storage/querylog_schema.zig");
|
||||
const rate_limiter = @import("server/rate_limiter.zig");
|
||||
const reconcile = @import("config/reconcile.zig");
|
||||
const retention_mod = @import("storage/retention.zig");
|
||||
const safe_url = @import("safe_url.zig");
|
||||
const shutdown = @import("server/shutdown.zig");
|
||||
const sse = @import("web/sse.zig");
|
||||
const static = @import("web/static.zig");
|
||||
const tcp_server = @import("server/tcp_server.zig");
|
||||
const transport = @import("upstream/transport.zig");
|
||||
const udp_server = @import("server/udp_server.zig");
|
||||
const upstream_owner = @import("upstream/owner.zig");
|
||||
const validate = @import("config/validate.zig");
|
||||
const version = @import("version.zig");
|
||||
const web_server = @import("web/server.zig");
|
||||
@@ -89,11 +85,6 @@ const maintenance_interval_s = 60;
|
||||
/// takes longer than this is not going to finish at all.
|
||||
const download_budget_s = 300;
|
||||
|
||||
/// Per DoH upstream. The sizes live in `doh_client.zig` so that `nxdns check`
|
||||
/// probes the buffers `nxdns run` serves with.
|
||||
const doh_request_buf_len = doh_client.default_request_buf_len;
|
||||
const doh_transfer_buf_len = doh_client.default_transfer_buf_len;
|
||||
|
||||
pub fn run(runner: cli.Runner, args: cli.RunArgs) u8 {
|
||||
const code = serve(runner, args) catch |err| code: {
|
||||
runner.err.print("nxdns run failed: {s}\n", .{@errorName(err)}) catch {};
|
||||
@@ -220,17 +211,13 @@ fn reconcileFromFileAt(
|
||||
return result;
|
||||
}
|
||||
|
||||
/// An upstream's identity is its url: the whole url is the key, and the
|
||||
/// redaction is the label, because a url can carry an account token.
|
||||
/// Boot's replay of one upstream finding. The rendering is the owner's, shared
|
||||
/// with the runtime reconciler so a warning raised at boot and the same warning
|
||||
/// raised by a settings PUT are byte-identical.
|
||||
fn noteUpstream(config_load: *ConfigLoad, url: []const u8, message: []const u8) void {
|
||||
var label_buf: [events.Store.max_subject_label_len]u8 = undefined;
|
||||
const label = std.fmt.bufPrint(&label_buf, "{f}", .{safe_url.redact(url)}) catch &label_buf;
|
||||
var detail_buf: [events.Store.max_detail_len]u8 = undefined;
|
||||
const detail = std.fmt.bufPrint(&detail_buf, "upstream {f} {s}", .{
|
||||
safe_url.redactQuoted(url),
|
||||
message,
|
||||
}) catch &detail_buf;
|
||||
config_load.note(url, label, detail);
|
||||
var rendered: upstream_owner.Rendered = .{};
|
||||
rendered.render(.{ .url = url, .message = message });
|
||||
config_load.note(url, rendered.label, rendered.detail);
|
||||
}
|
||||
|
||||
/// Returns the moment the transaction committed, which is what the settings
|
||||
@@ -529,49 +516,50 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
|
||||
defer bundle.deinit(gpa);
|
||||
var bundle_lock: std.Io.RwLock = .init;
|
||||
|
||||
var upstreams = try Upstreams.build(io, gpa, cfg.upstreams, &dns_http, &bundle, &bundle_lock, &config_load);
|
||||
defer upstreams.deinit(io, gpa);
|
||||
|
||||
var pool: pool_mod.Pool = .init(
|
||||
upstreams.active(),
|
||||
.{},
|
||||
.{
|
||||
const upstream_generation = try upstream_owner.build(.{
|
||||
.gpa = gpa,
|
||||
.io = io,
|
||||
.servers = cfg.upstreams,
|
||||
.http = &dns_http,
|
||||
.bundle = &bundle,
|
||||
.bundle_lock = &bundle_lock,
|
||||
.timeouts = .{
|
||||
.attempt = .{ .raw = model.attemptTimeout(cfg.upstream), .clock = .awake },
|
||||
.total = .{ .raw = model.totalTimeout(cfg.upstream), .clock = .awake },
|
||||
},
|
||||
@truncate(@as(u96, @bitCast(std.Io.Clock.real.now(io).nanoseconds))),
|
||||
);
|
||||
.seed = @truncate(@as(u96, @bitCast(std.Io.Clock.real.now(io).nanoseconds))),
|
||||
.diagnostics = event_store,
|
||||
});
|
||||
|
||||
pool.diagnostics = event_store;
|
||||
// `build` writes no event rows, so that a settings PUT can prepare a
|
||||
// candidate that is never published without leaving a trace. Boot has no
|
||||
// such candidate: this generation is the one that serves, and replaying its
|
||||
// report through the collector is what keeps the `configuration.load`
|
||||
// episodes — and the keys `finalize` below spares — exactly what they were
|
||||
// when this composition lived in this file.
|
||||
for (upstream_generation.report().notes) |finding| {
|
||||
noteUpstream(&config_load, finding.url, finding.message);
|
||||
}
|
||||
const upstream_count = upstream_generation.activeCount();
|
||||
|
||||
var upstreams: upstream_owner.Owner = .init(upstream_generation);
|
||||
defer upstreams.deinit(io);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// per-query state
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
var cache: dns_cache.DnsCache = try .init(gpa, cfg.cache);
|
||||
defer cache.deinit();
|
||||
|
||||
var limiter: rate_limiter.RateLimiter = try .init(gpa, .{
|
||||
.limit = cfg.dns.rate_limit,
|
||||
.window_seconds = cfg.dns.rate_window_seconds,
|
||||
});
|
||||
defer limiter.deinit();
|
||||
|
||||
var paused: pause.Pause = .{};
|
||||
var tracker: clients.Tracker = .init(cfg.logging.retention_days);
|
||||
// One cell for both retention consumers, owned here so a settings apply
|
||||
// moves the daily query-log prune and the stale-client prune together.
|
||||
var retention_days: retention_mod.RetentionDays = .init(cfg.logging.retention_days);
|
||||
var tracker: clients.Tracker = .init(&retention_days);
|
||||
tracker.diagnostics = event_store;
|
||||
// Naming rides the tracker's pass, on the tracker's task and connection
|
||||
// (milestone-25 ruling 1), and reads the live forward zones.
|
||||
var client_names_resolver: client_names.Resolver = .init(&tables);
|
||||
client_names_resolver.diagnostics = event_store;
|
||||
|
||||
// The queue holds waiting tasks in intrusive lists, so neither the buffer
|
||||
// nor the `Logger` may move once a task has touched either.
|
||||
const queue_buf = try gpa.alloc(logger_mod.Entry, cfg.logging.query_log_buffer_max);
|
||||
defer gpa.free(queue_buf);
|
||||
var query_logger: logger_mod.Logger = .init(cfg.logging, queue_buf);
|
||||
query_logger.diagnostics = event_store;
|
||||
|
||||
// Milestone 8 fans every logged query out to the SSE hub as well. The hub
|
||||
// exists only when the web interface does (ruling 6) — without it the sink
|
||||
// costs the query path one null check. Its rings are ~900 KiB, so it lives
|
||||
@@ -584,18 +572,20 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
|
||||
created.init();
|
||||
hub = created;
|
||||
}
|
||||
var sink: query_sink.QuerySink = .init(&query_logger, hub);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// disk, retention and the remaining connections (ruling 21)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
const data_path = try arena.dupeZ(u8, paths.data_dir);
|
||||
const log_dir_path: ?[:0]const u8 = if (cfg.logging.output == .file)
|
||||
try arena.dupeZ(u8, std.fs.path.dirname(cfg.logging.file_path) orelse ".")
|
||||
const log_dir_path: ?[:0]const u8 = if (logging.logDirname(cfg.logging)) |dir|
|
||||
try arena.dupeZ(u8, dir)
|
||||
else
|
||||
null;
|
||||
var monitor: disk_monitor.Monitor = .init(cfg.disk, data.dir, data_path, log_dir_path);
|
||||
// Frees whatever log-directory generation a settings apply installed; boot's
|
||||
// path is borrowed from the arena and owned by nobody here.
|
||||
defer monitor.deinit(io);
|
||||
|
||||
// Ruling 17. The scheduler consults it before every scheduled pass; the
|
||||
// startup `reload` below is an operator action and stays ungated.
|
||||
@@ -618,13 +608,31 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
|
||||
// policy and the right one here too.
|
||||
monitor.sample(io, event_store, boot_now_s);
|
||||
|
||||
var retention: retention_mod.Retention = .init(cfg.logging);
|
||||
var retention: retention_mod.Retention = .init(&retention_days);
|
||||
|
||||
var querylog_opened = try data.openQuerylogDb(io);
|
||||
var querylog_writer_db = querylog_opened.database;
|
||||
defer querylog_writer_db.close();
|
||||
|
||||
reportQuerylogRecreated(event_store, io, boot_now_s, &querylog_opened, &querylog_writer_db);
|
||||
|
||||
// The controller adopts that first connection and owns the query logger
|
||||
// from here: the buffer, the `Logger`, the writer task and the connection
|
||||
// are one generation, and `logging.query_log_buffer_max` can replace all
|
||||
// four while the server runs (milestone 34 §S4). Its writer starts now and
|
||||
// parks on an empty queue, which is where it would be anyway — no producer
|
||||
// exists until the listeners below start serving.
|
||||
var log_controller: logger_controller.Controller = undefined;
|
||||
try log_controller.init(io, .{
|
||||
.gpa = gpa,
|
||||
.database = querylog_writer_db,
|
||||
.source = .{ .dir = std.Io.Dir.cwd(), .path = data.querylog_db_path },
|
||||
.logging = cfg.logging,
|
||||
.monitor = &monitor,
|
||||
.diagnostics = event_store,
|
||||
});
|
||||
defer log_controller.deinit(io);
|
||||
var sink: query_sink.QuerySink = .init(&log_controller, hub);
|
||||
|
||||
var querylog_retention_db = try data.reopenQuerylogDb(io);
|
||||
defer querylog_retention_db.close();
|
||||
var tracker_db = try data.openConfigDb(io);
|
||||
@@ -683,20 +691,56 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
|
||||
// listeners
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// Both are on the heap and both are freed through the handler rather than
|
||||
// through this frame: a `cache.size` or `dns.rate_limit` change swaps in a
|
||||
// replacement built with this same `gpa` and frees what it displaced, so
|
||||
// what the handler holds at shutdown is not necessarily what boot built.
|
||||
// Both are built inside one block so their errdefers end with it: after
|
||||
// the block the handler is the sole owner, and the teardown defers below
|
||||
// are what free them. An errdefer that outlived the block would free the
|
||||
// same object those defers free.
|
||||
const both = blk: {
|
||||
const c = try gpa.create(dns_cache.DnsCache);
|
||||
errdefer gpa.destroy(c);
|
||||
c.* = try .init(gpa, cfg.cache);
|
||||
errdefer c.deinit();
|
||||
|
||||
const l = try gpa.create(rate_limiter.RateLimiter);
|
||||
errdefer gpa.destroy(l);
|
||||
l.* = try .init(gpa, .{
|
||||
.limit = cfg.dns.rate_limit,
|
||||
.window_seconds = cfg.dns.rate_window_seconds,
|
||||
});
|
||||
break :blk .{ .cache = c, .limiter = l };
|
||||
};
|
||||
const cache = both.cache;
|
||||
const limiter = both.limiter;
|
||||
|
||||
var h: handler.Handler = .{
|
||||
.upstream = pool.client(),
|
||||
.blocking = .{ .mode = cfg.blocking.response, .ttl = cfg.blocking.ttl },
|
||||
.ecs_mode = cfg.edns.ecs_mode,
|
||||
.forward_read_timeout = .{ .raw = model.readTimeout(cfg.upstream), .clock = .awake },
|
||||
.upstream = &upstreams,
|
||||
.policy = .{
|
||||
.blocking = .{ .mode = cfg.blocking.response, .ttl = cfg.blocking.ttl },
|
||||
.ecs_mode = cfg.edns.ecs_mode,
|
||||
.forward_read_timeout = .{ .raw = model.readTimeout(cfg.upstream), .clock = .awake },
|
||||
.negative_ttl_max = cfg.cache.negative_ttl_max,
|
||||
},
|
||||
.manager = &manager,
|
||||
.local_tables = &tables,
|
||||
.cache = &cache,
|
||||
.negative_ttl_max = cfg.cache.negative_ttl_max,
|
||||
.limiter = &limiter,
|
||||
.cache = cache,
|
||||
.limiter = limiter,
|
||||
.sink = &sink,
|
||||
.pause = &paused,
|
||||
.tracker = &tracker,
|
||||
};
|
||||
// These run after `group.cancel` below, so no query is inside either table.
|
||||
defer if (h.replaceCache(io, null)) |live| {
|
||||
live.deinit();
|
||||
gpa.destroy(live);
|
||||
};
|
||||
defer if (h.replaceRateLimiter(io, null)) |live| {
|
||||
live.deinit();
|
||||
gpa.destroy(live);
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// DoH/DoT listeners (milestone-10 ruling 11)
|
||||
@@ -770,9 +814,13 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
|
||||
// The live hash may own a gpa replacement after a settings PUT; this defer
|
||||
// runs after `group.cancel` below, so no web task can still read it.
|
||||
defer web_state.live_hash.deinit(gpa);
|
||||
// Same argument as the live hash: a settings PUT may have installed an
|
||||
// owned generation, and this runs after `group.cancel`.
|
||||
defer web_state.proxies.deinit(gpa);
|
||||
if (cfg.web.enabled) web_state = .{
|
||||
.gpa = gpa,
|
||||
.web = cfg.web,
|
||||
.proxies = .init(cfg.web.trusted_proxies),
|
||||
.authority = authority,
|
||||
.reconciled_at = reconciled_at,
|
||||
.live_hash = .init(cfg.web.password_hash orelse ""),
|
||||
@@ -781,11 +829,17 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
|
||||
.tracker = &tracker,
|
||||
.client_names = &client_names_resolver,
|
||||
.manager = &manager,
|
||||
.pool = &pool,
|
||||
.upstreams = &upstreams,
|
||||
.upstream_build = .{
|
||||
.http = &dns_http,
|
||||
.bundle = &bundle,
|
||||
.bundle_lock = &bundle_lock,
|
||||
},
|
||||
.monitor = &monitor,
|
||||
.local_tables = &tables,
|
||||
.logger = &query_logger,
|
||||
.logger = &log_controller,
|
||||
.retention = &retention,
|
||||
.retention_days = &retention_days,
|
||||
.sessions = if (sessions) |*s| s else null,
|
||||
.limiter = if (web_limiter) |*l| l else null,
|
||||
.hub = hub,
|
||||
@@ -894,25 +948,20 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
|
||||
// (disk_monitor.zig:63), so nothing is refused for want of a sample.
|
||||
const gate: ?*disk_monitor.Monitor = &monitor;
|
||||
|
||||
// The query-log writer is deliberately *not* in `group`, and starts before
|
||||
// every producer. Inside the group its life would end with the same
|
||||
// `cancel` that stops the producers, and cancellation would race the
|
||||
// queue's close: whichever landed first decided whether the batch the
|
||||
// writer was holding reached the database or was counted as dropped. Given
|
||||
// its own future, it outlives the producers by construction, and the
|
||||
// teardown below can close the queue with nobody left to fill it and then
|
||||
// wait for the writer to finish emptying it.
|
||||
var writer_future = try io.concurrent(
|
||||
logger_mod.Logger.runWriter,
|
||||
.{ &query_logger, io, &querylog_writer_db, gate },
|
||||
);
|
||||
|
||||
// The query-log writers are deliberately *not* in `group`, and the live one
|
||||
// started before every producer. Inside the group their lives would end
|
||||
// with the same `cancel` that stops the producers, and cancellation would
|
||||
// race the queue's close: whichever landed first decided whether the batch
|
||||
// a writer was holding reached the database or was counted as dropped. The
|
||||
// controller owns their futures instead, so they outlive the producers by
|
||||
// construction.
|
||||
//
|
||||
// Ruling 4's shutdown order, on the one path every exit from here takes:
|
||||
// every producer stops and is joined, then the queue closes, then the
|
||||
// writer is awaited — so the last batch is written rather than raced. A
|
||||
// writer the disk gate will not let write counts its batch as dropped
|
||||
// instead of holding the exit open (`logger.zig`), so this wait always
|
||||
// ends.
|
||||
// every producer stops and is joined, then `Controller.shutdown` closes
|
||||
// each queue and awaits each writer — so the last batch is written rather
|
||||
// than raced. A writer the disk gate will not let write counts its batch as
|
||||
// dropped instead of holding the exit open (`logger.zig`), so this wait
|
||||
// always ends.
|
||||
//
|
||||
// A `defer` and not straight-line code after `shutdown.wait`, because a
|
||||
// `concurrent` spawn below can fail with the DNS listeners already
|
||||
@@ -920,8 +969,7 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
|
||||
// signal gets.
|
||||
defer {
|
||||
group.cancel(io);
|
||||
query_logger.shutdown(io);
|
||||
writer_future.await(io) catch {};
|
||||
log_controller.shutdown(io);
|
||||
}
|
||||
|
||||
if (udp6) |*s| try group.concurrent(io, udp_server.UdpServer.serve, .{ s, io });
|
||||
@@ -946,7 +994,7 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
|
||||
// this box exists for — keeps serving.
|
||||
if (cfg.web.enabled) try group.concurrent(io, web_server.serve, .{ &web_state, io });
|
||||
|
||||
logStartup(io, authority, &manager, upstreams.active().len, .{
|
||||
logStartup(io, authority, &manager, upstream_count, .{
|
||||
.udp6 = if (udp6) |*s| s.boundAddress() else null,
|
||||
.udp4 = if (udp4) |*s| s.boundAddress() else null,
|
||||
.tcp6 = if (tcp6) |*s| s.boundAddress() else null,
|
||||
@@ -1121,18 +1169,21 @@ fn maintenanceOnce(
|
||||
api: ?*api_limiter.ApiLimiter,
|
||||
io: std.Io,
|
||||
) std.Io.Cancelable!void {
|
||||
if (h.cache) |cache| {
|
||||
// Both pointers are read inside the mutex that guards their replacement,
|
||||
// the same discipline the query path follows: a load taken before the lock
|
||||
// could sweep a table `replaceCache`/`replaceRateLimiter` has just freed.
|
||||
{
|
||||
const now_s = std.Io.Clock.real.now(io).toSeconds();
|
||||
try h.cache_mutex.lock(io);
|
||||
_ = cache.sweep(now_s);
|
||||
h.cache_mutex.unlock(io);
|
||||
defer h.cache_mutex.unlock(io);
|
||||
if (h.cache) |cache| _ = cache.sweep(now_s);
|
||||
}
|
||||
|
||||
if (h.limiter) |limiter| {
|
||||
{
|
||||
const now = std.Io.Clock.awake.now(io);
|
||||
try h.limiter_mutex.lock(io);
|
||||
_ = limiter.sweep(now);
|
||||
h.limiter_mutex.unlock(io);
|
||||
defer h.limiter_mutex.unlock(io);
|
||||
if (h.limiter) |limiter| _ = limiter.sweep(now);
|
||||
}
|
||||
|
||||
// The API limiter takes its own mutex, unlike the two above, which are the
|
||||
@@ -1140,215 +1191,6 @@ fn maintenanceOnce(
|
||||
if (api) |limiter| _ = limiter.sweep(io, std.Io.Clock.awake.now(io));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// upstreams
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The pool's entries and everything they point into.
|
||||
///
|
||||
/// Every enabled upstream gets `pool_mod.slots_per_entry` leaf clients, one per
|
||||
/// slot of its entry, so that many exchanges can be in flight against it at
|
||||
/// once. `Slot.client` is a type-erased pointer into `doh` or `dot`, each of
|
||||
/// those clients borrows a slice of `doh_buf`/`dot_buf`, and each entry borrows
|
||||
/// a run of `slot_storage` and one counter of `recovery_counters` — so every
|
||||
/// allocation here lives exactly as long as the pool does, and none of them is
|
||||
/// ever resized. One slot is used by one task at a time, which is why the
|
||||
/// buffers are per client and not shared the way `cli.probeUpstreams` shares
|
||||
/// them.
|
||||
const Upstreams = struct {
|
||||
entries: []pool_mod.Entry,
|
||||
used: usize,
|
||||
/// Sliced per entry into `Entry.slots`, never pointing into the client
|
||||
/// arrays: `Pool.init` sorts entries and the slices have to survive it.
|
||||
slot_storage: []pool_mod.Slot,
|
||||
/// One per enabled upstream, and the reason it is a separate allocation:
|
||||
/// `Pool.init` sorts entries by value, so a counter living inside an entry
|
||||
/// would be pointed at by the wrong upstream's clients after the sort.
|
||||
recovery_counters: []std.atomic.Value(u64),
|
||||
doh: []doh_client.DohClient,
|
||||
dot: []dot_client.DotClient,
|
||||
/// How much of `doh`/`dot` was actually initialized. A malformed or skipped
|
||||
/// upstream leaves the tail of an over-allocated array undefined, and both
|
||||
/// `deinit` and `build`'s failure paths iterate only the initialized
|
||||
/// prefix — reading a `DotClient` that was never built, or closing a
|
||||
/// session that was never opened, is what these two counts prevent.
|
||||
doh_used: usize,
|
||||
dot_used: usize,
|
||||
doh_buf: []u8,
|
||||
dot_buf: []u8,
|
||||
|
||||
/// A disabled upstream is left out entirely; a malformed one warns and is
|
||||
/// skipped, because one bad row in a table of four must not take DNS down.
|
||||
/// No usable row at all is a configuration fault.
|
||||
fn build(
|
||||
io: std.Io,
|
||||
gpa: Allocator,
|
||||
servers: []const model.UpstreamServer,
|
||||
http: *std.http.Client,
|
||||
bundle: *Certificate.Bundle,
|
||||
bundle_lock: *std.Io.RwLock,
|
||||
config_load: *ConfigLoad,
|
||||
) (Allocator.Error || error{NoUsableUpstreams})!Upstreams {
|
||||
var enabled: usize = 0;
|
||||
for (servers) |server| {
|
||||
if (server.enabled) enabled += 1;
|
||||
}
|
||||
if (enabled == 0) return error.NoUsableUpstreams;
|
||||
|
||||
const chunk = tls.Client.min_buffer_len;
|
||||
const slots = pool_mod.slots_per_entry;
|
||||
const leaf_clients = enabled * slots;
|
||||
|
||||
var self: Upstreams = .{
|
||||
.entries = try gpa.alloc(pool_mod.Entry, enabled),
|
||||
.used = 0,
|
||||
.slot_storage = &.{},
|
||||
.recovery_counters = &.{},
|
||||
.doh = &.{},
|
||||
.dot = &.{},
|
||||
.doh_used = 0,
|
||||
.dot_used = 0,
|
||||
.doh_buf = &.{},
|
||||
.dot_buf = &.{},
|
||||
};
|
||||
errdefer self.deinit(io, gpa);
|
||||
|
||||
self.slot_storage = try gpa.alloc(pool_mod.Slot, leaf_clients);
|
||||
self.recovery_counters = try gpa.alloc(std.atomic.Value(u64), enabled);
|
||||
for (self.recovery_counters) |*counter| counter.* = .init(0);
|
||||
self.doh = try gpa.alloc(doh_client.DohClient, leaf_clients);
|
||||
self.dot = try gpa.alloc(dot_client.DotClient, leaf_clients);
|
||||
self.doh_buf = try gpa.alloc(u8, leaf_clients * (doh_request_buf_len + doh_transfer_buf_len));
|
||||
self.dot_buf = try gpa.alloc(u8, leaf_clients * 4 * chunk);
|
||||
|
||||
for (servers) |server| {
|
||||
if (!server.enabled) continue;
|
||||
|
||||
const endpoint = transport.Endpoint.parse(server.url) catch {
|
||||
log.warn(
|
||||
"upstream {f} is not an https:// or tls:// endpoint; skipped",
|
||||
.{safe_url.redactQuoted(server.url)},
|
||||
);
|
||||
noteUpstream(config_load, server.url, "not an https:// or tls:// endpoint; skipped");
|
||||
continue;
|
||||
};
|
||||
|
||||
const entry_slots = self.slot_storage[self.used * slots ..][0..slots];
|
||||
switch (endpoint.scheme) {
|
||||
.doh => if (!self.wireDoh(http, endpoint, entry_slots)) {
|
||||
log.warn(
|
||||
"upstream {f} is not a usable DoH url; skipped",
|
||||
.{safe_url.redactQuoted(server.url)},
|
||||
);
|
||||
noteUpstream(config_load, server.url, "not a usable DoH url; skipped");
|
||||
continue;
|
||||
},
|
||||
.dot => self.wireDot(gpa, endpoint, server.tls_name, bundle, bundle_lock, entry_slots),
|
||||
}
|
||||
|
||||
self.entries[self.used] = .{
|
||||
.endpoint = endpoint,
|
||||
.slots = entry_slots,
|
||||
.priority = server.priority,
|
||||
.enabled = true,
|
||||
.health = .init,
|
||||
.sem = .{ .permits = entry_slots.len },
|
||||
.reuse_recoveries = &self.recovery_counters[self.used],
|
||||
};
|
||||
self.used += 1;
|
||||
}
|
||||
|
||||
if (self.used == 0) return error.NoUsableUpstreams;
|
||||
return self;
|
||||
}
|
||||
|
||||
/// One `DohClient` per slot, all sharing the one `std.http.Client`: its
|
||||
/// connection pool already serves concurrent requests, and a `DohClient`'s
|
||||
/// only mutable state is the two buffers this gives each slot its own of.
|
||||
///
|
||||
/// False means the url is not a usable DoH url, which `DohClient.init`
|
||||
/// decides from the url alone — so it fails on the first slot or on none.
|
||||
/// `doh_used` still advances per client rather than per entry: it means
|
||||
/// "initialized", and a skipped entry's clients are simply never reached.
|
||||
fn wireDoh(
|
||||
self: *Upstreams,
|
||||
http: *std.http.Client,
|
||||
endpoint: transport.Endpoint,
|
||||
slots: []pool_mod.Slot,
|
||||
) bool {
|
||||
for (slots) |*slot| {
|
||||
const index = self.doh_used;
|
||||
const base = index * (doh_request_buf_len + doh_transfer_buf_len);
|
||||
self.doh[index] = doh_client.DohClient.init(
|
||||
http,
|
||||
endpoint,
|
||||
self.doh_buf[base..][0..doh_request_buf_len],
|
||||
self.doh_buf[base + doh_request_buf_len ..][0..doh_transfer_buf_len],
|
||||
) catch return false;
|
||||
self.doh_used = index + 1;
|
||||
slot.* = .{ .client = self.doh[index].client() };
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// One `DotClient` per slot, each with its own four TLS buffers and all
|
||||
/// sharing the trust store. Every client of one entry reports its stale-reuse
|
||||
/// recoveries through that entry's counter.
|
||||
fn wireDot(
|
||||
self: *Upstreams,
|
||||
gpa: Allocator,
|
||||
endpoint: transport.Endpoint,
|
||||
tls_name: []const u8,
|
||||
bundle: *Certificate.Bundle,
|
||||
bundle_lock: *std.Io.RwLock,
|
||||
slots: []pool_mod.Slot,
|
||||
) void {
|
||||
const chunk = tls.Client.min_buffer_len;
|
||||
const recoveries = &self.recovery_counters[self.used];
|
||||
for (slots) |*slot| {
|
||||
const index = self.dot_used;
|
||||
const base = index * 4 * chunk;
|
||||
self.dot[index] = dot_client.DotClient.init(
|
||||
endpoint,
|
||||
tls_name,
|
||||
gpa,
|
||||
bundle,
|
||||
bundle_lock,
|
||||
recoveries,
|
||||
.{
|
||||
.tls_read = self.dot_buf[base..][0..chunk],
|
||||
.tls_write = self.dot_buf[base + chunk ..][0..chunk],
|
||||
.stream_read = self.dot_buf[base + 2 * chunk ..][0..chunk],
|
||||
.stream_write = self.dot_buf[base + 3 * chunk ..][0..chunk],
|
||||
},
|
||||
);
|
||||
self.dot_used = index + 1;
|
||||
slot.* = .{ .client = self.dot[index].client() };
|
||||
}
|
||||
}
|
||||
|
||||
/// The prefix `Pool.init` is given. The rest of `entries` is allocated but
|
||||
/// never filled, which is what keeps `deinit` able to free the whole block.
|
||||
fn active(self: *Upstreams) []pool_mod.Entry {
|
||||
return self.entries[0..self.used];
|
||||
}
|
||||
|
||||
/// Connections first, memory second: a `DotClient` holds a socket its
|
||||
/// buffers belong to, so nothing it points at may be freed before it is
|
||||
/// closed.
|
||||
fn deinit(self: *Upstreams, io: std.Io, gpa: Allocator) void {
|
||||
for (self.dot[0..self.dot_used]) |*client| client.close(io);
|
||||
gpa.free(self.dot_buf);
|
||||
gpa.free(self.doh_buf);
|
||||
gpa.free(self.dot);
|
||||
gpa.free(self.doh);
|
||||
gpa.free(self.recovery_counters);
|
||||
gpa.free(self.slot_storage);
|
||||
gpa.free(self.entries);
|
||||
self.* = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// listeners
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -2414,10 +2256,11 @@ test "one maintenance pass drops the api limiter's stale buckets" {
|
||||
_ = limiter.check(io, .{ .nanoseconds = now.nanoseconds - 2 * window_ns }, client);
|
||||
try std.testing.expectEqual(@as(u32, 1), limiter.trackedClients(io));
|
||||
|
||||
// Nothing here exchanges: the pass only sweeps the two tables.
|
||||
var unreachable_upstream: upstream_owner.Borrowed = .{};
|
||||
var h: handler.Handler = .{
|
||||
.upstream = .{ .ptr = undefined, .exchangeFn = undefined },
|
||||
.blocking = .{ .mode = .zero, .ttl = 5 },
|
||||
.forward_read_timeout = .{ .raw = .fromMilliseconds(50), .clock = .awake },
|
||||
.upstream = unreachable_upstream.client(.{ .ptr = undefined, .exchangeFn = undefined }),
|
||||
.policy = .{ .blocking = .{ .mode = .zero, .ttl = 5 }, .forward_read_timeout = .{ .raw = .fromMilliseconds(50), .clock = .awake } },
|
||||
};
|
||||
try maintenanceOnce(&h, &limiter, io);
|
||||
|
||||
@@ -2520,6 +2363,80 @@ test "a configuration finding is reported once and finalize closes the rest" {
|
||||
));
|
||||
}
|
||||
|
||||
test "the upstream build's report replays into the same rows the boot path used to write" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var fx: events_fixture.Fixture = .{};
|
||||
try fx.init(io, 1000);
|
||||
defer fx.deinit();
|
||||
|
||||
// Last boot's finding for a row this boot has fixed. Only `finalize`
|
||||
// closes it, which is why the replay has to run before it.
|
||||
fx.store.report(io, 900, .configuration_load, "ftp://fixed.example", "ftp://fixed.example", .warning, "stale");
|
||||
|
||||
var http: std.http.Client = .{ .allocator = testing.allocator, .io = io };
|
||||
defer http.deinit();
|
||||
var bundle: Certificate.Bundle = .empty;
|
||||
defer bundle.deinit(testing.allocator);
|
||||
var bundle_lock: std.Io.RwLock = .init;
|
||||
|
||||
const generation = try upstream_owner.build(.{
|
||||
.gpa = testing.allocator,
|
||||
.io = io,
|
||||
// The credential in the bad row is why the key and the rendered text
|
||||
// differ: the key is the whole url, and both rendered forms drop it.
|
||||
.servers = &.{
|
||||
.{ .url = "ftp://user:hunter2@nope.example" },
|
||||
.{ .url = "https://good.example/dns-query" },
|
||||
},
|
||||
.http = &http,
|
||||
.bundle = &bundle,
|
||||
.bundle_lock = &bundle_lock,
|
||||
.timeouts = .{
|
||||
.attempt = .{ .raw = .fromMilliseconds(50), .clock = .awake },
|
||||
.total = .{ .raw = .fromMilliseconds(200), .clock = .awake },
|
||||
},
|
||||
.seed = 1,
|
||||
});
|
||||
var upstreams: upstream_owner.Owner = .init(generation);
|
||||
defer upstreams.deinit(io);
|
||||
|
||||
// `build` itself wrote nothing: a candidate a settings PUT never publishes
|
||||
// must leave the diagnostics log exactly as it found it.
|
||||
try testing.expectEqual(@as(i64, 1), try fx.count("SELECT count(*) FROM operational_events"));
|
||||
|
||||
var collector: ConfigLoad = .{ .store = &fx.store, .io = io, .now_s = 1000 };
|
||||
for (generation.report().notes) |finding| {
|
||||
noteUpstream(&collector, finding.url, finding.message);
|
||||
}
|
||||
collector.finalize();
|
||||
|
||||
// The whole url is the key, the redaction is the label, and the detail is
|
||||
// the sentence `noteUpstream` has always written — byte for byte.
|
||||
try testing.expectEqual(
|
||||
@as(i64, 1),
|
||||
try fx.count("SELECT count(*) FROM operational_events WHERE resolved_at IS NULL"),
|
||||
);
|
||||
try testing.expectEqualStrings("ftp://user:hunter2@nope.example", try fx.text(
|
||||
"SELECT subject_key FROM operational_events WHERE resolved_at IS NULL",
|
||||
));
|
||||
try testing.expectEqualStrings("ftp://nope.example", try fx.text(
|
||||
"SELECT subject_label FROM operational_events WHERE resolved_at IS NULL",
|
||||
));
|
||||
try testing.expectEqualStrings(
|
||||
"upstream 'ftp://nope.example' not an https:// or tls:// endpoint; skipped",
|
||||
try fx.text("SELECT detail FROM operational_events WHERE resolved_at IS NULL"),
|
||||
);
|
||||
|
||||
// The row that was wrong last boot and is not wrong now is closed by the
|
||||
// same `finalize` as ever: the replay is what puts the keys in front of it.
|
||||
try testing.expectEqual(@as(i64, 1), try fx.count(
|
||||
"SELECT count(*) FROM operational_events WHERE subject_key = 'ftp://fixed.example' AND resolved_at IS NOT NULL",
|
||||
));
|
||||
}
|
||||
|
||||
test "an over-long boot finding list refuses to finalize rather than truncate" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
|
||||
Reference in New Issue
Block a user