//! The serving handler: raw bytes from a listener in, bytes to send back out. //! It owns no socket and no allocator — the listener supplies the buffers, the //! upstream supplies the answer, and every collaborator is an optional pointer //! the composition root fills in. //! //! `handle` returns no error union. Every failure is either a DNS response the //! client can act on or a counted drop, because a listener has nothing useful //! to do with an error value: it cannot retry and it must not die. That is the //! "every failure mode is visible" rule applied to the hot path — the counters //! are the failure surface. The same rule is why every mutex here is taken with //! `lockUncancelable`: `handle` has no error union to carry `error.Canceled` //! out of, and each critical section is a bounded, I/O-free table access. //! //! The pipeline is PLAN §4: rate limit → parse → group → local records → //! forward zones → filtering → safe search → cache → upstream → CNAME //! uncloaking → cache store → query log. It logs nothing per query: the query //! log is a database row, and `std.log` is reserved for failures nobody else //! records. const std = @import("std"); const address = @import("../platform/address.zig"); const clients = @import("clients.zig"); const dns_cache = @import("../cache/dns_cache.zig"); const edns = @import("../dns/edns.zig"); const forward_client = @import("../local/forward_client.zig"); const forward_zones = @import("../local/forward_zones.zig"); const header = @import("../dns/header.zig"); const local_tables_mod = @import("local_tables.zig"); const logger_controller = @import("../storage/logger_controller.zig"); const logger_mod = @import("../storage/logger.zig"); const manager = @import("../filter/manager.zig"); const matcher = @import("../filter/matcher.zig"); const model = @import("../config/model.zig"); const name = @import("../dns/name.zig"); const packet = @import("../dns/packet.zig"); const provenance = @import("../storage/provenance.zig"); const pause = @import("pause.zig"); const query_sink = @import("query_sink.zig"); const question = @import("../dns/question.zig"); const rate_limiter = @import("rate_limiter.zig"); const record = @import("../dns/record.zig"); const records = @import("../local/records.zig"); const response = @import("../filter/response.zig"); const retention_mod = @import("../storage/retention.zig"); const safe_url = @import("../safe_url.zig"); const safesearch = @import("../filter/safesearch.zig"); const transport = @import("../upstream/transport.zig"); const upstream_owner = @import("../upstream/owner.zig"); const types = @import("../dns/types.zig"); const validate = @import("../config/validate.zig"); /// Which listener a query arrived on. Only the UDP size limit depends on it. pub const Transport = enum { udp, tcp }; /// RFC 1035 §4.2.1: 512 bytes is what a client accepts over UDP without EDNS, /// and RFC 6891 §6.2.3 says a smaller advertised size must not be honoured /// below that floor. pub const udp_limit_min: u16 = types.max_udp_payload; /// The ceiling nxdns advertises and accepts. Above this, fragmentation and /// reflection amplification cost more than a TCP retry. pub const udp_limit_max: u16 = 4096; /// CNAME links followed when uncloaking (PLAN §6.3). A chain longer than this /// is either a loop or an attempt to outrun the walk. pub const max_cname_depth = 8; /// `udp://` or `tcp://`, an IPv6 literal in brackets, and a port. A forward /// zone's resolver text shares the entry's upstream buffer with the pool's /// redacted identity, so the wider of the two is what has to fit. const max_resolver_text = "tcp://[".len + logger_mod.max_client_len + "]:65535".len; comptime { std.debug.assert(max_resolver_text <= logger_mod.max_upstream_len); } /// A synthesized reply is a header, at most one question and at most one OPT /// record: 12 + (255 + 4) + 11 = 282 bytes worst case. Every buffer this file /// builds into is at least `udp_limit_min`, so the builder cannot run out of /// room and its `Writer.Error` is unreachable. const max_synthetic_len = types.header_len + types.max_name_len + 4 + 11; comptime { std.debug.assert(max_synthetic_len <= udp_limit_min); } /// What a handler with no `local_tables` reads: no local record and no forward /// zone. Static, so the null case costs a pointer rather than a branch in every /// stage that consults them. const empty_records: records.Records = .empty; const empty_zones: forward_zones.Zones = .empty; /// The per-query configuration, as one value. Every query copies it once at /// query start and reads nothing else afterwards, so a `setPolicy` landing /// mid-query can never make one query answer with the new blocking TTL and the /// old ECS mode: a query runs entirely under the policy it started with. pub const Policy = struct { blocking: response.Options, ecs_mode: model.EcsMode = .strip, forward_read_timeout: std.Io.Clock.Duration, /// `cfg.cache.negative_ttl_max`. `DnsCache` keeps no copy of its config and /// `dns_cache.classify` needs the value, so the policy carries it. Zero /// disables negative caching, which is what a cache-less handler wants. negative_ttl_max: u32 = 0, }; pub const Handler = struct { /// The published upstream generation. A query pins one for the length of /// its exchange and copies out everything it keeps, so a `replace` can land /// mid-query without freeing anything the query still reads. upstream: *upstream_owner.Owner, /// Read through `policySnapshot`, published through `setPolicy`. The /// filter manager's discipline (manager.zig:438) — a shared lock for the /// copy, an exclusive one for the swap. A handler that is not serving yet /// may be built field by field; once queries run, `setPolicy` is the only /// writer. policy: Policy, policy_lock: std.Io.RwLock = .init, manager: ?*manager.Manager = null, /// The published local records and forward zones (milestone-8 ruling 12). /// Null means neither table exists, which is what a handler built for one /// upstream test wants; the API rebuilds and swaps them while queries run. local_tables: ?*local_tables_mod.LocalTables = null, cache: ?*dns_cache.DnsCache = null, cache_mutex: std.Io.Mutex = .init, limiter: ?*rate_limiter.RateLimiter = null, limiter_mutex: std.Io.Mutex = .init, sink: ?*query_sink.QuerySink = null, pause: ?*pause.Pause = null, tracker: ?*clients.Tracker = null, stats: Stats = .{}, pub const Stats = struct { /// Queries answered by the upstream pool. queries: std.atomic.Value(u64) = .init(0), dropped_malformed: std.atomic.Value(u64) = .init(0), formerr: std.atomic.Value(u64) = .init(0), notimp: std.atomic.Value(u64) = .init(0), /// Queries asking for an EDNS version this server does not implement, /// answered with BADVERS (RFC 6891 §6.1.3). badvers: std.atomic.Value(u64) = .init(0), servfail: std.atomic.Value(u64) = .init(0), truncated: std.atomic.Value(u64) = .init(0), refused: std.atomic.Value(u64) = .init(0), blocked: std.atomic.Value(u64) = .init(0), uncloak_blocked: std.atomic.Value(u64) = .init(0), local_answers: std.atomic.Value(u64) = .init(0), forward_zone_answers: std.atomic.Value(u64) = .init(0), /// The three forward-client counters, folded in after every exchange. /// `ForwardClient` is built per query and dropped with the query, so /// these are where its numbers survive. Its `queries` counter is not /// mirrored: `forward_zone_answers` already counts the exchanges. forward_udp_truncated: std.atomic.Value(u64) = .init(0), forward_foreign_datagrams: std.atomic.Value(u64) = .init(0), forward_failures: std.atomic.Value(u64) = .init(0), cache_hits: std.atomic.Value(u64) = .init(0), paused_queries: std.atomic.Value(u64) = .init(0), /// Queries answered before the first snapshot existed, so no group and /// no filtering applied (ruling 4). 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 /// 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), }; pub const Outcome = union(enum) { /// A prefix of `response_buf`. reply: []u8, /// No response is possible or appropriate. drop, }; /// The copy every query takes at its start. The shared lock is held for /// the copy alone: nothing downstream may hold it across an exchange. pub fn policySnapshot(self: *Handler, io: std.Io) Policy { self.policy_lock.lockSharedUncancelable(io); defer self.policy_lock.unlockShared(io); return self.policy; } /// Publish. Queries already running finish under the policy they copied; /// every query that starts after this returns reads `p`. pub fn setPolicy(h: *Handler, io: std.Io, p: Policy) void { h.policy_lock.lockUncancelable(io); defer h.policy_lock.unlock(io); h.policy = p; } /// Publishes a new cache and returns the old one for the caller to retire. /// Under `cache_mutex`, which is also where every query loads the pointer, /// so no query can be inside the cache this displaces. /// /// The entries of the old cache are lost. A resize rebuilds the table /// anyway, and copying entries across would have to re-derive every TTL /// against the new clock for no benefit a household would notice. pub fn replaceCache(h: *Handler, io: std.Io, next: ?*dns_cache.DnsCache) ?*dns_cache.DnsCache { h.cache_mutex.lockUncancelable(io); defer h.cache_mutex.unlock(io); const old = h.cache; h.cache = next; return old; } /// The rate limiter's equivalent. The in-flight windows are lost, so a /// client mid-burst gets a fresh allowance — accepted: the alternative is /// carrying counters across a table whose capacity just changed. pub fn replaceRateLimiter( h: *Handler, io: std.Io, next: ?*rate_limiter.RateLimiter, ) ?*rate_limiter.RateLimiter { h.limiter_mutex.lockUncancelable(io); defer h.limiter_mutex.unlock(io); const old = h.limiter; h.limiter = next; return old; } /// `response_buf.len` must be >= 512 and <= 65535. pub fn handle( self: *Handler, io: std.Io, which: Transport, from: address.NetAddress, query: []const u8, response_buf: []u8, scratch: *Scratch, ) Outcome { std.debug.assert(response_buf.len >= udp_limit_min); std.debug.assert(response_buf.len <= transport.max_message_len); // Once, before anything decides anything: every stage below, and every // refusal path that logs, reads this copy and never the live policy. const policy = self.policySnapshot(io); // Fewer than 12 bytes: there is no ID and no question, so no reply can // be addressed to this query (PLAN §6.1). const hdr = header.parse(query) catch { bump(&self.stats.dropped_malformed); return .drop; }; // A response on a listener port is either a misdirected reply or a // reflection attempt. Answering it would make this server the amplifier. if (hdr.flags.qr) { bump(&self.stats.dropped_malformed); return .drop; } // Ruling 8: the limit is charged before the parse, so a flood of // malformed queries costs one header read each. The REFUSED reply needs // the ID, which is why the header is read first. Localhost is not // exempt. { // Same discipline as the cache: the pointer is loaded inside the // mutex `replaceRateLimiter` swaps it under. self.limiter_mutex.lockUncancelable(io); const allowed = if (self.limiter) |limiter| limiter.check(std.Io.Clock.awake.now(io), from.key()) else true; self.limiter_mutex.unlock(io); if (!allowed) { return synthesize(hdr, null, null, .refused, &self.stats.refused, response_buf); } } const p = packet.parse(query) catch |err| switch (err) { error.Truncated => { bump(&self.stats.dropped_malformed); return .drop; }, // The header is intact but a section is not. The question is what // failed to parse, so nothing is echoed and no OPT is trusted. else => return synthesize(hdr, null, null, .form_err, &self.stats.formerr, response_buf), }; // RFC 6891 §6.1.1 puts the OPT record in the additional section, and §7 // answers a violation with FORMERR. `packet.parse` already rejects a // second OPT wherever it sits; the case it accepts and this walk does // not is a lone OPT in the answer or authority section, which // `findOptRecord` cannot see. if (containsOpt(packet.answers(p)) or containsOpt(packet.authorities(p))) { const echo = if (hdr.qdcount == 1) packet.firstQuestion(p) else null; const out = synthesize(hdr, echo, null, .form_err, &self.stats.formerr, response_buf); if (echo) |q| self.logRefusal(io, policy, which, from, query, p, hdr, q, null, response_buf, scratch, out.reply); return out; } // RFC 6891 §6.1.1 also gives the OPT record a root owner name. An OPT // that is present but unusable must not be forwarded as if the query // carried none: the reply's OPT echo would then disappear with nothing // to say it ever existed. const opt: ?edns.OptRecord = if (packet.findOptRecord(p)) |rec| edns.parseOpt(query, rec) catch { const echo = if (hdr.qdcount == 1) packet.firstQuestion(p) else null; const out = synthesize(hdr, echo, null, .form_err, &self.stats.formerr, response_buf); if (echo) |q| self.logRefusal(io, policy, which, from, query, p, hdr, q, null, response_buf, scratch, out.reply); return out; } else null; // RFC 6891 §6.1.3: a query naming an EDNS version this server does not // implement is answered with BADVERS, and the reply's OPT reports the // highest version the server does implement. The check precedes the // opcode check because the version governs the whole EDNS exchange, // whatever the query asks for. if (opt) |o| { if (o.version != 0) { bump(&self.stats.badvers); const echo = if (hdr.qdcount == 1) packet.firstQuestion(p) else null; const bytes = build(hdr, echo, o, edns.badvers, false, response_buf); if (echo) |q| self.logRefusal(io, policy, which, from, query, p, hdr, q, o, response_buf, scratch, bytes); return .{ .reply = bytes }; } } if (hdr.flags.opcode != .query) { const out = synthesize(hdr, null, opt, .not_imp, &self.stats.notimp, response_buf); // The reply echoes no question — an opcode this server does not // implement has no defined question semantics — but the query named // one, so the row can say which name was refused. if (hdr.qdcount == 1) if (packet.firstQuestion(p)) |q| { self.logRefusal(io, policy, which, from, query, p, hdr, q, opt, response_buf, scratch, out.reply); }; return out; } // RFC 9619: exactly one question, in both directions. Any other count // has no valid interpretation, so there is no question to echo either. if (hdr.qdcount != 1) { return synthesize(hdr, null, opt, .form_err, &self.stats.formerr, response_buf); } // `parse` already walked the question section, so a packet with // qdcount 1 always has a first question. The fallback keeps a // hand-assembled `Packet` from turning into a crash. const q = packet.firstQuestion(p) orelse return synthesize(hdr, null, opt, .form_err, &self.stats.formerr, response_buf); // One clock read serves the log's elapsed time and every cache // timestamp, so nothing inside one query disagrees about when it ran. const started = std.Io.Clock.real.now(io); if (self.tracker) |tracker| { self.stats.tracker_full.store(tracker.track(io, from), .monotonic); } // Ruling 4: no snapshot means no group and no filtering, and the query // is answered anyway — DNS availability beats filtering for a // household. const acquired = if (self.manager) |m| m.acquire(io) else null; defer if (acquired) |snapshot| snapshot.release(io); const snapshot: ?*const matcher.Snapshot = if (acquired) |a| a.snapshot else null; if (snapshot == null) bump(&self.stats.unfiltered_queries); // Ruling 12: the local tables are published the same way the snapshot // is, so one query reads one generation of both and the API can swap // either while queries run. const local = if (self.local_tables) |tables| tables.acquire(io) else null; defer if (local) |held| held.release(io); const group = if (snapshot) |s| s.groupForClient(from) else 0; var ctx: Context = .{ .handler = self, .io = io, .policy = policy, .which = which, .from = from, .query = query, .response_buf = response_buf, .scratch = scratch, .hdr = hdr, .p = p, .q = q, .opt = opt, .do_bit = if (opt) |o| o.do_bit else false, .started = started, .now_s = started.toSeconds(), .snapshot = snapshot, .records = if (local) |held| held.records else &empty_records, .zones = if (local) |held| held.zones else &empty_zones, .group = group, .domain = matcher.normalize(q.name, &scratch.normalize), .qclass = @intFromEnum(q.qclass), // Borrowed from the snapshot this query holds a handle on, so both // outlive the log call. .group_id = if (snapshot) |s| s.groups[group].id else null, .group_name = if (snapshot) |s| s.groups[group].name else "", }; return ctx.run(); } /// S3.3: a request refused on protocol grounds — BADVERS, NOTIMP, an OPT /// record that will not parse or sits in the wrong section — still names a /// question, so it is a logged row and not only a counter. `bytes` is the /// reply already encoded, because the logged RCODE is read back off the /// packet the client receives. /// /// Refusals that precede a parsed question — the rate limit, an unreadable /// packet, a QDCOUNT other than one — stay counters: there is no name to /// put in a row. fn logRefusal( self: *Handler, io: std.Io, policy: Policy, which: Transport, from: address.NetAddress, query: []const u8, p: packet.Packet, hdr: header.Header, q: question.Question, opt: ?edns.OptRecord, response_buf: []u8, scratch: *Scratch, bytes: []const u8, ) void { if (self.sink == null) return; const started = std.Io.Clock.real.now(io); var ctx: Context = .{ .handler = self, .io = io, .policy = policy, .which = which, .from = from, .query = query, .response_buf = response_buf, .scratch = scratch, .hdr = hdr, .p = p, .q = q, .opt = opt, .do_bit = if (opt) |o| o.do_bit else false, .started = started, .now_s = started.toSeconds(), .domain = matcher.normalize(q.name, &scratch.normalize), .qclass = @intFromEnum(q.qclass), .policy_reason = .protocol_error, .route_kind = .rejected, }; ctx.log(bytes); } }; /// Per-query working memory, owned by the listener's slot so that no query path /// allocates. Every buffer is used by one query at a time. pub const Scratch = struct { normalize: [types.max_name_len]u8, key: [dns_cache.max_key_len]u8, /// The rewritten outgoing query. build: [512]u8, /// Forward-zone client frame buffer. frame: [forward_client.min_frame_buf]u8, /// The safe-search reply, and before that the ECS-stripped form of a /// safe-search query — the two never overlap in time, because the outgoing /// query is spent once the upstream has answered. synth: [4096]u8, /// Uncloaking normalizes each CNAME target while `normalize` still holds /// the queried name the log entry needs. uncloak: [types.max_name_len]u8, }; /// What the pipeline is allowed to do on the upstream path. Filtering is off /// while paused (ruling 18) and for a non-IN class (ruling 9), which also skips /// the cache. const Mode = struct { filter: bool, cache: bool }; /// The fields every stage of one query shares. It exists so that the stages can /// be separate functions without threading a dozen parameters through each. const Context = struct { handler: *Handler, io: std.Io, /// This query's copy, taken once in `handle`. No stage reads /// `handler.policy`; that is what makes one query internally consistent. policy: Policy, which: Transport, from: address.NetAddress, query: []const u8, response_buf: []u8, scratch: *Scratch, hdr: header.Header, p: packet.Packet, q: question.Question, opt: ?edns.OptRecord, do_bit: bool, started: std.Io.Timestamp, now_s: i64, snapshot: ?*const matcher.Snapshot = null, /// Borrowed from the `LocalTables` handle this query holds, so both tables /// belong to one generation and neither can be freed mid-query. records: *const records.Records = &empty_records, zones: *const forward_zones.Zones = &empty_zones, group: u32 = 0, /// The queried name, normalized into `scratch.normalize`. domain: []const u8, // --- provenance --------------------------------------------------------- // // Every stage that decides something about the query records it here, and // `log` reads the finished set once. The text fields are Context-local // buffers rather than slices because the matcher's `Decision.matched` and // the uncloak walk both borrow from scratch buffers that the next chain // step overwrites (matcher.zig `Candidates`, `scratch.uncloak`). qclass: u16, /// Null with no snapshot: no group applied, so none is named. group_id: ?i64 = null, /// Snapshot-owned, so it lives as long as this query's handle. group_name: []const u8 = "", policy_action: provenance.PolicyAction = .not_evaluated, policy_reason: provenance.PolicyReason = .no_match, matched_buf: [logger_mod.max_matched_len]u8 = undefined, matched_len: u16 = 0, source_id: ?i64 = null, source_name: []const u8 = "", cname_buf: [logger_mod.max_domain_len]u8 = undefined, cname_len: u8 = 0, safe_search_buf: [logger_mod.max_domain_len]u8 = undefined, safe_search_len: u8 = 0, route_kind: provenance.RouteKind = .upstream, /// Borrowed from the forward-zone table this query holds a handle on. forward_zone: []const u8 = "", /// The resolver identity of the exchange this query attempted, already /// redacted. Empty on every route that attempted none, which reaches the /// row as NULL: a cache hit, a local record and a blocked answer name no /// upstream (ruling 20's replacement — the `"pool"` and `"local"` markers /// are gone). upstream_buf: [logger_mod.max_upstream_len]u8 = undefined, upstream_len: u16 = 0, fn notePolicy( ctx: *Context, action: provenance.PolicyAction, reason: provenance.PolicyReason, ) void { ctx.policy_action = action; ctx.policy_reason = reason; } /// The matcher's verdict, copied out of the buffers it borrows. fn applyDecision(ctx: *Context, snapshot: *const matcher.Snapshot, decision: matcher.Decision) void { ctx.notePolicy( if (decision.blocked) .block else .allow, provenance.fromMatcherReason(decision.reason), ); copyInto(&ctx.matched_buf, &ctx.matched_len, decision.matched); if (decision.source) |index| { ctx.source_id = snapshot.sources[index].id; ctx.source_name = snapshot.sources[index].name; } } fn setCnameTarget(ctx: *Context, target: name.Name) void { var text: [types.max_name_len]u8 = undefined; copyInto(&ctx.cname_buf, &ctx.cname_len, matcher.normalize(target, &text)); } fn setSafeSearchTarget(ctx: *Context, target: name.Name) void { var text: [types.max_name_len]u8 = undefined; copyInto(&ctx.safe_search_buf, &ctx.safe_search_len, matcher.normalize(target, &text)); } /// The pool's answering resolver. It is operator-supplied text that may /// carry a credential, so it is redacted here — before `Entry.init` copies /// it — and never stored raw. fn setUpstreamRedacted(ctx: *Context, identity: []const u8) void { var w: std.Io.Writer = .fixed(&ctx.upstream_buf); // `logger_mod.max_upstream_len` is at least `safe_url.redact`'s own // output bound, so the buffer cannot run short. w.print("{f}", .{safe_url.redact(identity)}) catch unreachable; ctx.upstream_len = @intCast(w.buffered().len); } /// A forward zone's resolver, spelled the way `validate.parseResolver` /// accepts it, so a row names the configured value. fn setUpstreamResolver(ctx: *Context, resolver: validate.Resolver) void { var w: std.Io.Writer = .fixed(&ctx.upstream_buf); w.writeAll(switch (resolver.scheme) { .udp => "udp://", .tcp => "tcp://", }) catch unreachable; const bracketed = switch (resolver.addr) { .ip4 => false, .ip6 => true, }; if (bracketed) w.writeByte('[') catch unreachable; resolver.addr.format(&w) catch unreachable; if (bracketed) w.writeByte(']') catch unreachable; w.print(":{d}", .{resolver.port}) catch unreachable; ctx.upstream_len = @intCast(w.buffered().len); } /// PLAN §6 in order. Local records win over forward zones (ruling 6), and /// both win over filtering: a name nxdns answers itself never reaches a /// blocklist. fn run(ctx: *Context) Handler.Outcome { if (ctx.q.qclass != .in) { ctx.notePolicy(.not_evaluated, .non_in_class); return ctx.viaUpstream(.{ .filter = false, .cache = false }); } if (ctx.records.hasName(ctx.domain)) return ctx.viaLocal(); if (ctx.zones.match(ctx.domain)) |zone| return ctx.viaForwardZone(zone); const paused = if (ctx.handler.pause) |p| p.isPaused(ctx.now_s) else false; if (paused) { bump(&ctx.handler.stats.paused_queries); ctx.notePolicy(.not_evaluated, .paused); } else if (ctx.snapshot == null) { ctx.notePolicy(.not_evaluated, .snapshot_unavailable); } return ctx.viaUpstream(.{ .filter = !paused, .cache = true }); } /// PLAN §6.4. A name that exists with no record of the queried type is /// NODATA, not a forward: nxdns owns the name either way. /// /// A local CNAME is returned as it stands (ruling 12). The client re-queries /// the target, and that query runs the whole pipeline. fn viaLocal(ctx: *Context) Handler.Outcome { ctx.notePolicy(.allow, .local_record); ctx.route_kind = .local; const found = ctx.records.lookup(ctx.domain, ctx.q.qtype); var b = packet.ResponseBuilder.init(ctx.response_buf, ctx.hdr, ctx.q) catch return ctx.servFail(); b.setAuthoritative(true); records.writeAnswers(&b, ctx.q.name, found) catch return ctx.servFail(); if (ctx.opt) |o| b.addOptEcho(o, ctx.do_bit) catch return ctx.servFail(); bump(&ctx.handler.stats.local_answers); return ctx.reply(b.finish()); } /// PLAN §6.5. The zone's resolver answers, and filtering, safe search and /// uncloaking are all bypassed (ruling 7): a conditional forward exists to /// reach a box on the LAN, and a blocklist entry must not stand between the /// two. fn viaForwardZone(ctx: *Context, zone: *const forward_zones.Zone) Handler.Outcome { ctx.notePolicy(.allow, .forward_zone); ctx.route_kind = .forward_zone; ctx.forward_zone = zone.zone; const key = dns_cache.buildKey( &ctx.scratch.key, ctx.domain, @intFromEnum(ctx.q.qtype), @intFromEnum(ctx.q.qclass), ctx.do_bit, null, ); if (ctx.cacheGet(key)) |hit| { ctx.route_kind = .cache; return ctx.reply(hit); } // Named before the exchange, so a resolver that fails still appears on // the SERVFAIL row it produces. ctx.setUpstreamResolver(zone.resolver); var client: forward_client.ForwardClient = .init( zone.resolver, &ctx.scratch.frame, ctx.policy.forward_read_timeout, ); // The client lives on this query's stack, so its counters have to move // into the handler's before it goes out of scope — on the failure path // too, which is the one `forward_failures` exists for. defer foldForwardStats(&ctx.handler.stats, client.stats); const answer = client.exchange(ctx.io, ctx.query, ctx.response_buf) catch |err| { return switch (transport.group(err)) { .cancellation => .drop, // A budget that ran out is SERVFAIL like any other failure: no // rcode says "I gave up in time". It is not logged per query — // `nxdns_upstream_budget_exhausted_total` is the record. .peer_fault, .local_resource, .budget_exhausted => ctx.servFail(), }; }; bump(&ctx.handler.stats.forward_zone_answers); ctx.cachePut(key, answer); return ctx.reply(answer); } /// Filtering, safe search, the cache and the upstream pool. fn viaUpstream(ctx: *Context, mode: Mode) Handler.Outcome { var target: ?name.Name = null; if (mode.filter) if (ctx.snapshot) |s| { const decision = s.evaluate(ctx.group, ctx.domain); ctx.applyDecision(s, decision); if (decision.blocked) return ctx.viaBlocked(false); if (s.safeSearch(ctx.group)) target = safesearch.rewrite(ctx.domain); }; const outgoing = ctx.outgoingQuery(target) orelse return ctx.servFail(); if (target) |t| { bump(&ctx.handler.stats.safesearch_rewrites); // A rewrite is not a policy decision: the answer is still allowed, // and `safe_search_target` is what says the question changed. ctx.setSafeSearchTarget(t); } // Ruling 5: a safe-search answer is not cached. It would have to be // stored under the rewritten name, and every hit would then have to // re-encode the answer records under the original one. const cacheable = mode.cache and target == null and outgoing.cacheable; const key: ?[]const u8 = if (cacheable) ctx.cacheKey() else null; if (key) |k| if (ctx.cacheGet(k)) |hit| { ctx.route_kind = .cache; return ctx.reply(hit); }; // The identity survives failure: `transport.Client` names the resolver // it is about to attempt, so a SERVFAIL row says which one lost. It is // borrowed from the generation's endpoint (transport.zig:350), so it is // redacted into this query's own buffer before the release below — // `Handler` is shared across concurrent queries and has no buffer of // its own to put it in. var selected: ?[]const u8 = null; const answer = exchanged: { const generation = ctx.handler.upstream.acquire(ctx.io); defer ctx.handler.upstream.release(ctx.io, generation); const result = generation.client.exchange( ctx.io, outgoing.bytes, ctx.response_buf, &selected, ); if (selected) |identity| ctx.setUpstreamRedacted(identity); break :exchanged result catch |err| switch (transport.group(err)) { // The process is shutting down. There is nothing to say, and // the client is about to lose the socket anyway; the listener's // own counters record the abandoned datagram. .cancellation => return .drop, // A budget that ran out is SERVFAIL like any other failure: no // rcode says "I gave up in time". It is not logged per query — // `nxdns_upstream_budget_exhausted_total` is the record — and // the pool leaves `selected` unchanged, so the row still names // the last attributable endpoint if there was one, and names // none only when no attributable attempt happened. .peer_fault, .local_resource, .budget_exhausted => return ctx.servFail(), }; }; bump(&ctx.handler.stats.queries); // Ruling 11. A safe-search answer is exempt: its records belong to the // provider's target name, which the client never asked about. if (mode.filter and target == null) if (ctx.snapshot) |s| { if (ctx.uncloak(s, answer)) |uncloaked| { ctx.setCnameTarget(uncloaked.target); ctx.applyDecision(s, uncloaked.decision); return ctx.viaBlocked(true); } }; const final = if (target) |t| (ctx.safeSearchReply(t, answer) orelse return ctx.servFail()) else answer; if (key) |k| ctx.cachePut(k, final); return ctx.reply(final); } /// PLAN §6.2. `uncloaked` distinguishes a name blocked in its own right /// from one blocked through its CNAME chain; either way the reply answers /// the question the client asked. fn viaBlocked(ctx: *Context, uncloaked: bool) Handler.Outcome { // The policy fields already describe the decision that got here: for an // uncloaked block they describe the target's own decision, and the // non-null `cname_target` is what says a chain was followed. There is // no `cname:` reason prefix any more. ctx.route_kind = .blocked; // Nothing was fetched for the client, so no upstream is named even // though the answer that revealed the chain came from one. ctx.upstream_len = 0; const bytes = response.writeBlocked( ctx.response_buf, ctx.hdr, ctx.q, ctx.opt, ctx.do_bit, ctx.policy.blocking, ) catch return ctx.servFail(); bump(if (uncloaked) &ctx.handler.stats.uncloak_blocked else &ctx.handler.stats.blocked); return ctx.reply(bytes); } /// S3.2 reverses ruling 20's silence: a SERVFAIL the client sees is a row /// like any other, carrying the policy and route the pipeline had reached /// and — when the failure came from an exchange — the resolver that lost. fn servFail(ctx: *Context) Handler.Outcome { bump(&ctx.handler.stats.servfail); return ctx.reply(build( ctx.hdr, ctx.q, ctx.opt, @intFromEnum(types.Rcode.serv_fail), false, ctx.response_buf, )); } /// The last step of every answered path: the UDP size check, then the query /// log row. `bytes` is always a prefix of `response_buf`. fn reply(ctx: *Context, bytes: []u8) Handler.Outcome { var message = bytes; if (ctx.which == .udp and message.len > udpLimit(ctx.p)) { // The answer occupies `response_buf`, so the replacement is built // beside it and copied over. Its RCODE is the one the answer // carried: rebuilding as NOERROR would hand the client an oversized // NXDOMAIN as a success, and the twelve-bit form has to be split // across the header and the reply's OPT to survive at all. bump(&ctx.handler.stats.truncated); var scratch: [udp_limit_min]u8 = undefined; const truncated = build(ctx.hdr, ctx.q, ctx.opt, messageRcode(message), true, &scratch); @memcpy(ctx.response_buf[0..truncated.len], truncated); message = ctx.response_buf[0..truncated.len]; } ctx.log(message); return .{ .reply = message }; } /// The one place a query becomes a row. Every provenance field is read off /// the context, and the RCODE off the packet the client is about to /// receive, so no path has to carry a "known rcode" of its own. fn log(ctx: *Context, message: []const u8) void { const sink = ctx.handler.sink orelse return; var ip_buf: [logger_mod.max_client_len]u8 = undefined; var w: std.Io.Writer = .fixed(&ip_buf); ctx.from.format(&w) catch unreachable; const now = std.Io.Clock.real.now(ctx.io); sink.log(ctx.io, logger_mod.Entry.init(.{ .timestamp = ctx.now_s, .domain = ctx.domain, .client_ip = w.buffered(), .qtype = @intFromEnum(ctx.q.qtype), .qclass = ctx.qclass, .rcode = messageRcode(message), .blocked = ctx.policy_action == .block, .response_time_us = now.toMicroseconds() - ctx.started.toMicroseconds(), .cache_hit = ctx.cacheHit(), .upstream = ctx.upstream_buf[0..ctx.upstream_len], .group_id = ctx.group_id, .group_name = ctx.group_name, .policy_action = ctx.policy_action, .policy_reason = ctx.policy_reason, .matched = ctx.matched_buf[0..ctx.matched_len], .source_id = ctx.source_id, .source_name = ctx.source_name, .cname_target = ctx.cname_buf[0..ctx.cname_len], .safe_search_target = ctx.safe_search_buf[0..ctx.safe_search_len], .route_kind = ctx.route_kind, .forward_zone = ctx.forward_zone, })); } /// Whether the cache answered, or null where it never applied: a local /// record, a blocked answer and a protocol refusal all bypass it entirely, /// and "false" would claim a lookup that never happened (ruling 20). fn cacheHit(ctx: *const Context) ?bool { return switch (ctx.route_kind) { .cache => true, .upstream, .forward_zone => false, .local, .blocked, .rejected => null, }; } /// The cache key for this query, or null when it must not be cached. fn cacheKey(ctx: *Context) ?[]const u8 { var ecs: ?[]const u8 = null; if (ctx.policy.ecs_mode == .forward) { if (ctx.opt) |o| switch (subnetForKey(ctx.query, o)) { .none => {}, .subnet => |payload| ecs = payload, .uncacheable => return null, }; } return dns_cache.buildKey( &ctx.scratch.key, ctx.domain, @intFromEnum(ctx.q.qtype), @intFromEnum(ctx.q.qclass), ctx.do_bit, ecs, ); } fn cacheGet(ctx: *Context, key: []const u8) ?[]u8 { // The pointer is read under the mutex, not before it: `replaceCache` // swaps it under the same lock, and a load taken outside would hand // this query the cache the swap has just freed. ctx.handler.cache_mutex.lockUncancelable(ctx.io); const hit = if (ctx.handler.cache) |cache| cache.get(ctx.now_s, key, ctx.response_buf) else null; ctx.handler.cache_mutex.unlock(ctx.io); const bytes = hit orelse return null; bump(&ctx.handler.stats.cache_hits); packet.setId(bytes, ctx.hdr.id); return bytes; } /// A response the cache declines to hold, or an allocation it cannot make, /// is not a query failure: the answer already went out. `DnsCache.stats` /// carries what happened. fn cachePut(ctx: *Context, key: []const u8, message: []const u8) void { const class = dns_cache.classify(message, ctx.policy.negative_ttl_max) orelse return; ctx.handler.cache_mutex.lockUncancelable(ctx.io); defer ctx.handler.cache_mutex.unlock(ctx.io); const cache = ctx.handler.cache orelse return; cache.put(ctx.now_s, key, message, class) catch {}; } /// The bytes to send upstream. Null means the rewrite did not fit and the /// caller answers SERVFAIL — which only a safe-search rewrite can hit, /// because ruling 13 sends the original query when an ECS strip overflows. fn outgoingQuery(ctx: *Context, target: ?name.Name) ?Outgoing { const t = target orelse { if (ctx.policy.ecs_mode != .strip) return .{ .bytes = ctx.query }; const opt = ctx.opt orelse return .{ .bytes = ctx.query }; return ctx.stripEcs(ctx.query, ctx.p, opt, &ctx.scratch.build); }; const rewritten = ctx.rewriteQuestion(t) orelse return null; if (ctx.policy.ecs_mode != .strip) return .{ .bytes = rewritten }; // The question rewrite moved the OPT record, so the ECS option comes // out of the rebuilt query rather than the client's. const p = packet.parse(rewritten) catch return null; const rec = packet.findOptRecord(p) orelse return .{ .bytes = rewritten }; const opt = edns.parseOpt(rewritten, rec) catch return .{ .bytes = rewritten }; return ctx.stripEcs(rewritten, p, opt, &ctx.scratch.synth); } /// Ruling 13: a query whose stripped form does not fit is forwarded as it /// arrived, with the subnet still in it, and the counter says so. Dropping /// the query instead would take the name off the air over a header nobody /// asked for. fn stripEcs( ctx: *Context, query: []const u8, p: packet.Packet, opt: edns.OptRecord, out: []u8, ) Outgoing { const stripped = edns.stripEcs(query, p, opt, out) catch { bump(&ctx.handler.stats.ecs_strip_failed); // The subnet reached the resolver after all, so the answer may be // specific to it while the key — built for `.strip` mode — says // nothing about a subnet. Caching it would file a subnet-specific // answer under the name every other client asks for, and reading // the cache would answer this query from an entry that ignored the // subnet. Neither direction is safe, so this query skips the cache. return .{ .bytes = query, .cacheable = false }; }; return switch (stripped) { .unchanged => .{ .bytes = query }, .rewritten => |bytes| .{ .bytes = bytes }, }; } /// The client's query with the safe-search target as its question name. /// /// The header counts are written fresh, so no record besides the OPT /// survives: a name in one of them may be a compression pointer into the /// question, and the question just changed length. A query carrying such a /// record is already outside RFC 1035 §4.1.2, and the alternative is /// forwarding a name that no longer decodes. fn rewriteQuestion(ctx: *Context, target: name.Name) ?[]const u8 { var w: std.Io.Writer = .fixed(&ctx.scratch.build); var hdr = ctx.hdr; hdr.qdcount = 1; hdr.ancount = 0; hdr.nscount = 0; hdr.arcount = if (ctx.opt == null) 0 else 1; var encoded: [types.header_len]u8 = undefined; header.encode(hdr, &encoded); w.writeAll(&encoded) catch return null; question.encode( .{ .name = target, .qtype = ctx.q.qtype, .qclass = ctx.q.qclass }, &w, ) catch return null; if (ctx.opt) |o| edns.encodeOpt(o, o.options.slice(ctx.query), &w) catch return null; return w.buffered(); } /// Ruling 10: the client asked about the original name, so the reply keeps /// the original question, states the CNAME to the target, and then carries /// the target's address records verbatim. Every other rtype is dropped — /// their RDATA can hold compression pointers into the upstream message, /// which would not decode inside this one. fn safeSearchReply(ctx: *Context, target: name.Name, answer: []const u8) ?[]u8 { const p = packet.parse(answer) catch return null; // The synthesized CNAME lives exactly as long as the addresses it // points at. With no address record there is nothing to hold, and a // zero TTL says so. var ttl: u32 = 0; var measure = packet.answers(p); var seen = false; while (measure.next() catch return null) |rec| { if (rec.rtype != .a and rec.rtype != .aaaa) continue; ttl = if (seen) @min(ttl, rec.ttl) else rec.ttl; seen = true; } var b = packet.ResponseBuilder.init(&ctx.scratch.synth, ctx.hdr, ctx.q) catch return null; b.setRcode(p.header.flags.rcode); b.addAnswer(ctx.q.name, .cname, .in, ttl, target.wire()) catch return null; var it = packet.answers(p); while (it.next() catch return null) |rec| { switch (rec.rtype) { .a, .aaaa => b.addAnswer( target, rec.rtype, .in, rec.ttl, rec.rdata.slice(answer), ) catch return null, else => {}, } } if (ctx.opt) |o| b.addOptEcho(o, ctx.do_bit) catch return null; const message = b.finish(); if (message.len > ctx.response_buf.len) return null; @memcpy(ctx.response_buf[0..message.len], message); return ctx.response_buf[0..message.len]; } /// PLAN §6.3: walk the answer section's CNAME chain and evaluate every /// target for the same group. Nothing is re-resolved — the chain is read out /// of the answer that already arrived. The reason of the first blocked /// target is returned; null means the chain ended, looped past /// `max_cname_depth`, or held nothing blocked. /// /// The full decision comes back, not just its reason: the row explains the /// block with the target's own verdict, matched rule and source. fn uncloak(ctx: *Context, snapshot: *const matcher.Snapshot, answer: []const u8) ?Uncloaked { const p = packet.parse(answer) catch return null; var current = ctx.q.name; var depth: usize = 0; while (depth < max_cname_depth) : (depth += 1) { const next = cnameTarget(p, current) orelse return null; const decision = snapshot.evaluate( ctx.group, matcher.normalize(next, &ctx.scratch.uncloak), ); // `decision.matched` may borrow from `scratch.uncloak`, which the // next iteration overwrites, so the caller copies before continuing. if (decision.blocked) return .{ .decision = decision, .target = next }; current = next; } return null; } }; /// The CNAME chain step that decided the query, and the name it named. const Uncloaked = struct { decision: matcher.Decision, target: name.Name, }; /// The query as it goes upstream, and whether the cache may describe it. const Outgoing = struct { bytes: []const u8, /// False when the subnet reaching the resolver is not the subnet the key /// would state. The cache is then skipped in both directions: a hit would /// answer from an entry built for a different subnet, and a store would /// hand this client's answer to every other client. cacheable: bool = true, }; /// What the cache key says about the client's subnet in `.forward` mode. const SubnetKey = union(enum) { /// The query carries no subnet, so the key states none. none, subnet: []const u8, uncacheable, }; /// The subnet for the cache key, or `.uncacheable` when no single value /// describes the query. /// /// RFC 7871 §6 allows one ECS option, but a query is client-controlled and can /// carry several. The whole OPT record is forwarded in `.forward` mode, so a /// resolver honouring the second option would answer for a subnet the key never /// mentioned — and the entry would then serve every client whose first option /// matched. Keying on the first option is what makes that reachable, so a /// repeated option is not cached at all. /// /// A payload longer than the key holds is refused for the same reason: /// truncating it would merge two subnets into one entry (PLAN §8). fn subnetForKey(query: []const u8, opt: edns.OptRecord) SubnetKey { var found: ?[]const u8 = null; var it = edns.options(query, opt); // `parseOpt` already walked this list, so the error is unreachable for an // `OptRecord` this file produced; refusing the cache is the safe direction // for one assembled by hand. while (it.next() catch return .uncacheable) |option| { if (option.code != edns.ecs_option_code) continue; if (found != null) return .uncacheable; found = option.data; } const payload = found orelse return .none; if (payload.len > dns_cache.max_ecs_len) return .uncacheable; return .{ .subnet = payload }; } /// Copies as much of `value` as `buf` holds and stores the length. The same /// helper serves the `u8` lengths and the `u16` one, because `buf.len` is what /// bounds the copy. fn copyInto(buf: []u8, len: anytype, value: []const u8) void { const n = @min(buf.len, value.len); @memcpy(buf[0..n], value[0..n]); len.* = @intCast(n); } /// The full twelve-bit RCODE of an encoded message, OPT record included. A /// message that will not re-parse still has a readable header, and a header /// that will not parse is not a message this file produced. fn messageRcode(message: []const u8) u12 { const p = packet.parse(message) catch { const hdr = header.parse(message) catch return 0; return @intFromEnum(hdr.flags.rcode); }; const rec = packet.findOptRecord(p) orelse return @intFromEnum(p.header.flags.rcode); const opt = edns.parseOpt(message, rec) catch return @intFromEnum(p.header.flags.rcode); return edns.extendedRcode(p.header.flags.rcode, opt); } /// The target of the answer-section CNAME owned by `owner`, or null when the /// chain stops there. fn cnameTarget(p: packet.Packet, owner: name.Name) ?name.Name { var it = packet.answers(p); while (it.next() catch return null) |rec| { if (rec.rtype != .cname) continue; if (!name.eqlIgnoreCase(rec.name, owner)) continue; return record.rdataCname(p.bytes, rec) catch null; } return null; } /// Counts one synthesized reply and encodes it. The counter is passed in /// because the counter *is* the record that this failure mode happened. fn synthesize( hdr: header.Header, q: ?question.Question, opt: ?edns.OptRecord, rcode: types.Rcode, counter: *std.atomic.Value(u64), response_buf: []u8, ) Handler.Outcome { bump(counter); return .{ .reply = build(hdr, q, opt, @intFromEnum(rcode), false, response_buf) }; } /// Whether the section holds a record of TYPE=OPT. A section that will not walk /// counts as holding one: the caller's only answer to either condition is /// FORMERR, and a `Packet` from `packet.parse` walks in full, so the fallback is /// reachable only for a `Packet` assembled by hand around unvalidated bytes. fn containsOpt(section: packet.RecordIterator) bool { var it = section; while (it.next() catch return true) |rec| { if (rec.rtype == .opt) return true; } return false; } /// The query's advertised UDP payload size, clamped to `[512, 4096]`, or 512 /// when the query carries no usable OPT record. pub fn udpLimit(query_packet: packet.Packet) u16 { const rec = packet.findOptRecord(query_packet) orelse return udp_limit_min; const opt = edns.parseOpt(query_packet.bytes, rec) catch return udp_limit_min; return std.math.clamp(opt.udp_payload_size, udp_limit_min, udp_limit_max); } /// Encodes one synthesized reply. `ResponseBuilder.init` copies the request's /// ID, opcode and RD bit and sets QR and RA, so every reply built here binds to /// its request. `buf` is at least `udp_limit_min`, which `max_synthetic_len` /// proves is enough, so the writer cannot fail. /// /// `rcode` is the full twelve bits (RFC 6891 §6.1.3), split across the header /// and the reply's OPT record. A code above 15 — BADVERS, or an extended code /// preserved from an answer being truncated — therefore needs an OPT record to /// survive: without one the upper eight bits have nowhere to go. fn build( hdr: header.Header, q: ?question.Question, opt: ?edns.OptRecord, rcode: u12, tc: bool, buf: []u8, ) []u8 { const split = edns.splitRcode(rcode); var b = packet.ResponseBuilder.init(buf, hdr, q) catch unreachable; b.setRcode(split.header); b.header.flags.tc = tc; // An EDNS query gets an EDNS reply, and the DO bit passes through // untouched — nxdns validates no signatures, so it must not claim the // client asked for none (PLAN §6.1). if (opt) |o| b.addOptWithRcode(o, o.do_bit, split.extended) catch unreachable; return b.finish(); } fn bump(counter: *std.atomic.Value(u64)) void { _ = counter.fetchAdd(1, .monotonic); } /// Moves one forward-zone exchange's counters into the handler's. `stats` is a /// plain per-instance struct and stays that way (milestone-16 ruling 14); this /// is the one place it becomes a process-wide number. fn foldForwardStats(into: *Handler.Stats, from: forward_client.ForwardClient.Stats) void { _ = into.forward_udp_truncated.fetchAdd(from.udp_truncated, .monotonic); _ = into.forward_foreign_datagrams.fetchAdd(from.foreign_datagrams, .monotonic); _ = into.forward_failures.fetchAdd(from.failures, .monotonic); } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- const sse = @import("../web/sse.zig"); const testing = std.testing; /// Every test needs a real `std.Io`: the handler reads the clock on every query /// and takes a mutex for every collaborator that is wired in. const TestIo = struct { threaded: std.Io.Threaded, fn init() TestIo { return .{ .threaded = .init(testing.allocator, .{}) }; } fn io(self: *TestIo) std.Io { return self.threaded.io(); } fn deinit(self: *TestIo) void { self.threaded.deinit(); } }; const blocking: response.Options = .{ .mode = .zero, .ttl = 5 }; const forward_timeout: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(50), .clock = .awake }; const client_ip: address.NetAddress = .{ .ip4 = .{ 192, 168, 1, 50 } }; /// The handler the check command and the pre-Phase-7 listeners build: an /// upstream and nothing else optional. fn bare(up: *upstream_owner.Owner) Handler { return .{ .upstream = up, .policy = .{ .blocking = blocking, .forward_read_timeout = forward_timeout }, }; } /// One query with a fresh scratch. The reply is always a prefix of `buf`, so it /// outlives the scratch this frame owns. fn handleQuery( h: *Handler, io: std.Io, which: Transport, from: address.NetAddress, query: []const u8, buf: []u8, ) Handler.Outcome { var scratch: Scratch = undefined; return h.handle(io, which, from, query, buf, &scratch); } fn udp(h: *Handler, io: std.Io, query: []const u8, buf: []u8) Handler.Outcome { return handleQuery(h, io, .udp, client_ip, query, buf); } /// A query for example.com A: id 0x1234, RD set, one question, no OPT. const query_bytes = "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00" ++ "\x07example\x03com\x00\x00\x01\x00\x01"; /// The matching response: the question echoed plus one A record. const response_bytes = "\x12\x34\x81\x80\x00\x01\x00\x01\x00\x00\x00\x00" ++ "\x07example\x03com\x00\x00\x01\x00\x01" ++ "\xc0\x0c\x00\x01\x00\x01\x00\x00\x01\x2c\x00\x04\x5d\xb8\xd8\x22"; const opt_len = 11; const query_with_opt_len = query_bytes.len + opt_len; /// The EDNS version byte, counted from the start of the OPT record: past the /// root owner name, TYPE, CLASS and the extended-RCODE byte. const opt_version_offset = 6; /// `query_bytes` plus an OPT record advertising `payload_size`, EDNS version 0. fn queryWithOpt(buf: *[query_with_opt_len]u8, payload_size: u16, do_bit: bool) []const u8 { return queryWithOptVersion(buf, payload_size, do_bit, 0); } /// `queryWithOpt` for a chosen EDNS version. fn queryWithOptVersion( buf: *[query_with_opt_len]u8, payload_size: u16, do_bit: bool, version: u8, ) []const u8 { @memcpy(buf[0..query_bytes.len], query_bytes); std.mem.writeInt(u16, buf[10..12], 1, .big); // arcount const opt = buf[query_bytes.len..][0..opt_len]; @memset(opt, 0); opt[2] = @intFromEnum(types.Type.opt); std.mem.writeInt(u16, opt[3..5], payload_size, .big); opt[opt_version_offset] = version; if (do_bit) opt[7] = 0x80; // the DO bit is bit 15 of the TTL word return buf; } /// `query_bytes` plus an OPT record whose owner name is `com.` instead of the /// root, which RFC 6891 §6.1.1 forbids. const query_with_named_opt = "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x01" ++ "\x07example\x03com\x00\x00\x01\x00\x01" ++ "\x03com\x00\x00\x29\x10\x00\x00\x00\x00\x00\x00\x00"; /// `query_bytes` plus an OPT record whose RDATA ends in a three-byte option /// header. The record itself fits the packet; only the option list is broken. const query_with_bad_option = "\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\x03\x00\x08\x00"; /// A bare OPT record: root owner, TYPE 41, 4096-byte payload, empty RDATA. const opt_record = "\x00\x00\x29\x10\x00\x00\x00\x00\x00\x00\x00"; /// `query_bytes` with the OPT record counted into the answer section, which /// RFC 6891 §6.1.1 forbids. const query_with_opt_in_answer = "\x12\x34\x01\x00\x00\x01\x00\x01\x00\x00\x00\x00" ++ "\x07example\x03com\x00\x00\x01\x00\x01" ++ opt_record; /// The same OPT record, counted into the authority section instead. const query_with_opt_in_authority = "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x01\x00\x00" ++ "\x07example\x03com\x00\x00\x01\x00\x01" ++ opt_record; /// Two OPT records in the additional section. RFC 6891 §6.1.1 allows one. const query_with_two_opts = "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x02" ++ "\x07example\x03com\x00\x00\x01\x00\x01" ++ opt_record ++ opt_record; const FakeUpstream = struct { reply: []const u8 = &.{}, err: ?transport.ExchangeError = null, /// What the pool would report as the resolver it attempted. A real one is /// operator-supplied text, so a test can put a credential in it. identity: []const u8 = "fake://handler-upstream", calls: usize = 0, /// The last query the handler sent, so a test can assert what the rewrite /// stages produced. seen: [1024]u8 = undefined, seen_len: usize = 0, fn exchangeFn( ptr: *anyopaque, io: std.Io, query: []const u8, response_buf: []u8, selected: *?[]const u8, ) transport.ExchangeError![]u8 { _ = io; const self: *FakeUpstream = @ptrCast(@alignCast(ptr)); // Named before the attempt, so the identity survives the failure below. selected.* = self.identity; self.calls += 1; self.seen_len = @min(query.len, self.seen.len); @memcpy(self.seen[0..self.seen_len], query[0..self.seen_len]); if (self.err) |e| return e; if (self.reply.len > response_buf.len) return error.ResponseTooLarge; @memcpy(response_buf[0..self.reply.len], self.reply); const bytes = response_buf[0..self.reply.len]; if (bytes.len >= types.header_len) { packet.setId(bytes, (header.parse(query) catch unreachable).id); } return bytes; } fn client(self: *FakeUpstream) transport.Client { return .{ .ptr = self, .exchangeFn = exchangeFn }; } fn sent(self: *const FakeUpstream) []const u8 { return self.seen[0..self.seen_len]; } }; fn expectReply(outcome: Handler.Outcome) ![]u8 { return switch (outcome) { .reply => |bytes| bytes, .drop => error.TestUnexpectedDrop, }; } test "a udp query is forwarded and the upstream reply is returned unchanged" { var t: TestIo = .init(); defer t.deinit(); var fake: FakeUpstream = .{ .reply = response_bytes }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); var buf: [udp_limit_min]u8 = undefined; const reply = try expectReply(udp(&h, t.io(), query_bytes, &buf)); try testing.expectEqualSlices(u8, response_bytes, reply); try testing.expectEqual(@as(usize, 1), fake.calls); try testing.expectEqual(@as(u64, 1), h.stats.queries.load(.monotonic)); try testing.expectEqual(@as(u64, 0), h.stats.truncated.load(.monotonic)); // A query that carries no OPT at all is not an EDNS violation. try testing.expectEqual(@as(u64, 0), h.stats.formerr.load(.monotonic)); } test "a response arriving on the listener port is dropped" { var t: TestIo = .init(); defer t.deinit(); var fake: FakeUpstream = .{ .reply = response_bytes }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); var buf: [udp_limit_min]u8 = undefined; try testing.expectEqual(Handler.Outcome.drop, udp(&h, t.io(), response_bytes, &buf)); try testing.expectEqual(@as(u64, 1), h.stats.dropped_malformed.load(.monotonic)); try testing.expectEqual(@as(usize, 0), fake.calls); } test "a query shorter than a header is dropped" { var t: TestIo = .init(); defer t.deinit(); var fake: FakeUpstream = .{ .reply = response_bytes }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); var buf: [udp_limit_min]u8 = undefined; try testing.expectEqual(Handler.Outcome.drop, udp(&h, t.io(), query_bytes[0..8], &buf)); try testing.expectEqual(@as(u64, 1), h.stats.dropped_malformed.load(.monotonic)); try testing.expectEqual(@as(usize, 0), fake.calls); } test "a query whose question runs off the end gets FORMERR" { var t: TestIo = .init(); defer t.deinit(); var fake: FakeUpstream = .{ .reply = response_bytes }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); // The header promises a question; the packet ends after 12 bytes plus a // partial name, which `packet.parse` reports as a section overrun. var buf: [udp_limit_min]u8 = undefined; const overrun = query_bytes[0 .. query_bytes.len - 3]; const reply = try expectReply(udp(&h, t.io(), overrun, &buf)); const p = try packet.parse(reply); try testing.expectEqual(types.Rcode.form_err, p.header.flags.rcode); try testing.expectEqual(@as(u64, 1), h.stats.formerr.load(.monotonic)); } test "a query with QDCOUNT 0 gets FORMERR with no question" { var t: TestIo = .init(); defer t.deinit(); var fake: FakeUpstream = .{ .reply = response_bytes }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); const no_question = "\x12\x34\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00"; var buf: [udp_limit_min]u8 = undefined; const reply = try expectReply(udp(&h, t.io(), no_question, &buf)); const p = try packet.parse(reply); try testing.expectEqual(types.Rcode.form_err, p.header.flags.rcode); try testing.expectEqual(@as(u16, 0x1234), p.header.id); try testing.expectEqual(@as(u16, 0), p.header.qdcount); try testing.expectEqual(true, p.header.flags.qr); try testing.expectEqual(true, p.header.flags.ra); try testing.expectEqual(@as(u64, 1), h.stats.formerr.load(.monotonic)); try testing.expectEqual(@as(usize, 0), fake.calls); } test "a query with QDCOUNT 2 gets FORMERR" { var t: TestIo = .init(); defer t.deinit(); var fake: FakeUpstream = .{ .reply = response_bytes }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); const two = "\x12\x34\x01\x00\x00\x02\x00\x00\x00\x00\x00\x00" ++ "\x07example\x03com\x00\x00\x01\x00\x01" ++ "\x07example\x03com\x00\x00\x1c\x00\x01"; var buf: [udp_limit_min]u8 = undefined; const reply = try expectReply(udp(&h, t.io(), two, &buf)); const p = try packet.parse(reply); try testing.expectEqual(types.Rcode.form_err, p.header.flags.rcode); try testing.expectEqual(@as(u16, 0), p.header.qdcount); try testing.expectEqual(@as(u64, 1), h.stats.formerr.load(.monotonic)); try testing.expectEqual(@as(usize, 0), fake.calls); } test "a structurally broken question gets FORMERR" { var t: TestIo = .init(); defer t.deinit(); var fake: FakeUpstream = .{ .reply = response_bytes }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); // A compression pointer to itself: the name never terminates. const loop = "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00" ++ "\xc0\x0c\x00\x01\x00\x01"; var buf: [udp_limit_min]u8 = undefined; const reply = try expectReply(udp(&h, t.io(), loop, &buf)); const p = try packet.parse(reply); try testing.expectEqual(types.Rcode.form_err, p.header.flags.rcode); try testing.expectEqual(@as(u16, 0x1234), p.header.id); try testing.expectEqual(@as(u16, 0), p.header.qdcount); try testing.expectEqual(@as(u64, 1), h.stats.formerr.load(.monotonic)); try testing.expectEqual(@as(usize, 0), fake.calls); } test "an unimplemented opcode gets NOTIMP" { var t: TestIo = .init(); defer t.deinit(); var fake: FakeUpstream = .{ .reply = response_bytes }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); var update: [query_bytes.len]u8 = query_bytes.*; std.mem.writeInt(u16, update[2..4], 0x2900, .big); // opcode update, RD set var buf: [udp_limit_min]u8 = undefined; const reply = try expectReply(udp(&h, t.io(), &update, &buf)); const p = try packet.parse(reply); try testing.expectEqual(types.Rcode.not_imp, p.header.flags.rcode); try testing.expectEqual(types.Opcode.update, p.header.flags.opcode); try testing.expectEqual(@as(u16, 0x1234), p.header.id); try testing.expectEqual(@as(u16, 0), p.header.qdcount); try testing.expectEqual(@as(u64, 1), h.stats.notimp.load(.monotonic)); try testing.expectEqual(@as(usize, 0), fake.calls); } test "a peer fault becomes SERVFAIL with the question echoed" { var t: TestIo = .init(); defer t.deinit(); var fake: FakeUpstream = .{ .err = error.Timeout }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); var buf: [udp_limit_min]u8 = undefined; const reply = try expectReply(udp(&h, t.io(), query_bytes, &buf)); const p = try packet.parse(reply); try testing.expectEqual(types.Rcode.serv_fail, p.header.flags.rcode); try testing.expectEqual(@as(u16, 0x1234), p.header.id); try testing.expectEqual(true, p.header.flags.rd); try testing.expectEqual(true, p.header.flags.qr); try testing.expectEqual(@as(u16, 1), p.header.qdcount); try testing.expectEqual(@as(u16, 0), p.header.ancount); const echoed = packet.firstQuestion(p).?; const asked = packet.firstQuestion(try packet.parse(query_bytes)).?; try testing.expectEqualSlices(u8, asked.name.wire(), echoed.name.wire()); try testing.expectEqual(types.Type.a, echoed.qtype); try testing.expectEqual(@as(u64, 1), h.stats.servfail.load(.monotonic)); try testing.expectEqual(@as(u64, 0), h.stats.queries.load(.monotonic)); } test "a local resource failure becomes SERVFAIL, not a drop" { var t: TestIo = .init(); defer t.deinit(); var fake: FakeUpstream = .{ .err = error.OutOfMemory }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); var buf: [udp_limit_min]u8 = undefined; const reply = try expectReply(udp(&h, t.io(), query_bytes, &buf)); const p = try packet.parse(reply); try testing.expectEqual(types.Rcode.serv_fail, p.header.flags.rcode); try testing.expectEqual(@as(u16, 1), p.header.qdcount); try testing.expectEqual(@as(u64, 1), h.stats.servfail.load(.monotonic)); } test "a canceled exchange is dropped" { var t: TestIo = .init(); defer t.deinit(); var fake: FakeUpstream = .{ .err = error.Canceled }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); var buf: [udp_limit_min]u8 = undefined; try testing.expectEqual(Handler.Outcome.drop, udp(&h, t.io(), query_bytes, &buf)); try testing.expectEqual(@as(u64, 0), h.stats.servfail.load(.monotonic)); try testing.expectEqual(@as(u64, 0), h.stats.queries.load(.monotonic)); try testing.expectEqual(@as(u64, 0), h.stats.dropped_malformed.load(.monotonic)); } test "udpLimit clamps the advertised payload size" { try testing.expectEqual(@as(u16, 512), udpLimit(try packet.parse(query_bytes))); const cases = [_]struct { advertised: u16, expected: u16 }{ .{ .advertised = 1232, .expected = 1232 }, .{ .advertised = 200, .expected = 512 }, .{ .advertised = 9000, .expected = 4096 }, .{ .advertised = 512, .expected = 512 }, .{ .advertised = 4096, .expected = 4096 }, }; for (cases) |c| { var buf: [query_with_opt_len]u8 = undefined; const q = queryWithOpt(&buf, c.advertised, false); try testing.expectEqual(c.expected, udpLimit(try packet.parse(q))); } } /// A valid response for `query_bytes` padded past 512 bytes with A records. fn oversizeResponse(buf: []u8) []u8 { return oversizeResponseRcode(buf, @intFromEnum(types.Rcode.no_error), false); } /// The same, carrying a chosen twelve-bit RCODE. `with_opt` adds the OPT record /// an extended code needs to carry its upper eight bits. fn oversizeResponseRcode(buf: []u8, rcode: u12, with_opt: bool) []u8 { const split = edns.splitRcode(rcode); const request = packet.parse(query_bytes) catch unreachable; const q = packet.firstQuestion(request).?; var b = packet.ResponseBuilder.init(buf, request.header, q) catch unreachable; b.setRcode(split.header); var i: usize = 0; while (i < 32) : (i += 1) { b.addAnswer(q.name, .a, .in, 300, "\x5d\xb8\xd8\x22") catch unreachable; } if (with_opt) b.addOptWithRcode(.{ .udp_payload_size = 1232, .extended_rcode = 0, .version = 0, .do_bit = false, .options = .{ .offset = 0, .len = 0 }, }, false, split.extended) catch unreachable; return b.finish(); } test "an oversize udp reply is replaced by a truncated one" { var t: TestIo = .init(); defer t.deinit(); var reply_buf: [2048]u8 = undefined; const oversize = oversizeResponse(&reply_buf); try testing.expect(oversize.len > udp_limit_min); var fake: FakeUpstream = .{ .reply = oversize }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); var buf: [4096]u8 = undefined; const reply = try expectReply(udp(&h, t.io(), query_bytes, &buf)); const p = try packet.parse(reply); try testing.expectEqual(true, p.header.flags.tc); try testing.expectEqual(types.Rcode.no_error, p.header.flags.rcode); try testing.expectEqual(@as(u16, 0), p.header.ancount); try testing.expectEqual(@as(u16, 1), p.header.qdcount); try testing.expectEqual(@as(u16, 0x1234), p.header.id); try testing.expectEqual(true, p.header.flags.rd); const echoed = packet.firstQuestion(p).?; const asked = packet.firstQuestion(try packet.parse(query_bytes)).?; try testing.expectEqualSlices(u8, asked.name.wire(), echoed.name.wire()); try testing.expectEqual(@as(u64, 1), h.stats.truncated.load(.monotonic)); try testing.expectEqual(@as(u64, 1), h.stats.queries.load(.monotonic)); } test "an oversize NXDOMAIN keeps its rcode when it is replaced by a truncated reply" { var t: TestIo = .init(); defer t.deinit(); var reply_buf: [2048]u8 = undefined; const oversize = oversizeResponseRcode(&reply_buf, @intFromEnum(types.Rcode.nx_domain), false); var queue_buf: [4]logger_mod.Entry = undefined; var lg: logger_mod.Logger = .init(.{}, &queue_buf); var log_owner: logger_controller.Borrowed = .{}; var sink: query_sink.QuerySink = .init(log_owner.over(&lg), null); var fake: FakeUpstream = .{ .reply = oversize }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); h.sink = &sink; var buf: [4096]u8 = undefined; const reply = try expectReply(udp(&h, t.io(), query_bytes, &buf)); // Rebuilding as NOERROR would hand the client an oversized NXDOMAIN as a // success, which is what the client acts on. const p = try packet.parse(reply); try testing.expectEqual(true, p.header.flags.tc); try testing.expectEqual(types.Rcode.nx_domain, p.header.flags.rcode); try testing.expectEqual(@as(u64, 1), h.stats.truncated.load(.monotonic)); var entries: [4]logger_mod.Entry = undefined; const logged = drainLog(&lg, t.io(), &entries); try testing.expectEqual(@as(usize, 1), logged.len); try testing.expectEqual(@as(u16, @intFromEnum(types.Rcode.nx_domain)), logged[0].rcode); } test "a truncated reply preserves a twelve-bit rcode on the wire and in the log" { var t: TestIo = .init(); defer t.deinit(); // RCODE 20: four bits in the header and one in the OPT record, so nothing // short of both halves carries it. const extended: u12 = 20; var reply_buf: [2048]u8 = undefined; const oversize = oversizeResponseRcode(&reply_buf, extended, true); var queue_buf: [4]logger_mod.Entry = undefined; var lg: logger_mod.Logger = .init(.{}, &queue_buf); var log_owner: logger_controller.Borrowed = .{}; var sink: query_sink.QuerySink = .init(log_owner.over(&lg), null); var fake: FakeUpstream = .{ .reply = oversize }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); h.sink = &sink; // The client's own OPT advertises 512, so the answer still does not fit. var query_buf: [query_with_opt_len]u8 = undefined; const query = queryWithOpt(&query_buf, 512, false); var buf: [4096]u8 = undefined; const reply = try expectReply(udp(&h, t.io(), query, &buf)); const p = try packet.parse(reply); try testing.expectEqual(true, p.header.flags.tc); const opt = try edns.parseOpt(reply, packet.findOptRecord(p).?); try testing.expectEqual(extended, edns.extendedRcode(p.header.flags.rcode, opt)); try testing.expectEqual(@as(u8, 1), opt.extended_rcode); var entries: [4]logger_mod.Entry = undefined; const logged = drainLog(&lg, t.io(), &entries); try testing.expectEqual(@as(usize, 1), logged.len); try testing.expectEqual(@as(u16, extended), logged[0].rcode); } test "the same oversize reply passes through untouched over tcp" { var t: TestIo = .init(); defer t.deinit(); var reply_buf: [2048]u8 = undefined; const oversize = oversizeResponse(&reply_buf); var fake: FakeUpstream = .{ .reply = oversize }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); var buf: [4096]u8 = undefined; const reply = try expectReply(handleQuery(&h, t.io(), .tcp, client_ip, query_bytes, &buf)); try testing.expectEqualSlices(u8, oversize, reply); try testing.expectEqual(@as(u64, 0), h.stats.truncated.load(.monotonic)); try testing.expectEqual(@as(u64, 1), h.stats.queries.load(.monotonic)); } test "a reply within the advertised EDNS limit is not truncated" { var t: TestIo = .init(); defer t.deinit(); var reply_buf: [2048]u8 = undefined; const oversize = oversizeResponse(&reply_buf); var fake: FakeUpstream = .{ .reply = oversize }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); var query_buf: [query_with_opt_len]u8 = undefined; const query = queryWithOpt(&query_buf, 4096, false); var buf: [4096]u8 = undefined; const reply = try expectReply(udp(&h, t.io(), query, &buf)); try testing.expectEqual(oversize.len, reply.len); try testing.expectEqual(@as(u64, 0), h.stats.truncated.load(.monotonic)); } test "the DO bit passes through into a synthesized reply" { var t: TestIo = .init(); defer t.deinit(); for ([_]bool{ false, true }) |do_bit| { var fake: FakeUpstream = .{ .err = error.Timeout }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); var query_buf: [query_with_opt_len]u8 = undefined; const query = queryWithOpt(&query_buf, 1232, do_bit); var buf: [udp_limit_min]u8 = undefined; const reply = try expectReply(udp(&h, t.io(), query, &buf)); const p = try packet.parse(reply); try testing.expectEqual(types.Rcode.serv_fail, p.header.flags.rcode); const opt = try edns.parseOpt(reply, packet.findOptRecord(p).?); try testing.expectEqual(do_bit, opt.do_bit); try testing.expectEqual(@as(u16, 1232), opt.udp_payload_size); } } test "the truncated reply echoes the OPT record" { var t: TestIo = .init(); defer t.deinit(); var reply_buf: [2048]u8 = undefined; const oversize = oversizeResponse(&reply_buf); var fake: FakeUpstream = .{ .reply = oversize }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); // 512 is the advertised size, so the oversize answer still does not fit. var query_buf: [query_with_opt_len]u8 = undefined; const query = queryWithOpt(&query_buf, 512, true); var buf: [4096]u8 = undefined; const reply = try expectReply(udp(&h, t.io(), query, &buf)); const p = try packet.parse(reply); try testing.expectEqual(true, p.header.flags.tc); const opt = try edns.parseOpt(reply, packet.findOptRecord(p).?); try testing.expectEqual(true, opt.do_bit); try testing.expectEqual(@as(u16, 512), opt.udp_payload_size); try testing.expectEqual(@as(u64, 1), h.stats.truncated.load(.monotonic)); } test "an EDNS version this server does not implement gets BADVERS" { var t: TestIo = .init(); defer t.deinit(); var fake: FakeUpstream = .{ .reply = response_bytes }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); var query_buf: [query_with_opt_len]u8 = undefined; const query = queryWithOptVersion(&query_buf, 1232, true, 1); var buf: [udp_limit_min]u8 = undefined; const reply = try expectReply(udp(&h, t.io(), query, &buf)); const p = try packet.parse(reply); const opt = try edns.parseOpt(reply, packet.findOptRecord(p).?); // RCODE 16 lives in neither half alone: the header carries 0 and the OPT // record carries 1. try testing.expectEqual(types.Rcode.no_error, p.header.flags.rcode); try testing.expectEqual(@as(u12, 16), edns.extendedRcode(p.header.flags.rcode, opt)); try testing.expectEqual(@as(u8, 0), opt.version); try testing.expectEqual(true, opt.do_bit); try testing.expectEqual(@as(u16, 1232), opt.udp_payload_size); try testing.expectEqual(@as(u16, 0x1234), p.header.id); try testing.expectEqual(true, p.header.flags.qr); try testing.expectEqual(@as(u16, 1), p.header.qdcount); try testing.expectEqual(@as(u16, 0), p.header.ancount); try testing.expectEqual(@as(u64, 1), h.stats.badvers.load(.monotonic)); // The query never reached the upstream. try testing.expectEqual(@as(u64, 0), h.stats.queries.load(.monotonic)); } test "an EDNS version 0 query is not answered with BADVERS" { var t: TestIo = .init(); defer t.deinit(); var fake: FakeUpstream = .{ .reply = response_bytes }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); var query_buf: [query_with_opt_len]u8 = undefined; const query = queryWithOptVersion(&query_buf, 1232, false, 0); var buf: [udp_limit_min]u8 = undefined; const reply = try expectReply(udp(&h, t.io(), query, &buf)); // The query is forwarded and the upstream's own answer comes back, so the // BADVERS arm never ran. const p = try packet.parse(reply); try testing.expectEqual(types.Rcode.no_error, p.header.flags.rcode); try testing.expectEqual(@as(u16, 1), p.header.ancount); try testing.expectEqual(@as(u64, 0), h.stats.badvers.load(.monotonic)); try testing.expectEqual(@as(u64, 1), h.stats.queries.load(.monotonic)); } test "a malformed OPT record is FORMERR before the version check reads it" { var t: TestIo = .init(); defer t.deinit(); var fake: FakeUpstream = .{ .reply = response_bytes }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); // The option list is broken and the EDNS version is 1. `parseOpt` fails // first, so nothing trusts the version byte of an OPT that will not parse. var query_buf: [query_with_bad_option.len]u8 = (query_with_bad_option ++ "").*; query_buf[query_bytes.len + opt_version_offset] = 1; var buf: [udp_limit_min]u8 = undefined; const reply = try expectReply(udp(&h, t.io(), &query_buf, &buf)); const p = try packet.parse(reply); try testing.expectEqual(types.Rcode.form_err, p.header.flags.rcode); try testing.expectEqual(@as(u64, 0), h.stats.badvers.load(.monotonic)); try testing.expectEqual(@as(u64, 1), h.stats.formerr.load(.monotonic)); } test "an OPT record with a non-root owner name gets FORMERR" { var t: TestIo = .init(); defer t.deinit(); var fake: FakeUpstream = .{ .reply = response_bytes }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); var buf: [udp_limit_min]u8 = undefined; const reply = try expectReply(udp(&h, t.io(), query_with_named_opt, &buf)); const p = try packet.parse(reply); try testing.expectEqual(types.Rcode.form_err, p.header.flags.rcode); try testing.expectEqual(@as(u16, 0x1234), p.header.id); try testing.expectEqual(true, p.header.flags.qr); try testing.expectEqual(true, p.header.flags.ra); // The question parsed, so it is echoed; the OPT did not, so none is. try testing.expectEqual(@as(u16, 1), p.header.qdcount); const echoed = packet.firstQuestion(p).?; const asked = packet.firstQuestion(try packet.parse(query_bytes)).?; try testing.expectEqualSlices(u8, asked.name.wire(), echoed.name.wire()); try testing.expect(packet.findOptRecord(p) == null); try testing.expectEqual(@as(u64, 1), h.stats.formerr.load(.monotonic)); try testing.expectEqual(@as(usize, 0), fake.calls); } test "an OPT record with a malformed option list gets FORMERR" { var t: TestIo = .init(); defer t.deinit(); var fake: FakeUpstream = .{ .reply = response_bytes }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); // The packet as a whole is well-formed: only `parseOpt` rejects it. const p_query = try packet.parse(query_with_bad_option); try testing.expectError( error.BadOption, edns.parseOpt(query_with_bad_option, packet.findOptRecord(p_query).?), ); var buf: [udp_limit_min]u8 = undefined; const reply = try expectReply(udp(&h, t.io(), query_with_bad_option, &buf)); const p = try packet.parse(reply); try testing.expectEqual(types.Rcode.form_err, p.header.flags.rcode); try testing.expectEqual(@as(u16, 0x1234), p.header.id); try testing.expect(packet.findOptRecord(p) == null); try testing.expectEqual(@as(u64, 1), h.stats.formerr.load(.monotonic)); try testing.expectEqual(@as(usize, 0), fake.calls); } test "an OPT record whose rdata runs off the end gets FORMERR" { var t: TestIo = .init(); defer t.deinit(); var fake: FakeUpstream = .{ .reply = response_bytes }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); // RDLENGTH claims three bytes and one follows, which `packet.parse` reports // as a section overrun before the OPT is ever read. const truncated = query_with_bad_option[0 .. query_with_bad_option.len - 2]; var buf: [udp_limit_min]u8 = undefined; const reply = try expectReply(udp(&h, t.io(), truncated, &buf)); const p = try packet.parse(reply); try testing.expectEqual(types.Rcode.form_err, p.header.flags.rcode); try testing.expectEqual(@as(u64, 1), h.stats.formerr.load(.monotonic)); try testing.expectEqual(@as(usize, 0), fake.calls); } test "an OPT record in the answer section gets FORMERR" { var t: TestIo = .init(); defer t.deinit(); var fake: FakeUpstream = .{ .reply = response_bytes }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); // The message walks: only its placement of the OPT is illegal, so the // handler's own section walk is what has to reject it. const p_query = try packet.parse(query_with_opt_in_answer); try testing.expect(packet.findOptRecord(p_query) == null); var buf: [udp_limit_min]u8 = undefined; const reply = try expectReply(udp(&h, t.io(), query_with_opt_in_answer, &buf)); const p = try packet.parse(reply); try testing.expectEqual(types.Rcode.form_err, p.header.flags.rcode); try testing.expectEqual(@as(u16, 0x1234), p.header.id); // The question parsed, so it is echoed; the OPT is not echoed anywhere. try testing.expectEqual(@as(u16, 1), p.header.qdcount); const echoed = packet.firstQuestion(p).?; const asked = packet.firstQuestion(try packet.parse(query_bytes)).?; try testing.expectEqualSlices(u8, asked.name.wire(), echoed.name.wire()); try testing.expect(packet.findOptRecord(p) == null); try testing.expectEqual(@as(u16, 0), p.header.arcount); try testing.expectEqual(@as(u64, 1), h.stats.formerr.load(.monotonic)); try testing.expectEqual(@as(usize, 0), fake.calls); } test "an OPT record in the authority section gets FORMERR" { var t: TestIo = .init(); defer t.deinit(); var fake: FakeUpstream = .{ .reply = response_bytes }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); const p_query = try packet.parse(query_with_opt_in_authority); try testing.expect(packet.findOptRecord(p_query) == null); var buf: [udp_limit_min]u8 = undefined; const reply = try expectReply(udp(&h, t.io(), query_with_opt_in_authority, &buf)); const p = try packet.parse(reply); try testing.expectEqual(types.Rcode.form_err, p.header.flags.rcode); try testing.expectEqual(@as(u16, 0x1234), p.header.id); try testing.expectEqual(@as(u16, 1), p.header.qdcount); try testing.expect(packet.findOptRecord(p) == null); try testing.expectEqual(@as(u64, 1), h.stats.formerr.load(.monotonic)); try testing.expectEqual(@as(usize, 0), fake.calls); } test "two OPT records in the additional section get FORMERR" { var t: TestIo = .init(); defer t.deinit(); var fake: FakeUpstream = .{ .reply = response_bytes }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); // Here the rejection comes from `packet.parse`, one section walk earlier. try testing.expectError(error.MultipleOptRecords, packet.parse(query_with_two_opts)); var buf: [udp_limit_min]u8 = undefined; const reply = try expectReply(udp(&h, t.io(), query_with_two_opts, &buf)); const p = try packet.parse(reply); try testing.expectEqual(types.Rcode.form_err, p.header.flags.rcode); try testing.expectEqual(@as(u16, 0x1234), p.header.id); try testing.expect(packet.findOptRecord(p) == null); try testing.expectEqual(@as(u64, 1), h.stats.formerr.load(.monotonic)); try testing.expectEqual(@as(usize, 0), fake.calls); } test "a query with a valid OPT record is still forwarded" { var t: TestIo = .init(); defer t.deinit(); var fake: FakeUpstream = .{ .reply = response_bytes }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); var query_buf: [query_with_opt_len]u8 = undefined; const query = queryWithOpt(&query_buf, 1232, true); var buf: [udp_limit_min]u8 = undefined; const reply = try expectReply(udp(&h, t.io(), query, &buf)); try testing.expectEqualSlices(u8, response_bytes, reply); try testing.expectEqual(@as(usize, 1), fake.calls); try testing.expectEqual(@as(u64, 1), h.stats.queries.load(.monotonic)); try testing.expectEqual(@as(u64, 0), h.stats.formerr.load(.monotonic)); // The same query, answered from the SERVFAIL path, still echoes the OPT. var fail: FakeUpstream = .{ .err = error.Timeout }; var h2_owner: upstream_owner.Borrowed = .{}; var h2 = bare(h2_owner.client(fail.client())); const synthesized = try expectReply(udp(&h2, t.io(), query, &buf)); const p = try packet.parse(synthesized); const opt = try edns.parseOpt(synthesized, packet.findOptRecord(p).?); try testing.expectEqual(true, opt.do_bit); try testing.expectEqual(@as(u16, 1232), opt.udp_payload_size); try testing.expectEqual(@as(u64, 0), h2.stats.formerr.load(.monotonic)); } test "every synthesized reply re-parses and binds to its request" { var t: TestIo = .init(); defer t.deinit(); const Case = struct { query: []const u8, err: ?transport.ExchangeError, rcode: types.Rcode, }; const broken_question = "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00" ++ "\xc0\x0c\x00\x01\x00\x01"; const no_question = "\x12\x34\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00"; const update = "\x12\x34\x29\x00\x00\x01\x00\x00\x00\x00\x00\x00" ++ "\x07example\x03com\x00\x00\x01\x00\x01"; const cases = [_]Case{ .{ .query = broken_question, .err = null, .rcode = .form_err }, .{ .query = no_question, .err = null, .rcode = .form_err }, .{ .query = query_with_named_opt, .err = null, .rcode = .form_err }, .{ .query = query_with_bad_option, .err = null, .rcode = .form_err }, .{ .query = query_with_opt_in_answer, .err = null, .rcode = .form_err }, .{ .query = query_with_opt_in_authority, .err = null, .rcode = .form_err }, .{ .query = query_with_two_opts, .err = null, .rcode = .form_err }, .{ .query = update, .err = null, .rcode = .not_imp }, .{ .query = query_bytes, .err = error.BadResponse, .rcode = .serv_fail }, .{ .query = query_bytes, .err = error.Unexpected, .rcode = .serv_fail }, }; for (cases) |c| { var fake: FakeUpstream = .{ .reply = response_bytes, .err = c.err }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); var buf: [udp_limit_min]u8 = undefined; const reply = try expectReply(udp(&h, t.io(), c.query, &buf)); const p = try packet.parse(reply); try testing.expectEqual(c.rcode, p.header.flags.rcode); try testing.expectEqual(@as(u16, 0x1234), p.header.id); try testing.expectEqual(true, p.header.flags.qr); try testing.expectEqual(true, p.header.flags.ra); try testing.expectEqual(@as(u16, 0), p.header.ancount); try testing.expectEqual(@as(u16, 0), p.header.nscount); } } // --- pipeline fixtures ----------------------------------------------------- const Allocator = std.mem.Allocator; /// A query with an arbitrary name, type and class, and no OPT record. fn queryFor( buf: []u8, id: u16, domain: []const u8, qtype: types.Type, qclass: types.Class, ) []const u8 { var w: std.Io.Writer = .fixed(buf); var encoded: [types.header_len]u8 = undefined; header.encode(.{ .id = id, .flags = .{ .rcode = .no_error, .z = 0, .ra = false, .rd = true, .tc = false, .aa = false, .opcode = .query, .qr = false, }, .qdcount = 1, .ancount = 0, .nscount = 0, .arcount = 0, }, &encoded); w.writeAll(&encoded) catch unreachable; question.encode(.{ .name = name.fromText(domain) catch unreachable, .qtype = qtype, .qclass = qclass, }, &w) catch unreachable; return w.buffered(); } /// The same query with an OPT record carrying `options_bytes` verbatim. fn queryWithOptions(buf: []u8, domain: []const u8, options_bytes: []const u8) []const u8 { var w: std.Io.Writer = .fixed(buf); var encoded: [types.header_len]u8 = undefined; header.encode(.{ .id = 0x1234, .flags = .{ .rcode = .no_error, .z = 0, .ra = false, .rd = true, .tc = false, .aa = false, .opcode = .query, .qr = false, }, .qdcount = 1, .ancount = 0, .nscount = 0, .arcount = 1, }, &encoded); w.writeAll(&encoded) catch unreachable; question.encode(.{ .name = name.fromText(domain) catch unreachable, .qtype = .a, .qclass = .in, }, &w) catch unreachable; edns.encodeOpt(.{ .udp_payload_size = 1232, .extended_rcode = 0, .version = 0, .do_bit = false, .options = .{ .offset = 0, .len = 0 }, }, options_bytes, &w) catch unreachable; return w.buffered(); } /// An EDNS Client Subnet option for `a.b.c.0/24`. fn ecsOption(buf: *[11]u8, third: u8) []const u8 { const bytes = [_]u8{ 0x00, 0x08, // code 8 0x00, 0x07, // length 0x00, 0x01, // family: IPv4 24, 0, // source prefix, scope prefix 192, 168, third, }; @memcpy(buf, &bytes); return buf; } /// A DNS cookie option (code 10), which a strip must leave alone. const cookie_option = "\x00\x0a\x00\x08\x01\x02\x03\x04\x05\x06\x07\x08"; /// A response to `query` whose answer section is the CNAME chain /// `names[0] → names[1] → …`. `names[0]` is the queried name. fn cnameChain(buf: []u8, query: []const u8, names: []const []const u8) []u8 { const p = packet.parse(query) catch unreachable; const q = packet.firstQuestion(p).?; var b = packet.ResponseBuilder.init(buf, p.header, q) catch unreachable; var i: usize = 0; while (i + 1 < names.len) : (i += 1) { const owner = name.fromText(names[i]) catch unreachable; const target = name.fromText(names[i + 1]) catch unreachable; b.addAnswer(owner, .cname, .in, 300, target.wire()) catch unreachable; } return b.finish(); } const SnapshotFixture = struct { groups: []const model.Group = &.{.{ .name = "default" }}, group_ids: []const i64 = &.{1}, rules: []const model.Rule = &.{}, sources: []const model.BlocklistSource = &.{}, source_ids: []const i64 = &.{}, group_sources: []const model.GroupSource = &.{}, compiled: []const ?matcher.Snapshot.Compiled = &.{}, }; fn buildSnapshot(gpa: Allocator, fixture: SnapshotFixture) !matcher.Snapshot { return matcher.Snapshot.build(gpa, .{ .groups = fixture.groups, .group_ids = fixture.group_ids, .group_sources = fixture.group_sources, .sources = fixture.sources, .source_ids = fixture.source_ids, .rules = fixture.rules, .clients = &.{}, .prefixes = &.{}, .compiled = fixture.compiled, .seed = 0x5eed, .generation = 1, }); } /// `Manager.acquire` reads the manager's lock and its current snapshot and /// nothing else, so a manager that publishes one hand-built snapshot needs /// none of the database, fetcher or blocklist directory the real one owns. fn fixtureManager(m: *manager.Manager, snapshot: *matcher.Snapshot) void { m.* = .{ .gpa = testing.allocator, .database = undefined, .paths = undefined, .fetcher = undefined, .update = .{}, .schedule_mutex = .init, .schedule_version = 0, .schedule_anchor_s = null, .schedule_event = .unset, .schedule_clock = .real, .total_budget = forward_timeout, .lock = .init, .writer_lock = .init, .refresh_lock = .init, .current = snapshot, .generation = 1, .statuses = &.{}, .status_arena = .init(testing.allocator), }; } fn blockRule(pattern: []const u8) model.Rule { return .{ .group = "default", .pattern = pattern, .kind = .exact, .action = .block }; } fn allowRule(pattern: []const u8) model.Rule { return .{ .group = "default", .pattern = pattern, .kind = .exact, .action = .allow }; } /// The A record of the first answer, which the blocking mode `.zero` sets to /// 0.0.0.0. fn firstAnswer(p: packet.Packet) !record.Record { var it = packet.answers(p); return (try it.next()) orelse error.TestExpectedAnswer; } fn drainLog(lg: *logger_mod.Logger, io: std.Io, out: []logger_mod.Entry) []logger_mod.Entry { const n = lg.queue.getUncancelable(io, out, 0) catch 0; return out[0..n]; } // --- policy snapshots ------------------------------------------------------ /// Parks one query inside its upstream exchange so that a `setPolicy` lands in /// the middle of it. `ecs_mode` is read before the exchange and `blocking` /// after it, on the uncloak path, so the two together are what a torn query /// would expose. const ParkedUpstream = struct { reply: []const u8, /// Set once the exchange has the query in hand. reached: std.Io.Event = .unset, /// Set by the test to let the exchange answer. release: std.Io.Event = .unset, seen: [1024]u8 = undefined, seen_len: usize = 0, fn exchangeFn( ptr: *anyopaque, io: std.Io, query: []const u8, response_buf: []u8, selected: *?[]const u8, ) transport.ExchangeError![]u8 { const self: *ParkedUpstream = @ptrCast(@alignCast(ptr)); selected.* = "fake://parked-upstream"; self.seen_len = @min(query.len, self.seen.len); @memcpy(self.seen[0..self.seen_len], query[0..self.seen_len]); self.reached.set(io); self.release.wait(io) catch {}; if (self.reply.len > response_buf.len) return error.ResponseTooLarge; @memcpy(response_buf[0..self.reply.len], self.reply); const bytes = response_buf[0..self.reply.len]; packet.setId(bytes, (header.parse(query) catch unreachable).id); return bytes; } fn client(self: *ParkedUpstream) transport.Client { return .{ .ptr = self, .exchangeFn = exchangeFn }; } fn sent(self: *const ParkedUpstream) []const u8 { return self.seen[0..self.seen_len]; } }; /// One query on its own task, reduced to the two facts the interleaving test /// asserts on. Nothing is asserted here: a failing assertion on a task the test /// has yet to await would leave `Threaded.deinit` joining a task nothing ends. const ParkedQuery = struct { handler: *Handler, io: std.Io, query: []const u8, buf: [udp_limit_min]u8 = undefined, answer_ttl: u32 = 0, failed: bool = false, fn run(self: *ParkedQuery) void { const bytes = switch (udp(self.handler, self.io, self.query, &self.buf)) { .reply => |b| b, .drop => { self.failed = true; return; }, }; const p = packet.parse(bytes) catch { self.failed = true; return; }; const answer = firstAnswer(p) catch { self.failed = true; return; }; self.answer_ttl = answer.ttl; } }; fn ecsPresent(message: []const u8) !bool { const p = try packet.parse(message); const rec = packet.findOptRecord(p) orelse return false; const opt = try edns.parseOpt(message, rec); return (try edns.findOption(message, opt, edns.ecs_option_code)) != null; } test "a setPolicy in the middle of a query changes nothing that query reads" { var t: TestIo = .init(); defer t.deinit(); var snapshot = try buildSnapshot(testing.allocator, .{ .rules = &.{blockRule("bad.example.org")}, }); defer snapshot.deinit(); var mgr: manager.Manager = undefined; fixtureManager(&mgr, &snapshot); var ecs_buf: [11]u8 = undefined; var query_buf: [512]u8 = undefined; const query = queryWithOptions(&query_buf, "example.com", ecsOption(&ecs_buf, 1)); var chain_buf: [512]u8 = undefined; const chain = cnameChain(&chain_buf, query, &.{ "example.com", "bad.example.org" }); var up: ParkedUpstream = .{ .reply = chain }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(up.client())); h.manager = &mgr; const before: Policy = .{ .blocking = .{ .mode = .zero, .ttl = 5 }, .ecs_mode = .strip, .forward_read_timeout = forward_timeout, }; const after: Policy = .{ .blocking = .{ .mode = .zero, .ttl = 999 }, .ecs_mode = .forward, .forward_read_timeout = forward_timeout, }; h.setPolicy(t.io(), before); var parked: ParkedQuery = .{ .handler = &h, .io = t.io(), .query = query }; var future = try t.io().concurrent(ParkedQuery.run, .{&parked}); try up.reached.wait(t.io()); h.setPolicy(t.io(), after); up.release.set(t.io()); future.await(t.io()); try testing.expect(!parked.failed); // Read before the exchange, under `before`: the subnet was stripped. try testing.expect(!try ecsPresent(up.sent())); // Read after the exchange, on the uncloak path, still under `before`. try testing.expectEqual(@as(u32, 5), parked.answer_ttl); // The publish did take effect — for the next query, which is the whole // point: the snapshot is per query, not a way to ignore a change. var next: ParkedQuery = .{ .handler = &h, .io = t.io(), .query = query }; next.run(); try testing.expect(!next.failed); try testing.expect(try ecsPresent(up.sent())); try testing.expectEqual(@as(u32, 999), next.answer_ttl); } // --- rate limit ------------------------------------------------------------ test "a client over its rate limit gets REFUSED before the query is parsed" { var t: TestIo = .init(); defer t.deinit(); var fake: FakeUpstream = .{ .reply = response_bytes }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); var limiter: rate_limiter.RateLimiter = try .init( testing.allocator, .{ .limit = 2, .window_seconds = 60 }, ); defer limiter.deinit(); h.limiter = &limiter; var buf: [udp_limit_min]u8 = undefined; for (0..2) |_| { const reply = try expectReply(udp(&h, t.io(), query_bytes, &buf)); try testing.expectEqual(types.Rcode.no_error, (try packet.parse(reply)).header.flags.rcode); } const refused = try expectReply(udp(&h, t.io(), query_bytes, &buf)); const p = try packet.parse(refused); try testing.expectEqual(types.Rcode.refused, p.header.flags.rcode); try testing.expectEqual(@as(u16, 0x1234), p.header.id); try testing.expectEqual(@as(u64, 1), h.stats.refused.load(.monotonic)); try testing.expectEqual(@as(usize, 2), fake.calls); // A different client has its own window. const other: address.NetAddress = .{ .ip4 = .{ 192, 168, 1, 51 } }; _ = try expectReply(handleQuery(&h, t.io(), .udp, other, query_bytes, &buf)); try testing.expectEqual(@as(usize, 3), fake.calls); } // --- local records --------------------------------------------------------- test "a local record answers authoritatively without reaching the upstream" { var t: TestIo = .init(); defer t.deinit(); var table = try records.Records.build(testing.allocator, &.{ .{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.10", .ttl = 60 }, }); defer table.deinit(testing.allocator); var fake: FakeUpstream = .{ .reply = response_bytes }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); var tables: local_tables_mod.LocalTables = .{ .records = table }; h.local_tables = &tables; var query_buf: [512]u8 = undefined; const query = queryFor(&query_buf, 0x1234, "nas.lan", .a, .in); var buf: [udp_limit_min]u8 = undefined; const reply = try expectReply(udp(&h, t.io(), query, &buf)); const p = try packet.parse(reply); try testing.expectEqual(types.Rcode.no_error, p.header.flags.rcode); try testing.expectEqual(true, p.header.flags.aa); try testing.expectEqual(@as(u16, 1), p.header.ancount); const answer = try firstAnswer(p); try testing.expectEqual(types.Type.a, answer.rtype); try testing.expectEqual(@as(u32, 60), answer.ttl); try testing.expectEqual([4]u8{ 192, 168, 1, 10 }, try record.rdataA(p.bytes, answer)); try testing.expectEqual(@as(usize, 0), fake.calls); try testing.expectEqual(@as(u64, 1), h.stats.local_answers.load(.monotonic)); } test "a local name with no record of the queried type is authoritative NODATA" { var t: TestIo = .init(); defer t.deinit(); var table = try records.Records.build(testing.allocator, &.{ .{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.10", .ttl = 60 }, }); defer table.deinit(testing.allocator); var fake: FakeUpstream = .{ .reply = response_bytes }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); var tables: local_tables_mod.LocalTables = .{ .records = table }; h.local_tables = &tables; var query_buf: [512]u8 = undefined; const query = queryFor(&query_buf, 0x1234, "nas.lan", .aaaa, .in); var buf: [udp_limit_min]u8 = undefined; const p = try packet.parse(try expectReply(udp(&h, t.io(), query, &buf))); try testing.expectEqual(types.Rcode.no_error, p.header.flags.rcode); try testing.expectEqual(true, p.header.flags.aa); try testing.expectEqual(@as(u16, 0), p.header.ancount); try testing.expectEqual(@as(usize, 0), fake.calls); } // --- forward zones --------------------------------------------------------- test "a forward zone answers from the cache and never reaches the pool" { var t: TestIo = .init(); defer t.deinit(); var zones = try forward_zones.Zones.build(testing.allocator, &.{ .{ .zone = "lan.home", .resolver = "udp://127.0.0.1:1" }, }); defer zones.deinit(testing.allocator); var cache: dns_cache.DnsCache = try .init(testing.allocator, .{ .size = 8, .negative_ttl_max = 3600 }); defer cache.deinit(); // The name is blocklisted, so only the zone path can answer it from the // cache: the upstream path would have blocked it before the lookup. var snapshot = try buildSnapshot(testing.allocator, .{ .rules = &.{blockRule("nas.lan.home")} }); defer snapshot.deinit(); var mgr: manager.Manager = undefined; fixtureManager(&mgr, &snapshot); var fake: FakeUpstream = .{ .reply = response_bytes }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); var tables: local_tables_mod.LocalTables = .{ .zones = zones }; h.local_tables = &tables; h.cache = &cache; h.policy.negative_ttl_max = 3600; h.manager = &mgr; var query_buf: [512]u8 = undefined; const query = queryFor(&query_buf, 0x1234, "nas.lan.home", .a, .in); // The zone resolver is unreachable, so the answer can only come from the // entry planted under the key the handler builds. var zone_reply_buf: [512]u8 = undefined; const zone_p = try packet.parse(query); var b = try packet.ResponseBuilder.init(&zone_reply_buf, zone_p.header, packet.firstQuestion(zone_p).?); try b.addAnswer(try name.fromText("nas.lan.home"), .a, .in, 300, "\xc0\xa8\x01\x0a"); const zone_reply = b.finish(); var key_buf: [dns_cache.max_key_len]u8 = undefined; const key = dns_cache.buildKey( &key_buf, "nas.lan.home", @intFromEnum(types.Type.a), @intFromEnum(types.Class.in), false, null, ); try cache.put(std.Io.Clock.real.now(t.io()).toSeconds(), key, zone_reply, .{ .ttl_seconds = 300, .negative = false }); var buf: [udp_limit_min]u8 = undefined; const p = try packet.parse(try expectReply(udp(&h, t.io(), query, &buf))); try testing.expectEqual(@as(u16, 1), p.header.ancount); try testing.expectEqual([4]u8{ 192, 168, 1, 10 }, try record.rdataA(p.bytes, try firstAnswer(p))); try testing.expectEqual(@as(usize, 0), fake.calls); try testing.expectEqual(@as(u64, 1), h.stats.cache_hits.load(.monotonic)); try testing.expectEqual(@as(u64, 0), h.stats.blocked.load(.monotonic)); } test "a forward zone bypasses the blocklist and fails on its own resolver" { var t: TestIo = .init(); defer t.deinit(); var snapshot = try buildSnapshot(testing.allocator, .{ .rules = &.{blockRule("ads.lan.home")}, }); defer snapshot.deinit(); var mgr: manager.Manager = undefined; fixtureManager(&mgr, &snapshot); var zones = try forward_zones.Zones.build(testing.allocator, &.{ .{ .zone = "lan.home", .resolver = "udp://127.0.0.1:1" }, }); defer zones.deinit(testing.allocator); var fake: FakeUpstream = .{ .reply = response_bytes }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); var tables: local_tables_mod.LocalTables = .{ .zones = zones }; h.local_tables = &tables; h.manager = &mgr; var query_buf: [512]u8 = undefined; const query = queryFor(&query_buf, 0x1234, "ads.lan.home", .a, .in); var buf: [udp_limit_min]u8 = undefined; const p = try packet.parse(try expectReply(udp(&h, t.io(), query, &buf))); // A blocked answer would be NOERROR with an address, so SERVFAIL is proof // the query went to the zone resolver instead of the blocklist. try testing.expectEqual(types.Rcode.serv_fail, p.header.flags.rcode); try testing.expectEqual(@as(u64, 0), h.stats.blocked.load(.monotonic)); try testing.expectEqual(@as(usize, 0), fake.calls); } // --- filtering ------------------------------------------------------------- test "a blocked domain gets the zero address with the blocking ttl" { var t: TestIo = .init(); defer t.deinit(); var snapshot = try buildSnapshot(testing.allocator, .{ .rules = &.{blockRule("example.com")} }); defer snapshot.deinit(); var mgr: manager.Manager = undefined; fixtureManager(&mgr, &snapshot); var fake: FakeUpstream = .{ .reply = response_bytes }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); h.manager = &mgr; var buf: [udp_limit_min]u8 = undefined; const p = try packet.parse(try expectReply(udp(&h, t.io(), query_bytes, &buf))); try testing.expectEqual(types.Rcode.no_error, p.header.flags.rcode); try testing.expectEqual(@as(u16, 1), p.header.ancount); const answer = try firstAnswer(p); try testing.expectEqual(@as(u32, blocking.ttl), answer.ttl); try testing.expectEqual([4]u8{ 0, 0, 0, 0 }, try record.rdataA(p.bytes, answer)); try testing.expectEqual(@as(usize, 0), fake.calls); try testing.expectEqual(@as(u64, 1), h.stats.blocked.load(.monotonic)); try testing.expectEqual(@as(u64, 0), h.stats.unfiltered_queries.load(.monotonic)); } test "an allow rule beats the block rule and the query reaches the upstream" { var t: TestIo = .init(); defer t.deinit(); var snapshot = try buildSnapshot(testing.allocator, .{ .rules = &.{ blockRule("com"), allowRule("example.com") }, }); defer snapshot.deinit(); var mgr: manager.Manager = undefined; fixtureManager(&mgr, &snapshot); var fake: FakeUpstream = .{ .reply = response_bytes }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); h.manager = &mgr; var buf: [udp_limit_min]u8 = undefined; const reply = try expectReply(udp(&h, t.io(), query_bytes, &buf)); try testing.expectEqualSlices(u8, response_bytes, reply); try testing.expectEqual(@as(usize, 1), fake.calls); try testing.expectEqual(@as(u64, 0), h.stats.blocked.load(.monotonic)); } test "a handler with no snapshot answers unfiltered and counts it" { var t: TestIo = .init(); defer t.deinit(); var mgr: manager.Manager = undefined; fixtureManager(&mgr, undefined); mgr.current = null; var fake: FakeUpstream = .{ .reply = response_bytes }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); h.manager = &mgr; var buf: [udp_limit_min]u8 = undefined; const reply = try expectReply(udp(&h, t.io(), query_bytes, &buf)); try testing.expectEqualSlices(u8, response_bytes, reply); try testing.expectEqual(@as(u64, 1), h.stats.unfiltered_queries.load(.monotonic)); } // --- cache ----------------------------------------------------------------- test "a miss stores the answer and the next query is a hit with a fresh id" { var t: TestIo = .init(); defer t.deinit(); var cache: dns_cache.DnsCache = try .init(testing.allocator, .{ .size = 8, .negative_ttl_max = 3600 }); defer cache.deinit(); var fake: FakeUpstream = .{ .reply = response_bytes }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); h.cache = &cache; h.policy.negative_ttl_max = 3600; var buf: [udp_limit_min]u8 = undefined; _ = try expectReply(udp(&h, t.io(), query_bytes, &buf)); try testing.expectEqual(@as(u32, 1), cache.len()); try testing.expectEqual(@as(usize, 1), fake.calls); var query_buf: [512]u8 = undefined; const again = queryFor(&query_buf, 0x5678, "example.com", .a, .in); const p = try packet.parse(try expectReply(udp(&h, t.io(), again, &buf))); try testing.expectEqual(@as(u16, 0x5678), p.header.id); try testing.expectEqual(@as(u16, 1), p.header.ancount); try testing.expectEqual(@as(usize, 1), fake.calls); try testing.expectEqual(@as(u64, 1), h.stats.cache_hits.load(.monotonic)); } test "a cache and a rate-limiter swap land in the middle of a query without a dangling read" { var t: TestIo = .init(); defer t.deinit(); const io = t.io(); var retired_cache: dns_cache.DnsCache = try .init(testing.allocator, .{ .size = 8, .negative_ttl_max = 3600 }); var live_cache: dns_cache.DnsCache = try .init(testing.allocator, .{ .size = 8, .negative_ttl_max = 3600 }); defer live_cache.deinit(); var retired_limiter: rate_limiter.RateLimiter = try .init( testing.allocator, .{ .limit = 100, .window_seconds = 60 }, ); var live_limiter: rate_limiter.RateLimiter = try .init( testing.allocator, .{ .limit = 100, .window_seconds = 60 }, ); defer live_limiter.deinit(); var up: ParkedUpstream = .{ .reply = response_bytes }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(up.client())); h.cache = &retired_cache; h.limiter = &retired_limiter; h.policy.negative_ttl_max = 3600; // The parked query is past its limiter charge and its cache lookup, and // has not reached its cache store: both swaps land while it is inside the // exchange, which is where a pointer loaded before the lock would leave // the query writing into freed memory. var parked: ParkedQuery = .{ .handler = &h, .io = io, .query = query_bytes }; var future = try io.concurrent(ParkedQuery.run, .{&parked}); try up.reached.wait(io); try testing.expectEqual(&retired_cache, h.replaceCache(io, &live_cache).?); try testing.expectEqual(&retired_limiter, h.replaceRateLimiter(io, &live_limiter).?); // Retire, which is the caller's: `replace` only publishes. retired_cache.deinit(); retired_limiter.deinit(); up.release.set(io); future.await(io); try testing.expect(!parked.failed); // The store went to the generation that is live now, and the next query // reads it there. try testing.expectEqual(@as(u32, 1), live_cache.len()); var buf: [udp_limit_min]u8 = undefined; var query_buf: [512]u8 = undefined; const again = queryFor(&query_buf, 0x5678, "example.com", .a, .in); _ = try expectReply(udp(&h, io, again, &buf)); try testing.expectEqual(@as(u64, 1), h.stats.cache_hits.load(.monotonic)); // A handler can also be left with neither, which is what a caller that // turns the cache off publishes. try testing.expectEqual(&live_cache, h.replaceCache(io, null).?); try testing.expectEqual(&live_limiter, h.replaceRateLimiter(io, null).?); _ = try expectReply(udp(&h, io, again, &buf)); } test "a blocked answer is never cached" { var t: TestIo = .init(); defer t.deinit(); var snapshot = try buildSnapshot(testing.allocator, .{ .rules = &.{blockRule("example.com")} }); defer snapshot.deinit(); var mgr: manager.Manager = undefined; fixtureManager(&mgr, &snapshot); var cache: dns_cache.DnsCache = try .init(testing.allocator, .{ .size = 8, .negative_ttl_max = 3600 }); defer cache.deinit(); var fake: FakeUpstream = .{ .reply = response_bytes }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); h.manager = &mgr; h.cache = &cache; h.policy.negative_ttl_max = 3600; var buf: [udp_limit_min]u8 = undefined; _ = try expectReply(udp(&h, t.io(), query_bytes, &buf)); try testing.expectEqual(@as(u32, 0), cache.len()); try testing.expectEqual(@as(u64, 1), h.stats.blocked.load(.monotonic)); } // --- CNAME uncloaking ------------------------------------------------------ test "a CNAME chain into a blocked target blocks the original question" { var t: TestIo = .init(); defer t.deinit(); var snapshot = try buildSnapshot(testing.allocator, .{ .rules = &.{blockRule("bad.example.org")}, }); defer snapshot.deinit(); var mgr: manager.Manager = undefined; fixtureManager(&mgr, &snapshot); var chain_buf: [512]u8 = undefined; const chain = cnameChain(&chain_buf, query_bytes, &.{ "example.com", "bad.example.org" }); var fake: FakeUpstream = .{ .reply = chain }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); h.manager = &mgr; var buf: [udp_limit_min]u8 = undefined; const p = try packet.parse(try expectReply(udp(&h, t.io(), query_bytes, &buf))); try testing.expectEqual(types.Rcode.no_error, p.header.flags.rcode); try testing.expectEqual(@as(u16, 1), p.header.ancount); // The answer is about the name the client asked for, not the target. const answer = try firstAnswer(p); try testing.expectEqual(types.Type.a, answer.rtype); try testing.expectEqualSlices( u8, (try name.fromText("example.com")).wire(), answer.name.wire(), ); try testing.expectEqual([4]u8{ 0, 0, 0, 0 }, try record.rdataA(p.bytes, answer)); try testing.expectEqual(@as(u64, 1), h.stats.uncloak_blocked.load(.monotonic)); try testing.expectEqual(@as(u64, 0), h.stats.blocked.load(.monotonic)); } test "uncloaking follows eight links and stops" { var t: TestIo = .init(); defer t.deinit(); var snapshot = try buildSnapshot(testing.allocator, .{ .rules = &.{blockRule("bad.example.org")}, }); defer snapshot.deinit(); var mgr: manager.Manager = undefined; fixtureManager(&mgr, &snapshot); const hops = [_][]const u8{ "h1.example.net", "h2.example.net", "h3.example.net", "h4.example.net", "h5.example.net", "h6.example.net", "h7.example.net", "h8.example.net", }; // `links` CNAME records, the last of which points at the blocked name. for ([_]usize{ 8, 9 }) |links| { var names: [11][]const u8 = undefined; names[0] = "example.com"; for (0..links - 1) |i| names[i + 1] = hops[i]; names[links] = "bad.example.org"; var chain_buf: [1024]u8 = undefined; const chain = cnameChain(&chain_buf, query_bytes, names[0 .. links + 1]); var fake: FakeUpstream = .{ .reply = chain }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); h.manager = &mgr; var buf: [udp_limit_min]u8 = undefined; const reply = try expectReply(udp(&h, t.io(), query_bytes, &buf)); if (links == max_cname_depth) { try testing.expectEqual(@as(u64, 1), h.stats.uncloak_blocked.load(.monotonic)); } else { try testing.expectEqual(@as(u64, 0), h.stats.uncloak_blocked.load(.monotonic)); try testing.expectEqualSlices(u8, chain, reply); } } } // --- safe search ----------------------------------------------------------- test "safe search sends the target upstream and answers the original question" { var t: TestIo = .init(); defer t.deinit(); var snapshot = try buildSnapshot(testing.allocator, .{ .groups = &.{.{ .name = "default", .safe_search = true }}, }); defer snapshot.deinit(); var mgr: manager.Manager = undefined; fixtureManager(&mgr, &snapshot); // The upstream answers the rewritten question with one address and one // record of another type, which must not be copied over. var target_buf: [512]u8 = undefined; const target_query = queryFor(&target_buf, 0x1234, "forcesafesearch.google.com", .a, .in); var upstream_buf: [512]u8 = undefined; const target_p = try packet.parse(target_query); var b = try packet.ResponseBuilder.init( &upstream_buf, target_p.header, packet.firstQuestion(target_p).?, ); const target_name = try name.fromText("forcesafesearch.google.com"); try b.addAnswer(target_name, .a, .in, 120, "\x08\x08\x08\x08"); try b.addAnswer(target_name, .txt, .in, 60, "\x03abc"); var fake: FakeUpstream = .{ .reply = b.finish() }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); h.manager = &mgr; var query_buf: [512]u8 = undefined; const query = queryFor(&query_buf, 0x1234, "www.google.com", .a, .in); var buf: [udp_limit_min]u8 = undefined; const p = try packet.parse(try expectReply(udp(&h, t.io(), query, &buf))); // What went out carried the target name. const sent = try packet.parse(fake.sent()); try testing.expectEqualSlices( u8, target_name.wire(), packet.firstQuestion(sent).?.name.wire(), ); // What came back keeps the original question. try testing.expectEqualSlices( u8, (try name.fromText("www.google.com")).wire(), packet.firstQuestion(p).?.name.wire(), ); try testing.expectEqual(@as(u16, 2), p.header.ancount); var it = packet.answers(p); const cname = (try it.next()).?; try testing.expectEqual(types.Type.cname, cname.rtype); try testing.expectEqual(@as(u32, 120), cname.ttl); try testing.expectEqualSlices(u8, target_name.wire(), (try record.rdataCname(p.bytes, cname)).wire()); const a = (try it.next()).?; try testing.expectEqual(types.Type.a, a.rtype); try testing.expectEqualSlices(u8, target_name.wire(), a.name.wire()); try testing.expectEqual([4]u8{ 8, 8, 8, 8 }, try record.rdataA(p.bytes, a)); try testing.expect((try it.next()) == null); try testing.expectEqual(@as(u64, 1), h.stats.safesearch_rewrites.load(.monotonic)); } // --- ECS ------------------------------------------------------------------- test "ecs strip removes option 8 from the outgoing query and keeps the rest" { var t: TestIo = .init(); defer t.deinit(); var ecs_buf: [11]u8 = undefined; var options_buf: [64]u8 = undefined; const ecs = ecsOption(&ecs_buf, 1); @memcpy(options_buf[0..cookie_option.len], cookie_option); @memcpy(options_buf[cookie_option.len..][0..ecs.len], ecs); const options_bytes = options_buf[0 .. cookie_option.len + ecs.len]; var query_buf: [512]u8 = undefined; const query = queryWithOptions(&query_buf, "example.com", options_bytes); // The query really carries a subnet, so the assertions below measure a // strip rather than an absence. const asked = try packet.parse(query); const asked_opt = try edns.parseOpt(query, packet.findOptRecord(asked).?); try testing.expect((try edns.findOption(query, asked_opt, edns.ecs_option_code)) != null); var fake: FakeUpstream = .{ .reply = response_bytes }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); var buf: [udp_limit_min]u8 = undefined; _ = try expectReply(udp(&h, t.io(), query, &buf)); const sent = try packet.parse(fake.sent()); const opt = try edns.parseOpt(fake.sent(), packet.findOptRecord(sent).?); try testing.expect((try edns.findOption(fake.sent(), opt, edns.ecs_option_code)) == null); const cookie = (try edns.findOption(fake.sent(), opt, 10)).?; try testing.expectEqualSlices(u8, cookie_option[4..], cookie.data); try testing.expectEqual(@as(u64, 0), h.stats.ecs_strip_failed.load(.monotonic)); } test "ecs forward keeps the subnet in the query and splits the cache key" { var t: TestIo = .init(); defer t.deinit(); var cache: dns_cache.DnsCache = try .init(testing.allocator, .{ .size = 8, .negative_ttl_max = 3600 }); defer cache.deinit(); var fake: FakeUpstream = .{ .reply = response_bytes }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); h.policy.ecs_mode = .forward; h.cache = &cache; h.policy.negative_ttl_max = 3600; var buf: [udp_limit_min]u8 = undefined; for ([_]u8{ 1, 2 }) |third| { var ecs_buf: [11]u8 = undefined; var query_buf: [512]u8 = undefined; const query = queryWithOptions(&query_buf, "example.com", ecsOption(&ecs_buf, third)); _ = try expectReply(udp(&h, t.io(), query, &buf)); // `.forward` sends the query unchanged, subnet included. const sent = try packet.parse(fake.sent()); const opt = try edns.parseOpt(fake.sent(), packet.findOptRecord(sent).?); try testing.expect((try edns.findOption(fake.sent(), opt, edns.ecs_option_code)) != null); } // Two subnets, two entries, two upstream exchanges. try testing.expectEqual(@as(u32, 2), cache.len()); try testing.expectEqual(@as(usize, 2), fake.calls); var ecs_buf: [11]u8 = undefined; var query_buf: [512]u8 = undefined; const repeat = queryWithOptions(&query_buf, "example.com", ecsOption(&ecs_buf, 1)); _ = try expectReply(udp(&h, t.io(), repeat, &buf)); try testing.expectEqual(@as(usize, 2), fake.calls); try testing.expectEqual(@as(u64, 1), h.stats.cache_hits.load(.monotonic)); } // --- pause, class and tracking --------------------------------------------- test "a paused handler answers a blocked domain from the upstream" { var t: TestIo = .init(); defer t.deinit(); var snapshot = try buildSnapshot(testing.allocator, .{ .rules = &.{blockRule("example.com")} }); defer snapshot.deinit(); var mgr: manager.Manager = undefined; fixtureManager(&mgr, &snapshot); var paused: pause.Pause = .{}; paused.pauseFor(0, null); var fake: FakeUpstream = .{ .reply = response_bytes }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); h.manager = &mgr; h.pause = &paused; var buf: [udp_limit_min]u8 = undefined; try testing.expectEqualSlices(u8, response_bytes, try expectReply(udp(&h, t.io(), query_bytes, &buf))); try testing.expectEqual(@as(u64, 1), h.stats.paused_queries.load(.monotonic)); try testing.expectEqual(@as(u64, 0), h.stats.blocked.load(.monotonic)); // Resuming puts the block back without a restart. paused.unpause(); const p = try packet.parse(try expectReply(udp(&h, t.io(), query_bytes, &buf))); try testing.expectEqual([4]u8{ 0, 0, 0, 0 }, try record.rdataA(p.bytes, try firstAnswer(p))); try testing.expectEqual(@as(u64, 1), h.stats.blocked.load(.monotonic)); } test "a non-IN class query skips filtering and the cache" { var t: TestIo = .init(); defer t.deinit(); var snapshot = try buildSnapshot(testing.allocator, .{ .rules = &.{blockRule("example.com")} }); defer snapshot.deinit(); var mgr: manager.Manager = undefined; fixtureManager(&mgr, &snapshot); var cache: dns_cache.DnsCache = try .init(testing.allocator, .{ .size = 8, .negative_ttl_max = 3600 }); defer cache.deinit(); var fake: FakeUpstream = .{ .reply = response_bytes }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); h.manager = &mgr; h.cache = &cache; h.policy.negative_ttl_max = 3600; var query_buf: [512]u8 = undefined; const query = queryFor(&query_buf, 0x1234, "example.com", .txt, .ch); var buf: [udp_limit_min]u8 = undefined; _ = try expectReply(udp(&h, t.io(), query, &buf)); try testing.expectEqual(@as(usize, 1), fake.calls); try testing.expectEqual(@as(u64, 0), h.stats.blocked.load(.monotonic)); try testing.expectEqual(@as(u32, 0), cache.len()); } test "the tracker sees the client address of every parsed query" { var t: TestIo = .init(); defer t.deinit(); var tracker_days: retention_mod.RetentionDays = .init(30); var tracker: clients.Tracker = .init(&tracker_days); var fake: FakeUpstream = .{ .reply = response_bytes }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); h.tracker = &tracker; var buf: [udp_limit_min]u8 = undefined; _ = try expectReply(udp(&h, t.io(), query_bytes, &buf)); _ = try expectReply(udp(&h, t.io(), query_bytes, &buf)); try testing.expectEqual(@as(u32, 1), tracker.pendingClients(t.io())); try testing.expectEqual(@as(u64, 2), tracker.snapshotStats(t.io()).tracked); try testing.expectEqual(@as(u64, 0), h.stats.tracker_full.load(.monotonic)); } // --- query log ------------------------------------------------------------- /// One expected `query_log` row. Every provenance field is asserted on every /// path, so a value that leaks from one path into another fails here rather /// than surviving as a plausible-looking row. /// /// The defaults are what a row says when nothing set the field: no group (no /// snapshot), no rule matched, no source, no rewrite, no upstream attempted. const ExpectedRow = struct { domain: []const u8, qtype: types.Type = .a, qclass: types.Class = .in, rcode: u12 = @intFromEnum(types.Rcode.no_error), blocked: bool = false, cache_hit: ?bool = null, upstream: []const u8 = "", group_id: ?i64 = null, group_name: []const u8 = "", action: provenance.PolicyAction, reason: provenance.PolicyReason, matched: []const u8 = "", source_id: ?i64 = null, source_name: []const u8 = "", cname_target: []const u8 = "", safe_search_target: []const u8 = "", route: provenance.RouteKind, forward_zone: []const u8 = "", }; fn expectRow(expected: ExpectedRow, entry: *const logger_mod.Entry) !void { try testing.expectEqualStrings(expected.domain, entry.domain()); try testing.expectEqualStrings("192.168.1.50", entry.clientIp()); try testing.expectEqual(@as(?u16, @intFromEnum(expected.qtype)), entry.qtype); try testing.expectEqual(@as(u16, @intFromEnum(expected.qclass)), entry.qclass); try testing.expectEqual(@as(u16, expected.rcode), entry.rcode); try testing.expectEqual(expected.blocked, entry.blocked); try testing.expectEqual(expected.cache_hit, entry.cache_hit); try testing.expectEqualStrings(expected.upstream, entry.upstream()); try testing.expectEqual(expected.group_id, entry.group_id); try testing.expectEqualStrings(expected.group_name, entry.groupName()); try testing.expectEqual(expected.action, entry.policy_action); try testing.expectEqual(expected.reason, entry.policy_reason); try testing.expectEqualStrings(expected.matched, entry.matched()); try testing.expectEqual(expected.source_id, entry.source_id); try testing.expectEqualStrings(expected.source_name, entry.sourceName()); try testing.expectEqualStrings(expected.cname_target, entry.cnameTarget()); try testing.expectEqualStrings(expected.safe_search_target, entry.safeSearchTarget()); try testing.expectEqual(expected.route, entry.route_kind); try testing.expectEqualStrings(expected.forward_zone, entry.forwardZone()); try testing.expect(entry.response_time_us.? >= 0); } /// The one blocklist source the provenance tests filter against: one blocked /// name and one `@@` exception, so a row can name the list that decided either /// way. const provenance_sources = [_]model.BlocklistSource{ .{ .url = "https://lists.test/a", .name = "list a" }, }; const provenance_source_ids = [_]i64{11}; const provenance_links = [_]model.GroupSource{ .{ .group = "default", .source_url = "https://lists.test/a" }, }; const provenance_compiled = [_]?matcher.Snapshot.Compiled{.{ .list_body = "ads.example.net\n", .wild_body = "", .allow_body = "safe.example.net\n", }}; fn provenanceSnapshot(rules: []const model.Rule) !matcher.Snapshot { return buildSnapshot(testing.allocator, .{ .rules = rules, .sources = &provenance_sources, .source_ids = &provenance_source_ids, .group_sources = &provenance_links, .compiled = &provenance_compiled, }); } test "the upstream pipeline explains every allowed and blocked answer" { var t: TestIo = .init(); defer t.deinit(); const io = t.io(); var snapshot = try provenanceSnapshot(&.{ blockRule("blocked.example.com"), allowRule("allowed.example.com"), }); defer snapshot.deinit(); var mgr: manager.Manager = undefined; fixtureManager(&mgr, &snapshot); var cache: dns_cache.DnsCache = try .init(testing.allocator, .{ .size = 8, .negative_ttl_max = 3600 }); defer cache.deinit(); var queue_buf: [8]logger_mod.Entry = undefined; var lg: logger_mod.Logger = .init(.{}, &queue_buf); var log_owner: logger_controller.Borrowed = .{}; var sink: query_sink.QuerySink = .init(log_owner.over(&lg), null); var fake: FakeUpstream = .{ .reply = response_bytes }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); h.manager = &mgr; h.cache = &cache; h.policy.negative_ttl_max = 3600; h.sink = &sink; const names = [_][]const u8{ "example.com", "example.com", "allowed.example.com", "blocked.example.com", "ads.example.net", "safe.example.net", }; var buf: [udp_limit_min]u8 = undefined; for (names, 0..) |domain, i| { var query_buf: [512]u8 = undefined; const query = queryFor(&query_buf, @intCast(i + 1), domain, .a, .in); _ = try expectReply(udp(&h, io, query, &buf)); } const expected = [_]ExpectedRow{ .{ .domain = "example.com", .group_id = 1, .group_name = "default", .action = .allow, .reason = .no_match, .route = .upstream, .upstream = "fake://handler-upstream", .cache_hit = false, }, // The second ask is the same decision answered from the cache, and a // cache hit names no upstream. .{ .domain = "example.com", .group_id = 1, .group_name = "default", .action = .allow, .reason = .no_match, .route = .cache, .cache_hit = true, }, .{ .domain = "allowed.example.com", .group_id = 1, .group_name = "default", .action = .allow, .reason = .rule_allow_exact, .matched = "allowed.example.com", .route = .upstream, .upstream = "fake://handler-upstream", .cache_hit = false, }, .{ .domain = "blocked.example.com", .group_id = 1, .group_name = "default", .blocked = true, .action = .block, .reason = .rule_block_exact, .matched = "blocked.example.com", .route = .blocked, }, .{ .domain = "ads.example.net", .group_id = 1, .group_name = "default", .blocked = true, .action = .block, .reason = .blocklist_domain, .matched = "ads.example.net", .source_id = 11, .source_name = "list a", .route = .blocked, }, .{ .domain = "safe.example.net", .group_id = 1, .group_name = "default", .action = .allow, .reason = .blocklist_exception, .matched = "safe.example.net", .source_id = 11, .source_name = "list a", .route = .upstream, .upstream = "fake://handler-upstream", .cache_hit = false, }, }; var entries: [8]logger_mod.Entry = undefined; const logged = drainLog(&lg, io, &entries); try testing.expectEqual(expected.len, logged.len); for (expected, logged) |row, *entry| try expectRow(row, entry); } test "a CNAME-uncloaked block names the target and the target's own decision" { var t: TestIo = .init(); defer t.deinit(); var snapshot = try provenanceSnapshot(&.{blockRule("bad.example.org")}); defer snapshot.deinit(); var mgr: manager.Manager = undefined; fixtureManager(&mgr, &snapshot); var chain_buf: [512]u8 = undefined; const chain = cnameChain(&chain_buf, query_bytes, &.{ "example.com", "bad.example.org" }); var queue_buf: [4]logger_mod.Entry = undefined; var lg: logger_mod.Logger = .init(.{}, &queue_buf); var log_owner: logger_controller.Borrowed = .{}; var sink: query_sink.QuerySink = .init(log_owner.over(&lg), null); var fake: FakeUpstream = .{ .reply = chain }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); h.manager = &mgr; h.sink = &sink; var buf: [udp_limit_min]u8 = undefined; _ = try expectReply(udp(&h, t.io(), query_bytes, &buf)); var entries: [4]logger_mod.Entry = undefined; const logged = drainLog(&lg, t.io(), &entries); try testing.expectEqual(@as(usize, 1), logged.len); // The row names the question the client asked; `cname_target` is what says // a chain was followed, and the reason is the target's own. try expectRow(.{ .domain = "example.com", .group_id = 1, .group_name = "default", .blocked = true, .action = .block, .reason = .rule_block_exact, .matched = "bad.example.org", .cname_target = "bad.example.org", .route = .blocked, }, &logged[0]); } test "a safe-search rewrite records its target and stays an allowed query" { var t: TestIo = .init(); defer t.deinit(); var snapshot = try buildSnapshot(testing.allocator, .{ .groups = &.{.{ .name = "default", .safe_search = true }}, }); defer snapshot.deinit(); var mgr: manager.Manager = undefined; fixtureManager(&mgr, &snapshot); var queue_buf: [4]logger_mod.Entry = undefined; var lg: logger_mod.Logger = .init(.{}, &queue_buf); var log_owner: logger_controller.Borrowed = .{}; var sink: query_sink.QuerySink = .init(log_owner.over(&lg), null); var fake: FakeUpstream = .{ .reply = response_bytes }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); h.manager = &mgr; h.sink = &sink; var query_buf: [512]u8 = undefined; const query = queryFor(&query_buf, 0x1234, "www.google.com", .a, .in); var buf: [udp_limit_min]u8 = undefined; _ = try expectReply(udp(&h, t.io(), query, &buf)); var entries: [4]logger_mod.Entry = undefined; const logged = drainLog(&lg, t.io(), &entries); try testing.expectEqual(@as(usize, 1), logged.len); try expectRow(.{ .domain = "www.google.com", .group_id = 1, .group_name = "default", .action = .allow, .reason = .no_match, .safe_search_target = "forcesafesearch.google.com", .route = .upstream, .upstream = "fake://handler-upstream", .cache_hit = false, }, &logged[0]); } test "a local record and a forward zone are explained with no upstream named" { var t: TestIo = .init(); defer t.deinit(); const io = t.io(); var table = try records.Records.build(testing.allocator, &.{ .{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.10", .ttl = 60 }, }); defer table.deinit(testing.allocator); var zones = try forward_zones.Zones.build(testing.allocator, &.{ .{ .zone = "lan.home", .resolver = "udp://127.0.0.1:1" }, }); defer zones.deinit(testing.allocator); var cache: dns_cache.DnsCache = try .init(testing.allocator, .{ .size = 8, .negative_ttl_max = 3600 }); defer cache.deinit(); var snapshot = try provenanceSnapshot(&.{}); defer snapshot.deinit(); var mgr: manager.Manager = undefined; fixtureManager(&mgr, &snapshot); var queue_buf: [4]logger_mod.Entry = undefined; var lg: logger_mod.Logger = .init(.{}, &queue_buf); var log_owner: logger_controller.Borrowed = .{}; var sink: query_sink.QuerySink = .init(log_owner.over(&lg), null); var fake: FakeUpstream = .{ .reply = response_bytes }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); h.manager = &mgr; var tables: local_tables_mod.LocalTables = .{ .records = table, .zones = zones }; h.local_tables = &tables; h.cache = &cache; h.policy.negative_ttl_max = 3600; h.sink = &sink; // The zone resolver is unreachable, so only a planted cache entry can // answer the zone query. var zone_query_buf: [512]u8 = undefined; const zone_query = queryFor(&zone_query_buf, 0x2222, "nas.lan.home", .a, .in); var zone_reply_buf: [512]u8 = undefined; const zone_p = try packet.parse(zone_query); var b = try packet.ResponseBuilder.init(&zone_reply_buf, zone_p.header, packet.firstQuestion(zone_p).?); try b.addAnswer(try name.fromText("nas.lan.home"), .a, .in, 300, "\xc0\xa8\x01\x0a"); var key_buf: [dns_cache.max_key_len]u8 = undefined; const key = dns_cache.buildKey( &key_buf, "nas.lan.home", @intFromEnum(types.Type.a), @intFromEnum(types.Class.in), false, null, ); try cache.put( std.Io.Clock.real.now(io).toSeconds(), key, b.finish(), .{ .ttl_seconds = 300, .negative = false }, ); var buf: [udp_limit_min]u8 = undefined; var local_query_buf: [512]u8 = undefined; _ = try expectReply(udp(&h, io, queryFor(&local_query_buf, 0x1111, "nas.lan", .a, .in), &buf)); _ = try expectReply(udp(&h, io, zone_query, &buf)); var entries: [4]logger_mod.Entry = undefined; const logged = drainLog(&lg, io, &entries); try testing.expectEqual(@as(usize, 2), logged.len); try expectRow(.{ .domain = "nas.lan", .group_id = 1, .group_name = "default", .action = .allow, .reason = .local_record, .route = .local, }, &logged[0]); try expectRow(.{ .domain = "nas.lan.home", .group_id = 1, .group_name = "default", .action = .allow, .reason = .forward_zone, .forward_zone = "lan.home", .route = .cache, .cache_hit = true, }, &logged[1]); } /// The zone resolver's answer, distinct from the fake upstream's, so the reply /// says which of the two produced it. const zone_rdata = [4]u8{ 10, 9, 9, 7 }; /// A forward zone's resolver: a real UDP socket. `viaForwardZone` builds its /// `ForwardClient` out of the zone's own resolver address, so no fake can stand /// in for it — only a socket that answers produces a forward-zone row for an /// exchange that succeeded. That is why the case below is gated behind /// `-Dintegration` ("loopback sockets only") like every other socket-bound test /// in the tree. /// /// The loop ends when the receive is canceled, which is what `group.cancel` /// does at the end of the case. fn zoneResolver(io: std.Io, socket: *const std.Io.net.Socket, calls: *std.atomic.Value(u64)) void { var buf: [udp_limit_min]u8 = undefined; while (true) { const msg = socket.receive(io, &buf) catch return; _ = calls.fetchAdd(1, .monotonic); const request = packet.parse(msg.data) catch continue; const q = packet.firstQuestion(request) orelse continue; var reply_buf: [udp_limit_min]u8 = undefined; var b = packet.ResponseBuilder.init(&reply_buf, request.header, q) catch continue; b.addAnswer(q.name, .a, .in, 120, &zone_rdata) catch continue; socket.send(io, &msg.from, b.finish()) catch return; } } test "a forward zone that answers names its resolver, and its cache hit does not" { const build_options = @import("build_options"); if (!build_options.integration) return error.SkipZigTest; var t: TestIo = .init(); defer t.deinit(); const io = t.io(); const bind_address: std.Io.net.IpAddress = try .parse("127.0.0.1", 0); const socket = try bind_address.bind(io, .{ .mode = .dgram }); defer socket.close(io); var calls: std.atomic.Value(u64) = .init(0); var group: std.Io.Group = .init; defer group.cancel(io); try group.concurrent(io, zoneResolver, .{ io, &socket, &calls }); var resolver_text: [64]u8 = undefined; const resolver_url = try std.fmt.bufPrint(&resolver_text, "udp://127.0.0.1:{d}", .{ socket.address.ip4.port, }); var zones = try forward_zones.Zones.build(testing.allocator, &.{ .{ .zone = "lan.home", .resolver = resolver_url }, }); defer zones.deinit(testing.allocator); var cache: dns_cache.DnsCache = try .init(testing.allocator, .{ .size = 8, .negative_ttl_max = 3600 }); defer cache.deinit(); var snapshot = try provenanceSnapshot(&.{}); defer snapshot.deinit(); var mgr: manager.Manager = undefined; fixtureManager(&mgr, &snapshot); var queue_buf: [4]logger_mod.Entry = undefined; var lg: logger_mod.Logger = .init(.{}, &queue_buf); var log_owner: logger_controller.Borrowed = .{}; var sink: query_sink.QuerySink = .init(log_owner.over(&lg), null); var fake: FakeUpstream = .{ .reply = response_bytes }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); h.manager = &mgr; var tables: local_tables_mod.LocalTables = .{ .zones = zones }; h.local_tables = &tables; h.cache = &cache; h.policy.negative_ttl_max = 3600; h.sink = &sink; // A real exchange over the loopback needs a deadline that survives a loaded // test machine; the fixture default bounds cases that expect no answer. h.policy.forward_read_timeout = .{ .raw = .fromSeconds(2), .clock = .awake }; var buf: [udp_limit_min]u8 = undefined; var first_buf: [512]u8 = undefined; const first = try expectReply(udp(&h, io, queryFor(&first_buf, 0x3333, "nas.lan.home", .a, .in), &buf)); const p = try packet.parse(first); try testing.expectEqual(zone_rdata, try record.rdataA(p.bytes, try firstAnswer(p))); var second_buf: [512]u8 = undefined; _ = try expectReply(udp(&h, io, queryFor(&second_buf, 0x3344, "nas.lan.home", .a, .in), &buf)); try testing.expectEqual(@as(u64, 1), calls.load(.monotonic)); try testing.expectEqual(@as(usize, 0), fake.calls); var entries: [4]logger_mod.Entry = undefined; const logged = drainLog(&lg, io, &entries); try testing.expectEqual(@as(usize, 2), logged.len); // The exchange that happened names the resolver it reached; the hit that // replaces it names none, and only the route tells the two apart. try expectRow(.{ .domain = "nas.lan.home", .group_id = 1, .group_name = "default", .action = .allow, .reason = .forward_zone, .forward_zone = "lan.home", .route = .forward_zone, .upstream = resolver_url, .cache_hit = false, }, &logged[0]); try expectRow(.{ .domain = "nas.lan.home", .group_id = 1, .group_name = "default", .action = .allow, .reason = .forward_zone, .forward_zone = "lan.home", .route = .cache, .cache_hit = true, }, &logged[1]); } test "a query answered before filtering could apply says which step decided it" { var t: TestIo = .init(); defer t.deinit(); const io = t.io(); var snapshot = try provenanceSnapshot(&.{blockRule("example.com")}); defer snapshot.deinit(); // Non-IN and paused both run against a published snapshot; the third has // none, so it names no group at all. var live: manager.Manager = undefined; fixtureManager(&live, &snapshot); var empty: manager.Manager = undefined; fixtureManager(&empty, undefined); empty.current = null; var paused: pause.Pause = .{}; paused.pauseFor(0, null); const Case = struct { mgr: *manager.Manager, pause: ?*pause.Pause, qclass: types.Class, expected: ExpectedRow, }; const cases = [_]Case{ .{ .mgr = &live, .pause = null, .qclass = .ch, .expected = .{ .domain = "example.com", .qclass = .ch, .group_id = 1, .group_name = "default", .action = .not_evaluated, .reason = .non_in_class, .route = .upstream, .upstream = "fake://handler-upstream", .cache_hit = false, } }, .{ .mgr = &live, .pause = &paused, .qclass = .in, .expected = .{ .domain = "example.com", .group_id = 1, .group_name = "default", .action = .not_evaluated, .reason = .paused, .route = .upstream, .upstream = "fake://handler-upstream", .cache_hit = false, } }, .{ .mgr = &empty, .pause = null, .qclass = .in, .expected = .{ .domain = "example.com", .action = .not_evaluated, .reason = .snapshot_unavailable, .route = .upstream, .upstream = "fake://handler-upstream", .cache_hit = false, } }, }; for (cases) |c| { var queue_buf: [4]logger_mod.Entry = undefined; var lg: logger_mod.Logger = .init(.{}, &queue_buf); var log_owner: logger_controller.Borrowed = .{}; var sink: query_sink.QuerySink = .init(log_owner.over(&lg), null); var fake: FakeUpstream = .{ .reply = response_bytes }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); h.manager = c.mgr; h.pause = c.pause; h.sink = &sink; var query_buf: [512]u8 = undefined; const query = queryFor(&query_buf, 0x1234, "example.com", .a, c.qclass); var buf: [udp_limit_min]u8 = undefined; _ = try expectReply(udp(&h, io, query, &buf)); var entries: [4]logger_mod.Entry = undefined; const logged = drainLog(&lg, io, &entries); try testing.expectEqual(@as(usize, 1), logged.len); try expectRow(c.expected, &logged[0]); } } test "a SERVFAIL is logged with the resolver that lost" { var t: TestIo = .init(); defer t.deinit(); const io = t.io(); // An upstream exchange that fails after naming its resolver. { var queue_buf: [4]logger_mod.Entry = undefined; var lg: logger_mod.Logger = .init(.{}, &queue_buf); var log_owner: logger_controller.Borrowed = .{}; var sink: query_sink.QuerySink = .init(log_owner.over(&lg), null); var fake: FakeUpstream = .{ .err = error.Timeout }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); h.sink = &sink; var buf: [udp_limit_min]u8 = undefined; _ = try expectReply(udp(&h, io, query_bytes, &buf)); var entries: [4]logger_mod.Entry = undefined; const logged = drainLog(&lg, io, &entries); try testing.expectEqual(@as(usize, 1), logged.len); try expectRow(.{ .domain = "example.com", .rcode = @intFromEnum(types.Rcode.serv_fail), .action = .not_evaluated, .reason = .snapshot_unavailable, .route = .upstream, .upstream = "fake://handler-upstream", .cache_hit = false, }, &logged[0]); } // A forward zone whose own resolver never answers. { var zones = try forward_zones.Zones.build(testing.allocator, &.{ .{ .zone = "lan.home", .resolver = "udp://127.0.0.1:1" }, }); defer zones.deinit(testing.allocator); var queue_buf: [4]logger_mod.Entry = undefined; var lg: logger_mod.Logger = .init(.{}, &queue_buf); var log_owner: logger_controller.Borrowed = .{}; var sink: query_sink.QuerySink = .init(log_owner.over(&lg), null); var fake: FakeUpstream = .{ .reply = response_bytes }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); var tables: local_tables_mod.LocalTables = .{ .zones = zones }; h.local_tables = &tables; h.sink = &sink; var query_buf: [512]u8 = undefined; const query = queryFor(&query_buf, 0x1234, "nas.lan.home", .a, .in); var buf: [udp_limit_min]u8 = undefined; _ = try expectReply(udp(&h, io, query, &buf)); var entries: [4]logger_mod.Entry = undefined; const logged = drainLog(&lg, io, &entries); try testing.expectEqual(@as(usize, 1), logged.len); try expectRow(.{ .domain = "nas.lan.home", .rcode = @intFromEnum(types.Rcode.serv_fail), .action = .allow, .reason = .forward_zone, .forward_zone = "lan.home", .route = .forward_zone, .upstream = "udp://127.0.0.1:1", .cache_hit = false, }, &logged[0]); } } // The remaining `servFail` seams are encoding failures, so each one is reached // by a query whose answer does not fit the reply the pipeline has to build. // Nothing here shrinks `response_buf`: `handle` asserts it holds at least // `udp_limit_min` (:168) and `max_synthetic_len` proves a header plus any // question fits that, so a smaller buffer would break the handler's contract // rather than exercise a reachable failure. /// The local name the encoding-failure cases answer for, and the arithmetic /// that says how many A records a 512-byte reply holds for it. The builder /// compresses nothing — `record.encode` writes every owner name in full — so an /// answer costs the wire name plus the fixed record fields. const local_owner = "nas.lan"; /// `\x03nas\x03lan\x00`: a length byte per label, plus the root label. const local_owner_wire_len = local_owner.len + 2; const local_question_len = local_owner_wire_len + 4; /// Owner name + TYPE + CLASS + TTL + RDLENGTH + the four address bytes. const local_a_answer_len = local_owner_wire_len + 10 + 4; /// The most A records that fit beside the header and the echoed question. const local_a_fit = (udp_limit_min - types.header_len - local_question_len) / local_a_answer_len; comptime { const packed_len = types.header_len + local_question_len + local_a_fit * local_a_answer_len; // `local_a_fit` answers fit and one more does not: that pair reaches the // `writeAnswers` seam. A reply packed to `local_a_fit` then has no room for // the OPT echo, which is the seam after it. std.debug.assert(packed_len <= udp_limit_min); std.debug.assert(packed_len + local_a_answer_len > udp_limit_min); std.debug.assert(packed_len + opt_len > udp_limit_min); } /// `n` distinct A records for `local_owner`. fn localARecords(comptime n: usize) [n]model.LocalRecord { var rows: [n]model.LocalRecord = undefined; for (&rows, 0..) |*row, i| row.* = .{ .name = local_owner, .rtype = .a, .value = std.fmt.comptimePrint("10.0.0.{d}", .{i + 1}), .ttl = 60, }; return rows; } const local_a_overflowing = localARecords(local_a_fit + 1); const local_a_packed = localARecords(local_a_fit); test "a local record that will not encode is a SERVFAIL that still says local" { var t: TestIo = .init(); defer t.deinit(); const io = t.io(); var snapshot = try provenanceSnapshot(&.{}); defer snapshot.deinit(); var mgr: manager.Manager = undefined; fixtureManager(&mgr, &snapshot); // Two seams inside `viaLocal`: the answers overrun the reply, and — with the // answers packed to the last byte that fits — the OPT echo the query asked // for does not. const Case = struct { rows: []const model.LocalRecord, with_opt: bool }; const cases = [_]Case{ .{ .rows = &local_a_overflowing, .with_opt = false }, .{ .rows = &local_a_packed, .with_opt = true }, }; for (cases) |c| { var table = try records.Records.build(testing.allocator, c.rows); defer table.deinit(testing.allocator); var tables: local_tables_mod.LocalTables = .{ .records = table }; var queue_buf: [4]logger_mod.Entry = undefined; var lg: logger_mod.Logger = .init(.{}, &queue_buf); var log_owner: logger_controller.Borrowed = .{}; var sink: query_sink.QuerySink = .init(log_owner.over(&lg), null); var fake: FakeUpstream = .{ .reply = response_bytes }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); h.manager = &mgr; h.local_tables = &tables; h.sink = &sink; var query_buf: [512]u8 = undefined; const query = if (c.with_opt) queryWithOptions(&query_buf, local_owner, &.{}) else queryFor(&query_buf, 0x1111, local_owner, .a, .in); var buf: [udp_limit_min]u8 = undefined; _ = try expectReply(udp(&h, io, query, &buf)); // The answer never went out, so nothing counts as a local answer — but // the row still says which step owned the query. try testing.expectEqual(@as(u64, 0), h.stats.local_answers.load(.monotonic)); try testing.expectEqual(@as(usize, 0), fake.calls); var entries: [4]logger_mod.Entry = undefined; const logged = drainLog(&lg, io, &entries); try testing.expectEqual(@as(usize, 1), logged.len); try expectRow(.{ .domain = local_owner, .rcode = @intFromEnum(types.Rcode.serv_fail), .group_id = 1, .group_name = "default", .action = .allow, .reason = .local_record, .route = .local, }, &logged[0]); } } test "a safe-search rewrite that will not fit is a SERVFAIL before any exchange" { var t: TestIo = .init(); defer t.deinit(); const io = t.io(); var snapshot = try buildSnapshot(testing.allocator, .{ .groups = &.{.{ .name = "default", .safe_search = true }}, }); defer snapshot.deinit(); var mgr: manager.Manager = undefined; fixtureManager(&mgr, &snapshot); var queue_buf: [4]logger_mod.Entry = undefined; var lg: logger_mod.Logger = .init(.{}, &queue_buf); var log_owner: logger_controller.Borrowed = .{}; var sink: query_sink.QuerySink = .init(log_owner.over(&lg), null); var fake: FakeUpstream = .{ .reply = response_bytes }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); h.manager = &mgr; h.sink = &sink; // The padding option makes the rewritten question too large for the // 512-byte build buffer, which is the one way `outgoingQuery` returns null. var options_buf: [1024]u8 = undefined; var query_buf: [1024]u8 = undefined; const query = queryWithOptions(&query_buf, "www.google.com", paddingOption(&options_buf, 600)); var buf: [udp_limit_min]u8 = undefined; _ = try expectReply(udp(&h, io, query, &buf)); try testing.expectEqual(@as(usize, 0), fake.calls); var entries: [4]logger_mod.Entry = undefined; const logged = drainLog(&lg, io, &entries); try testing.expectEqual(@as(usize, 1), logged.len); // The filter ran and allowed the name; the rewrite never became a target, // so the row names none and no resolver was attempted. try expectRow(.{ .domain = "www.google.com", .rcode = @intFromEnum(types.Rcode.serv_fail), .group_id = 1, .group_name = "default", .action = .allow, .reason = .no_match, .route = .upstream, .cache_hit = false, }, &logged[0]); } /// An answer whose header promises a record the message does not carry. /// `safeSearchReply` has to re-read the upstream answer to rebuild it under the /// original question, and this is an answer it cannot read. const unparseable_response = "\x12\x34\x81\x80\x00\x01\x00\x01\x00\x00\x00\x00" ++ "\x03www\x06google\x03com\x00\x00\x01\x00\x01"; test "a safe-search answer that will not rebuild is a SERVFAIL naming the resolver" { var t: TestIo = .init(); defer t.deinit(); const io = t.io(); var snapshot = try buildSnapshot(testing.allocator, .{ .groups = &.{.{ .name = "default", .safe_search = true }}, }); defer snapshot.deinit(); var mgr: manager.Manager = undefined; fixtureManager(&mgr, &snapshot); var queue_buf: [4]logger_mod.Entry = undefined; var lg: logger_mod.Logger = .init(.{}, &queue_buf); var log_owner: logger_controller.Borrowed = .{}; var sink: query_sink.QuerySink = .init(log_owner.over(&lg), null); var fake: FakeUpstream = .{ .reply = unparseable_response }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); h.manager = &mgr; h.sink = &sink; var query_buf: [512]u8 = undefined; const query = queryFor(&query_buf, 0x1234, "www.google.com", .a, .in); var buf: [udp_limit_min]u8 = undefined; _ = try expectReply(udp(&h, io, query, &buf)); try testing.expectEqual(@as(usize, 1), fake.calls); var entries: [4]logger_mod.Entry = undefined; const logged = drainLog(&lg, io, &entries); try testing.expectEqual(@as(usize, 1), logged.len); // The exchange happened, so the resolver is named and the rewrite that went // out is on the row — the failure is downstream of both. try expectRow(.{ .domain = "www.google.com", .rcode = @intFromEnum(types.Rcode.serv_fail), .group_id = 1, .group_name = "default", .action = .allow, .reason = .no_match, .safe_search_target = "forcesafesearch.google.com", .route = .upstream, .upstream = "fake://handler-upstream", .cache_hit = false, }, &logged[0]); } /// A name at the wire maximum. A blocked reply for it carries the name twice — /// once in the echoed question, once as the A record's owner — and the pair /// does not fit a 512-byte UDP reply. const max_length_name = "a" ** 63 ++ "." ++ "b" ** 63 ++ "." ++ "c" ** 63 ++ "." ++ "d" ** 61; test "a blocked reply that will not encode is a SERVFAIL that still says blocked" { var t: TestIo = .init(); defer t.deinit(); const io = t.io(); var snapshot = try provenanceSnapshot(&.{blockRule(max_length_name)}); defer snapshot.deinit(); var mgr: manager.Manager = undefined; fixtureManager(&mgr, &snapshot); var queue_buf: [4]logger_mod.Entry = undefined; var lg: logger_mod.Logger = .init(.{}, &queue_buf); var log_owner: logger_controller.Borrowed = .{}; var sink: query_sink.QuerySink = .init(log_owner.over(&lg), null); var fake: FakeUpstream = .{ .reply = response_bytes }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); h.manager = &mgr; h.sink = &sink; var query_buf: [512]u8 = undefined; const query = queryFor(&query_buf, 0x1234, max_length_name, .a, .in); var buf: [udp_limit_min]u8 = undefined; _ = try expectReply(udp(&h, io, query, &buf)); // No blocked answer reached the client, so none is counted — and the answer // the upstream would have given was never asked for either. try testing.expectEqual(@as(u64, 0), h.stats.blocked.load(.monotonic)); try testing.expectEqual(@as(usize, 0), fake.calls); var entries: [4]logger_mod.Entry = undefined; const logged = drainLog(&lg, io, &entries); try testing.expectEqual(@as(usize, 1), logged.len); try expectRow(.{ .domain = max_length_name, .rcode = @intFromEnum(types.Rcode.serv_fail), .blocked = true, .group_id = 1, .group_name = "default", .action = .block, .reason = .rule_block_exact, .matched = max_length_name, .route = .blocked, }, &logged[0]); } test "a protocol refusal that names a question is a row, not only a counter" { var t: TestIo = .init(); defer t.deinit(); const io = t.io(); var badvers_buf: [query_with_opt_len]u8 = undefined; const badvers = queryWithOptVersion(&badvers_buf, 1232, false, 1); var update: [query_bytes.len]u8 = query_bytes.*; std.mem.writeInt(u16, update[2..4], 0x2900, .big); // opcode update, RD set const Case = struct { query: []const u8, rcode: u12 }; const cases = [_]Case{ .{ .query = badvers, .rcode = edns.badvers }, .{ .query = &update, .rcode = @intFromEnum(types.Rcode.not_imp) }, .{ .query = query_with_bad_option, .rcode = @intFromEnum(types.Rcode.form_err) }, .{ .query = query_with_named_opt, .rcode = @intFromEnum(types.Rcode.form_err) }, .{ .query = query_with_opt_in_answer, .rcode = @intFromEnum(types.Rcode.form_err) }, }; for (cases) |c| { var queue_buf: [4]logger_mod.Entry = undefined; var lg: logger_mod.Logger = .init(.{}, &queue_buf); var log_owner: logger_controller.Borrowed = .{}; var sink: query_sink.QuerySink = .init(log_owner.over(&lg), null); var fake: FakeUpstream = .{ .reply = response_bytes }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); h.sink = &sink; var buf: [udp_limit_min]u8 = undefined; _ = try expectReply(udp(&h, io, c.query, &buf)); try testing.expectEqual(@as(usize, 0), fake.calls); var entries: [4]logger_mod.Entry = undefined; const logged = drainLog(&lg, io, &entries); try testing.expectEqual(@as(usize, 1), logged.len); try expectRow(.{ .domain = "example.com", .rcode = c.rcode, .action = .not_evaluated, .reason = .protocol_error, .route = .rejected, }, &logged[0]); } } test "a query refused before any question is parsed stays a counter" { var t: TestIo = .init(); defer t.deinit(); var queue_buf: [4]logger_mod.Entry = undefined; var lg: logger_mod.Logger = .init(.{}, &queue_buf); var log_owner: logger_controller.Borrowed = .{}; var sink: query_sink.QuerySink = .init(log_owner.over(&lg), null); var fake: FakeUpstream = .{ .reply = response_bytes }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); h.sink = &sink; const no_question = "\x12\x34\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00"; const two_questions = "\x12\x34\x01\x00\x00\x02\x00\x00\x00\x00\x00\x00" ++ "\x07example\x03com\x00\x00\x01\x00\x01" ++ "\x07example\x03com\x00\x00\x1c\x00\x01"; var buf: [udp_limit_min]u8 = undefined; _ = try expectReply(udp(&h, t.io(), no_question, &buf)); _ = try expectReply(udp(&h, t.io(), two_questions, &buf)); var entries: [4]logger_mod.Entry = undefined; try testing.expectEqual(@as(usize, 0), drainLog(&lg, t.io(), &entries).len); try testing.expectEqual(@as(u64, 2), h.stats.formerr.load(.monotonic)); } test "an upstream identity carrying a credential is redacted before it is stored" { var t: TestIo = .init(); defer t.deinit(); var queue_buf: [4]logger_mod.Entry = undefined; var lg: logger_mod.Logger = .init(.{}, &queue_buf); var log_owner: logger_controller.Borrowed = .{}; var sink: query_sink.QuerySink = .init(log_owner.over(&lg), null); // `Endpoint.parse` rejects `@`, so this shape cannot come through the // production config — the redaction is asserted against an injected one. var fake: FakeUpstream = .{ .reply = response_bytes, .identity = "https://user:hunter2@dns.example.net/dns-query?token=abc", }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); h.sink = &sink; var buf: [udp_limit_min]u8 = undefined; _ = try expectReply(udp(&h, t.io(), query_bytes, &buf)); var entries: [4]logger_mod.Entry = undefined; const logged = drainLog(&lg, t.io(), &entries); try testing.expectEqual(@as(usize, 1), logged.len); try testing.expectEqualStrings("https://dns.example.net", logged[0].upstream()); try testing.expect(std.mem.indexOf(u8, logged[0].upstream(), "hunter2") == null); try testing.expect(std.mem.indexOf(u8, logged[0].upstream(), "token") == null); } test "the sink both streams and logs the query the handler answered" { var t: TestIo = .init(); defer t.deinit(); const io = t.io(); const hub = try testing.allocator.create(sse.Hub); defer testing.allocator.destroy(hub); hub.init(); var queue_buf: [4]logger_mod.Entry = undefined; var lg: logger_mod.Logger = .init(.{}, &queue_buf); var log_owner: logger_controller.Borrowed = .{}; var sink: query_sink.QuerySink = .init(log_owner.over(&lg), hub); var fake: FakeUpstream = .{ .reply = response_bytes }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); h.sink = &sink; const id = hub.subscribe(io).?; defer hub.unsubscribe(io, id); var buf: [udp_limit_min]u8 = undefined; _ = try expectReply(udp(&h, io, query_bytes, &buf)); const streamed = hub.next(io, id).?; try testing.expectEqualStrings("example.com", streamed.domain()); try testing.expectEqualStrings("192.168.1.50", streamed.clientIp()); try testing.expectEqualStrings("fake://handler-upstream", streamed.upstream()); try testing.expect(hub.next(io, id) == null); var entries: [4]logger_mod.Entry = undefined; const logged = drainLog(&lg, io, &entries); try testing.expectEqual(@as(usize, 1), logged.len); try testing.expectEqualStrings("example.com", logged[0].domain()); } test "a refused query is counted and never logged" { var t: TestIo = .init(); defer t.deinit(); var limiter: rate_limiter.RateLimiter = try .init( testing.allocator, .{ .limit = 1, .window_seconds = 60 }, ); defer limiter.deinit(); var queue_buf: [4]logger_mod.Entry = undefined; var lg: logger_mod.Logger = .init(.{}, &queue_buf); var log_owner: logger_controller.Borrowed = .{}; var sink: query_sink.QuerySink = .init(log_owner.over(&lg), null); var fake: FakeUpstream = .{ .reply = response_bytes }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); h.limiter = &limiter; h.sink = &sink; var buf: [udp_limit_min]u8 = undefined; _ = try expectReply(udp(&h, t.io(), query_bytes, &buf)); _ = try expectReply(udp(&h, t.io(), query_bytes, &buf)); var entries: [4]logger_mod.Entry = undefined; const logged = drainLog(&lg, t.io(), &entries); try testing.expectEqual(@as(usize, 1), logged.len); try testing.expectEqual(@as(u64, 1), h.stats.refused.load(.monotonic)); } test "safe search and an ecs strip compose into one outgoing query" { var t: TestIo = .init(); defer t.deinit(); var snapshot = try buildSnapshot(testing.allocator, .{ .groups = &.{.{ .name = "default", .safe_search = true }}, }); defer snapshot.deinit(); var mgr: manager.Manager = undefined; fixtureManager(&mgr, &snapshot); var ecs_buf: [11]u8 = undefined; var options_buf: [64]u8 = undefined; const ecs = ecsOption(&ecs_buf, 1); @memcpy(options_buf[0..cookie_option.len], cookie_option); @memcpy(options_buf[cookie_option.len..][0..ecs.len], ecs); var query_buf: [512]u8 = undefined; const query = queryWithOptions( &query_buf, "www.google.com", options_buf[0 .. cookie_option.len + ecs.len], ); var fake: FakeUpstream = .{ .reply = response_bytes }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); h.manager = &mgr; var buf: [udp_limit_min]u8 = undefined; _ = try expectReply(udp(&h, t.io(), query, &buf)); const sent = try packet.parse(fake.sent()); try testing.expectEqualSlices( u8, (try name.fromText("forcesafesearch.google.com")).wire(), packet.firstQuestion(sent).?.name.wire(), ); const opt = try edns.parseOpt(fake.sent(), packet.findOptRecord(sent).?); try testing.expect((try edns.findOption(fake.sent(), opt, edns.ecs_option_code)) == null); try testing.expect((try edns.findOption(fake.sent(), opt, 10)) != null); try testing.expectEqual(@as(u64, 1), h.stats.safesearch_rewrites.load(.monotonic)); } /// An RFC 7830 padding option (code 12) of `padding_len` zero bytes, written at /// the start of `buf`. Long enough padding puts a query out of reach of the /// 512-byte build buffer, which is what the rebuild paths fail on. fn paddingOption(buf: []u8, padding_len: u16) []const u8 { std.mem.writeInt(u16, buf[0..2], 12, .big); std.mem.writeInt(u16, buf[2..4], padding_len, .big); @memset(buf[4..][0..padding_len], 0); return buf[0 .. 4 + padding_len]; } /// A query whose OPT record is too large for the 512-byte build buffer to /// rebuild, carrying a subnet behind the padding. fn paddedEcsQuery(options_buf: *[1024]u8, query_buf: *[1024]u8) []const u8 { const padding = paddingOption(options_buf, 600); var ecs_buf: [11]u8 = undefined; const ecs = ecsOption(&ecs_buf, 1); @memcpy(options_buf[padding.len..][0..ecs.len], ecs); return queryWithOptions(query_buf, "example.com", options_buf[0 .. padding.len + ecs.len]); } test "a query too large to rebuild keeps its subnet and is counted" { var t: TestIo = .init(); defer t.deinit(); var options_buf: [1024]u8 = undefined; var query_buf: [1024]u8 = undefined; const query = paddedEcsQuery(&options_buf, &query_buf); var fake: FakeUpstream = .{ .reply = response_bytes }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); var buf: [udp_limit_min]u8 = undefined; _ = try expectReply(udp(&h, t.io(), query, &buf)); // Ruling 13: the original goes out unchanged rather than the query failing. try testing.expectEqualSlices(u8, query, fake.sent()); try testing.expectEqual(@as(u64, 1), h.stats.ecs_strip_failed.load(.monotonic)); } test "a failed ecs strip keeps the query out of the cache in both directions" { var t: TestIo = .init(); defer t.deinit(); var cache: dns_cache.DnsCache = try .init(testing.allocator, .{ .size = 8, .negative_ttl_max = 3600 }); defer cache.deinit(); // An entry stored under the subnet-free key every other client shares. var planted_buf: [512]u8 = undefined; const planted_p = try packet.parse(query_bytes); var b = try packet.ResponseBuilder.init( &planted_buf, planted_p.header, packet.firstQuestion(planted_p).?, ); try b.addAnswer(try name.fromText("example.com"), .a, .in, 300, "\x09\x09\x09\x09"); const planted = b.finish(); var key_buf: [dns_cache.max_key_len]u8 = undefined; const global_key = dns_cache.buildKey( &key_buf, "example.com", @intFromEnum(types.Type.a), @intFromEnum(types.Class.in), false, null, ); try cache.put( std.Io.Clock.real.now(t.io()).toSeconds(), global_key, planted, .{ .ttl_seconds = 300, .negative = false }, ); var fake: FakeUpstream = .{ .reply = response_bytes }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); h.cache = &cache; h.policy.negative_ttl_max = 3600; var options_buf: [1024]u8 = undefined; var query_buf: [1024]u8 = undefined; const query = paddedEcsQuery(&options_buf, &query_buf); var buf: [udp_limit_min]u8 = undefined; const p = try packet.parse(try expectReply(udp(&h, t.io(), query, &buf))); // The subnet-free entry must not answer a query whose subnet went upstream. try testing.expectEqual(@as(u64, 1), h.stats.ecs_strip_failed.load(.monotonic)); try testing.expectEqual(@as(u64, 0), h.stats.cache_hits.load(.monotonic)); try testing.expectEqual(@as(usize, 1), fake.calls); try testing.expectEqual([4]u8{ 93, 184, 216, 34 }, try record.rdataA(p.bytes, try firstAnswer(p))); // Nor may the subnet-specific answer be stored where they would find it. try testing.expectEqual(@as(u32, 1), cache.len()); _ = try expectReply(udp(&h, t.io(), query, &buf)); try testing.expectEqual(@as(usize, 2), fake.calls); try testing.expectEqual(@as(u32, 1), cache.len()); } test "forward mode refuses to cache a query carrying two subnets" { var t: TestIo = .init(); defer t.deinit(); var cache: dns_cache.DnsCache = try .init(testing.allocator, .{ .size = 8, .negative_ttl_max = 3600 }); defer cache.deinit(); var fake: FakeUpstream = .{ .reply = response_bytes }; var h_owner: upstream_owner.Borrowed = .{}; var h = bare(h_owner.client(fake.client())); h.policy.ecs_mode = .forward; h.cache = &cache; h.policy.negative_ttl_max = 3600; // Two ECS options: the key could only name one of the two subnets, and the // resolver is free to honour the other. var first_buf: [11]u8 = undefined; var second_buf: [11]u8 = undefined; var options_buf: [64]u8 = undefined; const first = ecsOption(&first_buf, 1); const second = ecsOption(&second_buf, 2); @memcpy(options_buf[0..first.len], first); @memcpy(options_buf[first.len..][0..second.len], second); var query_buf: [512]u8 = undefined; const query = queryWithOptions(&query_buf, "example.com", options_buf[0 .. first.len + second.len]); var buf: [udp_limit_min]u8 = undefined; _ = try expectReply(udp(&h, t.io(), query, &buf)); try testing.expectEqual(@as(u32, 0), cache.len()); try testing.expectEqual(@as(usize, 1), fake.calls); // Repeating it finds nothing to hit, in either subnet's key or the global one. _ = try expectReply(udp(&h, t.io(), query, &buf)); try testing.expectEqual(@as(usize, 2), fake.calls); try testing.expectEqual(@as(u64, 0), h.stats.cache_hits.load(.monotonic)); // One subnet is still cached normally. var single_buf: [512]u8 = undefined; const single = queryWithOptions(&single_buf, "example.com", first); _ = try expectReply(udp(&h, t.io(), single, &buf)); try testing.expectEqual(@as(u32, 1), cache.len()); _ = try expectReply(udp(&h, t.io(), single, &buf)); try testing.expectEqual(@as(u64, 1), h.stats.cache_hits.load(.monotonic)); }