diff --git a/AGENTS.md b/AGENTS.md index b112d31..23202b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,6 +32,8 @@ internal/ qlog/ bbolt query-log ring + live in-memory ring + SSE fanout metrics/ Prometheus collectors + rolling time-series rings cluster/ Valkey pub/sub, leader lease, divergence detection + kubeedit/ the console's writes to ClientGroup/BlockList/DNSZone objects + console/ the UI/edit half: auth, cluster writes, proxy to the resolver kubesource/ read-only watch of the fancydns.k6n.net CRDs (DNSZone, ClientGroup, BlockList) -> Snapshot -> config overlay api/ admin REST + SSE, auth, CORS, rate limiting @@ -115,6 +117,17 @@ Break these and you break the design, not just the test. skipped and keeps its last good version, it never fails the merge. A BlockList has no `path` and refuses `file://`, because a namespaced object must not choose a file the runtime reads or writes. +- **The resolver and the console are separate binaries and only the console + writes the cluster.** `cmd/fancy-dns-console` serves the UI, authenticates, + writes objects through `kubeedit` (validated with the runtime's own + `kubesource.Check*`, so it cannot store what the runtime would reject) and + proxies every other `/api` route to the resolver with its own key, dropping + the browser's credentials. `kubeedit` is never imported by the resolver and + `kubesource` never writes. Reads that mix the two views (`/api/clients`) are + made consistent with the cluster in the console, because the resolver only + sees an edit after its watch fires: a save made just after another would + otherwise delete the first. `source` on a group says where it is defined; + `history` rows are counters, never groups, and are not sent to the UI. - **API keys are compared by SHA-256 hash, constant-time**, and `X-Forwarded-For` is honoured *only* from `trusted_proxies`. Honouring it from anyone lets a client spoof its identity past the ACL and the rate limiter. diff --git a/Dockerfile b/Dockerfile index 4c40eaa..57fa443 100644 --- a/Dockerfile +++ b/Dockerfile @@ -53,6 +53,13 @@ RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ -ldflags "-s -w -X git.k8s-git-k6n.net/mats/fancy-dns/internal/app.Version=${VERSION}" \ -o /out/fancy-dns ./cmd/fancy-dns +# The console is a separate binary in the same image: run it with +# `command: ["/usr/local/bin/fancy-dns-console"]`. It shares the embedded UI. +RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ + go build -trimpath \ + -ldflags "-s -w -X git.k8s-git-k6n.net/mats/fancy-dns/internal/app.Version=${VERSION}" \ + -o /out/fancy-dns-console ./cmd/fancy-dns-console + # ---- stage 3: runtime ------------------------------------------------------ FROM ${RUNTIME_IMAGE} @@ -66,6 +73,7 @@ EXPOSE 8080/tcp VOLUME ["/var/lib/fancy-dns"] COPY --from=build /out/fancy-dns /usr/local/bin/fancy-dns +COPY --from=build /out/fancy-dns-console /usr/local/bin/fancy-dns-console COPY --from=build /src/deploy/config.example.yaml /etc/fancy-dns/config.example.yaml COPY --from=build /src/deploy/zones/ /etc/fancy-dns/zones/ diff --git a/Makefile b/Makefile index 615e987..c20fed2 100644 --- a/Makefile +++ b/Makefile @@ -37,11 +37,13 @@ help: ## Show this help build: web ## Build the binary with the console embedded @mkdir -p $(DIST) CGO_ENABLED=0 $(GO) build -trimpath -ldflags '$(LDFLAGS)' -o $(DIST)/$(BINARY) $(CMD) - @echo "built $(DIST)/$(BINARY) ($(VERSION))" + CGO_ENABLED=0 $(GO) build -trimpath -ldflags '$(LDFLAGS)' -o $(DIST)/$(BINARY)-console ./cmd/fancy-dns-console + @echo "built $(DIST)/$(BINARY) and $(DIST)/$(BINARY)-console ($(VERSION))" .PHONY: go-build go-build: ## Build without the console (fast; used by the Go test loop) CGO_ENABLED=0 $(GO) build -trimpath -ldflags '$(LDFLAGS)' -o $(DIST)/$(BINARY)-noassets $(CMD) + CGO_ENABLED=0 $(GO) build -trimpath -ldflags '$(LDFLAGS)' -o $(DIST)/$(BINARY)-console-noassets ./cmd/fancy-dns-console .PHONY: install install: ## Install into GOBIN diff --git a/cmd/fancy-dns-console/main.go b/cmd/fancy-dns-console/main.go new file mode 100644 index 0000000..abf4acc --- /dev/null +++ b/cmd/fancy-dns-console/main.go @@ -0,0 +1,138 @@ +// Command fancy-dns-console is the editing half of fancy-dns: the web UI and +// the API behind it, writing ClientGroup, BlockList and DNSZone objects to +// Kubernetes and forwarding everything else to a running resolver. +// +// It can run in the cluster or on a workstation (with a kubeconfig); the +// resolver it manages need not be in the same place. +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "log/slog" + "net" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "git.k8s-git-k6n.net/mats/fancy-dns/internal/app" + "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" +) + +func main() { + var ( + configPath = flag.String("config", envOr("FANCYDNS_CONSOLE_CONFIG", "/etc/fancy-dns/console.yaml"), "console configuration (api: listen, auth, ui)") + runtimeURL = flag.String("runtime-url", os.Getenv("FANCYDNS_RUNTIME_URL"), "admin API of the resolver, e.g. http://fancy-dns.dns:9053") + kubeconfig = flag.String("kubeconfig", os.Getenv("FANCYDNS_KUBECONFIG"), "kubeconfig (default: in-cluster service account, then $KUBECONFIG, then ~/.kube/config)") + kubeContext = flag.String("kube-context", os.Getenv("FANCYDNS_KUBE_CONTEXT"), "kubeconfig context") + kubeNS = flag.String("kube-namespace", os.Getenv("FANCYDNS_KUBE_NAMESPACE"), "namespace the objects live in (default: the pod's own)") + showVersion = flag.Bool("version", false, "print the version and exit") + checkOnly = flag.Bool("check", false, "validate the configuration and exit") + ) + flag.Parse() + if *showVersion { + fmt.Println("fancy-dns-console", app.Version) + return + } + if err := run(*configPath, *runtimeURL, *kubeconfig, *kubeContext, *kubeNS, *checkOnly); err != nil { + fmt.Fprintln(os.Stderr, "fancy-dns-console:", err) + os.Exit(1) + } +} + +func run(configPath, runtimeURL, kubeconfig, kubeContext, ns string, checkOnly bool) error { + cfg, err := config.Load(configPath) + if err != nil { + if strings.Contains(err.Error(), "blocklist is enabled") { + err = fmt.Errorf("%w (the console has no blocklists of its own: put \"blocklist: {enabled: false}\" in its config)", err) + } + return err + } + if runtimeURL == "" { + return errors.New("-runtime-url (or FANCYDNS_RUNTIME_URL) is required") + } + rt, err := console.NewRuntime(runtimeURL, os.Getenv("FANCYDNS_RUNTIME_KEY")) + if err != nil { + return err + } + if ns == "" { + ns = kubesource.Namespace() + } + if ns == "" { + return errors.New("-kube-namespace is required when not running in a pod") + } + client, from, err := kubesource.NewClient(kubesource.ClientOptions{Kubeconfig: kubeconfig, Context: kubeContext}) + if err != nil { + return err + } + + level := slog.LevelInfo + if cfg.Log.Level == "debug" { + level = slog.LevelDebug + } + log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level})).With("service", "fancy-dns-console", "version", app.Version) + + c, err := console.New(console.Options{ + Config: config.NewStore(cfg, configPath), + Runtime: rt, + Editor: kubeedit.New(client, ns), + Logger: log, + Version: app.Version, + }) + if err != nil { + return err + } + handler, err := c.Handler() + if err != nil { + return err + } + if checkOnly { + fmt.Println("configuration is valid:", configPath) + return nil + } + if len(cfg.API.TLSListen) > 0 { + log.Warn("api.tls_listen is ignored by the console; terminate TLS at an ingress or proxy") + } + if len(cfg.API.Listen) == 0 { + return errors.New("api.listen is empty: nowhere to serve the console") + } + + srv := &http.Server{Handler: handler, ReadHeaderTimeout: 15 * time.Second, IdleTimeout: 120 * time.Second} + errc := make(chan error, len(cfg.API.Listen)) + for _, addr := range cfg.API.Listen { + ln, err := net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf("bind %s: %w", addr, err) + } + log.Info("console listening", "addr", ln.Addr().String(), "namespace", ns, "kubernetes", from, "resolver", runtimeURL) + go func() { errc <- srv.Serve(ln) }() + } + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + select { + case <-ctx.Done(): + case err := <-errc: + if !errors.Is(err, http.ErrServerClosed) { + return err + } + } + sctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return srv.Shutdown(sctx) +} + +func envOr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} diff --git a/deploy/console.example.yaml b/deploy/console.example.yaml new file mode 100644 index 0000000..1ae7ebc --- /dev/null +++ b/deploy/console.example.yaml @@ -0,0 +1,17 @@ +# Console configuration for running fancy-dns-console on a workstation: +# +# export FANCYDNS_RUNTIME_KEY=... # a key the resolver's admin API accepts +# fancy-dns-console -config deploy/console.example.yaml \ +# -runtime-url http://127.0.0.1:8080 -kube-namespace dns +# +# Credentials for the cluster come from -kubeconfig / $KUBECONFIG / ~/.kube/config. +blocklist: {enabled: false} +api: + enabled: true + listen: ["127.0.0.1:8081"] + base_path: /api + ui: true + auth: + mode: apikey + api_keys: ["${FANCYDNS_CONSOLE_KEY}"] + trust_loopback: false diff --git a/deploy/k8s/console/console.yaml b/deploy/k8s/console/console.yaml new file mode 100644 index 0000000..9796b5e --- /dev/null +++ b/deploy/k8s/console/console.yaml @@ -0,0 +1,113 @@ +# The console: web UI plus the API behind it, as its own Deployment. It writes +# ClientGroup / BlockList / DNSZone objects and forwards everything else to the +# resolver's admin API. It is not on the DNS path; stopping it changes no answer. +# +# Secrets it needs (create them out of band): +# fancy-dns-console-runtime key FANCYDNS_RUNTIME_KEY : an API key the resolver accepts +# fancy-dns-console-apikey key FANCYDNS_API_KEY : the console's own machine key +apiVersion: v1 +kind: ConfigMap +metadata: + name: fancy-dns-console + namespace: dns +data: + console.yaml: | + # The console has no blocklists or listeners of its own. + blocklist: {enabled: false} + api: + enabled: true + listen: ["0.0.0.0:8080"] + base_path: /api + ui: true + auth: + mode: oidc + api_keys: ["${FANCYDNS_API_KEY}"] + oidc_issuer: https://users.k6n.net + oidc_audience: https://dns.k6n.net + oidc_redirect_uri: https://dns.k6n.net/auth/oidc/callback + oidc_client_id: fancy-dns + oidc_write_roles: [admin] + trust_loopback: false +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: fancy-dns-console + namespace: dns +--- +# The only component allowed to write the objects. +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: fancy-dns-console + namespace: dns +rules: + - apiGroups: [fancydns.k6n.net] + resources: [dnszones, clientgroups, blocklists] + verbs: [get, list, create, update, delete] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: fancy-dns-console + namespace: dns +subjects: + - {kind: ServiceAccount, name: fancy-dns-console, namespace: dns} +roleRef: {apiGroup: rbac.authorization.k8s.io, kind: Role, name: fancy-dns-console} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: fancy-dns-console + namespace: dns + labels: {app.kubernetes.io/name: fancy-dns-console} +spec: + # One replica: OIDC login state and sessions are in memory, as in the + # resolver's console. More than one needs sticky routing. + replicas: 1 + selector: + matchLabels: {app.kubernetes.io/name: fancy-dns-console} + template: + metadata: + labels: {app.kubernetes.io/name: fancy-dns-console} + spec: + serviceAccountName: fancy-dns-console + securityContext: + runAsNonRoot: true + runAsUser: 65532 + seccompProfile: {type: RuntimeDefault} + containers: + - name: console + image: fancydns/fancy-dns:latest + command: ["/usr/local/bin/fancy-dns-console"] + args: + - -config=/etc/fancy-dns/console.yaml + # A Service in front of the resolver pods. With several replicas the + # statistics and query log come from whichever pod answers. + - -runtime-url=http://fancy-dns-admin.dns.svc:8080 + env: + - name: FANCYDNS_RUNTIME_KEY + valueFrom: {secretKeyRef: {name: fancy-dns-console-runtime, key: FANCYDNS_RUNTIME_KEY}} + - name: FANCYDNS_API_KEY + valueFrom: {secretKeyRef: {name: fancy-dns-console-apikey, key: FANCYDNS_API_KEY}} + ports: [{name: http, containerPort: 8080}] + readinessProbe: + httpGet: {path: /api/healthz, port: http} + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: {drop: [ALL]} + volumeMounts: + - {name: config, mountPath: /etc/fancy-dns, readOnly: true} + volumes: + - name: config + configMap: {name: fancy-dns-console} +--- +apiVersion: v1 +kind: Service +metadata: + name: fancy-dns-console + namespace: dns +spec: + selector: {app.kubernetes.io/name: fancy-dns-console} + ports: [{name: http, port: 80, targetPort: http}] diff --git a/internal/acl/acl.go b/internal/acl/acl.go index 30d259d..0cdfa2b 100644 --- a/internal/acl/acl.go +++ b/internal/acl/acl.go @@ -368,6 +368,11 @@ type GroupSummary struct { BlockedCount uint64 `json:"blocked_count"` // Matched is populated by a dry-run lookup from the API. Matched bool `json:"matched"` + // Source names where the group is defined when it is not the config + // file: "kubernetes" for a ClientGroup object, "history" for a name that + // only has traffic counters (the "(default)" bucket, or a group removed + // since). Empty means the file. + Source string `json:"source,omitempty"` } // Summaries renders the engine's groups for the API. diff --git a/internal/api/extend.go b/internal/api/extend.go new file mode 100644 index 0000000..097b560 --- /dev/null +++ b/internal/api/extend.go @@ -0,0 +1,24 @@ +package api + +import "net/http" + +// The console is a second binary that serves this API's authentication and +// session handling in front of a different backend. These exports let it +// register routes of its own behind the same checks, without the api package +// knowing anything about the console. + +// Protect wraps h with this server's authentication, write-permission check +// and rate limit. write marks a mutating route that a read-only key must not +// reach. The authenticated Principal is available through PrincipalFrom. +func (s *Server) Protect(h http.Handler, write bool) http.Handler { + return s.authed(h.ServeHTTP, write) +} + +// WriteJSON answers with v as JSON, the way every handler here does. +func WriteJSON(w http.ResponseWriter, code int, v any) { writeJSON(w, code, v) } + +// WriteError answers with the standard {"error": msg} body. +func WriteError(w http.ResponseWriter, code int, msg string) { writeError(w, code, msg) } + +// DecodeJSON reads a size-limited JSON request body. +func DecodeJSON(r *http.Request, v any) error { return decodeJSON(r, v) } diff --git a/internal/app/console_e2e_test.go b/internal/app/console_e2e_test.go new file mode 100644 index 0000000..e3c537f --- /dev/null +++ b/internal/app/console_e2e_test.go @@ -0,0 +1,156 @@ +package app + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/miekg/dns" + + "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" +) + +// 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, nil, 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) + + 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) + } + + // 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"]}}]}`) + 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" { + 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{} + if err := json.Unmarshal([]byte(ok("GET", "/api/clients", "")), &view); err != nil || len(view.Groups) != 0 { + 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 + }) +} diff --git a/internal/app/contract_test.go b/internal/app/contract_test.go index 17aa5cd..ad1bba7 100644 --- a/internal/app/contract_test.go +++ b/internal/app/contract_test.go @@ -183,11 +183,14 @@ func TestAdminEndpointKeySets(t *testing.T) { // so every group card read "matches nothing" and the editor sent a // shape the server refused. `comment` stays omitempty: a group without // one must not carry a key the console would have to special-case. + // `source` is omitted for a group from the config file. It is present + // here because the queries above made groups[0] the "(default)" + // traffic bucket, which is reported as source "history". requireKeys(t, "GET /api/clients groups[0]", keysOfField(t, body, "groups", "0"), wantKeys("allow_domains", "blocked_count", "client_count", "deny_domains", "log_queries", "match", "max_concurrent", "matched", "name", "policy", "query_count", "rate_limit", "skip_blocklist", - "skip_cache", "upstreams")) + "skip_cache", "source", "upstreams")) requireKeys(t, "GET /api/clients groups[0].match", keysOfField(t, body, "groups", "0", "match"), wantKeys("any", "cidrs", "ptr_suffixes", "tags")) }) diff --git a/internal/app/hooks.go b/internal/app/hooks.go index 7b84f94..b5f7b94 100644 --- a/internal/app/hooks.go +++ b/internal/app/hooks.go @@ -765,6 +765,17 @@ func (a *App) deleteDomain(_ context.Context, name, action, list string) error { // getACL returns the ACL groups. func (a *App) getACL() any { groups := a.engine.GroupStats() + if a.kubeSnap.Load() != nil { + inFile := map[string]bool{} + for _, g := range a.cfg.Base().ACL.Groups { + inFile[g.Name] = true + } + for i := range groups { + if !inFile[groups[i].Name] && a.clusterOwned("group", groups[i].Name) { + groups[i].Source = "kubernetes" + } + } + } return map[string]any{ "groups": groups, "default_policy": a.cfg.Get().ACL.DefaultPolicy, diff --git a/internal/app/zones.go b/internal/app/zones.go index ff5b1ac..4c1a9d2 100644 --- a/internal/app/zones.go +++ b/internal/app/zones.go @@ -26,7 +26,13 @@ import ( // listZones renders the compiled store for the console. func (a *App) listZones() (any, error) { - dir := a.managedZoneDir() + dir, writable := a.managedZoneDir(), a.managedZones.Writable() + if a.kube != nil { + // With the Kubernetes source the zones that can be edited are the + // DNSZone objects, so those are the "managed" ones. The runtime itself + // cannot write them; the console, which can, reports writable. + dir, writable = a.kubeZoneDir(), false + } store := a.local.Get() // The slices are never nil: the console reads lengths and maps without // a null check, and an empty list is a list. @@ -34,7 +40,7 @@ func (a *App) listZones() (any, error) { Zones: []api.Zone{}, Files: []string{}, ManagedDir: dir, - Writable: a.managedZones.Writable(), + Writable: writable, Types: localzone.EditableTypes(), } if store != nil { diff --git a/internal/console/console.go b/internal/console/console.go new file mode 100644 index 0000000..8832851 --- /dev/null +++ b/internal/console/console.go @@ -0,0 +1,120 @@ +// Package console is the editing and viewing half of fancy-dns, split from the +// resolver so that it can run somewhere else. It owns authentication and the +// web UI, writes ClientGroup, BlockList and DNSZone objects to the cluster, and +// forwards everything the resolver owns (statistics, the query log, the cache) +// to the resolver's admin API. +// +// The resolver never calls the console and the console is never on the DNS +// path: stopping it cannot affect a query. +package console + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "strings" + "time" + + "git.k8s-git-k6n.net/mats/fancy-dns/internal/api" + "git.k8s-git-k6n.net/mats/fancy-dns/internal/config" + "git.k8s-git-k6n.net/mats/fancy-dns/internal/kubeedit" + "git.k8s-git-k6n.net/mats/fancy-dns/internal/web" +) + +// Options configure a Console. +type Options struct { + Config *config.Store + Runtime *Runtime + Editor *kubeedit.Editor + Logger *slog.Logger + Version string +} + +// Console serves the UI and the edit API. +type Console struct { + opt Options + log *slog.Logger + api *api.Server + started time.Time +} + +// New builds a Console. +func New(opt Options) (*Console, error) { + if opt.Config == nil || opt.Runtime == nil || opt.Editor == nil { + return nil, errors.New("console: config, runtime and editor are required") + } + if opt.Logger == nil { + opt.Logger = slog.Default() + } + c := &Console{opt: opt, log: opt.Logger.With("component", "console"), started: time.Now()} + srv, err := api.New(c.hooks()) + if err != nil { + return nil, err + } + c.api = srv + return c, nil +} + +// actor names who is making a request, for the object annotation. +func actor(ctx context.Context) string { + if p, ok := api.PrincipalFrom(ctx); ok && p.Name != "" { + return p.Name + } + return "" +} + +// Handler is the whole HTTP surface: the console's own routes, the resolver's +// API behind the same authentication, and the single-page app. +func (c *Console) Handler() (http.Handler, error) { + cfg := c.opt.Config.Get() + base := strings.TrimSuffix(cfg.API.BasePath, "/") + own := c.api.Handler() + + mux := http.NewServeMux() + // Handled by the shared API server: authentication, the identity of the + // caller, and the edits whose hooks write to the cluster. + for _, p := range []string{ + base + "/auth/", + "GET " + base + "/whoami", + "GET " + base + "/healthz", + "PUT " + base + "/clients", + "POST " + base + "/zones/records", + "PUT " + base + "/zones/records", + "DELETE " + base + "/zones/records", + "POST " + base + "/zones/delegations", + "DELETE " + base + "/zones/delegations", + "DELETE " + base + "/zones", + "POST " + base + "/blocklists/toggle", + } { + mux.Handle(p, own) + } + + // Reads that combine the cluster's objects with the resolver's view. + mux.Handle("GET "+base+"/zones", c.api.Protect(http.HandlerFunc(c.handleZones), false)) + mux.Handle("GET "+base+"/clients", c.api.Protect(http.HandlerFunc(c.handleClients), false)) + mux.Handle("GET "+base+"/blocklist-sources", c.api.Protect(http.HandlerFunc(c.handleListSources), false)) + mux.Handle("PUT "+base+"/blocklist-sources/{name}", c.api.Protect(http.HandlerFunc(c.handleListSourcePut), true)) + mux.Handle("DELETE "+base+"/blocklist-sources/{name}", c.api.Protect(http.HandlerFunc(c.handleListSourceDelete), true)) + + // Everything else under the API belongs to the resolver. + proxy := c.opt.Runtime.Proxy() + read, write := c.api.Protect(proxy, false), c.api.Protect(proxy, true) + mux.Handle(base+"/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet || r.Method == http.MethodHead { + read.ServeHTTP(w, r) + return + } + write.ServeHTTP(w, r) + })) + + if cfg.API.UI { + ui, err := web.Handler("") + if err != nil { + return nil, fmt.Errorf("web console: %w", err) + } + mux.Handle("/", ui) + } + return mux, nil +} diff --git a/internal/console/hooks.go b/internal/console/hooks.go new file mode 100644 index 0000000..73f958a --- /dev/null +++ b/internal/console/hooks.go @@ -0,0 +1,276 @@ +package console + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "git.k8s-git-k6n.net/mats/fancy-dns/internal/api" + "git.k8s-git-k6n.net/mats/fancy-dns/internal/config" + "git.k8s-git-k6n.net/mats/fancy-dns/internal/kubeedit" + "git.k8s-git-k6n.net/mats/fancy-dns/internal/kubesource" +) + +// hooks wires the api package's edit endpoints to the cluster. Only the hooks +// the console owns are set: the api server never sees the others, because the +// router sends those routes to the resolver. +func (c *Console) hooks() api.Hooks { + return api.Hooks{ + Store: c.opt.Config, + Logger: c.log, + Version: c.opt.Version, + StartedAt: c.started, + Health: c.health, + + PutACL: c.putACL, + ToggleBlocklist: c.toggleBlocklist, + AddZoneRecord: c.addZoneRecord, + PutZoneRecord: c.putZoneRecord, + DeleteZoneRecord: c.deleteZoneRecord, + AddZoneDelegation: c.addZoneDelegation, + DeleteZoneDelegation: c.deleteZoneDelegation, + DeleteZone: c.deleteZone, + } +} + +// health reports the console itself. The resolver has its own /healthz; a dead +// resolver must not make the console look dead, because the console is how an +// operator finds out. +func (c *Console) health() any { + return map[string]any{"status": "ok", "component": "console", "version": c.opt.Version} +} + +// clientsView is the part of the resolver's /api/clients answer the console +// reads. +type clientsView struct { + Groups []struct { + Name string `json:"name"` + Source string `json:"source"` + } `json:"groups"` + DefaultPolicy string `json:"default_policy"` +} + +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 { + // Without the resolver's list the console cannot tell the groups that + // live in the resolver's YAML from its own, and guessing would create + // objects that shadow them. + return 0, err + } + 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{} + for _, g := range view.Groups { + if g.Source == "" { + inFile[g.Name] = true + } + } + res, err := c.opt.Editor.SyncGroups(ctx, groups, func(n string) bool { return inFile[n] }, actor(ctx)) + if err != nil { + return 0, err + } + c.log.Info("client groups saved", "by", actor(ctx), "created", res.Created, "updated", res.Updated, "deleted", res.Deleted) + return 0, nil +} + +func (c *Console) toggleBlocklist(name string, enabled bool) error { + // The api hook has no context; an edit is short and bounded by the client. + err := c.opt.Editor.SetListDisabled(context.Background(), name, !enabled, "") + if errors.Is(err, kubeedit.ErrNotFound) { + return fmt.Errorf("no BlockList named %q (a list defined in the resolver's config is toggled there)", name) + } + return err +} + +func recordInput(in api.ZoneRecordInput) kubeedit.RecordInput { + return kubeedit.RecordInput{Name: in.Name, Type: in.Type, TTL: in.TTL, Value: in.Value} +} + +func zoneResponse(res kubeedit.WriteResult) api.ZoneWriteResponse { + return api.ZoneWriteResponse{OK: true, Zone: res.Zone, Serial: res.Serial} +} + +func (c *Console) addZoneRecord(ctx context.Context, in api.ZoneRecordInput) (any, error) { + res, err := c.opt.Editor.AddRecord(ctx, in.Zone, recordInput(in), actor(ctx)) + if err != nil { + return nil, err + } + return zoneResponse(res), nil +} + +func (c *Console) putZoneRecord(ctx context.Context, in api.ZoneRecordInput) (any, error) { + res, err := c.opt.Editor.PutRecord(ctx, in.Zone, recordInput(in), actor(ctx)) + if err != nil { + return nil, err + } + return zoneResponse(res), nil +} + +func (c *Console) deleteZoneRecord(ctx context.Context, zone, name, rrtype, value string) error { + _, err := c.opt.Editor.DeleteRecord(ctx, zone, name, rrtype, value, actor(ctx)) + return err +} + +func (c *Console) addZoneDelegation(ctx context.Context, in api.ZoneDelegationInput) (any, error) { + targets := make([]string, 0, len(in.Targets)) + for _, t := range in.Targets { + if t = strings.TrimSpace(t); t != "" { + targets = append(targets, t) + } + } + res, err := c.opt.Editor.SetDelegation(ctx, in.Zone, in.Name, targets, in.TTL, actor(ctx)) + if err != nil { + return nil, err + } + return zoneResponse(res), nil +} + +func (c *Console) deleteZoneDelegation(ctx context.Context, zone, name string) error { + _, err := c.opt.Editor.RemoveDelegation(ctx, zone, name, actor(ctx)) + return err +} + +func (c *Console) deleteZone(ctx context.Context, zone string) error { + return c.opt.Editor.DeleteZone(ctx, zone) +} + +// handleZones is the resolver's zone view with the console's editing +// capability stated truthfully: the resolver cannot write DNSZone objects, so +// it reports its zones read-only, and it is the console that can. +func (c *Console) handleZones(w http.ResponseWriter, r *http.Request) { + var view map[string]any + if err := c.opt.Runtime.Get(r.Context(), "/api/zones", &view); err != nil { + api.WriteError(w, http.StatusBadGateway, err.Error()) + return + } + view["writable"] = true + view["managed_dir"] = "kubernetes namespace " + c.opt.Editor.Namespace() + api.WriteJSON(w, http.StatusOK, view) +} + +// handleClients is the resolver's group list (with its live counters), made +// consistent with the cluster. A ClientGroup the resolver has not picked up +// yet is added; one that has just been deleted is dropped. Without this a +// save made a moment after another would not see the first one and would +// delete it. +func (c *Console) handleClients(w http.ResponseWriter, r *http.Request) { + var view map[string]any + if err := c.opt.Runtime.Get(r.Context(), "/api/clients", &view); err != nil { + api.WriteError(w, http.StatusBadGateway, err.Error()) + return + } + objs, err := c.opt.Editor.Groups(r.Context()) + if err != nil { + api.WriteError(w, http.StatusBadGateway, "cluster: "+err.Error()) + return + } + inCluster := make(map[string]kubeedit.GroupObject, len(objs)) + for _, o := range objs { + inCluster[o.Name] = o + } + groups, _ := view["groups"].([]any) + var merged []any + seen := map[string]bool{} + for _, g := range groups { + m, _ := g.(map[string]any) + name, _ := m["name"].(string) + src, _ := m["source"].(string) + if src == "history" { + // Counters for a name that is not a group (the default bucket, or + // one removed since). Saving it back would try to create it. + continue + } + if src == "kubernetes" { + if _, still := inCluster[name]; !still { + continue // deleted a moment ago; the resolver has not noticed + } + } + seen[name] = true + merged = append(merged, g) + } + // The resolver's list is in evaluation order: its file's groups, then the + // cluster's by priority. Groups it has not seen yet go at the end, in + // priority order. + for _, o := range objs { + if seen[o.Name] { + continue + } + merged = append(merged, groupJSON(o)) + } + view["groups"] = merged + api.WriteJSON(w, http.StatusOK, view) +} + +// groupJSON renders a ClientGroup in the shape the resolver reports groups. +func groupJSON(o kubeedit.GroupObject) map[string]any { + s := o.Spec + nz := func(v []string) []string { + if v == nil { + return []string{} + } + return v + } + return map[string]any{ + "name": o.Name, "comment": s.Comment, "policy": s.Policy, + "match": map[string]any{ + "cidrs": nz(s.Match.CIDRs), "ptr_suffixes": nz(s.Match.PTRSuffixes), + "tags": nz(s.Match.Tags), "any": s.Match.Any, + }, + "allow_domains": nz(s.AllowDomains), "deny_domains": nz(s.DenyDomains), + "skip_blocklist": s.SkipBlocklist, "skip_cache": s.SkipCache, + "rate_limit": s.RateLimit, "max_concurrent": s.MaxConcurrent, + "log_queries": s.LogQueries, "upstreams": nz(s.Upstreams), + "client_count": 0, "query_count": 0, "blocked_count": 0, "matched": false, + "source": "kubernetes", + } +} + +// listSourceView is a BlockList as the console reports it. +type listSourceView struct { + Name string `json:"name"` + Spec kubesource.BlockListSpec `json:"spec"` +} + +func (c *Console) handleListSources(w http.ResponseWriter, r *http.Request) { + ls, err := c.opt.Editor.Lists(r.Context()) + if err != nil { + api.WriteError(w, http.StatusBadGateway, "cluster: "+err.Error()) + return + } + out := make([]listSourceView, 0, len(ls)) + for _, l := range ls { + out = append(out, listSourceView{Name: l.Name, Spec: l.Spec}) + } + api.WriteJSON(w, http.StatusOK, map[string]any{"sources": out}) +} + +func (c *Console) handleListSourcePut(w http.ResponseWriter, r *http.Request) { + var spec kubesource.BlockListSpec + if err := api.DecodeJSON(r, &spec); err != nil { + api.WriteError(w, http.StatusBadRequest, "malformed request body") + return + } + name := r.PathValue("name") + if err := c.opt.Editor.PutList(r.Context(), name, spec, actor(r.Context())); err != nil { + api.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + api.WriteJSON(w, http.StatusOK, map[string]any{"ok": true, "name": name}) +} + +func (c *Console) handleListSourceDelete(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if err := c.opt.Editor.DeleteList(r.Context(), name); err != nil { + code := http.StatusBadRequest + if errors.Is(err, kubeedit.ErrNotFound) { + code = http.StatusNotFound + } + api.WriteError(w, code, err.Error()) + return + } + api.WriteJSON(w, http.StatusOK, map[string]any{"ok": true, "name": name}) +} diff --git a/internal/console/runtime.go b/internal/console/runtime.go new file mode 100644 index 0000000..72514a5 --- /dev/null +++ b/internal/console/runtime.go @@ -0,0 +1,79 @@ +package console + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httputil" + "net/url" + "strings" + "time" +) + +// Runtime is the resolver's admin API as the console sees it. The console never +// reads the resolver's state any other way: everything it shows that the +// resolver owns (statistics, query log, cache, health) comes through here. +type Runtime struct { + base *url.URL + key string + hc *http.Client +} + +// NewRuntime points at a resolver's admin API, e.g. http://fancy-dns.dns:9053. +// key is sent as X-API-Key; the console's own users never see it. +func NewRuntime(rawURL, key string) (*Runtime, error) { + u, err := url.Parse(rawURL) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return nil, fmt.Errorf("runtime URL %q must be http(s)://host[:port]", rawURL) + } + u.Path = strings.TrimSuffix(u.Path, "/") + return &Runtime{base: u, key: key, hc: &http.Client{Timeout: 10 * time.Second}}, nil +} + +// Get fetches path (e.g. "/api/zones") and decodes the JSON answer into v. +func (r *Runtime) Get(ctx context.Context, path string, v any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, r.base.String()+path, nil) + if err != nil { + return err + } + if r.key != "" { + req.Header.Set("X-API-Key", r.key) + } + res, err := r.hc.Do(req) + if err != nil { + return fmt.Errorf("resolver unreachable: %w", err) + } + defer res.Body.Close() + body, _ := io.ReadAll(io.LimitReader(res.Body, 32<<20)) + if res.StatusCode != http.StatusOK { + return fmt.Errorf("resolver answered %d: %s", res.StatusCode, strings.TrimSpace(string(body))) + } + return json.Unmarshal(body, v) +} + +// Proxy forwards a request to the resolver unchanged except for credentials: +// whatever the browser sent is dropped and the console's own key is used. +// Streaming responses (the live query log) are flushed as they arrive. +func (r *Runtime) Proxy() http.Handler { + p := &httputil.ReverseProxy{ + Rewrite: func(pr *httputil.ProxyRequest) { + pr.SetURL(r.base) + h := pr.Out.Header + for _, k := range []string{"Cookie", "Authorization", "X-Api-Key", "X-Forwarded-For", "X-Forwarded-Host", "X-Forwarded-Proto", "Forwarded", "Origin", "Referer"} { + h.Del(k) + } + if r.key != "" { + h.Set("X-API-Key", r.key) + } + }, + FlushInterval: -1, + ErrorHandler: func(w http.ResponseWriter, _ *http.Request, err error) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadGateway) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "resolver unreachable: " + err.Error()}) + }, + } + return p +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 37a5bc5..298833e 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -1169,7 +1169,7 @@ func (t *groupStatTable) merge(base []acl.GroupSummary) []acl.GroupSummary { if _, ok := byName[name]; ok { continue } - byName[name] = acl.GroupSummary{Name: name, Policy: string(acl.PolicyAllow)} + byName[name] = acl.GroupSummary{Name: name, Policy: string(acl.PolicyAllow), Source: "history"} } out := make([]acl.GroupSummary, 0, len(byName)) for name, g := range byName { diff --git a/internal/kubeedit/edit.go b/internal/kubeedit/edit.go new file mode 100644 index 0000000..84e1b0f --- /dev/null +++ b/internal/kubeedit/edit.go @@ -0,0 +1,155 @@ +// Package kubeedit is the write side of the Kubernetes integration: the +// console's edits become changes to ClientGroup, BlockList and DNSZone objects. +// +// It is the only code that writes to the cluster, and the runtime never +// imports it. Every spec is validated with the runtime's own converters +// (kubesource.Check*) before it is written, so the console cannot store an +// object the runtime would reject. +package kubeedit + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "reflect" + "sort" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/validation" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/util/retry" + + "git.k8s-git-k6n.net/mats/fancy-dns/internal/kubesource" +) + +// ActorAnnotation records who last changed an object through the console. +const ActorAnnotation = "fancydns.k6n.net/last-edited-by" + +// ErrNotFound is returned when the object or record to change does not exist. +var ErrNotFound = errors.New("not found") + +// Editor writes fancy-dns objects into one namespace. +type Editor struct { + c dynamic.Interface + ns string +} + +// New returns an Editor for namespace ns. +func New(c dynamic.Interface, ns string) *Editor { return &Editor{c: c, ns: ns} } + +// Namespace is where objects are written. +func (e *Editor) Namespace() string { return e.ns } + +func (e *Editor) res(gvr schema.GroupVersionResource) dynamic.ResourceInterface { + return e.c.Resource(gvr).Namespace(e.ns) +} + +func decodeSpec(u *unstructured.Unstructured, into any) error { + raw, _ := u.Object["spec"].(map[string]any) + if raw == nil { + return nil + } + return runtime.DefaultUnstructuredConverter.FromUnstructured(raw, into) +} + +func encodeSpec(spec any) (map[string]any, error) { + // ToUnstructured wants a pointer; the specs are passed by value. + v := reflect.New(reflect.TypeOf(spec)) + v.Elem().Set(reflect.ValueOf(spec)) + return runtime.DefaultUnstructuredConverter.ToUnstructured(v.Interface()) +} + +func sameSpec(a, b any) bool { + x, _ := json.Marshal(a) + y, _ := json.Marshal(b) + return string(x) == string(y) +} + +func checkName(kind, name string) error { + if errs := validation.IsDNS1123Subdomain(name); len(errs) > 0 { + return fmt.Errorf("%s name %q cannot be a Kubernetes object name (%s): use lowercase letters, digits, '-' and '.'", kind, name, errs[0]) + } + return nil +} + +func stamp(u *unstructured.Unstructured, actor string) { + ann := u.GetAnnotations() + if ann == nil { + ann = map[string]string{} + } + if actor != "" { + ann[ActorAnnotation] = actor + } + u.SetAnnotations(ann) + labels := u.GetLabels() + if labels == nil { + labels = map[string]string{} + } + labels["app.kubernetes.io/managed-by"] = "fancy-dns-console" + u.SetLabels(labels) +} + +func newObject(kind, name string, spec any) (*unstructured.Unstructured, error) { + raw, err := encodeSpec(spec) + if err != nil { + return nil, err + } + return &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": kubesource.APIGroup + "/" + kubesource.Version, + "kind": kind, + "metadata": map[string]any{"name": name, "namespace": ""}, + "spec": raw, + }}, nil +} + +// upsert creates the object or replaces its spec, retrying on conflict. +func (e *Editor) upsert(ctx context.Context, gvr schema.GroupVersionResource, kind, name, actor string, spec any) (*unstructured.Unstructured, error) { + var out *unstructured.Unstructured + err := retry.RetryOnConflict(retry.DefaultRetry, func() error { + cur, err := e.res(gvr).Get(ctx, name, metav1.GetOptions{}) + if apierrors.IsNotFound(err) { + obj, err := newObject(kind, name, spec) + if err != nil { + return err + } + obj.SetNamespace(e.ns) + stamp(obj, actor) + out, err = e.res(gvr).Create(ctx, obj, metav1.CreateOptions{}) + return err + } + if err != nil { + return err + } + raw, err := encodeSpec(spec) + if err != nil { + return err + } + cur.Object["spec"] = raw + stamp(cur, actor) + out, err = e.res(gvr).Update(ctx, cur, metav1.UpdateOptions{}) + return err + }) + return out, err +} + +func (e *Editor) remove(ctx context.Context, gvr schema.GroupVersionResource, name string) error { + err := e.res(gvr).Delete(ctx, name, metav1.DeleteOptions{}) + if apierrors.IsNotFound(err) { + return fmt.Errorf("%w: %s", ErrNotFound, name) + } + return err +} + +func (e *Editor) list(ctx context.Context, gvr schema.GroupVersionResource) ([]unstructured.Unstructured, error) { + l, err := e.res(gvr).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, err + } + sort.Slice(l.Items, func(i, j int) bool { return l.Items[i].GetName() < l.Items[j].GetName() }) + return l.Items, nil +} diff --git a/internal/kubeedit/edit_test.go b/internal/kubeedit/edit_test.go new file mode 100644 index 0000000..4fe07a6 --- /dev/null +++ b/internal/kubeedit/edit_test.go @@ -0,0 +1,218 @@ +package kubeedit + +import ( + "context" + "errors" + "strings" + "testing" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + dynfake "k8s.io/client-go/dynamic/fake" + + "git.k8s-git-k6n.net/mats/fancy-dns/internal/config" + "git.k8s-git-k6n.net/mats/fancy-dns/internal/kubesource" +) + +func newEditor() *Editor { + c := dynfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), + map[schema.GroupVersionResource]string{ + kubesource.ZoneGVR: "DNSZoneList", + kubesource.GroupGVR: "ClientGroupList", + kubesource.ListGVR: "BlockListList", + }) + return New(c, "dns") +} + +var ctx = context.Background() + +func grp(name, policy string) config.Group { + return config.Group{Name: name, Policy: policy, Match: config.Match{CIDRs: []string{"10.0.0.0/8"}}} +} + +func TestSyncGroupsCreatesUpdatesDeletesAndOrders(t *testing.T) { + e := newEditor() + res, err := e.SyncGroups(ctx, []config.Group{grp("a", "allow"), grp("b", "deny")}, nil, "alice") + if err != nil || len(res.Created) != 2 { + t.Fatalf("create: %+v %v", res, err) + } + // Nothing changed: nothing written. + res, err = e.SyncGroups(ctx, []config.Group{grp("a", "allow"), grp("b", "deny")}, nil, "alice") + if err != nil || len(res.Created)+len(res.Updated)+len(res.Deleted) != 0 { + t.Fatalf("idempotent: %+v %v", res, err) + } + // Reorder, change one, drop none. + res, err = e.SyncGroups(ctx, []config.Group{grp("b", "deny"), grp("a", "block_only")}, nil, "alice") + if err != nil || len(res.Updated) != 2 { + t.Fatalf("reorder: %+v %v", res, err) + } + got, _ := e.Groups(ctx) + if got[0].Name != "b" || got[1].Name != "a" || got[1].Spec.Policy != "block_only" { + t.Fatalf("groups = %+v", got) + } + res, err = e.SyncGroups(ctx, []config.Group{grp("b", "deny")}, nil, "alice") + if err != nil || len(res.Deleted) != 1 || res.Deleted[0] != "a" { + t.Fatalf("delete: %+v %v", res, err) + } +} + +func TestSyncGroupsLeavesForeignGroupsAlone(t *testing.T) { + e := newEditor() + yaml := func(n string) bool { return n == "from-yaml" } + res, err := e.SyncGroups(ctx, []config.Group{grp("from-yaml", "allow"), grp("mine", "deny")}, yaml, "alice") + if err != nil || len(res.Created) != 1 || res.Created[0] != "mine" { + t.Fatalf("%+v %v", res, err) + } +} + +func TestSyncGroupsValidatesBeforeWriting(t *testing.T) { + e := newEditor() + _, err := e.SyncGroups(ctx, []config.Group{grp("ok", "allow"), grp("bad", "maybe")}, nil, "alice") + if err == nil || !strings.Contains(err.Error(), "bad") { + t.Fatalf("err = %v", err) + } + if got, _ := e.Groups(ctx); len(got) != 0 { + t.Fatalf("a rejected save wrote %+v", got) + } + if _, err := e.SyncGroups(ctx, []config.Group{grp("Kids Room", "allow")}, nil, "alice"); err == nil { + t.Fatal("a name that is not a Kubernetes object name must be refused") + } +} + +func TestActorIsRecorded(t *testing.T) { + e := newEditor() + if _, err := e.SyncGroups(ctx, []config.Group{grp("a", "allow")}, nil, "alice@example.com"); err != nil { + t.Fatal(err) + } + u, err := e.res(kubesource.GroupGVR).Get(ctx, "a", getOpts) + if err != nil { + t.Fatal(err) + } + if u.GetAnnotations()[ActorAnnotation] != "alice@example.com" { + t.Fatalf("annotations = %v", u.GetAnnotations()) + } +} + +func TestZoneRecordLifecycle(t *testing.T) { + e := newEditor() + const apex = "home.example.com." + + // A zone starts with its servers: without an NS the runtime cannot answer + // NXDOMAIN and the editor says so rather than storing a dead zone. + if _, err := e.AddRecord(ctx, apex, RecordInput{Name: "nas", Type: "A", Value: "10.0.0.2"}, "a"); err == nil { + t.Fatal("a zone with no NS must be refused") + } + if zs, _ := e.Zones(ctx); len(zs) != 0 { + t.Fatal("the refused zone was stored") + } + + // An apex NS on its own is a valid zone. + if _, err := e.AddRecord(ctx, apex, RecordInput{Name: "@", Type: "NS", Value: "ns1.home.example.com."}, "a"); err != nil { + t.Fatal(err) + } + res, err := e.AddRecord(ctx, apex, RecordInput{Name: "nas", Type: "A", TTL: 60, Value: "10.0.0.2"}, "a") + if err != nil || res.Serial < 1 { + t.Fatalf("add: %+v %v", res, err) + } + // Names are stored the way an operator writes them. + zs, _ := e.Zones(ctx) + if len(zs) != 1 || zs[0].Name != "home.example.com" { + t.Fatalf("zones = %+v", zs) + } + var names []string + for _, r := range zs[0].Spec.Records { + names = append(names, r.Name) + } + if strings.Join(names, ",") != "@,nas" { + t.Fatalf("stored names = %v", names) + } + + // The same record, spelled differently, is a duplicate. + if _, err := e.AddRecord(ctx, apex, RecordInput{Name: "nas.home.example.com.", Type: "A", Value: "10.0.0.2"}, "a"); err == nil { + t.Fatal("duplicate accepted") + } + // Put replaces the RRset. + if _, err := e.PutRecord(ctx, apex, RecordInput{Name: "nas", Type: "A", Value: "10.0.0.9"}, "a"); err != nil { + t.Fatal(err) + } + zs, _ = e.Zones(ctx) + if n := countType(zs[0].Spec, "A"); n != 1 || zs[0].Spec.Records[len(zs[0].Spec.Records)-1].Value != "10.0.0.9" { + t.Fatalf("after put = %+v", zs[0].Spec.Records) + } + // Delete one record, then one that is not there. + if _, err := e.DeleteRecord(ctx, apex, "nas", "A", "10.0.0.9", "a"); err != nil { + t.Fatal(err) + } + if _, err := e.DeleteRecord(ctx, apex, "nas", "A", "", "a"); !errors.Is(err, ErrNotFound) { + t.Fatalf("err = %v, want not found", err) + } + // An edit that would leave the zone invalid is refused and changes nothing. + if _, err := e.DeleteRecord(ctx, apex, "@", "NS", "", "a"); err == nil { + t.Fatal("removing the last apex NS must be refused") + } + if zs, _ = e.Zones(ctx); countType(zs[0].Spec, "NS") != 1 { + t.Fatal("the refused delete changed the zone") + } +} + +func countType(s kubesource.DNSZoneSpec, typ string) int { + n := 0 + for _, r := range s.Records { + if r.Type == typ { + n++ + } + } + return n +} + +func TestDelegationsAndZoneDelete(t *testing.T) { + e := newEditor() + const apex = "lab.example.com." + // A delegation in a brand new zone also gives the zone its own servers. + if _, err := e.SetDelegation(ctx, apex, "sub", []string{"ns.sub.lab.example.com.", "ns2.sub.lab.example.com."}, 300, "a"); err != nil { + t.Fatal(err) + } + zs, _ := e.Zones(ctx) + if got := countType(zs[0].Spec, "NS"); got != 4 { + t.Fatalf("NS records = %d, want 2 for the cut and 2 for the apex: %+v", got, zs[0].Spec.Records) + } + // Data under the cut is refused, as in the file-based writer. + if _, err := e.AddRecord(ctx, apex, RecordInput{Name: "x.sub", Type: "A", Value: "10.0.0.1"}, "a"); err == nil { + t.Fatal("a record below a delegation was accepted") + } + if _, err := e.RemoveDelegation(ctx, apex, "sub", "a"); err != nil { + t.Fatal(err) + } + if _, err := e.RemoveDelegation(ctx, apex, "sub", "a"); !errors.Is(err, ErrNotFound) { + t.Fatalf("err = %v", err) + } + if err := e.DeleteZone(ctx, apex); err != nil { + t.Fatal(err) + } + if err := e.DeleteZone(ctx, apex); !errors.Is(err, ErrNotFound) { + t.Fatalf("err = %v", err) + } +} + +func TestListEditing(t *testing.T) { + e := newEditor() + if err := e.PutList(ctx, "ads", kubesource.BlockListSpec{URL: "https://e.com/hosts", Format: "hosts"}, "a"); err != nil { + t.Fatal(err) + } + if err := e.PutList(ctx, "evil", kubesource.BlockListSpec{URL: "file:///etc/passwd"}, "a"); err == nil { + t.Fatal("file:// accepted") + } + if err := e.SetListDisabled(ctx, "ads", true, "a"); err != nil { + t.Fatal(err) + } + ls, _ := e.Lists(ctx) + if len(ls) != 1 || !ls[0].Spec.Disabled || ls[0].Spec.Format != "hosts" { + t.Fatalf("lists = %+v", ls) + } + if err := e.SetListDisabled(ctx, "nope", true, "a"); !errors.Is(err, ErrNotFound) { + t.Fatalf("err = %v", err) + } + if err := e.DeleteList(ctx, "ads"); err != nil { + t.Fatal(err) + } +} diff --git a/internal/kubeedit/groups.go b/internal/kubeedit/groups.go new file mode 100644 index 0000000..7cfbb25 --- /dev/null +++ b/internal/kubeedit/groups.go @@ -0,0 +1,131 @@ +package kubeedit + +import ( + "context" + "fmt" + "sort" + + "git.k8s-git-k6n.net/mats/fancy-dns/internal/config" + "git.k8s-git-k6n.net/mats/fancy-dns/internal/kubesource" +) + +// GroupObject is a stored ClientGroup. +type GroupObject struct { + Name string + Spec kubesource.ClientGroupSpec +} + +// Groups lists the ClientGroup objects in evaluation order. +func (e *Editor) Groups(ctx context.Context) ([]GroupObject, error) { + items, err := e.list(ctx, kubesource.GroupGVR) + if err != nil { + return nil, err + } + out := make([]GroupObject, 0, len(items)) + for i := range items { + var s kubesource.ClientGroupSpec + if err := decodeSpec(&items[i], &s); err != nil { + continue // the runtime reports an undecodable object; the console skips it + } + out = append(out, GroupObject{Name: items[i].GetName(), Spec: s}) + } + sort.SliceStable(out, func(i, j int) bool { + if out[i].Spec.Priority != out[j].Spec.Priority { + return out[i].Spec.Priority < out[j].Spec.Priority + } + return out[i].Name < out[j].Name + }) + return out, nil +} + +// SpecFromGroup converts the console's group to an object spec. +func SpecFromGroup(g config.Group, priority int32) kubesource.ClientGroupSpec { + return kubesource.ClientGroupSpec{ + Priority: priority, + Comment: g.Comment, + Match: g.Match, + Policy: g.Policy, + AllowDomains: g.AllowDomains, + DenyDomains: g.DenyDomains, + SkipBlocklist: g.SkipBlocklist, + SkipCache: g.SkipCache, + RateLimit: g.RateLimit, + MaxConcurrent: g.MaxConcurrent, + LogQueries: g.LogQueries, + Upstreams: g.Upstreams, + } +} + +// SyncResult says what SyncGroups changed. +type SyncResult struct { + Created, Updated, Deleted []string +} + +// SyncGroups makes the stored ClientGroups match the console's list. +// +// desired is the full list the console sent, in the order the operator put it. +// Groups for which foreign returns true belong to something else (the runtime's +// YAML) and are neither written nor deleted. Every group is validated before +// anything is written, so a bad group changes nothing. Priorities follow the +// list position, so reordering in the console reorders evaluation. +func (e *Editor) SyncGroups(ctx context.Context, desired []config.Group, foreign func(string) bool, actor string) (SyncResult, error) { + var res SyncResult + existing, err := e.Groups(ctx) + if err != nil { + return res, err + } + have := make(map[string]GroupObject, len(existing)) + for _, g := range existing { + have[g.Name] = g + } + + type write struct { + name string + spec kubesource.ClientGroupSpec + new bool + } + var writes []write + keep := map[string]bool{} + for i, g := range desired { + if foreign != nil && foreign(g.Name) { + if _, ours := have[g.Name]; !ours { + continue + } + } + if err := checkName("group", g.Name); err != nil { + return res, err + } + spec := SpecFromGroup(g, int32(i+1)*10) + if _, err := kubesource.CheckGroup(g.Name, spec); err != nil { + return res, fmt.Errorf("group %q: %w", g.Name, err) + } + keep[g.Name] = true + cur, ok := have[g.Name] + switch { + case !ok: + writes = append(writes, write{g.Name, spec, true}) + case !sameSpec(cur.Spec, spec): + writes = append(writes, write{g.Name, spec, false}) + } + } + for _, w := range writes { + if _, err := e.upsert(ctx, kubesource.GroupGVR, "ClientGroup", w.name, actor, w.spec); err != nil { + return res, fmt.Errorf("write group %q: %w", w.name, err) + } + if w.new { + res.Created = append(res.Created, w.name) + } else { + res.Updated = append(res.Updated, w.name) + } + } + for _, g := range existing { + if keep[g.Name] { + continue + } + if err := e.remove(ctx, kubesource.GroupGVR, g.Name); err != nil { + return res, err + } + res.Deleted = append(res.Deleted, g.Name) + } + return res, nil +} diff --git a/internal/kubeedit/helpers_test.go b/internal/kubeedit/helpers_test.go new file mode 100644 index 0000000..7c8a537 --- /dev/null +++ b/internal/kubeedit/helpers_test.go @@ -0,0 +1,5 @@ +package kubeedit + +import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + +var getOpts = metav1.GetOptions{} diff --git a/internal/kubeedit/lists.go b/internal/kubeedit/lists.go new file mode 100644 index 0000000..7b07144 --- /dev/null +++ b/internal/kubeedit/lists.go @@ -0,0 +1,63 @@ +package kubeedit + +import ( + "context" + "fmt" + + "git.k8s-git-k6n.net/mats/fancy-dns/internal/kubesource" +) + +// ListObject is a stored BlockList. +type ListObject struct { + Name string + Spec kubesource.BlockListSpec +} + +// Lists returns the BlockList objects. +func (e *Editor) Lists(ctx context.Context) ([]ListObject, error) { + items, err := e.list(ctx, kubesource.ListGVR) + if err != nil { + return nil, err + } + out := make([]ListObject, 0, len(items)) + for i := range items { + var s kubesource.BlockListSpec + if err := decodeSpec(&items[i], &s); err != nil { + continue + } + out = append(out, ListObject{Name: items[i].GetName(), Spec: s}) + } + return out, nil +} + +// PutList creates or replaces a BlockList. +func (e *Editor) PutList(ctx context.Context, name string, spec kubesource.BlockListSpec, actor string) error { + if err := checkName("blocklist", name); err != nil { + return err + } + if _, err := kubesource.CheckList(name, spec); err != nil { + return fmt.Errorf("blocklist %q: %w", name, err) + } + _, err := e.upsert(ctx, kubesource.ListGVR, "BlockList", name, actor, spec) + return err +} + +// SetListDisabled flips spec.disabled on one BlockList. +func (e *Editor) SetListDisabled(ctx context.Context, name string, disabled bool, actor string) error { + items, err := e.Lists(ctx) + if err != nil { + return err + } + for _, l := range items { + if l.Name == name { + l.Spec.Disabled = disabled + return e.PutList(ctx, name, l.Spec, actor) + } + } + return fmt.Errorf("%w: no BlockList named %q", ErrNotFound, name) +} + +// DeleteList removes a BlockList. +func (e *Editor) DeleteList(ctx context.Context, name string) error { + return e.remove(ctx, kubesource.ListGVR, name) +} diff --git a/internal/kubeedit/zones.go b/internal/kubeedit/zones.go new file mode 100644 index 0000000..2f2f2f1 --- /dev/null +++ b/internal/kubeedit/zones.go @@ -0,0 +1,345 @@ +package kubeedit + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/client-go/util/retry" + + "git.k8s-git-k6n.net/mats/fancy-dns/internal/kubesource" + "git.k8s-git-k6n.net/mats/fancy-dns/internal/localzone" +) + +// ZoneObject is a stored DNSZone. +type ZoneObject struct { + Name string + Apex string // canonical, fully qualified + Spec kubesource.DNSZoneSpec +} + +// Zones lists the DNSZone objects. +func (e *Editor) Zones(ctx context.Context) ([]ZoneObject, error) { + items, err := e.list(ctx, kubesource.ZoneGVR) + if err != nil { + return nil, err + } + out := make([]ZoneObject, 0, len(items)) + for i := range items { + z, ok := zoneOf(&items[i]) + if ok { + out = append(out, z) + } + } + return out, nil +} + +func zoneOf(u *unstructured.Unstructured) (ZoneObject, bool) { + var s kubesource.DNSZoneSpec + if err := decodeSpec(u, &s); err != nil { + return ZoneObject{}, false + } + apex, err := localzone.NormalizeOrigin(s.Zone) + if err != nil { + return ZoneObject{}, false + } + return ZoneObject{Name: u.GetName(), Apex: apex, Spec: s}, true +} + +// WriteResult is what a zone edit reports. +type WriteResult struct { + Zone string + // Serial is the SOA serial the runtime will serve: the object's + // generation after this write. + Serial uint32 +} + +// editZone applies fn to the zone's spec and writes it back, retrying on a +// conflicting concurrent edit. A zone that does not exist yet is created when +// create is set. The result is rendered with the runtime's own code before it +// is stored, so a spec the runtime would reject is never written. +func (e *Editor) editZone(ctx context.Context, apex string, create bool, actor string, fn func(spec *kubesource.DNSZoneSpec) error) (WriteResult, error) { + apex, err := localzone.NormalizeOrigin(apex) + if err != nil { + return WriteResult{}, err + } + var res WriteResult + err = retry.RetryOnConflict(retry.DefaultRetry, func() error { + items, err := e.list(ctx, kubesource.ZoneGVR) + if err != nil { + return err + } + var cur *unstructured.Unstructured + var zo ZoneObject + for i := range items { + if z, ok := zoneOf(&items[i]); ok && z.Apex == apex { + cur, zo = &items[i], z + break + } + } + spec := zo.Spec + if cur == nil { + if !create { + return fmt.Errorf("%w: no DNSZone for %s", ErrNotFound, apex) + } + spec = kubesource.DNSZoneSpec{Zone: strings.TrimSuffix(apex, ".")} + } + spec.Records = append([]kubesource.ZoneRecord(nil), spec.Records...) + if err := fn(&spec); err != nil { + return err + } + if _, err := kubesource.CheckZone(spec); err != nil { + return err + } + var out *unstructured.Unstructured + if cur == nil { + name := strings.TrimSuffix(apex, ".") + if err := checkName("zone", name); err != nil { + return err + } + obj, err := newObject("DNSZone", name, spec) + if err != nil { + return err + } + obj.SetNamespace(e.ns) + stamp(obj, actor) + out, err = e.res(kubesource.ZoneGVR).Create(ctx, obj, metav1.CreateOptions{}) + if err != nil { + return err + } + } else { + raw, err := encodeSpec(spec) + if err != nil { + return err + } + cur.Object["spec"] = raw + stamp(cur, actor) + out, err = e.res(kubesource.ZoneGVR).Update(ctx, cur, metav1.UpdateOptions{}) + if err != nil { + return err + } + } + res = WriteResult{Zone: apex, Serial: uint32(max(out.GetGeneration(), 1))} + return nil + }) + return res, err +} + +// RecordInput is one record as the console sends it. +type RecordInput struct { + Name string + Type string + TTL uint32 + Value string +} + +func canon(apex string, in RecordInput) (localzone.Record, error) { + name := in.Name + if name == "@" { + name = apex + } + return localzone.CanonicalRecord(apex, localzone.Record{Name: name, Type: in.Type, TTL: in.TTL, Value: in.Value}) +} + +// relative stores a name the way an operator would write it. +func relative(apex, name string) string { + if name == apex { + return "@" + } + return strings.TrimSuffix(name, "."+apex) +} + +func stored(apex string, r localzone.Record) kubesource.ZoneRecord { + return kubesource.ZoneRecord{Name: relative(apex, r.Name), Type: r.Type, TTL: r.TTL, Value: r.Value} +} + +// existing canonicalises the spec's records for comparison. A record that does +// not canonicalise is kept as it is, under a key that matches nothing. +func existing(apex string, spec *kubesource.DNSZoneSpec) []localzone.Record { + out := make([]localzone.Record, len(spec.Records)) + for i, r := range spec.Records { + c, err := canon(apex, RecordInput{Name: r.Name, Type: r.Type, TTL: r.TTL, Value: r.Value}) + if err != nil { + c = localzone.Record{Name: "\x00invalid", Type: r.Type, Value: r.Value} + } + out[i] = c + } + return out +} + +// AddRecord adds one record, creating the zone when it does not exist. +func (e *Editor) AddRecord(ctx context.Context, apex string, in RecordInput, actor string) (WriteResult, error) { + return e.editZone(ctx, apex, true, actor, func(spec *kubesource.DNSZoneSpec) error { + a, _ := localzone.NormalizeOrigin(apex) + rec, err := canon(a, in) + if err != nil { + return err + } + for _, have := range existing(a, spec) { + if have.Key() == rec.Key() && have.Value == rec.Value { + return fmt.Errorf("%s %s already exists in %s", rec.Name, rec.Type, a) + } + } + spec.Records = append(spec.Records, stored(a, rec)) + return nil + }) +} + +// PutRecord replaces the whole RRset of one name and type. +func (e *Editor) PutRecord(ctx context.Context, apex string, in RecordInput, actor string) (WriteResult, error) { + return e.editZone(ctx, apex, true, actor, func(spec *kubesource.DNSZoneSpec) error { + a, _ := localzone.NormalizeOrigin(apex) + rec, err := canon(a, in) + if err != nil { + return err + } + spec.Records = dropWhere(a, spec, func(have localzone.Record) bool { return have.Key() == rec.Key() }) + spec.Records = append(spec.Records, stored(a, rec)) + return nil + }) +} + +// DeleteRecord removes one record, or the whole RRset when value is empty. +func (e *Editor) DeleteRecord(ctx context.Context, apex, name, rrtype, value, actor string) (WriteResult, error) { + return e.editZone(ctx, apex, false, actor, func(spec *kubesource.DNSZoneSpec) error { + a, _ := localzone.NormalizeOrigin(apex) + var probe localzone.Record + var want string + if value != "" { + full, err := canon(a, RecordInput{Name: name, Type: rrtype, Value: value}) + if err != nil { + return err + } + probe, want = full, full.Value + } else { + var err error + if probe, err = canonProbe(a, name, rrtype); err != nil { + return err + } + } + before := len(spec.Records) + spec.Records = dropWhere(a, spec, func(have localzone.Record) bool { + return have.Key() == probe.Key() && (want == "" || have.Value == want) + }) + if len(spec.Records) == before { + return fmt.Errorf("%w: no record matches %s %s in %s", ErrNotFound, name, rrtype, a) + } + return nil + }) +} + +// canonProbe canonicalises only the owner and type of a record, for matching. +func canonProbe(apex, name, rrtype string) (localzone.Record, error) { + for _, v := range []string{"10.0.0.1", "ns.example.", "x", "0 0 0 x", "10 ns.example.", "0 issue \"x\""} { + if r, err := canon(apex, RecordInput{Name: name, Type: rrtype, Value: v}); err == nil { + return r, nil + } + } + return localzone.Record{}, fmt.Errorf("cannot interpret record %s %s", name, rrtype) +} + +func dropWhere(apex string, spec *kubesource.DNSZoneSpec, match func(localzone.Record) bool) []kubesource.ZoneRecord { + canonical := existing(apex, spec) + out := make([]kubesource.ZoneRecord, 0, len(spec.Records)) + for i, r := range spec.Records { + if !match(canonical[i]) { + out = append(out, r) + } + } + return out +} + +// SetDelegation hands a name below the apex to other servers: it replaces the +// NS set at that name. In a zone that does not exist yet the targets also +// become the zone's own NS set, as the file-based writer does. +func (e *Editor) SetDelegation(ctx context.Context, apex, name string, targets []string, ttl uint32, actor string) (WriteResult, error) { + return e.editZone(ctx, apex, true, actor, func(spec *kubesource.DNSZoneSpec) error { + a, _ := localzone.NormalizeOrigin(apex) + if len(targets) == 0 { + return errors.New("a delegation needs at least one NS target") + } + fresh := len(spec.Records) == 0 + cut, err := canon(a, RecordInput{Name: name, Type: "NS", TTL: ttl, Value: targets[0]}) + if err != nil { + return err + } + if cut.Name == a { + return errors.New("the apex NS set is the zone's own; a delegation is a name below it") + } + spec.Records = dropWhere(a, spec, func(have localzone.Record) bool { return have.Key() == cut.Key() }) + for _, t := range targets { + r, err := canon(a, RecordInput{Name: name, Type: "NS", TTL: ttl, Value: strings.TrimSpace(t)}) + if err != nil { + return err + } + spec.Records = append(spec.Records, stored(a, r)) + } + if fresh { + for _, t := range targets { + r, err := canon(a, RecordInput{Name: "@", Type: "NS", TTL: ttl, Value: strings.TrimSpace(t)}) + if err != nil { + return err + } + spec.Records = append(spec.Records, stored(a, r)) + } + } + return nil + }) +} + +// RemoveDelegation removes the cut at name. +func (e *Editor) RemoveDelegation(ctx context.Context, apex, name, actor string) (WriteResult, error) { + return e.editZone(ctx, apex, false, actor, func(spec *kubesource.DNSZoneSpec) error { + a, _ := localzone.NormalizeOrigin(apex) + cut, err := canon(a, RecordInput{Name: name, Type: "NS", Value: "ns.example."}) + if err != nil { + return err + } + if cut.Name == a { + return errors.New("the apex NS set is the zone's own, not a delegation") + } + before := len(spec.Records) + spec.Records = dropWhere(a, spec, func(have localzone.Record) bool { return have.Key() == cut.Key() }) + if len(spec.Records) == before { + return fmt.Errorf("%w: no delegation for %s in %s", ErrNotFound, name, a) + } + return nil + }) +} + +// DeleteZone removes the DNSZone object, which hands the names back to +// whatever answered for them before. +func (e *Editor) DeleteZone(ctx context.Context, apex string) error { + apex, err := localzone.NormalizeOrigin(apex) + if err != nil { + return err + } + zones, err := e.Zones(ctx) + if err != nil { + return err + } + for _, z := range zones { + if z.Apex == apex { + return e.remove(ctx, kubesource.ZoneGVR, z.Name) + } + } + return fmt.Errorf("%w: no DNSZone for %s", ErrNotFound, apex) +} + +// ZoneApexes is the set of apexes held by DNSZone objects. +func (e *Editor) ZoneApexes(ctx context.Context) ([]string, error) { + zones, err := e.Zones(ctx) + if err != nil { + return nil, err + } + out := make([]string, len(zones)) + for i, z := range zones { + out[i] = z.Apex + } + sort.Strings(out) + return out, nil +} diff --git a/internal/kubesource/convert.go b/internal/kubesource/convert.go index 0d1c257..5a0b50c 100644 --- a/internal/kubesource/convert.go +++ b/internal/kubesource/convert.go @@ -42,8 +42,19 @@ func convertGroup(u *unstructured.Unstructured) (group, error) { if err := spec(u, &s); err != nil { return group{}, err } + return groupFromSpec(u.GetName(), s) +} + +// CheckGroup validates a ClientGroup spec the way the runtime will, so an +// editor can refuse a bad one before it is written. +func CheckGroup(name string, s ClientGroupSpec) (config.Group, error) { + g, err := groupFromSpec(name, s) + return g.cfg, err +} + +func groupFromSpec(name string, s ClientGroupSpec) (group, error) { g := config.Group{ - Name: u.GetName(), + Name: name, Comment: s.Comment, Match: s.Match, Policy: s.Policy, @@ -92,8 +103,17 @@ func convertList(u *unstructured.Unstructured) (config.ListSource, error) { if err := spec(u, &s); err != nil { return config.ListSource{}, err } + return listFromSpec(u.GetName(), s) +} + +// CheckList validates a BlockList spec the way the runtime will. +func CheckList(name string, s BlockListSpec) (config.ListSource, error) { + return listFromSpec(name, s) +} + +func listFromSpec(name string, s BlockListSpec) (config.ListSource, error) { l := config.ListSource{ - Name: u.GetName(), + Name: name, Format: s.Format, URL: s.URL, Mirror: s.Mirror, @@ -152,6 +172,17 @@ func convertZone(u *unstructured.Unstructured) (zone, error) { if err := spec(u, &s); err != nil { return zone{}, err } + return zoneFromSpec(s, u.GetGeneration()) +} + +// CheckZone validates a DNSZone spec by rendering it exactly as the runtime +// will, and returns the master file. +func CheckZone(s DNSZoneSpec) (string, error) { + z, err := zoneFromSpec(s, 1) + return z.text, err +} + +func zoneFromSpec(s DNSZoneSpec, generation int64) (zone, error) { if s.Zone == "" { return zone{}, fmt.Errorf("spec.zone is required") } @@ -179,7 +210,7 @@ func convertZone(u *unstructured.Unstructured) (zone, error) { } // The generation changes exactly when the spec does, so every replica // renders the same bytes for the same object version. - serial := uint32(u.GetGeneration()) + serial := uint32(generation) if serial == 0 { serial = 1 } diff --git a/internal/localzone/render.go b/internal/localzone/render.go index da2eb1d..751b950 100644 --- a/internal/localzone/render.go +++ b/internal/localzone/render.go @@ -59,3 +59,20 @@ const ZoneSourceHeader = `; fancy-dns zone rendered from a Kubernetes DNSZone ob ; ; Regenerated whenever the object changes; edit the DNSZone, not this file. ` + +// CanonicalRecord returns a record in the form the zone store holds it: the +// owner fully qualified, the type uppercased, the RDATA as the master-file +// parser reads it. An editor compares records in this form, so "www" and +// "www.example.com." are the same record. +func CanonicalRecord(origin string, in Record) (Record, error) { + origin, err := NormalizeOrigin(origin) + if err != nil { + return Record{}, fmt.Errorf("zone name: %w", err) + } + rec, err := newRecord(origin, in.Name, in.Type, ttlOr(in.TTL, DefaultZoneTTL), in.Value) + if err != nil { + return Record{}, err + } + rec.TTL = in.TTL // 0 still means "the zone's default" + return rec, nil +} diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 41fc10a..23a2e40 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -367,6 +367,12 @@ export interface ClientGroup { query_count: number blocked_count: number matched: boolean + /** + * Where the group is defined when it is not the resolver's config file: + * 'kubernetes' for a ClientGroup object, 'history' for a name that only has + * traffic counters. Omitted for a group from the file. + */ + source?: 'kubernetes' | 'history' } export interface ClientsResponse {