detached 7748089

Merge pull request #63 from bnema/feature/registry-ip-allowlist

feat(registry): add IP/CIDR allowlist for registry access control
commit
7748089b82fb6a2ed8e883523df8537ef55f3686
author
bnema · · 2026-02-07 07:05 UTC
committer
GitHub · · 2026-02-07 07:05 UTC
merge of
657aee7 + 08d5671 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v2.13.0
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
1818 gordon_domain = "" # Required: Gordon domain (registry + API)
1919 data_dir = "~/.gordon" # Data directory (varies by install type)
2020 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)
2122
2223 # =============================================================================
2324 # AUTHENTICATION (required - Gordon won't start without credentials configured)
@@ -135,6 +136,7 @@ preserve = true # Keep volumes when containers are
135136 | `server.gordon_domain` | `""` | **Required** - Gordon domain |
136137 | `server.data_dir` | `~/.gordon` | Data directory |
137138 | `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) |
138140 | `auth.enabled` | `true` | Enable authentication |
139141 | `auth.secrets_backend` | `"unsafe"` | Secrets storage |
140142 | `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 chunk20 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)
3131 | `data_dir` | string | `~/.gordon` | Directory for registry data, logs, and env files |
3232 | `max_proxy_body_size` | string | `"512MB"` | Maximum request body size for proxied requests |
3333 | `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) |
3435
3536 ## Port Configuration
3637
@@ -192,6 +193,37 @@ max_blob_chunk_size = "1GB" # Allow larger layers
192193 - Docker/Podman may send a full layer in one request, so set this high enough for your base images
193194 - Default (`95MB`) is compatible with Cloudflare and most CDN proxies; increase if pushing very large layers directly
194195
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+
195227 ## Examples
196228
197229 ### 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 Configuration36 ## 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 images193 - 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 directly194 - 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 ## Examples227 ## Examples
196 228
197 ### Development Configuration229 ### 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 (
7070 // Config holds the application configuration.
7171 type Config struct {
7272 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"`
8687 } `mapstructure:"server"`
8788
8889 Logging struct {
@@ -1171,6 +1172,27 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
11711172 middleware.SecurityHeaders,
11721173 }
11731174
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+
11741196 // Create rate limiters using the factory
11751197 var rateLimitMiddleware func(http.Handler) http.Handler
11761198 if cfg.API.RateLimit.Enabled {
@@ -1206,12 +1228,16 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
12061228 if svc.authHandler != nil {
12071229 // Auth endpoints are NOT protected by auth - they're where clients authenticate
12081230 // but still need rate limiting to prevent brute force attacks
1209- authWithMiddleware := middleware.Chain(
1231+ authMiddlewares := []func(http.Handler) http.Handler{
12101232 middleware.PanicRecovery(log),
12111233 middleware.RequestLogger(log, trustedNets),
12121234 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)
12151241 registryMux.Handle("/auth/", authWithMiddleware)
12161242 }
12171243 registryMux.Handle("/v2/", registryWithMiddleware)
@@ -1745,6 +1771,7 @@ func loadConfig(v *viper.Viper, configPath string) error {
17451771 v.SetDefault("volumes.preserve", true)
17461772 v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged)
17471773 v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit
1774+ v.SetDefault("server.registry_allowed_ips", []string{})
17481775 v.SetDefault("deploy.readiness_delay", "5s")
17491776
17501777 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_domain76+ 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 limit84+ 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 factory1196 // Create rate limiters using the factory
1175 var rateLimitMiddleware func(http.Handler) http.Handler1197 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 authenticate1229 // Auth endpoints are NOT protected by auth - they're where clients authenticate
1208 // but still need rate limiting to prevent brute force attacks1230 // 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 limit1773 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)

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