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.
927 lines
35 KiB
Zig
927 lines
35 KiB
Zig
//! The upstream generation and the owner that publishes it.
|
|
//!
|
|
//! A `Generation` is everything one upstream configuration needs to answer a
|
|
//! query: the parsed endpoints, the leaf clients behind them, the pool that
|
|
//! chooses between them, and — the reason it is a generation rather than a
|
|
//! plain struct — every configuration string it borrows, copied into an arena
|
|
//! it owns. `transport.Endpoint` borrows the URL text (transport.zig:51), so a
|
|
//! generation built from database rows that live in a request arena must not
|
|
//! outlive that arena unless it took its own copy. It takes its own copy.
|
|
//!
|
|
//! `Owner` publishes one generation at a time under the `CertStore` discipline
|
|
//! (cert_store.zig:199/:237): a mutex held briefly around a refcounted borrow,
|
|
//! a swap that marks the old generation retired, and a last release that tears
|
|
//! it down. A query acquires for the length of one exchange and copies whatever
|
|
//! it needs out of the generation before releasing, so a `replace` landing
|
|
//! mid-query never frees anything the query still reads.
|
|
|
|
const std = @import("std");
|
|
const Allocator = std.mem.Allocator;
|
|
const Certificate = std.crypto.Certificate;
|
|
const tls = std.crypto.tls;
|
|
|
|
const doh_client = @import("doh_client.zig");
|
|
const dot_client = @import("dot_client.zig");
|
|
const events = @import("../storage/events.zig");
|
|
const health = @import("health.zig");
|
|
const model = @import("../config/model.zig");
|
|
const pool_mod = @import("pool.zig");
|
|
const safe_url = @import("../safe_url.zig");
|
|
const transport = @import("transport.zig");
|
|
|
|
const log = std.log.scoped(.nxdns);
|
|
|
|
const doh_request_buf_len = doh_client.default_request_buf_len;
|
|
const doh_transfer_buf_len = doh_client.default_transfer_buf_len;
|
|
|
|
/// The `configuration.load` findings of one `build`, without the side effects.
|
|
///
|
|
/// `build` writes no event rows: at boot the caller replays this report through
|
|
/// its own `ConfigLoad.note` so the diagnostics are exactly what they were when
|
|
/// the composition lived in `app.zig`, and at runtime a candidate that is never
|
|
/// published must leave no trace at all. The strings are owned by the
|
|
/// generation's arena, so the report is readable for as long as the generation
|
|
/// is.
|
|
pub const BuildReport = struct {
|
|
notes: []const Note = &.{},
|
|
|
|
/// `url` is the subject key an upstream finding is filed under: the whole
|
|
/// URL is the identity, and redaction is the caller's job because the
|
|
/// caller is what renders it.
|
|
pub const Note = struct {
|
|
url: []const u8,
|
|
message: []const u8,
|
|
};
|
|
};
|
|
|
|
/// One finding rendered for the event store: the redacted label and the detail
|
|
/// line. Both borrow `Rendered`'s own buffers, so it must outlive the call that
|
|
/// writes the row.
|
|
pub const Rendered = struct {
|
|
label_buf: [events.Store.max_subject_label_len]u8 = undefined,
|
|
detail_buf: [events.Store.max_detail_len]u8 = undefined,
|
|
label: []const u8 = "",
|
|
detail: []const u8 = "",
|
|
|
|
/// An upstream's identity is its url: the whole url is the key, and the
|
|
/// redaction is the label, because a url can carry an account token.
|
|
pub fn render(self: *Rendered, finding: BuildReport.Note) void {
|
|
self.label = std.fmt.bufPrint(&self.label_buf, "{f}", .{
|
|
safe_url.redact(finding.url),
|
|
}) catch &self.label_buf;
|
|
self.detail = std.fmt.bufPrint(&self.detail_buf, "upstream {f} {s}", .{
|
|
safe_url.redactQuoted(finding.url),
|
|
finding.message,
|
|
}) catch &self.detail_buf;
|
|
}
|
|
};
|
|
|
|
/// Reconciles the `configuration.load` episodes of an upstream replace, on the
|
|
/// task that published it and after the publish.
|
|
///
|
|
/// SCOPED, never `Store.resolveExcept`: that call resolves every active
|
|
/// `configuration.load` episode outside its kept set, which at runtime would
|
|
/// falsely close boot warnings about settings this replace never touched. Only
|
|
/// the keys the previous generation reported and the new one does not are
|
|
/// resolved, one at a time.
|
|
///
|
|
/// `previous_keys` is the copy taken at prepare, so nothing here depends on the
|
|
/// retired generation still being alive.
|
|
pub fn reconcileReport(
|
|
store: *events.Store,
|
|
io: std.Io,
|
|
now_s: i64,
|
|
new_notes: []const BuildReport.Note,
|
|
previous_keys: []const []const u8,
|
|
) void {
|
|
var rendered: Rendered = .{};
|
|
for (new_notes) |finding| {
|
|
rendered.render(finding);
|
|
store.report(
|
|
io,
|
|
now_s,
|
|
.configuration_load,
|
|
finding.url,
|
|
rendered.label,
|
|
.warning,
|
|
rendered.detail,
|
|
);
|
|
}
|
|
|
|
var previous_buf: [events.Store.max_subject_key_len]u8 = undefined;
|
|
var current_buf: [events.Store.max_subject_key_len]u8 = undefined;
|
|
outer: for (previous_keys) |key| {
|
|
const previous = events.canonicalKey(key, &previous_buf);
|
|
for (new_notes) |finding| {
|
|
if (std.mem.eql(u8, previous, events.canonicalKey(finding.url, ¤t_buf))) continue :outer;
|
|
}
|
|
store.resolve(io, now_s, .configuration_load, key);
|
|
}
|
|
}
|
|
|
|
/// Everything a generation built from configuration rows owns. Absent when the
|
|
/// caller supplied the `transport.Client` directly — a test seam, and the shape
|
|
/// `nxdns check` would want for a single probe.
|
|
const Built = struct {
|
|
gpa: Allocator,
|
|
/// Every configuration string the generation borrows: the URL text each
|
|
/// `Endpoint` slices its host and path out of, and each DoT `tls_name`.
|
|
arena: std.heap.ArenaAllocator,
|
|
upstreams: Upstreams,
|
|
pool: pool_mod.Pool,
|
|
report: BuildReport,
|
|
};
|
|
|
|
pub const Generation = struct {
|
|
/// The exchange entry point. `built.pool.client()` for a configured
|
|
/// generation, the caller's client otherwise.
|
|
client: transport.Client,
|
|
/// The pool behind `client`, for the health and metrics reads. Null when
|
|
/// the client is not a pool.
|
|
pool: ?*pool_mod.Pool = null,
|
|
built: ?Built = null,
|
|
/// Set when `retire` must free the generation's own storage. Null when the
|
|
/// caller owns it — a generation on a test's stack.
|
|
destroy_with: ?Allocator = null,
|
|
/// Guarded by the owner's mutex, never touched outside it.
|
|
refs: usize = 0,
|
|
retired: bool = false,
|
|
|
|
/// A generation over a client the caller owns and keeps alive. Owns
|
|
/// nothing, so `retire` only invalidates it.
|
|
pub fn borrowing(client: transport.Client) Generation {
|
|
return .{ .client = client };
|
|
}
|
|
|
|
/// A generation over a pool the caller owns and keeps alive. The metrics
|
|
/// and health paths read the pool through this.
|
|
pub fn borrowingPool(pool: *pool_mod.Pool) Generation {
|
|
return .{ .client = pool.client(), .pool = pool };
|
|
}
|
|
|
|
pub fn report(self: *const Generation) BuildReport {
|
|
const built = self.built orelse return .{};
|
|
return built.report;
|
|
}
|
|
|
|
/// The active entries, for a caller that wants the count rather than the
|
|
/// health of each.
|
|
pub fn activeCount(self: *Generation) usize {
|
|
const built = &(self.built orelse return 0);
|
|
return built.upstreams.used;
|
|
}
|
|
|
|
/// Connections first, memory second, storage last. Only ever called with
|
|
/// `refs == 0`: by `Owner.release` when the last reader of a retired
|
|
/// generation leaves, by the caller `Owner.replace` handed an idle
|
|
/// generation back to, or by `Owner.deinit` at shutdown.
|
|
pub fn retire(self: *Generation, io: std.Io) void {
|
|
if (self.built) |*built| {
|
|
built.upstreams.deinit(io, built.gpa);
|
|
built.arena.deinit();
|
|
}
|
|
const destroy_with = self.destroy_with;
|
|
self.* = undefined;
|
|
if (destroy_with) |gpa| gpa.destroy(self);
|
|
}
|
|
};
|
|
|
|
pub const BuildError = Allocator.Error || error{NoUsableUpstreams};
|
|
|
|
pub const BuildOptions = struct {
|
|
gpa: Allocator,
|
|
io: std.Io,
|
|
/// Borrowed for the length of the call only: every string this generation
|
|
/// keeps is copied into its arena before `build` returns.
|
|
servers: []const model.UpstreamServer,
|
|
http: *std.http.Client,
|
|
bundle: *Certificate.Bundle,
|
|
bundle_lock: *std.Io.RwLock,
|
|
timeouts: pool_mod.Timeouts,
|
|
health_config: health.Config = .{},
|
|
seed: u64,
|
|
diagnostics: ?*events.Store = null,
|
|
};
|
|
|
|
/// Builds one heap-stable generation from a row set. Side-effect free: it
|
|
/// writes no event rows and publishes nothing. Every failure frees everything
|
|
/// it allocated.
|
|
pub fn build(opts: BuildOptions) BuildError!*Generation {
|
|
const gpa = opts.gpa;
|
|
|
|
const generation = try gpa.create(Generation);
|
|
errdefer gpa.destroy(generation);
|
|
|
|
var arena_state: std.heap.ArenaAllocator = .init(gpa);
|
|
errdefer arena_state.deinit();
|
|
const arena = arena_state.allocator();
|
|
|
|
// The rows this generation keeps, copied out of whatever memory the caller
|
|
// read them into. `Endpoint.parse` slices host and path out of the URL, so
|
|
// duplicating the URL covers all three.
|
|
const owned = try arena.alloc(model.UpstreamServer, opts.servers.len);
|
|
for (opts.servers, owned) |server, *copy| {
|
|
copy.* = .{
|
|
.url = try arena.dupe(u8, server.url),
|
|
.priority = server.priority,
|
|
.enabled = server.enabled,
|
|
.tls_name = try arena.dupe(u8, server.tls_name),
|
|
};
|
|
}
|
|
|
|
var notes: std.ArrayList(BuildReport.Note) = .empty;
|
|
var upstreams = try Upstreams.build(
|
|
opts.io,
|
|
gpa,
|
|
arena,
|
|
owned,
|
|
opts.http,
|
|
opts.bundle,
|
|
opts.bundle_lock,
|
|
¬es,
|
|
);
|
|
errdefer upstreams.deinit(opts.io, gpa);
|
|
|
|
generation.* = .{
|
|
.client = undefined,
|
|
.pool = null,
|
|
.destroy_with = gpa,
|
|
.built = .{
|
|
.gpa = gpa,
|
|
.arena = arena_state,
|
|
.upstreams = upstreams,
|
|
.pool = .init(
|
|
upstreams.active(),
|
|
opts.health_config,
|
|
opts.timeouts,
|
|
opts.seed,
|
|
),
|
|
.report = .{ .notes = try notes.toOwnedSlice(arena) },
|
|
},
|
|
};
|
|
const built = &generation.built.?;
|
|
built.pool.diagnostics = opts.diagnostics;
|
|
// Taken after the generation is in its final storage: the client is a
|
|
// pointer to the pool inside it.
|
|
generation.pool = &built.pool;
|
|
generation.client = built.pool.client();
|
|
return generation;
|
|
}
|
|
|
|
/// Publishes one generation at a time.
|
|
///
|
|
/// The initial generation is the caller's to provide and the owner's to tear
|
|
/// down: `deinit` retires whatever is live, and every generation a `replace`
|
|
/// displaces is retired by the owner or handed back to the caller idle.
|
|
pub const Owner = struct {
|
|
mutex: std.Io.Mutex = .init,
|
|
live: *Generation,
|
|
/// How many generations `replace` has published. One candidate per owner
|
|
/// means one increment per configuration write, however many keys of this
|
|
/// owner that write named. Guarded by `mutex`, like everything else here.
|
|
///
|
|
/// Not a `std.atomic.Value(u64)`: in Debug the x86_64 self-hosted backend
|
|
/// of zig 0.16.0 miscompiles `replace` when a `lock xadd` sits between the
|
|
/// `old.refs == 0` comparison and the branch on its result, and `replace`
|
|
/// then returns null for every input. See AGENTS.md.
|
|
published: u64 = 0,
|
|
|
|
pub fn init(live: *Generation) Owner {
|
|
return .{ .live = live };
|
|
}
|
|
|
|
/// Pins the live generation for one exchange or one scrape. The returned
|
|
/// generation stays valid until the matching `release`, across any number
|
|
/// of replaces.
|
|
pub fn acquire(self: *Owner, io: std.Io) *Generation {
|
|
self.mutex.lockUncancelable(io);
|
|
defer self.mutex.unlock(io);
|
|
self.live.refs += 1;
|
|
return self.live;
|
|
}
|
|
|
|
/// The subject keys the live generation's report is filed under, copied
|
|
/// into `arena`.
|
|
///
|
|
/// Taken at prepare so that the retire-time reconciliation never depends on
|
|
/// the displaced generation still being alive: by then its last reader may
|
|
/// have freed it. The copy runs under the mutex because that is what pins
|
|
/// the generation whose arena the strings live in; the allocation is from a
|
|
/// bump arena and touches no `std.Io` primitive, so the hold stays as brief
|
|
/// as every other one this type takes.
|
|
pub fn copyLiveReportKeys(
|
|
self: *Owner,
|
|
io: std.Io,
|
|
arena: Allocator,
|
|
) Allocator.Error![]const []const u8 {
|
|
self.mutex.lockUncancelable(io);
|
|
defer self.mutex.unlock(io);
|
|
|
|
const notes = self.live.report().notes;
|
|
const copies = try arena.alloc([]const u8, notes.len);
|
|
for (notes, copies) |finding, *slot| slot.* = try arena.dupe(u8, finding.url);
|
|
return copies;
|
|
}
|
|
|
|
pub fn release(self: *Owner, io: std.Io, generation: *Generation) void {
|
|
self.mutex.lockUncancelable(io);
|
|
std.debug.assert(generation.refs > 0);
|
|
generation.refs -= 1;
|
|
const retire_it = generation.retired and generation.refs == 0;
|
|
self.mutex.unlock(io);
|
|
if (retire_it) generation.retire(io);
|
|
}
|
|
|
|
/// Publishes `prepared` and retires the live generation. Infallible and
|
|
/// I/O-free by construction: a pointer swap under the mutex.
|
|
///
|
|
/// Returns the displaced generation when no reader held it, because there
|
|
/// is then no release left to retire it and the caller must — the same
|
|
/// `refs == 0` branch `CertStore.reload` takes. Returns null when a reader
|
|
/// still holds it; that reader's release retires it.
|
|
pub fn replace(self: *Owner, io: std.Io, prepared: *Generation) ?*Generation {
|
|
std.debug.assert(prepared.refs == 0);
|
|
std.debug.assert(!prepared.retired);
|
|
self.mutex.lockUncancelable(io);
|
|
const old = self.live;
|
|
self.live = prepared;
|
|
old.retired = true;
|
|
const idle = old.refs == 0;
|
|
self.published += 1;
|
|
self.mutex.unlock(io);
|
|
return if (idle) old else null;
|
|
}
|
|
|
|
/// How many replaces this owner has published.
|
|
pub fn publishedCount(self: *Owner, io: std.Io) u64 {
|
|
self.mutex.lockUncancelable(io);
|
|
defer self.mutex.unlock(io);
|
|
return self.published;
|
|
}
|
|
|
|
/// Shutdown teardown. Every listener and every metrics reader must have
|
|
/// stopped: a live generation with readers left is a caller that tore down
|
|
/// out of order, and a retired one with readers was freed by its own last
|
|
/// release already.
|
|
pub fn deinit(self: *Owner, io: std.Io) void {
|
|
self.mutex.lockUncancelable(io);
|
|
const live = self.live;
|
|
std.debug.assert(live.refs == 0);
|
|
self.mutex.unlock(io);
|
|
live.retire(io);
|
|
self.* = undefined;
|
|
}
|
|
};
|
|
|
|
/// An owner over transport the caller already has: a fake client in a handler
|
|
/// test, or a pool a fixture built from fake entries. Owns nothing, so there is
|
|
/// nothing to tear down; both parts are inline so a caller keeps them on its
|
|
/// stack beside the handler.
|
|
pub const Borrowed = struct {
|
|
generation: Generation = undefined,
|
|
owner: Owner = undefined,
|
|
|
|
pub fn client(self: *Borrowed, c: transport.Client) *Owner {
|
|
self.generation = .borrowing(c);
|
|
self.owner = .init(&self.generation);
|
|
return &self.owner;
|
|
}
|
|
|
|
pub fn pool(self: *Borrowed, p: *pool_mod.Pool) *Owner {
|
|
self.generation = .borrowingPool(p);
|
|
self.owner = .init(&self.generation);
|
|
return &self.owner;
|
|
}
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// the pool's entries and everything they point into
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Every enabled upstream gets `pool_mod.slots_per_entry` leaf clients, one per
|
|
/// slot of its entry, so that many exchanges can be in flight against it at
|
|
/// once. `Slot.client` is a type-erased pointer into `doh` or `dot`, each of
|
|
/// those clients borrows a slice of `doh_buf`/`dot_buf`, and each entry borrows
|
|
/// a run of `slot_storage` and one counter of `recovery_counters` — so every
|
|
/// allocation here lives exactly as long as the generation does, and none of
|
|
/// them is ever resized. One slot is used by one task at a time, which is why
|
|
/// the buffers are per client and not shared the way `cli.probeUpstreams`
|
|
/// shares them.
|
|
const Upstreams = struct {
|
|
entries: []pool_mod.Entry,
|
|
used: usize,
|
|
/// Sliced per entry into `Entry.slots`, never pointing into the client
|
|
/// arrays: `Pool.init` sorts entries and the slices have to survive it.
|
|
slot_storage: []pool_mod.Slot,
|
|
/// One per enabled upstream, and the reason it is a separate allocation:
|
|
/// `Pool.init` sorts entries by value, so a counter living inside an entry
|
|
/// would be pointed at by the wrong upstream's clients after the sort.
|
|
recovery_counters: []std.atomic.Value(u64),
|
|
doh: []doh_client.DohClient,
|
|
dot: []dot_client.DotClient,
|
|
/// How much of `doh`/`dot` was actually initialized. A malformed or skipped
|
|
/// upstream leaves the tail of an over-allocated array undefined, and both
|
|
/// `deinit` and `build`'s failure paths iterate only the initialized
|
|
/// prefix — reading a `DotClient` that was never built, or closing a
|
|
/// session that was never opened, is what these two counts prevent.
|
|
doh_used: usize,
|
|
dot_used: usize,
|
|
doh_buf: []u8,
|
|
dot_buf: []u8,
|
|
|
|
/// A disabled upstream is left out entirely; a malformed one is noted and
|
|
/// skipped, because one bad row in a table of four must not take DNS down.
|
|
/// No usable row at all is a configuration fault.
|
|
///
|
|
/// `arena` is the generation's: `notes` borrows from it and so does every
|
|
/// string in `servers`, which the caller has already copied there.
|
|
fn build(
|
|
io: std.Io,
|
|
gpa: Allocator,
|
|
arena: Allocator,
|
|
servers: []const model.UpstreamServer,
|
|
http: *std.http.Client,
|
|
bundle: *Certificate.Bundle,
|
|
bundle_lock: *std.Io.RwLock,
|
|
notes: *std.ArrayList(BuildReport.Note),
|
|
) BuildError!Upstreams {
|
|
var enabled: usize = 0;
|
|
for (servers) |server| {
|
|
if (server.enabled) enabled += 1;
|
|
}
|
|
if (enabled == 0) return error.NoUsableUpstreams;
|
|
|
|
const chunk = tls.Client.min_buffer_len;
|
|
const slots = pool_mod.slots_per_entry;
|
|
const leaf_clients = enabled * slots;
|
|
|
|
var self: Upstreams = .{
|
|
.entries = try gpa.alloc(pool_mod.Entry, enabled),
|
|
.used = 0,
|
|
.slot_storage = &.{},
|
|
.recovery_counters = &.{},
|
|
.doh = &.{},
|
|
.dot = &.{},
|
|
.doh_used = 0,
|
|
.dot_used = 0,
|
|
.doh_buf = &.{},
|
|
.dot_buf = &.{},
|
|
};
|
|
errdefer self.deinit(io, gpa);
|
|
|
|
self.slot_storage = try gpa.alloc(pool_mod.Slot, leaf_clients);
|
|
self.recovery_counters = try gpa.alloc(std.atomic.Value(u64), enabled);
|
|
for (self.recovery_counters) |*counter| counter.* = .init(0);
|
|
self.doh = try gpa.alloc(doh_client.DohClient, leaf_clients);
|
|
self.dot = try gpa.alloc(dot_client.DotClient, leaf_clients);
|
|
self.doh_buf = try gpa.alloc(u8, leaf_clients * (doh_request_buf_len + doh_transfer_buf_len));
|
|
self.dot_buf = try gpa.alloc(u8, leaf_clients * 4 * chunk);
|
|
|
|
for (servers) |server| {
|
|
if (!server.enabled) continue;
|
|
|
|
const endpoint = transport.Endpoint.parse(server.url) catch {
|
|
try note(arena, notes, server.url, "not an https:// or tls:// endpoint; skipped");
|
|
continue;
|
|
};
|
|
|
|
const entry_slots = self.slot_storage[self.used * slots ..][0..slots];
|
|
switch (endpoint.scheme) {
|
|
.doh => if (!self.wireDoh(http, endpoint, entry_slots)) {
|
|
try note(arena, notes, server.url, "not a usable DoH url; skipped");
|
|
continue;
|
|
},
|
|
.dot => self.wireDot(gpa, endpoint, server.tls_name, bundle, bundle_lock, entry_slots),
|
|
}
|
|
|
|
self.entries[self.used] = .{
|
|
.endpoint = endpoint,
|
|
.slots = entry_slots,
|
|
.priority = server.priority,
|
|
.enabled = true,
|
|
.health = .init,
|
|
.sem = .{ .permits = entry_slots.len },
|
|
.reuse_recoveries = &self.recovery_counters[self.used],
|
|
};
|
|
self.used += 1;
|
|
}
|
|
|
|
if (self.used == 0) return error.NoUsableUpstreams;
|
|
return self;
|
|
}
|
|
|
|
/// One `DohClient` per slot, all sharing the one `std.http.Client`: its
|
|
/// connection pool already serves concurrent requests, and a `DohClient`'s
|
|
/// only mutable state is the two buffers this gives each slot its own of.
|
|
///
|
|
/// False means the url is not a usable DoH url, which `DohClient.init`
|
|
/// decides from the url alone — so it fails on the first slot or on none.
|
|
/// `doh_used` still advances per client rather than per entry: it means
|
|
/// "initialized", and a skipped entry's clients are simply never reached.
|
|
fn wireDoh(
|
|
self: *Upstreams,
|
|
http: *std.http.Client,
|
|
endpoint: transport.Endpoint,
|
|
slots: []pool_mod.Slot,
|
|
) bool {
|
|
for (slots) |*slot| {
|
|
const index = self.doh_used;
|
|
const base = index * (doh_request_buf_len + doh_transfer_buf_len);
|
|
self.doh[index] = doh_client.DohClient.init(
|
|
http,
|
|
endpoint,
|
|
self.doh_buf[base..][0..doh_request_buf_len],
|
|
self.doh_buf[base + doh_request_buf_len ..][0..doh_transfer_buf_len],
|
|
) catch return false;
|
|
self.doh_used = index + 1;
|
|
slot.* = .{ .client = self.doh[index].client() };
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/// One `DotClient` per slot, each with its own four TLS buffers and all
|
|
/// sharing the trust store. Every client of one entry reports its
|
|
/// stale-reuse recoveries through that entry's counter.
|
|
fn wireDot(
|
|
self: *Upstreams,
|
|
gpa: Allocator,
|
|
endpoint: transport.Endpoint,
|
|
tls_name: []const u8,
|
|
bundle: *Certificate.Bundle,
|
|
bundle_lock: *std.Io.RwLock,
|
|
slots: []pool_mod.Slot,
|
|
) void {
|
|
const chunk = tls.Client.min_buffer_len;
|
|
const recoveries = &self.recovery_counters[self.used];
|
|
for (slots) |*slot| {
|
|
const index = self.dot_used;
|
|
const base = index * 4 * chunk;
|
|
self.dot[index] = dot_client.DotClient.init(
|
|
endpoint,
|
|
tls_name,
|
|
gpa,
|
|
bundle,
|
|
bundle_lock,
|
|
recoveries,
|
|
.{
|
|
.tls_read = self.dot_buf[base..][0..chunk],
|
|
.tls_write = self.dot_buf[base + chunk ..][0..chunk],
|
|
.stream_read = self.dot_buf[base + 2 * chunk ..][0..chunk],
|
|
.stream_write = self.dot_buf[base + 3 * chunk ..][0..chunk],
|
|
},
|
|
);
|
|
self.dot_used = index + 1;
|
|
slot.* = .{ .client = self.dot[index].client() };
|
|
}
|
|
}
|
|
|
|
/// The prefix `Pool.init` is given. The rest of `entries` is allocated but
|
|
/// never filled, which is what keeps `deinit` able to free the whole block.
|
|
fn active(self: *Upstreams) []pool_mod.Entry {
|
|
return self.entries[0..self.used];
|
|
}
|
|
|
|
/// Connections first, memory second: a `DotClient` holds a socket its
|
|
/// buffers belong to, so nothing it points at may be freed before it is
|
|
/// closed.
|
|
fn deinit(self: *Upstreams, io: std.Io, gpa: Allocator) void {
|
|
for (self.dot[0..self.dot_used]) |*client| client.close(io);
|
|
gpa.free(self.dot_buf);
|
|
gpa.free(self.doh_buf);
|
|
gpa.free(self.dot);
|
|
gpa.free(self.doh);
|
|
gpa.free(self.recovery_counters);
|
|
gpa.free(self.slot_storage);
|
|
gpa.free(self.entries);
|
|
self.* = undefined;
|
|
}
|
|
};
|
|
|
|
/// The warning goes out here as well as into the report: `std.log` is the
|
|
/// operator's boot transcript and a runtime candidate that is refused is still
|
|
/// worth a line, while the report is what writes the event row — at boot, or in
|
|
/// retire once a candidate is published.
|
|
fn note(
|
|
arena: Allocator,
|
|
notes: *std.ArrayList(BuildReport.Note),
|
|
url: []const u8,
|
|
message: []const u8,
|
|
) Allocator.Error!void {
|
|
log.warn("upstream {f} {s}", .{ safe_url.redactQuoted(url), message });
|
|
// `url` already lives in the generation's arena; the message is a literal.
|
|
try notes.append(arena, .{ .url = url, .message = message });
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const testing = std.testing;
|
|
|
|
const TestIo = struct {
|
|
threaded: std.Io.Threaded,
|
|
|
|
fn init(gpa: Allocator) TestIo {
|
|
return .{ .threaded = .init(gpa, .{}) };
|
|
}
|
|
|
|
fn io(self: *TestIo) std.Io {
|
|
return self.threaded.io();
|
|
}
|
|
|
|
fn deinit(self: *TestIo) void {
|
|
self.threaded.deinit();
|
|
}
|
|
};
|
|
|
|
const test_timeouts: pool_mod.Timeouts = .{
|
|
.attempt = .{ .raw = .fromMilliseconds(50), .clock = .awake },
|
|
.total = .{ .raw = .fromMilliseconds(200), .clock = .awake },
|
|
};
|
|
|
|
/// A client that answers from a fixed reply and records the identity it named,
|
|
/// so a test can prove which generation served an exchange.
|
|
const FakeClient = struct {
|
|
identity: []const u8,
|
|
calls: std.atomic.Value(u64) = .init(0),
|
|
/// Set once the exchange is inside the client, so a test knows the
|
|
/// generation is really pinned before it swaps.
|
|
entered: ?*std.Io.Event = null,
|
|
/// Waited on before the exchange returns, so a test can hold an exchange
|
|
/// open across a `replace`.
|
|
gate: ?*std.Io.Event = null,
|
|
|
|
fn client(self: *FakeClient) transport.Client {
|
|
return .{ .ptr = self, .exchangeFn = exchangeFn };
|
|
}
|
|
|
|
fn exchangeFn(
|
|
ptr: *anyopaque,
|
|
io: std.Io,
|
|
query: []const u8,
|
|
response_buf: []u8,
|
|
selected: *?[]const u8,
|
|
) transport.ExchangeError![]u8 {
|
|
const self: *FakeClient = @ptrCast(@alignCast(ptr));
|
|
selected.* = self.identity;
|
|
_ = self.calls.fetchAdd(1, .monotonic);
|
|
if (self.entered) |entered| entered.set(io);
|
|
if (self.gate) |gate| gate.wait(io) catch return error.Canceled;
|
|
@memcpy(response_buf[0..query.len], query);
|
|
return response_buf[0..query.len];
|
|
}
|
|
};
|
|
|
|
fn buildTestGeneration(
|
|
io: std.Io,
|
|
gpa: Allocator,
|
|
http: *std.http.Client,
|
|
bundle: *Certificate.Bundle,
|
|
bundle_lock: *std.Io.RwLock,
|
|
servers: []const model.UpstreamServer,
|
|
) BuildError!*Generation {
|
|
return build(.{
|
|
.gpa = gpa,
|
|
.io = io,
|
|
.servers = servers,
|
|
.http = http,
|
|
.bundle = bundle,
|
|
.bundle_lock = bundle_lock,
|
|
.timeouts = test_timeouts,
|
|
.seed = 1,
|
|
});
|
|
}
|
|
|
|
test "an owner hands out the live generation and retires the old one on the last release" {
|
|
var t: TestIo = .init(testing.allocator);
|
|
defer t.deinit();
|
|
const io = t.io();
|
|
|
|
var first: FakeClient = .{ .identity = "fake://g1" };
|
|
var second: FakeClient = .{ .identity = "fake://g2" };
|
|
var g1: Generation = .borrowing(first.client());
|
|
var g2: Generation = .borrowing(second.client());
|
|
|
|
var owner: Owner = .init(&g1);
|
|
|
|
const held = owner.acquire(io);
|
|
try testing.expectEqual(&g1, held);
|
|
|
|
// A reader holds G1, so the swap cannot retire it here.
|
|
try testing.expectEqual(@as(?*Generation, null), owner.replace(io, &g2));
|
|
try testing.expect(g1.retired);
|
|
|
|
// A new acquire lands on G2 while the old reader is still on G1.
|
|
const fresh = owner.acquire(io);
|
|
try testing.expectEqual(&g2, fresh);
|
|
owner.release(io, fresh);
|
|
|
|
owner.release(io, held);
|
|
owner.deinit(io);
|
|
}
|
|
|
|
test "a replace with no reader holding the live generation retires it through the return path" {
|
|
var t: TestIo = .init(testing.allocator);
|
|
defer t.deinit();
|
|
const io = t.io();
|
|
|
|
var http: std.http.Client = .{ .allocator = testing.allocator, .io = io };
|
|
defer http.deinit();
|
|
var bundle: Certificate.Bundle = .empty;
|
|
defer bundle.deinit(testing.allocator);
|
|
var bundle_lock: std.Io.RwLock = .init;
|
|
|
|
const g1 = try buildTestGeneration(io, testing.allocator, &http, &bundle, &bundle_lock, &.{
|
|
.{ .url = "https://one.example/dns-query" },
|
|
});
|
|
const g2 = try buildTestGeneration(io, testing.allocator, &http, &bundle, &bundle_lock, &.{
|
|
.{ .url = "https://two.example/dns-query" },
|
|
});
|
|
|
|
var owner: Owner = .init(g1);
|
|
|
|
// Nobody holds G1: `replace` must hand it back, because no release will.
|
|
const displaced = owner.replace(io, g2) orelse return error.ExpectedIdleGeneration;
|
|
try testing.expectEqual(g1, displaced);
|
|
displaced.retire(io);
|
|
|
|
owner.deinit(io);
|
|
}
|
|
|
|
test "a generation owns its configuration strings after the rows they came from are freed" {
|
|
var t: TestIo = .init(testing.allocator);
|
|
defer t.deinit();
|
|
const io = t.io();
|
|
|
|
var http: std.http.Client = .{ .allocator = testing.allocator, .io = io };
|
|
defer http.deinit();
|
|
var bundle: Certificate.Bundle = .empty;
|
|
defer bundle.deinit(testing.allocator);
|
|
var bundle_lock: std.Io.RwLock = .init;
|
|
|
|
// The rows a request arena would hand `build`. Freeing the arena poisons
|
|
// every byte of them, so a generation that kept a borrow reads garbage.
|
|
var rows_arena: std.heap.ArenaAllocator = .init(testing.allocator);
|
|
const rows = rows_arena.allocator();
|
|
const servers = try rows.dupe(model.UpstreamServer, &.{
|
|
.{ .url = try rows.dupe(u8, "tls://dot.example.net:853"), .tls_name = try rows.dupe(u8, "dot.example.net") },
|
|
});
|
|
|
|
const generation = try buildTestGeneration(io, testing.allocator, &http, &bundle, &bundle_lock, servers);
|
|
rows_arena.deinit();
|
|
|
|
var owner: Owner = .init(generation);
|
|
defer owner.deinit(io);
|
|
|
|
const held = owner.acquire(io);
|
|
defer owner.release(io, held);
|
|
|
|
var snapshots: [4]pool_mod.Snapshot = undefined;
|
|
const count = try held.pool.?.snapshot(io, &snapshots);
|
|
try testing.expectEqual(@as(usize, 1), count);
|
|
try testing.expectEqualStrings("tls://dot.example.net:853", snapshots[0].url);
|
|
try testing.expectEqual(@as(usize, 1), held.activeCount());
|
|
}
|
|
|
|
test "build reports a malformed row instead of writing it, and refuses a row set with nothing usable" {
|
|
var t: TestIo = .init(testing.allocator);
|
|
defer t.deinit();
|
|
const io = t.io();
|
|
|
|
var http: std.http.Client = .{ .allocator = testing.allocator, .io = io };
|
|
defer http.deinit();
|
|
var bundle: Certificate.Bundle = .empty;
|
|
defer bundle.deinit(testing.allocator);
|
|
var bundle_lock: std.Io.RwLock = .init;
|
|
|
|
const generation = try buildTestGeneration(io, testing.allocator, &http, &bundle, &bundle_lock, &.{
|
|
.{ .url = "ftp://nope.example" },
|
|
.{ .url = "https://good.example/dns-query" },
|
|
.{ .url = "https://disabled.example/dns-query", .enabled = false },
|
|
});
|
|
var owner: Owner = .init(generation);
|
|
defer owner.deinit(io);
|
|
|
|
const report = generation.report();
|
|
try testing.expectEqual(@as(usize, 1), report.notes.len);
|
|
try testing.expectEqualStrings("ftp://nope.example", report.notes[0].url);
|
|
try testing.expectEqualStrings("not an https:// or tls:// endpoint; skipped", report.notes[0].message);
|
|
try testing.expectEqual(@as(usize, 1), generation.activeCount());
|
|
|
|
try testing.expectError(error.NoUsableUpstreams, buildTestGeneration(
|
|
io,
|
|
testing.allocator,
|
|
&http,
|
|
&bundle,
|
|
&bundle_lock,
|
|
&.{.{ .url = "ftp://nope.example" }},
|
|
));
|
|
try testing.expectError(error.NoUsableUpstreams, buildTestGeneration(
|
|
io,
|
|
testing.allocator,
|
|
&http,
|
|
&bundle,
|
|
&bundle_lock,
|
|
&.{.{ .url = "https://off.example", .enabled = false }},
|
|
));
|
|
}
|
|
|
|
// The concurrency criterion: an exchange in flight on G1 completes on G1, G1
|
|
// deinits only after that reader releases, and every exchange started after
|
|
// the swap runs on G2.
|
|
test "an exchange in flight survives a replace and the old generation retires after it" {
|
|
var t: TestIo = .init(testing.allocator);
|
|
defer t.deinit();
|
|
const io = t.io();
|
|
|
|
var entered: std.Io.Event = .unset;
|
|
var gate: std.Io.Event = .unset;
|
|
var first: FakeClient = .{ .identity = "fake://g1", .entered = &entered, .gate = &gate };
|
|
var second: FakeClient = .{ .identity = "fake://g2" };
|
|
var g1: Generation = .borrowing(first.client());
|
|
var g2: Generation = .borrowing(second.client());
|
|
var owner: Owner = .init(&g1);
|
|
|
|
const Exchange = struct {
|
|
fn run(o: *Owner, inner_io: std.Io, out: *?[]const u8) void {
|
|
const generation = o.acquire(inner_io);
|
|
defer o.release(inner_io, generation);
|
|
var buf: [16]u8 = undefined;
|
|
var selected: ?[]const u8 = null;
|
|
_ = generation.client.exchange(inner_io, "abc", &buf, &selected) catch {};
|
|
out.* = selected;
|
|
}
|
|
};
|
|
|
|
var in_flight: ?[]const u8 = null;
|
|
var future = try io.concurrent(Exchange.run, .{ &owner, io, &in_flight });
|
|
|
|
// The swap must land with G1 really pinned, not merely likely to be.
|
|
entered.waitUncancelable(io);
|
|
|
|
try testing.expectEqual(@as(?*Generation, null), owner.replace(io, &g2));
|
|
|
|
var after: ?[]const u8 = null;
|
|
Exchange.run(&owner, io, &after);
|
|
try testing.expectEqualStrings("fake://g2", after.?);
|
|
|
|
gate.set(io);
|
|
future.await(io);
|
|
try testing.expectEqualStrings("fake://g1", in_flight.?);
|
|
|
|
owner.deinit(io);
|
|
}
|
|
|
|
test "a metrics scrape running against the owner survives a replace under it" {
|
|
var t: TestIo = .init(testing.allocator);
|
|
defer t.deinit();
|
|
const io = t.io();
|
|
|
|
var http: std.http.Client = .{ .allocator = testing.allocator, .io = io };
|
|
defer http.deinit();
|
|
var bundle: Certificate.Bundle = .empty;
|
|
defer bundle.deinit(testing.allocator);
|
|
var bundle_lock: std.Io.RwLock = .init;
|
|
|
|
const g1 = try buildTestGeneration(io, testing.allocator, &http, &bundle, &bundle_lock, &.{
|
|
.{ .url = "https://one.example/dns-query" },
|
|
});
|
|
const g2 = try buildTestGeneration(io, testing.allocator, &http, &bundle, &bundle_lock, &.{
|
|
.{ .url = "https://two.example/dns-query" },
|
|
.{ .url = "tls://two.example:853", .tls_name = "two.example" },
|
|
});
|
|
var owner: Owner = .init(g1);
|
|
defer owner.deinit(io);
|
|
|
|
const Scrape = struct {
|
|
/// What every scrape must be true of, whichever generation answered
|
|
/// it: a URL the scrape read out of a generation it holds is a URL
|
|
/// nothing has freed.
|
|
fn run(o: *Owner, inner_io: std.Io, started: *std.Io.Event, seen: *usize) void {
|
|
for (0..256) |i| {
|
|
const generation = o.acquire(inner_io);
|
|
defer o.release(inner_io, generation);
|
|
|
|
var raw: [8]pool_mod.Snapshot = undefined;
|
|
const count = generation.pool.?.snapshot(inner_io, &raw) catch 0;
|
|
for (raw[0..count]) |entry| {
|
|
if (std.mem.startsWith(u8, entry.url, "https://") or
|
|
std.mem.startsWith(u8, entry.url, "tls://")) seen.* += 1;
|
|
}
|
|
if (i == 0) started.set(inner_io);
|
|
}
|
|
}
|
|
};
|
|
|
|
var started: std.Io.Event = .unset;
|
|
var seen: usize = 0;
|
|
var future = try io.concurrent(Scrape.run, .{ &owner, io, &started, &seen });
|
|
|
|
started.waitUncancelable(io);
|
|
if (owner.replace(io, g2)) |old| old.retire(io);
|
|
|
|
future.await(io);
|
|
// Every one of the 256 scrapes read at least one intact URL.
|
|
try testing.expect(seen >= 256);
|
|
}
|