package api import ( "net/http" "strings" "github.com/owen/vibedns/internal/app" "github.com/owen/vibedns/internal/database" "github.com/owen/vibedns/internal/models" "github.com/owen/vibedns/internal/validate" "github.com/owen/vibedns/internal/zonefile" ) // --- Zones -------------------------------------------------------------- func (s *Server) listZones(w http.ResponseWriter, r *http.Request) error { zones, err := s.app.Zones(r.Context(), database.ZoneFilter{ Kind: query(r, "kind"), Search: query(r, "search"), }) if err != nil { return err } writeJSON(w, http.StatusOK, listBody{Items: nonNil(zones), Total: len(zones)}) return nil } func (s *Server) getZone(w http.ResponseWriter, r *http.Request) error { id, err := pathID(r, "id") if err != nil { return err } zone, err := s.app.Zone(r.Context(), id) if err != nil { return err } writeJSON(w, http.StatusOK, zone) return nil } func (s *Server) createZone(w http.ResponseWriter, r *http.Request) error { var in app.ZoneInput if err := decode(r, &in); err != nil { return err } zone, err := s.app.CreateZone(r.Context(), s.actor(r), in) if err != nil { return err } writeJSON(w, http.StatusCreated, zone) return nil } func (s *Server) updateZone(w http.ResponseWriter, r *http.Request) error { id, err := pathID(r, "id") if err != nil { return err } var in app.ZoneInput if err := decode(r, &in); err != nil { return err } zone, err := s.app.UpdateZone(r.Context(), s.actor(r), id, in) if err != nil { return err } writeJSON(w, http.StatusOK, zone) return nil } func (s *Server) deleteZone(w http.ResponseWriter, r *http.Request) error { id, err := pathID(r, "id") if err != nil { return err } if err := s.app.DeleteZone(r.Context(), s.actor(r), id); err != nil { return err } w.WriteHeader(http.StatusNoContent) return nil } func (s *Server) cloneZone(w http.ResponseWriter, r *http.Request) error { id, err := pathID(r, "id") if err != nil { return err } var in struct { Name string `json:"name"` Description string `json:"description"` } if err := decode(r, &in); err != nil { return err } zone, err := s.app.CloneZone(r.Context(), s.actor(r), id, in.Name, in.Description) if err != nil { return err } writeJSON(w, http.StatusCreated, zone) return nil } func (s *Server) exportZone(w http.ResponseWriter, r *http.Request) error { id, err := pathID(r, "id") if err != nil { return err } zone, body, err := s.app.ExportZoneFile(r.Context(), id) if err != nil { return err } w.Header().Set("Content-Type", "text/dns; charset=utf-8") w.Header().Set("Content-Disposition", "attachment; filename=\""+zonefile.SuggestFilename(zone.Name)+"\"") _, _ = w.Write(body) return nil } // importZone accepts a zone file as the raw request body, which is what // `curl --data-binary @example.zone` sends. func (s *Server) importZone(w http.ResponseWriter, r *http.Request) error { id, err := pathID(r, "id") if err != nil { return err } mode := app.ImportMode(query(r, "mode")) if mode == "" { mode = app.ImportMerge } result, err := s.app.ImportZoneFile(r.Context(), s.actor(r), id, r.Body, mode) if err != nil { return err } writeJSON(w, http.StatusOK, result) return nil } // --- Records ------------------------------------------------------------ func (s *Server) recordFilter(r *http.Request, zoneID int64) database.RecordFilter { limit, offset := limitOffset(r, 100) return database.RecordFilter{ ZoneID: zoneID, Search: query(r, "search"), Type: query(r, "type"), Enabled: query(r, "status"), Limit: limit, Offset: offset, } } func (s *Server) listRecords(w http.ResponseWriter, r *http.Request) error { f := s.recordFilter(r, queryInt64(r, "zone_id")) records, total, err := s.app.Records(r.Context(), f) if err != nil { return err } writeJSON(w, http.StatusOK, listBody{ Items: nonNil(records), Total: total, Limit: f.Limit, Offset: f.Offset, }) return nil } func (s *Server) listZoneRecords(w http.ResponseWriter, r *http.Request) error { id, err := pathID(r, "id") if err != nil { return err } if _, err := s.app.Zone(r.Context(), id); err != nil { return err } f := s.recordFilter(r, id) records, total, err := s.app.Records(r.Context(), f) if err != nil { return err } writeJSON(w, http.StatusOK, listBody{ Items: nonNil(records), Total: total, Limit: f.Limit, Offset: f.Offset, }) return nil } func (s *Server) getRecord(w http.ResponseWriter, r *http.Request) error { id, err := pathID(r, "id") if err != nil { return err } rec, err := s.app.Record(r.Context(), id) if err != nil { return err } writeJSON(w, http.StatusOK, rec) return nil } func (s *Server) createRecord(w http.ResponseWriter, r *http.Request) error { zoneID, err := pathID(r, "id") if err != nil { return err } var in app.RecordInput if err := decode(r, &in); err != nil { return err } rec, err := s.app.CreateRecord(r.Context(), s.actor(r), zoneID, in) if err != nil { return err } writeJSON(w, http.StatusCreated, rec) return nil } func (s *Server) updateRecord(w http.ResponseWriter, r *http.Request) error { id, err := pathID(r, "id") if err != nil { return err } var in app.RecordInput if err := decode(r, &in); err != nil { return err } rec, err := s.app.UpdateRecord(r.Context(), s.actor(r), id, in) if err != nil { return err } writeJSON(w, http.StatusOK, rec) return nil } func (s *Server) deleteRecord(w http.ResponseWriter, r *http.Request) error { id, err := pathID(r, "id") if err != nil { return err } if err := s.app.DeleteRecord(r.Context(), s.actor(r), id); err != nil { return err } w.WriteHeader(http.StatusNoContent) return nil } // listRecordTypes publishes the record type catalogue, including the field // definitions the UI builds its editors from. func (s *Server) listRecordTypes(w http.ResponseWriter, r *http.Request) error { types := s.app.RecordTypes() writeJSON(w, http.StatusOK, listBody{Items: types, Total: len(types)}) return nil } // --- Networks ----------------------------------------------------------- func (s *Server) listNetworks(w http.ResponseWriter, r *http.Request) error { nets, err := s.app.Networks(r.Context(), query(r, "search")) if err != nil { return err } writeJSON(w, http.StatusOK, listBody{Items: nonNil(nets), Total: len(nets)}) return nil } func (s *Server) getNetwork(w http.ResponseWriter, r *http.Request) error { id, err := pathID(r, "id") if err != nil { return err } n, err := s.app.Network(r.Context(), id) if err != nil { return err } writeJSON(w, http.StatusOK, n) return nil } func (s *Server) createNetwork(w http.ResponseWriter, r *http.Request) error { var in app.NetworkInput if err := decode(r, &in); err != nil { return err } n, err := s.app.CreateNetwork(r.Context(), s.actor(r), in) if err != nil { return err } writeJSON(w, http.StatusCreated, n) return nil } func (s *Server) updateNetwork(w http.ResponseWriter, r *http.Request) error { id, err := pathID(r, "id") if err != nil { return err } var in app.NetworkInput if err := decode(r, &in); err != nil { return err } n, err := s.app.UpdateNetwork(r.Context(), s.actor(r), id, in) if err != nil { return err } writeJSON(w, http.StatusOK, n) return nil } func (s *Server) deleteNetwork(w http.ResponseWriter, r *http.Request) error { id, err := pathID(r, "id") if err != nil { return err } if err := s.app.DeleteNetwork(r.Context(), s.actor(r), id); err != nil { return err } w.WriteHeader(http.StatusNoContent) return nil } // --- Policies ----------------------------------------------------------- func (s *Server) listPolicies(w http.ResponseWriter, r *http.Request) error { p, err := s.app.Policies(r.Context()) if err != nil { return err } writeJSON(w, http.StatusOK, listBody{Items: nonNil(p), Total: len(p)}) return nil } func (s *Server) getPolicy(w http.ResponseWriter, r *http.Request) error { id, err := pathID(r, "id") if err != nil { return err } p, err := s.app.Policy(r.Context(), id) if err != nil { return err } writeJSON(w, http.StatusOK, p) return nil } func (s *Server) createPolicy(w http.ResponseWriter, r *http.Request) error { var in app.PolicyInput if err := decode(r, &in); err != nil { return err } p, err := s.app.CreatePolicy(r.Context(), s.actor(r), in) if err != nil { return err } writeJSON(w, http.StatusCreated, p) return nil } func (s *Server) updatePolicy(w http.ResponseWriter, r *http.Request) error { id, err := pathID(r, "id") if err != nil { return err } var in app.PolicyInput if err := decode(r, &in); err != nil { return err } p, err := s.app.UpdatePolicy(r.Context(), s.actor(r), id, in) if err != nil { return err } writeJSON(w, http.StatusOK, p) return nil } func (s *Server) deletePolicy(w http.ResponseWriter, r *http.Request) error { id, err := pathID(r, "id") if err != nil { return err } if err := s.app.DeletePolicy(r.Context(), s.actor(r), id); err != nil { return err } w.WriteHeader(http.StatusNoContent) return nil } // --- Domain lists ------------------------------------------------------- // kindFor maps the URL segment to the stored list kind. func kindFor(segment string) string { if segment == "allowlists" { return models.KindAllowlist } return models.KindBlacklist } func (s *Server) listListsFor(segment string) handler { kind := kindFor(segment) return func(w http.ResponseWriter, r *http.Request) error { lists, err := s.app.DomainLists(r.Context(), kind, query(r, "search")) if err != nil { return err } writeJSON(w, http.StatusOK, listBody{Items: nonNil(lists), Total: len(lists)}) return nil } } func (s *Server) createListFor(segment string) handler { kind := kindFor(segment) return func(w http.ResponseWriter, r *http.Request) error { var in app.ListInput if err := decode(r, &in); err != nil { return err } in.Kind = kind // the URL decides the kind, not the body l, err := s.app.CreateDomainList(r.Context(), s.actor(r), in) if err != nil { return err } writeJSON(w, http.StatusCreated, l) return nil } } func (s *Server) getList(w http.ResponseWriter, r *http.Request) error { id, err := pathID(r, "id") if err != nil { return err } l, err := s.app.DomainList(r.Context(), id) if err != nil { return err } writeJSON(w, http.StatusOK, l) return nil } func (s *Server) updateList(w http.ResponseWriter, r *http.Request) error { id, err := pathID(r, "id") if err != nil { return err } var in app.ListInput if err := decode(r, &in); err != nil { return err } l, err := s.app.UpdateDomainList(r.Context(), s.actor(r), id, in) if err != nil { return err } writeJSON(w, http.StatusOK, l) return nil } func (s *Server) deleteList(w http.ResponseWriter, r *http.Request) error { id, err := pathID(r, "id") if err != nil { return err } if err := s.app.DeleteDomainList(r.Context(), s.actor(r), id); err != nil { return err } w.WriteHeader(http.StatusNoContent) return nil } func (s *Server) listDomains(w http.ResponseWriter, r *http.Request) error { id, err := pathID(r, "id") if err != nil { return err } limit, offset := limitOffset(r, 100) entries, total, err := s.app.DomainEntries(r.Context(), id, query(r, "search"), limit, offset) if err != nil { return err } writeJSON(w, http.StatusOK, listBody{ Items: nonNil(entries), Total: total, Limit: limit, Offset: offset, }) return nil } func (s *Server) addDomain(w http.ResponseWriter, r *http.Request) error { id, err := pathID(r, "id") if err != nil { return err } var in struct { Domain string `json:"domain"` MatchSubdomains *bool `json:"match_subdomains"` Comment string `json:"comment"` } if err := decode(r, &in); err != nil { return err } // Subdomain matching is the useful default: it is what makes a blocklist // of a few hundred thousand names cover the millions of hosts beneath them. match := true if in.MatchSubdomains != nil { match = *in.MatchSubdomains } entry, err := s.app.AddDomain(r.Context(), s.actor(r), id, in.Domain, match, in.Comment) if err != nil { return err } writeJSON(w, http.StatusCreated, entry) return nil } func (s *Server) deleteDomain(w http.ResponseWriter, r *http.Request) error { id, err := pathID(r, "id") if err != nil { return err } if err := s.app.DeleteDomain(r.Context(), s.actor(r), id); err != nil { return err } w.WriteHeader(http.StatusNoContent) return nil } func (s *Server) clearDomains(w http.ResponseWriter, r *http.Request) error { id, err := pathID(r, "id") if err != nil { return err } n, err := s.app.ClearDomains(r.Context(), s.actor(r), id) if err != nil { return err } writeJSON(w, http.StatusOK, map[string]any{"removed": n}) return nil } // importDomains reads the list from the raw request body, so a caller can pipe // a multi-megabyte hosts file straight in with --data-binary. func (s *Server) importDomains(w http.ResponseWriter, r *http.Request) error { id, err := pathID(r, "id") if err != nil { return err } match := true if v := query(r, "match_subdomains"); v != "" { match = queryBool(r, "match_subdomains") } summary, err := s.app.ImportDomains(r.Context(), s.actor(r), id, r.Body, match) if err != nil { return err } writeJSON(w, http.StatusOK, summary) return nil } func (s *Server) exportDomains(w http.ResponseWriter, r *http.Request) error { id, err := pathID(r, "id") if err != nil { return err } w.Header().Set("Content-Type", "text/plain; charset=utf-8") if _, err := s.app.ExportDomains(r.Context(), id, w); err != nil { return err } return nil } // --- Cache -------------------------------------------------------------- func (s *Server) getCache(w http.ResponseWriter, r *http.Request) error { writeJSON(w, http.StatusOK, s.app.CacheView()) return nil } func (s *Server) flushCache(w http.ResponseWriter, r *http.Request) error { if name := query(r, "name"); name != "" { n, err := s.app.FlushCacheName(r.Context(), s.actor(r), name) if err != nil { return err } writeJSON(w, http.StatusOK, map[string]any{"removed": n, "name": name}) return nil } n, err := s.app.FlushCache(r.Context(), s.actor(r)) if err != nil { return err } writeJSON(w, http.StatusOK, map[string]any{"removed": n}) return nil } func (s *Server) listCacheEntries(w http.ResponseWriter, r *http.Request) error { limit, offset := limitOffset(r, 100) entries, total := s.app.CacheEntries(query(r, "search"), limit, offset) writeJSON(w, http.StatusOK, listBody{ Items: nonNil(entries), Total: total, Limit: limit, Offset: offset, }) return nil } func (s *Server) deleteCacheEntry(w http.ResponseWriter, r *http.Request) error { name := query(r, "name") qtype := query(r, "type") if name == "" || qtype == "" { return app.Invalid("Both the name and type query parameters are required.") } if err := s.app.DeleteCacheEntry(r.Context(), s.actor(r), name, qtype, queryBool(r, "dnssec")); err != nil { return err } w.WriteHeader(http.StatusNoContent) return nil } // --- Settings ----------------------------------------------------------- func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) error { writeJSON(w, http.StatusOK, s.app.Settings()) return nil } // updateSettings merges the submitted fields over the current settings, so a // caller can change one value without restating the whole configuration. func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request) error { next := s.app.Settings() if err := decode(r, &next); err != nil { return err } if err := s.app.SaveSettings(r.Context(), s.actor(r), app.GroupDNS, next); err != nil { return err } writeJSON(w, http.StatusOK, s.app.Settings()) return nil } // --- Statistics and logs ------------------------------------------------ func (s *Server) getStats(w http.ResponseWriter, r *http.Request) error { dash, err := s.app.Dashboard(r.Context(), queryInt(r, "top", 10)) if err != nil { return err } writeJSON(w, http.StatusOK, dash) return nil } func (s *Server) listQueryLog(w http.ResponseWriter, r *http.Request) error { limit, offset := limitOffset(r, 100) f := database.QueryLogFilter{ Domain: query(r, "domain"), ClientIP: query(r, "client"), QType: query(r, "type"), Rcode: query(r, "rcode"), Source: query(r, "source"), Blocked: query(r, "blocked"), NetworkID: queryInt64(r, "network_id"), Limit: limit, Offset: offset, } entries, total, err := s.app.QueryLogs(r.Context(), f) if err != nil { return err } writeJSON(w, http.StatusOK, listBody{ Items: nonNil(entries), Total: total, Limit: limit, Offset: offset, }) return nil } func (s *Server) clearQueryLog(w http.ResponseWriter, r *http.Request) error { n, err := s.app.ClearQueryLog(r.Context(), s.actor(r)) if err != nil { return err } writeJSON(w, http.StatusOK, map[string]any{"removed": n}) return nil } func (s *Server) listAuditLog(w http.ResponseWriter, r *http.Request) error { limit, offset := limitOffset(r, 100) f := database.AuditFilter{ Search: query(r, "search"), ObjectType: query(r, "object_type"), Source: query(r, "source"), Limit: limit, Offset: offset, } entries, total, err := s.app.AuditLogs(r.Context(), f) if err != nil { return err } writeJSON(w, http.StatusOK, listBody{ Items: nonNil(entries), Total: total, Limit: limit, Offset: offset, }) return nil } // --- Operations --------------------------------------------------------- func (s *Server) listBackups(w http.ResponseWriter, r *http.Request) error { backups, err := s.app.BackupList() if err != nil { return err } writeJSON(w, http.StatusOK, map[string]any{ "status": s.app.BackupStatus(), "backups": nonNil(backups), }) return nil } func (s *Server) createBackup(w http.ResponseWriter, r *http.Request) error { info, err := s.app.RunBackup(r.Context(), s.actor(r)) if err != nil { return err } writeJSON(w, http.StatusCreated, info) return nil } func (s *Server) exportConfig(w http.ResponseWriter, r *http.Request) error { w.Header().Set("Content-Type", "application/json; charset=utf-8") return s.app.WriteConfigExport(r.Context(), w, queryBool(r, "include_domains")) } func (s *Server) importConfig(w http.ResponseWriter, r *http.Request) error { report, err := s.app.ImportConfig(r.Context(), s.actor(r), r.Body, queryBool(r, "apply_settings")) if err != nil { return err } writeJSON(w, http.StatusOK, report) return nil } // --- Tools -------------------------------------------------------------- // reverseZone previews the zone apex a subnet maps to. The zone creation form // calls this as the operator types. func (s *Server) reverseZone(w http.ResponseWriter, r *http.Request) error { cidr := query(r, "cidr") if cidr == "" { return app.Invalid("The cidr query parameter is required.") } name, note, err := s.app.ReverseZoneName(cidr) if err != nil { return err } kind, _ := validate.ReverseZoneKindForCIDR(cidr) writeJSON(w, http.StatusOK, map[string]any{ "cidr": cidr, "zone": strings.TrimSuffix(name, "."), "fqdn": name, "kind": kind, "note": note, }) return nil } func (s *Server) lookup(w http.ResponseWriter, r *http.Request) error { name := query(r, "name") if name == "" { return app.Invalid("The name query parameter is required.") } result, err := s.app.Lookup(r.Context(), name, query(r, "type"), query(r, "client"), queryBool(r, "dnssec")) if err != nil { return err } writeJSON(w, http.StatusOK, result) return nil } // domainCheck reports which policy lists cover a name. func (s *Server) domainCheck(w http.ResponseWriter, r *http.Request) error { name := query(r, "domain") if name == "" { return app.Invalid("The domain query parameter is required.") } hits, err := s.app.LookupDomain(r.Context(), name) if err != nil { return err } writeJSON(w, http.StatusOK, map[string]any{ "domain": name, "matches": nonNil(hits), }) return nil } // nonNil turns a nil slice into an empty one so JSON responses always carry // [] rather than null, which is what most clients expect from a collection. func nonNil[T any](v []T) []T { if v == nil { return []T{} } return v }