milestone 10: doh and dot listeners, cert store with hot reload and cert reload api

This commit is contained in:
2026-08-02 14:39:18 +02:00
parent 617cc966a2
commit a589df7515
20 changed files with 4398 additions and 21 deletions
+136
View File
@@ -0,0 +1,136 @@
//! `POST /api/certs/reload` — reload the DoH/DoT certificates from disk
//! (milestone-10 ruling 8).
//!
//! Always answers 200: the per-endpoint outcome IS the payload. A failed
//! reload is an outcome, not a server error — the store publishes nothing on
//! failure (PLAN:297), so the old certificate keeps serving and nothing about
//! the web server's own state is exceptional. A disabled endpoint has no
//! store and reports `{enabled: false, reloaded: false, error: null}`.
const std = @import("std");
const cert_store = @import("../../server/cert_store.zig");
const http_util = @import("../http_util.zig");
const server = @import("../server.zig");
const Request = http_util.Request;
const HandlerError = http_util.HandlerError;
pub const Outcome = struct {
enabled: bool,
reloaded: bool,
/// `cert_store.humanMessage` text; null on success and while disabled.
@"error": ?[]const u8,
};
pub const View = struct {
doh: Outcome,
dot: Outcome,
};
/// Reloads every enabled endpoint's store. A null store is a disabled
/// endpoint: the composition root only constructs one for an enabled
/// endpoint (milestone-10 ruling 11).
pub fn applyReload(state: *server.WebState, io: std.Io) View {
return .{
.doh = outcome(state.doh_certs, io),
.dot = outcome(state.dot_certs, io),
};
}
fn outcome(store: ?*cert_store.CertStore, io: std.Io) Outcome {
const live = store orelse return .{ .enabled = false, .reloaded = false, .@"error" = null };
live.reload(io) catch |err| return .{
.enabled = true,
.reloaded = false,
.@"error" = cert_store.humanMessage(err),
};
return .{ .enabled = true, .reloaded = true, .@"error" = null };
}
pub fn post(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
return http_util.respondJson(request, .ok, applyReload(state, io), &.{});
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const fixtures = @import("test_fixtures");
const testing = std.testing;
test "both endpoints disabled report exactly the disabled outcome" {
var state: server.WebState = .{ .gpa = testing.allocator };
const view = applyReload(&state, undefined);
for ([_]Outcome{ view.doh, view.dot }) |per_endpoint| {
try testing.expect(!per_endpoint.enabled);
try testing.expect(!per_endpoint.reloaded);
try testing.expectEqual(@as(?[]const u8, null), per_endpoint.@"error");
}
}
/// Fixture PEMs in a tmp directory, addressed the 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 "a wired store reloads; a broken one reports the message in the same payload" {
var env: TestEnv = undefined;
try env.init();
defer env.deinit();
const io = env.io();
var store = try cert_store.CertStore.init(testing.allocator, io, env.cert_path, env.key_path, null);
defer store.deinit(io);
var state: server.WebState = .{ .gpa = testing.allocator, .doh_certs = &store };
const succeeded = applyReload(&state, io);
try testing.expect(succeeded.doh.enabled);
try testing.expect(succeeded.doh.reloaded);
try testing.expectEqual(@as(?[]const u8, null), succeeded.doh.@"error");
try testing.expect(!succeeded.dot.enabled);
try testing.expectEqual(@as(u64, 1), store.snapshotStats().reloads);
// The certificate file vanishes: the outcome names the failure, the store
// keeps its old generation, and the dot half is untouched.
try env.tmp.dir.deleteFile(io, "cert.pem");
const failed = applyReload(&state, io);
try testing.expect(failed.doh.enabled);
try testing.expect(!failed.doh.reloaded);
try testing.expectEqualStrings(
cert_store.humanMessage(error.CertUnreadable),
failed.doh.@"error".?,
);
try testing.expect(!failed.dot.enabled);
try testing.expectEqual(@as(u64, 1), store.snapshotStats().reload_failures);
}