702 lines
22 KiB
Go
702 lines
22 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"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(),
|
|
})
|
|
}
|
|
|
|
// handleDashboard serves the aggregated dashboard payload.
|
|
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
|
if s.hooks.Dashboard == nil {
|
|
writeError(w, http.StatusNotImplemented, "dashboard not available")
|
|
return
|
|
}
|
|
v, err := s.hooks.Dashboard(r.Context(), r.URL.Query().Get("range"))
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, v)
|
|
}
|
|
|
|
// 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 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)
|
|
}
|
|
|
|
// 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. Health fields
|
|
// from the UI are ignored on write: the server owns them.
|
|
type upstreamPayload struct {
|
|
Policy string `json:"policy"`
|
|
Servers []config.UpstreamServer `json:"servers"`
|
|
Bootstraps []config.UpstreamBootstrap `json:"bootstraps"`
|
|
HealthCheckSeconds float64 `json:"healthcheck_interval_s"`
|
|
MaxAttempts int `json:"max_attempts"`
|
|
BreakerThreshold int `json:"breaker_threshold"`
|
|
}
|
|
|
|
// 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
|
|
up := config.Upstream{
|
|
Policy: in.Policy,
|
|
Servers: in.Servers,
|
|
Bootstraps: in.Bootstraps,
|
|
MaxAttempts: in.MaxAttempts,
|
|
HealthCheck: config.Duration(in.HealthCheckSeconds * float64(time.Second)),
|
|
BreakerThreshold: in.BreakerThreshold,
|
|
BreakerCooldown: cur.BreakerCooldown,
|
|
}
|
|
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})
|
|
}
|
|
|
|
// handleLogs pages the query log.
|
|
func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) {
|
|
if s.hooks.SearchLog == nil {
|
|
writeError(w, http.StatusNotImplemented, "query log not available")
|
|
return
|
|
}
|
|
q := LogQuery{
|
|
Limit: queryInt(r, "limit", 200),
|
|
Offset: queryInt(r, "offset", 0),
|
|
Text: r.URL.Query().Get("q"),
|
|
Action: r.URL.Query().Get("action"),
|
|
Client: r.URL.Query().Get("client"),
|
|
RCode: queryInt(r, "rcode", 0),
|
|
BlockedOnly: queryBool(r, "blocked"),
|
|
Since: queryTime(r, "since"),
|
|
}
|
|
v, err := s.hooks.SearchLog(r.Context(), q)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, v)
|
|
}
|
|
|
|
// handleLogStream is the Server-Sent Events endpoint feeding the live view.
|
|
func (s *Server) handleLogStream(w http.ResponseWriter, r *http.Request) {
|
|
flusher, ok := w.(http.Flusher)
|
|
if !ok {
|
|
writeError(w, http.StatusInternalServerError, "streaming unsupported")
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.Header().Set("Connection", "keep-alive")
|
|
// Without this, a reverse proxy buffers the stream and the UI shows
|
|
// nothing until the buffer fills.
|
|
w.Header().Set("X-Accel-Buffering", "no")
|
|
w.WriteHeader(http.StatusOK)
|
|
flusher.Flush()
|
|
|
|
send := func(event string, v any) bool {
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
return true
|
|
}
|
|
// A single-line data field is what the SSE grammar requires, so the
|
|
// payload is compact JSON with no newlines (json.Marshal never emits
|
|
// a raw newline).
|
|
if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, b); err != nil {
|
|
return false
|
|
}
|
|
flusher.Flush()
|
|
return true
|
|
}
|
|
// An initial event tells the client the stream is live, so the UI can show
|
|
// a "connected" state before any query happens.
|
|
if !send("ready", map[string]any{"at": time.Now(), "version": s.hooks.Version}) {
|
|
return
|
|
}
|
|
|
|
var (
|
|
entries <-chan LogEntry
|
|
cancel func()
|
|
)
|
|
if s.hooks.SubscribeLog != nil {
|
|
entries, cancel = s.hooks.SubscribeLog()
|
|
defer cancel()
|
|
}
|
|
|
|
stats := time.NewTicker(time.Second)
|
|
defer stats.Stop()
|
|
ping := time.NewTicker(20 * time.Second)
|
|
defer ping.Stop()
|
|
|
|
ctx := r.Context()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case e, ok := <-entries:
|
|
if !ok {
|
|
return
|
|
}
|
|
if !send("entry", e) {
|
|
return
|
|
}
|
|
case <-stats.C:
|
|
if s.hooks.LogStats == nil {
|
|
continue
|
|
}
|
|
if !send("stats", s.hooks.LogStats()) {
|
|
return
|
|
}
|
|
case <-ping.C:
|
|
// A comment frame keeps proxies from timing the stream out.
|
|
if _, err := fmt.Fprint(w, ": ping\n\n"); err != nil {
|
|
return
|
|
}
|
|
flusher.Flush()
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleLogsClear empties the query log.
|
|
func (s *Server) handleLogsClear(w http.ResponseWriter, _ *http.Request) {
|
|
if s.hooks.ClearLog == nil {
|
|
writeError(w, http.StatusNotImplemented, "query log not available")
|
|
return
|
|
}
|
|
if err := s.hooks.ClearLog(); err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
|
}
|
|
|
|
// 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())
|
|
}
|
|
|
|
// handleCacheTop returns the hottest cached names.
|
|
func (s *Server) handleCacheTop(w http.ResponseWriter, r *http.Request) {
|
|
if s.hooks.CacheTop == nil {
|
|
writeError(w, http.StatusNotImplemented, "cache not available")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"items": s.hooks.CacheTop(queryInt(r, "limit", 50)),
|
|
})
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
cur := s.hooks.Store.Get()
|
|
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.QueryLog.Enabled && cfg.QueryLog.SampleRate == 1 && cfg.QueryLog.LogCached {
|
|
add("qlog: logging every cached query on a busy instance is a lot of disk for little value; consider lowering sample_rate")
|
|
}
|
|
if cfg.Metrics.Enabled && !cfg.Metrics.Protect {
|
|
add("metrics.protect: /metrics is unauthenticated and exposes client and domain statistics")
|
|
}
|
|
return out
|
|
}
|