Skip to content

Commit 8c799d9

Browse files
committed
test(e2e): exercise the reputation peers mesh end to end
Runs the feedupdater with a fake CrowdSec LAPI as one peer among two real HAProxy instances and asserts via the admin sockets that entries reach both, per-decision durations arrive as timed updates, deletes zero out everywhere, HAProxy-to-HAProxy replication keeps working with the daemon in the mesh, and a restarted instance resyncs.
1 parent fbbf0a7 commit 8c799d9

1 file changed

Lines changed: 261 additions & 0 deletions

File tree

test/e2e/peers_test.go

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
//go:build e2e
2+
3+
package e2e
4+
5+
import (
6+
"fmt"
7+
"io"
8+
"net"
9+
"net/http"
10+
"net/http/httptest"
11+
"os"
12+
"os/exec"
13+
"path/filepath"
14+
"strconv"
15+
"strings"
16+
"sync"
17+
"testing"
18+
"time"
19+
)
20+
21+
// TestReputationPeersMesh runs the reputation service (cmd/feedupdater) as one
22+
// peer in a mesh with TWO HAProxy instances and asserts, via the admin
23+
// sockets, that:
24+
//
25+
// - banlist and CrowdSec decisions appear in the reputation stick-tables of
26+
// BOTH instances (live push over the peers protocol),
27+
// - the CrowdSec decision's duration is honored (timed entry update),
28+
// - a deleted decision is zeroed everywhere,
29+
// - the HAProxies still replicate their own tables through the mesh while
30+
// the daemon is a member (it acknowledges their updates), and
31+
// - a restarted HAProxy resyncs back to the full state.
32+
func TestReputationPeersMesh(t *testing.T) {
33+
if _, err := exec.LookPath("haproxy"); err != nil {
34+
t.Skip("haproxy binary not available")
35+
}
36+
37+
const (
38+
peerA = "127.0.0.1:19100"
39+
peerB = "127.0.0.1:19101"
40+
peerFeed = "127.0.0.1:19102"
41+
feA = "127.0.0.1:19110"
42+
feB = "127.0.0.1:19111"
43+
44+
banlistIP = "198.51.100.10"
45+
crowdsecIP = "198.51.100.20"
46+
)
47+
48+
dir := t.TempDir()
49+
50+
// --- fake CrowdSec LAPI ------------------------------------------------
51+
var (
52+
lapiMu sync.Mutex
53+
startupBody = `{"new":[{"scope":"Ip","value":"` + crowdsecIP + `","type":"ban","duration":"4h"}],"deleted":[]}`
54+
deltas []string
55+
)
56+
lapi := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
57+
if r.Header.Get("X-Api-Key") != "e2e-key" {
58+
w.WriteHeader(http.StatusForbidden)
59+
return
60+
}
61+
lapiMu.Lock()
62+
defer lapiMu.Unlock()
63+
w.Header().Set("Content-Type", "application/json")
64+
if r.URL.Query().Get("startup") == "true" {
65+
io.WriteString(w, startupBody)
66+
return
67+
}
68+
if len(deltas) > 0 {
69+
io.WriteString(w, deltas[0])
70+
deltas = deltas[1:]
71+
return
72+
}
73+
io.WriteString(w, `{"new":[],"deleted":[]}`)
74+
}))
75+
defer lapi.Close()
76+
77+
// --- reputation daemon --------------------------------------------------
78+
banlist := filepath.Join(dir, "banlist.txt")
79+
if err := os.WriteFile(banlist, []byte(banlistIP+"\n"), 0o644); err != nil {
80+
t.Fatal(err)
81+
}
82+
83+
feedBin := filepath.Join(dir, "feedupdater")
84+
build := exec.Command("go", "-C", "../..", "build", "-o", feedBin, "./cmd/feedupdater")
85+
if out, err := build.CombinedOutput(); err != nil {
86+
t.Fatalf("building feedupdater: %v\n%s", err, out)
87+
}
88+
89+
feed := exec.Command(feedBin,
90+
"-peer-listen", peerFeed,
91+
"-banlist", banlist,
92+
"-tor-exits=false",
93+
"-interval", "2s",
94+
"-crowdsec-url", lapi.URL,
95+
"-crowdsec-interval", "1s",
96+
)
97+
feed.Env = append(os.Environ(), "CROWDSEC_API_KEY=e2e-key")
98+
feedLog := &strings.Builder{}
99+
feed.Stdout, feed.Stderr = feedLog, feedLog
100+
if err := feed.Start(); err != nil {
101+
t.Fatal(err)
102+
}
103+
defer func() {
104+
_ = feed.Process.Kill()
105+
_, _ = feed.Process.Wait()
106+
if t.Failed() {
107+
t.Logf("feedupdater log:\n%s", feedLog.String())
108+
}
109+
}()
110+
111+
// --- two HAProxy instances ----------------------------------------------
112+
haproxyCfg := func(sock, fe string) string {
113+
return `
114+
global
115+
stats socket ` + sock + ` level admin
116+
log stdout format raw local0
117+
118+
defaults
119+
mode http
120+
timeout client 5s
121+
timeout server 5s
122+
timeout connect 5s
123+
124+
peers test_peers
125+
peer haproxy_a ` + peerA + `
126+
peer haproxy_b ` + peerB + `
127+
peer berghain_feed ` + peerFeed + `
128+
129+
frontend fe
130+
bind ` + fe + `
131+
http-request track-sc1 src table st_visits
132+
http-request return status 200 content-type "text/plain" string "ok"
133+
134+
backend st_visits
135+
stick-table type ip size 1m expire 10m store http_req_cnt peers test_peers
136+
137+
backend st_reputation_v4
138+
stick-table type ip size 1m expire 24h store gpt0 peers test_peers
139+
140+
backend st_reputation_v6
141+
stick-table type ipv6 size 1m expire 24h store gpt0 peers test_peers
142+
`
143+
}
144+
145+
sockA := filepath.Join(dir, "a.sock")
146+
sockB := filepath.Join(dir, "b.sock")
147+
cfgA := filepath.Join(dir, "a.cfg")
148+
cfgB := filepath.Join(dir, "b.cfg")
149+
if err := os.WriteFile(cfgA, []byte(haproxyCfg(sockA, feA)), 0o644); err != nil {
150+
t.Fatal(err)
151+
}
152+
if err := os.WriteFile(cfgB, []byte(haproxyCfg(sockB, feB)), 0o644); err != nil {
153+
t.Fatal(err)
154+
}
155+
156+
startHAProxy := func(localPeer, cfg string) *exec.Cmd {
157+
t.Helper()
158+
cmd := exec.Command("haproxy", "-db", "-L", localPeer, "-f", cfg)
159+
log := &strings.Builder{}
160+
cmd.Stdout, cmd.Stderr = log, log
161+
if err := cmd.Start(); err != nil {
162+
t.Fatal(err)
163+
}
164+
t.Cleanup(func() {
165+
_ = cmd.Process.Kill()
166+
_, _ = cmd.Process.Wait()
167+
if t.Failed() {
168+
t.Logf("haproxy %s log:\n%s", localPeer, log.String())
169+
}
170+
})
171+
return cmd
172+
}
173+
haproxyA := startHAProxy("haproxy_a", cfgA)
174+
startHAProxy("haproxy_b", cfgB)
175+
176+
// --- helpers --------------------------------------------------------------
177+
showTable := func(sock, table string) string {
178+
c, err := net.Dial("unix", sock)
179+
if err != nil {
180+
return ""
181+
}
182+
defer c.Close()
183+
_ = c.SetDeadline(time.Now().Add(2 * time.Second))
184+
fmt.Fprintf(c, "show table %s\n", table)
185+
b, _ := io.ReadAll(c)
186+
return string(b)
187+
}
188+
189+
// entryLine returns the table line for a key, if present.
190+
entryLine := func(sock, table, key string) (string, bool) {
191+
for _, line := range strings.Split(showTable(sock, table), "\n") {
192+
if strings.Contains(line, "key="+key+" ") {
193+
return line, true
194+
}
195+
}
196+
return "", false
197+
}
198+
199+
waitFor := func(what string, cond func() bool) {
200+
t.Helper()
201+
deadline := time.Now().Add(30 * time.Second)
202+
for time.Now().Before(deadline) {
203+
if cond() {
204+
return
205+
}
206+
time.Sleep(250 * time.Millisecond)
207+
}
208+
t.Fatalf("timed out waiting for %s", what)
209+
}
210+
211+
hasValue := func(sock, key, gpt0 string) bool {
212+
line, ok := entryLine(sock, "st_reputation_v4", key)
213+
return ok && strings.Contains(line, "gpt0="+gpt0)
214+
}
215+
216+
// --- assertions -----------------------------------------------------------
217+
218+
// 1. Both feeds reach both HAProxy instances.
219+
for _, sock := range []string{sockA, sockB} {
220+
waitFor("banlist entry on "+sock, func() bool { return hasValue(sock, banlistIP, "1") })
221+
waitFor("crowdsec entry on "+sock, func() bool { return hasValue(sock, crowdsecIP, "1") })
222+
}
223+
224+
// 2. The CrowdSec decision's 4h duration is honored via a timed update:
225+
// its expiry must sit well below the 24h table default.
226+
line, _ := entryLine(sockA, "st_reputation_v4", crowdsecIP)
227+
exp := 0
228+
for _, f := range strings.Fields(line) {
229+
if v, ok := strings.CutPrefix(f, "exp="); ok {
230+
exp, _ = strconv.Atoi(v)
231+
}
232+
}
233+
if exp <= 0 || exp > int((4*time.Hour+time.Minute).Milliseconds()) {
234+
t.Fatalf("crowdsec entry expiry = %dms, want ~4h (timed update): %q", exp, line)
235+
}
236+
237+
// 3. The HAProxies still replicate their own tables through the mesh while
238+
// the daemon is connected as a peer.
239+
if _, err := http.Get("http://" + feA + "/"); err != nil {
240+
t.Fatal(err)
241+
}
242+
waitFor("st_visits replication a->b", func() bool {
243+
_, ok := entryLine(sockB, "st_visits", "127.0.0.1")
244+
return ok
245+
})
246+
247+
// 4. A deleted decision is zeroed on both instances.
248+
lapiMu.Lock()
249+
startupBody = `{"new":[],"deleted":[]}`
250+
deltas = append(deltas, `{"new":[],"deleted":[{"scope":"Ip","value":"`+crowdsecIP+`","type":"ban","duration":"-1s"}]}`)
251+
lapiMu.Unlock()
252+
for _, sock := range []string{sockA, sockB} {
253+
waitFor("crowdsec delete on "+sock, func() bool { return hasValue(sock, crowdsecIP, "0") })
254+
}
255+
256+
// 5. A restarted HAProxy resyncs the reputation state from the mesh.
257+
_ = haproxyA.Process.Kill()
258+
_, _ = haproxyA.Process.Wait()
259+
startHAProxy("haproxy_a", cfgA)
260+
waitFor("banlist entry after restart", func() bool { return hasValue(sockA, banlistIP, "1") })
261+
}

0 commit comments

Comments
 (0)