diff --git a/Dockerfile b/Dockerfile index 6fd2e3c..da443c7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,7 @@ RUN set -x \ RUN mkdir -p /opt/banjax COPY ./ /opt/banjax/ -RUN cd /opt/banjax && go test && go build +RUN cd /opt/banjax && go test && go build -o banjax -tags debug RUN mkdir -p /etc/banjax COPY ./banjax-config.yaml /etc/banjax/ @@ -31,7 +31,7 @@ WORKDIR /opt/banjax # To enable live reload for dev, uncomment the following lines # COPY ./.air.toml /opt/banjax/ -# RUN go install github.com/air-verse/air@latest +# RUN go install github.com/air-verse/air@v1.52.3 # RUN mkdir -p /opt/banjax/tmp # CMD ["air", "-c", ".air.toml"] CMD ["./banjax"] diff --git a/banjax.go b/banjax.go index 2c4445e..869d0cf 100644 --- a/banjax.go +++ b/banjax.go @@ -171,14 +171,14 @@ func init_ipset(config *internal.Config) ipset.IPSet { func main() { // XXX protects ipToRegexStates and failedChallengeStates // (why both? because there are too many parameters already?) - var rateLimitMutex sync.Mutex + var rateLimitMutex sync.RWMutex ipToRegexStates := internal.IpToRegexStates{} failedChallengeStates := internal.FailedChallengeStates{} var passwordProtectedPaths internal.PasswordProtectedPaths // XXX protects decisionLists - var decisionListsMutex sync.Mutex + var decisionListsMutex sync.RWMutex var decisionLists internal.DecisionLists standaloneTestingPtr := flag.Bool("standalone-testing", false, "makes it easy to test standalone") diff --git a/docker-compose.yml b/docker-compose.yml index d99cd67..31a4640 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,3 +33,13 @@ services: dockerfile: Dockerfile ports: - "8080:8080" + + # wrk: + # image: ghcr.io/william-yeh/wrk + # platform: linux/amd64 + # volumes: + # - ./tmp:/data + # command: -t12 -c1024 -d10s http://localhost + # # Automatically removes the container when it exits + # restart: "no" + # network_mode: "service:nginx" diff --git a/go.mod b/go.mod index cb731a4..6d9cc31 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( require ( github.com/brianvoe/gofakeit/v6 v6.16.0 + github.com/gin-contrib/pprof v1.5.0 github.com/gonetx/ipset v0.1.0 github.com/jeremy5189/ipfilter-no-iploc/v2 v2.0.3 github.com/stretchr/testify v1.9.0 diff --git a/go.sum b/go.sum index e306e55..def7e4c 100644 --- a/go.sum +++ b/go.sum @@ -18,6 +18,8 @@ github.com/fsnotify/fsnotify v1.5.1 h1:mZcQUHVQUQWoPXXtuf9yuEXKudkV2sx1E06UadKWp github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU= github.com/gabriel-vasile/mimetype v1.4.5 h1:J7wGKdGu33ocBOhGy0z653k/lFKLFDPJMG8Gql0kxn4= github.com/gabriel-vasile/mimetype v1.4.5/go.mod h1:ibHel+/kbxn9x2407k1izTA1S81ku1z/DlgOW2QE0M4= +github.com/gin-contrib/pprof v1.5.0 h1:E/Oy7g+kNw94KfdCy3bZxQFtyDnAX2V7axRS7sNYVrU= +github.com/gin-contrib/pprof v1.5.0/go.mod h1:GqFL6LerKoCQ/RSWnkYczkTJ+tOAUVN/8sbnEtaqOKs= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= diff --git a/internal/config.go b/internal/config.go index c02ba8a..2699703 100644 --- a/internal/config.go +++ b/internal/config.go @@ -451,7 +451,7 @@ func checkExpiringDecisionListsByDomain(domain string, decisionLists *DecisionLi // XXX mmm could hold the lock for a while? func RemoveExpiredDecisions( - decisionListsMutex *sync.Mutex, + decisionListsMutex *sync.RWMutex, decisionLists *DecisionLists, ) { decisionListsMutex.Lock() @@ -466,7 +466,7 @@ func RemoveExpiredDecisions( } func removeExpiredDecisionsByIp( - decisionListsMutex *sync.Mutex, + decisionListsMutex *sync.RWMutex, decisionLists *DecisionLists, ip string, ) { @@ -480,7 +480,7 @@ func removeExpiredDecisionsByIp( func updateExpiringDecisionLists( config *Config, ip string, - decisionListsMutex *sync.Mutex, + decisionListsMutex *sync.RWMutex, decisionLists *DecisionLists, expires time.Time, newDecision Decision, @@ -514,7 +514,7 @@ func updateExpiringDecisionListsSessionId( config *Config, ip string, sessionId string, - decisionListsMutex *sync.Mutex, + decisionListsMutex *sync.RWMutex, decisionLists *DecisionLists, expires time.Time, newDecision Decision, @@ -550,14 +550,14 @@ type MetricsLogLine struct { func WriteMetricsToEncoder( metricsLogEncoder *json.Encoder, - decisionListsMutex *sync.Mutex, + decisionListsMutex *sync.RWMutex, decisionLists *DecisionLists, - rateLimitMutex *sync.Mutex, + rateLimitMutex *sync.RWMutex, ipToRegexStates *IpToRegexStates, failedChallengeStates *FailedChallengeStates, ) { - decisionListsMutex.Lock() - defer decisionListsMutex.Unlock() + decisionListsMutex.RLock() + defer decisionListsMutex.RUnlock() lenExpiringChallenges := 0 lenExpiringBlocks := 0 diff --git a/internal/http_server.go b/internal/http_server.go index 74225de..4ee553a 100644 --- a/internal/http_server.go +++ b/internal/http_server.go @@ -18,6 +18,7 @@ import ( "sync" "time" + "github.com/gin-contrib/pprof" "github.com/gin-gonic/gin" ) @@ -28,10 +29,10 @@ const ( func RunHttpServer( config *Config, - decisionListsMutex *sync.Mutex, + decisionListsMutex *sync.RWMutex, decisionLists *DecisionLists, passwordProtectedPaths *PasswordProtectedPaths, - rateLimitMutex *sync.Mutex, + rateLimitMutex *sync.RWMutex, ipToRegexStates *IpToRegexStates, failedChallengeStates *FailedChallengeStates, banner BannerInterface, @@ -54,6 +55,9 @@ func RunHttpServer( } r := gin.New() + pprof.Register(r) + runtime.SetBlockProfileRate(1) + runtime.SetMutexProfileFraction(1) type LogLine struct { Time string @@ -189,14 +193,14 @@ func RunHttpServer( }) r.GET("/rate_limit_states", func(c *gin.Context) { - rateLimitMutex.Lock() + rateLimitMutex.RLock() c.String(200, fmt.Sprintf("regexes:\n%v\nfailed challenges:\n%v", ipToRegexStates.String(), failedChallengeStates.String(), ), ) - rateLimitMutex.Unlock() + rateLimitMutex.RUnlock() }) // API to check if given IP was banned by iptables @@ -484,10 +488,10 @@ func tooManyFailedChallenges( path string, banner BannerInterface, challengeType string, - rateLimitMutex *sync.Mutex, + rateLimitMutex *sync.RWMutex, failedChallengeStates *FailedChallengeStates, method string, - decisionListsMutex *sync.Mutex, + decisionListsMutex *sync.RWMutex, decisionLists *DecisionLists, ) (tooManyFailedChallengesResult TooManyFailedChallengesResult) { rateLimitMutex.Lock() @@ -588,10 +592,10 @@ func sendOrValidateShaChallenge( config *Config, c *gin.Context, banner BannerInterface, - rateLimitMutex *sync.Mutex, + rateLimitMutex *sync.RWMutex, failedChallengeStates *FailedChallengeStates, failAction FailAction, - decisionListsMutex *sync.Mutex, + decisionListsMutex *sync.RWMutex, decisionLists *DecisionLists, ) (sendOrValidateShaChallengeResult SendOrValidateShaChallengeResult) { clientIp := c.Request.Header.Get("X-Client-IP") @@ -691,9 +695,9 @@ func sendOrValidatePassword( passwordProtectedPaths *PasswordProtectedPaths, c *gin.Context, banner BannerInterface, - rateLimitMutex *sync.Mutex, + rateLimitMutex *sync.RWMutex, failedChallengeStates *FailedChallengeStates, - decisionListsMutex *sync.Mutex, + decisionListsMutex *sync.RWMutex, decisionLists *DecisionLists, ) (sendOrValidatePasswordResult SendOrValidatePasswordResult) { clientIp := c.Request.Header.Get("X-Client-IP") @@ -833,10 +837,10 @@ type DecisionForNginxResult struct { func decisionForNginx( config *Config, - decisionListsMutex *sync.Mutex, + decisionListsMutex *sync.RWMutex, decisionLists *DecisionLists, passwordProtectedPaths *PasswordProtectedPaths, - rateLimitMutex *sync.Mutex, + rateLimitMutex *sync.RWMutex, failedChallengeStates *FailedChallengeStates, banner BannerInterface, ) gin.HandlerFunc { @@ -868,16 +872,16 @@ func decisionForNginx( func checkPerSiteDecisionLists( config *Config, - decisionListsMutex *sync.Mutex, + decisionListsMutex *sync.RWMutex, decisionLists *DecisionLists, requestedHost string, clientIp string, ) (bool, Decision) { // XXX ugh this locking is awful // i got bit by just checking against the zero value here, which is a valid iota enum - decisionListsMutex.Lock() + decisionListsMutex.RLock() decision, ok := (*decisionLists).PerSiteDecisionLists[requestedHost][clientIp] - decisionListsMutex.Unlock() + decisionListsMutex.RUnlock() // found as plain IP form, no need to check IPFilter if ok { @@ -907,10 +911,10 @@ func checkPerSiteDecisionLists( func decisionForNginx2( c *gin.Context, config *Config, - decisionListsMutex *sync.Mutex, + decisionListsMutex *sync.RWMutex, decisionLists *DecisionLists, passwordProtectedPaths *PasswordProtectedPaths, - rateLimitMutex *sync.Mutex, + rateLimitMutex *sync.RWMutex, failedChallengeStates *FailedChallengeStates, banner BannerInterface, ) (decisionForNginxResult DecisionForNginxResult) { @@ -1020,9 +1024,9 @@ func decisionForNginx2( } } - decisionListsMutex.Lock() + decisionListsMutex.RLock() decision, ok = (*decisionLists).GlobalDecisionLists[clientIp] - decisionListsMutex.Unlock() + decisionListsMutex.RUnlock() foundInIpFilter := false if !ok { for _, iterateDecision := range []Decision{Allow, Challenge, NginxBlock, IptablesBlock} { @@ -1074,9 +1078,9 @@ func decisionForNginx2( // when we insert something into the list, really we might just be extending the expiry time and/or // changing the decision. // XXX i forget if that comment is stale^ - decisionListsMutex.Lock() + decisionListsMutex.RLock() expiringDecision, ok := checkExpiringDecisionLists(c, clientIp, decisionLists) - decisionListsMutex.Unlock() + decisionListsMutex.RUnlock() if !ok { // log.Println("no mention in expiring lists") } else { @@ -1118,9 +1122,9 @@ func decisionForNginx2( // the legacy banjax_sha_inv and user_banjax_sha_inv // difference is one blocks after many failures and the other doesn't - decisionListsMutex.Lock() + decisionListsMutex.RLock() failAction, ok := (*decisionLists).SitewideShaInvList[requestedHost] - decisionListsMutex.Unlock() + decisionListsMutex.RUnlock() if !ok { // log.Println("no mention in sitewide list") } else { diff --git a/internal/iptables.go b/internal/iptables.go index aa381a0..50886f6 100644 --- a/internal/iptables.go +++ b/internal/iptables.go @@ -126,7 +126,7 @@ type BannerInterface interface { } type Banner struct { - DecisionListsMutex *sync.Mutex + DecisionListsMutex *sync.RWMutex DecisionLists *DecisionLists Logger *log.Logger LoggerTemp *log.Logger diff --git a/internal/kafka.go b/internal/kafka.go index 5a5833a..57571e4 100644 --- a/internal/kafka.go +++ b/internal/kafka.go @@ -81,7 +81,7 @@ func getDialer(config *Config) *kafka.Dialer { func RunKafkaReader( config *Config, - decisionListsMutex *sync.Mutex, + decisionListsMutex *sync.RWMutex, decisionLists *DecisionLists, wg *sync.WaitGroup, ) { @@ -158,7 +158,7 @@ func getBlockSessionTtl(config *Config, host string) (blockSessionTtl int) { func handleCommand( config *Config, command commandMessage, - decisionListsMutex *sync.Mutex, + decisionListsMutex *sync.RWMutex, decisionLists *DecisionLists, ) { // exempt a site from baskerville according to config @@ -191,7 +191,7 @@ func handleCommand( func handleIPCommand( config *Config, command commandMessage, - decisionListsMutex *sync.Mutex, + decisionListsMutex *sync.RWMutex, decisionLists *DecisionLists, decision Decision, expireDuration int, @@ -219,7 +219,7 @@ func handleIPCommand( func handleSessionCommand( config *Config, command commandMessage, - decisionListsMutex *sync.Mutex, + decisionListsMutex *sync.RWMutex, decisionLists *DecisionLists, decision Decision, expireDuration int, diff --git a/internal/regex_rate_limiter.go b/internal/regex_rate_limiter.go index 2d4861b..d52bbf1 100644 --- a/internal/regex_rate_limiter.go +++ b/internal/regex_rate_limiter.go @@ -22,9 +22,9 @@ import ( func RunLogTailer( config *Config, banner BannerInterface, - rateLimitMutex *sync.Mutex, + rateLimitMutex *sync.RWMutex, ipToRegexStates *IpToRegexStates, - decisionListsMutex *sync.Mutex, + decisionListsMutex *sync.RWMutex, decisionLists *DecisionLists, wg *sync.WaitGroup, ) { @@ -120,12 +120,12 @@ func parseTimestamp(timeIpRest []string) (timestamp time.Time, err error) { func checkIpInGlobalDecisionList( ipString string, - decisionListsMutex *sync.Mutex, + decisionListsMutex *sync.RWMutex, decisionLists *DecisionLists, )(bool) { // Check if IP is in the global allow list that should be skipped - decisionListsMutex.Lock() - defer decisionListsMutex.Unlock() + decisionListsMutex.RLock() + defer decisionListsMutex.RUnlock() decision, ok := (*decisionLists).GlobalDecisionLists[ipString] if (ok && decision == Allow) { @@ -146,11 +146,11 @@ func checkIpInGlobalDecisionList( func checkIpInPerSiteDecisionList( urlString string, ipString string, - decisionListsMutex *sync.Mutex, + decisionListsMutex *sync.RWMutex, decisionLists *DecisionLists, ) (bool) { - decisionListsMutex.Lock() - defer decisionListsMutex.Unlock() + decisionListsMutex.RLock() + defer decisionListsMutex.RUnlock() decision, ok := (*decisionLists).PerSiteDecisionLists[urlString][ipString] if (ok && decision == Allow) { @@ -177,11 +177,11 @@ func checkIpInPerSiteDecisionList( // parsing these unescaped space-separated strings is gross. maybe pass json instead. func consumeLine( line *tail.Line, - rateLimitMutex *sync.Mutex, + rateLimitMutex *sync.RWMutex, ipToRegexStates *IpToRegexStates, banner BannerInterface, config *Config, - decisionListsMutex *sync.Mutex, + decisionListsMutex *sync.RWMutex, decisionLists *DecisionLists, ) (consumeLineResult ConsumeLineResult) { diff --git a/internal/regex_rate_limiter_test.go b/internal/regex_rate_limiter_test.go index 69d18b4..1a17174 100644 --- a/internal/regex_rate_limiter_test.go +++ b/internal/regex_rate_limiter_test.go @@ -89,7 +89,7 @@ func configToStructs( } func TestConsumeLine(t *testing.T) { - var rateLimitMutex sync.Mutex + var rateLimitMutex sync.RWMutex configString := ` regexes_with_rates: - rule: 'rule1' @@ -116,7 +116,7 @@ per_site_regexes_with_rates: } ipToRegexStates := IpToRegexStates{} mockBanner := MockBanner{} - var decisionListsMutex sync.Mutex + var decisionListsMutex sync.RWMutex var decisionLists DecisionLists var passwordProtectedPaths PasswordProtectedPaths configToStructs(&config, &passwordProtectedPaths, &decisionLists) @@ -293,7 +293,7 @@ per_site_regexes_with_rates: } func TestConsumeLineHostsToSkip(t *testing.T) { - var rateLimitMutex sync.Mutex + var rateLimitMutex sync.RWMutex configString := ` regexes_with_rates: - rule: 'rule1' @@ -311,7 +311,7 @@ regexes_with_rates: } ipToRegexStates := IpToRegexStates{} mockBanner := MockBanner{} - var decisionListsMutex sync.Mutex + var decisionListsMutex sync.RWMutex var decisionLists DecisionLists var passwordProtectedPaths PasswordProtectedPaths configToStructs(&config, &passwordProtectedPaths, &decisionLists) @@ -340,7 +340,7 @@ regexes_with_rates: } func TestPerSiteRegexStress(t *testing.T) { - var rateLimitMutex sync.Mutex + var rateLimitMutex sync.RWMutex var domains []string var paths []string testCount := 10000 @@ -372,7 +372,7 @@ regexes_with_rates: if err != nil { panic("couldn't parse config file!") } - var decisionListsMutex sync.Mutex + var decisionListsMutex sync.RWMutex var decisionLists DecisionLists var passwordProtectedPaths PasswordProtectedPaths configToStructs(&config, &passwordProtectedPaths, &decisionLists) diff --git a/supporting-containers/nginx/nginx.conf b/supporting-containers/nginx/nginx.conf index 8e963d4..237101e 100644 --- a/supporting-containers/nginx/nginx.conf +++ b/supporting-containers/nginx/nginx.conf @@ -81,6 +81,10 @@ http { server_name localhost www.localhost; proxy_set_header Host $host; + location /debug/pprof { + proxy_pass http://127.0.0.1:8081/debug/pprof; + } + location /wp-admin/ { set $loc_in "pass_prot"; set $deflect_session "$upstream_http_x_deflect_session";