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
+66
View File
@@ -18,10 +18,19 @@ const log = std.log.scoped(.migrations);
pub const Step = struct { version: u32, sql: [:0]const u8 };
/// Append only. Editing a released step — or `config_schema.ddl_v1` — would make
/// a fresh database and an upgraded one disagree, and nothing would detect it.
pub const steps = [_]Step{
.{ .version = 1, .sql = config_schema.ddl_v1 },
.{ .version = 2, .sql = ddl_v2 },
};
/// The DoT verification name (`upstreams.tls_name`). Empty keeps the pre-step-2
/// behavior: verify the certificate against the url host.
const ddl_v2: [:0]const u8 =
\\ALTER TABLE upstreams ADD COLUMN tls_name TEXT NOT NULL DEFAULT '';
;
pub const target_version: u32 = steps[steps.len - 1].version;
comptime {
@@ -236,6 +245,63 @@ test "a stepwise upgrade applies only the new steps" {
try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM groups"));
}
fn columnExists(database: *db.Db, table: []const u8, column: []const u8) !bool {
var stmt = try database.prepare("SELECT count(*) FROM pragma_table_info(?1) WHERE name = ?2");
defer stmt.deinit();
try stmt.bindText(1, table);
try stmt.bindText(2, column);
if (!try stmt.step()) return error.SqliteError;
return stmt.columnInt(0) != 0;
}
test "a fresh database reaches version 2 with the tls_name column" {
var database = try openMigrated();
defer database.close();
try testing.expectEqual(@as(u32, 2), try migrate(&database));
try testing.expectEqual(@as(u32, 2), target_version);
try testing.expect(try columnExists(&database, "upstreams", "tls_name"));
}
test "a version 1 database upgrades to 2 and keeps its rows with an empty tls_name" {
var database = try openMigrated();
defer database.close();
const first = [_]Step{.{ .version = 1, .sql = config_schema.ddl_v1 }};
try testing.expectEqual(@as(u32, 1), try migrateSteps(&database, &first));
try testing.expect(!try columnExists(&database, "upstreams", "tls_name"));
try database.exec("INSERT INTO upstreams (url, priority, enabled) VALUES ('tls://1.1.1.1:853', 10, 1);");
try testing.expectEqual(@as(u32, 2), try migrate(&database));
try testing.expectEqual(@as(u32, 2), try readVersion(&database));
try testing.expect(try columnExists(&database, "upstreams", "tls_name"));
var stmt = try database.prepare("SELECT url, tls_name FROM upstreams");
defer stmt.deinit();
try testing.expect(try stmt.step());
try testing.expectEqualStrings("tls://1.1.1.1:853", stmt.columnText(0));
try testing.expectEqualStrings("", stmt.columnText(1));
}
test "a failing step after step 2 rolls back the whole upgrade from version 1" {
var database = try openMigrated();
defer database.close();
const first = [_]Step{.{ .version = 1, .sql = config_schema.ddl_v1 }};
_ = try migrateSteps(&database, &first);
const broken = [_]Step{
steps[0],
steps[1],
.{ .version = 3, .sql = "CREATE TABLE third (" },
};
try testing.expectError(error.Unexpected, migrateSteps(&database, &broken));
// One transaction: the ALTER TABLE of step 2 went back with step 3.
try testing.expect(!try columnExists(&database, "upstreams", "tls_name"));
try testing.expectEqual(@as(u32, 1), try readVersion(&database));
}
test "delete_order and content_tables name exactly the tables the schema creates" {
var database = try openMigrated();
defer database.close();
+28 -5
View File
@@ -18,7 +18,9 @@ const InsertContext = context.InsertContext;
/// Every string in the result is a heap copy owned by `gpa`.
pub fn listUpstreams(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.UpstreamServer) {
var stmt = try database.prepare("SELECT url, priority, enabled FROM upstreams ORDER BY priority, url");
var stmt = try database.prepare(
"SELECT url, priority, enabled, tls_name FROM upstreams ORDER BY priority, url",
);
defer stmt.deinit();
var out: std.ArrayList(model.UpstreamServer) = .empty;
@@ -31,22 +33,35 @@ pub fn listUpstreams(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(mo
const url = try stmt.columnTextAlloc(gpa, 0);
errdefer gpa.free(url);
const priority = std.math.cast(i32, stmt.columnInt(1)) orelse return error.Mismatch;
try out.append(gpa, .{ .url = url, .priority = priority, .enabled = stmt.columnBool(2) });
const tls_name = try stmt.columnTextAlloc(gpa, 3);
errdefer gpa.free(tls_name);
try out.append(gpa, .{
.url = url,
.priority = priority,
.enabled = stmt.columnBool(2),
.tls_name = tls_name,
});
}
return out;
}
pub fn freeUpstreams(gpa: Allocator, items: []const model.UpstreamServer) void {
for (items) |item| gpa.free(item.url);
for (items) |item| {
gpa.free(item.url);
gpa.free(item.tls_name);
}
}
pub fn insertUpstream(database: *db.Db, item: model.UpstreamServer, ctx: InsertContext) db.Error!void {
_ = ctx;
var stmt = try database.prepare("INSERT INTO upstreams (url, priority, enabled) VALUES (?1, ?2, ?3)");
var stmt = try database.prepare(
"INSERT INTO upstreams (url, priority, enabled, tls_name) VALUES (?1, ?2, ?3, ?4)",
);
defer stmt.deinit();
try stmt.bindText(1, item.url);
try stmt.bindInt(2, item.priority);
try stmt.bindBool(3, item.enabled);
try stmt.bindText(4, item.tls_name);
try stmt.exec();
}
@@ -75,7 +90,12 @@ fn openMigrated() !db.Db {
fn seedUpstreams(database: *db.Db) !void {
const ctx: InsertContext = .{};
try insertUpstream(database, .{ .url = "https://dns.example/dns-query", .priority = 50 }, ctx);
try insertUpstream(database, .{ .url = "tls://1.1.1.1:853", .priority = 10, .enabled = false }, ctx);
try insertUpstream(database, .{
.url = "tls://1.1.1.1:853",
.priority = 10,
.enabled = false,
.tls_name = "one.one.one.one",
}, ctx);
try insertUpstream(database, .{ .url = "https://a.example/dns-query", .priority = 50 }, ctx);
}
@@ -92,7 +112,10 @@ test "upstreams round-trip in priority then url order" {
try testing.expectEqualStrings("tls://1.1.1.1:853", items.items[0].url);
try testing.expectEqual(@as(i32, 10), items.items[0].priority);
try testing.expect(!items.items[0].enabled);
try testing.expectEqualStrings("one.one.one.one", items.items[0].tls_name);
try testing.expectEqualStrings("https://a.example/dns-query", items.items[1].url);
// An upstream inserted without one reads back as the empty column default.
try testing.expectEqualStrings("", items.items[1].tls_name);
try testing.expectEqual(@as(i32, 50), items.items[1].priority);
try testing.expect(items.items[1].enabled);
try testing.expectEqualStrings("https://dns.example/dns-query", items.items[2].url);