695 lines
28 KiB
Zig
695 lines
28 KiB
Zig
//! `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 builtin = @import("builtin");
|
|
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 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,
|
|
trusted_proxies: []const u8,
|
|
/// 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 {
|
|
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 .{
|
|
.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,
|
|
.trusted_proxies = cfg.web.trusted_proxies,
|
|
.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 = mutations.requireConfigDb(state) catch return .{ .fail = mutations.no_config_db };
|
|
|
|
// Ruling 18 of milestone 16: argon2id at m=19 MiB is the longest thing this
|
|
// handler does, and its input is the parsed patch alone — nothing under the
|
|
// lock. Hashing inside the lock stalled every settings read and every other
|
|
// mutation for its duration. The login path already hashes unlocked
|
|
// (auth.zig), and `LiveHash`'s generation check closes the install race.
|
|
const password = newPassword(patch);
|
|
var new_hash: []const u8 = "";
|
|
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);
|
|
new_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 } },
|
|
};
|
|
}
|
|
|
|
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: the hash made above is what the merged
|
|
// configuration — and therefore the settings table — carries.
|
|
const previous_hash = cfg.web.password_hash;
|
|
if (password != null) cfg.web.password_hash = new_hash;
|
|
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 {
|
|
hash_stall.park(io);
|
|
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;
|
|
},
|
|
};
|
|
}
|
|
|
|
/// Holds a hash still so a test can prove another request runs beside it. The
|
|
/// hash finishing on its own would prove nothing: before ruling 18 a settings
|
|
/// GET also completed, it merely waited out the hash first. The storage exists
|
|
/// in a test build only, and `park` reduces to nothing everywhere else — the
|
|
/// rotation seam's shape (logging.zig).
|
|
const hash_stall = if (builtin.is_test) struct {
|
|
var armed: bool = false;
|
|
var parked: std.Io.Event = .unset;
|
|
var release: std.Io.Event = .unset;
|
|
|
|
fn park(io: std.Io) void {
|
|
if (!armed) return;
|
|
parked.set(io);
|
|
release.waitUncancelable(io);
|
|
}
|
|
} else struct {
|
|
fn park(io: std.Io) void {
|
|
_ = io;
|
|
}
|
|
};
|
|
|
|
/// The seam's controls, for the test that proves a settings read runs beside a
|
|
/// hash in flight. Present in a test build only.
|
|
pub const hash_stall_control = if (builtin.is_test) struct {
|
|
pub fn arm() void {
|
|
hash_stall.parked = .unset;
|
|
hash_stall.release = .unset;
|
|
hash_stall.armed = true;
|
|
}
|
|
|
|
/// Returns once a hash is parked on the seam.
|
|
pub fn waitParked(io: std.Io) void {
|
|
hash_stall.parked.waitUncancelable(io);
|
|
}
|
|
|
|
/// Lets the parked hash finish and disarms the seam for the next test.
|
|
pub fn release(io: std.Io) void {
|
|
hash_stall.armed = false;
|
|
hash_stall.release.set(io);
|
|
}
|
|
} else struct {};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// routes
|
|
// ---------------------------------------------------------------------------
|
|
|
|
pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
|
const database = mutations.requireConfigDb(state) catch
|
|
return mutations.respondFailure(request, mutations.no_config_db, "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 },
|
|
});
|
|
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.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);
|
|
}
|