mirror of
https://github.com/netbirdio/netbird.git
synced 2026-04-15 23:06:38 +00:00
338 lines
9.5 KiB
Go
338 lines
9.5 KiB
Go
package crowdsec
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/netip"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/crowdsecurity/crowdsec/pkg/models"
|
|
log "github.com/sirupsen/logrus"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/netbirdio/netbird/proxy/internal/restrict"
|
|
)
|
|
|
|
func TestBouncer_CheckIP_Empty(t *testing.T) {
|
|
b := newTestBouncer()
|
|
b.ready.Store(true)
|
|
|
|
assert.Nil(t, b.CheckIP(netip.MustParseAddr("1.2.3.4")))
|
|
}
|
|
|
|
func TestBouncer_CheckIP_ExactMatch(t *testing.T) {
|
|
b := newTestBouncer()
|
|
b.ready.Store(true)
|
|
b.ips[netip.MustParseAddr("10.0.0.1")] = &restrict.CrowdSecDecision{Type: restrict.DecisionBan}
|
|
|
|
d := b.CheckIP(netip.MustParseAddr("10.0.0.1"))
|
|
require.NotNil(t, d)
|
|
assert.Equal(t, restrict.DecisionBan, d.Type)
|
|
|
|
assert.Nil(t, b.CheckIP(netip.MustParseAddr("10.0.0.2")))
|
|
}
|
|
|
|
func TestBouncer_CheckIP_PrefixMatch(t *testing.T) {
|
|
b := newTestBouncer()
|
|
b.ready.Store(true)
|
|
b.prefixes[netip.MustParsePrefix("192.168.1.0/24")] = &restrict.CrowdSecDecision{Type: restrict.DecisionBan}
|
|
|
|
d := b.CheckIP(netip.MustParseAddr("192.168.1.100"))
|
|
require.NotNil(t, d)
|
|
assert.Equal(t, restrict.DecisionBan, d.Type)
|
|
|
|
assert.Nil(t, b.CheckIP(netip.MustParseAddr("192.168.2.1")))
|
|
}
|
|
|
|
func TestBouncer_CheckIP_UnmapsV4InV6(t *testing.T) {
|
|
b := newTestBouncer()
|
|
b.ready.Store(true)
|
|
b.ips[netip.MustParseAddr("10.0.0.1")] = &restrict.CrowdSecDecision{Type: restrict.DecisionBan}
|
|
|
|
d := b.CheckIP(netip.MustParseAddr("::ffff:10.0.0.1"))
|
|
require.NotNil(t, d)
|
|
assert.Equal(t, restrict.DecisionBan, d.Type)
|
|
}
|
|
|
|
func TestBouncer_Ready(t *testing.T) {
|
|
b := newTestBouncer()
|
|
assert.False(t, b.Ready())
|
|
|
|
b.ready.Store(true)
|
|
assert.True(t, b.Ready())
|
|
}
|
|
|
|
func TestBouncer_CheckIP_ExactBeforePrefix(t *testing.T) {
|
|
b := newTestBouncer()
|
|
b.ready.Store(true)
|
|
b.ips[netip.MustParseAddr("10.0.0.1")] = &restrict.CrowdSecDecision{Type: restrict.DecisionCaptcha}
|
|
b.prefixes[netip.MustParsePrefix("10.0.0.0/8")] = &restrict.CrowdSecDecision{Type: restrict.DecisionBan}
|
|
|
|
d := b.CheckIP(netip.MustParseAddr("10.0.0.1"))
|
|
require.NotNil(t, d)
|
|
assert.Equal(t, restrict.DecisionCaptcha, d.Type)
|
|
|
|
d2 := b.CheckIP(netip.MustParseAddr("10.0.0.2"))
|
|
require.NotNil(t, d2)
|
|
assert.Equal(t, restrict.DecisionBan, d2.Type)
|
|
}
|
|
|
|
func TestBouncer_ApplyNew_IP(t *testing.T) {
|
|
b := newTestBouncer()
|
|
|
|
b.applyNew(makeDecisions(
|
|
decision{scope: "ip", value: "1.2.3.4", dtype: "ban", scenario: "test/brute"},
|
|
decision{scope: "ip", value: "5.6.7.8", dtype: "captcha", scenario: "test/crawl"},
|
|
))
|
|
|
|
require.Len(t, b.ips, 2)
|
|
assert.Equal(t, restrict.DecisionBan, b.ips[netip.MustParseAddr("1.2.3.4")].Type)
|
|
assert.Equal(t, restrict.DecisionCaptcha, b.ips[netip.MustParseAddr("5.6.7.8")].Type)
|
|
}
|
|
|
|
func TestBouncer_ApplyNew_Range(t *testing.T) {
|
|
b := newTestBouncer()
|
|
|
|
b.applyNew(makeDecisions(
|
|
decision{scope: "range", value: "10.0.0.0/8", dtype: "ban"},
|
|
))
|
|
|
|
require.Len(t, b.prefixes, 1)
|
|
assert.NotNil(t, b.prefixes[netip.MustParsePrefix("10.0.0.0/8")])
|
|
}
|
|
|
|
func TestBouncer_ApplyDeleted_IP(t *testing.T) {
|
|
b := newTestBouncer()
|
|
b.ips[netip.MustParseAddr("1.2.3.4")] = &restrict.CrowdSecDecision{Type: restrict.DecisionBan}
|
|
b.ips[netip.MustParseAddr("5.6.7.8")] = &restrict.CrowdSecDecision{Type: restrict.DecisionBan}
|
|
|
|
b.applyDeleted(makeDecisions(
|
|
decision{scope: "ip", value: "1.2.3.4", dtype: "ban"},
|
|
))
|
|
|
|
assert.Len(t, b.ips, 1)
|
|
assert.Nil(t, b.ips[netip.MustParseAddr("1.2.3.4")])
|
|
assert.NotNil(t, b.ips[netip.MustParseAddr("5.6.7.8")])
|
|
}
|
|
|
|
func TestBouncer_ApplyDeleted_Range(t *testing.T) {
|
|
b := newTestBouncer()
|
|
b.prefixes[netip.MustParsePrefix("10.0.0.0/8")] = &restrict.CrowdSecDecision{Type: restrict.DecisionBan}
|
|
b.prefixes[netip.MustParsePrefix("192.168.0.0/16")] = &restrict.CrowdSecDecision{Type: restrict.DecisionBan}
|
|
|
|
b.applyDeleted(makeDecisions(
|
|
decision{scope: "range", value: "10.0.0.0/8", dtype: "ban"},
|
|
))
|
|
|
|
require.Len(t, b.prefixes, 1)
|
|
assert.NotNil(t, b.prefixes[netip.MustParsePrefix("192.168.0.0/16")])
|
|
}
|
|
|
|
func TestBouncer_ApplyNew_OverwritesExisting(t *testing.T) {
|
|
b := newTestBouncer()
|
|
b.ips[netip.MustParseAddr("1.2.3.4")] = &restrict.CrowdSecDecision{Type: restrict.DecisionBan}
|
|
|
|
b.applyNew(makeDecisions(
|
|
decision{scope: "ip", value: "1.2.3.4", dtype: "captcha"},
|
|
))
|
|
|
|
assert.Equal(t, restrict.DecisionCaptcha, b.ips[netip.MustParseAddr("1.2.3.4")].Type)
|
|
}
|
|
|
|
func TestBouncer_ApplyNew_SkipsInvalid(t *testing.T) {
|
|
b := newTestBouncer()
|
|
|
|
b.applyNew(makeDecisions(
|
|
decision{scope: "ip", value: "not-an-ip", dtype: "ban"},
|
|
decision{scope: "range", value: "also-not-valid", dtype: "ban"},
|
|
))
|
|
|
|
assert.Empty(t, b.ips)
|
|
assert.Empty(t, b.prefixes)
|
|
}
|
|
|
|
// TestBouncer_StreamIntegration tests the full flow: fake LAPI → StreamBouncer → Bouncer cache → CheckIP.
|
|
func TestBouncer_StreamIntegration(t *testing.T) {
|
|
lapi := newFakeLAPI()
|
|
ts := httptest.NewServer(lapi)
|
|
defer ts.Close()
|
|
|
|
// Seed the LAPI with initial decisions.
|
|
lapi.setDecisions(
|
|
decision{scope: "ip", value: "1.2.3.4", dtype: "ban", scenario: "crowdsecurity/ssh-bf"},
|
|
decision{scope: "range", value: "10.0.0.0/8", dtype: "ban", scenario: "crowdsecurity/http-probing"},
|
|
decision{scope: "ip", value: "5.5.5.5", dtype: "captcha", scenario: "crowdsecurity/http-crawl"},
|
|
)
|
|
|
|
b := NewBouncer(ts.URL, "test-key", log.NewEntry(log.StandardLogger()))
|
|
b.tickerInterval = 200 * time.Millisecond
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
require.NoError(t, b.Start(ctx))
|
|
defer b.Stop()
|
|
|
|
// Wait for initial sync.
|
|
require.Eventually(t, b.Ready, 5*time.Second, 50*time.Millisecond, "bouncer should become ready")
|
|
|
|
// Verify decisions are cached.
|
|
d := b.CheckIP(netip.MustParseAddr("1.2.3.4"))
|
|
require.NotNil(t, d, "1.2.3.4 should be banned")
|
|
assert.Equal(t, restrict.DecisionBan, d.Type)
|
|
|
|
d2 := b.CheckIP(netip.MustParseAddr("10.1.2.3"))
|
|
require.NotNil(t, d2, "10.1.2.3 should match range ban")
|
|
assert.Equal(t, restrict.DecisionBan, d2.Type)
|
|
|
|
d3 := b.CheckIP(netip.MustParseAddr("5.5.5.5"))
|
|
require.NotNil(t, d3, "5.5.5.5 should have captcha")
|
|
assert.Equal(t, restrict.DecisionCaptcha, d3.Type)
|
|
|
|
assert.Nil(t, b.CheckIP(netip.MustParseAddr("9.9.9.9")), "unknown IP should be nil")
|
|
|
|
// Simulate a delta update: delete one IP, add a new one.
|
|
lapi.setDelta(
|
|
[]decision{{scope: "ip", value: "1.2.3.4", dtype: "ban"}},
|
|
[]decision{{scope: "ip", value: "2.3.4.5", dtype: "throttle", scenario: "crowdsecurity/http-flood"}},
|
|
)
|
|
|
|
// Wait for the delta to be picked up.
|
|
require.Eventually(t, func() bool {
|
|
return b.CheckIP(netip.MustParseAddr("2.3.4.5")) != nil
|
|
}, 5*time.Second, 50*time.Millisecond, "new decision should appear")
|
|
|
|
assert.Nil(t, b.CheckIP(netip.MustParseAddr("1.2.3.4")), "deleted decision should be gone")
|
|
|
|
d4 := b.CheckIP(netip.MustParseAddr("2.3.4.5"))
|
|
require.NotNil(t, d4)
|
|
assert.Equal(t, restrict.DecisionThrottle, d4.Type)
|
|
|
|
// Range ban should still be active.
|
|
assert.NotNil(t, b.CheckIP(netip.MustParseAddr("10.99.99.99")))
|
|
}
|
|
|
|
// Helpers
|
|
|
|
func newTestBouncer() *Bouncer {
|
|
return &Bouncer{
|
|
ips: make(map[netip.Addr]*restrict.CrowdSecDecision),
|
|
prefixes: make(map[netip.Prefix]*restrict.CrowdSecDecision),
|
|
logger: log.NewEntry(log.StandardLogger()),
|
|
}
|
|
}
|
|
|
|
type decision struct {
|
|
scope string
|
|
value string
|
|
dtype string
|
|
scenario string
|
|
}
|
|
|
|
func makeDecisions(decs ...decision) []*models.Decision {
|
|
out := make([]*models.Decision, len(decs))
|
|
for i, d := range decs {
|
|
out[i] = &models.Decision{
|
|
Scope: strPtr(d.scope),
|
|
Value: strPtr(d.value),
|
|
Type: strPtr(d.dtype),
|
|
Scenario: strPtr(d.scenario),
|
|
Duration: strPtr("1h"),
|
|
Origin: strPtr("cscli"),
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func strPtr(s string) *string { return &s }
|
|
|
|
// fakeLAPI is a minimal fake CrowdSec LAPI that serves /v1/decisions/stream.
|
|
type fakeLAPI struct {
|
|
mu sync.Mutex
|
|
initial []decision
|
|
newDelta []decision
|
|
delDelta []decision
|
|
served bool // true after the initial snapshot has been served
|
|
}
|
|
|
|
func newFakeLAPI() *fakeLAPI {
|
|
return &fakeLAPI{}
|
|
}
|
|
|
|
func (f *fakeLAPI) setDecisions(decs ...decision) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.initial = decs
|
|
f.served = false
|
|
}
|
|
|
|
func (f *fakeLAPI) setDelta(deleted, added []decision) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.delDelta = deleted
|
|
f.newDelta = added
|
|
}
|
|
|
|
func (f *fakeLAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/v1/decisions/stream" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
|
|
resp := streamResponse{}
|
|
|
|
if !f.served {
|
|
for _, d := range f.initial {
|
|
resp.New = append(resp.New, toLAPIDecision(d))
|
|
}
|
|
f.served = true
|
|
} else {
|
|
for _, d := range f.delDelta {
|
|
resp.Deleted = append(resp.Deleted, toLAPIDecision(d))
|
|
}
|
|
for _, d := range f.newDelta {
|
|
resp.New = append(resp.New, toLAPIDecision(d))
|
|
}
|
|
// Clear delta after serving once.
|
|
f.delDelta = nil
|
|
f.newDelta = nil
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp) //nolint:errcheck
|
|
}
|
|
|
|
// streamResponse mirrors the CrowdSec LAPI /v1/decisions/stream JSON structure.
|
|
type streamResponse struct {
|
|
New []*lapiDecision `json:"new"`
|
|
Deleted []*lapiDecision `json:"deleted"`
|
|
}
|
|
|
|
type lapiDecision struct {
|
|
Duration *string `json:"duration"`
|
|
Origin *string `json:"origin"`
|
|
Scenario *string `json:"scenario"`
|
|
Scope *string `json:"scope"`
|
|
Type *string `json:"type"`
|
|
Value *string `json:"value"`
|
|
}
|
|
|
|
func toLAPIDecision(d decision) *lapiDecision {
|
|
return &lapiDecision{
|
|
Duration: strPtr("1h"),
|
|
Origin: strPtr("cscli"),
|
|
Scenario: strPtr(d.scenario),
|
|
Scope: strPtr(d.scope),
|
|
Type: strPtr(d.dtype),
|
|
Value: strPtr(d.value),
|
|
}
|
|
}
|