2779 lines
109 KiB
Zig
2779 lines
109 KiB
Zig
//! 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_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 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 safesearch = @import("../filter/safesearch.zig");
|
|
const transport = @import("../upstream/transport.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;
|
|
|
|
/// `"cname:"` plus the longest `matcher.Reason` tag, which the comptime block
|
|
/// below proves fits the 32 bytes `logger.Entry` stores.
|
|
const cname_reason_prefix = "cname:";
|
|
const max_reason_len = 32;
|
|
|
|
comptime {
|
|
for (std.enums.values(matcher.Reason)) |reason| {
|
|
if (cname_reason_prefix.len + @tagName(reason).len > max_reason_len) {
|
|
@compileError("a matcher.Reason tag no longer fits the query log's reason field");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// RFC 5952 text of any IPv6 address. `logger.Entry` truncates at the same
|
|
/// width, so nothing an address formats to is ever cut.
|
|
const max_ip_text = 45;
|
|
|
|
/// `udp://` or `tcp://`, an IPv6 literal in brackets, and a port.
|
|
const max_resolver_text = "tcp://[".len + max_ip_text + "]:65535".len;
|
|
|
|
/// Ruling 20 leaves the pool's answering endpoint out of reach: the pool tracks
|
|
/// it, but reading it back would mean new plumbing through `transport.Client`
|
|
/// for one log field. Every pool answer is therefore logged as coming from
|
|
/// "pool"; forward zones name their resolver and local records say "local".
|
|
const pool_upstream = "pool";
|
|
|
|
/// 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;
|
|
|
|
pub const Handler = struct {
|
|
/// In production this is `pool.client()`.
|
|
upstream: transport.Client,
|
|
blocking: response.Options,
|
|
ecs_mode: model.EcsMode = .strip,
|
|
forward_read_timeout: std.Io.Clock.Duration,
|
|
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,
|
|
/// `cfg.cache.negative_ttl_max`. `DnsCache` keeps no copy of its config and
|
|
/// `dns_cache.classify` needs the value, so the handler carries it. Zero
|
|
/// disables negative caching, which is what a cache-less handler wants.
|
|
negative_ttl_max: u32 = 0,
|
|
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),
|
|
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:
|
|
/// `Tracker.track` reports nothing back, and 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,
|
|
};
|
|
|
|
/// `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);
|
|
|
|
// 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.
|
|
if (self.limiter) |limiter| {
|
|
self.limiter_mutex.lockUncancelable(io);
|
|
const allowed = limiter.check(std.Io.Clock.awake.now(io), from.key());
|
|
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;
|
|
return synthesize(hdr, echo, null, .form_err, &self.stats.formerr, response_buf);
|
|
}
|
|
|
|
// 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;
|
|
return synthesize(hdr, echo, null, .form_err, &self.stats.formerr, response_buf);
|
|
}
|
|
else
|
|
null;
|
|
|
|
if (hdr.flags.opcode != .query) {
|
|
return synthesize(hdr, null, opt, .not_imp, &self.stats.notimp, response_buf);
|
|
}
|
|
|
|
// 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| {
|
|
tracker.track(io, from);
|
|
self.stats.tracker_full.store(tracker.snapshotStats(io).dropped_full, .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);
|
|
|
|
var ctx: Context = .{
|
|
.handler = self,
|
|
.io = io,
|
|
.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 = if (snapshot) |s| s.groupForClient(from) else 0,
|
|
.domain = matcher.normalize(q.name, &scratch.normalize),
|
|
};
|
|
return ctx.run();
|
|
}
|
|
};
|
|
|
|
/// 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,
|
|
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,
|
|
/// 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,
|
|
zones: *const forward_zones.Zones,
|
|
group: u32,
|
|
/// The queried name, normalized into `scratch.normalize`.
|
|
domain: []const u8,
|
|
|
|
/// 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) 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);
|
|
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 {
|
|
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(), .{ .upstream = "local" });
|
|
}
|
|
|
|
/// 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 {
|
|
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| return ctx.reply(hit, .{ .cache_hit = true });
|
|
|
|
var client: forward_client.ForwardClient = .init(
|
|
zone.resolver,
|
|
&ctx.scratch.frame,
|
|
ctx.handler.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,
|
|
.peer_fault, .local_resource => ctx.servFail(),
|
|
};
|
|
};
|
|
|
|
bump(&ctx.handler.stats.forward_zone_answers);
|
|
ctx.cachePut(key, answer);
|
|
|
|
var text: [max_resolver_text]u8 = undefined;
|
|
return ctx.reply(answer, .{
|
|
.cache_hit = false,
|
|
.upstream = resolverText(zone.resolver, &text),
|
|
});
|
|
}
|
|
|
|
/// 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);
|
|
if (decision.blocked) return ctx.viaBlocked(decision.reason, false);
|
|
if (s.safeSearch(ctx.group)) target = safesearch.rewrite(ctx.domain);
|
|
};
|
|
|
|
const outgoing = ctx.outgoingQuery(target) orelse return ctx.servFail();
|
|
if (target != null) bump(&ctx.handler.stats.safesearch_rewrites);
|
|
|
|
// 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| return ctx.reply(hit, .{ .cache_hit = true });
|
|
|
|
const answer = ctx.handler.upstream.exchange(ctx.io, outgoing.bytes, ctx.response_buf) catch |err| {
|
|
return 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 => .drop,
|
|
.peer_fault, .local_resource => 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)) |reason| return ctx.viaBlocked(reason, 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, .{ .cache_hit = false, .upstream = pool_upstream });
|
|
}
|
|
|
|
/// 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, reason: matcher.Reason, uncloaked: bool) Handler.Outcome {
|
|
const bytes = response.writeBlocked(
|
|
ctx.response_buf,
|
|
ctx.hdr,
|
|
ctx.q,
|
|
ctx.opt,
|
|
ctx.do_bit,
|
|
ctx.handler.blocking,
|
|
) catch return ctx.servFail();
|
|
|
|
bump(if (uncloaked) &ctx.handler.stats.uncloak_blocked else &ctx.handler.stats.blocked);
|
|
|
|
var reason_buf: [max_reason_len]u8 = undefined;
|
|
return ctx.reply(bytes, .{
|
|
.blocked = true,
|
|
.block_reason = blockReason(&reason_buf, reason, uncloaked),
|
|
});
|
|
}
|
|
|
|
/// Ruling 20: a failure the client can see is still a failure nobody
|
|
/// resolved, so it is counted and not query-logged.
|
|
fn servFail(ctx: *Context) Handler.Outcome {
|
|
return synthesize(
|
|
ctx.hdr,
|
|
ctx.q,
|
|
ctx.opt,
|
|
.serv_fail,
|
|
&ctx.handler.stats.servfail,
|
|
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, fields: LogFields) 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.
|
|
bump(&ctx.handler.stats.truncated);
|
|
var scratch: [udp_limit_min]u8 = undefined;
|
|
const truncated = build(ctx.hdr, ctx.q, ctx.opt, .no_error, true, &scratch);
|
|
@memcpy(ctx.response_buf[0..truncated.len], truncated);
|
|
message = ctx.response_buf[0..truncated.len];
|
|
}
|
|
ctx.log(fields);
|
|
return .{ .reply = message };
|
|
}
|
|
|
|
fn log(ctx: *Context, fields: LogFields) void {
|
|
const sink = ctx.handler.sink orelse return;
|
|
|
|
var ip_buf: [max_ip_text]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),
|
|
.blocked = fields.blocked,
|
|
.block_reason = fields.block_reason,
|
|
.response_time_us = now.toMicroseconds() - ctx.started.toMicroseconds(),
|
|
.cache_hit = fields.cache_hit,
|
|
.upstream = fields.upstream,
|
|
}));
|
|
}
|
|
|
|
/// 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.handler.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 {
|
|
const cache = ctx.handler.cache orelse return null;
|
|
|
|
ctx.handler.cache_mutex.lockUncancelable(ctx.io);
|
|
const hit = cache.get(ctx.now_s, key, ctx.response_buf);
|
|
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 cache = ctx.handler.cache orelse return;
|
|
const class = dns_cache.classify(message, ctx.handler.negative_ttl_max) orelse return;
|
|
|
|
ctx.handler.cache_mutex.lockUncancelable(ctx.io);
|
|
defer ctx.handler.cache_mutex.unlock(ctx.io);
|
|
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.handler.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.handler.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.
|
|
fn uncloak(ctx: *Context, snapshot: *const matcher.Snapshot, answer: []const u8) ?matcher.Reason {
|
|
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),
|
|
);
|
|
if (decision.blocked) return decision.reason;
|
|
current = next;
|
|
}
|
|
return null;
|
|
}
|
|
};
|
|
|
|
/// 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 };
|
|
}
|
|
|
|
/// What a query log row records beyond what the context already knows.
|
|
const LogFields = struct {
|
|
blocked: bool = false,
|
|
block_reason: []const u8 = "",
|
|
/// Null on the paths where the cache never applied: local records, forward
|
|
/// zones bypassing it, and blocked answers (ruling 20).
|
|
cache_hit: ?bool = null,
|
|
upstream: []const u8 = "",
|
|
};
|
|
|
|
/// 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;
|
|
}
|
|
|
|
/// The `block_reason` column. An uncloaked block names the level that matched
|
|
/// the CNAME target, prefixed so that it is not mistaken for a decision about
|
|
/// the name the client asked for.
|
|
fn blockReason(buf: *[max_reason_len]u8, reason: matcher.Reason, uncloaked: bool) []const u8 {
|
|
const tag = @tagName(reason);
|
|
if (!uncloaked) return tag;
|
|
@memcpy(buf[0..cname_reason_prefix.len], cname_reason_prefix);
|
|
@memcpy(buf[cname_reason_prefix.len..][0..tag.len], tag);
|
|
return buf[0 .. cname_reason_prefix.len + tag.len];
|
|
}
|
|
|
|
/// `udp://192.168.1.1:53`, `tcp://[fd00::1]:53` — the same spelling
|
|
/// `validate.parseResolver` accepts, so a log row names the configured value.
|
|
fn resolverText(resolver: validate.Resolver, buf: *[max_resolver_text]u8) []const u8 {
|
|
var w: std.Io.Writer = .fixed(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;
|
|
return w.buffered();
|
|
}
|
|
|
|
/// 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, 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.
|
|
fn build(
|
|
hdr: header.Header,
|
|
q: ?question.Question,
|
|
opt: ?edns.OptRecord,
|
|
rcode: types.Rcode,
|
|
tc: bool,
|
|
buf: []u8,
|
|
) []u8 {
|
|
var b = packet.ResponseBuilder.init(buf, hdr, q) catch unreachable;
|
|
b.setRcode(rcode);
|
|
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.addOptEcho(o, o.do_bit) 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(client: transport.Client) Handler {
|
|
return .{
|
|
.upstream = client,
|
|
.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;
|
|
|
|
/// `query_bytes` plus an OPT record advertising `payload_size`.
|
|
fn queryWithOpt(buf: *[query_with_opt_len]u8, payload_size: u16, do_bit: bool) []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);
|
|
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,
|
|
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,
|
|
) transport.ExchangeError![]u8 {
|
|
_ = io;
|
|
const self: *FakeUpstream = @ptrCast(@alignCast(ptr));
|
|
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 = bare(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 = bare(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 = bare(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 = bare(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 = bare(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 = bare(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 = bare(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 = bare(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 = bare(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 = bare(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 = bare(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 {
|
|
const request = packet.parse(query_bytes) catch unreachable;
|
|
const q = packet.firstQuestion(request).?;
|
|
var b = packet.ResponseBuilder.init(buf, request.header, q) catch unreachable;
|
|
var i: usize = 0;
|
|
while (i < 32) : (i += 1) {
|
|
b.addAnswer(q.name, .a, .in, 300, "\x5d\xb8\xd8\x22") 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 = bare(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 "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 = bare(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 = bare(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 = bare(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 = bare(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 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 = bare(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 = bare(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 = bare(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 = bare(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 = bare(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 = bare(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 = bare(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 = bare(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 = bare(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 = &.{},
|
|
};
|
|
|
|
fn buildSnapshot(gpa: Allocator, fixture: SnapshotFixture) !matcher.Snapshot {
|
|
return matcher.Snapshot.build(gpa, .{
|
|
.groups = fixture.groups,
|
|
.group_ids = fixture.group_ids,
|
|
.group_sources = &.{},
|
|
.sources = &.{},
|
|
.source_ids = &.{},
|
|
.rules = fixture.rules,
|
|
.clients = &.{},
|
|
.prefixes = &.{},
|
|
.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 = .{},
|
|
.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];
|
|
}
|
|
|
|
// --- 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 = bare(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 = bare(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 = bare(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 = bare(fake.client());
|
|
var tables: local_tables_mod.LocalTables = .{ .zones = zones };
|
|
h.local_tables = &tables;
|
|
h.cache = &cache;
|
|
h.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 = bare(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 = bare(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 = bare(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 = bare(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 = bare(fake.client());
|
|
h.cache = &cache;
|
|
h.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 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 = bare(fake.client());
|
|
h.manager = &mgr;
|
|
h.cache = &cache;
|
|
h.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 = bare(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 = bare(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 = bare(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 = bare(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 = bare(fake.client());
|
|
h.ecs_mode = .forward;
|
|
h.cache = &cache;
|
|
h.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 = bare(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 = bare(fake.client());
|
|
h.manager = &mgr;
|
|
h.cache = &cache;
|
|
h.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: clients.Tracker = .init(30);
|
|
|
|
var fake: FakeUpstream = .{ .reply = response_bytes };
|
|
var h = bare(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 -------------------------------------------------------------
|
|
|
|
test "every answered path logs the fields ruling 20 defines" {
|
|
var t: TestIo = .init();
|
|
defer t.deinit();
|
|
const io = t.io();
|
|
|
|
var snapshot = try buildSnapshot(testing.allocator, .{ .rules = &.{blockRule("blocked.example.com")} });
|
|
defer snapshot.deinit();
|
|
var mgr: manager.Manager = undefined;
|
|
fixtureManager(&mgr, &snapshot);
|
|
|
|
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 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 sink: query_sink.QuerySink = .init(&lg, null);
|
|
|
|
var fake: FakeUpstream = .{ .reply = response_bytes };
|
|
var h = bare(fake.client());
|
|
h.manager = &mgr;
|
|
var tables: local_tables_mod.LocalTables = .{ .records = table };
|
|
h.local_tables = &tables;
|
|
h.cache = &cache;
|
|
h.negative_ttl_max = 3600;
|
|
h.sink = &sink;
|
|
|
|
var buf: [udp_limit_min]u8 = undefined;
|
|
var query_buf: [512]u8 = undefined;
|
|
|
|
// Local, blocked, upstream, then the same name again for a cache hit.
|
|
_ = try expectReply(udp(&h, io, queryFor(&query_buf, 1, "nas.lan", .a, .in), &buf));
|
|
var blocked_buf: [512]u8 = undefined;
|
|
_ = try expectReply(udp(&h, io, queryFor(&blocked_buf, 2, "blocked.example.com", .a, .in), &buf));
|
|
_ = try expectReply(udp(&h, io, query_bytes, &buf));
|
|
_ = try expectReply(udp(&h, io, query_bytes, &buf));
|
|
|
|
var entries: [8]logger_mod.Entry = undefined;
|
|
const logged = drainLog(&lg, io, &entries);
|
|
try testing.expectEqual(@as(usize, 4), logged.len);
|
|
|
|
try testing.expectEqualStrings("nas.lan", logged[0].domain());
|
|
try testing.expectEqualStrings("192.168.1.50", logged[0].clientIp());
|
|
try testing.expectEqualStrings("local", logged[0].upstream());
|
|
try testing.expectEqual(@as(?bool, null), logged[0].cache_hit);
|
|
try testing.expectEqual(false, logged[0].blocked);
|
|
try testing.expectEqual(@as(?u16, @intFromEnum(types.Type.a)), logged[0].qtype);
|
|
try testing.expect(logged[0].response_time_us.? >= 0);
|
|
|
|
try testing.expectEqualStrings("blocked.example.com", logged[1].domain());
|
|
try testing.expectEqual(true, logged[1].blocked);
|
|
try testing.expectEqualStrings("rule_block_exact", logged[1].blockReason());
|
|
try testing.expectEqualStrings("", logged[1].upstream());
|
|
try testing.expectEqual(@as(?bool, null), logged[1].cache_hit);
|
|
|
|
try testing.expectEqualStrings("example.com", logged[2].domain());
|
|
try testing.expectEqualStrings("pool", logged[2].upstream());
|
|
try testing.expectEqual(@as(?bool, false), logged[2].cache_hit);
|
|
|
|
try testing.expectEqual(@as(?bool, true), logged[3].cache_hit);
|
|
try testing.expectEqualStrings("", logged[3].upstream());
|
|
}
|
|
|
|
test "an uncloaked block logs the cname-prefixed reason" {
|
|
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 queue_buf: [4]logger_mod.Entry = undefined;
|
|
var lg: logger_mod.Logger = .init(.{}, &queue_buf);
|
|
var sink: query_sink.QuerySink = .init(&lg, null);
|
|
|
|
var fake: FakeUpstream = .{ .reply = chain };
|
|
var h = bare(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);
|
|
try testing.expectEqual(true, logged[0].blocked);
|
|
try testing.expectEqualStrings("cname:rule_block_exact", logged[0].blockReason());
|
|
// The row names the question the client asked, not the target.
|
|
try testing.expectEqualStrings("example.com", logged[0].domain());
|
|
}
|
|
|
|
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 sink: query_sink.QuerySink = .init(&lg, hub);
|
|
|
|
var fake: FakeUpstream = .{ .reply = response_bytes };
|
|
var h = bare(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("pool", 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 sink: query_sink.QuerySink = .init(&lg, null);
|
|
|
|
var fake: FakeUpstream = .{ .reply = response_bytes };
|
|
var h = bare(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 = bare(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));
|
|
}
|
|
|
|
/// A query whose OPT record is too large for the 512-byte build buffer to
|
|
/// rebuild, carrying a subnet behind an RFC 7830 padding option.
|
|
fn paddedEcsQuery(options_buf: *[1024]u8, query_buf: *[1024]u8) []const u8 {
|
|
const padding_len = 600;
|
|
std.mem.writeInt(u16, options_buf[0..2], 12, .big);
|
|
std.mem.writeInt(u16, options_buf[2..4], padding_len, .big);
|
|
@memset(options_buf[4..][0..padding_len], 0);
|
|
|
|
var ecs_buf: [11]u8 = undefined;
|
|
const ecs = ecsOption(&ecs_buf, 1);
|
|
@memcpy(options_buf[4 + padding_len ..][0..ecs.len], ecs);
|
|
return queryWithOptions(query_buf, "example.com", options_buf[0 .. 4 + 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 = bare(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 = bare(fake.client());
|
|
h.cache = &cache;
|
|
h.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 = bare(fake.client());
|
|
h.ecs_mode = .forward;
|
|
h.cache = &cache;
|
|
h.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));
|
|
}
|