milestone 8: web server, rest api, sse, auth, metrics and static assets
This commit is contained in:
+146
-11
@@ -30,10 +30,13 @@ 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 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");
|
||||
@@ -41,7 +44,9 @@ const dot_client = @import("upstream/dot_client.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");
|
||||
@@ -49,14 +54,18 @@ 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 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);
|
||||
|
||||
@@ -84,8 +93,8 @@ const ConfigError = error{
|
||||
BadRateLimit,
|
||||
};
|
||||
|
||||
pub fn run(runner: cli.Runner, paths: cli.Paths) u8 {
|
||||
const code = serve(runner, paths) catch |err| code: {
|
||||
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 {};
|
||||
if (isConfigFault(err)) {
|
||||
runner.err.writeAll("run `nxdns check` to see the configuration in full\n") catch {};
|
||||
@@ -108,9 +117,10 @@ fn isConfigFault(err: anyerror) bool {
|
||||
};
|
||||
}
|
||||
|
||||
fn serve(r: cli.Runner, paths: cli.Paths) !u8 {
|
||||
fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
|
||||
const io = r.io;
|
||||
const gpa = r.gpa;
|
||||
const paths = args.paths;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// storage and configuration
|
||||
@@ -154,15 +164,27 @@ fn serve(r: cli.Runner, paths: cli.Paths) !u8 {
|
||||
|
||||
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
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
var records = try local_records.Records.build(gpa, cfg.local_records);
|
||||
defer records.deinit(gpa);
|
||||
|
||||
var zones = try forward_zones.Zones.build(gpa, cfg.forward_zones);
|
||||
defer zones.deinit(gpa);
|
||||
// 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
|
||||
@@ -238,6 +260,20 @@ fn serve(r: cli.Runner, paths: cli.Paths) !u8 {
|
||||
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)
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -278,6 +314,30 @@ fn serve(r: cli.Runner, paths: cli.Paths) !u8 {
|
||||
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
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -302,16 +362,55 @@ fn serve(r: cli.Runner, paths: cli.Paths) !u8 {
|
||||
.ecs_mode = cfg.edns.ecs_mode,
|
||||
.forward_read_timeout = .{ .raw = model.readTimeout(cfg.upstream), .clock = .awake },
|
||||
.manager = &manager,
|
||||
.records = &records,
|
||||
.zones = &zones,
|
||||
.local_tables = &tables,
|
||||
.cache = &cache,
|
||||
.negative_ttl_max = cfg.cache.negative_ttl_max,
|
||||
.limiter = &limiter,
|
||||
.logger = &query_logger,
|
||||
.sink = &sink,
|
||||
.pause = &paused,
|
||||
.tracker = &tracker,
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 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.
|
||||
if (args.web_dev) |dir| web_dev_dir = dir;
|
||||
|
||||
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,
|
||||
.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,
|
||||
.reload_fn = reloadManager,
|
||||
};
|
||||
|
||||
const v6_bind = parseBind(r, cfg.dns.bind_ipv6, cfg.dns.port, "dns.bind_ipv6") catch |err| return err;
|
||||
const v4_bind = parseBind(r, cfg.dns.bind_ipv4, cfg.dns.port, "dns.bind_ipv4") catch |err| return err;
|
||||
|
||||
@@ -373,6 +472,13 @@ fn serve(r: cli.Runner, paths: cli.Paths) !u8 {
|
||||
try group.concurrent(io, clients.Tracker.run, .{ &tracker, io, &tracker_db, gate });
|
||||
try group.concurrent(io, runMaintenance, .{ &h, 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,
|
||||
@@ -392,6 +498,35 @@ fn serve(r: cli.Runner, paths: cli.Paths) !u8 {
|
||||
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);
|
||||
}
|
||||
|
||||
/// The `--web-dev` directory. `WebState.fallback` is a bare function pointer
|
||||
/// with no closure to carry the path, and one process runs one composition
|
||||
/// root, so the directory lives here: written once by `serve` before the web
|
||||
/// task starts, read only by `serveWebDev`.
|
||||
var web_dev_dir: []const u8 = "";
|
||||
|
||||
/// 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 {
|
||||
_ = state;
|
||||
return static.serveFromDisk(web_dev_dir, io, request);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// background maintenance
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user