milestone 17: real deadlines, validator holes, upstream editor, trusted proxies, contract samples, badvers
This commit is contained in:
@@ -161,7 +161,7 @@ const seed_source: [:0]const u8 =
|
||||
\\ .groups = .{ .{ .name = "default" }, .{ .name = "kids", .safe_search = true } },
|
||||
\\ .upstreams = .{
|
||||
\\ .{ .url = "https://dns.example/dns-query", .priority = 10 },
|
||||
\\ .{ .url = "tls://dot.example:853", .priority = 20, .enabled = false, .tls_name = "dot.example" },
|
||||
\\ .{ .url = "tls://192.0.2.53:853", .priority = 20, .enabled = false, .tls_name = "dot.example" },
|
||||
\\ },
|
||||
\\ .clients = .{ .{ .ip = "fd00::1", .name = "tablet", .group = "kids" } },
|
||||
\\ .client_prefixes = .{ .{ .prefix = "192.168.1.0/24", .group = "kids", .priority = 50 } },
|
||||
|
||||
@@ -507,7 +507,7 @@ const full_source: [:0]const u8 =
|
||||
\\ .groups = .{ .{ .name = "default" }, .{ .name = "kids", .safe_search = true } },
|
||||
\\ .upstreams = .{
|
||||
\\ .{ .url = "https://dns.example/dns-query", .priority = 10 },
|
||||
\\ .{ .url = "tls://dot.example:853", .priority = 20, .enabled = false, .tls_name = "dot.example" },
|
||||
\\ .{ .url = "tls://192.0.2.53:853", .priority = 20, .enabled = false, .tls_name = "dot.example" },
|
||||
\\ },
|
||||
\\ .clients = .{ .{ .ip = "FD00:0:0:0:0:0:0:1", .name = "tablet", .group = "kids" } },
|
||||
\\ .client_prefixes = .{ .{ .prefix = "192.168.1.0/24", .group = "kids", .priority = 50 } },
|
||||
|
||||
+57
-1
@@ -42,7 +42,14 @@ pub const Config = struct {
|
||||
};
|
||||
|
||||
pub const Upstream = struct {
|
||||
/// Bounds one attempt against one upstream inside the pool's failover loop.
|
||||
attempt_timeout_ms: u32 = 2500,
|
||||
/// The forward-zone client's read deadline, and nothing else. It bounds a
|
||||
/// different subsystem from the two above (`src/local/forward_client.zig`),
|
||||
/// so no cross-check relates it to them.
|
||||
read_timeout_ms: u32 = 3000,
|
||||
/// The whole-exchange budget: every failover attempt together, not one of
|
||||
/// them. The pool races the entire loop against it.
|
||||
total_timeout_ms: u32 = 5000,
|
||||
};
|
||||
|
||||
@@ -91,6 +98,39 @@ pub const Web = struct {
|
||||
/// abuse the limiter defends against (PLAN §10).
|
||||
api_localhost_exempt: bool = true,
|
||||
sse_max_connections_per_ip: u16 = 3,
|
||||
/// Comma-separated IP literals. A request arriving from one of these peers
|
||||
/// is identified by the last entry of its `X-Forwarded-For` header instead
|
||||
/// of by the socket peer, so the API limiter and the per-address SSE cap
|
||||
/// bind the real client rather than the proxy. Empty means no proxy is
|
||||
/// trusted and the socket peer is always the client. One string rather than
|
||||
/// a list because the settings codec stores scalars only.
|
||||
trusted_proxies: []const u8 = "",
|
||||
};
|
||||
|
||||
/// Walks `web.trusted_proxies`. The validator and the web server both read the
|
||||
/// setting, so what counts as one element — comma-separated, surrounding spaces
|
||||
/// and tabs trimmed — is spelled out once here. An empty setting yields nothing;
|
||||
/// an empty element (a stray comma) is yielded, so the validator can name it
|
||||
/// rather than silently drop it.
|
||||
pub fn trustedProxies(text: []const u8) TrustedProxyIterator {
|
||||
return .{ .rest = text, .done = text.len == 0 };
|
||||
}
|
||||
|
||||
pub const TrustedProxyIterator = struct {
|
||||
rest: []const u8,
|
||||
done: bool,
|
||||
|
||||
pub fn next(self: *TrustedProxyIterator) ?[]const u8 {
|
||||
if (self.done) return null;
|
||||
const end = std.mem.findScalar(u8, self.rest, ',') orelse {
|
||||
const last = self.rest;
|
||||
self.done = true;
|
||||
return std.mem.trim(u8, last, " \t");
|
||||
};
|
||||
const element = self.rest[0..end];
|
||||
self.rest = self.rest[end + 1 ..];
|
||||
return std.mem.trim(u8, element, " \t");
|
||||
}
|
||||
};
|
||||
|
||||
pub const TlsEndpoint = struct {
|
||||
@@ -300,6 +340,10 @@ pub fn readTimeout(u: Upstream) std.Io.Duration {
|
||||
return .{ .nanoseconds = @as(i96, u.read_timeout_ms) * std.time.ns_per_ms };
|
||||
}
|
||||
|
||||
pub fn attemptTimeout(u: Upstream) std.Io.Duration {
|
||||
return .{ .nanoseconds = @as(i96, u.attempt_timeout_ms) * std.time.ns_per_ms };
|
||||
}
|
||||
|
||||
pub fn totalTimeout(u: Upstream) std.Io.Duration {
|
||||
return .{ .nanoseconds = @as(i96, u.total_timeout_ms) * std.time.ns_per_ms };
|
||||
}
|
||||
@@ -480,6 +524,7 @@ const expected_keys = [_][]const u8{
|
||||
"logging.output",
|
||||
"logging.query_log_buffer_max",
|
||||
"logging.retention_days",
|
||||
"upstream.attempt_timeout_ms",
|
||||
"upstream.read_timeout_ms",
|
||||
"upstream.total_timeout_ms",
|
||||
"web.api_localhost_exempt",
|
||||
@@ -490,6 +535,7 @@ const expected_keys = [_][]const u8{
|
||||
"web.port",
|
||||
"web.session_ttl_hours",
|
||||
"web.sse_max_connections_per_ip",
|
||||
"web.trusted_proxies",
|
||||
};
|
||||
|
||||
fn lessThanKey(_: void, a: SettingPair, b: SettingPair) bool {
|
||||
@@ -530,7 +576,7 @@ test "toSettings never emits web.password" {
|
||||
test "toSettings and fromSettings round-trip a non-default config" {
|
||||
const gpa = testing.allocator;
|
||||
const original: Config = .{
|
||||
.upstream = .{ .read_timeout_ms = 222, .total_timeout_ms = 333 },
|
||||
.upstream = .{ .attempt_timeout_ms = 111, .read_timeout_ms = 222, .total_timeout_ms = 333 },
|
||||
.dns = .{
|
||||
.bind_ipv4 = "127.0.0.1",
|
||||
.bind_ipv6 = "::1",
|
||||
@@ -549,6 +595,7 @@ test "toSettings and fromSettings round-trip a non-default config" {
|
||||
.api_rate_limit_per_min = 29,
|
||||
.api_localhost_exempt = false,
|
||||
.sse_max_connections_per_ip = 31,
|
||||
.trusted_proxies = "10.0.0.9,fd00::9",
|
||||
},
|
||||
.doh_server = .{
|
||||
.enabled = true,
|
||||
@@ -685,6 +732,10 @@ test "RecordType stores the uppercase DDL spelling" {
|
||||
}
|
||||
|
||||
test "unit conversions" {
|
||||
try testing.expectEqual(
|
||||
@as(i96, 2500) * std.time.ns_per_ms,
|
||||
attemptTimeout(.{}).nanoseconds,
|
||||
);
|
||||
try testing.expectEqual(
|
||||
@as(i96, 3000) * std.time.ns_per_ms,
|
||||
readTimeout(.{}).nanoseconds,
|
||||
@@ -703,6 +754,7 @@ test "unit conversions" {
|
||||
|
||||
test "unit conversions at the field maximum do not overflow" {
|
||||
const max_upstream: Upstream = .{
|
||||
.attempt_timeout_ms = std.math.maxInt(u32),
|
||||
.read_timeout_ms = std.math.maxInt(u32),
|
||||
.total_timeout_ms = std.math.maxInt(u32),
|
||||
};
|
||||
@@ -710,6 +762,10 @@ test "unit conversions at the field maximum do not overflow" {
|
||||
@as(i96, std.math.maxInt(u32)) * std.time.ns_per_ms,
|
||||
readTimeout(max_upstream).nanoseconds,
|
||||
);
|
||||
try testing.expectEqual(
|
||||
@as(i96, std.math.maxInt(u32)) * std.time.ns_per_ms,
|
||||
attemptTimeout(max_upstream).nanoseconds,
|
||||
);
|
||||
try testing.expectEqual(
|
||||
@as(i64, std.math.maxInt(u16)) * 3600,
|
||||
sessionTtlSeconds(.{ .session_ttl_hours = std.math.maxInt(u16) }),
|
||||
|
||||
+187
-6
@@ -63,6 +63,7 @@ const Prefix = address.Prefix;
|
||||
pub const ValidateError = error{
|
||||
NoUpstreams,
|
||||
BadUpstreamUrl,
|
||||
UpstreamHostNotIpLiteral,
|
||||
DuplicateUpstreamUrl,
|
||||
BadTlsName,
|
||||
TlsNameOnNonTlsUpstream,
|
||||
@@ -89,11 +90,13 @@ pub const ValidateError = error{
|
||||
BadPort,
|
||||
BadTimeout,
|
||||
BadTtl,
|
||||
BadCacheSize,
|
||||
BadRetention,
|
||||
BadLogRotation,
|
||||
BadDiskThresholds,
|
||||
BadRateLimit,
|
||||
BadBindAddress,
|
||||
BadTrustedProxy,
|
||||
MissingCertPath,
|
||||
MissingKeyPath,
|
||||
MissingLogPath,
|
||||
@@ -310,17 +313,31 @@ const max_ttl_seconds = 86_400;
|
||||
const max_record_ttl_seconds = 604_800;
|
||||
const max_rate_window_seconds = 3_600;
|
||||
|
||||
/// The ceiling on every entry count nxdns allocates in full at boot: the query
|
||||
/// log's ring and the DNS cache's slot array. Neither is grown on demand, so an
|
||||
/// unchecked `maxInt(u32)` is a multi-terabyte allocation request during
|
||||
/// startup — a `maxInt(u32)` query-log ring alone asks for about 1.8 TB. This
|
||||
/// is a sanity bound, not a memory-fit guarantee: what actually fits depends on
|
||||
/// the box, and nxdns does not try to know that.
|
||||
const max_boot_entries = 1_000_000;
|
||||
|
||||
fn checkScalars(cfg: Config, diags: *Diagnostics) error{OutOfMemory}!void {
|
||||
const up = cfg.upstream;
|
||||
try checkTimeout(diags, up.attempt_timeout_ms, "upstream.attempt_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.read_timeout_ms) {
|
||||
// The only cross-check that relates two knobs of one subsystem: the pool
|
||||
// races one attempt against `attempt` and the whole failover loop against
|
||||
// `total`, so an attempt budget above the total one can never be reached.
|
||||
// `read_timeout_ms` belongs to the forward-zone client and is deliberately
|
||||
// unrelated to both.
|
||||
if (up.attempt_timeout_ms > up.total_timeout_ms) {
|
||||
try diags.add(
|
||||
error.BadTimeout,
|
||||
"upstream.total_timeout_ms",
|
||||
"upstream.attempt_timeout_ms",
|
||||
.{},
|
||||
"total budget {d}ms is below read {d}ms",
|
||||
.{ up.total_timeout_ms, up.read_timeout_ms },
|
||||
"attempt budget {d}ms is above the total budget {d}ms",
|
||||
.{ up.attempt_timeout_ms, up.total_timeout_ms },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -350,6 +367,19 @@ fn checkScalars(cfg: Config, diags: *Diagnostics) error{OutOfMemory}!void {
|
||||
);
|
||||
}
|
||||
|
||||
if (cfg.cache.size < 1) {
|
||||
try diags.add(error.BadCacheSize, "cache.size", .{}, "must be at least 1", .{});
|
||||
}
|
||||
if (cfg.cache.size > max_boot_entries) {
|
||||
try diags.add(
|
||||
error.BadCacheSize,
|
||||
"cache.size",
|
||||
.{},
|
||||
"must be at most {d}, got {d}",
|
||||
.{ max_boot_entries, cfg.cache.size },
|
||||
);
|
||||
}
|
||||
|
||||
if (cfg.cache.negative_ttl_max > max_ttl_seconds) {
|
||||
try diags.add(
|
||||
error.BadTtl,
|
||||
@@ -381,6 +411,7 @@ fn checkScalars(cfg: Config, diags: *Diagnostics) error{OutOfMemory}!void {
|
||||
if (cfg.web.sse_max_connections_per_ip < 1) {
|
||||
try diags.add(error.BadRateLimit, "web.sse_max_connections_per_ip", .{}, "must be at least 1", .{});
|
||||
}
|
||||
try checkTrustedProxies(diags, cfg.web.trusted_proxies);
|
||||
|
||||
try checkTlsEndpoint(diags, cfg.doh_server, "doh_server");
|
||||
try checkTlsEndpoint(diags, cfg.dot_server, "dot_server");
|
||||
@@ -391,6 +422,15 @@ fn checkScalars(cfg: Config, diags: *Diagnostics) error{OutOfMemory}!void {
|
||||
if (cfg.logging.query_log_buffer_max < 1) {
|
||||
try diags.add(error.BadRetention, "logging.query_log_buffer_max", .{}, "must be at least 1", .{});
|
||||
}
|
||||
if (cfg.logging.query_log_buffer_max > max_boot_entries) {
|
||||
try diags.add(
|
||||
error.BadRetention,
|
||||
"logging.query_log_buffer_max",
|
||||
.{},
|
||||
"must be at most {d}, got {d}",
|
||||
.{ max_boot_entries, cfg.logging.query_log_buffer_max },
|
||||
);
|
||||
}
|
||||
if (cfg.logging.max_size_mb < 1) {
|
||||
try diags.add(error.BadLogRotation, "logging.max_size_mb", .{}, "must be at least 1", .{});
|
||||
}
|
||||
@@ -478,6 +518,25 @@ fn checkBind(
|
||||
}
|
||||
}
|
||||
|
||||
/// Every element must be an IP literal, because the web server compares the
|
||||
/// socket peer against them byte for byte — a hostname here would silently
|
||||
/// trust nothing, and an operator who wrote one would believe their proxy was
|
||||
/// trusted while the API limiter kept seeing loopback.
|
||||
fn checkTrustedProxies(diags: *Diagnostics, text: []const u8) error{OutOfMemory}!void {
|
||||
var it = model.trustedProxies(text);
|
||||
while (it.next()) |element| {
|
||||
_ = NetAddress.parse(element) catch {
|
||||
try diags.add(
|
||||
error.BadTrustedProxy,
|
||||
"web.trusted_proxies",
|
||||
.{},
|
||||
"{f} is not an IP address; the list is comma-separated IP literals",
|
||||
.{safe_url.quoteText(element)},
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
fn checkTlsEndpoint(
|
||||
diags: *Diagnostics,
|
||||
endpoint: model.TlsEndpoint,
|
||||
@@ -573,6 +632,39 @@ fn checkTlsName(
|
||||
};
|
||||
}
|
||||
|
||||
/// A `tls://` upstream's host must be an IP literal, because `DotClient` refuses
|
||||
/// anything else on every dial (`upstream/dot_client.zig`'s `resolveAddress`:
|
||||
/// resolving an upstream's own name is a bootstrap problem nxdns declines to
|
||||
/// have). Without this check a hostname `tls://` config validates clean and then
|
||||
/// fails at query time as a peer fault, which reads as "the upstream is down"
|
||||
/// rather than "this line is wrong".
|
||||
///
|
||||
/// The mirror is exact: `NetAddress.parse` is `net.IpAddress.parse(text, 0)`
|
||||
/// (`platform/address.zig`), the same call the client makes, so the two cannot
|
||||
/// disagree about what a literal is. Note this is not `parseResolver` above —
|
||||
/// that one validates a forward zone's resolver, a different field.
|
||||
///
|
||||
/// A `tls://` host is a hostname often enough that the message says what to do
|
||||
/// instead rather than only what is wrong.
|
||||
fn checkDotHost(
|
||||
diags: *Diagnostics,
|
||||
server: model.UpstreamServer,
|
||||
endpoint: transport.Endpoint,
|
||||
index: usize,
|
||||
) error{OutOfMemory}!void {
|
||||
if (endpoint.scheme != .dot) return;
|
||||
_ = NetAddress.parse(endpoint.host) catch {
|
||||
try diags.add(
|
||||
error.UpstreamHostNotIpLiteral,
|
||||
"upstreams[{d}].url",
|
||||
.{index},
|
||||
"tls:// upstreams take an IP literal host; {f} names a host nxdns will not resolve. " ++
|
||||
"Use the address and put the name in tls_name, or configure the https:// form.",
|
||||
.{safe_url.redactQuoted(server.url)},
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
fn checkCollections(cfg: Config, diags: *Diagnostics, scratch: Allocator) error{OutOfMemory}!void {
|
||||
var group_names: IndexSet = .empty;
|
||||
var has_default = false;
|
||||
@@ -608,6 +700,7 @@ fn checkCollections(cfg: Config, diags: *Diagnostics, scratch: Allocator) error{
|
||||
// `BadUpstreamUrl`: what its scheme would have been is unknown.
|
||||
if (transport.Endpoint.parse(server.url)) |endpoint| {
|
||||
try checkTlsName(diags, server, endpoint.scheme, i);
|
||||
try checkDotHost(diags, server, endpoint, i);
|
||||
} else |_| {
|
||||
try diags.add(
|
||||
error.BadUpstreamUrl,
|
||||
@@ -1114,6 +1207,36 @@ test "error.DuplicateUpstreamUrl" {
|
||||
try expectProblem(cfg, error.DuplicateUpstreamUrl, "upstreams[1].url");
|
||||
}
|
||||
|
||||
test "error.UpstreamHostNotIpLiteral on a hostname tls:// upstream" {
|
||||
// The form an operator copies off the NextDNS setup page. It parses, so it
|
||||
// used to validate clean and then fail every exchange as a peer fault.
|
||||
var cfg = baseConfig();
|
||||
cfg.upstreams = &.{.{ .url = "tls://abcd12.dns.nextdns.io" }};
|
||||
try expectProblem(cfg, error.UpstreamHostNotIpLiteral, "upstreams[0].url");
|
||||
|
||||
// A tls_name does not excuse it: the name is what the certificate is
|
||||
// matched against, not what nxdns dials.
|
||||
var named = baseConfig();
|
||||
named.upstreams = &.{.{ .url = "tls://dns.quad9.net:853", .tls_name = "dns.quad9.net" }};
|
||||
try expectProblem(named, error.UpstreamHostNotIpLiteral, "upstreams[0].url");
|
||||
}
|
||||
|
||||
test "an https:// upstream may name a host" {
|
||||
// The check is scheme-specific: DoH resolves through the HTTP client, so a
|
||||
// hostname there is the normal form.
|
||||
var cfg = baseConfig();
|
||||
cfg.upstreams = &.{.{ .url = "https://dns.nextdns.io/abcd12" }};
|
||||
try expectClean(cfg);
|
||||
}
|
||||
|
||||
test "an IPv6 literal tls:// upstream validates cleanly" {
|
||||
// `Endpoint.parse` strips the brackets, so the host reaching the check is
|
||||
// exactly what the client hands to the address parser.
|
||||
var cfg = baseConfig();
|
||||
cfg.upstreams = &.{.{ .url = "tls://[2620:fe::fe]:853", .tls_name = "dns.quad9.net" }};
|
||||
try expectClean(cfg);
|
||||
}
|
||||
|
||||
test "a tls_name on a tls:// upstream validates cleanly" {
|
||||
var cfg = baseConfig();
|
||||
cfg.upstreams = &.{.{ .url = "tls://1.1.1.1:853", .tls_name = "one.one.one.one" }};
|
||||
@@ -1701,9 +1824,37 @@ test "error.BadTimeout" {
|
||||
cfg.upstream.read_timeout_ms = 10;
|
||||
try expectProblem(cfg, error.BadTimeout, "upstream.read_timeout_ms");
|
||||
|
||||
var attempt = baseConfig();
|
||||
attempt.upstream.attempt_timeout_ms = 10;
|
||||
try expectProblem(attempt, error.BadTimeout, "upstream.attempt_timeout_ms");
|
||||
|
||||
// An attempt budget above the total one can never be reached: the pool
|
||||
// cancels the whole loop when the total expires.
|
||||
var budget = baseConfig();
|
||||
budget.upstream = .{ .read_timeout_ms = 4000, .total_timeout_ms = 1000 };
|
||||
try expectProblem(budget, error.BadTimeout, "upstream.total_timeout_ms");
|
||||
budget.upstream = .{ .attempt_timeout_ms = 4000, .total_timeout_ms = 1000 };
|
||||
try expectProblem(budget, error.BadTimeout, "upstream.attempt_timeout_ms");
|
||||
|
||||
// `read_timeout_ms` bounds the forward-zone client, a different subsystem,
|
||||
// so it is deliberately free to sit above the pool's total budget.
|
||||
var unrelated = baseConfig();
|
||||
unrelated.upstream = .{ .attempt_timeout_ms = 500, .read_timeout_ms = 9000, .total_timeout_ms = 1000 };
|
||||
try expectClean(unrelated);
|
||||
}
|
||||
|
||||
test "error.BadCacheSize" {
|
||||
var zero = baseConfig();
|
||||
zero.cache.size = 0;
|
||||
try expectProblem(zero, error.BadCacheSize, "cache.size");
|
||||
|
||||
// The slot array is allocated in full at boot, so an unchecked maxInt is a
|
||||
// startup that asks the kernel for more memory than the box has.
|
||||
var huge = baseConfig();
|
||||
huge.cache.size = std.math.maxInt(u32);
|
||||
try expectProblem(huge, error.BadCacheSize, "cache.size");
|
||||
|
||||
var edge = baseConfig();
|
||||
edge.cache.size = max_boot_entries;
|
||||
try expectClean(edge);
|
||||
}
|
||||
|
||||
test "error.BadTtl" {
|
||||
@@ -1720,6 +1871,16 @@ test "error.BadRetention" {
|
||||
var cfg = baseConfig();
|
||||
cfg.logging.retention_days = 0;
|
||||
try expectProblem(cfg, error.BadRetention, "logging.retention_days");
|
||||
|
||||
var zero_buffer = baseConfig();
|
||||
zero_buffer.logging.query_log_buffer_max = 0;
|
||||
try expectProblem(zero_buffer, error.BadRetention, "logging.query_log_buffer_max");
|
||||
|
||||
// The ring is allocated in full at boot; an Entry is a few hundred bytes,
|
||||
// so maxInt(u32) asks for terabytes before the first query arrives.
|
||||
var huge_buffer = baseConfig();
|
||||
huge_buffer.logging.query_log_buffer_max = 4_000_000_000;
|
||||
try expectProblem(huge_buffer, error.BadRetention, "logging.query_log_buffer_max");
|
||||
}
|
||||
|
||||
test "error.BadLogRotation" {
|
||||
@@ -1756,6 +1917,26 @@ test "error.BadBindAddress on an IPv4 literal in dns.bind_ipv6" {
|
||||
try expectProblem(cfg, error.BadBindAddress, "dns.bind_ipv6");
|
||||
}
|
||||
|
||||
test "error.BadTrustedProxy" {
|
||||
var cfg = baseConfig();
|
||||
cfg.web.trusted_proxies = "127.0.0.1, proxy.example";
|
||||
try expectProblem(cfg, error.BadTrustedProxy, "web.trusted_proxies");
|
||||
|
||||
// A stray comma is named rather than dropped, so the operator sees the typo.
|
||||
var trailing = baseConfig();
|
||||
trailing.web.trusted_proxies = "127.0.0.1,";
|
||||
try expectProblem(trailing, error.BadTrustedProxy, "web.trusted_proxies");
|
||||
}
|
||||
|
||||
test "a list of IP literals, and an empty one, both validate" {
|
||||
var cfg = baseConfig();
|
||||
cfg.web.trusted_proxies = " 127.0.0.1 , ::1,10.0.0.1 ";
|
||||
try expectClean(cfg);
|
||||
|
||||
cfg.web.trusted_proxies = "";
|
||||
try expectClean(cfg);
|
||||
}
|
||||
|
||||
test "error.MissingCertPath" {
|
||||
var cfg = baseConfig();
|
||||
cfg.doh_server = .{ .enabled = true, .cert_path = "" };
|
||||
|
||||
Reference in New Issue
Block a user