// 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 []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 { 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 } // 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 }