mirror of
https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin.git
synced 2026-09-02 20:28:50 +02:00
* ♻️ cidr: build keys through net.IPNet instead of hand-masking bytes CIDRKeys masked the address byte by byte and formatted the result with string concatenation, while SetCIDR/DeleteCIDR format their keys with net.IPNet.String() via NormalizeCIDR. The two agreed only by coincidence: any divergence in formatting silently stops every range decision from matching, with no test covering the invariant. Mask with net.IP.Mask and format through net.IPNet.String() so both sides go through the same formatter. Output is byte for byte identical to the previous implementation (checked against a golden dump of both IPv4 and IPv6 keys, including ::ffff: forms). Dropping the inner byte loops also removes the only intrange violation in the tree, so the linter exclusion added for them is no longer needed, and the redundant import alias on pkg/ip goes away with it. * ⚡ cidr: only probe the prefix lengths that have a decision In stream mode nothing caches a negative result per IP, so the exact IP lookup misses on every legitimate request and each one fell through to GetCIDR, which probed every possible prefix length: 33 cache reads for an IPv4 client, 129 for an IPv6 one, even when no range decision existed at all. On the local cache that is wasted work on the request path; with redis it is 33 to 129 sequential round trips per request. Keep the set of prefix lengths that have at least one decision under a single key, written before the decision itself, and probe only those. Measured cache reads per request: 1 with no range decision (was 33 / 129), 2 with a single /24 in use, 4 with four prefix lengths in use. The set only grows, so a deleted or expired decision leaves a length behind that costs one extra read rather than risking an unmatched decision, and it is written with an effectively infinite duration since it has to outlive every decision it describes. If it is ever missing while decisions live (a redis eviction under maxmemory), range decisions stop matching until the next one arrives; it is the hottest key of the namespace, so an LRU policy evicts it last. * 🔊 cidr: log the decisions dropped for an unparsable CIDR SetCIDR and DeleteCIDR returned silently when NormalizeCIDR rejected the value, so a range decision the plugin does not understand is not enforced and nothing says why. Every other operation of the package logs, and this one fails open, which is the direction worth shouting about. Log at Error with the raw value and what the consequence is, so an unexpected decision format shows up in the logs instead of looking like a decision that was applied. * ✅ cidr: cover the invariant the range matching rests on The helpers were tested in isolation but nothing tied them together, and what actually has to hold is that the key SetCIDR writes for a decision is one of the keys GetCIDR looks up for an IP that decision covers. A formatting change on either side would have silently stopped every range decision from matching with all tests green. TestCIDRKeys_MatchNormalizeCIDR pins that both ways, including the cases worth being explicit about: a decision that is not on a network address, IPv4 mapped clients against an IPv4 range, and that the two families do not mix. Dropping the mask in cidrKey fails 9 of its cases. Also covers CIDRLookupKeys against CIDRKeys length by length, the IPv6 side of the network address test, and CIDRPrefixLen. * ✅ cache: cover the CIDR operations and what a lookup costs pkg/cache had tests for Get, Set and Delete but none for their CIDR counterparts, so the range keyspace was only exercised end to end by the e2e scenario. Test_GetCIDR covers hits, the boundaries of a range, IPv6, IPv4 mapped clients and invalid input. Test_GetCIDR_MostSpecific pins the precedence between overlapping decisions, which is deliberate behaviour that nothing was holding in place: a captcha on a /24 is not overruled by a ban on its /8. Test_DeleteCIDR checks the wider decision survives a narrower one being removed, and Test_SetCIDR_InvalidIsNotStored that a rejected value stores nothing at all. Test_GetCIDR_Reads counts cache reads through an isolated cacheInterface, so the cost of a lookup is part of the contract: probing every prefix length again turns it into 34 reads for IPv4 and 130 for IPv6 and fails. * 🐛 e2e: stop racing the deadline in the stream failure check handleStreamTicker compares updateFailure to updateMaxFailure before incrementing it, so with updateMaxFailure 2 and a 1s interval the bouncer gives up on the third consecutive failed poll, roughly 3s after the endpoint starts failing. The check slept 2s and then polled for a 200 for up to 15s, leaving about a second of margin, and once that window closes it never reopens: a runner under load turns this into a 15s wait followed by a failure. Assert the 200 immediately after the endpoint starts failing, which is always inside the window, and keep polling for the 403 that follows. * 🐛 e2e: do not fail a scenario on a single slow response The new -m 1 is right for the polling helpers, where a timed out request is just another attempt, but the assertions and the mock control plane have no retry: one request that takes over a second on a loaded runner fails the scenario, and since common.sh runs under set -euo pipefail a timed out lapi_add_decision aborts it before the decision even exists. Bound the connect at 1s instead and give the whole request 5s in the seven places that get a single attempt. Nothing waits longer on the happy path. * ⏪ e2e: restore the stream-mode failure check Revert99673b1. The check is not ours to change: it must stay as it was. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * 🔥 e2e: drop the comment above wait_for_status The timeout change in3f78887stands; only the comment goes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
302 lines
7.7 KiB
Go
302 lines
7.7 KiB
Go
package ip
|
|
|
|
import (
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestCIDRKeys(t *testing.T) {
|
|
tests := []struct {
|
|
ip string
|
|
wantKeys int
|
|
checks map[int]string
|
|
}{
|
|
{
|
|
ip: "10.0.0.1",
|
|
wantKeys: 33,
|
|
checks: map[int]string{0: "10.0.0.1/32", 8: "10.0.0.0/24", 32: "0.0.0.0/0"},
|
|
},
|
|
{
|
|
ip: "2001:db8::1",
|
|
wantKeys: 129,
|
|
checks: map[int]string{0: "2001:db8::1/128", 32: "2001:db8::/96", 128: "::/0"},
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.ip, func(t *testing.T) {
|
|
keys := CIDRKeys(tt.ip)
|
|
if keys == nil {
|
|
t.Fatal("CIDRKeys returned nil")
|
|
}
|
|
if len(keys) != tt.wantKeys {
|
|
t.Fatalf("expected %d keys, got %d", tt.wantKeys, len(keys))
|
|
}
|
|
for idx, want := range tt.checks {
|
|
if keys[idx] != want {
|
|
t.Errorf("keys[%d] should be %s, got %s", idx, want, keys[idx])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCIDRKeys_MostToLeastSpecific(t *testing.T) {
|
|
ips := []string{"10.0.0.1", "2001:db8::1"}
|
|
for _, ip := range ips {
|
|
t.Run(ip, func(t *testing.T) {
|
|
keys := CIDRKeys(ip)
|
|
for i := 1; i < len(keys); i++ {
|
|
prevBits := strings.Split(keys[i-1], "/")[1]
|
|
curBits := strings.Split(keys[i], "/")[1]
|
|
prevN, _ := strconv.Atoi(prevBits)
|
|
curN, _ := strconv.Atoi(curBits)
|
|
if prevN <= curN {
|
|
t.Errorf("keys should go from most specific to least specific at index %d: /%d <= /%d", i, prevN, curN)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCIDRKeys_IPVariants(t *testing.T) {
|
|
tests := []struct {
|
|
ip string
|
|
wantKeys int
|
|
}{
|
|
{"0.0.0.0", 33},
|
|
{"255.255.255.255", 33},
|
|
{"1.2.3.4", 33},
|
|
{"10.0.0.1", 33},
|
|
{"192.168.1.1", 33},
|
|
{"invalid", 0},
|
|
{"", 0},
|
|
{" ", 0},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.ip, func(t *testing.T) {
|
|
keys := CIDRKeys(tt.ip)
|
|
if len(keys) != tt.wantKeys {
|
|
t.Errorf("CIDRKeys(%q) returned %d keys, want %d", tt.ip, len(keys), tt.wantKeys)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCIDRKeys_VerifyNetworkAddress(t *testing.T) {
|
|
keys := CIDRKeys("10.1.2.3")
|
|
tests := []struct {
|
|
bits int
|
|
want string
|
|
}{
|
|
{24, "10.1.2.0/24"},
|
|
{16, "10.1.0.0/16"},
|
|
{8, "10.0.0.0/8"},
|
|
}
|
|
for _, tt := range tests {
|
|
if got := keys[32-tt.bits]; got != tt.want {
|
|
t.Errorf("/%d network should be %s, got %s", tt.bits, tt.want, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCIDRKeys_VerifyNetworkAddressIPv6(t *testing.T) {
|
|
keys := CIDRKeys("2001:db8:1:2:3:4:5:6")
|
|
tests := []struct {
|
|
bits int
|
|
want string
|
|
}{
|
|
{64, "2001:db8:1:2::/64"},
|
|
{48, "2001:db8:1::/48"},
|
|
{32, "2001:db8::/32"},
|
|
}
|
|
for _, tt := range tests {
|
|
if got := keys[128-tt.bits]; got != tt.want {
|
|
t.Errorf("/%d network should be %s, got %s", tt.bits, tt.want, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestCIDRKeys_MatchNormalizeCIDR covers the invariant range support rests on: the key
|
|
// written for a decision is a key looked up for the IPs it covers, and only those.
|
|
func TestCIDRKeys_MatchNormalizeCIDR(t *testing.T) {
|
|
tests := []struct {
|
|
cidr string
|
|
ip string
|
|
match bool
|
|
}{
|
|
{cidr: "10.0.0.0/8", ip: "10.1.2.3", match: true},
|
|
{cidr: "10.0.0.0/24", ip: "10.0.0.1", match: true},
|
|
{cidr: "10.0.0.0/24", ip: "10.0.1.1", match: false},
|
|
{cidr: "1.2.3.4/32", ip: "1.2.3.4", match: true},
|
|
{cidr: "1.2.3.4/32", ip: "1.2.3.5", match: false},
|
|
{cidr: "0.0.0.0/0", ip: "8.8.8.8", match: true},
|
|
// LAPI does not have to send a network address, the host bits are dropped.
|
|
{cidr: "10.0.0.5/24", ip: "10.0.0.9", match: true},
|
|
{cidr: " 192.168.1.0/24 ", ip: "192.168.1.42", match: true},
|
|
{cidr: "2001:db8::/32", ip: "2001:db8::1", match: true},
|
|
{cidr: "2001:db8::/32", ip: "2001:db9::1", match: false},
|
|
{cidr: "::/0", ip: "2001:db8::1", match: true},
|
|
// An IPv4 range and an IPv4 mapped client still have to meet.
|
|
{cidr: "10.0.0.0/8", ip: "::ffff:10.1.2.3", match: true},
|
|
{cidr: "::ffff:10.0.0.0/104", ip: "10.1.2.3", match: true},
|
|
// Families do not mix.
|
|
{cidr: "::/0", ip: "8.8.8.8", match: false},
|
|
{cidr: "2001:db8::/32", ip: "::ffff:10.0.0.1", match: false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.cidr+"_"+tt.ip, func(t *testing.T) {
|
|
key := NormalizeCIDR(tt.cidr)
|
|
if key == "" {
|
|
t.Fatalf("NormalizeCIDR(%q) returned nothing", tt.cidr)
|
|
}
|
|
found := false
|
|
for _, candidate := range CIDRKeys(tt.ip) {
|
|
if candidate == key {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if found != tt.match {
|
|
t.Errorf("key %q of %q found in CIDRKeys(%q) = %v, want %v", key, tt.cidr, tt.ip, found, tt.match)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCIDRLookupKeys(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
ip string
|
|
prefixLens []int
|
|
want []string
|
|
}{
|
|
{
|
|
name: "most specific first",
|
|
ip: "10.1.2.3",
|
|
prefixLens: []int{8, 32, 16},
|
|
want: []string{"10.1.2.3/32", "10.1.0.0/16", "10.0.0.0/8"},
|
|
},
|
|
{
|
|
name: "duplicates are dropped",
|
|
ip: "10.1.2.3",
|
|
prefixLens: []int{24, 24, 24},
|
|
want: []string{"10.1.2.0/24"},
|
|
},
|
|
{
|
|
name: "lengths of the other family are skipped",
|
|
ip: "10.1.2.3",
|
|
prefixLens: []int{48, 64, 24},
|
|
want: []string{"10.1.2.0/24"},
|
|
},
|
|
{
|
|
name: "out of range lengths are skipped",
|
|
ip: "10.1.2.3",
|
|
prefixLens: []int{-1, 33, 129, 8},
|
|
want: []string{"10.0.0.0/8"},
|
|
},
|
|
{
|
|
name: "ipv6 keeps its own lengths",
|
|
ip: "2001:db8::1",
|
|
prefixLens: []int{32, 64},
|
|
want: []string{"2001:db8::/64", "2001:db8::/32"},
|
|
},
|
|
{
|
|
name: "no length gives no key",
|
|
ip: "10.1.2.3",
|
|
prefixLens: []int{},
|
|
want: []string{},
|
|
},
|
|
{
|
|
name: "invalid ip gives no key",
|
|
ip: "invalid",
|
|
prefixLens: []int{24},
|
|
want: nil,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := CIDRLookupKeys(tt.ip, tt.prefixLens)
|
|
if len(got) != len(tt.want) {
|
|
t.Fatalf("CIDRLookupKeys(%q, %v) = %v, want %v", tt.ip, tt.prefixLens, got, tt.want)
|
|
}
|
|
for i := range got {
|
|
if got[i] != tt.want[i] {
|
|
t.Errorf("CIDRLookupKeys(%q, %v)[%d] = %q, want %q", tt.ip, tt.prefixLens, i, got[i], tt.want[i])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCIDRLookupKeys_SubsetOfCIDRKeys: restricting the lengths only removes candidates,
|
|
// it never changes the key of a length that is kept.
|
|
func TestCIDRLookupKeys_SubsetOfCIDRKeys(t *testing.T) {
|
|
for _, ipStr := range []string{"10.1.2.3", "2001:db8::1", "::ffff:10.1.2.3"} {
|
|
t.Run(ipStr, func(t *testing.T) {
|
|
all := CIDRKeys(ipStr)
|
|
maxBits := len(all) - 1
|
|
for bits := 0; bits <= maxBits; bits++ {
|
|
got := CIDRLookupKeys(ipStr, []int{bits})
|
|
if len(got) != 1 {
|
|
t.Fatalf("CIDRLookupKeys(%q, [%d]) returned %d keys", ipStr, bits, len(got))
|
|
}
|
|
if want := all[maxBits-bits]; got[0] != want {
|
|
t.Errorf("CIDRLookupKeys(%q, [%d]) = %q, want %q", ipStr, bits, got[0], want)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCIDRPrefixLen(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
want int
|
|
}{
|
|
{"10.0.0.0/8", 8},
|
|
{"10.0.0.0/32", 32},
|
|
{"0.0.0.0/0", 0},
|
|
{"10.0.0.5/24", 24},
|
|
{" 10.0.0.0/16 ", 16},
|
|
{"2001:db8::/32", 32},
|
|
{"2001:db8::/128", 128},
|
|
{"::/0", 0},
|
|
{"10.0.0.1", -1},
|
|
{"invalid", -1},
|
|
{"", -1},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.input, func(t *testing.T) {
|
|
if got := CIDRPrefixLen(tt.input); got != tt.want {
|
|
t.Errorf("CIDRPrefixLen(%q) = %d, want %d", tt.input, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNormalizeCIDR(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
want string
|
|
}{
|
|
{"10.0.0.0/8", "10.0.0.0/8"},
|
|
{"10.0.0.0/16", "10.0.0.0/16"},
|
|
{"192.168.1.0/24", "192.168.1.0/24"},
|
|
{"2001:db8::/32", "2001:db8::/32"},
|
|
{"0.0.0.0/0", "0.0.0.0/0"},
|
|
{"::/0", "::/0"},
|
|
{"invalid", ""},
|
|
{"", ""},
|
|
{" 10.0.0.0/8 ", "10.0.0.0/8"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.input, func(t *testing.T) {
|
|
got := NormalizeCIDR(tt.input)
|
|
if got != tt.want {
|
|
t.Errorf("NormalizeCIDR(%q) = %q, want %q", tt.input, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|