Files
fancy-dns/internal/console/hooks.go
T
matsandClaude Sonnet 5.5 88479b9a12 Add AddressOverride and DomainRule objects for what lived in a ConfigMap and the config file
The address redirects page rewrote an RPZ ConfigMap through the resolver's own
service account, and the domains page rewrote allow_entries/block_entries in the
config file. Both become cluster objects like the rest.

AddressOverride is one name answered with fixed addresses (the name and
everything under it), exactly what an RPZ "name A address" line did. DomainRule
is a block or allow list of inline names. The resolver reads both with the
existing watch: overrides are rendered into the managed RPZ source the blocklist
manager already has, and rule names join the config's inline entries in the
merged overlay, so the file's config is never written to.

Names are checked to be DNS names or wildcard patterns before they are used,
because they are joined into line-oriented parser input and a newline or a
comment character in one would otherwise add a rule of its own.

The console edits both: the address-overrides and domains endpoints now write
objects. The console keeps its added rules in one DomainRule per action
(inline-block, inline-allow) instead of an object per name, and removes a name
from whichever DomainRule holds it, wildcard or not. An end-to-end test drives
the pages' endpoints and checks the resolver's answers.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 10:08:08 +02:00

348 lines
11 KiB
Go

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,
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 {
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})
}
// 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
}