milestone 7: serving pipeline, client tracking, pause and lifecycle

This commit is contained in:
2026-08-01 21:43:52 +02:00
parent 8c50b6617f
commit a8092bb1b9
17 changed files with 5916 additions and 131 deletions
+273
View File
@@ -10,6 +10,8 @@
const std = @import("std");
const types = @import("types.zig");
const record = @import("record.zig");
const question = @import("question.zig");
const packet_mod = @import("packet.zig");
const Writer = std.Io.Writer;
/// The EDNS Client Subnet option code (RFC 7871 §6).
@@ -159,6 +161,111 @@ fn ttlFrom(opt: OptRecord) u32 {
(@as(u32, @intFromBool(opt.do_bit)) << 15);
}
pub const StripResult = union(enum) {
/// The query needs no rewrite and nothing was written to `out`.
unchanged,
/// The rewritten query, a prefix of `out`.
rewritten: []u8,
};
/// Byte offset of RDLENGTH inside an OPT record as `encodeOpt` writes it: a
/// one-byte root owner name, TYPE, CLASS and TTL come first.
const opt_rdlength_offset = 1 + 2 + 2 + 4;
/// Rebuilds `query` into `out` without its ECS option, so that a client's
/// subnet never reaches the upstream resolver (PLAN §6.1). Every byte outside
/// the OPT record is copied verbatim — ID, flags, all four section counts, the
/// question and any record beside the OPT — and the OPT keeps its payload size,
/// its flags and every option other than code 8.
///
/// `pkt` and `opt` are the caller's already-parsed views of `query`, and `out`
/// must not overlap `query`.
///
/// One byte pattern does not survive the rewrite: a compression pointer in a
/// record that follows the OPT and targets a byte inside the OPT's option list.
/// Removing an option shifts those bytes, so the pointer then decodes to
/// something else. RFC 1035 §4.1.4 only ever points a name at an earlier *name*,
/// so such a pointer is malformed to begin with, and the upstream resolver
/// answers the rewritten query with FORMERR instead of the original's answer.
pub fn stripEcs(
query: []const u8,
pkt: packet_mod.Packet,
opt: OptRecord,
out: []u8,
) error{ BadOption, Overflow }!StripResult {
std.debug.assert(query.ptr == pkt.bytes.ptr);
std.debug.assert(query.len == pkt.bytes.len);
// The rebuild reads `query` while it writes `out`, so an overlap would let
// it consume bytes it has already overwritten. The two buffers must be
// disjoint, and the caller finds that out here rather than in the packet it
// sends upstream.
std.debug.assert(@intFromPtr(out.ptr) + out.len <= @intFromPtr(query.ptr) or
@intFromPtr(query.ptr) + query.len <= @intFromPtr(out.ptr));
const opt_start = (try optRecordStart(pkt, opt)) orelse return .unchanged;
if ((try findOption(query, opt, ecs_option_code)) == null) return .unchanged;
var w = Writer.fixed(out);
w.writeAll(query[0..opt_start]) catch return error.Overflow;
encodeOpt(opt, &.{}, &w) catch |err| switch (err) {
error.OptionsTooLong => unreachable, // the list written here is empty
error.WriteFailed => return error.Overflow,
};
// RDLENGTH is only known once the list has been filtered, and the surviving
// options are not contiguous in `query`, so `encodeOpt` writes a placeholder
// and the options stream in behind it.
var kept_len: usize = 0;
var it = options(query, opt);
while (try it.next()) |o| {
if (o.code == ecs_option_code) continue;
var option_header: [4]u8 = undefined;
std.mem.writeInt(u16, option_header[0..2], o.code, .big);
std.mem.writeInt(u16, option_header[2..4], @intCast(o.data.len), .big);
w.writeAll(&option_header) catch return error.Overflow;
w.writeAll(o.data) catch return error.Overflow;
kept_len += option_header.len + o.data.len;
}
w.writeAll(query[opt.options.offset + opt.options.len ..]) catch return error.Overflow;
const message = w.buffered();
std.mem.writeInt(u16, message[opt_start + opt_rdlength_offset ..][0..2], @intCast(kept_len), .big);
return .{ .rewritten = message };
}
/// Where the record holding `opt` begins, or null when `pkt` has no such
/// record. A record's start offset is not part of a parsed view — only a walk
/// of every section before it reaches that byte — so this repeats the walk
/// `packet.parse` already did.
///
/// The match is on the whole RDATA span, which keeps a hand-built `OptRecord`
/// from pointing this function at a different record than the one it describes.
/// A section that will not walk lands on `error.BadOption` for the same reason:
/// a `Packet` from `packet.parse` always walks, so only a `Packet` assembled by
/// hand around unvalidated bytes gets here.
fn optRecordStart(pkt: packet_mod.Packet, opt: OptRecord) error{BadOption}!?usize {
var pos: usize = types.header_len;
var q: u16 = 0;
while (q < pkt.header.qdcount) : (q += 1) {
const parsed = question.parse(pkt.bytes, pos) catch return error.BadOption;
pos = parsed.end;
}
const record_count = @as(u32, pkt.header.ancount) +
@as(u32, pkt.header.nscount) + @as(u32, pkt.header.arcount);
var r: u32 = 0;
while (r < record_count) : (r += 1) {
const parsed = record.parse(pkt.bytes, pos) catch return error.BadOption;
if (parsed.record.rtype == .opt and
parsed.record.rdata.offset == opt.options.offset and
parsed.record.rdata.len == opt.options.len) return pos;
pos = parsed.end;
}
return null;
}
/// The full 12-bit RCODE (RFC 6891 §6.1.3): the OPT record supplies the upper
/// eight bits, the header the lower four. Without an OPT record the value is
/// just the header's four bits.
@@ -406,6 +513,172 @@ test "encodeOpt reports a short buffer" {
try testing.expectError(error.WriteFailed, encodeOpt(opt, "", &w));
}
/// A query for example.com A whose OPT record carries an ECS option for
/// 192.0.2.0/24 followed by a two-byte padding option, with DO set and a
/// 4096-byte payload size.
const ecs_query =
"\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x01" ++ // header
"\x07example\x03com\x00\x00\x01\x00\x01" ++ // question
"\x00\x00\x29\x10\x00\x00\x00\x80\x00\x00\x11" ++ // OPT, 17 rdata bytes
"\x00\x08\x00\x07\x00\x01\x18\x00\xc0\x00\x02" ++ // ECS
"\x00\x0c\x00\x02\x00\x00"; // padding
/// `ecs_query` as `stripEcs` must rebuild it.
const ecs_query_stripped =
"\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x01" ++
"\x07example\x03com\x00\x00\x01\x00\x01" ++
"\x00\x00\x29\x10\x00\x00\x00\x80\x00\x00\x06" ++
"\x00\x0c\x00\x02\x00\x00";
const ParsedQuery = struct { pkt: packet_mod.Packet, opt: OptRecord };
fn parseQuery(bytes: []const u8) !ParsedQuery {
const p = try packet_mod.parse(bytes);
return .{ .pkt = p, .opt = try parseOpt(bytes, packet_mod.findOptRecord(p).?) };
}
fn expectUnchanged(result: StripResult) !void {
switch (result) {
.unchanged => {},
.rewritten => return error.TestExpectedUnchanged,
}
}
test "stripEcs rebuilds the query without the ECS option" {
const parsed = try parseQuery(ecs_query);
var out: [512]u8 = undefined;
const result = try stripEcs(ecs_query, parsed.pkt, parsed.opt, &out);
const rewritten = result.rewritten;
try testing.expectEqualSlices(u8, ecs_query_stripped, rewritten);
const p = try packet_mod.parse(rewritten);
try testing.expectEqual(@as(u16, 0x1234), p.header.id);
try testing.expectEqual(@as(u16, 1), p.header.qdcount);
try testing.expectEqual(@as(u16, 1), p.header.arcount);
const opt = try parseOpt(rewritten, packet_mod.findOptRecord(p).?);
try testing.expectEqual(@as(u16, 4096), opt.udp_payload_size);
try testing.expectEqual(true, opt.do_bit);
try testing.expectEqual(@as(?Option, null), try findOption(rewritten, opt, ecs_option_code));
try testing.expectEqualSlices(u8, "\x00\x00", (try findOption(rewritten, opt, 12)).?.data);
}
test "stripEcs reports a query with nothing to strip as unchanged" {
const parsed = try parseQuery(ecs_query_stripped);
var out: [512]u8 = undefined;
try expectUnchanged(try stripEcs(ecs_query_stripped, parsed.pkt, parsed.opt, &out));
// No OPT record at all: the caller still holds an `OptRecord`, so the
// absence has to be found by the walk rather than assumed.
const no_opt = "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00" ++
"\x07example\x03com\x00\x00\x01\x00\x01";
const p = try packet_mod.parse(no_opt);
try testing.expectEqual(@as(?record.Record, null), packet_mod.findOptRecord(p));
const absent: OptRecord = .{
.udp_payload_size = 4096,
.extended_rcode = 0,
.version = 0,
.do_bit = false,
.options = .{ .offset = 0, .len = 0 },
};
try expectUnchanged(try stripEcs(no_opt, p, absent, &out));
}
test "stripEcs keeps a record that follows the OPT" {
const head = "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x02" ++
"\x07example\x03com\x00\x00\x01\x00\x01";
const trailing = "\x00\x00\x01\x00\x01\x00\x00\x00\x0a\x00\x04\x01\x02\x03\x04";
const query = head ++
"\x00\x00\x29\x10\x00\x00\x00\x80\x00\x00\x0b" ++
"\x00\x08\x00\x07\x00\x01\x18\x00\xc0\x00\x02" ++
trailing;
const parsed = try parseQuery(query);
var out: [512]u8 = undefined;
const rewritten = (try stripEcs(query, parsed.pkt, parsed.opt, &out)).rewritten;
try testing.expectEqualSlices(
u8,
head ++ "\x00\x00\x29\x10\x00\x00\x00\x80\x00\x00\x00" ++ trailing,
rewritten,
);
try testing.expectEqual(@as(u16, 2), (try packet_mod.parse(rewritten)).header.arcount);
}
test "stripEcs removes a repeated ECS option" {
const ecs = "\x00\x08\x00\x07\x00\x01\x18\x00\xc0\x00\x02";
const query = "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x01" ++
"\x07example\x03com\x00\x00\x01\x00\x01" ++
"\x00\x00\x29\x10\x00\x00\x00\x80\x00\x00\x1c" ++
ecs ++ "\x00\x0c\x00\x02\x00\x00" ++ ecs;
const parsed = try parseQuery(query);
var out: [512]u8 = undefined;
const rewritten = (try stripEcs(query, parsed.pkt, parsed.opt, &out)).rewritten;
try testing.expectEqualSlices(u8, ecs_query_stripped, rewritten);
}
test "stripEcs reports an output buffer one byte short" {
const parsed = try parseQuery(ecs_query);
var exact: [ecs_query_stripped.len]u8 = undefined;
_ = (try stripEcs(ecs_query, parsed.pkt, parsed.opt, &exact)).rewritten;
var short: [ecs_query_stripped.len - 1]u8 = undefined;
try testing.expectError(error.Overflow, stripEcs(ecs_query, parsed.pkt, parsed.opt, &short));
// Every shorter buffer fails the same way, including one that cannot even
// hold the copied header.
var i: usize = 0;
while (i < short.len) : (i += 1) {
try testing.expectError(
error.Overflow,
stripEcs(ecs_query, parsed.pkt, parsed.opt, short[0..i]),
);
}
}
test "stripEcs rejects a malformed option list" {
// The record is well-formed; only its option list overruns, so `parseOpt`
// is the one thing that rejects this packet.
const query = "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x01" ++
"\x07example\x03com\x00\x00\x01\x00\x01" ++
"\x00\x00\x29\x10\x00\x00\x00\x00\x00\x00\x08" ++
"\x00\x08\x00\x08\x00\x01\x18\x00";
const p = try packet_mod.parse(query);
const rec = packet_mod.findOptRecord(p).?;
try testing.expectError(error.BadOption, parseOpt(query, rec));
const hand_built: OptRecord = .{
.udp_payload_size = rec.class,
.extended_rcode = 0,
.version = 0,
.do_bit = false,
.options = rec.rdata,
};
var out: [512]u8 = undefined;
try testing.expectError(error.BadOption, stripEcs(query, p, hand_built, &out));
}
test "encodeOpt writes RDLENGTH where stripEcs patches it" {
const opt: OptRecord = .{
.udp_payload_size = 4096,
.extended_rcode = 0,
.version = 0,
.do_bit = true,
.options = .{ .offset = 0, .len = 0 },
};
var buf: [32]u8 = undefined;
var w = Writer.fixed(&buf);
try encodeOpt(opt, "\x00\x0c\x00\x02\x00\x00", &w);
const bytes = w.buffered();
try testing.expectEqual(
@as(u16, 6),
std.mem.readInt(u16, bytes[opt_rdlength_offset..][0..2], .big),
);
}
test "extendedRcode composes the twelve bits" {
try testing.expectEqual(@as(u12, 3), extendedRcode(.nx_domain, null));