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}) }