Gates / test-aarch64 (push) Successful in 6m45s
Gates / frontend (push) Successful in 51s
Gates / test (push) Successful in 1m37s
Gates / container (push) Failing after 7m31s
Gates / package (push) Failing after 15m14s
CI / gates (push) Failing after 24m27s
362 lines
14 KiB
Zig
362 lines
14 KiB
Zig
//! `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` from the running configuration
|
|
\\
|
|
;
|
|
|
|
/// 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, so the exported
|
|
// file always states it as absent. Absent rather than `""`: a present empty
|
|
// password is refused by `validate` (ruling 4), so exporting one would make
|
|
// every export fail its own rules. It is also what makes the round trip
|
|
// stable — re-applying the file takes the "password_hash written verbatim"
|
|
// branch and stores the same hash.
|
|
cfg.web.password = null;
|
|
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://192.0.2.53:853", .priority = 20, .enabled = false, .tls_name = "dot.example" },
|
|
\\ },
|
|
\\ .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.upstreams.len, b.upstreams.len);
|
|
for (a.upstreams, b.upstreams) |left, right| {
|
|
try testing.expectEqualStrings(left.url, right.url);
|
|
try testing.expectEqualStrings(left.tls_name, right.tls_name);
|
|
}
|
|
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" },
|
|
};
|
|
var diags: validate.Diagnostics = .init(gpa);
|
|
defer diags.deinit();
|
|
try import.apply(io, gpa, &database, cfg, 42, .{}, &diags);
|
|
|
|
var arena_state: std.heap.ArenaAllocator = .init(gpa);
|
|
defer arena_state.deinit();
|
|
const exported = try readConfig(&database, arena_state.allocator());
|
|
|
|
try testing.expectEqual(@as(?[]const u8, null), exported.web.password);
|
|
try testing.expect(std.mem.startsWith(u8, exported.web.password_hash.?, "$argon2id$"));
|
|
}
|
|
|
|
test "the exported password form is the one validate accepts" {
|
|
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();
|
|
var apply_diags: validate.Diagnostics = .init(gpa);
|
|
defer apply_diags.deinit();
|
|
try import.apply(io, gpa, &database, .{
|
|
.groups = &.{.{ .name = "default" }},
|
|
.upstreams = &.{.{ .url = "https://dns.example/dns-query" }},
|
|
.web = .{ .password = "correct horse battery staple" },
|
|
}, 42, .{}, &apply_diags);
|
|
|
|
var out: Writer.Allocating = .init(gpa);
|
|
defer out.deinit();
|
|
try writeToWriter(gpa, &database, &out.writer);
|
|
|
|
// The literal form matters: an export carrying `password = ""` beside a
|
|
// stored hash would trip `EmptyWebPassword` on the way back in, so export
|
|
// would produce a file its own validator refuses.
|
|
try testing.expect(std.mem.indexOf(u8, out.written(), ".password = null,") != null);
|
|
|
|
const source = try gpa.dupeZ(u8, out.written());
|
|
defer gpa.free(source);
|
|
var arena_state: std.heap.ArenaAllocator = .init(gpa);
|
|
defer arena_state.deinit();
|
|
const reparsed = try std.zon.parse.fromSliceAlloc(
|
|
model.Config,
|
|
arena_state.allocator(),
|
|
source,
|
|
null,
|
|
.{},
|
|
);
|
|
var diags: validate.Diagnostics = .init(gpa);
|
|
defer diags.deinit();
|
|
try validate.validate(reparsed, &diags);
|
|
try testing.expectEqual(@as(?[]const u8, null), reparsed.web.password);
|
|
try testing.expect(std.mem.startsWith(u8, reparsed.web.password_hash.?, "$argon2id$"));
|
|
}
|