Files
nxdns/src/web/handlers/lookup.zig
T

349 lines
14 KiB
Zig

//! `GET /api/lookup?domain=&group_id=` — what the pipeline would do with a name
//! (ruling 14).
//!
//! The answer is assembled from the same three sources a query reads, in the
//! same order PLAN §6 gives them: the local records, the forward zones, then
//! the filter snapshot. Nothing is re-implemented here; a divergence between
//! this endpoint and a real query would make the tool that explains blocking
//! the one thing an operator cannot trust.
//!
//! `evaluate` is pure, so the whole decision table is testable against a
//! hand-built snapshot. The handler adds the two things that need the outside
//! world: the snapshot and local-table handles, and the source row that turns a
//! source index into the URL an operator recognises.
const std = @import("std");
const Allocator = std.mem.Allocator;
const forward_zones = @import("../../local/forward_zones.zig");
const http_util = @import("../http_util.zig");
const matcher = @import("../../filter/matcher.zig");
const name_mod = @import("../../dns/name.zig");
const records_mod = @import("../../local/records.zig");
const safesearch = @import("../../filter/safesearch.zig");
const server = @import("../server.zig");
const sources_repo = @import("../../storage/repositories/sources_repo.zig");
const types = @import("../../dns/types.zig");
const log = std.log.scoped(.web_lookup);
pub const Body = struct {
domain: []const u8,
/// The `groups` row id the decision was made for, not the snapshot index.
group_id: i64,
local_records: bool,
/// The matching zone, or null when no zone claims the name.
forward_zone: ?[]const u8,
blocked: bool,
reason: []const u8,
/// The rule or list entry that decided it; "" when nothing matched.
matched: []const u8,
/// The list that decided it, which for `blocklist_exception` is the list
/// whose `@@` rule lifted the block rather than one that made it.
source_url: ?[]const u8,
safe_search_rewrite: ?[]const u8,
};
/// The pure part: everything but the source URL, which is a database read.
pub const Result = struct {
group_id: i64,
local_records: bool,
forward_zone: ?[]const u8,
blocked: bool,
reason: matcher.Reason,
matched: []const u8,
/// `blocklist_sources` row id of the list that matched, whether it blocked
/// the name or lifted it through an `@@` exception.
source_id: ?i64,
safe_search_rewrite: ?[]const u8,
};
/// `domain` must already be normalized. `group` is an index into
/// `snapshot.groups`.
pub fn evaluate(
snapshot: *const matcher.Snapshot,
group: u32,
domain: []const u8,
records: *const records_mod.Records,
zones: *const forward_zones.Zones,
) Result {
const decision = snapshot.evaluate(group, domain);
const source_id: ?i64 = if (decision.source) |index| snapshot.sources[index].id else null;
return .{
.group_id = snapshot.groups[group].id,
.local_records = records.hasName(domain),
.forward_zone = if (zones.match(domain)) |zone| zone.zone else null,
.blocked = decision.blocked,
.reason = decision.reason,
.matched = decision.matched,
.source_id = source_id,
.safe_search_rewrite = if (snapshot.safeSearch(group)) safesearch.lookup(domain) else null,
};
}
pub fn body(domain: []const u8, result: Result, source_url: ?[]const u8) Body {
return .{
.domain = domain,
.group_id = result.group_id,
.local_records = result.local_records,
.forward_zone = result.forward_zone,
.blocked = result.blocked,
.reason = @tagName(result.reason),
.matched = result.matched,
.source_url = source_url,
.safe_search_rewrite = result.safe_search_rewrite,
};
}
pub fn handle(
state: *server.WebState,
io: std.Io,
request: *http_util.Request,
) http_util.HandlerError!void {
var raw: [types.max_name_len]u8 = undefined;
const found = http_util.queryValue(request.query, "domain", &raw) catch
return http_util.respondError(request, .bad_request, "domain is not a valid name");
const text = found orelse
return http_util.respondError(request, .bad_request, "domain is required");
if (text.len == 0) return http_util.respondError(request, .bad_request, "domain is required");
// Ruling 29: the same normalization the query path applies, so the answer
// is about the name the pipeline would actually see.
var normalized_buf: [types.max_name_len]u8 = undefined;
const parsed = name_mod.fromText(text) catch
return http_util.respondError(request, .bad_request, "domain is not a valid name");
const domain = matcher.normalize(parsed, &normalized_buf);
if (domain.len == 0) return http_util.respondError(request, .bad_request, "domain is not a valid name");
const requested_group = http_util.queryInt(i64, request.query, "group_id") catch
return http_util.respondError(request, .bad_request, "group_id must be a row id");
const manager = state.manager orelse
return http_util.respondError(request, .service_unavailable, "no snapshot loaded");
const acquired = manager.acquire(io) orelse
return http_util.respondError(request, .service_unavailable, "no snapshot loaded");
defer acquired.release(io);
const snapshot = acquired.snapshot;
const group = if (requested_group) |id|
snapshot.groupIndexById(id) orelse
return http_util.respondError(request, .bad_request, "unknown group_id")
else
snapshot.default_group;
const result = if (state.handler) |handler| local: {
// The local tables are published like the snapshot is, so the reader
// brackets its lookups the same way (ruling 12).
if (handler.local_tables) |tables| {
const held = tables.acquire(io);
defer held.release(io);
break :local evaluate(snapshot, group, domain, held.records, held.zones);
}
break :local evaluate(snapshot, group, domain, &empty_records, &empty_zones);
} else evaluate(snapshot, group, domain, &empty_records, &empty_zones);
return http_util.respondJson(request, .ok, body(domain, result, sourceUrl(state, request.arena, result)), &.{});
}
const empty_records: records_mod.Records = .empty;
const empty_zones: forward_zones.Zones = .empty;
/// The blocking list's URL, when there is one to read. A source row that cannot
/// be read leaves the field null rather than failing the lookup: the decision
/// is the answer, and the URL is a label on it.
fn sourceUrl(state: *server.WebState, arena: Allocator, result: Result) ?[]const u8 {
const id = result.source_id orelse return null;
const database = state.config_db orelse return null;
const row = sources_repo.getSource(database, arena, id) catch |err| {
log.warn("lookup could not read source {d}: {s}", .{ id, @errorName(err) });
return null;
};
return if (row) |found| found.url else null;
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const model = @import("../../config/model.zig");
const testing = std.testing;
const group_ids = [_]i64{ 10, 20 };
/// `Snapshot.Input` has no defaults on purpose — the compiler is what stops the
/// manager from forgetting a table. A test that cares about one table would
/// still have to spell out the other eight, so they are spelled out once here.
const Fixture = struct {
groups: []const model.Group,
group_ids: []const i64,
group_sources: []const model.GroupSource = &.{},
sources: []const model.BlocklistSource = &.{},
source_ids: []const i64 = &.{},
rules: []const model.Rule = &.{},
compiled: []const ?matcher.Snapshot.Compiled = &.{},
};
fn buildSnapshot(fixture: Fixture) !matcher.Snapshot {
return matcher.Snapshot.build(testing.allocator, .{
.groups = fixture.groups,
.group_ids = fixture.group_ids,
.group_sources = fixture.group_sources,
.sources = fixture.sources,
.source_ids = fixture.source_ids,
.rules = fixture.rules,
.clients = &.{},
.prefixes = &.{},
.compiled = fixture.compiled,
.seed = 1,
.generation = 1,
});
}
test "a name nothing matches is allowed, with no reason and no source" {
const groups = [_]model.Group{.{ .name = "default" }};
var snapshot = try buildSnapshot(.{ .groups = &groups, .group_ids = group_ids[0..1] });
defer snapshot.deinit();
const result = evaluate(&snapshot, snapshot.default_group, "example.com", &empty_records, &empty_zones);
try testing.expectEqual(@as(i64, 10), result.group_id);
try testing.expect(!result.blocked);
try testing.expectEqual(matcher.Reason.none, result.reason);
try testing.expectEqualStrings("", result.matched);
try testing.expectEqual(@as(?i64, null), result.source_id);
try testing.expectEqual(@as(?[]const u8, null), result.forward_zone);
try testing.expect(!result.local_records);
try testing.expectEqual(@as(?[]const u8, null), result.safe_search_rewrite);
}
test "a blocking rule names itself and the pattern that matched" {
const groups = [_]model.Group{.{ .name = "default" }};
const rules = [_]model.Rule{
.{ .group = "default", .pattern = "ads.example", .kind = .exact, .action = .block },
};
var snapshot = try buildSnapshot(.{
.groups = &groups,
.group_ids = group_ids[0..1],
.rules = &rules,
});
defer snapshot.deinit();
const result = evaluate(&snapshot, 0, "ads.example", &empty_records, &empty_zones);
try testing.expect(result.blocked);
try testing.expectEqual(matcher.Reason.rule_block_exact, result.reason);
try testing.expectEqualStrings("ads.example", result.matched);
const rendered = body("ads.example", result, "https://lists.test/a");
try testing.expectEqualStrings("rule_block_exact", rendered.reason);
try testing.expectEqualStrings("https://lists.test/a", rendered.source_url.?);
}
test "a blocklist hit carries the source row id the URL is read from" {
const groups = [_]model.Group{.{ .name = "default" }};
const sources = [_]model.BlocklistSource{.{ .url = "https://lists.test/a", .name = "list a" }};
const group_sources = [_]model.GroupSource{
.{ .group = "default", .source_url = "https://lists.test/a" },
};
var snapshot = try buildSnapshot(.{
.groups = &groups,
.group_ids = group_ids[0..1],
.group_sources = &group_sources,
.sources = &sources,
.source_ids = &.{77},
.compiled = &.{.{ .list_body = "blocked.example\n", .wild_body = "" }},
});
defer snapshot.deinit();
const result = evaluate(&snapshot, 0, "blocked.example", &empty_records, &empty_zones);
try testing.expect(result.blocked);
try testing.expectEqual(matcher.Reason.blocklist_domain, result.reason);
try testing.expectEqual(@as(?i64, 77), result.source_id);
}
test "local records and forward zones are reported beside the decision" {
const groups = [_]model.Group{.{ .name = "default" }};
var snapshot = try buildSnapshot(.{ .groups = &groups, .group_ids = group_ids[0..1] });
defer snapshot.deinit();
var records = try records_mod.Records.build(testing.allocator, &.{
.{ .name = "nas.lan.home", .rtype = .a, .value = "192.168.1.10" },
});
defer records.deinit(testing.allocator);
var zones = try forward_zones.Zones.build(testing.allocator, &.{
.{ .zone = "lan.home", .resolver = "udp://192.168.1.1:53" },
});
defer zones.deinit(testing.allocator);
const local = evaluate(&snapshot, 0, "nas.lan.home", &records, &zones);
try testing.expect(local.local_records);
try testing.expectEqualStrings("lan.home", local.forward_zone.?);
const zone_only = evaluate(&snapshot, 0, "printer.lan.home", &records, &zones);
try testing.expect(!zone_only.local_records);
try testing.expectEqualStrings("lan.home", zone_only.forward_zone.?);
const neither = evaluate(&snapshot, 0, "example.com", &records, &zones);
try testing.expect(!neither.local_records);
try testing.expectEqual(@as(?[]const u8, null), neither.forward_zone);
}
test "safe search is reported only for a group that has it on" {
const groups = [_]model.Group{
.{ .name = "default" },
.{ .name = "kids", .safe_search = true },
};
var snapshot = try buildSnapshot(.{ .groups = &groups, .group_ids = &group_ids });
defer snapshot.deinit();
const off = evaluate(&snapshot, 0, "www.google.com", &empty_records, &empty_zones);
try testing.expectEqual(@as(?[]const u8, null), off.safe_search_rewrite);
const on = evaluate(&snapshot, 1, "www.google.com", &empty_records, &empty_zones);
try testing.expectEqualStrings(safesearch.lookup("www.google.com").?, on.safe_search_rewrite.?);
try testing.expectEqual(@as(i64, 20), on.group_id);
// A name safe search says nothing about stays null even in that group.
const unrelated = evaluate(&snapshot, 1, "example.com", &empty_records, &empty_zones);
try testing.expectEqual(@as(?[]const u8, null), unrelated.safe_search_rewrite);
}
test "a requested group is resolved by row id, not by index" {
const groups = [_]model.Group{
.{ .name = "default" },
.{ .name = "kids", .safe_search = true },
};
var snapshot = try buildSnapshot(.{ .groups = &groups, .group_ids = &group_ids });
defer snapshot.deinit();
try testing.expectEqual(@as(?u32, 1), snapshot.groupIndexById(20));
try testing.expectEqual(@as(?u32, null), snapshot.groupIndexById(999));
}
test "the body serializes with the fields ruling 14 names" {
const groups = [_]model.Group{.{ .name = "default" }};
var snapshot = try buildSnapshot(.{ .groups = &groups, .group_ids = group_ids[0..1] });
defer snapshot.deinit();
const result = evaluate(&snapshot, 0, "example.com", &empty_records, &empty_zones);
var allocating: std.Io.Writer.Allocating = .init(testing.allocator);
defer allocating.deinit();
try std.json.Stringify.value(body("example.com", result, null), .{}, &allocating.writer);
const text = allocating.written();
for ([_][]const u8{
"\"domain\":\"example.com\"",
"\"group_id\":10",
"\"local_records\":false",
"\"forward_zone\":null",
"\"blocked\":false",
"\"reason\":\"none\"",
"\"matched\":\"\"",
"\"source_url\":null",
"\"safe_search_rewrite\":null",
}) |fragment| {
try testing.expect(std.mem.containsAtLeast(u8, text, 1, fragment));
}
}