1364 lines
58 KiB
Zig
1364 lines
58 KiB
Zig
//! The composition root: everything `nxdns run` owns, in the order it is built.
|
|
//!
|
|
//! Nothing else in the program constructs a collaborator. Each module takes its
|
|
//! dependencies as parameters and knows nothing about who supplies them, so
|
|
//! this file is the only place where the object graph exists — and the only
|
|
//! place a lifetime spans the whole process.
|
|
//!
|
|
//! Two rules shape it:
|
|
//!
|
|
//! 1. **Nothing that is pointed at may move.** `Pool.Entry.client` erases a
|
|
//! pointer into a DoH or DoT client, the `Logger`'s queue holds waiting
|
|
//! tasks in intrusive lists, and the `Manager` is addressed through `self`.
|
|
//! Every such value therefore lives in `serve`'s frame or in an owned heap
|
|
//! allocation, never in a temporary that is copied afterwards.
|
|
//! 2. **Every background loop is a task in one `std.Io.Group`.** Shutdown is
|
|
//! `group.cancel`, which requests cancellation and joins, so no loop can
|
|
//! still be running when the resources it borrows are released.
|
|
//!
|
|
//! Failure policy: a startup failure prints one line to the runner's error
|
|
//! writer and exits. A configuration the operator can fix exits 2, so that
|
|
//! `nxdns check` is the obvious next step; anything else exits 1. Once serving
|
|
//! starts, nothing is fatal — the pipeline answers queries with a snapshot or
|
|
//! without one (ruling 4), and every loop logs its own failures and keeps
|
|
//! going.
|
|
|
|
const std = @import("std");
|
|
const Allocator = std.mem.Allocator;
|
|
const Certificate = std.crypto.Certificate;
|
|
const Writer = std.Io.Writer;
|
|
const net = std.Io.net;
|
|
const tls = std.crypto.tls;
|
|
|
|
const api_limiter = @import("web/api_limiter.zig");
|
|
const auth = @import("web/auth.zig");
|
|
const bootstrap = @import("config/bootstrap.zig");
|
|
const cert_store = @import("server/cert_store.zig");
|
|
const cli = @import("cli.zig");
|
|
const clients = @import("server/clients.zig");
|
|
const config_export = @import("config/export.zig");
|
|
const db = @import("storage/db.zig");
|
|
const disk_monitor = @import("storage/disk_monitor.zig");
|
|
const dns_cache = @import("cache/dns_cache.zig");
|
|
const doh_client = @import("upstream/doh_client.zig");
|
|
const doh_server = @import("server/doh_server.zig");
|
|
const dot_client = @import("upstream/dot_client.zig");
|
|
const dot_server = @import("server/dot_server.zig");
|
|
const faults = @import("config/faults.zig");
|
|
const fetcher = @import("filter/fetcher.zig");
|
|
const forward_zones = @import("local/forward_zones.zig");
|
|
const handler = @import("server/handler.zig");
|
|
const http_util = @import("web/http_util.zig");
|
|
const local_records = @import("local/records.zig");
|
|
const local_tables = @import("server/local_tables.zig");
|
|
const logger_mod = @import("storage/logger.zig");
|
|
const logging = @import("platform/logging.zig");
|
|
const manager_mod = @import("filter/manager.zig");
|
|
const migrations = @import("storage/migrations.zig");
|
|
const model = @import("config/model.zig");
|
|
const pause = @import("server/pause.zig");
|
|
const pool_mod = @import("upstream/pool.zig");
|
|
const query_sink = @import("server/query_sink.zig");
|
|
const rate_limiter = @import("server/rate_limiter.zig");
|
|
const retention_mod = @import("storage/retention.zig");
|
|
const safe_url = @import("safe_url.zig");
|
|
const shutdown = @import("server/shutdown.zig");
|
|
const sse = @import("web/sse.zig");
|
|
const static = @import("web/static.zig");
|
|
const tcp_server = @import("server/tcp_server.zig");
|
|
const transport = @import("upstream/transport.zig");
|
|
const udp_server = @import("server/udp_server.zig");
|
|
const validate = @import("config/validate.zig");
|
|
const version = @import("version.zig");
|
|
const web_server = @import("web/server.zig");
|
|
|
|
const log = std.log.scoped(.nxdns);
|
|
|
|
/// How long the cache and rate-limiter sweeps wait between passes. Both tables
|
|
/// evict lazily on lookup as well; this is what keeps memory bounded for a
|
|
/// client that asked once and never came back.
|
|
const maintenance_interval_s = 60;
|
|
|
|
/// Bounds one blocklist download (`Manager.total_budget`). Generous, because a
|
|
/// megabyte-scale list over a household uplink is normal, and a download that
|
|
/// takes longer than this is not going to finish at all.
|
|
const download_budget_s = 300;
|
|
|
|
/// Per DoH upstream. The sizes live in `doh_client.zig` so that `nxdns check`
|
|
/// probes the buffers `nxdns run` serves with.
|
|
const doh_request_buf_len = doh_client.default_request_buf_len;
|
|
const doh_transfer_buf_len = doh_client.default_transfer_buf_len;
|
|
|
|
pub fn run(runner: cli.Runner, args: cli.RunArgs) u8 {
|
|
const code = serve(runner, args) catch |err| code: {
|
|
runner.err.print("nxdns run failed: {s}\n", .{@errorName(err)}) catch {};
|
|
const mapped = failureExitCode(err);
|
|
if (mapped == cli.exit_check) {
|
|
runner.err.writeAll("run `nxdns check` to see the configuration in full\n") catch {};
|
|
}
|
|
break :code mapped;
|
|
};
|
|
|
|
// Output the operator never received is not output, so a failed flush
|
|
// outranks a successful run — the same rule `cli.finish` applies.
|
|
runner.out.flush() catch return cli.exit_runtime;
|
|
runner.err.flush() catch return cli.exit_runtime;
|
|
return code;
|
|
}
|
|
|
|
/// The one classification, from `config/faults.zig`. This file keeps no list of
|
|
/// its own: `run` exiting 1 on a seed file `check` and `import` exit 2 on was
|
|
/// exactly the cost of the second list that used to be here.
|
|
fn failureExitCode(err: anyerror) u8 {
|
|
return if (faults.isConfigFault(err)) cli.exit_check else cli.exit_runtime;
|
|
}
|
|
|
|
/// First run only: the file seeds an empty database and is ignored forever
|
|
/// after. Its diagnostics are the operator's one chance to see what the file
|
|
/// said, so they are printed the way `check` and `import` print them.
|
|
///
|
|
/// Printed on the way out either way. A seed file can be accepted and still
|
|
/// carry warnings — a blocklist source in no group is downloaded and compiled
|
|
/// into nothing — and a warning that only appears when the start fails is a
|
|
/// warning nobody ever reads: the start it describes is the one that worked.
|
|
/// `check` reported it and `run` did not, which left the same file graded two
|
|
/// ways.
|
|
///
|
|
/// The runner's error writer, not `std.log`: this runs before
|
|
/// `logging.install`, and one rendering of a diagnostic across `run`, `check`
|
|
/// and `import` is the point of `Diagnostics.writeAll`.
|
|
///
|
|
/// Flushed here rather than left to `run`'s exit flush. That writer is buffered
|
|
/// (`main` gives it 4 KiB) and `serve` does not return for as long as the
|
|
/// service runs, so a line left in the buffer reaches the operator when the
|
|
/// process stops — days after the start it describes. A failure path flushes
|
|
/// anyway because it returns immediately; the successful start is the one that
|
|
/// needs this.
|
|
fn seedFromFile(
|
|
r: cli.Runner,
|
|
config_db: *db.Db,
|
|
dir: std.Io.Dir,
|
|
config_path: []const u8,
|
|
) bootstrap.Error!bootstrap.Outcome {
|
|
var diags: validate.Diagnostics = .init(r.gpa);
|
|
defer diags.deinit();
|
|
|
|
const result = bootstrap.bootstrap(r.io, r.gpa, config_db, dir, config_path, &diags);
|
|
|
|
// Neither discard is an oversight, and the two answer different questions.
|
|
//
|
|
// On a rejected seed file, `result` is returned untouched: the operator gets
|
|
// the reason the start failed, never a writer error standing in front of it.
|
|
// A broken stderr is not why the configuration was refused.
|
|
//
|
|
// On a seed that worked, a failure here does not stop the start. The trade
|
|
// is one lost warning line against a household with no name resolution, and
|
|
// `run` before this point is the only stretch of this program where an
|
|
// output failure could take DNS down at all — ruling 4 already says nothing
|
|
// after it is fatal. Nor could the failure be reported: this writer *is* the
|
|
// error channel, and `logging.install` has not run yet, so `std.log` resolves
|
|
// to the same stderr a diagnostic about it would have to travel down.
|
|
//
|
|
// It is not lost from the process either. A failed drain consumes nothing,
|
|
// so whatever the buffer held it still holds — that half is observed, in
|
|
// "a broken error writer does not stop a first start that succeeded" below,
|
|
// which reads the retained warning back out of the same writer.
|
|
//
|
|
// What happens to those bytes afterwards is derived, not watched, and is
|
|
// labelled so deliberately. `Io.Writer.defaultFlush` drains while `end != 0`
|
|
// and `run`'s exit flush maps a failure to exit 1, so a stderr still broken
|
|
// at shutdown should carry the condition out in the exit code, and one that
|
|
// recovered should deliver the line late. No test drives `run` that far.
|
|
// Two limits come with the derivation: an empty buffer flushes clean and
|
|
// reports nothing at all, and a failure that recovers ends at exit 0 with a
|
|
// line the operator reads days after the start it describes.
|
|
diags.writeAll(r.err) catch {};
|
|
r.err.flush() catch {};
|
|
return result;
|
|
}
|
|
|
|
fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
|
|
const io = r.io;
|
|
const gpa = r.gpa;
|
|
const paths = args.paths;
|
|
|
|
// -----------------------------------------------------------------------
|
|
// storage and configuration
|
|
// -----------------------------------------------------------------------
|
|
|
|
var data = try cli.DataDir.open(io, gpa, paths.data_dir, true);
|
|
defer data.close(io, gpa);
|
|
|
|
var config_db = try data.openConfigDb(io);
|
|
defer config_db.close();
|
|
_ = try migrations.migrate(&config_db);
|
|
|
|
_ = try seedFromFile(r, &config_db, std.Io.Dir.cwd(), paths.config);
|
|
|
|
// Every string in `cfg` points into this arena, and the pool's endpoints,
|
|
// the handler's records and the monitor's paths all keep such strings. It
|
|
// therefore outlives all of them, which is why it is declared here and not
|
|
// inside a narrower scope.
|
|
var arena_state: std.heap.ArenaAllocator = .init(gpa);
|
|
defer arena_state.deinit();
|
|
const arena = arena_state.allocator();
|
|
|
|
const cfg = try config_export.readConfig(&config_db, arena);
|
|
|
|
// From here on `std.log` goes wherever the operator asked. Before this call
|
|
// the sink passes through to stderr, which is where a failure above needs
|
|
// to appear anyway.
|
|
logging.install(io, cfg.logging);
|
|
defer logging.deinstall();
|
|
|
|
if (cfg.dns.rate_limit == 0 or cfg.dns.rate_window_seconds == 0) return error.BadRateLimit;
|
|
|
|
// The API limiter and the session store assert these are nonzero
|
|
// (`validate` refuses such a config, but nothing validates a database an
|
|
// operator edited by hand), and a fault the operator can fix must exit 2,
|
|
// not trip an assertion.
|
|
if (cfg.web.enabled and
|
|
(cfg.web.api_rate_limit_per_min == 0 or cfg.web.session_ttl_hours == 0))
|
|
{
|
|
return error.BadRateLimit;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// local answers
|
|
// -----------------------------------------------------------------------
|
|
|
|
// Ruling 12: the tables are published through the holder the API swaps, so
|
|
// the holder owns them from here on and frees whichever generation is
|
|
// current at shutdown.
|
|
var tables: local_tables.LocalTables = .empty;
|
|
defer tables.deinit(gpa);
|
|
tables.records = try local_records.Records.build(gpa, cfg.local_records);
|
|
tables.zones = try forward_zones.Zones.build(gpa, cfg.forward_zones);
|
|
|
|
// -----------------------------------------------------------------------
|
|
// blocklists
|
|
// -----------------------------------------------------------------------
|
|
|
|
// Two HTTP clients on purpose. A blocklist download streams tens of
|
|
// megabytes and holds its connection for the whole of it; DoH queries must
|
|
// not queue behind that, and the two have nothing to share but a type.
|
|
var fetch_http: std.http.Client = .{ .allocator = gpa, .io = io };
|
|
defer fetch_http.deinit();
|
|
var dns_http: std.http.Client = .{ .allocator = gpa, .io = io };
|
|
defer dns_http.deinit();
|
|
|
|
const fetch_transfer = try gpa.alloc(u8, fetcher.min_transfer_buf);
|
|
defer gpa.free(fetch_transfer);
|
|
const fetch_redirect = try gpa.alloc(u8, fetcher.redirect_buffer_len);
|
|
defer gpa.free(fetch_redirect);
|
|
var fetch: fetcher.Fetcher = .{
|
|
.http = &fetch_http,
|
|
.transfer_buf = fetch_transfer,
|
|
.redirect_buf = fetch_redirect,
|
|
};
|
|
|
|
var manager: manager_mod.Manager = try .init(
|
|
gpa,
|
|
&config_db,
|
|
.{ .dir = data.dir },
|
|
&fetch,
|
|
cfg.blocklist_update,
|
|
.{ .raw = .fromSeconds(download_budget_s), .clock = .awake },
|
|
);
|
|
defer manager.deinit(io);
|
|
|
|
// -----------------------------------------------------------------------
|
|
// upstream pool
|
|
// -----------------------------------------------------------------------
|
|
|
|
// Shared by every DoT client: the trust store does not vary per upstream
|
|
// and the scan is expensive. Both must outlive the clients that hold them.
|
|
var bundle: Certificate.Bundle = .empty;
|
|
defer bundle.deinit(gpa);
|
|
var bundle_lock: std.Io.RwLock = .init;
|
|
|
|
var upstreams = try Upstreams.build(gpa, cfg.upstreams, &dns_http, &bundle, &bundle_lock);
|
|
defer upstreams.deinit(gpa);
|
|
|
|
var pool: pool_mod.Pool = .init(
|
|
upstreams.active(),
|
|
.{},
|
|
.{
|
|
.attempt = .{ .raw = model.attemptTimeout(cfg.upstream), .clock = .awake },
|
|
.total = .{ .raw = model.totalTimeout(cfg.upstream), .clock = .awake },
|
|
},
|
|
@truncate(@as(u96, @bitCast(std.Io.Clock.real.now(io).nanoseconds))),
|
|
);
|
|
|
|
// -----------------------------------------------------------------------
|
|
// per-query state
|
|
// -----------------------------------------------------------------------
|
|
|
|
var cache: dns_cache.DnsCache = try .init(gpa, cfg.cache);
|
|
defer cache.deinit();
|
|
|
|
var limiter: rate_limiter.RateLimiter = try .init(gpa, .{
|
|
.limit = cfg.dns.rate_limit,
|
|
.window_seconds = cfg.dns.rate_window_seconds,
|
|
});
|
|
defer limiter.deinit();
|
|
|
|
var paused: pause.Pause = .{};
|
|
var tracker: clients.Tracker = .init(cfg.logging.retention_days);
|
|
|
|
// The queue holds waiting tasks in intrusive lists, so neither the buffer
|
|
// nor the `Logger` may move once a task has touched either.
|
|
const queue_buf = try gpa.alloc(logger_mod.Entry, cfg.logging.query_log_buffer_max);
|
|
defer gpa.free(queue_buf);
|
|
var query_logger: logger_mod.Logger = .init(cfg.logging, queue_buf);
|
|
|
|
// Milestone 8 fans every logged query out to the SSE hub as well. The hub
|
|
// exists only when the web interface does (ruling 6) — without it the sink
|
|
// costs the query path one null check. Its rings are ~900 KiB, so it lives
|
|
// on the heap and initializes in place; a by-value init would copy the
|
|
// whole of it through this frame.
|
|
var hub: ?*sse.Hub = null;
|
|
defer if (hub) |hub_ptr| gpa.destroy(hub_ptr);
|
|
if (cfg.web.enabled) {
|
|
const created = try gpa.create(sse.Hub);
|
|
created.init();
|
|
hub = created;
|
|
}
|
|
var sink: query_sink.QuerySink = .init(&query_logger, hub);
|
|
|
|
// -----------------------------------------------------------------------
|
|
// disk, retention and the remaining connections (ruling 21)
|
|
// -----------------------------------------------------------------------
|
|
|
|
const data_path = try arena.dupeZ(u8, paths.data_dir);
|
|
const log_dir_path: ?[:0]const u8 = if (cfg.logging.output == .file)
|
|
try arena.dupeZ(u8, std.fs.path.dirname(cfg.logging.file_path) orelse ".")
|
|
else
|
|
null;
|
|
var monitor: disk_monitor.Monitor = .init(cfg.disk, data.dir, data_path, log_dir_path);
|
|
|
|
// Ruling 17. The scheduler consults it before every scheduled pass; the
|
|
// startup `reload` below is an operator action and stays ungated.
|
|
manager.monitor = &monitor;
|
|
|
|
// One synchronous sample before anything can consult the gate. `Monitor`
|
|
// initializes to `.ok`, and `Monitor.run` takes its first sample inside the
|
|
// task — so without this call the log writer, the tracker and the blocklist
|
|
// scheduler would all see "writes allowed" during the boot window and could
|
|
// write to a critically full disk before the gate ever reflected it.
|
|
// `run`'s own first sample repeats this one, which costs one extra statvfs
|
|
// and directory scan at startup and keeps `run`'s "sample first, then
|
|
// sleep" contract intact.
|
|
//
|
|
// `sample` returns nothing: every failure warns and counts inside the
|
|
// monitor, and leaves the previous reading in place. A first sample that
|
|
// fails therefore leaves the state at `.ok` — an unreadable filesystem is
|
|
// not evidence that the disk is full, which is the monitor's documented
|
|
// policy and the right one here too.
|
|
monitor.sample(io);
|
|
|
|
var retention: retention_mod.Retention = .init(cfg.logging);
|
|
|
|
var querylog_writer_db = try data.openQuerylogDb(io);
|
|
defer querylog_writer_db.close();
|
|
var querylog_retention_db = try data.reopenQuerylogDb(io);
|
|
defer querylog_retention_db.close();
|
|
var tracker_db = try data.openConfigDb(io);
|
|
defer tracker_db.close();
|
|
|
|
// The web task's own two connections (ruling 26; m7 ruling 21: one SQLite
|
|
// connection per task), opened only when the web interface is (ruling 6).
|
|
// `reopenQuerylogDb` requires the file `openQuerylogDb` established above.
|
|
var web_config_db: ?db.Db = null;
|
|
defer if (web_config_db) |*database| database.close();
|
|
var web_querylog_db: ?db.Db = null;
|
|
defer if (web_querylog_db) |*database| database.close();
|
|
if (cfg.web.enabled) {
|
|
web_config_db = try data.openConfigDb(io);
|
|
web_querylog_db = try data.reopenQuerylogDb(io);
|
|
}
|
|
|
|
var sessions: ?auth.Sessions = if (cfg.web.enabled) .init(cfg.web.session_ttl_hours) else null;
|
|
|
|
var web_limiter: ?api_limiter.ApiLimiter = null;
|
|
defer if (web_limiter) |*limiter_ptr| limiter_ptr.deinit();
|
|
if (cfg.web.enabled) {
|
|
web_limiter = try api_limiter.ApiLimiter.init(gpa, .{
|
|
.rate_per_min = cfg.web.api_rate_limit_per_min,
|
|
.localhost_exempt = cfg.web.api_localhost_exempt,
|
|
.sse_max_per_ip = cfg.web.sse_max_connections_per_ip,
|
|
});
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// first snapshot
|
|
// -----------------------------------------------------------------------
|
|
|
|
// Ruling 4: serving starts either way. A household loses more from a DNS
|
|
// server that refuses to start than from one that answers unfiltered until
|
|
// the scheduler's first pass succeeds.
|
|
manager.reload(io) catch |err| {
|
|
log.warn(
|
|
"loading the blocklist snapshot failed ({s}); serving unfiltered until the next refresh",
|
|
.{@errorName(err)},
|
|
);
|
|
};
|
|
|
|
// -----------------------------------------------------------------------
|
|
// listeners
|
|
// -----------------------------------------------------------------------
|
|
|
|
var h: handler.Handler = .{
|
|
.upstream = pool.client(),
|
|
.blocking = .{ .mode = cfg.blocking.response, .ttl = cfg.blocking.ttl },
|
|
.ecs_mode = cfg.edns.ecs_mode,
|
|
.forward_read_timeout = .{ .raw = model.readTimeout(cfg.upstream), .clock = .awake },
|
|
.manager = &manager,
|
|
.local_tables = &tables,
|
|
.cache = &cache,
|
|
.negative_ttl_max = cfg.cache.negative_ttl_max,
|
|
.limiter = &limiter,
|
|
.sink = &sink,
|
|
.pause = &paused,
|
|
.tracker = &tracker,
|
|
};
|
|
|
|
// -----------------------------------------------------------------------
|
|
// DoH/DoT listeners (milestone-10 ruling 11)
|
|
// -----------------------------------------------------------------------
|
|
|
|
// The stores are declared before the listeners on purpose: their deinit
|
|
// defers run last, and `CertStore.deinit` asserts every connection has
|
|
// released its generation, which only holds once the listeners are gone.
|
|
// A certificate that does not load at boot is exit 2 — `nxdns check`
|
|
// promises that an enabled endpoint has readable certs — while anything
|
|
// that breaks later is the watcher's to absorb.
|
|
var doh_certs: ?cert_store.CertStore = null;
|
|
defer if (doh_certs) |*store| store.deinit(io);
|
|
if (cfg.doh_server.enabled) {
|
|
doh_certs = try openCertStore(r, gpa, io, cfg.doh_server, "doh_server", doh_server.alpn_protocols);
|
|
}
|
|
|
|
var dot_certs: ?cert_store.CertStore = null;
|
|
defer if (dot_certs) |*store| store.deinit(io);
|
|
if (cfg.dot_server.enabled) {
|
|
dot_certs = try openCertStore(r, gpa, io, cfg.dot_server, "dot_server", dot_alpn);
|
|
}
|
|
|
|
// Bound here, in this frame, rather than through doh_server's module-level
|
|
// entry: `/metrics` reads the listeners' counters through `WebState`, and
|
|
// only a listener that lives in this frame has an address to wire there.
|
|
// A failed bind warns and stays off (ruling 1, the web precedent): TLS DNS
|
|
// failing to come up must not stop the plain-DNS side this box exists for.
|
|
var doh: ?doh_server.DohServer = null;
|
|
defer if (doh) |*server| server.deinit(io);
|
|
if (doh_certs) |*store| doh = bindDoh(gpa, io, cfg.doh_server, &h, store);
|
|
|
|
var dot: ?dot_server.DotServer = null;
|
|
defer if (dot) |*server| server.deinit(io);
|
|
if (dot_certs) |*store| dot = bindDot(gpa, io, cfg.dot_server, &h, store);
|
|
|
|
// -----------------------------------------------------------------------
|
|
// web interface (ruling 26)
|
|
// -----------------------------------------------------------------------
|
|
|
|
// Everything the web layer borrows lives above; the group below cancels the
|
|
// web task before any of it is released. With the web interface disabled
|
|
// the state stays in its null-defaulted shape and no task reads it.
|
|
var web_state: web_server.WebState = .{ .gpa = gpa };
|
|
// The live hash may own a gpa replacement after a settings PUT; this defer
|
|
// runs after `group.cancel` below, so no web task can still read it.
|
|
defer web_state.live_hash.deinit(gpa);
|
|
if (cfg.web.enabled) web_state = .{
|
|
.gpa = gpa,
|
|
.web = cfg.web,
|
|
.live_hash = .init(cfg.web.password_hash),
|
|
.handler = &h,
|
|
.pause = &paused,
|
|
.tracker = &tracker,
|
|
.manager = &manager,
|
|
.pool = &pool,
|
|
.monitor = &monitor,
|
|
.local_tables = &tables,
|
|
.logger = &query_logger,
|
|
.retention = &retention,
|
|
.sessions = if (sessions) |*s| s else null,
|
|
.limiter = if (web_limiter) |*l| l else null,
|
|
.hub = hub,
|
|
.sink = &sink,
|
|
.doh_certs = if (doh_certs) |*store| store else null,
|
|
.dot_certs = if (dot_certs) |*store| store else null,
|
|
.doh_listener = if (doh) |*server| server else null,
|
|
.dot_listener = if (dot) |*server| server else null,
|
|
.config_db = if (web_config_db) |*database| database else null,
|
|
.querylog_db = if (web_querylog_db) |*database| database else null,
|
|
.version = version.string,
|
|
.started_unix = std.Io.Clock.real.now(io).toSeconds(),
|
|
// Ruling 24: `--web-dev` serves from disk with no cache headers;
|
|
// otherwise the embedded assets answer every non-/api miss.
|
|
.fallback = if (args.web_dev != null) serveWebDev else static.fallback,
|
|
.dev_dir = args.web_dev orelse "",
|
|
.reload_fn = reloadManager,
|
|
};
|
|
|
|
const v6_bind = parseBind(r, cfg.dns.bind_ipv6, cfg.dns.port, "dns.bind_ipv6", .ip6) catch |err| return err;
|
|
const v4_bind = parseBind(r, cfg.dns.bind_ipv4, cfg.dns.port, "dns.bind_ipv4", .ip4) catch |err| return err;
|
|
|
|
// IPv6 first, and the order is load-bearing — see `Listeners`.
|
|
var udp6: ?udp_server.UdpServer = udp_server.UdpServer.bind(gpa, io, v6_bind, &h, .{}) catch |err| bound: {
|
|
if (!ipv6Unavailable(err)) return reportBind(r, "udp", v6_bind, err);
|
|
log.warn("this system has no IPv6; serving IPv4 only", .{});
|
|
break :bound null;
|
|
};
|
|
defer if (udp6) |*s| s.deinit(gpa, io);
|
|
|
|
const v6_is_wildcard = udp6 != null and isWildcard(v6_bind);
|
|
|
|
var udp4: ?udp_server.UdpServer = udp_server.UdpServer.bind(gpa, io, v4_bind, &h, .{}) catch |err| bound: {
|
|
if (err != error.AddressInUse or !v6_is_wildcard) return reportBind(r, "udp", v4_bind, err);
|
|
log.info("the IPv6 UDP listener is dual-stack and already serves IPv4", .{});
|
|
break :bound null;
|
|
};
|
|
defer if (udp4) |*s| s.deinit(gpa, io);
|
|
|
|
var tcp6: ?tcp_server.TcpServer = tcp_server.TcpServer.listen(gpa, io, v6_bind, &h, .{}) catch |err| bound: {
|
|
if (!ipv6Unavailable(err)) return reportBind(r, "tcp", v6_bind, err);
|
|
break :bound null;
|
|
};
|
|
defer if (tcp6) |*s| s.deinit(io);
|
|
|
|
var tcp4: ?tcp_server.TcpServer = tcp_server.TcpServer.listen(gpa, io, v4_bind, &h, .{}) catch |err| bound: {
|
|
if (err != error.AddressInUse or !(tcp6 != null and isWildcard(v6_bind))) {
|
|
return reportBind(r, "tcp", v4_bind, err);
|
|
}
|
|
log.info("the IPv6 TCP listener is dual-stack and already serves IPv4", .{});
|
|
break :bound null;
|
|
};
|
|
defer if (tcp4) |*s| s.deinit(io);
|
|
|
|
// Ruling 13: `/metrics` sums each transport's listeners into one family, so
|
|
// the web state carries pointers to whichever of the four came up. The
|
|
// arrays are declared here rather than beside `web_state` because a
|
|
// listener that failed to bind is not in them; `group.cancel` below runs
|
|
// before this frame is released, so no web task can outlive them.
|
|
var udp_listeners: [2]*udp_server.UdpServer = undefined;
|
|
var udp_count: usize = 0;
|
|
if (udp6) |*s| {
|
|
udp_listeners[udp_count] = s;
|
|
udp_count += 1;
|
|
}
|
|
if (udp4) |*s| {
|
|
udp_listeners[udp_count] = s;
|
|
udp_count += 1;
|
|
}
|
|
web_state.udp_listeners = udp_listeners[0..udp_count];
|
|
|
|
var tcp_listeners: [2]*tcp_server.TcpServer = undefined;
|
|
var tcp_count: usize = 0;
|
|
if (tcp6) |*s| {
|
|
tcp_listeners[tcp_count] = s;
|
|
tcp_count += 1;
|
|
}
|
|
if (tcp4) |*s| {
|
|
tcp_listeners[tcp_count] = s;
|
|
tcp_count += 1;
|
|
}
|
|
web_state.tcp_listeners = tcp_listeners[0..tcp_count];
|
|
|
|
// -----------------------------------------------------------------------
|
|
// run
|
|
// -----------------------------------------------------------------------
|
|
|
|
shutdown.install(io);
|
|
|
|
// Declared after everything it borrows, so its `cancel` — which both
|
|
// requests cancellation and joins — is the first thing that runs on the way
|
|
// out (ruling 22). Nothing below this line may be released while a task
|
|
// could still touch it.
|
|
var group: std.Io.Group = .init;
|
|
defer group.cancel(io);
|
|
|
|
if (udp6) |*s| try group.concurrent(io, udp_server.UdpServer.serve, .{ s, io });
|
|
if (udp4) |*s| try group.concurrent(io, udp_server.UdpServer.serve, .{ s, io });
|
|
if (tcp6) |*s| try group.concurrent(io, tcp_server.TcpServer.serve, .{ s, io });
|
|
if (tcp4) |*s| try group.concurrent(io, tcp_server.TcpServer.serve, .{ s, io });
|
|
if (doh) |*s| try group.concurrent(io, doh_server.DohServer.serve, .{ s, io });
|
|
if (dot) |*s| try group.concurrent(io, dot_server.DotServer.serve, .{ s, io });
|
|
if (doh_certs) |*store| try group.concurrent(io, cert_store.CertStore.watch, .{ store, io });
|
|
if (dot_certs) |*store| try group.concurrent(io, cert_store.CertStore.watch, .{ store, io });
|
|
|
|
const gate: ?*disk_monitor.Monitor = &monitor;
|
|
try group.concurrent(io, logger_mod.Logger.runWriter, .{ &query_logger, io, &querylog_writer_db, gate });
|
|
try group.concurrent(io, retention_mod.Retention.run, .{ &retention, io, &querylog_retention_db, gate });
|
|
try group.concurrent(io, disk_monitor.Monitor.run, .{ &monitor, io });
|
|
try group.concurrent(io, manager_mod.Manager.runScheduler, .{ &manager, io });
|
|
try group.concurrent(io, clients.Tracker.run, .{ &tracker, io, &tracker_db, gate });
|
|
try group.concurrent(io, runMaintenance, .{ &h, if (web_limiter) |*l| l else null, io });
|
|
|
|
// Started last (ruling 26), canceled by the same `group.cancel`; its inner
|
|
// connection group is canceled, not awaited (ruling 4), so an idle
|
|
// keep-alive client cannot hold shutdown open. A web bind failure is not
|
|
// fatal: `web_server.serve` warns and returns, and the DNS side — the thing
|
|
// this box exists for — keeps serving.
|
|
if (cfg.web.enabled) try group.concurrent(io, web_server.serve, .{ &web_state, io });
|
|
|
|
logStartup(io, &manager, upstreams.active().len, .{
|
|
.udp6 = if (udp6) |*s| s.boundAddress() else null,
|
|
.udp4 = if (udp4) |*s| s.boundAddress() else null,
|
|
.tcp6 = if (tcp6) |*s| s.boundAddress() else null,
|
|
.tcp4 = if (tcp4) |*s| s.boundAddress() else null,
|
|
});
|
|
|
|
// A canceled wait is a shutdown request too: whoever canceled this task
|
|
// wants the process to stop, and the teardown below is how it stops.
|
|
shutdown.wait(io) catch {};
|
|
log.info("shutting down", .{});
|
|
|
|
// Before the group is canceled, so the writer sees a closed queue and
|
|
// drains what it holds rather than losing it to cancellation (ruling 22).
|
|
query_logger.shutdown(io);
|
|
|
|
return cli.exit_ok;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// web seams
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Ruling 12: mutations to rules, blocklists, groups, clients and prefixes
|
|
/// rebuild the blocklist snapshot so the change is live on the next query. A
|
|
/// state without a manager has nothing to rebuild.
|
|
fn reloadManager(state: *web_server.WebState, io: std.Io) anyerror!void {
|
|
const manager = state.manager orelse return;
|
|
try manager.reload(io);
|
|
}
|
|
|
|
/// Dev-mode asset serving (ruling 24): straight from disk, no cache headers,
|
|
/// so an edit shows up on the next reload.
|
|
fn serveWebDev(
|
|
state: *web_server.WebState,
|
|
io: std.Io,
|
|
request: *http_util.Request,
|
|
) http_util.HandlerError!void {
|
|
return static.serveFromDisk(state.dev_dir, io, request);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// DoH/DoT listeners
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// RFC 7858 has no IANA-registered ALPN id in wide use beyond "dot"
|
|
/// (milestone-10 ruling 5). Mbed TLS records the pointer, so the list must
|
|
/// outlive every `ServerContext` built with it; module scope gives it static
|
|
/// lifetime, the same shape as `doh_server.alpn_protocols`.
|
|
const dot_alpn: [*:null]const ?[*:0]const u8 = &.{"dot"};
|
|
|
|
/// The boot-time certificate load for one enabled endpoint. A failure is a
|
|
/// configuration fault the operator can fix — the same files `nxdns check`
|
|
/// verifies — reported in `check`'s style and mapped to exit 2 (ruling 11).
|
|
/// Out of memory is the one exception: nothing about the configuration is
|
|
/// wrong, so it keeps its own name and exits 1.
|
|
fn openCertStore(
|
|
r: cli.Runner,
|
|
gpa: Allocator,
|
|
io: std.Io,
|
|
endpoint: model.TlsEndpoint,
|
|
section: []const u8,
|
|
alpn: ?[*:null]const ?[*:0]const u8,
|
|
) !cert_store.CertStore {
|
|
return cert_store.CertStore.init(gpa, io, endpoint.cert_path, endpoint.key_path, alpn) catch |err| {
|
|
if (err == error.OutOfMemory) return error.OutOfMemory;
|
|
r.err.print("{s}: '{s}' + '{s}': {s}\n", .{
|
|
section,
|
|
endpoint.cert_path,
|
|
endpoint.key_path,
|
|
cert_store.humanMessage(err),
|
|
}) catch {};
|
|
return error.BadCertificate;
|
|
};
|
|
}
|
|
|
|
/// Ruling 1: a listener that cannot bind warns and stays off. The bind text
|
|
/// itself gets the same treatment — `validate` refuses it, but a hand-edited
|
|
/// database can still carry one, and it is not worth taking DNS down over.
|
|
fn bindDoh(
|
|
gpa: Allocator,
|
|
io: std.Io,
|
|
endpoint: model.TlsEndpoint,
|
|
h: *handler.Handler,
|
|
store: *cert_store.CertStore,
|
|
) ?doh_server.DohServer {
|
|
const bind_address = net.IpAddress.parse(endpoint.bind, endpoint.port) catch {
|
|
log.warn("doh_server.bind '{s}' is not an IP address; DoH is disabled", .{endpoint.bind});
|
|
return null;
|
|
};
|
|
const server = doh_server.DohServer.listen(gpa, io, bind_address, h, store, .{}) catch |err| {
|
|
log.warn("doh listener cannot listen on {s}:{d}: {t}", .{ endpoint.bind, endpoint.port, err });
|
|
return null;
|
|
};
|
|
log.info("doh listener on {f}", .{server.boundAddress()});
|
|
return server;
|
|
}
|
|
|
|
fn bindDot(
|
|
gpa: Allocator,
|
|
io: std.Io,
|
|
endpoint: model.TlsEndpoint,
|
|
h: *handler.Handler,
|
|
store: *cert_store.CertStore,
|
|
) ?dot_server.DotServer {
|
|
const bind_address = net.IpAddress.parse(endpoint.bind, endpoint.port) catch {
|
|
log.warn("dot_server.bind '{s}' is not an IP address; DoT is disabled", .{endpoint.bind});
|
|
return null;
|
|
};
|
|
const server = dot_server.DotServer.listen(gpa, io, bind_address, h, store, .{}) catch |err| {
|
|
log.warn("dot listener cannot listen on {s}:{d}: {t}", .{ endpoint.bind, endpoint.port, err });
|
|
return null;
|
|
};
|
|
log.info("dot listener on {f}", .{server.boundAddress()});
|
|
return server;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// background maintenance
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Sweeps the cache, the DNS rate-limiter table and the API rate-limiter table.
|
|
/// The first two are guarded by mutexes the handler owns, because the handler is
|
|
/// what contends for them; the sweeps live here because walking a whole table is
|
|
/// not work a query should pay for. The API limiter needs the same schedule for
|
|
/// the same reason: its table holds 4096 addresses, and once it is full every
|
|
/// unknown address pays an eviction scan.
|
|
///
|
|
/// The locks are taken cancelably: unlike `handle`, this loop has an error
|
|
/// union to carry `error.Canceled` out of, and a shutdown that arrives while
|
|
/// the query path holds a lock should not wait for it.
|
|
fn runMaintenance(
|
|
h: *handler.Handler,
|
|
api: ?*api_limiter.ApiLimiter,
|
|
io: std.Io,
|
|
) std.Io.Cancelable!void {
|
|
const interval: std.Io.Clock.Duration = .{
|
|
.raw = .fromSeconds(maintenance_interval_s),
|
|
.clock = .boot,
|
|
};
|
|
while (true) {
|
|
try interval.sleep(io);
|
|
try maintenanceOnce(h, api, io);
|
|
}
|
|
}
|
|
|
|
/// One sweep of each table. Separate from the loop so a test can run a pass
|
|
/// without waiting out `maintenance_interval_s`.
|
|
fn maintenanceOnce(
|
|
h: *handler.Handler,
|
|
api: ?*api_limiter.ApiLimiter,
|
|
io: std.Io,
|
|
) std.Io.Cancelable!void {
|
|
if (h.cache) |cache| {
|
|
const now_s = std.Io.Clock.real.now(io).toSeconds();
|
|
try h.cache_mutex.lock(io);
|
|
_ = cache.sweep(now_s);
|
|
h.cache_mutex.unlock(io);
|
|
}
|
|
|
|
if (h.limiter) |limiter| {
|
|
const now = std.Io.Clock.awake.now(io);
|
|
try h.limiter_mutex.lock(io);
|
|
_ = limiter.sweep(now);
|
|
h.limiter_mutex.unlock(io);
|
|
}
|
|
|
|
// The API limiter takes its own mutex, unlike the two above, which are the
|
|
// handler's. Nothing here holds a lock across the call.
|
|
if (api) |limiter| _ = limiter.sweep(io, std.Io.Clock.awake.now(io));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// upstreams
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// The pool's entries and everything they point into.
|
|
///
|
|
/// `Pool.Entry.client` is a type-erased pointer into `doh` or `dot`, and each
|
|
/// client borrows a slice of `doh_buf`/`dot_buf`, so all five allocations live
|
|
/// exactly as long as the pool does. One entry is used by one task at a time
|
|
/// (`Entry.busy`), which is why the buffers are per client and not shared the
|
|
/// way `cli.probeUpstreams` shares them.
|
|
const Upstreams = struct {
|
|
entries: []pool_mod.Entry,
|
|
used: usize,
|
|
doh: []doh_client.DohClient,
|
|
dot: []dot_client.DotClient,
|
|
doh_buf: []u8,
|
|
dot_buf: []u8,
|
|
|
|
/// A disabled upstream is left out entirely; a malformed one warns and is
|
|
/// skipped, because one bad row in a table of four must not take DNS down.
|
|
/// No usable row at all is a configuration fault.
|
|
fn build(
|
|
gpa: Allocator,
|
|
servers: []const model.UpstreamServer,
|
|
http: *std.http.Client,
|
|
bundle: *Certificate.Bundle,
|
|
bundle_lock: *std.Io.RwLock,
|
|
) (Allocator.Error || error{NoUsableUpstreams})!Upstreams {
|
|
var enabled: usize = 0;
|
|
for (servers) |server| {
|
|
if (server.enabled) enabled += 1;
|
|
}
|
|
if (enabled == 0) return error.NoUsableUpstreams;
|
|
|
|
const chunk = tls.Client.min_buffer_len;
|
|
|
|
var self: Upstreams = .{
|
|
.entries = try gpa.alloc(pool_mod.Entry, enabled),
|
|
.used = 0,
|
|
.doh = &.{},
|
|
.dot = &.{},
|
|
.doh_buf = &.{},
|
|
.dot_buf = &.{},
|
|
};
|
|
errdefer self.deinit(gpa);
|
|
|
|
self.doh = try gpa.alloc(doh_client.DohClient, enabled);
|
|
self.dot = try gpa.alloc(dot_client.DotClient, enabled);
|
|
self.doh_buf = try gpa.alloc(u8, enabled * (doh_request_buf_len + doh_transfer_buf_len));
|
|
self.dot_buf = try gpa.alloc(u8, enabled * 4 * chunk);
|
|
|
|
var doh_count: usize = 0;
|
|
var dot_count: usize = 0;
|
|
|
|
for (servers) |server| {
|
|
if (!server.enabled) continue;
|
|
|
|
const endpoint = transport.Endpoint.parse(server.url) catch {
|
|
log.warn(
|
|
"upstream {f} is not an https:// or tls:// endpoint; skipped",
|
|
.{safe_url.redactQuoted(server.url)},
|
|
);
|
|
continue;
|
|
};
|
|
|
|
const client: transport.Client = switch (endpoint.scheme) {
|
|
.doh => doh: {
|
|
const base = doh_count * (doh_request_buf_len + doh_transfer_buf_len);
|
|
const slot = &self.doh[doh_count];
|
|
slot.* = doh_client.DohClient.init(
|
|
http,
|
|
endpoint,
|
|
self.doh_buf[base..][0..doh_request_buf_len],
|
|
self.doh_buf[base + doh_request_buf_len ..][0..doh_transfer_buf_len],
|
|
) catch {
|
|
log.warn(
|
|
"upstream {f} is not a usable DoH url; skipped",
|
|
.{safe_url.redactQuoted(server.url)},
|
|
);
|
|
continue;
|
|
};
|
|
doh_count += 1;
|
|
break :doh slot.client();
|
|
},
|
|
.dot => dot: {
|
|
const base = dot_count * 4 * chunk;
|
|
const slot = &self.dot[dot_count];
|
|
slot.* = dot_client.DotClient.init(endpoint, server.tls_name, gpa, bundle, bundle_lock, .{
|
|
.tls_read = self.dot_buf[base..][0..chunk],
|
|
.tls_write = self.dot_buf[base + chunk ..][0..chunk],
|
|
.stream_read = self.dot_buf[base + 2 * chunk ..][0..chunk],
|
|
.stream_write = self.dot_buf[base + 3 * chunk ..][0..chunk],
|
|
});
|
|
dot_count += 1;
|
|
break :dot slot.client();
|
|
},
|
|
};
|
|
|
|
self.entries[self.used] = .{
|
|
.endpoint = endpoint,
|
|
.client = client,
|
|
.priority = server.priority,
|
|
.enabled = true,
|
|
.health = .init,
|
|
};
|
|
self.used += 1;
|
|
}
|
|
|
|
if (self.used == 0) return error.NoUsableUpstreams;
|
|
return self;
|
|
}
|
|
|
|
/// The prefix `Pool.init` is given. The rest of `entries` is allocated but
|
|
/// never filled, which is what keeps `deinit` able to free the whole block.
|
|
fn active(self: *Upstreams) []pool_mod.Entry {
|
|
return self.entries[0..self.used];
|
|
}
|
|
|
|
fn deinit(self: *Upstreams, gpa: Allocator) void {
|
|
gpa.free(self.dot_buf);
|
|
gpa.free(self.doh_buf);
|
|
gpa.free(self.dot);
|
|
gpa.free(self.doh);
|
|
gpa.free(self.entries);
|
|
self.* = undefined;
|
|
}
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// listeners
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// The addresses actually bound, for the startup line. A null is a family this
|
|
/// process does not listen on separately — either because the system has no
|
|
/// IPv6, or because the IPv6 socket is dual-stack and already serves IPv4.
|
|
const Listeners = struct {
|
|
udp6: ?net.IpAddress,
|
|
udp4: ?net.IpAddress,
|
|
tcp6: ?net.IpAddress,
|
|
tcp4: ?net.IpAddress,
|
|
};
|
|
|
|
const BindFamily = enum { ip4, ip6 };
|
|
|
|
/// `config/validate.checkBind` enforces the same family rule on import, check
|
|
/// and settings PUT — but not on a config.db written before the rule existed,
|
|
/// and `serve` loads that DB without re-validating. Boot is the last seam: a
|
|
/// cross-family literal here would bind the wrong family's socket and make the
|
|
/// real one fail with AddressInUse, silently losing a family.
|
|
fn parseBind(
|
|
r: cli.Runner,
|
|
text: []const u8,
|
|
port: u16,
|
|
field: []const u8,
|
|
family: BindFamily,
|
|
) !net.IpAddress {
|
|
const addr = net.IpAddress.parse(text, port) catch {
|
|
r.err.print("{s}: '{s}' is not an IP address\n", .{ field, text }) catch {};
|
|
return error.BadBindAddress;
|
|
};
|
|
const matches = switch (addr) {
|
|
.ip4 => family == .ip4,
|
|
.ip6 => family == .ip6,
|
|
};
|
|
if (!matches) {
|
|
const digit: u8 = if (family == .ip4) '4' else '6';
|
|
r.err.print(
|
|
"{s}: '{s}' is not an IPv{c} address; re-import the configuration or correct it with a settings PUT\n",
|
|
.{ field, text, digit },
|
|
) catch {};
|
|
return error.BadBindAddress;
|
|
}
|
|
return addr;
|
|
}
|
|
|
|
test "parseBind refuses a bind address of the wrong family" {
|
|
var out_buf: [8]u8 = undefined;
|
|
var err_buf: [256]u8 = undefined;
|
|
var out: Writer = .fixed(&out_buf);
|
|
var err_writer: Writer = .fixed(&err_buf);
|
|
const r: cli.Runner = .{
|
|
.io = std.testing.io,
|
|
.gpa = std.testing.allocator,
|
|
.out = &out,
|
|
.err = &err_writer,
|
|
};
|
|
|
|
_ = try parseBind(r, "0.0.0.0", 53, "dns.bind_ipv4", .ip4);
|
|
_ = try parseBind(r, "::", 53, "dns.bind_ipv6", .ip6);
|
|
|
|
try std.testing.expectError(
|
|
error.BadBindAddress,
|
|
parseBind(r, "0.0.0.0", 53, "dns.bind_ipv6", .ip6),
|
|
);
|
|
try std.testing.expectError(
|
|
error.BadBindAddress,
|
|
parseBind(r, "::", 53, "dns.bind_ipv4", .ip4),
|
|
);
|
|
|
|
const printed = err_writer.buffered();
|
|
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "is not an IPv6 address"));
|
|
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "is not an IPv4 address"));
|
|
}
|
|
|
|
test "run maps a rejected configuration to exit 2 and everything else to exit 1" {
|
|
try std.testing.expectEqual(cli.exit_check, failureExitCode(error.MissingDefaultGroup));
|
|
try std.testing.expectEqual(cli.exit_check, failureExitCode(error.ParseZon));
|
|
try std.testing.expectEqual(cli.exit_check, failureExitCode(error.NoUsableUpstreams));
|
|
try std.testing.expectEqual(cli.exit_check, failureExitCode(error.BadCertificate));
|
|
try std.testing.expectEqual(cli.exit_runtime, failureExitCode(error.AccessDenied));
|
|
try std.testing.expectEqual(cli.exit_runtime, failureExitCode(error.OutOfMemory));
|
|
}
|
|
|
|
test "run, check and import agree on a seed file with no default group" {
|
|
const config_import = @import("config/import.zig");
|
|
|
|
var threaded: std.Io.Threaded = .init(std.testing.allocator, .{});
|
|
defer threaded.deinit();
|
|
const io = threaded.io();
|
|
const gpa = std.testing.allocator;
|
|
|
|
// The file from discrepancy D1: parseable, one upstream, no group named
|
|
// 'default'.
|
|
const source: [:0]const u8 =
|
|
\\.{
|
|
\\ .groups = .{ .{ .name = "kids" } },
|
|
\\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
|
|
\\}
|
|
;
|
|
|
|
// `run`: `serve` seeds through `bootstrap`, which is a wrapper over this
|
|
// exact call, so this is the error `run` classifies.
|
|
var database = try db.Db.open(":memory:", .{ .mode = .memory });
|
|
defer database.close();
|
|
try db.applyPragmas(&database, .{});
|
|
_ = try migrations.migrate(&database);
|
|
|
|
var diags: validate.Diagnostics = .init(gpa);
|
|
defer diags.deinit();
|
|
try std.testing.expectError(
|
|
error.MissingDefaultGroup,
|
|
config_import.importSource(io, gpa, &database, source, .{}, &diags),
|
|
);
|
|
try std.testing.expectEqual(cli.exit_check, failureExitCode(error.MissingDefaultGroup));
|
|
|
|
// `import`: `cli.failureExitCode` reaches exit 2 by either route — the
|
|
// recorded failures, or the classification `run` just used.
|
|
try std.testing.expect(diags.failureCount() != 0);
|
|
try std.testing.expect(faults.isConfigFault(error.MissingDefaultGroup));
|
|
|
|
// `check`: the same file, through the code `nxdns check` runs.
|
|
var arena_state: std.heap.ArenaAllocator = .init(gpa);
|
|
defer arena_state.deinit();
|
|
const cfg = try std.zon.parse.fromSliceAlloc(model.Config, arena_state.allocator(), source, null, .{});
|
|
|
|
var out_buf: [2048]u8 = undefined;
|
|
var err_buf: [256]u8 = undefined;
|
|
var out: Writer = .fixed(&out_buf);
|
|
var err_writer: Writer = .fixed(&err_buf);
|
|
const r: cli.Runner = .{ .io = io, .gpa = gpa, .out = &out, .err = &err_writer };
|
|
|
|
try std.testing.expectEqual(cli.exit_check, try cli.checkConfig(r, cfg, false));
|
|
}
|
|
|
|
test "a configuration whose blocklist source is in no group imports and checks clean" {
|
|
const config_import = @import("config/import.zig");
|
|
|
|
var threaded: std.Io.Threaded = .init(std.testing.allocator, .{});
|
|
defer threaded.deinit();
|
|
const io = threaded.io();
|
|
const gpa = std.testing.allocator;
|
|
|
|
// A source is created before it is attached — `docs/tutorial/first-run.md`
|
|
// POSTs the blocklist and then PUTs the group's sources — so an unattached
|
|
// source is a legal intermediate state on every write path. It warns; it
|
|
// never fails.
|
|
const source: [:0]const u8 =
|
|
\\.{
|
|
\\ .groups = .{ .{ .name = "default" } },
|
|
\\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
|
|
\\ .blocklist_sources = .{ .{ .url = "https://lists.example/hosts.txt", .name = "ads" } },
|
|
\\}
|
|
;
|
|
|
|
var database = try db.Db.open(":memory:", .{ .mode = .memory });
|
|
defer database.close();
|
|
try db.applyPragmas(&database, .{});
|
|
_ = try migrations.migrate(&database);
|
|
|
|
var diags: validate.Diagnostics = .init(gpa);
|
|
defer diags.deinit();
|
|
try config_import.importSource(io, gpa, &database, source, .{}, &diags);
|
|
try std.testing.expectEqual(@as(usize, 0), diags.failureCount());
|
|
try std.testing.expectEqual(@as(usize, 1), diags.warningCount());
|
|
|
|
var arena_state: std.heap.ArenaAllocator = .init(gpa);
|
|
defer arena_state.deinit();
|
|
const cfg = try std.zon.parse.fromSliceAlloc(model.Config, arena_state.allocator(), source, null, .{});
|
|
|
|
var check_diags: validate.Diagnostics = .init(gpa);
|
|
defer check_diags.deinit();
|
|
// No error means no subcommand may reject it; the warning is report-only.
|
|
try validate.validate(cfg, &check_diags);
|
|
try std.testing.expectEqual(@as(usize, 0), check_diags.failureCount());
|
|
try std.testing.expectEqual(@as(usize, 1), check_diags.warningCount());
|
|
}
|
|
|
|
test "a first start that seeds from a file prints the warnings the file earned" {
|
|
// D5, second half. The seed file is read once in the life of a database, so
|
|
// a warning it earns is printed on that start or never. `run` printed
|
|
// diagnostics only when the file was rejected, which made a successful first
|
|
// start the one place the finding could not surface.
|
|
var threaded: std.Io.Threaded = .init(std.testing.allocator, .{});
|
|
defer threaded.deinit();
|
|
const io = threaded.io();
|
|
const gpa = std.testing.allocator;
|
|
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
try tmp.dir.writeFile(io, .{ .sub_path = "config.zon", .data =
|
|
\\.{
|
|
\\ .groups = .{ .{ .name = "default" } },
|
|
\\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
|
|
\\ .blocklist_sources = .{ .{ .url = "https://lists.example/hosts.txt", .name = "ads" } },
|
|
\\}
|
|
});
|
|
|
|
var database = try db.Db.open(":memory:", .{ .mode = .memory });
|
|
defer database.close();
|
|
try db.applyPragmas(&database, .{});
|
|
_ = try migrations.migrate(&database);
|
|
|
|
// A buffered file writer, the shape `main` builds over stderr, and not
|
|
// `Writer.fixed`: a fixed writer's flush is a no-op, so it counts a line
|
|
// still sitting in the buffer as delivered. Reading the file back is the
|
|
// only way to ask what the operator can actually see.
|
|
var out_buf: [64]u8 = undefined;
|
|
var out: Writer = .fixed(&out_buf);
|
|
var err_file = try tmp.dir.createFile(io, "stderr.txt", .{});
|
|
defer err_file.close(io);
|
|
var err_buf: [4096]u8 = undefined;
|
|
var err_writer = err_file.writer(io, &err_buf);
|
|
const r: cli.Runner = .{ .io = io, .gpa = gpa, .out = &out, .err = &err_writer.interface };
|
|
|
|
// The file is valid, so the start succeeds and the database is seeded.
|
|
try std.testing.expectEqual(
|
|
bootstrap.Outcome.seeded,
|
|
try seedFromFile(r, &database, tmp.dir, "config.zon"),
|
|
);
|
|
try std.testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM upstreams"));
|
|
|
|
// Nothing flushes here on purpose. In production `serve` runs from this
|
|
// point until the service stops, so a line that has not reached the file by
|
|
// now is a line the operator does not get for days.
|
|
const printed = try tmp.dir.readFileAlloc(io, "stderr.txt", gpa, .limited(8192));
|
|
defer gpa.free(printed);
|
|
|
|
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "WARN blocklist_sources[0]: "));
|
|
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "belongs to no group"));
|
|
// A warning is not a rejection: nothing here claims the start failed.
|
|
try std.testing.expectEqual(@as(usize, 0), std.mem.count(u8, printed, "FAIL"));
|
|
}
|
|
|
|
/// A broken stderr, in the shape `main` builds: a buffered `File.Writer`, with
|
|
/// its drain switched to the mode that fails. `Writer.fixed` cannot stand in —
|
|
/// its flush is `noopFlush`, so it has no failure to report and its `written()`
|
|
/// counts a line still sitting in the buffer as delivered.
|
|
///
|
|
/// The file stays empty for as long as the mode is `.failure`, which is what
|
|
/// lets a test tell "the writer really failed" from "the writer worked".
|
|
fn brokenErrWriter(io: std.Io, file: std.Io.File, buffer: []u8) std.Io.File.Writer {
|
|
var w = file.writer(io, buffer);
|
|
w.mode = .failure;
|
|
return w;
|
|
}
|
|
|
|
test "a broken error writer does not replace the reason a seed file was rejected" {
|
|
// The operator has to see why seeding failed, and a broken stderr is not
|
|
// that reason.
|
|
var threaded: std.Io.Threaded = .init(std.testing.allocator, .{});
|
|
defer threaded.deinit();
|
|
const io = threaded.io();
|
|
const gpa = std.testing.allocator;
|
|
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
// No group named 'default': rejected, and it records a FAIL line on the way.
|
|
try tmp.dir.writeFile(io, .{ .sub_path = "config.zon", .data =
|
|
\\.{
|
|
\\ .groups = .{ .{ .name = "kids" } },
|
|
\\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
|
|
\\}
|
|
});
|
|
|
|
var database = try db.Db.open(":memory:", .{ .mode = .memory });
|
|
defer database.close();
|
|
try db.applyPragmas(&database, .{});
|
|
_ = try migrations.migrate(&database);
|
|
|
|
var out_buf: [64]u8 = undefined;
|
|
var out: Writer = .fixed(&out_buf);
|
|
var err_file = try tmp.dir.createFile(io, "stderr.txt", .{});
|
|
defer err_file.close(io);
|
|
// Eight bytes: no FAIL line fits, so `writeAll` must drain mid-line and is
|
|
// itself the call that fails. The test below covers the other discard, where
|
|
// the line fits and only the flush fails.
|
|
var err_buf: [8]u8 = undefined;
|
|
var err_writer = brokenErrWriter(io, err_file, &err_buf);
|
|
const r: cli.Runner = .{ .io = io, .gpa = gpa, .out = &out, .err = &err_writer.interface };
|
|
|
|
try std.testing.expectError(
|
|
error.MissingDefaultGroup,
|
|
seedFromFile(r, &database, tmp.dir, "config.zon"),
|
|
);
|
|
|
|
// Empty, so the writer did fail — without this the assertion above would
|
|
// hold just as well against a writer that worked.
|
|
const printed = try tmp.dir.readFileAlloc(io, "stderr.txt", gpa, .limited(8192));
|
|
defer gpa.free(printed);
|
|
try std.testing.expectEqual(@as(usize, 0), printed.len);
|
|
}
|
|
|
|
test "a broken error writer does not stop a first start that succeeded" {
|
|
// The call this file makes: a DNS server for a household does not refuse to
|
|
// resolve because stderr is broken. What it must not do is drop the warning
|
|
// on the floor, so the second half checks the buffer still holds it.
|
|
var threaded: std.Io.Threaded = .init(std.testing.allocator, .{});
|
|
defer threaded.deinit();
|
|
const io = threaded.io();
|
|
const gpa = std.testing.allocator;
|
|
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
// Valid, and it earns one warning: the source belongs to no group.
|
|
try tmp.dir.writeFile(io, .{ .sub_path = "config.zon", .data =
|
|
\\.{
|
|
\\ .groups = .{ .{ .name = "default" } },
|
|
\\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
|
|
\\ .blocklist_sources = .{ .{ .url = "https://lists.example/hosts.txt", .name = "ads" } },
|
|
\\}
|
|
});
|
|
|
|
var database = try db.Db.open(":memory:", .{ .mode = .memory });
|
|
defer database.close();
|
|
try db.applyPragmas(&database, .{});
|
|
_ = try migrations.migrate(&database);
|
|
|
|
var out_buf: [64]u8 = undefined;
|
|
var out: Writer = .fixed(&out_buf);
|
|
var err_file = try tmp.dir.createFile(io, "stderr.txt", .{});
|
|
defer err_file.close(io);
|
|
// 4 KiB, the buffer `main` gives the real stderr writer: the warning fits,
|
|
// so `writeAll` succeeds into the buffer and the flush is what fails. That
|
|
// is the shape production hits.
|
|
var err_buf: [4096]u8 = undefined;
|
|
var err_writer = brokenErrWriter(io, err_file, &err_buf);
|
|
const r: cli.Runner = .{ .io = io, .gpa = gpa, .out = &out, .err = &err_writer.interface };
|
|
|
|
try std.testing.expectEqual(
|
|
bootstrap.Outcome.seeded,
|
|
try seedFromFile(r, &database, tmp.dir, "config.zon"),
|
|
);
|
|
try std.testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM upstreams"));
|
|
|
|
// Nothing reached the file, so the flush really did fail.
|
|
const undelivered = try tmp.dir.readFileAlloc(io, "stderr.txt", gpa, .limited(8192));
|
|
defer gpa.free(undelivered);
|
|
try std.testing.expectEqual(@as(usize, 0), undelivered.len);
|
|
|
|
// And the warning is still buffered rather than dropped: this is the same
|
|
// writer, and these are the bytes `run`'s exit flush meets on the way out.
|
|
err_writer.mode = .positional;
|
|
try err_writer.interface.flush();
|
|
const printed = try tmp.dir.readFileAlloc(io, "stderr.txt", gpa, .limited(8192));
|
|
defer gpa.free(printed);
|
|
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "WARN blocklist_sources[0]: "));
|
|
}
|
|
|
|
fn reportBind(r: cli.Runner, which: []const u8, addr: net.IpAddress, err: anyerror) anyerror {
|
|
r.err.print("cannot bind {s} {f}: {s}\n", .{ which, addr, @errorName(err) }) catch {};
|
|
return err;
|
|
}
|
|
|
|
/// The kernel has no IPv6 at all. Refusing to start would be the wrong answer
|
|
/// for the default configuration, which asks for both families.
|
|
fn ipv6Unavailable(err: anyerror) bool {
|
|
return switch (err) {
|
|
error.AddressFamilyUnsupported,
|
|
error.ProtocolUnsupportedBySystem,
|
|
error.ProtocolUnsupportedByAddressFamily,
|
|
=> true,
|
|
else => false,
|
|
};
|
|
}
|
|
|
|
fn isWildcard(addr: net.IpAddress) bool {
|
|
switch (addr) {
|
|
.ip4 => |a| {
|
|
for (a.bytes) |b| if (b != 0) return false;
|
|
},
|
|
.ip6 => |a| {
|
|
for (a.bytes) |b| if (b != 0) return false;
|
|
},
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// startup line
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// One line, at info, naming what an operator needs to see in `journalctl`
|
|
/// right after a restart: where it listens, how many upstreams it has, and
|
|
/// whether filtering is live.
|
|
fn logStartup(io: std.Io, manager: *manager_mod.Manager, upstream_count: usize, bound: Listeners) void {
|
|
var buf: [256]u8 = undefined;
|
|
var w: Writer = .fixed(&buf);
|
|
appendBind(&w, "udp", bound.udp6);
|
|
appendBind(&w, "udp", bound.udp4);
|
|
appendBind(&w, "tcp", bound.tcp6);
|
|
appendBind(&w, "tcp", bound.tcp4);
|
|
|
|
// The generation is read under the snapshot handle's shared lock, which is
|
|
// the same lock the reload took to publish it.
|
|
if (manager.acquire(io)) |snapshot| {
|
|
defer snapshot.release(io);
|
|
log.info("nxdns {s} serving on{s}; {d} upstream(s); blocklist generation {d}", .{
|
|
version.string,
|
|
w.buffered(),
|
|
upstream_count,
|
|
manager.generation,
|
|
});
|
|
} else {
|
|
log.info("nxdns {s} serving on{s}; {d} upstream(s); unfiltered (no blocklist snapshot)", .{
|
|
version.string,
|
|
w.buffered(),
|
|
upstream_count,
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Silent on overflow: a truncated startup line is not worth a failure path,
|
|
/// and 256 bytes hold four addresses.
|
|
fn appendBind(w: *Writer, which: []const u8, addr: ?net.IpAddress) void {
|
|
const value = addr orelse return;
|
|
w.print(" {s} {f}", .{ which, value }) catch {};
|
|
}
|
|
|
|
const test_address = @import("platform/address.zig");
|
|
|
|
test "one maintenance pass drops the api limiter's stale buckets" {
|
|
var threaded: std.Io.Threaded = .init(std.testing.allocator, .{});
|
|
defer threaded.deinit();
|
|
const io = threaded.io();
|
|
|
|
var limiter = try api_limiter.ApiLimiter.init(std.testing.allocator, .{
|
|
.rate_per_min = 60,
|
|
.localhost_exempt = false,
|
|
.sse_max_per_ip = 3,
|
|
});
|
|
defer limiter.deinit();
|
|
|
|
// A bucket last touched a full window ago has refilled to capacity, so a
|
|
// fresh bucket would answer identically and the sweep may drop it. The
|
|
// pass reads the real `.awake` clock, so the bucket is aged by dating the
|
|
// request rather than by waiting.
|
|
const now = std.Io.Clock.awake.now(io);
|
|
const window_ns = @as(i96, api_limiter.window_seconds) * std.time.ns_per_s;
|
|
const client: test_address.NetAddress = .{ .ip4 = .{ 192, 168, 1, 10 } };
|
|
_ = limiter.check(io, .{ .nanoseconds = now.nanoseconds - 2 * window_ns }, client);
|
|
try std.testing.expectEqual(@as(u32, 1), limiter.trackedClients(io));
|
|
|
|
var h: handler.Handler = .{
|
|
.upstream = .{ .ptr = undefined, .exchangeFn = undefined },
|
|
.blocking = .{ .mode = .zero, .ttl = 5 },
|
|
.forward_read_timeout = .{ .raw = .fromMilliseconds(50), .clock = .awake },
|
|
};
|
|
try maintenanceOnce(&h, &limiter, io);
|
|
|
|
// Before ruling 12 the sweep had no production caller, so the table kept
|
|
// this bucket until the process restarted.
|
|
try std.testing.expectEqual(@as(u32, 0), limiter.trackedClients(io));
|
|
|
|
// A limiter the app did not build is not a reason for the pass to fail.
|
|
try maintenanceOnce(&h, null, io);
|
|
}
|