db-mode config changes apply live in-process
Gates / frontend (push) Successful in 1m43s
Gates / test (push) Successful in 2m14s
Gates / test-aarch64 (push) Successful in 8m3s
Gates / package (push) Successful in 5m42s
Gates / container (push) Successful in 54s
CI / gates (push) Successful in 50m24s
Gates / frontend (push) Successful in 1m43s
Gates / test (push) Successful in 2m14s
Gates / test-aarch64 (push) Successful in 8m3s
Gates / package (push) Successful in 5m42s
Gates / container (push) Successful in 54s
CI / gates (push) Successful in 50m24s
settings and upstream writes now follow a prepare, commit, publish, retire contract: candidates are built and validated before the database transaction, published as infallible pointer swaps, and old generations retire after their readers drain. per-query policy values snapshot once per query; upstream pool, cache, rate limiter, sessions, api limiter, log sink, blocklist scheduler and the query-log queue each gained one named live operation. restart_required shrinks from every scalar key to the bind keys and web.enabled; the admin ui drops its restart notices for everything else. file mode is unchanged.
This commit is contained in:
+158
-5
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user