Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,18 @@ Customize the maps under [`examples/haproxy/maps/`](examples/haproxy/maps/) and
Map keys are lowercase literal substrings without whitespace. User-Agent headers are trivial to spoof, so this policy is
traffic shaping only; it must not protect authenticated or otherwise sensitive endpoints.

### Optional IP reputation (CrowdSec)

Berghain ships a reputation service that acts as a [CrowdSec](https://www.crowdsec.net/) bouncer and pushes
decisions (plus static feeds such as Tor exit nodes) into HAProxy stick-tables **live over the peers protocol** —
no reloads, and Berghain stays stateless. Bans are silent-dropped, captcha decisions raise the minimum challenge
level, and per-decision durations are honored via timed stick-table entries.

It runs either embedded in the agent (a `reputation:` section in the spop config) or standalone
(`cmd/feedupdater`) for setups that scale the feed separately; the daemon behaves as a first-class peer in
meshes with multiple HAProxy instances. See [`examples/crowdsec/`](examples/crowdsec/) and
[`examples/haproxy/haproxy-reputation.cfg`](examples/haproxy/haproxy-reputation.cfg).

## Running with Docker

To run the project using Docker, follow these steps:
Expand Down
58 changes: 58 additions & 0 deletions cmd/feedupdater/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Command feedupdater runs the Berghain IP reputation service standalone.
//
// It is a thin wrapper around internal/reputation: it fetches CrowdSec
// decisions and static feeds and pushes individual-IP reputation into HAProxy
// stick-tables over the peers protocol. Simple deployments can skip this
// binary entirely and enable the same service inside cmd/spop via the
// `reputation:` config section; this command exists for setups that scale or
// isolate the feed daemon separately.
package main

import (
"context"
"flag"
"log/slog"
"os"
"os/signal"

"github.com/DropMorePackets/berghain/internal/reputation"
)

func main() {
var (
cfg reputation.Config
logLevelArg string
)

flag.StringVar(&cfg.PeerListen, "peer-listen", "127.0.0.1:10001", "listen address for the HAProxy peers protocol")
flag.StringVar(&cfg.LocalPeer, "local-peer", "", "this peer's name in the HAProxy peers section (default berghain_feed)")
flag.StringVar(&cfg.TableV4, "table-v4", "", "IPv4 reputation stick-table name (default st_reputation_v4)")
flag.StringVar(&cfg.TableV6, "table-v6", "", "IPv6 reputation stick-table name (default st_reputation_v6)")
flag.DurationVar(&cfg.TableExpiry, "table-expiry", 0, "stick-table expire value, bounds entries without their own duration (default 24h)")
flag.StringVar(&cfg.MapsDir, "maps-dir", "", "directory to write CIDR map/ACL files into; empty disables CIDR feeds")
flag.DurationVar(&cfg.Interval, "interval", 0, "static feed refresh interval (default 6h)")
flag.StringVar(&cfg.Banlist, "banlist", "", "optional file of individual IPs to block (one per line)")
flag.StringVar(&cfg.CrowdSec.URL, "crowdsec-url", "", "CrowdSec LAPI URL (e.g. http://crowdsec:8080); empty disables the source")
flag.StringVar(&cfg.CrowdSec.APIKey, "crowdsec-api-key", "", "CrowdSec bouncer API key (or CROWDSEC_API_KEY env)")
flag.DurationVar(&cfg.CrowdSec.Interval, "crowdsec-interval", 0, "CrowdSec decision-stream poll interval (default 10s)")
torExits := flag.Bool("tor-exits", true, "challenge Tor exit nodes")
flag.StringVar(&logLevelArg, "loglevel", "info", "Logging level")
flag.Parse()

cfg.TorExits = torExits

var logLevel slog.Level
if err := logLevel.UnmarshalText([]byte(logLevelArg)); err != nil {
slog.Error("invalid log level, cannot proceed", "loglevel", logLevelArg)
os.Exit(1)
}
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: logLevel})))

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()

if err := reputation.New(cfg).Run(ctx); err != nil {
slog.Error("reputation service failed", "error", err)
os.Exit(1)
}
}
7 changes: 7 additions & 0 deletions cmd/spop/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,20 @@ import (
"github.com/goccy/go-yaml"

"github.com/DropMorePackets/berghain"
"github.com/DropMorePackets/berghain/internal/reputation"
)

type Config struct {
Secret Secret `yaml:"secret"`
Listen string `yaml:"listen"`
Default FrontendConfig `yaml:"default"`
Frontend map[string]FrontendConfig `yaml:"frontend"`

// Reputation optionally embeds the IP reputation service (CrowdSec +
// static feeds over the peers protocol) into this process, so a simple
// deployment needs no separate feed daemon. Enabled when peer_listen is
// set; cmd/feedupdater runs the same service standalone.
Reputation reputation.Config `yaml:"reputation"`
}

