initial commit
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user