Files
2026-08-16 21:18:45 -05:00

892 lines
26 KiB
Go

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