Skip to content

Commit b30cd88

Browse files
Merge branch 'main' into feature/lj-appsec
Resolved conflicts in: - config/haproxy.cfg - internal/api/admin_handlers.go - internal/api/worker_handlers.go - lua/crowdsec.lua - pkg/spoa/root.go All AppSec functionality preserved and integrated with latest main branch changes.
2 parents 6a04337 + db4887d commit b30cd88

File tree

5 files changed

+113
-39
lines changed

5 files changed

+113
-39
lines changed

config/haproxy.cfg

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,12 @@ frontend test
3636
## Set a custom header on the request for upstream services to use
3737
http-request set-header X-CrowdSec-IsoCode %[var(txn.crowdsec.isocode)] if { var(txn.crowdsec.isocode) -m found }
3838

39-
## Call lua script to handle the remediation (ban/captcha pages)
40-
http-request lua.crowdsec_handle if { var(txn.crowdsec.remediation) -m found }
41-
## Note if the remediation is allow you should still call the handler incase a redirect is needed following a captcha
39+
## Handle 302 redirect for successful captcha validation (native HAProxy redirect)
40+
http-request redirect code 302 location %[var(txn.crowdsec.redirect)] if { var(txn.crowdsec.remediation) -m str "allow" } { var(txn.crowdsec.redirect) -m found }
41+
42+
## Call lua script only for ban and captcha remediations (performance optimization)
43+
http-request lua.crowdsec_handle if { var(txn.crowdsec.remediation) -m str "captcha" }
44+
http-request lua.crowdsec_handle if { var(txn.crowdsec.remediation) -m str "ban" }
4245

4346
## Handle captcha cookie management via HAProxy (new approach)
4447
## Set captcha cookie when SPOA provides captcha_status (pending or valid)

internal/api/admin_handlers.go

