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

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:
2026-08-24 00:04:28 +02:00
parent f7f4c8be09
commit ce143d1d87
47 changed files with 7698 additions and 926 deletions
+158 -5
View File
@@ -36,11 +36,11 @@ const events_mod = @import("../storage/events.zig");
const http_util = @import("http_util.zig");
const listener_core = @import("../server/listener.zig");
const local_tables_mod = @import("../server/local_tables.zig");
const logger_mod = @import("../storage/logger.zig");
const logger_controller = @import("../storage/logger_controller.zig");
const manager_mod = @import("../filter/manager.zig");
const model = @import("../config/model.zig");
const pause_mod = @import("../server/pause.zig");
const pool_mod = @import("../upstream/pool.zig");
const upstream_owner = @import("../upstream/owner.zig");
const query_sink = @import("../server/query_sink.zig");
const retention_mod = @import("../storage/retention.zig");
const router = @import("router.zig");
@@ -115,9 +115,75 @@ pub const Authority = union(enum) {
managed_file: []const u8,
};
/// `web.trusted_proxies`, live. The boot text is borrowed from the loaded
/// configuration; every replacement is an owned, immutable generation that a
/// reader holds a shared lock on for as long as it borrows the text.
///
/// Taking the exclusive lock is what drains the readers: once `install`
/// returns, nothing holds the generation it hands back, so the caller can free
/// it. The free itself belongs to the caller and not to this type, because a
/// publish must not do work that a retire owns.
pub const LiveProxies = struct {
lock: std.Io.RwLock = .init,
text: []const u8 = "",
owned: bool = false,
pub fn init(boot_text: []const u8) LiveProxies {
return .{ .text = boot_text };
}
/// The reader's hold. Release it, and do not retain `text` afterwards.
pub const Handle = struct {
text: []const u8,
live: *LiveProxies,
pub fn release(self: Handle, io: std.Io) void {
self.live.lock.unlockShared(io);
}
};
pub fn acquire(self: *LiveProxies, io: std.Io) Handle {
self.lock.lockSharedUncancelable(io);
return .{ .text = self.text, .live = self };
}
/// Takes ownership of `prepared`, which must be a `gpa` allocation, and
/// returns the generation it replaced for the caller to free — or null
/// when what it replaced was the borrowed boot text.
pub fn install(self: *LiveProxies, io: std.Io, prepared: []const u8) ?[]const u8 {
self.lock.lockUncancelable(io);
defer self.lock.unlock(io);
const retired: ?[]const u8 = if (self.owned) self.text else null;
self.text = prepared;
self.owned = true;
return retired;
}
pub fn deinit(self: *LiveProxies, gpa: Allocator) void {
if (self.owned) gpa.free(self.text);
self.* = undefined;
}
};
/// The long-lived collaborators `upstream_owner.build` needs, which are the
/// composition root's and not the request's: the shared HTTP client every DoH
/// leaf borrows and the one certificate bundle every DoT leaf verifies against.
pub const UpstreamBuild = struct {
http: *std.http.Client,
bundle: *std.crypto.Certificate.Bundle,
bundle_lock: *std.Io.RwLock,
};
pub const WebState = struct {
gpa: Allocator,
web: model.Web = .{},
/// The live `web.trusted_proxies`. `web` above is the boot configuration
/// and goes stale the moment `PUT /api/settings` changes the list, exactly
/// as it does for the password: the request path reads this holder and
/// never `web.trusted_proxies`. The composition root seeds it from the boot
/// value, and whoever owns the `WebState` calls `proxies.deinit`.
proxies: LiveProxies = .{},
/// Defaults to `.database`: a `WebState` nobody told about a managed file
/// governs nothing declaratively, which is the safe reading — the mutation
@@ -141,14 +207,28 @@ pub const WebState = struct {
/// The learned-name resolver, for `metrics.collect` (milestone-25 ruling 9).
client_names: ?*client_names.Resolver = null,
manager: ?*manager_mod.Manager = null,
pool: ?*pool_mod.Pool = null,
/// The published upstream generation. A metrics or health scrape pins one
/// for the length of its read, so a `replace` cannot free the pool it is
/// copying out of.
upstreams: ?*upstream_owner.Owner = null,
/// What building a replacement upstream generation needs beyond the rows
/// themselves. Null in a state whose upstream owner is a test's borrowed
/// one: there is then nothing to build, and an upstream mutation applies to
/// the database alone.
upstream_build: ?UpstreamBuild = null,
monitor: ?*disk_monitor.Monitor = null,
/// The local records and forward zones the DNS path reads. The
/// local-records and forward-zones handlers rebuild and swap them
/// (ruling 12).
local_tables: ?*local_tables_mod.LocalTables = null,
logger: ?*logger_mod.Logger = null,
/// The query logger's controller, not a `Logger`: a resize replaces the
/// generation, and the counters a scrape reads are the controller's.
logger: ?*logger_controller.Controller = null,
retention: ?*retention_mod.Retention = null,
/// The one `logging.retention_days` cell both prune passes read. Separate
/// from `retention` above, which is one of the two readers: a settings
/// apply stores here and moves both of them together.
retention_days: ?*retention_mod.RetentionDays = null,
sessions: ?*auth.Sessions = null,
/// The password hash every auth decision reads. `web` above is the boot
/// configuration and goes stale the moment `PUT /api/settings` changes the
@@ -530,7 +610,15 @@ pub const Server = struct {
const peer = address.NetAddress.fromIp(conn.peer);
const forwarded_for = copyHeaderSuffix(request, "x-forwarded-for", &conn.payload.xff_buf);
const client_addr = switch (clientAddr(self.state.web.trusted_proxies, peer, forwarded_for)) {
// The hold ends with the verdict, before dispatch: a settings PUT takes
// the exclusive lock from inside its own request, so a hold that lasted
// the request would be that request waiting on itself.
const verdict = blk: {
const proxies = self.state.proxies.acquire(io);
defer proxies.release(io);
break :blk clientAddr(proxies.text, peer, forwarded_for);
};
const client_addr = switch (verdict) {
.addr => |addr| addr,
.bad_forwarded_for => {
var view = bareRequest(request, conn, arena);
@@ -882,3 +970,68 @@ test "a trusted-proxy element that is not an IP literal trusts nobody" {
try testing.expect(trustsPeer("proxy.example, 10.0.0.1", peer));
try testing.expect(!trustsPeer("", peer));
}
/// Every generation this test installs trusts `10.0.0.1` and nothing else, so
/// a reader that ever disagrees read a generation that was already freed.
const proxy_generations = [_][]const u8{
"10.0.0.1",
"10.0.0.1, 10.0.0.2",
"10.0.0.1,fd00::1,10.0.0.3",
" 10.0.0.1 ",
};
fn readProxiesRepeatedly(live: *LiveProxies, io: std.Io, rounds: usize, disagreed: *bool) void {
const peer = ip("10.0.0.1");
const stranger = ip("198.51.100.7");
for (0..rounds) |_| {
const held = live.acquire(io);
defer held.release(io);
if (!trustsPeer(held.text, peer)) disagreed.* = true;
if (trustsPeer(held.text, stranger)) disagreed.* = true;
}
}
test "trusted proxies are replaced under concurrent request-path reads" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var live: LiveProxies = .init(proxy_generations[0]);
defer live.deinit(testing.allocator);
var disagreed = false;
var reader = try io.concurrent(readProxiesRepeatedly, .{ &live, io, 2_000, &disagreed });
for (0..2_000) |i| {
const prepared = try testing.allocator.dupe(u8, proxy_generations[i % proxy_generations.len]);
// Publish, then retire: `install` returns only once no reader holds
// what it replaced, which is what makes this free safe.
if (live.install(io, prepared)) |retired| testing.allocator.free(retired);
}
reader.await(io);
try testing.expect(!disagreed);
}
test "the boot text is borrowed and the first install is what starts owning" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var boot_text: [8]u8 = "10.0.0.1".*;
var live: LiveProxies = .init(&boot_text);
defer live.deinit(testing.allocator);
// Nothing to retire: the boot text belongs to the loaded configuration.
const first = try testing.allocator.dupe(u8, "10.0.0.2");
try testing.expect(live.install(io, first) == null);
const second = try testing.allocator.dupe(u8, "10.0.0.3");
const retired = live.install(io, second).?;
try testing.expectEqualStrings("10.0.0.2", retired);
testing.allocator.free(retired);
const held = live.acquire(io);
defer held.release(io);
try testing.expectEqualStrings("10.0.0.3", held.text);
}