milestone 19: hygiene sweep - dead ecs surface, single-source constants, tls classification, frontend state hazards, docker smoke network fix
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user