initial commit
This commit is contained in:
1 parent
5b935301e5
commit
85453852d5
105 files changed
+24846
No files matched your search
@@ -0,0 +1,701 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user