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(), "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("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 }