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>
329 lines
8.1 KiB
Go
329 lines
8.1 KiB
Go
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
|
|
}
|