initial commit

This commit is contained in:
2025-12-26 18:42:04 +01:00
commit d8d9ddfc53
52 changed files with 16863 additions and 0 deletions
+539
View File
@@ -0,0 +1,539 @@
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);
}