milestone 16: behavioral fixes for silent failures, locks, counters and the query log
This commit is contained in:
@@ -147,9 +147,10 @@ pub fn applyDelete(state: *server.WebState, io: std.Io, id: i64) ?Failure {
|
||||
///
|
||||
/// After the reload and never part of the response: the row is gone and the
|
||||
/// snapshot has stopped enforcing the list, so bytes still on disk are not a
|
||||
/// failed delete. `Manager.pruneOrphans` takes the manager's writer lock, which
|
||||
/// the reload above has already taken and released — nothing here holds it, and
|
||||
/// `state.config_lock` was released before either.
|
||||
/// failed delete. `Manager.pruneOrphans` takes the manager's refresh lock and
|
||||
/// then its writer lock; the reload above has already taken and released the
|
||||
/// writer lock — nothing here holds either, and `state.config_lock` was
|
||||
/// released before both.
|
||||
fn pruneFiles(state: *server.WebState, io: std.Io) void {
|
||||
const manager = state.manager orelse return;
|
||||
manager.pruneOrphans(io) catch |err| {
|
||||
|
||||
@@ -112,9 +112,16 @@ pub fn stream(
|
||||
if (hub.overflowed(io, id)) break;
|
||||
|
||||
const wake = hub.wait(io, id, heartbeat_interval) catch return;
|
||||
if (wake == .timeout) {
|
||||
try w.writeAll(heartbeat);
|
||||
try response.flush();
|
||||
switch (wake) {
|
||||
// Ruling 11 of milestone 16: the server is shutting down. Returning
|
||||
// without `end` leaves the response unterminated, which is what a
|
||||
// shutdown is; the client reconnects or gives up on its own.
|
||||
.closed => return,
|
||||
.timeout => {
|
||||
try w.writeAll(heartbeat);
|
||||
try response.flush();
|
||||
},
|
||||
.ready => {},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
//! the next start runs — before a single row is written, so a settings PUT
|
||||
//! cannot leave a configuration the server would refuse to boot from.
|
||||
|
||||
const builtin = @import("builtin");
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
@@ -283,6 +284,25 @@ pub fn applyPut(
|
||||
.fail => |failure| return .{ .fail = failure },
|
||||
};
|
||||
|
||||
// Ruling 18 of milestone 16: argon2id at m=19 MiB is the longest thing this
|
||||
// handler does, and its input is the parsed patch alone — nothing under the
|
||||
// lock. Hashing inside the lock stalled every settings read and every other
|
||||
// mutation for its duration. The login path already hashes unlocked
|
||||
// (auth.zig), and `LiveHash`'s generation check closes the install race.
|
||||
const password = newPassword(patch);
|
||||
var new_hash: []const u8 = "";
|
||||
if (password) |plain| {
|
||||
if (plain.len > auth.max_password_len) {
|
||||
return .{ .fail = .{ .invalid = "web.password is too long" } };
|
||||
}
|
||||
const buf = try arena.alloc(u8, hash_buf_len);
|
||||
new_hash = hashPassword(io, arena, plain, buf) catch |err| switch (err) {
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
error.Canceled => return .{ .fail = .{ .unavailable = "shutting down" } },
|
||||
else => return .{ .fail = .{ .internal = error.Unexpected } },
|
||||
};
|
||||
}
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
defer state.config_lock.unlock(io);
|
||||
|
||||
@@ -300,21 +320,10 @@ pub fn applyPut(
|
||||
) } };
|
||||
}
|
||||
|
||||
// The password never becomes a row. It is hashed here and the hash is what
|
||||
// the merged configuration — and therefore the settings table — carries.
|
||||
const password = newPassword(patch);
|
||||
// The password never becomes a row: the hash made above is what the merged
|
||||
// configuration — and therefore the settings table — carries.
|
||||
const previous_hash = cfg.web.password_hash;
|
||||
if (password) |plain| {
|
||||
if (plain.len > auth.max_password_len) {
|
||||
return .{ .fail = .{ .invalid = "web.password is too long" } };
|
||||
}
|
||||
const buf = try arena.alloc(u8, hash_buf_len);
|
||||
cfg.web.password_hash = hashPassword(io, arena, plain, buf) catch |err| switch (err) {
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
error.Canceled => return .{ .fail = .{ .unavailable = "shutting down" } },
|
||||
else => return .{ .fail = .{ .internal = error.Unexpected } },
|
||||
};
|
||||
}
|
||||
if (password != null) cfg.web.password_hash = new_hash;
|
||||
cfg.web.password = "";
|
||||
|
||||
if (try problem(arena, cfg)) |text| return .{ .fail = .{ .invalid = text } };
|
||||
@@ -374,6 +383,7 @@ fn problem(arena: Allocator, cfg: model.Config) error{OutOfMemory}!?[]const u8 {
|
||||
/// password set through the API and one set through a config import produce the
|
||||
/// same kind of hash.
|
||||
fn hashPassword(io: std.Io, gpa: Allocator, password: []const u8, buf: []u8) ![]const u8 {
|
||||
hash_stall.park(io);
|
||||
return std.crypto.pwhash.argon2.strHash(password, .{
|
||||
.allocator = gpa,
|
||||
.params = .owasp_2id,
|
||||
@@ -390,6 +400,48 @@ fn hashPassword(io: std.Io, gpa: Allocator, password: []const u8, buf: []u8) ![]
|
||||
};
|
||||
}
|
||||
|
||||
/// Holds a hash still so a test can prove another request runs beside it. The
|
||||
/// hash finishing on its own would prove nothing: before ruling 18 a settings
|
||||
/// GET also completed, it merely waited out the hash first. The storage exists
|
||||
/// in a test build only, and `park` reduces to nothing everywhere else — the
|
||||
/// rotation seam's shape (logging.zig).
|
||||
const hash_stall = if (builtin.is_test) struct {
|
||||
var armed: bool = false;
|
||||
var parked: std.Io.Event = .unset;
|
||||
var release: std.Io.Event = .unset;
|
||||
|
||||
fn park(io: std.Io) void {
|
||||
if (!armed) return;
|
||||
parked.set(io);
|
||||
release.waitUncancelable(io);
|
||||
}
|
||||
} else struct {
|
||||
fn park(io: std.Io) void {
|
||||
_ = io;
|
||||
}
|
||||
};
|
||||
|
||||
/// The seam's controls, for the test that proves a settings read runs beside a
|
||||
/// hash in flight. Present in a test build only.
|
||||
pub const hash_stall_control = if (builtin.is_test) struct {
|
||||
pub fn arm() void {
|
||||
hash_stall.parked = .unset;
|
||||
hash_stall.release = .unset;
|
||||
hash_stall.armed = true;
|
||||
}
|
||||
|
||||
/// Returns once a hash is parked on the seam.
|
||||
pub fn waitParked(io: std.Io) void {
|
||||
hash_stall.parked.waitUncancelable(io);
|
||||
}
|
||||
|
||||
/// Lets the parked hash finish and disarms the seam for the next test.
|
||||
pub fn release(io: std.Io) void {
|
||||
hash_stall.armed = false;
|
||||
hash_stall.release.set(io);
|
||||
}
|
||||
} else struct {};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// routes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -36,6 +36,8 @@ const rate_limiter = @import("../server/rate_limiter.zig");
|
||||
const retention_mod = @import("../storage/retention.zig");
|
||||
const safe_url = @import("../safe_url.zig");
|
||||
const server = @import("server.zig");
|
||||
const tcp_server = @import("../server/tcp_server.zig");
|
||||
const udp_server = @import("../server/udp_server.zig");
|
||||
|
||||
/// The exposition format version, as the 0.0.4 specification writes it.
|
||||
pub const content_type = "text/plain; version=0.0.4; charset=utf-8";
|
||||
@@ -133,6 +135,13 @@ pub const Sample = struct {
|
||||
/// disabled or its bind failed, like every other unwired collaborator.
|
||||
doh_listener: ?DohListenerSample = null,
|
||||
dot_listener: ?dot_server.StatsSnapshot = null,
|
||||
/// The plain-DNS listener families (ruling 13). The app binds one listener
|
||||
/// per address family, and both answer the same port for the same reason,
|
||||
/// so their counters are summed into one family rather than labelled: an
|
||||
/// operator asks how much UDP/53 dropped, not how much of it arrived over
|
||||
/// IPv6. Absent when no listener is wired, like every other collaborator.
|
||||
udp_listener: ?udp_server.Snapshot = null,
|
||||
tcp_listener: ?tcp_server.Snapshot = null,
|
||||
upstreams: []const UpstreamSample = &.{},
|
||||
};
|
||||
|
||||
@@ -214,11 +223,36 @@ pub fn collect(state: *server.WebState, io: std.Io, arena: Allocator) Allocator.
|
||||
}
|
||||
if (state.dot_listener) |listener| sample.dot_listener = listener.snapshotStats();
|
||||
|
||||
sample.udp_listener = sumListeners(udp_server.Snapshot, udp_server.UdpServer, state.udp_listeners);
|
||||
sample.tcp_listener = sumListeners(tcp_server.Snapshot, tcp_server.TcpServer, state.tcp_listeners);
|
||||
|
||||
if (state.pool) |pool| sample.upstreams = try upstreams(pool, io, arena);
|
||||
|
||||
return sample;
|
||||
}
|
||||
|
||||
/// Adds one snapshot per listener field by field. Null for an empty slice, so
|
||||
/// an unbound listener omits its family rather than reporting zeros.
|
||||
///
|
||||
/// A `u64` counter cannot realistically overflow the sum of four of them, and
|
||||
/// wrapping addition would be a worse answer than a wrong-looking large one, so
|
||||
/// the addition is the ordinary checked one.
|
||||
fn sumListeners(comptime Snapshot: type, comptime Server: type, listeners: []const *Server) ?Snapshot {
|
||||
if (listeners.len == 0) return null;
|
||||
|
||||
var total: Snapshot = undefined;
|
||||
inline for (@typeInfo(Snapshot).@"struct".fields) |field| {
|
||||
@field(total, field.name) = 0;
|
||||
}
|
||||
for (listeners) |listener| {
|
||||
const one = listener.snapshotStats();
|
||||
inline for (@typeInfo(Snapshot).@"struct".fields) |field| {
|
||||
@field(total, field.name) += @field(one, field.name);
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
fn dnsCounters(stats: *const dns_handler.Handler.Stats) DnsCounters {
|
||||
var out: DnsCounters = undefined;
|
||||
inline for (dns_stat_fields, 0..) |field, i| {
|
||||
@@ -339,6 +373,12 @@ pub fn render(w: *std.Io.Writer, sample: Sample) std.Io.Writer.Error!void {
|
||||
);
|
||||
}
|
||||
|
||||
if (sample.udp_listener) |listener| {
|
||||
try counterGroup(w, "nxdns_udp_server_", "UDP/53 listener counter", listener);
|
||||
}
|
||||
if (sample.tcp_listener) |listener| {
|
||||
try counterGroup(w, "nxdns_tcp_server_", "TCP/53 listener counter", listener);
|
||||
}
|
||||
if (sample.doh_listener) |listener| {
|
||||
try counterGroup(w, "nxdns_doh_server_", "DoH listener counter", listener);
|
||||
}
|
||||
@@ -705,6 +745,103 @@ test "an unwired collaborator omits its family rather than reporting zeros" {
|
||||
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_"));
|
||||
try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_"));
|
||||
try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_"));
|
||||
}
|
||||
|
||||
test "the plain-DNS listener families carry every counter of both listeners" {
|
||||
const text = try renderToString(testing.allocator, .{
|
||||
.udp_listener = .{
|
||||
.received = 90,
|
||||
.dropped_oversize = 1,
|
||||
.dropped_no_slot = 2,
|
||||
.dropped_handler = 3,
|
||||
.receive_errors = 4,
|
||||
.send_errors = 5,
|
||||
},
|
||||
.tcp_listener = .{
|
||||
.accepted = 12,
|
||||
.rejected_at_capacity = 6,
|
||||
.rejected_at_shutdown = 7,
|
||||
.accept_errors = 8,
|
||||
.connection_errors = 9,
|
||||
.idle_timeouts = 10,
|
||||
},
|
||||
});
|
||||
defer testing.allocator.free(text);
|
||||
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_received_total 90\n"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_dropped_oversize_total 1\n"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_dropped_no_slot_total 2\n"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_dropped_handler_total 3\n"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_receive_errors_total 4\n"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_udp_server_send_errors_total 5\n"));
|
||||
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_accepted_total 12\n"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_rejected_at_capacity_total 6\n"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_rejected_at_shutdown_total 7\n"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_accept_errors_total 8\n"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_connection_errors_total 9\n"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_tcp_server_idle_timeouts_total 10\n"));
|
||||
|
||||
// One family per listener kind, whatever the number of listeners behind it:
|
||||
// the counters are summed, not labelled.
|
||||
try testing.expectEqual(
|
||||
@as(usize, 1),
|
||||
std.mem.count(u8, text, "# TYPE nxdns_udp_server_received_total counter\n"),
|
||||
);
|
||||
}
|
||||
|
||||
test "one family covers all four listeners, summed" {
|
||||
// Only `stats` is read, so the listeners need no socket: `snapshotStats`
|
||||
// loads counters and touches nothing else.
|
||||
var udp6: udp_server.UdpServer = undefined;
|
||||
udp6.stats = .{};
|
||||
udp6.stats.received.store(10, .monotonic);
|
||||
udp6.stats.dropped_no_slot.store(1, .monotonic);
|
||||
|
||||
var udp4: udp_server.UdpServer = undefined;
|
||||
udp4.stats = .{};
|
||||
udp4.stats.received.store(7, .monotonic);
|
||||
udp4.stats.dropped_no_slot.store(2, .monotonic);
|
||||
|
||||
var tcp6: tcp_server.TcpServer = undefined;
|
||||
tcp6.stats = .{};
|
||||
tcp6.stats.accepted.store(4, .monotonic);
|
||||
|
||||
var tcp4: tcp_server.TcpServer = undefined;
|
||||
tcp4.stats = .{};
|
||||
tcp4.stats.accepted.store(5, .monotonic);
|
||||
tcp4.stats.idle_timeouts.store(3, .monotonic);
|
||||
|
||||
const udp = sumListeners(udp_server.Snapshot, udp_server.UdpServer, &.{ &udp6, &udp4 }).?;
|
||||
try testing.expectEqual(@as(u64, 17), udp.received);
|
||||
try testing.expectEqual(@as(u64, 3), udp.dropped_no_slot);
|
||||
try testing.expectEqual(@as(u64, 0), udp.send_errors);
|
||||
|
||||
const tcp = sumListeners(tcp_server.Snapshot, tcp_server.TcpServer, &.{ &tcp6, &tcp4 }).?;
|
||||
try testing.expectEqual(@as(u64, 9), tcp.accepted);
|
||||
try testing.expectEqual(@as(u64, 3), tcp.idle_timeouts);
|
||||
|
||||
// No listener at all is a missing family, not a family of zeros.
|
||||
try testing.expectEqual(
|
||||
@as(?udp_server.Snapshot, null),
|
||||
sumListeners(udp_server.Snapshot, udp_server.UdpServer, &.{}),
|
||||
);
|
||||
}
|
||||
|
||||
test "the three forward-client counters reach the DNS families" {
|
||||
var dns: DnsCounters = @splat(0);
|
||||
dns[fieldIndex("forward_udp_truncated")] = 2;
|
||||
dns[fieldIndex("forward_foreign_datagrams")] = 3;
|
||||
dns[fieldIndex("forward_failures")] = 4;
|
||||
|
||||
const text = try renderToString(testing.allocator, .{ .dns = dns });
|
||||
defer testing.allocator.free(text);
|
||||
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_dns_forward_udp_truncated_total 2\n"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_dns_forward_foreign_datagrams_total 3\n"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_dns_forward_failures_total 4\n"));
|
||||
}
|
||||
|
||||
test "listener counters render only for the wired servers" {
|
||||
|
||||
+93
-9
@@ -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();
|
||||
|
||||
+76
-6
@@ -29,14 +29,18 @@ pub const ring_capacity = 64;
|
||||
|
||||
pub const SubscriberId = enum(u8) { _ };
|
||||
|
||||
/// What `wait` returns: an entry (or the overflow flag) is ready, or the
|
||||
/// caller's timeout passed and it owes the client a heartbeat.
|
||||
pub const Wake = enum { ready, timeout };
|
||||
/// What `wait` returns: an entry (or the overflow flag) is ready, the caller's
|
||||
/// timeout passed and it owes the client a heartbeat, or the hub is closing and
|
||||
/// the subscriber must end its response now.
|
||||
pub const Wake = enum { ready, timeout, closed };
|
||||
|
||||
pub const Hub = struct {
|
||||
/// Guards every field of every slot. `publish` runs on the DNS hot path,
|
||||
/// so the critical section is copies and flag writes only.
|
||||
mutex: std.Io.Mutex,
|
||||
/// Milestone-16 ruling 11. Set once, never cleared: a hub that is closing
|
||||
/// belongs to a server that is going away.
|
||||
closing: bool,
|
||||
slots: [max_subscribers]Slot,
|
||||
|
||||
const Slot = struct {
|
||||
@@ -56,6 +60,7 @@ pub const Hub = struct {
|
||||
/// The ring storage stays undefined: `len` says which slots hold entries.
|
||||
pub fn init(self: *Hub) void {
|
||||
self.mutex = .init;
|
||||
self.closing = false;
|
||||
for (&self.slots) |*slot| {
|
||||
slot.active = false;
|
||||
slot.overflowed = false;
|
||||
@@ -99,6 +104,22 @@ pub const Hub = struct {
|
||||
slot.head = 0;
|
||||
}
|
||||
|
||||
/// Milestone-16 ruling 11. Ends every live stream.
|
||||
///
|
||||
/// Without this, a graceful drain waits out one heartbeat interval per idle
|
||||
/// subscriber: shutting the sockets down does not reach a task parked inside
|
||||
/// `wait`, which is waiting on an event, not on the peer. Called before the
|
||||
/// web listener begins its shutdown.
|
||||
pub fn close(self: *Hub, io: std.Io) void {
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
|
||||
self.closing = true;
|
||||
for (&self.slots) |*slot| {
|
||||
if (slot.active) slot.event.set(io);
|
||||
}
|
||||
}
|
||||
|
||||
/// Copies `entry` into every live ring and wakes its subscriber. Called
|
||||
/// once per logged query.
|
||||
pub fn publish(self: *Hub, io: std.Io, entry: Entry) void {
|
||||
@@ -140,8 +161,9 @@ pub const Hub = struct {
|
||||
return self.slotOf(id).overflowed;
|
||||
}
|
||||
|
||||
/// Blocks until something is ready for this subscriber or `timeout`
|
||||
/// passes; `.timeout` is the heartbeat's cue.
|
||||
/// Blocks until something is ready for this subscriber, `timeout` passes,
|
||||
/// or the hub closes; `.timeout` is the heartbeat's cue and `.closed` ends
|
||||
/// the stream.
|
||||
///
|
||||
/// The event is reset under the mutex and only while the ring is empty, so
|
||||
/// a `publish` that lands between the check and the wait sets the event
|
||||
@@ -157,6 +179,10 @@ pub const Hub = struct {
|
||||
timeout: std.Io.Clock.Duration,
|
||||
) std.Io.Cancelable!Wake {
|
||||
self.mutex.lockUncancelable(io);
|
||||
if (self.closing) {
|
||||
self.mutex.unlock(io);
|
||||
return .closed;
|
||||
}
|
||||
const slot = self.slotOf(id);
|
||||
if (slot.len > 0 or slot.overflowed) {
|
||||
self.mutex.unlock(io);
|
||||
@@ -169,7 +195,13 @@ pub const Hub = struct {
|
||||
error.Timeout => return .timeout,
|
||||
error.Canceled => |e| return e,
|
||||
};
|
||||
return .ready;
|
||||
|
||||
// `close` wakes the same event a publish does, so the flag is what tells
|
||||
// the two apart. Reading it here rather than on the next call through
|
||||
// keeps the drain from writing one more frame into a dying connection.
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
return if (self.closing) .closed else .ready;
|
||||
}
|
||||
|
||||
fn slotOf(self: *Hub, id: SubscriberId) *Slot {
|
||||
@@ -401,6 +433,44 @@ test "an overflow wakes a waiting subscriber" {
|
||||
try testing.expect(hub.overflowed(io, id));
|
||||
}
|
||||
|
||||
test "close wakes a parked subscriber instead of leaving it on the heartbeat" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
const hub = try newHub(testing.allocator);
|
||||
defer testing.allocator.destroy(hub);
|
||||
|
||||
const id = hub.subscribe(io).?;
|
||||
defer hub.unsubscribe(io, id);
|
||||
|
||||
// Far longer than the 15 s heartbeat the handler passes, so a pass here
|
||||
// cannot come from the timeout arm.
|
||||
const long: std.Io.Clock.Duration = .{ .raw = .fromSeconds(600), .clock = .awake };
|
||||
var future = try io.concurrent(Hub.wait, .{ hub, io, id, long });
|
||||
|
||||
hub.close(io);
|
||||
|
||||
try testing.expectEqual(Wake.closed, try future.await(io));
|
||||
}
|
||||
|
||||
test "wait on a closed hub returns at once, with no entry pending" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
const hub = try newHub(testing.allocator);
|
||||
defer testing.allocator.destroy(hub);
|
||||
|
||||
const id = hub.subscribe(io).?;
|
||||
defer hub.unsubscribe(io, id);
|
||||
|
||||
hub.close(io);
|
||||
|
||||
const long: std.Io.Clock.Duration = .{ .raw = .fromSeconds(600), .clock = .awake };
|
||||
try testing.expectEqual(Wake.closed, try hub.wait(io, id, long));
|
||||
}
|
||||
|
||||
test "publishing while subscribers come and go reaches only the live ones" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
|
||||
@@ -62,6 +62,7 @@ const handlers_live = @import("handlers/live.zig");
|
||||
const handlers_lookup = @import("handlers/lookup.zig");
|
||||
const handlers_pause = @import("handlers/pause.zig");
|
||||
const handlers_queries = @import("handlers/queries.zig");
|
||||
const handlers_settings = @import("handlers/settings.zig");
|
||||
const handlers_stats = @import("handlers/stats.zig");
|
||||
const handlers_upstream_health = @import("handlers/upstream_health.zig");
|
||||
const handlers_version = @import("handlers/version.zig");
|
||||
@@ -147,7 +148,9 @@ const Conn = struct {
|
||||
extra_header: ?[]const u8,
|
||||
body: ?[]const u8,
|
||||
) !void {
|
||||
var buf: [2048]u8 = undefined;
|
||||
// Room for an over-budget cookie header (ruling 7 of milestone 16) and
|
||||
// still under the server's 8 KiB maximum request head.
|
||||
var buf: [6144]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
try w.print("{s} {s} HTTP/1.1\r\nhost: t\r\n", .{ method, target });
|
||||
if (extra_header) |line| try w.print("{s}\r\n", .{line});
|
||||
@@ -814,6 +817,19 @@ test "the certs reload payload with both endpoints disabled parses strictly" {
|
||||
|
||||
const test_password = "correct horse battery staple";
|
||||
|
||||
/// Hashes on an `Io` of its own, before the environment exists, so nothing
|
||||
/// mutates a `WebState` the server tasks are already reading.
|
||||
fn hashTestPassword(gpa: Allocator, buf: []u8) ![]const u8 {
|
||||
var hash_threaded: std.Io.Threaded = .init(gpa, .{});
|
||||
defer hash_threaded.deinit();
|
||||
return std.crypto.pwhash.argon2.strHash(test_password, .{
|
||||
.allocator = gpa,
|
||||
.params = .owasp_2id,
|
||||
.mode = .argon2id,
|
||||
.encoding = .phc,
|
||||
}, buf, hash_threaded.io());
|
||||
}
|
||||
|
||||
fn authMatrix(io: std.Io, env: *Env) anyerror!void {
|
||||
var body_buf: [4096]u8 = undefined;
|
||||
|
||||
@@ -871,21 +887,8 @@ test "W10 auth on: password-hashed environment enforces the session matrix" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
|
||||
// Hashed before the environment exists, so nothing mutates a `WebState`
|
||||
// the server tasks are reading.
|
||||
var hash_threaded: std.Io.Threaded = .init(gpa, .{});
|
||||
var hash_buf: [256]u8 = undefined;
|
||||
const hash = std.crypto.pwhash.argon2.strHash(test_password, .{
|
||||
.allocator = gpa,
|
||||
.params = .owasp_2id,
|
||||
.mode = .argon2id,
|
||||
.encoding = .phc,
|
||||
}, &hash_buf, hash_threaded.io()) catch |err| {
|
||||
hash_threaded.deinit();
|
||||
return err;
|
||||
};
|
||||
hash_threaded.deinit();
|
||||
const hash = try hashTestPassword(gpa, &hash_buf);
|
||||
|
||||
var env = try Env.create(gpa, .{ .password_hash = hash });
|
||||
defer env.destroy();
|
||||
@@ -923,6 +926,77 @@ test "W10 auth off: an empty hash leaves every route open" {
|
||||
try bounded(env.io(), default_budget, authOff, .{ env.io(), env });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// oversized cookie headers (ruling 7 of milestone 16)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Pads `list` past `http_util.max_cookie_len` with foreign cookies, the way a
|
||||
/// reverse proxy on a shared domain does.
|
||||
fn padCookies(gpa: Allocator, list: *std.ArrayList(u8)) !void {
|
||||
var index: usize = 0;
|
||||
while (list.items.len <= http_util.max_cookie_len * 2) : (index += 1) {
|
||||
try list.print(gpa, "ad_id_{d}=0123456789abcdef; ", .{index});
|
||||
}
|
||||
}
|
||||
|
||||
fn oversizedCookie(io: std.Io, env: *Env) anyerror!void {
|
||||
var body_buf: [4096]u8 = undefined;
|
||||
|
||||
var conn: Conn = undefined;
|
||||
try conn.connect(io, env.addr);
|
||||
defer conn.close(io);
|
||||
|
||||
try conn.request("POST", "/api/auth/login", null, "{\"password\":\"" ++ test_password ++ "\"}");
|
||||
var response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 200), response.status);
|
||||
const set_cookie = response.header("set-cookie") orelse return error.TestNoCookie;
|
||||
const pair_end = std.mem.findScalar(u8, set_cookie, ';') orelse set_cookie.len;
|
||||
|
||||
var session_pair: [256]u8 = undefined;
|
||||
@memcpy(session_pair[0..pair_end], set_cookie[0..pair_end]);
|
||||
|
||||
// The session pair buried in the middle of an over-budget header. Before
|
||||
// ruling 7 the whole header read as absent and this 401'd.
|
||||
var with_session: std.ArrayList(u8) = .empty;
|
||||
defer with_session.deinit(env.gpa);
|
||||
try with_session.appendSlice(env.gpa, "cookie: ");
|
||||
try padCookies(env.gpa, &with_session);
|
||||
try with_session.appendSlice(env.gpa, session_pair[0..pair_end]);
|
||||
try with_session.appendSlice(env.gpa, "; ");
|
||||
try padCookies(env.gpa, &with_session);
|
||||
try testing.expect(with_session.items.len > http_util.max_cookie_len);
|
||||
|
||||
try conn.request("GET", "/api/groups", with_session.items, null);
|
||||
response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 200), response.status);
|
||||
|
||||
// The same size of header carrying no session pair stays unauthenticated:
|
||||
// the degradation keeps the session, it does not open the door.
|
||||
var without_session: std.ArrayList(u8) = .empty;
|
||||
defer without_session.deinit(env.gpa);
|
||||
try without_session.appendSlice(env.gpa, "cookie: ");
|
||||
try padCookies(env.gpa, &without_session);
|
||||
try testing.expect(without_session.items.len > http_util.max_cookie_len);
|
||||
|
||||
try conn.request("GET", "/api/groups", without_session.items, null);
|
||||
response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 401), response.status);
|
||||
try testing.expectEqualStrings("{\"error\":\"authentication required\"}", response.body);
|
||||
}
|
||||
|
||||
test "W10 a 2 KiB cookie header keeps the session and still refuses without one" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
var hash_buf: [256]u8 = undefined;
|
||||
const hash = try hashTestPassword(gpa, &hash_buf);
|
||||
|
||||
var env = try Env.create(gpa, .{ .password_hash = hash });
|
||||
defer env.destroy();
|
||||
|
||||
try bounded(env.io(), default_budget, oversizedCookie, .{ env.io(), env });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// rate limiting (ruling 19)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1023,6 +1097,58 @@ test "W10 SSE: retry preamble, query frame, per-address cap and heartbeat" {
|
||||
try bounded(env.io(), sse_budget, sseStream, .{ env.io(), env });
|
||||
}
|
||||
|
||||
/// Opens a live stream and reads the preamble, so the subscriber task is in
|
||||
/// its wait loop by the time this returns.
|
||||
fn openLiveStream(io: std.Io, env: *Env, conn: *Conn, seen: *std.ArrayList(u8)) anyerror!void {
|
||||
try conn.connect(io, env.addr);
|
||||
// Stays open on success — the caller closes it. Only a failure here leaves
|
||||
// a socket for this to reclaim.
|
||||
errdefer conn.close(io);
|
||||
|
||||
try conn.request("GET", "/api/queries/live", null, null);
|
||||
const head = try conn.receiveHead();
|
||||
try testing.expectEqual(@as(u16, 200), head.status);
|
||||
try conn.readChunkedUntil(seen, env.gpa, "retry: 3000");
|
||||
}
|
||||
|
||||
test "W10 shutdown with a live stream open does not wait out a heartbeat" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
|
||||
// The teardown frees the environment's own `Io`, so the clock that times it
|
||||
// has to be somebody else's.
|
||||
var clock_threaded: std.Io.Threaded = .init(gpa, .{});
|
||||
defer clock_threaded.deinit();
|
||||
const clock_io = clock_threaded.io();
|
||||
|
||||
var env = try Env.create(gpa, .{});
|
||||
|
||||
var conn: Conn = undefined;
|
||||
var seen: std.ArrayList(u8) = .empty;
|
||||
defer seen.deinit(gpa);
|
||||
|
||||
// Not a `defer`: the teardown is what this test measures, so it runs below
|
||||
// rather than after the assertion.
|
||||
bounded(env.io(), default_budget, openLiveStream, .{ env.io(), env, &conn, &seen }) catch |err| {
|
||||
env.destroy();
|
||||
return err;
|
||||
};
|
||||
|
||||
// Closed before the teardown because the teardown frees the `Io` this
|
||||
// socket belongs to. It does not weaken the test: the subscriber task is
|
||||
// parked on a hub event, not on this socket, so closing the client end
|
||||
// does not wake it — only `Hub.close` does (ruling 11 of milestone 16).
|
||||
conn.close(env.io());
|
||||
|
||||
const started = std.Io.Clock.awake.now(clock_io);
|
||||
env.destroy();
|
||||
const elapsed = started.durationTo(std.Io.Clock.awake.now(clock_io));
|
||||
|
||||
// Before ruling 11 the drain waited out the full 15 s heartbeat interval.
|
||||
try testing.expect(elapsed.toMilliseconds() < 5_000);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// pagination walk (ruling 11)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1399,6 +1525,57 @@ test "W10 settings written through the API read back changed" {
|
||||
try bounded(env.io(), default_budget, settingsRoundTrip, .{ env.io(), env });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// password hashing outside config_lock (ruling 18 of milestone 16)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn putNewPassword(io: std.Io, env: *Env) anyerror!void {
|
||||
var body_buf: [16 * 1024]u8 = undefined;
|
||||
var conn: Conn = undefined;
|
||||
try conn.connect(io, env.addr);
|
||||
defer conn.close(io);
|
||||
|
||||
try conn.request("PUT", "/api/settings", null, "{\"web\":{\"password\":\"" ++ test_password ++ "\"}}");
|
||||
const response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 200), response.status);
|
||||
}
|
||||
|
||||
fn getWhileHashParked(io: std.Io, env: *Env) anyerror!void {
|
||||
var put = try io.concurrent(putNewPassword, .{ io, env });
|
||||
errdefer {
|
||||
handlers_settings.hash_stall_control.release(io);
|
||||
put.await(io) catch {};
|
||||
}
|
||||
|
||||
handlers_settings.hash_stall_control.waitParked(io);
|
||||
|
||||
// The whole point of the ruling: this read completes while the hash is
|
||||
// still held. Before the fix it blocked on `config_lock` until the hash
|
||||
// finished, and the seam would deadlock the test rather than answer.
|
||||
var body_buf: [16 * 1024]u8 = undefined;
|
||||
var reader: Conn = undefined;
|
||||
try reader.connect(io, env.addr);
|
||||
defer reader.close(io);
|
||||
|
||||
try reader.request("GET", "/api/settings", null, null);
|
||||
const response = try reader.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 200), response.status);
|
||||
|
||||
handlers_settings.hash_stall_control.release(io);
|
||||
try put.await(io);
|
||||
}
|
||||
|
||||
test "W10 a settings read completes while a password hash is still running" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
var env = try Env.create(gpa, .{});
|
||||
defer env.destroy();
|
||||
|
||||
handlers_settings.hash_stall_control.arm();
|
||||
try bounded(env.io(), default_budget, getWhileHashParked, .{ env.io(), env });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// static assets: /, SPA fallback, ETag → 304 (ruling 24)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user