all the changes
Build and Publish / Metadata (push) Successful in 2s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m23s

This commit is contained in:
mats committed 2026-09-19 23:25:59 +02:00
1 parent 94f30165f9
commit f30adf05af
35 files changed
+2979 -1607

No files matched your search

+383
View File
@@ -0,0 +1,383 @@
// Package blocker turns the honeypot's promoted-IP feed into ingress-nginx
// `block-cidrs` entries.
//
// Safety properties, all deliberate:
// - dry-run is the default; nothing is written unless DryRun is false;
// - it only manages entries it added itself (tracked in an annotation on the
// ConfigMap), so manually configured block-cidrs are preserved untouched;
// - loopback/private/link-local addresses and an operator allowlist are never
// blocked;
// - blocks expire, the set is capped, growth per tick is limited, and writes
// are rate-limited because every ConfigMap change reloads nginx.
package blocker
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"net/netip"
"os"
"sort"
"strconv"
"strings"
"sync"
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
"git.tornberg.me/go-otel/internal/honeypot"
)
const AnnotationManaged = "otel-debug.k6n.net/blocked"
type Source interface {
PromotedIPs() []honeypot.IP
}
type Config struct {
Enabled bool
DryRun bool
Namespace string
ConfigMap string
Key string
Interval time.Duration // how often the feed is evaluated
MinWrite time.Duration // minimum gap between ConfigMap writes
BlockTTL time.Duration // how long a block lasts after last sighting
MinProbes int // per-window probe hits required to block
MinPaths int // distinct probe paths required to block
BroadProbes int // hits+paths that block regardless of MinProbes
BroadPaths int
MaxEntries int // cap on entries this blocker manages
MaxNewPerTick int // limit on newly added entries per evaluation
Allow []netip.Prefix
}
func DefaultConfig() Config {
return Config{
DryRun: true, Namespace: "ingress-nginx", ConfigMap: "ingress-nginx-controller", Key: "block-cidrs",
Interval: time.Minute, MinWrite: 5 * time.Minute, BlockTTL: 24 * time.Hour,
MinProbes: 10, MinPaths: 3, BroadProbes: 100, BroadPaths: 50, MaxEntries: 500, MaxNewPerTick: 20,
}
}
// ConfigFromEnv reads OTEL_BLOCKER_*. Enabled defaults to false and DryRun to
// true; both must be changed on purpose.
func ConfigFromEnv() Config {
c := DefaultConfig()
c.Enabled = strings.EqualFold(os.Getenv("OTEL_BLOCKER_ENABLED"), "true")
if v := os.Getenv("OTEL_BLOCKER_DRY_RUN"); v != "" {
c.DryRun = !strings.EqualFold(v, "false")
}
str := func(env string, dst *string) {
if v := os.Getenv(env); v != "" {
*dst = v
}
}
num := func(env string, dst *int) {
if n, err := strconv.Atoi(os.Getenv(env)); err == nil && n > 0 {
*dst = n
}
}
dur := func(env string, dst *time.Duration) {
if d, err := time.ParseDuration(os.Getenv(env)); err == nil && d > 0 {
*dst = d
}
}
str("OTEL_BLOCKER_NAMESPACE", &c.Namespace)
str("OTEL_BLOCKER_CONFIGMAP", &c.ConfigMap)
str("OTEL_BLOCKER_KEY", &c.Key)
num("OTEL_BLOCKER_MIN_PROBES", &c.MinProbes)
num("OTEL_BLOCKER_MIN_PATHS", &c.MinPaths)
num("OTEL_BLOCKER_MAX_ENTRIES", &c.MaxEntries)
dur("OTEL_BLOCKER_INTERVAL", &c.Interval)
dur("OTEL_BLOCKER_MIN_WRITE", &c.MinWrite)
dur("OTEL_BLOCKER_TTL", &c.BlockTTL)
for _, item := range strings.Split(os.Getenv("OTEL_BLOCKER_ALLOW"), ",") {
item = strings.TrimSpace(item)
if item == "" {
continue
}
if p, err := netip.ParsePrefix(item); err == nil {
c.Allow = append(c.Allow, p)
} else if a, err := netip.ParseAddr(item); err == nil {
c.Allow = append(c.Allow, netip.PrefixFrom(a, a.BitLen()))
} else {
log.Printf("blocker: ignoring invalid OTEL_BLOCKER_ALLOW entry %q", item)
}
}
return c
}
// Start builds a client (in-cluster, or KUBECONFIG locally) and runs the
// blocker in the background. It returns nil, nil when disabled.
func Start(ctx context.Context, cfg Config, source Source) (*Blocker, error) {
if !cfg.Enabled {
return nil, nil
}
rc, err := rest.InClusterConfig()
if err != nil {
kubeconfig := os.Getenv("KUBECONFIG")
if kubeconfig == "" {
home, herr := os.UserHomeDir()
if herr != nil {
return nil, herr
}
kubeconfig = home + "/.kube/config"
}
if rc, err = clientcmd.BuildConfigFromFlags("", kubeconfig); err != nil {
return nil, fmt.Errorf("blocker kubernetes config: %w", err)
}
}
client, err := kubernetes.NewForConfig(rc)
if err != nil {
return nil, fmt.Errorf("blocker kubernetes client: %w", err)
}
b := New(cfg, client, source)
go b.Run(ctx)
return b, nil
}
type Blocker struct {
cfg Config
client kubernetes.Interface
source Source
now func() time.Time
mu sync.Mutex
lastWrite time.Time
lastErr string
last Status
}
// Status is a snapshot for the UI and for tests.
type Status struct {
Enabled bool `json:"enabled"`
DryRun bool `json:"dryRun"`
Evaluated time.Time `json:"evaluated"`
Candidates []string `json:"candidates"`
Managed map[string]int64 `json:"managed"` // ip -> unix expiry
Wrote bool `json:"wrote"`
LastWrite time.Time `json:"lastWrite"`
Error string `json:"error,omitempty"`
}
func New(cfg Config, client kubernetes.Interface, source Source) *Blocker {
d := DefaultConfig()
if cfg.Interval <= 0 {
cfg.Interval = d.Interval
}
if cfg.MinWrite <= 0 {
cfg.MinWrite = d.MinWrite
}
if cfg.BlockTTL <= 0 {
cfg.BlockTTL = d.BlockTTL
}
if cfg.MaxEntries <= 0 {
cfg.MaxEntries = d.MaxEntries
}
if cfg.MaxNewPerTick <= 0 {
cfg.MaxNewPerTick = d.MaxNewPerTick
}
if cfg.MinProbes <= 0 {
cfg.MinProbes = d.MinProbes
}
if cfg.MinPaths <= 0 {
cfg.MinPaths = d.MinPaths
}
if cfg.BroadProbes <= 0 {
cfg.BroadProbes = d.BroadProbes
}
if cfg.BroadPaths <= 0 {
cfg.BroadPaths = d.BroadPaths
}
return &Blocker{cfg: cfg, client: client, source: source, now: time.Now}
}
// Run evaluates the feed until ctx is cancelled.
func (b *Blocker) Run(ctx context.Context) {
log.Printf("blocker: started (dryRun=%t, %s/%s key=%s)", b.cfg.DryRun, b.cfg.Namespace, b.cfg.ConfigMap, b.cfg.Key)
t := time.NewTicker(b.cfg.Interval)
defer t.Stop()
for {
if err := b.Tick(ctx); err != nil {
log.Printf("blocker: %v", err)
}
select {
case <-ctx.Done():
return
case <-t.C:
}
}
}
// Blockable applies the policy to one promoted IP.
func (b *Blocker) Blockable(ip honeypot.IP) bool {
if honeypot.Untrackable(ip.Address) {
return false
}
addr, err := netip.ParseAddr(ip.Address)
if err != nil {
return false
}
for _, p := range b.cfg.Allow {
if p.Contains(addr) {
return false
}
}
return (ip.Count >= b.cfg.MinProbes && ip.Paths >= b.cfg.MinPaths) ||
(ip.Count >= b.cfg.BroadProbes && ip.Paths >= b.cfg.BroadPaths)
}
// Tick runs one evaluation. It is exported so tests and a manual trigger can
// drive it without waiting on the timer.
func (b *Blocker) Tick(ctx context.Context) error {
b.mu.Lock()
defer b.mu.Unlock()
now := b.now()
status := Status{Enabled: true, DryRun: b.cfg.DryRun, Evaluated: now, Managed: map[string]int64{}, LastWrite: b.lastWrite}
defer func() { b.last = status }()
cms := b.client.CoreV1().ConfigMaps(b.cfg.Namespace)
cm, err := cms.Get(ctx, b.cfg.ConfigMap, metav1.GetOptions{})
if err != nil {
b.lastErr = err.Error()
status.Error = b.lastErr
return fmt.Errorf("get configmap: %w", err)
}
previous := map[string]int64{}
if raw := cm.Annotations[AnnotationManaged]; raw != "" {
if err := json.Unmarshal([]byte(raw), &previous); err != nil {
previous = map[string]int64{} // unreadable state: treat as empty rather than guess
}
}
managed := map[string]int64{}
for ip, exp := range previous {
if exp > now.Unix() {
managed[ip] = exp
}
}
added := 0
for _, cand := range b.source.PromotedIPs() {
if !b.Blockable(cand) {
continue
}
status.Candidates = append(status.Candidates, cand.Address)
_, exists := managed[cand.Address]
if !exists {
if added >= b.cfg.MaxNewPerTick {
continue
}
added++
}
managed[cand.Address] = now.Add(b.cfg.BlockTTL).Unix()
}
sort.Strings(status.Candidates)
for len(managed) > b.cfg.MaxEntries { // drop the soonest-expiring first
var oldest string
for ip, exp := range managed {
if oldest == "" || exp < managed[oldest] || (exp == managed[oldest] && ip < oldest) {
oldest = ip
}
}
delete(managed, oldest)
}
status.Managed = managed
current := splitCIDRs(cm.Data[b.cfg.Key])
manual := make([]string, 0, len(current))
for _, entry := range current {
if _, wasOurs := previous[entry]; !wasOurs {
manual = append(manual, entry)
}
}
desired := append([]string(nil), manual...)
for ip := range managed {
desired = append(desired, ip)
}
desired = dedupSorted(desired)
changed := strings.Join(desired, ",") != strings.Join(dedupSorted(current), ",")
if !changed {
return nil
}
if b.cfg.DryRun {
log.Printf("blocker[dry-run]: would set %s to %d entries (%d managed): %v", b.cfg.Key, len(desired), len(managed), managedList(managed))
return nil
}
if !b.lastWrite.IsZero() && now.Sub(b.lastWrite) < b.cfg.MinWrite {
return nil // rate-limited; the change is picked up on a later tick
}
state, _ := json.Marshal(managed)
if cm.Annotations == nil {
cm.Annotations = map[string]string{}
}
if cm.Data == nil {
cm.Data = map[string]string{}
}
cm.Annotations[AnnotationManaged] = string(state)
cm.Data[b.cfg.Key] = strings.Join(desired, ",")
if len(desired) == 0 {
delete(cm.Data, b.cfg.Key)
}
if _, err := cms.Update(ctx, cm, metav1.UpdateOptions{}); err != nil {
b.lastErr = err.Error()
status.Error = b.lastErr
return fmt.Errorf("update configmap: %w", err)
}
b.lastWrite, b.lastErr = now, ""
status.Wrote, status.LastWrite = true, now
log.Printf("blocker: updated %s: %d entries (%d managed)", b.cfg.Key, len(desired), len(managed))
return nil
}
// HandleStatus serves the last evaluation. Register it behind authentication.
func (b *Blocker) HandleStatus(w http.ResponseWriter, r *http.Request) {
b.mu.Lock()
s := b.last
b.mu.Unlock()
if s.Evaluated.IsZero() {
s = Status{Enabled: true, DryRun: b.cfg.DryRun, Managed: map[string]int64{}}
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(s)
}
func splitCIDRs(v string) []string {
var out []string
for _, p := range strings.Split(v, ",") {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
func dedupSorted(in []string) []string {
seen := map[string]struct{}{}
out := make([]string, 0, len(in))
for _, v := range in {
if _, ok := seen[v]; !ok {
seen[v] = struct{}{}
out = append(out, v)
}
}
sort.Strings(out)
return out
}
func managedList(m map[string]int64) []string {
out := make([]string, 0, len(m))
for ip := range m {
out = append(out, ip)
}
sort.Strings(out)
return out
}
+153
View File
@@ -0,0 +1,153 @@
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)
}
}