377 lines
13 KiB
Go
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
|
|
}
|