Files
Debian 1d06b28064 feat: HA-Cluster v1.2.x — Split-Brain, TOTP, Enterprise-FW, Drift-Fix, VIP-Recovery
- keepalived: pg_role='standby' hat Vorrang vor role für BACKUP-Bestimmung
- keepalived-master.sh: gecrasht Dienste beim MASTER-Übergang starten (nicht nur reload)
- confighash: ip_addresses per Interface-Name hashen statt per FK (Cross-Node-Drift-Fix)
- TOTP/2FA: RFC 6238 — Setup-Flow, QR-Code, Admin-Disable; two-step Login
- Firewall-UI: Enterprise-Design — auto-Beschreibung, icon-only Actions, zero-hit Indikator
- fe80-Filter: Link-local IPv6 aus NTP/DNS Listen-Dropdowns entfernen
- VIP-Dashboard, Dual-Path VRRP, GW-Tracking (Migrations 0033/0034)
- Forward Proxy + DNS erweiterte Einstellungen (Migrations 0031/0032)
- unbound-control: edgeguard in unbound-Gruppe via postinst

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 18:18:31 +02:00

469 lines
12 KiB
Go

package handlers
import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"os/exec"
"strconv"
"strings"
"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)
g.GET("/stats", h.Stats)
g.POST("/flush-cache", h.FlushCache)
}
// ── 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
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dns.zone.delete",
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
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
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dns.record.delete",
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
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
}
if err := validateSettings(&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")
}
// FlushCache runs `unbound-control flush_zone .` which discards all
// cached RRs from the resolver. Useful after DNS propagation or when
// stale records need to be evicted immediately.
func (h *DNSHandler) FlushCache(c *gin.Context) {
out, err := exec.CommandContext(c.Request.Context(), "/usr/sbin/unbound-control", "flush_zone", ".").CombinedOutput()
if err != nil {
slog.Error("dns flush-cache failed", "err", err, "out", string(out))
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dns.flush-cache", "unbound", nil, h.NodeID)
response.OK(c, gin.H{"message": "cache flushed", "output": string(out)})
}
// ── Validation ─────────────────────────────────────────────────
// validateSettings checks user-supplied DNS global settings before they
// reach unbound. A malformed upstream IP or CIDR would cause unbound to
// fail on the next reload without any visible error.
func validateSettings(s *models.DNSSettings) error {
if s.ListenPort < 1 || s.ListenPort > 65535 {
return fmt.Errorf("listen_port %d out of range (1-65535)", s.ListenPort)
}
if s.CacheMaxTTL < s.CacheMinTTL {
return fmt.Errorf("cache_max_ttl (%d) must be ≥ cache_min_ttl (%d)", s.CacheMaxTTL, s.CacheMinTTL)
}
for _, raw := range strings.Split(s.UpstreamForwards, ",") {
entry := strings.TrimSpace(raw)
if entry == "" {
continue
}
// strip optional @port suffix (e.g. 1.1.1.1@853)
host, _, _ := strings.Cut(entry, "@")
if net.ParseIP(host) == nil {
return fmt.Errorf("invalid upstream forwarder IP: %q", host)
}
}
for _, raw := range strings.Split(s.AccessACL, ",") {
entry := strings.TrimSpace(raw)
if entry == "" {
continue
}
if strings.Contains(entry, "/") {
if _, _, err := net.ParseCIDR(entry); err != nil {
return fmt.Errorf("invalid access ACL CIDR: %q", entry)
}
} else if net.ParseIP(entry) == nil {
return fmt.Errorf("invalid access ACL IP: %q", entry)
}
}
for _, raw := range strings.Split(s.ListenAddresses, ",") {
addr := strings.TrimSpace(raw)
if addr == "" {
continue
}
if net.ParseIP(addr) == nil {
return fmt.Errorf("invalid listen address: %q", addr)
}
}
return nil
}
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
}
// Stats liefert Unbound-Resolver-Statistiken via `unbound-control stats_noreset`.
// stats_noreset liest die Zähler ohne sie zurückzusetzen — safe für
// wiederholte Aufrufe aus dem UI.
func (h *DNSHandler) Stats(c *gin.Context) {
out, err := exec.Command("/usr/sbin/unbound-control", "stats_noreset").Output()
if err != nil {
response.OK(c, gin.H{
"error": "unbound-control nicht verfügbar: " + err.Error(),
})
return
}
response.OK(c, gin.H{"stats": parseUnboundStats(string(out))})
}
type unboundStats struct {
TotalQueries int64 `json:"total_queries"`
CacheHits int64 `json:"cache_hits"`
CacheMiss int64 `json:"cache_miss"`
CacheHitPct float64 `json:"cache_hit_pct"`
RecursiveReplies int64 `json:"recursive_replies"`
Prefetch int64 `json:"prefetch"`
RateLimited int64 `json:"rate_limited"`
RRSetCacheBytes int64 `json:"rrset_cache_bytes"`
MsgCacheBytes int64 `json:"msg_cache_bytes"`
TCPUsage int64 `json:"tcp_usage"`
Unwanted int64 `json:"unwanted"`
}
func parseUnboundStats(out string) unboundStats {
s := unboundStats{}
for _, line := range strings.Split(out, "\n") {
k, v, ok := strings.Cut(line, "=")
if !ok {
continue
}
n, _ := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
switch strings.TrimSpace(k) {
case "total.num.queries":
s.TotalQueries = n
case "total.num.cachehits":
s.CacheHits = n
case "total.num.cachemiss":
s.CacheMiss = n
case "total.num.recursivereplies":
s.RecursiveReplies = n
case "total.num.prefetch":
s.Prefetch = n
case "total.num.queries_ip_ratelimited":
s.RateLimited = n
case "mem.cache.rrset":
s.RRSetCacheBytes = n
case "mem.cache.message":
s.MsgCacheBytes = n
case "total.tcpusage":
s.TCPUsage = n
case "unwanted.queries":
s.Unwanted = n
}
}
if s.TotalQueries > 0 {
s.CacheHitPct = float64(s.CacheHits) / float64(s.TotalQueries) * 100
}
return s
}
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
}