Merge pull request #63 from bnema/feature/registry-ip-allowlist
feat(registry): add IP/CIDR allowlist for registry access control
5 files · +239 -16
M docs/config/reference.md +2 -0
| @@ -18,6 +18,7 @@ tls_key_file = "" # PEM key path (auto-generated if e | |||
| 18 | 18 | gordon_domain = "" # Required: Gordon domain (registry + API) | |
| 19 | 19 | data_dir = "~/.gordon" # Data directory (varies by install type) | |
| 20 | 20 | max_blob_chunk_size = "512MB" # Max size per registry blob upload chunk | |
| 21 | + | registry_allowed_ips = [] # IPs or CIDR ranges allowed to access the registry (empty = allow all) | |
| 21 | 22 | ||
| 22 | 23 | # ============================================================================= | |
| 23 | 24 | # AUTHENTICATION (required - Gordon won't start without credentials configured) | |
| @@ -135,6 +136,7 @@ preserve = true # Keep volumes when containers are | |||
| 135 | 136 | | `server.gordon_domain` | `""` | **Required** - Gordon domain | | |
| 136 | 137 | | `server.data_dir` | `~/.gordon` | Data directory | | |
| 137 | 138 | | `server.max_blob_chunk_size` | `"512MB"` | Max size per registry blob upload chunk | | |
| 139 | + | | `server.registry_allowed_ips` | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) | | |
| 138 | 140 | | `auth.enabled` | `true` | Enable authentication | | |
| 139 | 141 | | `auth.secrets_backend` | `"unsafe"` | Secrets storage | | |
| 140 | 142 | | `auth.token_expiry` | `"720h"` | 30 days | | |
| @@ -18,6 +18,7 @@ tls_key_file = "" # PEM key path (auto-generated if e | |||||
| 18 | gordon_domain = "" # Required: Gordon domain (registry + API) | 18 | gordon_domain = "" # Required: Gordon domain (registry + API) | ||
| 19 | data_dir = "~/.gordon" # Data directory (varies by install type) | 19 | data_dir = "~/.gordon" # Data directory (varies by install type) | ||
| 20 | max_blob_chunk_size = "512MB" # Max size per registry blob upload chunk | 20 | max_blob_chunk_size = "512MB" # Max size per registry blob upload chunk | ||
| 21 | + | registry_allowed_ips = [] # IPs or CIDR ranges allowed to access the registry (empty = allow all) | |||
| 21 | 22 | ||||
| 22 | # ============================================================================= | 23 | # ============================================================================= | ||
| 23 | # AUTHENTICATION (required - Gordon won't start without credentials configured) | 24 | # AUTHENTICATION (required - Gordon won't start without credentials configured) | ||
| @@ -135,6 +136,7 @@ preserve = true # Keep volumes when containers are | |||||
| 135 | | `server.gordon_domain` | `""` | **Required** - Gordon domain | | 136 | | `server.gordon_domain` | `""` | **Required** - Gordon domain | | ||
| 136 | | `server.data_dir` | `~/.gordon` | Data directory | | 137 | | `server.data_dir` | `~/.gordon` | Data directory | | ||
| 137 | | `server.max_blob_chunk_size` | `"512MB"` | Max size per registry blob upload chunk | | 138 | | `server.max_blob_chunk_size` | `"512MB"` | Max size per registry blob upload chunk | | ||
| 139 | + | | `server.registry_allowed_ips` | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) | | |||
| 138 | | `auth.enabled` | `true` | Enable authentication | | 140 | | `auth.enabled` | `true` | Enable authentication | | ||
| 139 | | `auth.secrets_backend` | `"unsafe"` | Secrets storage | | 141 | | `auth.secrets_backend` | `"unsafe"` | Secrets storage | | ||
| 140 | | `auth.token_expiry` | `"720h"` | 30 days | | 142 | | `auth.token_expiry` | `"720h"` | 30 days | | ||
M docs/config/server.md +32 -0
| @@ -31,6 +31,7 @@ gordon_domain = "gordon.mydomain.com" # Gordon domain (required) | |||
| 31 | 31 | | `data_dir` | string | `~/.gordon` | Directory for registry data, logs, and env files | | |
| 32 | 32 | | `max_proxy_body_size` | string | `"512MB"` | Maximum request body size for proxied requests | | |
| 33 | 33 | | `max_blob_chunk_size` | string | `"95MB"` | Maximum request body size for a single registry blob upload chunk | | |
| 34 | + | | `registry_allowed_ips` | []string | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) | | |
| 34 | 35 | ||
| 35 | 36 | ## Port Configuration | |
| 36 | 37 | ||
| @@ -192,6 +193,37 @@ max_blob_chunk_size = "1GB" # Allow larger layers | |||
| 192 | 193 | - Docker/Podman may send a full layer in one request, so set this high enough for your base images | |
| 193 | 194 | - Default (`95MB`) is compatible with Cloudflare and most CDN proxies; increase if pushing very large layers directly | |
| 194 | 195 | ||
| 196 | + | ## Registry IP Allowlist | |
| 197 | + | ||
| 198 | + | The `registry_allowed_ips` setting restricts registry access to specific IPs or IP ranges. Accepts both CIDR notation (`100.64.0.0/10`) and individual IPs (`203.0.113.50`). When set, only requests from listed addresses (plus localhost) can reach registry and auth endpoints. An empty list allows all traffic (default). | |
| 199 | + | ||
| 200 | + | ```toml | |
| 201 | + | [server] | |
| 202 | + | registry_allowed_ips = [ | |
| 203 | + | "100.64.0.0/10", # Tailscale CGNAT range | |
| 204 | + | "203.0.113.50", # single IP (treated as /32) | |
| 205 | + | ] | |
| 206 | + | ``` | |
| 207 | + | ||
| 208 | + | **Behavior:** | |
| 209 | + | - Single IPs are automatically converted to `/32` (IPv4) or `/128` (IPv6) | |
| 210 | + | - Denied requests receive `403 Forbidden` | |
| 211 | + | - Localhost (`127.0.0.0/8`, `::1`) is always allowed so Gordon can pull from its own registry | |
| 212 | + | - Respects `trusted_proxies` for accurate client IP extraction behind reverse proxies | |
| 213 | + | - Applied before rate limiting and authentication | |
| 214 | + | - Changes require a Gordon server restart; not picked up by config hot-reload | |
| 215 | + | ||
| 216 | + | **Use cases:** | |
| 217 | + | - Restrict registry to Tailscale network: `["100.64.0.0/10"]` | |
| 218 | + | - Restrict to private subnets: `["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]` | |
| 219 | + | - Allow a specific CI server: `["100.64.0.0/10", "203.0.113.50"]` | |
| 220 | + | ||
| 221 | + | Via environment variable: | |
| 222 | + | ||
| 223 | + | ```bash | |
| 224 | + | GORDON_SERVER_REGISTRY_ALLOWED_IPS=100.64.0.0/10 | |
| 225 | + | ``` | |
| 226 | + | ||
| 195 | 227 | ## Examples | |
| 196 | 228 | ||
| 197 | 229 | ### Development Configuration | |
| @@ -31,6 +31,7 @@ gordon_domain = "gordon.mydomain.com" # Gordon domain (required) | |||||
| 31 | | `data_dir` | string | `~/.gordon` | Directory for registry data, logs, and env files | | 31 | | `data_dir` | string | `~/.gordon` | Directory for registry data, logs, and env files | | ||
| 32 | | `max_proxy_body_size` | string | `"512MB"` | Maximum request body size for proxied requests | | 32 | | `max_proxy_body_size` | string | `"512MB"` | Maximum request body size for proxied requests | | ||
| 33 | | `max_blob_chunk_size` | string | `"95MB"` | Maximum request body size for a single registry blob upload chunk | | 33 | | `max_blob_chunk_size` | string | `"95MB"` | Maximum request body size for a single registry blob upload chunk | | ||
| 34 | + | | `registry_allowed_ips` | []string | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) | | |||
| 34 | 35 | ||||
| 35 | ## Port Configuration | 36 | ## Port Configuration | ||
| 36 | 37 | ||||
| @@ -192,6 +193,37 @@ max_blob_chunk_size = "1GB" # Allow larger layers | |||||
| 192 | - Docker/Podman may send a full layer in one request, so set this high enough for your base images | 193 | - Docker/Podman may send a full layer in one request, so set this high enough for your base images | ||
| 193 | - Default (`95MB`) is compatible with Cloudflare and most CDN proxies; increase if pushing very large layers directly | 194 | - Default (`95MB`) is compatible with Cloudflare and most CDN proxies; increase if pushing very large layers directly | ||
| 194 | 195 | ||||
| 196 | + | ## Registry IP Allowlist | |||
| 197 | + | ||||
| 198 | + | The `registry_allowed_ips` setting restricts registry access to specific IPs or IP ranges. Accepts both CIDR notation (`100.64.0.0/10`) and individual IPs (`203.0.113.50`). When set, only requests from listed addresses (plus localhost) can reach registry and auth endpoints. An empty list allows all traffic (default). | |||
| 199 | + | ||||
| 200 | + | ```toml | |||
| 201 | + | [server] | |||
| 202 | + | registry_allowed_ips = [ | |||
| 203 | + | "100.64.0.0/10", # Tailscale CGNAT range | |||
| 204 | + | "203.0.113.50", # single IP (treated as /32) | |||
| 205 | + | ] | |||
| 206 | + | ``` | |||
| 207 | + | ||||
| 208 | + | **Behavior:** | |||
| 209 | + | - Single IPs are automatically converted to `/32` (IPv4) or `/128` (IPv6) | |||
| 210 | + | - Denied requests receive `403 Forbidden` | |||
| 211 | + | - Localhost (`127.0.0.0/8`, `::1`) is always allowed so Gordon can pull from its own registry | |||
| 212 | + | - Respects `trusted_proxies` for accurate client IP extraction behind reverse proxies | |||
| 213 | + | - Applied before rate limiting and authentication | |||
| 214 | + | - Changes require a Gordon server restart; not picked up by config hot-reload | |||
| 215 | + | ||||
| 216 | + | **Use cases:** | |||
| 217 | + | - Restrict registry to Tailscale network: `["100.64.0.0/10"]` | |||
| 218 | + | - Restrict to private subnets: `["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]` | |||
| 219 | + | - Allow a specific CI server: `["100.64.0.0/10", "203.0.113.50"]` | |||
| 220 | + | ||||
| 221 | + | Via environment variable: | |||
| 222 | + | ||||
| 223 | + | ```bash | |||
| 224 | + | GORDON_SERVER_REGISTRY_ALLOWED_IPS=100.64.0.0/10 | |||
| 225 | + | ``` | |||
| 226 | + | ||||
| 195 | ## Examples | 227 | ## Examples | ||
| 196 | 228 | ||||
| 197 | ### Development Configuration | 229 | ### Development Configuration | ||
A internal/adapters/in/http/middleware/cidr.go +51 -0
| @@ -0,0 +1,51 @@ | |||
| 1 | + | package middleware | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "net" | |
| 6 | + | "net/http" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/zerowrap" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/gordon/internal/adapters/dto" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // localhostNets contains IPv4 and IPv6 loopback ranges that are always allowed. | |
| 14 | + | var localhostNets = ParseTrustedProxies([]string{"127.0.0.0/8", "::1"}) | |
| 15 | + | ||
| 16 | + | // RegistryCIDRAllowlist returns middleware that restricts access to the given CIDR ranges. | |
| 17 | + | // Localhost is always allowed so Gordon can pull from its own registry. | |
| 18 | + | // An empty allowedNets slice is a no-op (all traffic passes through). | |
| 19 | + | func RegistryCIDRAllowlist(allowedNets, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler { | |
| 20 | + | return func(next http.Handler) http.Handler { | |
| 21 | + | if len(allowedNets) == 0 { | |
| 22 | + | return next | |
| 23 | + | } | |
| 24 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 25 | + | clientIP := GetClientIP(r, trustedNets) | |
| 26 | + | ||
| 27 | + | // Always allow localhost — Gordon must reach its own registry. | |
| 28 | + | if IsTrustedProxy(clientIP, localhostNets) { | |
| 29 | + | next.ServeHTTP(w, r) | |
| 30 | + | return | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | if IsTrustedProxy(clientIP, allowedNets) { | |
| 34 | + | next.ServeHTTP(w, r) | |
| 35 | + | return | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | log.Warn(). | |
| 39 | + | Str(zerowrap.FieldLayer, "adapter"). | |
| 40 | + | Str(zerowrap.FieldAdapter, "http"). | |
| 41 | + | Str(zerowrap.FieldMethod, r.Method). | |
| 42 | + | Str(zerowrap.FieldPath, r.URL.Path). | |
| 43 | + | Str(zerowrap.FieldClientIP, clientIP). | |
| 44 | + | Msg("registry access denied by CIDR allowlist") | |
| 45 | + | ||
| 46 | + | w.Header().Set("Content-Type", "application/json") | |
| 47 | + | w.WriteHeader(http.StatusForbidden) | |
| 48 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Forbidden"}) | |
| 49 | + | }) | |
| 50 | + | } | |
| 51 | + | } | |
| @@ -0,0 +1,51 @@ | |||||
| 1 | + | package middleware | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/json" | |||
| 5 | + | "net" | |||
| 6 | + | "net/http" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/zerowrap" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/gordon/internal/adapters/dto" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // localhostNets contains IPv4 and IPv6 loopback ranges that are always allowed. | |||
| 14 | + | var localhostNets = ParseTrustedProxies([]string{"127.0.0.0/8", "::1"}) | |||
| 15 | + | ||||
| 16 | + | // RegistryCIDRAllowlist returns middleware that restricts access to the given CIDR ranges. | |||
| 17 | + | // Localhost is always allowed so Gordon can pull from its own registry. | |||
| 18 | + | // An empty allowedNets slice is a no-op (all traffic passes through). | |||
| 19 | + | func RegistryCIDRAllowlist(allowedNets, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler { | |||
| 20 | + | return func(next http.Handler) http.Handler { | |||
| 21 | + | if len(allowedNets) == 0 { | |||
| 22 | + | return next | |||
| 23 | + | } | |||
| 24 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 25 | + | clientIP := GetClientIP(r, trustedNets) | |||
| 26 | + | ||||
| 27 | + | // Always allow localhost — Gordon must reach its own registry. | |||
| 28 | + | if IsTrustedProxy(clientIP, localhostNets) { | |||
| 29 | + | next.ServeHTTP(w, r) | |||
| 30 | + | return | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | if IsTrustedProxy(clientIP, allowedNets) { | |||
| 34 | + | next.ServeHTTP(w, r) | |||
| 35 | + | return | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | log.Warn(). | |||
| 39 | + | Str(zerowrap.FieldLayer, "adapter"). | |||
| 40 | + | Str(zerowrap.FieldAdapter, "http"). | |||
| 41 | + | Str(zerowrap.FieldMethod, r.Method). | |||
| 42 | + | Str(zerowrap.FieldPath, r.URL.Path). | |||
| 43 | + | Str(zerowrap.FieldClientIP, clientIP). | |||
| 44 | + | Msg("registry access denied by CIDR allowlist") | |||
| 45 | + | ||||
| 46 | + | w.Header().Set("Content-Type", "application/json") | |||
| 47 | + | w.WriteHeader(http.StatusForbidden) | |||
| 48 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Forbidden"}) | |||
| 49 | + | }) | |||
| 50 | + | } | |||
| 51 | + | } | |||
A internal/adapters/in/http/middleware/cidr_test.go +111 -0
| @@ -0,0 +1,111 @@ | |||
| 1 | + | package middleware | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "net" | |
| 6 | + | "net/http" | |
| 7 | + | "net/http/httptest" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/gordon/internal/adapters/dto" | |
| 11 | + | "github.com/stretchr/testify/assert" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestRegistryCIDRAllowlist(t *testing.T) { | |
| 16 | + | log := testLogger() | |
| 17 | + | ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 18 | + | w.WriteHeader(http.StatusOK) | |
| 19 | + | }) | |
| 20 | + | ||
| 21 | + | tests := []struct { | |
| 22 | + | name string | |
| 23 | + | allowedNets []*net.IPNet | |
| 24 | + | trustedNets []*net.IPNet | |
| 25 | + | remoteAddr string | |
| 26 | + | xff string | |
| 27 | + | wantStatus int | |
| 28 | + | }{ | |
| 29 | + | { | |
| 30 | + | name: "no CIDRs configured passes through", | |
| 31 | + | allowedNets: nil, | |
| 32 | + | remoteAddr: "203.0.113.50:1234", | |
| 33 | + | wantStatus: http.StatusOK, | |
| 34 | + | }, | |
| 35 | + | { | |
| 36 | + | name: "empty CIDRs passes through", | |
| 37 | + | allowedNets: []*net.IPNet{}, | |
| 38 | + | remoteAddr: "203.0.113.50:1234", | |
| 39 | + | wantStatus: http.StatusOK, | |
| 40 | + | }, | |
| 41 | + | { | |
| 42 | + | name: "allowed IP returns 200", | |
| 43 | + | allowedNets: ParseTrustedProxies([]string{"100.64.0.0/10"}), | |
| 44 | + | remoteAddr: "100.100.1.1:1234", | |
| 45 | + | wantStatus: http.StatusOK, | |
| 46 | + | }, | |
| 47 | + | { | |
| 48 | + | name: "denied IP returns 403", | |
| 49 | + | allowedNets: ParseTrustedProxies([]string{"100.64.0.0/10"}), | |
| 50 | + | remoteAddr: "203.0.113.50:1234", | |
| 51 | + | wantStatus: http.StatusForbidden, | |
| 52 | + | }, | |
| 53 | + | { | |
| 54 | + | name: "localhost always allowed even when not in allowlist", | |
| 55 | + | allowedNets: ParseTrustedProxies([]string{"100.64.0.0/10"}), | |
| 56 | + | remoteAddr: "127.0.0.1:1234", | |
| 57 | + | wantStatus: http.StatusOK, | |
| 58 | + | }, | |
| 59 | + | { | |
| 60 | + | name: "127.x.x.x always allowed", | |
| 61 | + | allowedNets: ParseTrustedProxies([]string{"100.64.0.0/10"}), | |
| 62 | + | remoteAddr: "127.0.0.2:1234", | |
| 63 | + | wantStatus: http.StatusOK, | |
| 64 | + | }, | |
| 65 | + | { | |
| 66 | + | name: "IPv6 localhost always allowed", | |
| 67 | + | allowedNets: ParseTrustedProxies([]string{"100.64.0.0/10"}), | |
| 68 | + | remoteAddr: "[::1]:1234", | |
| 69 | + | wantStatus: http.StatusOK, | |
| 70 | + | }, | |
| 71 | + | { | |
| 72 | + | name: "trusted proxy forwards allowed client IP", | |
| 73 | + | allowedNets: ParseTrustedProxies([]string{"100.64.0.0/10"}), | |
| 74 | + | trustedNets: ParseTrustedProxies([]string{"10.0.0.1"}), | |
| 75 | + | remoteAddr: "10.0.0.1:1234", | |
| 76 | + | xff: "100.100.1.1", | |
| 77 | + | wantStatus: http.StatusOK, | |
| 78 | + | }, | |
| 79 | + | { | |
| 80 | + | name: "trusted proxy forwards denied client IP", | |
| 81 | + | allowedNets: ParseTrustedProxies([]string{"100.64.0.0/10"}), | |
| 82 | + | trustedNets: ParseTrustedProxies([]string{"10.0.0.1"}), | |
| 83 | + | remoteAddr: "10.0.0.1:1234", | |
| 84 | + | xff: "203.0.113.50", | |
| 85 | + | wantStatus: http.StatusForbidden, | |
| 86 | + | }, | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | for _, tt := range tests { | |
| 90 | + | t.Run(tt.name, func(t *testing.T) { | |
| 91 | + | handler := RegistryCIDRAllowlist(tt.allowedNets, tt.trustedNets, log)(ok) | |
| 92 | + | ||
| 93 | + | req := httptest.NewRequest(http.MethodGet, "/v2/", nil) | |
| 94 | + | req.RemoteAddr = tt.remoteAddr | |
| 95 | + | if tt.xff != "" { | |
| 96 | + | req.Header.Set("X-Forwarded-For", tt.xff) | |
| 97 | + | } | |
| 98 | + | rr := httptest.NewRecorder() | |
| 99 | + | ||
| 100 | + | handler.ServeHTTP(rr, req) | |
| 101 | + | ||
| 102 | + | assert.Equal(t, tt.wantStatus, rr.Code) | |
| 103 | + | ||
| 104 | + | if tt.wantStatus == http.StatusForbidden { | |
| 105 | + | var errResp dto.ErrorResponse | |
| 106 | + | require.NoError(t, json.NewDecoder(rr.Body).Decode(&errResp)) | |
| 107 | + | assert.Equal(t, "Forbidden", errResp.Error) | |
| 108 | + | } | |
| 109 | + | }) | |
| 110 | + | } | |
| 111 | + | } | |
| @@ -0,0 +1,111 @@ | |||||
| 1 | + | package middleware | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/json" | |||
| 5 | + | "net" | |||
| 6 | + | "net/http" | |||
| 7 | + | "net/http/httptest" | |||
| 8 | + | "testing" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/gordon/internal/adapters/dto" | |||
| 11 | + | "github.com/stretchr/testify/assert" | |||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func TestRegistryCIDRAllowlist(t *testing.T) { | |||
| 16 | + | log := testLogger() | |||
| 17 | + | ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 18 | + | w.WriteHeader(http.StatusOK) | |||
| 19 | + | }) | |||
| 20 | + | ||||
| 21 | + | tests := []struct { | |||
| 22 | + | name string | |||
| 23 | + | allowedNets []*net.IPNet | |||
| 24 | + | trustedNets []*net.IPNet | |||
| 25 | + | remoteAddr string | |||
| 26 | + | xff string | |||
| 27 | + | wantStatus int | |||
| 28 | + | }{ | |||
| 29 | + | { | |||
| 30 | + | name: "no CIDRs configured passes through", | |||
| 31 | + | allowedNets: nil, | |||
| 32 | + | remoteAddr: "203.0.113.50:1234", | |||
| 33 | + | wantStatus: http.StatusOK, | |||
| 34 | + | }, | |||
| 35 | + | { | |||
| 36 | + | name: "empty CIDRs passes through", | |||
| 37 | + | allowedNets: []*net.IPNet{}, | |||
| 38 | + | remoteAddr: "203.0.113.50:1234", | |||
| 39 | + | wantStatus: http.StatusOK, | |||
| 40 | + | }, | |||
| 41 | + | { | |||
| 42 | + | name: "allowed IP returns 200", | |||
| 43 | + | allowedNets: ParseTrustedProxies([]string{"100.64.0.0/10"}), | |||
| 44 | + | remoteAddr: "100.100.1.1:1234", | |||
| 45 | + | wantStatus: http.StatusOK, | |||
| 46 | + | }, | |||
| 47 | + | { | |||
| 48 | + | name: "denied IP returns 403", | |||
| 49 | + | allowedNets: ParseTrustedProxies([]string{"100.64.0.0/10"}), | |||
| 50 | + | remoteAddr: "203.0.113.50:1234", | |||
| 51 | + | wantStatus: http.StatusForbidden, | |||
| 52 | + | }, | |||
| 53 | + | { | |||
| 54 | + | name: "localhost always allowed even when not in allowlist", | |||
| 55 | + | allowedNets: ParseTrustedProxies([]string{"100.64.0.0/10"}), | |||
| 56 | + | remoteAddr: "127.0.0.1:1234", | |||
| 57 | + | wantStatus: http.StatusOK, | |||
| 58 | + | }, | |||
| 59 | + | { | |||
| 60 | + | name: "127.x.x.x always allowed", | |||
| 61 | + | allowedNets: ParseTrustedProxies([]string{"100.64.0.0/10"}), | |||
| 62 | + | remoteAddr: "127.0.0.2:1234", | |||
| 63 | + | wantStatus: http.StatusOK, | |||
| 64 | + | }, | |||
| 65 | + | { | |||
| 66 | + | name: "IPv6 localhost always allowed", | |||
| 67 | + | allowedNets: ParseTrustedProxies([]string{"100.64.0.0/10"}), | |||
| 68 | + | remoteAddr: "[::1]:1234", | |||
| 69 | + | wantStatus: http.StatusOK, | |||
| 70 | + | }, | |||
| 71 | + | { | |||
| 72 | + | name: "trusted proxy forwards allowed client IP", | |||
| 73 | + | allowedNets: ParseTrustedProxies([]string{"100.64.0.0/10"}), | |||
| 74 | + | trustedNets: ParseTrustedProxies([]string{"10.0.0.1"}), | |||
| 75 | + | remoteAddr: "10.0.0.1:1234", | |||
| 76 | + | xff: "100.100.1.1", | |||
| 77 | + | wantStatus: http.StatusOK, | |||
| 78 | + | }, | |||
| 79 | + | { | |||
| 80 | + | name: "trusted proxy forwards denied client IP", | |||
| 81 | + | allowedNets: ParseTrustedProxies([]string{"100.64.0.0/10"}), | |||
| 82 | + | trustedNets: ParseTrustedProxies([]string{"10.0.0.1"}), | |||
| 83 | + | remoteAddr: "10.0.0.1:1234", | |||
| 84 | + | xff: "203.0.113.50", | |||
| 85 | + | wantStatus: http.StatusForbidden, | |||
| 86 | + | }, | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | for _, tt := range tests { | |||
| 90 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 91 | + | handler := RegistryCIDRAllowlist(tt.allowedNets, tt.trustedNets, log)(ok) | |||
| 92 | + | ||||
| 93 | + | req := httptest.NewRequest(http.MethodGet, "/v2/", nil) | |||
| 94 | + | req.RemoteAddr = tt.remoteAddr | |||
| 95 | + | if tt.xff != "" { | |||
| 96 | + | req.Header.Set("X-Forwarded-For", tt.xff) | |||
| 97 | + | } | |||
| 98 | + | rr := httptest.NewRecorder() | |||
| 99 | + | ||||
| 100 | + | handler.ServeHTTP(rr, req) | |||
| 101 | + | ||||
| 102 | + | assert.Equal(t, tt.wantStatus, rr.Code) | |||
| 103 | + | ||||
| 104 | + | if tt.wantStatus == http.StatusForbidden { | |||
| 105 | + | var errResp dto.ErrorResponse | |||
| 106 | + | require.NoError(t, json.NewDecoder(rr.Body).Decode(&errResp)) | |||
| 107 | + | assert.Equal(t, "Forbidden", errResp.Error) | |||
| 108 | + | } | |||
| 109 | + | }) | |||
| 110 | + | } | |||
| 111 | + | } | |||
M internal/app/run.go +43 -16
| @@ -70,19 +70,20 @@ import ( | |||
| 70 | 70 | // Config holds the application configuration. | |
| 71 | 71 | type Config struct { | |
| 72 | 72 | Server struct { | |
| 73 | - | Port int `mapstructure:"port"` | |
| 74 | - | RegistryPort int `mapstructure:"registry_port"` | |
| 75 | - | GordonDomain string `mapstructure:"gordon_domain"` | |
| 76 | - | RegistryDomain string `mapstructure:"registry_domain"` // Deprecated: use gordon_domain | |
| 77 | - | TLSEnabled bool `mapstructure:"tls_enabled"` | |
| 78 | - | TLSPort int `mapstructure:"tls_port"` | |
| 79 | - | TLSCertFile string `mapstructure:"tls_cert_file"` | |
| 80 | - | TLSKeyFile string `mapstructure:"tls_key_file"` | |
| 81 | - | DataDir string `mapstructure:"data_dir"` | |
| 82 | - | MaxProxyBodySize string `mapstructure:"max_proxy_body_size"` // e.g., "512MB", "1GB" | |
| 83 | - | MaxBlobChunkSize string `mapstructure:"max_blob_chunk_size"` // e.g., "512MB", "1GB" | |
| 84 | - | MaxProxyResponseSize string `mapstructure:"max_proxy_response_size"` // e.g., "1GB", "0" for no limit | |
| 85 | - | MaxConcurrentConns int `mapstructure:"max_concurrent_connections"` | |
| 73 | + | Port int `mapstructure:"port"` | |
| 74 | + | RegistryPort int `mapstructure:"registry_port"` | |
| 75 | + | GordonDomain string `mapstructure:"gordon_domain"` | |
| 76 | + | RegistryDomain string `mapstructure:"registry_domain"` // Deprecated: use gordon_domain | |
| 77 | + | TLSEnabled bool `mapstructure:"tls_enabled"` | |
| 78 | + | TLSPort int `mapstructure:"tls_port"` | |
| 79 | + | TLSCertFile string `mapstructure:"tls_cert_file"` | |
| 80 | + | TLSKeyFile string `mapstructure:"tls_key_file"` | |
| 81 | + | DataDir string `mapstructure:"data_dir"` | |
| 82 | + | MaxProxyBodySize string `mapstructure:"max_proxy_body_size"` // e.g., "512MB", "1GB" | |
| 83 | + | MaxBlobChunkSize string `mapstructure:"max_blob_chunk_size"` // e.g., "512MB", "1GB" | |
| 84 | + | MaxProxyResponseSize string `mapstructure:"max_proxy_response_size"` // e.g., "1GB", "0" for no limit | |
| 85 | + | MaxConcurrentConns int `mapstructure:"max_concurrent_connections"` | |
| 86 | + | RegistryAllowedIPs []string `mapstructure:"registry_allowed_ips"` | |
| 86 | 87 | } `mapstructure:"server"` | |
| 87 | 88 | ||
| 88 | 89 | Logging struct { | |
| @@ -1171,6 +1172,27 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||
| 1171 | 1172 | middleware.SecurityHeaders, | |
| 1172 | 1173 | } | |
| 1173 | 1174 | ||
| 1175 | + | // CIDR allowlist for registry access (e.g., restrict to Tailscale IPs). | |
| 1176 | + | // Inserted before rate limiting so denied IPs never consume limiter tokens. | |
| 1177 | + | var cidrAllowlistMiddleware func(http.Handler) http.Handler | |
| 1178 | + | if len(cfg.Server.RegistryAllowedIPs) > 0 { | |
| 1179 | + | allowedNets := middleware.ParseTrustedProxies(cfg.Server.RegistryAllowedIPs) | |
| 1180 | + | if len(allowedNets) != len(cfg.Server.RegistryAllowedIPs) { | |
| 1181 | + | for _, entry := range cfg.Server.RegistryAllowedIPs { | |
| 1182 | + | if nets := middleware.ParseTrustedProxies([]string{entry}); len(nets) == 0 { | |
| 1183 | + | log.Warn().Str("entry", entry).Msg("ignoring invalid registry_allowed_ips entry") | |
| 1184 | + | } | |
| 1185 | + | } | |
| 1186 | + | } | |
| 1187 | + | if len(allowedNets) == 0 { | |
| 1188 | + | log.Error(). | |
| 1189 | + | Strs("registry_allowed_ips", cfg.Server.RegistryAllowedIPs). | |
| 1190 | + | Msg("registry_allowed_ips is set but no valid entries were parsed; registry will allow all traffic") | |
| 1191 | + | } | |
| 1192 | + | cidrAllowlistMiddleware = middleware.RegistryCIDRAllowlist(allowedNets, trustedNets, log) | |
| 1193 | + | registryMiddlewares = append(registryMiddlewares, cidrAllowlistMiddleware) | |
| 1194 | + | } | |
| 1195 | + | ||
| 1174 | 1196 | // Create rate limiters using the factory | |
| 1175 | 1197 | var rateLimitMiddleware func(http.Handler) http.Handler | |
| 1176 | 1198 | if cfg.API.RateLimit.Enabled { | |
| @@ -1206,12 +1228,16 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||
| 1206 | 1228 | if svc.authHandler != nil { | |
| 1207 | 1229 | // Auth endpoints are NOT protected by auth - they're where clients authenticate | |
| 1208 | 1230 | // but still need rate limiting to prevent brute force attacks | |
| 1209 | - | authWithMiddleware := middleware.Chain( | |
| 1231 | + | authMiddlewares := []func(http.Handler) http.Handler{ | |
| 1210 | 1232 | middleware.PanicRecovery(log), | |
| 1211 | 1233 | middleware.RequestLogger(log, trustedNets), | |
| 1212 | 1234 | middleware.SecurityHeaders, | |
| 1213 | - | rateLimitMiddleware, | |
| 1214 | - | )(svc.authHandler) | |
| 1235 | + | } | |
| 1236 | + | if cidrAllowlistMiddleware != nil { | |
| 1237 | + | authMiddlewares = append(authMiddlewares, cidrAllowlistMiddleware) | |
| 1238 | + | } | |
| 1239 | + | authMiddlewares = append(authMiddlewares, rateLimitMiddleware) | |
| 1240 | + | authWithMiddleware := middleware.Chain(authMiddlewares...)(svc.authHandler) | |
| 1215 | 1241 | registryMux.Handle("/auth/", authWithMiddleware) | |
| 1216 | 1242 | } | |
| 1217 | 1243 | registryMux.Handle("/v2/", registryWithMiddleware) | |
| @@ -1745,6 +1771,7 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||
| 1745 | 1771 | v.SetDefault("volumes.preserve", true) | |
| 1746 | 1772 | v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged) | |
| 1747 | 1773 | v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit | |
| 1774 | + | v.SetDefault("server.registry_allowed_ips", []string{}) | |
| 1748 | 1775 | v.SetDefault("deploy.readiness_delay", "5s") | |
| 1749 | 1776 | ||
| 1750 | 1777 | ConfigureViper(v, configPath) | |
| @@ -70,19 +70,20 @@ import ( | |||||
| 70 | // Config holds the application configuration. | 70 | // Config holds the application configuration. | ||
| 71 | type Config struct { | 71 | type Config struct { | ||
| 72 | Server struct { | 72 | Server struct { | ||
| 73 | - | Port int `mapstructure:"port"` | 73 | + | Port int `mapstructure:"port"` |
| 74 | - | RegistryPort int `mapstructure:"registry_port"` | 74 | + | RegistryPort int `mapstructure:"registry_port"` |
| 75 | - | GordonDomain string `mapstructure:"gordon_domain"` | 75 | + | GordonDomain string `mapstructure:"gordon_domain"` |
| 76 | - | RegistryDomain string `mapstructure:"registry_domain"` // Deprecated: use gordon_domain | 76 | + | RegistryDomain string `mapstructure:"registry_domain"` // Deprecated: use gordon_domain |
| 77 | - | TLSEnabled bool `mapstructure:"tls_enabled"` | 77 | + | TLSEnabled bool `mapstructure:"tls_enabled"` |
| 78 | - | TLSPort int `mapstructure:"tls_port"` | 78 | + | TLSPort int `mapstructure:"tls_port"` |
| 79 | - | TLSCertFile string `mapstructure:"tls_cert_file"` | 79 | + | TLSCertFile string `mapstructure:"tls_cert_file"` |
| 80 | - | TLSKeyFile string `mapstructure:"tls_key_file"` | 80 | + | TLSKeyFile string `mapstructure:"tls_key_file"` |
| 81 | - | DataDir string `mapstructure:"data_dir"` | 81 | + | DataDir string `mapstructure:"data_dir"` |
| 82 | - | MaxProxyBodySize string `mapstructure:"max_proxy_body_size"` // e.g., "512MB", "1GB" | 82 | + | MaxProxyBodySize string `mapstructure:"max_proxy_body_size"` // e.g., "512MB", "1GB" |
| 83 | - | MaxBlobChunkSize string `mapstructure:"max_blob_chunk_size"` // e.g., "512MB", "1GB" | 83 | + | MaxBlobChunkSize string `mapstructure:"max_blob_chunk_size"` // e.g., "512MB", "1GB" |
| 84 | - | MaxProxyResponseSize string `mapstructure:"max_proxy_response_size"` // e.g., "1GB", "0" for no limit | 84 | + | MaxProxyResponseSize string `mapstructure:"max_proxy_response_size"` // e.g., "1GB", "0" for no limit |
| 85 | - | MaxConcurrentConns int `mapstructure:"max_concurrent_connections"` | 85 | + | MaxConcurrentConns int `mapstructure:"max_concurrent_connections"` |
| 86 | + | RegistryAllowedIPs []string `mapstructure:"registry_allowed_ips"` | |||
| 86 | } `mapstructure:"server"` | 87 | } `mapstructure:"server"` | ||
| 87 | 88 | ||||
| 88 | Logging struct { | 89 | Logging struct { | ||
| @@ -1171,6 +1172,27 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||||
| 1171 | middleware.SecurityHeaders, | 1172 | middleware.SecurityHeaders, | ||
| 1172 | } | 1173 | } | ||
| 1173 | 1174 | ||||
| 1175 | + | // CIDR allowlist for registry access (e.g., restrict to Tailscale IPs). | |||
| 1176 | + | // Inserted before rate limiting so denied IPs never consume limiter tokens. | |||
| 1177 | + | var cidrAllowlistMiddleware func(http.Handler) http.Handler | |||
| 1178 | + | if len(cfg.Server.RegistryAllowedIPs) > 0 { | |||
| 1179 | + | allowedNets := middleware.ParseTrustedProxies(cfg.Server.RegistryAllowedIPs) | |||
| 1180 | + | if len(allowedNets) != len(cfg.Server.RegistryAllowedIPs) { | |||
| 1181 | + | for _, entry := range cfg.Server.RegistryAllowedIPs { | |||
| 1182 | + | if nets := middleware.ParseTrustedProxies([]string{entry}); len(nets) == 0 { | |||
| 1183 | + | log.Warn().Str("entry", entry).Msg("ignoring invalid registry_allowed_ips entry") | |||
| 1184 | + | } | |||
| 1185 | + | } | |||
| 1186 | + | } | |||
| 1187 | + | if len(allowedNets) == 0 { | |||
| 1188 | + | log.Error(). | |||
| 1189 | + | Strs("registry_allowed_ips", cfg.Server.RegistryAllowedIPs). | |||
| 1190 | + | Msg("registry_allowed_ips is set but no valid entries were parsed; registry will allow all traffic") | |||
| 1191 | + | } | |||
| 1192 | + | cidrAllowlistMiddleware = middleware.RegistryCIDRAllowlist(allowedNets, trustedNets, log) | |||
| 1193 | + | registryMiddlewares = append(registryMiddlewares, cidrAllowlistMiddleware) | |||
| 1194 | + | } | |||
| 1195 | + | ||||
| 1174 | // Create rate limiters using the factory | 1196 | // Create rate limiters using the factory | ||
| 1175 | var rateLimitMiddleware func(http.Handler) http.Handler | 1197 | var rateLimitMiddleware func(http.Handler) http.Handler | ||
| 1176 | if cfg.API.RateLimit.Enabled { | 1198 | if cfg.API.RateLimit.Enabled { | ||
| @@ -1206,12 +1228,16 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||||
| 1206 | if svc.authHandler != nil { | 1228 | if svc.authHandler != nil { | ||
| 1207 | // Auth endpoints are NOT protected by auth - they're where clients authenticate | 1229 | // Auth endpoints are NOT protected by auth - they're where clients authenticate | ||
| 1208 | // but still need rate limiting to prevent brute force attacks | 1230 | // but still need rate limiting to prevent brute force attacks | ||
| 1209 | - | authWithMiddleware := middleware.Chain( | 1231 | + | authMiddlewares := []func(http.Handler) http.Handler{ |
| 1210 | middleware.PanicRecovery(log), | 1232 | middleware.PanicRecovery(log), | ||
| 1211 | middleware.RequestLogger(log, trustedNets), | 1233 | middleware.RequestLogger(log, trustedNets), | ||
| 1212 | middleware.SecurityHeaders, | 1234 | middleware.SecurityHeaders, | ||
| 1213 | - | rateLimitMiddleware, | 1235 | + | } |
| 1214 | - | )(svc.authHandler) | 1236 | + | if cidrAllowlistMiddleware != nil { |
| 1237 | + | authMiddlewares = append(authMiddlewares, cidrAllowlistMiddleware) | |||
| 1238 | + | } | |||
| 1239 | + | authMiddlewares = append(authMiddlewares, rateLimitMiddleware) | |||
| 1240 | + | authWithMiddleware := middleware.Chain(authMiddlewares...)(svc.authHandler) | |||
| 1215 | registryMux.Handle("/auth/", authWithMiddleware) | 1241 | registryMux.Handle("/auth/", authWithMiddleware) | ||
| 1216 | } | 1242 | } | ||
| 1217 | registryMux.Handle("/v2/", registryWithMiddleware) | 1243 | registryMux.Handle("/v2/", registryWithMiddleware) | ||
| @@ -1745,6 +1771,7 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||||
| 1745 | v.SetDefault("volumes.preserve", true) | 1771 | v.SetDefault("volumes.preserve", true) | ||
| 1746 | v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged) | 1772 | v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged) | ||
| 1747 | v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit | 1773 | v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit | ||
| 1774 | + | v.SetDefault("server.registry_allowed_ips", []string{}) | |||
| 1748 | v.SetDefault("deploy.readiness_delay", "5s") | 1775 | v.SetDefault("deploy.readiness_delay", "5s") | ||
| 1749 | 1776 | ||||
| 1750 | ConfigureViper(v, configPath) | 1777 | ConfigureViper(v, configPath) | ||