️ Do not block middleware startup due to stream intialization (#281)

* 🍱 not block on stream startup

* 🍱 fix

* 🍱 fix lint

* 🍱 fix lint

* 🍱 fix lint

* 🍱 update readme

* 📚 clarify StreamStartupBlock warning in README

Make the fail-open implication of StreamStartupBlock=false explicit:
banned IPs are allowed through until the first stream sync completes.

* 🐛 clear isCrowdsecStreamStartup on alreadyUpdated path

When cacheTimeoutKey is already set (another instance/process has
populated the stream cache within the update window), the early return
in handleStreamCache used to leave isCrowdsecStreamStartup at true,
causing this instance to keep sending startup=true on subsequent ticks.
Flip the flag in the early-return path so startup is correctly tracked
across multi-instance deployments sharing a cache.

---------

Co-authored-by: mhx <mathieu@hanotaux.fr>
This commit is contained in:
maxlerebourg
2026-06-06 11:07:18 +02:00
committed by GitHub
co-authored by mhx
parent 0d8fd2a7a9
commit 71d845faae
3 changed files with 20 additions and 6 deletions
+7
View File
@@ -465,6 +465,12 @@ make run
- int64 - int64
- default: 0 - default: 0
- Used only in `stream` and `alone` mode, the maximum number of time we can not reach Crowdsec before blocking traffic (set -1 to never block) - Used only in `stream` and `alone` mode, the maximum number of time we can not reach Crowdsec before blocking traffic (set -1 to never block)
- StreamStartupBlock
- bool
- default: true
- Used only in `stream` and `alone` mode, controls whether the initial stream update runs synchronously or asynchronously during plugin initialization
- When `true`, plugin initialization waits for Crowdsec to be ready before serving traffic.
- **Warning**: When `false`, all requests bypass remediation until the first stream sync completes — banned IPs will be allowed through during this window. Only disable when startup availability is more important than blocking at startup.
- DefaultDecisionSeconds - DefaultDecisionSeconds
- int64 - int64
- default: 60 - default: 60
@@ -598,6 +604,7 @@ http:
LogFilePath: "" LogFilePath: ""
updateIntervalSeconds: 60 updateIntervalSeconds: 60
updateMaxFailure: 0 updateMaxFailure: 0
streamStartupBlock: true
defaultDecisionSeconds: 60 defaultDecisionSeconds: 60
remediationStatusCode: 403 remediationStatusCode: 403
httpTimeoutSeconds: 10 httpTimeoutSeconds: 10
+11 -6
View File
@@ -64,7 +64,7 @@ const (
//nolint:gochecknoglobals //nolint:gochecknoglobals
var ( var (
isStartup = true isCrowdsecStreamStartup = true
isCrowdsecStreamHealthy = true isCrowdsecStreamHealthy = true
updateFailure int64 updateFailure int64
streamTicker chan bool streamTicker chan bool
@@ -123,7 +123,7 @@ type Bouncer struct {
// New creates the crowdsec bouncer plugin. // New creates the crowdsec bouncer plugin.
// //
//nolint:gocyclo //nolint:nestif,gocyclo,gocognit
func New(_ context.Context, next http.Handler, config *configuration.Config, name string) (http.Handler, error) { func New(_ context.Context, next http.Handler, config *configuration.Config, name string) (http.Handler, error) {
config.LogLevel = strings.ToUpper(config.LogLevel) config.LogLevel = strings.ToUpper(config.LogLevel)
log := logger.NewWithFormat(config.LogLevel, config.LogFilePath, config.LogFormat) log := logger.NewWithFormat(config.LogLevel, config.LogFilePath, config.LogFormat)
@@ -291,8 +291,11 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
return nil, err return nil, err
} }
} }
handleStreamTicker(bouncer) if config.StreamStartupBlock {
isStartup = false handleStreamTicker(bouncer)
} else {
go handleStreamTicker(bouncer)
}
streamTicker = startTicker("stream", config.UpdateIntervalSeconds, log, func() { streamTicker = startTicker("stream", config.UpdateIntervalSeconds, log, func() {
handleStreamTicker(bouncer) handleStreamTicker(bouncer)
}) })
@@ -301,7 +304,7 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
// Start metrics ticker if not already running // Start metrics ticker if not already running
if metricsTicker == nil && config.MetricsUpdateIntervalSeconds > 0 { if metricsTicker == nil && config.MetricsUpdateIntervalSeconds > 0 {
lastMetricsPush = time.Now() // Initialize lastMetricsPush when starting the metrics ticker lastMetricsPush = time.Now() // Initialize lastMetricsPush when starting the metrics ticker
handleMetricsTicker(bouncer) go handleMetricsTicker(bouncer)
metricsTicker = startTicker("metrics", config.MetricsUpdateIntervalSeconds, log, func() { metricsTicker = startTicker("metrics", config.MetricsUpdateIntervalSeconds, log, func() {
handleMetricsTicker(bouncer) handleMetricsTicker(bouncer)
}) })
@@ -624,6 +627,7 @@ func handleStreamCache(bouncer *Bouncer) error {
_, err := bouncer.cacheClient.Get(cacheTimeoutKey) _, err := bouncer.cacheClient.Get(cacheTimeoutKey)
if err == nil { if err == nil {
bouncer.log.Debug("handleStreamCache:alreadyUpdated") bouncer.log.Debug("handleStreamCache:alreadyUpdated")
isCrowdsecStreamStartup = false
return nil return nil
} }
if err.Error() != cache.CacheMiss { if err.Error() != cache.CacheMiss {
@@ -634,7 +638,7 @@ func handleStreamCache(bouncer *Bouncer) error {
Scheme: bouncer.crowdsecScheme, Scheme: bouncer.crowdsecScheme,
Host: bouncer.crowdsecHost, Host: bouncer.crowdsecHost,
Path: bouncer.crowdsecPath + bouncer.crowdsecStreamRoute, Path: bouncer.crowdsecPath + bouncer.crowdsecStreamRoute,
RawQuery: fmt.Sprintf("startup=%t", !isCrowdsecStreamHealthy || isStartup), RawQuery: fmt.Sprintf("startup=%t", !isCrowdsecStreamHealthy || isCrowdsecStreamStartup),
} }
body, err := crowdsecQuery(bouncer, streamRouteURL.String(), nil) body, err := crowdsecQuery(bouncer, streamRouteURL.String(), nil)
if err != nil { if err != nil {
@@ -664,6 +668,7 @@ func handleStreamCache(bouncer *Bouncer) error {
bouncer.cacheClient.Delete(decision.Value) bouncer.cacheClient.Delete(decision.Value)
} }
bouncer.log.Debug("handleStreamCache:updated") bouncer.log.Debug("handleStreamCache:updated")
isCrowdsecStreamStartup = false
return nil return nil
} }
+2
View File
@@ -84,6 +84,7 @@ type Config struct {
UpdateIntervalSeconds int64 `json:"updateIntervalSeconds,omitempty"` UpdateIntervalSeconds int64 `json:"updateIntervalSeconds,omitempty"`
MetricsUpdateIntervalSeconds int64 `json:"metricsUpdateIntervalSeconds,omitempty"` MetricsUpdateIntervalSeconds int64 `json:"metricsUpdateIntervalSeconds,omitempty"`
UpdateMaxFailure int64 `json:"updateMaxFailure,omitempty"` UpdateMaxFailure int64 `json:"updateMaxFailure,omitempty"`
StreamStartupBlock bool `json:"streamStartupBlock,omitempty"`
DefaultDecisionSeconds int64 `json:"defaultDecisionSeconds,omitempty"` DefaultDecisionSeconds int64 `json:"defaultDecisionSeconds,omitempty"`
RemediationStatusCode int `json:"remediationStatusCode,omitempty"` RemediationStatusCode int `json:"remediationStatusCode,omitempty"`
HTTPTimeoutSeconds int64 `json:"httpTimeoutSeconds,omitempty"` HTTPTimeoutSeconds int64 `json:"httpTimeoutSeconds,omitempty"`
@@ -146,6 +147,7 @@ func New() *Config {
UpdateIntervalSeconds: 60, UpdateIntervalSeconds: 60,
MetricsUpdateIntervalSeconds: 600, MetricsUpdateIntervalSeconds: 600,
UpdateMaxFailure: 0, UpdateMaxFailure: 0,
StreamStartupBlock: true,
DefaultDecisionSeconds: 60, DefaultDecisionSeconds: 60,
RemediationStatusCode: http.StatusForbidden, RemediationStatusCode: http.StatusForbidden,
HTTPTimeoutSeconds: 10, HTTPTimeoutSeconds: 10,