milestone 8: web server, rest api, sse, auth, metrics and static assets
This commit is contained in:
@@ -0,0 +1,382 @@
|
||||
//! `/api/auth/login` and `/api/auth/logout` (rulings 17 and 18).
|
||||
//!
|
||||
//! Login is the one route that is always reachable without a session, and the
|
||||
//! one that must never help a guess along: a wrong password and an unknown one
|
||||
//! are the same 401, and the only thing this file logs is the client address
|
||||
//! and the outcome. No password, no hash, no token and no cookie value is ever
|
||||
//! formatted anywhere here (ruling 29).
|
||||
//!
|
||||
//! A hash this build cannot read is a 500, not a 401. Answering 401 would tell
|
||||
//! an operator with a corrupted `web.password_hash` that their password is
|
||||
//! wrong, and they would go on retyping a password that can never verify.
|
||||
//!
|
||||
//! With no password set, authentication is off and every route is already open,
|
||||
//! so a login attempt succeeds without minting anything: the answer says
|
||||
//! `auth_required: false` and carries no cookie, because a session that
|
||||
//! authorises nothing would be a lie the browser stores.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const auth = @import("../auth.zig");
|
||||
const http_util = @import("../http_util.zig");
|
||||
const model = @import("../../config/model.zig");
|
||||
const mutations = @import("mutations.zig");
|
||||
const server = @import("../server.zig");
|
||||
|
||||
const Failure = mutations.Failure;
|
||||
const Request = http_util.Request;
|
||||
const HandlerError = http_util.HandlerError;
|
||||
|
||||
const log = std.log.scoped(.web_auth);
|
||||
|
||||
const LoginBody = struct {
|
||||
password: []const u8,
|
||||
};
|
||||
|
||||
/// Long enough for the cookie plus its attributes and a `Max-Age`.
|
||||
const cookie_buf_len = 192;
|
||||
|
||||
pub const Login = union(enum) {
|
||||
/// A session was minted; the value is the cookie to set.
|
||||
cookie: auth.Cookie,
|
||||
/// No password is configured, so there is nothing to log in to.
|
||||
no_auth,
|
||||
fail: Failure,
|
||||
};
|
||||
|
||||
/// Verifies and, on success, mints a session (ruling 17).
|
||||
pub fn applyLogin(state: *server.WebState, io: std.Io, password: []const u8) Login {
|
||||
// The live hash, never `state.web.password_hash`: a settings PUT may have
|
||||
// replaced the password since boot, and the revoked one must stop minting
|
||||
// sessions immediately. An unreadable stored hash is a 500, not a 401,
|
||||
// for the same reason a broken PHC string is.
|
||||
var hash_buf: [auth.LiveHash.max_len]u8 = undefined;
|
||||
const snapshot = state.live_hash.copy(io, &hash_buf) catch
|
||||
return .{ .fail = .{ .internal = error.Unexpected } };
|
||||
if (snapshot.hash.len == 0) return .no_auth;
|
||||
|
||||
const sessions = state.sessions orelse
|
||||
return .{ .fail = .{ .unavailable = "no session store" } };
|
||||
|
||||
const outcome = auth.verifyPassword(io, state.gpa, snapshot.hash, password) catch
|
||||
return .{ .fail = .{ .unavailable = "shutting down" } };
|
||||
|
||||
switch (outcome) {
|
||||
.denied => return .{ .fail = .{ .invalid = "invalid password" } },
|
||||
.unavailable => return .{ .fail = .{ .internal = error.Unexpected } },
|
||||
.ok => {},
|
||||
}
|
||||
|
||||
return confirmLogin(state, io, sessions, snapshot.generation);
|
||||
}
|
||||
|
||||
/// The step after a successful verification, separated so a test can install
|
||||
/// a replacement hash between verify and confirm. Verification ran against a
|
||||
/// copy, outside any lock: a settings PUT may have installed a new hash and
|
||||
/// cleared every session in the meantime, and minting for the old hash then
|
||||
/// would hand the revoked password a live session. `confirmSession` answers
|
||||
/// null exactly in that case, and the login is denied the same way a wrong
|
||||
/// password is — the operator retries with the password that now applies.
|
||||
fn confirmLogin(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
sessions: *auth.Sessions,
|
||||
generation: u64,
|
||||
) Login {
|
||||
const cookie = state.live_hash.confirmSession(io, sessions, generation) catch
|
||||
return .{ .fail = .{ .internal = error.Unexpected } };
|
||||
if (cookie) |value| return .{ .cookie = value };
|
||||
return .{ .fail = .{ .invalid = "invalid password" } };
|
||||
}
|
||||
|
||||
/// Ends the session the cookie names. An unknown cookie is not an error: the
|
||||
/// point of logging out is to end up logged out, which is where it already is.
|
||||
pub fn applyLogout(state: *server.WebState, io: std.Io, cookie_header: []const u8) bool {
|
||||
const sessions = state.sessions orelse return false;
|
||||
const value = http_util.cookieValue(cookie_header, auth.cookie_name) orelse return false;
|
||||
return sessions.logout(io, value);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// routes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn login(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
const parsed = http_util.parseBody(LoginBody, request) catch |err|
|
||||
return mutations.respondBadBody(request, err);
|
||||
|
||||
switch (applyLogin(state, io, parsed.value.password)) {
|
||||
.no_auth => {
|
||||
return http_util.respondJson(request, .ok, .{
|
||||
.authenticated = true,
|
||||
.auth_required = false,
|
||||
}, &.{});
|
||||
},
|
||||
.fail => |failure| {
|
||||
// Ruling 18: a refused login is a 401, not the 400 an invalid value
|
||||
// would earn elsewhere. Only the address and the outcome are logged.
|
||||
if (failure == .invalid) {
|
||||
log.warn("web login refused for {f}", .{request.peer});
|
||||
return http_util.respondError(request, .unauthorized, "invalid password");
|
||||
}
|
||||
return mutations.respondFailure(request, failure, "verifying the web password");
|
||||
},
|
||||
.cookie => |cookie| {
|
||||
log.info("web login accepted for {f}", .{request.peer});
|
||||
var buf: [cookie_buf_len]u8 = undefined;
|
||||
const header = http_util.formatSetCookie(
|
||||
&buf,
|
||||
auth.cookie_name,
|
||||
&cookie,
|
||||
model.sessionTtlSeconds(state.web),
|
||||
) catch return error.OutOfMemory;
|
||||
return http_util.respondJson(request, .ok, .{
|
||||
.authenticated = true,
|
||||
.auth_required = true,
|
||||
}, &.{.{ .name = "set-cookie", .value = header }});
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn logout(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
_ = applyLogout(state, io, request.cookie);
|
||||
|
||||
var buf: [cookie_buf_len]u8 = undefined;
|
||||
const header = http_util.formatSetCookie(&buf, auth.cookie_name, "", 0) catch
|
||||
return error.OutOfMemory;
|
||||
|
||||
return http_util.respondJson(
|
||||
request,
|
||||
.ok,
|
||||
.{ .authenticated = false },
|
||||
&.{.{ .name = "set-cookie", .value = header }},
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
/// Hashes `password` the way `PUT /api/settings` does, so the login tests
|
||||
/// verify against a hash this build actually produced.
|
||||
fn hashOf(io: std.Io, buf: []u8, password: []const u8) ![]const u8 {
|
||||
return std.crypto.pwhash.argon2.strHash(password, .{
|
||||
.allocator = testing.allocator,
|
||||
.params = .owasp_2id,
|
||||
.mode = .argon2id,
|
||||
.encoding = .phc,
|
||||
}, buf, io);
|
||||
}
|
||||
|
||||
test "with no password set, a login succeeds without minting a session" {
|
||||
var t: TestIo = .init();
|
||||
defer t.deinit();
|
||||
|
||||
var sessions: auth.Sessions = .init(24);
|
||||
var state: server.WebState = .{ .gpa = testing.allocator, .sessions = &sessions };
|
||||
|
||||
try testing.expectEqual(Login.no_auth, applyLogin(&state, t.io(), "anything"));
|
||||
try testing.expectEqual(@as(u32, 0), sessions.count(t.io(), 0));
|
||||
}
|
||||
|
||||
test "the right password mints a session the cookie then validates" {
|
||||
var t: TestIo = .init();
|
||||
defer t.deinit();
|
||||
|
||||
var buf: [256]u8 = undefined;
|
||||
const hash = try hashOf(t.io(), &buf, "hunter2");
|
||||
|
||||
var sessions: auth.Sessions = .init(24);
|
||||
var state: server.WebState = .{
|
||||
.gpa = testing.allocator,
|
||||
.live_hash = .init(hash),
|
||||
.sessions = &sessions,
|
||||
};
|
||||
|
||||
const outcome = applyLogin(&state, t.io(), "hunter2");
|
||||
try testing.expect(sessions.validate(t.io(), &outcome.cookie));
|
||||
}
|
||||
|
||||
test "the wrong password is refused and mints nothing" {
|
||||
var t: TestIo = .init();
|
||||
defer t.deinit();
|
||||
|
||||
var buf: [256]u8 = undefined;
|
||||
const hash = try hashOf(t.io(), &buf, "hunter2");
|
||||
|
||||
var sessions: auth.Sessions = .init(24);
|
||||
var state: server.WebState = .{
|
||||
.gpa = testing.allocator,
|
||||
.live_hash = .init(hash),
|
||||
.sessions = &sessions,
|
||||
};
|
||||
|
||||
const outcome = applyLogin(&state, t.io(), "hunter3");
|
||||
try testing.expect(outcome.fail == .invalid);
|
||||
try testing.expectEqual(@as(u32, 0), sessions.count(t.io(), 0));
|
||||
|
||||
// An empty password is refused without reaching argon2 at all.
|
||||
try testing.expect(applyLogin(&state, t.io(), "").fail == .invalid);
|
||||
}
|
||||
|
||||
test "a password change between verify and confirm denies the login" {
|
||||
var t: TestIo = .init();
|
||||
defer t.deinit();
|
||||
const gpa = testing.allocator;
|
||||
|
||||
var buf: [256]u8 = undefined;
|
||||
const hash = try hashOf(t.io(), &buf, "hunter2");
|
||||
|
||||
var sessions: auth.Sessions = .init(24);
|
||||
var state: server.WebState = .{
|
||||
.gpa = gpa,
|
||||
.live_hash = .init(hash),
|
||||
.sessions = &sessions,
|
||||
};
|
||||
defer state.live_hash.deinit(gpa);
|
||||
|
||||
// The login path up to and including verification, as applyLogin runs it.
|
||||
var hash_buf: [auth.LiveHash.max_len]u8 = undefined;
|
||||
const snapshot = try state.live_hash.copy(t.io(), &hash_buf);
|
||||
try testing.expectEqual(
|
||||
auth.Outcome.ok,
|
||||
try auth.verifyPassword(t.io(), gpa, snapshot.hash, "hunter2"),
|
||||
);
|
||||
|
||||
// A settings PUT lands while argon2 was grinding: new hash in, every
|
||||
// session out, one operation.
|
||||
state.live_hash.installAndRevoke(
|
||||
t.io(),
|
||||
gpa,
|
||||
&sessions,
|
||||
try gpa.dupe(u8, "$argon2id$v=19$m=19456,t=2,p=1$a$b"),
|
||||
);
|
||||
|
||||
// The confirm step must not mint from the revoked password.
|
||||
const outcome = confirmLogin(&state, t.io(), &sessions, snapshot.generation);
|
||||
try testing.expect(outcome.fail == .invalid);
|
||||
try testing.expectEqual(@as(u32, 0), sessions.count(t.io(), 0));
|
||||
}
|
||||
|
||||
test "a login confirmed before the password transition does not survive it" {
|
||||
var t: TestIo = .init();
|
||||
defer t.deinit();
|
||||
const gpa = testing.allocator;
|
||||
|
||||
var buf: [256]u8 = undefined;
|
||||
const hash = try hashOf(t.io(), &buf, "hunter2");
|
||||
|
||||
var sessions: auth.Sessions = .init(24);
|
||||
var state: server.WebState = .{
|
||||
.gpa = gpa,
|
||||
.live_hash = .init(hash),
|
||||
.sessions = &sessions,
|
||||
};
|
||||
defer state.live_hash.deinit(gpa);
|
||||
|
||||
var hash_buf: [auth.LiveHash.max_len]u8 = undefined;
|
||||
const snapshot = try state.live_hash.copy(t.io(), &hash_buf);
|
||||
const outcome = confirmLogin(&state, t.io(), &sessions, snapshot.generation);
|
||||
try testing.expect(sessions.validate(t.io(), &outcome.cookie));
|
||||
|
||||
// The settings PUT lands after the confirm: the revocation nested in the
|
||||
// transition ends the session it just minted.
|
||||
state.live_hash.installAndRevoke(
|
||||
t.io(),
|
||||
gpa,
|
||||
&sessions,
|
||||
try gpa.dupe(u8, "$argon2id$v=19$m=19456,t=2,p=1$a$b"),
|
||||
);
|
||||
try testing.expect(!sessions.validate(t.io(), &outcome.cookie));
|
||||
try testing.expectEqual(@as(u32, 0), sessions.count(t.io(), 0));
|
||||
}
|
||||
|
||||
test "confirmLogin mints when no install intervened" {
|
||||
var t: TestIo = .init();
|
||||
defer t.deinit();
|
||||
|
||||
var buf: [256]u8 = undefined;
|
||||
const hash = try hashOf(t.io(), &buf, "hunter2");
|
||||
|
||||
var sessions: auth.Sessions = .init(24);
|
||||
var state: server.WebState = .{
|
||||
.gpa = testing.allocator,
|
||||
.live_hash = .init(hash),
|
||||
.sessions = &sessions,
|
||||
};
|
||||
|
||||
var hash_buf: [auth.LiveHash.max_len]u8 = undefined;
|
||||
const snapshot = try state.live_hash.copy(t.io(), &hash_buf);
|
||||
const outcome = confirmLogin(&state, t.io(), &sessions, snapshot.generation);
|
||||
try testing.expect(sessions.validate(t.io(), &outcome.cookie));
|
||||
}
|
||||
|
||||
test "a hash this build cannot read is a 500, not a refusal" {
|
||||
var t: TestIo = .init();
|
||||
defer t.deinit();
|
||||
|
||||
var sessions: auth.Sessions = .init(24);
|
||||
var state: server.WebState = .{
|
||||
.gpa = testing.allocator,
|
||||
.live_hash = .init("$argon2id$not a phc string"),
|
||||
.sessions = &sessions,
|
||||
};
|
||||
|
||||
const outcome = applyLogin(&state, t.io(), "hunter2");
|
||||
try testing.expectEqual(auth.Outcome.unavailable, try auth.verifyPassword(
|
||||
t.io(),
|
||||
testing.allocator,
|
||||
"$argon2id$not a phc string",
|
||||
"hunter2",
|
||||
));
|
||||
try testing.expect(outcome.fail == .internal);
|
||||
}
|
||||
|
||||
test "a password set with no session store refuses rather than opens" {
|
||||
var t: TestIo = .init();
|
||||
defer t.deinit();
|
||||
|
||||
var state: server.WebState = .{
|
||||
.gpa = testing.allocator,
|
||||
.live_hash = .init("$argon2id$v=19$m=19456,t=2,p=1$a$b"),
|
||||
};
|
||||
|
||||
try testing.expect(applyLogin(&state, t.io(), "hunter2").fail == .unavailable);
|
||||
}
|
||||
|
||||
test "logging out ends the session the cookie names" {
|
||||
var t: TestIo = .init();
|
||||
defer t.deinit();
|
||||
|
||||
var sessions: auth.Sessions = .init(24);
|
||||
var state: server.WebState = .{ .gpa = testing.allocator, .sessions = &sessions };
|
||||
|
||||
const cookie = sessions.createWithToken(t.io(), @splat(3), 1_000);
|
||||
var header_buf: [128]u8 = undefined;
|
||||
const header = try std.fmt.bufPrint(&header_buf, "{s}={s}", .{ auth.cookie_name, &cookie });
|
||||
|
||||
try testing.expect(applyLogout(&state, t.io(), header));
|
||||
try testing.expect(!sessions.validateAt(t.io(), &cookie, 1_001));
|
||||
|
||||
// Logging out twice, or with no cookie at all, is not an error.
|
||||
try testing.expect(!applyLogout(&state, t.io(), header));
|
||||
try testing.expect(!applyLogout(&state, t.io(), ""));
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
//! `/api/blocklists` — the blocklist sources table, and the manual refresh.
|
||||
//!
|
||||
//! The resource is `blocklist_sources`: its four configuration columns are what
|
||||
//! an operator edits, and the counters the refresh writes ride along in the
|
||||
//! read shape so the UI can show a list's size next to its url (ruling 9).
|
||||
//!
|
||||
//! `POST /api/blocklists/update` runs `Manager.refreshAll` and then the reload
|
||||
//! seam, and answers 202 with the status of every source (ruling 12). The
|
||||
//! refresh downloads and compiles before the response is written: 202 is
|
||||
//! "accepted and done as far as this connection is concerned", and the status
|
||||
//! table in the body is what tells the operator which sources actually landed.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const http_util = @import("../http_util.zig");
|
||||
const manager_mod = @import("../../filter/manager.zig");
|
||||
const model = @import("../../config/model.zig");
|
||||
const mutations = @import("mutations.zig");
|
||||
const server = @import("../server.zig");
|
||||
const sources_repo = @import("../../storage/repositories/sources_repo.zig");
|
||||
|
||||
const Failure = mutations.Failure;
|
||||
const Request = http_util.Request;
|
||||
const HandlerError = http_util.HandlerError;
|
||||
|
||||
const log = std.log.scoped(.web_api);
|
||||
|
||||
const url_conflict = "a blocklist with that url already exists";
|
||||
|
||||
/// How many source statuses one refresh response carries. A household runs a
|
||||
/// handful of lists; a table longer than this is truncated in the response
|
||||
/// only, never in the refresh.
|
||||
pub const max_statuses = 64;
|
||||
|
||||
const Body = struct {
|
||||
url: []const u8,
|
||||
name: []const u8,
|
||||
enabled: bool = true,
|
||||
is_suggested: bool = false,
|
||||
};
|
||||
|
||||
const Created = union(enum) { id: i64, fail: Failure };
|
||||
|
||||
/// One source's status, in the shape the API speaks: the fixed-size text fields
|
||||
/// of `manager.SourceStatus` become plain strings, and the compile counts are
|
||||
/// flattened next to them.
|
||||
pub const StatusView = struct {
|
||||
id: i64,
|
||||
state: []const u8,
|
||||
loaded: bool,
|
||||
last_attempt: i64,
|
||||
last_success: i64,
|
||||
url: []const u8,
|
||||
last_error: []const u8,
|
||||
domains: u32,
|
||||
wildcards: u32,
|
||||
skipped_regex: u32,
|
||||
|
||||
pub fn from(status: *const manager_mod.SourceStatus) StatusView {
|
||||
return .{
|
||||
.id = status.id,
|
||||
.state = @tagName(status.state),
|
||||
.loaded = status.loaded,
|
||||
.last_attempt = status.last_attempt,
|
||||
.last_success = status.last_success,
|
||||
.url = status.urlText(),
|
||||
.last_error = status.errorText(),
|
||||
.domains = status.counts.domains,
|
||||
.wildcards = status.counts.wildcards,
|
||||
.skipped_regex = status.counts.skipped_regex,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// decisions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn applyCreate(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
arena: Allocator,
|
||||
item: model.BlocklistSource,
|
||||
) error{OutOfMemory}!Created {
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return .{ .fail = failure },
|
||||
};
|
||||
if (try mutations.checkSource(arena, item)) |problem| return .{ .fail = .{ .invalid = problem } };
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
const inserted = sources_repo.insertSourceRow(database, item);
|
||||
state.config_lock.unlock(io);
|
||||
|
||||
const id = inserted catch |err| return .{ .fail = mutations.dbFailure(err, url_conflict) };
|
||||
if (mutations.reload(state, io)) |failure| return .{ .fail = failure };
|
||||
return .{ .id = id };
|
||||
}
|
||||
|
||||
pub fn applyUpdate(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
arena: Allocator,
|
||||
id: i64,
|
||||
item: model.BlocklistSource,
|
||||
) error{OutOfMemory}!?Failure {
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return failure,
|
||||
};
|
||||
if (try mutations.checkSource(arena, item)) |problem| return .{ .invalid = problem };
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
const written = sources_repo.updateSource(database, id, item);
|
||||
state.config_lock.unlock(io);
|
||||
|
||||
written catch |err| return mutations.dbFailure(err, url_conflict);
|
||||
return mutations.reload(state, io);
|
||||
}
|
||||
|
||||
pub fn applyDelete(state: *server.WebState, io: std.Io, id: i64) ?Failure {
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return failure,
|
||||
};
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
const written = sources_repo.deleteSource(database, id);
|
||||
state.config_lock.unlock(io);
|
||||
|
||||
written catch |err| return mutations.dbFailure(err, url_conflict);
|
||||
return mutations.reload(state, io);
|
||||
}
|
||||
|
||||
/// Refreshes every enabled source, then applies the result (ruling 12).
|
||||
///
|
||||
/// `refreshAll` already ends in the manager's own reload; the seam is called
|
||||
/// too, because it is how the composition root learns that a change landed and
|
||||
/// the only reload a test can observe.
|
||||
pub fn applyRefresh(state: *server.WebState, io: std.Io, out: []manager_mod.SourceStatus) union(enum) {
|
||||
statuses: usize,
|
||||
fail: Failure,
|
||||
} {
|
||||
const manager = state.manager orelse return .{ .fail = .{ .unavailable = "no blocklist manager" } };
|
||||
|
||||
manager.refreshAll(io) catch |err| switch (err) {
|
||||
error.Canceled => return .{ .fail = .{ .unavailable = "shutting down" } },
|
||||
error.OutOfMemory => return .{ .fail = .{ .internal = error.OutOfMemory } },
|
||||
// A source that fails to fetch or compile records that in the status
|
||||
// table and returns cleanly, so reaching here means the pass itself
|
||||
// broke. `Manager.Error` is wider than `db.Error`, so the cause is
|
||||
// logged here and the client is told only that it was internal.
|
||||
else => {
|
||||
log.warn("refreshing the blocklists failed: {s}", .{@errorName(err)});
|
||||
return .{ .fail = .{ .internal = error.Unexpected } };
|
||||
},
|
||||
};
|
||||
if (mutations.reload(state, io)) |failure| return .{ .fail = failure };
|
||||
return .{ .statuses = manager.statusSnapshot(io, out) };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// routes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn list(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
_ = io;
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return mutations.respondFailure(request, failure, "listing blocklists"),
|
||||
};
|
||||
|
||||
const rows = sources_repo.listSourceRows(database, request.arena) catch |err|
|
||||
return mutations.respondFailure(request, .{ .internal = err }, "listing blocklists");
|
||||
|
||||
return http_util.respondJson(request, .ok, .{ .blocklists = rows.items }, &.{});
|
||||
}
|
||||
|
||||
pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
_ = io;
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return mutations.respondFailure(request, failure, "reading a blocklist"),
|
||||
};
|
||||
|
||||
const row = sources_repo.getSource(database, request.arena, request.id.?) catch |err|
|
||||
return mutations.respondFailure(request, .{ .internal = err }, "reading a blocklist");
|
||||
const found = row orelse return mutations.respondFailure(request, .not_found, "");
|
||||
|
||||
return http_util.respondJson(request, .ok, found, &.{});
|
||||
}
|
||||
|
||||
pub fn create(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
const parsed = http_util.parseBody(Body, request) catch |err|
|
||||
return mutations.respondBadBody(request, err);
|
||||
const item = toModel(parsed.value);
|
||||
|
||||
return switch (try applyCreate(state, io, request.arena, item)) {
|
||||
.fail => |failure| mutations.respondFailure(request, failure, "creating a blocklist"),
|
||||
.id => |id| http_util.respondJson(request, .created, .{
|
||||
.id = id,
|
||||
.url = item.url,
|
||||
.name = item.name,
|
||||
.enabled = item.enabled,
|
||||
.is_suggested = item.is_suggested,
|
||||
}, &.{}),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn update(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
const parsed = http_util.parseBody(Body, request) catch |err|
|
||||
return mutations.respondBadBody(request, err);
|
||||
const item = toModel(parsed.value);
|
||||
const id = request.id.?;
|
||||
|
||||
if (try applyUpdate(state, io, request.arena, id, item)) |failure| {
|
||||
return mutations.respondFailure(request, failure, "updating a blocklist");
|
||||
}
|
||||
return http_util.respondJson(request, .ok, .{
|
||||
.id = id,
|
||||
.url = item.url,
|
||||
.name = item.name,
|
||||
.enabled = item.enabled,
|
||||
.is_suggested = item.is_suggested,
|
||||
}, &.{});
|
||||
}
|
||||
|
||||
pub fn remove(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
if (applyDelete(state, io, request.id.?)) |failure| {
|
||||
return mutations.respondFailure(request, failure, "deleting a blocklist");
|
||||
}
|
||||
return http_util.respondEmpty(request, .no_content);
|
||||
}
|
||||
|
||||
/// `POST /api/blocklists/update`.
|
||||
pub fn refresh(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
const statuses = try request.arena.alloc(manager_mod.SourceStatus, max_statuses);
|
||||
|
||||
return switch (applyRefresh(state, io, statuses)) {
|
||||
.fail => |failure| mutations.respondFailure(request, failure, "refreshing the blocklists"),
|
||||
.statuses => |count| respondStatuses(request, statuses[0..count]),
|
||||
};
|
||||
}
|
||||
|
||||
fn respondStatuses(request: *Request, statuses: []const manager_mod.SourceStatus) HandlerError!void {
|
||||
const views = try request.arena.alloc(StatusView, statuses.len);
|
||||
for (views, statuses) |*view, *status| view.* = .from(status);
|
||||
return http_util.respondJson(request, .accepted, .{ .sources = views }, &.{});
|
||||
}
|
||||
|
||||
fn toModel(body: Body) model.BlocklistSource {
|
||||
return .{
|
||||
.url = body.url,
|
||||
.name = body.name,
|
||||
.enabled = body.enabled,
|
||||
.is_suggested = body.is_suggested,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
const valid: model.BlocklistSource = .{ .url = "https://a.test/list.txt", .name = "a" };
|
||||
|
||||
test "a created blocklist is stored with its runtime columns at their defaults" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
const created = try applyCreate(&bench.state, bench.io(), bench.arena(), valid);
|
||||
try testing.expectEqual(@as(usize, 1), bench.reloads);
|
||||
|
||||
const row = (try sources_repo.getSource(&bench.database, bench.arena(), created.id)).?;
|
||||
try testing.expectEqualStrings("https://a.test/list.txt", row.url);
|
||||
try testing.expect(row.enabled);
|
||||
try testing.expectEqual(@as(?i64, null), row.last_updated);
|
||||
try testing.expectEqual(@as(i64, 0), row.domain_count);
|
||||
}
|
||||
|
||||
test "a url the validator refuses never reaches the database" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
const created = try applyCreate(&bench.state, bench.io(), bench.arena(), .{
|
||||
.url = "ftp://a.test/list.txt",
|
||||
.name = "a",
|
||||
});
|
||||
try testing.expect(created.fail == .invalid);
|
||||
try testing.expectEqual(@as(i64, 0), try bench.queryInt("SELECT count(*) FROM blocklist_sources"));
|
||||
|
||||
const unnamed = try applyCreate(&bench.state, bench.io(), bench.arena(), .{
|
||||
.url = "https://a.test/list.txt",
|
||||
.name = "",
|
||||
});
|
||||
try testing.expect(unnamed.fail == .invalid);
|
||||
try testing.expectEqual(@as(usize, 0), bench.reloads);
|
||||
}
|
||||
|
||||
test "a duplicate url is a conflict" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
_ = try applyCreate(&bench.state, bench.io(), bench.arena(), valid);
|
||||
const again = try applyCreate(&bench.state, bench.io(), bench.arena(), valid);
|
||||
try testing.expectEqualStrings(url_conflict, again.fail.conflict);
|
||||
}
|
||||
|
||||
test "editing a blocklist keeps the counters the refresh wrote" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
const created = try applyCreate(&bench.state, bench.io(), bench.arena(), valid);
|
||||
try sources_repo.updateSourceStats(&bench.database, created.id, .{
|
||||
.last_updated = 1700,
|
||||
.domain_count = 42,
|
||||
.wildcard_count = 3,
|
||||
.skipped_regex_count = 1,
|
||||
.checksum = "abc",
|
||||
});
|
||||
|
||||
const failure = try applyUpdate(&bench.state, bench.io(), bench.arena(), created.id, .{
|
||||
.url = "https://a.test/list.txt",
|
||||
.name = "renamed",
|
||||
.enabled = false,
|
||||
});
|
||||
try testing.expectEqual(@as(?Failure, null), failure);
|
||||
|
||||
const row = (try sources_repo.getSource(&bench.database, bench.arena(), created.id)).?;
|
||||
try testing.expectEqualStrings("renamed", row.name);
|
||||
try testing.expect(!row.enabled);
|
||||
try testing.expectEqual(@as(i64, 42), row.domain_count);
|
||||
try testing.expectEqual(@as(usize, 2), bench.reloads);
|
||||
}
|
||||
|
||||
test "updating and deleting an id no row holds is a 404" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
try testing.expectEqual(
|
||||
Failure.not_found,
|
||||
(try applyUpdate(&bench.state, bench.io(), bench.arena(), 999, valid)).?,
|
||||
);
|
||||
try testing.expectEqual(Failure.not_found, applyDelete(&bench.state, bench.io(), 999).?);
|
||||
try testing.expectEqual(@as(usize, 0), bench.reloads);
|
||||
}
|
||||
|
||||
test "deleting a blocklist takes its group assignments with it" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
const created = try applyCreate(&bench.state, bench.io(), bench.arena(), valid);
|
||||
try bench.exec("INSERT INTO group_sources (group_id, source_id) VALUES (1, 1);");
|
||||
|
||||
try testing.expectEqual(@as(?Failure, null), applyDelete(&bench.state, bench.io(), created.id));
|
||||
try testing.expectEqual(@as(i64, 0), try bench.queryInt("SELECT count(*) FROM group_sources"));
|
||||
try testing.expectEqual(@as(usize, 2), bench.reloads);
|
||||
}
|
||||
|
||||
test "a refresh with no manager is unavailable rather than a silent success" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
var statuses: [4]manager_mod.SourceStatus = undefined;
|
||||
const outcome = applyRefresh(&bench.state, bench.io(), &statuses);
|
||||
try testing.expect(outcome.fail == .unavailable);
|
||||
try testing.expectEqual(@as(usize, 0), bench.reloads);
|
||||
}
|
||||
|
||||
test "a status becomes the flat shape the API answers with" {
|
||||
var status: manager_mod.SourceStatus = .{ .id = 7, .state = .fetch_failed, .loaded = true };
|
||||
const url = "https://a.test/list.txt";
|
||||
@memcpy(status.url[0..url.len], url);
|
||||
status.url_len = url.len;
|
||||
const message = "connection refused";
|
||||
@memcpy(status.last_error[0..message.len], message);
|
||||
status.last_error_len = message.len;
|
||||
status.counts = .{ .domains = 10, .wildcards = 2, .skipped_regex = 1 };
|
||||
|
||||
const view: StatusView = .from(&status);
|
||||
try testing.expectEqual(@as(i64, 7), view.id);
|
||||
try testing.expectEqualStrings("fetch_failed", view.state);
|
||||
try testing.expect(view.loaded);
|
||||
try testing.expectEqualStrings(url, view.url);
|
||||
try testing.expectEqualStrings(message, view.last_error);
|
||||
try testing.expectEqual(@as(u32, 10), view.domains);
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
//! `/api/clients` and `/api/client-prefixes` — which device belongs to which
|
||||
//! group.
|
||||
//!
|
||||
//! Clients have no POST (ruling 9): a row appears because the DNS path saw the
|
||||
//! address or because an import wrote it. What the API adds is an edit — a name
|
||||
//! and a group — and an edit is what turns a materialised row into
|
||||
//! configuration, so every PUT sets `hand_edited` and the stale-client prune
|
||||
//! stops considering the row (W2's `ClientEdit`).
|
||||
//!
|
||||
//! `ip` is not editable. It is the identity `upsertSeen` matches a live device
|
||||
//! by; rewriting it would collide with the row the tracker re-materialises for
|
||||
//! the device that still holds the address. A DELETE is how an operator forgets
|
||||
//! a device, and a device that keeps querying comes back materialised.
|
||||
//!
|
||||
//! Client prefixes are one small list resource, replaced whole and atomically
|
||||
//! (ruling 9): the table is a handful of rows and a partial update of an
|
||||
//! ordered, priority-carrying set is more ways to be wrong than to be right.
|
||||
//! Each prefix is stored in canonical text (dotted decimal, RFC 5952, host
|
||||
//! bits zeroed), so two spellings of one network collide in the API instead
|
||||
//! of surviving as an ambiguous pair the next restart's validation rejects.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const address = @import("../../platform/address.zig");
|
||||
const clients_repo = @import("../../storage/repositories/clients_repo.zig");
|
||||
const http_util = @import("../http_util.zig");
|
||||
const model = @import("../../config/model.zig");
|
||||
const mutations = @import("mutations.zig");
|
||||
const server = @import("../server.zig");
|
||||
|
||||
const Failure = mutations.Failure;
|
||||
const Request = http_util.Request;
|
||||
const HandlerError = http_util.HandlerError;
|
||||
|
||||
const group_conflict = "that group does not exist";
|
||||
const prefix_conflict = "that prefix is listed twice, or names a group that does not exist";
|
||||
|
||||
const ClientBody = struct {
|
||||
name: []const u8 = "",
|
||||
group_id: i64,
|
||||
};
|
||||
|
||||
const PrefixItem = struct {
|
||||
prefix: []const u8,
|
||||
group_id: i64,
|
||||
priority: i32 = 100,
|
||||
};
|
||||
|
||||
const PrefixesBody = struct {
|
||||
client_prefixes: []const PrefixItem,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// decisions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn applyUpdate(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
id: i64,
|
||||
edit: clients_repo.ClientEdit,
|
||||
) ?Failure {
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return failure,
|
||||
};
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
const written = clients_repo.updateClient(database, id, edit);
|
||||
state.config_lock.unlock(io);
|
||||
|
||||
written catch |err| return mutations.dbFailure(err, group_conflict);
|
||||
return mutations.reload(state, io);
|
||||
}
|
||||
|
||||
pub fn applyDelete(state: *server.WebState, io: std.Io, id: i64) ?Failure {
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return failure,
|
||||
};
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
const written = clients_repo.deleteClient(database, id);
|
||||
state.config_lock.unlock(io);
|
||||
|
||||
written catch |err| return mutations.dbFailure(err, group_conflict);
|
||||
return mutations.reload(state, io);
|
||||
}
|
||||
|
||||
pub fn applyReplacePrefixes(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
arena: Allocator,
|
||||
items: []const clients_repo.ClientPrefixInput,
|
||||
) error{OutOfMemory}!?Failure {
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return failure,
|
||||
};
|
||||
|
||||
// Canonical duplicates are the same UNIQUE collision the database would
|
||||
// report for identical text, so they answer 409 (ruling 9) before the
|
||||
// validator can call the second spelling a 400. Unparseable text stays
|
||||
// out of the set; the validator names it below.
|
||||
const stored = try arena.alloc(clients_repo.ClientPrefixInput, items.len);
|
||||
var seen: std.StringHashMapUnmanaged(void) = .empty;
|
||||
for (stored, items) |*out, item| {
|
||||
out.* = item;
|
||||
const parsed = address.Prefix.parse(item.prefix) catch continue;
|
||||
out.prefix = try canonicalText(arena, parsed);
|
||||
const entry = try seen.getOrPut(arena, out.prefix);
|
||||
if (entry.found_existing) return .{ .conflict = prefix_conflict };
|
||||
}
|
||||
|
||||
if (try checkPrefixSet(arena, stored)) |problem| return .{ .invalid = problem };
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
const written = clients_repo.replaceClientPrefixes(database, stored);
|
||||
state.config_lock.unlock(io);
|
||||
|
||||
written catch |err| return mutations.dbFailure(err, prefix_conflict);
|
||||
return mutations.reload(state, io);
|
||||
}
|
||||
|
||||
fn canonicalText(arena: Allocator, prefix: address.Prefix) error{OutOfMemory}![]u8 {
|
||||
// The longest form this writes is an IPv6 prefix, 45 + 4 bytes.
|
||||
var buf: [64]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
prefix.format(&w) catch unreachable;
|
||||
return arena.dupe(u8, w.buffered());
|
||||
}
|
||||
|
||||
/// The whole candidate list through the real validator, inside the same
|
||||
/// skeleton `mutations.checkClientPrefix` uses — group ids cannot be mapped
|
||||
/// to names here, so every row wears the skeleton group and the foreign key
|
||||
/// still answers for ids that name no group.
|
||||
fn checkPrefixSet(
|
||||
arena: Allocator,
|
||||
items: []const clients_repo.ClientPrefixInput,
|
||||
) error{OutOfMemory}!?[]const u8 {
|
||||
const rows = try arena.alloc(model.ClientPrefix, items.len);
|
||||
for (rows, items) |*row, item| row.* = .{ .prefix = item.prefix, .priority = item.priority };
|
||||
return mutations.firstProblem(arena, .{
|
||||
.upstreams = &.{.{ .url = "https://dns.example/dns-query" }},
|
||||
.groups = &.{.{ .name = "default" }},
|
||||
.client_prefixes = rows,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// routes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn list(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
_ = io;
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return mutations.respondFailure(request, failure, "listing clients"),
|
||||
};
|
||||
|
||||
const rows = clients_repo.listClientRows(database, request.arena) catch |err|
|
||||
return mutations.respondFailure(request, .{ .internal = err }, "listing clients");
|
||||
|
||||
return http_util.respondJson(request, .ok, .{ .clients = rows.items }, &.{});
|
||||
}
|
||||
|
||||
pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
_ = io;
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return mutations.respondFailure(request, failure, "reading a client"),
|
||||
};
|
||||
|
||||
const row = clients_repo.getClient(database, request.arena, request.id.?) catch |err|
|
||||
return mutations.respondFailure(request, .{ .internal = err }, "reading a client");
|
||||
const found = row orelse return mutations.respondFailure(request, .not_found, "");
|
||||
|
||||
return http_util.respondJson(request, .ok, found, &.{});
|
||||
}
|
||||
|
||||
pub fn update(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
const parsed = http_util.parseBody(ClientBody, request) catch |err|
|
||||
return mutations.respondBadBody(request, err);
|
||||
const id = request.id.?;
|
||||
|
||||
if (applyUpdate(state, io, id, .{
|
||||
.name = parsed.value.name,
|
||||
.group_id = parsed.value.group_id,
|
||||
})) |failure| {
|
||||
return mutations.respondFailure(request, failure, "updating a client");
|
||||
}
|
||||
|
||||
const database = state.config_db.?;
|
||||
const row = clients_repo.getClient(database, request.arena, id) catch |err|
|
||||
return mutations.respondFailure(request, .{ .internal = err }, "reading a client");
|
||||
const found = row orelse return mutations.respondFailure(request, .not_found, "");
|
||||
|
||||
return http_util.respondJson(request, .ok, found, &.{});
|
||||
}
|
||||
|
||||
pub fn remove(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
if (applyDelete(state, io, request.id.?)) |failure| {
|
||||
return mutations.respondFailure(request, failure, "deleting a client");
|
||||
}
|
||||
return http_util.respondEmpty(request, .no_content);
|
||||
}
|
||||
|
||||
pub fn listPrefixes(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
_ = io;
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return mutations.respondFailure(request, failure, "listing client prefixes"),
|
||||
};
|
||||
|
||||
const rows = clients_repo.listClientPrefixRows(database, request.arena) catch |err|
|
||||
return mutations.respondFailure(request, .{ .internal = err }, "listing client prefixes");
|
||||
|
||||
return http_util.respondJson(request, .ok, .{ .client_prefixes = rows.items }, &.{});
|
||||
}
|
||||
|
||||
pub fn putPrefixes(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
const parsed = http_util.parseBody(PrefixesBody, request) catch |err|
|
||||
return mutations.respondBadBody(request, err);
|
||||
|
||||
const items = try request.arena.alloc(clients_repo.ClientPrefixInput, parsed.value.client_prefixes.len);
|
||||
for (items, parsed.value.client_prefixes) |*item, body| item.* = .{
|
||||
.prefix = body.prefix,
|
||||
.group_id = body.group_id,
|
||||
.priority = body.priority,
|
||||
};
|
||||
|
||||
if (try applyReplacePrefixes(state, io, request.arena, items)) |failure| {
|
||||
return mutations.respondFailure(request, failure, "replacing the client prefixes");
|
||||
}
|
||||
|
||||
const database = state.config_db.?;
|
||||
const rows = clients_repo.listClientPrefixRows(database, request.arena) catch |err|
|
||||
return mutations.respondFailure(request, .{ .internal = err }, "listing client prefixes");
|
||||
|
||||
return http_util.respondJson(request, .ok, .{ .client_prefixes = rows.items }, &.{});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
fn seedClient(bench: *mutations.Bench) !void {
|
||||
try bench.exec(
|
||||
\\INSERT INTO groups (id, name) VALUES (2, 'kids');
|
||||
\\INSERT INTO clients (id, ip, group_id, hand_edited, first_seen, last_seen)
|
||||
\\VALUES (1, '192.168.1.10', 1, 0, 100, 200);
|
||||
);
|
||||
}
|
||||
|
||||
test "editing a client names it, moves it and marks it hand edited" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
try seedClient(&bench);
|
||||
|
||||
const failure = applyUpdate(&bench.state, bench.io(), 1, .{ .name = "laptop", .group_id = 2 });
|
||||
try testing.expectEqual(@as(?Failure, null), failure);
|
||||
try testing.expectEqual(@as(usize, 1), bench.reloads);
|
||||
|
||||
const row = (try clients_repo.getClient(&bench.database, bench.arena(), 1)).?;
|
||||
try testing.expectEqualStrings("laptop", row.name);
|
||||
try testing.expectEqualStrings("kids", row.group);
|
||||
try testing.expect(row.hand_edited);
|
||||
// The tracker's timestamps and the address are not the API's to move.
|
||||
try testing.expectEqualStrings("192.168.1.10", row.ip);
|
||||
try testing.expectEqual(@as(i64, 100), row.first_seen);
|
||||
}
|
||||
|
||||
test "editing a client into a group that does not exist is a conflict" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
try seedClient(&bench);
|
||||
|
||||
const failure = applyUpdate(&bench.state, bench.io(), 1, .{ .name = "laptop", .group_id = 404 });
|
||||
try testing.expectEqualStrings(group_conflict, failure.?.conflict);
|
||||
try testing.expectEqual(@as(usize, 0), bench.reloads);
|
||||
}
|
||||
|
||||
test "an id no client holds is a 404 on both update and delete" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
try testing.expectEqual(
|
||||
Failure.not_found,
|
||||
applyUpdate(&bench.state, bench.io(), 999, .{ .group_id = 1 }).?,
|
||||
);
|
||||
try testing.expectEqual(Failure.not_found, applyDelete(&bench.state, bench.io(), 999).?);
|
||||
}
|
||||
|
||||
test "deleting a client removes the row and announces the change" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
try seedClient(&bench);
|
||||
|
||||
try testing.expectEqual(@as(?Failure, null), applyDelete(&bench.state, bench.io(), 1));
|
||||
try testing.expectEqual(@as(i64, 0), try bench.queryInt("SELECT count(*) FROM clients"));
|
||||
try testing.expectEqual(@as(usize, 1), bench.reloads);
|
||||
}
|
||||
|
||||
test "the prefix list is replaced whole" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
try bench.exec("INSERT INTO groups (id, name) VALUES (2, 'kids');");
|
||||
|
||||
try testing.expectEqual(@as(?Failure, null), try applyReplacePrefixes(
|
||||
&bench.state,
|
||||
bench.io(),
|
||||
bench.arena(),
|
||||
&.{
|
||||
.{ .prefix = "192.168.1.0/24", .group_id = 1, .priority = 10 },
|
||||
.{ .prefix = "192.168.2.0/24", .group_id = 2, .priority = 20 },
|
||||
},
|
||||
));
|
||||
try testing.expectEqual(@as(i64, 2), try bench.queryInt("SELECT count(*) FROM client_prefixes"));
|
||||
|
||||
try testing.expectEqual(@as(?Failure, null), try applyReplacePrefixes(
|
||||
&bench.state,
|
||||
bench.io(),
|
||||
bench.arena(),
|
||||
&.{.{ .prefix = "10.0.0.0/8", .group_id = 1 }},
|
||||
));
|
||||
const rows = try clients_repo.listClientPrefixRows(&bench.database, bench.arena());
|
||||
try testing.expectEqual(@as(usize, 1), rows.items.len);
|
||||
try testing.expectEqualStrings("10.0.0.0/8", rows.items[0].prefix);
|
||||
try testing.expectEqual(@as(i32, 100), rows.items[0].priority);
|
||||
try testing.expectEqual(@as(usize, 2), bench.reloads);
|
||||
}
|
||||
|
||||
test "an empty prefix list clears the table" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
_ = try applyReplacePrefixes(&bench.state, bench.io(), bench.arena(), &.{
|
||||
.{ .prefix = "192.168.1.0/24", .group_id = 1 },
|
||||
});
|
||||
try testing.expectEqual(@as(?Failure, null), try applyReplacePrefixes(
|
||||
&bench.state,
|
||||
bench.io(),
|
||||
bench.arena(),
|
||||
&.{},
|
||||
));
|
||||
try testing.expectEqual(@as(i64, 0), try bench.queryInt("SELECT count(*) FROM client_prefixes"));
|
||||
}
|
||||
|
||||
test "a malformed prefix is refused and the stored list survives" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
_ = try applyReplacePrefixes(&bench.state, bench.io(), bench.arena(), &.{
|
||||
.{ .prefix = "192.168.1.0/24", .group_id = 1 },
|
||||
});
|
||||
|
||||
const failure = try applyReplacePrefixes(&bench.state, bench.io(), bench.arena(), &.{
|
||||
.{ .prefix = "192.168.2.0/24", .group_id = 1 },
|
||||
.{ .prefix = "not-a-prefix", .group_id = 1 },
|
||||
});
|
||||
try testing.expect(failure.? == .invalid);
|
||||
|
||||
const rows = try clients_repo.listClientPrefixRows(&bench.database, bench.arena());
|
||||
try testing.expectEqual(@as(usize, 1), rows.items.len);
|
||||
try testing.expectEqualStrings("192.168.1.0/24", rows.items[0].prefix);
|
||||
try testing.expectEqual(@as(usize, 1), bench.reloads);
|
||||
}
|
||||
|
||||
test "one prefix twice is a conflict and the old list survives" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
_ = try applyReplacePrefixes(&bench.state, bench.io(), bench.arena(), &.{
|
||||
.{ .prefix = "10.0.0.0/8", .group_id = 1 },
|
||||
});
|
||||
|
||||
const failure = try applyReplacePrefixes(&bench.state, bench.io(), bench.arena(), &.{
|
||||
.{ .prefix = "192.168.1.0/24", .group_id = 1 },
|
||||
.{ .prefix = "192.168.1.0/24", .group_id = 1, .priority = 50 },
|
||||
});
|
||||
try testing.expectEqualStrings(prefix_conflict, failure.?.conflict);
|
||||
|
||||
const rows = try clients_repo.listClientPrefixRows(&bench.database, bench.arena());
|
||||
try testing.expectEqual(@as(usize, 1), rows.items.len);
|
||||
try testing.expectEqualStrings("10.0.0.0/8", rows.items[0].prefix);
|
||||
}
|
||||
|
||||
test "two spellings of one prefix in one PUT are a conflict" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
_ = try applyReplacePrefixes(&bench.state, bench.io(), bench.arena(), &.{
|
||||
.{ .prefix = "10.0.0.0/8", .group_id = 1 },
|
||||
});
|
||||
|
||||
const v6_case = try applyReplacePrefixes(&bench.state, bench.io(), bench.arena(), &.{
|
||||
.{ .prefix = "fd00:abcd::/48", .group_id = 1 },
|
||||
.{ .prefix = "FD00:ABCD:0:0:0:0:0:0/48", .group_id = 1, .priority = 50 },
|
||||
});
|
||||
try testing.expectEqualStrings(prefix_conflict, v6_case.?.conflict);
|
||||
|
||||
const host_bits = try applyReplacePrefixes(&bench.state, bench.io(), bench.arena(), &.{
|
||||
.{ .prefix = "192.168.1.0/24", .group_id = 1 },
|
||||
.{ .prefix = "192.168.1.55/24", .group_id = 1, .priority = 50 },
|
||||
});
|
||||
try testing.expectEqualStrings(prefix_conflict, host_bits.?.conflict);
|
||||
|
||||
const rows = try clients_repo.listClientPrefixRows(&bench.database, bench.arena());
|
||||
try testing.expectEqual(@as(usize, 1), rows.items.len);
|
||||
try testing.expectEqualStrings("10.0.0.0/8", rows.items[0].prefix);
|
||||
try testing.expectEqual(@as(usize, 1), bench.reloads);
|
||||
}
|
||||
|
||||
test "a prefix is stored and listed in canonical form" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
try testing.expectEqual(@as(?Failure, null), try applyReplacePrefixes(
|
||||
&bench.state,
|
||||
bench.io(),
|
||||
bench.arena(),
|
||||
&.{
|
||||
.{ .prefix = "FD00:ABCD:0:0:0:0:0:0/48", .group_id = 1 },
|
||||
.{ .prefix = "192.168.1.55/24", .group_id = 1, .priority = 50 },
|
||||
},
|
||||
));
|
||||
|
||||
// `listPrefixes` serves these rows, so the GET body carries the same text.
|
||||
const rows = try clients_repo.listClientPrefixRows(&bench.database, bench.arena());
|
||||
try testing.expectEqual(@as(usize, 2), rows.items.len);
|
||||
try testing.expectEqualStrings("192.168.1.0/24", rows.items[0].prefix);
|
||||
try testing.expectEqualStrings("fd00:abcd::/48", rows.items[1].prefix);
|
||||
}
|
||||
|
||||
test "a prefix naming a group that does not exist is a conflict" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
const failure = try applyReplacePrefixes(&bench.state, bench.io(), bench.arena(), &.{
|
||||
.{ .prefix = "192.168.1.0/24", .group_id = 404 },
|
||||
});
|
||||
try testing.expectEqualStrings(prefix_conflict, failure.?.conflict);
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
//! `/api/groups` — the client groups, and each group's blocklist assignment
|
||||
//! (ruling 9).
|
||||
//!
|
||||
//! A group change is live (ruling 12): the write lands, the snapshot is rebuilt
|
||||
//! through `state.reload_fn`, and the next query is filtered by the new rules.
|
||||
//!
|
||||
//! The group named `default` is the one every client falls back to and the one
|
||||
//! `config/validate.zig` insists on, so it can be edited but neither renamed
|
||||
//! nor deleted. Both refusals are 409: the request is well formed and names a
|
||||
//! row that exists, and the conflict is with an invariant of the configuration.
|
||||
//!
|
||||
//! Each route is two functions: an `apply` that decides and writes, and the
|
||||
//! handler that parses the body and turns the decision into a response. The
|
||||
//! split is what lets the decisions be tested against an in-memory database
|
||||
//! with no socket in the way.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const db = @import("../../storage/db.zig");
|
||||
const groups_repo = @import("../../storage/repositories/groups_repo.zig");
|
||||
const http_util = @import("../http_util.zig");
|
||||
const model = @import("../../config/model.zig");
|
||||
const mutations = @import("mutations.zig");
|
||||
const server = @import("../server.zig");
|
||||
|
||||
const Failure = mutations.Failure;
|
||||
const Request = http_util.Request;
|
||||
const HandlerError = http_util.HandlerError;
|
||||
|
||||
/// The group every client without one of its own belongs to.
|
||||
pub const default_group_name = "default";
|
||||
|
||||
const name_conflict = "a group with that name already exists";
|
||||
|
||||
const Body = struct {
|
||||
name: []const u8,
|
||||
safe_search: bool = false,
|
||||
};
|
||||
|
||||
const SourcesBody = struct {
|
||||
source_ids: []const i64,
|
||||
};
|
||||
|
||||
const Created = union(enum) { id: i64, fail: Failure };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// decisions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn applyCreate(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
arena: Allocator,
|
||||
item: model.Group,
|
||||
) error{OutOfMemory}!Created {
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return .{ .fail = failure },
|
||||
};
|
||||
if (try mutations.checkGroupName(arena, item.name)) |problem| {
|
||||
return .{ .fail = .{ .invalid = problem } };
|
||||
}
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
const inserted = groups_repo.insertGroupRow(database, item);
|
||||
state.config_lock.unlock(io);
|
||||
|
||||
const id = inserted catch |err| return .{ .fail = mutations.dbFailure(err, name_conflict) };
|
||||
if (mutations.reload(state, io)) |failure| return .{ .fail = failure };
|
||||
return .{ .id = id };
|
||||
}
|
||||
|
||||
pub fn applyUpdate(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
arena: Allocator,
|
||||
id: i64,
|
||||
item: model.Group,
|
||||
) error{OutOfMemory}!?Failure {
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return failure,
|
||||
};
|
||||
if (try mutations.checkGroupName(arena, item.name)) |problem| {
|
||||
return .{ .invalid = problem };
|
||||
}
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
const outcome = updateLocked(database, arena, id, item);
|
||||
state.config_lock.unlock(io);
|
||||
|
||||
if (outcome) |failure| return failure;
|
||||
return mutations.reload(state, io);
|
||||
}
|
||||
|
||||
/// The read and the write are one critical section: the name that decides
|
||||
/// whether the edit is legal must be the name the update overwrites.
|
||||
fn updateLocked(database: *db.Db, arena: Allocator, id: i64, item: model.Group) ?Failure {
|
||||
const row = groups_repo.getGroup(database, arena, id) catch |err|
|
||||
return mutations.dbFailure(err, name_conflict);
|
||||
const current = row orelse return .not_found;
|
||||
if (std.mem.eql(u8, current.name, default_group_name) and
|
||||
!std.mem.eql(u8, item.name, default_group_name))
|
||||
{
|
||||
return .{ .conflict = "the default group cannot be renamed" };
|
||||
}
|
||||
|
||||
groups_repo.updateGroup(database, id, item) catch |err|
|
||||
return mutations.dbFailure(err, name_conflict);
|
||||
return null;
|
||||
}
|
||||
|
||||
pub fn applyDelete(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure {
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return failure,
|
||||
};
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
const outcome = deleteLocked(database, arena, id);
|
||||
state.config_lock.unlock(io);
|
||||
|
||||
if (outcome) |failure| return failure;
|
||||
return mutations.reload(state, io);
|
||||
}
|
||||
|
||||
fn deleteLocked(database: *db.Db, arena: Allocator, id: i64) ?Failure {
|
||||
// `clients.group_id` has no `ON DELETE`, so a group a client still belongs
|
||||
// to cannot go; rules, prefixes and assignments cascade (W2's map).
|
||||
const clients_conflict = "the group still has clients; move them first";
|
||||
|
||||
const row = groups_repo.getGroup(database, arena, id) catch |err|
|
||||
return mutations.dbFailure(err, clients_conflict);
|
||||
const current = row orelse return .not_found;
|
||||
if (std.mem.eql(u8, current.name, default_group_name)) {
|
||||
return .{ .conflict = "the default group cannot be deleted" };
|
||||
}
|
||||
|
||||
groups_repo.deleteGroup(database, id) catch |err|
|
||||
return mutations.dbFailure(err, clients_conflict);
|
||||
return null;
|
||||
}
|
||||
|
||||
pub fn applySetSources(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
id: i64,
|
||||
source_ids: []const i64,
|
||||
) ?Failure {
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return failure,
|
||||
};
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
const outcome = groups_repo.setGroupSources(database, id, source_ids);
|
||||
state.config_lock.unlock(io);
|
||||
|
||||
outcome catch |err| return mutations.dbFailure(err, "one of those blocklist sources does not exist");
|
||||
return mutations.reload(state, io);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// routes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn list(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
_ = io;
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return mutations.respondFailure(request, failure, "listing groups"),
|
||||
};
|
||||
|
||||
const rows = groups_repo.listGroupRows(database, request.arena) catch |err|
|
||||
return mutations.respondFailure(request, .{ .internal = err }, "listing groups");
|
||||
|
||||
return http_util.respondJson(request, .ok, .{ .groups = rows.items }, &.{});
|
||||
}
|
||||
|
||||
pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
_ = io;
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return mutations.respondFailure(request, failure, "reading a group"),
|
||||
};
|
||||
|
||||
const row = groups_repo.getGroup(database, request.arena, request.id.?) catch |err|
|
||||
return mutations.respondFailure(request, .{ .internal = err }, "reading a group");
|
||||
const found = row orelse return mutations.respondFailure(request, .not_found, "");
|
||||
|
||||
return http_util.respondJson(request, .ok, found, &.{});
|
||||
}
|
||||
|
||||
pub fn create(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
const parsed = http_util.parseBody(Body, request) catch |err|
|
||||
return mutations.respondBadBody(request, err);
|
||||
const item: model.Group = .{ .name = parsed.value.name, .safe_search = parsed.value.safe_search };
|
||||
|
||||
return switch (try applyCreate(state, io, request.arena, item)) {
|
||||
.fail => |failure| mutations.respondFailure(request, failure, "creating a group"),
|
||||
.id => |id| http_util.respondJson(request, .created, .{
|
||||
.id = id,
|
||||
.name = item.name,
|
||||
.safe_search = item.safe_search,
|
||||
}, &.{}),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn update(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
const parsed = http_util.parseBody(Body, request) catch |err|
|
||||
return mutations.respondBadBody(request, err);
|
||||
const item: model.Group = .{ .name = parsed.value.name, .safe_search = parsed.value.safe_search };
|
||||
const id = request.id.?;
|
||||
|
||||
if (try applyUpdate(state, io, request.arena, id, item)) |failure| {
|
||||
return mutations.respondFailure(request, failure, "updating a group");
|
||||
}
|
||||
return http_util.respondJson(request, .ok, .{
|
||||
.id = id,
|
||||
.name = item.name,
|
||||
.safe_search = item.safe_search,
|
||||
}, &.{});
|
||||
}
|
||||
|
||||
pub fn remove(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
if (applyDelete(state, io, request.arena, request.id.?)) |failure| {
|
||||
return mutations.respondFailure(request, failure, "deleting a group");
|
||||
}
|
||||
return http_util.respondEmpty(request, .no_content);
|
||||
}
|
||||
|
||||
/// `GET /api/groups/{id}/sources` — the assignment the PUT replaces.
|
||||
pub fn getSources(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
_ = io;
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return mutations.respondFailure(request, failure, "reading a group"),
|
||||
};
|
||||
const id = request.id.?;
|
||||
|
||||
const row = groups_repo.getGroup(database, request.arena, id) catch |err|
|
||||
return mutations.respondFailure(request, .{ .internal = err }, "reading a group");
|
||||
if (row == null) return mutations.respondFailure(request, .not_found, "");
|
||||
|
||||
const ids = groups_repo.listGroupSourceIds(database, request.arena, id) catch |err|
|
||||
return mutations.respondFailure(request, .{ .internal = err }, "reading a group's blocklists");
|
||||
|
||||
return http_util.respondJson(request, .ok, .{ .source_ids = ids.items }, &.{});
|
||||
}
|
||||
|
||||
/// `PUT /api/groups/{id}/sources` — the whole assignment, replaced (ruling 9).
|
||||
/// Sending the same set twice leaves the same server state, which is what makes
|
||||
/// the UI's checkbox list safe to save repeatedly.
|
||||
pub fn putSources(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
const parsed = http_util.parseBody(SourcesBody, request) catch |err|
|
||||
return mutations.respondBadBody(request, err);
|
||||
|
||||
if (applySetSources(state, io, request.id.?, parsed.value.source_ids)) |failure| {
|
||||
return mutations.respondFailure(request, failure, "assigning blocklists to a group");
|
||||
}
|
||||
return http_util.respondJson(request, .ok, .{ .source_ids = parsed.value.source_ids }, &.{});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "a created group is stored, returned by id and reloaded" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
const created = try applyCreate(&bench.state, bench.io(), bench.arena(), .{
|
||||
.name = "kids",
|
||||
.safe_search = true,
|
||||
});
|
||||
const id = created.id;
|
||||
try testing.expectEqual(@as(usize, 1), bench.reloads);
|
||||
|
||||
const row = (try groups_repo.getGroup(&bench.database, bench.arena(), id)).?;
|
||||
try testing.expectEqualStrings("kids", row.name);
|
||||
try testing.expect(row.safe_search);
|
||||
}
|
||||
|
||||
test "a duplicate group name is a conflict, not a validation failure" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
_ = try applyCreate(&bench.state, bench.io(), bench.arena(), .{ .name = "kids" });
|
||||
const again = try applyCreate(&bench.state, bench.io(), bench.arena(), .{ .name = "kids" });
|
||||
|
||||
try testing.expectEqualStrings(name_conflict, again.fail.conflict);
|
||||
// The failed write must not have been announced as a change.
|
||||
try testing.expectEqual(@as(usize, 1), bench.reloads);
|
||||
}
|
||||
|
||||
test "an empty group name is refused before any write" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
const created = try applyCreate(&bench.state, bench.io(), bench.arena(), .{ .name = "" });
|
||||
try testing.expect(created.fail == .invalid);
|
||||
try testing.expectEqual(@as(usize, 0), bench.reloads);
|
||||
try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT count(*) FROM groups"));
|
||||
}
|
||||
|
||||
test "updating a group that does not exist is a 404" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
const failure = try applyUpdate(&bench.state, bench.io(), bench.arena(), 999, .{ .name = "kids" });
|
||||
try testing.expectEqual(Failure.not_found, failure.?);
|
||||
try testing.expectEqual(@as(usize, 0), bench.reloads);
|
||||
}
|
||||
|
||||
test "a group edit renames and reloads" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
const created = try applyCreate(&bench.state, bench.io(), bench.arena(), .{ .name = "kids" });
|
||||
const failure = try applyUpdate(&bench.state, bench.io(), bench.arena(), created.id, .{
|
||||
.name = "children",
|
||||
.safe_search = true,
|
||||
});
|
||||
|
||||
try testing.expectEqual(@as(?Failure, null), failure);
|
||||
try testing.expectEqual(@as(usize, 2), bench.reloads);
|
||||
const row = (try groups_repo.getGroup(&bench.database, bench.arena(), created.id)).?;
|
||||
try testing.expectEqualStrings("children", row.name);
|
||||
}
|
||||
|
||||
test "the default group may be edited but not renamed or deleted" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
const kept = try applyUpdate(&bench.state, bench.io(), bench.arena(), 1, .{
|
||||
.name = "default",
|
||||
.safe_search = true,
|
||||
});
|
||||
try testing.expectEqual(@as(?Failure, null), kept);
|
||||
|
||||
const renamed = try applyUpdate(&bench.state, bench.io(), bench.arena(), 1, .{ .name = "primary" });
|
||||
try testing.expectEqualStrings("the default group cannot be renamed", renamed.?.conflict);
|
||||
|
||||
const deleted = applyDelete(&bench.state, bench.io(), bench.arena(), 1);
|
||||
try testing.expectEqualStrings("the default group cannot be deleted", deleted.?.conflict);
|
||||
try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT count(*) FROM groups WHERE id = 1"));
|
||||
}
|
||||
|
||||
test "a group with clients cannot be deleted" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
const created = try applyCreate(&bench.state, bench.io(), bench.arena(), .{ .name = "kids" });
|
||||
try bench.exec("INSERT INTO clients (ip, group_id, first_seen, last_seen) VALUES ('192.168.1.9', 2, 0, 0);");
|
||||
|
||||
const failure = applyDelete(&bench.state, bench.io(), bench.arena(), created.id);
|
||||
try testing.expectEqualStrings("the group still has clients; move them first", failure.?.conflict);
|
||||
try testing.expectEqual(@as(usize, 1), bench.reloads);
|
||||
}
|
||||
|
||||
test "deleting a group removes it and reloads" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
const created = try applyCreate(&bench.state, bench.io(), bench.arena(), .{ .name = "kids" });
|
||||
try testing.expectEqual(@as(?Failure, null), applyDelete(&bench.state, bench.io(), bench.arena(), created.id));
|
||||
try testing.expectEqual(@as(usize, 2), bench.reloads);
|
||||
try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT count(*) FROM groups"));
|
||||
|
||||
try testing.expectEqual(
|
||||
Failure.not_found,
|
||||
applyDelete(&bench.state, bench.io(), bench.arena(), created.id).?,
|
||||
);
|
||||
}
|
||||
|
||||
test "a group's blocklist assignment is replaced as a set" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
try bench.exec(
|
||||
\\INSERT INTO blocklist_sources (id, url, name) VALUES
|
||||
\\ (1, 'https://a.test/list.txt', 'a'), (2, 'https://b.test/list.txt', 'b');
|
||||
);
|
||||
|
||||
try testing.expectEqual(
|
||||
@as(?Failure, null),
|
||||
applySetSources(&bench.state, bench.io(), 1, &.{ 1, 2 }),
|
||||
);
|
||||
try testing.expectEqual(@as(i64, 2), try bench.queryInt("SELECT count(*) FROM group_sources"));
|
||||
|
||||
// Idempotent, and a shorter set removes what it leaves out.
|
||||
try testing.expectEqual(@as(?Failure, null), applySetSources(&bench.state, bench.io(), 1, &.{2}));
|
||||
try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT count(*) FROM group_sources"));
|
||||
try testing.expectEqual(@as(i64, 2), try bench.queryInt("SELECT source_id FROM group_sources"));
|
||||
try testing.expectEqual(@as(usize, 2), bench.reloads);
|
||||
}
|
||||
|
||||
test "assigning a source that does not exist is a conflict" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
const failure = applySetSources(&bench.state, bench.io(), 1, &.{404});
|
||||
try testing.expectEqualStrings("one of those blocklist sources does not exist", failure.?.conflict);
|
||||
try testing.expectEqual(@as(i64, 0), try bench.queryInt("SELECT count(*) FROM group_sources"));
|
||||
}
|
||||
|
||||
test "assigning sources to a group that does not exist is a 404" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
try testing.expectEqual(Failure.not_found, applySetSources(&bench.state, bench.io(), 999, &.{}).?);
|
||||
}
|
||||
|
||||
test "a write with no configuration database is unavailable, not a crash" {
|
||||
var state: server.WebState = .{ .gpa = testing.allocator };
|
||||
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
|
||||
const created = try applyCreate(&state, undefined, arena_state.allocator(), .{ .name = "kids" });
|
||||
try testing.expect(created.fail == .unavailable);
|
||||
}
|
||||
|
||||
test "a reload failure after a successful write is reported as not applied" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
bench.reload_fails = true;
|
||||
const created = try applyCreate(&bench.state, bench.io(), bench.arena(), .{ .name = "kids" });
|
||||
|
||||
try testing.expectEqual(Failure.not_applied, created.fail);
|
||||
// The row is there: the write succeeded and only the announcement failed.
|
||||
try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT count(*) FROM groups WHERE name = 'kids'"));
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
//! `GET /api/health` — the rollup a monitor scrapes (ruling 22).
|
||||
//!
|
||||
//! Always 200. "degraded" is a fact about the box, not a failure of the
|
||||
//! request, and answering 503 would make an uptime check flap on a full disk
|
||||
//! while nxdns is still resolving perfectly well.
|
||||
//!
|
||||
//! Unauthenticated and rate-limit exempt, like `/metrics`.
|
||||
//!
|
||||
//! `rollup` is pure so the whole degraded matrix is testable without a running
|
||||
//! server; `handle` only gathers the inputs.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const disk_monitor = @import("../../storage/disk_monitor.zig");
|
||||
const http_util = @import("../http_util.zig");
|
||||
const metrics = @import("../metrics.zig");
|
||||
const pool_mod = @import("../../upstream/pool.zig");
|
||||
const server = @import("../server.zig");
|
||||
|
||||
pub const Disk = struct {
|
||||
state: []const u8,
|
||||
free_bytes: u64,
|
||||
db_bytes: u64,
|
||||
log_bytes: u64,
|
||||
sample_failures: u64,
|
||||
};
|
||||
|
||||
pub const Upstreams = struct {
|
||||
available: u32,
|
||||
total: u32,
|
||||
};
|
||||
|
||||
pub const Body = struct {
|
||||
status: []const u8,
|
||||
disk: Disk,
|
||||
upstreams: Upstreams,
|
||||
queries_dropped: u64,
|
||||
writer_failed: bool,
|
||||
refreshes_gated: u64,
|
||||
/// Null before the first filter snapshot is published.
|
||||
snapshot_generation: ?u64,
|
||||
};
|
||||
|
||||
/// What the rollup is computed from. Every field has a defined value even when
|
||||
/// its collaborator is missing, and the defaults are the ones a half-wired
|
||||
/// server should report: no disk reading, no upstreams, nothing published.
|
||||
pub const Input = struct {
|
||||
disk_state: disk_monitor.State = .ok,
|
||||
disk: disk_monitor.Gauges = .{ .free_bytes = 0, .db_bytes = 0, .log_bytes = 0 },
|
||||
disk_sample_failures: u64 = 0,
|
||||
upstreams_available: u32 = 0,
|
||||
upstreams_total: u32 = 0,
|
||||
queries_dropped: u64 = 0,
|
||||
writer_failed: bool = false,
|
||||
refreshes_gated: u64 = 0,
|
||||
snapshot_generation: ?u64 = null,
|
||||
};
|
||||
|
||||
pub const status_ok = "ok";
|
||||
pub const status_degraded = "degraded";
|
||||
|
||||
/// Ruling 22's three conditions. Each one is something an operator must act on:
|
||||
/// a disk that is filling stops the query log, a pool with nothing available
|
||||
/// stops resolution, and a failed writer means rows are being lost right now.
|
||||
pub fn degraded(input: Input) bool {
|
||||
return input.disk_state != .ok or input.upstreams_available == 0 or input.writer_failed;
|
||||
}
|
||||
|
||||
pub fn rollup(input: Input) Body {
|
||||
return .{
|
||||
.status = if (degraded(input)) status_degraded else status_ok,
|
||||
.disk = .{
|
||||
.state = @tagName(input.disk_state),
|
||||
.free_bytes = input.disk.free_bytes,
|
||||
.db_bytes = input.disk.db_bytes,
|
||||
.log_bytes = input.disk.log_bytes,
|
||||
.sample_failures = input.disk_sample_failures,
|
||||
},
|
||||
.upstreams = .{ .available = input.upstreams_available, .total = input.upstreams_total },
|
||||
.queries_dropped = input.queries_dropped,
|
||||
.writer_failed = input.writer_failed,
|
||||
.refreshes_gated = input.refreshes_gated,
|
||||
.snapshot_generation = input.snapshot_generation,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn handle(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
request: *http_util.Request,
|
||||
) http_util.HandlerError!void {
|
||||
return http_util.respondJson(request, .ok, rollup(collect(state, io)), &.{});
|
||||
}
|
||||
|
||||
pub fn collect(state: *server.WebState, io: std.Io) Input {
|
||||
var input: Input = .{};
|
||||
|
||||
if (state.monitor) |monitor| {
|
||||
input.disk_state = monitor.state();
|
||||
input.disk = monitor.gauges();
|
||||
input.disk_sample_failures = monitor.sample_failures.load(.monotonic);
|
||||
}
|
||||
|
||||
if (state.pool) |pool| {
|
||||
var raw: [metrics.max_upstreams]pool_mod.Snapshot = undefined;
|
||||
const count = metrics.poolSnapshot(pool, io, &raw);
|
||||
input.upstreams_total = @intCast(count);
|
||||
for (raw[0..count]) |entry| {
|
||||
if (entry.available) input.upstreams_available += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (state.logger) |logger| {
|
||||
input.queries_dropped = logger.queries_dropped.load(.monotonic);
|
||||
input.writer_failed = logger.writer_failed.load(.monotonic);
|
||||
}
|
||||
|
||||
if (state.manager) |manager| {
|
||||
input.refreshes_gated = manager.refreshesGated();
|
||||
if (manager.acquire(io)) |acquired| {
|
||||
defer acquired.release(io);
|
||||
input.snapshot_generation = acquired.snapshot.generation;
|
||||
}
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const logger_mod = @import("../../storage/logger.zig");
|
||||
const testing = std.testing;
|
||||
|
||||
/// A box with nothing wrong with it: one upstream up, disk ok, writer alive.
|
||||
const healthy: Input = .{
|
||||
.disk_state = .ok,
|
||||
.upstreams_available = 1,
|
||||
.upstreams_total = 1,
|
||||
.writer_failed = false,
|
||||
};
|
||||
|
||||
test "the degraded matrix covers disk state, availability and the writer" {
|
||||
const cases = [_]struct { input: Input, degraded: bool }{
|
||||
.{ .input = healthy, .degraded = false },
|
||||
.{ .input = withDisk(healthy, .warn), .degraded = true },
|
||||
.{ .input = withDisk(healthy, .critical), .degraded = true },
|
||||
.{ .input = withAvailable(healthy, 0), .degraded = true },
|
||||
.{ .input = withWriterFailed(healthy), .degraded = true },
|
||||
// Two faults at once still report one status.
|
||||
.{ .input = withWriterFailed(withDisk(healthy, .critical)), .degraded = true },
|
||||
// Some upstreams down is not degraded while one still answers.
|
||||
.{ .input = .{ .upstreams_available = 1, .upstreams_total = 3 }, .degraded = false },
|
||||
};
|
||||
|
||||
for (cases, 0..) |case, i| {
|
||||
errdefer std.debug.print("case {d}\n", .{i});
|
||||
try testing.expectEqual(case.degraded, degraded(case.input));
|
||||
try testing.expectEqualStrings(
|
||||
if (case.degraded) status_degraded else status_ok,
|
||||
rollup(case.input).status,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn withDisk(input: Input, state: disk_monitor.State) Input {
|
||||
var out = input;
|
||||
out.disk_state = state;
|
||||
return out;
|
||||
}
|
||||
|
||||
fn withAvailable(input: Input, available: u32) Input {
|
||||
var out = input;
|
||||
out.upstreams_available = available;
|
||||
return out;
|
||||
}
|
||||
|
||||
fn withWriterFailed(input: Input) Input {
|
||||
var out = input;
|
||||
out.writer_failed = true;
|
||||
return out;
|
||||
}
|
||||
|
||||
test "the body reports every input verbatim" {
|
||||
const body = rollup(.{
|
||||
.disk_state = .warn,
|
||||
.disk = .{ .free_bytes = 100, .db_bytes = 20, .log_bytes = 3 },
|
||||
.disk_sample_failures = 2,
|
||||
.upstreams_available = 2,
|
||||
.upstreams_total = 4,
|
||||
.queries_dropped = 9,
|
||||
.writer_failed = false,
|
||||
.refreshes_gated = 1,
|
||||
.snapshot_generation = 12,
|
||||
});
|
||||
|
||||
try testing.expectEqualStrings("degraded", body.status);
|
||||
try testing.expectEqualStrings("warn", body.disk.state);
|
||||
try testing.expectEqual(@as(u64, 100), body.disk.free_bytes);
|
||||
try testing.expectEqual(@as(u64, 20), body.disk.db_bytes);
|
||||
try testing.expectEqual(@as(u64, 3), body.disk.log_bytes);
|
||||
try testing.expectEqual(@as(u64, 2), body.disk.sample_failures);
|
||||
try testing.expectEqual(@as(u32, 2), body.upstreams.available);
|
||||
try testing.expectEqual(@as(u32, 4), body.upstreams.total);
|
||||
try testing.expectEqual(@as(u64, 9), body.queries_dropped);
|
||||
try testing.expectEqual(@as(u64, 1), body.refreshes_gated);
|
||||
try testing.expectEqual(@as(?u64, 12), body.snapshot_generation);
|
||||
}
|
||||
|
||||
test "an unpublished snapshot serializes as null, not as zero" {
|
||||
var buffer: [512]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(&buffer);
|
||||
try std.json.Stringify.value(rollup(.{}), .{}, &writer);
|
||||
try testing.expect(std.mem.containsAtLeast(u8, writer.buffered(), 1, "\"snapshot_generation\":null"));
|
||||
}
|
||||
|
||||
test "collect reads the logger's counters and reports a bare state as degraded" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var queue_buf: [2]logger_mod.Entry = undefined;
|
||||
var query_logger: logger_mod.Logger = .init(.{}, &queue_buf);
|
||||
query_logger.queries_dropped.store(4, .monotonic);
|
||||
query_logger.writer_failed.store(true, .monotonic);
|
||||
|
||||
var state: server.WebState = .{ .gpa = testing.allocator, .logger = &query_logger };
|
||||
const input = collect(&state, io);
|
||||
|
||||
try testing.expectEqual(@as(u64, 4), input.queries_dropped);
|
||||
try testing.expect(input.writer_failed);
|
||||
try testing.expectEqual(@as(u32, 0), input.upstreams_total);
|
||||
try testing.expectEqual(@as(?u64, null), input.snapshot_generation);
|
||||
try testing.expectEqualStrings("degraded", rollup(input).status);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
//! `GET /api/queries/live` — the query log as it happens (ruling 20).
|
||||
//!
|
||||
//! Server-sent events over chunked transfer. The response buffer is EMPTY on
|
||||
//! purpose: `BodyWriter.flush` pushes only the protocol writer, never the body
|
||||
//! writer's own buffer (http.zig:780), so with a zero-length buffer every
|
||||
//! write lands in the chunked drain and one `flush` puts the frame on the
|
||||
//! wire. `retry: 3000` goes out first so a dropped stream reconnects on the
|
||||
//! browser's side without configuration.
|
||||
//!
|
||||
//! The subscriber owns one hub slot and drains it between waits. A ring
|
||||
//! overflow means this client is too slow for the query rate; the stream ends
|
||||
//! cleanly and the reconnecting client re-syncs through `/api/queries` —
|
||||
//! dropping the client beats holding queries back (PLAN §11.4). The `: ping`
|
||||
//! heartbeat every 15 s keeps middleboxes from reaping an idle connection.
|
||||
//!
|
||||
//! The route is rate-limit exempt (a long-lived stream must not drain its
|
||||
//! address's token bucket) but pays the per-address SSE connection cap, which
|
||||
//! binds loopback too: hub slots are a fixed resource.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const address = @import("../../platform/address.zig");
|
||||
const http_util = @import("../http_util.zig");
|
||||
const queries_repo = @import("../../storage/repositories/queries_repo.zig");
|
||||
const server = @import("../server.zig");
|
||||
const sse = @import("../sse.zig");
|
||||
|
||||
pub const retry_preamble = "retry: 3000\n\n";
|
||||
pub const heartbeat = ": ping\n\n";
|
||||
|
||||
/// Ruling 20's heartbeat cadence. Awake clock: a suspended box owes no pings.
|
||||
pub const heartbeat_interval: std.Io.Clock.Duration = .{
|
||||
.raw = .fromSeconds(15),
|
||||
.clock = .awake,
|
||||
};
|
||||
|
||||
/// One event's `data:` payload — the `/api/queries` row fields (ruling 20),
|
||||
/// minus `id`: a live entry precedes persistence, so no row id exists yet.
|
||||
pub const EventView = struct {
|
||||
ts: i64,
|
||||
domain: []const u8,
|
||||
client_ip: []const u8,
|
||||
qtype: ?u16,
|
||||
blocked: bool,
|
||||
block_reason: []const u8,
|
||||
response_time_us: ?i64,
|
||||
cache_hit: ?bool,
|
||||
upstream: []const u8,
|
||||
};
|
||||
|
||||
pub fn view(entry: *const sse.Entry) EventView {
|
||||
return .{
|
||||
.ts = entry.timestamp,
|
||||
.domain = entry.domain(),
|
||||
.client_ip = entry.clientIp(),
|
||||
.qtype = entry.qtype,
|
||||
.blocked = entry.blocked,
|
||||
.block_reason = entry.blockReason(),
|
||||
.response_time_us = entry.response_time_us,
|
||||
.cache_hit = entry.cache_hit,
|
||||
.upstream = entry.upstream(),
|
||||
};
|
||||
}
|
||||
|
||||
/// One `event: query` frame. JSON never contains a raw newline, so the whole
|
||||
/// payload is a single `data:` line.
|
||||
pub fn writeEvent(w: *std.Io.Writer, entry: *const sse.Entry) std.Io.Writer.Error!void {
|
||||
try w.writeAll("event: query\ndata: ");
|
||||
var stringify: std.json.Stringify = .{ .writer = w };
|
||||
try stringify.write(view(entry));
|
||||
try w.writeAll("\n\n");
|
||||
}
|
||||
|
||||
pub fn stream(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
request: *http_util.Request,
|
||||
) http_util.HandlerError!void {
|
||||
const hub = state.hub orelse
|
||||
return http_util.respondError(request, .service_unavailable, "live stream unavailable");
|
||||
|
||||
const peer = address.NetAddress.fromIp(request.peer);
|
||||
if (state.limiter) |limiter| {
|
||||
if (!limiter.tryAcquireSse(io, std.Io.Clock.awake.now(io), peer))
|
||||
return http_util.respondError(request, .too_many_requests, "too many live streams from this address");
|
||||
}
|
||||
defer if (state.limiter) |limiter| limiter.releaseSse(io, peer);
|
||||
|
||||
const id = hub.subscribe(io) orelse
|
||||
return http_util.respondError(request, .service_unavailable, "live stream is full");
|
||||
defer hub.unsubscribe(io, id);
|
||||
|
||||
var response = try request.http.respondStreaming(&.{}, .{
|
||||
.respond_options = .{
|
||||
.extra_headers = &.{
|
||||
.{ .name = "content-type", .value = "text/event-stream" },
|
||||
.{ .name = "cache-control", .value = "no-store" },
|
||||
},
|
||||
},
|
||||
});
|
||||
const w = &response.writer;
|
||||
try w.writeAll(retry_preamble);
|
||||
// The browser acts on the headers, not the first event; send them now.
|
||||
try response.flush();
|
||||
|
||||
while (true) {
|
||||
while (hub.next(io, id)) |entry| try writeEvent(w, &entry);
|
||||
try response.flush();
|
||||
|
||||
// Checked after the drain: entries that predate the overflow still
|
||||
// reach the client before the stream ends.
|
||||
if (hub.overflowed(io, id)) break;
|
||||
|
||||
const wake = hub.wait(io, id, heartbeat_interval) catch return;
|
||||
if (wake == .timeout) {
|
||||
try w.writeAll(heartbeat);
|
||||
try response.flush();
|
||||
}
|
||||
}
|
||||
|
||||
try response.end();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "the event payload carries the /api/queries row fields, minus id" {
|
||||
const row_fields = @typeInfo(queries_repo.QueryRow).@"struct".fields;
|
||||
const view_fields = @typeInfo(EventView).@"struct".fields;
|
||||
comptime {
|
||||
std.debug.assert(view_fields.len == row_fields.len - 1);
|
||||
std.debug.assert(std.mem.eql(u8, row_fields[0].name, "id"));
|
||||
for (row_fields[1..], view_fields) |row_field, view_field| {
|
||||
std.debug.assert(std.mem.eql(u8, row_field.name, view_field.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test "a frame is one event line and one data line of JSON" {
|
||||
const entry: sse.Entry = .init(.{
|
||||
.timestamp = 1_700_000_000,
|
||||
.domain = "ads.example",
|
||||
.client_ip = "192.0.2.10",
|
||||
.qtype = 1,
|
||||
.blocked = true,
|
||||
.block_reason = "blocklist_domain",
|
||||
.response_time_us = 42,
|
||||
.cache_hit = false,
|
||||
.upstream = "https://dns.example/dns-query",
|
||||
});
|
||||
|
||||
var buf: [1024]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(&buf);
|
||||
try writeEvent(&writer, &entry);
|
||||
const frame = writer.buffered();
|
||||
|
||||
try testing.expect(std.mem.startsWith(u8, frame, "event: query\ndata: {"));
|
||||
try testing.expect(std.mem.endsWith(u8, frame, "}\n\n"));
|
||||
try testing.expectEqual(@as(usize, 3), std.mem.count(u8, frame, "\n"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"ts\":1700000000"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"domain\":\"ads.example\""));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"blocked\":true"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"block_reason\":\"blocklist_domain\""));
|
||||
}
|
||||
|
||||
test "an unlogged field stays null and an empty string stays a string" {
|
||||
const entry: sse.Entry = .init(.{
|
||||
.timestamp = 1,
|
||||
.domain = "safe.example",
|
||||
.client_ip = "192.0.2.11",
|
||||
});
|
||||
|
||||
var buf: [1024]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(&buf);
|
||||
try writeEvent(&writer, &entry);
|
||||
const frame = writer.buffered();
|
||||
|
||||
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"qtype\":null"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"cache_hit\":null"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"upstream\":\"\""));
|
||||
}
|
||||
@@ -0,0 +1,551 @@
|
||||
//! `/api/local-records` and `/api/forward-zones` — the names nxdns answers
|
||||
//! itself and the zones it hands to another resolver.
|
||||
//!
|
||||
//! Both take effect live (ruling 12), and not through the blocklist snapshot:
|
||||
//! the two tables are rebuilt from the database and published into
|
||||
//! `state.local_tables`, so the next query reads the new generation. The reload
|
||||
//! seam is called as well, so the composition root learns about every
|
||||
//! configuration change through one path.
|
||||
//!
|
||||
//! A record's type travels as the word the schema stores — `A`, `AAAA`,
|
||||
//! `CNAME` — which is also the word the config file uses.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const http_util = @import("../http_util.zig");
|
||||
const local_repo = @import("../../storage/repositories/local_repo.zig");
|
||||
const model = @import("../../config/model.zig");
|
||||
const mutations = @import("mutations.zig");
|
||||
const server = @import("../server.zig");
|
||||
|
||||
const Failure = mutations.Failure;
|
||||
const Request = http_util.Request;
|
||||
const HandlerError = http_util.HandlerError;
|
||||
|
||||
const record_conflict = "that name, type and value are already stored";
|
||||
const zone_conflict = "that zone already has a resolver";
|
||||
|
||||
const RecordBody = struct {
|
||||
name: []const u8,
|
||||
rtype: []const u8,
|
||||
value: []const u8,
|
||||
ttl: u32 = 300,
|
||||
};
|
||||
|
||||
const ZoneBody = struct {
|
||||
zone: []const u8,
|
||||
resolver: []const u8,
|
||||
};
|
||||
|
||||
const Created = union(enum) { id: i64, fail: Failure };
|
||||
|
||||
fn toRecord(body: RecordBody) union(enum) { record: model.LocalRecord, fail: Failure } {
|
||||
const rtype = model.RecordType.fromDb(body.rtype) orelse
|
||||
return .{ .fail = .{ .invalid = "rtype must be 'A', 'AAAA' or 'CNAME'" } };
|
||||
return .{ .record = .{
|
||||
.name = body.name,
|
||||
.rtype = rtype,
|
||||
.value = body.value,
|
||||
.ttl = body.ttl,
|
||||
} };
|
||||
}
|
||||
|
||||
/// The wire shape of a local record: the row with its type spelled the way the
|
||||
/// schema spells it.
|
||||
const RecordView = struct {
|
||||
id: i64,
|
||||
name: []const u8,
|
||||
rtype: []const u8,
|
||||
value: []const u8,
|
||||
ttl: u32,
|
||||
|
||||
fn from(row: local_repo.LocalRecordRow) RecordView {
|
||||
return .{
|
||||
.id = row.id,
|
||||
.name = row.name,
|
||||
.rtype = row.rtype.toDb(),
|
||||
.value = row.value,
|
||||
.ttl = row.ttl,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// local records: decisions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Publishes the rebuilt tables and then announces the change. The swap comes
|
||||
/// first because it is what makes the answer live; the seam only tells the rest
|
||||
/// of the server that something moved.
|
||||
///
|
||||
/// Callers hold `state.config_lock` across the database write and this call:
|
||||
/// the rebuild reads the generation the write produced, and the swap publishes
|
||||
/// in write order — a concurrent mutation cannot overwrite a newer generation
|
||||
/// with an older one.
|
||||
fn publish(state: *server.WebState, io: std.Io, arena: Allocator, database: *@import("../../storage/db.zig").Db) ?Failure {
|
||||
if (mutations.swapLocalTables(state, io, arena, database)) |failure| return failure;
|
||||
return mutations.reload(state, io);
|
||||
}
|
||||
|
||||
pub fn applyCreateRecord(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
arena: Allocator,
|
||||
item: model.LocalRecord,
|
||||
) error{OutOfMemory}!Created {
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return .{ .fail = failure },
|
||||
};
|
||||
if (try mutations.checkLocalRecord(arena, item)) |problem| return .{ .fail = .{ .invalid = problem } };
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
defer state.config_lock.unlock(io);
|
||||
|
||||
const id = local_repo.insertLocalRecordRow(database, item) catch |err|
|
||||
return .{ .fail = mutations.dbFailure(err, record_conflict) };
|
||||
if (publish(state, io, arena, database)) |failure| return .{ .fail = failure };
|
||||
return .{ .id = id };
|
||||
}
|
||||
|
||||
pub fn applyUpdateRecord(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
arena: Allocator,
|
||||
id: i64,
|
||||
item: model.LocalRecord,
|
||||
) error{OutOfMemory}!?Failure {
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return failure,
|
||||
};
|
||||
if (try mutations.checkLocalRecord(arena, item)) |problem| return .{ .invalid = problem };
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
defer state.config_lock.unlock(io);
|
||||
|
||||
local_repo.updateLocalRecord(database, id, item) catch |err|
|
||||
return mutations.dbFailure(err, record_conflict);
|
||||
return publish(state, io, arena, database);
|
||||
}
|
||||
|
||||
pub fn applyDeleteRecord(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure {
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return failure,
|
||||
};
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
defer state.config_lock.unlock(io);
|
||||
|
||||
local_repo.deleteLocalRecord(database, id) catch |err|
|
||||
return mutations.dbFailure(err, record_conflict);
|
||||
return publish(state, io, arena, database);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// forward zones: decisions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn applyCreateZone(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
arena: Allocator,
|
||||
item: model.ForwardZone,
|
||||
) error{OutOfMemory}!Created {
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return .{ .fail = failure },
|
||||
};
|
||||
if (try mutations.checkForwardZone(arena, item)) |problem| return .{ .fail = .{ .invalid = problem } };
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
defer state.config_lock.unlock(io);
|
||||
|
||||
const id = local_repo.insertForwardZoneRow(database, item) catch |err|
|
||||
return .{ .fail = mutations.dbFailure(err, zone_conflict) };
|
||||
if (publish(state, io, arena, database)) |failure| return .{ .fail = failure };
|
||||
return .{ .id = id };
|
||||
}
|
||||
|
||||
pub fn applyUpdateZone(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
arena: Allocator,
|
||||
id: i64,
|
||||
item: model.ForwardZone,
|
||||
) error{OutOfMemory}!?Failure {
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return failure,
|
||||
};
|
||||
if (try mutations.checkForwardZone(arena, item)) |problem| return .{ .invalid = problem };
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
defer state.config_lock.unlock(io);
|
||||
|
||||
local_repo.updateForwardZone(database, id, item) catch |err|
|
||||
return mutations.dbFailure(err, zone_conflict);
|
||||
return publish(state, io, arena, database);
|
||||
}
|
||||
|
||||
pub fn applyDeleteZone(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure {
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return failure,
|
||||
};
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
defer state.config_lock.unlock(io);
|
||||
|
||||
local_repo.deleteForwardZone(database, id) catch |err|
|
||||
return mutations.dbFailure(err, zone_conflict);
|
||||
return publish(state, io, arena, database);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// local records: routes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn listRecords(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
_ = io;
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return mutations.respondFailure(request, failure, "listing local records"),
|
||||
};
|
||||
|
||||
const rows = local_repo.listLocalRecordRows(database, request.arena) catch |err|
|
||||
return mutations.respondFailure(request, .{ .internal = err }, "listing local records");
|
||||
|
||||
const views = try request.arena.alloc(RecordView, rows.items.len);
|
||||
for (views, rows.items) |*view, row| view.* = .from(row);
|
||||
|
||||
return http_util.respondJson(request, .ok, .{ .local_records = views }, &.{});
|
||||
}
|
||||
|
||||
pub fn getRecord(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
_ = io;
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return mutations.respondFailure(request, failure, "reading a local record"),
|
||||
};
|
||||
|
||||
const row = local_repo.getLocalRecord(database, request.arena, request.id.?) catch |err|
|
||||
return mutations.respondFailure(request, .{ .internal = err }, "reading a local record");
|
||||
const found = row orelse return mutations.respondFailure(request, .not_found, "");
|
||||
|
||||
return http_util.respondJson(request, .ok, RecordView.from(found), &.{});
|
||||
}
|
||||
|
||||
pub fn createRecord(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
const parsed = http_util.parseBody(RecordBody, request) catch |err|
|
||||
return mutations.respondBadBody(request, err);
|
||||
const item = switch (toRecord(parsed.value)) {
|
||||
.fail => |failure| return mutations.respondFailure(request, failure, "creating a local record"),
|
||||
.record => |value| value,
|
||||
};
|
||||
|
||||
return switch (try applyCreateRecord(state, io, request.arena, item)) {
|
||||
.fail => |failure| mutations.respondFailure(request, failure, "creating a local record"),
|
||||
.id => |id| http_util.respondJson(request, .created, .{
|
||||
.id = id,
|
||||
.name = item.name,
|
||||
.rtype = item.rtype.toDb(),
|
||||
.value = item.value,
|
||||
.ttl = item.ttl,
|
||||
}, &.{}),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn updateRecord(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
const parsed = http_util.parseBody(RecordBody, request) catch |err|
|
||||
return mutations.respondBadBody(request, err);
|
||||
const item = switch (toRecord(parsed.value)) {
|
||||
.fail => |failure| return mutations.respondFailure(request, failure, "updating a local record"),
|
||||
.record => |value| value,
|
||||
};
|
||||
const id = request.id.?;
|
||||
|
||||
if (try applyUpdateRecord(state, io, request.arena, id, item)) |failure| {
|
||||
return mutations.respondFailure(request, failure, "updating a local record");
|
||||
}
|
||||
return http_util.respondJson(request, .ok, .{
|
||||
.id = id,
|
||||
.name = item.name,
|
||||
.rtype = item.rtype.toDb(),
|
||||
.value = item.value,
|
||||
.ttl = item.ttl,
|
||||
}, &.{});
|
||||
}
|
||||
|
||||
pub fn removeRecord(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
if (applyDeleteRecord(state, io, request.arena, request.id.?)) |failure| {
|
||||
return mutations.respondFailure(request, failure, "deleting a local record");
|
||||
}
|
||||
return http_util.respondEmpty(request, .no_content);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// forward zones: routes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn listZones(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
_ = io;
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return mutations.respondFailure(request, failure, "listing forward zones"),
|
||||
};
|
||||
|
||||
const rows = local_repo.listForwardZoneRows(database, request.arena) catch |err|
|
||||
return mutations.respondFailure(request, .{ .internal = err }, "listing forward zones");
|
||||
|
||||
return http_util.respondJson(request, .ok, .{ .forward_zones = rows.items }, &.{});
|
||||
}
|
||||
|
||||
pub fn getZone(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
_ = io;
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return mutations.respondFailure(request, failure, "reading a forward zone"),
|
||||
};
|
||||
|
||||
const row = local_repo.getForwardZone(database, request.arena, request.id.?) catch |err|
|
||||
return mutations.respondFailure(request, .{ .internal = err }, "reading a forward zone");
|
||||
const found = row orelse return mutations.respondFailure(request, .not_found, "");
|
||||
|
||||
return http_util.respondJson(request, .ok, found, &.{});
|
||||
}
|
||||
|
||||
pub fn createZone(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
const parsed = http_util.parseBody(ZoneBody, request) catch |err|
|
||||
return mutations.respondBadBody(request, err);
|
||||
const item: model.ForwardZone = .{ .zone = parsed.value.zone, .resolver = parsed.value.resolver };
|
||||
|
||||
return switch (try applyCreateZone(state, io, request.arena, item)) {
|
||||
.fail => |failure| mutations.respondFailure(request, failure, "creating a forward zone"),
|
||||
.id => |id| http_util.respondJson(request, .created, .{
|
||||
.id = id,
|
||||
.zone = item.zone,
|
||||
.resolver = item.resolver,
|
||||
}, &.{}),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn updateZone(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
const parsed = http_util.parseBody(ZoneBody, request) catch |err|
|
||||
return mutations.respondBadBody(request, err);
|
||||
const item: model.ForwardZone = .{ .zone = parsed.value.zone, .resolver = parsed.value.resolver };
|
||||
const id = request.id.?;
|
||||
|
||||
if (try applyUpdateZone(state, io, request.arena, id, item)) |failure| {
|
||||
return mutations.respondFailure(request, failure, "updating a forward zone");
|
||||
}
|
||||
return http_util.respondJson(request, .ok, .{
|
||||
.id = id,
|
||||
.zone = item.zone,
|
||||
.resolver = item.resolver,
|
||||
}, &.{});
|
||||
}
|
||||
|
||||
pub fn removeZone(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
if (applyDeleteZone(state, io, request.arena, request.id.?)) |failure| {
|
||||
return mutations.respondFailure(request, failure, "deleting a forward zone");
|
||||
}
|
||||
return http_util.respondEmpty(request, .no_content);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
const nas: model.LocalRecord = .{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.10", .ttl = 60 };
|
||||
const lan: model.ForwardZone = .{ .zone = "lan", .resolver = "udp://10.0.0.1:53" };
|
||||
|
||||
test "a created local record is answered by the published table" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
const created = try applyCreateRecord(&bench.state, bench.io(), bench.arena(), nas);
|
||||
try testing.expect(created == .id);
|
||||
try testing.expectEqual(@as(usize, 1), bench.reloads);
|
||||
|
||||
const handle = bench.tables.acquire(bench.io());
|
||||
defer handle.release(bench.io());
|
||||
try testing.expect(handle.records.hasName("nas.lan"));
|
||||
try testing.expectEqual(@as(usize, 1), handle.records.lookup("nas.lan", .a).len);
|
||||
}
|
||||
|
||||
test "an edited local record replaces what the table answers" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
const created = try applyCreateRecord(&bench.state, bench.io(), bench.arena(), nas);
|
||||
const failure = try applyUpdateRecord(&bench.state, bench.io(), bench.arena(), created.id, .{
|
||||
.name = "printer.lan",
|
||||
.rtype = .a,
|
||||
.value = "192.168.1.11",
|
||||
.ttl = 120,
|
||||
});
|
||||
try testing.expectEqual(@as(?Failure, null), failure);
|
||||
|
||||
const handle = bench.tables.acquire(bench.io());
|
||||
defer handle.release(bench.io());
|
||||
try testing.expect(!handle.records.hasName("nas.lan"));
|
||||
try testing.expect(handle.records.hasName("printer.lan"));
|
||||
}
|
||||
|
||||
test "a deleted local record leaves the published table empty" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
const created = try applyCreateRecord(&bench.state, bench.io(), bench.arena(), nas);
|
||||
try testing.expectEqual(
|
||||
@as(?Failure, null),
|
||||
applyDeleteRecord(&bench.state, bench.io(), bench.arena(), created.id),
|
||||
);
|
||||
|
||||
const handle = bench.tables.acquire(bench.io());
|
||||
defer handle.release(bench.io());
|
||||
try testing.expect(!handle.records.hasName("nas.lan"));
|
||||
try testing.expectEqual(@as(usize, 2), bench.reloads);
|
||||
}
|
||||
|
||||
test "a record value the validator refuses never reaches the database" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
const bad_value = try applyCreateRecord(&bench.state, bench.io(), bench.arena(), .{
|
||||
.name = "nas.lan",
|
||||
.rtype = .a,
|
||||
.value = "2001:db8::1",
|
||||
.ttl = 60,
|
||||
});
|
||||
try testing.expect(bad_value.fail == .invalid);
|
||||
|
||||
const bad_ttl = try applyCreateRecord(&bench.state, bench.io(), bench.arena(), .{
|
||||
.name = "nas.lan",
|
||||
.rtype = .a,
|
||||
.value = "192.168.1.10",
|
||||
.ttl = 0,
|
||||
});
|
||||
try testing.expect(bad_ttl.fail == .invalid);
|
||||
|
||||
try testing.expectEqual(@as(i64, 0), try bench.queryInt("SELECT count(*) FROM local_records"));
|
||||
try testing.expectEqual(@as(usize, 0), bench.reloads);
|
||||
}
|
||||
|
||||
test "the same record twice is a conflict" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
_ = try applyCreateRecord(&bench.state, bench.io(), bench.arena(), nas);
|
||||
const again = try applyCreateRecord(&bench.state, bench.io(), bench.arena(), nas);
|
||||
try testing.expectEqualStrings(record_conflict, again.fail.conflict);
|
||||
}
|
||||
|
||||
test "an id no record holds is a 404 on both update and delete" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
try testing.expectEqual(
|
||||
Failure.not_found,
|
||||
(try applyUpdateRecord(&bench.state, bench.io(), bench.arena(), 999, nas)).?,
|
||||
);
|
||||
try testing.expectEqual(
|
||||
Failure.not_found,
|
||||
applyDeleteRecord(&bench.state, bench.io(), bench.arena(), 999).?,
|
||||
);
|
||||
}
|
||||
|
||||
test "a created forward zone is matched by the published table" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
const created = try applyCreateZone(&bench.state, bench.io(), bench.arena(), lan);
|
||||
try testing.expect(created == .id);
|
||||
|
||||
const handle = bench.tables.acquire(bench.io());
|
||||
defer handle.release(bench.io());
|
||||
try testing.expect(handle.zones.match("nas.lan") != null);
|
||||
try testing.expect(handle.zones.match("example.test") == null);
|
||||
}
|
||||
|
||||
test "a resolver the validator refuses never reaches the database" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
const created = try applyCreateZone(&bench.state, bench.io(), bench.arena(), .{
|
||||
.zone = "lan",
|
||||
.resolver = "https://10.0.0.1",
|
||||
});
|
||||
try testing.expect(created.fail == .invalid);
|
||||
try testing.expectEqual(@as(i64, 0), try bench.queryInt("SELECT count(*) FROM forward_zones"));
|
||||
}
|
||||
|
||||
test "one zone cannot have two resolvers" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
_ = try applyCreateZone(&bench.state, bench.io(), bench.arena(), lan);
|
||||
const again = try applyCreateZone(&bench.state, bench.io(), bench.arena(), .{
|
||||
.zone = "lan",
|
||||
.resolver = "tcp://10.0.0.2:53",
|
||||
});
|
||||
try testing.expectEqualStrings(zone_conflict, again.fail.conflict);
|
||||
}
|
||||
|
||||
test "a deleted zone stops matching" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
const created = try applyCreateZone(&bench.state, bench.io(), bench.arena(), lan);
|
||||
try testing.expectEqual(
|
||||
@as(?Failure, null),
|
||||
applyDeleteZone(&bench.state, bench.io(), bench.arena(), created.id),
|
||||
);
|
||||
|
||||
const handle = bench.tables.acquire(bench.io());
|
||||
defer handle.release(bench.io());
|
||||
try testing.expect(handle.zones.match("nas.lan") == null);
|
||||
}
|
||||
|
||||
test "a record change is visible to a reader that acquires afterwards" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
const before = bench.tables.acquire(bench.io());
|
||||
try testing.expect(!before.records.hasName("nas.lan"));
|
||||
before.release(bench.io());
|
||||
|
||||
_ = try applyCreateRecord(&bench.state, bench.io(), bench.arena(), nas);
|
||||
|
||||
const after = bench.tables.acquire(bench.io());
|
||||
defer after.release(bench.io());
|
||||
try testing.expect(after.records.hasName("nas.lan"));
|
||||
}
|
||||
|
||||
test "an unknown record type is a 400 before anything is written" {
|
||||
try testing.expect(toRecord(.{
|
||||
.name = "nas.lan",
|
||||
.rtype = "MX",
|
||||
.value = "mail.lan",
|
||||
}).fail == .invalid);
|
||||
|
||||
const good = toRecord(.{ .name = "nas.lan", .rtype = "CNAME", .value = "other.lan" });
|
||||
try testing.expectEqual(model.RecordType.cname, good.record.rtype);
|
||||
try testing.expectEqual(@as(u32, 300), good.record.ttl);
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
//! `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,
|
||||
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.
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
//! 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 config connection, or the 503 a state without one earns.
|
||||
pub fn configDb(state: *server.WebState) union(enum) { database: *db.Db, fail: Failure } {
|
||||
if (state.config_db) |database| return .{ .database = database };
|
||||
return .{ .fail = .{ .unavailable = "no configuration database" } };
|
||||
}
|
||||
|
||||
pub fn nowSeconds(io: std.Io) i64 {
|
||||
return std.Io.Clock.real.now(io).toSeconds();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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.
|
||||
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 problem's text,
|
||||
/// or null when the candidate is valid. The text is arena-allocated.
|
||||
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 => {},
|
||||
};
|
||||
if (diags.problems.items.len == 0) return null;
|
||||
const problem = diags.problems.items[0];
|
||||
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 checkClientIp(arena: Allocator, ip: []const u8) error{OutOfMemory}!?[]const u8 {
|
||||
var cfg = skeleton(&default_groups);
|
||||
cfg.clients = &.{.{ .ip = ip, .group = skeleton_group }};
|
||||
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 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 checkClientIp(arena, "192.168.1.10"));
|
||||
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" }),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
//! `/api/pause` — the global pause of filtering (ruling 15).
|
||||
//!
|
||||
//! Pausing suspends filtering only: local records, forward zones, the cache,
|
||||
//! the upstream and the query log all keep working (milestone-7 ruling 18).
|
||||
//!
|
||||
//! `until` is null both while filtering is on and while an indefinite pause is
|
||||
//! in force, so `paused` is the field that disambiguates the two. The pause is
|
||||
//! deliberately not persisted, so a restart resumes filtering.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const http_util = @import("../http_util.zig");
|
||||
const mutations = @import("mutations.zig");
|
||||
const pause_mod = @import("../../server/pause.zig");
|
||||
const server = @import("../server.zig");
|
||||
|
||||
const Failure = mutations.Failure;
|
||||
const Request = http_util.Request;
|
||||
const HandlerError = http_util.HandlerError;
|
||||
|
||||
/// Longest pause a single request may set: one week. An operator who wants
|
||||
/// longer wants the indefinite pause, which is one word shorter to ask for.
|
||||
pub const max_duration_seconds: u32 = 7 * 24 * 3600;
|
||||
|
||||
const Body = struct {
|
||||
paused: bool,
|
||||
duration_seconds: ?u32 = null,
|
||||
};
|
||||
|
||||
pub const View = struct {
|
||||
paused: bool,
|
||||
/// Unix seconds when filtering resumes; null while unpaused and null while
|
||||
/// the pause is indefinite.
|
||||
until: ?i64,
|
||||
};
|
||||
|
||||
/// The state a `Pause` is in at `now_s`, in the shape the API answers with.
|
||||
pub fn view(pause: *const pause_mod.Pause, now_s: i64) View {
|
||||
const until = pause.until.load(.monotonic);
|
||||
if (until == 0) return .{ .paused = false, .until = null };
|
||||
if (until < 0) return .{ .paused = true, .until = null };
|
||||
if (now_s >= until) return .{ .paused = false, .until = null };
|
||||
return .{ .paused = true, .until = until };
|
||||
}
|
||||
|
||||
pub fn apply(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
body: Body,
|
||||
) union(enum) { view: View, fail: Failure } {
|
||||
const pause = state.pause orelse return .{ .fail = .{ .unavailable = "filtering is not running" } };
|
||||
if (body.duration_seconds) |seconds| {
|
||||
if (!body.paused) return .{ .fail = .{
|
||||
.invalid = "duration_seconds is only meaningful with paused = true",
|
||||
} };
|
||||
if (seconds == 0 or seconds > max_duration_seconds) return .{ .fail = .{
|
||||
.invalid = "duration_seconds must be 1 to 604800",
|
||||
} };
|
||||
}
|
||||
|
||||
const now_s = mutations.nowSeconds(io);
|
||||
if (body.paused) {
|
||||
pause.pauseFor(now_s, body.duration_seconds);
|
||||
} else {
|
||||
pause.unpause();
|
||||
}
|
||||
return .{ .view = view(pause, now_s) };
|
||||
}
|
||||
|
||||
pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
const pause = state.pause orelse
|
||||
return mutations.respondFailure(request, .{ .unavailable = "filtering is not running" }, "");
|
||||
|
||||
return http_util.respondJson(request, .ok, view(pause, mutations.nowSeconds(io)), &.{});
|
||||
}
|
||||
|
||||
pub fn post(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
const parsed = http_util.parseBody(Body, request) catch |err|
|
||||
return mutations.respondBadBody(request, err);
|
||||
|
||||
return switch (apply(state, io, parsed.value)) {
|
||||
.fail => |failure| mutations.respondFailure(request, failure, "pausing"),
|
||||
.view => |current| http_util.respondJson(request, .ok, current, &.{}),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "an unpaused server reports neither a pause nor an expiry" {
|
||||
const pause: pause_mod.Pause = .{};
|
||||
const current = view(&pause, 1_700_000_000);
|
||||
try testing.expect(!current.paused);
|
||||
try testing.expectEqual(@as(?i64, null), current.until);
|
||||
}
|
||||
|
||||
test "an indefinite pause reports paused with no expiry" {
|
||||
var pause: pause_mod.Pause = .{};
|
||||
pause.pauseFor(1_000, null);
|
||||
const current = view(&pause, 1_000);
|
||||
try testing.expect(current.paused);
|
||||
try testing.expectEqual(@as(?i64, null), current.until);
|
||||
}
|
||||
|
||||
test "a timed pause reports the second filtering comes back" {
|
||||
var pause: pause_mod.Pause = .{};
|
||||
pause.pauseFor(1_000, 60);
|
||||
try testing.expectEqual(@as(?i64, 1_060), view(&pause, 1_000).until);
|
||||
// Past its expiry it reads as unpaused, exactly as the query path sees it.
|
||||
try testing.expect(!view(&pause, 1_060).paused);
|
||||
try testing.expectEqual(@as(?i64, null), view(&pause, 1_060).until);
|
||||
}
|
||||
|
||||
test "a pause round trip goes through the running pause flag" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
var pause: pause_mod.Pause = .{};
|
||||
bench.state.pause = &pause;
|
||||
|
||||
const paused = apply(&bench.state, bench.io(), .{ .paused = true, .duration_seconds = 60 });
|
||||
try testing.expect(paused.view.paused);
|
||||
try testing.expect(pause.isPaused(mutations.nowSeconds(bench.io())));
|
||||
try testing.expect(paused.view.until.? > mutations.nowSeconds(bench.io()));
|
||||
|
||||
const resumed = apply(&bench.state, bench.io(), .{ .paused = false });
|
||||
try testing.expect(!resumed.view.paused);
|
||||
try testing.expect(!pause.isPaused(mutations.nowSeconds(bench.io())));
|
||||
}
|
||||
|
||||
test "an indefinite pause set through the API never expires" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
var pause: pause_mod.Pause = .{};
|
||||
bench.state.pause = &pause;
|
||||
|
||||
const paused = apply(&bench.state, bench.io(), .{ .paused = true });
|
||||
try testing.expect(paused.view.paused);
|
||||
try testing.expectEqual(@as(?i64, null), paused.view.until);
|
||||
try testing.expect(pause.isPaused(std.math.maxInt(i64) - 1));
|
||||
}
|
||||
|
||||
test "a duration outside the range, or one sent with paused false, is refused" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
var pause: pause_mod.Pause = .{};
|
||||
bench.state.pause = &pause;
|
||||
|
||||
try testing.expect(apply(&bench.state, bench.io(), .{
|
||||
.paused = true,
|
||||
.duration_seconds = 0,
|
||||
}).fail == .invalid);
|
||||
try testing.expect(apply(&bench.state, bench.io(), .{
|
||||
.paused = true,
|
||||
.duration_seconds = max_duration_seconds + 1,
|
||||
}).fail == .invalid);
|
||||
try testing.expect(apply(&bench.state, bench.io(), .{
|
||||
.paused = false,
|
||||
.duration_seconds = 60,
|
||||
}).fail == .invalid);
|
||||
|
||||
try testing.expect(!pause.isPaused(mutations.nowSeconds(bench.io())));
|
||||
}
|
||||
|
||||
test "pausing a server that has no pause flag is unavailable" {
|
||||
var state: server.WebState = .{ .gpa = testing.allocator };
|
||||
try testing.expect(apply(&state, undefined, .{ .paused = true }).fail == .unavailable);
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
//! `GET /api/queries` — the query log, newest first (ruling 11).
|
||||
//!
|
||||
//! Keyset pagination rather than an offset: the table is append-only and the
|
||||
//! UI reads the head of it, so `id < before` is one index seek no matter how
|
||||
//! deep the client has scrolled, and rows arriving between two pages cannot
|
||||
//! shift the window and duplicate a row.
|
||||
//!
|
||||
//! Filter parsing is separated from fetching, because parsing is where the
|
||||
//! input validation of PLAN §19 lives and it is worth testing on its own. Every
|
||||
//! value is length-capped here and bound as a SQL parameter by the repository;
|
||||
//! nothing this file reads is ever concatenated into a statement.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const db = @import("../../storage/db.zig");
|
||||
const http_util = @import("../http_util.zig");
|
||||
const queries_repo = @import("../../storage/repositories/queries_repo.zig");
|
||||
const server = @import("../server.zig");
|
||||
|
||||
const log = std.log.scoped(.web_queries);
|
||||
|
||||
pub const default_limit: u32 = 100;
|
||||
pub const max_limit: u32 = queries_repo.max_limit;
|
||||
|
||||
/// A domain filter longer than the longest legal domain name matches nothing.
|
||||
pub const max_domain_len = 253;
|
||||
|
||||
/// Long enough for an IPv6 address with a zone identifier.
|
||||
pub const max_client_len = 64;
|
||||
|
||||
/// Where the two string filters are copied to. The parsed filter borrows them,
|
||||
/// so it must not outlive the buffers — in the handler both live in the same
|
||||
/// stack frame.
|
||||
pub const Buffers = struct {
|
||||
domain: [max_domain_len]u8 = undefined,
|
||||
client: [max_client_len]u8 = undefined,
|
||||
};
|
||||
|
||||
pub const Page = struct {
|
||||
queries: []const queries_repo.QueryRow,
|
||||
/// The cursor for the next page, or null when this page is the last one.
|
||||
next_before: ?i64,
|
||||
};
|
||||
|
||||
pub const FilterError = error{
|
||||
BadLimit,
|
||||
BadBefore,
|
||||
BadDomain,
|
||||
BadClient,
|
||||
BadBlocked,
|
||||
BadSince,
|
||||
BadUntil,
|
||||
};
|
||||
|
||||
/// Ruling 11's query string. An absent parameter drops the filter; a malformed
|
||||
/// one is a 400 rather than a filter silently left off, which would answer a
|
||||
/// question the client did not ask.
|
||||
pub fn parseFilter(query: []const u8, buffers: *Buffers) FilterError!queries_repo.QueryFilter {
|
||||
var filter: queries_repo.QueryFilter = .{};
|
||||
|
||||
if (http_util.queryInt(u32, query, "limit") catch return error.BadLimit) |limit| {
|
||||
if (limit == 0 or limit > max_limit) return error.BadLimit;
|
||||
filter.limit = limit;
|
||||
}
|
||||
|
||||
if (http_util.queryInt(i64, query, "before") catch return error.BadBefore) |before| {
|
||||
// Row ids are positive, so a non-positive cursor is a client bug, not
|
||||
// an empty page.
|
||||
if (before <= 0) return error.BadBefore;
|
||||
filter.before = before;
|
||||
}
|
||||
|
||||
if (http_util.queryValue(query, "domain", &buffers.domain) catch return error.BadDomain) |domain| {
|
||||
if (domain.len != 0) filter.domain_substring = domain;
|
||||
}
|
||||
|
||||
if (http_util.queryValue(query, "client", &buffers.client) catch return error.BadClient) |client| {
|
||||
if (client.len != 0) filter.client = client;
|
||||
}
|
||||
|
||||
filter.blocked = http_util.queryBool(query, "blocked") catch return error.BadBlocked;
|
||||
filter.since = http_util.queryInt(i64, query, "since") catch return error.BadSince;
|
||||
filter.until = http_util.queryInt(i64, query, "until") catch return error.BadUntil;
|
||||
|
||||
return filter;
|
||||
}
|
||||
|
||||
pub fn message(err: FilterError) []const u8 {
|
||||
return switch (err) {
|
||||
error.BadLimit => "limit must be between 1 and 1000",
|
||||
error.BadBefore => "before must be a positive row id",
|
||||
error.BadDomain => "domain is not a valid filter",
|
||||
error.BadClient => "client is not a valid filter",
|
||||
error.BadBlocked => "blocked must be true or false",
|
||||
error.BadSince => "since must be a unix timestamp in seconds",
|
||||
error.BadUntil => "until must be a unix timestamp in seconds",
|
||||
};
|
||||
}
|
||||
|
||||
/// A full page carries a cursor and a short one does not: a client stops when
|
||||
/// `next_before` is null, without a count query telling it how many rows exist.
|
||||
pub fn page(
|
||||
database: *db.Db,
|
||||
arena: Allocator,
|
||||
filter: queries_repo.QueryFilter,
|
||||
) db.Error!Page {
|
||||
const rows = try queries_repo.selectQueries(database, arena, filter);
|
||||
const full = rows.items.len == @min(filter.limit, max_limit);
|
||||
return .{
|
||||
.queries = rows.items,
|
||||
.next_before = if (full and rows.items.len != 0) rows.items[rows.items.len - 1].id else null,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn list(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
request: *http_util.Request,
|
||||
) http_util.HandlerError!void {
|
||||
_ = io;
|
||||
|
||||
var buffers: Buffers = .{};
|
||||
const filter = parseFilter(request.query, &buffers) catch |err| {
|
||||
return http_util.respondError(request, .bad_request, message(err));
|
||||
};
|
||||
|
||||
const database = state.querylog_db orelse
|
||||
return http_util.respondError(request, .service_unavailable, "query log unavailable");
|
||||
|
||||
const result = page(database, request.arena, filter) catch |err| {
|
||||
// The one thing this handler logs: a database fault is a property of
|
||||
// the box, not of the request, and the client is told nothing about it.
|
||||
log.warn("query log read failed: {s}", .{@errorName(err)});
|
||||
return http_util.respondError(request, .internal_server_error, "internal error");
|
||||
};
|
||||
|
||||
return http_util.respondJson(request, .ok, result, &.{});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const querylog_schema = @import("../../storage/querylog_schema.zig");
|
||||
const testing = std.testing;
|
||||
|
||||
test "an empty query string is the default page" {
|
||||
var buffers: Buffers = .{};
|
||||
const filter = try parseFilter("", &buffers);
|
||||
try testing.expectEqual(default_limit, filter.limit);
|
||||
try testing.expectEqual(@as(?i64, null), filter.before);
|
||||
try testing.expectEqual(@as(?[]const u8, null), filter.domain_substring);
|
||||
try testing.expectEqual(@as(?bool, null), filter.blocked);
|
||||
}
|
||||
|
||||
test "every filter reaches the repository untouched" {
|
||||
var buffers: Buffers = .{};
|
||||
const filter = try parseFilter(
|
||||
"limit=250&before=900&domain=ads.example&client=192.0.2.10&blocked=true&since=100&until=200",
|
||||
&buffers,
|
||||
);
|
||||
try testing.expectEqual(@as(u32, 250), filter.limit);
|
||||
try testing.expectEqual(@as(?i64, 900), filter.before);
|
||||
try testing.expectEqualStrings("ads.example", filter.domain_substring.?);
|
||||
try testing.expectEqualStrings("192.0.2.10", filter.client.?);
|
||||
try testing.expectEqual(@as(?bool, true), filter.blocked);
|
||||
try testing.expectEqual(@as(?i64, 100), filter.since);
|
||||
try testing.expectEqual(@as(?i64, 200), filter.until);
|
||||
}
|
||||
|
||||
test "an empty string filter is no filter at all" {
|
||||
var buffers: Buffers = .{};
|
||||
const filter = try parseFilter("domain=&client=", &buffers);
|
||||
try testing.expectEqual(@as(?[]const u8, null), filter.domain_substring);
|
||||
try testing.expectEqual(@as(?[]const u8, null), filter.client);
|
||||
}
|
||||
|
||||
test "each malformed parameter names itself in a 400" {
|
||||
var buffers: Buffers = .{};
|
||||
try testing.expectError(error.BadLimit, parseFilter("limit=0", &buffers));
|
||||
try testing.expectError(error.BadLimit, parseFilter("limit=1001", &buffers));
|
||||
try testing.expectError(error.BadLimit, parseFilter("limit=ten", &buffers));
|
||||
try testing.expectError(error.BadBefore, parseFilter("before=0", &buffers));
|
||||
try testing.expectError(error.BadBefore, parseFilter("before=-4", &buffers));
|
||||
try testing.expectError(error.BadBlocked, parseFilter("blocked=maybe", &buffers));
|
||||
try testing.expectError(error.BadSince, parseFilter("since=yesterday", &buffers));
|
||||
try testing.expectError(error.BadUntil, parseFilter("until=", &buffers));
|
||||
try testing.expectError(error.BadDomain, parseFilter("domain=%zz", &buffers));
|
||||
|
||||
var long: [max_domain_len + 8]u8 = @splat('a');
|
||||
var text: std.ArrayList(u8) = .empty;
|
||||
defer text.deinit(testing.allocator);
|
||||
try text.appendSlice(testing.allocator, "domain=");
|
||||
try text.appendSlice(testing.allocator, &long);
|
||||
try testing.expectError(error.BadDomain, parseFilter(text.items, &buffers));
|
||||
}
|
||||
|
||||
test "the limit cap is the repository's" {
|
||||
var buffers: Buffers = .{};
|
||||
try testing.expectEqual(max_limit, (try parseFilter("limit=1000", &buffers)).limit);
|
||||
try testing.expectEqual(@as(u32, 1000), queries_repo.max_limit);
|
||||
}
|
||||
|
||||
fn openLog() !db.Db {
|
||||
var database = try db.Db.open(":memory:", .{ .mode = .memory });
|
||||
errdefer database.close();
|
||||
try db.applyPragmas(&database, .{});
|
||||
try database.exec(querylog_schema.ddl);
|
||||
return database;
|
||||
}
|
||||
|
||||
fn seed(database: *db.Db, count: usize) !void {
|
||||
var writer = try queries_repo.BatchWriter.init(database);
|
||||
defer writer.deinit();
|
||||
var rows: [16]queries_repo.Row = undefined;
|
||||
for (rows[0..count], 0..) |*row, i| {
|
||||
row.* = .{
|
||||
.timestamp = 1_700_000_000 + @as(i64, @intCast(i)),
|
||||
.domain = if (i % 2 == 0) "ads.example" else "safe.example",
|
||||
.client_ip = "192.0.2.10",
|
||||
.qtype = 1,
|
||||
.blocked = i % 2 == 0,
|
||||
.block_reason = if (i % 2 == 0) "blocklist_domain" else null,
|
||||
.response_time_us = 500,
|
||||
.cache_hit = false,
|
||||
.upstream = null,
|
||||
};
|
||||
}
|
||||
try writer.writeBatch(rows[0..count]);
|
||||
}
|
||||
|
||||
test "a full page carries a cursor and the last page does not" {
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
try seed(&database, 5);
|
||||
|
||||
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
|
||||
const first = try page(&database, arena.allocator(), .{ .limit = 2 });
|
||||
try testing.expectEqual(@as(usize, 2), first.queries.len);
|
||||
try testing.expectEqual(first.queries[1].id, first.next_before.?);
|
||||
// Newest first.
|
||||
try testing.expect(first.queries[0].id > first.queries[1].id);
|
||||
|
||||
const second = try page(&database, arena.allocator(), .{ .limit = 2, .before = first.next_before });
|
||||
try testing.expect(second.queries[0].id < first.queries[1].id);
|
||||
|
||||
const third = try page(&database, arena.allocator(), .{ .limit = 2, .before = second.next_before });
|
||||
try testing.expectEqual(@as(usize, 1), third.queries.len);
|
||||
try testing.expectEqual(@as(?i64, null), third.next_before);
|
||||
}
|
||||
|
||||
test "an empty result is a page with no cursor" {
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
|
||||
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
|
||||
const empty = try page(&database, arena.allocator(), .{});
|
||||
try testing.expectEqual(@as(usize, 0), empty.queries.len);
|
||||
try testing.expectEqual(@as(?i64, null), empty.next_before);
|
||||
}
|
||||
|
||||
test "the parsed filters narrow the rows the page returns" {
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
try seed(&database, 6);
|
||||
|
||||
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
|
||||
var buffers: Buffers = .{};
|
||||
const blocked = try page(
|
||||
&database,
|
||||
arena.allocator(),
|
||||
try parseFilter("blocked=true", &buffers),
|
||||
);
|
||||
try testing.expectEqual(@as(usize, 3), blocked.queries.len);
|
||||
for (blocked.queries) |row| try testing.expect(row.blocked);
|
||||
|
||||
const by_domain = try page(
|
||||
&database,
|
||||
arena.allocator(),
|
||||
try parseFilter("domain=safe", &buffers),
|
||||
);
|
||||
try testing.expectEqual(@as(usize, 3), by_domain.queries.len);
|
||||
for (by_domain.queries) |row| try testing.expectEqualStrings("safe.example", row.domain);
|
||||
|
||||
const nobody = try page(
|
||||
&database,
|
||||
arena.allocator(),
|
||||
try parseFilter("client=198.51.100.1", &buffers),
|
||||
);
|
||||
try testing.expectEqual(@as(usize, 0), nobody.queries.len);
|
||||
}
|
||||
|
||||
test "the page serializes as the envelope ruling 11 defines" {
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
try seed(&database, 1);
|
||||
|
||||
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
|
||||
const result = try page(&database, arena.allocator(), .{ .limit = 100 });
|
||||
var allocating: std.Io.Writer.Allocating = .init(testing.allocator);
|
||||
defer allocating.deinit();
|
||||
try std.json.Stringify.value(result, .{}, &allocating.writer);
|
||||
const text = allocating.written();
|
||||
|
||||
try testing.expect(std.mem.startsWith(u8, text, "{\"queries\":["));
|
||||
try testing.expect(std.mem.endsWith(u8, text, "\"next_before\":null}"));
|
||||
for ([_][]const u8{
|
||||
"\"id\":", "\"ts\":", "\"domain\":", "\"client_ip\":",
|
||||
"\"qtype\":", "\"blocked\":", "\"cache_hit\":", "\"upstream\":",
|
||||
"\"upstream\":", "\"response_time_us\":", "\"block_reason\":",
|
||||
}) |field| {
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, field));
|
||||
}
|
||||
// W1's ruling: a NULL column reads as "", and "" stays "" on the wire.
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "\"upstream\":\"\""));
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
//! `/api/rules` — the per-group allow and block rules.
|
||||
//!
|
||||
//! A rule names its group by row id, not by name: the API identifies every
|
||||
//! resource by id, and a `group_id` no group holds is then the foreign-key
|
||||
//! violation it is (409) rather than a lookup that quietly writes nothing.
|
||||
//!
|
||||
//! `kind` and `action` travel as the words the database stores (`exact` /
|
||||
//! `wildcard`, `allow` / `block`), so one vocabulary describes a rule in the
|
||||
//! config file, in the database and on the wire.
|
||||
//!
|
||||
//! Rules take effect live: the write is followed by the reload seam, and the
|
||||
//! next query is matched against the new snapshot (ruling 12).
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const http_util = @import("../http_util.zig");
|
||||
const model = @import("../../config/model.zig");
|
||||
const mutations = @import("mutations.zig");
|
||||
const rules_repo = @import("../../storage/repositories/rules_repo.zig");
|
||||
const server = @import("../server.zig");
|
||||
|
||||
const Failure = mutations.Failure;
|
||||
const Request = http_util.Request;
|
||||
const HandlerError = http_util.HandlerError;
|
||||
|
||||
const group_conflict = "that group does not exist";
|
||||
|
||||
const Body = struct {
|
||||
group_id: i64,
|
||||
pattern: []const u8,
|
||||
kind: []const u8,
|
||||
action: []const u8,
|
||||
};
|
||||
|
||||
const Created = union(enum) { id: i64, fail: Failure };
|
||||
|
||||
/// A body's `kind` and `action` decoded, or the 400 that says which word was
|
||||
/// not understood.
|
||||
fn toInput(body: Body) union(enum) { input: rules_repo.RuleInput, fail: Failure } {
|
||||
const kind = model.RuleKind.fromDb(body.kind) orelse
|
||||
return .{ .fail = .{ .invalid = "kind must be 'exact' or 'wildcard'" } };
|
||||
const action = model.RuleAction.fromDb(body.action) orelse
|
||||
return .{ .fail = .{ .invalid = "action must be 'allow' or 'block'" } };
|
||||
return .{ .input = .{
|
||||
.group_id = body.group_id,
|
||||
.pattern = body.pattern,
|
||||
.kind = kind,
|
||||
.action = action,
|
||||
} };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// decisions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn applyCreate(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
arena: Allocator,
|
||||
item: rules_repo.RuleInput,
|
||||
) error{OutOfMemory}!Created {
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return .{ .fail = failure },
|
||||
};
|
||||
if (try mutations.checkRule(arena, item.pattern, item.kind)) |problem| {
|
||||
return .{ .fail = .{ .invalid = problem } };
|
||||
}
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
const inserted = rules_repo.insertRuleRow(database, item, mutations.nowSeconds(io));
|
||||
state.config_lock.unlock(io);
|
||||
|
||||
const id = inserted catch |err| return .{ .fail = mutations.dbFailure(err, group_conflict) };
|
||||
if (mutations.reload(state, io)) |failure| return .{ .fail = failure };
|
||||
return .{ .id = id };
|
||||
}
|
||||
|
||||
pub fn applyUpdate(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
arena: Allocator,
|
||||
id: i64,
|
||||
item: rules_repo.RuleInput,
|
||||
) error{OutOfMemory}!?Failure {
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return failure,
|
||||
};
|
||||
if (try mutations.checkRule(arena, item.pattern, item.kind)) |problem| {
|
||||
return .{ .invalid = problem };
|
||||
}
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
const written = rules_repo.updateRule(database, id, item);
|
||||
state.config_lock.unlock(io);
|
||||
|
||||
written catch |err| return mutations.dbFailure(err, group_conflict);
|
||||
return mutations.reload(state, io);
|
||||
}
|
||||
|
||||
pub fn applyDelete(state: *server.WebState, io: std.Io, id: i64) ?Failure {
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return failure,
|
||||
};
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
const written = rules_repo.deleteRule(database, id);
|
||||
state.config_lock.unlock(io);
|
||||
|
||||
written catch |err| return mutations.dbFailure(err, group_conflict);
|
||||
return mutations.reload(state, io);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// routes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The wire shape of a rule: the row with its enums spelled the way the
|
||||
/// database spells them.
|
||||
const RuleView = struct {
|
||||
id: i64,
|
||||
group_id: i64,
|
||||
group: []const u8,
|
||||
pattern: []const u8,
|
||||
kind: []const u8,
|
||||
action: []const u8,
|
||||
created_at: i64,
|
||||
|
||||
fn from(row: rules_repo.RuleRow) RuleView {
|
||||
return .{
|
||||
.id = row.id,
|
||||
.group_id = row.group_id,
|
||||
.group = row.group,
|
||||
.pattern = row.pattern,
|
||||
.kind = row.kind.toDb(),
|
||||
.action = row.action.toDb(),
|
||||
.created_at = row.created_at,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
pub fn list(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
_ = io;
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return mutations.respondFailure(request, failure, "listing rules"),
|
||||
};
|
||||
|
||||
const rows = rules_repo.listRuleRows(database, request.arena) catch |err|
|
||||
return mutations.respondFailure(request, .{ .internal = err }, "listing rules");
|
||||
|
||||
const views = try request.arena.alloc(RuleView, rows.items.len);
|
||||
for (views, rows.items) |*view, row| view.* = .from(row);
|
||||
|
||||
return http_util.respondJson(request, .ok, .{ .rules = views }, &.{});
|
||||
}
|
||||
|
||||
pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
_ = io;
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return mutations.respondFailure(request, failure, "reading a rule"),
|
||||
};
|
||||
|
||||
const row = rules_repo.getRule(database, request.arena, request.id.?) catch |err|
|
||||
return mutations.respondFailure(request, .{ .internal = err }, "reading a rule");
|
||||
const found = row orelse return mutations.respondFailure(request, .not_found, "");
|
||||
|
||||
return http_util.respondJson(request, .ok, RuleView.from(found), &.{});
|
||||
}
|
||||
|
||||
pub fn create(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
const parsed = http_util.parseBody(Body, request) catch |err|
|
||||
return mutations.respondBadBody(request, err);
|
||||
const item = switch (toInput(parsed.value)) {
|
||||
.fail => |failure| return mutations.respondFailure(request, failure, "creating a rule"),
|
||||
.input => |value| value,
|
||||
};
|
||||
|
||||
return switch (try applyCreate(state, io, request.arena, item)) {
|
||||
.fail => |failure| mutations.respondFailure(request, failure, "creating a rule"),
|
||||
.id => |id| http_util.respondJson(request, .created, .{
|
||||
.id = id,
|
||||
.group_id = item.group_id,
|
||||
.pattern = item.pattern,
|
||||
.kind = item.kind.toDb(),
|
||||
.action = item.action.toDb(),
|
||||
}, &.{}),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn update(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
const parsed = http_util.parseBody(Body, request) catch |err|
|
||||
return mutations.respondBadBody(request, err);
|
||||
const item = switch (toInput(parsed.value)) {
|
||||
.fail => |failure| return mutations.respondFailure(request, failure, "updating a rule"),
|
||||
.input => |value| value,
|
||||
};
|
||||
const id = request.id.?;
|
||||
|
||||
if (try applyUpdate(state, io, request.arena, id, item)) |failure| {
|
||||
return mutations.respondFailure(request, failure, "updating a rule");
|
||||
}
|
||||
return http_util.respondJson(request, .ok, .{
|
||||
.id = id,
|
||||
.group_id = item.group_id,
|
||||
.pattern = item.pattern,
|
||||
.kind = item.kind.toDb(),
|
||||
.action = item.action.toDb(),
|
||||
}, &.{});
|
||||
}
|
||||
|
||||
pub fn remove(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
if (applyDelete(state, io, request.id.?)) |failure| {
|
||||
return mutations.respondFailure(request, failure, "deleting a rule");
|
||||
}
|
||||
return http_util.respondEmpty(request, .no_content);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
const block_ads: rules_repo.RuleInput = .{
|
||||
.group_id = 1,
|
||||
.pattern = "ads.example",
|
||||
.kind = .exact,
|
||||
.action = .block,
|
||||
};
|
||||
|
||||
test "a created rule is stored with the clock's created_at and reloads" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
const created = try applyCreate(&bench.state, bench.io(), bench.arena(), block_ads);
|
||||
try testing.expectEqual(@as(usize, 1), bench.reloads);
|
||||
|
||||
const row = (try rules_repo.getRule(&bench.database, bench.arena(), created.id)).?;
|
||||
try testing.expectEqualStrings("ads.example", row.pattern);
|
||||
try testing.expectEqual(model.RuleAction.block, row.action);
|
||||
try testing.expectEqualStrings("default", row.group);
|
||||
try testing.expect(row.created_at > 0);
|
||||
}
|
||||
|
||||
test "a pattern the validator refuses never reaches the database" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
const starred = try applyCreate(&bench.state, bench.io(), bench.arena(), .{
|
||||
.group_id = 1,
|
||||
.pattern = "ads.*.example",
|
||||
.kind = .exact,
|
||||
.action = .block,
|
||||
});
|
||||
try testing.expect(starred.fail == .invalid);
|
||||
|
||||
const starless = try applyCreate(&bench.state, bench.io(), bench.arena(), .{
|
||||
.group_id = 1,
|
||||
.pattern = "ads.example",
|
||||
.kind = .wildcard,
|
||||
.action = .allow,
|
||||
});
|
||||
try testing.expect(starless.fail == .invalid);
|
||||
|
||||
try testing.expectEqual(@as(i64, 0), try bench.queryInt("SELECT count(*) FROM rules"));
|
||||
try testing.expectEqual(@as(usize, 0), bench.reloads);
|
||||
}
|
||||
|
||||
test "a group id no group holds is a conflict" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
const created = try applyCreate(&bench.state, bench.io(), bench.arena(), .{
|
||||
.group_id = 404,
|
||||
.pattern = "ads.example",
|
||||
.kind = .exact,
|
||||
.action = .block,
|
||||
});
|
||||
try testing.expectEqualStrings(group_conflict, created.fail.conflict);
|
||||
try testing.expectEqual(@as(usize, 0), bench.reloads);
|
||||
}
|
||||
|
||||
test "an edited rule keeps its created_at" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
const created = try applyCreate(&bench.state, bench.io(), bench.arena(), block_ads);
|
||||
const before = (try rules_repo.getRule(&bench.database, bench.arena(), created.id)).?.created_at;
|
||||
|
||||
const failure = try applyUpdate(&bench.state, bench.io(), bench.arena(), created.id, .{
|
||||
.group_id = 1,
|
||||
.pattern = "*.ads.example",
|
||||
.kind = .wildcard,
|
||||
.action = .allow,
|
||||
});
|
||||
try testing.expectEqual(@as(?Failure, null), failure);
|
||||
|
||||
const row = (try rules_repo.getRule(&bench.database, bench.arena(), created.id)).?;
|
||||
try testing.expectEqualStrings("*.ads.example", row.pattern);
|
||||
try testing.expectEqual(model.RuleKind.wildcard, row.kind);
|
||||
try testing.expectEqual(before, row.created_at);
|
||||
try testing.expectEqual(@as(usize, 2), bench.reloads);
|
||||
}
|
||||
|
||||
test "an id no rule holds is a 404 on both update and delete" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
try testing.expectEqual(
|
||||
Failure.not_found,
|
||||
(try applyUpdate(&bench.state, bench.io(), bench.arena(), 999, block_ads)).?,
|
||||
);
|
||||
try testing.expectEqual(Failure.not_found, applyDelete(&bench.state, bench.io(), 999).?);
|
||||
try testing.expectEqual(@as(usize, 0), bench.reloads);
|
||||
}
|
||||
|
||||
test "a deleted rule is gone and the change is announced" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
const created = try applyCreate(&bench.state, bench.io(), bench.arena(), block_ads);
|
||||
try testing.expectEqual(@as(?Failure, null), applyDelete(&bench.state, bench.io(), created.id));
|
||||
try testing.expectEqual(@as(i64, 0), try bench.queryInt("SELECT count(*) FROM rules"));
|
||||
try testing.expectEqual(@as(usize, 2), bench.reloads);
|
||||
}
|
||||
|
||||
test "an unknown kind or action is a 400 before anything is written" {
|
||||
try testing.expect(toInput(.{
|
||||
.group_id = 1,
|
||||
.pattern = "ads.example",
|
||||
.kind = "regex",
|
||||
.action = "block",
|
||||
}).fail == .invalid);
|
||||
|
||||
try testing.expect(toInput(.{
|
||||
.group_id = 1,
|
||||
.pattern = "ads.example",
|
||||
.kind = "exact",
|
||||
.action = "drop",
|
||||
}).fail == .invalid);
|
||||
|
||||
const good = toInput(.{
|
||||
.group_id = 1,
|
||||
.pattern = "ads.example",
|
||||
.kind = "wildcard",
|
||||
.action = "allow",
|
||||
});
|
||||
try testing.expectEqual(model.RuleKind.wildcard, good.input.kind);
|
||||
try testing.expectEqual(model.RuleAction.allow, good.input.action);
|
||||
}
|
||||
@@ -0,0 +1,650 @@
|
||||
//! `GET`/`PUT /api/settings` — the scalar configuration, the rows of the
|
||||
//! `settings` table (ruling 16).
|
||||
//!
|
||||
//! Everything here is restart-required this milestone, and the response says so
|
||||
//! for every key: what changes live is the resource endpoints and the pause,
|
||||
//! not a setting. The list is generated from `model.Config` itself, so a
|
||||
//! section added to the model appears here without anyone remembering to add
|
||||
//! it.
|
||||
//!
|
||||
//! `web.password` is write-only and `web.password_hash` is neither readable nor
|
||||
//! directly writable. A PUT carrying `web.password` hashes it with the import
|
||||
//! path's argon2id parameters and stores the hash alone (PLAN §19: the plain
|
||||
//! password is never stored, never logged, never echoed). Changing the hash
|
||||
//! ends every session, because the old cookies were minted under the old
|
||||
//! password.
|
||||
//!
|
||||
//! A PUT is partial: a section left out, or a field left out of a section, keeps
|
||||
//! what is stored. The merged configuration is validated whole — the same check
|
||||
//! the next start runs — before a single row is written, so a settings PUT
|
||||
//! cannot leave a configuration the server would refuse to boot from.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const auth = @import("../auth.zig");
|
||||
const db = @import("../../storage/db.zig");
|
||||
const http_util = @import("../http_util.zig");
|
||||
const model = @import("../../config/model.zig");
|
||||
const mutations = @import("mutations.zig");
|
||||
const server = @import("../server.zig");
|
||||
const settings_repo = @import("../../storage/repositories/settings_repo.zig");
|
||||
|
||||
const Failure = mutations.Failure;
|
||||
const Request = http_util.Request;
|
||||
const HandlerError = http_util.HandlerError;
|
||||
|
||||
const log = std.log.scoped(.web_api);
|
||||
|
||||
/// Holds any PHC-encoded argon2id string comfortably (import.zig's number).
|
||||
/// Equal to the live holder's capacity by construction, so a hash written here
|
||||
/// always fits the copy `applyLogin` takes.
|
||||
const hash_buf_len = auth.LiveHash.max_len;
|
||||
|
||||
/// Fields a client may neither read nor write directly. `password_hash` is
|
||||
/// derived from `password`; exposing it would let a client install a hash
|
||||
/// nxdns never computed.
|
||||
fn isHidden(comptime section: []const u8, comptime field: []const u8) bool {
|
||||
return std.mem.eql(u8, section, "web") and std.mem.eql(u8, field, "password_hash");
|
||||
}
|
||||
|
||||
/// `web.password` is accepted on a PUT and never returned.
|
||||
fn isWriteOnly(comptime section: []const u8, comptime field: []const u8) bool {
|
||||
return std.mem.eql(u8, section, "web") and std.mem.eql(u8, field, "password");
|
||||
}
|
||||
|
||||
fn isScalarSection(comptime T: type) bool {
|
||||
return @typeInfo(T) == .@"struct";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// the restart-required table (ruling 16)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Every settings key, in `model.Config` declaration order. Ruling 16: all of
|
||||
/// them are restart-required this milestone, so the table is the key list and
|
||||
/// the flag is implied by membership.
|
||||
pub const restart_required_keys: []const []const u8 = &keys;
|
||||
|
||||
const keys = blk: {
|
||||
var list: [countKeys()][]const u8 = undefined;
|
||||
var index = 0;
|
||||
for (@typeInfo(model.Config).@"struct".fields) |section_field| {
|
||||
if (!isScalarSection(section_field.type)) continue;
|
||||
for (@typeInfo(section_field.type).@"struct".fields) |field| {
|
||||
if (isHidden(section_field.name, field.name)) continue;
|
||||
if (isWriteOnly(section_field.name, field.name)) continue;
|
||||
list[index] = section_field.name ++ "." ++ field.name;
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
break :blk list;
|
||||
};
|
||||
|
||||
fn countKeys() usize {
|
||||
var count = 0;
|
||||
for (@typeInfo(model.Config).@"struct".fields) |section_field| {
|
||||
if (!isScalarSection(section_field.type)) continue;
|
||||
for (@typeInfo(section_field.type).@"struct".fields) |field| {
|
||||
if (isHidden(section_field.name, field.name)) continue;
|
||||
if (isWriteOnly(section_field.name, field.name)) continue;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// the patch a PUT carries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `Section` with every field optional, so an absent field means "leave it".
|
||||
/// Generated rather than written out: a hand-copied mirror of `model.Config`
|
||||
/// would drift the first time a setting is added.
|
||||
fn Partial(comptime Section: type, comptime section_name: []const u8) type {
|
||||
const info = @typeInfo(Section).@"struct";
|
||||
var names: [info.fields.len][:0]const u8 = undefined;
|
||||
var types: [info.fields.len]type = undefined;
|
||||
var attrs: [info.fields.len]std.builtin.Type.StructField.Attributes = undefined;
|
||||
var count: usize = 0;
|
||||
for (info.fields) |field| {
|
||||
if (isHidden(section_name, field.name)) continue;
|
||||
const Field = ?FieldType(field.type);
|
||||
const default: Field = null;
|
||||
names[count] = field.name;
|
||||
types[count] = Field;
|
||||
attrs[count] = .{ .default_value_ptr = @ptrCast(&default) };
|
||||
count += 1;
|
||||
}
|
||||
const final_names = names[0..count].*;
|
||||
const final_types = types[0..count].*;
|
||||
const final_attrs = attrs[0..count].*;
|
||||
return @Struct(.auto, null, &final_names, &final_types, &final_attrs);
|
||||
}
|
||||
|
||||
/// Enums arrive as the words the database stores, so they are parsed from text
|
||||
/// rather than by tag name (`logging.level` is `error`, whose tag cannot be).
|
||||
fn FieldType(comptime T: type) type {
|
||||
return switch (@typeInfo(T)) {
|
||||
.@"enum" => []const u8,
|
||||
else => T,
|
||||
};
|
||||
}
|
||||
|
||||
/// The whole PUT body: every section optional, every field optional.
|
||||
pub const Patch = blk: {
|
||||
const config_fields = @typeInfo(model.Config).@"struct".fields;
|
||||
var names: [config_fields.len][:0]const u8 = undefined;
|
||||
var types: [config_fields.len]type = undefined;
|
||||
var attrs: [config_fields.len]std.builtin.Type.StructField.Attributes = undefined;
|
||||
var count: usize = 0;
|
||||
for (config_fields) |section_field| {
|
||||
if (!isScalarSection(section_field.type)) continue;
|
||||
const Section = ?Partial(section_field.type, section_field.name);
|
||||
const default: Section = null;
|
||||
names[count] = section_field.name;
|
||||
types[count] = Section;
|
||||
attrs[count] = .{ .default_value_ptr = @ptrCast(&default) };
|
||||
count += 1;
|
||||
}
|
||||
const final_names = names[0..count].*;
|
||||
const final_types = types[0..count].*;
|
||||
const final_attrs = attrs[0..count].*;
|
||||
break :blk @Struct(.auto, null, &final_names, &final_types, &final_attrs);
|
||||
};
|
||||
|
||||
/// Applies `patch` onto `cfg`. A word an enum does not know is the one failure
|
||||
/// this can report, and it names the key.
|
||||
fn merge(cfg: *model.Config, patch: Patch, bad_key: *[]const u8) bool {
|
||||
inline for (@typeInfo(Patch).@"struct".fields) |section_field| {
|
||||
if (@field(patch, section_field.name)) |section| {
|
||||
inline for (@typeInfo(@TypeOf(section)).@"struct".fields) |field| {
|
||||
if (@field(section, field.name)) |value| {
|
||||
const Target = @TypeOf(@field(@field(cfg, section_field.name), field.name));
|
||||
if (@typeInfo(Target) == .@"enum") {
|
||||
const decoded = Target.fromDb(value) orelse {
|
||||
bad_key.* = section_field.name ++ "." ++ field.name;
|
||||
return false;
|
||||
};
|
||||
@field(@field(cfg, section_field.name), field.name) = decoded;
|
||||
} else {
|
||||
@field(@field(cfg, section_field.name), field.name) = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Whether the patch carries a new password.
|
||||
fn newPassword(patch: Patch) ?[]const u8 {
|
||||
const web = patch.web orelse return null;
|
||||
const password = web.password orelse return null;
|
||||
if (password.len == 0) return null;
|
||||
return password;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// the read shape
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const RuntimeView = struct { io_backend: []const u8 };
|
||||
const BlockingView = struct { response: []const u8, ttl: u32 };
|
||||
const EdnsView = struct { ecs_mode: []const u8 };
|
||||
|
||||
const LoggingView = struct {
|
||||
level: []const u8,
|
||||
retention_days: u16,
|
||||
query_log_buffer_max: u32,
|
||||
hide_domains: bool,
|
||||
hide_client_ips: bool,
|
||||
output: []const u8,
|
||||
file_path: []const u8,
|
||||
max_size_mb: u32,
|
||||
max_files: u8,
|
||||
};
|
||||
|
||||
const WebView = struct {
|
||||
enabled: bool,
|
||||
bind: []const u8,
|
||||
port: u16,
|
||||
session_ttl_hours: u16,
|
||||
api_rate_limit_per_min: u32,
|
||||
api_localhost_exempt: bool,
|
||||
sse_max_connections_per_ip: u16,
|
||||
/// Derived, not stored: the hash itself is never serialized, and the UI
|
||||
/// still has to know whether a password is set.
|
||||
auth_enabled: bool,
|
||||
};
|
||||
|
||||
pub const View = struct {
|
||||
runtime: RuntimeView,
|
||||
upstream: model.Upstream,
|
||||
dns: model.Dns,
|
||||
blocking: BlockingView,
|
||||
cache: model.Cache,
|
||||
web: WebView,
|
||||
doh_server: model.TlsEndpoint,
|
||||
dot_server: model.TlsEndpoint,
|
||||
edns: EdnsView,
|
||||
logging: LoggingView,
|
||||
disk: model.Disk,
|
||||
blocklist_update: model.BlocklistUpdate,
|
||||
};
|
||||
|
||||
pub fn view(cfg: model.Config) View {
|
||||
return .{
|
||||
.runtime = .{ .io_backend = cfg.runtime.io_backend.toDb() },
|
||||
.upstream = cfg.upstream,
|
||||
.dns = cfg.dns,
|
||||
.blocking = .{ .response = cfg.blocking.response.toDb(), .ttl = cfg.blocking.ttl },
|
||||
.cache = cfg.cache,
|
||||
.web = .{
|
||||
.enabled = cfg.web.enabled,
|
||||
.bind = cfg.web.bind,
|
||||
.port = cfg.web.port,
|
||||
.session_ttl_hours = cfg.web.session_ttl_hours,
|
||||
.api_rate_limit_per_min = cfg.web.api_rate_limit_per_min,
|
||||
.api_localhost_exempt = cfg.web.api_localhost_exempt,
|
||||
.sse_max_connections_per_ip = cfg.web.sse_max_connections_per_ip,
|
||||
.auth_enabled = auth.authEnabled(cfg.web),
|
||||
},
|
||||
.doh_server = cfg.doh_server,
|
||||
.dot_server = cfg.dot_server,
|
||||
.edns = .{ .ecs_mode = cfg.edns.ecs_mode.toDb() },
|
||||
.logging = .{
|
||||
.level = cfg.logging.level.toDb(),
|
||||
.retention_days = cfg.logging.retention_days,
|
||||
.query_log_buffer_max = cfg.logging.query_log_buffer_max,
|
||||
.hide_domains = cfg.logging.hide_domains,
|
||||
.hide_client_ips = cfg.logging.hide_client_ips,
|
||||
.output = cfg.logging.output.toDb(),
|
||||
.file_path = cfg.logging.file_path,
|
||||
.max_size_mb = cfg.logging.max_size_mb,
|
||||
.max_files = cfg.logging.max_files,
|
||||
},
|
||||
.disk = cfg.disk,
|
||||
.blocklist_update = cfg.blocklist_update,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// decisions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Reads, merges, validates, writes, and — when the password changed — ends
|
||||
/// every session. Returns the configuration as it now stands.
|
||||
pub fn applyPut(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
arena: Allocator,
|
||||
patch: Patch,
|
||||
) error{OutOfMemory}!union(enum) { config: model.Config, fail: Failure } {
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return .{ .fail = failure },
|
||||
};
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
defer state.config_lock.unlock(io);
|
||||
|
||||
var cfg = mutations.loadConfig(arena, database) catch |err| switch (err) {
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
else => return .{ .fail = .{ .internal = err } },
|
||||
};
|
||||
|
||||
var bad_key: []const u8 = "";
|
||||
if (!merge(&cfg, patch, &bad_key)) {
|
||||
return .{ .fail = .{ .invalid = try std.fmt.allocPrint(
|
||||
arena,
|
||||
"{s}: not one of the values this setting accepts",
|
||||
.{bad_key},
|
||||
) } };
|
||||
}
|
||||
|
||||
// The password never becomes a row. It is hashed here and the hash is what
|
||||
// the merged configuration — and therefore the settings table — carries.
|
||||
const password = newPassword(patch);
|
||||
const previous_hash = cfg.web.password_hash;
|
||||
if (password) |plain| {
|
||||
if (plain.len > auth.max_password_len) {
|
||||
return .{ .fail = .{ .invalid = "web.password is too long" } };
|
||||
}
|
||||
const buf = try arena.alloc(u8, hash_buf_len);
|
||||
cfg.web.password_hash = hashPassword(io, arena, plain, buf) catch |err| switch (err) {
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
error.Canceled => return .{ .fail = .{ .unavailable = "shutting down" } },
|
||||
else => return .{ .fail = .{ .internal = error.Unexpected } },
|
||||
};
|
||||
}
|
||||
cfg.web.password = "";
|
||||
|
||||
if (try problem(arena, cfg)) |text| return .{ .fail = .{ .invalid = text } };
|
||||
|
||||
// The gpa copy the live holder will own, made before the write so a
|
||||
// committed transaction can never be followed by a failed revocation.
|
||||
const hash_changed = password != null and !std.mem.eql(u8, previous_hash, cfg.web.password_hash);
|
||||
const replacement: ?[]u8 = if (hash_changed) try state.gpa.dupe(u8, cfg.web.password_hash) else null;
|
||||
|
||||
if (writeSettings(arena, database, cfg)) |err| {
|
||||
if (replacement) |hash| state.gpa.free(hash);
|
||||
return .{ .fail = .{ .internal = err } };
|
||||
}
|
||||
|
||||
if (replacement) |hash| {
|
||||
// Ruling 17, both halves: the running server must verify against the
|
||||
// new hash at once — a restart-free credential change — and the
|
||||
// cookies in flight were minted under the old password. One
|
||||
// LiveHash-ordered operation: a login with the new password cannot
|
||||
// mint between the hash swap and the revocation and then lose its
|
||||
// fresh cookie to it.
|
||||
state.live_hash.installAndRevoke(io, state.gpa, state.sessions, hash);
|
||||
}
|
||||
|
||||
return .{ .config = cfg };
|
||||
}
|
||||
|
||||
/// Writes every key of `cfg` in one transaction. Rewriting the unchanged rows
|
||||
/// costs a few dozen upserts and buys the guarantee that the table is exactly
|
||||
/// what `model.toSettings` says the merged configuration is — no key can be
|
||||
/// missed and none can be left behind.
|
||||
fn writeSettings(arena: Allocator, database: *db.Db, cfg: model.Config) ?db.Error {
|
||||
var pairs: std.ArrayList(model.SettingPair) = .empty;
|
||||
model.toSettings(cfg, arena, &pairs) catch return error.OutOfMemory;
|
||||
|
||||
var tx = db.Tx.begin(database) catch |err| return err;
|
||||
errdefer tx.rollback();
|
||||
|
||||
for (pairs.items) |pair| {
|
||||
settings_repo.putSetting(database, pair.key, pair.value) catch |err| {
|
||||
tx.rollback();
|
||||
return err;
|
||||
};
|
||||
}
|
||||
tx.commit() catch |err| {
|
||||
tx.rollback();
|
||||
return err;
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
fn problem(arena: Allocator, cfg: model.Config) error{OutOfMemory}!?[]const u8 {
|
||||
return mutations.firstProblem(arena, cfg);
|
||||
}
|
||||
|
||||
/// argon2id with the import path's parameters (OWASP t=2, m=19 MiB, p=1), so a
|
||||
/// password set through the API and one set through a config import produce the
|
||||
/// same kind of hash.
|
||||
fn hashPassword(io: std.Io, gpa: Allocator, password: []const u8, buf: []u8) ![]const u8 {
|
||||
return std.crypto.pwhash.argon2.strHash(password, .{
|
||||
.allocator = gpa,
|
||||
.params = .owasp_2id,
|
||||
.mode = .argon2id,
|
||||
.encoding = .phc,
|
||||
}, buf, io) catch |err| switch (err) {
|
||||
error.OutOfMemory => error.OutOfMemory,
|
||||
error.Canceled => error.Canceled,
|
||||
else => {
|
||||
// Never the password, never the hash: only what went wrong.
|
||||
log.warn("hashing the new web password failed: {s}", .{@errorName(err)});
|
||||
return error.Unexpected;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// routes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return mutations.respondFailure(request, failure, "reading the settings"),
|
||||
};
|
||||
|
||||
// Under the same lock the mutation handlers hold: a PUT rewrites every
|
||||
// settings row in one transaction on this shared connection, and SQLite's
|
||||
// own mutex serializes statements, not transactions — an unlocked read
|
||||
// could see half a PUT. Released before responding, like the mutations.
|
||||
state.config_lock.lockUncancelable(io);
|
||||
const loaded = mutations.loadConfig(request.arena, database);
|
||||
state.config_lock.unlock(io);
|
||||
|
||||
const cfg = loaded catch |err|
|
||||
return mutations.respondFailure(request, .{ .internal = err }, "reading the settings");
|
||||
|
||||
return respondSettings(request, .ok, cfg);
|
||||
}
|
||||
|
||||
pub fn put(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
const parsed = http_util.parseBody(Patch, request) catch |err|
|
||||
return mutations.respondBadBody(request, err);
|
||||
|
||||
return switch (try applyPut(state, io, request.arena, parsed.value)) {
|
||||
.fail => |failure| mutations.respondFailure(request, failure, "writing the settings"),
|
||||
.config => |cfg| respondSettings(request, .ok, cfg),
|
||||
};
|
||||
}
|
||||
|
||||
fn respondSettings(request: *Request, status: std.http.Status, cfg: model.Config) HandlerError!void {
|
||||
return http_util.respondJson(request, status, .{
|
||||
.settings = view(cfg),
|
||||
.restart_required = restart_required_keys,
|
||||
}, &.{});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const testing = std.testing;
|
||||
const auth_handlers = @import("auth.zig");
|
||||
|
||||
test "the restart-required table lists every settings key and no secret" {
|
||||
// `model.toSettings` is the other half of the same fact: the keys the
|
||||
// database stores, minus the hash the API never serializes.
|
||||
var pairs: std.ArrayList(model.SettingPair) = .empty;
|
||||
defer {
|
||||
model.freeSettings(testing.allocator, pairs.items);
|
||||
pairs.deinit(testing.allocator);
|
||||
}
|
||||
try model.toSettings(.{}, testing.allocator, &pairs);
|
||||
|
||||
try testing.expectEqual(pairs.items.len - 1, restart_required_keys.len);
|
||||
for (restart_required_keys) |key| {
|
||||
try testing.expect(!std.mem.eql(u8, key, "web.password_hash"));
|
||||
try testing.expect(!std.mem.eql(u8, key, "web.password"));
|
||||
}
|
||||
|
||||
var found_port = false;
|
||||
for (restart_required_keys) |key| {
|
||||
if (std.mem.eql(u8, key, "dns.port")) found_port = true;
|
||||
}
|
||||
try testing.expect(found_port);
|
||||
}
|
||||
|
||||
test "the read shape spells every enum the way the database does" {
|
||||
const rendered = view(.{
|
||||
.logging = .{ .level = .err, .output = .file },
|
||||
.blocking = .{ .response = .nxdomain },
|
||||
.edns = .{ .ecs_mode = .forward },
|
||||
.runtime = .{ .io_backend = .evented },
|
||||
});
|
||||
try testing.expectEqualStrings("error", rendered.logging.level);
|
||||
try testing.expectEqualStrings("file", rendered.logging.output);
|
||||
try testing.expectEqualStrings("nxdomain", rendered.blocking.response);
|
||||
try testing.expectEqualStrings("forward", rendered.edns.ecs_mode);
|
||||
try testing.expectEqualStrings("evented", rendered.runtime.io_backend);
|
||||
try testing.expect(!rendered.web.auth_enabled);
|
||||
|
||||
const with_password = view(.{ .web = .{ .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$a$b" } });
|
||||
try testing.expect(with_password.web.auth_enabled);
|
||||
}
|
||||
|
||||
test "the patch type has no password_hash field and every field is optional" {
|
||||
const WebPatch = @typeInfo(@FieldType(Patch, "web")).optional.child;
|
||||
comptime var has_password = false;
|
||||
inline for (@typeInfo(WebPatch).@"struct".fields) |field| {
|
||||
comptime std.debug.assert(@typeInfo(field.type) == .optional);
|
||||
comptime std.debug.assert(!std.mem.eql(u8, field.name, "password_hash"));
|
||||
if (comptime std.mem.eql(u8, field.name, "password")) has_password = true;
|
||||
}
|
||||
try testing.expect(has_password);
|
||||
}
|
||||
|
||||
fn seeded(bench: *mutations.Bench) !void {
|
||||
try bench.exec(
|
||||
\\INSERT INTO upstreams (url, priority, enabled) VALUES ('https://dns.example/dns-query', 100, 1);
|
||||
\\INSERT INTO settings (key, value) VALUES ('dns.port', '53'), ('logging.level', 'info');
|
||||
);
|
||||
}
|
||||
|
||||
test "a partial put changes the keys it names and keeps the rest" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
try seeded(&bench);
|
||||
|
||||
var patch: Patch = .{};
|
||||
patch.dns = .{ .port = 5353 };
|
||||
patch.logging = .{ .level = "debug" };
|
||||
|
||||
const outcome = try applyPut(&bench.state, bench.io(), bench.arena(), patch);
|
||||
try testing.expectEqual(@as(u16, 5353), outcome.config.dns.port);
|
||||
try testing.expectEqual(model.LogLevel.debug, outcome.config.logging.level);
|
||||
// Untouched keys keep their stored value, not the model default.
|
||||
try testing.expectEqual(@as(u32, 1000), outcome.config.dns.rate_limit);
|
||||
|
||||
const stored = try mutations.loadConfig(bench.arena(), &bench.database);
|
||||
try testing.expectEqual(@as(u16, 5353), stored.dns.port);
|
||||
try testing.expectEqual(model.LogLevel.debug, stored.logging.level);
|
||||
}
|
||||
|
||||
test "a put that would not validate writes nothing" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
try seeded(&bench);
|
||||
|
||||
var patch: Patch = .{};
|
||||
patch.dns = .{ .port = 0 };
|
||||
|
||||
const outcome = try applyPut(&bench.state, bench.io(), bench.arena(), patch);
|
||||
try testing.expect(outcome.fail == .invalid);
|
||||
|
||||
const stored = try mutations.loadConfig(bench.arena(), &bench.database);
|
||||
try testing.expectEqual(@as(u16, 53), stored.dns.port);
|
||||
}
|
||||
|
||||
test "an enum value the model does not know names the key it came from" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
try seeded(&bench);
|
||||
|
||||
var patch: Patch = .{};
|
||||
patch.logging = .{ .level = "verbose" };
|
||||
|
||||
const outcome = try applyPut(&bench.state, bench.io(), bench.arena(), patch);
|
||||
try testing.expect(std.mem.startsWith(u8, outcome.fail.invalid, "logging.level:"));
|
||||
}
|
||||
|
||||
test "a new password is stored as a hash and ends every session" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
try seeded(&bench);
|
||||
|
||||
var sessions: auth.Sessions = .init(24);
|
||||
bench.state.sessions = &sessions;
|
||||
const cookie = sessions.createWithToken(bench.io(), @splat(7), 1_000);
|
||||
try testing.expect(sessions.validateAt(bench.io(), &cookie, 1_001));
|
||||
|
||||
var patch: Patch = .{};
|
||||
patch.web = .{ .password = "correct horse battery staple" };
|
||||
|
||||
const outcome = try applyPut(&bench.state, bench.io(), bench.arena(), patch);
|
||||
try testing.expect(std.mem.startsWith(u8, outcome.config.web.password_hash, "$argon2id$"));
|
||||
try testing.expect(!sessions.validateAt(bench.io(), &cookie, 1_001));
|
||||
|
||||
// The plain password is nowhere in the table, and the hash is.
|
||||
try testing.expectEqual(
|
||||
@as(i64, 0),
|
||||
try bench.queryInt("SELECT count(*) FROM settings WHERE key = 'web.password'"),
|
||||
);
|
||||
const stored = try mutations.loadConfig(bench.arena(), &bench.database);
|
||||
try testing.expect(std.mem.startsWith(u8, stored.web.password_hash, "$argon2id$"));
|
||||
try testing.expectEqual(
|
||||
auth.Outcome.ok,
|
||||
try auth.verifyPassword(bench.io(), testing.allocator, stored.web.password_hash, "correct horse battery staple"),
|
||||
);
|
||||
}
|
||||
|
||||
test "changing the password revokes the old one without a restart" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
try seeded(&bench);
|
||||
|
||||
var sessions: auth.Sessions = .init(24);
|
||||
bench.state.sessions = &sessions;
|
||||
|
||||
var first: Patch = .{};
|
||||
first.web = .{ .password = "old password" };
|
||||
try testing.expect(try applyPut(&bench.state, bench.io(), bench.arena(), first) == .config);
|
||||
|
||||
const old_login = auth_handlers.applyLogin(&bench.state, bench.io(), "old password");
|
||||
try testing.expect(sessions.validate(bench.io(), &old_login.cookie));
|
||||
|
||||
var second: Patch = .{};
|
||||
second.web = .{ .password = "new password" };
|
||||
try testing.expect(try applyPut(&bench.state, bench.io(), bench.arena(), second) == .config);
|
||||
|
||||
// The session minted under the old password is dead...
|
||||
try testing.expect(!sessions.validate(bench.io(), &old_login.cookie));
|
||||
// ...the old password no longer mints one...
|
||||
try testing.expect(auth_handlers.applyLogin(&bench.state, bench.io(), "old password").fail == .invalid);
|
||||
// ...and the new one works immediately, no restart in between.
|
||||
const new_login = auth_handlers.applyLogin(&bench.state, bench.io(), "new password");
|
||||
try testing.expect(sessions.validate(bench.io(), &new_login.cookie));
|
||||
}
|
||||
|
||||
test "a put that does not carry a password leaves the sessions alone" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
try seeded(&bench);
|
||||
|
||||
var sessions: auth.Sessions = .init(24);
|
||||
bench.state.sessions = &sessions;
|
||||
const cookie = sessions.createWithToken(bench.io(), @splat(9), 1_000);
|
||||
|
||||
var patch: Patch = .{};
|
||||
patch.cache = .{ .size = 5000 };
|
||||
_ = try applyPut(&bench.state, bench.io(), bench.arena(), patch);
|
||||
|
||||
try testing.expect(sessions.validateAt(bench.io(), &cookie, 1_001));
|
||||
}
|
||||
|
||||
test "an empty password is not a password change" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
try seeded(&bench);
|
||||
|
||||
var patch: Patch = .{};
|
||||
patch.web = .{ .password = "" };
|
||||
|
||||
const outcome = try applyPut(&bench.state, bench.io(), bench.arena(), patch);
|
||||
try testing.expectEqualStrings("", outcome.config.web.password_hash);
|
||||
}
|
||||
|
||||
test "reading the settings with no database is unavailable" {
|
||||
var state: server.WebState = .{ .gpa = testing.allocator };
|
||||
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
|
||||
const outcome = try applyPut(&state, undefined, arena_state.allocator(), .{});
|
||||
try testing.expect(outcome.fail == .unavailable);
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
//! `GET /api/stats` and `GET /api/stats/timeseries` (ruling 13).
|
||||
//!
|
||||
//! One period grammar, four widths, and one window shared by both endpoints:
|
||||
//! the totals cover exactly the span the chart draws, so a dashboard cannot
|
||||
//! show a sum that disagrees with the bars above it.
|
||||
//!
|
||||
//! Buckets are aligned to the UTC grid, not to the moment of the request. Every
|
||||
//! width divides a day, so flooring the current time to a multiple of the width
|
||||
//! puts each bucket on the same boundary a human reads off a clock, and two
|
||||
//! requests a second apart return the same bucket starts. The last bucket is
|
||||
//! the one in progress; it fills as the period runs.
|
||||
//!
|
||||
//! The aggregates run on the web task's own query-log connection (m7 ruling 21).
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const db = @import("../../storage/db.zig");
|
||||
const http_util = @import("../http_util.zig");
|
||||
const queries_repo = @import("../../storage/repositories/queries_repo.zig");
|
||||
const server = @import("../server.zig");
|
||||
|
||||
const log = std.log.scoped(.web_stats);
|
||||
|
||||
/// The four periods ruling 13 defines. The tag names are the wire spellings.
|
||||
pub const Period = enum {
|
||||
@"1h",
|
||||
@"24h",
|
||||
@"7d",
|
||||
@"30d",
|
||||
|
||||
pub fn parse(text: []const u8) ?Period {
|
||||
return std.meta.stringToEnum(Period, text);
|
||||
}
|
||||
|
||||
/// Ruling 13: 1h→60×1m, 24h→48×30m, 7d→168×1h, 30d→120×6h.
|
||||
pub fn bucketSeconds(self: Period) u32 {
|
||||
return switch (self) {
|
||||
.@"1h" => 60,
|
||||
.@"24h" => 30 * 60,
|
||||
.@"7d" => 60 * 60,
|
||||
.@"30d" => 6 * 60 * 60,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn bucketCount(self: Period) u32 {
|
||||
return switch (self) {
|
||||
.@"1h" => 60,
|
||||
.@"24h" => 48,
|
||||
.@"7d" => 168,
|
||||
.@"30d" => 120,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn label(self: Period) []const u8 {
|
||||
return @tagName(self);
|
||||
}
|
||||
};
|
||||
|
||||
pub const default_period: Period = .@"24h";
|
||||
|
||||
/// The widest period's bucket count, so one stack array serves every request.
|
||||
pub const max_buckets = 168;
|
||||
|
||||
comptime {
|
||||
for (std.enums.values(Period)) |period| {
|
||||
std.debug.assert(period.bucketCount() <= max_buckets);
|
||||
// The UTC alignment argument holds only while every width divides a day.
|
||||
std.debug.assert(86_400 % period.bucketSeconds() == 0);
|
||||
}
|
||||
}
|
||||
|
||||
pub const Window = struct {
|
||||
/// Inclusive, on the bucket grid.
|
||||
since: i64,
|
||||
/// Exclusive: the end of the bucket that `now` falls in.
|
||||
until: i64,
|
||||
bucket_seconds: u32,
|
||||
bucket_count: u32,
|
||||
};
|
||||
|
||||
pub fn window(period: Period, now_unix: i64) Window {
|
||||
const width: i64 = period.bucketSeconds();
|
||||
const count: i64 = period.bucketCount();
|
||||
const until = @divFloor(now_unix, width) * width + width;
|
||||
return .{
|
||||
.since = until - width * count,
|
||||
.until = until,
|
||||
.bucket_seconds = period.bucketSeconds(),
|
||||
.bucket_count = period.bucketCount(),
|
||||
};
|
||||
}
|
||||
|
||||
pub const TotalsBody = struct {
|
||||
period: []const u8,
|
||||
since: i64,
|
||||
until: i64,
|
||||
queries: u64,
|
||||
blocked: u64,
|
||||
cached: u64,
|
||||
clients: u64,
|
||||
avg_response_time_us: ?i64,
|
||||
};
|
||||
|
||||
pub const TimeseriesBody = struct {
|
||||
period: []const u8,
|
||||
since: i64,
|
||||
until: i64,
|
||||
bucket_seconds: u32,
|
||||
buckets: []const queries_repo.Bucket,
|
||||
};
|
||||
|
||||
pub fn totals(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
request: *http_util.Request,
|
||||
) http_util.HandlerError!void {
|
||||
const period = periodParam(request.query) catch return badPeriod(request);
|
||||
const database = state.querylog_db orelse return unavailable(request);
|
||||
const span = window(period, std.Io.Clock.real.now(io).toSeconds());
|
||||
|
||||
const result = queries_repo.statsTotals(database, span.since, span.until) catch |err| {
|
||||
return internal(request, "stats totals", err);
|
||||
};
|
||||
|
||||
return http_util.respondJson(request, .ok, TotalsBody{
|
||||
.period = period.label(),
|
||||
.since = span.since,
|
||||
.until = span.until,
|
||||
.queries = result.queries,
|
||||
.blocked = result.blocked,
|
||||
.cached = result.cached,
|
||||
.clients = result.distinct_clients,
|
||||
.avg_response_time_us = result.avg_response_time_us,
|
||||
}, &.{});
|
||||
}
|
||||
|
||||
pub fn timeseries(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
request: *http_util.Request,
|
||||
) http_util.HandlerError!void {
|
||||
const period = periodParam(request.query) catch return badPeriod(request);
|
||||
const database = state.querylog_db orelse return unavailable(request);
|
||||
const span = window(period, std.Io.Clock.real.now(io).toSeconds());
|
||||
|
||||
var buckets: [max_buckets]queries_repo.Bucket = undefined;
|
||||
const out = buckets[0..span.bucket_count];
|
||||
const written = queries_repo.timeseries(database, span.since, span.bucket_seconds, out) catch |err| {
|
||||
return internal(request, "stats timeseries", err);
|
||||
};
|
||||
|
||||
return http_util.respondJson(request, .ok, TimeseriesBody{
|
||||
.period = period.label(),
|
||||
.since = span.since,
|
||||
.until = span.until,
|
||||
.bucket_seconds = span.bucket_seconds,
|
||||
.buckets = out[0..written],
|
||||
}, &.{});
|
||||
}
|
||||
|
||||
pub const PeriodError = error{BadPeriod};
|
||||
|
||||
/// An absent `period` is the default; anything else it cannot read is a 400,
|
||||
/// never a silent fallback — a typo must not return a window nobody asked for.
|
||||
pub fn periodParam(query: []const u8) PeriodError!Period {
|
||||
var buf: [8]u8 = undefined;
|
||||
const found = http_util.queryValue(query, "period", &buf) catch return error.BadPeriod;
|
||||
const text = found orelse return default_period;
|
||||
return Period.parse(text) orelse error.BadPeriod;
|
||||
}
|
||||
|
||||
fn badPeriod(request: *http_util.Request) http_util.HandlerError!void {
|
||||
return http_util.respondError(request, .bad_request, "period must be one of 1h, 24h, 7d, 30d");
|
||||
}
|
||||
|
||||
fn unavailable(request: *http_util.Request) http_util.HandlerError!void {
|
||||
return http_util.respondError(request, .service_unavailable, "query log unavailable");
|
||||
}
|
||||
|
||||
/// The one thing this file logs. A failed aggregate is a fault in the box, not
|
||||
/// a property of the request, and the client is told nothing beyond "internal
|
||||
/// error" (ruling 8, PLAN §19).
|
||||
fn internal(
|
||||
request: *http_util.Request,
|
||||
what: []const u8,
|
||||
err: db.Error,
|
||||
) http_util.HandlerError!void {
|
||||
log.warn("{s} failed: {s}", .{ what, @errorName(err) });
|
||||
return http_util.respondError(request, .internal_server_error, "internal error");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const querylog_schema = @import("../../storage/querylog_schema.zig");
|
||||
const testing = std.testing;
|
||||
|
||||
test "the period grammar accepts exactly the four spellings" {
|
||||
try testing.expectEqual(Period.@"1h", Period.parse("1h").?);
|
||||
try testing.expectEqual(Period.@"24h", Period.parse("24h").?);
|
||||
try testing.expectEqual(Period.@"7d", Period.parse("7d").?);
|
||||
try testing.expectEqual(Period.@"30d", Period.parse("30d").?);
|
||||
try testing.expectEqual(@as(?Period, null), Period.parse("12h"));
|
||||
try testing.expectEqual(@as(?Period, null), Period.parse("1H"));
|
||||
try testing.expectEqual(@as(?Period, null), Period.parse(""));
|
||||
}
|
||||
|
||||
test "an absent period defaults and a bad one is rejected" {
|
||||
try testing.expectEqual(default_period, try periodParam(""));
|
||||
try testing.expectEqual(default_period, try periodParam("limit=5"));
|
||||
try testing.expectEqual(Period.@"7d", try periodParam("period=7d"));
|
||||
try testing.expectError(error.BadPeriod, periodParam("period=12h"));
|
||||
try testing.expectError(error.BadPeriod, periodParam("period=%2"));
|
||||
// Longer than any spelling: rejected rather than truncated to "1h".
|
||||
try testing.expectError(error.BadPeriod, periodParam("period=1hhhhhhhhhh"));
|
||||
}
|
||||
|
||||
test "each period spans its own bucket width times its count" {
|
||||
for (std.enums.values(Period)) |period| {
|
||||
const span = window(period, 1_700_000_000);
|
||||
const width: i64 = period.bucketSeconds();
|
||||
try testing.expectEqual(width * @as(i64, period.bucketCount()), span.until - span.since);
|
||||
}
|
||||
}
|
||||
|
||||
test "the window sits on the UTC grid and ends with the bucket in progress" {
|
||||
// 2023-11-14T22:13:20Z, which is not on any bucket boundary.
|
||||
const now: i64 = 1_700_000_000;
|
||||
const span = window(.@"24h", now);
|
||||
|
||||
try testing.expectEqual(@as(i64, 0), @rem(span.since, 1800));
|
||||
try testing.expectEqual(@as(i64, 0), @rem(span.until, 1800));
|
||||
try testing.expect(span.until > now);
|
||||
try testing.expect(span.until - now <= 1800);
|
||||
try testing.expectEqual(@as(u32, 48), span.bucket_count);
|
||||
}
|
||||
|
||||
test "two requests inside one bucket see the same window" {
|
||||
// A bucket boundary, so the offsets below stay inside one minute.
|
||||
const boundary: i64 = 1_700_000_000 - @rem(1_700_000_000, 60);
|
||||
const first = window(.@"1h", boundary);
|
||||
const second = window(.@"1h", boundary + 59);
|
||||
try testing.expectEqual(first.since, second.since);
|
||||
try testing.expectEqual(first.until, second.until);
|
||||
|
||||
const next = window(.@"1h", boundary + 60);
|
||||
try testing.expectEqual(first.until + 60, next.until);
|
||||
}
|
||||
|
||||
test "a timestamp exactly on a boundary starts a new bucket" {
|
||||
const span = window(.@"7d", 1_700_000_000 - 1_700_000_000 % 3600);
|
||||
try testing.expectEqual(@as(i64, 0), @rem(span.since, 3600));
|
||||
try testing.expectEqual(@as(u32, 168), span.bucket_count);
|
||||
}
|
||||
|
||||
fn openLog() !db.Db {
|
||||
var database = try db.Db.open(":memory:", .{ .mode = .memory });
|
||||
errdefer database.close();
|
||||
try db.applyPragmas(&database, .{});
|
||||
try database.exec(querylog_schema.ddl);
|
||||
return database;
|
||||
}
|
||||
|
||||
fn writeRow(writer: *queries_repo.BatchWriter, timestamp: i64, blocked: bool, cached: ?bool) !void {
|
||||
const rows = [_]queries_repo.Row{.{
|
||||
.timestamp = timestamp,
|
||||
.domain = "example.com",
|
||||
.client_ip = "192.0.2.10",
|
||||
.qtype = 1,
|
||||
.blocked = blocked,
|
||||
.block_reason = if (blocked) "blocklist_domain" else null,
|
||||
.response_time_us = 1000,
|
||||
.cache_hit = cached,
|
||||
.upstream = null,
|
||||
}};
|
||||
try writer.writeBatch(&rows);
|
||||
}
|
||||
|
||||
test "the totals and the buckets agree over the same window" {
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
|
||||
const now: i64 = 1_700_000_000;
|
||||
const span = window(.@"1h", now);
|
||||
|
||||
var writer = try queries_repo.BatchWriter.init(&database);
|
||||
defer writer.deinit();
|
||||
// One row in the first bucket, two in the last, one just outside.
|
||||
try writeRow(&writer, span.since, false, false);
|
||||
try writeRow(&writer, span.until - 1, true, false);
|
||||
try writeRow(&writer, span.until - 2, false, true);
|
||||
try writeRow(&writer, span.since - 1, false, false);
|
||||
|
||||
const result = try queries_repo.statsTotals(&database, span.since, span.until);
|
||||
try testing.expectEqual(@as(u64, 3), result.queries);
|
||||
try testing.expectEqual(@as(u64, 1), result.blocked);
|
||||
try testing.expectEqual(@as(u64, 1), result.cached);
|
||||
try testing.expectEqual(@as(u64, 1), result.distinct_clients);
|
||||
try testing.expectEqual(@as(?i64, 1000), result.avg_response_time_us);
|
||||
|
||||
var buckets: [max_buckets]queries_repo.Bucket = undefined;
|
||||
const out = buckets[0..span.bucket_count];
|
||||
const written = try queries_repo.timeseries(&database, span.since, span.bucket_seconds, out);
|
||||
try testing.expectEqual(@as(usize, 60), written);
|
||||
|
||||
var summed: u64 = 0;
|
||||
var blocked: u64 = 0;
|
||||
for (out) |bucket| {
|
||||
summed += bucket.queries;
|
||||
blocked += bucket.blocked;
|
||||
}
|
||||
try testing.expectEqual(result.queries, summed);
|
||||
try testing.expectEqual(result.blocked, blocked);
|
||||
|
||||
try testing.expectEqual(span.since, out[0].ts);
|
||||
try testing.expectEqual(@as(u64, 1), out[0].queries);
|
||||
try testing.expectEqual(@as(u64, 2), out[59].queries);
|
||||
try testing.expectEqual(span.until - span.bucket_seconds, out[59].ts);
|
||||
}
|
||||
|
||||
test "an empty window reports zeros with a null mean" {
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
|
||||
const span = window(.@"30d", 1_700_000_000);
|
||||
const result = try queries_repo.statsTotals(&database, span.since, span.until);
|
||||
try testing.expectEqual(@as(u64, 0), result.queries);
|
||||
try testing.expectEqual(@as(?i64, null), result.avg_response_time_us);
|
||||
|
||||
var buckets: [max_buckets]queries_repo.Bucket = undefined;
|
||||
const out = buckets[0..span.bucket_count];
|
||||
try testing.expectEqual(@as(usize, 120), try queries_repo.timeseries(
|
||||
&database,
|
||||
span.since,
|
||||
span.bucket_seconds,
|
||||
out,
|
||||
));
|
||||
for (out) |bucket| try testing.expectEqual(@as(u64, 0), bucket.queries);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//! `GET /api/upstream/health` — the pool's own view of its upstreams.
|
||||
//!
|
||||
//! The rows are `Pool.Snapshot` with the borrowed strings copied. `last_error`
|
||||
//! points into the entry that produced it and is rewritten by that entry's next
|
||||
//! failure, so it is duplicated into the request arena before the pool's mutex
|
||||
//! is out of sight.
|
||||
//!
|
||||
//! No timestamps: the health fields are stamped on the `awake` clock, which
|
||||
//! stops while the box is suspended and means nothing to a client reading wall
|
||||
//! time. What an operator needs — is it up, how often does it fail, what did it
|
||||
//! say last — is here without them.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const http_util = @import("../http_util.zig");
|
||||
const metrics = @import("../metrics.zig");
|
||||
const pool_mod = @import("../../upstream/pool.zig");
|
||||
const server = @import("../server.zig");
|
||||
|
||||
pub const Upstream = struct {
|
||||
url: []const u8,
|
||||
enabled: bool,
|
||||
available: bool,
|
||||
consecutive_failures: u32,
|
||||
total_successes: u64,
|
||||
total_failures: u64,
|
||||
success_rate: f32,
|
||||
/// "" when the upstream has never failed.
|
||||
last_error: []const u8,
|
||||
};
|
||||
|
||||
pub const Body = struct {
|
||||
upstreams: []const Upstream,
|
||||
available: u32,
|
||||
total: u32,
|
||||
};
|
||||
|
||||
pub fn handle(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
request: *http_util.Request,
|
||||
) http_util.HandlerError!void {
|
||||
const pool = state.pool orelse
|
||||
return http_util.respondError(request, .service_unavailable, "no upstream pool");
|
||||
return http_util.respondJson(request, .ok, try collect(pool, io, request.arena), &.{});
|
||||
}
|
||||
|
||||
pub fn collect(pool: *pool_mod.Pool, io: std.Io, arena: Allocator) Allocator.Error!Body {
|
||||
var raw: [metrics.max_upstreams]pool_mod.Snapshot = undefined;
|
||||
const count = metrics.poolSnapshot(pool, io, &raw);
|
||||
|
||||
const out = try arena.alloc(Upstream, count);
|
||||
var available: u32 = 0;
|
||||
for (raw[0..count], out) |entry, *slot| {
|
||||
if (entry.available) available += 1;
|
||||
slot.* = .{
|
||||
.url = try arena.dupe(u8, entry.url),
|
||||
.enabled = entry.enabled,
|
||||
.available = entry.available,
|
||||
.consecutive_failures = entry.consecutive_failures,
|
||||
.total_successes = entry.total_successes,
|
||||
.total_failures = entry.total_failures,
|
||||
.success_rate = entry.success_rate,
|
||||
.last_error = try arena.dupe(u8, entry.last_error),
|
||||
};
|
||||
}
|
||||
|
||||
return .{ .upstreams = out, .available = available, .total = @intCast(count) };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const transport = @import("../../upstream/transport.zig");
|
||||
const testing = std.testing;
|
||||
|
||||
/// The client is never called: every test here reads health, not answers.
|
||||
fn testEntry(url: []const u8, enabled: bool) pool_mod.Entry {
|
||||
return .{
|
||||
.endpoint = transport.Endpoint.parse(url) catch unreachable,
|
||||
.client = .{ .ptr = undefined, .exchangeFn = undefined },
|
||||
.priority = 1,
|
||||
.enabled = enabled,
|
||||
.health = .init,
|
||||
};
|
||||
}
|
||||
|
||||
fn testPool(entries: []pool_mod.Entry) pool_mod.Pool {
|
||||
return .init(entries, .{}, .{ .raw = .fromMilliseconds(50), .clock = .awake }, 1);
|
||||
}
|
||||
|
||||
test "every upstream is copied, counted and owned by the arena" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var entries = [_]pool_mod.Entry{
|
||||
testEntry("https://a.test/dns-query", true),
|
||||
testEntry("https://b.test/dns-query", false),
|
||||
};
|
||||
var pool = testPool(&entries);
|
||||
|
||||
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
|
||||
const body = try collect(&pool, io, arena.allocator());
|
||||
try testing.expectEqual(@as(u32, 2), body.total);
|
||||
try testing.expectEqual(@as(usize, 2), body.upstreams.len);
|
||||
try testing.expectEqualStrings("https://a.test/dns-query", body.upstreams[0].url);
|
||||
try testing.expect(body.upstreams[0].enabled);
|
||||
try testing.expect(body.upstreams[0].available);
|
||||
try testing.expect(!body.upstreams[1].enabled);
|
||||
try testing.expect(!body.upstreams[1].available);
|
||||
// A disabled upstream is not available, so it is not counted.
|
||||
try testing.expectEqual(@as(u32, 1), body.available);
|
||||
try testing.expectEqualStrings("", body.upstreams[0].last_error);
|
||||
}
|
||||
|
||||
test "the copied strings survive the entry they came from" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var entries = [_]pool_mod.Entry{testEntry("https://a.test/dns-query", true)};
|
||||
var pool = testPool(&entries);
|
||||
|
||||
const at = std.Io.Clock.awake.now(io);
|
||||
entries[0].health.recordFailure(at, "ConnectFailed", .{}, 0);
|
||||
|
||||
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
const body = try collect(&pool, io, arena.allocator());
|
||||
try testing.expectEqualStrings("ConnectFailed", body.upstreams[0].last_error);
|
||||
|
||||
// The entry rewrites its buffer; the copy must not change with it.
|
||||
entries[0].health.recordFailure(at, "Timeout", .{}, 0);
|
||||
try testing.expectEqualStrings("ConnectFailed", body.upstreams[0].last_error);
|
||||
}
|
||||
|
||||
test "the body serializes with snake_case field names" {
|
||||
const upstreams = [_]Upstream{.{
|
||||
.url = "https://a.test/dns-query",
|
||||
.enabled = true,
|
||||
.available = false,
|
||||
.consecutive_failures = 3,
|
||||
.total_successes = 10,
|
||||
.total_failures = 4,
|
||||
.success_rate = 0.5,
|
||||
.last_error = "ConnectFailed",
|
||||
}};
|
||||
|
||||
var allocating: std.Io.Writer.Allocating = .init(testing.allocator);
|
||||
defer allocating.deinit();
|
||||
try std.json.Stringify.value(
|
||||
Body{ .upstreams = &upstreams, .available = 0, .total = 1 },
|
||||
.{},
|
||||
&allocating.writer,
|
||||
);
|
||||
const text = allocating.written();
|
||||
|
||||
for ([_][]const u8{
|
||||
"\"consecutive_failures\":3",
|
||||
"\"total_successes\":10",
|
||||
"\"total_failures\":4",
|
||||
"\"last_error\":\"ConnectFailed\"",
|
||||
"\"available\":0",
|
||||
"\"total\":1",
|
||||
}) |fragment| {
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, fragment));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
//! `/api/upstreams` — the resolvers nxdns forwards to.
|
||||
//!
|
||||
//! Ruling 9 makes this a resource like any other; ruling 12 makes it the one
|
||||
//! mutable resource that is NOT live. The pool builds its clients, its health
|
||||
//! state and its TLS material at startup, so an upstream added, edited or
|
||||
//! removed here takes effect at the next restart. The response says so through
|
||||
//! `restart_required`, which is the same word `/api/settings` uses, so the UI
|
||||
//! has one banner and one meaning for it.
|
||||
//!
|
||||
//! `tls_name` is the DoT-only SNI and certificate name (migration v2). It is
|
||||
//! empty for every other scheme, and the validator refuses it there.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const http_util = @import("../http_util.zig");
|
||||
const model = @import("../../config/model.zig");
|
||||
const mutations = @import("mutations.zig");
|
||||
const server = @import("../server.zig");
|
||||
const upstreams_repo = @import("../../storage/repositories/upstreams_repo.zig");
|
||||
|
||||
const Failure = mutations.Failure;
|
||||
const Request = http_util.Request;
|
||||
const HandlerError = http_util.HandlerError;
|
||||
|
||||
const url_conflict = "an upstream with that url already exists";
|
||||
|
||||
const Body = struct {
|
||||
url: []const u8,
|
||||
priority: i32 = 100,
|
||||
enabled: bool = true,
|
||||
tls_name: []const u8 = "",
|
||||
};
|
||||
|
||||
const Created = union(enum) { id: i64, fail: Failure };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// decisions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn applyCreate(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
arena: Allocator,
|
||||
item: model.UpstreamServer,
|
||||
) error{OutOfMemory}!Created {
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return .{ .fail = failure },
|
||||
};
|
||||
if (try mutations.checkUpstream(arena, item)) |problem| return .{ .fail = .{ .invalid = problem } };
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
const inserted = upstreams_repo.insertUpstreamRow(database, item);
|
||||
state.config_lock.unlock(io);
|
||||
|
||||
const id = inserted catch |err| return .{ .fail = mutations.dbFailure(err, url_conflict) };
|
||||
return .{ .id = id };
|
||||
}
|
||||
|
||||
pub fn applyUpdate(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
arena: Allocator,
|
||||
id: i64,
|
||||
item: model.UpstreamServer,
|
||||
) error{OutOfMemory}!?Failure {
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return failure,
|
||||
};
|
||||
if (try mutations.checkUpstream(arena, item)) |problem| return .{ .invalid = problem };
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
defer state.config_lock.unlock(io);
|
||||
|
||||
// The same rule `applyDelete` enforces: a set with no enabled upstream
|
||||
// would refuse to boot, so the write that would create one is a conflict.
|
||||
if (!item.enabled) {
|
||||
const remaining = countEnabledExcept(database, arena, id) catch |err|
|
||||
return mutations.dbFailure(err, url_conflict);
|
||||
switch (remaining) {
|
||||
.missing => return .not_found,
|
||||
.count => |left| if (left == 0) return .{
|
||||
.conflict = "the last enabled upstream cannot be disabled",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
upstreams_repo.updateUpstream(database, id, item) catch |err|
|
||||
return mutations.dbFailure(err, url_conflict);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// The last enabled upstream cannot go: a resolver with nowhere to forward to
|
||||
/// answers nothing, and `validate.validate` refuses that configuration at
|
||||
/// startup — so allowing it here would only produce a box that will not boot.
|
||||
pub fn applyDelete(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure {
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return failure,
|
||||
};
|
||||
|
||||
state.config_lock.lockUncancelable(io);
|
||||
defer state.config_lock.unlock(io);
|
||||
|
||||
const remaining = countEnabledExcept(database, arena, id) catch |err|
|
||||
return mutations.dbFailure(err, url_conflict);
|
||||
switch (remaining) {
|
||||
.missing => return .not_found,
|
||||
.count => |left| if (left == 0) return .{
|
||||
.conflict = "the last enabled upstream cannot be removed",
|
||||
},
|
||||
}
|
||||
|
||||
upstreams_repo.deleteUpstream(database, id) catch |err|
|
||||
return mutations.dbFailure(err, url_conflict);
|
||||
return null;
|
||||
}
|
||||
|
||||
const Remaining = union(enum) { missing, count: usize };
|
||||
|
||||
fn countEnabledExcept(
|
||||
database: *@import("../../storage/db.zig").Db,
|
||||
arena: Allocator,
|
||||
id: i64,
|
||||
) @import("../../storage/db.zig").Error!Remaining {
|
||||
const rows = try upstreams_repo.listUpstreamRows(database, arena);
|
||||
var found = false;
|
||||
var left: usize = 0;
|
||||
for (rows.items) |row| {
|
||||
if (row.id == id) {
|
||||
found = true;
|
||||
continue;
|
||||
}
|
||||
if (row.enabled) left += 1;
|
||||
}
|
||||
return if (found) .{ .count = left } else .missing;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// routes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn list(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
_ = io;
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return mutations.respondFailure(request, failure, "listing upstreams"),
|
||||
};
|
||||
|
||||
const rows = upstreams_repo.listUpstreamRows(database, request.arena) catch |err|
|
||||
return mutations.respondFailure(request, .{ .internal = err }, "listing upstreams");
|
||||
|
||||
return http_util.respondJson(request, .ok, .{ .upstreams = rows.items }, &.{});
|
||||
}
|
||||
|
||||
pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
_ = io;
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return mutations.respondFailure(request, failure, "reading an upstream"),
|
||||
};
|
||||
|
||||
const row = upstreams_repo.getUpstream(database, request.arena, request.id.?) catch |err|
|
||||
return mutations.respondFailure(request, .{ .internal = err }, "reading an upstream");
|
||||
const found = row orelse return mutations.respondFailure(request, .not_found, "");
|
||||
|
||||
return http_util.respondJson(request, .ok, found, &.{});
|
||||
}
|
||||
|
||||
pub fn create(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
const parsed = http_util.parseBody(Body, request) catch |err|
|
||||
return mutations.respondBadBody(request, err);
|
||||
const item = toModel(parsed.value);
|
||||
|
||||
return switch (try applyCreate(state, io, request.arena, item)) {
|
||||
.fail => |failure| mutations.respondFailure(request, failure, "creating an upstream"),
|
||||
.id => |id| http_util.respondJson(request, .created, .{
|
||||
.id = id,
|
||||
.url = item.url,
|
||||
.priority = item.priority,
|
||||
.enabled = item.enabled,
|
||||
.tls_name = item.tls_name,
|
||||
.restart_required = true,
|
||||
}, &.{}),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn update(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
const parsed = http_util.parseBody(Body, request) catch |err|
|
||||
return mutations.respondBadBody(request, err);
|
||||
const item = toModel(parsed.value);
|
||||
const id = request.id.?;
|
||||
|
||||
if (try applyUpdate(state, io, request.arena, id, item)) |failure| {
|
||||
return mutations.respondFailure(request, failure, "updating an upstream");
|
||||
}
|
||||
return http_util.respondJson(request, .ok, .{
|
||||
.id = id,
|
||||
.url = item.url,
|
||||
.priority = item.priority,
|
||||
.enabled = item.enabled,
|
||||
.tls_name = item.tls_name,
|
||||
.restart_required = true,
|
||||
}, &.{});
|
||||
}
|
||||
|
||||
pub fn remove(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
if (applyDelete(state, io, request.arena, request.id.?)) |failure| {
|
||||
return mutations.respondFailure(request, failure, "deleting an upstream");
|
||||
}
|
||||
return http_util.respondEmpty(request, .no_content);
|
||||
}
|
||||
|
||||
fn toModel(body: Body) model.UpstreamServer {
|
||||
return .{
|
||||
.url = body.url,
|
||||
.priority = body.priority,
|
||||
.enabled = body.enabled,
|
||||
.tls_name = body.tls_name,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
const doh: model.UpstreamServer = .{ .url = "https://dns.example/dns-query" };
|
||||
|
||||
test "a created upstream is stored" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
const created = try applyCreate(&bench.state, bench.io(), bench.arena(), doh);
|
||||
const row = (try upstreams_repo.getUpstream(&bench.database, bench.arena(), created.id)).?;
|
||||
try testing.expectEqualStrings(doh.url, row.url);
|
||||
try testing.expect(row.enabled);
|
||||
try testing.expectEqualStrings("", row.tls_name);
|
||||
}
|
||||
|
||||
test "an upstream change never announces a reload" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
const created = try applyCreate(&bench.state, bench.io(), bench.arena(), doh);
|
||||
_ = try applyUpdate(&bench.state, bench.io(), bench.arena(), created.id, .{
|
||||
.url = doh.url,
|
||||
.priority = 50,
|
||||
.enabled = true,
|
||||
});
|
||||
// Ruling 12: the pool is built at startup, so nothing is live to reload.
|
||||
try testing.expectEqual(@as(usize, 0), bench.reloads);
|
||||
}
|
||||
|
||||
test "a url the validator refuses never reaches the database" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
const scheme = try applyCreate(&bench.state, bench.io(), bench.arena(), .{ .url = "udp://1.1.1.1:53" });
|
||||
try testing.expect(scheme.fail == .invalid);
|
||||
|
||||
const misplaced_name = try applyCreate(&bench.state, bench.io(), bench.arena(), .{
|
||||
.url = "https://dns.example/dns-query",
|
||||
.tls_name = "dns.example",
|
||||
});
|
||||
try testing.expect(misplaced_name.fail == .invalid);
|
||||
|
||||
try testing.expectEqual(@as(i64, 0), try bench.queryInt("SELECT count(*) FROM upstreams"));
|
||||
}
|
||||
|
||||
test "a duplicate url is a conflict" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
_ = try applyCreate(&bench.state, bench.io(), bench.arena(), doh);
|
||||
const again = try applyCreate(&bench.state, bench.io(), bench.arena(), doh);
|
||||
try testing.expectEqualStrings(url_conflict, again.fail.conflict);
|
||||
}
|
||||
|
||||
test "the last enabled upstream cannot be deleted" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
const created = try applyCreate(&bench.state, bench.io(), bench.arena(), doh);
|
||||
const failure = applyDelete(&bench.state, bench.io(), bench.arena(), created.id);
|
||||
try testing.expectEqualStrings("the last enabled upstream cannot be removed", failure.?.conflict);
|
||||
|
||||
const second = try applyCreate(&bench.state, bench.io(), bench.arena(), .{
|
||||
.url = "tls://1.1.1.1:853",
|
||||
.tls_name = "one.one.one.one",
|
||||
});
|
||||
try testing.expectEqual(
|
||||
@as(?Failure, null),
|
||||
applyDelete(&bench.state, bench.io(), bench.arena(), created.id),
|
||||
);
|
||||
try testing.expectEqual(
|
||||
@as(i64, 1),
|
||||
try bench.queryInt("SELECT count(*) FROM upstreams"),
|
||||
);
|
||||
try testing.expect(second == .id);
|
||||
}
|
||||
|
||||
test "a disabled upstream can be created while an enabled one exists" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
_ = try applyCreate(&bench.state, bench.io(), bench.arena(), doh);
|
||||
const spare = try applyCreate(&bench.state, bench.io(), bench.arena(), .{
|
||||
.url = "tls://1.1.1.1:853",
|
||||
.tls_name = "one.one.one.one",
|
||||
.enabled = false,
|
||||
});
|
||||
try testing.expect(spare == .id);
|
||||
try testing.expectEqual(
|
||||
@as(i64, 0),
|
||||
try bench.queryInt("SELECT enabled FROM upstreams WHERE url = 'tls://1.1.1.1:853'"),
|
||||
);
|
||||
}
|
||||
|
||||
test "the last enabled upstream cannot be disabled" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
const created = try applyCreate(&bench.state, bench.io(), bench.arena(), doh);
|
||||
const off: model.UpstreamServer = .{ .url = doh.url, .enabled = false };
|
||||
|
||||
const refused = try applyUpdate(&bench.state, bench.io(), bench.arena(), created.id, off);
|
||||
try testing.expectEqualStrings("the last enabled upstream cannot be disabled", refused.?.conflict);
|
||||
try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT count(*) FROM upstreams WHERE enabled = 1"));
|
||||
|
||||
_ = try applyCreate(&bench.state, bench.io(), bench.arena(), .{
|
||||
.url = "tls://1.1.1.1:853",
|
||||
.tls_name = "one.one.one.one",
|
||||
});
|
||||
try testing.expectEqual(
|
||||
@as(?Failure, null),
|
||||
try applyUpdate(&bench.state, bench.io(), bench.arena(), created.id, off),
|
||||
);
|
||||
try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT count(*) FROM upstreams WHERE enabled = 1"));
|
||||
}
|
||||
|
||||
test "an id no upstream holds is a 404 on both update and delete" {
|
||||
var bench: mutations.Bench = undefined;
|
||||
try bench.init(testing.allocator);
|
||||
defer bench.deinit(testing.allocator);
|
||||
|
||||
try testing.expectEqual(
|
||||
Failure.not_found,
|
||||
(try applyUpdate(&bench.state, bench.io(), bench.arena(), 999, doh)).?,
|
||||
);
|
||||
try testing.expectEqual(
|
||||
Failure.not_found,
|
||||
applyDelete(&bench.state, bench.io(), bench.arena(), 999).?,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//! `GET /api/version` — what this binary is and how long it has been running.
|
||||
//!
|
||||
//! Unauthenticated (ruling 18), like the other monitoring endpoints. The three
|
||||
//! strings are build options, so nothing here reads the running configuration.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const http_util = @import("../http_util.zig");
|
||||
const server = @import("../server.zig");
|
||||
const version = @import("../../version.zig");
|
||||
|
||||
pub const Body = struct {
|
||||
version: []const u8,
|
||||
git_commit: []const u8,
|
||||
zig_version: []const u8,
|
||||
/// Seconds since the process started. Zero until `started_unix` is wired,
|
||||
/// and never negative: a clock stepped backwards must not report a
|
||||
/// process that started in the future.
|
||||
uptime_seconds: u64,
|
||||
};
|
||||
|
||||
pub fn handle(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
request: *http_util.Request,
|
||||
) http_util.HandlerError!void {
|
||||
const now = std.Io.Clock.real.now(io).toSeconds();
|
||||
return http_util.respondJson(request, .ok, body(state.version, state.started_unix, now), &.{});
|
||||
}
|
||||
|
||||
pub fn body(version_string: []const u8, started_unix: i64, now_unix: i64) Body {
|
||||
return .{
|
||||
.version = if (version_string.len == 0) version.string else version_string,
|
||||
.git_commit = version.git_commit,
|
||||
.zig_version = version.zig_version_string,
|
||||
.uptime_seconds = uptime(started_unix, now_unix),
|
||||
};
|
||||
}
|
||||
|
||||
fn uptime(started_unix: i64, now_unix: i64) u64 {
|
||||
if (started_unix <= 0 or now_unix <= started_unix) return 0;
|
||||
return @intCast(now_unix - started_unix);
|
||||
}
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "the body carries the build strings and the elapsed time" {
|
||||
const out = body("", 1_000, 1_060);
|
||||
try testing.expectEqualStrings(version.string, out.version);
|
||||
try testing.expectEqualStrings(version.git_commit, out.git_commit);
|
||||
try testing.expectEqualStrings(version.zig_version_string, out.zig_version);
|
||||
try testing.expectEqual(@as(u64, 60), out.uptime_seconds);
|
||||
}
|
||||
|
||||
test "the state's version string wins over the compiled-in one" {
|
||||
try testing.expectEqualStrings("9.9.9", body("9.9.9", 0, 0).version);
|
||||
}
|
||||
|
||||
test "an unset start time and a clock that stepped back both read as zero uptime" {
|
||||
try testing.expectEqual(@as(u64, 0), body("", 0, 5_000).uptime_seconds);
|
||||
try testing.expectEqual(@as(u64, 0), body("", 5_000, 4_000).uptime_seconds);
|
||||
}
|
||||
|
||||
test "the body serializes with snake_case field names" {
|
||||
var buffer: [256]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(&buffer);
|
||||
try std.json.Stringify.value(body("1.2.3", 10, 20), .{}, &writer);
|
||||
const text = writer.buffered();
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "\"git_commit\":"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "\"zig_version\":"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "\"uptime_seconds\":10"));
|
||||
}
|
||||
Reference in New Issue
Block a user