package database import ( "context" "database/sql" "errors" "fmt" "strings" "time" "github.com/owen/vibedns/internal/models" ) // --- Networks ----------------------------------------------------------- // Networks lists client networks. When withPolicies is true each network is // populated with the policies assigned to it. func (db *DB) Networks(ctx context.Context, search string, withPolicies bool) ([]models.Network, error) { q := `SELECT id, name, cidr, description, enabled, created_at, updated_at FROM networks` var args []any if s := strings.TrimSpace(search); s != "" { q += ` WHERE name LIKE ? OR cidr LIKE ? OR description LIKE ?` pat := "%" + s + "%" args = append(args, pat, pat, pat) } q += ` ORDER BY name` rows, err := db.QueryContext(ctx, q, args...) if err != nil { return nil, fmt.Errorf("list networks: %w", err) } defer rows.Close() var out []models.Network index := map[int64]int{} for rows.Next() { var n models.Network var enabled int var created, updated int64 if err := rows.Scan(&n.ID, &n.Name, &n.CIDR, &n.Description, &enabled, &created, &updated); err != nil { return nil, err } n.Enabled = enabled != 0 n.CreatedAt = time.Unix(created, 0) n.UpdatedAt = time.Unix(updated, 0) index[n.ID] = len(out) out = append(out, n) } if err := rows.Err(); err != nil { return nil, err } if !withPolicies || len(out) == 0 { return out, nil } // One extra query joins in every assignment rather than N+1 lookups. prows, err := db.QueryContext(ctx, ` SELECT np.network_id, p.id, p.name, p.description, p.enabled, p.block_action, p.sinkhole_ipv4, p.sinkhole_ipv6, p.block_ttl FROM network_policies np JOIN policies p ON p.id = np.policy_id ORDER BY p.name`) if err != nil { return nil, fmt.Errorf("list network policies: %w", err) } defer prows.Close() for prows.Next() { var nid int64 var p models.Policy var enabled int if err := prows.Scan(&nid, &p.ID, &p.Name, &p.Description, &enabled, &p.BlockAction, &p.SinkholeIPv4, &p.SinkholeIPv6, &p.BlockTTL); err != nil { return nil, err } p.Enabled = enabled != 0 if i, ok := index[nid]; ok { out[i].Policies = append(out[i].Policies, p) } } return out, prows.Err() } // Network loads one network with its policy assignments. func (db *DB) Network(ctx context.Context, id int64) (models.Network, error) { var n models.Network var enabled int var created, updated int64 err := db.QueryRowContext(ctx, `SELECT id, name, cidr, description, enabled, created_at, updated_at FROM networks WHERE id = ?`, id). Scan(&n.ID, &n.Name, &n.CIDR, &n.Description, &enabled, &created, &updated) if errors.Is(err, sql.ErrNoRows) { return n, ErrNotFound } if err != nil { return n, fmt.Errorf("load network: %w", err) } n.Enabled = enabled != 0 n.CreatedAt = time.Unix(created, 0) n.UpdatedAt = time.Unix(updated, 0) ids, err := db.networkPolicyIDs(ctx, id) if err != nil { return n, err } for _, pid := range ids { p, err := db.Policy(ctx, pid) if err != nil { return n, err } n.Policies = append(n.Policies, p) } return n, nil } func (db *DB) networkPolicyIDs(ctx context.Context, networkID int64) ([]int64, error) { rows, err := db.QueryContext(ctx, `SELECT policy_id FROM network_policies WHERE network_id = ?`, networkID) if err != nil { return nil, fmt.Errorf("load policy assignments: %w", err) } defer rows.Close() var out []int64 for rows.Next() { var id int64 if err := rows.Scan(&id); err != nil { return nil, err } out = append(out, id) } return out, rows.Err() } // CreateNetwork inserts a network and its policy assignments. func (db *DB) CreateNetwork(ctx context.Context, n models.Network, policyIDs []int64) (models.Network, error) { var id int64 err := db.InTx(ctx, func(tx *sql.Tx) error { res, err := tx.ExecContext(ctx, `INSERT INTO networks (name, cidr, description, enabled) VALUES (?, ?, ?, ?)`, n.Name, n.CIDR, n.Description, boolInt(n.Enabled)) if err != nil { if isUniqueViolation(err) { return ErrConflict } return fmt.Errorf("create network: %w", err) } id, _ = res.LastInsertId() return setNetworkPoliciesTx(ctx, tx, id, policyIDs) }) if err != nil { return models.Network{}, err } return db.Network(ctx, id) } // UpdateNetwork saves a network and replaces its policy assignments. func (db *DB) UpdateNetwork(ctx context.Context, n models.Network, policyIDs []int64) (models.Network, error) { err := db.InTx(ctx, func(tx *sql.Tx) error { res, err := tx.ExecContext(ctx, ` UPDATE networks SET name = ?, cidr = ?, description = ?, enabled = ?, updated_at = unixepoch() WHERE id = ?`, n.Name, n.CIDR, n.Description, boolInt(n.Enabled), n.ID) if err != nil { if isUniqueViolation(err) { return ErrConflict } return fmt.Errorf("update network: %w", err) } if k, _ := res.RowsAffected(); k == 0 { return ErrNotFound } return setNetworkPoliciesTx(ctx, tx, n.ID, policyIDs) }) if err != nil { return models.Network{}, err } return db.Network(ctx, n.ID) } func setNetworkPoliciesTx(ctx context.Context, tx *sql.Tx, networkID int64, policyIDs []int64) error { if _, err := tx.ExecContext(ctx, `DELETE FROM network_policies WHERE network_id = ?`, networkID); err != nil { return fmt.Errorf("clear policy assignments: %w", err) } if len(policyIDs) == 0 { return nil } stmt, err := tx.PrepareContext(ctx, `INSERT OR IGNORE INTO network_policies (network_id, policy_id) VALUES (?, ?)`) if err != nil { return err } defer stmt.Close() for _, pid := range policyIDs { if _, err := stmt.ExecContext(ctx, networkID, pid); err != nil { return fmt.Errorf("assign policy %d: %w", pid, err) } } return nil } // DeleteNetwork removes a network; assignments cascade. func (db *DB) DeleteNetwork(ctx context.Context, id int64) error { res, err := db.ExecContext(ctx, `DELETE FROM networks WHERE id = ?`, id) if err != nil { return fmt.Errorf("delete network: %w", err) } if n, _ := res.RowsAffected(); n == 0 { return ErrNotFound } return nil } // SetNetworkEnabled toggles a network. func (db *DB) SetNetworkEnabled(ctx context.Context, id int64, enabled bool) error { res, err := db.ExecContext(ctx, `UPDATE networks SET enabled = ?, updated_at = unixepoch() WHERE id = ?`, boolInt(enabled), id) if err != nil { return fmt.Errorf("update network: %w", err) } if n, _ := res.RowsAffected(); n == 0 { return ErrNotFound } return nil } // --- Policies ----------------------------------------------------------- const policyColumns = `id, name, description, enabled, block_action, sinkhole_ipv4, sinkhole_ipv6, block_ttl, created_at, updated_at` func scanPolicy(sc interface{ Scan(...any) error }) (models.Policy, error) { var p models.Policy var enabled int var created, updated int64 err := sc.Scan(&p.ID, &p.Name, &p.Description, &enabled, &p.BlockAction, &p.SinkholeIPv4, &p.SinkholeIPv6, &p.BlockTTL, &created, &updated) if err != nil { return p, err } p.Enabled = enabled != 0 p.CreatedAt = time.Unix(created, 0) p.UpdatedAt = time.Unix(updated, 0) return p, nil } // Policies lists every policy with its list assignments and network usage. func (db *DB) Policies(ctx context.Context) ([]models.Policy, error) { rows, err := db.QueryContext(ctx, `SELECT `+policyColumns+` FROM policies ORDER BY name`) if err != nil { return nil, fmt.Errorf("list policies: %w", err) } defer rows.Close() var out []models.Policy index := map[int64]int{} for rows.Next() { p, err := scanPolicy(rows) if err != nil { return nil, err } index[p.ID] = len(out) out = append(out, p) } if err := rows.Err(); err != nil { return nil, err } if len(out) == 0 { return out, nil } lrows, err := db.QueryContext(ctx, ` SELECT pl.policy_id, l.id, l.kind, l.name FROM policy_lists pl JOIN domain_lists l ON l.id = pl.list_id ORDER BY l.name`) if err != nil { return nil, fmt.Errorf("list policy lists: %w", err) } defer lrows.Close() for lrows.Next() { var pid, lid int64 var kind, name string if err := lrows.Scan(&pid, &lid, &kind, &name); err != nil { return nil, err } i, ok := index[pid] if !ok { continue } if kind == models.KindAllowlist { out[i].AllowlistIDs = append(out[i].AllowlistIDs, lid) out[i].AllowlistName = append(out[i].AllowlistName, name) } else { out[i].BlacklistIDs = append(out[i].BlacklistIDs, lid) out[i].BlacklistName = append(out[i].BlacklistName, name) } } if err := lrows.Err(); err != nil { return nil, err } nrows, err := db.QueryContext(ctx, `SELECT policy_id, COUNT(*) FROM network_policies GROUP BY policy_id`) if err != nil { return nil, err } defer nrows.Close() for nrows.Next() { var pid int64 var c int if err := nrows.Scan(&pid, &c); err != nil { return nil, err } if i, ok := index[pid]; ok { out[i].NetworkCount = c } } return out, nrows.Err() } // Policy loads one policy with its list assignments. func (db *DB) Policy(ctx context.Context, id int64) (models.Policy, error) { row := db.QueryRowContext(ctx, `SELECT `+policyColumns+` FROM policies WHERE id = ?`, id) p, err := scanPolicy(row) if errors.Is(err, sql.ErrNoRows) { return p, ErrNotFound } if err != nil { return p, fmt.Errorf("load policy: %w", err) } rows, err := db.QueryContext(ctx, ` SELECT l.id, l.kind, l.name FROM policy_lists pl JOIN domain_lists l ON l.id = pl.list_id WHERE pl.policy_id = ? ORDER BY l.name`, id) if err != nil { return p, fmt.Errorf("load policy lists: %w", err) } defer rows.Close() for rows.Next() { var lid int64 var kind, name string if err := rows.Scan(&lid, &kind, &name); err != nil { return p, err } if kind == models.KindAllowlist { p.AllowlistIDs = append(p.AllowlistIDs, lid) p.AllowlistName = append(p.AllowlistName, name) } else { p.BlacklistIDs = append(p.BlacklistIDs, lid) p.BlacklistName = append(p.BlacklistName, name) } } return p, rows.Err() } // CreatePolicy inserts a policy with its list assignments. func (db *DB) CreatePolicy(ctx context.Context, p models.Policy, listIDs []int64) (models.Policy, error) { var id int64 err := db.InTx(ctx, func(tx *sql.Tx) error { res, err := tx.ExecContext(ctx, ` INSERT INTO policies (name, description, enabled, block_action, sinkhole_ipv4, sinkhole_ipv6, block_ttl) VALUES (?, ?, ?, ?, ?, ?, ?)`, p.Name, p.Description, boolInt(p.Enabled), string(p.BlockAction), p.SinkholeIPv4, p.SinkholeIPv6, p.BlockTTL) if err != nil { if isUniqueViolation(err) { return ErrConflict } return fmt.Errorf("create policy: %w", err) } id, _ = res.LastInsertId() return setPolicyListsTx(ctx, tx, id, listIDs) }) if err != nil { return models.Policy{}, err } return db.Policy(ctx, id) } // UpdatePolicy saves a policy and replaces its list assignments. func (db *DB) UpdatePolicy(ctx context.Context, p models.Policy, listIDs []int64) (models.Policy, error) { err := db.InTx(ctx, func(tx *sql.Tx) error { res, err := tx.ExecContext(ctx, ` UPDATE policies SET name = ?, description = ?, enabled = ?, block_action = ?, sinkhole_ipv4 = ?, sinkhole_ipv6 = ?, block_ttl = ?, updated_at = unixepoch() WHERE id = ?`, p.Name, p.Description, boolInt(p.Enabled), string(p.BlockAction), p.SinkholeIPv4, p.SinkholeIPv6, p.BlockTTL, p.ID) if err != nil { if isUniqueViolation(err) { return ErrConflict } return fmt.Errorf("update policy: %w", err) } if n, _ := res.RowsAffected(); n == 0 { return ErrNotFound } return setPolicyListsTx(ctx, tx, p.ID, listIDs) }) if err != nil { return models.Policy{}, err } return db.Policy(ctx, p.ID) } func setPolicyListsTx(ctx context.Context, tx *sql.Tx, policyID int64, listIDs []int64) error { if _, err := tx.ExecContext(ctx, `DELETE FROM policy_lists WHERE policy_id = ?`, policyID); err != nil { return fmt.Errorf("clear policy lists: %w", err) } if len(listIDs) == 0 { return nil } stmt, err := tx.PrepareContext(ctx, `INSERT OR IGNORE INTO policy_lists (policy_id, list_id) VALUES (?, ?)`) if err != nil { return err } defer stmt.Close() for _, lid := range listIDs { if _, err := stmt.ExecContext(ctx, policyID, lid); err != nil { return fmt.Errorf("assign list %d: %w", lid, err) } } return nil } // DeletePolicy removes a policy; assignments cascade. func (db *DB) DeletePolicy(ctx context.Context, id int64) error { res, err := db.ExecContext(ctx, `DELETE FROM policies WHERE id = ?`, id) if err != nil { return fmt.Errorf("delete policy: %w", err) } if n, _ := res.RowsAffected(); n == 0 { return ErrNotFound } return nil } // SetPolicyEnabled toggles a policy. func (db *DB) SetPolicyEnabled(ctx context.Context, id int64, enabled bool) error { res, err := db.ExecContext(ctx, `UPDATE policies SET enabled = ?, updated_at = unixepoch() WHERE id = ?`, boolInt(enabled), id) if err != nil { return fmt.Errorf("update policy: %w", err) } if n, _ := res.RowsAffected(); n == 0 { return ErrNotFound } return nil } // --- Domain lists ------------------------------------------------------- // DomainLists returns blacklists or allowlists (kind may be "" for both) with // domain counts and the policies that reference them. func (db *DB) DomainLists(ctx context.Context, kind, search string) ([]models.DomainList, error) { q := `SELECT l.id, l.kind, l.name, l.description, l.enabled, l.source_url, l.created_at, l.updated_at, (SELECT COUNT(*) FROM domain_entries e WHERE e.list_id = l.id) AS domain_count FROM domain_lists l` var where []string var args []any if kind != "" { where = append(where, "l.kind = ?") args = append(args, kind) } if s := strings.TrimSpace(search); s != "" { where = append(where, "(l.name LIKE ? OR l.description LIKE ?)") pat := "%" + s + "%" args = append(args, pat, pat) } if len(where) > 0 { q += " WHERE " + strings.Join(where, " AND ") } q += " ORDER BY l.name" rows, err := db.QueryContext(ctx, q, args...) if err != nil { return nil, fmt.Errorf("list domain lists: %w", err) } defer rows.Close() var out []models.DomainList index := map[int64]int{} for rows.Next() { var l models.DomainList var enabled int var created, updated int64 if err := rows.Scan(&l.ID, &l.Kind, &l.Name, &l.Description, &enabled, &l.SourceURL, &created, &updated, &l.DomainCount); err != nil { return nil, err } l.Enabled = enabled != 0 l.CreatedAt = time.Unix(created, 0) l.UpdatedAt = time.Unix(updated, 0) index[l.ID] = len(out) out = append(out, l) } if err := rows.Err(); err != nil { return nil, err } if len(out) == 0 { return out, nil } urows, err := db.QueryContext(ctx, ` SELECT pl.list_id, p.name FROM policy_lists pl JOIN policies p ON p.id = pl.policy_id ORDER BY p.name`) if err != nil { return nil, err } defer urows.Close() for urows.Next() { var lid int64 var name string if err := urows.Scan(&lid, &name); err != nil { return nil, err } if i, ok := index[lid]; ok { out[i].UsedBy = append(out[i].UsedBy, name) } } return out, urows.Err() } // DomainList loads one list with its domain count and referencing policies. func (db *DB) DomainList(ctx context.Context, id int64) (models.DomainList, error) { var l models.DomainList var enabled int var created, updated int64 err := db.QueryRowContext(ctx, ` SELECT l.id, l.kind, l.name, l.description, l.enabled, l.source_url, l.created_at, l.updated_at, (SELECT COUNT(*) FROM domain_entries e WHERE e.list_id = l.id) FROM domain_lists l WHERE l.id = ?`, id). Scan(&l.ID, &l.Kind, &l.Name, &l.Description, &enabled, &l.SourceURL, &created, &updated, &l.DomainCount) if errors.Is(err, sql.ErrNoRows) { return l, ErrNotFound } if err != nil { return l, fmt.Errorf("load domain list: %w", err) } l.Enabled = enabled != 0 l.CreatedAt = time.Unix(created, 0) l.UpdatedAt = time.Unix(updated, 0) rows, err := db.QueryContext(ctx, ` SELECT p.name FROM policy_lists pl JOIN policies p ON p.id = pl.policy_id WHERE pl.list_id = ? ORDER BY p.name`, id) if err != nil { return l, err } defer rows.Close() for rows.Next() { var n string if err := rows.Scan(&n); err != nil { return l, err } l.UsedBy = append(l.UsedBy, n) } return l, rows.Err() } // CreateDomainList inserts a blacklist or allowlist. func (db *DB) CreateDomainList(ctx context.Context, l models.DomainList) (models.DomainList, error) { res, err := db.ExecContext(ctx, ` INSERT INTO domain_lists (kind, name, description, enabled, source_url) VALUES (?, ?, ?, ?, ?)`, l.Kind, l.Name, l.Description, boolInt(l.Enabled), l.SourceURL) if err != nil { if isUniqueViolation(err) { return models.DomainList{}, ErrConflict } return models.DomainList{}, fmt.Errorf("create domain list: %w", err) } id, _ := res.LastInsertId() return db.DomainList(ctx, id) } // UpdateDomainList saves list metadata. func (db *DB) UpdateDomainList(ctx context.Context, l models.DomainList) (models.DomainList, error) { res, err := db.ExecContext(ctx, ` UPDATE domain_lists SET name = ?, description = ?, enabled = ?, source_url = ?, updated_at = unixepoch() WHERE id = ?`, l.Name, l.Description, boolInt(l.Enabled), l.SourceURL, l.ID) if err != nil { if isUniqueViolation(err) { return models.DomainList{}, ErrConflict } return models.DomainList{}, fmt.Errorf("update domain list: %w", err) } if n, _ := res.RowsAffected(); n == 0 { return models.DomainList{}, ErrNotFound } return db.DomainList(ctx, l.ID) } // DeleteDomainList removes a list and every domain in it. func (db *DB) DeleteDomainList(ctx context.Context, id int64) error { res, err := db.ExecContext(ctx, `DELETE FROM domain_lists WHERE id = ?`, id) if err != nil { return fmt.Errorf("delete domain list: %w", err) } if n, _ := res.RowsAffected(); n == 0 { return ErrNotFound } return nil } // SetDomainListEnabled toggles a list. func (db *DB) SetDomainListEnabled(ctx context.Context, id int64, enabled bool) error { res, err := db.ExecContext(ctx, `UPDATE domain_lists SET enabled = ?, updated_at = unixepoch() WHERE id = ?`, boolInt(enabled), id) if err != nil { return fmt.Errorf("update domain list: %w", err) } if n, _ := res.RowsAffected(); n == 0 { return ErrNotFound } return nil } // --- Domain entries ----------------------------------------------------- // DomainEntries pages through the domains of one list. func (db *DB) DomainEntries(ctx context.Context, listID int64, search string, limit, offset int) ([]models.DomainEntry, int, error) { where := " WHERE list_id = ?" args := []any{listID} if s := strings.TrimSpace(search); s != "" { where += " AND domain LIKE ?" args = append(args, "%"+strings.ToLower(s)+"%") } var total int if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM domain_entries`+where, args...).Scan(&total); err != nil { return nil, 0, fmt.Errorf("count domains: %w", err) } q := `SELECT id, list_id, domain, match_subdomains, enabled, comment, created_at FROM domain_entries` + where + ` ORDER BY domain` qargs := args if limit > 0 { q += " LIMIT ? OFFSET ?" qargs = append(append([]any{}, args...), limit, offset) } rows, err := db.QueryContext(ctx, q, qargs...) if err != nil { return nil, 0, fmt.Errorf("list domains: %w", err) } defer rows.Close() var out []models.DomainEntry for rows.Next() { var e models.DomainEntry var sub, enabled int var created int64 if err := rows.Scan(&e.ID, &e.ListID, &e.Domain, &sub, &enabled, &e.Comment, &created); err != nil { return nil, 0, err } e.MatchSubdomains = sub != 0 e.Enabled = enabled != 0 e.CreatedAt = time.Unix(created, 0) out = append(out, e) } return out, total, rows.Err() } // AddDomain inserts a single domain. It returns ErrConflict when the domain is // already present in that list. func (db *DB) AddDomain(ctx context.Context, e models.DomainEntry) (models.DomainEntry, error) { res, err := db.ExecContext(ctx, ` INSERT INTO domain_entries (list_id, domain, match_subdomains, enabled, comment) VALUES (?, ?, ?, ?, ?)`, e.ListID, e.Domain, boolInt(e.MatchSubdomains), boolInt(e.Enabled), e.Comment) if err != nil { if isUniqueViolation(err) { return models.DomainEntry{}, ErrConflict } return models.DomainEntry{}, fmt.Errorf("add domain: %w", err) } id, _ := res.LastInsertId() e.ID = id e.CreatedAt = time.Now() db.touchList(ctx, e.ListID) return e, nil } // UpdateDomain saves an existing domain entry. func (db *DB) UpdateDomain(ctx context.Context, e models.DomainEntry) error { res, err := db.ExecContext(ctx, ` UPDATE domain_entries SET domain = ?, match_subdomains = ?, enabled = ?, comment = ? WHERE id = ?`, e.Domain, boolInt(e.MatchSubdomains), boolInt(e.Enabled), e.Comment, e.ID) if err != nil { if isUniqueViolation(err) { return ErrConflict } return fmt.Errorf("update domain: %w", err) } if n, _ := res.RowsAffected(); n == 0 { return ErrNotFound } db.touchList(ctx, e.ListID) return nil } // DeleteDomain removes one domain entry. func (db *DB) DeleteDomain(ctx context.Context, id int64) error { var listID int64 _ = db.QueryRowContext(ctx, `SELECT list_id FROM domain_entries WHERE id = ?`, id).Scan(&listID) res, err := db.ExecContext(ctx, `DELETE FROM domain_entries WHERE id = ?`, id) if err != nil { return fmt.Errorf("delete domain: %w", err) } if n, _ := res.RowsAffected(); n == 0 { return ErrNotFound } db.touchList(ctx, listID) return nil } // ClearDomains removes every domain from a list and returns how many went. func (db *DB) ClearDomains(ctx context.Context, listID int64) (int64, error) { res, err := db.ExecContext(ctx, `DELETE FROM domain_entries WHERE list_id = ?`, listID) if err != nil { return 0, fmt.Errorf("clear domains: %w", err) } n, _ := res.RowsAffected() db.touchList(ctx, listID) return n, nil } func (db *DB) touchList(ctx context.Context, listID int64) { if listID == 0 { return } _, _ = db.ExecContext(ctx, `UPDATE domain_lists SET updated_at = unixepoch() WHERE id = ?`, listID) } // ImportDomains bulk-inserts normalised domains into a list. // // Everything happens inside one transaction with a single prepared statement, // so importing a few hundred thousand domains is one commit rather than one // commit per domain. INSERT OR IGNORE gives duplicate detection for free. func (db *DB) ImportDomains(ctx context.Context, listID int64, domains []ImportDomain) (imported, duplicates int, err error) { if len(domains) == 0 { return 0, 0, nil } err = db.InTx(ctx, func(tx *sql.Tx) error { stmt, err := tx.PrepareContext(ctx, ` INSERT OR IGNORE INTO domain_entries (list_id, domain, match_subdomains, enabled, comment) VALUES (?, ?, ?, 1, ?)`) if err != nil { return err } defer stmt.Close() for _, d := range domains { res, err := stmt.ExecContext(ctx, listID, d.Domain, boolInt(d.MatchSubdomains), d.Comment) if err != nil { return fmt.Errorf("import %q: %w", d.Domain, err) } if n, _ := res.RowsAffected(); n > 0 { imported++ } else { duplicates++ } } _, err = tx.ExecContext(ctx, `UPDATE domain_lists SET updated_at = unixepoch() WHERE id = ?`, listID) return err }) if err != nil { return 0, 0, err } return imported, duplicates, nil } // ImportDomain is one normalised domain destined for a list. type ImportDomain struct { Domain string MatchSubdomains bool Comment string } // SnapshotDomainEntry is the minimal shape the in-memory matcher needs. type SnapshotDomainEntry struct { ListID int64 Domain string MatchSubdomains bool } // SnapshotDomains streams every enabled domain of every enabled list. It is // called on configuration change, never on the DNS query path. func (db *DB) SnapshotDomains(ctx context.Context, fn func(SnapshotDomainEntry)) error { rows, err := db.QueryContext(ctx, ` SELECT e.list_id, e.domain, e.match_subdomains FROM domain_entries e JOIN domain_lists l ON l.id = e.list_id WHERE e.enabled = 1 AND l.enabled = 1`) if err != nil { return fmt.Errorf("snapshot domains: %w", err) } defer rows.Close() for rows.Next() { var e SnapshotDomainEntry var sub int if err := rows.Scan(&e.ListID, &e.Domain, &sub); err != nil { return err } e.MatchSubdomains = sub != 0 fn(e) } return rows.Err() } // CountDomainLists returns blacklist and total-domain counts for the dashboard. func (db *DB) CountDomainLists(ctx context.Context) (blacklists, blacklistDomains, allowlists, allowlistDomains int, err error) { err = db.QueryRowContext(ctx, ` SELECT (SELECT COUNT(*) FROM domain_lists WHERE kind = 'blacklist'), (SELECT COUNT(*) FROM domain_entries e JOIN domain_lists l ON l.id = e.list_id WHERE l.kind = 'blacklist'), (SELECT COUNT(*) FROM domain_lists WHERE kind = 'allowlist'), (SELECT COUNT(*) FROM domain_entries e JOIN domain_lists l ON l.id = e.list_id WHERE l.kind = 'allowlist')`). Scan(&blacklists, &blacklistDomains, &allowlists, &allowlistDomains) if err != nil { return 0, 0, 0, 0, fmt.Errorf("count domain lists: %w", err) } return } // ExportDomains streams every domain of a list in sorted order. func (db *DB) ExportDomains(ctx context.Context, listID int64, fn func(domain string, matchSubdomains bool)) error { rows, err := db.QueryContext(ctx, `SELECT domain, match_subdomains FROM domain_entries WHERE list_id = ? ORDER BY domain`, listID) if err != nil { return fmt.Errorf("export domains: %w", err) } defer rows.Close() for rows.Next() { var d string var sub int if err := rows.Scan(&d, &sub); err != nil { return err } fn(d, sub != 0) } return rows.Err() } // SnapshotNetworks loads enabled networks with the IDs of their enabled // policies, for building the CIDR index. func (db *DB) SnapshotNetworks(ctx context.Context) ([]models.Network, map[int64][]int64, error) { rows, err := db.QueryContext(ctx, ` SELECT id, name, cidr, description, enabled, created_at, updated_at FROM networks WHERE enabled = 1`) if err != nil { return nil, nil, fmt.Errorf("snapshot networks: %w", err) } defer rows.Close() var nets []models.Network for rows.Next() { var n models.Network var enabled int var created, updated int64 if err := rows.Scan(&n.ID, &n.Name, &n.CIDR, &n.Description, &enabled, &created, &updated); err != nil { return nil, nil, err } n.Enabled = enabled != 0 n.CreatedAt = time.Unix(created, 0) n.UpdatedAt = time.Unix(updated, 0) nets = append(nets, n) } if err := rows.Err(); err != nil { return nil, nil, err } arows, err := db.QueryContext(ctx, ` SELECT np.network_id, np.policy_id FROM network_policies np JOIN policies p ON p.id = np.policy_id WHERE p.enabled = 1`) if err != nil { return nil, nil, fmt.Errorf("snapshot policy assignments: %w", err) } defer arows.Close() assign := map[int64][]int64{} for arows.Next() { var nid, pid int64 if err := arows.Scan(&nid, &pid); err != nil { return nil, nil, err } assign[nid] = append(assign[nid], pid) } return nets, assign, arows.Err() }