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 }