milestone 10: doh and dot listeners, cert store with hot reload and cert reload api
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user