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.
1262 lines
49 KiB
Zig
1262 lines
49 KiB
Zig
//! Refcounted certificate holder for the DoH/DoT listeners (PLAN §9,
|
|
//! milestone-10 rulings 6-7). One `CertStore` owns the published
|
|
//! `tls_server.ServerContext` generation for one endpoint; listeners `acquire`
|
|
//! it per connection and `release` it when the connection ends, so a reload
|
|
//! never frees a context while a handshake or stream still uses it.
|
|
//!
|
|
//! Reload is publish-nothing-on-failure (PLAN:297): both PEM files are read
|
|
//! and a whole new context built before anything is swapped, and any failure
|
|
//! leaves the old generation serving. The watcher polls `statFile` every
|
|
//! `poll_interval_s` — there is no usable file-change notification inside
|
|
//! `std.Io` — and compares mtime+size of BOTH files against the pair recorded
|
|
//! at the last successful load, so a same-second atomic rename still registers
|
|
//! through the size.
|
|
|
|
const std = @import("std");
|
|
|
|
const events = @import("../storage/events.zig");
|
|
const tls_server = @import("../platform/tls_server.zig");
|
|
|
|
const log = std.log.scoped(.cert_store);
|
|
|
|
pub const poll_interval_s = 30;
|
|
|
|
/// Largest PEM file `reload` accepts, applied to the certificate chain and the
|
|
/// key separately. Real chains are a few KiB; anything bigger is a wrong file.
|
|
pub const max_pem_bytes = 64 * 1024;
|
|
|
|
pub const ReloadError = error{
|
|
OutOfMemory,
|
|
/// The certificate file could not be opened, read, or stat'ed.
|
|
CertUnreadable,
|
|
CertTooLarge,
|
|
/// The private key file could not be opened, read, or stat'ed.
|
|
KeyUnreadable,
|
|
KeyTooLarge,
|
|
/// The certificate chain PEM could not be parsed.
|
|
CertParse,
|
|
/// The private key PEM could not be parsed.
|
|
KeyParse,
|
|
/// The private key does not belong to the leaf certificate.
|
|
KeyMismatch,
|
|
/// Seeding the CTR-DRBG from the platform entropy source failed.
|
|
EntropyFailed,
|
|
/// Mbed TLS rejected the server configuration.
|
|
ConfigFailed,
|
|
};
|
|
|
|
/// One line for the operator, used by `POST /api/certs/reload` as the `error`
|
|
/// payload field and by the watcher's warning.
|
|
pub fn humanMessage(err: ReloadError) []const u8 {
|
|
return switch (err) {
|
|
error.OutOfMemory => "out of memory",
|
|
error.CertUnreadable => "certificate file is not readable",
|
|
error.CertTooLarge => "certificate file exceeds 64 KiB",
|
|
error.KeyUnreadable => "private key file is not readable",
|
|
error.KeyTooLarge => "private key file exceeds 64 KiB",
|
|
error.CertParse => "certificate PEM could not be parsed",
|
|
error.KeyParse => "private key PEM could not be parsed",
|
|
error.KeyMismatch => "private key does not belong to the certificate",
|
|
error.EntropyFailed => "seeding the TLS random generator failed",
|
|
error.ConfigFailed => "building the TLS server configuration failed",
|
|
};
|
|
}
|
|
|
|
/// The identity of one file's content as far as the watcher can see it
|
|
/// without reading: modification time and size together, because an atomic
|
|
/// rename within one filesystem timestamp granule still changes the size in
|
|
/// any realistic re-issue, and a truncate-and-rewrite changes the mtime.
|
|
pub const FileSig = struct {
|
|
mtime_ns: i96,
|
|
size: u64,
|
|
};
|
|
|
|
/// The stat pair recorded at the last successful load.
|
|
pub const Signature = struct {
|
|
cert: FileSig,
|
|
key: FileSig,
|
|
};
|
|
|
|
/// The watcher's whole decision, pure so a table can test it: reload exactly
|
|
/// when either file's mtime or size differs from the loaded pair.
|
|
pub fn changed(loaded: Signature, observed: Signature) bool {
|
|
return fileChanged(loaded.cert, observed.cert) or fileChanged(loaded.key, observed.key);
|
|
}
|
|
|
|
fn fileChanged(loaded: FileSig, observed: FileSig) bool {
|
|
return loaded.mtime_ns != observed.mtime_ns or loaded.size != observed.size;
|
|
}
|
|
|
|
/// One published generation. Freed only when `retired` and `refs == 0`; both
|
|
/// transitions happen under the store's mutex, so the last releaser (or the
|
|
/// reload that retires an idle entry) frees it exactly once.
|
|
pub const Entry = struct {
|
|
ctx: tls_server.ServerContext,
|
|
refs: u32,
|
|
retired: bool,
|
|
};
|
|
|
|
/// See `CertStore.after_load_hook`.
|
|
pub const ReloadHook = struct {
|
|
ctx: *anyopaque,
|
|
call: *const fn (ctx: *anyopaque, io: std.Io) void,
|
|
};
|
|
|
|
pub const CertStore = struct {
|
|
/// Which endpoint's certificate this store holds. It is the subject of
|
|
/// every `certificate.reload` event, and a box serving both DoH and DoT
|
|
/// runs two stores over two file pairs.
|
|
pub const Kind = enum { doh, dot };
|
|
|
|
gpa: std.mem.Allocator,
|
|
/// Owned. A settings apply replaces both paths, so a borrowed config slice
|
|
/// would dangle the moment the row it came from went away. Read and
|
|
/// written only under `reload_mutex`, which every apply and every reload
|
|
/// holds for its whole read-build-publish sequence.
|
|
cert_path: []u8,
|
|
/// Owned; see `cert_path`.
|
|
key_path: []u8,
|
|
/// Passed through to every `ServerContext.init`; the same lifetime rule
|
|
/// applies — a comptime-constant NULL-terminated array, never memory that
|
|
/// can go away before the store.
|
|
alpn: ?[*:null]const ?[*:0]const u8,
|
|
|
|
/// Guards `current`, `loaded`, and every `Entry.refs`/`Entry.retired`.
|
|
/// `lockUncancelable` throughout: `acquire`/`release` sit on the
|
|
/// per-connection path with no error union for `error.Canceled`, and no
|
|
/// critical section here holds I/O.
|
|
mutex: std.Io.Mutex,
|
|
/// Serializes whole reloads (read files, build context, publish) so an
|
|
/// overlapping reload cannot publish an older read after a newer one:
|
|
/// whichever reload reads the files last publishes last. Held across file
|
|
/// I/O, which is fine — `acquire`/`release` never touch it, so connection
|
|
/// latency is unchanged. Lock order: `reload_mutex` is always taken BEFORE
|
|
/// `mutex` and never while holding it.
|
|
reload_mutex: std.Io.Mutex,
|
|
current: *Entry,
|
|
loaded: Signature,
|
|
|
|
/// Test seam: runs inside `reload` between a successful `load` and the
|
|
/// publish, i.e. inside `reload_mutex`. Lets a test occupy the window
|
|
/// where an unserialized reload could be overtaken. Must not call
|
|
/// `reload`, `pollOnce` or `preparePathChange` synchronously — all four
|
|
/// take `reload_mutex`, which is not reentrant.
|
|
after_load_hook: ?ReloadHook,
|
|
|
|
/// Set by the composition root right after `init`, with `diagnostics`.
|
|
kind: Kind = .doh,
|
|
/// Wired the same way and for the same reason as every other subsystem's:
|
|
/// the store is fully usable without it, and `nxdns check` has none.
|
|
diagnostics: ?*events.Store = null,
|
|
|
|
reloads: std.atomic.Value(u64),
|
|
reload_failures: std.atomic.Value(u64),
|
|
/// Wall-clock second of the last successful load, including the one in
|
|
/// `init`.
|
|
last_reload_unix: std.atomic.Value(i64),
|
|
|
|
pub const Stats = struct {
|
|
reloads: u64,
|
|
reload_failures: u64,
|
|
last_reload_unix: i64,
|
|
};
|
|
|
|
/// Performs the initial load. A failure here is the boot-time "bad cert"
|
|
/// case: nothing is constructed and the caller exits with
|
|
/// `humanMessage(err)` (milestone-10 ruling 11).
|
|
pub fn init(
|
|
gpa: std.mem.Allocator,
|
|
io: std.Io,
|
|
cert_path: []const u8,
|
|
key_path: []const u8,
|
|
alpn: ?[*:null]const ?[*:0]const u8,
|
|
) ReloadError!CertStore {
|
|
const first = try load(gpa, io, cert_path, key_path, alpn);
|
|
errdefer {
|
|
first.entry.ctx.deinit(gpa);
|
|
gpa.destroy(first.entry);
|
|
}
|
|
const owned_cert = try gpa.dupe(u8, cert_path);
|
|
errdefer gpa.free(owned_cert);
|
|
const owned_key = try gpa.dupe(u8, key_path);
|
|
return .{
|
|
.gpa = gpa,
|
|
.cert_path = owned_cert,
|
|
.key_path = owned_key,
|
|
.alpn = alpn,
|
|
.mutex = .init,
|
|
.reload_mutex = .init,
|
|
.current = first.entry,
|
|
.loaded = first.sig,
|
|
.after_load_hook = null,
|
|
.reloads = .init(0),
|
|
.reload_failures = .init(0),
|
|
.last_reload_unix = .init(std.Io.Clock.real.now(io).toSeconds()),
|
|
};
|
|
}
|
|
|
|
/// Every listener must have released its entries first: the current
|
|
/// generation must be idle, and a retired generation with refs would have
|
|
/// been freed by its last release already.
|
|
pub fn deinit(self: *CertStore, io: std.Io) void {
|
|
self.mutex.lockUncancelable(io);
|
|
const current = self.current;
|
|
std.debug.assert(current.refs == 0);
|
|
self.mutex.unlock(io);
|
|
self.destroyEntry(current);
|
|
self.gpa.free(self.cert_path);
|
|
self.gpa.free(self.key_path);
|
|
self.* = undefined;
|
|
}
|
|
|
|
/// Pins the current generation for one connection. The returned entry
|
|
/// stays valid until the matching `release`, across any number of reloads.
|
|
pub fn acquire(self: *CertStore, io: std.Io) *Entry {
|
|
self.mutex.lockUncancelable(io);
|
|
defer self.mutex.unlock(io);
|
|
self.current.refs += 1;
|
|
return self.current;
|
|
}
|
|
|
|
pub fn release(self: *CertStore, io: std.Io, entry: *Entry) void {
|
|
self.mutex.lockUncancelable(io);
|
|
std.debug.assert(entry.refs > 0);
|
|
entry.refs -= 1;
|
|
const free_it = entry.retired and entry.refs == 0;
|
|
self.mutex.unlock(io);
|
|
if (free_it) self.destroyEntry(entry);
|
|
}
|
|
|
|
/// Builds a whole new generation from the files on disk, then swaps it in
|
|
/// and retires the old one. On ANY failure the store is untouched: the old
|
|
/// generation keeps serving and the loaded signature keeps its value, so
|
|
/// the watcher retries on its next poll.
|
|
///
|
|
/// `reload_mutex` covers the whole read-build-publish sequence: without
|
|
/// it, a reload could read the files, stall in `ServerContext.init`, and
|
|
/// publish that stale read over a generation another reload already
|
|
/// published from newer bytes.
|
|
pub fn reload(self: *CertStore, io: std.Io) ReloadError!void {
|
|
self.reload_mutex.lockUncancelable(io);
|
|
defer self.reload_mutex.unlock(io);
|
|
return self.reloadLocked(io);
|
|
}
|
|
|
|
/// `reload`'s body, for callers that already hold `reload_mutex` —
|
|
/// `pollOnce` does, because it must read `cert_path`/`key_path` under the
|
|
/// same lock an apply replaces them under. `std.Io.Mutex` is not
|
|
/// reentrant, so this exists rather than a recursive `reload` call.
|
|
fn reloadLocked(self: *CertStore, io: std.Io) ReloadError!void {
|
|
const next = load(self.gpa, io, self.cert_path, self.key_path, self.alpn) catch |err| {
|
|
_ = self.reload_failures.fetchAdd(1, .monotonic);
|
|
return err;
|
|
};
|
|
|
|
if (self.after_load_hook) |hook| hook.call(hook.ctx, io);
|
|
|
|
self.mutex.lockUncancelable(io);
|
|
const old = self.current;
|
|
self.current = next.entry;
|
|
self.loaded = next.sig;
|
|
old.retired = true;
|
|
const free_old = old.refs == 0;
|
|
self.mutex.unlock(io);
|
|
|
|
if (free_old) self.destroyEntry(old);
|
|
_ = self.reloads.fetchAdd(1, .monotonic);
|
|
self.last_reload_unix.store(std.Io.Clock.real.now(io).toSeconds(), .monotonic);
|
|
}
|
|
|
|
/// A candidate certificate loaded from new paths, not yet published.
|
|
/// Holding one means holding `reload_mutex`: exactly one of
|
|
/// `publishPathChange` or `abortPathChange` must follow, and it releases
|
|
/// the lock.
|
|
pub const PreparedPaths = struct {
|
|
cert_path: []u8,
|
|
key_path: []u8,
|
|
loaded: Loaded,
|
|
/// Read at prepare so publish reads no clock: publish must touch
|
|
/// nothing outside memory it already owns.
|
|
loaded_at_unix: i64,
|
|
};
|
|
|
|
/// Prepare half of a `doh_server`/`dot_server` cert-path change: takes
|
|
/// `reload_mutex` and loads the certificate and key from the NEW paths.
|
|
/// Nothing is published, so a failure leaves the store exactly as it was —
|
|
/// the old certificate keeps serving and the caller writes no DB row. The
|
|
/// lock is released on failure and held on success, which is what makes
|
|
/// the whole apply serialized against `reload` and `pollOnce`.
|
|
pub fn preparePathChange(
|
|
self: *CertStore,
|
|
io: std.Io,
|
|
cert_path: []const u8,
|
|
key_path: []const u8,
|
|
) ReloadError!PreparedPaths {
|
|
self.reload_mutex.lockUncancelable(io);
|
|
errdefer self.reload_mutex.unlock(io);
|
|
|
|
const owned_cert = try self.gpa.dupe(u8, cert_path);
|
|
errdefer self.gpa.free(owned_cert);
|
|
const owned_key = try self.gpa.dupe(u8, key_path);
|
|
errdefer self.gpa.free(owned_key);
|
|
|
|
const next = load(self.gpa, io, cert_path, key_path, self.alpn) catch |err| {
|
|
_ = self.reload_failures.fetchAdd(1, .monotonic);
|
|
return err;
|
|
};
|
|
return .{
|
|
.cert_path = owned_cert,
|
|
.key_path = owned_key,
|
|
.loaded = next,
|
|
.loaded_at_unix = std.Io.Clock.real.now(io).toSeconds(),
|
|
};
|
|
}
|
|
|
|
/// Publish half: infallible and I/O-free. The paths and the generation
|
|
/// they were loaded from are installed together — the generation `mutex`
|
|
/// is taken only for that swap, inside `reload_mutex`, the same lock order
|
|
/// `reload` uses. Releases `reload_mutex`.
|
|
///
|
|
/// Connections that pinned the old generation finish on the old
|
|
/// certificate; the old entry is freed once its last reader releases.
|
|
pub fn publishPathChange(self: *CertStore, io: std.Io, prepared: PreparedPaths) void {
|
|
const old_cert_path = self.cert_path;
|
|
const old_key_path = self.key_path;
|
|
|
|
self.mutex.lockUncancelable(io);
|
|
const old = self.current;
|
|
self.cert_path = prepared.cert_path;
|
|
self.key_path = prepared.key_path;
|
|
self.current = prepared.loaded.entry;
|
|
self.loaded = prepared.loaded.sig;
|
|
old.retired = true;
|
|
const free_old = old.refs == 0;
|
|
self.mutex.unlock(io);
|
|
|
|
// The branch runs before the counter bump, never after: a `bool` still
|
|
// live across an atomic read-modify-write is the zig 0.16.0 Debug
|
|
// miscompile AGENTS.md documents.
|
|
if (free_old) self.destroyEntry(old);
|
|
self.gpa.free(old_cert_path);
|
|
self.gpa.free(old_key_path);
|
|
|
|
_ = self.reloads.fetchAdd(1, .monotonic);
|
|
self.last_reload_unix.store(prepared.loaded_at_unix, .monotonic);
|
|
self.reload_mutex.unlock(io);
|
|
}
|
|
|
|
/// Discards a prepared candidate — the commit that would have published it
|
|
/// failed, or a sibling owner's prepare did. Releases `reload_mutex`.
|
|
pub fn abortPathChange(self: *CertStore, io: std.Io, prepared: PreparedPaths) void {
|
|
self.gpa.free(prepared.cert_path);
|
|
self.gpa.free(prepared.key_path);
|
|
self.destroyEntry(prepared.loaded.entry);
|
|
self.reload_mutex.unlock(io);
|
|
}
|
|
|
|
/// Sleep first: `init` just loaded the files this poll would compare
|
|
/// against. `.boot` so a suspended box still sees the interval elapse.
|
|
pub fn watch(self: *CertStore, io: std.Io) std.Io.Cancelable!void {
|
|
const interval: std.Io.Clock.Duration = .{
|
|
.raw = .fromSeconds(poll_interval_s),
|
|
.clock = .boot,
|
|
};
|
|
while (true) {
|
|
try interval.sleep(io);
|
|
self.pollOnce(io, std.Io.Clock.real.now(io).toSeconds());
|
|
}
|
|
}
|
|
|
|
/// One watcher pass. A file that cannot be stat'ed only warns — a
|
|
/// mid-rename window or a permission slip is not evidence the certificate
|
|
/// changed, and the old one keeps serving either way. A failed reload
|
|
/// warns and counts (`reload_failures`); the signature stays at the loaded
|
|
/// pair, so every subsequent poll retries until the files parse.
|
|
///
|
|
/// The whole pass runs under `reload_mutex`: the paths it stats are the
|
|
/// ones an apply replaces, and a poll that read a path outside the lock
|
|
/// could stat a freed slice or reload a pair that was never published
|
|
/// together.
|
|
pub fn pollOnce(self: *CertStore, io: std.Io, now_s: i64) void {
|
|
self.reload_mutex.lockUncancelable(io);
|
|
defer self.reload_mutex.unlock(io);
|
|
|
|
const cert_sig = statSig(io, self.cert_path) catch |err| {
|
|
log.warn("stat {s} failed; keeping the loaded certificate", .{self.cert_path});
|
|
self.reportReload(io, now_s, "stat of the certificate failed", @errorName(err));
|
|
return;
|
|
};
|
|
const key_sig = statSig(io, self.key_path) catch |err| {
|
|
log.warn("stat {s} failed; keeping the loaded certificate", .{self.key_path});
|
|
self.reportReload(io, now_s, "stat of the private key failed", @errorName(err));
|
|
return;
|
|
};
|
|
const observed: Signature = .{ .cert = cert_sig, .key = key_sig };
|
|
|
|
self.mutex.lockUncancelable(io);
|
|
const loaded = self.loaded;
|
|
self.mutex.unlock(io);
|
|
if (!changed(loaded, observed)) {
|
|
// A poll that stat'ed both files and found nothing to do is a
|
|
// fully healthy pass, so it closes any episode a transient stat
|
|
// failure opened. Without this, a file that never changes again
|
|
// would leave that episode open forever.
|
|
if (self.diagnostics) |store| store.resolve(io, now_s, .certificate_reload, @tagName(self.kind));
|
|
return;
|
|
}
|
|
|
|
if (self.reloadLocked(io)) {
|
|
log.info("certificate reloaded from {s}", .{self.cert_path});
|
|
if (self.diagnostics) |store| store.resolve(io, now_s, .certificate_reload, @tagName(self.kind));
|
|
} else |err| {
|
|
log.warn("certificate reload from {s} failed ({s}); the old certificate keeps serving", .{
|
|
self.cert_path,
|
|
humanMessage(err),
|
|
});
|
|
self.reportReload(io, now_s, "certificate reload failed", humanMessage(err));
|
|
}
|
|
}
|
|
|
|
/// A warning, never an error: a stat failure can be a rename window, and a
|
|
/// failed reload leaves the loaded certificate serving. Nothing is down.
|
|
fn reportReload(self: *CertStore, io: std.Io, now_s: i64, message: []const u8, reason: []const u8) void {
|
|
const store = self.diagnostics orelse return;
|
|
var buf: [events.Store.max_detail_len]u8 = undefined;
|
|
const detail = std.fmt.bufPrint(&buf, "{s}: {s}", .{ message, reason }) catch buf[0..];
|
|
store.report(io, now_s, .certificate_reload, @tagName(self.kind), @tagName(self.kind), .warning, detail);
|
|
}
|
|
|
|
pub fn snapshotStats(self: *const CertStore) Stats {
|
|
return .{
|
|
.reloads = self.reloads.load(.monotonic),
|
|
.reload_failures = self.reload_failures.load(.monotonic),
|
|
.last_reload_unix = self.last_reload_unix.load(.monotonic),
|
|
};
|
|
}
|
|
|
|
fn destroyEntry(self: *CertStore, entry: *Entry) void {
|
|
entry.ctx.deinit(self.gpa);
|
|
self.gpa.destroy(entry);
|
|
}
|
|
};
|
|
|
|
const Loaded = struct {
|
|
entry: *Entry,
|
|
sig: Signature,
|
|
};
|
|
|
|
/// Stat first, then read: a file replaced between the two makes the recorded
|
|
/// signature stale, so the watcher's next poll sees the difference and loads
|
|
/// again. The other order would record the new signature over the old bytes
|
|
/// and miss the update.
|
|
fn load(
|
|
gpa: std.mem.Allocator,
|
|
io: std.Io,
|
|
cert_path: []const u8,
|
|
key_path: []const u8,
|
|
alpn: ?[*:null]const ?[*:0]const u8,
|
|
) ReloadError!Loaded {
|
|
const cert_sig = statSig(io, cert_path) catch return error.CertUnreadable;
|
|
const key_sig = statSig(io, key_path) catch return error.KeyUnreadable;
|
|
|
|
const cert_pem = readPem(gpa, io, cert_path) catch |err| return switch (err) {
|
|
error.OutOfMemory => error.OutOfMemory,
|
|
error.TooLarge => error.CertTooLarge,
|
|
error.Unreadable => error.CertUnreadable,
|
|
};
|
|
defer gpa.free(cert_pem);
|
|
|
|
const key_pem = readKeyPem(gpa, io, key_path) catch |err| return switch (err) {
|
|
error.OutOfMemory => error.OutOfMemory,
|
|
error.TooLarge => error.KeyTooLarge,
|
|
error.Unreadable => error.KeyUnreadable,
|
|
};
|
|
defer {
|
|
std.crypto.secureZero(u8, key_pem);
|
|
gpa.free(key_pem);
|
|
}
|
|
|
|
const entry = try gpa.create(Entry);
|
|
errdefer gpa.destroy(entry);
|
|
entry.* = .{
|
|
.ctx = try tls_server.ServerContext.init(gpa, cert_pem, key_pem, alpn),
|
|
.refs = 0,
|
|
.retired = false,
|
|
};
|
|
|
|
return .{
|
|
.entry = entry,
|
|
.sig = .{ .cert = cert_sig, .key = key_sig },
|
|
};
|
|
}
|
|
|
|
/// Mbed TLS wants PEM with a terminating zero byte counted in the length, so
|
|
/// the file lands in a sentinel-terminated allocation. The limit admits
|
|
/// exactly `max_pem_bytes` and rejects the first byte beyond it.
|
|
///
|
|
/// The certificate only. A private key goes through `readKeyPem`, which does
|
|
/// not leave copies behind.
|
|
fn readPem(
|
|
gpa: std.mem.Allocator,
|
|
io: std.Io,
|
|
path: []const u8,
|
|
) error{ OutOfMemory, TooLarge, Unreadable }![:0]u8 {
|
|
return std.Io.Dir.cwd().readFileAllocOptions(
|
|
io,
|
|
path,
|
|
gpa,
|
|
.limited(max_pem_bytes + 1),
|
|
.of(u8),
|
|
0,
|
|
) catch |err| switch (err) {
|
|
error.OutOfMemory => error.OutOfMemory,
|
|
error.StreamTooLong => error.TooLarge,
|
|
else => error.Unreadable,
|
|
};
|
|
}
|
|
|
|
/// `readPem` for the private key, with the same cap and the same
|
|
/// sentinel-terminated result. The difference is that no copy of the key
|
|
/// survives this function: `readFileAllocOptions` grows its buffer as it reads,
|
|
/// and every intermediate copy it abandons stays legible in freed pages that no
|
|
/// wipe at the call site can reach. One fixed staging buffer never grows, and it
|
|
/// is wiped before it goes back to the allocator. The caller wipes the returned
|
|
/// slice the same way before freeing it (the idiom is auth.zig's `secureZero`
|
|
/// defers).
|
|
fn readKeyPem(
|
|
gpa: std.mem.Allocator,
|
|
io: std.Io,
|
|
path: []const u8,
|
|
) error{ OutOfMemory, TooLarge, Unreadable }![:0]u8 {
|
|
const staging = try gpa.alloc(u8, max_pem_bytes + 1);
|
|
defer {
|
|
std.crypto.secureZero(u8, staging);
|
|
gpa.free(staging);
|
|
}
|
|
|
|
const bytes = std.Io.Dir.cwd().readFile(io, path, staging) catch return error.Unreadable;
|
|
// A read that filled the staging buffer is ambiguous — `readFile` cannot
|
|
// say whether more followed — and one byte past `max_pem_bytes` is over the
|
|
// cap either way.
|
|
if (bytes.len == staging.len) return error.TooLarge;
|
|
|
|
const key = try gpa.allocSentinel(u8, bytes.len, 0);
|
|
@memcpy(key, bytes);
|
|
return key;
|
|
}
|
|
|
|
fn statSig(io: std.Io, path: []const u8) !FileSig {
|
|
const st = try std.Io.Dir.cwd().statFile(io, path, .{});
|
|
return .{ .mtime_ns = st.mtime.nanoseconds, .size = st.size };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const events_fixture = @import("../storage/events_fixture.zig");
|
|
const fixtures = @import("test_fixtures");
|
|
const testing = std.testing;
|
|
|
|
test "the stat-compare decision table" {
|
|
const a: FileSig = .{ .mtime_ns = 1_000, .size = 100 };
|
|
const a_later: FileSig = .{ .mtime_ns = 2_000, .size = 100 };
|
|
const a_grown: FileSig = .{ .mtime_ns = 1_000, .size = 101 };
|
|
const b: FileSig = .{ .mtime_ns = 5_000, .size = 500 };
|
|
const b_later: FileSig = .{ .mtime_ns = 6_000, .size = 500 };
|
|
const b_grown: FileSig = .{ .mtime_ns = 5_000, .size = 501 };
|
|
|
|
const cases = [_]struct {
|
|
loaded: Signature,
|
|
observed: Signature,
|
|
want: bool,
|
|
}{
|
|
.{ .loaded = .{ .cert = a, .key = b }, .observed = .{ .cert = a, .key = b }, .want = false },
|
|
.{ .loaded = .{ .cert = a, .key = b }, .observed = .{ .cert = a_later, .key = b }, .want = true },
|
|
.{ .loaded = .{ .cert = a, .key = b }, .observed = .{ .cert = a_grown, .key = b }, .want = true },
|
|
.{ .loaded = .{ .cert = a, .key = b }, .observed = .{ .cert = a, .key = b_later }, .want = true },
|
|
.{ .loaded = .{ .cert = a, .key = b }, .observed = .{ .cert = a, .key = b_grown }, .want = true },
|
|
.{ .loaded = .{ .cert = a, .key = b }, .observed = .{ .cert = a_later, .key = b_grown }, .want = true },
|
|
// A same-second rewrite registers through the size alone.
|
|
.{
|
|
.loaded = .{ .cert = a, .key = b },
|
|
.observed = .{ .cert = .{ .mtime_ns = 1_000, .size = 99 }, .key = b },
|
|
.want = true,
|
|
},
|
|
// The pair swapped between the two files is a change, not a wash.
|
|
.{ .loaded = .{ .cert = a, .key = b }, .observed = .{ .cert = b, .key = a }, .want = true },
|
|
};
|
|
|
|
for (cases) |case| {
|
|
try testing.expectEqual(case.want, changed(case.loaded, case.observed));
|
|
}
|
|
}
|
|
|
|
test "every reload error carries a human message" {
|
|
inline for (comptime @typeInfo(ReloadError).error_set.?) |e| {
|
|
try testing.expect(humanMessage(@field(anyerror, e.name)).len > 0);
|
|
}
|
|
}
|
|
|
|
/// Fixture PEMs written into a tmp directory, addressed by cwd-relative paths
|
|
/// the same way `app.zig` hands config paths to the store. Must not move after
|
|
/// `init`: the path slices point into the buffers below.
|
|
const TestEnv = struct {
|
|
threaded: std.Io.Threaded,
|
|
tmp: testing.TmpDir,
|
|
cert_path_buf: [128]u8,
|
|
key_path_buf: [128]u8,
|
|
cert_path: []const u8,
|
|
key_path: []const u8,
|
|
|
|
fn init(env: *TestEnv) !void {
|
|
env.threaded = .init(testing.allocator, .{});
|
|
errdefer env.threaded.deinit();
|
|
env.tmp = testing.tmpDir(.{});
|
|
errdefer env.tmp.cleanup();
|
|
|
|
const env_io = env.threaded.io();
|
|
try env.tmp.dir.writeFile(env_io, .{ .sub_path = "cert.pem", .data = fixtures.cert_pem });
|
|
try env.tmp.dir.writeFile(env_io, .{ .sub_path = "key.pem", .data = fixtures.key_pem });
|
|
env.cert_path = try std.fmt.bufPrint(&env.cert_path_buf, ".zig-cache/tmp/{s}/cert.pem", .{env.tmp.sub_path});
|
|
env.key_path = try std.fmt.bufPrint(&env.key_path_buf, ".zig-cache/tmp/{s}/key.pem", .{env.tmp.sub_path});
|
|
}
|
|
|
|
fn deinit(env: *TestEnv) void {
|
|
env.tmp.cleanup();
|
|
env.threaded.deinit();
|
|
}
|
|
|
|
fn io(env: *TestEnv) std.Io {
|
|
return env.threaded.io();
|
|
}
|
|
};
|
|
|
|
test "loaded PEM bytes are NUL-terminated and intact" {
|
|
var env: TestEnv = undefined;
|
|
try env.init();
|
|
defer env.deinit();
|
|
|
|
const pem = try readPem(testing.allocator, env.io(), env.cert_path);
|
|
defer testing.allocator.free(pem);
|
|
|
|
try testing.expectEqualStrings(fixtures.cert_pem, pem);
|
|
try testing.expectEqual(@as(u8, 0), pem[pem.len]);
|
|
}
|
|
|
|
test "the size cap admits 64 KiB and rejects one byte more" {
|
|
var env: TestEnv = undefined;
|
|
try env.init();
|
|
defer env.deinit();
|
|
const io = env.io();
|
|
|
|
const at_cap = try testing.allocator.alloc(u8, max_pem_bytes);
|
|
defer testing.allocator.free(at_cap);
|
|
@memset(at_cap, 'a');
|
|
try env.tmp.dir.writeFile(io, .{ .sub_path = "cert.pem", .data = at_cap });
|
|
|
|
const loaded = try readPem(testing.allocator, io, env.cert_path);
|
|
testing.allocator.free(loaded);
|
|
|
|
const over = try testing.allocator.alloc(u8, max_pem_bytes + 1);
|
|
defer testing.allocator.free(over);
|
|
@memset(over, 'a');
|
|
try env.tmp.dir.writeFile(io, .{ .sub_path = "cert.pem", .data = over });
|
|
|
|
try testing.expectError(error.TooLarge, readPem(testing.allocator, io, env.cert_path));
|
|
}
|
|
|
|
test "the key PEM path keeps the shape and the cap of the certificate path" {
|
|
var env: TestEnv = undefined;
|
|
try env.init();
|
|
defer env.deinit();
|
|
const io = env.io();
|
|
|
|
const key = try readKeyPem(testing.allocator, io, env.key_path);
|
|
defer testing.allocator.free(key);
|
|
try testing.expectEqualStrings(fixtures.key_pem, key);
|
|
try testing.expectEqual(@as(u8, 0), key[key.len]);
|
|
|
|
const at_cap = try testing.allocator.alloc(u8, max_pem_bytes);
|
|
defer testing.allocator.free(at_cap);
|
|
@memset(at_cap, 'a');
|
|
try env.tmp.dir.writeFile(io, .{ .sub_path = "key.pem", .data = at_cap });
|
|
testing.allocator.free(try readKeyPem(testing.allocator, io, env.key_path));
|
|
|
|
const over = try testing.allocator.alloc(u8, max_pem_bytes + 1);
|
|
defer testing.allocator.free(over);
|
|
@memset(over, 'a');
|
|
try env.tmp.dir.writeFile(io, .{ .sub_path = "key.pem", .data = over });
|
|
try testing.expectError(error.TooLarge, readKeyPem(testing.allocator, io, env.key_path));
|
|
|
|
try testing.expectError(
|
|
error.Unreadable,
|
|
readKeyPem(testing.allocator, io, "./nxdns-no-such-key-9b31.pem"),
|
|
);
|
|
}
|
|
|
|
test "init fails typed on a missing file and on garbage PEM" {
|
|
var env: TestEnv = undefined;
|
|
try env.init();
|
|
defer env.deinit();
|
|
const io = env.io();
|
|
|
|
try testing.expectError(
|
|
error.CertUnreadable,
|
|
CertStore.init(testing.allocator, io, "./nxdns-no-such-cert-9b31.pem", env.key_path, null),
|
|
);
|
|
try testing.expectError(
|
|
error.KeyUnreadable,
|
|
CertStore.init(testing.allocator, io, env.cert_path, "./nxdns-no-such-key-9b31.pem", null),
|
|
);
|
|
|
|
try env.tmp.dir.writeFile(io, .{ .sub_path = "cert.pem", .data = "not a certificate" });
|
|
try testing.expectError(
|
|
error.CertParse,
|
|
CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null),
|
|
);
|
|
}
|
|
|
|
test "init fails typed on an oversized certificate" {
|
|
var env: TestEnv = undefined;
|
|
try env.init();
|
|
defer env.deinit();
|
|
const io = env.io();
|
|
|
|
const over = try testing.allocator.alloc(u8, max_pem_bytes + 1);
|
|
defer testing.allocator.free(over);
|
|
@memset(over, 'a');
|
|
try env.tmp.dir.writeFile(io, .{ .sub_path = "cert.pem", .data = over });
|
|
|
|
try testing.expectError(
|
|
error.CertTooLarge,
|
|
CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null),
|
|
);
|
|
}
|
|
|
|
test "acquire and release across a reload free the old entry after the last release" {
|
|
var env: TestEnv = undefined;
|
|
try env.init();
|
|
defer env.deinit();
|
|
const io = env.io();
|
|
|
|
var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null);
|
|
defer store.deinit(io);
|
|
|
|
const old = store.acquire(io);
|
|
const old_again = store.acquire(io);
|
|
try testing.expectEqual(old, old_again);
|
|
try testing.expectEqual(@as(u32, 2), old.refs);
|
|
try testing.expect(!old.retired);
|
|
|
|
try store.reload(io);
|
|
|
|
// The old generation is retired but pinned; the store publishes a new one.
|
|
try testing.expect(old.retired);
|
|
try testing.expectEqual(@as(u32, 2), old.refs);
|
|
const fresh = store.acquire(io);
|
|
try testing.expect(fresh != old);
|
|
try testing.expect(!fresh.retired);
|
|
|
|
// The first release leaves the old entry alive for the second holder;
|
|
// the testing allocator proves the second release frees it exactly once.
|
|
store.release(io, old);
|
|
try testing.expectEqual(@as(u32, 1), old.refs);
|
|
store.release(io, old);
|
|
|
|
store.release(io, fresh);
|
|
try testing.expectEqual(@as(u64, 1), store.snapshotStats().reloads);
|
|
}
|
|
|
|
test "a reload with no holders frees the old entry immediately" {
|
|
var env: TestEnv = undefined;
|
|
try env.init();
|
|
defer env.deinit();
|
|
const io = env.io();
|
|
|
|
var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null);
|
|
defer store.deinit(io);
|
|
|
|
// No acquire in flight: the swap itself must free the old generation, or
|
|
// the testing allocator reports the leak at the end of this test.
|
|
try store.reload(io);
|
|
try store.reload(io);
|
|
try testing.expectEqual(@as(u64, 2), store.snapshotStats().reloads);
|
|
}
|
|
|
|
test "a failed reload publishes nothing" {
|
|
var env: TestEnv = undefined;
|
|
try env.init();
|
|
defer env.deinit();
|
|
const io = env.io();
|
|
|
|
var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null);
|
|
defer store.deinit(io);
|
|
|
|
const before = store.acquire(io);
|
|
store.release(io, before);
|
|
|
|
// Bad PEM bytes: the file reads fine and fails in the parser.
|
|
try env.tmp.dir.writeFile(io, .{ .sub_path = "cert.pem", .data = "garbage" });
|
|
try testing.expectError(error.CertParse, store.reload(io));
|
|
|
|
var held = store.acquire(io);
|
|
try testing.expectEqual(before, held);
|
|
try testing.expect(!held.retired);
|
|
store.release(io, held);
|
|
|
|
// Bad path: the key file vanishes.
|
|
try env.tmp.dir.deleteFile(io, "key.pem");
|
|
try testing.expectError(error.KeyUnreadable, store.reload(io));
|
|
|
|
held = store.acquire(io);
|
|
try testing.expectEqual(before, held);
|
|
store.release(io, held);
|
|
|
|
const stats = store.snapshotStats();
|
|
try testing.expectEqual(@as(u64, 0), stats.reloads);
|
|
try testing.expectEqual(@as(u64, 2), stats.reload_failures);
|
|
}
|
|
|
|
test "a mismatched key pair fails the reload and keeps the old pair" {
|
|
var env: TestEnv = undefined;
|
|
try env.init();
|
|
defer env.deinit();
|
|
const io = env.io();
|
|
|
|
var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null);
|
|
defer store.deinit(io);
|
|
|
|
const before = store.acquire(io);
|
|
store.release(io, before);
|
|
|
|
try env.tmp.dir.writeFile(io, .{ .sub_path = "key.pem", .data = fixtures.mismatched_key_pem });
|
|
try testing.expectError(error.KeyMismatch, store.reload(io));
|
|
|
|
const held = store.acquire(io);
|
|
try testing.expectEqual(before, held);
|
|
store.release(io, held);
|
|
try testing.expectEqual(@as(u64, 1), store.snapshotStats().reload_failures);
|
|
}
|
|
|
|
test "pollOnce reloads on a changed stat pair and stays put on an unchanged one" {
|
|
var env: TestEnv = undefined;
|
|
try env.init();
|
|
defer env.deinit();
|
|
const io = env.io();
|
|
|
|
var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null);
|
|
defer store.deinit(io);
|
|
|
|
store.pollOnce(io, 1000);
|
|
try testing.expectEqual(@as(u64, 0), store.snapshotStats().reloads);
|
|
|
|
// Same certificate plus a trailing newline: the PEM still parses and the
|
|
// size differs even when the rewrite lands within one timestamp granule.
|
|
const grown = try std.mem.concat(testing.allocator, u8, &.{ fixtures.cert_pem, "\n" });
|
|
defer testing.allocator.free(grown);
|
|
try env.tmp.dir.writeFile(io, .{ .sub_path = "cert.pem", .data = grown });
|
|
|
|
const old = store.acquire(io);
|
|
store.release(io, old);
|
|
store.pollOnce(io, 1000);
|
|
|
|
try testing.expectEqual(@as(u64, 1), store.snapshotStats().reloads);
|
|
const fresh = store.acquire(io);
|
|
try testing.expect(fresh != old);
|
|
store.release(io, fresh);
|
|
|
|
store.pollOnce(io, 1000);
|
|
try testing.expectEqual(@as(u64, 1), store.snapshotStats().reloads);
|
|
}
|
|
|
|
test "pollOnce warns and keeps serving when a reload fails" {
|
|
var env: TestEnv = undefined;
|
|
try env.init();
|
|
defer env.deinit();
|
|
const io = env.io();
|
|
|
|
var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null);
|
|
defer store.deinit(io);
|
|
|
|
const before = store.acquire(io);
|
|
store.release(io, before);
|
|
|
|
try env.tmp.dir.writeFile(io, .{ .sub_path = "cert.pem", .data = "still not a certificate" });
|
|
store.pollOnce(io, 1000);
|
|
|
|
const stats = store.snapshotStats();
|
|
try testing.expectEqual(@as(u64, 0), stats.reloads);
|
|
try testing.expectEqual(@as(u64, 1), stats.reload_failures);
|
|
|
|
const held = store.acquire(io);
|
|
try testing.expectEqual(before, held);
|
|
store.release(io, held);
|
|
}
|
|
|
|
test "pollOnce does nothing when a file cannot be stat'ed" {
|
|
var env: TestEnv = undefined;
|
|
try env.init();
|
|
defer env.deinit();
|
|
const io = env.io();
|
|
|
|
var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null);
|
|
defer store.deinit(io);
|
|
|
|
try env.tmp.dir.deleteFile(io, "cert.pem");
|
|
store.pollOnce(io, 1000);
|
|
|
|
const stats = store.snapshotStats();
|
|
try testing.expectEqual(@as(u64, 0), stats.reloads);
|
|
try testing.expectEqual(@as(u64, 0), stats.reload_failures);
|
|
}
|
|
|
|
test "init records the load time and reload advances it" {
|
|
var env: TestEnv = undefined;
|
|
try env.init();
|
|
defer env.deinit();
|
|
const io = env.io();
|
|
|
|
var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null);
|
|
defer store.deinit(io);
|
|
|
|
const at_init = store.snapshotStats();
|
|
try testing.expectEqual(@as(u64, 0), at_init.reloads);
|
|
try testing.expectEqual(@as(u64, 0), at_init.reload_failures);
|
|
try testing.expect(at_init.last_reload_unix > 0);
|
|
|
|
try store.reload(io);
|
|
const after = store.snapshotStats();
|
|
try testing.expectEqual(@as(u64, 1), after.reloads);
|
|
try testing.expect(after.last_reload_unix >= at_init.last_reload_unix);
|
|
}
|
|
|
|
test "init accepts a comptime ALPN list" {
|
|
var env: TestEnv = undefined;
|
|
try env.init();
|
|
defer env.deinit();
|
|
const io = env.io();
|
|
|
|
const alpn: [1:null]?[*:0]const u8 = .{"http/1.1"};
|
|
var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, &alpn);
|
|
defer store.deinit(io);
|
|
|
|
try store.reload(io);
|
|
try testing.expectEqual(@as(u64, 1), store.snapshotStats().reloads);
|
|
}
|
|
|
|
test "the watch loop sleeps before polling and returns on cancel" {
|
|
var env: TestEnv = undefined;
|
|
try env.init();
|
|
defer env.deinit();
|
|
const io = env.io();
|
|
|
|
var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null);
|
|
defer store.deinit(io);
|
|
|
|
var future = try io.concurrent(CertStore.watch, .{ &store, io });
|
|
// Cancelling immediately proves the loop starts by sleeping rather than
|
|
// by reloading.
|
|
try testing.expectError(error.Canceled, future.cancel(io));
|
|
try testing.expectEqual(@as(u64, 0), store.snapshotStats().reloads);
|
|
}
|
|
|
|
// Pins the reload-ordering race via `after_load_hook`: the hook runs inside
|
|
// reload A's load-to-publish window, where it (1) probes that `reload_mutex`
|
|
// is held, the serialization this exists for (without it a second reload
|
|
// could publish here and be overwritten by A's stale read), then (2) stages
|
|
// newer cert bytes and starts an overlapping reload B. B can only read the
|
|
// files after A publishes, so B both reads and publishes last and the newer
|
|
// bytes win. This proves the lock spans the window and last-read-wins for
|
|
// this schedule; it does not enumerate every interleaving.
|
|
test "a reload overlapping another reload's window publishes last" {
|
|
var env: TestEnv = undefined;
|
|
try env.init();
|
|
defer env.deinit();
|
|
const io = env.io();
|
|
|
|
var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null);
|
|
defer store.deinit(io);
|
|
|
|
const grown = try std.mem.concat(testing.allocator, u8, &.{ fixtures.cert_pem, "\n" });
|
|
defer testing.allocator.free(grown);
|
|
|
|
const Window = struct {
|
|
store: *CertStore,
|
|
env: *TestEnv,
|
|
grown: []const u8,
|
|
overlapping: ?std.Io.Future(ReloadError!void) = null,
|
|
lock_spans_window: bool = false,
|
|
staged: bool = false,
|
|
|
|
// Reload B re-enters this hook under `reload_mutex`, so its read of
|
|
// `overlapping` is ordered after A's write and the early return makes
|
|
// the window work run exactly once.
|
|
fn call(ctx: *anyopaque, hook_io: std.Io) void {
|
|
const w: *@This() = @ptrCast(@alignCast(ctx));
|
|
if (w.overlapping != null) return;
|
|
if (w.store.reload_mutex.tryLock()) {
|
|
w.store.reload_mutex.unlock(hook_io);
|
|
} else {
|
|
w.lock_spans_window = true;
|
|
}
|
|
w.env.tmp.dir.writeFile(hook_io, .{ .sub_path = "cert.pem", .data = w.grown }) catch return;
|
|
w.staged = true;
|
|
w.overlapping = hook_io.concurrent(CertStore.reload, .{ w.store, hook_io }) catch null;
|
|
}
|
|
};
|
|
|
|
var window: Window = .{ .store = &store, .env = &env, .grown = grown };
|
|
store.after_load_hook = .{ .ctx = &window, .call = Window.call };
|
|
|
|
try store.reload(io);
|
|
|
|
try testing.expect(window.lock_spans_window);
|
|
try testing.expect(window.staged);
|
|
var overlapping = window.overlapping orelse return error.ConcurrentUnavailable;
|
|
try overlapping.await(io);
|
|
|
|
try testing.expectEqual(@as(u64, 2), store.snapshotStats().reloads);
|
|
store.mutex.lockUncancelable(io);
|
|
const final = store.loaded;
|
|
store.mutex.unlock(io);
|
|
try testing.expectEqual(@as(u64, grown.len), final.cert.size);
|
|
}
|
|
|
|
test "a failed reload opens an episode keyed by endpoint kind and a good one closes it" {
|
|
var env: TestEnv = undefined;
|
|
try env.init();
|
|
defer env.deinit();
|
|
const io = env.io();
|
|
|
|
var fx: events_fixture.Fixture = .{};
|
|
try fx.init(io, 1000);
|
|
defer fx.deinit();
|
|
|
|
var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null);
|
|
defer store.deinit(io);
|
|
store.kind = .dot;
|
|
store.diagnostics = &fx.store;
|
|
|
|
try env.tmp.dir.writeFile(io, .{ .sub_path = "cert.pem", .data = "still not a certificate" });
|
|
store.pollOnce(io, 1000);
|
|
|
|
try testing.expectEqualStrings("certificate.reload", try fx.text("SELECT code FROM operational_events"));
|
|
try testing.expectEqualStrings("dot", try fx.text("SELECT subject_key FROM operational_events"));
|
|
try testing.expectEqualStrings("warning", try fx.text("SELECT severity FROM operational_events"));
|
|
|
|
// The same certificate plus a newline: it parses, and the size differs even
|
|
// within one timestamp granule.
|
|
const grown = try std.mem.concat(testing.allocator, u8, &.{ fixtures.cert_pem, "\n" });
|
|
defer testing.allocator.free(grown);
|
|
try env.tmp.dir.writeFile(io, .{ .sub_path = "cert.pem", .data = grown });
|
|
store.pollOnce(io, 1100);
|
|
|
|
try testing.expectEqual(@as(u64, 1), store.snapshotStats().reloads);
|
|
try testing.expectEqual(
|
|
@as(i64, 0),
|
|
try fx.count("SELECT count(*) FROM operational_events WHERE resolved_at IS NULL"),
|
|
);
|
|
}
|
|
|
|
test "a failed stat opens the same episode a failed reload would" {
|
|
var env: TestEnv = undefined;
|
|
try env.init();
|
|
defer env.deinit();
|
|
const io = env.io();
|
|
|
|
var fx: events_fixture.Fixture = .{};
|
|
try fx.init(io, 1000);
|
|
defer fx.deinit();
|
|
|
|
var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null);
|
|
defer store.deinit(io);
|
|
store.diagnostics = &fx.store;
|
|
|
|
try env.tmp.dir.deleteFile(io, "cert.pem");
|
|
store.pollOnce(io, 1000);
|
|
|
|
try testing.expectEqualStrings("doh", try fx.text(
|
|
"SELECT subject_key FROM operational_events WHERE resolved_at IS NULL",
|
|
));
|
|
try testing.expectEqual(@as(u64, 0), store.snapshotStats().reload_failures);
|
|
}
|
|
|
|
test "an unchanged poll closes the episode a transient stat failure opened" {
|
|
var env: TestEnv = undefined;
|
|
try env.init();
|
|
defer env.deinit();
|
|
const io = env.io();
|
|
|
|
var fx: events_fixture.Fixture = .{};
|
|
try fx.init(io, 1000);
|
|
defer fx.deinit();
|
|
|
|
var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null);
|
|
defer store.deinit(io);
|
|
store.diagnostics = &fx.store;
|
|
|
|
// What a stat failure in a rename window left open. The file it named is
|
|
// back and unchanged, so no reload will ever close this episode.
|
|
fx.store.report(io, 1000, .certificate_reload, "doh", "doh", .warning, "stat of the certificate failed: FileNotFound");
|
|
|
|
store.pollOnce(io, 1100);
|
|
|
|
try testing.expectEqual(@as(u64, 0), store.snapshotStats().reloads);
|
|
try testing.expectEqual(
|
|
@as(i64, 0),
|
|
try fx.count("SELECT count(*) FROM operational_events WHERE resolved_at IS NULL"),
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// path apply (milestone-34 S3.4)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test "a bad candidate is refused at prepare and the store keeps serving" {
|
|
var env: TestEnv = undefined;
|
|
try env.init();
|
|
defer env.deinit();
|
|
const io = env.io();
|
|
|
|
var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null);
|
|
defer store.deinit(io);
|
|
|
|
const before = store.acquire(io);
|
|
store.release(io, before);
|
|
const before_paths_cert = store.cert_path;
|
|
|
|
try env.tmp.dir.writeFile(io, .{ .sub_path = "bad.pem", .data = "not a certificate" });
|
|
var bad_buf: [128]u8 = undefined;
|
|
const bad_path = try std.fmt.bufPrint(&bad_buf, ".zig-cache/tmp/{s}/bad.pem", .{env.tmp.sub_path});
|
|
|
|
try testing.expectError(
|
|
error.CertParse,
|
|
store.preparePathChange(io, bad_path, env.key_path),
|
|
);
|
|
|
|
// Nothing published: same generation, same paths, and `reload_mutex` was
|
|
// released — a second prepare would deadlock otherwise.
|
|
const after = store.acquire(io);
|
|
store.release(io, after);
|
|
try testing.expectEqual(before, after);
|
|
try testing.expectEqual(before_paths_cert.ptr, store.cert_path.ptr);
|
|
try testing.expectEqualStrings(env.cert_path, store.cert_path);
|
|
try testing.expectEqual(@as(u64, 0), store.snapshotStats().reloads);
|
|
try testing.expectEqual(@as(u64, 1), store.snapshotStats().reload_failures);
|
|
|
|
// A missing candidate path is refused the same way.
|
|
try testing.expectError(
|
|
error.CertUnreadable,
|
|
store.preparePathChange(io, "./nxdns-no-such-cert-4a11.pem", env.key_path),
|
|
);
|
|
}
|
|
|
|
test "a published path change installs the new pair and retires the old generation" {
|
|
var env: TestEnv = undefined;
|
|
try env.init();
|
|
defer env.deinit();
|
|
const io = env.io();
|
|
|
|
var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null);
|
|
defer store.deinit(io);
|
|
|
|
// A second, byte-different copy of the same valid pair under new names.
|
|
const grown = try std.mem.concat(testing.allocator, u8, &.{ fixtures.cert_pem, "\n" });
|
|
defer testing.allocator.free(grown);
|
|
try env.tmp.dir.writeFile(io, .{ .sub_path = "next-cert.pem", .data = grown });
|
|
try env.tmp.dir.writeFile(io, .{ .sub_path = "next-key.pem", .data = fixtures.key_pem });
|
|
var cert_buf: [128]u8 = undefined;
|
|
var key_buf: [128]u8 = undefined;
|
|
const next_cert = try std.fmt.bufPrint(&cert_buf, ".zig-cache/tmp/{s}/next-cert.pem", .{env.tmp.sub_path});
|
|
const next_key = try std.fmt.bufPrint(&key_buf, ".zig-cache/tmp/{s}/next-key.pem", .{env.tmp.sub_path});
|
|
|
|
// A connection pinned to the old generation finishes on it.
|
|
const pinned = store.acquire(io);
|
|
|
|
const prepared = try store.preparePathChange(io, next_cert, next_key);
|
|
store.publishPathChange(io, prepared);
|
|
|
|
try testing.expectEqualStrings(next_cert, store.cert_path);
|
|
try testing.expectEqualStrings(next_key, store.key_path);
|
|
try testing.expectEqual(@as(u64, 1), store.snapshotStats().reloads);
|
|
try testing.expect(pinned.retired);
|
|
|
|
const serving = store.acquire(io);
|
|
try testing.expect(serving != pinned);
|
|
store.release(io, serving);
|
|
store.release(io, pinned);
|
|
|
|
// The watcher now measures the new pair, so an untouched pair polls clean
|
|
// and a rewritten one reloads.
|
|
store.pollOnce(io, 1_000);
|
|
try testing.expectEqual(@as(u64, 1), store.snapshotStats().reloads);
|
|
try env.tmp.dir.writeFile(io, .{ .sub_path = "next-cert.pem", .data = fixtures.cert_pem });
|
|
store.pollOnce(io, 1_100);
|
|
try testing.expectEqual(@as(u64, 2), store.snapshotStats().reloads);
|
|
}
|
|
|
|
test "an aborted path change frees the candidate and leaves the store untouched" {
|
|
var env: TestEnv = undefined;
|
|
try env.init();
|
|
defer env.deinit();
|
|
const io = env.io();
|
|
|
|
var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null);
|
|
defer store.deinit(io);
|
|
|
|
const before = store.acquire(io);
|
|
store.release(io, before);
|
|
|
|
// The commit this candidate was built for failed; the testing allocator
|
|
// proves the abort frees everything the prepare took.
|
|
const prepared = try store.preparePathChange(io, env.cert_path, env.key_path);
|
|
store.abortPathChange(io, prepared);
|
|
|
|
const after = store.acquire(io);
|
|
store.release(io, after);
|
|
try testing.expectEqual(before, after);
|
|
try testing.expectEqual(@as(u64, 0), store.snapshotStats().reloads);
|
|
|
|
// `reload_mutex` came back, so the store still reloads.
|
|
try store.reload(io);
|
|
}
|
|
|
|
test "a reload racing a path apply is serialized behind it" {
|
|
var env: TestEnv = undefined;
|
|
try env.init();
|
|
defer env.deinit();
|
|
const io = env.io();
|
|
|
|
var store = try CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null);
|
|
defer store.deinit(io);
|
|
|
|
const grown = try std.mem.concat(testing.allocator, u8, &.{ fixtures.cert_pem, "\n" });
|
|
defer testing.allocator.free(grown);
|
|
try env.tmp.dir.writeFile(io, .{ .sub_path = "next-cert.pem", .data = grown });
|
|
try env.tmp.dir.writeFile(io, .{ .sub_path = "next-key.pem", .data = fixtures.key_pem });
|
|
var cert_buf: [128]u8 = undefined;
|
|
var key_buf: [128]u8 = undefined;
|
|
const next_cert = try std.fmt.bufPrint(&cert_buf, ".zig-cache/tmp/{s}/next-cert.pem", .{env.tmp.sub_path});
|
|
const next_key = try std.fmt.bufPrint(&key_buf, ".zig-cache/tmp/{s}/next-key.pem", .{env.tmp.sub_path});
|
|
|
|
// Prepare holds `reload_mutex` across the whole apply.
|
|
const prepared = try store.preparePathChange(io, next_cert, next_key);
|
|
try testing.expect(!store.reload_mutex.tryLock());
|
|
|
|
// The concurrent reload cannot start, so it cannot publish the OLD paths
|
|
// over the new generation.
|
|
var racing = try io.concurrent(CertStore.reload, .{ &store, io });
|
|
store.publishPathChange(io, prepared);
|
|
try racing.await(io);
|
|
|
|
// Two publications, and the last word is the apply's pair: the racing
|
|
// reload reread the paths the apply installed.
|
|
try testing.expectEqual(@as(u64, 2), store.snapshotStats().reloads);
|
|
try testing.expectEqualStrings(next_cert, store.cert_path);
|
|
store.mutex.lockUncancelable(io);
|
|
const final = store.loaded;
|
|
store.mutex.unlock(io);
|
|
try testing.expectEqual(@as(u64, grown.len), final.cert.size);
|
|
}
|