55 "encoding/json"
66 "fmt"
77 "log/slog"
8+ "math/rand"
89 "net"
910 "net/http"
1011 "net/url"
@@ -26,6 +27,8 @@ import (
2627const (
2728 // StateSaveInterval is how often the persistent state file is written to disk
2829 StateSaveInterval = 5 * time .Second
30+ // StateSaveJitter is the maximum random jitter added to save interval to prevent thundering herd
31+ StateSaveJitter = 2 * time .Second
2932)
3033
3134type Config struct {
@@ -54,7 +57,12 @@ type Config struct {
5457 EnableStatsPage string `json:"enableStatsPage"`
5558 LogLevel string `json:"loglevel,omitempty"`
5659 PersistentStateFile string `json:"persistentStateFile"`
57- Mode string `json:"mode"`
60+ // EnableStateReconciliation is a string instead of bool due to Traefik's label parsing limitations
61+ // When enabled, the plugin will read and merge state from disk before each save to prevent
62+ // multiple instances from overwriting each other's data. This adds extra I/O overhead.
63+ // Only enable this if running multiple plugin instances sharing the same state file.
64+ EnableStateReconciliation string `json:"enableStateReconciliation"`
65+ Mode string `json:"mode"`
5866}
5967
6068type CaptchaProtect struct {
@@ -87,27 +95,28 @@ type captchaResponse struct {
8795
8896func CreateConfig () * Config {
8997 return & Config {
90- RateLimit : 20 ,
91- Window : 86400 ,
92- IPv4SubnetMask : 16 ,
93- IPv6SubnetMask : 64 ,
94- IPForwardedHeader : "" ,
95- ProtectParameters : "false" ,
96- ProtectRoutes : []string {},
97- ExcludeRoutes : []string {},
98- ProtectHttpMethods : []string {},
99- ProtectFileExtensions : []string {},
100- GoodBots : []string {},
101- ExemptIPs : []string {},
102- ExemptUserAgents : []string {},
103- ChallengeURL : "/challenge" ,
104- ChallengeTmpl : "challenge.tmpl.html" ,
105- ChallengeStatusCode : 0 ,
106- EnableStatsPage : "false" ,
107- LogLevel : "INFO" ,
108- IPDepth : 0 ,
109- CaptchaProvider : "turnstile" ,
110- Mode : "prefix" ,
98+ RateLimit : 20 ,
99+ Window : 86400 ,
100+ IPv4SubnetMask : 16 ,
101+ IPv6SubnetMask : 64 ,
102+ IPForwardedHeader : "" ,
103+ ProtectParameters : "false" ,
104+ ProtectRoutes : []string {},
105+ ExcludeRoutes : []string {},
106+ ProtectHttpMethods : []string {},
107+ ProtectFileExtensions : []string {},
108+ GoodBots : []string {},
109+ ExemptIPs : []string {},
110+ ExemptUserAgents : []string {},
111+ ChallengeURL : "/challenge" ,
112+ ChallengeTmpl : "challenge.tmpl.html" ,
113+ ChallengeStatusCode : 0 ,
114+ EnableStatsPage : "false" ,
115+ LogLevel : "INFO" ,
116+ IPDepth : 0 ,
117+ CaptchaProvider : "turnstile" ,
118+ Mode : "prefix" ,
119+ EnableStateReconciliation : "false" ,
111120 }
112121}
113122
@@ -705,7 +714,13 @@ func (c *Config) ParseHttpMethods(log *slog.Logger) {
705714}
706715
707716func (bc * CaptchaProtect ) saveState (ctx context.Context ) {
708- ticker := time .NewTicker (StateSaveInterval )
717+ // Add random jitter to prevent multiple instances from trying to save simultaneously
718+ jitter := time .Duration (rand .Intn (int (StateSaveJitter .Milliseconds ()))) * time .Millisecond
719+ interval := StateSaveInterval + jitter
720+
721+ bc .log .Debug ("State save configured" , "baseInterval" , StateSaveInterval , "jitter" , jitter , "actualInterval" , interval )
722+
723+ ticker := time .NewTicker (interval )
709724 defer ticker .Stop ()
710725
711726 file , err := os .OpenFile (bc .config .PersistentStateFile , os .O_CREATE | os .O_WRONLY , 0644 )
@@ -730,9 +745,12 @@ func (bc *CaptchaProtect) saveState(ctx context.Context) {
730745 }
731746}
732747
733- // saveStateNow performs an immediate state save with file locking and reconciliation.
734- // This prevents multiple plugin instances from overwriting each other's state.
748+ // saveStateNow performs an immediate state save with file locking and optional reconciliation.
749+ // When reconciliation is enabled, it reads and merges state from disk before saving to prevent
750+ // multiple plugin instances from overwriting each other's data (at the cost of extra I/O).
735751func (bc * CaptchaProtect ) saveStateNow () {
752+ startTime := time .Now ()
753+
736754 lock , err := state .NewFileLock (bc .config .PersistentStateFile + ".lock" )
737755 if err != nil {
738756 bc .log .Error ("failed to create file lock for saving" , "err" , err )
@@ -744,33 +762,62 @@ func (bc *CaptchaProtect) saveStateNow() {
744762 bc .log .Error ("failed to acquire lock for saving state" , "err" , err )
745763 return
746764 }
765+ lockDuration := time .Since (startTime )
747766
748- // First, load and reconcile with existing file state
749- // This ensures we don't overwrite newer data from other instances
750- fileContent , err := os .ReadFile (bc .config .PersistentStateFile )
751- if err == nil && len (fileContent ) > 0 {
752- var fileState state.State
753- if err := json .Unmarshal (fileContent , & fileState ); err == nil {
754- bc .log .Debug ("Reconciling state before save" )
755- state .ReconcileState (fileState , bc .rateCache , bc .botCache , bc .verifiedCache )
767+ var readDuration , reconcileDuration , marshalDuration , writeDuration time.Duration
768+
769+ // Reconcile with existing file state if enabled
770+ // This prevents multiple instances from overwriting each other's data
771+ if bc .config .EnableStateReconciliation == "true" {
772+ readStart := time .Now ()
773+ fileContent , err := os .ReadFile (bc .config .PersistentStateFile )
774+ readDuration = time .Since (readStart )
775+
776+ if err == nil && len (fileContent ) > 0 {
777+ reconcileStart := time .Now ()
778+ var fileState state.State
779+ if err := json .Unmarshal (fileContent , & fileState ); err == nil {
780+ bc .log .Debug ("Reconciling state before save" , "fileBytes" , len (fileContent ))
781+ state .ReconcileState (fileState , bc .rateCache , bc .botCache , bc .verifiedCache )
782+ }
783+ reconcileDuration = time .Since (reconcileStart )
756784 }
757785 }
758786
759- // Now save our current state
787+ // Marshal current state
788+ marshalStart := time .Now ()
760789 currentState := state .GetState (bc .rateCache .Items (), bc .botCache .Items (), bc .verifiedCache .Items ())
761790 jsonData , err := json .Marshal (currentState )
791+ marshalDuration = time .Since (marshalStart )
792+
762793 if err != nil {
763794 bc .log .Error ("failed to marshal state data" , "err" , err )
764795 return
765796 }
766797
798+ // Write to disk
799+ writeStart := time .Now ()
767800 err = os .WriteFile (bc .config .PersistentStateFile , jsonData , 0644 )
801+ writeDuration = time .Since (writeStart )
802+
768803 if err != nil {
769804 bc .log .Error ("failed to save state data" , "err" , err )
770805 return
771806 }
772807
773- bc .log .Debug ("State saved successfully" )
808+ totalDuration := time .Since (startTime )
809+ bc .log .Debug ("State saved successfully" ,
810+ "bytes" , len (jsonData ),
811+ "rateEntries" , len (currentState .Rate ),
812+ "botEntries" , len (currentState .Bots ),
813+ "verifiedEntries" , len (currentState .Verified ),
814+ "lockMs" , lockDuration .Milliseconds (),
815+ "readMs" , readDuration .Milliseconds (),
816+ "reconcileMs" , reconcileDuration .Milliseconds (),
817+ "marshalMs" , marshalDuration .Milliseconds (),
818+ "writeMs" , writeDuration .Milliseconds (),
819+ "totalMs" , totalDuration .Milliseconds (),
820+ )
774821}
775822
776823func (bc * CaptchaProtect ) loadState () {
0 commit comments