//! `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)); } }