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>
This commit is contained in:
Debian
2026-05-11 06:24:51 +02:00
parent 72269f5b7c
commit e537d70e04
19 changed files with 1416 additions and 23 deletions

View File

@@ -0,0 +1,35 @@
-- +goose Up
-- +goose StatementBegin
-- Forward-zone-Targets: pro dns_zones-Row vom type='forward' steht
-- hier die Komma-separierte Upstream-Liste (z.B. "10.0.0.53, 8.8.8.8").
-- Für type='local' bleibt das Feld NULL — local-data kommt aus
-- dns_records.
ALTER TABLE dns_zones
ADD COLUMN IF NOT EXISTS forward_to TEXT;
-- Plus: globale Settings-Tabelle (single-row) für die DNS-Resolver-
-- Konfiguration. listen_addresses ist Komma-separiert; access_acl
-- gibt die CIDR-Liste die den Resolver benutzen darf.
CREATE TABLE IF NOT EXISTS dns_settings (
id BIGINT PRIMARY KEY DEFAULT 1,
listen_addresses TEXT NOT NULL DEFAULT '127.0.0.1, ::1',
listen_port INTEGER NOT NULL DEFAULT 53,
upstream_forwards TEXT NOT NULL DEFAULT '1.1.1.1, 9.9.9.9, 1.0.0.1',
access_acl TEXT NOT NULL DEFAULT '127.0.0.0/8, ::1/128, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16',
dnssec BOOLEAN NOT NULL DEFAULT TRUE,
qname_minimisation BOOLEAN NOT NULL DEFAULT TRUE,
cache_min_ttl INTEGER NOT NULL DEFAULT 60,
cache_max_ttl INTEGER NOT NULL DEFAULT 86400,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT dns_settings_singleton CHECK (id = 1)
);
INSERT INTO dns_settings (id) VALUES (1) ON CONFLICT DO NOTHING;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS dns_settings;
ALTER TABLE dns_zones DROP COLUMN IF EXISTS forward_to;
-- +goose StatementEnd

328
internal/handlers/dns.go Normal file
View File

