540 lines
17 KiB
Zig
540 lines
17 KiB
Zig
const std = @import("std");
|
|
const Database = @import("db.zig").Database;
|
|
|
|
pub const SCHEMA_VERSION: i64 = 1;
|
|
|
|
// =============================================================================
|
|
// Query Status - tracks exactly why a query was allowed/denied
|
|
// =============================================================================
|
|
|
|
pub const QueryStatus = enum(u8) {
|
|
unknown = 0, // Status not determined
|
|
forwarded = 1, // Sent to upstream, got response
|
|
cached = 2, // Answered from cache
|
|
denied_denylist = 3, // Blocked by denylist (gravity)
|
|
denied_rule = 4, // Blocked by user rule
|
|
denied_upstream = 5, // Upstream returned block (e.g., safe search rewrite)
|
|
allowed_rule = 6, // Explicitly allowed by rule, overriding denylist
|
|
upstream_error = 7, // Upstream failed, SERVFAIL, etc.
|
|
|
|
pub fn toString(self: QueryStatus) []const u8 {
|
|
return switch (self) {
|
|
.unknown => "unknown",
|
|
.forwarded => "forwarded",
|
|
.cached => "cached",
|
|
.denied_denylist => "denied_denylist",
|
|
.denied_rule => "denied_rule",
|
|
.denied_upstream => "denied_upstream",
|
|
.allowed_rule => "allowed_rule",
|
|
.upstream_error => "upstream_error",
|
|
};
|
|
}
|
|
|
|
pub fn fromInt(value: i64) QueryStatus {
|
|
return switch (value) {
|
|
1 => .forwarded,
|
|
2 => .cached,
|
|
3 => .denied_denylist,
|
|
4 => .denied_rule,
|
|
5 => .denied_upstream,
|
|
6 => .allowed_rule,
|
|
7 => .upstream_error,
|
|
else => .unknown,
|
|
};
|
|
}
|
|
|
|
pub fn isDenied(self: QueryStatus) bool {
|
|
return switch (self) {
|
|
.denied_denylist, .denied_rule, .denied_upstream => true,
|
|
else => false,
|
|
};
|
|
}
|
|
};
|
|
|
|
// =============================================================================
|
|
// Reply Type - tracks DNS response type
|
|
// =============================================================================
|
|
|
|
pub const ReplyType = enum(u8) {
|
|
unknown = 0,
|
|
nodata = 1, // Empty response (no records)
|
|
nxdomain = 2, // Domain doesn't exist
|
|
cname = 3, // CNAME response
|
|
ip = 4, // A/AAAA response
|
|
servfail = 5, // Server failure
|
|
refused = 6, // Query refused
|
|
|
|
pub fn toString(self: ReplyType) []const u8 {
|
|
return switch (self) {
|
|
.unknown => "unknown",
|
|
.nodata => "nodata",
|
|
.nxdomain => "nxdomain",
|
|
.cname => "cname",
|
|
.ip => "ip",
|
|
.servfail => "servfail",
|
|
.refused => "refused",
|
|
};
|
|
}
|
|
|
|
pub fn fromInt(value: i64) ReplyType {
|
|
return switch (value) {
|
|
1 => .nodata,
|
|
2 => .nxdomain,
|
|
3 => .cname,
|
|
4 => .ip,
|
|
5 => .servfail,
|
|
6 => .refused,
|
|
else => .unknown,
|
|
};
|
|
}
|
|
};
|
|
|
|
// =============================================================================
|
|
// Source Type - denylist vs allowlist
|
|
// =============================================================================
|
|
|
|
pub const SourceType = enum(u8) {
|
|
denylist = 0,
|
|
allowlist = 1,
|
|
|
|
pub fn toString(self: SourceType) []const u8 {
|
|
return switch (self) {
|
|
.denylist => "denylist",
|
|
.allowlist => "allowlist",
|
|
};
|
|
}
|
|
|
|
pub fn fromInt(value: i64) SourceType {
|
|
return switch (value) {
|
|
1 => .allowlist,
|
|
else => .denylist,
|
|
};
|
|
}
|
|
};
|
|
|
|
// =============================================================================
|
|
// Client Protocol - UDP vs TCP
|
|
// =============================================================================
|
|
|
|
pub const ClientProtocol = enum(u8) {
|
|
udp = 0,
|
|
tcp = 1,
|
|
|
|
pub fn toString(self: ClientProtocol) []const u8 {
|
|
return switch (self) {
|
|
.udp => "udp",
|
|
.tcp => "tcp",
|
|
};
|
|
}
|
|
|
|
pub fn fromInt(value: i64) ClientProtocol {
|
|
return switch (value) {
|
|
1 => .tcp,
|
|
else => .udp,
|
|
};
|
|
}
|
|
};
|
|
|
|
/// Denylist fetch status (follows Pi-hole conventions)
|
|
pub const DenylistStatus = enum(u8) {
|
|
pending = 0, // Never fetched / unknown
|
|
updated = 1, // Successfully fetched new data
|
|
unchanged = 2, // Content unchanged (same hash)
|
|
cached = 3, // Fetch failed, using cached data
|
|
failed = 4, // Fetch failed, no data available
|
|
|
|
pub fn toString(self: DenylistStatus) []const u8 {
|
|
return switch (self) {
|
|
.pending => "pending",
|
|
.updated => "updated",
|
|
.unchanged => "unchanged",
|
|
.cached => "cached",
|
|
.failed => "failed",
|
|
};
|
|
}
|
|
|
|
pub fn fromInt(value: i64) DenylistStatus {
|
|
return switch (value) {
|
|
1 => .updated,
|
|
2 => .unchanged,
|
|
3 => .cached,
|
|
4 => .failed,
|
|
else => .pending,
|
|
};
|
|
}
|
|
};
|
|
|
|
/// Create the initial database schema
|
|
pub fn createSchema(db: *Database) !void {
|
|
try db.execMulti(
|
|
\\-- Schema version tracking
|
|
\\CREATE TABLE IF NOT EXISTS schema_version (
|
|
\\ version INTEGER PRIMARY KEY
|
|
\\);
|
|
\\
|
|
\\-- Groups for client classification
|
|
\\CREATE TABLE IF NOT EXISTS groups (
|
|
\\ id INTEGER PRIMARY KEY,
|
|
\\ name TEXT NOT NULL UNIQUE,
|
|
\\ description TEXT,
|
|
\\ created_at INTEGER DEFAULT (strftime('%s', 'now')),
|
|
\\ date_modified INTEGER
|
|
\\);
|
|
\\
|
|
\\-- Insert default group
|
|
\\INSERT OR IGNORE INTO groups (id, name, description) VALUES (0, 'default', 'Default group for all clients');
|
|
\\
|
|
\\-- Clients (devices)
|
|
\\CREATE TABLE IF NOT EXISTS clients (
|
|
\\ id INTEGER PRIMARY KEY,
|
|
\\ ip TEXT NOT NULL UNIQUE,
|
|
\\ name TEXT,
|
|
\\ group_id INTEGER DEFAULT 0 REFERENCES groups(id),
|
|
\\ first_seen INTEGER DEFAULT (strftime('%s', 'now')),
|
|
\\ last_seen INTEGER DEFAULT (strftime('%s', 'now')),
|
|
\\ date_modified INTEGER
|
|
\\);
|
|
\\
|
|
\\CREATE INDEX IF NOT EXISTS idx_clients_ip ON clients(ip);
|
|
\\CREATE INDEX IF NOT EXISTS idx_clients_group ON clients(group_id);
|
|
\\
|
|
\\-- Denylist sources (type: 0=denylist, 1=allowlist)
|
|
\\CREATE TABLE IF NOT EXISTS denylist_sources (
|
|
\\ id INTEGER PRIMARY KEY,
|
|
\\ url TEXT NOT NULL UNIQUE,
|
|
\\ comment TEXT,
|
|
\\ category TEXT DEFAULT 'ads',
|
|
\\ enabled INTEGER DEFAULT 1,
|
|
\\ status INTEGER DEFAULT 0,
|
|
\\ last_updated INTEGER,
|
|
\\ domain_count INTEGER DEFAULT 0,
|
|
\\ invalid_domains INTEGER DEFAULT 0,
|
|
\\ content_hash TEXT,
|
|
\\ update_interval INTEGER DEFAULT 86400,
|
|
\\ type INTEGER DEFAULT 0,
|
|
\\ created_at INTEGER DEFAULT (strftime('%s', 'now')),
|
|
\\ date_modified INTEGER
|
|
\\);
|
|
\\
|
|
\\-- Domains from denylists
|
|
\\CREATE TABLE IF NOT EXISTS denylist_domains (
|
|
\\ id INTEGER PRIMARY KEY,
|
|
\\ domain TEXT NOT NULL,
|
|
\\ source_id INTEGER NOT NULL REFERENCES denylist_sources(id) ON DELETE CASCADE,
|
|
\\ UNIQUE(domain, source_id)
|
|
\\);
|
|
\\
|
|
\\CREATE INDEX IF NOT EXISTS idx_denylist_domains_domain ON denylist_domains(domain);
|
|
\\CREATE INDEX IF NOT EXISTS idx_denylist_domains_source ON denylist_domains(source_id);
|
|
\\
|
|
\\-- Which groups use which denylist sources
|
|
\\CREATE TABLE IF NOT EXISTS group_sources (
|
|
\\ group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
|
\\ source_id INTEGER NOT NULL REFERENCES denylist_sources(id) ON DELETE CASCADE,
|
|
\\ PRIMARY KEY (group_id, source_id)
|
|
\\);
|
|
\\
|
|
\\-- Custom allow/deny rules (per-group)
|
|
\\CREATE TABLE IF NOT EXISTS rules (
|
|
\\ id INTEGER PRIMARY KEY,
|
|
\\ domain TEXT NOT NULL,
|
|
\\ group_id INTEGER REFERENCES groups(id) ON DELETE CASCADE,
|
|
\\ action TEXT NOT NULL CHECK(action IN ('allow', 'deny')),
|
|
\\ comment TEXT,
|
|
\\ created_at INTEGER DEFAULT (strftime('%s', 'now')),
|
|
\\ date_modified INTEGER,
|
|
\\ UNIQUE(domain, group_id)
|
|
\\);
|
|
\\
|
|
\\CREATE INDEX IF NOT EXISTS idx_rules_domain ON rules(domain);
|
|
\\CREATE INDEX IF NOT EXISTS idx_rules_group ON rules(group_id);
|
|
\\
|
|
\\-- String interning for query log (domains)
|
|
\\CREATE TABLE IF NOT EXISTS domains (
|
|
\\ id INTEGER PRIMARY KEY,
|
|
\\ domain TEXT NOT NULL UNIQUE
|
|
\\);
|
|
\\
|
|
\\CREATE INDEX IF NOT EXISTS idx_domains_domain ON domains(domain);
|
|
\\
|
|
\\-- Query log (status: see QueryStatus enum, protocol: 0=udp, 1=tcp)
|
|
\\CREATE TABLE IF NOT EXISTS query_log (
|
|
\\ id INTEGER PRIMARY KEY,
|
|
\\ timestamp INTEGER NOT NULL,
|
|
\\ domain_id INTEGER NOT NULL REFERENCES domains(id),
|
|
\\ client_id INTEGER NOT NULL REFERENCES clients(id),
|
|
\\ qtype INTEGER,
|
|
\\ denied INTEGER NOT NULL DEFAULT 0,
|
|
\\ status INTEGER DEFAULT 0,
|
|
\\ list_id INTEGER REFERENCES denylist_sources(id),
|
|
\\ rule_id INTEGER REFERENCES rules(id),
|
|
\\ reply_type INTEGER DEFAULT 0,
|
|
\\ protocol INTEGER DEFAULT 0,
|
|
\\ response_time_us INTEGER,
|
|
\\ upstream TEXT,
|
|
\\ reason TEXT,
|
|
\\ dnssec_validated INTEGER NOT NULL DEFAULT 0
|
|
\\);
|
|
\\
|
|
\\CREATE INDEX IF NOT EXISTS idx_query_log_timestamp ON query_log(timestamp);
|
|
\\CREATE INDEX IF NOT EXISTS idx_query_log_client ON query_log(client_id);
|
|
\\CREATE INDEX IF NOT EXISTS idx_query_log_domain ON query_log(domain_id);
|
|
\\CREATE INDEX IF NOT EXISTS idx_query_log_denied ON query_log(denied);
|
|
\\CREATE INDEX IF NOT EXISTS idx_query_log_status ON query_log(status);
|
|
\\
|
|
\\-- Client-group many-to-many junction table
|
|
\\CREATE TABLE IF NOT EXISTS client_groups (
|
|
\\ client_id INTEGER NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
|
|
\\ group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
|
\\ PRIMARY KEY (client_id, group_id)
|
|
\\);
|
|
\\
|
|
\\-- Allowlist domains (for allowlist sources)
|
|
\\CREATE TABLE IF NOT EXISTS allowlist_domains (
|
|
\\ id INTEGER PRIMARY KEY,
|
|
\\ domain TEXT NOT NULL,
|
|
\\ source_id INTEGER NOT NULL REFERENCES denylist_sources(id) ON DELETE CASCADE,
|
|
\\ UNIQUE(domain, source_id)
|
|
\\);
|
|
\\CREATE INDEX IF NOT EXISTS idx_allowlist_domains_domain ON allowlist_domains(domain);
|
|
\\CREATE INDEX IF NOT EXISTS idx_allowlist_domains_source ON allowlist_domains(source_id);
|
|
\\
|
|
\\-- Settings
|
|
\\CREATE TABLE IF NOT EXISTS settings (
|
|
\\ key TEXT PRIMARY KEY,
|
|
\\ value TEXT NOT NULL
|
|
\\);
|
|
\\
|
|
\\-- Default settings
|
|
\\INSERT OR IGNORE INTO settings (key, value) VALUES ('blocking_response', 'zero');
|
|
\\INSERT OR IGNORE INTO settings (key, value) VALUES ('safe_search_enabled', 'true');
|
|
\\INSERT OR IGNORE INTO settings (key, value) VALUES ('log_retention', '30 days');
|
|
);
|
|
|
|
try db.exec("INSERT OR REPLACE INTO schema_version (version) VALUES (1)");
|
|
}
|
|
|
|
/// Get current schema version
|
|
pub fn getSchemaVersion(db: *Database) !i64 {
|
|
// Check if schema_version table exists
|
|
var stmt = db.prepare(
|
|
\\SELECT name FROM sqlite_master
|
|
\\WHERE type='table' AND name='schema_version'
|
|
) catch return 0;
|
|
defer stmt.finalize();
|
|
|
|
if (!(try stmt.step())) {
|
|
return 0;
|
|
}
|
|
|
|
// Get version
|
|
var version_stmt = try db.prepare("SELECT version FROM schema_version LIMIT 1");
|
|
defer version_stmt.finalize();
|
|
|
|
if (try version_stmt.step()) {
|
|
return version_stmt.getInt(0);
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
pub fn migrate(db: *Database) !void {
|
|
const current_version = try getSchemaVersion(db);
|
|
if (current_version == 0) {
|
|
try createSchema(db);
|
|
}
|
|
}
|
|
|
|
/// Denylist categories
|
|
pub const DenylistCategory = enum {
|
|
ads,
|
|
malware,
|
|
adult,
|
|
telemetry,
|
|
gambling,
|
|
social,
|
|
|
|
pub fn toString(self: DenylistCategory) []const u8 {
|
|
return switch (self) {
|
|
.ads => "ads",
|
|
.malware => "malware",
|
|
.adult => "adult",
|
|
.telemetry => "telemetry",
|
|
.gambling => "gambling",
|
|
.social => "social",
|
|
};
|
|
}
|
|
};
|
|
|
|
/// Pre-configured denylist source
|
|
const DefaultDenylist = struct {
|
|
url: []const u8,
|
|
comment: ?[]const u8,
|
|
category: DenylistCategory,
|
|
enabled_by_default: bool,
|
|
};
|
|
|
|
/// Default denylist sources from PLAN.md
|
|
pub const DEFAULT_DENYLISTS = [_]DefaultDenylist{
|
|
// Ads & Trackers
|
|
.{
|
|
.url = "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts",
|
|
.comment = "StevenBlack Unified",
|
|
.category = .ads,
|
|
.enabled_by_default = true,
|
|
},
|
|
.{
|
|
.url = "https://big.oisd.nl/domainswild",
|
|
.comment = "OISD Big (large list)",
|
|
.category = .ads,
|
|
.enabled_by_default = false,
|
|
},
|
|
.{
|
|
.url = "https://adguardteam.github.io/AdGuardSDNSFilter/Filters/filter.txt",
|
|
.comment = "AdGuard DNS Filter",
|
|
.category = .ads,
|
|
.enabled_by_default = true,
|
|
},
|
|
.{
|
|
.url = "https://pgl.yoyo.org/adservers/serverlist.php?hostformat=hosts&showintro=0",
|
|
.comment = "Peter Lowe's Ad Servers",
|
|
.category = .ads,
|
|
.enabled_by_default = true,
|
|
},
|
|
|
|
// Malware & Phishing
|
|
.{
|
|
.url = "https://urlhaus.abuse.ch/downloads/hostfile/",
|
|
.comment = "URLhaus Malware",
|
|
.category = .malware,
|
|
.enabled_by_default = true,
|
|
},
|
|
.{
|
|
.url = "https://threatfox.abuse.ch/downloads/hostfile/",
|
|
.comment = "ThreatFox IOCs",
|
|
.category = .malware,
|
|
.enabled_by_default = true,
|
|
},
|
|
.{
|
|
.url = "https://malware-filter.gitlab.io/malware-filter/phishing-filter-hosts.txt",
|
|
.comment = "Phishing Filter",
|
|
.category = .malware,
|
|
.enabled_by_default = true,
|
|
},
|
|
|
|
// Adult Content
|
|
.{
|
|
.url = "https://nsfw.oisd.nl/domainswild",
|
|
.comment = "OISD NSFW",
|
|
.category = .adult,
|
|
.enabled_by_default = false,
|
|
},
|
|
.{
|
|
.url = "https://raw.githubusercontent.com/StevenBlack/hosts/master/alternates/porn/hosts",
|
|
.comment = null,
|
|
.category = .adult,
|
|
.enabled_by_default = false,
|
|
},
|
|
|
|
// Native Telemetry
|
|
.{
|
|
.url = "https://raw.githubusercontent.com/nickspaargaren/no-google/master/categories/proxies",
|
|
.comment = null,
|
|
.category = .telemetry,
|
|
.enabled_by_default = false,
|
|
},
|
|
.{
|
|
.url = "https://raw.githubusercontent.com/nickspaargaren/no-google/master/categories/analytics",
|
|
.comment = null,
|
|
.category = .telemetry,
|
|
.enabled_by_default = false,
|
|
},
|
|
|
|
// Gambling
|
|
.{
|
|
.url = "https://raw.githubusercontent.com/StevenBlack/hosts/master/alternates/gambling/hosts",
|
|
.comment = null,
|
|
.category = .gambling,
|
|
.enabled_by_default = false,
|
|
},
|
|
|
|
// Social Media
|
|
.{
|
|
.url = "https://raw.githubusercontent.com/StevenBlack/hosts/master/alternates/social/hosts",
|
|
.comment = null,
|
|
.category = .social,
|
|
.enabled_by_default = false,
|
|
},
|
|
};
|
|
|
|
/// Insert default denylist sources
|
|
pub fn insertDefaultDenylists(db: *Database) !void {
|
|
for (DEFAULT_DENYLISTS) |list| {
|
|
var stmt = try db.prepare(
|
|
\\INSERT OR IGNORE INTO denylist_sources (url, comment, category, enabled)
|
|
\\VALUES (?1, ?2, ?3, ?4)
|
|
);
|
|
defer stmt.finalize();
|
|
|
|
try stmt.bindText(1, list.url);
|
|
if (list.comment) |comment| {
|
|
try stmt.bindText(2, comment);
|
|
} else {
|
|
try stmt.bindNull(2);
|
|
}
|
|
try stmt.bindText(3, list.category.toString());
|
|
try stmt.bindInt(4, if (list.enabled_by_default) 1 else 0);
|
|
_ = try stmt.step();
|
|
}
|
|
|
|
// Associate enabled denylists with default group
|
|
try db.exec(
|
|
\\INSERT OR IGNORE INTO group_sources (group_id, source_id)
|
|
\\SELECT 0, id FROM denylist_sources WHERE enabled = 1
|
|
);
|
|
}
|
|
|
|
test "schema creation" {
|
|
const testing = std.testing;
|
|
const allocator = testing.allocator;
|
|
|
|
var db = try Database.open(":memory:", allocator);
|
|
defer db.close();
|
|
|
|
try createSchema(&db);
|
|
|
|
// Verify tables exist
|
|
var stmt = try db.prepare(
|
|
\\SELECT name FROM sqlite_master
|
|
\\WHERE type='table' ORDER BY name
|
|
);
|
|
defer stmt.finalize();
|
|
|
|
var tables = std.ArrayListUnmanaged([]const u8){};
|
|
defer tables.deinit(allocator);
|
|
|
|
while (try stmt.step()) {
|
|
if (stmt.getText(0)) |name| {
|
|
try tables.append(allocator, try allocator.dupe(u8, name));
|
|
}
|
|
}
|
|
defer {
|
|
for (tables.items) |t| allocator.free(t);
|
|
}
|
|
|
|
try testing.expect(tables.items.len > 0);
|
|
}
|
|
|
|
test "migration from fresh db" {
|
|
const testing = std.testing;
|
|
const allocator = testing.allocator;
|
|
|
|
var db = try Database.open(":memory:", allocator);
|
|
defer db.close();
|
|
|
|
try migrate(&db);
|
|
|
|
const version = try getSchemaVersion(&db);
|
|
try testing.expectEqual(SCHEMA_VERSION, version);
|
|
}
|