Lines changed: 71 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,15 @@ func (a *API) handleAdminConnection(ctx context.Context, sc server.SocketConn) {
263263
reader := bufio.NewReader(sc.Conn)
264264

265265
for {
266+
// Check if context is canceled (shutdown signal)
267+
select {
268+
case <-ctx.Done():
269+
log.Debug("Context canceled, shutting down admin connection handler")
270+
return
271+
default:
272+
// Continue with normal processing
273+
}
274+
266275
// Read line by line instead of fixed buffer - more efficient for admin commands
267276
line, err := reader.ReadString('\n')
268277
if err != nil {
@@ -322,47 +331,82 @@ func (a *API) handleAdminConnection(ctx context.Context, sc server.SocketConn) {
322331
}
323332
}
324333

325-
// parseAdminCommand parses admin commands - optimized version using strings.Fields
334+
// parseAdminCommand parses admin commands using a smart pattern-matching approach
326335
func (a *API) parseAdminCommand(line string) ([]string, []string, error) {
327-
// Split by whitespace - much more efficient than byte-by-byte parsing
328336
parts := strings.Fields(line)
329337
if len(parts) == 0 {
330338
return nil, nil, fmt.Errorf("empty command")
331339
}
332340

333-
// First part is always the verb
334-
verb := parts[0]
335-
336341
if len(parts) < 2 {
337342
return nil, nil, fmt.Errorf("missing module")
338343
}
339344

340-
// Second part is the module
341-
module := parts[1]
342-
343-
apiCommand := []string{verb, module}
344-
args := []string{}
345-
346-
// Handle submodule and arguments
347-
if len(parts) > 2 {
348-
// Check if third part could be a submodule (no spaces, reasonable length)
349-
potentialSubmodule := parts[2]
350-
if len(potentialSubmodule) <= 16 && !strings.Contains(potentialSubmodule, " ") {
351-
// Treat as submodule if it makes sense contextually
352-
if (verb == "get" || verb == "set" || verb == "del" || verb == "val") &&
353-
(module == "host" && (potentialSubmodule == "session" || potentialSubmodule == "cookie" || potentialSubmodule == "captcha")) ||
354-
(module == "geo" && potentialSubmodule == "iso") {
355-
apiCommand = append(apiCommand, potentialSubmodule)
356-
args = parts[3:] // Remaining parts are arguments
357-
} else {
358-
args = parts[2:] // All remaining parts are arguments
345+
// For 3-part commands, we need to handle cases where arguments come between command parts
346+
// e.g., "get host 127.0.0.1 session uuid key" should parse as "get:host:session"
347+
if len(parts) >= 4 {
348+
// Try verb:module:submodule pattern with different positions for submodule
349+
verb := parts[0]
350+
module := parts[1]
351+
352+
// Look for known submodules in the remaining parts
353+
for i := 2; i < len(parts); i++ {
354+
candidateCmd := fmt.Sprintf("%s:%s:%s", verb, module, parts[i])
355+
if a.isValidCommand(candidateCmd) {
356+
// Found valid 3-part command, collect arguments from everywhere except the command parts
357+
cmdParts := []string{verb, module, parts[i]}
358+
args := make([]string, 0)
359+
360+
// Add arguments that come before the submodule
361+
args = append(args, parts[2:i]...)
362+
// Add arguments that come after the submodule
363+
args = append(args, parts[i+1:]...)
364+
365+
return cmdParts, args, nil
359366
}
360-
} else {
361-
args = parts[2:] // All remaining parts are arguments
362367
}
363368
}
364369

365-
return apiCommand, args, nil
370+
// Try standard 3-part commands (verb:module:submodule at start)
371+
if len(parts) >= 3 {
372+
threePartCmd := strings.Join(parts[:3], ":")
373+
if a.isValidCommand(threePartCmd) {
374+
return parts[:3], parts[3:], nil
375+
}
376+
}
377+
378+
// Try 2-part commands (verb:module)
379+
twoPartCmd := strings.Join(parts[:2], ":")
380+
if a.isValidCommand(twoPartCmd) {
381+
return parts[:2], parts[2:], nil
382+
}
383+
384+
// If no valid command found, return an explicit error
385+
return nil, nil, fmt.Errorf("invalid command: %q", line)
386+
}
387+
388+
// isValidCommand checks if a command string matches any of our defined APICommands
389+
func (a *API) isValidCommand(cmdStr string) bool {
390+
cmd := messages.CommandFromString(cmdStr)
391+
392+
// Use the actual APICommand constants - no duplication!
393+
switch cmd {
394+
case messages.GetIP,
395+
messages.GetCN,
396+
messages.GetGeoIso,
397+
messages.GetHosts,
398+
messages.GetHostCookie,
399+
messages.GetHostSession,
400+
messages.ValHostCookie,
401+
messages.ValHostCaptcha,
402+
messages.SetHostSession,
403+
messages.DelHostSession,
404+
messages.DelHosts,
405+
messages.ValHostAppSec:
406+
return true
407+
default:
408+
return false
409+
}
366410
}
367411

368412
// handleAdminCommand directly dispatches admin commands using clean APICommand constants

internal/api/worker_handlers.go

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"io"
77
"net"
88
"strings"
9+
"time"
910

1011
"github.com/crowdsecurity/crowdsec-spoa/internal/api/messages"
1112
"github.com/crowdsecurity/crowdsec-spoa/internal/api/types"
@@ -40,8 +41,38 @@ func (a *API) handleWorkerConnectionEncoded(ctx context.Context, sc server.Socke
4041
}()
4142

4243
for {
44+
// Check if context is canceled (shutdown signal)
45+
select {
46+
case <-ctx.Done():
47+
log.Debugf("Context canceled, shutting down worker connection handler for worker: %s", workerName)
48+
return
49+
default:
50+
// Continue with normal processing
51+
}
52+
53+
// Set short read timeout to make decode responsive to context cancellation
54+
if err := sc.Conn.SetReadDeadline(time.Now().Add(10 * time.Millisecond)); err != nil {
55+
log.Errorf("Failed to set read deadline for worker %s: %v", workerName, err)
56+
return
57+
}
58+
4359
var req messages.APIRequest
44-
if err := sc.Decoder.Decode(&req); err != nil {
60+
err := sc.Decoder.Decode(&req)
61+
62+
// Clear deadline immediately after decode attempt
63+
if err := sc.Conn.SetReadDeadline(time.Time{}); err != nil {
64+
log.Errorf("Failed to clear read deadline for worker %s: %v", workerName, err)
65+
return
66+
}
67+
68+
if err != nil {
69+
// Check if it's a timeout error using errors.As for wrapped errors
70+
var netErr net.Error
71+
if errors.As(err, &netErr) && netErr.Timeout() {
72+
// Timeout occurred - loop back to check context again
73+
continue
74+
}
75+
4576
if errors.Is(err, io.EOF) {
4677
// Client closed the connection gracefully
4778
break

lua/crowdsec.lua

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -113,14 +113,11 @@ function runtime.Handle(txn)
113113
reply:add_header("cache-control", "no-cache")
114114
reply:add_header("cache-control", "no-store")
115115

116+
-- NOTE: "allow" remediation with redirects is now handled natively by HAProxy
117+
-- This Lua handler is only called for "captcha" and "ban" remediations
116118
if remediation == "allow" then
117-
local redirect_uri = get_txn_var(txn, "crowdsec.redirect")
118-
if redirect_uri ~= "" then
119-
reply:set_status(302)
120-
reply:add_header("Location", redirect_uri)
121-
else
122-
return
123-
end
119+
runtime.logger.warning("Lua handler called for 'allow' remediation - this should not happen with native redirects")
120+
return
124121
end
125122

126123
if remediation == "captcha" then

pkg/spoa/root.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,6 @@ func (s *Spoa) extractHTTPRequestData(mes *message.Message) (*string, *string, h
238238
return method, url, headers, body, nil
239239
}
240240

241-
242241
// handleAppSecValidation handles AppSec validation logic
243242
func (s *Spoa) handleAppSecValidation(mes *message.Message, host *host.Host, method, url *string, headers http.Header, body *[]byte) remediation.Remediation {
244243
// Extract additional information for AppSec validation

0 commit comments

Comments
 (0)