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

194 lines
6.6 KiB
Go

package console
import (
"context"
"errors"
"net/http"
"net/netip"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/acl"
"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"
)
// validateRequest is a ClientGroup as the editor has it, before it is saved.
type validateRequest struct {
Group config.Group `json:"group"`
// TestIP, when set, says which group an ordinary client at that address
// would land in once this group is saved.
TestIP string `json:"test_ip,omitempty"`
}
// validateResponse separates what blocks a save (errors) from what is legal
// but probably unintended (warnings) and from plain information (notes).
type validateResponse struct {
OK bool `json:"ok"`
Errors []acl.Finding `json:"errors"`
Warnings []acl.Finding `json:"warnings"`
Notes []acl.Finding `json:"notes"`
Summary []string `json:"summary"`
Unreachable bool `json:"unreachable"`
Position int `json:"position"`
Total int `json:"total"`
Test *validateTestIP `json:"test,omitempty"`
}
type validateTestIP struct {
IP string `json:"ip"`
Valid bool `json:"valid"`
Group string `json:"group"`
Default bool `json:"default"`
Note string `json:"note"`
}
// handleClientsValidate checks a group without saving it. It applies the same
// checks the save applies (kubesource.CheckGroup, the object-name rule) and
// then says what the group would do and what is probably wrong with it, in the
// context of the groups that already exist. A group that is not already saved
// is placed last, which is where a save puts it.
func (c *Console) handleClientsValidate(w http.ResponseWriter, r *http.Request) {
var in validateRequest
if err := api.DecodeJSON(r, &in); err != nil {
api.WriteError(w, http.StatusBadRequest, "malformed request body")
return
}
current, meta, err := c.currentGroups(r.Context())
if err != nil {
api.WriteError(w, http.StatusBadGateway, err.Error())
return
}
out := validateResponse{Errors: []acl.Finding{}, Warnings: []acl.Finding{}, Notes: []acl.Finding{}, Summary: []string{}}
name := in.Group.Name
if err := kubeedit.CheckGroupName(name); err != nil {
out.Errors = append(out.Errors, acl.Finding{Field: "name", Message: err.Error()})
}
spec := kubeedit.SpecFromGroup(in.Group, 0)
for _, fe := range kubesource.CheckGroupAll(name, spec) {
out.Errors = append(out.Errors, acl.Finding{Field: fe.Field, Message: fe.Msg})
}
if have, ok := meta.fileGroups[name]; ok && !sameGroup(have, in.Group) {
out.Errors = append(out.Errors, acl.Finding{Field: "name", Message: "A group with this name is defined in the resolver's config file and cannot be changed here. Pick another name."})
}
groups, idx := place(current, in.Group)
out.Position, out.Total = idx+1, len(groups)
out.OK = len(out.Errors) == 0
// The analysis is useful even for a group that cannot be saved yet (it
// explains what the typed-so-far group would do), but it only makes sense
// once the group selects something.
an := acl.Analyze(acl.Context{
Groups: groups, Index: idx,
DefaultRateLimit: meta.defaultRateLimit, DefaultPolicy: meta.defaultPolicy,
Upstreams: meta.upstreams,
})
out.Summary, out.Warnings, out.Notes, out.Unreachable = an.Summary, an.Warnings, an.Notes, an.Unreachable
if in.TestIP != "" {
out.Test = testIP(in.TestIP, groups, meta.defaultPolicy)
}
api.WriteJSON(w, http.StatusOK, out)
}
// place puts g in the list: in its own slot when a group of that name exists,
// otherwise last, which is where a save adds it.
func place(list []config.Group, g config.Group) ([]config.Group, int) {
out := append([]config.Group(nil), list...)
for i := range out {
if out[i].Name == g.Name && g.Name != "" {
out[i] = g
return out, i
}
}
return append(out, g), len(out)
}
func testIP(raw string, groups []config.Group, defaultPolicy string) *validateTestIP {
res := &validateTestIP{IP: raw}
ip, err := netip.ParseAddr(raw)
if err != nil {
res.Note = "Not an IP address."
return res
}
res.Valid = true
d := acl.Compile(config.ACL{DefaultPolicy: defaultPolicy, Groups: groups}).Match(acl.Client{IP: ip})
res.Group, res.Default = d.Group, d.Default
switch {
case d.Default:
res.Note = "No group matches this address by IP; it gets the default policy (" + string(d.Policy) + ")."
default:
res.Note = "Matched on address. Clients can also match by reverse-DNS name or tag, which cannot be tested here."
}
return res
}
type groupsMeta struct {
fileGroups map[string]config.Group
defaultPolicy string
defaultRateLimit int
upstreams []string
}
// currentGroups is the evaluation order the resolver will use, made consistent
// with the cluster: the same merge handleClients does, as typed groups.
func (c *Console) currentGroups(ctx context.Context) ([]config.Group, groupsMeta, error) {
meta := groupsMeta{fileGroups: map[string]config.Group{}}
var view clientsView
if err := c.opt.Runtime.Get(ctx, "/api/clients", &view); err != nil {
return nil, meta, err
}
objs, err := c.opt.Editor.Groups(ctx)
if err != nil {
return nil, meta, errors.New("cluster: " + err.Error())
}
meta.defaultPolicy, meta.defaultRateLimit = view.DefaultPolicy, view.DefaultRateLimit
inCluster := map[string]bool{}
for _, o := range objs {
inCluster[o.Name] = true
}
var out []config.Group
seen := map[string]bool{}
for _, g := range view.Groups {
switch g.Source {
case "history":
continue
case "kubernetes":
if !inCluster[g.Name] {
continue
}
default:
meta.fileGroups[g.Name] = g.Group
}
seen[g.Name] = true
out = append(out, g.Group)
}
for _, o := range objs {
if !seen[o.Name] {
g := config.Group{Name: o.Name, Comment: o.Spec.Comment, Match: o.Spec.Match, Policy: o.Spec.Policy,
AllowDomains: o.Spec.AllowDomains, DenyDomains: o.Spec.DenyDomains, SkipBlocklist: o.Spec.SkipBlocklist,
SkipCache: o.Spec.SkipCache, RateLimit: o.Spec.RateLimit, MaxConcurrent: o.Spec.MaxConcurrent,
LogQueries: o.Spec.LogQueries, Upstreams: o.Spec.Upstreams}
out = append(out, g)
}
}
// Upstreams are a nicety for one warning; an unreachable or older resolver
// must not make validation fail.
var ups struct {
Servers []struct {
Addr string `json:"addr"`
} `json:"servers"`
}
if err := c.opt.Runtime.Get(ctx, "/api/upstreams", &ups); err == nil {
meta.upstreams = []string{}
for _, s := range ups.Servers {
meta.upstreams = append(meta.upstreams, s.Addr)
}
}
return out, meta, nil
}