milestone 7: serving pipeline, client tracking, pause and lifecycle

This commit is contained in:
2026-08-01 21:43:52 +02:00
parent 8c50b6617f
commit a8092bb1b9
17 changed files with 5916 additions and 131 deletions
+59 -13
View File
@@ -18,12 +18,14 @@ const Writer = std.Io.Writer;
const Certificate = std.crypto.Certificate;
const tls = std.crypto.tls;
const app = @import("app.zig");
const config_export = @import("config/export.zig");
const import = @import("config/import.zig");
const model = @import("config/model.zig");
const validate = @import("config/validate.zig");
const db = @import("storage/db.zig");
const migrations = @import("storage/migrations.zig");
const querylog_schema = @import("storage/querylog_schema.zig");
const doh_client = @import("upstream/doh_client.zig");
const dot_client = @import("upstream/dot_client.zig");
const pool = @import("upstream/pool.zig");
@@ -204,7 +206,9 @@ pub const DataDir = struct {
_ = try std.Io.Dir.cwd().createDirPathStatus(io, data_dir, .fromMode(0o700));
}
var dir = try std.Io.Dir.cwd().openDir(io, data_dir, .{});
// `.iterate`: the disk monitor sizes the databases by scanning this
// directory, and an fd opened without it cannot be read as a directory.
var dir = try std.Io.Dir.cwd().openDir(io, data_dir, .{ .iterate = true });
errdefer dir.close(io);
// SQLite opens by path, not by directory handle, so both paths are
@@ -243,6 +247,54 @@ pub const DataDir = struct {
try db.applyPragmas(&database, .{});
return database;
}
/// The first connection to `querylog.db`: `querylog_schema.open` creates the
/// file when it is missing and recreates it when it is unusable, so this is
/// the call that establishes the schema. `reopenQuerylogDb` is for the
/// connections that follow.
///
/// The 0600 chmod cannot come first the way `openConfigDb` does it — the
/// file may not exist yet, and a recreate replaces it — so the main file and
/// both WAL sidecars are locked down afterwards instead. A query log holds
/// every domain every client asked for, which is as sensitive as anything in
/// `config.db`.
///
/// `querylog_schema.open` resolves the path through SQLite's VFS as well as
/// through the directory handle, so it is given `cwd` and the joined path
/// rather than `self.dir` and a name (see its doc comment).
pub fn openQuerylogDb(self: *const DataDir, io: std.Io) !db.Db {
const opened = try querylog_schema.open(io, std.Io.Dir.cwd(), self.querylog_db_path);
var database = opened.database;
errdefer database.close();
try self.restrictQuerylogPermissions(io);
return database;
}
/// An additional connection to a `querylog.db` that `openQuerylogDb` has
/// already established. Phase 7 needs two — the log writer and the retention
/// pass each own one (`retention.zig`'s contract).
pub fn reopenQuerylogDb(self: *const DataDir, io: std.Io) !db.Db {
_ = io;
var database = try db.Db.open(self.querylog_db_path, .{ .mode = .read_write_existing });
errdefer database.close();
try db.applyPragmas(&database, .{});
return database;
}
/// A sidecar that does not exist yet is not a failure: `-wal` and `-shm`
/// appear when SQLite first writes, and the next call catches them.
fn restrictQuerylogPermissions(self: *const DataDir, io: std.Io) !void {
for ([_][]const u8{
querylog_db_name,
querylog_db_name ++ "-wal",
querylog_db_name ++ "-shm",
}) |entry| {
self.dir.setFilePermissions(io, entry, .fromMode(0o600), .{}) catch |e| switch (e) {
error.FileNotFound => {},
else => |other| return other,
};
}
}
};
// ---------------------------------------------------------------------------
@@ -320,12 +372,11 @@ pub fn runVersion(r: Runner) u8 {
return finish(r, exit_ok);
}
/// Unchanged from milestone 1. Wiring the configuration into the servers is
/// Phase 7; there is deliberately no half-built serving path here.
/// Serves DNS until SIGINT or SIGTERM. The whole of it lives in `app.zig`,
/// which is where the composition root belongs; this stays the entry point so
/// that `main` dispatches every command the same way.
pub fn runRun(r: Runner, paths: Paths) u8 {
_ = paths;
r.out.writeAll("not implemented\n") catch return finish(r, exit_runtime);
return finish(r, exit_check);
return app.run(r, paths);
}
pub fn runExport(r: Runner, args: ExportArgs) u8 {
@@ -879,13 +930,8 @@ test "runVersion prints the milestone-1 version lines and exits 0" {
try testing.expectEqual(@as(usize, 2), countLines(captured.out.written()));
}
test "runRun still reports that serving is not implemented" {
var captured: Captured = .init(testing.allocator);
defer captured.deinit();
try testing.expectEqual(exit_check, runRun(captured.runner(), .{}));
try testing.expectEqualStrings("not implemented\n", captured.out.written());
}
// `runRun` now binds sockets and serves until a signal arrives, so it has no
// unit test: booting it is `src/server/phase7_integration_test.zig`'s case 11.
test "runUsageError names the fault and prints the usage text" {
var captured: Captured = .init(testing.allocator);