milestone 16: behavioral fixes for silent failures, locks, counters and the query log
CI / test (push) Failing after 11s
CI / test-aarch64 (push) Failing after 2m22s
CI / frontend (push) Successful in 43s
CI / cross (push) Failing after 25s
CI / docker (push) Failing after 24s

This commit is contained in:
2026-08-07 01:54:40 +02:00
parent 5802148887
commit 25455e5ae2
31 changed files with 2054 additions and 297 deletions
+93 -9
View File
@@ -51,6 +51,8 @@ const query_sink = @import("../server/query_sink.zig");
const retention_mod = @import("../storage/retention.zig");
const router = @import("router.zig");
const sse = @import("sse.zig");
const tcp_server = @import("../server/tcp_server.zig");
const udp_server = @import("../server/udp_server.zig");
const log = std.log.scoped(.web_server);
@@ -151,6 +153,10 @@ pub const WebState = struct {
/// (milestone-10 ruling 10).
doh_listener: ?*doh_server.DohServer = null,
dot_listener: ?*dot_server.DotServer = null,
/// The plain DNS listeners. The app binds one per family per protocol, so
/// `/metrics` sums each family across its slice (milestone-16 ruling 13).
udp_listeners: []const *udp_server.UdpServer = &.{},
tcp_listeners: []const *tcp_server.TcpServer = &.{},
/// The web task's own connections (m7 ruling 21) — never the DNS path's.
config_db: ?*db.Db = null,
@@ -357,6 +363,12 @@ pub const Server = struct {
log.debug("web listener shutdown failed: {t}", .{err});
};
// Ruling 11 of milestone 16, before `beginShutdown`: a live-query task
// parked in `Hub.wait` is waiting on an event, not on its socket, so
// shutting the connection down does not reach it. Without this the
// drain below waits out one heartbeat interval per idle stream.
if (self.state.hub) |hub| hub.close(io);
self.beginShutdown(io);
if (was_serving) self.stopped.waitUncancelable(io);
@@ -507,7 +519,7 @@ pub const Server = struct {
const raw_path = copied[0..split];
const query = if (split == copied.len) copied[split..] else copied[split + 1 ..];
const cookie = copyHeader(request, "cookie", &conn.cookie_buf);
const cookie = copyCookie(request, &conn.cookie_buf);
const accept_encoding = copyHeader(request, "accept-encoding", &conn.accept_encoding_buf);
const if_none_match = copyHeader(request, "if-none-match", &conn.if_none_match_buf);
@@ -606,18 +618,60 @@ pub const Server = struct {
};
/// Copies one header value into `buf`. A value too long for its budget reads as
/// absent: the three headers this applies to are a session cookie, an
/// `accept-encoding` and an `if-none-match`, and losing any of them degrades to
/// unauthenticated, uncompressed and unconditional — never to a wrong answer.
/// absent: the headers this applies to are an `accept-encoding` and an
/// `if-none-match`, and losing either degrades to uncompressed and
/// unconditional — never to a wrong answer. The cookie header has its own
/// copier, because losing it costs the session (ruling 7 of milestone 16).
fn copyHeader(request: *http.Server.Request, name: []const u8, buf: []u8) []const u8 {
const value = headerValue(request, name) orelse return "";
if (value.len > buf.len) return "";
@memcpy(buf[0..value.len], value);
return buf[0..value.len];
}
/// The first value sent under `name`, borrowed from the request head.
fn headerValue(request: *http.Server.Request, name: []const u8) ?[]const u8 {
var it = request.iterateHeaders();
while (it.next()) |header| {
if (!std.ascii.eqlIgnoreCase(header.name, name)) continue;
if (header.value.len > buf.len) return "";
@memcpy(buf[0..header.value.len], header.value);
return buf[0..header.value.len];
if (std.ascii.eqlIgnoreCase(header.name, name)) return header.value;
}
return "";
return null;
}
/// Milestone-16 ruling 7. The cookie header is the one budget a foreign party
/// can spend: behind a reverse proxy on a shared domain, every other cookie set
/// for the domain rides along. Treating the whole header as absent then logs the
/// operator out of a working session with nothing in the log to explain it, so
/// an oversized header keeps the session pair, drops the rest, and says so.
fn copyCookie(request: *http.Server.Request, buf: []u8) []const u8 {
const value = headerValue(request, "cookie") orelse return "";
if (value.len <= buf.len) {
@memcpy(buf[0..value.len], value);
return buf[0..value.len];
}
const kept = sessionPairOnly(value, buf);
// The session value is a random id and the name is a constant, so neither
// the size nor the outcome discloses anything the client did not send.
log.debug("cookie header of {d} bytes exceeds the {d} byte budget; {s}", .{
value.len,
buf.len,
if (kept.len == 0) "no session cookie kept" else "kept the session cookie alone",
});
return kept;
}
/// Rewrites an oversized cookie header as just its session pair. Empty when the
/// header carries no session cookie, or when even the pair is over budget.
fn sessionPairOnly(value: []const u8, buf: []u8) []const u8 {
const session = http_util.cookieValue(value, auth.cookie_name) orelse return "";
const len = auth.cookie_name.len + 1 + session.len;
if (len > buf.len) return "";
@memcpy(buf[0..auth.cookie_name.len], auth.cookie_name);
buf[auth.cookie_name.len] = '=';
@memcpy(buf[auth.cookie_name.len + 1 ..][0..session.len], session);
return buf[0..len];
}
/// The whole claim rule, without the mutex, so it is testable without a backend.
@@ -697,6 +751,36 @@ test "the over-capacity response is a well formed 503" {
try testing.expectEqualStrings(over_capacity_body, over_capacity_response[split + 4 ..]);
}
test "an oversized cookie header keeps its session pair and nothing else" {
var buf: [http_util.max_cookie_len]u8 = undefined;
var header: std.ArrayList(u8) = .empty;
defer header.deinit(testing.allocator);
try header.appendSlice(testing.allocator, "consent=yes; ");
try header.appendSlice(testing.allocator, auth.cookie_name ++ "=abc123; ");
while (header.items.len < 2048) try header.appendSlice(testing.allocator, "ad_id=0123456789; ");
const kept = sessionPairOnly(header.items, &buf);
try testing.expectEqualStrings(auth.cookie_name ++ "=abc123", kept);
try testing.expectEqualStrings("abc123", http_util.cookieValue(kept, auth.cookie_name).?);
}
test "an oversized cookie header with no session pair keeps nothing" {
var buf: [http_util.max_cookie_len]u8 = undefined;
var header: std.ArrayList(u8) = .empty;
defer header.deinit(testing.allocator);
while (header.items.len < 2048) try header.appendSlice(testing.allocator, "ad_id=0123456789; ");
try testing.expectEqualStrings("", sessionPairOnly(header.items, &buf));
}
test "a session pair too long for the buffer keeps nothing" {
var buf: [32]u8 = undefined;
const header = auth.cookie_name ++ "=" ++ ("v" ** 64);
try testing.expectEqualStrings("", sessionPairOnly(header, &buf));
}
test "an unconfigured password leaves every route open" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();