Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-pages.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJaime Martinez <jmartinez@gitlab.com>2021-09-22 09:38:25 +0300
committerJaime Martinez <jmartinez@gitlab.com>2021-10-14 08:59:51 +0300
commitccfdff303646b86daed2bd9ae7e2f2a5eb4a2c5c (patch)
tree81bc3444ad0417cd4454077fd187323a292bfc93
parent247bd7ba2fd9139711218c6a42ed03c551f958d9 (diff)
feat: add source IP ratelimiter middleware
It gets the source IP from `r.RemoteAddr` or from the `X-Forwarded-For` header for proxied requests (when `--listen-proxy` is enabled). The first iteration will only report logs and metrics when an IP is being rate limited. The rate limiter uses a Token Bucket approach using golang.org/x/time/rate, which can be configured with the newly added flags `rate-limit-source-ip` and `rate-limit-source-ip-burst`. To enable the rate limiter, set `rate-limit-source-ip` to value > 1, which is the number of requests per second to allow. It is enabled by default in "dry-run" mode so requests won't be dropped until the environment variable `FF_ENABLE_RATE_LIMITER` is set to `"true"`. See metrics.go for the newly added metrics. Changelog: added
-rw-r--r--app.go11
-rw-r--r--go.mod1
-rw-r--r--internal/config/config.go11
-rw-r--r--internal/config/flags.go2
-rw-r--r--internal/httperrors/httperrors.go13
-rw-r--r--internal/logging/logging.go1
-rw-r--r--internal/ratelimiter/middleware.go89
-rw-r--r--internal/ratelimiter/middleware_test.go180
-rw-r--r--internal/ratelimiter/ratelimiter.go15
-rw-r--r--internal/ratelimiter/ratelimiter_test.go82
-rw-r--r--internal/testhelpers/testhelpers.go14
-rw-r--r--metrics/metrics.go31
-rw-r--r--test/acceptance/ratelimiter_test.go39
13 files changed, 457 insertions, 32 deletions
diff --git a/app.go b/app.go
index 23e8a3cd..27094cff 100644
--- a/app.go
+++ b/app.go
@@ -32,6 +32,7 @@ import (
"gitlab.com/gitlab-org/gitlab-pages/internal/httperrors"
"gitlab.com/gitlab-org/gitlab-pages/internal/logging"
"gitlab.com/gitlab-org/gitlab-pages/internal/netutil"
+ "gitlab.com/gitlab-org/gitlab-pages/internal/ratelimiter"
"gitlab.com/gitlab-org/gitlab-pages/internal/rejectmethods"
"gitlab.com/gitlab-org/gitlab-pages/internal/request"
"gitlab.com/gitlab-org/gitlab-pages/internal/routing"
@@ -262,6 +263,16 @@ func (a *theApp) buildHandlerPipeline() (http.Handler, error) {
handler = routing.NewMiddleware(handler, a.source)
+ if a.config.RateLimit.SourceIPLimitPerSecond > 0 {
+ rl := ratelimiter.New(
+ ratelimiter.WithSourceIPLimitPerSecond(a.config.RateLimit.SourceIPLimitPerSecond),
+ ratelimiter.WithSourceIPBurstSize(a.config.RateLimit.SourceIPBurst),
+ ratelimiter.WithProxied(len(a.config.Listeners.Proxy) > 0),
+ )
+
+ handler = rl.SourceIPLimiter(handler)
+ }
+
// Health Check
handler, err = a.healthCheckMiddleware(handler)
if err != nil {
diff --git a/go.mod b/go.mod
index 0ff516ed..be91cf20 100644
--- a/go.mod
+++ b/go.mod
@@ -16,6 +16,7 @@ require (
github.com/pires/go-proxyproto v0.2.0
github.com/prometheus/client_golang v1.6.0
github.com/rs/cors v1.7.0
+ github.com/sebest/xff v0.0.0-20160910043805-6c115e0ffa35
github.com/sirupsen/logrus v1.7.0
github.com/stretchr/testify v1.6.1
github.com/tj/assert v0.0.3 // indirect
diff --git a/internal/config/config.go b/internal/config/config.go
index 94c22328..3e03f7d6 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -16,6 +16,7 @@ import (
// Config stores all the config options relevant to GitLab Pages.
type Config struct {
General General
+ RateLimit RateLimit
ArtifactsServer ArtifactsServer
Authentication Auth
GitLab GitLab
@@ -62,6 +63,12 @@ type General struct {
CustomHeaders []string
}
+// RateLimit config struct
+type RateLimit struct {
+ SourceIPLimitPerSecond float64
+ SourceIPBurst int
+}
+
// ArtifactsServer groups settings related to configuring Artifacts
// server
type ArtifactsServer struct {
@@ -184,6 +191,10 @@ func loadConfig() (*Config, error) {
CustomHeaders: header.Split(),
ShowVersion: *showVersion,
},
+ RateLimit: RateLimit{
+ SourceIPLimitPerSecond: *rateLimitSourceIP,
+ SourceIPBurst: *rateLimitSourceIPBurst,
+ },
GitLab: GitLab{
ClientHTTPTimeout: *gitlabClientHTTPTimeout,
JWTTokenExpiration: *gitlabClientJWTExpiry,
diff --git a/internal/config/flags.go b/internal/config/flags.go
index 52b7be18..c61447c7 100644
--- a/internal/config/flags.go
+++ b/internal/config/flags.go
@@ -15,6 +15,8 @@ var (
_ = flag.Bool("use-http2", true, "DEPRECATED: HTTP2 is always enabled for pages")
pagesRoot = flag.String("pages-root", "shared/pages", "The directory where pages are stored")
pagesDomain = flag.String("pages-domain", "gitlab-example.com", "The domain to serve static pages")
+ rateLimitSourceIP = flag.Float64("rate-limit-source-ip", 0.0, "Rate limit per source IP in number of requests per second, 0 means is disabled")
+ rateLimitSourceIPBurst = flag.Int("rate-limit-source-ip-burst", 100, "Rate limit per source IP maximum burst allowed per second")
artifactsServer = flag.String("artifacts-server", "", "API URL to proxy artifact requests to, e.g.: 'https://gitlab.com/api/v4'")
artifactsServerTimeout = flag.Int("artifacts-server-timeout", 10, "Timeout (in seconds) for a proxied request to the artifacts server")
pagesStatus = flag.String("pages-status", "", "The url path for a status page, e.g., /@status")
diff --git a/internal/httperrors/httperrors.go b/internal/httperrors/httperrors.go
index ed56ee10..8e61d590 100644
--- a/internal/httperrors/httperrors.go
+++ b/internal/httperrors/httperrors.go
@@ -34,6 +34,14 @@ var (
<p>Make sure the address is correct and that the page hasn't moved.</p>
<p>Please contact your GitLab administrator if you think this is a mistake.</p>`,
}
+
+ content429 = content{
+ http.StatusTooManyRequests,
+ "Too many requests (429)",
+ "429",
+ "Too many requests.",
+ `<p>The resource that you are attempting to access is being rate limited.</p>`,
+ }
content500 = content{
http.StatusInternalServerError,
"Something went wrong (500)",
@@ -176,6 +184,11 @@ func Serve404(w http.ResponseWriter) {
serveErrorPage(w, content404)
}
+// Serve429 returns a 429 error response / HTML page to the http.ResponseWriter
+func Serve429(w http.ResponseWriter) {
+ serveErrorPage(w, content429)
+}
+
// Serve500 returns a 500 error response / HTML page to the http.ResponseWriter
func Serve500(w http.ResponseWriter) {
serveErrorPage(w, content500)
diff --git a/internal/logging/logging.go b/internal/logging/logging.go
index 4ffbeb4b..27edf865 100644
--- a/internal/logging/logging.go
+++ b/internal/logging/logging.go
@@ -85,6 +85,7 @@ func BasicAccessLogger(handler http.Handler, format string, extraFields log.Extr
return log.AccessLogger(handler,
log.WithExtraFields(enrichExtraFields(extraFields)),
log.WithAccessLogger(accessLogger),
+ // TODO: log IP for HTTP requests https://gitlab.com/gitlab-org/gitlab-pages/-/issues/640
log.WithXFFAllowed(func(sip string) bool { return false }),
), nil
}
diff --git a/internal/ratelimiter/middleware.go b/internal/ratelimiter/middleware.go
new file mode 100644
index 00000000..d0996021
--- /dev/null
+++ b/internal/ratelimiter/middleware.go
@@ -0,0 +1,89 @@
+package ratelimiter
+
+import (
+ "net"
+ "net/http"
+ "os"
+
+ "github.com/sebest/xff"
+ "github.com/sirupsen/logrus"
+ "gitlab.com/gitlab-org/labkit/correlation"
+
+ "gitlab.com/gitlab-org/gitlab-pages/internal/httperrors"
+)
+
+const (
+ headerGitLabRealIP = "GitLab-Real-IP"
+ headerXForwardedFor = "X-Forwarded-For"
+ headerXForwardedProto = "X-Forwarded-Proto"
+)
+
+// SourceIPLimiter middleware ensures that the originating
+// rate limit. See -rate-limiter
+func (rl *RateLimiter) SourceIPLimiter(logger logrus.FieldLogger, handler http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ host, sourceIP := rl.getReqDetails(r)
+
+ if !rl.SourceIPAllowed(sourceIP) {
+ rl.logSourceIP(logger, r, host, sourceIP)
+
+ // Only drop requests once FF_ENABLE_RATE_LIMITER is enabled
+ // https://gitlab.com/gitlab-org/gitlab-pages/-/issues/629
+ if rateLimiterEnabled() {
+ rl.sourceIPBlockedCount.WithLabelValues("true").Inc()
+ httperrors.Serve429(w)
+ return
+ }
+
+ rl.sourceIPBlockedCount.WithLabelValues("false").Inc()
+ }
+
+ handler.ServeHTTP(w, r)
+ })
+}
+
+func (rl *RateLimiter) getReqDetails(r *http.Request) (string, string) {
+ host, _, err := net.SplitHostPort(r.Host)
+ if err != nil {
+ host = r.Host
+ }
+
+ // choose between r.RemoteAddr and X-Forwarded-For. Only uses XFF when proxied
+ remoteAddr := xff.GetRemoteAddrIfAllowed(r, func(sip string) bool {
+ // We enable github.com/gorilla/handlers.ProxyHeaders which sets r.RemoteAddr
+ // with the value of X-Forwarded-For when --listen-proxy is set
+ return rl.proxied
+ })
+
+ ip, _, err := net.SplitHostPort(remoteAddr)
+ if err != nil {
+ ip = remoteAddr
+ }
+
+ return host, ip
+}
+
+func (rl *RateLimiter) logSourceIP(logger logrus.FieldLogger, r *http.Request, host, sourceIP string) {
+ logger.WithFields(logrus.Fields{
+ "handler": "source_ip_rate_limiter",
+ "correlation_id": correlation.ExtractFromContext(r.Context()),
+ "req_scheme": r.URL.Scheme,
+ "req_host": r.Host,
+ "req_path": r.URL.Path,
+ "pages_domain": host,
+ "remote_addr": r.RemoteAddr,
+ "source_ip": sourceIP,
+ "proxied": rl.proxied,
+ "x_forwarded_proto": r.Header.Get(headerXForwardedProto),
+ "x_forwarded_for": r.Header.Get(headerXForwardedFor),
+ "gitlab_real_ip": r.Header.Get(headerGitLabRealIP),
+ "rate_limiter_enabled": rateLimiterEnabled(),
+ "rate_limiter_limit_per_second": rl.sourceIPLimitPerSecond,
+ "rate_limiter_burst_size": rl.sourceIPBurstSize,
+ }). // TODO: change to Debug with https://gitlab.com/gitlab-org/gitlab-pages/-/issues/629
+ Info("source IP hit rate limit")
+}
+
+func rateLimiterEnabled() bool {
+ return os.Getenv("FF_ENABLE_RATE_LIMITER") == "true"
+}
diff --git a/internal/ratelimiter/middleware_test.go b/internal/ratelimiter/middleware_test.go
new file mode 100644
index 00000000..eaa199c4
--- /dev/null
+++ b/internal/ratelimiter/middleware_test.go
@@ -0,0 +1,180 @@
+package ratelimiter
+
+import (
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ ghandlers "github.com/gorilla/handlers"
+ testlog "github.com/sirupsen/logrus/hooks/test"
+ "github.com/stretchr/testify/require"
+
+ "gitlab.com/gitlab-org/gitlab-pages/internal/testhelpers"
+)
+
+const (
+ xForwardedFor = "172.16.123.1"
+ remoteAddr = "192.168.1.1"
+)
+
+var next = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNoContent)
+})
+
+func TestSourceIPLimiterWithDifferentLimits(t *testing.T) {
+ log, hook := testlog.NewNullLogger()
+ testhelpers.EnableRateLimiter(t)
+
+ for tn, tc := range sharedTestCases {
+ t.Run(tn, func(t *testing.T) {
+ rl := New(
+ WithNow(mockNow),
+ WithSourceIPLimitPerSecond(tc.sourceIPLimit),
+ WithSourceIPBurstSize(tc.sourceIPBurstSize),
+ WithProxied(tc.proxied),
+ )
+
+ for i := 0; i < tc.reqNum; i++ {
+ ww := httptest.NewRecorder()
+ rr := httptest.NewRequest(http.MethodGet, "https://domain.gitlab.io", nil)
+ rr.Header.Set(headerXForwardedFor, xForwardedFor)
+ rr.RemoteAddr = remoteAddr
+
+ handler := rl.SourceIPLimiter(log, next)
+ if tc.proxied {
+ handler = ghandlers.ProxyHeaders(handler)
+ }
+
+ handler.ServeHTTP(ww, rr)
+ res := ww.Result()
+
+ if i < tc.sourceIPBurstSize {
+ require.Equal(t, http.StatusNoContent, res.StatusCode, "req: %d failed", i)
+ } else {
+ // requests should fail after reaching tc.perDomainBurstPerSecond because mockNow
+ // always returns the same time
+ require.Equal(t, http.StatusTooManyRequests, res.StatusCode, "req: %d failed", i)
+ b, err := io.ReadAll(res.Body)
+ require.NoError(t, err)
+
+ require.Contains(t, string(b), "Too many requests.")
+ res.Body.Close()
+
+ assertSourceIPLog(t, tc.proxied, xForwardedFor, remoteAddr, hook)
+ }
+ }
+ })
+ }
+}
+
+func TestSourceIPLimiterDenyRequestsAfterBurst(t *testing.T) {
+ log, hook := testlog.NewNullLogger()
+
+ tcs := map[string]struct {
+ enabled bool
+ proxied bool
+ host string
+ expectedStatus int
+ }{
+ "disabled_rate_limit_http": {
+ enabled: false,
+ host: "http://gitlab.com",
+ expectedStatus: http.StatusNoContent,
+ },
+ "disabled_rate_limit_https": {
+ enabled: false,
+ host: "https://gitlab.com",
+ expectedStatus: http.StatusNoContent,
+ },
+ "enabled_rate_limit_http_blocks": {
+ enabled: true,
+ host: "http://gitlab.com",
+ expectedStatus: http.StatusTooManyRequests,
+ },
+ "enabled_rate_limit_https_blocks": {
+ enabled: true,
+ host: "https://gitlab.com",
+ expectedStatus: http.StatusTooManyRequests,
+ },
+ "disabled_rate_limit_http_proxied": {
+ enabled: false,
+ proxied: true,
+ host: "http://gitlab.com",
+ expectedStatus: http.StatusNoContent,
+ },
+ "disabled_rate_limit_https_proxied": {
+ enabled: false,
+ proxied: true,
+ host: "https://gitlab.com",
+ expectedStatus: http.StatusNoContent,
+ },
+ "enabled_rate_limit_http_blocks_proxied": {
+ enabled: true,
+ proxied: true,
+ host: "http://gitlab.com",
+ expectedStatus: http.StatusTooManyRequests,
+ },
+ "enabled_rate_limit_https_blocks_proxied": {
+ enabled: true,
+ proxied: true,
+ host: "https://gitlab.com",
+ expectedStatus: http.StatusTooManyRequests,
+ },
+ }
+
+ for tn, tc := range tcs {
+ t.Run(tn, func(t *testing.T) {
+ rl := New(
+ WithNow(mockNow),
+ WithSourceIPLimitPerSecond(1),
+ WithSourceIPBurstSize(1),
+ WithProxied(tc.proxied),
+ )
+
+ for i := 0; i < 5; i++ {
+ ww := httptest.NewRecorder()
+ rr := httptest.NewRequest(http.MethodGet, tc.host, nil)
+ if tc.enabled {
+ testhelpers.EnableRateLimiter(t)
+ }
+
+ rr.Header.Set(headerXForwardedFor, xForwardedFor)
+ rr.RemoteAddr = remoteAddr
+
+ // middleware is evaluated in reverse order
+ handler := rl.SourceIPLimiter(log, next)
+ if tc.proxied {
+ handler = ghandlers.ProxyHeaders(handler)
+ }
+
+ handler.ServeHTTP(ww, rr)
+ res := ww.Result()
+
+ if i == 0 {
+ require.Equal(t, http.StatusNoContent, res.StatusCode)
+ continue
+ }
+
+ // burst is 1 and limit is 1 per second, all subsequent requests should fail
+ require.Equal(t, tc.expectedStatus, res.StatusCode)
+ assertSourceIPLog(t, tc.proxied, xForwardedFor, remoteAddr, hook)
+ }
+ })
+ }
+}
+
+func assertSourceIPLog(t *testing.T, proxied bool, xForwardedFor, remoteAddr string, hook *testlog.Hook) {
+ t.Helper()
+
+ require.NotNil(t, hook.LastEntry())
+
+ // source_ip that was rate limited
+ if proxied {
+ require.Equal(t, xForwardedFor, hook.LastEntry().Data["source_ip"])
+ } else {
+ require.Equal(t, remoteAddr, hook.LastEntry().Data["source_ip"])
+ }
+
+ hook.Reset()
+}
diff --git a/internal/ratelimiter/ratelimiter.go b/internal/ratelimiter/ratelimiter.go
index e1cf076d..36c72cde 100644
--- a/internal/ratelimiter/ratelimiter.go
+++ b/internal/ratelimiter/ratelimiter.go
@@ -7,6 +7,7 @@ import (
"golang.org/x/time/rate"
"gitlab.com/gitlab-org/gitlab-pages/internal/lru"
+ "gitlab.com/gitlab-org/gitlab-pages/metrics"
)
const (
@@ -34,8 +35,10 @@ type Option func(*RateLimiter)
// It also holds a now function that can be mocked in unit tests.
type RateLimiter struct {
now func() time.Time
+ proxied bool
sourceIPLimitPerSecond float64
sourceIPBurstSize int
+ sourceIPBlockedCount *prometheus.GaugeVec
sourceIPCache *lru.Cache
// TODO: add domainCache https://gitlab.com/gitlab-org/gitlab-pages/-/issues/630
}
@@ -46,13 +49,13 @@ func New(opts ...Option) *RateLimiter {
now: time.Now,
sourceIPLimitPerSecond: DefaultSourceIPLimitPerSecond,
sourceIPBurstSize: DefaultSourceIPBurstSize,
+ sourceIPBlockedCount: metrics.RateLimitSourceIPBlockedCount,
sourceIPCache: lru.New(
"source_ip",
defaultSourceIPItems,
defaultSourceIPExpirationInterval,
- // TODO: @jaime to add proper metrics in subsequent MR
- prometheus.NewGaugeVec(prometheus.GaugeOpts{}, []string{"op"}),
- prometheus.NewCounterVec(prometheus.CounterOpts{}, []string{"op", "cache"}),
+ metrics.RateLimitSourceIPCachedEntries,
+ metrics.RateLimitSourceIPCacheRequests,
),
}
@@ -84,6 +87,12 @@ func WithSourceIPBurstSize(burst int) Option {
}
}
+// WithProxied sets the proxy flag to true. Used by the SourceIPLimiter middleware.
+func WithProxied(proxied bool) Option {
+ return func(rl *RateLimiter) {
+ rl.proxied = proxied
+ }
+}
func (rl *RateLimiter) getSourceIPLimiter(sourceIP string) *rate.Limiter {
limiterI, _ := rl.sourceIPCache.FindOrFetch(sourceIP, sourceIP, func() (interface{}, error) {
return rate.NewLimiter(rate.Limit(rl.sourceIPLimitPerSecond), rl.sourceIPBurstSize), nil
diff --git a/internal/ratelimiter/ratelimiter_test.go b/internal/ratelimiter/ratelimiter_test.go
index cdf12fe6..03e764f2 100644
--- a/internal/ratelimiter/ratelimiter_test.go
+++ b/internal/ratelimiter/ratelimiter_test.go
@@ -17,38 +17,62 @@ func mockNow() time.Time {
return validTime
}
+var sharedTestCases = map[string]struct {
+ sourceIPLimit float64
+ sourceIPBurstSize int
+ reqNum int
+ proxied bool
+}{
+ "one_request_per_second": {
+ sourceIPLimit: 1,
+ sourceIPBurstSize: 1,
+ reqNum: 2,
+ },
+ "one_request_per_second_but_big_bucket": {
+ sourceIPLimit: 1,
+ sourceIPBurstSize: 10,
+ reqNum: 11,
+ },
+ "three_req_per_second_bucket_size_one": {
+ sourceIPLimit: 3,
+ sourceIPBurstSize: 1, // max burst 1 means 1 at a time
+ reqNum: 3,
+ },
+ "10_requests_per_second": {
+ sourceIPLimit: 10,
+ sourceIPBurstSize: 10,
+ reqNum: 11,
+ },
+ "one_request_per_second_proxied": {
+ proxied: true,
+ sourceIPLimit: 1,
+ sourceIPBurstSize: 1,
+ reqNum: 2,
+ },
+ "one_request_per_second_but_big_bucket_proxied": {
+ proxied: true,
+ sourceIPLimit: 1,
+ sourceIPBurstSize: 10,
+ reqNum: 11,
+ },
+ "three_req_per_second_bucket_size_one_proxied": {
+ proxied: true,
+ sourceIPLimit: 3,
+ sourceIPBurstSize: 1, // max burst 1 means 1 at a time
+ reqNum: 3,
+ },
+ "10_requests_per_second_proxied": {
+ proxied: true,
+ sourceIPLimit: 10,
+ sourceIPBurstSize: 10,
+ reqNum: 11,
+ },
+}
+
func TestSourceIPAllowed(t *testing.T) {
t.Parallel()
- tcs := map[string]struct {
- now string
- sourceIPLimit float64
- sourceIPBurstSize int
- reqNum int
- }{
- "one_request_per_second": {
- sourceIPLimit: 1,
- sourceIPBurstSize: 1,
- reqNum: 2,
- },
- "one_request_per_second_but_big_bucket": {
- sourceIPLimit: 1,
- sourceIPBurstSize: 10,
- reqNum: 11,
- },
- "three_req_per_second_bucket_size_one": {
- sourceIPLimit: 3,
- sourceIPBurstSize: 1, // max burst 1 means 1 at a time
- reqNum: 3,
- },
- "10_requests_per_second": {
- sourceIPLimit: 10,
- sourceIPBurstSize: 10,
- reqNum: 11,
- },
- }
-
- for tn, tc := range tcs {
+ for tn, tc := range sharedTestCases {
t.Run(tn, func(t *testing.T) {
rl := New(
WithNow(mockNow),
diff --git a/internal/testhelpers/testhelpers.go b/internal/testhelpers/testhelpers.go
index 3ec97a79..04dfc3d2 100644
--- a/internal/testhelpers/testhelpers.go
+++ b/internal/testhelpers/testhelpers.go
@@ -77,3 +77,17 @@ func Getwd(t *testing.T) string {
return wd
}
+
+// EnableRateLimiter environment variable
+func EnableRateLimiter(t *testing.T) {
+ t.Helper()
+
+ orig := os.Getenv("FF_ENABLE_RATE_LIMITER")
+
+ err := os.Setenv("FF_ENABLE_RATE_LIMITER", "true")
+ require.NoError(t, err)
+
+ t.Cleanup(func() {
+ os.Setenv("FF_ENABLE_RATE_LIMITER", orig)
+ })
+}
diff --git a/metrics/metrics.go b/metrics/metrics.go
index b4ac9415..23962dc4 100644
--- a/metrics/metrics.go
+++ b/metrics/metrics.go
@@ -184,6 +184,34 @@ var (
Help: "The number of backlogged connections waiting on concurrency limit.",
},
)
+
+ // RateLimitSourceIPCacheRequests is the number of cache hits/misses
+ RateLimitSourceIPCacheRequests = prometheus.NewCounterVec(
+ prometheus.CounterOpts{
+ Name: "gitlab_pages_rate_limit_source_ip_cache_requests",
+ Help: "The number of source_ip cache hits/misses in the rate limiter",
+ },
+ []string{"op", "cache"},
+ )
+
+ // RateLimitSourceIPCachedEntries is the number of entries in the cache
+ RateLimitSourceIPCachedEntries = prometheus.NewGaugeVec(
+ prometheus.GaugeOpts{
+ Name: "gitlab_pages_rate_limit_source_ip_cached_entries",
+ Help: "The number of entries in the cache",
+ },
+ []string{"op"},
+ )
+
+ // RateLimitSourceIPBlockedCount is the number of source IPs that have been blocked by the
+ // source IP rate limiter
+ RateLimitSourceIPBlockedCount = prometheus.NewGaugeVec(
+ prometheus.GaugeOpts{
+ Name: "gitlab_pages_rate_limit_source_ip_blocked_count",
+ Help: "The number of source IP addresses that have been blocked by the rate limiter",
+ },
+ []string{"enforced"},
+ )
)
// MustRegister collectors with the Prometheus client
@@ -211,5 +239,8 @@ func MustRegister() {
LimitListenerMaxConns,
LimitListenerConcurrentConns,
LimitListenerWaitingConns,
+ RateLimitSourceIPCacheRequests,
+ RateLimitSourceIPCachedEntries,
+ RateLimitSourceIPBlockedCount,
)
}
diff --git a/test/acceptance/ratelimiter_test.go b/test/acceptance/ratelimiter_test.go
new file mode 100644
index 00000000..d67b3d04
--- /dev/null
+++ b/test/acceptance/ratelimiter_test.go
@@ -0,0 +1,39 @@
+package acceptance_test
+
+import (
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+
+ "gitlab.com/gitlab-org/gitlab-pages/internal/testhelpers"
+)
+
+func TestRateLimitMiddleware(t *testing.T) {
+ testhelpers.EnableRateLimiter(t)
+
+ RunPagesProcess(t,
+ withListeners([]ListenSpec{httpListener}),
+ // 10 = 1 req every 100ms
+ withExtraArgument("rate-limit-source-ip", "1.0"),
+ withExtraArgument("rate-limit-source-ip-burst", "1"),
+ )
+
+ for i := 0; i < 20; i++ {
+ rsp1, err := GetPageFromListener(t, httpListener, "group.gitlab-example.com", "project/")
+ require.NoError(t, err)
+ rsp1.Body.Close()
+
+ // every other request should fail
+ //if i%2 != 0 {
+ // require.Equal(t, http.StatusTooManyRequests, rsp1.StatusCode, "group.gitlab-example.com request: %d failed", i)
+ // // wait for another token to become available
+ // time.Sleep(100 * time.Millisecond)
+ // continue
+ //}
+
+ require.Equal(t, http.StatusOK, rsp1.StatusCode, "group.gitlab-example.com request: %d failed", i)
+ time.Sleep(time.Millisecond)
+ }
+}