detached 7ec476b

feat(config): add unified API rate limit configuration

commit
7ec476ba7e653bb16f8f03fcc6c37bb0239aa68f
author
bnema · · 2026-01-17 21:05 UTC
committer
bnema · · 2026-01-17 21:06 UTC
parent
d4ff823
signature
unsigned
2 files · +40 -2
M docs/config/reference.md +13 -0
@@ -26,6 +26,15 @@ password_hash = "" # Path in secrets backend to bcrypt
2626 token_secret = "" # Path in secrets backend to JWT signing key (REQUIRED for token type)
2727 token_expiry = "720h" # Token expiry duration (720h = 30 days)
2828
29+# =============================================================================
30+# API (applies to both Registry and Admin endpoints)
31+# =============================================================================
32+[api.rate_limit]
33+enabled = true # Enable rate limiting (default: true)
34+global_rps = 500 # Max requests/second globally
35+per_ip_rps = 50 # Max requests/second per client IP
36+burst = 100 # Burst size for rate limiters
37+
2938 # =============================================================================
3039 # LOGGING
3140 # =============================================================================
@@ -119,6 +128,10 @@ preserve = true # Keep volumes when containers are
119128 | `auth.type` | `"password"` | Auth type |
120129 | `auth.secrets_backend` | `"unsafe"` | Secrets storage |
121130 | `auth.token_expiry` | `"720h"` | 30 days |
131+| `api.rate_limit.enabled` | `true` | Enable rate limiting |
132+| `api.rate_limit.global_rps` | `500` | Global requests/second |
133+| `api.rate_limit.per_ip_rps` | `50` | Per-IP requests/second |
134+| `api.rate_limit.burst` | `100` | Burst size |
122135 | `logging.level` | `"info"` | Log level |
123136 | `logging.format` | `"console"` | Log format |
124137 | `logging.file.enabled` | `false` | File logging disabled |
@@ -26,6 +26,15 @@ password_hash = "" # Path in secrets backend to bcrypt
26 token_secret = "" # Path in secrets backend to JWT signing key (REQUIRED for token type)26 token_secret = "" # Path in secrets backend to JWT signing key (REQUIRED for token type)
27 token_expiry = "720h" # Token expiry duration (720h = 30 days)27 token_expiry = "720h" # Token expiry duration (720h = 30 days)
28 28
29+# =============================================================================
30+# API (applies to both Registry and Admin endpoints)
31+# =============================================================================
32+[api.rate_limit]
33+enabled = true # Enable rate limiting (default: true)
34+global_rps = 500 # Max requests/second globally
35+per_ip_rps = 50 # Max requests/second per client IP
36+burst = 100 # Burst size for rate limiters
37+
29 # =============================================================================38 # =============================================================================
30 # LOGGING39 # LOGGING
31 # =============================================================================40 # =============================================================================
@@ -119,6 +128,10 @@ preserve = true # Keep volumes when containers are
119 | `auth.type` | `"password"` | Auth type |128 | `auth.type` | `"password"` | Auth type |
120 | `auth.secrets_backend` | `"unsafe"` | Secrets storage |129 | `auth.secrets_backend` | `"unsafe"` | Secrets storage |
121 | `auth.token_expiry` | `"720h"` | 30 days |130 | `auth.token_expiry` | `"720h"` | 30 days |
131+| `api.rate_limit.enabled` | `true` | Enable rate limiting |
132+| `api.rate_limit.global_rps` | `500` | Global requests/second |
133+| `api.rate_limit.per_ip_rps` | `50` | Per-IP requests/second |
134+| `api.rate_limit.burst` | `100` | Burst size |
122 | `logging.level` | `"info"` | Log level |135 | `logging.level` | `"info"` | Log level |
123 | `logging.format` | `"console"` | Log format |136 | `logging.format` | `"console"` | Log format |
124 | `logging.file.enabled` | `false` | File logging disabled |137 | `logging.file.enabled` | `false` | File logging disabled |
M internal/app/run.go +27 -2
@@ -100,6 +100,15 @@ type Config struct {
100100 TokenSecret string `mapstructure:"token_secret"` // path in secrets backend
101101 TokenExpiry string `mapstructure:"token_expiry"` // e.g., "720h", "30d"
102102 } `mapstructure:"auth"`
103+
104+ API struct {
105+ RateLimit struct {
106+ Enabled bool `mapstructure:"enabled"`
107+ GlobalRPS float64 `mapstructure:"global_rps"`
108+ PerIPRPS float64 `mapstructure:"per_ip_rps"`
109+ Burst int `mapstructure:"burst"`
110+ } `mapstructure:"rate_limit"`
111+ } `mapstructure:"api"`
103112 }
104113
105114 // services holds all the services used by the application.
@@ -863,6 +872,15 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
863872 middleware.RequestLogger(log),
864873 }
865874
875+ // Add rate limiting middleware
876+ rateLimitConfig := registry.RateLimitConfig{
877+ Enabled: cfg.API.RateLimit.Enabled,
878+ GlobalRPS: cfg.API.RateLimit.GlobalRPS,
879+ PerIPRPS: cfg.API.RateLimit.PerIPRPS,
880+ Burst: cfg.API.RateLimit.Burst,
881+ }
882+ registryMiddlewares = append(registryMiddlewares, registry.RateLimitMiddleware(rateLimitConfig))
883+
866884 if cfg.Auth.Enabled && svc.authSvc != nil {
867885 internalAuth := middleware.InternalRegistryAuth{
868886 Username: svc.internalRegUser,
@@ -895,8 +913,11 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
895913 }
896914 // Add admin auth middleware if auth is enabled
897915 if svc.authSvc != nil {
898- // Rate limiter for admin API: 10 req/s with burst of 20
899- adminLimiter := rate.NewLimiter(rate.Limit(10), 20)
916+ // Rate limiter for admin API uses per-IP rate from config
917+ var adminLimiter *rate.Limiter
918+ if cfg.API.RateLimit.Enabled {
919+ adminLimiter = rate.NewLimiter(rate.Limit(cfg.API.RateLimit.PerIPRPS), cfg.API.RateLimit.Burst)
920+ }
900921 adminMiddlewares = append(adminMiddlewares, admin.AuthMiddleware(svc.authSvc, adminLimiter, log))
901922 }
902923 adminWithMiddleware := middleware.Chain(adminMiddlewares...)(svc.adminHandler)
@@ -1230,6 +1251,10 @@ func loadConfig(v *viper.Viper, configPath string) error {
12301251 v.SetDefault("auth.type", "password")
12311252 v.SetDefault("auth.secrets_backend", "unsafe")
12321253 v.SetDefault("auth.token_expiry", "720h")
1254+ v.SetDefault("api.rate_limit.enabled", true)
1255+ v.SetDefault("api.rate_limit.global_rps", 500)
1256+ v.SetDefault("api.rate_limit.per_ip_rps", 50)
1257+ v.SetDefault("api.rate_limit.burst", 100)
12331258 v.SetDefault("auto_route.enabled", false)
12341259 v.SetDefault("network_isolation.enabled", false)
12351260 v.SetDefault("network_isolation.network_prefix", "gordon")
@@ -100,6 +100,15 @@ type Config struct {
100 TokenSecret string `mapstructure:"token_secret"` // path in secrets backend100 TokenSecret string `mapstructure:"token_secret"` // path in secrets backend
101 TokenExpiry string `mapstructure:"token_expiry"` // e.g., "720h", "30d"101 TokenExpiry string `mapstructure:"token_expiry"` // e.g., "720h", "30d"
102 } `mapstructure:"auth"`102 } `mapstructure:"auth"`
103+
104+ API struct {
105+ RateLimit struct {
106+ Enabled bool `mapstructure:"enabled"`
107+ GlobalRPS float64 `mapstructure:"global_rps"`
108+ PerIPRPS float64 `mapstructure:"per_ip_rps"`
109+ Burst int `mapstructure:"burst"`
110+ } `mapstructure:"rate_limit"`
111+ } `mapstructure:"api"`
103 }112 }
104 113
105 // services holds all the services used by the application.114 // services holds all the services used by the application.
@@ -863,6 +872,15 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
863 middleware.RequestLogger(log),872 middleware.RequestLogger(log),
864 }873 }
865 874
875+ // Add rate limiting middleware
876+ rateLimitConfig := registry.RateLimitConfig{
877+ Enabled: cfg.API.RateLimit.Enabled,
878+ GlobalRPS: cfg.API.RateLimit.GlobalRPS,
879+ PerIPRPS: cfg.API.RateLimit.PerIPRPS,
880+ Burst: cfg.API.RateLimit.Burst,
881+ }
882+ registryMiddlewares = append(registryMiddlewares, registry.RateLimitMiddleware(rateLimitConfig))
883+
866 if cfg.Auth.Enabled && svc.authSvc != nil {884 if cfg.Auth.Enabled && svc.authSvc != nil {
867 internalAuth := middleware.InternalRegistryAuth{885 internalAuth := middleware.InternalRegistryAuth{
868 Username: svc.internalRegUser,886 Username: svc.internalRegUser,
@@ -895,8 +913,11 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
895 }913 }
896 // Add admin auth middleware if auth is enabled914 // Add admin auth middleware if auth is enabled
897 if svc.authSvc != nil {915 if svc.authSvc != nil {
898- // Rate limiter for admin API: 10 req/s with burst of 20916+ // Rate limiter for admin API uses per-IP rate from config
899- adminLimiter := rate.NewLimiter(rate.Limit(10), 20)917+ var adminLimiter *rate.Limiter
918+ if cfg.API.RateLimit.Enabled {
919+ adminLimiter = rate.NewLimiter(rate.Limit(cfg.API.RateLimit.PerIPRPS), cfg.API.RateLimit.Burst)
920+ }
900 adminMiddlewares = append(adminMiddlewares, admin.AuthMiddleware(svc.authSvc, adminLimiter, log))921 adminMiddlewares = append(adminMiddlewares, admin.AuthMiddleware(svc.authSvc, adminLimiter, log))
901 }922 }
902 adminWithMiddleware := middleware.Chain(adminMiddlewares...)(svc.adminHandler)923 adminWithMiddleware := middleware.Chain(adminMiddlewares...)(svc.adminHandler)
@@ -1230,6 +1251,10 @@ func loadConfig(v *viper.Viper, configPath string) error {
1230 v.SetDefault("auth.type", "password")1251 v.SetDefault("auth.type", "password")
1231 v.SetDefault("auth.secrets_backend", "unsafe")1252 v.SetDefault("auth.secrets_backend", "unsafe")
1232 v.SetDefault("auth.token_expiry", "720h")1253 v.SetDefault("auth.token_expiry", "720h")
1254+ v.SetDefault("api.rate_limit.enabled", true)
1255+ v.SetDefault("api.rate_limit.global_rps", 500)
1256+ v.SetDefault("api.rate_limit.per_ip_rps", 50)
1257+ v.SetDefault("api.rate_limit.burst", 100)
1233 v.SetDefault("auto_route.enabled", false)1258 v.SetDefault("auto_route.enabled", false)
1234 v.SetDefault("network_isolation.enabled", false)1259 v.SetDefault("network_isolation.enabled", false)
1235 v.SetDefault("network_isolation.network_prefix", "gordon")1260 v.SetDefault("network_isolation.network_prefix", "gordon")

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close