package blocker import ( "context" "encoding/json" "net/netip" "strings" "testing" "time" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes/fake" "git.tornberg.me/go-otel/internal/honeypot" ) type feed []honeypot.IP func (f feed) PromotedIPs() []honeypot.IP { return f } func setup(t *testing.T, cfg Config, data map[string]string, src Source) (*Blocker, *fake.Clientset, *time.Time) { t.Helper() client := fake.NewSimpleClientset(&corev1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{Namespace: "ingress-nginx", Name: "ingress-nginx-controller"}, Data: data, }) b := New(cfg, client, src) clock := time.Date(2026, 9, 19, 12, 0, 0, 0, time.UTC) b.now = func() time.Time { return clock } return b, client, &clock } func current(t *testing.T, c *fake.Clientset) *corev1.ConfigMap { t.Helper() cm, err := c.CoreV1().ConfigMaps("ingress-nginx").Get(context.Background(), "ingress-nginx-controller", metav1.GetOptions{}) if err != nil { t.Fatal(err) } return cm } func scanner(ip string) honeypot.IP { return honeypot.IP{Address: ip, Count: 40, Paths: 12} } func liveConfig() Config { c := DefaultConfig() c.DryRun = false return c } func TestDryRunWritesNothing(t *testing.T) { b, client, _ := setup(t, DefaultConfig(), map[string]string{"use-gzip": "true"}, feed{scanner("203.0.113.5")}) if err := b.Tick(context.Background()); err != nil { t.Fatal(err) } cm := current(t, client) if _, ok := cm.Data["block-cidrs"]; ok || cm.Annotations[AnnotationManaged] != "" { t.Fatalf("dry run wrote to the configmap: %+v %+v", cm.Data, cm.Annotations) } if len(b.last.Candidates) != 1 { t.Fatalf("dry run should still report candidates: %+v", b.last) } } func TestBlocksScannerAndPreservesManualEntries(t *testing.T) { b, client, _ := setup(t, liveConfig(), map[string]string{"block-cidrs": "198.51.100.0/24", "use-gzip": "true"}, feed{scanner("203.0.113.5")}) if err := b.Tick(context.Background()); err != nil { t.Fatal(err) } cm := current(t, client) if got := cm.Data["block-cidrs"]; got != "198.51.100.0/24,203.0.113.5" { t.Fatalf("block-cidrs = %q", got) } if cm.Data["use-gzip"] != "true" { t.Fatal("unrelated keys must be untouched") } var state map[string]int64 if err := json.Unmarshal([]byte(cm.Annotations[AnnotationManaged]), &state); err != nil || len(state) != 1 { t.Fatalf("annotation = %q (%v)", cm.Annotations[AnnotationManaged], err) } } func TestNeverBlocksInternalOrAllowlisted(t *testing.T) { cfg := liveConfig() cfg.Allow = []netip.Prefix{netip.MustParsePrefix("203.0.113.0/24")} b, client, _ := setup(t, cfg, nil, feed{scanner("127.0.0.1"), scanner("10.244.4.1"), scanner("192.168.1.9"), scanner("203.0.113.77")}) if err := b.Tick(context.Background()); err != nil { t.Fatal(err) } if v := current(t, client).Data["block-cidrs"]; v != "" { t.Fatalf("blocked %q", v) } } func TestThresholdsKeepOutOccasionalVisitors(t *testing.T) { b, client, _ := setup(t, liveConfig(), nil, feed{ {Address: "203.0.113.1", Count: 4, Paths: 4}, // too few hits {Address: "203.0.113.2", Count: 500, Paths: 1}, // one path hammered {Address: "203.0.113.3", Count: 200, Paths: 60}, // broad scanner }) if err := b.Tick(context.Background()); err != nil { t.Fatal(err) } if v := current(t, client).Data["block-cidrs"]; v != "203.0.113.3" { t.Fatalf("block-cidrs = %q", v) } } func TestExpiryRemovesOnlyManagedEntries(t *testing.T) { src := feed{scanner("203.0.113.5")} b, client, clock := setup(t, liveConfig(), map[string]string{"block-cidrs": "198.51.100.7"}, src) if err := b.Tick(context.Background()); err != nil { t.Fatal(err) } b.source = feed{} // scanner goes quiet *clock = clock.Add(25 * time.Hour) if err := b.Tick(context.Background()); err != nil { t.Fatal(err) } if v := current(t, client).Data["block-cidrs"]; v != "198.51.100.7" { t.Fatalf("after expiry block-cidrs = %q, want only the manual entry", v) } } func TestWritesAreRateLimitedAndNewPerTickCapped(t *testing.T) { cfg := liveConfig() cfg.MaxNewPerTick = 2 var many feed for _, ip := range []string{"203.0.113.1", "203.0.113.2", "203.0.113.3", "203.0.113.4"} { many = append(many, scanner(ip)) } b, client, clock := setup(t, cfg, nil, many) if err := b.Tick(context.Background()); err != nil { t.Fatal(err) } if n := len(strings.Split(current(t, client).Data["block-cidrs"], ",")); n != 2 { t.Fatalf("first tick added %d, want 2", n) } *clock = clock.Add(time.Minute) // inside MinWrite if err := b.Tick(context.Background()); err != nil { t.Fatal(err) } if n := len(strings.Split(current(t, client).Data["block-cidrs"], ",")); n != 2 { t.Fatalf("write was not rate limited: %d entries", n) } *clock = clock.Add(10 * time.Minute) if err := b.Tick(context.Background()); err != nil { t.Fatal(err) } if n := len(strings.Split(current(t, client).Data["block-cidrs"], ",")); n != 4 { t.Fatalf("after the interval got %d entries, want 4", n) } }