dot upstreams: per-upstream tls_name for sni and cert verification by dns name
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 },
|
||||
\\ .{ .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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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" }};
|
||||
|
||||
Reference in New Issue
Block a user