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:
1 parent
7d58b80583
commit
7fa48bcdf9
18 files changed
+726
-404
No files matched your search
@@ -1,6 +1,7 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -10,10 +11,13 @@ import (
|
||||
"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
|
||||
@@ -22,34 +26,15 @@ import (
|
||||
// which is the whole deployment contract.
|
||||
func TestConsoleEditsReachTheResolver(t *testing.T) {
|
||||
cluster := kubeClient()
|
||||
h := newHarness(t, nil, func(o *Options) {
|
||||
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}
|
||||
})
|
||||
|
||||
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)
|
||||
ts := startConsole(t, h, cluster)
|
||||
|
||||
do := func(method, path, body, key string) (int, string) {
|
||||
t.Helper()
|
||||
@@ -88,14 +73,40 @@ func TestConsoleEditsReachTheResolver(t *testing.T) {
|
||||
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":"no-loopback","policy":"deny","match":{"cidrs":["127.0.0.0/8"]}}]}`)
|
||||
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) != 1 || view.Groups[0].Name != "no-loopback" {
|
||||
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 {
|
||||
@@ -105,10 +116,7 @@ func TestConsoleEditsReachTheResolver(t *testing.T) {
|
||||
ok("PUT", "/api/clients", `{"groups":[]}`)
|
||||
view = clients{}
|
||||
raw := ok("GET", "/api/clients", "")
|
||||
if !strings.Contains(raw, `"groups":[]`) {
|
||||
t.Errorf("an empty group list must be [] and not null: %s", raw)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &view); err != nil || len(view.Groups) != 0 {
|
||||
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 {
|
||||
@@ -158,6 +166,47 @@ func TestConsoleEditsReachTheResolver(t *testing.T) {
|
||||
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 {
|
||||
@@ -209,3 +258,54 @@ func dnsFirstA(m *dns.Msg) (string, bool) {
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user