diff --git a/.golangci.yml b/.golangci.yml index 3ee3205..33598d6 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -75,7 +75,6 @@ linters: - mnd - exportloopref - contextcheck - - intrange # not supported by yaegi issues: exclude-use-default: false max-same-issues: 0 diff --git a/pkg/cache/cache.go b/pkg/cache/cache.go index 83f8743..f1624d3 100644 --- a/pkg/cache/cache.go +++ b/pkg/cache/cache.go @@ -6,12 +6,14 @@ import ( "errors" "fmt" "log/slog" + "strconv" + "strings" "sync/atomic" ttl_map "github.com/leprosus/golang-ttl-map" simpleredis "github.com/maxlerebourg/simpleredis" - ip "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip" + "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip" ) const ( @@ -27,8 +29,17 @@ const ( CacheMiss = "cache:miss" // CacheUnreachable error string when cache is unreachable. CacheUnreachable = "cache:unreachable" - // cidrPrefix store all cidr within the same key. + // cidrPrefix namespaces a CIDR decision, one cache key per CIDR. cidrPrefix = "cidr:" + // cidrPrefixLensKey holds the prefix lengths that have a decision, so a lookup probes + // only those. Its absence means no CIDR decision was ever stored. + cidrPrefixLensKey = "cidrprefixlens" + // cidrPrefixLensSeparator separates the prefix lengths in cidrPrefixLensKey. + cidrPrefixLensSeparator = "," + // cidrPrefixLensDuration has to outlive every decision it describes, so it is + // effectively infinite. It cannot be zero: the local cache ignores a zero duration + // and redis rejects a non positive EX. + cidrPrefixLensDuration = 10 * 365 * 24 * 60 * 60 ) //nolint:gochecknoglobals @@ -151,23 +162,26 @@ func (c *Client) Set(key string, value string, duration int64) { // DeleteCIDR removes a CIDR decision from the cache. func (c *Client) DeleteCIDR(cidr string) { - cidr = ip.NormalizeCIDR(cidr) - if cidr == "" { + normalized := ip.NormalizeCIDR(cidr) + if normalized == "" { + c.log.Error(fmt.Sprintf("cache:DeleteCIDR:invalidCIDR cidr:%v decision is left in cache", cidr)) return } + cidr = normalized c.cache.delete(cidrPrefix + cidr) c.log.Debug(fmt.Sprintf("cache:DeleteCIDR cidr:%v", cidr)) } // GetCIDR checks if an IP matches a CIDR decision in the cache. +// Only probes the prefix lengths that have a decision: it is on the request path. func (c *Client) GetCIDR(ipStr string) (string, error) { - keys := ip.CIDRKeys(ipStr) - if keys == nil { - return "", errors.New(CacheMiss) + prefixLens, err := c.cache.get(cidrPrefixLensKey) + if err != nil { + return "", err } - for _, key := range keys { - value, err := c.cache.get(cidrPrefix + key) - if err == nil { + for _, key := range ip.CIDRLookupKeys(ipStr, parsePrefixLens(prefixLens)) { + value, getErr := c.cache.get(cidrPrefix + key) + if getErr == nil { return value, nil } } @@ -176,10 +190,44 @@ func (c *Client) GetCIDR(ipStr string) (string, error) { // SetCIDR stores a CIDR decision in the cache. func (c *Client) SetCIDR(cidr, value string, duration int64) { - cidr = ip.NormalizeCIDR(cidr) - if cidr == "" { + normalized := ip.NormalizeCIDR(cidr) + prefixLen := ip.CIDRPrefixLen(cidr) + if normalized == "" || prefixLen < 0 { + c.log.Error(fmt.Sprintf("cache:SetCIDR:invalidCIDR cidr:%v value:%v decision is not enforced", cidr, value)) return } - c.cache.set(cidrPrefix+cidr, value, duration) - c.log.Debug(fmt.Sprintf("cache:SetCIDR cidr:%v value:%v duration:%vs", cidr, value, duration)) + // Publish the length first, or a concurrent lookup misses the decision. + c.addCIDRPrefixLen(prefixLen) + c.cache.set(cidrPrefix+normalized, value, duration) + c.log.Debug(fmt.Sprintf("cache:SetCIDR cidr:%v value:%v duration:%vs", normalized, value, duration)) +} + +// addCIDRPrefixLen records a prefix length in the set probed on lookup. The set only grows: +// a stale length costs one extra read, dropping one too early leaves decisions unmatched. +func (c *Client) addCIDRPrefixLen(prefixLen int) { + prefixLens, err := c.cache.get(cidrPrefixLensKey) + if err == nil { + for _, known := range parsePrefixLens(prefixLens) { + if known == prefixLen { + return + } + } + prefixLens += cidrPrefixLensSeparator + strconv.Itoa(prefixLen) + } else { + prefixLens = strconv.Itoa(prefixLen) + } + c.cache.set(cidrPrefixLensKey, prefixLens, cidrPrefixLensDuration) + c.log.Debug(fmt.Sprintf("cache:addCIDRPrefixLen prefixLens:%v", prefixLens)) +} + +// parsePrefixLens decodes the set of prefix lengths stored in cidrPrefixLensKey. +func parsePrefixLens(value string) []int { + fields := strings.Split(value, cidrPrefixLensSeparator) + prefixLens := make([]int, 0, len(fields)) + for _, field := range fields { + if prefixLen, err := strconv.Atoi(field); err == nil { + prefixLens = append(prefixLens, prefixLen) + } + } + return prefixLens } diff --git a/pkg/cache/cache_test.go b/pkg/cache/cache_test.go index 3aa3aea..63e9e42 100644 --- a/pkg/cache/cache_test.go +++ b/pkg/cache/cache_test.go @@ -3,6 +3,7 @@ package cache import ( + "errors" "testing" logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger" @@ -160,3 +161,186 @@ func Test_nextReader(t *testing.T) { }) } } + +// countingCache is an isolated cacheInterface recording how many reads a lookup costs, +// so a CIDR lookup can be checked for both its result and its price. +type countingCache struct { + values map[string]string + reads int +} + +func newCountingCache() *countingCache { + return &countingCache{values: map[string]string{}} +} + +func (c *countingCache) get(key string) (string, error) { + c.reads++ + if value, found := c.values[key]; found && value != "" { + return value, nil + } + return "", errors.New(CacheMiss) +} + +func (c *countingCache) set(key, value string, _ int64) { + c.values[key] = value +} + +func (c *countingCache) delete(key string) { + delete(c.values, key) +} + +func newCIDRClient(decisions map[string]string) (*Client, *countingCache) { + counting := newCountingCache() + client := &Client{cache: counting, log: logger.New("INFO", "")} + for cidr, value := range decisions { + client.SetCIDR(cidr, value, 60) + } + return client, counting +} + +func Test_GetCIDR(t *testing.T) { + decisions := map[string]string{ + "10.0.0.0/24": BannedValue, + "192.168.1.42/24": CaptchaValue, // not a network address, host bits are dropped + "2001:db8::/32": BannedValue, + } + tests := []struct { + name string + clientIP string + want string + wantErr bool + }{ + {name: "IP inside a banned range", clientIP: "10.0.0.7", want: BannedValue}, + {name: "network address itself", clientIP: "10.0.0.0", want: BannedValue}, + {name: "broadcast address of the range", clientIP: "10.0.0.255", want: BannedValue}, + {name: "IP just outside the range", clientIP: "10.0.1.0", wantErr: true}, + {name: "IP inside a captcha range", clientIP: "192.168.1.7", want: CaptchaValue}, + {name: "IP inside an IPv6 range", clientIP: "2001:db8::dead:beef", want: BannedValue}, + {name: "IP outside the IPv6 range", clientIP: "2001:db9::1", wantErr: true}, + {name: "IPv4 mapped client against an IPv4 range", clientIP: "::ffff:10.0.0.7", want: BannedValue}, + {name: "unknown IP", clientIP: "8.8.8.8", wantErr: true}, + {name: "invalid IP", clientIP: "not-an-ip", wantErr: true}, + {name: "empty IP", clientIP: "", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, _ := newCIDRClient(decisions) + got, err := client.GetCIDR(tt.clientIP) + if (err != nil) != tt.wantErr { + t.Fatalf("GetCIDR(%q) error = %v, wantErr %v", tt.clientIP, err, tt.wantErr) + } + if got != tt.want { + t.Errorf("GetCIDR(%q) = %q, want %q", tt.clientIP, got, tt.want) + } + }) + } +} + +// Test_GetCIDR_MostSpecific pins precedence: the narrowest range wins, so a captcha on +// a /24 is not overruled by a ban on its /8. +func Test_GetCIDR_MostSpecific(t *testing.T) { + client, _ := newCIDRClient(map[string]string{ + "10.0.0.0/8": BannedValue, + "10.1.0.0/16": CaptchaValue, + "10.1.2.0/24": BannedValue, + "2001:db8::/32": BannedValue, + "2001:db8::/48": CaptchaValue, + }) + tests := []struct { + clientIP string + want string + }{ + {clientIP: "10.1.2.3", want: BannedValue}, + {clientIP: "10.1.3.3", want: CaptchaValue}, + {clientIP: "10.2.3.4", want: BannedValue}, + {clientIP: "2001:db8::1", want: CaptchaValue}, + {clientIP: "2001:db8:1::1", want: BannedValue}, + } + for _, tt := range tests { + t.Run(tt.clientIP, func(t *testing.T) { + got, err := client.GetCIDR(tt.clientIP) + if err != nil { + t.Fatalf("GetCIDR(%q) unexpected error %v", tt.clientIP, err) + } + if got != tt.want { + t.Errorf("GetCIDR(%q) = %q, want %q", tt.clientIP, got, tt.want) + } + }) + } +} + +func Test_DeleteCIDR(t *testing.T) { + client, _ := newCIDRClient(map[string]string{ + "10.0.0.0/8": BannedValue, + "10.1.2.0/24": CaptchaValue, + }) + client.DeleteCIDR("10.1.2.0/24") + // The wider decision is untouched and takes over. + if got, err := client.GetCIDR("10.1.2.3"); err != nil || got != BannedValue { + t.Errorf("after deleting the /24, GetCIDR = %q %v, want %q", got, err, BannedValue) + } + client.DeleteCIDR("10.0.0.0/8") + if _, err := client.GetCIDR("10.1.2.3"); err == nil { + t.Error("GetCIDR should miss once every decision is deleted") + } +} + +func Test_SetCIDR_InvalidIsNotStored(t *testing.T) { + for _, cidr := range []string{"", "garbage", "10.0.0.1", "10.0.0.0/33", "10.0.0.0/-1"} { + t.Run(cidr, func(t *testing.T) { + _, counting := newCIDRClient(map[string]string{cidr: BannedValue}) + if len(counting.values) != 0 { + t.Errorf("SetCIDR(%q) stored %v, want nothing", cidr, counting.values) + } + }) + } +} + +// Test_GetCIDR_Reads guards the cost of the lookup: it must probe only the prefix +// lengths that have a decision, not every possible one. +func Test_GetCIDR_Reads(t *testing.T) { + tests := []struct { + name string + decisions map[string]string + clientIP string + wantReads int + }{ + {name: "no decision at all, IPv4", decisions: nil, clientIP: "10.0.0.1", wantReads: 1}, + {name: "no decision at all, IPv6", decisions: nil, clientIP: "2001:db8::1", wantReads: 1}, + { + name: "one prefix length, hit", + decisions: map[string]string{"10.0.0.0/24": BannedValue}, + clientIP: "10.0.0.1", + wantReads: 2, + }, + { + name: "one prefix length, miss", + decisions: map[string]string{"10.0.0.0/24": BannedValue}, + clientIP: "11.0.0.1", + wantReads: 2, + }, + { + name: "three prefix lengths, miss probes each once", + decisions: map[string]string{"10.0.0.0/8": BannedValue, "10.1.0.0/16": BannedValue, "10.1.2.0/24": BannedValue}, + clientIP: "11.0.0.1", + wantReads: 4, + }, + { + name: "IPv6 client does not probe every length", + decisions: map[string]string{"2001:db8::/32": BannedValue}, + clientIP: "2001:dead::1", + wantReads: 2, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, counting := newCIDRClient(tt.decisions) + counting.reads = 0 + // Only the number of reads matters here, the result is covered above. + _, _ = client.GetCIDR(tt.clientIP) + if counting.reads != tt.wantReads { + t.Errorf("GetCIDR(%q) did %d cache reads, want %d", tt.clientIP, counting.reads, tt.wantReads) + } + }) + } +} diff --git a/pkg/ip/cidr.go b/pkg/ip/cidr.go index 49a0129..bf9ead4 100644 --- a/pkg/ip/cidr.go +++ b/pkg/ip/cidr.go @@ -2,38 +2,45 @@ package ip import ( "net" - "strconv" "strings" ) +const ( + maxIPv4PrefixLen = 32 + maxIPv6PrefixLen = 128 +) + // CIDRKeys returns all possible CIDR prefixes of an IP, from the most specific (/32 for IPv4, /128 for IPv6) to the least specific (/0). func CIDRKeys(ipStr string) []string { - ip := net.ParseIP(ipStr) - if ip == nil { + parsed, maxBits := parseForPrefix(ipStr) + if parsed == nil { return nil } - ip4 := ip.To4() - if ip4 != nil { - keys := make([]string, 0, 33) - for bits := 32; bits >= 0; bits-- { - mask := net.CIDRMask(bits, 32) - n := make(net.IP, 4) - for i := 0; i < 4; i++ { - n[i] = ip4[i] & mask[i] - } - keys = append(keys, n.String()+"/"+strconv.Itoa(bits)) - } - return keys + keys := make([]string, 0, maxBits+1) + for bits := maxBits; bits >= 0; bits-- { + keys = append(keys, cidrKey(parsed, bits, maxBits)) } - ip16 := ip.To16() - keys := make([]string, 0, 129) - for bits := 128; bits >= 0; bits-- { - mask := net.CIDRMask(bits, 128) - n := make(net.IP, 16) - for i := 0; i < 16; i++ { - n[i] = ip16[i] & mask[i] + return keys +} + +// CIDRLookupKeys returns the keys of the CIDRs containing an IP for the given prefix lengths +// only, most specific first. Duplicates and lengths of the other family are skipped. +func CIDRLookupKeys(ipStr string, prefixLens []int) []string { + parsed, maxBits := parseForPrefix(ipStr) + if parsed == nil { + return nil + } + var wanted [maxIPv6PrefixLen + 1]bool + for _, bits := range prefixLens { + if bits >= 0 && bits <= maxBits { + wanted[bits] = true + } + } + keys := make([]string, 0, len(prefixLens)) + for bits := maxBits; bits >= 0; bits-- { + if wanted[bits] { + keys = append(keys, cidrKey(parsed, bits, maxBits)) } - keys = append(keys, n.String()+"/"+strconv.Itoa(bits)) } return keys } @@ -46,3 +53,33 @@ func NormalizeCIDR(cidrStr string) string { } return ipNet.String() } + +// CIDRPrefixLen returns the prefix length of a CIDR, or -1 if it is not a valid CIDR. +func CIDRPrefixLen(cidrStr string) int { + _, ipNet, err := net.ParseCIDR(strings.TrimSpace(cidrStr)) + if err != nil { + return -1 + } + prefixLen, _ := ipNet.Mask.Size() + return prefixLen +} + +// parseForPrefix returns the IP in the native form of its family, and that family's bit length. +func parseForPrefix(ipStr string) (net.IP, int) { + parsed := net.ParseIP(ipStr) + if parsed == nil { + return nil, 0 + } + if parsed4 := parsed.To4(); parsed4 != nil { + return parsed4, maxIPv4PrefixLen + } + return parsed.To16(), maxIPv6PrefixLen +} + +// cidrKey builds the key of the CIDR of bits length containing the IP. +// It formats through net.IPNet like NormalizeCIDR, so writes and lookups agree. +func cidrKey(parsed net.IP, bits, maxBits int) string { + mask := net.CIDRMask(bits, maxBits) + ipNet := net.IPNet{IP: parsed.Mask(mask), Mask: mask} + return ipNet.String() +} diff --git a/pkg/ip/cidr_test.go b/pkg/ip/cidr_test.go index 5ede450..8e3eff1 100644 --- a/pkg/ip/cidr_test.go +++ b/pkg/ip/cidr_test.go @@ -100,6 +100,181 @@ func TestCIDRKeys_VerifyNetworkAddress(t *testing.T) { } } +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 diff --git a/tests/e2e/mock/lib/common.sh b/tests/e2e/mock/lib/common.sh index 7699c6d..8b3f007 100644 --- a/tests/e2e/mock/lib/common.sh +++ b/tests/e2e/mock/lib/common.sh @@ -119,7 +119,7 @@ assert_status() { local url="$1" expected="$2" shift 2 || true local got - got=$(curl -s -m 1 -o /dev/null -w '%{http_code}' "$@" "$url") + got=$(curl -s --connect-timeout 1 -m 5 -o /dev/null -w '%{http_code}' "$@" "$url") if [[ "$got" != "$expected" ]]; then echo "assert_status: $url expected $expected, got $got" >&2 return 1 @@ -132,7 +132,7 @@ assert_header() { local url="$1" header="$2" expected="$3" shift 3 || true local got - got=$(curl -s -m 1 -D - -o /dev/null "$@" "$url" | tr -d '\r' \ + got=$(curl -s --connect-timeout 1 -m 5 -D - -o /dev/null "$@" "$url" | tr -d '\r' \ | awk -v h="${header,,}" -F': ' 'tolower($1) == h { print $2; exit }') if [[ "$got" != "$expected" ]]; then echo "assert_header: $url header $header expected \"$expected\", got \"$got\"" >&2 @@ -146,7 +146,7 @@ assert_body_contains() { local url="$1" needle="$2" shift 2 || true local body - body=$(curl -s -m 1 "$@" "$url") + body=$(curl -s --connect-timeout 1 -m 5 "$@" "$url") if ! grep -q "$needle" <<<"$body"; then echo "assert_body_contains: $url expected to contain \"$needle\", got:" >&2 echo "$body" >&2 @@ -158,20 +158,20 @@ assert_body_contains() { lapi_add_decision() { local ip="$1" type="${2:-ban}" duration="${3:-4h}" - curl -sS -m 1 -X POST "http://127.0.0.1:${LAPI_PORT}/admin/decisions?ip=${ip}&type=${type}&duration=${duration}" >/dev/null + curl -sS --connect-timeout 1 -m 5 -X POST "http://127.0.0.1:${LAPI_PORT}/admin/decisions?ip=${ip}&type=${type}&duration=${duration}" >/dev/null } lapi_delete_decision() { local ip="$1" - curl -sS -m 1 -X DELETE "http://127.0.0.1:${LAPI_PORT}/admin/decisions?ip=${ip}" >/dev/null + curl -sS --connect-timeout 1 -m 5 -X DELETE "http://127.0.0.1:${LAPI_PORT}/admin/decisions?ip=${ip}" >/dev/null } lapi_set_stream_fail() { - curl -sS -m 1 -X POST "http://127.0.0.1:${LAPI_PORT}/admin/stream-fail" >/dev/null + curl -sS --connect-timeout 1 -m 5 -X POST "http://127.0.0.1:${LAPI_PORT}/admin/stream-fail" >/dev/null } lapi_clear_stream_fail() { - curl -sS -m 1 -X DELETE "http://127.0.0.1:${LAPI_PORT}/admin/stream-fail" >/dev/null + curl -sS --connect-timeout 1 -m 5 -X DELETE "http://127.0.0.1:${LAPI_PORT}/admin/stream-fail" >/dev/null } # --- stack lifecycle ---------------------------------------------------------