storage and config: sqlite wrapper, migrations, querylog policy, repositories, zon config with import/export/check cli
This commit is contained in:
@@ -0,0 +1,309 @@
|
||||
//! `nxdns export`: the database rendered back as canonical ZON.
|
||||
//!
|
||||
//! Deterministic by construction. Every list arrives through a repository whose
|
||||
//! `ORDER BY` ends in a unique column set, every scalar comes from the settings
|
||||
//! map, and the header carries no timestamp, version or host name. That is what
|
||||
//! makes `export` → `import` → `export` byte-identical, and it keeps a config
|
||||
//! diff free of noise.
|
||||
//!
|
||||
//! Runtime columns are absent from the model on purpose, so two exports taken
|
||||
//! minutes apart on a live server are identical too.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const Writer = std.Io.Writer;
|
||||
|
||||
const db = @import("../storage/db.zig");
|
||||
const clients_repo = @import("../storage/repositories/clients_repo.zig");
|
||||
const groups_repo = @import("../storage/repositories/groups_repo.zig");
|
||||
const local_repo = @import("../storage/repositories/local_repo.zig");
|
||||
const rules_repo = @import("../storage/repositories/rules_repo.zig");
|
||||
const settings_repo = @import("../storage/repositories/settings_repo.zig");
|
||||
const sources_repo = @import("../storage/repositories/sources_repo.zig");
|
||||
const upstreams_repo = @import("../storage/repositories/upstreams_repo.zig");
|
||||
const model = @import("model.zig");
|
||||
|
||||
const log = std.log.scoped(.config_export);
|
||||
|
||||
/// What reading the database into a `Config` can fail with. A caller that only
|
||||
/// wants the value never has to handle a filesystem error.
|
||||
pub const ReadError = db.Error || model.SettingsError;
|
||||
|
||||
pub const Error = ReadError || Writer.Error ||
|
||||
std.Io.Dir.CreateFileAtomicError || std.Io.File.SyncError || std.Io.File.Atomic.ReplaceError;
|
||||
|
||||
const header =
|
||||
\\// nxdns configuration
|
||||
\\// generated by `nxdns export` — the database is the source of truth
|
||||
\\
|
||||
;
|
||||
|
||||
/// Reads every repository into `arena` and assembles a `Config`. Every string in
|
||||
/// the result belongs to `arena`; there is nothing else to free.
|
||||
pub fn readConfig(database: *db.Db, arena: Allocator) ReadError!model.Config {
|
||||
var cfg: model.Config = .{};
|
||||
|
||||
// The scalar sections first: an absent key keeps the model default, which is
|
||||
// how a migration adds a setting with no data step. Unknown keys are counted
|
||||
// and logged rather than refused — downgrading a binary must not make a
|
||||
// config database unreadable.
|
||||
const settings = try settings_repo.listSettings(database, arena);
|
||||
var unknown_keys: usize = 0;
|
||||
try model.fromSettings(settings.items, &cfg, &unknown_keys);
|
||||
if (unknown_keys != 0) {
|
||||
log.warn("{d} unknown settings key(s) were ignored while exporting", .{unknown_keys});
|
||||
}
|
||||
|
||||
cfg.groups = (try groups_repo.listGroups(database, arena)).items;
|
||||
cfg.upstreams = (try upstreams_repo.listUpstreams(database, arena)).items;
|
||||
cfg.clients = (try clients_repo.listClients(database, arena)).items;
|
||||
cfg.client_prefixes = (try clients_repo.listClientPrefixes(database, arena)).items;
|
||||
cfg.blocklist_sources = (try sources_repo.listBlocklistSources(database, arena)).items;
|
||||
cfg.group_sources = (try groups_repo.listGroupSources(database, arena)).items;
|
||||
cfg.rules = (try rules_repo.listRules(database, arena)).items;
|
||||
cfg.local_records = (try local_repo.listLocalRecords(database, arena)).items;
|
||||
cfg.forward_zones = (try local_repo.listForwardZones(database, arena)).items;
|
||||
|
||||
// `web.password` is operator input and is never stored; the exported file
|
||||
// always carries an empty one. This is exactly what makes the round trip
|
||||
// stable: re-importing takes the "password is empty" branch and stores the
|
||||
// same hash.
|
||||
cfg.web.password = "";
|
||||
return cfg;
|
||||
}
|
||||
|
||||
/// Canonical ZON: the fixed header, then the value with every default emitted.
|
||||
/// Emitting defaults makes the file a complete record of the running
|
||||
/// configuration and makes the round trip independent of a later change to a
|
||||
/// default value.
|
||||
pub fn writeConfig(cfg: model.Config, w: *Writer) Writer.Error!void {
|
||||
try w.writeAll(header);
|
||||
try std.zon.stringify.serialize(cfg, .{
|
||||
.whitespace = true,
|
||||
.emit_default_optional_fields = true,
|
||||
}, w);
|
||||
try w.writeAll("\n");
|
||||
}
|
||||
|
||||
pub fn writeToWriter(gpa: Allocator, database: *db.Db, w: *Writer) Error!void {
|
||||
var arena_state: std.heap.ArenaAllocator = .init(gpa);
|
||||
defer arena_state.deinit();
|
||||
|
||||
const cfg = try readConfig(database, arena_state.allocator());
|
||||
return writeConfig(cfg, w);
|
||||
}
|
||||
|
||||
/// Atomic and owner-only. The exported file carries `web.password_hash`, so 0600
|
||||
/// is not optional.
|
||||
///
|
||||
/// `createFileAtomic` puts its temporary file in the destination's own directory
|
||||
/// (verified in `Io/Threaded.zig`: `atomicFileInit` receives either `dir` or the
|
||||
/// directory opened on `dirname(dest_path)`), so the final `replace` is a
|
||||
/// same-filesystem `rename` and is genuinely atomic.
|
||||
pub fn writeToFile(
|
||||
io: std.Io,
|
||||
gpa: Allocator,
|
||||
database: *db.Db,
|
||||
dir: std.Io.Dir,
|
||||
path: []const u8,
|
||||
) Error!void {
|
||||
var arena_state: std.heap.ArenaAllocator = .init(gpa);
|
||||
defer arena_state.deinit();
|
||||
const cfg = try readConfig(database, arena_state.allocator());
|
||||
|
||||
var af = try dir.createFileAtomic(io, path, .{
|
||||
.permissions = .fromMode(0o600),
|
||||
.replace = true,
|
||||
});
|
||||
defer af.deinit(io);
|
||||
|
||||
var buf: [4096]u8 = undefined;
|
||||
var fw = af.file.writer(io, &buf);
|
||||
writeConfig(cfg, &fw.interface) catch |e| return reportWriteFailure(&fw, e);
|
||||
fw.interface.flush() catch |e| return reportWriteFailure(&fw, e);
|
||||
|
||||
// Before `replace`, which closes the file: the rename must publish durable
|
||||
// bytes, not an empty file with the content still in the page cache.
|
||||
try af.file.sync(io);
|
||||
try af.replace(io);
|
||||
}
|
||||
|
||||
/// `Writer.Error` is a single `error.WriteFailed`; the cause lives on the
|
||||
/// `File.Writer`. Logging it at `warn` is what turns "export failed" into
|
||||
/// something an operator can act on.
|
||||
fn reportWriteFailure(fw: *std.Io.File.Writer, e: Writer.Error) Writer.Error {
|
||||
if (fw.err) |cause| log.warn("writing the export failed: {s}", .{@errorName(cause)});
|
||||
return e;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const testing = std.testing;
|
||||
const migrations = @import("../storage/migrations.zig");
|
||||
const import = @import("import.zig");
|
||||
const validate = @import("validate.zig");
|
||||
|
||||
fn openMigrated() !db.Db {
|
||||
var database = try db.Db.open(":memory:", .{ .mode = .memory });
|
||||
errdefer database.close();
|
||||
try db.applyPragmas(&database, .{});
|
||||
_ = try migrations.migrate(&database);
|
||||
return database;
|
||||
}
|
||||
|
||||
const seed_source: [:0]const u8 =
|
||||
\\.{
|
||||
\\ .dns = .{ .port = 5353 },
|
||||
\\ .logging = .{ .level = .err, .retention_days = 7 },
|
||||
\\ .web = .{ .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$c2FsdHNhbHQ$aGFzaGhhc2g" },
|
||||
\\ .groups = .{ .{ .name = "default" }, .{ .name = "kids", .safe_search = true } },
|
||||
\\ .upstreams = .{
|
||||
\\ .{ .url = "https://dns.example/dns-query", .priority = 10 },
|
||||
\\ .{ .url = "tls://dot.example:853", .priority = 20, .enabled = false },
|
||||
\\ },
|
||||
\\ .clients = .{ .{ .ip = "fd00::1", .name = "tablet", .group = "kids" } },
|
||||
\\ .client_prefixes = .{ .{ .prefix = "192.168.1.0/24", .group = "kids", .priority = 50 } },
|
||||
\\ .blocklist_sources = .{ .{ .url = "https://lists.example/ads.txt", .name = "ads" } },
|
||||
\\ .group_sources = .{ .{ .group = "kids", .source_url = "https://lists.example/ads.txt" } },
|
||||
\\ .rules = .{
|
||||
\\ .{ .group = "kids", .pattern = "*.tracker.example", .kind = .wildcard, .action = .block },
|
||||
\\ .{ .group = "default", .pattern = "allowed.example", .kind = .exact, .action = .allow },
|
||||
\\ },
|
||||
\\ .local_records = .{
|
||||
\\ .{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.10", .ttl = 600 },
|
||||
\\ .{ .name = "nas.lan", .rtype = .aaaa, .value = "fd00::10" },
|
||||
\\ },
|
||||
\\ .forward_zones = .{ .{ .zone = "lan", .resolver = "udp://192.168.1.1:53" } },
|
||||
\\}
|
||||
;
|
||||
|
||||
fn seed(io: std.Io, database: *db.Db, source: [:0]const u8) !void {
|
||||
var diags: validate.Diagnostics = .init(testing.allocator);
|
||||
defer diags.deinit();
|
||||
return import.importSource(io, testing.allocator, database, source, .{}, &diags);
|
||||
}
|
||||
|
||||
test "writeConfig emits the fixed header and re-parses into an equal config" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
const gpa = testing.allocator;
|
||||
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
try seed(io, &database, seed_source);
|
||||
|
||||
var out: Writer.Allocating = .init(gpa);
|
||||
defer out.deinit();
|
||||
try writeToWriter(gpa, &database, &out.writer);
|
||||
|
||||
// The header is fixed text. Anything variable in it — a timestamp, a version
|
||||
// or a host name — would break the byte-stable round trip.
|
||||
const text = out.written();
|
||||
try testing.expect(std.mem.startsWith(u8, text, header));
|
||||
try testing.expectEqualStrings(header, text[0..header.len]);
|
||||
|
||||
var arena_state: std.heap.ArenaAllocator = .init(gpa);
|
||||
defer arena_state.deinit();
|
||||
const source = try gpa.dupeZ(u8, text);
|
||||
defer gpa.free(source);
|
||||
const reparsed = try std.zon.parse.fromSliceAlloc(
|
||||
model.Config,
|
||||
arena_state.allocator(),
|
||||
source,
|
||||
null,
|
||||
.{},
|
||||
);
|
||||
try testing.expectEqual(@as(u16, 5353), reparsed.dns.port);
|
||||
try testing.expectEqual(model.LogLevel.err, reparsed.logging.level);
|
||||
}
|
||||
|
||||
test "readConfig, writeConfig, import and readConfig again produce an equal config" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
const gpa = testing.allocator;
|
||||
|
||||
var first = try openMigrated();
|
||||
defer first.close();
|
||||
try seed(io, &first, seed_source);
|
||||
|
||||
var text: Writer.Allocating = .init(gpa);
|
||||
defer text.deinit();
|
||||
try writeToWriter(gpa, &first, &text.writer);
|
||||
const source = try text.toOwnedSliceSentinel(0);
|
||||
defer gpa.free(source);
|
||||
|
||||
var second = try openMigrated();
|
||||
defer second.close();
|
||||
try seed(io, &second, source);
|
||||
|
||||
var arena_a: std.heap.ArenaAllocator = .init(gpa);
|
||||
defer arena_a.deinit();
|
||||
var arena_b: std.heap.ArenaAllocator = .init(gpa);
|
||||
defer arena_b.deinit();
|
||||
|
||||
const a = try readConfig(&first, arena_a.allocator());
|
||||
const b = try readConfig(&second, arena_b.allocator());
|
||||
|
||||
try testing.expectEqual(a.dns.port, b.dns.port);
|
||||
try testing.expectEqual(a.logging.level, b.logging.level);
|
||||
try testing.expectEqualStrings(a.web.password_hash, b.web.password_hash);
|
||||
try testing.expectEqual(a.groups.len, b.groups.len);
|
||||
try testing.expectEqual(a.rules.len, b.rules.len);
|
||||
try testing.expectEqualStrings(a.clients[0].ip, b.clients[0].ip);
|
||||
try testing.expectEqualStrings(a.forward_zones[0].resolver, b.forward_zones[0].resolver);
|
||||
}
|
||||
|
||||
test "export is byte-stable across a re-import" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
const gpa = testing.allocator;
|
||||
|
||||
var first = try openMigrated();
|
||||
defer first.close();
|
||||
try seed(io, &first, seed_source);
|
||||
|
||||
var a: Writer.Allocating = .init(gpa);
|
||||
defer a.deinit();
|
||||
try writeToWriter(gpa, &first, &a.writer);
|
||||
const source = try gpa.dupeZ(u8, a.written());
|
||||
defer gpa.free(source);
|
||||
|
||||
var second = try openMigrated();
|
||||
defer second.close();
|
||||
try seed(io, &second, source);
|
||||
|
||||
var b: Writer.Allocating = .init(gpa);
|
||||
defer b.deinit();
|
||||
try writeToWriter(gpa, &second, &b.writer);
|
||||
|
||||
try testing.expectEqualStrings(a.written(), b.written());
|
||||
}
|
||||
|
||||
test "an exported password_hash survives a re-import unchanged" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
const gpa = testing.allocator;
|
||||
|
||||
var database = try openMigrated();
|
||||
defer database.close();
|
||||
|
||||
const cfg: model.Config = .{
|
||||
.groups = &.{.{ .name = "default" }},
|
||||
.upstreams = &.{.{ .url = "https://dns.example/dns-query" }},
|
||||
.web = .{ .password = "correct horse battery staple" },
|
||||
};
|
||||
try import.applyToDb(io, gpa, &database, cfg, 42, .{});
|
||||
|
||||
var arena_state: std.heap.ArenaAllocator = .init(gpa);
|
||||
defer arena_state.deinit();
|
||||
const exported = try readConfig(&database, arena_state.allocator());
|
||||
|
||||
try testing.expectEqualStrings("", exported.web.password);
|
||||
try testing.expect(std.mem.startsWith(u8, exported.web.password_hash, "$argon2id$"));
|
||||
}
|
||||
Reference in New Issue
Block a user