type Secret []byte
Expand Down
10 changes: 10 additions & 0 deletions cmd/spop/config.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
secret: JMal0XJRROOMsMdPqggG2tR56CTkpgN3r47GgUN/WSQ=

# Optional embedded IP reputation: serve CrowdSec decisions and static feeds
# to HAProxy over the peers protocol from this same process. See
# examples/haproxy/haproxy-reputation.cfg for the matching HAProxy side.
#reputation:
# peer_listen: 0.0.0.0:10001 # must match this peer's entry in the peers section
# local_peer: berghain_feed # our name in the peers section
# crowdsec:
# url: http://crowdsec:8080 # LAPI; api_key via CROWDSEC_API_KEY or api_key:
# maps_dir: examples/haproxy/maps # where CIDR feeds land; empty disables them

default:
levels:
- duration: 24h
Expand Down
45 changes: 45 additions & 0 deletions cmd/spop/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package main

import (
"testing"
"time"

"github.com/goccy/go-yaml"
)

func TestConfigReputationBlock(t *testing.T) {
var cfg Config
err := yaml.Unmarshal([]byte(`
secret: JMal0XJRROOMsMdPqggG2tR56CTkpgN3r47GgUN/WSQ=
reputation:
peer_listen: 0.0.0.0:10001
crowdsec:
url: http://crowdsec:8080
interval: 30s
`), &cfg)
if err != nil {
t.Fatal(err)
}
if !cfg.Reputation.Enabled() {
t.Error("reputation block with peer_listen must enable the service")
}
if cfg.Reputation.CrowdSec.URL != "http://crowdsec:8080" {
t.Errorf("crowdsec url = %q", cfg.Reputation.CrowdSec.URL)
}
if cfg.Reputation.CrowdSec.Interval != 30*time.Second {
t.Errorf("crowdsec interval = %v, want 30s", cfg.Reputation.CrowdSec.Interval)
}
}

func TestConfigReputationAbsent(t *testing.T) {
var cfg Config
err := yaml.Unmarshal([]byte(`
secret: JMal0XJRROOMsMdPqggG2tR56CTkpgN3r47GgUN/WSQ=
`), &cfg)
if err != nil {
t.Fatal(err)
}
if cfg.Reputation.Enabled() {
t.Error("absent reputation block must leave the service disabled")
}
}
22 changes: 19 additions & 3 deletions cmd/spop/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import (

"github.com/dropmorepackets/haproxy-go/pkg/encoding"
"github.com/dropmorepackets/haproxy-go/spop"

"github.com/DropMorePackets/berghain/internal/reputation"
)

