milestone 11: systemd and docker packaging, operator and architecture docs, config and api reference, docs drift guards
This commit is contained in:
+59
-4
@@ -458,8 +458,8 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
|
||||
.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;
|
||||
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: {
|
||||
@@ -826,11 +826,66 @@ const Listeners = struct {
|
||||
tcp4: ?net.IpAddress,
|
||||
};
|
||||
|
||||
fn parseBind(r: cli.Runner, text: []const u8, port: u16, field: []const u8) !net.IpAddress {
|
||||
return net.IpAddress.parse(text, port) catch {
|
||||
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"));
|
||||
}
|
||||
|
||||
fn reportBind(r: cli.Runner, which: []const u8, addr: net.IpAddress, err: anyerror) anyerror {
|
||||
|
||||
+2
-37
@@ -18,7 +18,6 @@ const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
pub const Config = struct {
|
||||
runtime: Runtime = .{},
|
||||
upstream: Upstream = .{},
|
||||
dns: Dns = .{},
|
||||
blocking: Blocking = .{},
|
||||
@@ -42,28 +41,7 @@ pub const Config = struct {
|
||||
forward_zones: []const ForwardZone = &.{},
|
||||
};
|
||||
|
||||
pub const IoBackend = enum {
|
||||
threaded,
|
||||
evented,
|
||||
|
||||
pub fn toDb(self: IoBackend) []const u8 {
|
||||
return switch (self) {
|
||||
.threaded => "threaded",
|
||||
.evented => "evented",
|
||||
};
|
||||
}
|
||||
|
||||
pub fn fromDb(text: []const u8) ?IoBackend {
|
||||
if (std.mem.eql(u8, text, "threaded")) return .threaded;
|
||||
if (std.mem.eql(u8, text, "evented")) return .evented;
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
pub const Runtime = struct { io_backend: IoBackend = .threaded };
|
||||
|
||||
pub const Upstream = struct {
|
||||
connect_timeout_ms: u32 = 2000,
|
||||
read_timeout_ms: u32 = 3000,
|
||||
total_timeout_ms: u32 = 5000,
|
||||
};
|
||||
@@ -318,10 +296,6 @@ comptime {
|
||||
assertFits(u32, 1024 * 1024, u64); // MiB conversions
|
||||
}
|
||||
|
||||
pub fn connectTimeout(u: Upstream) std.Io.Duration {
|
||||
return .{ .nanoseconds = @as(i96, u.connect_timeout_ms) * std.time.ns_per_ms };
|
||||
}
|
||||
|
||||
pub fn readTimeout(u: Upstream) std.Io.Duration {
|
||||
return .{ .nanoseconds = @as(i96, u.read_timeout_ms) * std.time.ns_per_ms };
|
||||
}
|
||||
@@ -506,8 +480,6 @@ const expected_keys = [_][]const u8{
|
||||
"logging.output",
|
||||
"logging.query_log_buffer_max",
|
||||
"logging.retention_days",
|
||||
"runtime.io_backend",
|
||||
"upstream.connect_timeout_ms",
|
||||
"upstream.read_timeout_ms",
|
||||
"upstream.total_timeout_ms",
|
||||
"web.api_localhost_exempt",
|
||||
@@ -558,8 +530,7 @@ test "toSettings never emits web.password" {
|
||||
test "toSettings and fromSettings round-trip a non-default config" {
|
||||
const gpa = testing.allocator;
|
||||
const original: Config = .{
|
||||
.runtime = .{ .io_backend = .evented },
|
||||
.upstream = .{ .connect_timeout_ms = 111, .read_timeout_ms = 222, .total_timeout_ms = 333 },
|
||||
.upstream = .{ .read_timeout_ms = 222, .total_timeout_ms = 333 },
|
||||
.dns = .{
|
||||
.bind_ipv4 = "127.0.0.1",
|
||||
.bind_ipv6 = "::1",
|
||||
@@ -697,7 +668,6 @@ fn expectEnumRoundTrip(comptime E: type) !void {
|
||||
}
|
||||
|
||||
test "every toDb and fromDb enum pair round-trips over all tags" {
|
||||
try expectEnumRoundTrip(IoBackend);
|
||||
try expectEnumRoundTrip(BlockResponse);
|
||||
try expectEnumRoundTrip(EcsMode);
|
||||
try expectEnumRoundTrip(LogLevel);
|
||||
@@ -715,10 +685,6 @@ test "RecordType stores the uppercase DDL spelling" {
|
||||
}
|
||||
|
||||
test "unit conversions" {
|
||||
try testing.expectEqual(
|
||||
@as(i96, 2000) * std.time.ns_per_ms,
|
||||
connectTimeout(.{}).nanoseconds,
|
||||
);
|
||||
try testing.expectEqual(
|
||||
@as(i96, 3000) * std.time.ns_per_ms,
|
||||
readTimeout(.{}).nanoseconds,
|
||||
@@ -737,13 +703,12 @@ test "unit conversions" {
|
||||
|
||||
test "unit conversions at the field maximum do not overflow" {
|
||||
const max_upstream: Upstream = .{
|
||||
.connect_timeout_ms = std.math.maxInt(u32),
|
||||
.read_timeout_ms = std.math.maxInt(u32),
|
||||
.total_timeout_ms = std.math.maxInt(u32),
|
||||
};
|
||||
try testing.expectEqual(
|
||||
@as(i96, std.math.maxInt(u32)) * std.time.ns_per_ms,
|
||||
connectTimeout(max_upstream).nanoseconds,
|
||||
readTimeout(max_upstream).nanoseconds,
|
||||
);
|
||||
try testing.expectEqual(
|
||||
@as(i64, std.math.maxInt(u16)) * 3600,
|
||||
|
||||
+31
-14
@@ -199,21 +199,20 @@ const max_rate_window_seconds = 3_600;
|
||||
|
||||
fn checkScalars(cfg: Config, diags: *Diagnostics) error{OutOfMemory}!void {
|
||||
const up = cfg.upstream;
|
||||
try checkTimeout(diags, up.connect_timeout_ms, "upstream.connect_timeout_ms");
|
||||
try checkTimeout(diags, up.read_timeout_ms, "upstream.read_timeout_ms");
|
||||
try checkTimeout(diags, up.total_timeout_ms, "upstream.total_timeout_ms");
|
||||
if (up.total_timeout_ms < up.connect_timeout_ms or up.total_timeout_ms < up.read_timeout_ms) {
|
||||
if (up.total_timeout_ms < up.read_timeout_ms) {
|
||||
try diags.add(
|
||||
error.BadTimeout,
|
||||
"upstream.total_timeout_ms",
|
||||
.{},
|
||||
"total budget {d}ms is below connect {d}ms or read {d}ms",
|
||||
.{ up.total_timeout_ms, up.connect_timeout_ms, up.read_timeout_ms },
|
||||
"total budget {d}ms is below read {d}ms",
|
||||
.{ up.total_timeout_ms, up.read_timeout_ms },
|
||||
);
|
||||
}
|
||||
|
||||
try checkBind(diags, cfg.dns.bind_ipv4, "dns.bind_ipv4", true);
|
||||
try checkBind(diags, cfg.dns.bind_ipv6, "dns.bind_ipv6", false);
|
||||
try checkBind(diags, cfg.dns.bind_ipv4, "dns.bind_ipv4", .ip4);
|
||||
try checkBind(diags, cfg.dns.bind_ipv6, "dns.bind_ipv6", .ip6);
|
||||
try checkPort(diags, cfg.dns.port, "dns.port");
|
||||
if (cfg.dns.rate_limit < 1) {
|
||||
try diags.add(error.BadRateLimit, "dns.rate_limit", .{}, "must be at least 1", .{});
|
||||
@@ -248,7 +247,7 @@ fn checkScalars(cfg: Config, diags: *Diagnostics) error{OutOfMemory}!void {
|
||||
);
|
||||
}
|
||||
|
||||
try checkBind(diags, cfg.web.bind, "web.bind", false);
|
||||
try checkBind(diags, cfg.web.bind, "web.bind", .any);
|
||||
try checkPort(diags, cfg.web.port, "web.port");
|
||||
if (cfg.web.password.len != 0 and cfg.web.password_hash.len != 0) {
|
||||
try diags.add(
|
||||
@@ -339,18 +338,30 @@ fn checkTimeout(diags: *Diagnostics, value: u32, comptime path: []const u8) erro
|
||||
}
|
||||
}
|
||||
|
||||
const BindFamily = enum { ip4, ip6, any };
|
||||
|
||||
/// `dns.bind_ipv4` and `dns.bind_ipv6` each name one socket of the dual-stack
|
||||
/// pair, so each must be a literal of its own family: an IPv4 wildcard in
|
||||
/// `bind_ipv6` would bind IPv4 as the "v6" socket and make the real IPv4 bind
|
||||
/// fail with AddressInUse — the IPv6 service silently disappears.
|
||||
fn checkBind(
|
||||
diags: *Diagnostics,
|
||||
text: []const u8,
|
||||
comptime path: []const u8,
|
||||
comptime require_ip4: bool,
|
||||
comptime family: BindFamily,
|
||||
) error{OutOfMemory}!void {
|
||||
const addr = NetAddress.parse(text) catch {
|
||||
try diags.add(error.BadBindAddress, path, .{}, "'{s}' is not an IP address", .{text});
|
||||
return;
|
||||
};
|
||||
if (require_ip4 and std.meta.activeTag(addr) != NetAddress.ip4) {
|
||||
try diags.add(error.BadBindAddress, path, .{}, "'{s}' is not an IPv4 address", .{text});
|
||||
switch (family) {
|
||||
.ip4 => if (std.meta.activeTag(addr) != NetAddress.ip4) {
|
||||
try diags.add(error.BadBindAddress, path, .{}, "'{s}' is not an IPv4 address", .{text});
|
||||
},
|
||||
.ip6 => if (std.meta.activeTag(addr) != NetAddress.ip6) {
|
||||
try diags.add(error.BadBindAddress, path, .{}, "'{s}' is not an IPv6 address", .{text});
|
||||
},
|
||||
.any => {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,7 +370,7 @@ fn checkTlsEndpoint(
|
||||
endpoint: model.TlsEndpoint,
|
||||
comptime section: []const u8,
|
||||
) error{OutOfMemory}!void {
|
||||
try checkBind(diags, endpoint.bind, section ++ ".bind", false);
|
||||
try checkBind(diags, endpoint.bind, section ++ ".bind", .any);
|
||||
try checkPort(diags, endpoint.port, section ++ ".port");
|
||||
if (!endpoint.enabled) return;
|
||||
// Readability of the files is `nxdns check`'s job, not the pure validator's.
|
||||
@@ -1162,11 +1173,11 @@ test "error.BadPort" {
|
||||
|
||||
test "error.BadTimeout" {
|
||||
var cfg = baseConfig();
|
||||
cfg.upstream.connect_timeout_ms = 10;
|
||||
try expectProblem(cfg, error.BadTimeout, "upstream.connect_timeout_ms");
|
||||
cfg.upstream.read_timeout_ms = 10;
|
||||
try expectProblem(cfg, error.BadTimeout, "upstream.read_timeout_ms");
|
||||
|
||||
var budget = baseConfig();
|
||||
budget.upstream = .{ .connect_timeout_ms = 4000, .read_timeout_ms = 4000, .total_timeout_ms = 1000 };
|
||||
budget.upstream = .{ .read_timeout_ms = 4000, .total_timeout_ms = 1000 };
|
||||
try expectProblem(budget, error.BadTimeout, "upstream.total_timeout_ms");
|
||||
}
|
||||
|
||||
@@ -1214,6 +1225,12 @@ test "error.BadBindAddress" {
|
||||
try expectProblem(web, error.BadBindAddress, "web.bind");
|
||||
}
|
||||
|
||||
test "error.BadBindAddress on an IPv4 literal in dns.bind_ipv6" {
|
||||
var cfg = baseConfig();
|
||||
cfg.dns.bind_ipv6 = "0.0.0.0";
|
||||
try expectProblem(cfg, error.BadBindAddress, "dns.bind_ipv6");
|
||||
}
|
||||
|
||||
test "error.MissingCertPath" {
|
||||
var cfg = baseConfig();
|
||||
cfg.doh_server = .{ .enabled = true, .cert_path = "" };
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
//! Textual-containment guards that keep the hand-written docs honest
|
||||
//! (milestone-11 ruling 3). They assert presence, not correctness — the same
|
||||
//! contract as openapi.zig's route guard.
|
||||
|
||||
const std = @import("std");
|
||||
const docs = @import("docs_files");
|
||||
const routes = @import("web/routes.zig");
|
||||
const model = @import("config/model.zig");
|
||||
|
||||
test "every served operation has its own table row in docs/api.md" {
|
||||
const gpa = std.testing.allocator;
|
||||
for (routes.table) |route| {
|
||||
// Matches one full method + path cell pair ("| GET | `/api/groups` |"),
|
||||
// so neither a same-path sibling method nor a longer-path prefix can
|
||||
// satisfy the check for a missing operation.
|
||||
const needle = try std.fmt.allocPrint(gpa, "| {s} | `{s}` |", .{
|
||||
@tagName(route.method), route.pattern,
|
||||
});
|
||||
defer gpa.free(needle);
|
||||
if (std.mem.indexOf(u8, docs.api_md, needle) == null) {
|
||||
std.debug.print("operation row missing from docs/api.md: {s}\n", .{needle});
|
||||
return error.OperationMissingFromApiDoc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test "every settings key appears in docs/config-reference.md" {
|
||||
const gpa = std.testing.allocator;
|
||||
var pairs: std.ArrayList(model.SettingPair) = .empty;
|
||||
defer {
|
||||
model.freeSettings(gpa, pairs.items);
|
||||
pairs.deinit(gpa);
|
||||
}
|
||||
try model.toSettings(.{}, gpa, &pairs);
|
||||
for (pairs.items) |pair| {
|
||||
if (std.mem.indexOf(u8, docs.config_reference_md, pair.key) == null) {
|
||||
std.debug.print("settings key missing from docs/config-reference.md: {s}\n", .{pair.key});
|
||||
return error.SettingsKeyMissingFromConfigDoc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test "every cli subcommand has its own reference heading in docs/operator.md" {
|
||||
const gpa = std.testing.allocator;
|
||||
const subcommands = [_][]const u8{ "run", "check", "export", "import", "version", "help" };
|
||||
for (subcommands) |name| {
|
||||
// Anchors on the reference-section heading ("### `import FILE`" starts
|
||||
// with "### `import"), so prose mentions elsewhere cannot mask a
|
||||
// removed command section.
|
||||
const needle = try std.fmt.allocPrint(gpa, "### `{s}", .{name});
|
||||
defer gpa.free(needle);
|
||||
if (std.mem.indexOf(u8, docs.operator_md, needle) == null) {
|
||||
std.debug.print("subcommand heading missing from docs/operator.md: {s}\n", .{needle});
|
||||
return error.SubcommandMissingFromOperatorDoc;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,9 @@
|
||||
//! need no `Io` because they read `std.Options.debug_io` (debug.zig:283), they
|
||||
//! are documented as recursive (debug.zig:263-270), and `Io/Threaded.zig`
|
||||
//! implements that recursion per OS thread (Threaded.zig:13787-13796). nxdns
|
||||
//! runs a `std.Io.Threaded` instance (cli.zig:793), so one task is one thread
|
||||
//! and the recursion holds. Taking it across the file writes as well keeps the
|
||||
//! runs on the `std.Io.Threaded` instance the stdlib start code constructs
|
||||
//! (start.zig:724, handed to `main` via `std.process.Init`), so one task is
|
||||
//! one thread and the recursion holds. Taking it across the file writes as well keeps the
|
||||
//! file path and the stderr fallback path from interleaving with each other,
|
||||
//! with `std.Progress`, or with a panic dump.
|
||||
//!
|
||||
|
||||
@@ -2,6 +2,7 @@ const std = @import("std");
|
||||
|
||||
comptime {
|
||||
_ = @import("main.zig");
|
||||
_ = @import("app.zig");
|
||||
_ = @import("version.zig");
|
||||
_ = @import("dns/types.zig");
|
||||
_ = @import("dns/header.zig");
|
||||
@@ -112,6 +113,7 @@ comptime {
|
||||
_ = @import("server/dot_server.zig");
|
||||
_ = @import("server/doh_server.zig");
|
||||
_ = @import("web/handlers/certs.zig");
|
||||
_ = @import("docs_drift_test.zig");
|
||||
}
|
||||
|
||||
extern fn sqlite3_libversion() [*:0]const u8;
|
||||
|
||||
@@ -189,7 +189,6 @@ fn newPassword(patch: Patch) ?[]const u8 {
|
||||
// the read shape
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const RuntimeView = struct { io_backend: []const u8 };
|
||||
const BlockingView = struct { response: []const u8, ttl: u32 };
|
||||
const EdnsView = struct { ecs_mode: []const u8 };
|
||||
|
||||
@@ -219,7 +218,6 @@ const WebView = struct {
|
||||
};
|
||||
|
||||
pub const View = struct {
|
||||
runtime: RuntimeView,
|
||||
upstream: model.Upstream,
|
||||
dns: model.Dns,
|
||||
blocking: BlockingView,
|
||||
@@ -235,7 +233,6 @@ pub const View = struct {
|
||||
|
||||
pub fn view(cfg: model.Config) View {
|
||||
return .{
|
||||
.runtime = .{ .io_backend = cfg.runtime.io_backend.toDb() },
|
||||
.upstream = cfg.upstream,
|
||||
.dns = cfg.dns,
|
||||
.blocking = .{ .response = cfg.blocking.response.toDb(), .ttl = cfg.blocking.ttl },
|
||||
@@ -469,13 +466,11 @@ test "the read shape spells every enum the way the database does" {
|
||||
.logging = .{ .level = .err, .output = .file },
|
||||
.blocking = .{ .response = .nxdomain },
|
||||
.edns = .{ .ecs_mode = .forward },
|
||||
.runtime = .{ .io_backend = .evented },
|
||||
});
|
||||
try testing.expectEqualStrings("error", rendered.logging.level);
|
||||
try testing.expectEqualStrings("file", rendered.logging.output);
|
||||
try testing.expectEqualStrings("nxdomain", rendered.blocking.response);
|
||||
try testing.expectEqualStrings("forward", rendered.edns.ecs_mode);
|
||||
try testing.expectEqualStrings("evented", rendered.runtime.io_backend);
|
||||
try testing.expect(!rendered.web.auth_enabled);
|
||||
|
||||
const with_password = view(.{ .web = .{ .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$a$b" } });
|
||||
|
||||
+2
-15
@@ -2088,20 +2088,12 @@ components:
|
||||
|
||||
Settings:
|
||||
type: object
|
||||
required: [runtime, upstream, dns, blocking, cache, web, doh_server, dot_server, edns, logging, disk, blocklist_update]
|
||||
required: [upstream, dns, blocking, cache, web, doh_server, dot_server, edns, logging, disk, blocklist_update]
|
||||
properties:
|
||||
runtime:
|
||||
type: object
|
||||
required: [io_backend]
|
||||
properties:
|
||||
io_backend:
|
||||
type: string
|
||||
enum: [threaded, evented]
|
||||
upstream:
|
||||
type: object
|
||||
required: [connect_timeout_ms, read_timeout_ms, total_timeout_ms]
|
||||
required: [read_timeout_ms, total_timeout_ms]
|
||||
properties:
|
||||
connect_timeout_ms: { type: integer }
|
||||
read_timeout_ms: { type: integer }
|
||||
total_timeout_ms: { type: integer }
|
||||
dns:
|
||||
@@ -2213,14 +2205,9 @@ components:
|
||||
the write-only `web.password`. `web.password_hash` is rejected as
|
||||
an unknown field.
|
||||
properties:
|
||||
runtime:
|
||||
type: object
|
||||
properties:
|
||||
io_backend: { type: string }
|
||||
upstream:
|
||||
type: object
|
||||
properties:
|
||||
connect_timeout_ms: { type: integer }
|
||||
read_timeout_ms: { type: integer }
|
||||
total_timeout_ms: { type: integer }
|
||||
dns:
|
||||
|
||||
@@ -525,8 +525,7 @@ const TlsEndpointView = struct {
|
||||
/// response never carries `web.password` or `web.password_hash` (ruling 16).
|
||||
const SettingsView = struct {
|
||||
settings: struct {
|
||||
runtime: struct { io_backend: []const u8 },
|
||||
upstream: struct { connect_timeout_ms: u32, read_timeout_ms: u32, total_timeout_ms: u32 },
|
||||
upstream: struct { read_timeout_ms: u32, total_timeout_ms: u32 },
|
||||
dns: struct {
|
||||
bind_ipv4: []const u8,
|
||||
bind_ipv6: []const u8,
|
||||
|
||||
Reference in New Issue
Block a user