From e537d70e043bbe8a47de1aac5df179bfcb32ad6b Mon Sep 17 00:00:00 2001 From: Debian Date: Mon, 11 May 2026 06:24:51 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Unbound=20DNS-Resolver=20=E2=80=94=20vo?= =?UTF-8?q?llst=C3=A4ndig=20(Renderer=20+=20Handler=20+=20UI)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- VERSION | 2 +- cmd/edgeguard-api/main.go | 12 +- cmd/edgeguard-ctl/main.go | 2 +- cmd/edgeguard-ctl/render.go | 2 +- cmd/edgeguard-scheduler/main.go | 2 +- .../migrations/0014_dns_forward_targets.sql | 35 ++ internal/handlers/dns.go | 328 +++++++++++++++ internal/models/dns.go | 25 ++ internal/services/dns/dns.go | 260 ++++++++++++ internal/squid/squid.go | 21 +- internal/unbound/unbound.cfg.tpl | 79 ++++ internal/unbound/unbound.go | 138 ++++++- management-ui/package.json | 2 +- management-ui/src/App.tsx | 2 + .../src/components/Layout/Sidebar.tsx | 3 +- management-ui/src/i18n/locales/de/common.json | 46 +++ management-ui/src/i18n/locales/en/common.json | 46 +++ management-ui/src/pages/DNS/index.tsx | 390 ++++++++++++++++++ .../debian/edgeguard-api/DEBIAN/postinst | 44 +- 19 files changed, 1416 insertions(+), 23 deletions(-) create mode 100644 internal/database/migrations/0014_dns_forward_targets.sql create mode 100644 internal/handlers/dns.go create mode 100644 internal/services/dns/dns.go create mode 100644 internal/unbound/unbound.cfg.tpl create mode 100644 management-ui/src/pages/DNS/index.tsx diff --git a/VERSION b/VERSION index 8955a01..ffcbe71 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.26 +1.0.34 diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index 658749d..9fd0d47 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -23,11 +23,13 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/haproxy" "git.netcell-it.de/projekte/edgeguard-native/internal/handlers" squidrender "git.netcell-it.de/projekte/edgeguard-native/internal/squid" + unboundrender "git.netcell-it.de/projekte/edgeguard-native/internal/unbound" wgrender "git.netcell-it.de/projekte/edgeguard-native/internal/wireguard" "git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response" "git.netcell-it.de/projekte/edgeguard-native/internal/services/acme" "git.netcell-it.de/projekte/edgeguard-native/internal/services/audit" "git.netcell-it.de/projekte/edgeguard-native/internal/services/backends" + dnssvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/dns" "git.netcell-it.de/projekte/edgeguard-native/internal/services/domains" "git.netcell-it.de/projekte/edgeguard-native/internal/services/firewall" "git.netcell-it.de/projekte/edgeguard-native/internal/services/forwardproxy" @@ -41,7 +43,7 @@ import ( wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard" ) -var version = "1.0.26" +var version = "1.0.34" func main() { addr := os.Getenv("EDGEGUARD_API_ADDR") @@ -144,6 +146,7 @@ func main() { wgIfaces := wgsvc.NewInterfacesRepo(pool) wgPeers := wgsvc.NewPeersRepo(pool) fwdProxyRepo := forwardproxy.New(pool) + dnsRepo := dnssvc.New(pool) // ACME (Let's Encrypt). Email comes from setup.json — the // wizard collects acme_email and the issuer registers an @@ -192,6 +195,13 @@ func main() { return squidrender.New(pool).Render(ctx) } handlers.NewForwardProxyHandler(fwdProxyRepo, auditRepo, nodeID, squidReloader).Register(authed) + + // Unbound DNS reload — re-render edgeguard.conf + reload + // unbound.service. + unboundReloader := func(ctx context.Context) error { + return unboundrender.New(pool).Render(ctx) + } + handlers.NewDNSHandler(dnsRepo, auditRepo, nodeID, unboundReloader).Register(authed) } mountUI(r) diff --git a/cmd/edgeguard-ctl/main.go b/cmd/edgeguard-ctl/main.go index 3a2412a..e89b2f6 100644 --- a/cmd/edgeguard-ctl/main.go +++ b/cmd/edgeguard-ctl/main.go @@ -9,7 +9,7 @@ import ( "os" ) -var version = "1.0.26" +var version = "1.0.34" const usage = `edgeguard-ctl — EdgeGuard CLI diff --git a/cmd/edgeguard-ctl/render.go b/cmd/edgeguard-ctl/render.go index 7d1d34d..ef5f7bb 100644 --- a/cmd/edgeguard-ctl/render.go +++ b/cmd/edgeguard-ctl/render.go @@ -56,7 +56,7 @@ func cmdRenderConfig(args []string) int { fw := firewall.New(pool) sq := squid.New(pool) wg := wireguard.New(pool, secrets.New("")) - ub := unbound.New() + ub := unbound.New(pool) if skipReload { hap.SkipReload = true fw.SkipReload = true diff --git a/cmd/edgeguard-scheduler/main.go b/cmd/edgeguard-scheduler/main.go index f405d32..7cd45d4 100644 --- a/cmd/edgeguard-scheduler/main.go +++ b/cmd/edgeguard-scheduler/main.go @@ -21,7 +21,7 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts" ) -var version = "1.0.26" +var version = "1.0.34" const ( // renewTickInterval — how often we re-evaluate expiring certs. diff --git a/internal/database/migrations/0014_dns_forward_targets.sql b/internal/database/migrations/0014_dns_forward_targets.sql new file mode 100644 index 0000000..68a6838 --- /dev/null +++ b/internal/database/migrations/0014_dns_forward_targets.sql @@ -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 diff --git a/internal/handlers/dns.go b/internal/handlers/dns.go new file mode 100644 index 0000000..91bb5a3 --- /dev/null +++ b/internal/handlers/dns.go @@ -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 +} diff --git a/internal/models/dns.go b/internal/models/dns.go index 6de7889..87cd2b5 100644 --- a/internal/models/dns.go +++ b/internal/models/dns.go @@ -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" } diff --git a/internal/services/dns/dns.go b/internal/services/dns/dns.go new file mode 100644 index 0000000..f9b0e74 --- /dev/null +++ b/internal/services/dns/dns.go @@ -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 +} diff --git a/internal/squid/squid.go b/internal/squid/squid.go index 4edfd30..81dd13d 100644 --- a/internal/squid/squid.go +++ b/internal/squid/squid.go @@ -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 } diff --git a/internal/unbound/unbound.cfg.tpl b/internal/unbound/unbound.cfg.tpl new file mode 100644 index 0000000..f384039 --- /dev/null +++ b/internal/unbound/unbound.cfg.tpl @@ -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}} diff --git a/internal/unbound/unbound.go b/internal/unbound/unbound.go index 7e73658..8dee80a 100644 --- a/internal/unbound/unbound.go +++ b/internal/unbound/unbound.go @@ -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 } diff --git a/management-ui/package.json b/management-ui/package.json index 19179df..fd63e68 100644 --- a/management-ui/package.json +++ b/management-ui/package.json @@ -1,7 +1,7 @@ { "name": "edgeguard-management-ui", "private": true, - "version": "1.0.26", + "version": "1.0.34", "type": "module", "scripts": { "dev": "vite", diff --git a/management-ui/src/App.tsx b/management-ui/src/App.tsx index 85bf0fd..7726111 100644 --- a/management-ui/src/App.tsx +++ b/management-ui/src/App.tsx @@ -22,6 +22,7 @@ const SSLPage = lazy(() => import('./pages/SSL')) const FirewallPage = lazy(() => import('./pages/Firewall')) const WireguardPage = lazy(() => import('./pages/Wireguard')) const ForwardProxyPage = lazy(() => import('./pages/ForwardProxy')) +const DNSPage = lazy(() => import('./pages/DNS')) const ClusterPage = lazy(() => import('./pages/Cluster')) const SettingsPage = lazy(() => import('./pages/Settings')) @@ -103,6 +104,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> diff --git a/management-ui/src/components/Layout/Sidebar.tsx b/management-ui/src/components/Layout/Sidebar.tsx index e9e86a6..b4440a1 100644 --- a/management-ui/src/components/Layout/Sidebar.tsx +++ b/management-ui/src/components/Layout/Sidebar.tsx @@ -53,6 +53,7 @@ const NAV: NavSection[] = [ { path: '/networks', labelKey: 'nav.networks', icon: }, { path: '/ip-addresses', labelKey: 'nav.ipAddresses', icon: }, { path: '/ssl', labelKey: 'nav.ssl', icon: }, + { path: '/dns', labelKey: 'nav.dns', icon: }, ], }, { @@ -72,7 +73,7 @@ const NAV: NavSection[] = [ }, ] -const VERSION = '1.0.26' +const VERSION = '1.0.34' export default function Sidebar({ isOpen, onClose }: SidebarProps) { const { t } = useTranslation() diff --git a/management-ui/src/i18n/locales/de/common.json b/management-ui/src/i18n/locales/de/common.json index 182159e..f0fb068 100644 --- a/management-ui/src/i18n/locales/de/common.json +++ b/management-ui/src/i18n/locales/de/common.json @@ -14,6 +14,7 @@ "vpn": "VPN", "wireguard": "WireGuard", "forwardProxy": "Forward-Proxy", + "dns": "DNS", "firewall": "Firewall", "cluster": "Cluster", "settings": "Einstellungen", @@ -399,6 +400,51 @@ "wg": "WireGuard" } }, + "dns": { + "title": "DNS (Unbound)", + "intro": "Unbound-Resolver auf :53. Lokale Zonen (authoritativ aus DNS-Records) und Forward-Zonen (per stub-zone weiter zu fremden Resolvern). Default-Forwarder für alles andere.", + "tabs": { "zones": "Zonen", "settings": "Resolver-Settings" }, + "zone": { + "name": "Zone-Name", + "nameExtra": "FQDN ohne führenden/abschließenden Punkt — z.B. internal.netcell-it.de", + "type": "Typ", + "typeLocal": "local — authoritativ (records hier)", + "typeForward": "forward — stub-zone zu fremdem Resolver", + "forwardTo": "Upstream-Resolver", + "forwardToExtra": "Komma-separierte IP-Liste — z.B. '10.0.0.53, 8.8.8.8'", + "description": "Beschreibung", + "records": "Records …", + "add": "Zone hinzufügen", + "edit": "Zone bearbeiten", + "deleteConfirm": "Zone {{name}} mit allen Records wirklich löschen?" + }, + "record": { + "name": "Name", + "nameExtra": "Relativ zur Zone (z.B. 'mailcow') oder FQDN mit abschließendem Punkt.", + "type": "Typ", + "value": "Wert", + "valueExtra": "RDATA in Textform: A → IP, CNAME → FQDN, MX → 'priority host', TXT → 'string'.", + "ttl": "TTL (sec)", + "drawerTitle": "DNS-Records", + "add": "Record hinzufügen", + "edit": "Record bearbeiten", + "deleteConfirm": "Record {{name}} wirklich löschen?" + }, + "settings": { + "intro": "Globale Resolver-Settings. Änderungen hier reloaden Unbound automatisch.", + "listenAddresses": "Listen-Adressen", + "listenAddressesExtra": "Komma-separiert. Standard 127.0.0.1+::1 — wenn LAN-Clients fragen sollen, z.B. die LAN-Iface-IP zusätzlich (10.10.20.3).", + "listenPort": "Port", + "upstreamForwards": "Default-Forwarders", + "upstreamForwardsExtra": "Wo geht alles hin was nicht lokal ist. Default 1.1.1.1 + 9.9.9.9.", + "accessACL": "Access-ACL (CIDRs)", + "accessACLExtra": "Wer darf diesen Resolver benutzen.", + "dnssec": "DNSSEC validieren", + "qnameMin": "QName-Minimisation (privacy)", + "cacheMin": "Cache min-TTL", + "cacheMax": "Cache max-TTL" + } + }, "fwd": { "title": "Forward-Proxy (Squid)", "intro": "Squid-basierter Forward-Proxy auf :3128. ACLs werden top-down nach Priority ausgewertet — first-match wins. Wenn keine Regel passt, gewinnt der Default: nur localnet (10/8, 172.16/12, 192.168/16) darf raus.", diff --git a/management-ui/src/i18n/locales/en/common.json b/management-ui/src/i18n/locales/en/common.json index b1a75d9..71afd4e 100644 --- a/management-ui/src/i18n/locales/en/common.json +++ b/management-ui/src/i18n/locales/en/common.json @@ -14,6 +14,7 @@ "vpn": "VPN", "wireguard": "WireGuard", "forwardProxy": "Forward proxy", + "dns": "DNS", "firewall": "Firewall", "cluster": "Cluster", "settings": "Settings", @@ -399,6 +400,51 @@ "wg": "WireGuard" } }, + "dns": { + "title": "DNS (Unbound)", + "intro": "Unbound resolver on :53. Local zones (authoritative from DNS records) and forward zones (stub-zone to remote resolvers). Default forwarders catch everything else.", + "tabs": { "zones": "Zones", "settings": "Resolver settings" }, + "zone": { + "name": "Zone name", + "nameExtra": "FQDN without leading/trailing dot — e.g. internal.netcell-it.de", + "type": "Type", + "typeLocal": "local — authoritative (records here)", + "typeForward": "forward — stub-zone to remote resolver", + "forwardTo": "Upstream resolvers", + "forwardToExtra": "Comma-separated IP list — e.g. '10.0.0.53, 8.8.8.8'", + "description": "Description", + "records": "Records …", + "add": "Add zone", + "edit": "Edit zone", + "deleteConfirm": "Really delete zone {{name}} and all its records?" + }, + "record": { + "name": "Name", + "nameExtra": "Relative to zone (e.g. 'mailcow') or FQDN with trailing dot.", + "type": "Type", + "value": "Value", + "valueExtra": "RDATA in text form: A → IP, CNAME → FQDN, MX → 'priority host', TXT → 'string'.", + "ttl": "TTL (sec)", + "drawerTitle": "DNS records", + "add": "Add record", + "edit": "Edit record", + "deleteConfirm": "Really delete record {{name}}?" + }, + "settings": { + "intro": "Global resolver settings. Saves reload Unbound automatically.", + "listenAddresses": "Listen addresses", + "listenAddressesExtra": "Comma-separated. Default 127.0.0.1+::1 — to let LAN clients query, add the LAN iface IP (e.g. 10.10.20.3).", + "listenPort": "Port", + "upstreamForwards": "Default forwarders", + "upstreamForwardsExtra": "Where everything not local goes. Default 1.1.1.1 + 9.9.9.9.", + "accessACL": "Access ACL (CIDRs)", + "accessACLExtra": "Who is allowed to use this resolver.", + "dnssec": "DNSSEC validation", + "qnameMin": "QName minimisation (privacy)", + "cacheMin": "Cache min-TTL", + "cacheMax": "Cache max-TTL" + } + }, "fwd": { "title": "Forward proxy (Squid)", "intro": "Squid-based forward proxy on :3128. ACLs are evaluated top-down by priority — first match wins. If no rule matches, the default permits only localnet (10/8, 172.16/12, 192.168/16).", diff --git a/management-ui/src/pages/DNS/index.tsx b/management-ui/src/pages/DNS/index.tsx new file mode 100644 index 0000000..40fc1b3 --- /dev/null +++ b/management-ui/src/pages/DNS/index.tsx @@ -0,0 +1,390 @@ +import { useState } from 'react' +import { Alert, Button, Drawer, Form, Input, InputNumber, Modal, Select, Space, Switch, Tabs, Tag, Typography, message } from 'antd' +import type { ColumnsType } from 'antd/es/table' +import { GlobalOutlined, NodeIndexOutlined, PlusOutlined, SettingOutlined } from '@ant-design/icons' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' + +import apiClient, { isEnvelope } from '../../api/client' +import DataTable from '../../components/DataTable' +import PageHeader from '../../components/PageHeader' +import ActionButtons from '../../components/ActionButtons' +import StatusDot from '../../components/StatusDot' + +const { Text } = Typography + +interface Zone { + id: number + name: string + zone_type: 'local' | 'forward' + description?: string | null + managed_by: string + forward_to?: string | null + active: boolean +} + +interface DNSRecord { + id: number + zone_id: number + name: string + record_type: string + value: string + ttl: number + active: boolean +} + +interface Settings { + listen_addresses: string + listen_port: number + upstream_forwards: string + access_acl: string + dnssec: boolean + qname_minimisation: boolean + cache_min_ttl: number + cache_max_ttl: number +} + +const RECORD_TYPES = ['A', 'AAAA', 'CNAME', 'TXT', 'MX', 'SRV', 'NS', 'PTR', 'CAA'] + +async function listZones(): Promise { + const r = await apiClient.get('/dns/zones') + if (!isEnvelope(r.data)) return [] + return (r.data.data as { zones?: Zone[] }).zones ?? [] +} +async function listRecords(zoneID: number): Promise { + const r = await apiClient.get(`/dns/zones/${zoneID}/records`) + if (!isEnvelope(r.data)) return [] + return (r.data.data as { records?: DNSRecord[] }).records ?? [] +} +async function getSettings(): Promise { + const r = await apiClient.get('/dns/settings') + return isEnvelope(r.data) ? (r.data.data as Settings) : null +} + +export default function DNSPage() { + const { t } = useTranslation() + return ( +
+ } + title={t('dns.title')} + subtitle={t('dns.intro')} + /> + {t('dns.tabs.zones')}, children: }, + { key: 'settings', label: {t('dns.tabs.settings')}, children: }, + ]} + /> +
+ ) +} + +// ── Zones tab ────────────────────────────────────────────────── + +function ZonesTab() { + const { t } = useTranslation() + const qc = useQueryClient() + const { data, isLoading } = useQuery({ queryKey: ['dns', 'zones'], queryFn: listZones }) + + const [editing, setEditing] = useState(null) + const [creating, setCreating] = useState(false) + const [form] = Form.useForm() + + const [recordsZone, setRecordsZone] = useState(null) + + const upsert = useMutation({ + mutationFn: async (v: Zone) => { + if (editing) return (await apiClient.put(`/dns/zones/${editing.id}`, v)).data + return (await apiClient.post('/dns/zones', v)).data + }, + onSuccess: () => { + message.success(t('common.save')) + setEditing(null); setCreating(false); form.resetFields() + void qc.invalidateQueries({ queryKey: ['dns', 'zones'] }) + }, + onError: (e: Error) => message.error(e.message), + }) + const del = useMutation({ + mutationFn: async (id: number) => { await apiClient.delete(`/dns/zones/${id}`) }, + onSuccess: () => { void qc.invalidateQueries({ queryKey: ['dns', 'zones'] }) }, + onError: (e: Error) => message.error(e.message), + }) + + const cols: ColumnsType = [ + { title: t('dns.zone.name'), dataIndex: 'name', key: 'name', render: (s: string) => {s} }, + { title: t('dns.zone.type'), dataIndex: 'zone_type', key: 'zone_type', + render: (s: string) => {s} }, + { title: t('dns.zone.forwardTo'), dataIndex: 'forward_to', key: 'forward_to', + render: (v?: string | null) => v ? {v} : '—' }, + { title: t('dns.zone.description'), dataIndex: 'description', key: 'description', render: (v?: string | null) => v ?? '—' }, + { title: t('common.active'), dataIndex: 'active', key: 'active', render: (v: boolean) => }, + { + title: t('common.actions'), key: 'actions', + render: (_, row) => ( + + {row.zone_type === 'local' && ( + + )} + { + setEditing(row) + form.setFieldsValue(row) + }} + onDelete={() => del.mutate(row.id)} + deleteConfirm={t('dns.zone.deleteConfirm', { name: row.name })} + /> + + ), + }, + ] + + return ( + <> + } onClick={() => { + setCreating(true); form.resetFields() + form.setFieldsValue({ zone_type: 'local', active: true } as Zone) + }}> + {t('dns.zone.add')} + + } + /> + + { setEditing(null); setCreating(false); form.resetFields() }} + onOk={() => { void form.submit() }} + confirmLoading={upsert.isPending} + width={580} + destroyOnClose + > +
upsert.mutate(v)}> + + + + + + + )} + + + + + + + +
+
+ + setRecordsZone(null)} /> + + ) +} + +// ── Records drawer ──────────────────────────────────────────── + +interface RecordsDrawerProps { + zone: Zone | null + onClose: () => void +} + +function RecordsDrawer({ zone, onClose }: RecordsDrawerProps) { + const { t } = useTranslation() + const qc = useQueryClient() + const open = zone !== null + const zoneID = zone?.id ?? 0 + + const { data, isLoading } = useQuery({ + queryKey: ['dns', 'records', zoneID], + queryFn: () => listRecords(zoneID), + enabled: open, + }) + + const [editing, setEditing] = useState(null) + const [creating, setCreating] = useState(false) + const [form] = Form.useForm() + + const upsert = useMutation({ + mutationFn: async (v: DNSRecord) => { + if (editing) return (await apiClient.put(`/dns/records/${editing.id}`, v)).data + return (await apiClient.post(`/dns/zones/${zoneID}/records`, v)).data + }, + onSuccess: () => { + message.success(t('common.save')) + setEditing(null); setCreating(false); form.resetFields() + void qc.invalidateQueries({ queryKey: ['dns', 'records', zoneID] }) + }, + onError: (e: Error) => message.error(e.message), + }) + const del = useMutation({ + mutationFn: async (id: number) => { await apiClient.delete(`/dns/records/${id}`) }, + onSuccess: () => { void qc.invalidateQueries({ queryKey: ['dns', 'records', zoneID] }) }, + onError: (e: Error) => message.error(e.message), + }) + + const cols: ColumnsType = [ + { title: t('dns.record.name'), dataIndex: 'name', key: 'name', render: (s: string) => {s} }, + { title: t('dns.record.type'), dataIndex: 'record_type', key: 'record_type', render: (s: string) => {s} }, + { title: t('dns.record.value'), dataIndex: 'value', key: 'value', render: (s: string) => {s} }, + { title: t('dns.record.ttl'), dataIndex: 'ttl', key: 'ttl', width: 80 }, + { title: t('common.active'), dataIndex: 'active', key: 'active', render: (v: boolean) => }, + { + title: t('common.actions'), key: 'actions', + render: (_, row) => ( + { + setEditing(row) + form.setFieldsValue(row) + }} + onDelete={() => del.mutate(row.id)} + deleteConfirm={t('dns.record.deleteConfirm', { name: row.name })} + /> + ), + }, + ] + + return ( + + {t('dns.record.drawerTitle')} + {zone.name} + + )} + destroyOnClose + > + } onClick={() => { + setCreating(true); form.resetFields() + form.setFieldsValue({ record_type: 'A', ttl: 300, active: true } as DNSRecord) + }}> + {t('dns.record.add')} + + } + /> + + { setEditing(null); setCreating(false); form.resetFields() }} + onOk={() => { void form.submit() }} + confirmLoading={upsert.isPending} + width={580} + destroyOnClose + > +
upsert.mutate(v)}> + + + + + + + + + + + + +
+
+
+ ) +} + +// ── Settings tab ────────────────────────────────────────────── + +function SettingsTab() { + const { t } = useTranslation() + const qc = useQueryClient() + const { data, isLoading } = useQuery({ queryKey: ['dns', 'settings'], queryFn: getSettings }) + const [form] = Form.useForm() + + const save = useMutation({ + mutationFn: async (v: Settings) => (await apiClient.put('/dns/settings', v)).data, + onSuccess: () => { + message.success(t('common.save')) + void qc.invalidateQueries({ queryKey: ['dns', 'settings'] }) + }, + onError: (e: Error) => message.error(e.message), + }) + + if (isLoading) return null + return ( +
save.mutate(v)} + style={{ maxWidth: 720 }} + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/packaging/debian/edgeguard-api/DEBIAN/postinst b/packaging/debian/edgeguard-api/DEBIAN/postinst index 714c57d..545cfc6 100755 --- a/packaging/debian/edgeguard-api/DEBIAN/postinst +++ b/packaging/debian/edgeguard-api/DEBIAN/postinst @@ -20,10 +20,17 @@ case "$1" in fi # ── Directories ────────────────────────────────────────────── - for d in /etc/edgeguard /var/lib/edgeguard /var/log/edgeguard \ - /etc/edgeguard/haproxy /etc/edgeguard/squid \ + # /etc/edgeguard und Service-Subdirs müssen für die Service-User + # (squid, unbound, haproxy laufen NICHT als edgeguard) traversier- + # bzw lesbar sein. 0755 statt 0750 — kein Geheimnis ist hier + # gespeichert, alles sind Renderer-Outputs. + for d in /etc/edgeguard /etc/edgeguard/haproxy /etc/edgeguard/squid \ /etc/edgeguard/wireguard /etc/edgeguard/unbound \ - /etc/edgeguard/nftables.d /etc/edgeguard/tls \ + /etc/edgeguard/nftables.d; do + install -d -m 0755 -o "$EG_USER" -g "$EG_USER" "$d" + done + # Sensitive Verzeichnisse bleiben 0750 (TLS-Keys, ACME-State). + for d in /etc/edgeguard/tls /var/lib/edgeguard /var/log/edgeguard \ /var/lib/edgeguard/acme; do install -d -m 0750 -o "$EG_USER" -g "$EG_USER" "$d" done @@ -49,7 +56,38 @@ edgeguard ALL=(root) NOPASSWD: /usr/bin/wg show all dump edgeguard ALL=(root) NOPASSWD: /usr/bin/wg show * edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl reload squid.service edgeguard ALL=(root) NOPASSWD: /bin/systemctl reload squid.service +edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl reload unbound.service +edgeguard ALL=(root) NOPASSWD: /bin/systemctl reload unbound.service SUDOERS + + # ── Distro-Conf-Includes für die per-Service Renderer ───────── + # Squid + Unbound lesen ihre Distro-Default-Conf, die wir per + # Symlink/Drop-in auf unsere managed conf zeigen lassen müssen. + # Renderer können das nicht selbst (kein Schreibrecht in /etc/squid + # bzw. /etc/unbound/unbound.conf.d/), daher hier einmalig. + # Plus: Squid + Unbound laufen als eigene User (squid/unbound), + # nicht edgeguard. Damit sie unsere Conf lesen können, müssen + # die Conf-Dirs world-readable sein (Configs ohne Secrets). + install -d -m 0755 -o "$EG_USER" -g "$EG_USER" /etc/edgeguard/squid /etc/edgeguard/unbound + # Squid: ersetzt die Distro-Datei durch Symlink (Backup .distro-bak) + if [ -f /etc/squid/squid.conf ] && [ ! -L /etc/squid/squid.conf ]; then + mv /etc/squid/squid.conf /etc/squid/squid.conf.distro-bak + fi + ln -sfn /etc/edgeguard/squid/squid.conf /etc/squid/squid.conf + # Unbound: Drop-in im conf.d-Verzeichnis. Wir schreiben direkt + # rein (statt /etc/edgeguard/unbound/...) weil das AppArmor- + # Profil unbound nur /etc/unbound erlaubt. Datei gehört dem + # edgeguard-User damit der Renderer sie überschreiben kann. + install -d /etc/unbound/unbound.conf.d + # Vorgänger-Symlink (aus früheren Versionen) wegräumen. + if [ -L /etc/unbound/unbound.conf.d/edgeguard.conf ]; then + rm /etc/unbound/unbound.conf.d/edgeguard.conf + fi + if [ ! -f /etc/unbound/unbound.conf.d/edgeguard.conf ]; then + : > /etc/unbound/unbound.conf.d/edgeguard.conf + fi + chown "$EG_USER":"$EG_USER" /etc/unbound/unbound.conf.d/edgeguard.conf + chmod 0644 /etc/unbound/unbound.conf.d/edgeguard.conf chmod 0440 /etc/sudoers.d/edgeguard # ── Sysctl-Profil für Edge-Gateway (NAT + HAProxy + Forwarding) ──