476 lines
14 KiB
Go
476 lines
14 KiB
Go
// 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"`
|
|
}
|
|
|
|
// Add manually adds one public IP to the blocker-managed set. It uses the
|
|
// same expiry, allowlist, ConfigMap ownership, and dry-run rules as automatic
|
|
// blocks.
|
|
func (b *Blocker) Add(ctx context.Context, address string) (Status, error) {
|
|
addr, err := netip.ParseAddr(strings.TrimSpace(address))
|
|
if err != nil || honeypot.Untrackable(address) {
|
|
return Status{}, fmt.Errorf("only public unicast IPs can be blocked")
|
|
}
|
|
for _, p := range b.cfg.Allow {
|
|
if p.Contains(addr) {
|
|
return Status{}, fmt.Errorf("IP is on the operator allowlist")
|
|
}
|
|
}
|
|
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
now := b.now()
|
|
cms := b.client.CoreV1().ConfigMaps(b.cfg.Namespace)
|
|
cm, err := cms.Get(ctx, b.cfg.ConfigMap, metav1.GetOptions{})
|
|
if err != nil {
|
|
return Status{}, fmt.Errorf("get configmap: %w", err)
|
|
}
|
|
previous := map[string]int64{}
|
|
if raw := cm.Annotations[AnnotationManaged]; raw != "" {
|
|
_ = json.Unmarshal([]byte(raw), &previous)
|
|
}
|
|
managed := map[string]int64{}
|
|
for ip, exp := range previous {
|
|
if exp > now.Unix() {
|
|
managed[ip] = exp
|
|
}
|
|
}
|
|
if _, exists := managed[addr.String()]; !exists && len(managed) >= b.cfg.MaxEntries {
|
|
return Status{}, fmt.Errorf("managed block list is full")
|
|
}
|
|
managed[addr.String()] = now.Add(b.cfg.BlockTTL).Unix()
|
|
|
|
status := Status{Enabled: true, DryRun: b.cfg.DryRun, Evaluated: now, Managed: managed, LastWrite: b.lastWrite}
|
|
if b.cfg.DryRun {
|
|
b.last = status
|
|
return status, nil
|
|
}
|
|
if !b.lastWrite.IsZero() && now.Sub(b.lastWrite) < b.cfg.MinWrite {
|
|
return status, fmt.Errorf("block list was recently updated; try again later")
|
|
}
|
|
|
|
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(manual, managedList(managed)...)
|
|
if cm.Annotations == nil {
|
|
cm.Annotations = map[string]string{}
|
|
}
|
|
if cm.Data == nil {
|
|
cm.Data = map[string]string{}
|
|
}
|
|
state, _ := json.Marshal(managed)
|
|
cm.Annotations[AnnotationManaged] = string(state)
|
|
cm.Data[b.cfg.Key] = strings.Join(dedupSorted(desired), ",")
|
|
if _, err := cms.Update(ctx, cm, metav1.UpdateOptions{}); err != nil {
|
|
return Status{}, fmt.Errorf("update configmap: %w", err)
|
|
}
|
|
b.lastWrite, b.lastErr = now, ""
|
|
status.Wrote, status.LastWrite = true, now
|
|
b.last = status
|
|
log.Printf("blocker: manually added %s (expires %s)", addr, time.Unix(managed[addr.String()], 0).UTC().Format(time.RFC3339))
|
|
return status, nil
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// HandleAdd serves POST /api/blocker/ips/{ip}. Register it behind
|
|
// authentication; it never permits private or allowlisted addresses.
|
|
func (b *Blocker) HandleAdd(w http.ResponseWriter, r *http.Request) {
|
|
status, err := b.Add(r.Context(), r.PathValue("ip"))
|
|
if err != nil {
|
|
code := http.StatusBadRequest
|
|
if strings.Contains(err.Error(), "recently updated") {
|
|
code = http.StatusTooManyRequests
|
|
}
|
|
http.Error(w, err.Error(), code)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if status.DryRun {
|
|
w.WriteHeader(http.StatusAccepted)
|
|
}
|
|
_ = json.NewEncoder(w).Encode(status)
|
|
}
|
|
|
|
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
|
|
}
|