//! `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$")); }