Files
fancy-dns/internal/console/hooks.go
T
mats 66f36cfac6
build / build-push (push) Successful in 1m11s
more stuff
2026-10-16 14:49:50 +02:00

377 lines
13 KiB
Go

package console
import (
"context"
"encoding/json"
"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,
AddressOverrides: c.listOverrides,
SaveAddressOverride: c.saveOverride,
DeleteAddressOverride: c.deleteOverride,
AddDomain: c.addDomain,
DeleteDomain: c.deleteDomain,
}
}
// 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 {
config.Group
Source string `json:"source"`
} `json:"groups"`
DefaultPolicy string `json:"default_policy"`
DefaultRateLimit int `json:"default_rate_limit"`
}
// sameGroup compares two groups by what they configure, so an empty list and a
// missing one are the same.
func sameGroup(a, b config.Group) bool {
norm := func(g config.Group) string {
for _, p := range []*[]string{&g.Match.CIDRs, &g.Match.PTRSuffixes, &g.Match.Tags, &g.AllowDomains, &g.DenyDomains, &g.Upstreams} {
if *p == nil {
*p = []string{}
}
}
out, _ := json.Marshal(g)
return string(out)
}
return norm(a) == norm(b)
}
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]config.Group{}
for _, g := range view.Groups {
if g.Source == "" {
inFile[g.Name] = g.Group
}
}
// The console writes cluster objects. A group the resolver's config file
// defines is left alone, and a change to it is refused, because skipping it
// quietly would make the save look as if it worked.
for _, g := range groups {
if have, ok := inFile[g.Name]; ok && !sameGroup(have, g) {
return 0, fmt.Errorf("group %q is defined in the resolver's config file; change it there", g.Name)
}
}
res, err := c.opt.Editor.SyncGroups(ctx, groups, func(n string) bool { _, ok := inFile[n]; return ok }, 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)
// Never nil: an empty list must be [] on the wire, not null.
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
// What this editor can and cannot change, so the page can say so.
view["default_policy_editable"] = false
view["file_groups_editable"] = false
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})
}
// addressOverride is one row of the Address redirects page.
type addressOverride struct {
Domain string `json:"domain"`
Address string `json:"address"`
}
func (c *Console) listOverrides(ctx context.Context) (any, error) {
objs, err := c.opt.Editor.Overrides(ctx)
if err != nil {
return nil, err
}
items := []addressOverride{}
for _, o := range objs {
for _, a := range o.Addresses {
items = append(items, addressOverride{Domain: o.Domain, Address: a})
}
}
return map[string]any{"items": items, "writable": true, "initialized": true}, nil
}
func (c *Console) saveOverride(ctx context.Context, domain, address string) error {
return c.opt.Editor.SetOverride(ctx, domain, address, actor(ctx))
}
func (c *Console) deleteOverride(ctx context.Context, domain string) error {
return c.opt.Editor.DeleteOverride(ctx, domain)
}
// addDomain adds inline block or allow rules. A bulk paste arrives as content,
// one name per line.
func (c *Console) addDomain(ctx context.Context, in api.DomainInput) (any, error) {
action := strings.ToLower(strings.TrimSpace(in.Action))
if action != "block" && action != "allow" {
return nil, fmt.Errorf("inline rules can block or allow a name; %q is not one of those (use an AddressOverride to answer with an address)", in.Action)
}
raw := []string{in.Name}
if in.Content != "" {
raw = strings.Split(in.Content, "\n")
}
var names []string
for _, line := range raw {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
names = append(names, line)
}
if len(names) == 0 {
return nil, errors.New("name is required")
}
n, err := c.opt.Editor.AddRules(ctx, action, names, actor(ctx))
if err != nil {
return nil, err
}
return map[string]any{"ok": true, "name": strings.TrimSpace(in.Name), "count": n}, nil
}
func (c *Console) deleteDomain(ctx context.Context, name, _, list string) error {
_, err := c.opt.Editor.DeleteRule(ctx, name, actor(ctx))
if errors.Is(err, kubeedit.ErrNotFound) && list != "" {
return fmt.Errorf("%q comes from the list %q; edit that list instead", name, list)
}
return err
}