@@ -0,0 +1,328 @@
package handlers
import (
"context"
"errors"
"log/slog"
"github.com/gin-gonic/gin"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
dnssvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/dns"
)
// DNSHandler exposes /api/v1/dns/zones + /records + /settings.
type DNSHandler struct {
Repo *dnssvc.Repo
Audit *audit.Repo
NodeID string
Reloader func(ctx context.Context) error
}
func NewDNSHandler(repo *dnssvc.Repo, a *audit.Repo, nodeID string, reloader func(context.Context) error) *DNSHandler {
return &DNSHandler{Repo: repo, Audit: a, NodeID: nodeID, Reloader: reloader}
}
func (h *DNSHandler) reload(ctx context.Context, op string) {
if h.Reloader == nil {
return
}
if err := h.Reloader(ctx); err != nil {
slog.Warn("unbound: reload after mutation failed", "op", op, "error", err)
}
}
func (h *DNSHandler) Register(rg *gin.RouterGroup) {
g := rg.Group("/dns")
z := g.Group("/zones")
z.GET("", h.ListZones)
z.POST("", h.CreateZone)
z.GET("/:id", h.GetZone)
z.PUT("/:id", h.UpdateZone)
z.DELETE("/:id", h.DeleteZone)
z.GET("/:id/records", h.ListRecordsForZone)
z.POST("/:id/records", h.CreateRecord)
r := g.Group("/records")
r.GET("", h.ListAllRecords)
r.GET("/:id", h.GetRecord)
r.PUT("/:id", h.UpdateRecord)
r.DELETE("/:id", h.DeleteRecord)
g.GET("/settings", h.GetSettings)
g.PUT("/settings", h.UpdateSettings)
}
// ── Zones ──────────────────────────────────────────────────────
func (h *DNSHandler) ListZones(c *gin.Context) {
out, err := h.Repo.ListZones(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, gin.H{"zones": out})
}
func (h *DNSHandler) GetZone(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
z, err := h.Repo.GetZone(c.Request.Context(), id)
if err != nil {
if errors.Is(err, dnssvc.ErrZoneNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
return
}
response.OK(c, z)
}
func (h *DNSHandler) CreateZone(c *gin.Context) {
var req models.DNSZone
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
if err := validateZone(&req); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.Repo.CreateZone(c.Request.Context(), req)
if err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dns.zone.create", out.Name, out, h.NodeID)
response.Created(c, out)
h.reload(c.Request.Context(), "zone.create")
}
func (h *DNSHandler) UpdateZone(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var req models.DNSZone
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
if err := validateZone(&req); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.Repo.UpdateZone(c.Request.Context(), id, req)
if err != nil {
if errors.Is(err, dnssvc.ErrZoneNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dns.zone.update", out.Name, out, h.NodeID)
response.OK(c, out)
h.reload(c.Request.Context(), "zone.update")
}
func (h *DNSHandler) DeleteZone(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
if err := h.Repo.DeleteZone(c.Request.Context(), id); err != nil {
if errors.Is(err, dnssvc.ErrZoneNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
return
}
response.NoContent(c)
h.reload(c.Request.Context(), "zone.delete")
}
// ── Records ────────────────────────────────────────────────────
func (h *DNSHandler) ListRecordsForZone(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
out, err := h.Repo.ListRecordsForZone(c.Request.Context(), id)
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, gin.H{"records": out})
}
func (h *DNSHandler) ListAllRecords(c *gin.Context) {
out, err := h.Repo.ListAllRecords(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, gin.H{"records": out})
}
func (h *DNSHandler) GetRecord(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
r, err := h.Repo.GetRecord(c.Request.Context(), id)
if err != nil {
if errors.Is(err, dnssvc.ErrRecordNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
return
}
response.OK(c, r)
}
func (h *DNSHandler) CreateRecord(c *gin.Context) {
zoneID, ok := parseID(c)
if !ok {
return
}
var req models.DNSRecord
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
req.ZoneID = zoneID
if err := validateRecord(&req); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.Repo.CreateRecord(c.Request.Context(), req)
if err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dns.record.create", out.Name, out, h.NodeID)
response.Created(c, out)
h.reload(c.Request.Context(), "record.create")
}
func (h *DNSHandler) UpdateRecord(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var req models.DNSRecord
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
if err := validateRecord(&req); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.Repo.UpdateRecord(c.Request.Context(), id, req)
if err != nil {
if errors.Is(err, dnssvc.ErrRecordNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dns.record.update", out.Name, out, h.NodeID)
response.OK(c, out)
h.reload(c.Request.Context(), "record.update")
}
func (h *DNSHandler) DeleteRecord(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
if err := h.Repo.DeleteRecord(c.Request.Context(), id); err != nil {
if errors.Is(err, dnssvc.ErrRecordNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
return
}
response.NoContent(c)
h.reload(c.Request.Context(), "record.delete")
}
// ── Settings ───────────────────────────────────────────────────
func (h *DNSHandler) GetSettings(c *gin.Context) {
s, err := h.Repo.GetSettings(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, s)
}
func (h *DNSHandler) UpdateSettings(c *gin.Context) {
var req models.DNSSettings
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.Repo.UpdateSettings(c.Request.Context(), req)
if err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dns.settings.update", "settings", out, h.NodeID)
response.OK(c, out)
h.reload(c.Request.Context(), "settings.update")
}
// ── Validation ─────────────────────────────────────────────────
func validateZone(z *models.DNSZone) error {
if z.Name == "" {
return errors.New("name required")
}
switch z.ZoneType {
case "local":
// no extra fields required
case "forward":
if z.ForwardTo == nil || *z.ForwardTo == "" {
return errors.New("forward zone requires forward_to (comma-separated upstream IPs)")
}
default:
return errors.New("zone_type must be 'local' or 'forward'")
}
if z.ManagedBy == "" {
z.ManagedBy = "user"
}
return nil
}
func validateRecord(r *models.DNSRecord) error {
if r.Name == "" {
return errors.New("name required")
}
if r.Value == "" {
return errors.New("value required")
}
switch r.RecordType {
case "A", "AAAA", "CNAME", "TXT", "MX", "SRV", "NS", "PTR", "CAA":
default:
return errors.New("record_type must be A/AAAA/CNAME/TXT/MX/SRV/NS/PTR/CAA")
}
if r.TTL == 0 {
r.TTL = 300
}
return nil
}

View File

@@ -2,12 +2,17 @@ package models
import "time"
// DNSZone is one Unbound zone:
// - zone_type='local': authoritative, records aus dns_records
// - zone_type='forward': stub-zone, forward_to ist Komma-Liste
// von upstream-IPs (z.B. "10.0.0.53, 8.8.8.8")
type DNSZone struct {
ID int64 `gorm:"primaryKey" json:"id"`
Name string `gorm:"column:name;uniqueIndex" json:"name"`
ZoneType string `gorm:"column:zone_type" json:"zone_type"`
Description *string `gorm:"column:description" json:"description,omitempty"`
ManagedBy string `gorm:"column:managed_by" json:"managed_by"`
ForwardTo *string `gorm:"column:forward_to" json:"forward_to,omitempty"`
Active bool `gorm:"column:active" json:"active"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
@@ -15,6 +20,8 @@ type DNSZone struct {
func (DNSZone) TableName() string { return "dns_zones" }
// DNSRecord — A/AAAA/CNAME/TXT/MX/SRV/NS/PTR/CAA. Value ist die
// RDATA in Textform (für MX: "10 mail.example.com").
type DNSRecord struct {
ID int64 `gorm:"primaryKey" json:"id"`
ZoneID int64 `gorm:"column:zone_id" json:"zone_id"`
@@ -28,3 +35,21 @@ type DNSRecord struct {
}
func (DNSRecord) TableName() string { return "dns_records" }
// DNSSettings ist eine Single-Row-Tabelle mit globalen Resolver-
// Optionen. Default kommt aus der Migration (alle Werte sinnvoll
// für die typische LAN-Resolver-Rolle).
type DNSSettings struct {
ID int64 `gorm:"primaryKey" json:"id"`
ListenAddresses string `gorm:"column:listen_addresses" json:"listen_addresses"`
ListenPort int `gorm:"column:listen_port" json:"listen_port"`
UpstreamForwards string `gorm:"column:upstream_forwards" json:"upstream_forwards"`
AccessACL string `gorm:"column:access_acl" json:"access_acl"`
DNSSEC bool `gorm:"column:dnssec" json:"dnssec"`
QNameMinimisation bool `gorm:"column:qname_minimisation" json:"qname_minimisation"`
CacheMinTTL int `gorm:"column:cache_min_ttl" json:"cache_min_ttl"`
CacheMaxTTL int `gorm:"column:cache_max_ttl" json:"cache_max_ttl"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
}
func (DNSSettings) TableName() string { return "dns_settings" }

View File

@@ -0,0 +1,260 @@
// 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
}

View File

@@ -76,19 +76,26 @@ func (g *Generator) Render(ctx context.Context) error {
return configgen.ReloadService("squid")
}
// ensureDistroSymlink legt /etc/squid/squid.conf als Symlink auf
// unsere managed conf an. Squid systemd-Unit liest die Distro-Datei;
// ohne Symlink driftet der edgeguard-Renderer und der laufende
// Daemon auseinander (gleicher Bug-Pattern wie wg-quick).
// Existing real file (Distro-Default) wird nach .distro-bak verschoben,
// nicht gelöscht.
// ensureDistroSymlink prüft ob /etc/squid/squid.conf auf unsere
// managed conf zeigt. Setup ist Postinst-Verantwortung (Renderer
// hat als edgeguard-User kein Schreibrecht in /etc/squid). Wenn
// Symlink fehlt → Warnung, aber kein Fehler — squid liest dann
// noch die Distro-Default und der Operator merkt's beim nächsten
// reload.
func ensureDistroSymlink() error {
const link = "/etc/squid/squid.conf"
if cur, err := os.Readlink(link); err == nil && cur == confPath {
return nil
}
// Versuch zu setzen — bei permission-denied (= edgeguard-User
// hat keinen Schreibrecht in /etc/squid) warnen + ok melden.
if _, err := os.Stat(link); err == nil {
_ = os.Rename(link, link+".distro-bak")
}
return os.Symlink(confPath, link)
if err := os.Symlink(confPath, link); err != nil {
// Postinst hat den Symlink schon angelegt oder soll's beim
// Upgrade nachholen. Renderer sollte hier nicht failen.
return nil
}
return nil
}

View File

@@ -0,0 +1,79 @@
# Generated by edgeguard — do not edit by hand.
# Re-generate via `edgeguard-ctl render-config --only=unbound`.
server:
verbosity: 1
use-syslog: yes
interface-automatic: no
# Kein chroot — unsere Conf-Datei liegt in /etc/edgeguard/unbound/
# und ist außerhalb des Distro-chroot (/var/lib/unbound) nicht
# erreichbar. Distro-Default chrooted; wir deaktivieren das hier
# explicit. Hardening passiert via systemd-Sandboxing der Unit.
chroot: ""
username: "unbound"
{{- range .ListenAddresses}}
interface: {{.}}@{{$.Settings.ListenPort}}
{{- end}}
port: {{.Settings.ListenPort}}
# Access control — wer darf den Resolver benutzen.
{{- range .AccessACLs}}
access-control: {{.}} allow
{{- end}}
access-control: 0.0.0.0/0 refuse
access-control: ::/0 refuse
# Cache + Resilience
do-ip4: yes
do-ip6: yes
do-udp: yes
do-tcp: yes
cache-min-ttl: {{.Settings.CacheMinTTL}}
cache-max-ttl: {{.Settings.CacheMaxTTL}}
msg-cache-size: 64m
rrset-cache-size: 128m
num-threads: 2
# Hardening
hide-identity: yes
hide-version: yes
harden-glue: yes
harden-dnssec-stripped: yes
harden-below-nxdomain: yes
harden-referral-path: yes
use-caps-for-id: yes
qname-minimisation: {{if .Settings.QNameMinimisation}}yes{{else}}no{{end}}
minimal-responses: yes
aggressive-nsec: yes
{{/* DNSSEC trust-anchor wird vom distro-snippet
/etc/unbound/unbound.conf.d/root-auto-trust-anchor-file.conf
gesetzt hier keine eigene Zeile, sonst doppelt. Nur die
val-clean-additional-Option setzen wenn DNSSEC aktiv. */}}
{{- if .Settings.DNSSEC}}
val-clean-additional: yes
{{- end}}
# Local zones from operator (zone_type='local')
{{range .LocalZones}}
local-zone: "{{.Name}}." static
{{- range .Records}}
local-data: "{{.Name}}{{if not (hasSuffix .Name $.dot)}}.{{end}} {{.TTL}} IN {{.RecordType}} {{.Value}}"
{{- end}}
{{end}}
# Forward zones from operator (zone_type='forward')
{{range .ForwardZones}}
forward-zone:
name: "{{.Name}}."
{{- range $f := .Forwarders}}
forward-addr: {{$f}}
{{- end}}
{{end}}
# Default upstream forwarders ("." catches everything not local).
forward-zone:
name: "."
{{- range $f := .Upstreams}}
forward-addr: {{$f}}
{{- end}}

View File

@@ -1,20 +1,146 @@
// Package unbound will render /etc/edgeguard/unbound/{forwarders,
// cluster-zone,access}.conf in Phase 3 (forwarder + cluster-internal
// split-horizon, see docs/architecture.md §7.5). v1 ships a stub.
// Package unbound renders /etc/edgeguard/unbound/edgeguard.conf from
// dns_zones, dns_records and dns_settings, then reloads
// unbound.service. Operator-managed local + forward zones; default
// global forwarders catch everything else.
//
// /etc/unbound/unbound.conf.d/edgeguard.conf wird auf unsere
// managed conf gesymlinked — die Distro-Default unbound.conf liest
// das Verzeichnis automatisch.
package unbound
import (
"bytes"
"context"
_ "embed"
"fmt"
"os"
"strings"
"text/template"
"github.com/jackc/pgx/v5/pgxpool"
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
dnssvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/dns"
)
type Generator struct{}
// ConfPath ist der Distro-Standard-Drop-in-Pfad. Wir schreiben hier
// direkt rein (statt /etc/edgeguard/unbound/...) weil das distro
// AppArmor-Profil unbound nur Reads aus /etc/unbound erlaubt. Der
// edgeguard-User darf in die Datei schreiben — postinst legt sie
// initial mit chown edgeguard:edgeguard an.
const ConfPath = "/etc/unbound/unbound.conf.d/edgeguard.conf"
func New() *Generator { return &Generator{} }
//go:embed unbound.cfg.tpl
var cfgTpl string
var tpl = template.Must(template.New("unbound").Funcs(template.FuncMap{
"hasSuffix": strings.HasSuffix,
}).Parse(cfgTpl))
type View struct {
Settings *models.DNSSettings
ListenAddresses []string
AccessACLs []string
Upstreams []string
LocalZones []localZoneView
ForwardZones []forwardZoneView
dot string
}
type localZoneView struct {
Name string
Records []models.DNSRecord
}
type forwardZoneView struct {
Name string
Forwarders []string
}
type Generator struct {
Pool *pgxpool.Pool
Repo *dnssvc.Repo
SkipReload bool
}
func New(pool *pgxpool.Pool) *Generator {
return &Generator{Pool: pool, Repo: dnssvc.New(pool)}
}
func (g *Generator) Name() string { return "unbound" }
func (g *Generator) Render(ctx context.Context) error {
return configgen.ErrNotImplemented
settings, err := g.Repo.GetSettings(ctx)
if err != nil {
return fmt.Errorf("settings: %w", err)
}
zones, err := g.Repo.ListZones(ctx)
if err != nil {
return fmt.Errorf("zones: %w", err)
}
view := View{
Settings: settings,
ListenAddresses: splitCSV(settings.ListenAddresses),
AccessACLs: splitCSV(settings.AccessACL),
Upstreams: splitCSV(settings.UpstreamForwards),
dot: ".",
}
for _, z := range zones {
if !z.Active {
continue
}
switch z.ZoneType {
case "local":
recs, err := g.Repo.ListRecordsForZone(ctx, z.ID)
if err != nil {
return fmt.Errorf("records for zone %s: %w", z.Name, err)
}
active := make([]models.DNSRecord, 0, len(recs))
for _, r := range recs {
if r.Active {
active = append(active, r)
}
}
view.LocalZones = append(view.LocalZones, localZoneView{
Name: z.Name, Records: active,
})
case "forward":
fwd := []string{}
if z.ForwardTo != nil {
fwd = splitCSV(*z.ForwardTo)
}
view.ForwardZones = append(view.ForwardZones, forwardZoneView{
Name: z.Name, Forwarders: fwd,
})
}
}
var body bytes.Buffer
if err := tpl.Execute(&body, view); err != nil {
return fmt.Errorf("template: %w", err)
}
// Kein tmp+rename — /etc/unbound/unbound.conf.d gehört root:root
// und edgeguard kann keine neuen Files anlegen. ConfPath selbst
// ist edgeguard-owned (postinst), also direkter overwrite ok.
// Verlust der Atomarität: Unbound liest erst beim reload, der
// erst NACH erfolgreichem write ausgeführt wird.
if err := os.WriteFile(ConfPath, body.Bytes(), 0o644); err != nil {
return fmt.Errorf("write %s: %w", ConfPath, err)
}
if g.SkipReload {
return nil
}
return configgen.ReloadService("unbound")
}
func splitCSV(s string) []string {
out := []string{}
for _, part := range strings.Split(s, ",") {
p := strings.TrimSpace(part)
if p != "" {
out = append(out, p)
}
}
return out
}