var (
Expand Down Expand Up @@ -58,12 +60,27 @@ func main() {

ec := make(chan error)

cfg := loadConfig()

var wg sync.WaitGroup
wg.Add(1)
go func() {
ec <- runBerghain(&wg, ctx)
ec <- runBerghain(&wg, ctx, cfg)
}()

// Embedded mode: run the IP reputation service (CrowdSec + static feeds
// over the peers protocol) in-process when configured, so simple setups
// need no separate feed daemon.
if cfg.Reputation.Enabled() {
wg.Add(1)
go func() {
defer wg.Done()
if err := reputation.New(cfg.Reputation).Run(ctx); err != nil {
ec <- err
}
}()
}

select {
case <-c:
cancelFunc()
Expand All @@ -81,10 +98,9 @@ func main() {
<-c
}

func runBerghain(wg *sync.WaitGroup, ctx context.Context) error {
func runBerghain(wg *sync.WaitGroup, ctx context.Context, cfg Config) error {
defer wg.Done()

cfg := loadConfig()
if len(cfg.Secret) != 32 {
Fatal("provided secret has invalid length", "have", len(cfg.Secret), "need", 32)
}
Expand Down
72 changes: 72 additions & 0 deletions examples/crowdsec/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# CrowdSec IP reputation (optional)

Berghain can act as a [CrowdSec](https://www.crowdsec.net/) *bouncer*: its
reputation service polls the CrowdSec local API decision stream and pushes
every decision into HAProxy stick-tables **live over the peers protocol** — no
map files, no reloads, and Berghain itself stays stateless. Decisions map to
the `gpt0` tag consumed by
[`examples/haproxy/haproxy-reputation.cfg`](../haproxy/haproxy-reputation.cfg):

| CrowdSec decision | gpt0 | HAProxy behaviour |
|-------------------|------|--------------------------------------|
| `ban` | 1 | `silent-drop` |
| `captcha` | 3 | minimum Berghain challenge level 3 |
| anything else | 1 | fail closed to a ban |

Per-decision durations are honored: entries are pushed as timed stick-table
updates and expire in HAProxy exactly when the decision does, even if the
service is down at that moment. The same service also challenges Tor exit
nodes (static feed) unless `tor_exits: false` is set.

## Running it

The service runs **embedded** in the Berghain agent (`reputation:` section in
the spop config), so the docker setup stays two containers: the existing
haproxy+berghain container and CrowdSec.

1. Start CrowdSec once so you can register the bouncer:

```sh
docker compose -f docker-compose.yml -f examples/crowdsec/docker-compose.crowdsec.yml up -d crowdsec
docker compose -f docker-compose.yml -f examples/crowdsec/docker-compose.crowdsec.yml \
exec crowdsec cscli bouncers add berghain
```

2. Export the printed key and start the rest:

```sh
export CROWDSEC_API_KEY=<key from step 1>
docker compose -f docker-compose.yml -f examples/crowdsec/docker-compose.crowdsec.yml up
```

3. Try it — add a decision and watch the stick-table:

```sh
docker compose -f docker-compose.yml -f examples/crowdsec/docker-compose.crowdsec.yml \
exec crowdsec cscli decisions add --ip 203.0.113.7 --type ban --duration 5m
```

Within the poll interval (10s by default) requests from that address are
silent-dropped; `cscli decisions delete --ip 203.0.113.7` lifts it again
within one poll.

CrowdSec only produces decisions when it can *see* traffic (or when another
machine in your CrowdSec network reports it): feed it your HAProxy logs via an
acquisition file, or rely on the community blocklist that comes with console
enrollment. Both are standard CrowdSec configuration — see their
[HAProxy collection](https://app.crowdsec.net/hub/author/crowdsecurity/collections/haproxy).

## Standalone mode

Deployments that scale HAProxy and the feed separately can run the exact same
service as its own daemon instead of embedding it:

```sh
go run ./cmd/feedupdater \
-peer-listen 0.0.0.0:10001 \
-crowdsec-url http://crowdsec:8080 # key via CROWDSEC_API_KEY
```

Every HAProxy in the peers mesh then lists `berghain_feed` once and they all
learn the same tables; the daemon copes fine with being one peer among many
(it validates handshakes and acknowledges the updates the other peers teach).
17 changes: 17 additions & 0 deletions examples/crowdsec/config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Berghain config for the CrowdSec example: the default challenge levels plus
# the embedded IP reputation service. The API key comes from the
# CROWDSEC_API_KEY environment variable (see examples/crowdsec/README.md).
secret: JMal0XJRROOMsMdPqggG2tR56CTkpgN3r47GgUN/WSQ=

reputation:
peer_listen: 127.0.0.1:10001
local_peer: berghain_feed
crowdsec:
url: http://crowdsec:8080

default:
levels:
- duration: 24h
type: none
- duration: 30m
type: pow
39 changes: 39 additions & 0 deletions examples/crowdsec/docker-compose.crowdsec.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# CrowdSec IP reputation overlay (issue #82).
#
# Runs CrowdSec next to the regular single-container Berghain setup and turns
# on Berghain's embedded reputation service: it polls the CrowdSec LAPI
# decision stream as a bouncer and pushes decisions into HAProxy stick-tables
# over the peers protocol. See examples/crowdsec/README.md for the key setup.
#
# docker compose -f docker-compose.yml -f examples/crowdsec/docker-compose.crowdsec.yml up

services:
berghain-haproxy:
volumes:
- ./examples/haproxy/haproxy-reputation.cfg:/app/haproxy.cfg
- ./examples/haproxy/berghain.cfg:/app/examples/haproxy/berghain.cfg
- ./examples/crowdsec/config.yaml:/app/config.yaml
# -L names this HAProxy's own entry in the peers section.
command: sh -c "haproxy -L haproxy_local -f haproxy.cfg & ./berghain -config config.yaml"
environment:
# Register with: docker compose exec crowdsec cscli bouncers add berghain
- CROWDSEC_API_KEY=${CROWDSEC_API_KEY:?run cscli bouncers add berghain and export the key}
depends_on:
- crowdsec

# Prefer the standalone daemon instead of embedded mode? Disable the
# reputation section in config.yaml and run cmd/feedupdater as its own
# service with the same flags-equivalent settings.

crowdsec:
image: crowdsecurity/crowdsec:latest
environment:
- COLLECTIONS=crowdsecurity/base-http-scenarios
volumes:
- crowdsec-db:/var/lib/crowdsec/data
- crowdsec-config:/etc/crowdsec
restart: unless-stopped

volumes:
crowdsec-db:
crowdsec-config:
Loading
Loading