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:
79
internal/unbound/unbound.cfg.tpl
Normal file
79
internal/unbound/unbound.cfg.tpl
Normal 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}}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user