Bug: Unbound bindet Listen-Sockets nur beim startup. Bei einer Mutation von dns_settings.listen_addresses (z.B. neue LAN-IP für Resolver-Zugriff) hat 'systemctl reload' die Config zwar gelesen, aber nicht neu gebound — neue IPs blieben tot. Fix: Renderer ruft RestartService statt ReloadService. ~200ms Resolver-Downtime beim Save, dafür konsistentes Verhalten für jede Settings/Zone/Record-Mutation. Plus configgen.RestartService Helper neu (analog ReloadService), sudoers im postinst um systemctl restart unbound.service erweitert. NOTE für DNS-LAN-Zugang: zwei Operator-FW-Rules nötig (DNS-UDP + DNS-TCP from any to any) wenn der Resolver auf LAN-IPs lauscht. Aktuell manuell anzulegen — ein Auto-Rule-Generator (analog NAT-auto-forward) wäre die nächste Iteration. Version 1.0.36. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
153 lines
4.2 KiB
Go
153 lines
4.2 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 []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
|
|
}
|