Files
edgeguard-native/internal/unbound/unbound.go
Debian 99982ef9d1 feat(ui): Quick-Toggles, Config-Preview, Dashboard-Alerts, Domain-Detail-Health
Quick-Toggle-Switches (kein Modal nötig) für: Backends, Backend-Server,
DNS-Zonen, DNS-Records, Domains (active), Firewall-Rules, NAT-Rules,
Forward-Proxy ACLs, Routing-Rules.

Dashboard: Alert-Banner für komplett ausgefallene Backends (HAProxy-Stats)
und Domains im Maintenance-Mode.

Domain-Detail: HAProxy-Live-Health-Badge (15s Polling), TLS-Cert
ausstellen/erneuern direkt aus dem Detail, Routing-Rules-Panel inline.

Config-Preview (Settings): alle 4 Generatoren (haproxy, nftables, squid,
unbound) rendern via RenderToString ohne Disk-Write — GET /system/config-preview.

ActionButtons: Viewer-Rolle blendet Delete aus (RBAC-Ergänzung).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 08:55:22 +02:00

198 lines
5.3 KiB
Go

// 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"
)
// 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"
//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 []recordView
}
// recordView ist DNSRecord plus FQDN — der Renderer expandiert
// "@"/leer zu zone.Name, relative names zu "<name>.<zone>." Punkt
// am Ende ist für unbound's local-data Pflicht.
type recordView struct {
models.DNSRecord
FQDN string
}
func resolveFQDN(recName, zoneName string) string {
zone := strings.TrimSuffix(zoneName, ".")
n := strings.TrimSpace(recName)
if n == "" || n == "@" {
return zone + "."
}
if strings.HasSuffix(n, ".") {
return n // schon FQDN mit Punkt
}
if strings.HasSuffix(n, "."+zone) || n == zone {
return n + "."
}
return n + "." + zone + "."
}
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) buildView(ctx context.Context) (*View, error) {
settings, err := g.Repo.GetSettings(ctx)
if err != nil {
return nil, fmt.Errorf("settings: %w", err)
}
zones, err := g.Repo.ListZones(ctx)
if err != nil {
return nil, 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 nil, fmt.Errorf("records for zone %s: %w", z.Name, err)
}
active := make([]recordView, 0, len(recs))
for _, r := range recs {
if r.Active {
active = append(active, recordView{
DNSRecord: r,
FQDN: resolveFQDN(r.Name, z.Name),
})
}
}
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,
})
}
}
return view, nil
}
func (g *Generator) RenderToString(ctx context.Context) (string, error) {
view, err := g.buildView(ctx)
if err != nil {
return "", err
}
var body bytes.Buffer
if err := tpl.Execute(&body, view); err != nil {
return "", fmt.Errorf("template: %w", err)
}
return body.String(), nil
}
func (g *Generator) Render(ctx context.Context) error {
view, err := g.buildView(ctx)
if err != nil {
return err
}
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
}
// Restart statt reload: unbound bindet Listen-Sockets nur beim
// Startup. Bei Settings-Änderungen (listen_addresses-Wechsel)
// greift ein bloßes 'systemctl reload' nicht — die neuen IPs
// werden erst nach echtem Restart gebound. Trade-off: ~200ms
// Downtime des Resolvers, dafür konsistentes Verhalten für jede
// Mutation.
return configgen.RestartService("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
}