package api import ( "context" "errors" "fmt" "net/http" "os" "sort" "strings" "time" "gopkg.in/yaml.v3" "git.k8s-git-k6n.net/mats/fancy-dns/internal/config" ) // metricsHandler is injected by the app so the api package does not import the // metrics package directly (keeps the dependency one-way). var metricsHandler func() http.Handler // SetMetricsHandler installs the /metrics handler. func SetMetricsHandler(h http.Handler) { metricsHandler = func() http.Handler { return h } } // handleHealth is the liveness/readiness probe. It is unauthenticated on // purpose, but deliberately reveals only a coarse status. func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) { h := s.hooks.Health() code := http.StatusOK if st, ok := h.(map[string]any); ok { if v, _ := st["status"].(string); v == "down" { code = http.StatusServiceUnavailable } } writeJSON(w, code, h) } // handleStatus reports the runtime state of every component. func (s *Server) handleStatus(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]any{ "version": s.hooks.Version, "uptime_s": int64(time.Since(s.hooks.StartedAt).Seconds()), "started": s.hooks.StartedAt, "config": map[string]any{ "version": s.hooks.Store.Get().Version, "generation": s.hooks.Store.Generation(), "path": s.hooks.Store.Path(), "last_error": s.hooks.Store.LastError(), "writable": isFileWritable(s.hooks.Store.Path()), "reload_ready": s.hooks.Store.Path() != "", }, "components": s.hooks.Status(), }) } // handleBlocklists serves the compiled blocklist state. func (s *Server) handleBlocklists(w http.ResponseWriter, _ *http.Request) { out := map[string]any{} if s.hooks.BlocklistState != nil { if m, ok := s.hooks.BlocklistState().(map[string]any); ok { out = m } } writeJSON(w, http.StatusOK, out) } // handleBlocklistRefresh recompiles the blocklists. func (s *Server) handleBlocklistRefresh(w http.ResponseWriter, r *http.Request) { if s.hooks.RefreshBlocklists == nil { writeError(w, http.StatusNotImplemented, "blocklist refresh not available") return } var in struct { Names []string `json:"names"` } // An empty body is valid and means "refresh everything". if r.ContentLength > 0 { if err := decodeJSON(r, &in); err != nil { writeError(w, http.StatusBadRequest, "malformed request body") return } } if in.Names == nil { // A nil slice marshals to null, and the console counts the names it // gets back to build its toast — so "refresh everything" reported // "0 sources". The key set must not depend on the request. in.Names = []string{} } if err := s.hooks.RefreshBlocklists(r.Context(), in.Names); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } s.log.Info("blocklist refresh requested", "names", in.Names) writeJSON(w, http.StatusOK, map[string]any{"started": true, "names": in.Names}) } // handleBlocklistToggle flips a source's enabled flag. func (s *Server) handleBlocklistToggle(w http.ResponseWriter, r *http.Request) { if s.hooks.ToggleBlocklist == nil { writeError(w, http.StatusNotImplemented, "blocklist toggle not available") return } var in struct { Name string `json:"name"` Enabled bool `json:"enabled"` } if err := decodeJSON(r, &in); err != nil || in.Name == "" { writeError(w, http.StatusBadRequest, "name is required") return } if err := s.hooks.ToggleBlocklist(in.Name, in.Enabled); err != nil { writeError(w, http.StatusNotFound, err.Error()) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true, "name": in.Name, "enabled": in.Enabled}) } // handleDomains pages the compiled rules. func (s *Server) handleDomains(w http.ResponseWriter, r *http.Request) { if s.hooks.ListDomains == nil { writeError(w, http.StatusNotImplemented, "domain listing not available") return } q := DomainQuery{ Text: r.URL.Query().Get("q"), Action: r.URL.Query().Get("action"), List: r.URL.Query().Get("list"), Page: queryInt(r, "page", 1), PerPage: queryInt(r, "per_page", 100), } if q.Page < 1 { q.Page = 1 } if q.PerPage < 1 || q.PerPage > 1000 { q.PerPage = 100 } v, err := s.hooks.ListDomains(q) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, v) } // handleDomainCheck explains how a name would be treated, which is the fastest // way for an operator to debug "why is this not blocked". func (s *Server) handleDomainCheck(w http.ResponseWriter, r *http.Request) { if s.hooks.DomainCheck == nil { writeError(w, http.StatusNotImplemented, "domain check not available") return } name := strings.TrimSpace(r.URL.Query().Get("name")) if name == "" { writeError(w, http.StatusBadRequest, "name is required") return } v, err := s.hooks.DomainCheck(name) if err != nil { writeJSON(w, http.StatusOK, map[string]any{"name": name, "error": err.Error()}) return } writeJSON(w, http.StatusOK, v) } // handleDNSTest answers one query through the live pipeline. It is the // console's "is my DNS actually answering" button: the answer comes from the // same engine the listeners use, so what it shows is what a client gets, and // a query error is reported inside the payload rather than as a status code — // a failed lookup is a result here, not a failed request. func (s *Server) handleDNSTest(w http.ResponseWriter, r *http.Request) { if s.hooks.Resolve == nil { writeError(w, http.StatusNotImplemented, "dns test not available") return } var in struct { Name string `json:"name"` QType string `json:"type"` Upstream string `json:"upstream"` } if err := decodeJSON(r, &in); err != nil { writeError(w, http.StatusBadRequest, "malformed request body") return } name := strings.TrimSpace(in.Name) if name == "" { writeError(w, http.StatusBadRequest, "name is required") return } resolve := s.hooks.Resolve if in.Upstream != "" { resolve = func(ctx context.Context, n, qt string) (any, error) { if s.hooks.ResolveUpstream == nil { return nil, errors.New("direct upstream lookup unavailable") } return s.hooks.ResolveUpstream(ctx, n, qt, in.Upstream) } } if resolve == nil { writeError(w, http.StatusNotImplemented, "dns test not available") return } v, err := resolve(r.Context(), name, in.QType) if err != nil { writeJSON(w, http.StatusOK, map[string]any{"name": name, "error": err.Error()}) return } writeJSON(w, http.StatusOK, v) } func (s *Server) handleAddressOverrides(w http.ResponseWriter, r *http.Request) { if s.hooks.AddressOverrides == nil { writeError(w, http.StatusNotImplemented, "address overrides unavailable") return } v, err := s.hooks.AddressOverrides(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, v) } func (s *Server) handleAddressOverridePut(w http.ResponseWriter, r *http.Request) { if s.hooks.SaveAddressOverride == nil { writeError(w, http.StatusNotImplemented, "address overrides are read-only") return } var in struct { Domain string `json:"domain"` Address string `json:"address"` } if decodeJSON(r, &in) != nil { writeError(w, http.StatusBadRequest, "malformed request body") return } if err := s.hooks.SaveAddressOverride(r.Context(), in.Domain, in.Address); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } func (s *Server) handleAddressOverrideDelete(w http.ResponseWriter, r *http.Request) { if s.hooks.DeleteAddressOverride == nil { writeError(w, http.StatusNotImplemented, "address overrides are read-only") return } domain := r.PathValue("domain") if err := s.hooks.DeleteAddressOverride(r.Context(), domain); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } // handleDomainAdd adds an inline rule. func (s *Server) handleDomainAdd(w http.ResponseWriter, r *http.Request) { if s.hooks.AddDomain == nil { writeError(w, http.StatusNotImplemented, "domain add not available") return } var in DomainInput if err := decodeJSON(r, &in); err != nil { writeError(w, http.StatusBadRequest, "malformed request body") return } if strings.TrimSpace(in.Name) == "" { writeError(w, http.StatusBadRequest, "name is required") return } v, err := s.hooks.AddDomain(r.Context(), in) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } writeJSON(w, http.StatusOK, v) } // handleDomainDelete removes a rule. func (s *Server) handleDomainDelete(w http.ResponseWriter, r *http.Request) { if s.hooks.DeleteDomain == nil { writeError(w, http.StatusNotImplemented, "domain delete not available") return } // The route is {name...} so a rule name containing dots or a wildcard // prefix survives without percent-encoding gymnastics. name := r.PathValue("name") if name == "" { writeError(w, http.StatusBadRequest, "name is required") return } if err := s.hooks.DeleteDomain(r.Context(), name, r.URL.Query().Get("action"), r.URL.Query().Get("list")); err != nil { writeError(w, http.StatusNotFound, err.Error()) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true, "name": name}) } // handleClients returns the ACL groups. func (s *Server) handleClients(w http.ResponseWriter, _ *http.Request) { if s.hooks.GetACL == nil { writeError(w, http.StatusNotImplemented, "acl not available") return } writeJSON(w, http.StatusOK, s.hooks.GetACL()) } // aclPayload is the PUT body for the clients endpoint. type aclPayload struct { Groups []config.Group `json:"groups"` DefaultPolicy string `json:"default_policy"` } // handleClientsPut validates and persists new ACL groups. func (s *Server) handleClientsPut(w http.ResponseWriter, r *http.Request) { if s.hooks.PutACL == nil { writeError(w, http.StatusNotImplemented, "acl write not available") return } var in aclPayload if err := decodeJSON(r, &in); err != nil { writeError(w, http.StatusBadRequest, "malformed request body: "+err.Error()) return } version, err := s.hooks.PutACL(r.Context(), in.Groups, in.DefaultPolicy) if err != nil { writeErrorFields(w, http.StatusBadRequest, err.Error(), configErrorFields(err)) return } s.log.Info("acl updated", "groups", len(in.Groups), "version", version) writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": version}) } // handleUpstreams returns the upstream pool state. func (s *Server) handleUpstreams(w http.ResponseWriter, _ *http.Request) { if s.hooks.GetUpstream == nil { writeError(w, http.StatusNotImplemented, "upstreams not available") return } writeJSON(w, http.StatusOK, s.hooks.GetUpstream()) } // upstreamPayload is the PUT body for the upstreams endpoint. The health // fields the console sends (health, latency_ms, fail_rate, breaker_open and // the counters) are accepted and ignored: the server owns them, and refusing // the object the console just read is what made every save answer 400. They are // carried on config.UpstreamServer with `json:"-"` on the yaml side, so // settings.yaml does not gain runtime state. // // HealthCheckInterval accepts both spellings the console has used: the // duration string ("30s") and the older seconds number under its old name. type upstreamPayload struct { Policy string `json:"policy"` Servers []config.UpstreamServer `json:"servers"` Bootstraps []config.UpstreamBootstrap `json:"bootstraps"` HealthCheck config.Duration `json:"healthcheck_interval"` HealthCheckSeconds float64 `json:"healthcheck_interval_s"` MaxAttempts int `json:"max_attempts"` BreakerThreshold int `json:"breaker_threshold"` // BreakerCooldown was read-only in the console; accepting it means a // round-trip of the object the console read does not fail. BreakerCooldown config.Duration `json:"breaker_cooldown"` } // handleUpstreamsPut validates and persists a new upstream config. func (s *Server) handleUpstreamsPut(w http.ResponseWriter, r *http.Request) { if s.hooks.PutUpstream == nil { writeError(w, http.StatusNotImplemented, "upstream write not available") return } var in upstreamPayload if err := decodeJSON(r, &in); err != nil { writeError(w, http.StatusBadRequest, "malformed request body: "+err.Error()) return } // Preserve fields the UI does not manage, so a UI write never silently // resets a breaker cooldown someone set by hand. cur := s.hooks.Store.Get().Upstream healthCheck := in.HealthCheck if healthCheck <= 0 { // The older seconds-number spelling; zero also means "not supplied". healthCheck = config.Duration(in.HealthCheckSeconds * float64(time.Second)) } cooldown := cur.BreakerCooldown if in.BreakerCooldown > 0 { cooldown = in.BreakerCooldown } up := config.Upstream{ Policy: in.Policy, Servers: in.Servers, Bootstraps: in.Bootstraps, MaxAttempts: in.MaxAttempts, HealthCheck: healthCheck, BreakerThreshold: in.BreakerThreshold, BreakerCooldown: cooldown, } if up.HealthCheck <= 0 { up.HealthCheck = cur.HealthCheck } if up.BreakerThreshold <= 0 { up.BreakerThreshold = cur.BreakerThreshold } version, err := s.hooks.PutUpstream(r.Context(), up) if err != nil { writeErrorFields(w, http.StatusBadRequest, err.Error(), configErrorFields(err)) return } s.log.Info("upstreams updated", "servers", len(up.Servers), "version", version) writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": version}) } // handleCache returns the cache snapshot. func (s *Server) handleCache(w http.ResponseWriter, _ *http.Request) { if s.hooks.CacheStats == nil { writeError(w, http.StatusNotImplemented, "cache not available") return } writeJSON(w, http.StatusOK, s.hooks.CacheStats()) } // handleCacheFlush empties the cache. func (s *Server) handleCacheFlush(w http.ResponseWriter, _ *http.Request) { if s.hooks.FlushCache == nil { writeError(w, http.StatusNotImplemented, "cache not available") return } n := s.hooks.FlushCache() s.log.Info("cache flushed", "entries", n) writeJSON(w, http.StatusOK, map[string]any{"ok": true, "entries": n}) } // handleSettingsGet returns the config document plus what is knowable about it. func (s *Server) handleSettingsGet(w http.ResponseWriter, _ *http.Request) { path := s.hooks.Store.Path() var fileYAML string if path != "" { if b, err := os.ReadFile(path); err == nil { fileYAML = string(b) } } // The base, not the effective config: the settings editor writes what it // shows back to the file, and cluster objects must not end up in the YAML. cur := s.hooks.Store.Base() effective, err := config.Marshal(cur) if err != nil { writeError(w, http.StatusInternalServerError, "cannot render config: "+err.Error()) return } defaults, _ := config.Marshal(config.Default()) if fileYAML == "" { // Running without a file: show the effective document as the editable // text so the UI still works. fileYAML = string(effective) } writeJSON(w, http.StatusOK, map[string]any{ "version": cur.Version, "path": path, "config_yaml": fileYAML, "effective_yaml": string(effective), "defaults_yaml": string(defaults), "last_error": s.hooks.Store.LastError(), "reload_available": path != "", "writable": isFileWritable(path), }) } // settingsPayload is the PUT body for the settings endpoint. type settingsPayload struct { ConfigYAML string `json:"config_yaml"` } // handleSettingsPut validates, writes and swaps a new config. func (s *Server) handleSettingsPut(w http.ResponseWriter, r *http.Request) { cfg := s.hooks.Store.Get() if !cfg.API.AllowConfigWrite { writeError(w, http.StatusForbidden, "config writes are disabled") return } var in settingsPayload if err := decodeJSON(r, &in); err != nil { writeError(w, http.StatusBadRequest, "malformed request body") return } if strings.TrimSpace(in.ConfigYAML) == "" { writeError(w, http.StatusBadRequest, "config_yaml is empty") return } next, err := parseConfigYAML(in.ConfigYAML) if err != nil { writeError(w, http.StatusBadRequest, "cannot parse config: "+err.Error()) return } // Relative paths in the document resolve against the config's directory, // so a file written from a different cwd still finds its zone files. next.WorkingDirOrSet(cfg.Server.WorkingDir) if err := next.Validate(); err != nil { writeErrorFields(w, http.StatusBadRequest, "invalid config: "+err.Error(), configErrorFields(err)) return } if err := s.commit(next); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } s.log.Info("config updated via api", "version", next.Version) writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": next.Version, "warnings": []string{}}) } // handleSettingsValidate checks a document without applying it. func (s *Server) handleSettingsValidate(w http.ResponseWriter, r *http.Request) { var in settingsPayload if err := decodeJSON(r, &in); err != nil { writeError(w, http.StatusBadRequest, "malformed request body") return } next, err := parseConfigYAML(in.ConfigYAML) if err != nil { writeJSON(w, http.StatusOK, map[string]any{ "ok": false, "errors": []map[string]string{{"field": "config", "message": err.Error()}}, "warnings": []map[string]string{}, }) return } next.WorkingDirOrSet(s.hooks.Store.Get().Server.WorkingDir) verr := next.Validate() out := map[string]any{"ok": verr == nil, "errors": []map[string]string{}, "warnings": []map[string]string{}} if verr != nil { errs := make([]map[string]string, 0) for _, f := range configErrorFields(verr) { errs = append(errs, map[string]string{"field": fieldOf(f), "message": f}) } out["errors"] = errs } if warnings := configWarnings(next); len(warnings) > 0 { ws := make([]map[string]string, 0, len(warnings)) for _, warn := range warnings { ws = append(ws, map[string]string{"field": fieldOf(warn), "message": warn}) } out["warnings"] = ws } writeJSON(w, http.StatusOK, out) } // handleSettingsReload re-reads the config file from disk. func (s *Server) handleSettingsReload(w http.ResponseWriter, _ *http.Request) { if s.hooks.Store.Path() == "" { writeError(w, http.StatusBadRequest, "no config file to reload from") return } if err := s.hooks.Store.Reload(); err != nil { writeError(w, http.StatusInternalServerError, "reload failed: "+err.Error()) return } s.log.Info("config reloaded from disk", "version", s.hooks.Store.Get().Version) writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": s.hooks.Store.Get().Version}) } // commit writes the config to disk, swaps it in and notifies the cluster. func (s *Server) commit(next *config.Config) error { path := s.hooks.Store.Path() if path == "" { return fmt.Errorf("cannot persist: no config file path is known") } if !isFileWritable(path) { return fmt.Errorf("cannot persist: %s is not writable", path) } next.Version = s.hooks.Store.Get().Version + 1 if err := next.Save(path); err != nil { return fmt.Errorf("write config: %w", err) } return s.hooks.Store.Swap(next) } // Commit exposes the config commit path so a component (for example the ACL or // upstream editor) goes through the same validate-write-swap flow. func (s *Server) Commit(next *config.Config) error { return s.commit(next) } // parseConfigYAML decodes a config document on top of the defaults, which is // what makes a partial document from the UI editor work. func parseConfigYAML(text string) (*config.Config, error) { next := config.Default() if err := yaml.Unmarshal([]byte(text), next); err != nil { return nil, err } return next, nil } // configErrorFields splits a joined validation error into individual messages. func configErrorFields(err error) []string { if err == nil { return nil } msg := err.Error() parts := strings.Split(msg, "\n") out := make([]string, 0, len(parts)) for _, p := range parts { if p = strings.TrimSpace(p); p != "" { out = append(out, p) } } if len(out) == 0 { out = append(out, msg) } sort.Strings(out) return out } // fieldOf extracts the leading dotted path from a validation message, e.g. // "blocklist.lists[2]: name is required" becomes "blocklist.lists[2]". func fieldOf(msg string) string { i := strings.Index(msg, ":") if i <= 0 { return "config" } if f := strings.TrimSpace(msg[:i]); f != "" { return f } return "config" } // configWarnings reports non-fatal configuration smells. A config can be valid // and still a bad idea, and this is where a validator earns its keep. func configWarnings(cfg *config.Config) []string { var out []string add := func(f string, a ...any) { out = append(out, fmt.Sprintf(f, a...)) } if cfg.API.Auth.Mode == "none" { add("api.auth.mode: the admin API is unauthenticated; anyone who can reach it can rewrite your config") } if !cfg.DNS.DoT.Enabled && !cfg.DNS.DoH.Enabled { add("dns: neither DoT nor DoH is enabled, so clients have no encrypted transport") } if cfg.Cache.Enabled && !cfg.Cache.Persist.Enabled { add("cache.persist.enabled: the cache will be cold after every restart") } if cfg.Cache.MaxTTL.D() > 24*time.Hour { add("cache.max_ttl: caching for more than a day serves records upstream has already forgotten") } if !cfg.Block.Enabled { add("blocklist.enabled: blocklists are off, so this instance is an open resolver for everything") } else if len(cfg.Block.Lists) == 0 && len(cfg.Block.LocalZones) == 0 && len(cfg.Block.BlockEntries) == 0 { add("blocklist: enabled but no source is configured") } for _, l := range cfg.Block.Lists { if strings.HasPrefix(l.URL, "http://") { add("blocklist.lists[%s]: the source is fetched over plain http and can be tampered with in transit", l.Name) } if l.Refresh.D() > 0 && l.Refresh.D() < time.Hour { add("blocklist.lists[%s]: a refresh interval under an hour is likely to get you rate-limited", l.Name) } if strings.EqualFold(l.Format, "rpz") && strings.ContainsAny(l.Path, "*?") { add("blocklist.lists[%s]: an rpz source should point at a zone file, not a pattern", l.Name) } } if cfg.ACL.DefaultPolicy == "allow" { for _, g := range cfg.ACL.Groups { if g.Match.Any { add("acl.groups[%s]: a catch-all group with policy allow makes the ACL a no-op", g.Name) } } } if len(cfg.Upstream.Servers) == 1 { add("upstream.servers: only one upstream is configured, so a failure of that resolver is an outage") } for _, u := range cfg.Upstream.Servers { if u.InsecureSkipVerify { add("upstream.servers[%s]: insecure_skip_verify disables TLS validation", u.Addr) } } if cfg.AccessLog.Enabled && cfg.AccessLog.SampleRate == 1 { add("access_log: sample_rate 1 logs every query; that is for short debugging sessions, the metrics already count ordinary traffic") } if cfg.Metrics.Enabled && !cfg.Metrics.Protect { add("metrics.protect: /metrics is unauthenticated and exposes client and domain statistics") } return out }