detached bcbd4d9

security: harden production deployment defaults

- Add container security defaults: CapDrop ALL, no-new-privileges,
  nofile ulimit (65536)
- Add proxy_allowed_ips config for origin IP validation (e.g. Cloudflare
  edge IPs) to prevent direct-to-origin attacks
- Add Cf-Connecting-IP header support in GetClientIP for accurate client
  IP resolution behind Cloudflare
- Default network_isolation.enabled to true for container-to-container
  isolation
- Add registry_listen_address config to bind registry to loopback only
- Add force_hsts config for HSTS behind TLS-terminating proxies
- Add explicit TLS 1.2 minimum version on TLS server
- Set FlushInterval: -1 on ReverseProxy for SSE/streaming support
- Update docs for all new config options and changed defaults
commit
bcbd4d90ecbc277b35e3a50b4807bdd7a0709eab
author
bnema · · 2026-03-27 22:11 UTC
parent
851aace
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
11 files · +212 -38
M docs/cli/attachments.md +1 -1
@@ -7,7 +7,7 @@ Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
77
88 ## Requirements
99
10-Attachments require `network_isolation.enabled = true` in your configuration. Without network isolation, containers use Docker's default bridge network which does not provide DNS resolution - your app won't be able to reach attachments by hostname (e.g., `postgres:5432`).
10+Attachments require `network_isolation.enabled = true` in your configuration (enabled by default). Without network isolation, containers use Docker's default bridge network which does not provide DNS resolution - your app won't be able to reach attachments by hostname (e.g., `postgres:5432`).
1111
1212 The CLI will warn you if you try to add an attachment without network isolation enabled.
1313
@@ -7,7 +7,7 @@ Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
7 7
8 ## Requirements8 ## Requirements
9 9
10-Attachments require `network_isolation.enabled = true` in your configuration. Without network isolation, containers use Docker's default bridge network which does not provide DNS resolution - your app won't be able to reach attachments by hostname (e.g., `postgres:5432`).10+Attachments require `network_isolation.enabled = true` in your configuration (enabled by default). Without network isolation, containers use Docker's default bridge network which does not provide DNS resolution - your app won't be able to reach attachments by hostname (e.g., `postgres:5432`).
11 11
12 The CLI will warn you if you try to add an attachment without network isolation enabled.12 The CLI will warn you if you try to add an attachment without network isolation enabled.
13 13
M docs/config/index.md +1 -1
@@ -197,7 +197,7 @@ keep_last = 3
197197 | `volumes.auto_create` | `true` |
198198 | `volumes.prefix` | `"gordon"` |
199199 | `volumes.preserve` | `true` |
200-| `network_isolation.enabled` | `false` |
200+| `network_isolation.enabled` | `true` |
201201 | `auto_route.enabled` | `false` |
202202 | `backups.enabled` | `false` |
203203 | `backups.schedule` | `"daily"` (`"hourly"`, `"daily"`, `"weekly"`, `"monthly"`) |
@@ -197,7 +197,7 @@ keep_last = 3
197 | `volumes.auto_create` | `true` |197 | `volumes.auto_create` | `true` |
198 | `volumes.prefix` | `"gordon"` |198 | `volumes.prefix` | `"gordon"` |
199 | `volumes.preserve` | `true` |199 | `volumes.preserve` | `true` |
200-| `network_isolation.enabled` | `false` |200+| `network_isolation.enabled` | `true` |
201 | `auto_route.enabled` | `false` |201 | `auto_route.enabled` | `false` |
202 | `backups.enabled` | `false` |202 | `backups.enabled` | `false` |
203 | `backups.schedule` | `"daily"` (`"hourly"`, `"daily"`, `"weekly"`, `"monthly"`) |203 | `backups.schedule` | `"daily"` (`"hourly"`, `"daily"`, `"weekly"`, `"monthly"`) |
M docs/config/network-isolation.md +1 -1
@@ -14,7 +14,7 @@ network_prefix = "gordon"
1414
1515 | Option | Type | Default | Description |
1616 |--------|------|---------|-------------|
17-| `enabled` | bool | `false` | Enable per-app network isolation |
17+| `enabled` | bool | `true` | Enable per-app network isolation |
1818 | `network_prefix` | string | `"gordon"` | Prefix for created networks |
1919
2020 ## How It Works
@@ -14,7 +14,7 @@ network_prefix = "gordon"
14 14
15 | Option | Type | Default | Description |15 | Option | Type | Default | Description |
16 |--------|------|---------|-------------|16 |--------|------|---------|-------------|
17-| `enabled` | bool | `false` | Enable per-app network isolation |17+| `enabled` | bool | `true` | Enable per-app network isolation |
18 | `network_prefix` | string | `"gordon"` | Prefix for created networks |18 | `network_prefix` | string | `"gordon"` | Prefix for created networks |
19 19
20 ## How It Works20 ## How It Works
M docs/config/reference.md +8 -2
@@ -19,6 +19,9 @@ gordon_domain = "" # Required: Gordon domain (registry
1919 data_dir = "~/.gordon" # Data directory (varies by install type)
2020 max_blob_chunk_size = "512MB" # Max size per registry blob upload chunk
2121 registry_allowed_ips = [] # IPs or CIDR ranges allowed to access the registry (empty = allow all)
22+proxy_allowed_ips = [] # IPs or CIDR ranges allowed to reach the proxy (empty = allow all, e.g. Cloudflare IPs)
23+registry_listen_address = "" # Bind address for registry (empty = all interfaces, "127.0.0.1" = loopback only)
24+force_hsts = false # Always send HSTS header (enable when behind TLS-terminating proxy)
2225
2326 # =============================================================================
2427 # AUTHENTICATION (required - Gordon won't start without credentials configured)
@@ -101,7 +104,7 @@ enabled = false # Create routes from image labels a
101104 # NETWORK ISOLATION
102105 # =============================================================================
103106 [network_isolation]
104-enabled = false # Enable per-app Docker networks
107+enabled = true # Enable per-app Docker networks
105108 network_prefix = "gordon" # Prefix for created networks
106109
107110 # =============================================================================
@@ -177,6 +180,9 @@ keep_last = 3 # Keep N newest tags per repository
177180 | `server.data_dir` | `~/.gordon` | Data directory |
178181 | `server.max_blob_chunk_size` | `"512MB"` | Max size per registry blob upload chunk |
179182 | `server.registry_allowed_ips` | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) |
183+| `server.proxy_allowed_ips` | `[]` | IPs or CIDR ranges allowed to reach the proxy (empty = allow all) |
184+| `server.registry_listen_address` | `""` | Bind address for registry (empty = all interfaces) |
185+| `server.force_hsts` | `false` | Always send HSTS header (for TLS-terminating proxy setups) |
180186 | `auth.enabled` | `true` | Enable authentication; when `false`, run local-only mode (loopback-only `/v2/*`, `/admin/*` disabled) |
181187 | `auth.secrets_backend` | `"unsafe"` | Secrets storage |
182188 | `auth.token_expiry` | `"720h"` | 30 days |
@@ -211,7 +217,7 @@ keep_last = 3 # Keep N newest tags per repository
211217 | `deploy.drain_timeout` | `"30s"` | Max wait for in-flight request drain before old stop |
212218 | `deploy.drain_delay` | `"2s"` | Delay before stopping previous container after cache invalidation |
213219 | `auto_route.enabled` | `false` | Auto-route disabled |
214-| `network_isolation.enabled` | `false` | Isolation disabled |
220+| `network_isolation.enabled` | `true` | Network isolation enabled |
215221 | `network_isolation.network_prefix` | `"gordon"` | Network prefix |
216222 | `volumes.auto_create` | `true` | Auto-create volumes |
217223 | `volumes.prefix` | `"gordon"` | Volume prefix |
@@ -19,6 +19,9 @@ gordon_domain = "" # Required: Gordon domain (registry
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 registry_allowed_ips = [] # IPs or CIDR ranges allowed to access the registry (empty = allow all)
22+proxy_allowed_ips = [] # IPs or CIDR ranges allowed to reach the proxy (empty = allow all, e.g. Cloudflare IPs)
23+registry_listen_address = "" # Bind address for registry (empty = all interfaces, "127.0.0.1" = loopback only)
24+force_hsts = false # Always send HSTS header (enable when behind TLS-terminating proxy)
22 25
23 # =============================================================================26 # =============================================================================
24 # AUTHENTICATION (required - Gordon won't start without credentials configured)27 # AUTHENTICATION (required - Gordon won't start without credentials configured)
@@ -101,7 +104,7 @@ enabled = false # Create routes from image labels a
101 # NETWORK ISOLATION104 # NETWORK ISOLATION
102 # =============================================================================105 # =============================================================================
103 [network_isolation]106 [network_isolation]
104-enabled = false # Enable per-app Docker networks107+enabled = true # Enable per-app Docker networks
105 network_prefix = "gordon" # Prefix for created networks108 network_prefix = "gordon" # Prefix for created networks
106 109
107 # =============================================================================110 # =============================================================================
@@ -177,6 +180,9 @@ keep_last = 3 # Keep N newest tags per repository
177 | `server.data_dir` | `~/.gordon` | Data directory |180 | `server.data_dir` | `~/.gordon` | Data directory |
178 | `server.max_blob_chunk_size` | `"512MB"` | Max size per registry blob upload chunk |181 | `server.max_blob_chunk_size` | `"512MB"` | Max size per registry blob upload chunk |
179 | `server.registry_allowed_ips` | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) |182 | `server.registry_allowed_ips` | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) |
183+| `server.proxy_allowed_ips` | `[]` | IPs or CIDR ranges allowed to reach the proxy (empty = allow all) |
184+| `server.registry_listen_address` | `""` | Bind address for registry (empty = all interfaces) |
185+| `server.force_hsts` | `false` | Always send HSTS header (for TLS-terminating proxy setups) |
180 | `auth.enabled` | `true` | Enable authentication; when `false`, run local-only mode (loopback-only `/v2/*`, `/admin/*` disabled) |186 | `auth.enabled` | `true` | Enable authentication; when `false`, run local-only mode (loopback-only `/v2/*`, `/admin/*` disabled) |
181 | `auth.secrets_backend` | `"unsafe"` | Secrets storage |187 | `auth.secrets_backend` | `"unsafe"` | Secrets storage |
182 | `auth.token_expiry` | `"720h"` | 30 days |188 | `auth.token_expiry` | `"720h"` | 30 days |
@@ -211,7 +217,7 @@ keep_last = 3 # Keep N newest tags per repository
211 | `deploy.drain_timeout` | `"30s"` | Max wait for in-flight request drain before old stop |217 | `deploy.drain_timeout` | `"30s"` | Max wait for in-flight request drain before old stop |
212 | `deploy.drain_delay` | `"2s"` | Delay before stopping previous container after cache invalidation |218 | `deploy.drain_delay` | `"2s"` | Delay before stopping previous container after cache invalidation |
213 | `auto_route.enabled` | `false` | Auto-route disabled |219 | `auto_route.enabled` | `false` | Auto-route disabled |
214-| `network_isolation.enabled` | `false` | Isolation disabled |220+| `network_isolation.enabled` | `true` | Network isolation enabled |
215 | `network_isolation.network_prefix` | `"gordon"` | Network prefix |221 | `network_isolation.network_prefix` | `"gordon"` | Network prefix |
216 | `volumes.auto_create` | `true` | Auto-create volumes |222 | `volumes.auto_create` | `true` | Auto-create volumes |
217 | `volumes.prefix` | `"gordon"` | Volume prefix |223 | `volumes.prefix` | `"gordon"` | Volume prefix |
M docs/config/server.md +45 -0
@@ -32,6 +32,9 @@ gordon_domain = "gordon.mydomain.com" # Gordon domain (required)
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 |
3434 | `registry_allowed_ips` | []string | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) |
35+| `proxy_allowed_ips` | []string | `[]` | IPs or CIDR ranges allowed to reach the proxy (empty = allow all) |
36+| `registry_listen_address` | string | `""` | Bind address for registry (empty = all interfaces) |
37+| `force_hsts` | bool | `false` | Always send HSTS header (for TLS-terminating proxy setups) |
3538
3639 ## Port Configuration
3740
@@ -255,6 +258,48 @@ Via environment variable:
255258 GORDON_SERVER_REGISTRY_ALLOWED_IPS=100.64.0.0/10
256259 ```
257260
261+## Proxy Origin IP Allowlist
262+
263+The `proxy_allowed_ips` setting restricts which IPs can reach the proxy server. This prevents direct-to-origin attacks that bypass CDN protections (e.g. Cloudflare WAF, DDoS mitigation). When set, only connections from listed CIDRs (plus localhost) are accepted.
264+
265+```toml
266+[server]
267+# Only accept proxy traffic from Cloudflare edge IPs
268+proxy_allowed_ips = [
269+ "173.245.48.0/20", "103.21.244.0/22", "103.22.200.0/22",
270+ "103.31.4.0/22", "141.101.64.0/18", "108.162.192.0/18",
271+ "190.93.240.0/20", "188.114.96.0/20", "197.234.240.0/22",
272+ "198.41.128.0/17", "162.158.0.0/15", "104.16.0.0/13",
273+ "104.24.0.0/14", "172.64.0.0/13", "131.0.72.0/22",
274+]
275+```
276+
277+**Behavior:**
278+- Checks the direct connection IP (`RemoteAddr`), not `X-Forwarded-For`
279+- Localhost is always allowed
280+- Denied requests receive `403 Forbidden`
281+- An empty list allows all traffic (default)
282+
283+**Important:** Cloudflare publishes their IP ranges at [cloudflare.com/ips](https://www.cloudflare.com/ips/). Keep your allowlist updated when Cloudflare adds new ranges.
284+
285+## Registry Listen Address
286+
287+The `registry_listen_address` controls which interface the registry server binds to. By default it binds to all interfaces. Set to `127.0.0.1` to restrict registry access to localhost only, preventing containers from reaching the registry via the Docker bridge gateway.
288+
289+```toml
290+[server]
291+registry_listen_address = "127.0.0.1" # Loopback only
292+```
293+
294+## Force HSTS
295+
296+When Gordon runs behind a TLS-terminating proxy (e.g. Cloudflare), `r.TLS` is nil so the HSTS header is not sent by default. Enable `force_hsts` to always include the `Strict-Transport-Security` header.
297+
298+```toml
299+[server]
300+force_hsts = true
301+```
302+
258303 ## Examples
259304
260305 ### Development Configuration
@@ -32,6 +32,9 @@ gordon_domain = "gordon.mydomain.com" # Gordon domain (required)
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 | `registry_allowed_ips` | []string | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) |
35+| `proxy_allowed_ips` | []string | `[]` | IPs or CIDR ranges allowed to reach the proxy (empty = allow all) |
36+| `registry_listen_address` | string | `""` | Bind address for registry (empty = all interfaces) |
37+| `force_hsts` | bool | `false` | Always send HSTS header (for TLS-terminating proxy setups) |
35 38
36 ## Port Configuration39 ## Port Configuration
37 40
@@ -255,6 +258,48 @@ Via environment variable:
255 GORDON_SERVER_REGISTRY_ALLOWED_IPS=100.64.0.0/10258 GORDON_SERVER_REGISTRY_ALLOWED_IPS=100.64.0.0/10
256 ```259 ```
257 260
261+## Proxy Origin IP Allowlist
262+
263+The `proxy_allowed_ips` setting restricts which IPs can reach the proxy server. This prevents direct-to-origin attacks that bypass CDN protections (e.g. Cloudflare WAF, DDoS mitigation). When set, only connections from listed CIDRs (plus localhost) are accepted.
264+
265+```toml
266+[server]
267+# Only accept proxy traffic from Cloudflare edge IPs
268+proxy_allowed_ips = [
269+ "173.245.48.0/20", "103.21.244.0/22", "103.22.200.0/22",
270+ "103.31.4.0/22", "141.101.64.0/18", "108.162.192.0/18",
271+ "190.93.240.0/20", "188.114.96.0/20", "197.234.240.0/22",
272+ "198.41.128.0/17", "162.158.0.0/15", "104.16.0.0/13",
273+ "104.24.0.0/14", "172.64.0.0/13", "131.0.72.0/22",
274+]
275+```
276+
277+**Behavior:**
278+- Checks the direct connection IP (`RemoteAddr`), not `X-Forwarded-For`
279+- Localhost is always allowed
280+- Denied requests receive `403 Forbidden`
281+- An empty list allows all traffic (default)
282+
283+**Important:** Cloudflare publishes their IP ranges at [cloudflare.com/ips](https://www.cloudflare.com/ips/). Keep your allowlist updated when Cloudflare adds new ranges.
284+
285+## Registry Listen Address
286+
287+The `registry_listen_address` controls which interface the registry server binds to. By default it binds to all interfaces. Set to `127.0.0.1` to restrict registry access to localhost only, preventing containers from reaching the registry via the Docker bridge gateway.
288+
289+```toml
290+[server]
291+registry_listen_address = "127.0.0.1" # Loopback only
292+```
293+
294+## Force HSTS
295+
296+When Gordon runs behind a TLS-terminating proxy (e.g. Cloudflare), `r.TLS` is nil so the HSTS header is not sent by default. Enable `force_hsts` to always include the `Strict-Transport-Security` header.
297+
298+```toml
299+[server]
300+force_hsts = true
301+```
302+
258 ## Examples303 ## Examples
259 304
260 ### Development Configuration305 ### Development Configuration
M internal/adapters/in/http/middleware/cidr.go +41 -0
@@ -49,3 +49,44 @@ func RegistryCIDRAllowlist(allowedNets, trustedNets []*net.IPNet, log zerowrap.L
4949 })
5050 }
5151 }
52+
53+// ProxyCIDRAllowlist returns middleware that restricts proxy access to the given CIDR ranges.
54+// This is used to ensure only trusted sources (e.g. Cloudflare edge IPs) can reach the
55+// proxy server, preventing direct-to-origin attacks that bypass CDN protections.
56+// An empty allowedNets slice is a no-op (all traffic passes through).
57+func ProxyCIDRAllowlist(allowedNets, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler {
58+ return func(next http.Handler) http.Handler {
59+ if len(allowedNets) == 0 {
60+ return next
61+ }
62+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
63+ // For proxy allowlisting we check the direct connection IP (RemoteAddr),
64+ // not the client IP from headers, because we're validating the network
65+ // peer (e.g. Cloudflare edge), not the end-user.
66+ remoteIP, _, err := net.SplitHostPort(r.RemoteAddr)
67+ if err != nil {
68+ remoteIP = r.RemoteAddr
69+ }
70+
71+ if IsTrustedProxy(remoteIP, localhostNets) {
72+ next.ServeHTTP(w, r)
73+ return
74+ }
75+
76+ if IsTrustedProxy(remoteIP, allowedNets) {
77+ next.ServeHTTP(w, r)
78+ return
79+ }
80+
81+ log.Warn().
82+ Str(zerowrap.FieldLayer, "adapter").
83+ Str(zerowrap.FieldAdapter, "http").
84+ Str(zerowrap.FieldMethod, r.Method).
85+ Str("host", r.Host).
86+ Str(zerowrap.FieldClientIP, remoteIP).
87+ Msg("proxy access denied by origin IP allowlist")
88+
89+ http.Error(w, "Forbidden", http.StatusForbidden)
90+ })
91+ }
92+}
@@ -49,3 +49,44 @@ func RegistryCIDRAllowlist(allowedNets, trustedNets []*net.IPNet, log zerowrap.L
49 })49 })
50 }50 }
51 }51 }
52+
53+// ProxyCIDRAllowlist returns middleware that restricts proxy access to the given CIDR ranges.
54+// This is used to ensure only trusted sources (e.g. Cloudflare edge IPs) can reach the
55+// proxy server, preventing direct-to-origin attacks that bypass CDN protections.
56+// An empty allowedNets slice is a no-op (all traffic passes through).
57+func ProxyCIDRAllowlist(allowedNets, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler {
58+ return func(next http.Handler) http.Handler {
59+ if len(allowedNets) == 0 {
60+ return next
61+ }
62+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
63+ // For proxy allowlisting we check the direct connection IP (RemoteAddr),
64+ // not the client IP from headers, because we're validating the network
65+ // peer (e.g. Cloudflare edge), not the end-user.
66+ remoteIP, _, err := net.SplitHostPort(r.RemoteAddr)
67+ if err != nil {
68+ remoteIP = r.RemoteAddr
69+ }
70+
71+ if IsTrustedProxy(remoteIP, localhostNets) {
72+ next.ServeHTTP(w, r)
73+ return
74+ }
75+
76+ if IsTrustedProxy(remoteIP, allowedNets) {
77+ next.ServeHTTP(w, r)
78+ return
79+ }
80+
81+ log.Warn().
82+ Str(zerowrap.FieldLayer, "adapter").
83+ Str(zerowrap.FieldAdapter, "http").
84+ Str(zerowrap.FieldMethod, r.Method).
85+ Str("host", r.Host).
86+ Str(zerowrap.FieldClientIP, remoteIP).
87+ Msg("proxy access denied by origin IP allowlist")
88+
89+ http.Error(w, "Forbidden", http.StatusForbidden)
90+ })
91+ }
92+}
M internal/adapters/in/http/middleware/clientip.go +9 -0
@@ -84,6 +84,15 @@ func GetClientIP(r *http.Request, trustedNets []*net.IPNet) string {
8484
8585 // Only honor proxy headers if the request comes from a trusted proxy
8686 if IsTrustedProxy(remoteIP, trustedNets) {
87+ // Cf-Connecting-IP is set by Cloudflare to the true client IP.
88+ // It is a single IP (not a chain) and cannot be spoofed through Cloudflare.
89+ // Check it first as it is the most reliable source behind Cloudflare.
90+ if cfIP := r.Header.Get("Cf-Connecting-Ip"); cfIP != "" {
91+ if ip := normalizeIP(cfIP); ip != "" {
92+ return ip
93+ }
94+ }
95+
8796 // Check X-Forwarded-For for proxied requests.
8897 // SECURITY: Parse from right to left and return the first untrusted IP.
8998 // This prevents spoofing when a trusted proxy appends to an attacker-
@@ -84,6 +84,15 @@ func GetClientIP(r *http.Request, trustedNets []*net.IPNet) string {
84 84
85 // Only honor proxy headers if the request comes from a trusted proxy85 // Only honor proxy headers if the request comes from a trusted proxy
86 if IsTrustedProxy(remoteIP, trustedNets) {86 if IsTrustedProxy(remoteIP, trustedNets) {
87+ // Cf-Connecting-IP is set by Cloudflare to the true client IP.
88+ // It is a single IP (not a chain) and cannot be spoofed through Cloudflare.
89+ // Check it first as it is the most reliable source behind Cloudflare.
90+ if cfIP := r.Header.Get("Cf-Connecting-Ip"); cfIP != "" {
91+ if ip := normalizeIP(cfIP); ip != "" {
92+ return ip
93+ }
94+ }
95+
87 // Check X-Forwarded-For for proxied requests.96 // Check X-Forwarded-For for proxied requests.
88 // SECURITY: Parse from right to left and return the first untrusted IP.97 // SECURITY: Parse from right to left and return the first untrusted IP.
89 // This prevents spoofing when a trusted proxy appends to an attacker-98 // This prevents spoofing when a trusted proxy appends to an attacker-
M internal/adapters/in/http/middleware/security.go +39 -30
@@ -7,35 +7,44 @@ import (
77 // SecurityHeaders middleware adds standard security headers to HTTP responses.
88 // This provides defense-in-depth against various web attacks.
99 func SecurityHeaders(next http.Handler) http.Handler {
10- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
11- // Prevent MIME type sniffing
12- w.Header().Set("X-Content-Type-Options", "nosniff")
13-
14- // Prevent clickjacking
15- w.Header().Set("X-Frame-Options", "DENY")
16-
17- // XSS protection (legacy, but still useful for older browsers)
18- w.Header().Set("X-XSS-Protection", "1; mode=block")
19-
20- // Control referrer information
21- w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
22-
23- // Restrict browser features
24- w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
25-
26- // HSTS: Enforce HTTPS-only for browsers. Only set when the request
27- // arrived over a real TLS connection. We do NOT trust X-Forwarded-Proto
28- // here because it is client-spoofable unless the request came from a
29- // trusted proxy, and we have no trusted-proxy list at this layer.
30- if r.TLS != nil {
31- w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
32- }
33-
34- // NOTE: CSP is intentionally NOT set here. Gordon proxies arbitrary web
35- // apps, so a blanket "default-src 'none'" would break all proxied sites.
36- // Instead, CSP is set directly on proxy-generated error responses in the
37- // proxy error handlers (service.go).
10+ return SecurityHeadersWithOptions(false)(next)
11+}
3812
39- next.ServeHTTP(w, r)
40- })
13+// SecurityHeadersWithOptions returns a middleware that adds standard security headers.
14+// When forceHSTS is true, the HSTS header is always set regardless of TLS state.
15+// This is needed when Gordon runs behind a TLS-terminating proxy (e.g. Cloudflare)
16+// where r.TLS is nil but the client connection is always HTTPS.
17+func SecurityHeadersWithOptions(forceHSTS bool) func(http.Handler) http.Handler {
18+ return func(next http.Handler) http.Handler {
19+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
20+ // Prevent MIME type sniffing
21+ w.Header().Set("X-Content-Type-Options", "nosniff")
22+
23+ // Prevent clickjacking
24+ w.Header().Set("X-Frame-Options", "DENY")
25+
26+ // XSS protection (legacy, but still useful for older browsers)
27+ w.Header().Set("X-XSS-Protection", "1; mode=block")
28+
29+ // Control referrer information
30+ w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
31+
32+ // Restrict browser features
33+ w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
34+
35+ // HSTS: Enforce HTTPS-only for browsers.
36+ // Set when the request arrived over TLS, or when force_hsts is enabled
37+ // (for deployments behind a TLS-terminating proxy like Cloudflare).
38+ if r.TLS != nil || forceHSTS {
39+ w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
40+ }
41+
42+ // NOTE: CSP is intentionally NOT set here. Gordon proxies arbitrary web
43+ // apps, so a blanket "default-src 'none'" would break all proxied sites.
44+ // Instead, CSP is set directly on proxy-generated error responses in the
45+ // proxy error handlers (service.go).
46+
47+ next.ServeHTTP(w, r)
48+ })
49+ }
4150 }
@@ -7,35 +7,44 @@ import (
7 // SecurityHeaders middleware adds standard security headers to HTTP responses.7 // SecurityHeaders middleware adds standard security headers to HTTP responses.
8 // This provides defense-in-depth against various web attacks.8 // This provides defense-in-depth against various web attacks.
9 func SecurityHeaders(next http.Handler) http.Handler {9 func SecurityHeaders(next http.Handler) http.Handler {
10- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {10+ return SecurityHeadersWithOptions(false)(next)
11- // Prevent MIME type sniffing11+}
12- w.Header().Set("X-Content-Type-Options", "nosniff")
13-
14- // Prevent clickjacking
15- w.Header().Set("X-Frame-Options", "DENY")
16-
17- // XSS protection (legacy, but still useful for older browsers)
18- w.Header().Set("X-XSS-Protection", "1; mode=block")
19-
20- // Control referrer information
21- w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
22-
23- // Restrict browser features
24- w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
25-
26- // HSTS: Enforce HTTPS-only for browsers. Only set when the request
27- // arrived over a real TLS connection. We do NOT trust X-Forwarded-Proto
28- // here because it is client-spoofable unless the request came from a
29- // trusted proxy, and we have no trusted-proxy list at this layer.
30- if r.TLS != nil {
31- w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
32- }
33-
34- // NOTE: CSP is intentionally NOT set here. Gordon proxies arbitrary web
35- // apps, so a blanket "default-src 'none'" would break all proxied sites.
36- // Instead, CSP is set directly on proxy-generated error responses in the
37- // proxy error handlers (service.go).
38 12
39- next.ServeHTTP(w, r)13+// SecurityHeadersWithOptions returns a middleware that adds standard security headers.
40- })14+// When forceHSTS is true, the HSTS header is always set regardless of TLS state.
15+// This is needed when Gordon runs behind a TLS-terminating proxy (e.g. Cloudflare)
16+// where r.TLS is nil but the client connection is always HTTPS.
17+func SecurityHeadersWithOptions(forceHSTS bool) func(http.Handler) http.Handler {
18+ return func(next http.Handler) http.Handler {
19+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
20+ // Prevent MIME type sniffing
21+ w.Header().Set("X-Content-Type-Options", "nosniff")
22+
23+ // Prevent clickjacking
24+ w.Header().Set("X-Frame-Options", "DENY")
25+
26+ // XSS protection (legacy, but still useful for older browsers)
27+ w.Header().Set("X-XSS-Protection", "1; mode=block")
28+
29+ // Control referrer information
30+ w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
31+
32+ // Restrict browser features
33+ w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
34+
35+ // HSTS: Enforce HTTPS-only for browsers.
36+ // Set when the request arrived over TLS, or when force_hsts is enabled
37+ // (for deployments behind a TLS-terminating proxy like Cloudflare).
38+ if r.TLS != nil || forceHSTS {
39+ w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
40+ }
41+
42+ // NOTE: CSP is intentionally NOT set here. Gordon proxies arbitrary web
43+ // apps, so a blanket "default-src 'none'" would break all proxied sites.
44+ // Instead, CSP is set directly on proxy-generated error responses in the
45+ // proxy error handlers (service.go).
46+
47+ next.ServeHTTP(w, r)
48+ })
49+ }
41 }50 }
M internal/adapters/in/http/proxy/forwarder.go +3 -0
@@ -204,6 +204,9 @@ func newReverseProxy(targetURL *url.URL, originalHost string, transport http.Rou
204204 Transport: transport,
205205 ErrorHandler: errorHandler,
206206 ModifyResponse: modifyResp,
207+ // Flush immediately so SSE, chunked streaming, and long-polling responses
208+ // are forwarded to the client without buffering delay.
209+ FlushInterval: -1,
207210 }
208211 }
209212
@@ -204,6 +204,9 @@ func newReverseProxy(targetURL *url.URL, originalHost string, transport http.Rou
204 Transport: transport,204 Transport: transport,
205 ErrorHandler: errorHandler,205 ErrorHandler: errorHandler,
206 ModifyResponse: modifyResp,206 ModifyResponse: modifyResp,
207+ // Flush immediately so SSE, chunked streaming, and long-polling responses
208+ // are forwarded to the client without buffering delay.
209+ FlushInterval: -1,
207 }210 }
208 }211 }
209 212
M internal/adapters/out/docker/runtime.go +7 -0
@@ -25,10 +25,12 @@ import (
2525 "github.com/docker/docker/api/types/image"
2626 "github.com/docker/docker/api/types/network"
2727 "github.com/docker/docker/api/types/registry"
28+ "github.com/docker/docker/api/types/strslice"
2829 "github.com/docker/docker/api/types/volume"
2930 "github.com/docker/docker/client"
3031 "github.com/docker/docker/pkg/stdcopy"
3132 "github.com/docker/go-connections/nat"
33+ units "github.com/docker/go-units"
3234
3335 "github.com/bnema/gordon/internal/boundaries/out"
3436 "github.com/bnema/gordon/internal/domain"
@@ -164,6 +166,9 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC
164166 resources := container.Resources{
165167 Memory: config.MemoryLimit,
166168 NanoCPUs: config.NanoCPUs,
169+ Ulimits: []*units.Ulimit{
170+ {Name: "nofile", Soft: 65536, Hard: 65536},
171+ },
167172 }
168173 if config.PidsLimit > 0 {
169174 resources.PidsLimit = &config.PidsLimit
@@ -174,6 +179,8 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC
174179 Binds: binds,
175180 NetworkMode: container.NetworkMode(config.NetworkMode),
176181 Resources: resources,
182+ SecurityOpt: []string{"no-new-privileges:true"},
183+ CapDrop: strslice.StrSlice{"ALL"},
177184 }
178185
179186 // Create network configuration for container
@@ -25,10 +25,12 @@ import (
25 "github.com/docker/docker/api/types/image"25 "github.com/docker/docker/api/types/image"
26 "github.com/docker/docker/api/types/network"26 "github.com/docker/docker/api/types/network"
27 "github.com/docker/docker/api/types/registry"27 "github.com/docker/docker/api/types/registry"
28+ "github.com/docker/docker/api/types/strslice"
28 "github.com/docker/docker/api/types/volume"29 "github.com/docker/docker/api/types/volume"
29 "github.com/docker/docker/client"30 "github.com/docker/docker/client"
30 "github.com/docker/docker/pkg/stdcopy"31 "github.com/docker/docker/pkg/stdcopy"
31 "github.com/docker/go-connections/nat"32 "github.com/docker/go-connections/nat"
33+ units "github.com/docker/go-units"
32 34
33 "github.com/bnema/gordon/internal/boundaries/out"35 "github.com/bnema/gordon/internal/boundaries/out"
34 "github.com/bnema/gordon/internal/domain"36 "github.com/bnema/gordon/internal/domain"
@@ -164,6 +166,9 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC
164 resources := container.Resources{166 resources := container.Resources{
165 Memory: config.MemoryLimit,167 Memory: config.MemoryLimit,
166 NanoCPUs: config.NanoCPUs,168 NanoCPUs: config.NanoCPUs,
169+ Ulimits: []*units.Ulimit{
170+ {Name: "nofile", Soft: 65536, Hard: 65536},
171+ },
167 }172 }
168 if config.PidsLimit > 0 {173 if config.PidsLimit > 0 {
169 resources.PidsLimit = &config.PidsLimit174 resources.PidsLimit = &config.PidsLimit
@@ -174,6 +179,8 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC
174 Binds: binds,179 Binds: binds,
175 NetworkMode: container.NetworkMode(config.NetworkMode),180 NetworkMode: container.NetworkMode(config.NetworkMode),
176 Resources: resources,181 Resources: resources,
182+ SecurityOpt: []string{"no-new-privileges:true"},
183+ CapDrop: strslice.StrSlice{"ALL"},
177 }184 }
178 185
179 // Create network configuration for container186 // Create network configuration for container
M internal/app/run.go +57 -3
@@ -6,6 +6,7 @@ import (
66 "crypto/ecdsa"
77 "crypto/elliptic"
88 "crypto/rand"
9+ "crypto/tls"
910 "crypto/x509"
1011 "crypto/x509/pkix"
1112 "encoding/hex"
@@ -100,6 +101,9 @@ type Config struct {
100101 MaxProxyResponseSize string `mapstructure:"max_proxy_response_size"` // e.g., "1GB", "0" for no limit
101102 MaxConcurrentConns int `mapstructure:"max_concurrent_connections"`
102103 RegistryAllowedIPs []string `mapstructure:"registry_allowed_ips"`
104+ ProxyAllowedIPs []string `mapstructure:"proxy_allowed_ips"`
105+ RegistryListenAddr string `mapstructure:"registry_listen_address"`
106+ ForceHSTS bool `mapstructure:"force_hsts"`
103107 } `mapstructure:"server"`
104108
105109 Logging struct {
@@ -1690,7 +1694,14 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
16901694 proxyMiddlewares := []func(http.Handler) http.Handler{
16911695 middleware.PanicRecovery(log),
16921696 middleware.RequestLogger(log, trustedNets),
1693- middleware.SecurityHeaders,
1697+ middleware.SecurityHeadersWithOptions(cfg.Server.ForceHSTS),
1698+ }
1699+
1700+ // SECURITY: When proxy_allowed_ips is set, only connections from those CIDRs
1701+ // can reach the proxy. This prevents direct-to-origin attacks that bypass
1702+ // CDN protections (e.g. Cloudflare WAF, DDoS mitigation).
1703+ if proxyCIDRMiddleware := buildProxyCIDRAllowlistMiddleware(cfg, trustedNets, log); proxyCIDRMiddleware != nil {
1704+ proxyMiddlewares = append(proxyMiddlewares, proxyCIDRMiddleware)
16941705 }
16951706
16961707 proxyHandler := proxyadapter.NewHandler(svc.proxySvc, log)
@@ -1770,6 +1781,42 @@ func buildRegistryCIDRAllowlistMiddleware(cfg Config, trustedNets []*net.IPNet,
17701781 return middleware.RegistryCIDRAllowlist(allowedNets, trustedNets, log)
17711782 }
17721783
1784+func buildProxyCIDRAllowlistMiddleware(cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler {
1785+ if len(cfg.Server.ProxyAllowedIPs) == 0 {
1786+ return nil
1787+ }
1788+
1789+ allowedNets := middleware.ParseTrustedProxies(cfg.Server.ProxyAllowedIPs)
1790+ if len(allowedNets) != len(cfg.Server.ProxyAllowedIPs) {
1791+ for _, entry := range cfg.Server.ProxyAllowedIPs {
1792+ if nets := middleware.ParseTrustedProxies([]string{entry}); len(nets) == 0 {
1793+ log.Warn().Str("entry", entry).Msg("ignoring invalid proxy_allowed_ips entry")
1794+ }
1795+ }
1796+ }
1797+
1798+ if len(allowedNets) == 0 {
1799+ log.Error().
1800+ Strs("proxy_allowed_ips", cfg.Server.ProxyAllowedIPs).
1801+ Msg("proxy_allowed_ips is set but no valid entries were parsed; proxy will deny all traffic (fail-closed)")
1802+ return func(next http.Handler) http.Handler {
1803+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1804+ log.Warn().
1805+ Str("host", r.Host).
1806+ Str(zerowrap.FieldClientIP, middleware.GetClientIP(r, trustedNets)).
1807+ Msg("proxy access denied due to invalid proxy_allowed_ips configuration")
1808+ http.Error(w, "Forbidden", http.StatusForbidden)
1809+ })
1810+ }
1811+ }
1812+
1813+ log.Info().
1814+ Strs("proxy_allowed_ips", cfg.Server.ProxyAllowedIPs).
1815+ Msg("proxy origin IP allowlist enabled")
1816+
1817+ return middleware.ProxyCIDRAllowlist(allowedNets, trustedNets, log)
1818+}
1819+
17731820 func buildRegistryRateLimitMiddleware(cfg Config, log zerowrap.Logger) func(http.Handler) http.Handler {
17741821 if cfg.API.RateLimit.Enabled {
17751822 globalLimiter := ratelimit.NewMemoryStore(cfg.API.RateLimit.GlobalRPS, cfg.API.RateLimit.Burst, log)
@@ -1920,7 +1967,8 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler
19201967
19211968 errChan := make(chan error, 3)
19221969
1923- registrySrv, registryReady := startServer(fmt.Sprintf(":%d", cfg.Server.RegistryPort), registryHandler, "registry", nil, errChan, log)
1970+ registryAddr := fmt.Sprintf("%s:%d", cfg.Server.RegistryListenAddr, cfg.Server.RegistryPort)
1971+ registrySrv, registryReady := startServer(registryAddr, registryHandler, "registry", nil, errChan, log)
19241972
19251973 proxySrv, proxyReady, tlsSrv, tlsReady, err := startProxyServers(cfg, proxyHandler, errChan, log)
19261974 if err != nil {
@@ -2301,6 +2349,9 @@ func startTLSServer(addr string, handler http.Handler, name, certFile, keyFile s
23012349 WriteTimeout: 5 * time.Minute,
23022350 IdleTimeout: 120 * time.Second,
23032351 MaxHeaderBytes: 1 << 20,
2352+ TLSConfig: &tls.Config{
2353+ MinVersion: tls.VersionTLS12,
2354+ },
23042355 }
23052356
23062357 go func() {
@@ -2605,7 +2656,7 @@ func loadConfig(v *viper.Viper, configPath string) error {
26052656 v.SetDefault("api.rate_limit.per_ip_rps", 50)
26062657 v.SetDefault("api.rate_limit.burst", 100)
26072658 v.SetDefault("auto_route.enabled", false)
2608- v.SetDefault("network_isolation.enabled", false)
2659+ v.SetDefault("network_isolation.enabled", true)
26092660 v.SetDefault("network_isolation.network_prefix", "gordon")
26102661 v.SetDefault("volumes.auto_create", true)
26112662 v.SetDefault("volumes.prefix", "gordon")
@@ -2631,6 +2682,9 @@ func loadConfig(v *viper.Viper, configPath string) error {
26312682
26322683 v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit
26332684 v.SetDefault("server.registry_allowed_ips", []string{})
2685+ v.SetDefault("server.proxy_allowed_ips", []string{})
2686+ v.SetDefault("server.registry_listen_address", "")
2687+ v.SetDefault("server.force_hsts", false)
26342688 v.SetDefault("deploy.readiness_delay", "5s")
26352689 v.SetDefault("deploy.readiness_mode", "auto")
26362690 v.SetDefault("deploy.health_timeout", "90s")
@@ -6,6 +6,7 @@ import (
6 "crypto/ecdsa"6 "crypto/ecdsa"
7 "crypto/elliptic"7 "crypto/elliptic"
8 "crypto/rand"8 "crypto/rand"
9+ "crypto/tls"
9 "crypto/x509"10 "crypto/x509"
10 "crypto/x509/pkix"11 "crypto/x509/pkix"
11 "encoding/hex"12 "encoding/hex"
@@ -100,6 +101,9 @@ type Config struct {
100 MaxProxyResponseSize string `mapstructure:"max_proxy_response_size"` // e.g., "1GB", "0" for no limit101 MaxProxyResponseSize string `mapstructure:"max_proxy_response_size"` // e.g., "1GB", "0" for no limit
101 MaxConcurrentConns int `mapstructure:"max_concurrent_connections"`102 MaxConcurrentConns int `mapstructure:"max_concurrent_connections"`
102 RegistryAllowedIPs []string `mapstructure:"registry_allowed_ips"`103 RegistryAllowedIPs []string `mapstructure:"registry_allowed_ips"`
104+ ProxyAllowedIPs []string `mapstructure:"proxy_allowed_ips"`
105+ RegistryListenAddr string `mapstructure:"registry_listen_address"`
106+ ForceHSTS bool `mapstructure:"force_hsts"`
103 } `mapstructure:"server"`107 } `mapstructure:"server"`
104 108
105 Logging struct {109 Logging struct {
@@ -1690,7 +1694,14 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
1690 proxyMiddlewares := []func(http.Handler) http.Handler{1694 proxyMiddlewares := []func(http.Handler) http.Handler{
1691 middleware.PanicRecovery(log),1695 middleware.PanicRecovery(log),
1692 middleware.RequestLogger(log, trustedNets),1696 middleware.RequestLogger(log, trustedNets),
1693- middleware.SecurityHeaders,1697+ middleware.SecurityHeadersWithOptions(cfg.Server.ForceHSTS),
1698+ }
1699+
1700+ // SECURITY: When proxy_allowed_ips is set, only connections from those CIDRs
1701+ // can reach the proxy. This prevents direct-to-origin attacks that bypass
1702+ // CDN protections (e.g. Cloudflare WAF, DDoS mitigation).
1703+ if proxyCIDRMiddleware := buildProxyCIDRAllowlistMiddleware(cfg, trustedNets, log); proxyCIDRMiddleware != nil {
1704+ proxyMiddlewares = append(proxyMiddlewares, proxyCIDRMiddleware)
1694 }1705 }
1695 1706
1696 proxyHandler := proxyadapter.NewHandler(svc.proxySvc, log)1707 proxyHandler := proxyadapter.NewHandler(svc.proxySvc, log)
@@ -1770,6 +1781,42 @@ func buildRegistryCIDRAllowlistMiddleware(cfg Config, trustedNets []*net.IPNet,
1770 return middleware.RegistryCIDRAllowlist(allowedNets, trustedNets, log)1781 return middleware.RegistryCIDRAllowlist(allowedNets, trustedNets, log)
1771 }1782 }
1772 1783
1784+func buildProxyCIDRAllowlistMiddleware(cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler {
1785+ if len(cfg.Server.ProxyAllowedIPs) == 0 {
1786+ return nil
1787+ }
1788+
1789+ allowedNets := middleware.ParseTrustedProxies(cfg.Server.ProxyAllowedIPs)
1790+ if len(allowedNets) != len(cfg.Server.ProxyAllowedIPs) {
1791+ for _, entry := range cfg.Server.ProxyAllowedIPs {
1792+ if nets := middleware.ParseTrustedProxies([]string{entry}); len(nets) == 0 {
1793+ log.Warn().Str("entry", entry).Msg("ignoring invalid proxy_allowed_ips entry")
1794+ }
1795+ }
1796+ }
1797+
1798+ if len(allowedNets) == 0 {
1799+ log.Error().
1800+ Strs("proxy_allowed_ips", cfg.Server.ProxyAllowedIPs).
1801+ Msg("proxy_allowed_ips is set but no valid entries were parsed; proxy will deny all traffic (fail-closed)")
1802+ return func(next http.Handler) http.Handler {
1803+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1804+ log.Warn().
1805+ Str("host", r.Host).
1806+ Str(zerowrap.FieldClientIP, middleware.GetClientIP(r, trustedNets)).
1807+ Msg("proxy access denied due to invalid proxy_allowed_ips configuration")
1808+ http.Error(w, "Forbidden", http.StatusForbidden)
1809+ })
1810+ }
1811+ }
1812+
1813+ log.Info().
1814+ Strs("proxy_allowed_ips", cfg.Server.ProxyAllowedIPs).
1815+ Msg("proxy origin IP allowlist enabled")
1816+
1817+ return middleware.ProxyCIDRAllowlist(allowedNets, trustedNets, log)
1818+}
1819+
1773 func buildRegistryRateLimitMiddleware(cfg Config, log zerowrap.Logger) func(http.Handler) http.Handler {1820 func buildRegistryRateLimitMiddleware(cfg Config, log zerowrap.Logger) func(http.Handler) http.Handler {
1774 if cfg.API.RateLimit.Enabled {1821 if cfg.API.RateLimit.Enabled {
1775 globalLimiter := ratelimit.NewMemoryStore(cfg.API.RateLimit.GlobalRPS, cfg.API.RateLimit.Burst, log)1822 globalLimiter := ratelimit.NewMemoryStore(cfg.API.RateLimit.GlobalRPS, cfg.API.RateLimit.Burst, log)
@@ -1920,7 +1967,8 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler
1920 1967
1921 errChan := make(chan error, 3)1968 errChan := make(chan error, 3)
1922 1969
1923- registrySrv, registryReady := startServer(fmt.Sprintf(":%d", cfg.Server.RegistryPort), registryHandler, "registry", nil, errChan, log)1970+ registryAddr := fmt.Sprintf("%s:%d", cfg.Server.RegistryListenAddr, cfg.Server.RegistryPort)
1971+ registrySrv, registryReady := startServer(registryAddr, registryHandler, "registry", nil, errChan, log)
1924 1972
1925 proxySrv, proxyReady, tlsSrv, tlsReady, err := startProxyServers(cfg, proxyHandler, errChan, log)1973 proxySrv, proxyReady, tlsSrv, tlsReady, err := startProxyServers(cfg, proxyHandler, errChan, log)
1926 if err != nil {1974 if err != nil {
@@ -2301,6 +2349,9 @@ func startTLSServer(addr string, handler http.Handler, name, certFile, keyFile s
2301 WriteTimeout: 5 * time.Minute,2349 WriteTimeout: 5 * time.Minute,
2302 IdleTimeout: 120 * time.Second,2350 IdleTimeout: 120 * time.Second,
2303 MaxHeaderBytes: 1 << 20,2351 MaxHeaderBytes: 1 << 20,
2352+ TLSConfig: &tls.Config{
2353+ MinVersion: tls.VersionTLS12,
2354+ },
2304 }2355 }
2305 2356
2306 go func() {2357 go func() {
@@ -2605,7 +2656,7 @@ func loadConfig(v *viper.Viper, configPath string) error {
2605 v.SetDefault("api.rate_limit.per_ip_rps", 50)2656 v.SetDefault("api.rate_limit.per_ip_rps", 50)
2606 v.SetDefault("api.rate_limit.burst", 100)2657 v.SetDefault("api.rate_limit.burst", 100)
2607 v.SetDefault("auto_route.enabled", false)2658 v.SetDefault("auto_route.enabled", false)
2608- v.SetDefault("network_isolation.enabled", false)2659+ v.SetDefault("network_isolation.enabled", true)
2609 v.SetDefault("network_isolation.network_prefix", "gordon")2660 v.SetDefault("network_isolation.network_prefix", "gordon")
2610 v.SetDefault("volumes.auto_create", true)2661 v.SetDefault("volumes.auto_create", true)
2611 v.SetDefault("volumes.prefix", "gordon")2662 v.SetDefault("volumes.prefix", "gordon")
@@ -2631,6 +2682,9 @@ func loadConfig(v *viper.Viper, configPath string) error {
2631 2682
2632 v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit2683 v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit
2633 v.SetDefault("server.registry_allowed_ips", []string{})2684 v.SetDefault("server.registry_allowed_ips", []string{})
2685+ v.SetDefault("server.proxy_allowed_ips", []string{})
2686+ v.SetDefault("server.registry_listen_address", "")
2687+ v.SetDefault("server.force_hsts", false)
2634 v.SetDefault("deploy.readiness_delay", "5s")2688 v.SetDefault("deploy.readiness_delay", "5s")
2635 v.SetDefault("deploy.readiness_mode", "auto")2689 v.SetDefault("deploy.readiness_mode", "auto")
2636 v.SetDefault("deploy.health_timeout", "90s")2690 v.SetDefault("deploy.health_timeout", "90s")

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