milestone 19: hygiene sweep - dead ecs surface, single-source constants, tls classification, frontend state hazards, docker smoke network fix
This commit is contained in:
+2
-10
@@ -465,8 +465,6 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
|
||||
// Everything the web layer borrows lives above; the group below cancels the
|
||||
// web task before any of it is released. With the web interface disabled
|
||||
// the state stays in its null-defaulted shape and no task reads it.
|
||||
if (args.web_dev) |dir| web_dev_dir = dir;
|
||||
|
||||
var web_state: web_server.WebState = .{ .gpa = gpa };
|
||||
// The live hash may own a gpa replacement after a settings PUT; this defer
|
||||
// runs after `group.cancel` below, so no web task can still read it.
|
||||
@@ -499,6 +497,7 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
|
||||
// Ruling 24: `--web-dev` serves from disk with no cache headers;
|
||||
// otherwise the embedded assets answer every non-/api miss.
|
||||
.fallback = if (args.web_dev != null) serveWebDev else static.fallback,
|
||||
.dev_dir = args.web_dev orelse "",
|
||||
.reload_fn = reloadManager,
|
||||
};
|
||||
|
||||
@@ -634,12 +633,6 @@ fn reloadManager(state: *web_server.WebState, io: std.Io) anyerror!void {
|
||||
try manager.reload(io);
|
||||
}
|
||||
|
||||
/// The `--web-dev` directory. `WebState.fallback` is a bare function pointer
|
||||
/// with no closure to carry the path, and one process runs one composition
|
||||
/// root, so the directory lives here: written once by `serve` before the web
|
||||
/// task starts, read only by `serveWebDev`.
|
||||
var web_dev_dir: []const u8 = "";
|
||||
|
||||
/// Dev-mode asset serving (ruling 24): straight from disk, no cache headers,
|
||||
/// so an edit shows up on the next reload.
|
||||
fn serveWebDev(
|
||||
@@ -647,8 +640,7 @@ fn serveWebDev(
|
||||
io: std.Io,
|
||||
request: *http_util.Request,
|
||||
) http_util.HandlerError!void {
|
||||
_ = state;
|
||||
return static.serveFromDisk(web_dev_dir, io, request);
|
||||
return static.serveFromDisk(state.dev_dir, io, request);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Vendored
+6
-4
@@ -7,7 +7,8 @@
|
||||
//! buffer and allocates nothing.
|
||||
//!
|
||||
//! **Not thread-safe.** No lock guards the slots, the index or the counters.
|
||||
//! Phase 7 decides the locking when it wires the cache into the query path.
|
||||
//! The query path owns the lock: `handler.Handler.cache_mutex` is held across
|
||||
//! every `get`, `put` and `sweep`.
|
||||
//!
|
||||
//! The store is a fixed array of slots plus a hash index from key bytes to slot
|
||||
//! number, both sized at `init` and never resized: a household resolver must
|
||||
@@ -39,8 +40,8 @@ pub const max_key_len = types.max_name_len + 1 + 2 + 2 + 1 + 1 + max_ecs_len;
|
||||
|
||||
/// Writes the cache key into `buf` and returns the written prefix.
|
||||
///
|
||||
/// ECS participates only when the caller passes it. Phase 7 passes the
|
||||
/// forwarded subnet under `ecs_mode=forward` and nothing otherwise (PLAN §8),
|
||||
/// ECS participates only when the caller passes it. `handler.Context.cacheKey`
|
||||
/// passes the forwarded subnet under `ecs_mode=forward` and nothing else (PLAN §8),
|
||||
/// so a deployment that does not forward ECS pays no key-space split for it.
|
||||
///
|
||||
/// The length bounds are assertions, not errors: both values reach here from
|
||||
@@ -436,7 +437,8 @@ pub const DnsCache = struct {
|
||||
return copy;
|
||||
}
|
||||
|
||||
/// Removes every expired entry and returns how many. Phase 7 schedules it;
|
||||
/// Removes every expired entry and returns how many. `app.maintenanceOnce`
|
||||
/// schedules it;
|
||||
/// expiry is also enforced on access, so this only reclaims memory held by
|
||||
/// names nobody asks for any more.
|
||||
pub fn sweep(self: *DnsCache, now_s: i64) u32 {
|
||||
|
||||
+2
-2
@@ -329,8 +329,8 @@ pub const DataDir = struct {
|
||||
}
|
||||
|
||||
/// An additional connection to a `querylog.db` that `openQuerylogDb` has
|
||||
/// already established. Phase 7 needs two — the log writer and the retention
|
||||
/// pass each own one (`retention.zig`'s contract).
|
||||
/// already established. A running server needs two — the log writer and the
|
||||
/// retention pass each own one (`retention.zig`'s contract).
|
||||
pub fn reopenQuerylogDb(self: *const DataDir, io: std.Io) !db.Db {
|
||||
_ = io;
|
||||
var database = try db.Db.open(self.querylog_db_path, .{ .mode = .read_write_existing });
|
||||
|
||||
+5
-130
@@ -90,59 +90,6 @@ pub fn findOption(packet: []const u8, opt: OptRecord, code: u16) error{BadOption
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Address families in the EDNS Client Subnet option, from the IANA Address
|
||||
/// Family Numbers registry.
|
||||
pub const ecs_family_ipv4: u16 = 1;
|
||||
pub const ecs_family_ipv6: u16 = 2;
|
||||
|
||||
pub const Ecs = struct {
|
||||
family: u16,
|
||||
source_prefix: u8,
|
||||
scope_prefix: u8,
|
||||
/// The truncated address, `ceil(source_prefix / 8)` bytes, as a slice into
|
||||
/// the option data.
|
||||
address: []const u8,
|
||||
};
|
||||
|
||||
pub const EcsError = error{BadEcs};
|
||||
|
||||
/// RFC 7871 §6: FAMILY, SOURCE PREFIX-LENGTH, SCOPE PREFIX-LENGTH, then only
|
||||
/// as many address bytes as the source prefix covers.
|
||||
pub fn parseEcs(data: []const u8) EcsError!Ecs {
|
||||
if (data.len < 4) return error.BadEcs;
|
||||
const family = std.mem.readInt(u16, data[0..2], .big);
|
||||
const source_prefix = data[2];
|
||||
const scope_prefix = data[3];
|
||||
|
||||
const max_prefix: u16 = switch (family) {
|
||||
ecs_family_ipv4 => 32,
|
||||
ecs_family_ipv6 => 128,
|
||||
// An unknown family has no known address width, so only the encoded
|
||||
// length can be checked.
|
||||
else => 255,
|
||||
};
|
||||
if (source_prefix > max_prefix) return error.BadEcs;
|
||||
if (scope_prefix > max_prefix) return error.BadEcs;
|
||||
|
||||
// RFC 7871 §6 truncates the address to the source prefix and pads the last
|
||||
// octet with zero bits, so a prefix of 0 carries no address bytes at all.
|
||||
const address_len = (@as(usize, source_prefix) + 7) / 8;
|
||||
if (data.len - 4 != address_len) return error.BadEcs;
|
||||
|
||||
const significant_bits: u3 = @intCast(source_prefix % 8);
|
||||
if (significant_bits != 0) {
|
||||
const padding_mask = @as(u8, 0xff) >> significant_bits;
|
||||
if (data[3 + address_len] & padding_mask != 0) return error.BadEcs;
|
||||
}
|
||||
|
||||
return .{
|
||||
.family = family,
|
||||
.source_prefix = source_prefix,
|
||||
.scope_prefix = scope_prefix,
|
||||
.address = data[4..],
|
||||
};
|
||||
}
|
||||
|
||||
/// Writes the OPT record: root owner name, type OPT, the payload size in
|
||||
/// CLASS, the flags in TTL, then the option list verbatim.
|
||||
pub fn encodeOpt(opt: OptRecord, options_bytes: []const u8, w: *Writer) (Writer.Error || error{OptionsTooLong})!void {
|
||||
@@ -386,81 +333,6 @@ test "parseOpt rejects a malformed option list" {
|
||||
try testing.expectError(error.BadOption, parseOpt(partial, rp.record));
|
||||
}
|
||||
|
||||
test "parseEcs reads an IPv4 subnet" {
|
||||
const ecs = try parseEcs("\x00\x01\x18\x00\xc0\x00\x02");
|
||||
try testing.expectEqual(ecs_family_ipv4, ecs.family);
|
||||
try testing.expectEqual(@as(u8, 24), ecs.source_prefix);
|
||||
try testing.expectEqual(@as(u8, 0), ecs.scope_prefix);
|
||||
try testing.expectEqualSlices(u8, "\xc0\x00\x02", ecs.address);
|
||||
}
|
||||
|
||||
test "parseEcs reads an IPv6 subnet" {
|
||||
const ecs = try parseEcs("\x00\x02\x38\x38\x20\x01\x0d\xb8\x00\x00\x00");
|
||||
try testing.expectEqual(ecs_family_ipv6, ecs.family);
|
||||
try testing.expectEqual(@as(u8, 56), ecs.source_prefix);
|
||||
try testing.expectEqual(@as(u8, 56), ecs.scope_prefix);
|
||||
try testing.expectEqual(@as(usize, 7), ecs.address.len);
|
||||
}
|
||||
|
||||
test "parseEcs reads a zero-length prefix" {
|
||||
const ecs = try parseEcs("\x00\x01\x00\x00");
|
||||
try testing.expectEqual(@as(u8, 0), ecs.source_prefix);
|
||||
try testing.expectEqual(@as(usize, 0), ecs.address.len);
|
||||
|
||||
// A prefix of 0 covers no address byte, so any address byte is a length
|
||||
// mismatch.
|
||||
try testing.expectError(error.BadEcs, parseEcs("\x00\x01\x00\x00\x00"));
|
||||
try testing.expectError(error.BadEcs, parseEcs("\x00\x01\x00\x00\xc0\x00\x02\x00"));
|
||||
}
|
||||
|
||||
test "parseEcs rejects nonzero padding bits past the source prefix" {
|
||||
// IPv4 /25: the low seven bits of the fourth address byte must be zero.
|
||||
try testing.expectError(error.BadEcs, parseEcs("\x00\x01\x19\x00\xc0\x00\x02\x01"));
|
||||
try testing.expectError(error.BadEcs, parseEcs("\x00\x01\x19\x00\xc0\x00\x02\xff"));
|
||||
|
||||
const zero_padded = try parseEcs("\x00\x01\x19\x00\xc0\x00\x02\x80");
|
||||
try testing.expectEqual(@as(u8, 25), zero_padded.source_prefix);
|
||||
try testing.expectEqualSlices(u8, "\xc0\x00\x02\x80", zero_padded.address);
|
||||
|
||||
// IPv4 /20: the low four bits of the third address byte must be zero.
|
||||
try testing.expectError(error.BadEcs, parseEcs("\x00\x01\x14\x00\xc0\x00\x0f"));
|
||||
try testing.expectEqualSlices(u8, "\xc0\x00\x00", (try parseEcs("\x00\x01\x14\x00\xc0\x00\x00")).address);
|
||||
|
||||
// IPv6 /57: the low seven bits of the eighth address byte must be zero.
|
||||
try testing.expectError(error.BadEcs, parseEcs("\x00\x02\x39\x00\x20\x01\x0d\xb8\x00\x00\x00\x7f"));
|
||||
try testing.expectEqualSlices(
|
||||
u8,
|
||||
"\x20\x01\x0d\xb8\x00\x00\x00\x80",
|
||||
(try parseEcs("\x00\x02\x39\x00\x20\x01\x0d\xb8\x00\x00\x00\x80")).address,
|
||||
);
|
||||
|
||||
// An unknown family uses the same encoding, so the rule holds there too.
|
||||
try testing.expectError(error.BadEcs, parseEcs("\x12\x34\x03\x00\xff"));
|
||||
try testing.expectEqualSlices(u8, "\xe0", (try parseEcs("\x12\x34\x03\x00\xe0")).address);
|
||||
}
|
||||
|
||||
test "parseEcs rejects malformed options" {
|
||||
// Shorter than the fixed fields.
|
||||
try testing.expectError(error.BadEcs, parseEcs(""));
|
||||
try testing.expectError(error.BadEcs, parseEcs("\x00\x01\x18"));
|
||||
// Prefix wider than the family allows.
|
||||
try testing.expectError(error.BadEcs, parseEcs("\x00\x01\x21" ++ "\x00\x00\x00\x00\x00"));
|
||||
try testing.expectError(error.BadEcs, parseEcs("\x00\x02\x81" ++ "\x00" ** 17));
|
||||
// Scope wider than the family allows.
|
||||
try testing.expectError(error.BadEcs, parseEcs("\x00\x01\x18\x21\xc0\x00\x02"));
|
||||
// Address shorter than the prefix needs.
|
||||
try testing.expectError(error.BadEcs, parseEcs("\x00\x01\x18\x00\xc0\x00"));
|
||||
// Address longer than the prefix needs.
|
||||
try testing.expectError(error.BadEcs, parseEcs("\x00\x01\x18\x00\xc0\x00\x02\x00"));
|
||||
}
|
||||
|
||||
test "parseEcs accepts an unknown family with a consistent length" {
|
||||
const ecs = try parseEcs("\x12\x34\x08\x00\xff");
|
||||
try testing.expectEqual(@as(u16, 0x1234), ecs.family);
|
||||
try testing.expectEqualSlices(u8, "\xff", ecs.address);
|
||||
try testing.expectError(error.BadEcs, parseEcs("\x12\x34\x08\x00\xff\xff"));
|
||||
}
|
||||
|
||||
test "encodeOpt round-trips through record parse and parseOpt" {
|
||||
const original: OptRecord = .{
|
||||
.udp_payload_size = 1232,
|
||||
@@ -485,8 +357,11 @@ test "encodeOpt round-trips through record parse and parseOpt" {
|
||||
try testing.expectEqual(original.do_bit, opt.do_bit);
|
||||
try testing.expectEqualSlices(u8, options_bytes, opt.options.slice(bytes));
|
||||
|
||||
const ecs = try parseEcs((try findOption(bytes, opt, ecs_option_code)).?.data);
|
||||
try testing.expectEqual(@as(u8, 24), ecs.source_prefix);
|
||||
try testing.expectEqualSlices(
|
||||
u8,
|
||||
"\x00\x01\x18\x00\xc0\x00\x02",
|
||||
(try findOption(bytes, opt, ecs_option_code)).?.data,
|
||||
);
|
||||
}
|
||||
|
||||
test "encodeOpt round-trips both DO states" {
|
||||
|
||||
+33
-6
@@ -156,6 +156,32 @@ fn parseUrl(url: []const u8) Error!std.Uri {
|
||||
/// first time two phases shared an error.
|
||||
const Phase = enum { connect, send, receive };
|
||||
|
||||
// `error.X` in an expression names a member into existence rather than
|
||||
// referring to one, so the switch in `mapError` would keep compiling — and
|
||||
// silently stop matching — if std renamed either of these. This is what fails
|
||||
// the build instead.
|
||||
comptime {
|
||||
for ([_][]const u8{ "TlsInitializationFailed", "CertificateBundleLoadFailure" }) |name| {
|
||||
if (!errorSetHas(std.http.Client.RequestError, name)) {
|
||||
@compileError("std.http.Client.RequestError no longer names " ++ name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn errorSetHas(comptime Set: type, comptime name: []const u8) bool {
|
||||
for (@typeInfo(Set).error_set.?) |member| {
|
||||
if (std.mem.eql(u8, member.name, name)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// The two names are the whole TLS surface this file can reach.
|
||||
/// `std.http.Client` collapses every handshake fault into
|
||||
/// `error.TlsInitializationFailed` (Client.zig:1470) and every bundle fault into
|
||||
/// `error.CertificateBundleLoadFailure`, and this file never unwraps a
|
||||
/// connection's stashed read cause — it maps the collapsed `error.ReadFailed` by
|
||||
/// phase — so the record-layer members of `std.crypto.tls.Client.ReadError`
|
||||
/// cannot arrive here. `doh_client.zig` does unwrap, and names them.
|
||||
fn mapError(err: anyerror, phase: Phase) Error {
|
||||
if (transport.mapLocal(err)) |local| return narrowLocal(local);
|
||||
switch (err) {
|
||||
@@ -165,9 +191,10 @@ fn mapError(err: anyerror, phase: Phase) Error {
|
||||
error.TooManyHttpRedirects => return error.HttpStatus,
|
||||
else => {},
|
||||
}
|
||||
const err_name = @errorName(err);
|
||||
if (std.mem.startsWith(u8, err_name, "Tls") or
|
||||
std.mem.startsWith(u8, err_name, "Certificate")) return error.TlsFailed;
|
||||
switch (err) {
|
||||
error.TlsInitializationFailed, error.CertificateBundleLoadFailure => return error.TlsFailed,
|
||||
else => {},
|
||||
}
|
||||
return switch (phase) {
|
||||
.connect => error.ConnectFailed,
|
||||
.send => error.SendFailed,
|
||||
@@ -307,10 +334,10 @@ test "mapError maps local errors before phase errors" {
|
||||
);
|
||||
}
|
||||
|
||||
test "mapError maps tls errors regardless of phase" {
|
||||
test "mapError maps the reachable tls errors regardless of phase" {
|
||||
try testing.expectEqual(error.TlsFailed, mapError(error.TlsInitializationFailed, .connect));
|
||||
try testing.expectEqual(error.TlsFailed, mapError(error.TlsAlert, .receive));
|
||||
try testing.expectEqual(error.TlsFailed, mapError(error.CertificateExpired, .connect));
|
||||
try testing.expectEqual(error.TlsFailed, mapError(error.TlsInitializationFailed, .receive));
|
||||
try testing.expectEqual(error.TlsFailed, mapError(error.CertificateBundleLoadFailure, .connect));
|
||||
}
|
||||
|
||||
test "mapError maps a redirect overrun to HttpStatus" {
|
||||
|
||||
@@ -1388,7 +1388,7 @@ fn requestHeader() header.Header {
|
||||
return (packet.parse(opt_query) catch unreachable).header;
|
||||
}
|
||||
|
||||
/// Answers `qname`/`qtype` from `table`, exactly as the Phase 7 handler will:
|
||||
/// Answers `qname`/`qtype` from `table`, exactly as `server/handler.zig` does:
|
||||
/// look the name up, then write the run it returns into a response.
|
||||
fn answerLocal(
|
||||
table: *const records.Records,
|
||||
|
||||
@@ -289,7 +289,7 @@ pub const Manager = struct {
|
||||
/// what every test and `nxdns check` want. Only the scheduler consults it —
|
||||
/// see `refreshGated`.
|
||||
monitor: ?*disk_monitor.Monitor = null,
|
||||
/// Scheduled refresh passes skipped by the disk gate. Phase 8's health
|
||||
/// Scheduled refresh passes skipped by the disk gate. The `/api/health`
|
||||
/// rollup reads it through `refreshesGated`.
|
||||
refreshes_gated: std.atomic.Value(u64) = .init(0),
|
||||
|
||||
@@ -610,8 +610,8 @@ pub const Manager = struct {
|
||||
///
|
||||
/// The status entry is found by row id, so a source added since the last
|
||||
/// `reload` has nowhere to record its outcome. `refreshAll` syncs the table
|
||||
/// before it refreshes anything, which is why it is the entry point Phase 8
|
||||
/// and the scheduler use.
|
||||
/// before it refreshes anything, which is why `POST /api/blocklists/update`
|
||||
/// and the scheduler both enter through `refreshAll`.
|
||||
pub fn refreshSource(self: *Manager, io: std.Io, row: sources_repo.SourceRow) Error!bool {
|
||||
self.refresh_lock.lockUncancelable(io);
|
||||
defer self.refresh_lock.unlock(io);
|
||||
@@ -1142,7 +1142,8 @@ pub const Manager = struct {
|
||||
/// a critically full disk must not take.
|
||||
///
|
||||
/// `reload` and `refreshAll` are deliberately not gated: both are operator
|
||||
/// actions (the composition root's startup load, Phase 8's manual refresh),
|
||||
/// actions (the composition root's startup load, `POST
|
||||
/// /api/blocklists/update`),
|
||||
/// and an operator who asks for a refresh on a full disk has asked for it.
|
||||
///
|
||||
/// Counting happens here, so a caller cannot skip a pass without recording
|
||||
|
||||
@@ -55,7 +55,7 @@ pub fn lookup(domain: []const u8) ?[]const u8 {
|
||||
|
||||
/// The rewritten question name for a matched query. Applying it — sending the
|
||||
/// rewritten question upstream and prefixing the answer with a CNAME from the
|
||||
/// original name to the target — is the handler's job (Phase 7). Nothing here
|
||||
/// original name to the target — is `server/handler.zig`'s job. Nothing here
|
||||
/// builds a response.
|
||||
pub fn rewrite(domain: []const u8) ?name.Name {
|
||||
const target = lookup(domain) orelse return null;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! resolver — which speaks port 53 and nothing else. `transport.Endpoint` knows
|
||||
//! only `https://` and `tls://` by design, so the configuration for this client
|
||||
//! comes from `validate.Resolver` instead. The interface it implements is the
|
||||
//! same `transport.Client` every upstream implements, so the Phase 7 handler
|
||||
//! same `transport.Client` every upstream implements, so `server/handler.zig`
|
||||
//! treats a forward zone exactly like any other exchange.
|
||||
//!
|
||||
//! No health tracking and no backoff live here. `upstream/health.zig` and
|
||||
|
||||
@@ -55,10 +55,19 @@ pub const max_rotated_path_bytes = std.Io.Dir.max_path_bytes + 4;
|
||||
pub const Stats = struct {
|
||||
lines_written: u64 = 0,
|
||||
lines_deduped: u64 = 0,
|
||||
/// Messages that did not fit `max_message_bytes` and carry the
|
||||
/// `truncation_marker` in place of their last bytes. Counted where the
|
||||
/// truncation happens, so a truncated message the dedup window then
|
||||
/// swallows counts here and under `lines_deduped`.
|
||||
lines_truncated: u64 = 0,
|
||||
rotations: u64 = 0,
|
||||
sink_errors: u64 = 0,
|
||||
};
|
||||
|
||||
/// What a truncated message ends in, the same three bytes `safe_url.zig` uses
|
||||
/// so both truncations read alike to an operator.
|
||||
pub const truncation_marker = "...";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Level
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -287,8 +296,18 @@ pub fn logFn(
|
||||
|
||||
var message_buf: [max_message_bytes]u8 = undefined;
|
||||
var mw: std.Io.Writer = .fixed(&message_buf);
|
||||
mw.print(format, args) catch {};
|
||||
const message = mw.buffered();
|
||||
// A message longer than the buffer is truncated rather than dropped, but a
|
||||
// silently cut line reads as a complete one: the marker says the sink cut
|
||||
// it, and the counter says how often that happens.
|
||||
const message = if (mw.print(format, args)) mw.buffered() else |_| blk: {
|
||||
state.stats.lines_truncated += 1;
|
||||
// `@min` rather than a plain subtraction: a `print` that fails on its
|
||||
// first chunk leaves fewer bytes buffered than the marker is long, and
|
||||
// the marker still has to fit.
|
||||
const kept = @min(mw.buffered().len, message_buf.len - truncation_marker.len);
|
||||
@memcpy(message_buf[kept..][0..truncation_marker.len], truncation_marker);
|
||||
break :blk message_buf[0 .. kept + truncation_marker.len];
|
||||
};
|
||||
|
||||
if (comptime isDedupScope(scope) and enabled(message_level, .warn)) {
|
||||
comptime std.debug.assert(@tagName(scope).len <= max_scope_name_bytes);
|
||||
@@ -1032,3 +1051,51 @@ test "rotation covers max_files - 1 generations" {
|
||||
try testing.expectEqualStrings("nxdns.log.1", names[0]);
|
||||
try testing.expectEqualStrings("nxdns.log.4", names[3]);
|
||||
}
|
||||
|
||||
test "a message over the buffer is marked and counted" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
|
||||
var tmp = testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
var path_buf: [128]u8 = undefined;
|
||||
const p = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/nxdns.log", .{tmp.sub_path});
|
||||
|
||||
var stderr_buf: [64]u8 = undefined;
|
||||
_ = std.debug.lockStderr(&stderr_buf);
|
||||
const saved = state;
|
||||
defer {
|
||||
closeFileLocked();
|
||||
state = saved;
|
||||
std.debug.unlockStderr();
|
||||
}
|
||||
|
||||
state.stats = .{};
|
||||
state.io = threaded.io();
|
||||
state.threshold = .info;
|
||||
state.output = .file;
|
||||
state.path_len = p.len;
|
||||
@memcpy(state.path_buf[0..p.len], p);
|
||||
state.max_files = 0;
|
||||
state.max_bytes = 0;
|
||||
state.file = null;
|
||||
state.file_pos = 0;
|
||||
state.rotate_pending = false;
|
||||
state.installed = true;
|
||||
|
||||
const oversized: [max_message_bytes + 100]u8 = @splat('a');
|
||||
logFn(.info, .default, "{s}", .{&oversized});
|
||||
|
||||
try testing.expectEqual(@as(u64, 1), state.stats.lines_truncated);
|
||||
try testing.expectEqual(@as(u64, 1), state.stats.lines_written);
|
||||
try testing.expectEqual(@as(u64, 0), state.stats.sink_errors);
|
||||
|
||||
closeFileLocked();
|
||||
var read_buf: [max_message_bytes * 2]u8 = undefined;
|
||||
const written = try std.Io.Dir.cwd().readFile(state.io, p, &read_buf);
|
||||
try testing.expect(std.mem.endsWith(u8, written, truncation_marker ++ "\n"));
|
||||
// The marker replaces the last bytes of the message rather than extending
|
||||
// it, so the record still fits what the buffer held.
|
||||
const colon = std.mem.indexOf(u8, written, ": ").?;
|
||||
try testing.expectEqual(max_message_bytes, written[colon + 2 ..].len - 1);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
#include <mbedtls/ctr_drbg.h>
|
||||
#include <mbedtls/entropy.h>
|
||||
#include <mbedtls/net_sockets.h>
|
||||
#include <mbedtls/pk.h>
|
||||
#include <mbedtls/ssl.h>
|
||||
#include <mbedtls/x509_crt.h>
|
||||
@@ -33,3 +34,19 @@ size_t nx_max_context_alignment(void)
|
||||
if (_Alignof(mbedtls_ctr_drbg_context) > max) max = _Alignof(mbedtls_ctr_drbg_context);
|
||||
return max;
|
||||
}
|
||||
|
||||
/* The configuration enums and error codes tls_server.zig transcribes from the
|
||||
* pinned headers. A version bump that renumbers one of these would otherwise
|
||||
* change behaviour silently -- a moved close_notify inverts the truncation
|
||||
* check -- so the Zig side compares every value against the getter here. */
|
||||
int nx_const_ssl_transport_stream(void) { return MBEDTLS_SSL_TRANSPORT_STREAM; }
|
||||
int nx_const_ssl_is_server(void) { return MBEDTLS_SSL_IS_SERVER; }
|
||||
int nx_const_ssl_verify_none(void) { return MBEDTLS_SSL_VERIFY_NONE; }
|
||||
int nx_const_ssl_preset_default(void) { return MBEDTLS_SSL_PRESET_DEFAULT; }
|
||||
|
||||
int nx_const_err_ssl_conn_eof(void) { return MBEDTLS_ERR_SSL_CONN_EOF; }
|
||||
int nx_const_err_ssl_peer_close_notify(void) { return MBEDTLS_ERR_SSL_PEER_CLOSE_NOTIFY; }
|
||||
int nx_const_err_ssl_want_read(void) { return MBEDTLS_ERR_SSL_WANT_READ; }
|
||||
int nx_const_err_ssl_want_write(void) { return MBEDTLS_ERR_SSL_WANT_WRITE; }
|
||||
int nx_const_err_net_recv_failed(void) { return MBEDTLS_ERR_NET_RECV_FAILED; }
|
||||
int nx_const_err_net_send_failed(void) { return MBEDTLS_ERR_NET_SEND_FAILED; }
|
||||
|
||||
@@ -76,6 +76,7 @@ pub const ServerContext = struct {
|
||||
alpn: ?[*:null]const ?[*:0]const u8,
|
||||
) InitError!ServerContext {
|
||||
assert(nx_max_context_alignment() <= context_alignment);
|
||||
assert(transcriptionsAgree());
|
||||
|
||||
// TLS 1.3 is on in the stock config; its key schedule runs through PSA.
|
||||
const psa_status = psa_crypto_init();
|
||||
@@ -671,6 +672,35 @@ extern fn nx_max_context_alignment() usize;
|
||||
/// The public key of the first certificate in `crt`.
|
||||
extern fn nx_x509_crt_pk(crt: *X509Crt) *PkContext;
|
||||
|
||||
extern fn nx_const_ssl_transport_stream() c_int;
|
||||
extern fn nx_const_ssl_is_server() c_int;
|
||||
extern fn nx_const_ssl_verify_none() c_int;
|
||||
extern fn nx_const_ssl_preset_default() c_int;
|
||||
extern fn nx_const_err_ssl_conn_eof() c_int;
|
||||
extern fn nx_const_err_ssl_peer_close_notify() c_int;
|
||||
extern fn nx_const_err_ssl_want_read() c_int;
|
||||
extern fn nx_const_err_ssl_want_write() c_int;
|
||||
extern fn nx_const_err_net_recv_failed() c_int;
|
||||
extern fn nx_const_err_net_send_failed() c_int;
|
||||
|
||||
/// Every transcribed value beside the header macro it came from. The sizes and
|
||||
/// alignments have always been read from the shim rather than transcribed;
|
||||
/// these ten were the exception, and a renumbering after a version bump would
|
||||
/// change behaviour with nothing to say so — a moved `close_notify` inverts the
|
||||
/// truncation detection in `read` and `close`.
|
||||
fn transcriptionsAgree() bool {
|
||||
return ssl_transport_stream == nx_const_ssl_transport_stream() and
|
||||
ssl_is_server == nx_const_ssl_is_server() and
|
||||
ssl_verify_none == nx_const_ssl_verify_none() and
|
||||
ssl_preset_default == nx_const_ssl_preset_default() and
|
||||
err_conn_eof == nx_const_err_ssl_conn_eof() and
|
||||
err_peer_close_notify == nx_const_err_ssl_peer_close_notify() and
|
||||
err_want_read == nx_const_err_ssl_want_read() and
|
||||
err_want_write == nx_const_err_ssl_want_write() and
|
||||
err_net_recv_failed == nx_const_err_net_recv_failed() and
|
||||
err_net_send_failed == nx_const_err_net_send_failed();
|
||||
}
|
||||
|
||||
// -- tests -----------------------------------------------------------------
|
||||
|
||||
test "ServerContext.init accepts the fixture cert and key" {
|
||||
@@ -736,6 +766,23 @@ test "the shim agrees with the alignment contexts are allocated at" {
|
||||
try std.testing.expect(nx_sizeof_ctr_drbg_context() > 0);
|
||||
}
|
||||
|
||||
test "the shim agrees with every transcribed mbedtls constant" {
|
||||
try std.testing.expect(transcriptionsAgree());
|
||||
|
||||
// Value by value, so a failure names the one that drifted instead of
|
||||
// reporting that something did.
|
||||
try std.testing.expectEqual(ssl_transport_stream, nx_const_ssl_transport_stream());
|
||||
try std.testing.expectEqual(ssl_is_server, nx_const_ssl_is_server());
|
||||
try std.testing.expectEqual(ssl_verify_none, nx_const_ssl_verify_none());
|
||||
try std.testing.expectEqual(ssl_preset_default, nx_const_ssl_preset_default());
|
||||
try std.testing.expectEqual(err_conn_eof, nx_const_err_ssl_conn_eof());
|
||||
try std.testing.expectEqual(err_peer_close_notify, nx_const_err_ssl_peer_close_notify());
|
||||
try std.testing.expectEqual(err_want_read, nx_const_err_ssl_want_read());
|
||||
try std.testing.expectEqual(err_want_write, nx_const_err_ssl_want_write());
|
||||
try std.testing.expectEqual(err_net_recv_failed, nx_const_err_net_recv_failed());
|
||||
try std.testing.expectEqual(err_net_send_failed, nx_const_err_net_send_failed());
|
||||
}
|
||||
|
||||
test "readErrorFor keeps a canceled read out of the TLS failure bucket" {
|
||||
try std.testing.expectEqual(ServerStream.ReadError.Canceled, readErrorFor(error.Canceled));
|
||||
try std.testing.expectEqual(ServerStream.ReadError.TlsFailed, readErrorFor(error.ConnectionResetByPeer));
|
||||
|
||||
@@ -318,12 +318,15 @@ fn load(
|
||||
};
|
||||
defer gpa.free(cert_pem);
|
||||
|
||||
const key_pem = readPem(gpa, io, key_path) catch |err| return switch (err) {
|
||||
const key_pem = readKeyPem(gpa, io, key_path) catch |err| return switch (err) {
|
||||
error.OutOfMemory => error.OutOfMemory,
|
||||
error.TooLarge => error.KeyTooLarge,
|
||||
error.Unreadable => error.KeyUnreadable,
|
||||
};
|
||||
defer gpa.free(key_pem);
|
||||
defer {
|
||||
std.crypto.secureZero(u8, key_pem);
|
||||
gpa.free(key_pem);
|
||||
}
|
||||
|
||||
const entry = try gpa.create(Entry);
|
||||
errdefer gpa.destroy(entry);
|
||||
@@ -342,6 +345,9 @@ fn load(
|
||||
/// Mbed TLS wants PEM with a terminating zero byte counted in the length, so
|
||||
/// the file lands in a sentinel-terminated allocation. The limit admits
|
||||
/// exactly `max_pem_bytes` and rejects the first byte beyond it.
|
||||
///
|
||||
/// The certificate only. A private key goes through `readKeyPem`, which does
|
||||
/// not leave copies behind.
|
||||
fn readPem(
|
||||
gpa: std.mem.Allocator,
|
||||
io: std.Io,
|
||||
@@ -361,6 +367,36 @@ fn readPem(
|
||||
};
|
||||
}
|
||||
|
||||
/// `readPem` for the private key, with the same cap and the same
|
||||
/// sentinel-terminated result. The difference is that no copy of the key
|
||||
/// survives this function: `readFileAllocOptions` grows its buffer as it reads,
|
||||
/// and every intermediate copy it abandons stays legible in freed pages that no
|
||||
/// wipe at the call site can reach. One fixed staging buffer never grows, and it
|
||||
/// is wiped before it goes back to the allocator. The caller wipes the returned
|
||||
/// slice the same way before freeing it (the idiom is auth.zig's `secureZero`
|
||||
/// defers).
|
||||
fn readKeyPem(
|
||||
gpa: std.mem.Allocator,
|
||||
io: std.Io,
|
||||
path: []const u8,
|
||||
) error{ OutOfMemory, TooLarge, Unreadable }![:0]u8 {
|
||||
const staging = try gpa.alloc(u8, max_pem_bytes + 1);
|
||||
defer {
|
||||
std.crypto.secureZero(u8, staging);
|
||||
gpa.free(staging);
|
||||
}
|
||||
|
||||
const bytes = std.Io.Dir.cwd().readFile(io, path, staging) catch return error.Unreadable;
|
||||
// A read that filled the staging buffer is ambiguous — `readFile` cannot
|
||||
// say whether more followed — and one byte past `max_pem_bytes` is over the
|
||||
// cap either way.
|
||||
if (bytes.len == staging.len) return error.TooLarge;
|
||||
|
||||
const key = try gpa.allocSentinel(u8, bytes.len, 0);
|
||||
@memcpy(key, bytes);
|
||||
return key;
|
||||
}
|
||||
|
||||
fn statSig(io: std.Io, path: []const u8) !FileSig {
|
||||
const st = try std.Io.Dir.cwd().statFile(io, path, .{});
|
||||
return .{ .mtime_ns = st.mtime.nanoseconds, .size = st.size };
|
||||
@@ -481,6 +517,35 @@ test "the size cap admits 64 KiB and rejects one byte more" {
|
||||
try testing.expectError(error.TooLarge, readPem(testing.allocator, io, env.cert_path));
|
||||
}
|
||||
|
||||
test "the key PEM path keeps the shape and the cap of the certificate path" {
|
||||
var env: TestEnv = undefined;
|
||||
try env.init();
|
||||
defer env.deinit();
|
||||
const io = env.io();
|
||||
|
||||
const key = try readKeyPem(testing.allocator, io, env.key_path);
|
||||
defer testing.allocator.free(key);
|
||||
try testing.expectEqualStrings(fixtures.key_pem, key);
|
||||
try testing.expectEqual(@as(u8, 0), key[key.len]);
|
||||
|
||||
const at_cap = try testing.allocator.alloc(u8, max_pem_bytes);
|
||||
defer testing.allocator.free(at_cap);
|
||||
@memset(at_cap, 'a');
|
||||
try env.tmp.dir.writeFile(io, .{ .sub_path = "key.pem", .data = at_cap });
|
||||
testing.allocator.free(try readKeyPem(testing.allocator, io, env.key_path));
|
||||
|
||||
const over = try testing.allocator.alloc(u8, max_pem_bytes + 1);
|
||||
defer testing.allocator.free(over);
|
||||
@memset(over, 'a');
|
||||
try env.tmp.dir.writeFile(io, .{ .sub_path = "key.pem", .data = over });
|
||||
try testing.expectError(error.TooLarge, readKeyPem(testing.allocator, io, env.key_path));
|
||||
|
||||
try testing.expectError(
|
||||
error.Unreadable,
|
||||
readKeyPem(testing.allocator, io, "./nxdns-no-such-key-9b31.pem"),
|
||||
);
|
||||
}
|
||||
|
||||
test "init fails typed on a missing file and on garbage PEM" {
|
||||
var env: TestEnv = undefined;
|
||||
try env.init();
|
||||
|
||||
+35
-27
@@ -24,13 +24,10 @@ const address = @import("../platform/address.zig");
|
||||
const clients_repo = @import("../storage/repositories/clients_repo.zig");
|
||||
const db = @import("../storage/db.zig");
|
||||
const disk_monitor = @import("../storage/disk_monitor.zig");
|
||||
const logger = @import("../storage/logger.zig");
|
||||
|
||||
const log = std.log.scoped(.clients);
|
||||
|
||||
/// RFC 5952 text of any IPv6 address. `format` never writes more than this, so
|
||||
/// the formatting in `flushOnce` cannot fail.
|
||||
const max_ip_text = 45;
|
||||
|
||||
/// One address waiting for its row, with the wall-clock second of its most
|
||||
/// recent query.
|
||||
const Pending = struct {
|
||||
@@ -83,16 +80,21 @@ pub const Tracker = struct {
|
||||
/// Records `addr` as seen now. Called from the query path, so it writes no
|
||||
/// database and returns no error: a full table drops the address.
|
||||
///
|
||||
/// Returns `dropped_full` as it stands after this call. The query path
|
||||
/// mirrors that counter into its own atomic, and reporting it from here
|
||||
/// costs the caller nothing — the mutex is already held — where a second
|
||||
/// `snapshotStats` call would take it again on every query.
|
||||
///
|
||||
/// `lockUncancelable` rather than `lock`: the caller is `Handler.handle`,
|
||||
/// which has no error union to carry `error.Canceled` out of. The critical
|
||||
/// section is a scan of at most `max_pending` addresses and holds no I/O.
|
||||
pub fn track(self: *Tracker, io: std.Io, addr: address.NetAddress) void {
|
||||
self.trackAt(io, addr, std.Io.Clock.real.now(io).toSeconds());
|
||||
pub fn track(self: *Tracker, io: std.Io, addr: address.NetAddress) u64 {
|
||||
return self.trackAt(io, addr, std.Io.Clock.real.now(io).toSeconds());
|
||||
}
|
||||
|
||||
/// `track` with the timestamp supplied, so a test does not depend on the
|
||||
/// wall clock.
|
||||
pub fn trackAt(self: *Tracker, io: std.Io, addr: address.NetAddress, now_s: i64) void {
|
||||
pub fn trackAt(self: *Tracker, io: std.Io, addr: address.NetAddress, now_s: i64) u64 {
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
|
||||
@@ -100,15 +102,16 @@ pub const Tracker = struct {
|
||||
if (!entry.addr.eql(addr)) continue;
|
||||
entry.last_seen = now_s;
|
||||
self.stats.tracked += 1;
|
||||
return;
|
||||
return self.stats.dropped_full;
|
||||
}
|
||||
if (self.count == max_pending) {
|
||||
self.stats.dropped_full += 1;
|
||||
return;
|
||||
return self.stats.dropped_full;
|
||||
}
|
||||
self.pending[self.count] = .{ .addr = addr, .last_seen = now_s };
|
||||
self.count += 1;
|
||||
self.stats.tracked += 1;
|
||||
return self.stats.dropped_full;
|
||||
}
|
||||
|
||||
/// Flush loop, first flush one interval in: an empty table at startup has
|
||||
@@ -167,7 +170,9 @@ pub const Tracker = struct {
|
||||
var flushed: u64 = 0;
|
||||
var failures: u64 = 0;
|
||||
for (batch) |entry| {
|
||||
var buf: [max_ip_text]u8 = undefined;
|
||||
// `logger.max_client_len` is the RFC 5952 bound every address text
|
||||
// in this program is sized by, so `format` cannot fail here.
|
||||
var buf: [logger.max_client_len]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
entry.addr.format(&w) catch unreachable;
|
||||
|
||||
@@ -267,8 +272,9 @@ test "a client tracked twice before a flush yields one row at the later time" {
|
||||
defer database.close();
|
||||
|
||||
var tracker: Tracker = .init(30);
|
||||
tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
|
||||
tracker.trackAt(io, parsed("192.168.1.10"), 1700000030);
|
||||
// A table with room reports no drops.
|
||||
try testing.expectEqual(@as(u64, 0), tracker.trackAt(io, parsed("192.168.1.10"), 1700000000));
|
||||
try testing.expectEqual(@as(u64, 0), tracker.trackAt(io, parsed("192.168.1.10"), 1700000030));
|
||||
try testing.expectEqual(@as(u32, 1), tracker.pendingClients(io));
|
||||
|
||||
tracker.flushOnce(io, &database, true);
|
||||
@@ -292,11 +298,11 @@ test "distinct clients each get a row and ipv6 text is canonical" {
|
||||
defer database.close();
|
||||
|
||||
var tracker: Tracker = .init(30);
|
||||
tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
|
||||
tracker.trackAt(io, parsed("192.168.1.11"), 1700000001);
|
||||
tracker.trackAt(io, parsed("fd00:0:0:0:0:0:0:1"), 1700000002);
|
||||
_ = tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
|
||||
_ = tracker.trackAt(io, parsed("192.168.1.11"), 1700000001);
|
||||
_ = tracker.trackAt(io, parsed("fd00:0:0:0:0:0:0:1"), 1700000002);
|
||||
// An IPv4-mapped literal is the same client as its plain form.
|
||||
tracker.trackAt(io, address.NetAddress.fromIp(try std.Io.net.IpAddress.parse("::ffff:192.168.1.10", 53)), 1700000003);
|
||||
_ = tracker.trackAt(io, address.NetAddress.fromIp(try std.Io.net.IpAddress.parse("::ffff:192.168.1.10", 53)), 1700000003);
|
||||
try testing.expectEqual(@as(u32, 3), tracker.pendingClients(io));
|
||||
|
||||
tracker.flushOnce(io, &database, true);
|
||||
@@ -319,13 +325,15 @@ test "a full table drops further clients and counts them" {
|
||||
for (0..Tracker.max_pending) |i| {
|
||||
var octets: [4]u8 = undefined;
|
||||
std.mem.writeInt(u32, &octets, @intCast(i), .big);
|
||||
tracker.trackAt(io, .{ .ip4 = octets }, 1700000000);
|
||||
_ = tracker.trackAt(io, .{ .ip4 = octets }, 1700000000);
|
||||
}
|
||||
try testing.expectEqual(@as(u32, Tracker.max_pending), tracker.pendingClients(io));
|
||||
try testing.expectEqual(@as(u64, 0), tracker.snapshotStats(io).dropped_full);
|
||||
|
||||
tracker.trackAt(io, parsed("203.0.113.7"), 1700000000);
|
||||
tracker.trackAt(io, parsed("203.0.113.8"), 1700000000);
|
||||
// The return value is what the handler mirrors, so it must agree with
|
||||
// `snapshotStats` without a second lock acquisition.
|
||||
try testing.expectEqual(@as(u64, 1), tracker.trackAt(io, parsed("203.0.113.7"), 1700000000));
|
||||
try testing.expectEqual(@as(u64, 2), tracker.trackAt(io, parsed("203.0.113.8"), 1700000000));
|
||||
try testing.expectEqual(@as(u32, Tracker.max_pending), tracker.pendingClients(io));
|
||||
|
||||
const stats = tracker.snapshotStats(io);
|
||||
@@ -334,12 +342,12 @@ test "a full table drops further clients and counts them" {
|
||||
|
||||
// A tracked client still refreshes while the table is full, and the flush
|
||||
// makes room for the next newcomer.
|
||||
tracker.trackAt(io, .{ .ip4 = .{ 0, 0, 0, 0 } }, 1700000060);
|
||||
_ = tracker.trackAt(io, .{ .ip4 = .{ 0, 0, 0, 0 } }, 1700000060);
|
||||
tracker.flushOnce(io, &database, true);
|
||||
try testing.expectEqual(@as(i64, Tracker.max_pending), try clients_repo.countClients(&database));
|
||||
try testing.expectEqual(@as(i64, 1700000060), try lastSeen(&database, "0.0.0.0"));
|
||||
|
||||
tracker.trackAt(io, parsed("203.0.113.7"), 1700000060);
|
||||
_ = tracker.trackAt(io, parsed("203.0.113.7"), 1700000060);
|
||||
try testing.expectEqual(@as(u32, 1), tracker.pendingClients(io));
|
||||
}
|
||||
|
||||
@@ -357,7 +365,7 @@ test "a flush touches a hand-edited row without changing what the operator set"
|
||||
);
|
||||
|
||||
var tracker: Tracker = .init(30);
|
||||
tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
|
||||
_ = tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
|
||||
tracker.flushOnce(io, &database, true);
|
||||
|
||||
try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
|
||||
@@ -385,7 +393,7 @@ test "a gated pass writes nothing and keeps the pending clients" {
|
||||
try testing.expect(!monitor.writesAllowed());
|
||||
|
||||
var tracker: Tracker = .init(30);
|
||||
tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
|
||||
_ = tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
|
||||
tracker.flushOnce(io, &database, monitor.writesAllowed());
|
||||
|
||||
try testing.expectEqual(@as(i64, 0), try clients_repo.countClients(&database));
|
||||
@@ -413,8 +421,8 @@ test "a failing upsert counts and leaves the client to be tracked again" {
|
||||
);
|
||||
|
||||
var tracker: Tracker = .init(30);
|
||||
tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
|
||||
tracker.trackAt(io, parsed("192.168.1.11"), 1700000000);
|
||||
_ = tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
|
||||
_ = tracker.trackAt(io, parsed("192.168.1.11"), 1700000000);
|
||||
tracker.flushOnce(io, &database, true);
|
||||
|
||||
try testing.expectEqual(@as(i64, 0), try clients_repo.countClients(&database));
|
||||
@@ -424,7 +432,7 @@ test "a failing upsert counts and leaves the client to be tracked again" {
|
||||
try testing.expectEqual(@as(u32, 0), tracker.pendingClients(io));
|
||||
|
||||
try database.exec("DROP TRIGGER refuse_insert;");
|
||||
tracker.trackAt(io, parsed("192.168.1.10"), 1700000060);
|
||||
_ = tracker.trackAt(io, parsed("192.168.1.10"), 1700000060);
|
||||
tracker.flushOnce(io, &database, true);
|
||||
try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
|
||||
}
|
||||
@@ -486,7 +494,7 @@ test "the run loop flushes on its interval and returns on cancel" {
|
||||
defer database.close();
|
||||
|
||||
var tracker: Tracker = .init(30);
|
||||
tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
|
||||
_ = tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
|
||||
|
||||
var future = try io.concurrent(Tracker.run, .{
|
||||
&tracker,
|
||||
|
||||
+10
-16
@@ -62,24 +62,19 @@ pub const udp_limit_max: u16 = 4096;
|
||||
pub const max_cname_depth = 8;
|
||||
|
||||
/// `"cname:"` plus the longest `matcher.Reason` tag, which the comptime block
|
||||
/// below proves fits the 32 bytes `logger.Entry` stores.
|
||||
/// below proves fits what `logger.Entry` stores.
|
||||
const cname_reason_prefix = "cname:";
|
||||
const max_reason_len = 32;
|
||||
|
||||
comptime {
|
||||
for (std.enums.values(matcher.Reason)) |reason| {
|
||||
if (cname_reason_prefix.len + @tagName(reason).len > max_reason_len) {
|
||||
if (cname_reason_prefix.len + @tagName(reason).len > logger_mod.max_reason_len) {
|
||||
@compileError("a matcher.Reason tag no longer fits the query log's reason field");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// RFC 5952 text of any IPv6 address. `logger.Entry` truncates at the same
|
||||
/// width, so nothing an address formats to is ever cut.
|
||||
const max_ip_text = 45;
|
||||
|
||||
/// `udp://` or `tcp://`, an IPv6 literal in brackets, and a port.
|
||||
const max_resolver_text = "tcp://[".len + max_ip_text + "]:65535".len;
|
||||
const max_resolver_text = "tcp://[".len + logger_mod.max_client_len + "]:65535".len;
|
||||
|
||||
/// Ruling 20 leaves the pool's answering endpoint out of reach: the pool tracks
|
||||
/// it, but reading it back would mean new plumbing through `transport.Client`
|
||||
@@ -157,9 +152,9 @@ pub const Handler = struct {
|
||||
unfiltered_queries: std.atomic.Value(u64) = .init(0),
|
||||
safesearch_rewrites: std.atomic.Value(u64) = .init(0),
|
||||
ecs_strip_failed: std.atomic.Value(u64) = .init(0),
|
||||
/// Mirrors the tracker's own `dropped_full`, refreshed on every query:
|
||||
/// `Tracker.track` reports nothing back, and a counter that only the
|
||||
/// tracker holds would not appear beside the rest of these.
|
||||
/// Mirrors the tracker's own `dropped_full`, refreshed on every query
|
||||
/// from what `Tracker.track` returns: a counter that only the tracker
|
||||
/// holds would not appear beside the rest of these.
|
||||
tracker_full: std.atomic.Value(u64) = .init(0),
|
||||
};
|
||||
|
||||
@@ -276,8 +271,7 @@ pub const Handler = struct {
|
||||
const started = std.Io.Clock.real.now(io);
|
||||
|
||||
if (self.tracker) |tracker| {
|
||||
tracker.track(io, from);
|
||||
self.stats.tracker_full.store(tracker.snapshotStats(io).dropped_full, .monotonic);
|
||||
self.stats.tracker_full.store(tracker.track(io, from), .monotonic);
|
||||
}
|
||||
|
||||
// Ruling 4: no snapshot means no group and no filtering, and the query
|
||||
@@ -501,7 +495,7 @@ const Context = struct {
|
||||
|
||||
bump(if (uncloaked) &ctx.handler.stats.uncloak_blocked else &ctx.handler.stats.blocked);
|
||||
|
||||
var reason_buf: [max_reason_len]u8 = undefined;
|
||||
var reason_buf: [logger_mod.max_reason_len]u8 = undefined;
|
||||
return ctx.reply(bytes, .{
|
||||
.blocked = true,
|
||||
.block_reason = blockReason(&reason_buf, reason, uncloaked),
|
||||
@@ -541,7 +535,7 @@ const Context = struct {
|
||||
fn log(ctx: *Context, fields: LogFields) void {
|
||||
const sink = ctx.handler.sink orelse return;
|
||||
|
||||
var ip_buf: [max_ip_text]u8 = undefined;
|
||||
var ip_buf: [logger_mod.max_client_len]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&ip_buf);
|
||||
ctx.from.format(&w) catch unreachable;
|
||||
|
||||
@@ -819,7 +813,7 @@ fn cnameTarget(p: packet.Packet, owner: name.Name) ?name.Name {
|
||||
/// The `block_reason` column. An uncloaked block names the level that matched
|
||||
/// the CNAME target, prefixed so that it is not mistaken for a decision about
|
||||
/// the name the client asked for.
|
||||
fn blockReason(buf: *[max_reason_len]u8, reason: matcher.Reason, uncloaked: bool) []const u8 {
|
||||
fn blockReason(buf: *[logger_mod.max_reason_len]u8, reason: matcher.Reason, uncloaked: bool) []const u8 {
|
||||
const tag = @tagName(reason);
|
||||
if (!uncloaked) return tag;
|
||||
@memcpy(buf[0..cname_reason_prefix.len], cname_reason_prefix);
|
||||
|
||||
+2
-2
@@ -268,8 +268,8 @@ pub const Db = struct {
|
||||
/// `verifyImmutable` to compare against when it ends.
|
||||
immutable_guard: ?ImmutableGuard = null,
|
||||
|
||||
/// Every mode carries `FULLMUTEX` (serialized mode). Phase 6's query logger
|
||||
/// and Phase 8's API handlers share one handle across `std.Io` tasks, and a
|
||||
/// Every mode carries `FULLMUTEX` (serialized mode). The query logger and
|
||||
/// the web API handlers share one handle across `std.Io` tasks, and a
|
||||
/// per-handle mutex inside SQLite is cheaper to be correct about than a
|
||||
/// hand-rolled one; `config.db` write volume is negligible. `EXRESCODE`
|
||||
/// makes `sqlite3_extended_errcode` meaningful from the first call.
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
//!
|
||||
//! The state is the gate other components read before a non-essential write:
|
||||
//! the query logger holds its batches while `writesAllowed` is false, and
|
||||
//! Phase 7 gates blocklist updates the same way. Nothing here edits a
|
||||
//! the blocklist scheduler gates its refresh passes the same way
|
||||
//! (`filter/manager.zig`'s `refreshGated`). Nothing here edits a
|
||||
//! milestone-5 file; the gate is pulled, not pushed.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
+12
-7
@@ -41,11 +41,14 @@ pub const hidden_marker = "hidden";
|
||||
/// How long a batch waits before it re-reads the disk monitor.
|
||||
pub const gate_retry_s = 1;
|
||||
|
||||
const max_domain_len = 253;
|
||||
/// The widths of the query log's text columns, and the single source of them:
|
||||
/// every producer that formats into one of these fields sizes its own buffer
|
||||
/// from the constant here, so nothing can format wider than the row stores.
|
||||
pub const max_domain_len = 253;
|
||||
/// RFC 5952 text of any IPv6 address, zone identifier included.
|
||||
const max_client_len = 45;
|
||||
const max_reason_len = 32;
|
||||
const max_upstream_len = 64;
|
||||
pub const max_client_len = 45;
|
||||
pub const max_reason_len = 32;
|
||||
pub const max_upstream_len = 64;
|
||||
|
||||
/// One row on its way to `query_log`, carrying its own bytes.
|
||||
pub const Entry = struct {
|
||||
@@ -175,8 +178,9 @@ pub const Logger = struct {
|
||||
/// that sees this must not expect rows.
|
||||
writer_failed: std.atomic.Value(bool),
|
||||
|
||||
/// `queue_buf.len` is the backpressure cap — Phase 7 passes
|
||||
/// `cfg.query_log_buffer_max` entries. The queue holds waiting tasks in
|
||||
/// `queue_buf.len` is the backpressure cap — the composition root
|
||||
/// (`app.zig:311`) allocates `cfg.logging.query_log_buffer_max` entries,
|
||||
/// which `config/validate.zig` bounds. The queue holds waiting tasks in
|
||||
/// intrusive lists, so a `Logger` must not be moved once anything has
|
||||
/// touched it.
|
||||
pub fn init(cfg: model.Logging, queue_buf: []Entry) Logger {
|
||||
@@ -300,7 +304,8 @@ pub const Logger = struct {
|
||||
/// it has flushed what was left.
|
||||
///
|
||||
/// A writer held by the disk gate keeps holding: it flushes when the disk
|
||||
/// recovers, and Phase 7 cancels the task if it will not wait. A canceled
|
||||
/// recovers, and the `group.cancel` that follows this call in `app.zig`
|
||||
/// stops a writer that will not wait. A canceled
|
||||
/// writer counts the batch it holds under `queries_dropped`.
|
||||
pub fn shutdown(self: *Logger, io: std.Io) void {
|
||||
self.queue.close(io);
|
||||
|
||||
@@ -68,7 +68,7 @@ pub const RecreateReason = enum { missing, corrupt, not_a_database, quick_check_
|
||||
|
||||
pub const OpenResult = struct {
|
||||
database: db.Db,
|
||||
/// Non-null feeds a counter and the `/api/health` rollup in Phase 8.
|
||||
/// Non-null feeds a counter and the `/api/health` rollup.
|
||||
recreated: ?RecreateReason,
|
||||
};
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
//! a test helper — it deliberately does not answer that question.
|
||||
//!
|
||||
//! The import path is list / insert / deleteAll / count, plus the two runtime
|
||||
//! calls `upsertSeen` and `pruneStale` that the Phase 7 client tracker owns.
|
||||
//! Phase 8's REST surface is the third section: it speaks row ids and shows
|
||||
//! calls `upsertSeen` and `pruneStale` that `server/clients.zig`'s tracker owns.
|
||||
//! The REST surface is the third section: it speaks row ids and shows
|
||||
//! every client, materialised ones included.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! stable across an import, so an export carrying them would not re-import into
|
||||
//! the same shape.
|
||||
//!
|
||||
//! The import path is list / insert / deleteAll / count. Phase 8's REST surface
|
||||
//! The import path is list / insert / deleteAll / count. The REST surface
|
||||
//! is the second half of this file: it speaks row ids, because that is what a
|
||||
//! `/api/groups/{id}` request names.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! `local_records` and `forward_zones`.
|
||||
//!
|
||||
//! The import path is list / insert / deleteAll / count. Phase 8's REST surface
|
||||
//! The import path is list / insert / deleteAll / count. The REST surface
|
||||
//! follows each table's section: it speaks row ids, because that is what an
|
||||
//! `/api/local-records/{id}` or `/api/forward-zones/{id}` request names.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
//! order as a whole is: `import` inserts the rules in export order, so the new
|
||||
//! ids ascend in exactly the order this statement produced.
|
||||
//!
|
||||
//! The import path is list / insert / deleteAll / count. Phase 8's REST surface
|
||||
//! The import path is list / insert / deleteAll / count. The REST surface
|
||||
//! is the second half of this file: it speaks row ids, because that is what an
|
||||
//! `/api/rules/{id}` request names.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! defaults so two exports taken minutes apart stay identical.
|
||||
//!
|
||||
//! The import path is list / insert / deleteAll / count; the runtime columns and
|
||||
//! Phase 8's REST surface follow it, both keyed by row id.
|
||||
//! the REST surface follow it, both keyed by row id.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! meaningful order — it matches what `Pool.init` expects — and `url` breaks
|
||||
//! ties uniquely, which is what makes an export byte-stable.
|
||||
//!
|
||||
//! The import path is list / insert / deleteAll / count. Phase 8's REST surface
|
||||
//! The import path is list / insert / deleteAll / count. The REST surface
|
||||
//! is the second half of this file: it speaks row ids, because that is what an
|
||||
//! `/api/upstreams/{id}` request names.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//!
|
||||
//! The pass touches `querylog.db` only. §3.6 walls `config.db` off from
|
||||
//! retention churn, and the `hand_edited=0` client rows of §7.2 are pruned by
|
||||
//! whatever creates them, which is Phase 7.
|
||||
//! whatever creates them, which is the client tracker (`server/clients.zig`).
|
||||
//!
|
||||
//! Nothing here retries within a pass. A failed step logs at `warn` and the
|
||||
//! next pass, a day later, does the same work again against the same data.
|
||||
@@ -134,7 +134,8 @@ pub const Retention = struct {
|
||||
_ = counter.fetchAdd(delta, .monotonic);
|
||||
}
|
||||
|
||||
/// Daily loop, first pass immediately. Phase 7 starts it.
|
||||
/// Daily loop, first pass immediately. The composition root starts it as a
|
||||
/// concurrent task (`app.zig`).
|
||||
///
|
||||
/// `boot` rather than `awake`: a box that suspends overnight must still see
|
||||
/// its day elapse.
|
||||
@@ -148,8 +149,9 @@ pub const Retention = struct {
|
||||
/// with the batch, and a checkpoint or a `VACUUM` can land inside a
|
||||
/// transaction that is still open.
|
||||
///
|
||||
/// Retention takes `database` per call and opens nothing itself; Phase 7
|
||||
/// opens the second connection. Isolation across the two connections is
|
||||
/// Retention takes `database` per call and opens nothing itself; the
|
||||
/// composition root opens the second connection with
|
||||
/// `cli.DataDir.reopenQuerylogDb`. Isolation across the two connections is
|
||||
/// SQLite's own — WAL plus the `busy_timeout` of `db.zig`'s open options —
|
||||
/// so a pass that still loses a race sees `error.Busy` or `error.Locked`,
|
||||
/// logs at `warn`, and repeats the work on the next interval.
|
||||
|
||||
@@ -162,11 +162,34 @@ pub const DohClient = struct {
|
||||
/// time two phases shared an error.
|
||||
const Phase = enum { connect, send, receive };
|
||||
|
||||
/// Every TLS failure this client can reach, named rather than matched by
|
||||
/// prefix. Two groups:
|
||||
///
|
||||
/// - `std.http.Client.RequestError` collapses every handshake and bundle fault
|
||||
/// into `TlsInitializationFailed` / `CertificateBundleLoadFailure`
|
||||
/// (Client.zig:1470, :1717).
|
||||
/// - `std.crypto.tls.Client.ReadError` is what `headCause`/`bodyCause` unwrap
|
||||
/// out of a collapsed `error.ReadFailed`, through
|
||||
/// `Connection.getReadError` (Client.zig:392), so its record-layer members
|
||||
/// arrive here as themselves. Without them a decode error or a bad record MAC
|
||||
/// would be reported as a plain receive failure.
|
||||
fn mapError(err: anyerror, phase: Phase) transport.ExchangeError {
|
||||
if (transport.mapLocal(err)) |local| return local;
|
||||
const err_name = @errorName(err);
|
||||
if (std.mem.startsWith(u8, err_name, "Tls") or
|
||||
std.mem.startsWith(u8, err_name, "Certificate")) return error.TlsFailed;
|
||||
switch (err) {
|
||||
error.TlsInitializationFailed,
|
||||
error.CertificateBundleLoadFailure,
|
||||
error.TlsAlert,
|
||||
error.TlsBadLength,
|
||||
error.TlsBadRecordMac,
|
||||
error.TlsConnectionTruncated,
|
||||
error.TlsDecodeError,
|
||||
error.TlsRecordOverflow,
|
||||
error.TlsUnexpectedMessage,
|
||||
error.TlsIllegalParameter,
|
||||
error.TlsSequenceOverflow,
|
||||
=> return error.TlsFailed,
|
||||
else => {},
|
||||
}
|
||||
return switch (phase) {
|
||||
.connect => error.ConnectFailed,
|
||||
.send => error.SendFailed,
|
||||
@@ -174,6 +197,27 @@ fn mapError(err: anyerror, phase: Phase) transport.ExchangeError {
|
||||
};
|
||||
}
|
||||
|
||||
// `error.X` in an expression names a member into existence rather than
|
||||
// referring to one, so the switch above would keep compiling — and silently
|
||||
// stop matching — if std renamed any of these. This block is what fails the
|
||||
// build instead. Every member of the read-cause set must be classified, and the
|
||||
// two collapsed names must still exist.
|
||||
comptime {
|
||||
for (@typeInfo(std.crypto.tls.Client.ReadError).error_set.?) |member| {
|
||||
const value: anyerror = @field(std.crypto.tls.Client.ReadError, member.name);
|
||||
if (mapError(value, .receive) != error.TlsFailed) {
|
||||
@compileError("unclassified TLS read cause: " ++ member.name);
|
||||
}
|
||||
}
|
||||
for ([_][]const u8{ "TlsInitializationFailed", "CertificateBundleLoadFailure" }) |name| {
|
||||
var found = false;
|
||||
for (@typeInfo(std.http.Client.RequestError).error_set.?) |member| {
|
||||
if (std.mem.eql(u8, member.name, name)) found = true;
|
||||
}
|
||||
if (!found) @compileError("std.http.Client.RequestError no longer names " ++ name);
|
||||
}
|
||||
}
|
||||
|
||||
const Connection = std.http.Client.Connection;
|
||||
const Request = std.http.Client.Request;
|
||||
const Response = std.http.Client.Response;
|
||||
@@ -307,10 +351,21 @@ test "mapError maps local errors before phase errors" {
|
||||
try testing.expectEqual(error.Unexpected, mapError(error.Unexpected, .send));
|
||||
}
|
||||
|
||||
test "mapError maps tls errors regardless of phase" {
|
||||
test "mapError maps the collapsed tls errors regardless of phase" {
|
||||
try testing.expectEqual(error.TlsFailed, mapError(error.TlsInitializationFailed, .connect));
|
||||
try testing.expectEqual(error.TlsFailed, mapError(error.TlsAlert, .receive));
|
||||
try testing.expectEqual(error.TlsFailed, mapError(error.CertificateExpired, .connect));
|
||||
try testing.expectEqual(error.TlsFailed, mapError(error.TlsInitializationFailed, .receive));
|
||||
try testing.expectEqual(error.TlsFailed, mapError(error.CertificateBundleLoadFailure, .connect));
|
||||
}
|
||||
|
||||
test "mapError maps every unwrapped record-layer cause to TlsFailed" {
|
||||
// The set is the one `Connection.getReadError` can hand back, so the loop
|
||||
// fails the day std adds a member the switch does not name.
|
||||
inline for (@typeInfo(std.crypto.tls.Client.ReadError).error_set.?) |member| {
|
||||
try testing.expectEqual(
|
||||
transport.ExchangeError.TlsFailed,
|
||||
mapError(@field(std.crypto.tls.Client.ReadError, member.name), .receive),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
test "mapError maps remaining errors by phase" {
|
||||
|
||||
@@ -116,13 +116,13 @@ pub fn login(state: *server.WebState, io: std.Io, request: *Request) HandlerErro
|
||||
// Ruling 18: a refused login is a 401, not the 400 an invalid value
|
||||
// would earn elsewhere. Only the address and the outcome are logged.
|
||||
if (failure == .invalid) {
|
||||
log.warn("web login refused for {f}", .{request.peer});
|
||||
log.warn("web login refused for {f}", .{request.client_addr});
|
||||
return http_util.respondError(request, .unauthorized, "invalid password");
|
||||
}
|
||||
return mutations.respondFailure(request, failure, "verifying the web password");
|
||||
},
|
||||
.cookie => |cookie| {
|
||||
log.info("web login accepted for {f}", .{request.peer});
|
||||
log.info("web login accepted for {f}", .{request.client_addr});
|
||||
var buf: [cookie_buf_len]u8 = undefined;
|
||||
const header = http_util.formatSetCookie(
|
||||
&buf,
|
||||
|
||||
@@ -259,6 +259,20 @@ fn expectType(comptime name: []const u8, comptime Actual: type, comptime Expecte
|
||||
///
|
||||
/// A state with no `reload_fn` has nothing to reload — that is the shape of a
|
||||
/// web layer under test, and of one whose composition root wired no manager.
|
||||
///
|
||||
/// Locking contract, and it is the reason every one of the fourteen call sites
|
||||
/// in `groups.zig`, `rules.zig`, `clients.zig` and `blocklists.zig` may call
|
||||
/// this *after* releasing `state.config_lock`: `reload_fn` must re-read all of
|
||||
/// the state it publishes from the database itself, under the manager's own
|
||||
/// writer lock. It must never accept rows the caller read. Rows read under
|
||||
/// `config_lock` and passed across its release are already stale, so a
|
||||
/// signature change that adds a row parameter here silently breaks the
|
||||
/// correctness of all fourteen sites — every one of them would have to move
|
||||
/// the call back inside the lock.
|
||||
///
|
||||
/// `swapLocalTables` below is the pre-read shape and is exactly the contrast:
|
||||
/// it takes the rows, so `local.zig`'s `publish` calls it while it still holds
|
||||
/// `config_lock`, and the ordering rationale lives on that function.
|
||||
pub fn reload(state: *server.WebState, io: std.Io) ?Failure {
|
||||
const reload_fn = state.reload_fn orelse return null;
|
||||
reload_fn(state, io) catch |err| {
|
||||
|
||||
@@ -15,6 +15,7 @@ const Allocator = std.mem.Allocator;
|
||||
|
||||
const db = @import("../../storage/db.zig");
|
||||
const http_util = @import("../http_util.zig");
|
||||
const logger = @import("../../storage/logger.zig");
|
||||
const queries_repo = @import("../../storage/repositories/queries_repo.zig");
|
||||
const server = @import("../server.zig");
|
||||
|
||||
@@ -23,11 +24,10 @@ const log = std.log.scoped(.web_queries);
|
||||
pub const default_limit: u32 = 100;
|
||||
pub const max_limit: u32 = queries_repo.max_limit;
|
||||
|
||||
/// A domain filter longer than the longest legal domain name matches nothing.
|
||||
pub const max_domain_len = 253;
|
||||
|
||||
/// Long enough for an IPv6 address with a zone identifier.
|
||||
pub const max_client_len = 64;
|
||||
/// The filter widths are the stored widths: a filter wider than the column it
|
||||
/// compares against could only match a row the query log cannot hold.
|
||||
pub const max_domain_len = logger.max_domain_len;
|
||||
pub const max_client_len = logger.max_client_len;
|
||||
|
||||
/// Where the two string filters are copied to. The parsed filter borrows them,
|
||||
/// so it must not outlive the buffers — in the handler both live in the same
|
||||
|
||||
@@ -332,10 +332,10 @@ pub fn applyPut(
|
||||
const hash_changed = password != null and !std.mem.eql(u8, previous_hash, cfg.web.password_hash);
|
||||
const replacement: ?[]u8 = if (hash_changed) try state.gpa.dupe(u8, cfg.web.password_hash) else null;
|
||||
|
||||
if (writeSettings(arena, database, cfg)) |err| {
|
||||
writeSettings(arena, database, cfg) catch |err| {
|
||||
if (replacement) |hash| state.gpa.free(hash);
|
||||
return .{ .fail = .{ .internal = err } };
|
||||
}
|
||||
};
|
||||
|
||||
if (replacement) |hash| {
|
||||
// Ruling 17, both halves: the running server must verify against the
|
||||
@@ -354,26 +354,36 @@ pub fn applyPut(
|
||||
/// costs a few dozen upserts and buys the guarantee that the table is exactly
|
||||
/// what `model.toSettings` says the merged configuration is — no key can be
|
||||
/// missed and none can be left behind.
|
||||
fn writeSettings(arena: Allocator, database: *db.Db, cfg: model.Config) ?db.Error {
|
||||
fn writeSettings(arena: Allocator, database: *db.Db, cfg: model.Config) db.Error!void {
|
||||
var pairs: std.ArrayList(model.SettingPair) = .empty;
|
||||
model.toSettings(cfg, arena, &pairs) catch return error.OutOfMemory;
|
||||
try model.toSettings(cfg, arena, &pairs);
|
||||
|
||||
var tx = db.Tx.begin(database) catch |err| return err;
|
||||
var tx = try db.Tx.begin(database);
|
||||
errdefer tx.rollback();
|
||||
|
||||
try write_fault.check();
|
||||
for (pairs.items) |pair| {
|
||||
settings_repo.putSetting(database, pair.key, pair.value) catch |err| {
|
||||
tx.rollback();
|
||||
return err;
|
||||
};
|
||||
try settings_repo.putSetting(database, pair.key, pair.value);
|
||||
}
|
||||
tx.commit() catch |err| {
|
||||
tx.rollback();
|
||||
return err;
|
||||
};
|
||||
return null;
|
||||
try tx.commit();
|
||||
}
|
||||
|
||||
/// Fails one `writeSettings` after its transaction has begun, so a test can
|
||||
/// prove the `errdefer` above rolls that transaction back rather than leaving
|
||||
/// the shared connection inside it. Test builds only, and it reduces to nothing
|
||||
/// everywhere else — the rotation seam's shape (logging.zig).
|
||||
const write_fault = if (builtin.is_test) struct {
|
||||
var armed: bool = false;
|
||||
|
||||
fn check() db.Error!void {
|
||||
if (!armed) return;
|
||||
armed = false;
|
||||
return error.Internal;
|
||||
}
|
||||
} else struct {
|
||||
fn check() db.Error!void {}
|
||||
};
|
||||
|
||||
fn problem(arena: Allocator, cfg: model.Config) error{OutOfMemory}!?[]const u8 {
|
||||
return mutations.firstProblem(arena, cfg);
|
||||
}
|
||||
@@ -581,6 +591,28 @@ test "a put that would not validate writes nothing" {
|
||||
try testing.expectEqual(@as(u16, 53), stored.dns.port);
|
||||
}
|
||||
|
||||
test "a write that fails after the transaction begins rolls it back" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
try seeded(&bench);
|
||||
|
||||
var patch: Patch = .{};
|
||||
patch.dns = .{ .port = 5353 };
|
||||
|
||||
write_fault.armed = true;
|
||||
const outcome = try applyPut(&bench.state, bench.io(), bench.arena(), patch);
|
||||
try testing.expect(outcome.fail == .internal);
|
||||
|
||||
// BEGIN IMMEDIATE inside an open transaction is an error, so a second
|
||||
// `begin` succeeding is what proves the errdefer ran.
|
||||
var tx = try db.Tx.begin(&bench.database);
|
||||
tx.rollback();
|
||||
|
||||
const stored = try mutations.loadConfig(bench.arena(), &bench.database);
|
||||
try testing.expectEqual(@as(u16, 53), stored.dns.port);
|
||||
}
|
||||
|
||||
test "an enum value the model does not know names the key it came from" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
|
||||
@@ -349,7 +349,10 @@ pub fn respondBytes(
|
||||
) HandlerError!void {
|
||||
var headers: [8]http.Header = undefined;
|
||||
headers[0] = .{ .name = "content-type", .value = content_type };
|
||||
if (extra_headers.len + 1 > headers.len) return error.OutOfMemory;
|
||||
// Every call site passes a comptime-known list, the longest of them three
|
||||
// headers, so overflowing this array is a programmer error and never a
|
||||
// condition a request can produce.
|
||||
std.debug.assert(extra_headers.len + 1 <= headers.len);
|
||||
@memcpy(headers[1 .. 1 + extra_headers.len], extra_headers);
|
||||
return request.http.respond(body, .{
|
||||
.status = status,
|
||||
|
||||
+10
-2
@@ -728,8 +728,16 @@ test "every HELP line has a TYPE line and a sample, and every sample a name" {
|
||||
}
|
||||
try testing.expectEqual(helps, types);
|
||||
try testing.expectEqual(helps, samples);
|
||||
// `nxdns_up` plus every DNS counter: the families a bare state still has.
|
||||
try testing.expectEqual(1 + dns_stat_fields.len + 7, samples);
|
||||
// The families a bare state still has: `nxdns_up`, every DNS counter, the
|
||||
// query log writer's, and the diagnostic log sink's. Derived rather than
|
||||
// counted, so a new counter in any of those structs extends the exposition
|
||||
// and this assertion together.
|
||||
try testing.expectEqual(
|
||||
1 + dns_stat_fields.len +
|
||||
@typeInfo(LoggerCounters).@"struct".fields.len +
|
||||
@typeInfo(logging.Stats).@"struct".fields.len,
|
||||
samples,
|
||||
);
|
||||
// The reflective walk names the counters, so a renamed `Handler.Stats`
|
||||
// field silently renames a scraped series. Pin the ones an operator alerts
|
||||
// on by name.
|
||||
|
||||
@@ -156,6 +156,9 @@ pub const WebState = struct {
|
||||
querylog_db: ?*db.Db = null,
|
||||
|
||||
version: []const u8 = "",
|
||||
/// The `--web-dev` asset directory, read by the dev-mode fallback. Empty
|
||||
/// whenever that fallback is not wired.
|
||||
dev_dir: []const u8 = "",
|
||||
/// Unix seconds at process start, for uptime.
|
||||
started_unix: i64 = 0,
|
||||
|
||||
|
||||
@@ -434,6 +434,19 @@ test "the embedded dist has an index and consistent gzip siblings" {
|
||||
// The gzip member header: build-time compression, not an accident.
|
||||
try testing.expectEqual(@as(u8, 0x1f), file.bytes[0]);
|
||||
try testing.expectEqual(@as(u8, 0x8b), file.bytes[1]);
|
||||
|
||||
// A sibling is served under its base file's identity, so equal
|
||||
// content is the only thing that makes the substitution honest.
|
||||
var input: std.Io.Reader = .fixed(file.bytes);
|
||||
const window = try testing.allocator.alloc(u8, std.compress.flate.max_window_len);
|
||||
defer testing.allocator.free(window);
|
||||
var decompress: std.compress.flate.Decompress = .init(&input, .gzip, window);
|
||||
const plain = try decompress.reader.allocRemaining(
|
||||
testing.allocator,
|
||||
.limited(max_disk_asset_bytes),
|
||||
);
|
||||
defer testing.allocator.free(plain);
|
||||
try testing.expectEqualSlices(u8, base.bytes, plain);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user