diff --git a/AGENTS.md b/AGENTS.md index 2092aeb..c4d3fd3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,6 +41,17 @@ log aggregation systems while keeping HTTP and application concerns separate. do their own bounded-TTL caching client-side - a second cache layer here would only add staleness without a clear benefit for this service's read volume. +## Rate limiting + +Per-client/IP rate limiting is on by default via the shared `api-core.go/ratelimit` middleware +(Redis-backed counters keyed by `X-API-Key` else client IP, `cheap` tier for `/status/*`, +fail-closed 503 when Redis is unreachable, 429 on exceed). This replaced the process-wide +`rate.NewLimiter` bucket, which one busy caller could exhaust for everyone. `REDIS_HOST`/ +`REDIS_PORT` are in the dev configmap; `REDIS_PASS` comes from the `api-cache` `ExternalSecret`. +Tune with `RATE_LIMIT_CHEAP`/`RATE_LIMIT_CHEAP_WINDOW_SECONDS`/`RATE_LIMIT_STANDARD`/ +`RATE_LIMIT_STANDARD_WINDOW_SECONDS`. See `platform`'s +`openspec/changes/fix-rate-limiting-per-client-ip`. + ## Committing Code Use [Conventional Commits](https://www.conventionalcommits.org/): diff --git a/CHANGELOG.md b/CHANGELOG.md index ddde35a..d49ec37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,11 @@ +## 0.12.0 - 2026-09-09 + +### Added +- Replace process-wide bucket with shared api-core middleware (#106) + + + ## 0.11.0 - 2026-09-07 ### Added diff --git a/cmd/admin-api/main.go b/cmd/admin-api/main.go index 7e1d49b..925b12c 100644 --- a/cmd/admin-api/main.go +++ b/cmd/admin-api/main.go @@ -2,10 +2,8 @@ package main import ( "context" - "fmt" "log" "log/slog" - "net/http" "os" "strconv" "strings" @@ -27,14 +25,14 @@ import ( "github.com/sweetrpg/admin-api/server" apiconstants "github.com/sweetrpg/api-core.go/constants" "github.com/sweetrpg/api-core.go/featureflags" + "github.com/sweetrpg/api-core.go/ratelimit" "github.com/sweetrpg/api-core.go/tracing" - "github.com/sweetrpg/api-core.go/vo" + apiutil "github.com/sweetrpg/api-core.go/util" "github.com/sweetrpg/authz-client.go/authz" "github.com/sweetrpg/common.go/logging" "github.com/sweetrpg/common.go/util" "github.com/sweetrpg/mongodb.go/database" "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin" - "golang.org/x/time/rate" ) // @title Admin API service @@ -98,8 +96,19 @@ func main() { // Swagger setupSwagger(r) - // Add rate limiter - r.Use(RateLimiter()) + // Per-client/IP rate limiter (Redis-backed, fail-closed). Replaces the process-wide bucket. + redisPool := apiutil.RedisPool() + if redisPool != nil { + pingCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + if err := ratelimit.Ping(pingCtx, redisPool); err != nil { + logging.Logger.Error("REDIS_HOST is configured but unreachable at startup; rate-limited requests will fail closed until it recovers", + "error", err.Error()) + } + cancel() + } else { + logging.Logger.Warn("REDIS_HOST is not configured; rate limiting will fail closed (503) on every limited request") + } + r.Use(ratelimit.Middleware(redisPool, ratelimit.DefaultOptions())) authzClient := authz.NewClient(util.GetEnv(constants.AUTH_API_URL, ""), util.GetEnv(constants.USERS_API_URL, "")) server.SetupHandlers(r, authzClient) @@ -250,19 +259,3 @@ func setupMetrics(r *gin.Engine) { m.SetDuration([]float64{0.1, 0.3, 1.2, 5, 10}) m.Use(r) } - -func RateLimiter() gin.HandlerFunc { - limiter := rate.NewLimiter(1, util.GetEnvInt(apiconstants.RATE_LIMIT, 10)) - - return func(c *gin.Context) { - if limiter.Allow() { - c.Next() - } else { - logging.Logger.Warn(fmt.Sprintf("Rate limit exceeded for request: %v", c.Request)) - c.JSON(http.StatusTooManyRequests, vo.ErrorVO{ - Error: apiconstants.ErrorRateLimited, - Message: "Limit exceeded", - }) - } - } -} diff --git a/go.mod b/go.mod index b717c0e..0400f2f 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.26.5 require ( github.com/getsentry/sentry-go v0.49.0 - github.com/gin-contrib/cors v1.7.7 + github.com/gin-contrib/cors v1.7.8 github.com/gin-gonic/gin v1.12.0 github.com/grafana/pyroscope-go v1.4.2 github.com/joho/godotenv v1.5.1 @@ -14,7 +14,7 @@ require ( github.com/swaggo/files v1.0.1 github.com/swaggo/gin-swagger v1.6.1 github.com/swaggo/swag v1.16.6 - github.com/sweetrpg/api-core.go v0.1.1 + github.com/sweetrpg/api-core.go v0.3.1 github.com/sweetrpg/authz-client.go v0.2.0 github.com/sweetrpg/common.go v0.0.16 github.com/sweetrpg/mongodb.go v0.0.193 @@ -22,7 +22,6 @@ require ( go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.71.0 go.opentelemetry.io/otel v1.46.0 go.opentelemetry.io/otel/trace v1.46.0 - golang.org/x/time v0.15.0 ) require ( @@ -57,6 +56,7 @@ require ( github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/golang/snappy v1.0.0 // indirect + github.com/gomodule/redigo v1.9.3 // indirect github.com/google/uuid v1.6.0 // indirect github.com/grafana/pyroscope-go/godeltaprof v0.1.11 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect diff --git a/go.sum b/go.sum index c3aae6b..8c4710a 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,8 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/alicebob/miniredis/v2 v2.39.0 h1:M7WbmV5BmV56L8KTG0rw6vEQ+woTOghpDgin2xv4A0g= +github.com/alicebob/miniredis/v2 v2.39.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bits-and-blooms/bitset v1.2.0 h1:Kn4yilvwNtMACtf1eYDlG8H77R07mZSPbMjLyS07ChA= @@ -45,8 +47,8 @@ github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ= github.com/getsentry/sentry-go v0.49.0 h1:Ehejknu1l023Ub7QoRBVLAI7g3Jnhqku4oWx4B4Sh5s= github.com/getsentry/sentry-go v0.49.0/go.mod h1:nuMJAoCfe1u0Bts2ocyNI+TW8HT84vRMqwA5Qq/SKUI= -github.com/gin-contrib/cors v1.7.7 h1:Oh9joP463x7Mw72vhvJ61YQm8ODh9b04YR7vsOErD0Q= -github.com/gin-contrib/cors v1.7.7/go.mod h1:K5tW0RkzJtWSiOdikXloy8VEZlgdVNpHNw8FpjUPNrE= +github.com/gin-contrib/cors v1.7.8 h1:U0jjyXlWXMEx27hFE6hmdg/TOZviYgsOGGT+kLmUZrc= +github.com/gin-contrib/cors v1.7.8/go.mod h1:u3nLI2pP1IlJn7tbvL7iiubDO9quBZ8FXP9LxmqpsPI= github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko= @@ -89,6 +91,8 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/gomodule/redigo v1.9.3 h1:dNPSXeXv6HCq2jdyWfjgmhBdqnR6PRO3m/G05nvpPC8= +github.com/gomodule/redigo v1.9.3/go.mod h1:KsU3hiK/Ay8U42qpaJk+kuNa3C+spxapWpM+ywhcgtw= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -210,8 +214,8 @@ github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw= github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= -github.com/sweetrpg/api-core.go v0.1.1 h1:qqr5Rgiaw7n2RtDZ7fdoy5vw8RT3L42orhJ/x+EYX9w= -github.com/sweetrpg/api-core.go v0.1.1/go.mod h1:ka2gLYUlukRaBXiaaohfRSHoNH77oSrTkkik07Z+lj0= +github.com/sweetrpg/api-core.go v0.3.1 h1:Fa0dzFjoohTGLt182pO55kksScGNKi6nFHRij/68GTw= +github.com/sweetrpg/api-core.go v0.3.1/go.mod h1:Li4x24U0q67096dbvOm1Fuq/xOMG1elcgiuJAV8WTJA= github.com/sweetrpg/authz-client.go v0.2.0 h1:HYzR872fxO/JvSgdWPvdc67bI/5OgmbNNHrGb1QOMX0= github.com/sweetrpg/authz-client.go v0.2.0/go.mod h1:D5v5R+rW71cy298b7dMaAPTtWpkAntqGMFN3vQdkaSA= github.com/sweetrpg/common.go v0.0.16 h1:XT8PwUXz0BOMIITXLjw3G6rEjjRUEs0Xs4+R9o2fVBk= @@ -233,6 +237,8 @@ github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gi github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= github.com/zerodha/logf v0.5.5 h1:AhxHlixHNYwhFjvlgTv6uO4VBKYKxx2I6SbHoHtWLBk= github.com/zerodha/logf v0.5.5/go.mod h1:HWpfKsie+WFFpnUnUxelT6Z0FC6xu9+qt+oXNMPg6y8= go.jtlabs.io/query v1.6.1 h1:AldVImTH2RR5M9h8fCdZNyN7bew5JMVuDPelUQnP0DE= @@ -321,8 +327,6 @@ golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= -golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= -golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/kubernetes/base/deployment.yaml b/kubernetes/base/deployment.yaml index 93d66d3..c0928dd 100644 --- a/kubernetes/base/deployment.yaml +++ b/kubernetes/base/deployment.yaml @@ -43,6 +43,8 @@ spec: name: api-db - secretRef: name: api-misc + - secretRef: + name: api-cache env: - name: GOMEMLIMIT value: "900MiB" diff --git a/kubernetes/overlays/dev/configmaps.yaml b/kubernetes/overlays/dev/configmaps.yaml index 2311a86..0614b16 100644 --- a/kubernetes/overlays/dev/configmaps.yaml +++ b/kubernetes/overlays/dev/configmaps.yaml @@ -23,3 +23,12 @@ data: SENTRY_RELEASE: "1" AUTH_API_URL: http://api-v1.sweetrpg-auth.svc.cluster.local:8000 USERS_API_URL: http://api-v1.sweetrpg-users.svc.cluster.local:8000 + # Per-client/IP rate limiting (platform rate-limiting capability, #106). api-core defaults. + RATE_LIMIT_CHEAP: "120" + RATE_LIMIT_CHEAP_WINDOW_SECONDS: "60" + RATE_LIMIT_STANDARD: "30" + RATE_LIMIT_STANDARD_WINDOW_SECONDS: "60" + # Namespace cache Service, backs the distributed rate-limit counters. REDIS_PASS comes from an + # ExternalSecret (#106). + REDIS_HOST: cache.sweetrpg-admin.svc.cluster.local + REDIS_PORT: "6379" diff --git a/kubernetes/overlays/dev/kustomization.yaml b/kubernetes/overlays/dev/kustomization.yaml index 8961690..c184ebd 100644 --- a/kubernetes/overlays/dev/kustomization.yaml +++ b/kubernetes/overlays/dev/kustomization.yaml @@ -11,6 +11,7 @@ resources: - middlewares.yaml - password.yaml - pod-monitor.yaml + - secrets/cache.yaml - secrets/db-creds.yaml - secrets/db.yaml - secrets/misc.yaml diff --git a/kubernetes/overlays/dev/secrets/cache.yaml b/kubernetes/overlays/dev/secrets/cache.yaml new file mode 100644 index 0000000..112e342 --- /dev/null +++ b/kubernetes/overlays/dev/secrets/cache.yaml @@ -0,0 +1,20 @@ +--- +apiVersion: external-secrets.io/v1 +kind: ExternalSecret + +metadata: + name: admin-api-cache + +spec: + refreshInterval: 12h + secretStoreRef: + name: akeyless + kind: ClusterSecretStore + target: + name: api-cache + creationPolicy: Owner + dataFrom: + # Provides REDIS_PASS for the api-core.go/ratelimit Redis pool (Valkey ACL auth is on in + # every namespace). Mirrors catalog-api's secrets/cache.yaml. See platform #106. + - extract: + key: /sweetrpg/admin/cache/auth