Files
matsandClaude Sonnet 5.5 7fa48bcdf9 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>
2026-10-16 10:56:21 +02:00

312 lines
13 KiB
Go

package app
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/miekg/dns"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/dynamic"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/console"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/kubeedit"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/kubesource"
)
// TestConsoleEditsReachTheResolver runs both halves: a console with its own
// authentication writing objects, and a resolver watching the same cluster.
// They share nothing but the (fake) apiserver and the resolver's admin API,
// which is the whole deployment contract.
func TestConsoleEditsReachTheResolver(t *testing.T) {
cluster := kubeClient()
h := newHarness(t, func(c *config.Config) {
// A group the resolver's own config file defines, which the console must
// show but not rewrite.
c.ACL.Groups = []config.Group{{Name: "from-file", Policy: "allow", Match: config.Match{CIDRs: []string{"198.51.100.0/24"}}}}
}, func(o *Options) {
o.Kube = KubeOptions{Enabled: true, Namespace: "dns", Client: cluster, SyncTimeout: 5 * time.Second}
})
ts := startConsole(t, h, cluster)
do := func(method, path, body, key string) (int, string) {
t.Helper()
req, _ := http.NewRequest(method, ts.URL+path, strings.NewReader(body))
if key != "" {
req.Header.Set("X-API-Key", key)
}
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
b, _ := io.ReadAll(res.Body)
return res.StatusCode, string(b)
}
ok := func(method, path, body string) string {
t.Helper()
code, out := do(method, path, body, "console-key")
if code != http.StatusOK {
t.Fatalf("%s %s = %d: %s", method, path, code, out)
}
return out
}
// The console's authentication guards the resolver's API too: the
// resolver's own key is not accepted here, and no key is not either.
for _, key := range []string{"", h.key} {
if code, _ := do("GET", "/api/status", "", key); code != http.StatusUnauthorized {
t.Errorf("key %q reached the proxy: %d", key, code)
}
}
// With the console's key, the resolver's own data comes through.
var status map[string]any
if err := json.Unmarshal([]byte(ok("GET", "/api/status", "")), &status); err != nil || status["version"] != "test" {
t.Fatalf("proxied status = %v (%v)", status, err)
}
// The config file's group is listed, marked as not editable here, and a
// change to it is refused with the reason rather than quietly ignored.
var pre struct {
Groups []struct{ Name, Source string } `json:"groups"`
Default *bool `json:"default_policy_editable"`
Files *bool `json:"file_groups_editable"`
}
if err := json.Unmarshal([]byte(ok("GET", "/api/clients", "")), &pre); err != nil || len(pre.Groups) != 1 || pre.Groups[0].Name != "from-file" || pre.Groups[0].Source != "" {
t.Fatalf("clients before any edit = %+v (%v)", pre, err)
}
if pre.Default == nil || *pre.Default || pre.Files == nil || *pre.Files {
t.Errorf("the console must say what it cannot edit: %+v", pre)
}
if code, out := do("PUT", "/api/clients", `{"groups":[{"name":"from-file","policy":"deny","match":{"cidrs":["198.51.100.0/24"]}}]}`, "console-key"); code != http.StatusBadRequest || !strings.Contains(out, "config file") {
t.Errorf("changing a config-file group = %d %s, want 400 naming the config file", code, out)
}
if code, out := do("PUT", "/api/clients", `{"groups":[],"default_policy":"deny"}`, "console-key"); code != http.StatusBadRequest || !strings.Contains(out, "default_policy") {
t.Errorf("changing the default policy = %d %s, want 400", code, out)
}
// Sending the file's group back unchanged, as the page does, is fine and
// writes nothing for it.
ok("PUT", "/api/clients", `{"groups":[{"name":"from-file","policy":"allow","match":{"cidrs":["198.51.100.0/24"]}}]}`)
if l, _ := cluster.Resource(kubesource.GroupGVR).Namespace("dns").List(context.Background(), metav1.ListOptions{}); len(l.Items) != 0 {
t.Fatalf("echoing the file's group created %d objects", len(l.Items))
}
// A group saved in the console becomes a ClientGroup, then policy.
ok("PUT", "/api/clients", `{"groups":[{"name":"from-file","policy":"allow","match":{"cidrs":["198.51.100.0/24"]}},{"name":"no-loopback","policy":"deny","match":{"cidrs":["127.0.0.0/8"]}}]}`)
type clients struct {
Groups []struct{ Name, Source string } `json:"groups"`
}
// Read back at once, before the resolver can have seen it.
var view clients
if err := json.Unmarshal([]byte(ok("GET", "/api/clients", "")), &view); err != nil || len(view.Groups) != 2 || view.Groups[1].Name != "no-loopback" {
t.Fatalf("clients right after save = %+v (%v)", view, err)
}
eventually(t, "the resolver to apply the saved group", func() bool {
return h.query("www.example.test", dns.TypeA).Rcode == dns.RcodeRefused
})
// A group the console just removed disappears from its own view at once.
ok("PUT", "/api/clients", `{"groups":[]}`)
view = clients{}
raw := ok("GET", "/api/clients", "")
if err := json.Unmarshal([]byte(raw), &view); err != nil || len(view.Groups) != 1 || view.Groups[0].Name != "from-file" {
t.Fatalf("clients after delete = %+v (%v)", view, err)
}
eventually(t, "the resolver to drop the deleted group", func() bool {
return h.query("www.example.test", dns.TypeA).Rcode == dns.RcodeSuccess
})
// A bad group is refused with a message and changes nothing.
if code, out := do("PUT", "/api/clients", `{"groups":[{"name":"x","policy":"maybe","match":{"cidrs":["10.0.0.0/8"]}}]}`, "console-key"); code != http.StatusBadRequest || !strings.Contains(out, "policy") {
t.Errorf("bad group = %d %s", code, out)
}
// A zone built record by record in the console is served by the resolver.
ok("POST", "/api/zones/records", `{"zone":"edit.test.","name":"@","type":"NS","value":"ns1.edit.test."}`)
ok("POST", "/api/zones/records", `{"zone":"edit.test.","name":"www","type":"A","ttl":60,"value":"10.7.7.7"}`)
eventually(t, "the resolver to serve the new zone", func() bool {
m := h.query("www.edit.test", dns.TypeA)
if len(m.Answer) != 1 {
return false
}
a, isA := m.Answer[0].(*dns.A)
return isA && a.A.String() == "10.7.7.7" && m.Authoritative
})
// The zone page is told it can edit, and that this zone is editable.
var zones struct {
Writable bool `json:"writable"`
Zones []struct {
Origin string `json:"origin"`
Managed bool `json:"managed"`
} `json:"zones"`
}
eventually(t, "the zone to appear in the zone view", func() bool {
zones.Zones = nil
_ = json.Unmarshal([]byte(ok("GET", "/api/zones", "")), &zones)
for _, z := range zones.Zones {
if z.Origin == "edit.test." {
return z.Managed
}
}
return false
})
if !zones.Writable {
t.Error("zones are not reported writable")
}
ok("DELETE", "/api/zones?zone=edit.test.", "")
eventually(t, "the deleted zone to stop answering", func() bool {
m := h.query("www.edit.test", dns.TypeA)
return !m.Authoritative
})
// Blocklist sources: BlockList objects the Blocklists page manages.
ok("PUT", "/api/blocklist-sources/test-list", `{"url":"https://example.invalid/hosts","format":"hosts","refresh":"12h","categories":["ads"]}`)
var srcs struct {
Sources []struct {
Name string `json:"name"`
Spec struct {
URL string `json:"url"`
Refresh string `json:"refresh"`
Disabled bool `json:"disabled"`
Cats []string `json:"categories"`
} `json:"spec"`
} `json:"sources"`
}
if err := json.Unmarshal([]byte(ok("GET", "/api/blocklist-sources", "")), &srcs); err != nil || len(srcs.Sources) != 1 ||
srcs.Sources[0].Name != "test-list" || srcs.Sources[0].Spec.Refresh != "12h" {
t.Fatalf("sources right after save = %+v (%v)", srcs, err)
}
// The toggle on the page turns the object off and back on.
ok("POST", "/api/blocklists/toggle", `{"name":"test-list","enabled":false}`)
srcs.Sources = nil
_ = json.Unmarshal([]byte(ok("GET", "/api/blocklist-sources", "")), &srcs)
if len(srcs.Sources) != 1 || !srcs.Sources[0].Spec.Disabled {
t.Errorf("the toggle did not disable the BlockList: %+v", srcs)
}
for body, why := range map[string]string{
`{"url":"file:///etc/passwd"}`: "a file URL",
`{"url":"https://x/y","format":"yaml"}`: "an unknown format",
`{"url":"https://x/y","refresh":"soon"}`: "an unparsable interval",
} {
if code, out := do("PUT", "/api/blocklist-sources/bad-list", body, "console-key"); code != http.StatusBadRequest {
t.Errorf("%s was accepted: %d %s", why, code, out)
}
}
if code, _ := do("PUT", "/api/blocklist-sources/Not_A_Name", `{"url":"https://x/y"}`, "console-key"); code != http.StatusBadRequest {
t.Errorf("a name that is not a Kubernetes object name = %d, want 400", code)
}
ok("DELETE", "/api/blocklist-sources/test-list", "")
if code, _ := do("DELETE", "/api/blocklist-sources/test-list", "", "console-key"); code != http.StatusNotFound {
t.Errorf("deleting a list twice = %d, want 404", code)
}
// Address redirects: the page that used to rewrite a ConfigMap.
ok("PUT", "/api/address-overrides", `{"domain":"pin.test","address":"10.7.7.1"}`)
var ov struct {
Items []struct{ Domain, Address string } `json:"items"`
}
if err := json.Unmarshal([]byte(ok("GET", "/api/address-overrides", "")), &ov); err != nil || len(ov.Items) != 1 || ov.Items[0].Address != "10.7.7.1" {
t.Fatalf("overrides right after save = %+v (%v)", ov, err)
}
eventually(t, "the resolver to answer the override", func() bool {
m := h.query("pin.test", dns.TypeA)
a, isA := dnsFirstA(m)
return isA && a == "10.7.7.1"
})
ok("DELETE", "/api/address-overrides/pin.test", "")
eventually(t, "the deleted override to stop answering", func() bool {
a, isA := dnsFirstA(h.query("pin.test", dns.TypeA))
return isA && a == "203.0.113.7"
})
// Domains: inline rules become DomainRule objects.
ok("POST", "/api/domains", `{"name":"console-blocked.test","action":"block"}`)
ok("POST", "/api/domains", `{"name":"x","action":"block","content":"# a comment\nbulk-one.test\n\nbulk-two.test"}`)
eventually(t, "the resolver to block the new rules", func() bool {
return h.query("console-blocked.test", dns.TypeA).Rcode == dns.RcodeNameError &&
h.query("bulk-two.test", dns.TypeA).Rcode == dns.RcodeNameError
})
if code, out := do("POST", "/api/domains", `{"name":"x.test","action":"redirect"}`, "console-key"); code != http.StatusBadRequest || !strings.Contains(out, "block or allow") {
t.Errorf("unsupported action = %d %s", code, out)
}
if code, _ := do("POST", "/api/domains", `{"name":"a.test\nb.test","action":"block"}`, "console-key"); code != http.StatusBadRequest {
t.Errorf("a name with a newline = %d, want 400", code)
}
ok("DELETE", "/api/domains/console-blocked.test", "")
eventually(t, "the removed rule to stop blocking", func() bool {
return h.query("console-blocked.test", dns.TypeA).Rcode == dns.RcodeSuccess
})
if code, _ := do("DELETE", "/api/domains/never-added.test", "", "console-key"); code != http.StatusNotFound {
t.Errorf("deleting an unknown rule = %d, want 404", code)
}
}
func dnsFirstA(m *dns.Msg) (string, bool) {
if len(m.Answer) == 0 {
return "", false
}
a, ok := m.Answer[0].(*dns.A)
if !ok {
return "", false
}
return a.A.String(), true
}
// startConsole runs a console, with its own authentication, in front of the
// resolver in h and writing to cluster. Its API key is "console-key".
func startConsole(t *testing.T, h *harness, cluster dynamic.Interface) *httptest.Server {
t.Helper()
rt, err := console.NewRuntime("http://"+h.api, h.key)
if err != nil {
t.Fatal(err)
}
cfg := config.Default()
cfg.API.Auth.Mode = "apikey"
cfg.API.Auth.APIKeys = []string{"console-key"}
cfg.API.Auth.TrustLoopback = false
cfg.API.UI = false
c, err := console.New(console.Options{
Config: config.NewStore(cfg, ""),
Runtime: rt,
Editor: kubeedit.New(cluster, "dns"),
Version: "test",
})
if err != nil {
t.Fatal(err)
}
handler, err := c.Handler()
if err != nil {
t.Fatal(err)
}
ts := httptest.NewServer(handler)
t.Cleanup(ts.Close)
return ts
}
// An empty list is [] on the wire, not null: the page counts what it is given.
func TestConsoleEmptyGroupListIsAnArray(t *testing.T) {
cluster := kubeClient()
h := newHarness(t, nil, func(o *Options) {
o.Kube = KubeOptions{Enabled: true, Namespace: "dns", Client: cluster, SyncTimeout: 5 * time.Second}
})
ts := startConsole(t, h, cluster)
req, _ := http.NewRequest("GET", ts.URL+"/api/clients", nil)
req.Header.Set("X-API-Key", "console-key")
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
if !strings.Contains(string(body), `"groups":[]`) {
t.Errorf("an empty group list must be [] and not null: %s", body)
}
}