Skip to content
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,9 @@ See [public documentation](https://doc.crowdsec.net/u/bouncers/haproxy_spoa)
This outlines the goals of the project, and the current status of each.

We are currently working on AppSec integration to this bouncer.

## Reload Support

The bouncer supports reloading host configurations without restarting the service. Use `systemctl reload crowdsec-spoa-bouncer` to reload hosts from both the main configuration file (`hosts:` section) and the `hosts_dir` directory. See [pkg/host/README.md](pkg/host/README.md) for detailed documentation.

**Note:** Only host configurations are reloadable. Changes to listener addresses, LAPI settings, or other configuration require a full service restart.
49 changes: 37 additions & 12 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ func Execute() error {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

// Set up SIGHUP handler for reload
reloadChan := make(chan os.Signal, 1)
signal.Notify(reloadChan, syscall.SIGHUP)

g, ctx := errgroup.WithContext(ctx)

config.Geo.Init(ctx)
Expand Down Expand Up @@ -172,22 +176,43 @@ func Execute() error {
sessionLogger := log.WithField("component", "global_sessions")
globalSessions.Init(sessionLogger, ctx)

// Handle SIGHUP for reload
g.Go(func() error {
HostManager.Run(ctx)
return nil
})
for {
select {
case <-reloadChan:
log.Info("Received SIGHUP, reloading host configuration")

// Re-read config file to get updated hosts
var reloadConfigHosts []*host.Host
if *configPath != "" {
configMerged, err := cfg.MergedConfig(*configPath)
if err != nil {
log.WithError(err).Error("Failed to read config file during reload")
} else {
configExpanded := csstring.StrictExpand(string(configMerged), os.LookupEnv)
reloadConfig, err := cfg.NewConfig(strings.NewReader(configExpanded))
if err != nil {
log.WithError(err).Error("Failed to parse config file during reload")
} else {
reloadConfigHosts = reloadConfig.Hosts
}
}
}

for _, h := range config.Hosts {
HostManager.Chan <- host.HostOp{
Host: h,
Op: host.OpAdd,
if err := HostManager.Reload(reloadConfigHosts); err != nil {
log.WithError(err).Error("Failed to reload host configuration")
// Don't return error, just log it so the service continues running
}
case <-ctx.Done():
return nil
}
}
}
})
Comment on lines 180 to +211
Copy link

Copilot AI Nov 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing validation that HostManager.Reload() is not called before SetHosts(). The Reload() method relies on h.hostsDir being set, but if SIGHUP is received before SetHosts() is called (lines 213-216), h.hostsDir will be empty and the reload will silently skip loading from the directory.

Consider either:

  1. Adding a check in Reload() to ensure initialization has completed
  2. Deferring SIGHUP handler registration until after SetHosts() completes
  3. Documenting this initialization order requirement

Copilot uses AI. Check for mistakes.

if config.HostsDir != "" {
if err := HostManager.LoadFromDirectory(config.HostsDir); err != nil {
return fmt.Errorf("failed to load hosts from directory: %w", err)
}
// Set hosts from both config and directory (merged, config takes precedence)
if err := HostManager.SetHosts(config.Hosts, config.HostsDir); err != nil {
return fmt.Errorf("failed to load hosts: %w", err)
}

// Create single SPOA listener - ultra-simplified architecture
Expand Down
1 change: 1 addition & 0 deletions config/crowdsec-spoa-bouncer.service
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Group=crowdsec-spoa
ExecStart=${BIN} -c ${CFG}/crowdsec-spoa-bouncer.yaml
ExecStartPre=${BIN} -c ${CFG}/crowdsec-spoa-bouncer.yaml -t
ExecStartPost=/bin/sleep 0.1
ExecReload=/bin/kill -HUP $MAINPID
Restart=always
RestartSec=10
# Security hardening
Expand Down
44 changes: 44 additions & 0 deletions pkg/host/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,47 @@ If no host is found for the incoming request, then the remediation will be sent
If the remediation is `captcha` and no host is found, then the remediation will be automatically changed to a `ban` since we have no way to display the captcha.

This is why we recommend having a catch-all configuration for the `ban` remediation it will allow you to change the `fallback_remediation` to `allow` or provide a `contact_us_url`. As this will impact user experience if they are not able to contact anyone for help.

#### Reloading Host Configuration

The bouncer supports reloading host configurations without restarting the service. This is useful when using the `hosts_dir` configuration option to manage hosts via individual YAML files.

**How to reload:**

```bash
# Using systemctl (recommended)
sudo systemctl reload crowdsec-spoa-bouncer

# Or manually send SIGHUP
sudo kill -HUP $(pidof crowdsec-spoa-bouncer)
```

**What gets reloaded:**

- ✅ Hosts from the main configuration file (`hosts:` section)
- ✅ Hosts loaded from `hosts_dir` directory (all `*.yaml` files)
- ✅ New hosts are added
- ✅ Removed hosts are deleted
- ✅ Modified hosts are updated

**Precedence:** If a host exists in both the main config and `hosts_dir`, the main config version takes precedence and will replace the directory version on reload.

**What does NOT get reloaded:**

- ❌ SPOA listener addresses (`listen_tcp`, `listen_unix`)
- ❌ LAPI connection settings (`api_url`, `api_key`, etc.)
- ❌ Logging configuration
- ❌ Prometheus configuration

For changes to non-host configuration, you must restart the service:

```bash
sudo systemctl restart crowdsec-spoa-bouncer
```

**Important notes:**

- Both hosts from the main config file and `hosts_dir` are reloaded on `systemctl reload`.
- If a host exists in both sources, the main config version takes precedence.
- If a host file has errors during reload, it will be logged but the reload will continue processing other files.
- The reload operation is thread-safe and does not interrupt active requests.
Loading
Loading