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 }