Cluster UI: Blocklists management, locked config-file items, read-only upstreams; honour allow_config_write

The console now works as the cluster's editor, without the monitoring pages.

Web: Blocklists gets Add/Edit/Remove for BlockList objects (and shows ones the
resolver has not compiled yet); Clients marks cluster groups and locks config-file
groups and the default policy; Upstreams becomes a read-only health view when the
config cannot be written; Zones and Domains lose the managed-directory and list
copy; the Local zones page and its API calls are removed; nav is Status,
Blocklists, Domains, Lookup, Address redirects, Zones, Clients, Upstreams, Settings.

Console: a change to a group the resolver's config file defines is refused with the
reason instead of being skipped silently, and /api/clients says what the console
can and cannot edit.

Server: isFileWritable called a read-only existing file (a mounted ConfigMap)
writable, and api.allow_config_write was not reflected in the writable flag nor
checked by the upstream/ACL edit path. Both fixed; tests pin them.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
matsandClaude Sonnet 5.5 committed 2026-10-16 10:56:21 +02:00
1 parent 7d58b80583
commit 7fa48bcdf9
18 files changed
+726 -404

No files matched your search

+43
View File
@@ -4,6 +4,8 @@ import (
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -789,3 +791,44 @@ func TestConcurrentQueries(t *testing.T) {
}
}
}
// TestConfigWritesFollowAllowConfigWrite: with api.allow_config_write off, which
// is how the cluster deployment runs, nothing may rewrite the config file, and
// the console is told so (config.writable) instead of offering editors whose
// every save fails. The upstream editor used to ignore the setting.
func TestConfigWritesFollowAllowConfigWrite(t *testing.T) {
h := newHarness(t, func(c *config.Config) { c.API.AllowConfigWrite = false })
before := h.OriginalConfig()
_, body := h.get("/api/status")
var st struct {
Config struct {
Writable bool `json:"writable"`
} `json:"config"`
}
if err := json.Unmarshal(body, &st); err != nil || st.Config.Writable {
t.Errorf("status config.writable = %v (%v), want false", st.Config.Writable, err)
}
_, body = h.get("/api/settings")
var settings struct {
Writable bool `json:"writable"`
}
if err := json.Unmarshal(body, &settings); err != nil || settings.Writable {
t.Errorf("settings writable = %v (%v), want false", settings.Writable, err)
}
up := fmt.Sprintf(`{"policy":"failover","servers":[{"addr":%q,"proto":"udp","weight":1}],"max_attempts":1,"breaker_threshold":5,"healthcheck_interval_s":0}`, h.up.addr)
if code, out := h.do("PUT", "/api/upstreams", up); code != http.StatusBadRequest || !strings.Contains(string(out), "allow_config_write") {
t.Errorf("PUT /api/upstreams = %d %s, want a 400 naming the setting", code, out)
}
if code, _ := h.do("PUT", "/api/clients", `{"groups":[]}`); code != http.StatusBadRequest {
t.Errorf("PUT /api/clients = %d, want 400", code)
}
if code, _ := h.do("PUT", "/api/settings", `{"config_yaml":"x: 1"}`); code != http.StatusForbidden {
t.Errorf("PUT /api/settings = %d, want 403", code)
}
raw, err := os.ReadFile(filepath.Join(h.dir, "config.yaml"))
if err != nil || string(raw) != before {
t.Errorf("the config file changed (%v)", err)
}
}