Stub raus, vollständig implementiert:
* Migration 0014: dns_settings (single-row) + dns_zones.forward_to.
Default-Settings sind sinnvoll für die typische LAN-Resolver-Rolle
(1.1.1.1 + 9.9.9.9 upstream, localnet allow, DNSSEC + qname-min on).
* internal/services/dns: CRUD-Repo für zones, records, settings.
* internal/handlers/dns.go: REST /api/v1/dns/zones, /records, /settings
mit Auto-Reload nach jeder Mutation.
* internal/unbound/unbound.cfg.tpl + unbound.go: Renderer schreibt
/etc/unbound/unbound.conf.d/edgeguard.conf direkt (kein Symlink-
Dance, weil AppArmor unbound nur /etc/unbound erlaubt). Local-zones
authoritativ aus dns_records; forward-zones per stub-zone; default-
forwarders catchen alles sonst.
* main.go: dnsRepo + unbound-Reloader injiziert.
* render.go: unbound.New() bekommt Pool.
* postinst:
- Conf-Datei /etc/unbound/unbound.conf.d/edgeguard.conf wird als
edgeguard:edgeguard 0644 angelegt damit Renderer schreiben kann.
- /etc/edgeguard + Service-Subdirs auf 0755 (Squid + Unbound laufen
NICHT als edgeguard, brauchen Read-Traversal).
- Sudoers: systemctl reload unbound.service whitelisted.
* Template: chroot:"" (Conf liegt außerhalb /var/lib/unbound default-
chroot), DNSSEC-Trust-Anchor NICHT setzen (Distro hat schon
root-auto-trust-anchor-file.conf — sonst doppelter Anchor → start
failure).
* Frontend /dns: PageHeader + zwei Tabs (Zones + Resolver-Settings).
Zones-Tab mit Drawer für Records (CRUD pro Zone, A/AAAA/CNAME/TXT/
MX/SRV/NS/PTR/CAA). Sidebar-Eintrag unter Network.
* i18n DE/EN für dns.* Block.
Verified end-to-end: render → unbound restart → dig @127.0.0.1
example.com → 104.20.23.154 / 172.66.147.243.
Version 1.0.34 (mehrere Iterationen wegen AppArmor + chroot + perms).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
261 lines
7.8 KiB
Go
261 lines
7.8 KiB
Go
// Package dns provides CRUD against dns_zones, dns_records and the
|
|
// single-row dns_settings table. Renderer in internal/unbound consumes
|
|
// the same data to emit /etc/edgeguard/unbound/edgeguard.conf.
|
|
package dns
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
|
)
|
|
|
|
var (
|
|
ErrZoneNotFound = errors.New("dns zone not found")
|
|
ErrRecordNotFound = errors.New("dns record not found")
|
|
)
|
|
|
|
type Repo struct {
|
|
Pool *pgxpool.Pool
|
|
}
|
|
|
|
func New(pool *pgxpool.Pool) *Repo { return &Repo{Pool: pool} }
|
|
|
|
// ── Zones ──────────────────────────────────────────────────────
|
|
|
|
const zoneSelect = `
|
|
SELECT id, name, zone_type, description, managed_by, forward_to, active,
|
|
created_at, updated_at
|
|
FROM dns_zones
|
|
`
|
|
|
|
func (r *Repo) ListZones(ctx context.Context) ([]models.DNSZone, error) {
|
|
rows, err := r.Pool.Query(ctx, zoneSelect+" ORDER BY name")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := make([]models.DNSZone, 0, 8)
|
|
for rows.Next() {
|
|
z, err := scanZone(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, *z)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (r *Repo) GetZone(ctx context.Context, id int64) (*models.DNSZone, error) {
|
|
row := r.Pool.QueryRow(ctx, zoneSelect+" WHERE id = $1", id)
|
|
z, err := scanZone(row)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrZoneNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
return z, nil
|
|
}
|
|
|
|
func (r *Repo) CreateZone(ctx context.Context, z models.DNSZone) (*models.DNSZone, error) {
|
|
if z.ManagedBy == "" {
|
|
z.ManagedBy = "user"
|
|
}
|
|
row := r.Pool.QueryRow(ctx, `
|
|
INSERT INTO dns_zones (name, zone_type, description, managed_by, forward_to, active)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
RETURNING id, name, zone_type, description, managed_by, forward_to, active,
|
|
created_at, updated_at`,
|
|
z.Name, z.ZoneType, z.Description, z.ManagedBy, z.ForwardTo, z.Active)
|
|
return scanZone(row)
|
|
}
|
|
|
|
func (r *Repo) UpdateZone(ctx context.Context, id int64, z models.DNSZone) (*models.DNSZone, error) {
|
|
row := r.Pool.QueryRow(ctx, `
|
|
UPDATE dns_zones SET name=$1, zone_type=$2, description=$3, forward_to=$4, active=$5,
|
|
updated_at=NOW()
|
|
WHERE id=$6
|
|
RETURNING id, name, zone_type, description, managed_by, forward_to, active,
|
|
created_at, updated_at`,
|
|
z.Name, z.ZoneType, z.Description, z.ForwardTo, z.Active, id)
|
|
out, err := scanZone(row)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrZoneNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (r *Repo) DeleteZone(ctx context.Context, id int64) error {
|
|
tag, err := r.Pool.Exec(ctx, `DELETE FROM dns_zones WHERE id=$1`, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrZoneNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ── Records ────────────────────────────────────────────────────
|
|
|
|
const recordSelect = `
|
|
SELECT id, zone_id, name, record_type, value, ttl, active,
|
|
created_at, updated_at
|
|
FROM dns_records
|
|
`
|
|
|
|
func (r *Repo) ListRecordsForZone(ctx context.Context, zoneID int64) ([]models.DNSRecord, error) {
|
|
rows, err := r.Pool.Query(ctx, recordSelect+" WHERE zone_id=$1 ORDER BY name, record_type", zoneID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := make([]models.DNSRecord, 0, 8)
|
|
for rows.Next() {
|
|
rec, err := scanRecord(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, *rec)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (r *Repo) ListAllRecords(ctx context.Context) ([]models.DNSRecord, error) {
|
|
rows, err := r.Pool.Query(ctx, recordSelect+" ORDER BY zone_id, name")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := make([]models.DNSRecord, 0, 16)
|
|
for rows.Next() {
|
|
rec, err := scanRecord(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, *rec)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (r *Repo) GetRecord(ctx context.Context, id int64) (*models.DNSRecord, error) {
|
|
row := r.Pool.QueryRow(ctx, recordSelect+" WHERE id=$1", id)
|
|
rec, err := scanRecord(row)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrRecordNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
return rec, nil
|
|
}
|
|
|
|
func (r *Repo) CreateRecord(ctx context.Context, rec models.DNSRecord) (*models.DNSRecord, error) {
|
|
if rec.TTL == 0 {
|
|
rec.TTL = 300
|
|
}
|
|
row := r.Pool.QueryRow(ctx, `
|
|
INSERT INTO dns_records (zone_id, name, record_type, value, ttl, active)
|
|
VALUES ($1,$2,$3,$4,$5,$6)
|
|
RETURNING id, zone_id, name, record_type, value, ttl, active,
|
|
created_at, updated_at`,
|
|
rec.ZoneID, rec.Name, rec.RecordType, rec.Value, rec.TTL, rec.Active)
|
|
return scanRecord(row)
|
|
}
|
|
|
|
func (r *Repo) UpdateRecord(ctx context.Context, id int64, rec models.DNSRecord) (*models.DNSRecord, error) {
|
|
row := r.Pool.QueryRow(ctx, `
|
|
UPDATE dns_records SET name=$1, record_type=$2, value=$3, ttl=$4, active=$5,
|
|
updated_at=NOW()
|
|
WHERE id=$6
|
|
RETURNING id, zone_id, name, record_type, value, ttl, active,
|
|
created_at, updated_at`,
|
|
rec.Name, rec.RecordType, rec.Value, rec.TTL, rec.Active, id)
|
|
out, err := scanRecord(row)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrRecordNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (r *Repo) DeleteRecord(ctx context.Context, id int64) error {
|
|
tag, err := r.Pool.Exec(ctx, `DELETE FROM dns_records WHERE id=$1`, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrRecordNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ── Settings (single row, id=1) ────────────────────────────────
|
|
|
|
func (r *Repo) GetSettings(ctx context.Context) (*models.DNSSettings, error) {
|
|
row := r.Pool.QueryRow(ctx, `
|
|
SELECT id, listen_addresses, listen_port, upstream_forwards, access_acl,
|
|
dnssec, qname_minimisation, cache_min_ttl, cache_max_ttl, updated_at
|
|
FROM dns_settings WHERE id=1`)
|
|
var s models.DNSSettings
|
|
if err := row.Scan(&s.ID, &s.ListenAddresses, &s.ListenPort, &s.UpstreamForwards,
|
|
&s.AccessACL, &s.DNSSEC, &s.QNameMinimisation,
|
|
&s.CacheMinTTL, &s.CacheMaxTTL, &s.UpdatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
return &s, nil
|
|
}
|
|
|
|
func (r *Repo) UpdateSettings(ctx context.Context, s models.DNSSettings) (*models.DNSSettings, error) {
|
|
row := r.Pool.QueryRow(ctx, `
|
|
UPDATE dns_settings SET
|
|
listen_addresses=$1, listen_port=$2, upstream_forwards=$3, access_acl=$4,
|
|
dnssec=$5, qname_minimisation=$6, cache_min_ttl=$7, cache_max_ttl=$8,
|
|
updated_at=NOW()
|
|
WHERE id=1
|
|
RETURNING id, listen_addresses, listen_port, upstream_forwards, access_acl,
|
|
dnssec, qname_minimisation, cache_min_ttl, cache_max_ttl, updated_at`,
|
|
s.ListenAddresses, s.ListenPort, s.UpstreamForwards, s.AccessACL,
|
|
s.DNSSEC, s.QNameMinimisation, s.CacheMinTTL, s.CacheMaxTTL)
|
|
var out models.DNSSettings
|
|
if err := row.Scan(&out.ID, &out.ListenAddresses, &out.ListenPort, &out.UpstreamForwards,
|
|
&out.AccessACL, &out.DNSSEC, &out.QNameMinimisation,
|
|
&out.CacheMinTTL, &out.CacheMaxTTL, &out.UpdatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// ── scan helpers ───────────────────────────────────────────────
|
|
|
|
func scanZone(row interface{ Scan(...any) error }) (*models.DNSZone, error) {
|
|
var z models.DNSZone
|
|
if err := row.Scan(
|
|
&z.ID, &z.Name, &z.ZoneType, &z.Description, &z.ManagedBy,
|
|
&z.ForwardTo, &z.Active, &z.CreatedAt, &z.UpdatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
return &z, nil
|
|
}
|
|
|
|
func scanRecord(row interface{ Scan(...any) error }) (*models.DNSRecord, error) {
|
|
var rec models.DNSRecord
|
|
if err := row.Scan(
|
|
&rec.ID, &rec.ZoneID, &rec.Name, &rec.RecordType, &rec.Value, &rec.TTL,
|
|
&rec.Active, &rec.CreatedAt, &rec.UpdatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
return &rec, nil
|
|
}
|