Gates / frontend (push) Successful in 1m43s
Gates / test (push) Successful in 2m14s
Gates / test-aarch64 (push) Successful in 8m3s
Gates / package (push) Successful in 5m42s
Gates / container (push) Successful in 54s
CI / gates (push) Successful in 50m24s
settings and upstream writes now follow a prepare, commit, publish, retire contract: candidates are built and validated before the database transaction, published as infallible pointer swaps, and old generations retire after their readers drain. per-query policy values snapshot once per query; upstream pool, cache, rate limiter, sessions, api limiter, log sink, blocklist scheduler and the query-log queue each gained one named live operation. restart_required shrinks from every scalar key to the bind keys and web.enabled; the admin ui drops its restart notices for everything else. file mode is unchanged.
715 lines
31 KiB
Zig
715 lines
31 KiB
Zig
//! What every mutation handler shares: the collaborator checks, the database
|
|
//! error mapping, the per-row validation, and the two ways a change is applied
|
|
//! to the running server.
|
|
//!
|
|
//! Three conventions hold across `web/handlers/`:
|
|
//!
|
|
//! - Every repository call in this layer allocates from the per-request arena,
|
|
//! so the repositories' `freeX` helpers are deliberately not called: the
|
|
//! arena is reset when the response is written. Nothing read here outlives
|
|
//! the request.
|
|
//! - A collaborator this layer needs and does not have is a 503, never a crash
|
|
//! and never a silent success. `web.enabled = false` opens no database at
|
|
//! all, and a half-wired `WebState` must fail the same way.
|
|
//! - Domain outcomes are status codes (ruling 8): `error.NotFound` is 404,
|
|
//! `error.Constraint` is 409 with the constraint named in words, a value the
|
|
//! validator rejects is 400, and everything else is a 500 whose cause is
|
|
//! logged at `warn` and never sent to the client (PLAN §19).
|
|
//!
|
|
//! `WebState.config_lock` exists because `std.http.Server` connections are served
|
|
//! concurrently while all of them share one config connection. SQLite is built
|
|
//! in serialized mode, so the connection is safe — but `changes()` and
|
|
//! `lastInsertRowid()` describe *the connection's* last statement, and those
|
|
//! are exactly what `crud.execStrict` and every `insertXRow` read. Two
|
|
//! concurrent writers would read each other's answer. One lock around the
|
|
//! database work of a mutation makes the read-back belong to the writer that
|
|
//! caused it.
|
|
|
|
const std = @import("std");
|
|
const Allocator = std.mem.Allocator;
|
|
|
|
const db = @import("../../storage/db.zig");
|
|
const forward_zones = @import("../../local/forward_zones.zig");
|
|
const local_repo = @import("../../storage/repositories/local_repo.zig");
|
|
const local_records = @import("../../local/records.zig");
|
|
const local_tables = @import("../../server/local_tables.zig");
|
|
const migrations = @import("../../storage/migrations.zig");
|
|
const http_util = @import("../http_util.zig");
|
|
const model = @import("../../config/model.zig");
|
|
const server = @import("../server.zig");
|
|
const settings_repo = @import("../../storage/repositories/settings_repo.zig");
|
|
const validate = @import("../../config/validate.zig");
|
|
|
|
const clients_repo = @import("../../storage/repositories/clients_repo.zig");
|
|
const groups_repo = @import("../../storage/repositories/groups_repo.zig");
|
|
const rules_repo = @import("../../storage/repositories/rules_repo.zig");
|
|
const sources_repo = @import("../../storage/repositories/sources_repo.zig");
|
|
const upstreams_repo = @import("../../storage/repositories/upstreams_repo.zig");
|
|
|
|
const log = std.log.scoped(.web_api);
|
|
|
|
pub const Request = http_util.Request;
|
|
pub const HandlerError = http_util.HandlerError;
|
|
|
|
/// Why a request did not succeed. Every handler in this directory decides in a
|
|
/// function that takes no `std.http.Server.Request`, returns one of these, and
|
|
/// leaves the response to `respondFailure` — so the decision is testable
|
|
/// against an in-memory database, with no socket anywhere.
|
|
pub const Failure = union(enum) {
|
|
/// The id names no row: 404.
|
|
not_found,
|
|
/// A constraint of the schema or of the configuration: 409. The text names
|
|
/// which one, because the client can only fix what it is told.
|
|
conflict: []const u8,
|
|
/// A value the validator refused: 400, with the validator's own text.
|
|
invalid: []const u8,
|
|
/// A collaborator this request needs is not wired: 503.
|
|
unavailable: []const u8,
|
|
/// Anything else the database reported: 500, cause logged, not sent.
|
|
internal: db.Error,
|
|
/// The write landed and the running server could not be told about it.
|
|
/// A 500 that says exactly that, because retrying the write would not help
|
|
/// and reporting success would leave the operator with a stale server.
|
|
not_applied,
|
|
};
|
|
|
|
pub fn respondFailure(request: *Request, failure: Failure, what: []const u8) HandlerError!void {
|
|
return switch (failure) {
|
|
.not_found => http_util.respondError(request, .not_found, "not found"),
|
|
.conflict => |message| http_util.respondError(request, .conflict, message),
|
|
.invalid => |message| http_util.respondError(request, .bad_request, message),
|
|
.unavailable => |message| http_util.respondError(request, .service_unavailable, message),
|
|
.internal => |err| {
|
|
log.warn("{s} failed: {t}", .{ what, err });
|
|
return http_util.respondError(request, .internal_server_error, "internal error");
|
|
},
|
|
.not_applied => http_util.respondError(
|
|
request,
|
|
.internal_server_error,
|
|
"the change was saved but could not be applied; restart nxdns",
|
|
),
|
|
};
|
|
}
|
|
|
|
/// Turns a repository error into a `Failure`. `conflict` names the constraint
|
|
/// that can fire for this statement (W2 documents one per function).
|
|
pub fn dbFailure(err: db.Error, conflict: []const u8) Failure {
|
|
return switch (err) {
|
|
error.NotFound => .not_found,
|
|
error.Constraint => .{ .conflict = conflict },
|
|
else => .{ .internal = err },
|
|
};
|
|
}
|
|
|
|
/// The 503 a state with no config connection earns. One constant, because every
|
|
/// caller reports the same missing collaborator in the same words.
|
|
pub const no_config_db: Failure = .{ .unavailable = "no configuration database" };
|
|
|
|
/// The config connection, or `error.NoConfigDb` for the caller to turn into
|
|
/// `no_config_db` in whatever shape it answers with — a `Failure`, a `Created`,
|
|
/// or a written response.
|
|
pub fn requireConfigDb(state: *server.WebState) error{NoConfigDb}!*db.Db {
|
|
return state.config_db orelse error.NoConfigDb;
|
|
}
|
|
|
|
pub fn nowSeconds(io: std.Io) i64 {
|
|
return std.Io.Clock.real.now(io).toSeconds();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// the identical half of an id-addressed resource
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// The `list`, `get` and `remove` handlers every id-addressed resource in this
|
|
/// directory writes the same way: take the config connection or answer 503, call
|
|
/// one repository function, and turn what comes back into the response. Nothing
|
|
/// a resource decides for itself is here — the create and update bodies, the
|
|
/// constraint texts, and the four reload flavors stay hand-written beside the
|
|
/// descriptor that names these three.
|
|
///
|
|
/// `desc` is an anonymous struct literal rather than a typed struct because
|
|
/// `anytype` is not legal as a struct *field* type and the members are functions
|
|
/// of five signatures. Every member is checked below, so a descriptor that is
|
|
/// missing one or spells one wrong is a compile error that names it.
|
|
///
|
|
/// Members:
|
|
///
|
|
/// - `Row: type` — what the repository returns for one row.
|
|
/// - `list: fn (*db.Db, Allocator) db.Error!std.ArrayList(Row)`.
|
|
/// - `get: fn (*db.Db, Allocator, i64) db.Error!?Row`, or `null` for a resource
|
|
/// with no `/{id}` route.
|
|
/// - `remove: fn (*server.WebState, std.Io, i64) ?Failure`, or the same with an
|
|
/// `Allocator` before the id for a delete decision that reads rows, or `null`.
|
|
/// - `label: []const u8` — "an upstream": what "reading" and "deleting" take as
|
|
/// their object in the log context a 500 carries.
|
|
/// - `plural: []const u8` — "upstreams": what "listing" takes as its object.
|
|
/// - `envelope: []const u8` — the JSON key the list arrives under.
|
|
/// - `view: fn (Row) View` — optional. A resource whose wire shape is not its
|
|
/// row spells the difference here; without it the row is serialised as it is.
|
|
pub fn Resource(comptime desc: anytype) type {
|
|
const Desc = @TypeOf(desc);
|
|
for ([_][]const u8{ "Row", "list", "get", "remove", "label", "plural", "envelope" }) |name| {
|
|
if (!@hasField(Desc, name)) {
|
|
@compileError("resource descriptor has no `" ++ name ++ "`");
|
|
}
|
|
}
|
|
if (@TypeOf(desc.Row) != type) @compileError("resource descriptor `Row` must be a type");
|
|
const Row = desc.Row;
|
|
|
|
expectType("list", @TypeOf(desc.list), fn (*db.Db, Allocator) db.Error!std.ArrayList(Row));
|
|
if (!isNull(@TypeOf(desc.get))) {
|
|
expectType("get", @TypeOf(desc.get), fn (*db.Db, Allocator, i64) db.Error!?Row);
|
|
}
|
|
if (!isNull(@TypeOf(desc.remove))) {
|
|
const Remove = @TypeOf(desc.remove);
|
|
if (removeTakesArena(Remove)) {
|
|
if (removeIsFallible(Remove)) {
|
|
expectType("remove", Remove, fn (*server.WebState, std.Io, Allocator, i64) error{OutOfMemory}!?Failure);
|
|
} else {
|
|
expectType("remove", Remove, fn (*server.WebState, std.Io, Allocator, i64) ?Failure);
|
|
}
|
|
} else {
|
|
expectType("remove", Remove, fn (*server.WebState, std.Io, i64) ?Failure);
|
|
}
|
|
}
|
|
_ = @as([]const u8, desc.label);
|
|
_ = @as([]const u8, desc.plural);
|
|
_ = @as([]const u8, desc.envelope);
|
|
|
|
const has_view = @hasField(Desc, "view");
|
|
if (has_view) {
|
|
const info = @typeInfo(@TypeOf(desc.view)).@"fn";
|
|
if (info.params.len != 1 or info.params[0].type.? != Row) {
|
|
@compileError("resource descriptor `view` must take one " ++ @typeName(Row));
|
|
}
|
|
}
|
|
const View = if (has_view) @typeInfo(@TypeOf(desc.view)).@"fn".return_type.? else Row;
|
|
|
|
const names: [1][:0]const u8 = .{desc.envelope};
|
|
const types: [1]type = .{[]const View};
|
|
const attrs: [1]std.builtin.Type.StructField.Attributes = .{.{}};
|
|
const Envelope = @Struct(.auto, null, &names, &types, &attrs);
|
|
|
|
const list_what = "listing " ++ desc.plural;
|
|
const get_what = "reading " ++ desc.label;
|
|
const remove_what = "deleting " ++ desc.label;
|
|
|
|
return struct {
|
|
pub fn list(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
|
_ = io;
|
|
const database = requireConfigDb(state) catch
|
|
return respondFailure(request, no_config_db, list_what);
|
|
|
|
const rows = desc.list(database, request.arena) catch |err|
|
|
return respondFailure(request, .{ .internal = err }, list_what);
|
|
|
|
const items: []const View = if (has_view) views: {
|
|
const views = try request.arena.alloc(View, rows.items.len);
|
|
for (views, rows.items) |*view, row| view.* = desc.view(row);
|
|
break :views views;
|
|
} else rows.items;
|
|
|
|
var payload: Envelope = undefined;
|
|
@field(payload, desc.envelope) = items;
|
|
return http_util.respondJson(request, .ok, payload, &.{});
|
|
}
|
|
|
|
pub const get = if (isNull(@TypeOf(desc.get))) {} else getRow;
|
|
pub const remove = if (isNull(@TypeOf(desc.remove))) {} else removeRow;
|
|
|
|
fn getRow(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
|
_ = io;
|
|
const database = requireConfigDb(state) catch
|
|
return respondFailure(request, no_config_db, get_what);
|
|
|
|
const row = desc.get(database, request.arena, request.id.?) catch |err|
|
|
return respondFailure(request, .{ .internal = err }, get_what);
|
|
const found = row orelse return respondFailure(request, .not_found, "");
|
|
|
|
const body: View = if (has_view) desc.view(found) else found;
|
|
return http_util.respondJson(request, .ok, body, &.{});
|
|
}
|
|
|
|
fn removeRow(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
|
const failure = if (comptime !removeTakesArena(@TypeOf(desc.remove)))
|
|
desc.remove(state, io, request.id.?)
|
|
else if (comptime removeIsFallible(@TypeOf(desc.remove)))
|
|
try desc.remove(state, io, request.arena, request.id.?)
|
|
else
|
|
desc.remove(state, io, request.arena, request.id.?);
|
|
|
|
if (failure) |value| return respondFailure(request, value, remove_what);
|
|
return http_util.respondEmpty(request, .no_content);
|
|
}
|
|
};
|
|
}
|
|
|
|
fn isNull(comptime T: type) bool {
|
|
return T == @TypeOf(null);
|
|
}
|
|
|
|
fn removeTakesArena(comptime T: type) bool {
|
|
return @typeInfo(T) == .@"fn" and @typeInfo(T).@"fn".params.len == 4;
|
|
}
|
|
|
|
/// A delete decision that builds a candidate for the running server allocates
|
|
/// while it does so, so it may run out of memory. The two shapes are checked
|
|
/// exactly, so a `remove` that returns some other error set is still a compile
|
|
/// error naming what it should have been.
|
|
fn removeIsFallible(comptime T: type) bool {
|
|
return @typeInfo(@typeInfo(T).@"fn".return_type.?) == .error_union;
|
|
}
|
|
|
|
fn expectType(comptime name: []const u8, comptime Actual: type, comptime Expected: type) void {
|
|
if (Actual != Expected) @compileError("resource descriptor `" ++ name ++ "` must be " ++
|
|
@typeName(Expected) ++ ", found " ++ @typeName(Actual));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// applying a change to the running server (ruling 12)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Rebuilds the blocklist snapshot so the change is live on the next query.
|
|
///
|
|
/// A state with no `reload_fn` has nothing to reload — that is the shape of a
|
|
/// web layer under test, and of one whose composition root wired no manager.
|
|
///
|
|
/// Locking contract, and it is the reason every one of the fourteen call sites
|
|
/// in `groups.zig`, `rules.zig`, `clients.zig` and `blocklists.zig` may call
|
|
/// this *after* releasing `state.config_lock`: `reload_fn` must re-read all of
|
|
/// the state it publishes from the database itself, under the manager's own
|
|
/// writer lock. It must never accept rows the caller read. Rows read under
|
|
/// `config_lock` and passed across its release are already stale, so a
|
|
/// signature change that adds a row parameter here silently breaks the
|
|
/// correctness of all fourteen sites — every one of them would have to move
|
|
/// the call back inside the lock.
|
|
///
|
|
/// `swapLocalTables` below is the pre-read shape and is exactly the contrast:
|
|
/// it takes the rows, so `local.zig`'s `publish` calls it while it still holds
|
|
/// `config_lock`, and the ordering rationale lives on that function.
|
|
pub fn reload(state: *server.WebState, io: std.Io) ?Failure {
|
|
const reload_fn = state.reload_fn orelse return null;
|
|
reload_fn(state, io) catch |err| {
|
|
log.warn("applying a configuration change failed: {s}", .{@errorName(err)});
|
|
return .not_applied;
|
|
};
|
|
return null;
|
|
}
|
|
|
|
/// Rebuilds the local records and the forward zones from the database and
|
|
/// publishes both (ruling 12). Local answers therefore change live, without the
|
|
/// blocklist snapshot being rebuilt.
|
|
///
|
|
/// Both tables are built before either is published, so a failure leaves the
|
|
/// running server with the generation it already had.
|
|
pub fn swapLocalTables(
|
|
state: *server.WebState,
|
|
io: std.Io,
|
|
arena: Allocator,
|
|
database: *db.Db,
|
|
) ?Failure {
|
|
const tables = state.local_tables orelse return null;
|
|
const gpa = state.gpa;
|
|
|
|
const record_rows = local_repo.listLocalRecords(database, arena) catch |err|
|
|
return rebuildFailed("reading the local records", @errorName(err));
|
|
|
|
const zone_rows = local_repo.listForwardZones(database, arena) catch |err|
|
|
return rebuildFailed("reading the forward zones", @errorName(err));
|
|
|
|
var built_records = local_records.Records.build(gpa, record_rows.items) catch |err|
|
|
return rebuildFailed("building the local records", @errorName(err));
|
|
errdefer built_records.deinit(gpa);
|
|
|
|
const built_zones = forward_zones.Zones.build(gpa, zone_rows.items) catch |err|
|
|
return rebuildFailed("building the forward zones", @errorName(err));
|
|
|
|
tables.swap(io, gpa, built_records, built_zones);
|
|
return null;
|
|
}
|
|
|
|
fn rebuildFailed(what: []const u8, cause: []const u8) Failure {
|
|
log.warn("{s} after a change failed: {s}", .{ what, cause });
|
|
return .not_applied;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// per-row validation
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// `config/validate.zig` validates a whole configuration and is not this
|
|
// session's to split, so a candidate row is checked by handing the real
|
|
// validator a configuration that holds the skeleton it insists on (one default
|
|
// group, one upstream) plus the one row under test. The row's own rules —
|
|
// domain syntax, record values, rule patterns, CIDR prefixes, source urls, TTL
|
|
// ranges — are then exactly the shipped ones, with no second copy to drift.
|
|
//
|
|
// Cross-row facts are deliberately NOT checked here: a duplicate is the
|
|
// database's UNIQUE constraint and answers 409 (ruling 9), and a group that
|
|
// does not exist is a foreign key and answers 409 too. Reporting either as a
|
|
// 400 would be a second, weaker opinion about the same fact.
|
|
|
|
const skeleton_group = "default";
|
|
const skeleton_upstream: model.UpstreamServer = .{ .url = "https://dns.example/dns-query" };
|
|
|
|
/// Runs the shipped validator over `cfg` and returns the first failure's text,
|
|
/// or null when the candidate is valid. The text is arena-allocated.
|
|
///
|
|
/// Failures only: a warning describes a configuration that is legal, and a row
|
|
/// this API is about to store cannot be rejected for one. The blocklist source
|
|
/// a POST creates is in no group yet — a warning by design, and never a 400.
|
|
pub fn firstProblem(arena: Allocator, cfg: model.Config) error{OutOfMemory}!?[]const u8 {
|
|
var diags: validate.Diagnostics = .init(arena);
|
|
defer diags.deinit();
|
|
|
|
validate.validate(cfg, &diags) catch |err| switch (err) {
|
|
error.OutOfMemory => return error.OutOfMemory,
|
|
else => {},
|
|
};
|
|
const problem = diags.firstFailure() orelse return null;
|
|
return try std.fmt.allocPrint(arena, "{s}: {s}", .{ problem.path, problem.message });
|
|
}
|
|
|
|
/// The configuration skeleton every candidate is validated inside.
|
|
fn skeleton(groups: []const model.Group) model.Config {
|
|
return .{
|
|
.upstreams = &.{skeleton_upstream},
|
|
.groups = groups,
|
|
};
|
|
}
|
|
|
|
const default_groups = [_]model.Group{.{ .name = skeleton_group }};
|
|
|
|
pub fn checkLocalRecord(arena: Allocator, record: model.LocalRecord) error{OutOfMemory}!?[]const u8 {
|
|
var cfg = skeleton(&default_groups);
|
|
cfg.local_records = &.{record};
|
|
return firstProblem(arena, cfg);
|
|
}
|
|
|
|
pub fn checkForwardZone(arena: Allocator, zone: model.ForwardZone) error{OutOfMemory}!?[]const u8 {
|
|
var cfg = skeleton(&default_groups);
|
|
cfg.forward_zones = &.{zone};
|
|
return firstProblem(arena, cfg);
|
|
}
|
|
|
|
pub fn checkRule(arena: Allocator, pattern: []const u8, kind: model.RuleKind) error{OutOfMemory}!?[]const u8 {
|
|
var cfg = skeleton(&default_groups);
|
|
cfg.rules = &.{.{ .group = skeleton_group, .pattern = pattern, .kind = kind, .action = .block }};
|
|
return firstProblem(arena, cfg);
|
|
}
|
|
|
|
pub fn checkSource(arena: Allocator, source: model.BlocklistSource) error{OutOfMemory}!?[]const u8 {
|
|
var cfg = skeleton(&default_groups);
|
|
cfg.blocklist_sources = &.{source};
|
|
return firstProblem(arena, cfg);
|
|
}
|
|
|
|
pub fn checkClientPrefix(arena: Allocator, prefix: []const u8, priority: i32) error{OutOfMemory}!?[]const u8 {
|
|
var cfg = skeleton(&default_groups);
|
|
cfg.client_prefixes = &.{.{ .prefix = prefix, .group = skeleton_group, .priority = priority }};
|
|
return firstProblem(arena, cfg);
|
|
}
|
|
|
|
/// A group name is checked inside a configuration that already holds the
|
|
/// default group, so a candidate named anything else is still complete.
|
|
pub fn checkGroupName(arena: Allocator, name: []const u8) error{OutOfMemory}!?[]const u8 {
|
|
if (std.mem.eql(u8, name, skeleton_group)) return firstProblem(arena, skeleton(&default_groups));
|
|
const groups = [_]model.Group{ .{ .name = skeleton_group }, .{ .name = name } };
|
|
return firstProblem(arena, skeleton(&groups));
|
|
}
|
|
|
|
/// An upstream candidate is validated next to one known-good enabled upstream,
|
|
/// so a disabled candidate does not trip the whole-config rule that at least
|
|
/// one upstream must be enabled — whether the stored set satisfies that rule is
|
|
/// the handler's own guard, not this row check's. The companion's url moves out
|
|
/// of the way of a candidate that holds the skeleton url, because a duplicate
|
|
/// is the database's answer, not the validator's.
|
|
pub fn checkUpstream(arena: Allocator, upstream: model.UpstreamServer) error{OutOfMemory}!?[]const u8 {
|
|
const companion: model.UpstreamServer = if (std.mem.eql(u8, upstream.url, skeleton_upstream.url))
|
|
.{ .url = "https://dns-b.example/dns-query" }
|
|
else
|
|
skeleton_upstream;
|
|
var cfg = skeleton(&default_groups);
|
|
cfg.upstreams = &.{ upstream, companion };
|
|
return firstProblem(arena, cfg);
|
|
}
|
|
|
|
/// The 400 a malformed or unparseable body earns.
|
|
pub fn respondBadBody(request: *Request, err: anyerror) HandlerError!void {
|
|
return switch (err) {
|
|
error.TooLarge => http_util.respondError(request, .payload_too_large, "request body too large"),
|
|
error.OutOfMemory => error.OutOfMemory,
|
|
error.WriteFailed => error.WriteFailed,
|
|
error.HttpExpectationFailed => error.HttpExpectationFailed,
|
|
// A vanished peer mid-body is the same event as a vanished peer
|
|
// mid-response, and ends the connection the same way (ruling 28).
|
|
error.ReadFailed => error.WriteFailed,
|
|
else => http_util.respondError(request, .bad_request, "malformed request body"),
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// reading the stored configuration
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Every settings row and every collection, as one `model.Config`. The settings
|
|
/// PUT validates against this (ruling 16), so the check sees the same
|
|
/// configuration the next start would.
|
|
///
|
|
/// Every string belongs to `arena`.
|
|
pub fn loadConfig(arena: Allocator, database: *db.Db) db.Error!model.Config {
|
|
var cfg: model.Config = .{};
|
|
|
|
const pairs = try settings_repo.listSettings(database, arena);
|
|
var unknown: usize = 0;
|
|
model.fromSettings(pairs.items, &cfg, &unknown) catch |err| switch (err) {
|
|
error.OutOfMemory => return error.OutOfMemory,
|
|
// A stored value this build cannot decode is a corrupt row, not a
|
|
// client error: the caller reports 500 and the operator sees the log.
|
|
error.BadSettingValue => return error.Mismatch,
|
|
};
|
|
|
|
const groups = try groups_repo.listGroups(database, arena);
|
|
cfg.groups = groups.items;
|
|
const upstreams = try upstreams_repo.listUpstreams(database, arena);
|
|
cfg.upstreams = upstreams.items;
|
|
const clients = try clients_repo.listClients(database, arena);
|
|
cfg.clients = clients.items;
|
|
const prefixes = try clients_repo.listClientPrefixes(database, arena);
|
|
cfg.client_prefixes = prefixes.items;
|
|
const sources = try sources_repo.listBlocklistSources(database, arena);
|
|
cfg.blocklist_sources = sources.items;
|
|
const group_sources = try groups_repo.listGroupSources(database, arena);
|
|
cfg.group_sources = group_sources.items;
|
|
const rules = try rules_repo.listRules(database, arena);
|
|
cfg.rules = rules.items;
|
|
const records = try local_repo.listLocalRecords(database, arena);
|
|
cfg.local_records = records.items;
|
|
const zones = try local_repo.listForwardZones(database, arena);
|
|
cfg.forward_zones = zones.items;
|
|
|
|
return cfg;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// the test bench every handler in this directory shares
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// A running web layer with no sockets in it: an in-memory config database at
|
|
/// the current schema, the local-table holder, a request arena, and a
|
|
/// `reload_fn` that counts instead of rebuilding a snapshot.
|
|
///
|
|
/// `state` is a field rather than a pointer so the reload seam can find the
|
|
/// bench through `@fieldParentPtr` — a `WebState` carries no user data, and a
|
|
/// global counter would make two tests in one binary share it.
|
|
///
|
|
/// Referenced only by this directory's tests; nothing in a shipped build calls
|
|
/// `init`, so it costs nothing there.
|
|
pub const Bench = struct {
|
|
threaded: std.Io.Threaded,
|
|
database: db.Db,
|
|
tables: local_tables.LocalTables,
|
|
arena_state: std.heap.ArenaAllocator,
|
|
state: server.WebState,
|
|
reloads: usize,
|
|
reload_fails: bool,
|
|
|
|
/// Initialises in place: `state` points at fields of `self`.
|
|
pub fn init(self: *Bench, gpa: Allocator) !void {
|
|
self.threaded = .init(gpa, .{});
|
|
errdefer self.threaded.deinit();
|
|
|
|
self.database = try db.Db.open(":memory:", .{ .mode = .memory });
|
|
errdefer self.database.close();
|
|
try db.applyPragmas(&self.database, .{});
|
|
_ = try migrations.migrate(&self.database);
|
|
|
|
self.tables = .empty;
|
|
self.arena_state = .init(gpa);
|
|
self.reloads = 0;
|
|
self.reload_fails = false;
|
|
self.state = .{
|
|
.gpa = gpa,
|
|
.config_db = &self.database,
|
|
.local_tables = &self.tables,
|
|
.reload_fn = countingReload,
|
|
};
|
|
}
|
|
|
|
pub fn deinit(self: *Bench, gpa: Allocator) void {
|
|
self.state.live_hash.deinit(gpa);
|
|
self.tables.deinit(gpa);
|
|
self.arena_state.deinit();
|
|
self.database.close();
|
|
self.threaded.deinit();
|
|
}
|
|
|
|
pub fn io(self: *Bench) std.Io {
|
|
return self.threaded.io();
|
|
}
|
|
|
|
pub fn arena(self: *Bench) Allocator {
|
|
return self.arena_state.allocator();
|
|
}
|
|
|
|
/// One statement of setup, for the rows a case needs before it starts.
|
|
pub fn exec(self: *Bench, sql: [:0]const u8) !void {
|
|
try self.database.exec(sql);
|
|
}
|
|
|
|
pub fn queryInt(self: *Bench, sql: []const u8) !i64 {
|
|
return self.database.queryInt(sql);
|
|
}
|
|
|
|
fn countingReload(state: *server.WebState, io_unused: std.Io) anyerror!void {
|
|
_ = io_unused;
|
|
const self: *Bench = @alignCast(@fieldParentPtr("state", state));
|
|
self.reloads += 1;
|
|
if (self.reload_fails) return error.ReloadFailed;
|
|
}
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const testing = std.testing;
|
|
|
|
fn arenaFor(state: *std.heap.ArenaAllocator) Allocator {
|
|
return state.allocator();
|
|
}
|
|
|
|
test "the bench wires a state whose reload seam counts" {
|
|
var bench: Bench = undefined;
|
|
try bench.init(testing.allocator);
|
|
defer bench.deinit(testing.allocator);
|
|
|
|
try testing.expect(bench.state.config_db != null);
|
|
try testing.expectEqual(@as(?Failure, null), reload(&bench.state, bench.io()));
|
|
try testing.expectEqual(@as(usize, 1), bench.reloads);
|
|
|
|
bench.reload_fails = true;
|
|
try testing.expectEqual(Failure.not_applied, reload(&bench.state, bench.io()).?);
|
|
try testing.expectEqual(@as(usize, 2), bench.reloads);
|
|
}
|
|
|
|
test "the schema the bench opens already holds the default group" {
|
|
var bench: Bench = undefined;
|
|
try bench.init(testing.allocator);
|
|
defer bench.deinit(testing.allocator);
|
|
|
|
try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT id FROM groups WHERE name = 'default'"));
|
|
}
|
|
|
|
test "a state with no configuration database is a 503, not a crash" {
|
|
var bench: Bench = undefined;
|
|
try bench.init(testing.allocator);
|
|
defer bench.deinit(testing.allocator);
|
|
|
|
try testing.expectEqual(&bench.database, try requireConfigDb(&bench.state));
|
|
|
|
var bare: server.WebState = .{ .gpa = testing.allocator };
|
|
try testing.expectError(error.NoConfigDb, requireConfigDb(&bare));
|
|
try testing.expectEqualStrings("no configuration database", no_config_db.unavailable);
|
|
}
|
|
|
|
test "a database error maps to the status its cause deserves" {
|
|
try testing.expectEqual(Failure.not_found, dbFailure(error.NotFound, "x"));
|
|
try testing.expectEqualStrings("taken", dbFailure(error.Constraint, "taken").conflict);
|
|
try testing.expectEqual(db.Error.Busy, dbFailure(error.Busy, "x").internal);
|
|
}
|
|
|
|
test "a valid candidate row reports no problem" {
|
|
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
|
defer arena_state.deinit();
|
|
const arena = arenaFor(&arena_state);
|
|
|
|
try testing.expectEqual(
|
|
@as(?[]const u8, null),
|
|
try checkLocalRecord(arena, .{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.10", .ttl = 60 }),
|
|
);
|
|
try testing.expectEqual(
|
|
@as(?[]const u8, null),
|
|
try checkForwardZone(arena, .{ .zone = "lan", .resolver = "udp://10.0.0.1:53" }),
|
|
);
|
|
try testing.expectEqual(@as(?[]const u8, null), try checkRule(arena, "*.ads.example", .wildcard));
|
|
try testing.expectEqual(@as(?[]const u8, null), try checkClientPrefix(arena, "192.168.1.0/24", 100));
|
|
try testing.expectEqual(@as(?[]const u8, null), try checkGroupName(arena, "kids"));
|
|
try testing.expectEqual(@as(?[]const u8, null), try checkGroupName(arena, "default"));
|
|
try testing.expectEqual(
|
|
@as(?[]const u8, null),
|
|
try checkSource(arena, .{ .url = "https://example.test/list.txt", .name = "list" }),
|
|
);
|
|
try testing.expectEqual(
|
|
@as(?[]const u8, null),
|
|
try checkUpstream(arena, .{ .url = "tls://1.1.1.1:853", .tls_name = "one.one.one.one" }),
|
|
);
|
|
}
|
|
|
|
test "a disabled upstream candidate is valid on its own merits" {
|
|
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
|
defer arena_state.deinit();
|
|
const arena = arenaFor(&arena_state);
|
|
|
|
try testing.expectEqual(
|
|
@as(?[]const u8, null),
|
|
try checkUpstream(arena, .{ .url = "https://dns.other/dns-query", .enabled = false }),
|
|
);
|
|
// The skeleton's own url must not read as a duplicate of the companion.
|
|
try testing.expectEqual(
|
|
@as(?[]const u8, null),
|
|
try checkUpstream(arena, .{ .url = skeleton_upstream.url, .enabled = false }),
|
|
);
|
|
// A disabled row's other fields are still judged.
|
|
const bad = try checkUpstream(arena, .{ .url = "udp://1.1.1.1:53", .enabled = false });
|
|
try testing.expect(bad != null);
|
|
}
|
|
|
|
test "an invalid candidate row names the field that failed" {
|
|
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
|
defer arena_state.deinit();
|
|
const arena = arenaFor(&arena_state);
|
|
|
|
const bad_value = try checkLocalRecord(
|
|
arena,
|
|
.{ .name = "nas.lan", .rtype = .a, .value = "not-an-ip", .ttl = 60 },
|
|
);
|
|
try testing.expect(bad_value != null);
|
|
try testing.expect(std.mem.startsWith(u8, bad_value.?, "local_records[0].value:"));
|
|
|
|
const bad_ttl = try checkLocalRecord(
|
|
arena,
|
|
.{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.10", .ttl = 0 },
|
|
);
|
|
try testing.expect(bad_ttl != null);
|
|
|
|
const bad_resolver = try checkForwardZone(arena, .{ .zone = "lan", .resolver = "http://10.0.0.1" });
|
|
try testing.expect(bad_resolver != null);
|
|
|
|
const bad_pattern = try checkRule(arena, "ads.*.example", .exact);
|
|
try testing.expect(bad_pattern != null);
|
|
|
|
const bad_prefix = try checkClientPrefix(arena, "192.168.1.0", 100);
|
|
try testing.expect(bad_prefix != null);
|
|
|
|
const empty_group = try checkGroupName(arena, "");
|
|
try testing.expect(empty_group != null);
|
|
|
|
const bad_source = try checkSource(arena, .{ .url = "ftp://example.test/list", .name = "list" });
|
|
try testing.expect(bad_source != null);
|
|
}
|
|
|
|
test "a candidate is judged alone, so a duplicate is left to the database" {
|
|
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
|
defer arena_state.deinit();
|
|
const arena = arenaFor(&arena_state);
|
|
|
|
// The same zone twice would be `DuplicateForwardZone` in a whole config;
|
|
// one candidate row cannot collide with itself, and the UNIQUE constraint
|
|
// is what answers 409.
|
|
try testing.expectEqual(
|
|
@as(?[]const u8, null),
|
|
try checkForwardZone(arena, .{ .zone = "lan", .resolver = "udp://10.0.0.1:53" }),
|
|
);
|
|
}
|