more stuff
build / build-push (push) Successful in 1m11s

This commit is contained in:
mats committed 2026-10-16 14:49:50 +02:00
1 parent 289a8cc2d8
commit 66f36cfac6
19 files changed
+1905 -817

No files matched your search

+1
View File
@@ -100,6 +100,7 @@ func (c *Console) Handler() (http.Handler, error) {
// Reads that combine the cluster's objects with the resolver's view.
mux.Handle("GET "+base+"/zones", c.api.Protect(http.HandlerFunc(c.handleZones), false))
mux.Handle("GET "+base+"/clients", c.api.Protect(http.HandlerFunc(c.handleClients), false))
mux.Handle("POST "+base+"/clients/validate", c.api.Protect(http.HandlerFunc(c.handleClientsValidate), false))
mux.Handle("GET "+base+"/blocklist-sources", c.api.Protect(http.HandlerFunc(c.handleListSources), false))
mux.Handle("PUT "+base+"/blocklist-sources/{name}", c.api.Protect(http.HandlerFunc(c.handleListSourcePut), true))
mux.Handle("DELETE "+base+"/blocklist-sources/{name}", c.api.Protect(http.HandlerFunc(c.handleListSourceDelete), true))
+2 -1
View File
@@ -56,7 +56,8 @@ type clientsView struct {
config.Group
Source string `json:"source"`
} `json:"groups"`
DefaultPolicy string `json:"default_policy"`
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
+193
View File
@@ -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
}
+181
View File
@@ -0,0 +1,181 @@
package console
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
dynfake "k8s.io/client-go/dynamic/fake"
"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"
)
// validator builds a console whose cluster holds the given groups and whose
// resolver reports one group of its own, defined in its config file.
func validator(t *testing.T, saved ...config.Group) *Console {
t.Helper()
cl := dynfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(),
map[schema.GroupVersionResource]string{
kubesource.ZoneGVR: "DNSZoneList", kubesource.GroupGVR: "ClientGroupList",
kubesource.ListGVR: "BlockListList", kubesource.OverrideGVR: "AddressOverrideList",
kubesource.RuleGVR: "DomainRuleList",
})
ed := kubeedit.New(cl, "dns")
if len(saved) > 0 {
if _, err := ed.SyncGroups(context.Background(), saved, nil, "test"); err != nil {
t.Fatal(err)
}
}
resolver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/clients":
groups := []map[string]any{{
"name": "from-file", "policy": "allow", "source": "",
"match": map[string]any{"cidrs": []string{"172.16.0.0/12"}},
}}
for _, g := range saved {
groups = append(groups, map[string]any{"name": g.Name, "policy": g.Policy, "source": "kubernetes", "match": g.Match})
}
json.NewEncoder(w).Encode(map[string]any{"groups": groups, "default_policy": "allow", "default_rate_limit": 50})
case "/api/upstreams":
json.NewEncoder(w).Encode(map[string]any{"servers": []map[string]any{{"addr": "1.1.1.1:53"}}})
default:
http.NotFound(w, r)
}
}))
t.Cleanup(resolver.Close)
rt, err := NewRuntime(resolver.URL, "")
if err != nil {
t.Fatal(err)
}
return &Console{opt: Options{Runtime: rt, Editor: ed}, log: slog.Default()}
}
func validate(t *testing.T, c *Console, body string) validateResponse {
t.Helper()
rec := httptest.NewRecorder()
c.handleClientsValidate(rec, httptest.NewRequest(http.MethodPost, "/api/clients/validate", strings.NewReader(body)))
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body)
}
var out validateResponse
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("%v: %s", err, rec.Body)
}
return out
}
func hasField(list []string, f string) bool {
for _, s := range list {
if s == f {
return true
}
}
return false
}
func errFields(r validateResponse) []string {
var out []string
for _, e := range r.Errors {
out = append(out, e.Field)
}
return out
}
func TestValidateReportsEveryErrorAtOnce(t *testing.T) {
c := validator(t)
r := validate(t, c, `{"group":{"name":"Bad Name","policy":"sometimes","match":{"cidrs":["10.0.0.0/33"]},"rate_limit":-1}}`)
if r.OK {
t.Fatal("a group with four problems validated")
}
for _, want := range []string{"name", "policy", "match.cidrs", "rate_limit"} {
if !hasField(errFields(r), want) {
t.Errorf("no error for %q; got %+v", want, r.Errors)
}
}
}
func TestValidateAcceptsAGoodGroupAndExplainsIt(t *testing.T) {
c := validator(t)
r := validate(t, c, `{"group":{"name":"kids","policy":"allow","match":{"cidrs":["10.0.5.0/24"]},"allow_domains":["*.school.example"]}}`)
if !r.OK || len(r.Errors) != 0 {
t.Fatalf("errors: %+v", r.Errors)
}
if r.Position != 2 || r.Total != 2 {
t.Errorf("a new group goes last: position %d of %d", r.Position, r.Total)
}
if len(r.Summary) < 3 {
t.Errorf("summary = %v", r.Summary)
}
// 0 rate limit under a default of 50 is the surprise worth flagging.
found := false
for _, w := range r.Warnings {
found = found || w.Field == "rate_limit"
}
if !found {
t.Errorf("warnings = %+v, want the rate-limit exemption", r.Warnings)
}
}
func TestValidateEditingKeepsThePosition(t *testing.T) {
a := config.Group{Name: "a", Policy: "allow", Match: config.Match{CIDRs: []string{"10.0.0.0/8"}}}
b := config.Group{Name: "b", Policy: "allow", Match: config.Match{CIDRs: []string{"192.168.0.0/16"}}}
c := validator(t, a, b)
r := validate(t, c, `{"group":{"name":"a","policy":"deny","match":{"cidrs":["10.0.0.0/8"]}}}`)
if r.Position != 2 || r.Total != 3 { // from-file, a, b
t.Errorf("position %d of %d", r.Position, r.Total)
}
}
func TestValidateRefusesAFileGroupName(t *testing.T) {
c := validator(t)
r := validate(t, c, `{"group":{"name":"from-file","policy":"deny","match":{"cidrs":["10.0.0.0/8"]}}}`)
if r.OK || !hasField(errFields(r), "name") {
t.Errorf("a group the resolver's file owns validated: %+v", r)
}
}
func TestValidateFlagsAGroupBuriedUnderAWiderOne(t *testing.T) {
wide := config.Group{Name: "wide", Policy: "allow", Match: config.Match{CIDRs: []string{"10.0.0.0/8"}}}
c := validator(t, wide)
r := validate(t, c, `{"group":{"name":"narrow","policy":"deny","match":{"cidrs":["10.1.0.0/16"]}}}`)
if !r.OK || !r.Unreachable {
t.Errorf("ok=%v unreachable=%v warnings=%+v", r.OK, r.Unreachable, r.Warnings)
}
}
func TestValidateTestIP(t *testing.T) {
g := config.Group{Name: "lan", Policy: "deny", Match: config.Match{CIDRs: []string{"10.0.0.0/8"}}}
c := validator(t, g)
r := validate(t, c, `{"group":{"name":"lan","policy":"deny","match":{"cidrs":["10.0.0.0/8"]}},"test_ip":"10.1.2.3"}`)
if r.Test == nil || r.Test.Group != "lan" || r.Test.Default {
t.Errorf("test = %+v", r.Test)
}
r = validate(t, c, `{"group":{"name":"lan","policy":"deny","match":{"cidrs":["10.0.0.0/8"]}},"test_ip":"8.8.8.8"}`)
if r.Test == nil || !r.Test.Default {
t.Errorf("test = %+v", r.Test)
}
r = validate(t, c, `{"group":{"name":"lan","policy":"deny","match":{"cidrs":["10.0.0.0/8"]}},"test_ip":"nope"}`)
if r.Test == nil || r.Test.Valid {
t.Errorf("test = %+v", r.Test)
}
}
func TestValidateUnreachableResolverIsABadGateway(t *testing.T) {
c := validator(t)
c.opt.Runtime, _ = NewRuntime("http://127.0.0.1:1", "")
rec := httptest.NewRecorder()
c.handleClientsValidate(rec, httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"group":{"name":"x"}}`)))
if rec.Code != http.StatusBadGateway {
t.Errorf("status %d", rec.Code)
}
}