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); }