892 lines
26 KiB
Go
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()
|
|
}
|