Files
Debian e537d70e04 feat: Unbound DNS-Resolver — vollständig (Renderer + Handler + UI)
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>
2026-05-11 06:24:51 +02:00

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
}