GET /api/clients returned "groups": null when there were no groups, because the merged list was a nil slice. Found against the real cluster. The console manifest, never applied before, had an image name that does not exist here, no image pull secret or architecture selector (the resolver's pods have both), and pointed at a Service (fancy-dns-admin) that is not there. It now uses the registry image, the pull secret, and the existing fancy-dns-web Service. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
349 lines
12 KiB
Go
349 lines
12 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)
|
|
// 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
|
|
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
|
|
}
|