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
+141
View File
@@ -21,10 +21,12 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const cert_store = @import("../server/cert_store.zig");
const clients = @import("../server/clients.zig");
const dns_cache = @import("../cache/dns_cache.zig");
const dns_handler = @import("../server/handler.zig");
const disk_monitor = @import("../storage/disk_monitor.zig");
const dot_server = @import("../server/dot_server.zig");
const http_util = @import("http_util.zig");
const logging = @import("../platform/logging.zig");
const pool_mod = @import("../upstream/pool.zig");
@@ -80,6 +82,18 @@ pub const DiskSample = struct {
sample_failures: u64,
};
/// The DoH listener counters this exposition exports (milestone-10 ruling 10):
/// the four every TLS listener keeps, plus DoH's `bad_requests`. A subset of
/// `doh_server.Snapshot` on purpose — the accept-side refusal counters stay
/// internal, exactly as they do for the DoT listener and TCP/53.
pub const DohListenerSample = struct {
connections: u64,
tls_handshake_failures: u64,
idle_timeouts: u64,
connection_errors: u64,
bad_requests: u64,
};
/// One upstream, with every string owned by the caller's arena.
pub const UpstreamSample = struct {
url: []const u8,
@@ -103,6 +117,16 @@ pub const Sample = struct {
retention: ?retention_mod.Stats = null,
blocklist: ?BlocklistSample = null,
disk: ?DiskSample = null,
/// One entry per enabled TLS endpoint (milestone-10 ruling 10). Rendered
/// under an `endpoint` label so both share the two `nxdns_cert_*`
/// families. `last_reload_unix` is deliberately not exported: the reload
/// endpoint reports cert state on demand.
doh_certs: ?cert_store.CertStore.Stats = null,
dot_certs: ?cert_store.CertStore.Stats = null,
/// The listener families (ruling 10): absent while an endpoint is
/// disabled or its bind failed, like every other unwired collaborator.
doh_listener: ?DohListenerSample = null,
dot_listener: ?dot_server.StatsSnapshot = null,
upstreams: []const UpstreamSample = &.{},
};
@@ -169,6 +193,21 @@ pub fn collect(state: *server.WebState, io: std.Io, arena: Allocator) Allocator.
.sample_failures = monitor.sample_failures.load(.monotonic),
};
if (state.doh_certs) |store| sample.doh_certs = store.snapshotStats();
if (state.dot_certs) |store| sample.dot_certs = store.snapshotStats();
if (state.doh_listener) |listener| {
const snapshot = listener.snapshotStats();
sample.doh_listener = .{
.connections = snapshot.connections,
.tls_handshake_failures = snapshot.tls_handshake_failures,
.idle_timeouts = snapshot.idle_timeouts,
.connection_errors = snapshot.connection_errors,
.bad_requests = snapshot.bad_requests,
};
}
if (state.dot_listener) |listener| sample.dot_listener = listener.snapshotStats();
if (state.pool) |pool| sample.upstreams = try upstreams(pool, io, arena);
return sample;
@@ -294,9 +333,48 @@ pub fn render(w: *std.Io.Writer, sample: Sample) std.Io.Writer.Error!void {
);
}
if (sample.doh_listener) |listener| {
try counterGroup(w, "nxdns_doh_server_", "DoH listener counter", listener);
}
if (sample.dot_listener) |listener| {
try counterGroup(w, "nxdns_dot_server_", "DoT listener counter", listener);
}
if (sample.doh_certs != null or sample.dot_certs != null) try renderCerts(w, sample);
if (sample.upstreams.len != 0) try renderUpstreams(w, sample.upstreams);
}
fn renderCerts(w: *std.Io.Writer, sample: Sample) std.Io.Writer.Error!void {
try labeledHead(w, "nxdns_cert_reloads_total", "Certificate reloads that published a new context.", "counter");
if (sample.doh_certs) |stats| try endpointValue(w, "nxdns_cert_reloads_total", "doh", stats.reloads);
if (sample.dot_certs) |stats| try endpointValue(w, "nxdns_cert_reloads_total", "dot", stats.reloads);
try labeledHead(
w,
"nxdns_cert_reload_failures_total",
"Certificate reloads that failed; the old certificate keeps serving.",
"counter",
);
if (sample.doh_certs) |stats| {
try endpointValue(w, "nxdns_cert_reload_failures_total", "doh", stats.reload_failures);
}
if (sample.dot_certs) |stats| {
try endpointValue(w, "nxdns_cert_reload_failures_total", "dot", stats.reload_failures);
}
}
/// The endpoint names are ours ("doh"/"dot"), so unlike a url label there is
/// nothing to escape.
fn endpointValue(
w: *std.Io.Writer,
name: []const u8,
endpoint: []const u8,
value: u64,
) std.Io.Writer.Error!void {
try w.print("{s}{{endpoint=\"{s}\"}} {d}\n", .{ name, endpoint, value });
}
fn renderUpstreams(w: *std.Io.Writer, list: []const UpstreamSample) std.Io.Writer.Error!void {
try labeledHead(w, "nxdns_upstream_up", "1 while an upstream is enabled and healthy.", "gauge");
for (list) |entry| try labeledValue(w, "nxdns_upstream_up", entry.url, @intFromBool(entry.available));
@@ -528,6 +606,69 @@ test "an unwired collaborator omits its family rather than reporting zeros" {
try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "nxdns_disk_"));
try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "nxdns_upstream_"));
try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "nxdns_blocklist_"));
try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "nxdns_cert_"));
try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "nxdns_doh_server_"));
try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "nxdns_dot_server_"));
}
test "listener counters render only for the wired servers" {
const doh_only = try renderToString(testing.allocator, .{
.doh_listener = .{
.connections = 9,
.tls_handshake_failures = 2,
.idle_timeouts = 1,
.connection_errors = 0,
.bad_requests = 4,
},
});
defer testing.allocator.free(doh_only);
try testing.expect(std.mem.containsAtLeast(u8, doh_only, 1, "nxdns_doh_server_connections_total 9\n"));
try testing.expect(std.mem.containsAtLeast(u8, doh_only, 1, "nxdns_doh_server_tls_handshake_failures_total 2\n"));
try testing.expect(std.mem.containsAtLeast(u8, doh_only, 1, "nxdns_doh_server_idle_timeouts_total 1\n"));
try testing.expect(std.mem.containsAtLeast(u8, doh_only, 1, "nxdns_doh_server_connection_errors_total 0\n"));
try testing.expect(std.mem.containsAtLeast(u8, doh_only, 1, "nxdns_doh_server_bad_requests_total 4\n"));
try testing.expect(!std.mem.containsAtLeast(u8, doh_only, 1, "nxdns_dot_server_"));
const dot_only = try renderToString(testing.allocator, .{
.dot_listener = .{
.connections = 5,
.tls_handshake_failures = 0,
.idle_timeouts = 3,
.connection_errors = 1,
},
});
defer testing.allocator.free(dot_only);
try testing.expect(std.mem.containsAtLeast(u8, dot_only, 1, "nxdns_dot_server_connections_total 5\n"));
try testing.expect(std.mem.containsAtLeast(u8, dot_only, 1, "nxdns_dot_server_idle_timeouts_total 3\n"));
try testing.expect(std.mem.containsAtLeast(u8, dot_only, 1, "nxdns_dot_server_connection_errors_total 1\n"));
// The DoT listener has no HTTP layer, so no bad_requests family.
try testing.expect(!std.mem.containsAtLeast(u8, dot_only, 1, "nxdns_dot_server_bad_requests_total"));
try testing.expect(!std.mem.containsAtLeast(u8, dot_only, 1, "nxdns_doh_server_"));
}
test "cert reload counters render per endpoint, only for the wired stores" {
const one = try renderToString(testing.allocator, .{
.doh_certs = .{ .reloads = 2, .reload_failures = 1, .last_reload_unix = 1_700_000_000 },
});
defer testing.allocator.free(one);
try testing.expect(std.mem.containsAtLeast(u8, one, 1, "nxdns_cert_reloads_total{endpoint=\"doh\"} 2\n"));
try testing.expect(std.mem.containsAtLeast(u8, one, 1, "nxdns_cert_reload_failures_total{endpoint=\"doh\"} 1\n"));
try testing.expect(!std.mem.containsAtLeast(u8, one, 1, "endpoint=\"dot\""));
// The wall-clock second stays off the exposition.
try testing.expect(!std.mem.containsAtLeast(u8, one, 1, "last_reload"));
const both = try renderToString(testing.allocator, .{
.doh_certs = .{ .reloads = 0, .reload_failures = 0, .last_reload_unix = 0 },
.dot_certs = .{ .reloads = 3, .reload_failures = 0, .last_reload_unix = 0 },
});
defer testing.allocator.free(both);
try testing.expect(std.mem.containsAtLeast(u8, both, 1, "nxdns_cert_reloads_total{endpoint=\"doh\"} 0\n"));
try testing.expect(std.mem.containsAtLeast(u8, both, 1, "nxdns_cert_reloads_total{endpoint=\"dot\"} 3\n"));
try testing.expect(std.mem.containsAtLeast(u8, both, 1, "nxdns_cert_reload_failures_total{endpoint=\"dot\"} 0\n"));
}
test "a label value escapes the characters the format reserves" {