349 lines
14 KiB
Zig
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));
|
|
}
|
|
}
|