1 parent
289a8cc2d8
commit
66f36cfac6
19 files changed
+1905
-817
No files matched your search
@@ -0,0 +1,193 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user