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>
348 lines
11 KiB
Go
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
|
|
}
|