initial commit
This commit is contained in:
@@ -0,0 +1,891 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user