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

+31 -4
View File
@@ -2,6 +2,7 @@ package console
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
@@ -52,12 +53,27 @@ func (c *Console) health() any {
// reads.
type clientsView struct {
Groups []struct {
Name string `json:"name"`
config.Group
Source string `json:"source"`
} `json:"groups"`
DefaultPolicy string `json:"default_policy"`
}
// sameGroup compares two groups by what they configure, so an empty list and a
// missing one are the same.
func sameGroup(a, b config.Group) bool {
norm := func(g config.Group) string {
for _, p := range []*[]string{&g.Match.CIDRs, &g.Match.PTRSuffixes, &g.Match.Tags, &g.AllowDomains, &g.DenyDomains, &g.Upstreams} {
if *p == nil {
*p = []string{}
}
}
out, _ := json.Marshal(g)
return string(out)
}
return norm(a) == norm(b)
}
func (c *Console) putACL(ctx context.Context, groups []config.Group, def string) (int64, error) {
var view clientsView
if err := c.opt.Runtime.Get(ctx, "/api/clients", &view); err != nil {
@@ -69,13 +85,21 @@ func (c *Console) putACL(ctx context.Context, groups []config.Group, def string)
if def != "" && def != view.DefaultPolicy {
return 0, fmt.Errorf("default_policy is resolver configuration (currently %q), not a ClientGroup; change it in the resolver's config", view.DefaultPolicy)
}
inFile := map[string]bool{}
inFile := map[string]config.Group{}
for _, g := range view.Groups {
if g.Source == "" {
inFile[g.Name] = true
inFile[g.Name] = g.Group
}
}
res, err := c.opt.Editor.SyncGroups(ctx, groups, func(n string) bool { return inFile[n] }, actor(ctx))
// The console writes cluster objects. A group the resolver's config file
// defines is left alone, and a change to it is refused, because skipping it
// quietly would make the save look as if it worked.
for _, g := range groups {
if have, ok := inFile[g.Name]; ok && !sameGroup(have, g) {
return 0, fmt.Errorf("group %q is defined in the resolver's config file; change it there", g.Name)
}
}
res, err := c.opt.Editor.SyncGroups(ctx, groups, func(n string) bool { _, ok := inFile[n]; return ok }, actor(ctx))
if err != nil {
return 0, err
}
@@ -209,6 +233,9 @@ func (c *Console) handleClients(w http.ResponseWriter, r *http.Request) {
merged = append(merged, groupJSON(o))
}
view["groups"] = merged
// What this editor can and cannot change, so the page can say so.
view["default_policy_editable"] = false
view["file_groups_editable"] = false
api.WriteJSON(w, http.StatusOK, view)
}