Skip to content

Commit 453e21e

Browse files
refactor: move session management to global level (#113)
* refactor: move session management to global manager - Replace per-captcha session managers with single global session manager - Sessions are now managed by SPOA instead of individual Captcha instances - Reduces goroutine overhead (one GC goroutine instead of N per host) - Simplifies reload logic (sessions persist automatically) - Prepares architecture for AppSec session integration - Captcha now only handles cookie generation from UUIDs * refactor: remove unused context from captcha and appsec initialization - Remove Cancel field from Captcha struct (never used during requests) - Remove ctx parameter from Captcha.Init() and AppSec.Init() - Remove Cancel() calls since there's no cleanup needed - Validate() uses context.Background() so it's not affected by reloads - Sessions persist in global manager, so no context lifecycle needed * refactor: extract session/cookie creation and improve captcha resilience - Extract session and cookie creation logic into createNewSessionAndCookie helper - Use helper in both initial session creation and session recovery after reload - Make URL reading non-critical for captcha remediation (only affects redirect) - Improve error messages to clarify critical vs non-critical failures - Ensure captcha remediation is preferred when captcha is configured, only falling back on truly critical failures
1 parent a3548b9 commit 453e21e

File tree

5 files changed

+117
-79
lines changed

5 files changed

+117
-79
lines changed

cmd/root.go

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"github.com/spf13/pflag"
1919
"golang.org/x/sync/errgroup"
2020

21+
"github.com/crowdsecurity/crowdsec-spoa/internal/session"
2122
"github.com/crowdsecurity/crowdsec-spoa/pkg/cfg"
2223
"github.com/crowdsecurity/crowdsec-spoa/pkg/dataset"
2324
"github.com/crowdsecurity/crowdsec-spoa/pkg/host"
@@ -162,6 +163,15 @@ func Execute() error {
162163
hostManagerLogger := log.WithField("component", "host_manager")
163164
HostManager := host.NewManager(hostManagerLogger)
164165

166+
// Create and initialize global session manager (single GC goroutine for all hosts)
167+
globalSessions := &session.Sessions{
168+
SessionIdleTimeout: "1h", // Default values
169+
SessionMaxTime: "12h",
170+
SessionGarbageSeconds: 60,
171+
}
172+
sessionLogger := log.WithField("component", "global_sessions")
173+
globalSessions.Init(sessionLogger, ctx)
174+
165175
g.Go(func() error {
166176
HostManager.Run(ctx)
167177
return nil
@@ -185,12 +195,13 @@ func Execute() error {
185195

186196
// Create single SPOA directly with minimal configuration
187197
spoaConfig := &spoa.SpoaConfig{
188-
TcpAddr: config.ListenTCP,
189-
UnixAddr: config.ListenUnix,
190-
Dataset: dataSet,
191-
HostManager: HostManager,
192-
GeoDatabase: &config.Geo,
193-
Logger: spoaLogger,
198+
TcpAddr: config.ListenTCP,
199+
UnixAddr: config.ListenUnix,
200+
Dataset: dataSet,
201+
HostManager: HostManager,
202+
GeoDatabase: &config.Geo,
203+
GlobalSessions: globalSessions,
204+
Logger: spoaLogger,
194205
}
195206

196207
singleSpoa, err := spoa.New(spoaConfig)

internal/appsec/root.go

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
package appsec
22

33
import (
4-
"context"
5-
64
log "github.com/sirupsen/logrus"
75
)
86

@@ -11,7 +9,7 @@ type AppSec struct {
119
logger *log.Entry `yaml:"-"`
1210
}
1311

14-
func (a *AppSec) Init(logger *log.Entry, ctx context.Context) error {
12+
func (a *AppSec) Init(logger *log.Entry) error {
1513
a.InitLogger(logger)
1614
return nil
1715
}

internal/remediation/captcha/root.go

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import (
1313

1414
"github.com/crowdsecurity/crowdsec-spoa/internal/cookie"
1515
"github.com/crowdsecurity/crowdsec-spoa/internal/remediation"
16-
"github.com/crowdsecurity/crowdsec-spoa/internal/session"
1716
"github.com/negasus/haproxy-spoe-go/action"
1817
log "github.com/sirupsen/logrus"
1918
)
@@ -35,18 +34,13 @@ type Captcha struct {
3534
FallbackRemediation string `yaml:"fallback_remediation"` // if captcha configuration is invalid what should we fallback too
3635
Timeout int `yaml:"timeout"` // HTTP client timeout in seconds (default: 5)
3736
CookieGenerator cookie.CookieGenerator `yaml:"cookie"` // CookieGenerator to generate cookies from sessions
38-
Sessions session.Sessions `yaml:",inline"` // sessions that are being traced for captcha
3937
logger *log.Entry `yaml:"-"`
4038
client *http.Client `yaml:"-"`
41-
Cancel context.CancelFunc `yaml:"-"`
4239
}
4340

44-
func (c *Captcha) Init(logger *log.Entry, ctx context.Context) error {
41+
func (c *Captcha) Init(logger *log.Entry) error {
4542
c.InitLogger(logger)
4643

47-
var cancelCtx context.Context
48-
cancelCtx, c.Cancel = context.WithCancel(ctx)
49-
5044
// Clone the default transport to preserve proxy settings and other defaults
5145
transport := http.DefaultTransport.(*http.Transport).Clone()
5246

@@ -76,7 +70,7 @@ func (c *Captcha) Init(logger *log.Entry, ctx context.Context) error {
7670
return err
7771
}
7872

79-
c.Sessions.Init(c.logger, cancelCtx)
73+
// Initialize cookie generator (sessions are managed by SPOA, not captcha)
8074
c.CookieGenerator.Init(c.logger, "crowdsec_captcha_cookie", c.SecretKey)
8175

8276
return nil

pkg/host/root.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ func (h *Manager) MatchFirstHost(toMatch string) *Host {
9999
}
100100

101101
func (h *Manager) Run(ctx context.Context) {
102+
102103
for {
103104
select {
104105
case instruction := <-h.Chan:
@@ -109,7 +110,7 @@ func (h *Manager) Run(ctx context.Context) {
109110
h.removeHost(instruction.Host)
110111
case OpAdd:
111112
h.cache = make(map[string]*Host)
112-
h.addHost(ctx, instruction.Host)
113+
h.addHost(instruction.Host)
113114
h.sort()
114115
case OpPatch:
115116
h.patchHost(instruction.Host)
@@ -179,7 +180,7 @@ func (h *Manager) sort() {
179180
func (h *Manager) removeHost(host *Host) {
180181
for i, th := range h.Hosts {
181182
if th == host {
182-
host.Captcha.Cancel()
183+
// Sessions persist in global manager, no cleanup needed
183184
if i == len(h.Hosts)-1 {
184185
h.Hosts = h.Hosts[:i]
185186
} else {
@@ -228,7 +229,7 @@ func (h *Manager) createHostLogger(host *Host) *log.Entry {
228229
return hostLogger.WithField("host", host.Host)
229230
}
230231

231-
func (h *Manager) addHost(ctx context.Context, host *Host) {
232+
func (h *Manager) addHost(host *Host) {
232233
// Create a logger for this host that inherits base logger values
233234
host.logger = h.createHostLogger(host)
234235

@@ -238,13 +239,14 @@ func (h *Manager) addHost(ctx context.Context, host *Host) {
238239
"has_ban": true, // Ban is always available
239240
})
240241

241-
if err := host.Captcha.Init(host.logger, ctx); err != nil {
242+
// Initialize captcha (no longer needs sessions - SPOA handles that)
243+
if err := host.Captcha.Init(host.logger); err != nil {
242244
host.logger.Error(err)
243245
}
244246
if err := host.Ban.Init(host.logger); err != nil {
245247
host.logger.Error(err)
246248
}
247-
if err := host.AppSec.Init(host.logger, ctx); err != nil {
249+
if err := host.AppSec.Init(host.logger); err != nil {
248250
host.logger.Error(err)
249251
}
250252
h.Hosts = append(h.Hosts, host)

pkg/spoa/root.go

Lines changed: 90 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -37,18 +37,20 @@ type Spoa struct {
3737
HAWaitGroup *sync.WaitGroup
3838
logger *log.Entry
3939
// Direct access to shared data (no IPC needed)
40-
dataset *dataset.DataSet
41-
hostManager *host.Manager
42-
geoDatabase *geo.GeoDatabase
40+
dataset *dataset.DataSet
41+
hostManager *host.Manager
42+
geoDatabase *geo.GeoDatabase
43+
globalSessions *session.Sessions // Global session manager for all hosts
4344
}
4445

4546
type SpoaConfig struct {
46-
TcpAddr string
47-
UnixAddr string
48-
Dataset *dataset.DataSet
49-
HostManager *host.Manager
50-
GeoDatabase *geo.GeoDatabase
51-
Logger *log.Entry // Parent logger to inherit from
47+
TcpAddr string
48+
UnixAddr string
49+
Dataset *dataset.DataSet
50+
HostManager *host.Manager
51+
GeoDatabase *geo.GeoDatabase
52+
GlobalSessions *session.Sessions // Global session manager for all hosts
53+
Logger *log.Entry // Parent logger to inherit from
5254
}
5355

5456
func New(config *SpoaConfig) (*Spoa, error) {
@@ -71,11 +73,12 @@ func New(config *SpoaConfig) (*Spoa, error) {
7173
// No worker-specific log level; inherits from parent logger
7274

7375
s := &Spoa{
74-
HAWaitGroup: &sync.WaitGroup{},
75-
logger: workerLogger,
76-
dataset: config.Dataset,
77-
hostManager: config.HostManager,
78-
geoDatabase: config.GeoDatabase,
76+
HAWaitGroup: &sync.WaitGroup{},
77+
logger: workerLogger,
78+
dataset: config.Dataset,
79+
hostManager: config.HostManager,
80+
geoDatabase: config.GeoDatabase,
81+
globalSessions: config.GlobalSessions,
7982
}
8083

8184
if config.TcpAddr != "" {
@@ -402,6 +405,47 @@ func parseHTTPData(logger *log.Entry, mes *message.Message) HTTPRequestData {
402405
return httpData
403406
}
404407

408+
// createNewSessionAndCookie creates a new session, generates a cookie, and sets it in the request.
409+
// Returns the session, uuid, and an error if any step fails.
410+
func (s *Spoa) createNewSessionAndCookie(req *request.Request, mes *message.Message, matchedHost *host.Host) (*session.Session, string, error) {
411+
ssl, err := readKeyFromMessage[bool](mes, "ssl")
412+
if err != nil {
413+
s.logger.WithFields(log.Fields{
414+
"error": err,
415+
"key": "ssl",
416+
}).Warn("failed to read ssl flag from message, cookie secure flag will default to false - ensure HAProxy is sending the 'ssl_fc' variable as 'ssl' in crowdsec-http message")
417+
}
418+
419+
// Create a new session using global session manager
420+
ses, err := s.globalSessions.NewRandomSession()
421+
if err != nil {
422+
s.logger.WithFields(log.Fields{
423+
"host": matchedHost.Host,
424+
"error": err,
425+
}).Error("Failed to create new session")
426+
return nil, "", err
427+
}
428+
429+
cookie, err := matchedHost.Captcha.CookieGenerator.GenerateCookie(ses, ssl)
430+
if err != nil {
431+
s.logger.WithFields(log.Fields{
432+
"host": matchedHost.Host,
433+
"ssl": ssl,
434+
"error": err,
435+
}).Error("Failed to generate host cookie")
436+
return nil, "", err
437+
}
438+
439+
// Set initial captcha status to pending
440+
ses.Set(session.CaptchaStatus, captcha.Pending)
441+
uuid := ses.UUID
442+
443+
// Set the captcha cookie - status will be set later based on session state
444+
req.Actions.SetVar(action.ScopeTransaction, "captcha_cookie", cookie.String())
445+
446+
return ses, uuid, nil
447+
}
448+
405449
// handleCaptchaRemediation handles all captcha-related logic including cookie validation,
406450
// session management, captcha validation, and status updates.
407451
// Returns the remediation and parsed HTTP request data for reuse in AppSec processing.
@@ -433,40 +477,18 @@ func (s *Spoa) handleCaptchaRemediation(req *request.Request, mes *message.Messa
433477
}
434478

435479
if uuid == "" {
436-
ssl, err := readKeyFromMessage[bool](mes, "ssl")
437-
if err != nil {
438-
s.logger.WithFields(log.Fields{
439-
"error": err,
440-
"key": "ssl",
441-
}).Warn("failed to read ssl flag from message, cookie secure flag will default to false - ensure HAProxy is sending the 'ssl_fc' variable as 'ssl' in crowdsec-http message")
442-
}
443-
444-
// Create a new session
445-
ses, err = matchedHost.Captcha.Sessions.NewRandomSession()
446-
if err != nil {
447-
s.logger.WithFields(log.Fields{
448-
"host": matchedHost.Host,
449-
"error": err,
450-
}).Error("Failed to create new session")
451-
return remediation.FromString(matchedHost.Captcha.FallbackRemediation), HTTPRequestData{}
452-
}
453-
454-
cookie, err := matchedHost.Captcha.CookieGenerator.GenerateCookie(ses, ssl)
480+
// No valid cookie, create new session and cookie
481+
var err error
482+
ses, uuid, err = s.createNewSessionAndCookie(req, mes, matchedHost)
455483
if err != nil {
484+
// Session creation is critical for captcha to work - without it we can't track captcha status
485+
// This is a critical failure, so we must fall back to fallback remediation
456486
s.logger.WithFields(log.Fields{
457487
"host": matchedHost.Host,
458-
"ssl": ssl,
459488
"error": err,
460-
}).Error("Failed to generate host cookie")
489+
}).Error("Failed to create new session and cookie, falling back to fallback remediation")
461490
return remediation.FromString(matchedHost.Captcha.FallbackRemediation), HTTPRequestData{}
462491
}
463-
464-
// Set initial captcha status to pending
465-
ses.Set(session.CaptchaStatus, captcha.Pending)
466-
uuid = ses.UUID
467-
468-
// Set the captcha cookie - status will be set later based on session state
469-
req.Actions.SetVar(action.ScopeTransaction, "captcha_cookie", cookie.String())
470492
}
471493

472494
if uuid == "" {
@@ -476,25 +498,26 @@ func (s *Spoa) handleCaptchaRemediation(req *request.Request, mes *message.Messa
476498
return remediation.FromString(matchedHost.Captcha.FallbackRemediation), HTTPRequestData{}
477499
}
478500

479-
url, err := readKeyFromMessage[string](mes, "url")
480-
if err != nil {
481-
s.logger.WithFields(log.Fields{
482-
"error": err,
483-
"key": "url",
484-
"host": matchedHost.Host,
485-
}).Error("failed to read url from message, cannot proceed with captcha remediation - ensure HAProxy is sending the 'url' variable in crowdsec-http message")
486-
return remediation.FromString(matchedHost.Captcha.FallbackRemediation), HTTPRequestData{}
487-
}
488-
489501
// Get the session only if we didn't just create it (i.e., we have an existing cookie)
490502
if ses == nil {
491-
ses = matchedHost.Captcha.Sessions.GetSession(uuid)
503+
ses = s.globalSessions.GetSession(uuid)
492504
if ses == nil {
505+
// Session lost from memory (e.g., after reload), create a new session and cookie
493506
s.logger.WithFields(log.Fields{
494507
"host": matchedHost.Host,
495508
"session": uuid,
496-
}).Warn("Session not found, cannot proceed with captcha")
497-
return remediation.FromString(matchedHost.Captcha.FallbackRemediation), HTTPRequestData{}
509+
}).Warn("Session not found in memory (likely lost after reload), creating new session and cookie")
510+
var err error
511+
ses, uuid, err = s.createNewSessionAndCookie(req, mes, matchedHost)
512+
if err != nil {
513+
// Session creation is critical for captcha to work - without it we can't track captcha status
514+
// This is a critical failure, so we must fall back to fallback remediation
515+
s.logger.WithFields(log.Fields{
516+
"host": matchedHost.Host,
517+
"error": err,
518+
}).Error("Failed to create new session after reload, falling back to fallback remediation")
519+
return remediation.FromString(matchedHost.Captcha.FallbackRemediation), HTTPRequestData{}
520+
}
498521
}
499522
}
500523

@@ -506,15 +529,25 @@ func (s *Spoa) handleCaptchaRemediation(req *request.Request, mes *message.Messa
506529

507530
// Set the captcha status in the transaction for HAProxy
508531
req.Actions.SetVar(action.ScopeTransaction, "captcha_status", captchaStatus)
509-
if captchaStatus != captcha.Valid {
532+
533+
// Read URL - this is not critical for showing the captcha page, only for redirect after validation
534+
url, err := readKeyFromMessage[string](mes, "url")
535+
if err != nil {
536+
s.logger.WithFields(log.Fields{
537+
"error": err,
538+
"key": "url",
539+
"host": matchedHost.Host,
540+
}).Warn("failed to read url from message, captcha will still be shown but redirect after validation may not work - ensure HAProxy is sending the 'url' variable in crowdsec-http message")
541+
// Continue with captcha even without URL - we just won't be able to redirect after validation
542+
} else if captchaStatus != captcha.Valid && url != nil {
510543
// Update the incoming url if it is different from the stored url for the session ignore favicon requests
511544
storedURL := ses.Get(session.URI)
512545
if storedURL == nil {
513546
storedURL = ""
514547
}
515548

516549
// Check url is not nil before dereferencing
517-
if url != nil && (storedURL == "" || *url != storedURL) && !strings.HasSuffix(*url, ".ico") {
550+
if (storedURL == "" || *url != storedURL) && !strings.HasSuffix(*url, ".ico") {
518551
s.logger.WithField("session", uuid).Debugf("updating stored url %s", *url)
519552
ses.Set(session.URI, *url)
520553
}

0 commit comments

Comments
 (0)