Split the console from the resolver: fancy-dns-console writes the cluster

fancy-dns-console is a second binary holding the web UI, authentication and
every edit. It writes ClientGroup, BlockList and DNSZone objects through the new
kubeedit package and forwards all other /api routes to a running resolver
with its own key, dropping the browser's credentials, so it can run in the
cluster or on a workstation with a kubeconfig and nothing about the resolver's
placement matters. The resolver never imports kubeedit and the console is not on
the DNS path.

kubeedit validates every spec with the runtime's own converters
(kubesource.CheckGroup/CheckList/CheckZone) before writing, so the console
cannot store an object the resolver would reject. Zone edits keep the
file-based writer's behaviour: duplicate and below-a-delegation records are
refused, a delegation in a new zone also gives the zone its servers, and a
zone with no apex NS is never stored. Each object records the editing user in
an annotation; updates retry on conflict.

The resolver reports groups from ClientGroup objects as source "kubernetes" and
counter-only rows (the "(default)" bucket, or a group removed since) as
"history", and lists DNSZone zones as the editable ones. The console uses that
to leave the YAML's groups alone and to drop history rows, which the UI would
otherwise send back on save. Its GET /api/clients overlays the cluster's
current objects on the resolver's list, because the resolver only sees an edit
after its watch fires and a save made just after another would delete the
first.

An end-to-end test runs both halves against one fake apiserver: a group saved
through the console refuses queries, a zone built record by record answers
authoritatively, and a request with the resolver's own key is rejected by the
console.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
matsandClaude Sonnet 5.5 committed 2026-10-16 08:43:41 +02:00
1 parent 4d38945d94
commit 1a536e7d0e
25 files changed
+1950 -8

No files matched your search

+13
View File
@@ -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.
+8
View File
@@ -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/
+3 -1
View File
@@ -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
+138
View File
@@ -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
}
+17
View File
@@ -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
+113
View File
@@ -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}]
+5
View File
@@ -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.
+24
View File
@@ -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) }
+156
View File
@@ -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
})
}
+4 -1
View File
@@ -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"))
})
+11
View File
@@ -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,
+8 -2
View File
@@ -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 {
+120
View File
@@ -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
}
+276
View File
@@ -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})
}
+79
View File
@@ -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
}
+1 -1
View File
@@ -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 {
+155
View File
@@ -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
}
+218
View File
@@ -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)
}
}
+131
View File
@@ -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
}
+5
View File
@@ -0,0 +1,5 @@
package kubeedit
import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
var getOpts = metav1.GetOptions{}
+63
View File
@@ -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)
}
+345
View File
@@ -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
}
+34 -3
View File
@@ -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
}
+17
View File
@@ -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
}
+6
View File
@@ -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 {