Files
2026-08-16 21:18:45 -05:00

789 lines
20 KiB
Go

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
}