dot upstreams: per-upstream tls_name for sni and cert verification by dns name

This commit is contained in:
2026-08-01 14:38:45 +02:00
parent 70bff22d75
commit 3baf5d6581
13 changed files with 405 additions and 17 deletions
+6 -1
View File
@@ -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 },
\\ .{ .url = "tls://dot.example: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 } },
@@ -252,6 +252,11 @@ test "readConfig, writeConfig, import and readConfig again produce an equal conf
try testing.expectEqual(a.logging.level, b.logging.level);
try testing.expectEqualStrings(a.web.password_hash, b.web.password_hash);
try testing.expectEqual(a.groups.len, b.groups.len);
try testing.expectEqual(a.upstreams.len, b.upstreams.len);
for (a.upstreams, b.upstreams) |left, right| {
try testing.expectEqualStrings(left.url, right.url);
try testing.expectEqualStrings(left.tls_name, right.tls_name);
}
try testing.expectEqual(a.rules.len, b.rules.len);
try testing.expectEqualStrings(a.clients[0].ip, b.clients[0].ip);
try testing.expectEqualStrings(a.forward_zones[0].resolver, b.forward_zones[0].resolver);
+9 -1
View File
@@ -388,7 +388,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 },
\\ .{ .url = "tls://dot.example: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 } },
@@ -449,6 +449,14 @@ test "importSource seeds a migrated database and group 'default' keeps id 1" {
try testing.expectEqual(@as(i64, 2), try database.queryInt("SELECT count(*) FROM upstreams"));
// The v6 client address was written in canonical form, not as typed.
try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM clients WHERE ip = 'fd00::1'"));
try testing.expectEqual(
@as(i64, 1),
try database.queryInt("SELECT count(*) FROM upstreams WHERE tls_name = 'dot.example'"),
);
try testing.expectEqual(
@as(i64, 1),
try database.queryInt("SELECT count(*) FROM upstreams WHERE tls_name = ''"),
);
}
test "applyToDb without force refuses a configured database and changes nothing" {
+10 -1
View File
@@ -204,7 +204,16 @@ pub const BlocklistUpdate = struct { enabled: bool = true, interval_hours: u16 =
pub const Group = struct { name: []const u8, safe_search: bool = false };
pub const UpstreamServer = struct { url: []const u8, priority: i32 = 100, enabled: bool = true };
pub const UpstreamServer = struct {
url: []const u8,
priority: i32 = 100,
enabled: bool = true,
/// DoT only. The DNS name used for SNI and certificate verification while
/// the connection still dials the URL's host. `std.crypto.Certificate`
/// matches dNSName SANs only, so a `tls://` upstream written as an IP
/// literal cannot verify without one. Empty means "verify by the URL host".
tls_name: []const u8 = "",
};
pub const Client = struct { ip: []const u8, name: []const u8 = "", group: []const u8 = "default" };
+74 -2
View File
@@ -31,6 +31,8 @@ pub const ValidateError = error{
NoUpstreams,
BadUpstreamUrl,
DuplicateUpstreamUrl,
BadTlsName,
TlsNameOnNonTlsUpstream,
MissingDefaultGroup,
DuplicateGroupName,
UnknownGroup,
@@ -400,6 +402,40 @@ fn canonical(scratch: Allocator, value: anytype) error{OutOfMemory}![]u8 {
return scratch.dupe(u8, w.buffered());
}
/// A `tls_name` overrides SNI and certificate verification for a DoT upstream,
/// which is the only transport that needs it: DoH verifies by the url host and
/// the http client would ignore this field, so a `tls_name` there is a config
/// error rather than a setting with no effect.
fn checkTlsName(
diags: *Diagnostics,
server: model.UpstreamServer,
scheme: transport.Scheme,
index: usize,
) error{OutOfMemory}!void {
if (server.tls_name.len == 0) return;
if (scheme != .dot) {
try diags.add(
error.TlsNameOnNonTlsUpstream,
"upstreams[{d}].tls_name",
.{index},
"tls_name is only for a tls:// upstream; '{s}' verifies by its url host",
.{server.url},
);
return;
}
_ = dns_name.fromText(server.tls_name) catch {
try diags.add(
error.BadTlsName,
"upstreams[{d}].tls_name",
.{index},
"'{s}' is not a valid domain name",
.{server.tls_name},
);
};
}
fn checkCollections(cfg: Config, diags: *Diagnostics, scratch: Allocator) error{OutOfMemory}!void {
var group_names: StringSet = .empty;
var has_default = false;
@@ -430,7 +466,12 @@ fn checkCollections(cfg: Config, diags: *Diagnostics, scratch: Allocator) error{
var upstream_urls: StringSet = .empty;
var enabled_upstreams: usize = 0;
for (cfg.upstreams, 0..) |server, i| {
_ = transport.Endpoint.parse(server.url) catch {
// The scheme decides whether `tls_name` is meaningful, so the parse
// result is kept rather than discarded. An unparseable url reports only
// `BadUpstreamUrl`: what its scheme would have been is unknown.
if (transport.Endpoint.parse(server.url)) |endpoint| {
try checkTlsName(diags, server, endpoint.scheme, i);
} else |_| {
try diags.add(
error.BadUpstreamUrl,
"upstreams[{d}].url",
@@ -438,7 +479,7 @@ fn checkCollections(cfg: Config, diags: *Diagnostics, scratch: Allocator) error{
"'{s}' is not an https:// or tls:// endpoint",
.{server.url},
);
};
}
if (try markSeen(&upstream_urls, scratch, server.url)) {
try diags.add(
error.DuplicateUpstreamUrl,
@@ -905,6 +946,37 @@ test "error.DuplicateUpstreamUrl" {
try expectProblem(cfg, error.DuplicateUpstreamUrl, "upstreams[1].url");
}
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" }};
try expectClean(cfg);
}
test "error.BadTlsName on a malformed name" {
var cfg = baseConfig();
cfg.upstreams = &.{.{ .url = "tls://1.1.1.1:853", .tls_name = "one..one.one" }};
try expectProblem(cfg, error.BadTlsName, "upstreams[0].tls_name");
var too_long = baseConfig();
too_long.upstreams = &.{.{ .url = "tls://1.1.1.1:853", .tls_name = "a" ** 64 ++ ".example" }};
try expectProblem(too_long, error.BadTlsName, "upstreams[0].tls_name");
}
test "error.TlsNameOnNonTlsUpstream on a DoH upstream" {
var cfg = baseConfig();
cfg.upstreams = &.{.{ .url = "https://dns.example/dns-query", .tls_name = "dns.example" }};
try expectProblem(cfg, error.TlsNameOnNonTlsUpstream, "upstreams[0].tls_name");
}
test "an empty tls_name is accepted on every scheme" {
var cfg = baseConfig();
cfg.upstreams = &.{
.{ .url = "https://dns.example/dns-query" },
.{ .url = "tls://9.9.9.9:853" },
};
try expectClean(cfg);
}
test "error.MissingDefaultGroup" {
var cfg = baseConfig();
cfg.groups = &.{.{ .name = "kids" }};