feat(traffic): make HTTP timeouts configurable with an inactivity deadline
Add server.http.* (registry) and traffic.http.* (entrypoints) duration settings with env overrides and load-time validation. Entrypoint HTTP servers drop fixed read/write timeouts in favour of a transfer_idle_timeout middleware that renews deadlines as data flows.
- commit
- e5ab8907d794190bc9a83c49f2eac0924e590643
- author
- brice · · 2026-10-16 19:08 UTC
- parent
- 4fc01fa
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
19 files · +1157 -39
M docs/config/index.md +9 -0
| @@ -156,6 +156,13 @@ Docker Hub (`docker.io` and `registry-1.docker.io`), `ghcr.io`, `quay.io`, and G | |||
| 156 | 156 | | `server.data_dir` | `~/.gordon` | | |
| 157 | 157 | | `server.max_blob_chunk_size` | `"95MB"` | | |
| 158 | 158 | | `server.max_blob_size` | `"1GB"` | | |
| 159 | + | | `server.http.read_header_timeout` | `"10s"` | | |
| 160 | + | | `server.http.read_timeout` | `"5m"` | | |
| 161 | + | | `server.http.write_timeout` | `"5m"` | | |
| 162 | + | | `server.http.idle_timeout` | `"120s"` | | |
| 163 | + | | `traffic.http.read_header_timeout` | `"5s"` | | |
| 164 | + | | `traffic.http.idle_timeout` | `"30s"` | | |
| 165 | + | | `traffic.http.transfer_idle_timeout` | `"2m"` | | |
| 159 | 166 | | `auth.enabled` | `true` | | |
| 160 | 167 | | `auth.secrets_backend` | `"unsafe"` | | |
| 161 | 168 | | `auth.token_expiry` | `"30d"` | | |
| @@ -210,6 +217,7 @@ gordon daemon reload | |||
| 210 | 217 | | `server.max_proxy_body_size` | | |
| 211 | 218 | | `server.max_proxy_response_size` | | |
| 212 | 219 | | `server.max_concurrent_conns` | | |
| 220 | + | | `traffic.http.*` (new connections after the traffic graph is applied) | | |
| 213 | 221 | ||
| 214 | 222 | ||
| 215 | 223 | ### Requires restart | |
| @@ -220,6 +228,7 @@ gordon daemon reload | |||
| 220 | 228 | | `server.data_dir` | | |
| 221 | 229 | | `server.max_blob_chunk_size` | | |
| 222 | 230 | | `server.max_blob_size` | | |
| 231 | + | | `server.http.*` | | |
| 223 | 232 | | `auth.*` | | |
| 224 | 233 | ||
| 225 | 234 | ## Environment Variable Override | |
| @@ -156,6 +156,13 @@ Docker Hub (`docker.io` and `registry-1.docker.io`), `ghcr.io`, `quay.io`, and G | |||||
| 156 | | `server.data_dir` | `~/.gordon` | | 156 | | `server.data_dir` | `~/.gordon` | | ||
| 157 | | `server.max_blob_chunk_size` | `"95MB"` | | 157 | | `server.max_blob_chunk_size` | `"95MB"` | | ||
| 158 | | `server.max_blob_size` | `"1GB"` | | 158 | | `server.max_blob_size` | `"1GB"` | | ||
| 159 | + | | `server.http.read_header_timeout` | `"10s"` | | |||
| 160 | + | | `server.http.read_timeout` | `"5m"` | | |||
| 161 | + | | `server.http.write_timeout` | `"5m"` | | |||
| 162 | + | | `server.http.idle_timeout` | `"120s"` | | |||
| 163 | + | | `traffic.http.read_header_timeout` | `"5s"` | | |||
| 164 | + | | `traffic.http.idle_timeout` | `"30s"` | | |||
| 165 | + | | `traffic.http.transfer_idle_timeout` | `"2m"` | | |||
| 159 | | `auth.enabled` | `true` | | 166 | | `auth.enabled` | `true` | | ||
| 160 | | `auth.secrets_backend` | `"unsafe"` | | 167 | | `auth.secrets_backend` | `"unsafe"` | | ||
| 161 | | `auth.token_expiry` | `"30d"` | | 168 | | `auth.token_expiry` | `"30d"` | | ||
| @@ -210,6 +217,7 @@ gordon daemon reload | |||||
| 210 | | `server.max_proxy_body_size` | | 217 | | `server.max_proxy_body_size` | | ||
| 211 | | `server.max_proxy_response_size` | | 218 | | `server.max_proxy_response_size` | | ||
| 212 | | `server.max_concurrent_conns` | | 219 | | `server.max_concurrent_conns` | | ||
| 220 | + | | `traffic.http.*` (new connections after the traffic graph is applied) | | |||
| 213 | 221 | ||||
| 214 | 222 | ||||
| 215 | ### Requires restart | 223 | ### Requires restart | ||
| @@ -220,6 +228,7 @@ gordon daemon reload | |||||
| 220 | | `server.data_dir` | | 228 | | `server.data_dir` | | ||
| 221 | | `server.max_blob_chunk_size` | | 229 | | `server.max_blob_chunk_size` | | ||
| 222 | | `server.max_blob_size` | | 230 | | `server.max_blob_size` | | ||
| 231 | + | | `server.http.*` | | |||
| 223 | | `auth.*` | | 232 | | `auth.*` | | ||
| 224 | 233 | ||||
| 225 | ## Environment Variable Override | 234 | ## Environment Variable Override | ||
M docs/config/reference.md +18 -0
| @@ -21,6 +21,12 @@ registry_allowed_ips = [] # IPs or CIDR ranges allowed to acc | |||
| 21 | 21 | proxy_allowed_ips = [] # IPs or CIDR ranges allowed to reach HTTP proxy paths (empty = allow all, e.g. Cloudflare IPs) | |
| 22 | 22 | registry_listen_address = "" # Bind address for registry (empty = all interfaces, "127.0.0.1" = loopback only) | |
| 23 | 23 | ||
| 24 | + | [server.http] # Registry HTTP server timeouts (Go durations) | |
| 25 | + | read_header_timeout = "10s" # Max time to read request headers | |
| 26 | + | read_timeout = "5m" # Max time to read a whole request | |
| 27 | + | write_timeout = "5m" # Max time to write a whole response | |
| 28 | + | idle_timeout = "120s" # Keep-alive idle timeout | |
| 29 | + | ||
| 24 | 30 | # ============================================================================= | |
| 25 | 31 | # ENTRYPOINTS | |
| 26 | 32 | # ============================================================================= | |
| @@ -32,6 +38,11 @@ trusted_cidrs = [] # Peer socket IP allowlist for all | |||
| 32 | 38 | # raw_fallback_trusted_cidrs = ["100.64.0.0/10"] | |
| 33 | 39 | # allow_public_raw_fallback = false | |
| 34 | 40 | ||
| 41 | + | [traffic.http] # HTTP servers behind entrypoints (apps + admin API) | |
| 42 | + | read_header_timeout = "5s" # Max time to read request headers | |
| 43 | + | idle_timeout = "30s" # Keep-alive idle timeout | |
| 44 | + | transfer_idle_timeout = "2m" # Cut a transfer only after this long without data (no fixed read/write timeout) | |
| 45 | + | ||
| 35 | 46 | # ============================================================================= | |
| 36 | 47 | # DNS | |
| 37 | 48 | # ============================================================================= | |
| @@ -241,6 +252,13 @@ keep_last = 3 # Keep N newest tags per repository | |||
| 241 | 252 | | `server.registry_allowed_ips` | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) | | |
| 242 | 253 | | `server.proxy_allowed_ips` | `[]` | IPs or CIDR ranges allowed to reach the proxy (empty = allow all) | | |
| 243 | 254 | | `server.registry_listen_address` | `""` | Bind address for registry (empty = all interfaces) | | |
| 255 | + | | `server.http.read_header_timeout` | `"10s"` | Registry servers: max time to read request headers (env `GORDON_SERVER_HTTP_READ_HEADER_TIMEOUT`) | | |
| 256 | + | | `server.http.read_timeout` | `"5m"` | Registry servers: max time to read a whole request (env `GORDON_SERVER_HTTP_READ_TIMEOUT`) | | |
| 257 | + | | `server.http.write_timeout` | `"5m"` | Registry servers: max time to write a whole response (env `GORDON_SERVER_HTTP_WRITE_TIMEOUT`) | | |
| 258 | + | | `server.http.idle_timeout` | `"120s"` | Registry servers: keep-alive idle timeout (env `GORDON_SERVER_HTTP_IDLE_TIMEOUT`) | | |
| 259 | + | | `traffic.http.read_header_timeout` | `"5s"` | Entrypoint HTTP servers: max time to read request headers (env `GORDON_TRAFFIC_HTTP_READ_HEADER_TIMEOUT`) | | |
| 260 | + | | `traffic.http.idle_timeout` | `"30s"` | Entrypoint HTTP servers: keep-alive idle timeout (env `GORDON_TRAFFIC_HTTP_IDLE_TIMEOUT`) | | |
| 261 | + | | `traffic.http.transfer_idle_timeout` | `"2m"` | Entrypoint HTTP servers: inactivity timeout for request bodies and responses (env `GORDON_TRAFFIC_HTTP_TRANSFER_IDLE_TIMEOUT`) | | |
| 244 | 262 | | `entrypoints.<name>.address` | none | Deployment-selected listen address; `edge` is conventional for route-capable entrypoints but is not required when exactly one `smart_tcp` or `tls_mux` entrypoint exists | | |
| 245 | 263 | | `entrypoints.<name>.protocol` | none | Entrypoint protocol: `smart_tcp`, `tls_mux`, `tcp`, or `udp` | | |
| 246 | 264 | | `entrypoints.<name>.trusted_cidrs` | `[]` | Peer socket IP allowlist for all traffic on the entrypoint | | |
| @@ -21,6 +21,12 @@ registry_allowed_ips = [] # IPs or CIDR ranges allowed to acc | |||||
| 21 | proxy_allowed_ips = [] # IPs or CIDR ranges allowed to reach HTTP proxy paths (empty = allow all, e.g. Cloudflare IPs) | 21 | proxy_allowed_ips = [] # IPs or CIDR ranges allowed to reach HTTP proxy paths (empty = allow all, e.g. Cloudflare IPs) | ||
| 22 | registry_listen_address = "" # Bind address for registry (empty = all interfaces, "127.0.0.1" = loopback only) | 22 | registry_listen_address = "" # Bind address for registry (empty = all interfaces, "127.0.0.1" = loopback only) | ||
| 23 | 23 | ||||
| 24 | + | [server.http] # Registry HTTP server timeouts (Go durations) | |||
| 25 | + | read_header_timeout = "10s" # Max time to read request headers | |||
| 26 | + | read_timeout = "5m" # Max time to read a whole request | |||
| 27 | + | write_timeout = "5m" # Max time to write a whole response | |||
| 28 | + | idle_timeout = "120s" # Keep-alive idle timeout | |||
| 29 | + | ||||
| 24 | # ============================================================================= | 30 | # ============================================================================= | ||
| 25 | # ENTRYPOINTS | 31 | # ENTRYPOINTS | ||
| 26 | # ============================================================================= | 32 | # ============================================================================= | ||
| @@ -32,6 +38,11 @@ trusted_cidrs = [] # Peer socket IP allowlist for all | |||||
| 32 | # raw_fallback_trusted_cidrs = ["100.64.0.0/10"] | 38 | # raw_fallback_trusted_cidrs = ["100.64.0.0/10"] | ||
| 33 | # allow_public_raw_fallback = false | 39 | # allow_public_raw_fallback = false | ||
| 34 | 40 | ||||
| 41 | + | [traffic.http] # HTTP servers behind entrypoints (apps + admin API) | |||
| 42 | + | read_header_timeout = "5s" # Max time to read request headers | |||
| 43 | + | idle_timeout = "30s" # Keep-alive idle timeout | |||
| 44 | + | transfer_idle_timeout = "2m" # Cut a transfer only after this long without data (no fixed read/write timeout) | |||
| 45 | + | ||||
| 35 | # ============================================================================= | 46 | # ============================================================================= | ||
| 36 | # DNS | 47 | # DNS | ||
| 37 | # ============================================================================= | 48 | # ============================================================================= | ||
| @@ -241,6 +252,13 @@ keep_last = 3 # Keep N newest tags per repository | |||||
| 241 | | `server.registry_allowed_ips` | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) | | 252 | | `server.registry_allowed_ips` | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) | | ||
| 242 | | `server.proxy_allowed_ips` | `[]` | IPs or CIDR ranges allowed to reach the proxy (empty = allow all) | | 253 | | `server.proxy_allowed_ips` | `[]` | IPs or CIDR ranges allowed to reach the proxy (empty = allow all) | | ||
| 243 | | `server.registry_listen_address` | `""` | Bind address for registry (empty = all interfaces) | | 254 | | `server.registry_listen_address` | `""` | Bind address for registry (empty = all interfaces) | | ||
| 255 | + | | `server.http.read_header_timeout` | `"10s"` | Registry servers: max time to read request headers (env `GORDON_SERVER_HTTP_READ_HEADER_TIMEOUT`) | | |||
| 256 | + | | `server.http.read_timeout` | `"5m"` | Registry servers: max time to read a whole request (env `GORDON_SERVER_HTTP_READ_TIMEOUT`) | | |||
| 257 | + | | `server.http.write_timeout` | `"5m"` | Registry servers: max time to write a whole response (env `GORDON_SERVER_HTTP_WRITE_TIMEOUT`) | | |||
| 258 | + | | `server.http.idle_timeout` | `"120s"` | Registry servers: keep-alive idle timeout (env `GORDON_SERVER_HTTP_IDLE_TIMEOUT`) | | |||
| 259 | + | | `traffic.http.read_header_timeout` | `"5s"` | Entrypoint HTTP servers: max time to read request headers (env `GORDON_TRAFFIC_HTTP_READ_HEADER_TIMEOUT`) | | |||
| 260 | + | | `traffic.http.idle_timeout` | `"30s"` | Entrypoint HTTP servers: keep-alive idle timeout (env `GORDON_TRAFFIC_HTTP_IDLE_TIMEOUT`) | | |||
| 261 | + | | `traffic.http.transfer_idle_timeout` | `"2m"` | Entrypoint HTTP servers: inactivity timeout for request bodies and responses (env `GORDON_TRAFFIC_HTTP_TRANSFER_IDLE_TIMEOUT`) | | |||
| 244 | | `entrypoints.<name>.address` | none | Deployment-selected listen address; `edge` is conventional for route-capable entrypoints but is not required when exactly one `smart_tcp` or `tls_mux` entrypoint exists | | 262 | | `entrypoints.<name>.address` | none | Deployment-selected listen address; `edge` is conventional for route-capable entrypoints but is not required when exactly one `smart_tcp` or `tls_mux` entrypoint exists | | ||
| 245 | | `entrypoints.<name>.protocol` | none | Entrypoint protocol: `smart_tcp`, `tls_mux`, `tcp`, or `udp` | | 263 | | `entrypoints.<name>.protocol` | none | Entrypoint protocol: `smart_tcp`, `tls_mux`, `tcp`, or `udp` | | ||
| 246 | | `entrypoints.<name>.trusted_cidrs` | `[]` | Peer socket IP allowlist for all traffic on the entrypoint | | 264 | | `entrypoints.<name>.trusted_cidrs` | `[]` | Peer socket IP allowlist for all traffic on the entrypoint | | ||
M docs/config/server.md +21 -0
| @@ -50,6 +50,27 @@ protocol = "smart_tcp" | |||
| 50 | 50 | ||
| 51 | 51 | `entrypoints.edge.address` is not an HTTP or HTTPS port. It is the TCP address Gordon sniffs for cleartext HTTP, h2c, TLS passthrough, normal HTTPS fallback, or explicit raw TCP fallback. Gordon does not provide a built-in default public port; choose `:443`, a high port such as `:9000`, or container/firewall mappings to match your deployment. | |
| 52 | 52 | ||
| 53 | + | ## Registry HTTP Timeouts | |
| 54 | + | ||
| 55 | + | The registry listeners (`registry_port`, plus the loopback listener used for the internal registry) apply fixed per-connection timeouts. Values are Go duration strings; malformed, zero, or negative values are rejected when the configuration loads. | |
| 56 | + | ||
| 57 | + | ```toml | |
| 58 | + | [server.http] | |
| 59 | + | read_header_timeout = "10s" | |
| 60 | + | read_timeout = "5m" | |
| 61 | + | write_timeout = "5m" | |
| 62 | + | idle_timeout = "120s" | |
| 63 | + | ``` | |
| 64 | + | ||
| 65 | + | | Option | Env | Default | Description | | |
| 66 | + | |--------|-----|---------|-------------| | |
| 67 | + | | `server.http.read_header_timeout` | `GORDON_SERVER_HTTP_READ_HEADER_TIMEOUT` | `"10s"` | Maximum time to read request headers | | |
| 68 | + | | `server.http.read_timeout` | `GORDON_SERVER_HTTP_READ_TIMEOUT` | `"5m"` | Maximum time to read the whole request, body included | | |
| 69 | + | | `server.http.write_timeout` | `GORDON_SERVER_HTTP_WRITE_TIMEOUT` | `"5m"` | Maximum time to write the whole response | | |
| 70 | + | | `server.http.idle_timeout` | `GORDON_SERVER_HTTP_IDLE_TIMEOUT` | `"120s"` | Maximum keep-alive idle time between requests | | |
| 71 | + | ||
| 72 | + | `read_timeout` and `write_timeout` bound a whole registry request or response, so a layer transfer longer than these values is cut even while data flows. Raise them for very large layers over slow links. Traffic behind entrypoints (apps and the admin API) uses an inactivity timeout instead; see [Traffic HTTP Timeouts](./traffic.md#http-timeouts). The owner-only local admin socket has no write timeout so log streaming stays open. Changes require a restart. | |
| 73 | + | ||
| 53 | 74 | ## Internal CA and TLS | |
| 54 | 75 | ||
| 55 | 76 | Gordon includes an internal certificate authority that issues on-demand TLS certificates for proxied domains served through HTTPS fallback on a TLS-capable entrypoint such as `smart_tcp`. | |
| @@ -50,6 +50,27 @@ protocol = "smart_tcp" | |||||
| 50 | 50 | ||||
| 51 | `entrypoints.edge.address` is not an HTTP or HTTPS port. It is the TCP address Gordon sniffs for cleartext HTTP, h2c, TLS passthrough, normal HTTPS fallback, or explicit raw TCP fallback. Gordon does not provide a built-in default public port; choose `:443`, a high port such as `:9000`, or container/firewall mappings to match your deployment. | 51 | `entrypoints.edge.address` is not an HTTP or HTTPS port. It is the TCP address Gordon sniffs for cleartext HTTP, h2c, TLS passthrough, normal HTTPS fallback, or explicit raw TCP fallback. Gordon does not provide a built-in default public port; choose `:443`, a high port such as `:9000`, or container/firewall mappings to match your deployment. | ||
| 52 | 52 | ||||
| 53 | + | ## Registry HTTP Timeouts | |||
| 54 | + | ||||
| 55 | + | The registry listeners (`registry_port`, plus the loopback listener used for the internal registry) apply fixed per-connection timeouts. Values are Go duration strings; malformed, zero, or negative values are rejected when the configuration loads. | |||
| 56 | + | ||||
| 57 | + | ```toml | |||
| 58 | + | [server.http] | |||
| 59 | + | read_header_timeout = "10s" | |||
| 60 | + | read_timeout = "5m" | |||
| 61 | + | write_timeout = "5m" | |||
| 62 | + | idle_timeout = "120s" | |||
| 63 | + | ``` | |||
| 64 | + | ||||
| 65 | + | | Option | Env | Default | Description | | |||
| 66 | + | |--------|-----|---------|-------------| | |||
| 67 | + | | `server.http.read_header_timeout` | `GORDON_SERVER_HTTP_READ_HEADER_TIMEOUT` | `"10s"` | Maximum time to read request headers | | |||
| 68 | + | | `server.http.read_timeout` | `GORDON_SERVER_HTTP_READ_TIMEOUT` | `"5m"` | Maximum time to read the whole request, body included | | |||
| 69 | + | | `server.http.write_timeout` | `GORDON_SERVER_HTTP_WRITE_TIMEOUT` | `"5m"` | Maximum time to write the whole response | | |||
| 70 | + | | `server.http.idle_timeout` | `GORDON_SERVER_HTTP_IDLE_TIMEOUT` | `"120s"` | Maximum keep-alive idle time between requests | | |||
| 71 | + | ||||
| 72 | + | `read_timeout` and `write_timeout` bound a whole registry request or response, so a layer transfer longer than these values is cut even while data flows. Raise them for very large layers over slow links. Traffic behind entrypoints (apps and the admin API) uses an inactivity timeout instead; see [Traffic HTTP Timeouts](./traffic.md#http-timeouts). The owner-only local admin socket has no write timeout so log streaming stays open. Changes require a restart. | |||
| 73 | + | ||||
| 53 | ## Internal CA and TLS | 74 | ## Internal CA and TLS | ||
| 54 | 75 | ||||
| 55 | Gordon includes an internal certificate authority that issues on-demand TLS certificates for proxied domains served through HTTPS fallback on a TLS-capable entrypoint such as `smart_tcp`. | 76 | Gordon includes an internal certificate authority that issues on-demand TLS certificates for proxied domains served through HTTPS fallback on a TLS-capable entrypoint such as `smart_tcp`. | ||
M docs/config/traffic.md +30 -0
| @@ -127,6 +127,36 @@ allow_public_raw_fallback = true | |||
| 127 | 127 | ||
| 128 | 128 | `trusted_cidrs` and `raw_fallback_trusted_cidrs` both use the peer socket IP. They do not use `X-Forwarded-For`. | |
| 129 | 129 | ||
| 130 | + | ## HTTP Timeouts | |
| 131 | + | ||
| 132 | + | The HTTP servers behind `smart_tcp` and `tls_mux` entrypoints serve apps and the admin API. They do not use a fixed read or write timeout, so a download or upload that keeps moving data is never cut for taking too long. A connection is cut only after `transfer_idle_timeout` passes with no data in the direction being waited on. | |
| 133 | + | ||
| 134 | + | ```toml | |
| 135 | + | [traffic.http] | |
| 136 | + | read_header_timeout = "5s" | |
| 137 | + | idle_timeout = "30s" | |
| 138 | + | transfer_idle_timeout = "2m" | |
| 139 | + | ``` | |
| 140 | + | ||
| 141 | + | | Option | Env | Default | Description | | |
| 142 | + | |--------|-----|---------|-------------| | |
| 143 | + | | `traffic.http.read_header_timeout` | `GORDON_TRAFFIC_HTTP_READ_HEADER_TIMEOUT` | `"5s"` | Maximum time to read request headers | | |
| 144 | + | | `traffic.http.idle_timeout` | `GORDON_TRAFFIC_HTTP_IDLE_TIMEOUT` | `"30s"` | Maximum keep-alive idle time between requests | | |
| 145 | + | | `traffic.http.transfer_idle_timeout` | `GORDON_TRAFFIC_HTTP_TRANSFER_IDLE_TIMEOUT` | `"2m"` | Inactivity timeout for request bodies and responses | | |
| 146 | + | ||
| 147 | + | Values are Go duration strings; malformed, zero, or negative values are rejected when the configuration loads. Servers pick up changes when the traffic graph is next applied (reload); connections already in flight keep their previous timeout. | |
| 148 | + | ||
| 149 | + | ### Inactivity timeout | |
| 150 | + | ||
| 151 | + | Gordon renews the response write deadline before every write and flush, and the request read deadline before every request body read. The deadline is also armed when a request starts, so the wait for the first response byte from a slow backend is bounded by `transfer_idle_timeout` too. Consequences: | |
| 152 | + | ||
| 153 | + | - a response that streams data at least once per `transfer_idle_timeout` can last arbitrarily long; | |
| 154 | + | - an upload whose body arrives at least once per `transfer_idle_timeout` can last arbitrarily long; | |
| 155 | + | - a backend that stays silent longer than `transfer_idle_timeout` before or between response chunks has its connection cut, so set it above the slowest expected backend response time; | |
| 156 | + | - WebSocket and other upgraded connections are not subject to it, and HTTP/2 streams are handled the same way as HTTP/1. | |
| 157 | + | ||
| 158 | + | `transfer_idle_timeout` does not apply to `[traffic.tcp]` or `[traffic.udp]` routers; those use their own `idle_timeout` (see below). | |
| 159 | + | ||
| 130 | 160 | ## TCP Routers | |
| 131 | 161 | ||
| 132 | 162 | Plain TCP entrypoints support one TCP router per entrypoint. TCP routers on a `smart_tcp` entrypoint are valid only when referenced by that entrypoint's `raw_fallback`. | |
| @@ -127,6 +127,36 @@ allow_public_raw_fallback = true | |||||
| 127 | 127 | ||||
| 128 | `trusted_cidrs` and `raw_fallback_trusted_cidrs` both use the peer socket IP. They do not use `X-Forwarded-For`. | 128 | `trusted_cidrs` and `raw_fallback_trusted_cidrs` both use the peer socket IP. They do not use `X-Forwarded-For`. | ||
| 129 | 129 | ||||
| 130 | + | ## HTTP Timeouts | |||
| 131 | + | ||||
| 132 | + | The HTTP servers behind `smart_tcp` and `tls_mux` entrypoints serve apps and the admin API. They do not use a fixed read or write timeout, so a download or upload that keeps moving data is never cut for taking too long. A connection is cut only after `transfer_idle_timeout` passes with no data in the direction being waited on. | |||
| 133 | + | ||||
| 134 | + | ```toml | |||
| 135 | + | [traffic.http] | |||
| 136 | + | read_header_timeout = "5s" | |||
| 137 | + | idle_timeout = "30s" | |||
| 138 | + | transfer_idle_timeout = "2m" | |||
| 139 | + | ``` | |||
| 140 | + | ||||
| 141 | + | | Option | Env | Default | Description | | |||
| 142 | + | |--------|-----|---------|-------------| | |||
| 143 | + | | `traffic.http.read_header_timeout` | `GORDON_TRAFFIC_HTTP_READ_HEADER_TIMEOUT` | `"5s"` | Maximum time to read request headers | | |||
| 144 | + | | `traffic.http.idle_timeout` | `GORDON_TRAFFIC_HTTP_IDLE_TIMEOUT` | `"30s"` | Maximum keep-alive idle time between requests | | |||
| 145 | + | | `traffic.http.transfer_idle_timeout` | `GORDON_TRAFFIC_HTTP_TRANSFER_IDLE_TIMEOUT` | `"2m"` | Inactivity timeout for request bodies and responses | | |||
| 146 | + | ||||
| 147 | + | Values are Go duration strings; malformed, zero, or negative values are rejected when the configuration loads. Servers pick up changes when the traffic graph is next applied (reload); connections already in flight keep their previous timeout. | |||
| 148 | + | ||||
| 149 | + | ### Inactivity timeout | |||
| 150 | + | ||||
| 151 | + | Gordon renews the response write deadline before every write and flush, and the request read deadline before every request body read. The deadline is also armed when a request starts, so the wait for the first response byte from a slow backend is bounded by `transfer_idle_timeout` too. Consequences: | |||
| 152 | + | ||||
| 153 | + | - a response that streams data at least once per `transfer_idle_timeout` can last arbitrarily long; | |||
| 154 | + | - an upload whose body arrives at least once per `transfer_idle_timeout` can last arbitrarily long; | |||
| 155 | + | - a backend that stays silent longer than `transfer_idle_timeout` before or between response chunks has its connection cut, so set it above the slowest expected backend response time; | |||
| 156 | + | - WebSocket and other upgraded connections are not subject to it, and HTTP/2 streams are handled the same way as HTTP/1. | |||
| 157 | + | ||||
| 158 | + | `transfer_idle_timeout` does not apply to `[traffic.tcp]` or `[traffic.udp]` routers; those use their own `idle_timeout` (see below). | |||
| 159 | + | ||||
| 130 | ## TCP Routers | 160 | ## TCP Routers | ||
| 131 | 161 | ||||
| 132 | Plain TCP entrypoints support one TCP router per entrypoint. TCP routers on a `smart_tcp` entrypoint are valid only when referenced by that entrypoint's `raw_fallback`. | 162 | Plain TCP entrypoints support one TCP router per entrypoint. TCP routers on a `smart_tcp` entrypoint are valid only when referenced by that entrypoint's `raw_fallback`. | ||
A internal/adapters/in/http/middleware/transfer_idle.go +171 -0
| @@ -0,0 +1,171 @@ | |||
| 1 | + | package middleware | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "errors" | |
| 6 | + | "io" | |
| 7 | + | "net" | |
| 8 | + | "net/http" | |
| 9 | + | "sync" | |
| 10 | + | "time" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // TransferIdleTimeout returns a middleware that replaces fixed per-request | |
| 14 | + | // read/write timeouts with an inactivity timeout. The server it wraps must not | |
| 15 | + | // set ReadTimeout or WriteTimeout. | |
| 16 | + | // | |
| 17 | + | // - The write deadline is set to now+idle when the request starts and renewed | |
| 18 | + | // before every Write and Flush. A response is cut when no byte is written | |
| 19 | + | // for idle (the next Write fails because the deadline has expired, or the | |
| 20 | + | // pending Write fails when the client stops reading). | |
| 21 | + | // - The read deadline is renewed before every request body Read, so a slow | |
| 22 | + | // but steady upload is never cut, while a stalled one is. | |
| 23 | + | // | |
| 24 | + | // Deadlines are applied with http.ResponseController, so HTTP/1.x and HTTP/2 | |
| 25 | + | // are both supported. Flush, Hijack (protocol upgrades) and Unwrap keep | |
| 26 | + | // working. A non-positive idle disables the middleware. | |
| 27 | + | func TransferIdleTimeout(idle time.Duration) func(http.Handler) http.Handler { | |
| 28 | + | return func(next http.Handler) http.Handler { | |
| 29 | + | if idle <= 0 { | |
| 30 | + | return next | |
| 31 | + | } | |
| 32 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 33 | + | rc := http.NewResponseController(w) | |
| 34 | + | iw := &idleResponseWriter{ResponseWriter: w, rc: rc, idle: idle} | |
| 35 | + | iw.renew() | |
| 36 | + | if r.Body != nil && r.Body != http.NoBody { | |
| 37 | + | ib := &idleBody{ReadCloser: r.Body, rc: rc, idle: idle, writer: iw} | |
| 38 | + | ib.renew() | |
| 39 | + | r.Body = ib | |
| 40 | + | } | |
| 41 | + | next.ServeHTTP(iw, r) | |
| 42 | + | }) | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // idleResponseWriter renews the write deadline on every Write/Flush. | |
| 47 | + | type idleResponseWriter struct { | |
| 48 | + | http.ResponseWriter | |
| 49 | + | rc *http.ResponseController | |
| 50 | + | idle time.Duration | |
| 51 | + | ||
| 52 | + | mu sync.Mutex | |
| 53 | + | deadline time.Time | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // renew extends the write deadline unless it already expired. An expired | |
| 57 | + | // deadline is left in place so that the write fails: the response was silent | |
| 58 | + | // for longer than the idle timeout. | |
| 59 | + | func (w *idleResponseWriter) renew() { | |
| 60 | + | w.mu.Lock() | |
| 61 | + | defer w.mu.Unlock() | |
| 62 | + | now := time.Now() | |
| 63 | + | if !w.deadline.IsZero() && now.After(w.deadline) { | |
| 64 | + | return | |
| 65 | + | } | |
| 66 | + | next := now.Add(w.idle) | |
| 67 | + | if err := w.rc.SetWriteDeadline(next); err == nil { | |
| 68 | + | w.deadline = next | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // extend pushes the write deadline forward unconditionally. It is called when | |
| 73 | + | // the request body makes progress: a client that is still uploading has not | |
| 74 | + | // left the response idle, so the wait for the first response byte starts from | |
| 75 | + | // the last upload activity. | |
| 76 | + | func (w *idleResponseWriter) extend() { | |
| 77 | + | w.mu.Lock() | |
| 78 | + | defer w.mu.Unlock() | |
| 79 | + | next := time.Now().Add(w.idle) | |
| 80 | + | if err := w.rc.SetWriteDeadline(next); err == nil { | |
| 81 | + | w.deadline = next | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // maxIdleWriteChunk bounds how much is handed to the connection per deadline | |
| 86 | + | // renewal, so one very large Write to a slow but steady client is not cut as if | |
| 87 | + | // it were stalled. | |
| 88 | + | const maxIdleWriteChunk = 64 * 1024 | |
| 89 | + | ||
| 90 | + | // Write renews the write deadline before each chunk, then writes it. | |
| 91 | + | func (w *idleResponseWriter) Write(p []byte) (int, error) { | |
| 92 | + | if len(p) == 0 { | |
| 93 | + | w.renew() | |
| 94 | + | return w.ResponseWriter.Write(p) | |
| 95 | + | } | |
| 96 | + | written := 0 | |
| 97 | + | for written < len(p) { | |
| 98 | + | chunk := p[written:min(written+maxIdleWriteChunk, len(p))] | |
| 99 | + | w.renew() | |
| 100 | + | n, err := w.ResponseWriter.Write(chunk) | |
| 101 | + | written += n | |
| 102 | + | if err != nil { | |
| 103 | + | return written, err | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | return written, nil | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // FlushError renews the write deadline, then flushes. It lets | |
| 110 | + | // http.ResponseController report flush failures. | |
| 111 | + | func (w *idleResponseWriter) FlushError() error { | |
| 112 | + | w.renew() | |
| 113 | + | return w.rc.Flush() | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | // Flush implements http.Flusher. | |
| 117 | + | func (w *idleResponseWriter) Flush() { | |
| 118 | + | _ = w.FlushError() | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // Hijack implements http.Hijacker. The server clears connection deadlines when | |
| 122 | + | // a connection is hijacked, so upgraded streams (e.g. WebSocket) are not | |
| 123 | + | // subject to the inactivity timeout. | |
| 124 | + | func (w *idleResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { | |
| 125 | + | return w.rc.Hijack() | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // Unwrap exposes the underlying writer to http.ResponseController. | |
| 129 | + | func (w *idleResponseWriter) Unwrap() http.ResponseWriter { | |
| 130 | + | return w.ResponseWriter | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | // idleBody renews the read deadline before every request body Read. | |
| 134 | + | type idleBody struct { | |
| 135 | + | io.ReadCloser | |
| 136 | + | rc *http.ResponseController | |
| 137 | + | idle time.Duration | |
| 138 | + | writer *idleResponseWriter | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | func (b *idleBody) renew() { | |
| 142 | + | _ = b.rc.SetReadDeadline(time.Now().Add(b.idle)) | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | // clear removes the read deadline. It must not stay armed once the body is | |
| 146 | + | // fully read: net/http then watches the connection for client disconnects and | |
| 147 | + | // treats a read timeout as an abort. | |
| 148 | + | func (b *idleBody) clear() { | |
| 149 | + | _ = b.rc.SetReadDeadline(time.Time{}) | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | // Read renews the read deadline, reads, and clears the deadline at EOF. | |
| 153 | + | func (b *idleBody) Read(p []byte) (int, error) { | |
| 154 | + | b.renew() | |
| 155 | + | n, err := b.ReadCloser.Read(p) | |
| 156 | + | if n > 0 || errors.Is(err, io.EOF) { | |
| 157 | + | b.writer.extend() | |
| 158 | + | } | |
| 159 | + | if errors.Is(err, io.EOF) { | |
| 160 | + | b.clear() | |
| 161 | + | } | |
| 162 | + | return n, err | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | // Close drains within a fresh deadline, then clears it. | |
| 166 | + | func (b *idleBody) Close() error { | |
| 167 | + | b.renew() | |
| 168 | + | err := b.ReadCloser.Close() | |
| 169 | + | b.clear() | |
| 170 | + | return err | |
| 171 | + | } | |
| @@ -0,0 +1,171 @@ | |||||
| 1 | + | package middleware | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bufio" | |||
| 5 | + | "errors" | |||
| 6 | + | "io" | |||
| 7 | + | "net" | |||
| 8 | + | "net/http" | |||
| 9 | + | "sync" | |||
| 10 | + | "time" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // TransferIdleTimeout returns a middleware that replaces fixed per-request | |||
| 14 | + | // read/write timeouts with an inactivity timeout. The server it wraps must not | |||
| 15 | + | // set ReadTimeout or WriteTimeout. | |||
| 16 | + | // | |||
| 17 | + | // - The write deadline is set to now+idle when the request starts and renewed | |||
| 18 | + | // before every Write and Flush. A response is cut when no byte is written | |||
| 19 | + | // for idle (the next Write fails because the deadline has expired, or the | |||
| 20 | + | // pending Write fails when the client stops reading). | |||
| 21 | + | // - The read deadline is renewed before every request body Read, so a slow | |||
| 22 | + | // but steady upload is never cut, while a stalled one is. | |||
| 23 | + | // | |||
| 24 | + | // Deadlines are applied with http.ResponseController, so HTTP/1.x and HTTP/2 | |||
| 25 | + | // are both supported. Flush, Hijack (protocol upgrades) and Unwrap keep | |||
| 26 | + | // working. A non-positive idle disables the middleware. | |||
| 27 | + | func TransferIdleTimeout(idle time.Duration) func(http.Handler) http.Handler { | |||
| 28 | + | return func(next http.Handler) http.Handler { | |||
| 29 | + | if idle <= 0 { | |||
| 30 | + | return next | |||
| 31 | + | } | |||
| 32 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 33 | + | rc := http.NewResponseController(w) | |||
| 34 | + | iw := &idleResponseWriter{ResponseWriter: w, rc: rc, idle: idle} | |||
| 35 | + | iw.renew() | |||
| 36 | + | if r.Body != nil && r.Body != http.NoBody { | |||
| 37 | + | ib := &idleBody{ReadCloser: r.Body, rc: rc, idle: idle, writer: iw} | |||
| 38 | + | ib.renew() | |||
| 39 | + | r.Body = ib | |||
| 40 | + | } | |||
| 41 | + | next.ServeHTTP(iw, r) | |||
| 42 | + | }) | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // idleResponseWriter renews the write deadline on every Write/Flush. | |||
| 47 | + | type idleResponseWriter struct { | |||
| 48 | + | http.ResponseWriter | |||
| 49 | + | rc *http.ResponseController | |||
| 50 | + | idle time.Duration | |||
| 51 | + | ||||
| 52 | + | mu sync.Mutex | |||
| 53 | + | deadline time.Time | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | // renew extends the write deadline unless it already expired. An expired | |||
| 57 | + | // deadline is left in place so that the write fails: the response was silent | |||
| 58 | + | // for longer than the idle timeout. | |||
| 59 | + | func (w *idleResponseWriter) renew() { | |||
| 60 | + | w.mu.Lock() | |||
| 61 | + | defer w.mu.Unlock() | |||
| 62 | + | now := time.Now() | |||
| 63 | + | if !w.deadline.IsZero() && now.After(w.deadline) { | |||
| 64 | + | return | |||
| 65 | + | } | |||
| 66 | + | next := now.Add(w.idle) | |||
| 67 | + | if err := w.rc.SetWriteDeadline(next); err == nil { | |||
| 68 | + | w.deadline = next | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // extend pushes the write deadline forward unconditionally. It is called when | |||
| 73 | + | // the request body makes progress: a client that is still uploading has not | |||
| 74 | + | // left the response idle, so the wait for the first response byte starts from | |||
| 75 | + | // the last upload activity. | |||
| 76 | + | func (w *idleResponseWriter) extend() { | |||
| 77 | + | w.mu.Lock() | |||
| 78 | + | defer w.mu.Unlock() | |||
| 79 | + | next := time.Now().Add(w.idle) | |||
| 80 | + | if err := w.rc.SetWriteDeadline(next); err == nil { | |||
| 81 | + | w.deadline = next | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | // maxIdleWriteChunk bounds how much is handed to the connection per deadline | |||
| 86 | + | // renewal, so one very large Write to a slow but steady client is not cut as if | |||
| 87 | + | // it were stalled. | |||
| 88 | + | const maxIdleWriteChunk = 64 * 1024 | |||
| 89 | + | ||||
| 90 | + | // Write renews the write deadline before each chunk, then writes it. | |||
| 91 | + | func (w *idleResponseWriter) Write(p []byte) (int, error) { | |||
| 92 | + | if len(p) == 0 { | |||
| 93 | + | w.renew() | |||
| 94 | + | return w.ResponseWriter.Write(p) | |||
| 95 | + | } | |||
| 96 | + | written := 0 | |||
| 97 | + | for written < len(p) { | |||
| 98 | + | chunk := p[written:min(written+maxIdleWriteChunk, len(p))] | |||
| 99 | + | w.renew() | |||
| 100 | + | n, err := w.ResponseWriter.Write(chunk) | |||
| 101 | + | written += n | |||
| 102 | + | if err != nil { | |||
| 103 | + | return written, err | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | return written, nil | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | // FlushError renews the write deadline, then flushes. It lets | |||
| 110 | + | // http.ResponseController report flush failures. | |||
| 111 | + | func (w *idleResponseWriter) FlushError() error { | |||
| 112 | + | w.renew() | |||
| 113 | + | return w.rc.Flush() | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | // Flush implements http.Flusher. | |||
| 117 | + | func (w *idleResponseWriter) Flush() { | |||
| 118 | + | _ = w.FlushError() | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | // Hijack implements http.Hijacker. The server clears connection deadlines when | |||
| 122 | + | // a connection is hijacked, so upgraded streams (e.g. WebSocket) are not | |||
| 123 | + | // subject to the inactivity timeout. | |||
| 124 | + | func (w *idleResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { | |||
| 125 | + | return w.rc.Hijack() | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | // Unwrap exposes the underlying writer to http.ResponseController. | |||
| 129 | + | func (w *idleResponseWriter) Unwrap() http.ResponseWriter { | |||
| 130 | + | return w.ResponseWriter | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | // idleBody renews the read deadline before every request body Read. | |||
| 134 | + | type idleBody struct { | |||
| 135 | + | io.ReadCloser | |||
| 136 | + | rc *http.ResponseController | |||
| 137 | + | idle time.Duration | |||
| 138 | + | writer *idleResponseWriter | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | func (b *idleBody) renew() { | |||
| 142 | + | _ = b.rc.SetReadDeadline(time.Now().Add(b.idle)) | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | // clear removes the read deadline. It must not stay armed once the body is | |||
| 146 | + | // fully read: net/http then watches the connection for client disconnects and | |||
| 147 | + | // treats a read timeout as an abort. | |||
| 148 | + | func (b *idleBody) clear() { | |||
| 149 | + | _ = b.rc.SetReadDeadline(time.Time{}) | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | // Read renews the read deadline, reads, and clears the deadline at EOF. | |||
| 153 | + | func (b *idleBody) Read(p []byte) (int, error) { | |||
| 154 | + | b.renew() | |||
| 155 | + | n, err := b.ReadCloser.Read(p) | |||
| 156 | + | if n > 0 || errors.Is(err, io.EOF) { | |||
| 157 | + | b.writer.extend() | |||
| 158 | + | } | |||
| 159 | + | if errors.Is(err, io.EOF) { | |||
| 160 | + | b.clear() | |||
| 161 | + | } | |||
| 162 | + | return n, err | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | // Close drains within a fresh deadline, then clears it. | |||
| 166 | + | func (b *idleBody) Close() error { | |||
| 167 | + | b.renew() | |||
| 168 | + | err := b.ReadCloser.Close() | |||
| 169 | + | b.clear() | |||
| 170 | + | return err | |||
| 171 | + | } | |||
A internal/adapters/in/http/middleware/transfer_idle_test.go +229 -0
| @@ -0,0 +1,229 @@ | |||
| 1 | + | package middleware | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "io" | |
| 6 | + | "net" | |
| 7 | + | "net/http" | |
| 8 | + | "net/http/httptest" | |
| 9 | + | "strings" | |
| 10 | + | "sync/atomic" | |
| 11 | + | "testing" | |
| 12 | + | "time" | |
| 13 | + | ||
| 14 | + | "github.com/stretchr/testify/assert" | |
| 15 | + | "github.com/stretchr/testify/require" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | const testIdle = 300 * time.Millisecond | |
| 19 | + | ||
| 20 | + | func newIdleServer(t *testing.T, handler http.Handler) *httptest.Server { | |
| 21 | + | t.Helper() | |
| 22 | + | srv := httptest.NewUnstartedServer(TransferIdleTimeout(testIdle)(handler)) | |
| 23 | + | srv.Config.ReadHeaderTimeout = 5 * time.Second | |
| 24 | + | srv.Start() | |
| 25 | + | t.Cleanup(srv.Close) | |
| 26 | + | return srv | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func TestTransferIdleTimeout_SlowActiveDownloadCompletes(t *testing.T) { | |
| 30 | + | const chunks = 12 // 12 x 50ms = 600ms, twice the idle timeout | |
| 31 | + | srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 32 | + | for range chunks { | |
| 33 | + | _, _ = w.Write([]byte("x")) | |
| 34 | + | w.(http.Flusher).Flush() | |
| 35 | + | time.Sleep(50 * time.Millisecond) | |
| 36 | + | } | |
| 37 | + | })) | |
| 38 | + | ||
| 39 | + | start := time.Now() | |
| 40 | + | resp, err := http.Get(srv.URL) | |
| 41 | + | require.NoError(t, err) | |
| 42 | + | defer resp.Body.Close() | |
| 43 | + | body, err := io.ReadAll(resp.Body) | |
| 44 | + | require.NoError(t, err) | |
| 45 | + | assert.Len(t, body, chunks) | |
| 46 | + | assert.Greater(t, time.Since(start), testIdle, "transfer must outlast the idle timeout") | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func TestTransferIdleTimeout_SilentResponseIsCut(t *testing.T) { | |
| 50 | + | srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 51 | + | _, _ = w.Write([]byte("start")) | |
| 52 | + | w.(http.Flusher).Flush() | |
| 53 | + | time.Sleep(3 * testIdle) | |
| 54 | + | _, _ = w.Write([]byte("late")) | |
| 55 | + | w.(http.Flusher).Flush() | |
| 56 | + | })) | |
| 57 | + | ||
| 58 | + | resp, err := http.Get(srv.URL) | |
| 59 | + | require.NoError(t, err) | |
| 60 | + | defer resp.Body.Close() | |
| 61 | + | body, err := io.ReadAll(resp.Body) | |
| 62 | + | require.Error(t, err, "connection must be cut after the idle timeout") | |
| 63 | + | assert.Equal(t, "start", string(body)) | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func TestTransferIdleTimeout_SilentHandlerBeforeFirstByteIsCut(t *testing.T) { | |
| 67 | + | srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 68 | + | time.Sleep(3 * testIdle) | |
| 69 | + | _, _ = w.Write([]byte("late")) | |
| 70 | + | })) | |
| 71 | + | ||
| 72 | + | resp, err := http.Get(srv.URL) | |
| 73 | + | if err == nil { | |
| 74 | + | defer resp.Body.Close() | |
| 75 | + | _, err = io.ReadAll(resp.Body) | |
| 76 | + | } | |
| 77 | + | require.Error(t, err) | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func TestTransferIdleTimeout_SlowActiveUploadCompletes(t *testing.T) { | |
| 81 | + | const chunks = 10 // 10 x 60ms = 600ms | |
| 82 | + | srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 83 | + | n, err := io.Copy(io.Discard, r.Body) | |
| 84 | + | if err != nil { | |
| 85 | + | http.Error(w, err.Error(), http.StatusBadRequest) | |
| 86 | + | return | |
| 87 | + | } | |
| 88 | + | _, _ = w.Write([]byte(strings.Repeat("o", int(n)))) | |
| 89 | + | })) | |
| 90 | + | ||
| 91 | + | pr, pw := io.Pipe() | |
| 92 | + | go func() { | |
| 93 | + | for range chunks { | |
| 94 | + | _, _ = pw.Write([]byte("u")) | |
| 95 | + | time.Sleep(60 * time.Millisecond) | |
| 96 | + | } | |
| 97 | + | _ = pw.Close() | |
| 98 | + | }() | |
| 99 | + | resp, err := http.Post(srv.URL, "application/octet-stream", pr) | |
| 100 | + | require.NoError(t, err) | |
| 101 | + | defer resp.Body.Close() | |
| 102 | + | body, err := io.ReadAll(resp.Body) | |
| 103 | + | require.NoError(t, err) | |
| 104 | + | assert.Equal(t, http.StatusOK, resp.StatusCode) | |
| 105 | + | assert.Len(t, body, chunks) | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func TestTransferIdleTimeout_StalledUploadIsCut(t *testing.T) { | |
| 109 | + | var readErr atomic.Value | |
| 110 | + | done := make(chan struct{}) | |
| 111 | + | srv := newIdleServer(t, http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { | |
| 112 | + | defer close(done) | |
| 113 | + | _, err := io.Copy(io.Discard, r.Body) | |
| 114 | + | if err != nil { | |
| 115 | + | readErr.Store(err) | |
| 116 | + | } | |
| 117 | + | })) | |
| 118 | + | ||
| 119 | + | conn, err := net.Dial("tcp", srv.Listener.Addr().String()) | |
| 120 | + | require.NoError(t, err) | |
| 121 | + | defer conn.Close() | |
| 122 | + | _, err = conn.Write([]byte("POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 100\r\n\r\nabc")) | |
| 123 | + | require.NoError(t, err) | |
| 124 | + | ||
| 125 | + | select { | |
| 126 | + | case <-done: | |
| 127 | + | case <-time.After(5 * testIdle): | |
| 128 | + | t.Fatal("stalled upload was not cut") | |
| 129 | + | } | |
| 130 | + | assert.NotNil(t, readErr.Load(), "body read must fail after the idle timeout") | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func TestTransferIdleTimeout_PreservesFlusherAndUnwrap(t *testing.T) { | |
| 134 | + | var isFlusher, unwrapped bool | |
| 135 | + | h := TransferIdleTimeout(time.Second)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 136 | + | _, isFlusher = w.(http.Flusher) | |
| 137 | + | _, unwrapped = w.(interface{ Unwrap() http.ResponseWriter }) | |
| 138 | + | })) | |
| 139 | + | h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil)) | |
| 140 | + | assert.True(t, isFlusher) | |
| 141 | + | assert.True(t, unwrapped) | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | func TestTransferIdleTimeout_HijackStillWorks(t *testing.T) { | |
| 145 | + | srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 146 | + | hj, ok := w.(http.Hijacker) | |
| 147 | + | if !ok { | |
| 148 | + | http.Error(w, "no hijacker", http.StatusInternalServerError) | |
| 149 | + | return | |
| 150 | + | } | |
| 151 | + | conn, rw, err := hj.Hijack() | |
| 152 | + | if err != nil { | |
| 153 | + | return | |
| 154 | + | } | |
| 155 | + | defer conn.Close() | |
| 156 | + | _, _ = rw.WriteString("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: test\r\n\r\n") | |
| 157 | + | _ = rw.Flush() | |
| 158 | + | // Stay open longer than the idle timeout: hijacked streams are exempt. | |
| 159 | + | time.Sleep(2 * testIdle) | |
| 160 | + | _, _ = rw.WriteString("hello") | |
| 161 | + | _ = rw.Flush() | |
| 162 | + | })) | |
| 163 | + | ||
| 164 | + | conn, err := net.Dial("tcp", srv.Listener.Addr().String()) | |
| 165 | + | require.NoError(t, err) | |
| 166 | + | defer conn.Close() | |
| 167 | + | _, err = conn.Write([]byte("GET / HTTP/1.1\r\nHost: x\r\nConnection: Upgrade\r\nUpgrade: test\r\n\r\n")) | |
| 168 | + | require.NoError(t, err) | |
| 169 | + | ||
| 170 | + | br := bufio.NewReader(conn) | |
| 171 | + | resp, err := http.ReadResponse(br, nil) | |
| 172 | + | require.NoError(t, err) | |
| 173 | + | assert.Equal(t, http.StatusSwitchingProtocols, resp.StatusCode) | |
| 174 | + | buf := make([]byte, 5) | |
| 175 | + | _, err = io.ReadFull(br, buf) | |
| 176 | + | require.NoError(t, err) | |
| 177 | + | assert.Equal(t, "hello", string(buf)) | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | func TestTransferIdleTimeout_ReverseProxyFlushesStream(t *testing.T) { | |
| 181 | + | // Flush propagation through a wrapped writer, as used by httputil.ReverseProxy. | |
| 182 | + | srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 183 | + | rc := http.NewResponseController(w) | |
| 184 | + | for range 8 { | |
| 185 | + | _, _ = w.Write([]byte(".")) | |
| 186 | + | if err := rc.Flush(); err != nil { | |
| 187 | + | return | |
| 188 | + | } | |
| 189 | + | time.Sleep(50 * time.Millisecond) | |
| 190 | + | } | |
| 191 | + | })) | |
| 192 | + | resp, err := http.Get(srv.URL) | |
| 193 | + | require.NoError(t, err) | |
| 194 | + | defer resp.Body.Close() | |
| 195 | + | body, err := io.ReadAll(resp.Body) | |
| 196 | + | require.NoError(t, err) | |
| 197 | + | assert.Len(t, body, 8) | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | func TestTransferIdleTimeout_DisabledWhenNonPositive(t *testing.T) { | |
| 201 | + | called := false | |
| 202 | + | inner := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true }) | |
| 203 | + | h := TransferIdleTimeout(0)(inner) | |
| 204 | + | h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil)) | |
| 205 | + | assert.True(t, called) | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | func TestTransferIdleTimeout_HTTP2(t *testing.T) { | |
| 209 | + | const chunks = 12 | |
| 210 | + | srv := httptest.NewUnstartedServer(TransferIdleTimeout(testIdle)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 211 | + | for range chunks { | |
| 212 | + | _, _ = w.Write([]byte("x")) | |
| 213 | + | w.(http.Flusher).Flush() | |
| 214 | + | time.Sleep(50 * time.Millisecond) | |
| 215 | + | } | |
| 216 | + | }))) | |
| 217 | + | srv.EnableHTTP2 = true | |
| 218 | + | srv.StartTLS() | |
| 219 | + | defer srv.Close() | |
| 220 | + | ||
| 221 | + | client := srv.Client() | |
| 222 | + | resp, err := client.Get(srv.URL) | |
| 223 | + | require.NoError(t, err) | |
| 224 | + | defer resp.Body.Close() | |
| 225 | + | assert.Equal(t, 2, resp.ProtoMajor) | |
| 226 | + | body, err := io.ReadAll(resp.Body) | |
| 227 | + | require.NoError(t, err) | |
| 228 | + | assert.Len(t, body, chunks) | |
| 229 | + | } | |
| @@ -0,0 +1,229 @@ | |||||
| 1 | + | package middleware | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bufio" | |||
| 5 | + | "io" | |||
| 6 | + | "net" | |||
| 7 | + | "net/http" | |||
| 8 | + | "net/http/httptest" | |||
| 9 | + | "strings" | |||
| 10 | + | "sync/atomic" | |||
| 11 | + | "testing" | |||
| 12 | + | "time" | |||
| 13 | + | ||||
| 14 | + | "github.com/stretchr/testify/assert" | |||
| 15 | + | "github.com/stretchr/testify/require" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | const testIdle = 300 * time.Millisecond | |||
| 19 | + | ||||
| 20 | + | func newIdleServer(t *testing.T, handler http.Handler) *httptest.Server { | |||
| 21 | + | t.Helper() | |||
| 22 | + | srv := httptest.NewUnstartedServer(TransferIdleTimeout(testIdle)(handler)) | |||
| 23 | + | srv.Config.ReadHeaderTimeout = 5 * time.Second | |||
| 24 | + | srv.Start() | |||
| 25 | + | t.Cleanup(srv.Close) | |||
| 26 | + | return srv | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func TestTransferIdleTimeout_SlowActiveDownloadCompletes(t *testing.T) { | |||
| 30 | + | const chunks = 12 // 12 x 50ms = 600ms, twice the idle timeout | |||
| 31 | + | srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 32 | + | for range chunks { | |||
| 33 | + | _, _ = w.Write([]byte("x")) | |||
| 34 | + | w.(http.Flusher).Flush() | |||
| 35 | + | time.Sleep(50 * time.Millisecond) | |||
| 36 | + | } | |||
| 37 | + | })) | |||
| 38 | + | ||||
| 39 | + | start := time.Now() | |||
| 40 | + | resp, err := http.Get(srv.URL) | |||
| 41 | + | require.NoError(t, err) | |||
| 42 | + | defer resp.Body.Close() | |||
| 43 | + | body, err := io.ReadAll(resp.Body) | |||
| 44 | + | require.NoError(t, err) | |||
| 45 | + | assert.Len(t, body, chunks) | |||
| 46 | + | assert.Greater(t, time.Since(start), testIdle, "transfer must outlast the idle timeout") | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | func TestTransferIdleTimeout_SilentResponseIsCut(t *testing.T) { | |||
| 50 | + | srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 51 | + | _, _ = w.Write([]byte("start")) | |||
| 52 | + | w.(http.Flusher).Flush() | |||
| 53 | + | time.Sleep(3 * testIdle) | |||
| 54 | + | _, _ = w.Write([]byte("late")) | |||
| 55 | + | w.(http.Flusher).Flush() | |||
| 56 | + | })) | |||
| 57 | + | ||||
| 58 | + | resp, err := http.Get(srv.URL) | |||
| 59 | + | require.NoError(t, err) | |||
| 60 | + | defer resp.Body.Close() | |||
| 61 | + | body, err := io.ReadAll(resp.Body) | |||
| 62 | + | require.Error(t, err, "connection must be cut after the idle timeout") | |||
| 63 | + | assert.Equal(t, "start", string(body)) | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func TestTransferIdleTimeout_SilentHandlerBeforeFirstByteIsCut(t *testing.T) { | |||
| 67 | + | srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 68 | + | time.Sleep(3 * testIdle) | |||
| 69 | + | _, _ = w.Write([]byte("late")) | |||
| 70 | + | })) | |||
| 71 | + | ||||
| 72 | + | resp, err := http.Get(srv.URL) | |||
| 73 | + | if err == nil { | |||
| 74 | + | defer resp.Body.Close() | |||
| 75 | + | _, err = io.ReadAll(resp.Body) | |||
| 76 | + | } | |||
| 77 | + | require.Error(t, err) | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func TestTransferIdleTimeout_SlowActiveUploadCompletes(t *testing.T) { | |||
| 81 | + | const chunks = 10 // 10 x 60ms = 600ms | |||
| 82 | + | srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 83 | + | n, err := io.Copy(io.Discard, r.Body) | |||
| 84 | + | if err != nil { | |||
| 85 | + | http.Error(w, err.Error(), http.StatusBadRequest) | |||
| 86 | + | return | |||
| 87 | + | } | |||
| 88 | + | _, _ = w.Write([]byte(strings.Repeat("o", int(n)))) | |||
| 89 | + | })) | |||
| 90 | + | ||||
| 91 | + | pr, pw := io.Pipe() | |||
| 92 | + | go func() { | |||
| 93 | + | for range chunks { | |||
| 94 | + | _, _ = pw.Write([]byte("u")) | |||
| 95 | + | time.Sleep(60 * time.Millisecond) | |||
| 96 | + | } | |||
| 97 | + | _ = pw.Close() | |||
| 98 | + | }() | |||
| 99 | + | resp, err := http.Post(srv.URL, "application/octet-stream", pr) | |||
| 100 | + | require.NoError(t, err) | |||
| 101 | + | defer resp.Body.Close() | |||
| 102 | + | body, err := io.ReadAll(resp.Body) | |||
| 103 | + | require.NoError(t, err) | |||
| 104 | + | assert.Equal(t, http.StatusOK, resp.StatusCode) | |||
| 105 | + | assert.Len(t, body, chunks) | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | func TestTransferIdleTimeout_StalledUploadIsCut(t *testing.T) { | |||
| 109 | + | var readErr atomic.Value | |||
| 110 | + | done := make(chan struct{}) | |||
| 111 | + | srv := newIdleServer(t, http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { | |||
| 112 | + | defer close(done) | |||
| 113 | + | _, err := io.Copy(io.Discard, r.Body) | |||
| 114 | + | if err != nil { | |||
| 115 | + | readErr.Store(err) | |||
| 116 | + | } | |||
| 117 | + | })) | |||
| 118 | + | ||||
| 119 | + | conn, err := net.Dial("tcp", srv.Listener.Addr().String()) | |||
| 120 | + | require.NoError(t, err) | |||
| 121 | + | defer conn.Close() | |||
| 122 | + | _, err = conn.Write([]byte("POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 100\r\n\r\nabc")) | |||
| 123 | + | require.NoError(t, err) | |||
| 124 | + | ||||
| 125 | + | select { | |||
| 126 | + | case <-done: | |||
| 127 | + | case <-time.After(5 * testIdle): | |||
| 128 | + | t.Fatal("stalled upload was not cut") | |||
| 129 | + | } | |||
| 130 | + | assert.NotNil(t, readErr.Load(), "body read must fail after the idle timeout") | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | func TestTransferIdleTimeout_PreservesFlusherAndUnwrap(t *testing.T) { | |||
| 134 | + | var isFlusher, unwrapped bool | |||
| 135 | + | h := TransferIdleTimeout(time.Second)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 136 | + | _, isFlusher = w.(http.Flusher) | |||
| 137 | + | _, unwrapped = w.(interface{ Unwrap() http.ResponseWriter }) | |||
| 138 | + | })) | |||
| 139 | + | h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil)) | |||
| 140 | + | assert.True(t, isFlusher) | |||
| 141 | + | assert.True(t, unwrapped) | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | func TestTransferIdleTimeout_HijackStillWorks(t *testing.T) { | |||
| 145 | + | srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 146 | + | hj, ok := w.(http.Hijacker) | |||
| 147 | + | if !ok { | |||
| 148 | + | http.Error(w, "no hijacker", http.StatusInternalServerError) | |||
| 149 | + | return | |||
| 150 | + | } | |||
| 151 | + | conn, rw, err := hj.Hijack() | |||
| 152 | + | if err != nil { | |||
| 153 | + | return | |||
| 154 | + | } | |||
| 155 | + | defer conn.Close() | |||
| 156 | + | _, _ = rw.WriteString("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: test\r\n\r\n") | |||
| 157 | + | _ = rw.Flush() | |||
| 158 | + | // Stay open longer than the idle timeout: hijacked streams are exempt. | |||
| 159 | + | time.Sleep(2 * testIdle) | |||
| 160 | + | _, _ = rw.WriteString("hello") | |||
| 161 | + | _ = rw.Flush() | |||
| 162 | + | })) | |||
| 163 | + | ||||
| 164 | + | conn, err := net.Dial("tcp", srv.Listener.Addr().String()) | |||
| 165 | + | require.NoError(t, err) | |||
| 166 | + | defer conn.Close() | |||
| 167 | + | _, err = conn.Write([]byte("GET / HTTP/1.1\r\nHost: x\r\nConnection: Upgrade\r\nUpgrade: test\r\n\r\n")) | |||
| 168 | + | require.NoError(t, err) | |||
| 169 | + | ||||
| 170 | + | br := bufio.NewReader(conn) | |||
| 171 | + | resp, err := http.ReadResponse(br, nil) | |||
| 172 | + | require.NoError(t, err) | |||
| 173 | + | assert.Equal(t, http.StatusSwitchingProtocols, resp.StatusCode) | |||
| 174 | + | buf := make([]byte, 5) | |||
| 175 | + | _, err = io.ReadFull(br, buf) | |||
| 176 | + | require.NoError(t, err) | |||
| 177 | + | assert.Equal(t, "hello", string(buf)) | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | func TestTransferIdleTimeout_ReverseProxyFlushesStream(t *testing.T) { | |||
| 181 | + | // Flush propagation through a wrapped writer, as used by httputil.ReverseProxy. | |||
| 182 | + | srv := newIdleServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 183 | + | rc := http.NewResponseController(w) | |||
| 184 | + | for range 8 { | |||
| 185 | + | _, _ = w.Write([]byte(".")) | |||
| 186 | + | if err := rc.Flush(); err != nil { | |||
| 187 | + | return | |||
| 188 | + | } | |||
| 189 | + | time.Sleep(50 * time.Millisecond) | |||
| 190 | + | } | |||
| 191 | + | })) | |||
| 192 | + | resp, err := http.Get(srv.URL) | |||
| 193 | + | require.NoError(t, err) | |||
| 194 | + | defer resp.Body.Close() | |||
| 195 | + | body, err := io.ReadAll(resp.Body) | |||
| 196 | + | require.NoError(t, err) | |||
| 197 | + | assert.Len(t, body, 8) | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | func TestTransferIdleTimeout_DisabledWhenNonPositive(t *testing.T) { | |||
| 201 | + | called := false | |||
| 202 | + | inner := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true }) | |||
| 203 | + | h := TransferIdleTimeout(0)(inner) | |||
| 204 | + | h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil)) | |||
| 205 | + | assert.True(t, called) | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | func TestTransferIdleTimeout_HTTP2(t *testing.T) { | |||
| 209 | + | const chunks = 12 | |||
| 210 | + | srv := httptest.NewUnstartedServer(TransferIdleTimeout(testIdle)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 211 | + | for range chunks { | |||
| 212 | + | _, _ = w.Write([]byte("x")) | |||
| 213 | + | w.(http.Flusher).Flush() | |||
| 214 | + | time.Sleep(50 * time.Millisecond) | |||
| 215 | + | } | |||
| 216 | + | }))) | |||
| 217 | + | srv.EnableHTTP2 = true | |||
| 218 | + | srv.StartTLS() | |||
| 219 | + | defer srv.Close() | |||
| 220 | + | ||||
| 221 | + | client := srv.Client() | |||
| 222 | + | resp, err := client.Get(srv.URL) | |||
| 223 | + | require.NoError(t, err) | |||
| 224 | + | defer resp.Body.Close() | |||
| 225 | + | assert.Equal(t, 2, resp.ProtoMajor) | |||
| 226 | + | body, err := io.ReadAll(resp.Body) | |||
| 227 | + | require.NoError(t, err) | |||
| 228 | + | assert.Len(t, body, chunks) | |||
| 229 | + | } | |||
A internal/adapters/in/traffic/http_server.go +31 -0
| @@ -0,0 +1,31 @@ | |||
| 1 | + | package traffic | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "net/http" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/gordon/internal/adapters/in/http/middleware" | |
| 7 | + | "github.com/bnema/gordon/internal/domain" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // httpOptions returns the effective HTTP server options of the current traffic | |
| 11 | + | // snapshot, falling back to the defaults before the first graph is applied. | |
| 12 | + | func (m *Manager) httpOptions() domain.HTTPOptions { | |
| 13 | + | graph := m.snapshot.Load() | |
| 14 | + | if graph == nil { | |
| 15 | + | return domain.DefaultHTTPOptions() | |
| 16 | + | } | |
| 17 | + | return graph.Options.HTTP.WithDefaults() | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // newEntryPointHTTPServer builds an HTTP server for a traffic entrypoint. | |
| 21 | + | // ReadTimeout and WriteTimeout stay zero: whole-request deadlines would cut | |
| 22 | + | // long downloads and uploads that are still moving data. The handler is | |
| 23 | + | // wrapped with the inactivity timeout instead, which renews the read and write | |
| 24 | + | // deadlines as data flows. | |
| 25 | + | func newEntryPointHTTPServer(handler http.Handler, options domain.HTTPOptions) *http.Server { | |
| 26 | + | return &http.Server{ | |
| 27 | + | Handler: middleware.TransferIdleTimeout(options.TransferIdleTimeout)(handler), | |
| 28 | + | ReadHeaderTimeout: options.ReadHeaderTimeout, | |
| 29 | + | IdleTimeout: options.IdleTimeout, | |
| 30 | + | } | |
| 31 | + | } | |
| @@ -0,0 +1,31 @@ | |||||
| 1 | + | package traffic | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "net/http" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/gordon/internal/adapters/in/http/middleware" | |||
| 7 | + | "github.com/bnema/gordon/internal/domain" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // httpOptions returns the effective HTTP server options of the current traffic | |||
| 11 | + | // snapshot, falling back to the defaults before the first graph is applied. | |||
| 12 | + | func (m *Manager) httpOptions() domain.HTTPOptions { | |||
| 13 | + | graph := m.snapshot.Load() | |||
| 14 | + | if graph == nil { | |||
| 15 | + | return domain.DefaultHTTPOptions() | |||
| 16 | + | } | |||
| 17 | + | return graph.Options.HTTP.WithDefaults() | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // newEntryPointHTTPServer builds an HTTP server for a traffic entrypoint. | |||
| 21 | + | // ReadTimeout and WriteTimeout stay zero: whole-request deadlines would cut | |||
| 22 | + | // long downloads and uploads that are still moving data. The handler is | |||
| 23 | + | // wrapped with the inactivity timeout instead, which renews the read and write | |||
| 24 | + | // deadlines as data flows. | |||
| 25 | + | func newEntryPointHTTPServer(handler http.Handler, options domain.HTTPOptions) *http.Server { | |||
| 26 | + | return &http.Server{ | |||
| 27 | + | Handler: middleware.TransferIdleTimeout(options.TransferIdleTimeout)(handler), | |||
| 28 | + | ReadHeaderTimeout: options.ReadHeaderTimeout, | |||
| 29 | + | IdleTimeout: options.IdleTimeout, | |||
| 30 | + | } | |||
| 31 | + | } | |||
A internal/adapters/in/traffic/http_server_test.go +118 -0
| @@ -0,0 +1,118 @@ | |||
| 1 | + | package traffic | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "context" | |
| 6 | + | "io" | |
| 7 | + | "net/http" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/stretchr/testify/assert" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/gordon/internal/domain" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | func TestNewEntryPointHTTPServerUsesConfiguredTimeouts(t *testing.T) { | |
| 18 | + | server := newEntryPointHTTPServer(http.NotFoundHandler(), domain.HTTPOptions{ | |
| 19 | + | ReadHeaderTimeout: 3 * time.Second, | |
| 20 | + | IdleTimeout: 7 * time.Second, | |
| 21 | + | TransferIdleTimeout: 9 * time.Second, | |
| 22 | + | }) | |
| 23 | + | ||
| 24 | + | assert.Equal(t, 3*time.Second, server.ReadHeaderTimeout) | |
| 25 | + | assert.Equal(t, 7*time.Second, server.IdleTimeout) | |
| 26 | + | assert.Zero(t, server.ReadTimeout, "whole-request read timeout must be replaced by the inactivity deadline") | |
| 27 | + | assert.Zero(t, server.WriteTimeout, "whole-response write timeout must be replaced by the inactivity deadline") | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func TestManagerHTTPOptionsDefaultsWithoutSnapshot(t *testing.T) { | |
| 31 | + | assert.Equal(t, domain.DefaultHTTPOptions(), NewManager().httpOptions()) | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func TestEntryPointServersReceiveConfiguredHTTPOptions(t *testing.T) { | |
| 35 | + | manager := NewManager() | |
| 36 | + | graph := smartTCPGraph(t, freeTCPAddress(t), nil, nil) | |
| 37 | + | graph.Options.HTTP = domain.HTTPOptions{ReadHeaderTimeout: 3 * time.Second, IdleTimeout: 7 * time.Second, TransferIdleTimeout: 9 * time.Second} | |
| 38 | + | manager.SetSmartTCPHTTPServer("edge", http.NotFoundHandler(), nil) | |
| 39 | + | manager.SetSmartTCPTLSServer("edge", http.NotFoundHandler(), testTLSConfig(t, "app.example.com")) | |
| 40 | + | require.NoError(t, manager.Apply(context.Background(), &graph)) | |
| 41 | + | defer shutdownManager(t, manager) | |
| 42 | + | ||
| 43 | + | runtime := tcpRuntimeForTest(t, manager, "edge") | |
| 44 | + | require.NotNil(t, runtime) | |
| 45 | + | runtime.mu.Lock() | |
| 46 | + | plain, secure := runtime.smartHTTPServer, runtime.smartTLSServer | |
| 47 | + | runtime.mu.Unlock() | |
| 48 | + | for name, server := range map[string]*http.Server{"plain": plain, "tls": secure} { | |
| 49 | + | require.NotNil(t, server, name) | |
| 50 | + | assert.Equal(t, 3*time.Second, server.ReadHeaderTimeout, name) | |
| 51 | + | assert.Equal(t, 7*time.Second, server.IdleTimeout, name) | |
| 52 | + | assert.Zero(t, server.ReadTimeout, name) | |
| 53 | + | assert.Zero(t, server.WriteTimeout, name) | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func TestTLSMuxServerReceivesConfiguredHTTPOptions(t *testing.T) { | |
| 58 | + | manager := NewManager() | |
| 59 | + | graph := domain.TrafficGraph{ | |
| 60 | + | Options: domain.TrafficOptions{HTTP: domain.HTTPOptions{ReadHeaderTimeout: 4 * time.Second, IdleTimeout: 8 * time.Second, TransferIdleTimeout: time.Minute}}, | |
| 61 | + | EntryPoints: []domain.EntryPoint{{Name: "websecure", Address: freeTCPAddress(t), Protocol: domain.EntryPointProtocolTLSMux}}, | |
| 62 | + | } | |
| 63 | + | manager.SetTLSHTTPServer("websecure", http.NotFoundHandler(), testTLSConfig(t, "app.example.com")) | |
| 64 | + | require.NoError(t, manager.Apply(context.Background(), &graph)) | |
| 65 | + | defer shutdownManager(t, manager) | |
| 66 | + | ||
| 67 | + | runtime := tcpRuntimeForTest(t, manager, "websecure") | |
| 68 | + | require.NotNil(t, runtime) | |
| 69 | + | runtime.mu.Lock() | |
| 70 | + | server := runtime.tlsHTTPServer | |
| 71 | + | runtime.mu.Unlock() | |
| 72 | + | require.NotNil(t, server) | |
| 73 | + | assert.Equal(t, 4*time.Second, server.ReadHeaderTimeout) | |
| 74 | + | assert.Equal(t, 8*time.Second, server.IdleTimeout) | |
| 75 | + | assert.Zero(t, server.ReadTimeout) | |
| 76 | + | assert.Zero(t, server.WriteTimeout) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func TestSmartTCPStreamingResponseOutlivesTransferIdleTimeout(t *testing.T) { | |
| 80 | + | const idle = 300 * time.Millisecond | |
| 81 | + | manager := NewManager() | |
| 82 | + | graph := smartTCPGraph(t, freeTCPAddress(t), nil, nil) | |
| 83 | + | graph.Options.HTTP = domain.HTTPOptions{TransferIdleTimeout: idle} | |
| 84 | + | manager.SetSmartTCPHTTPServer("edge", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 85 | + | silent := r.URL.Path == "/silent" | |
| 86 | + | for i := 0; i < 12; i++ { | |
| 87 | + | _, _ = w.Write([]byte("x")) | |
| 88 | + | w.(http.Flusher).Flush() | |
| 89 | + | if silent { | |
| 90 | + | time.Sleep(3 * idle) | |
| 91 | + | } else { | |
| 92 | + | time.Sleep(50 * time.Millisecond) | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | }), nil) | |
| 96 | + | require.NoError(t, manager.Apply(context.Background(), &graph)) | |
| 97 | + | defer shutdownManager(t, manager) | |
| 98 | + | ||
| 99 | + | get := func(path string) (int, error) { | |
| 100 | + | conn := dialTCP(t, graph.EntryPoints[0].Address) | |
| 101 | + | defer conn.Close() | |
| 102 | + | _, err := conn.Write([]byte("GET " + path + " HTTP/1.1\r\nHost: app.example.com\r\nConnection: close\r\n\r\n")) | |
| 103 | + | require.NoError(t, err) | |
| 104 | + | resp, err := http.ReadResponse(bufio.NewReader(conn), nil) | |
| 105 | + | require.NoError(t, err) | |
| 106 | + | defer resp.Body.Close() | |
| 107 | + | body, err := io.ReadAll(resp.Body) | |
| 108 | + | return len(body), err | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | n, err := get("/active") | |
| 112 | + | require.NoError(t, err) | |
| 113 | + | assert.Equal(t, 12, n, "active transfer longer than the idle timeout must complete") | |
| 114 | + | ||
| 115 | + | n, err = get("/silent") | |
| 116 | + | require.Error(t, err, "silent transfer must be cut") | |
| 117 | + | assert.Less(t, n, 12) | |
| 118 | + | } | |
| @@ -0,0 +1,118 @@ | |||||
| 1 | + | package traffic | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bufio" | |||
| 5 | + | "context" | |||
| 6 | + | "io" | |||
| 7 | + | "net/http" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/stretchr/testify/assert" | |||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ||||
| 14 | + | "github.com/bnema/gordon/internal/domain" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | func TestNewEntryPointHTTPServerUsesConfiguredTimeouts(t *testing.T) { | |||
| 18 | + | server := newEntryPointHTTPServer(http.NotFoundHandler(), domain.HTTPOptions{ | |||
| 19 | + | ReadHeaderTimeout: 3 * time.Second, | |||
| 20 | + | IdleTimeout: 7 * time.Second, | |||
| 21 | + | TransferIdleTimeout: 9 * time.Second, | |||
| 22 | + | }) | |||
| 23 | + | ||||
| 24 | + | assert.Equal(t, 3*time.Second, server.ReadHeaderTimeout) | |||
| 25 | + | assert.Equal(t, 7*time.Second, server.IdleTimeout) | |||
| 26 | + | assert.Zero(t, server.ReadTimeout, "whole-request read timeout must be replaced by the inactivity deadline") | |||
| 27 | + | assert.Zero(t, server.WriteTimeout, "whole-response write timeout must be replaced by the inactivity deadline") | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func TestManagerHTTPOptionsDefaultsWithoutSnapshot(t *testing.T) { | |||
| 31 | + | assert.Equal(t, domain.DefaultHTTPOptions(), NewManager().httpOptions()) | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func TestEntryPointServersReceiveConfiguredHTTPOptions(t *testing.T) { | |||
| 35 | + | manager := NewManager() | |||
| 36 | + | graph := smartTCPGraph(t, freeTCPAddress(t), nil, nil) | |||
| 37 | + | graph.Options.HTTP = domain.HTTPOptions{ReadHeaderTimeout: 3 * time.Second, IdleTimeout: 7 * time.Second, TransferIdleTimeout: 9 * time.Second} | |||
| 38 | + | manager.SetSmartTCPHTTPServer("edge", http.NotFoundHandler(), nil) | |||
| 39 | + | manager.SetSmartTCPTLSServer("edge", http.NotFoundHandler(), testTLSConfig(t, "app.example.com")) | |||
| 40 | + | require.NoError(t, manager.Apply(context.Background(), &graph)) | |||
| 41 | + | defer shutdownManager(t, manager) | |||
| 42 | + | ||||
| 43 | + | runtime := tcpRuntimeForTest(t, manager, "edge") | |||
| 44 | + | require.NotNil(t, runtime) | |||
| 45 | + | runtime.mu.Lock() | |||
| 46 | + | plain, secure := runtime.smartHTTPServer, runtime.smartTLSServer | |||
| 47 | + | runtime.mu.Unlock() | |||
| 48 | + | for name, server := range map[string]*http.Server{"plain": plain, "tls": secure} { | |||
| 49 | + | require.NotNil(t, server, name) | |||
| 50 | + | assert.Equal(t, 3*time.Second, server.ReadHeaderTimeout, name) | |||
| 51 | + | assert.Equal(t, 7*time.Second, server.IdleTimeout, name) | |||
| 52 | + | assert.Zero(t, server.ReadTimeout, name) | |||
| 53 | + | assert.Zero(t, server.WriteTimeout, name) | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func TestTLSMuxServerReceivesConfiguredHTTPOptions(t *testing.T) { | |||
| 58 | + | manager := NewManager() | |||
| 59 | + | graph := domain.TrafficGraph{ | |||
| 60 | + | Options: domain.TrafficOptions{HTTP: domain.HTTPOptions{ReadHeaderTimeout: 4 * time.Second, IdleTimeout: 8 * time.Second, TransferIdleTimeout: time.Minute}}, | |||
| 61 | + | EntryPoints: []domain.EntryPoint{{Name: "websecure", Address: freeTCPAddress(t), Protocol: domain.EntryPointProtocolTLSMux}}, | |||
| 62 | + | } | |||
| 63 | + | manager.SetTLSHTTPServer("websecure", http.NotFoundHandler(), testTLSConfig(t, "app.example.com")) | |||
| 64 | + | require.NoError(t, manager.Apply(context.Background(), &graph)) | |||
| 65 | + | defer shutdownManager(t, manager) | |||
| 66 | + | ||||
| 67 | + | runtime := tcpRuntimeForTest(t, manager, "websecure") | |||
| 68 | + | require.NotNil(t, runtime) | |||
| 69 | + | runtime.mu.Lock() | |||
| 70 | + | server := runtime.tlsHTTPServer | |||
| 71 | + | runtime.mu.Unlock() | |||
| 72 | + | require.NotNil(t, server) | |||
| 73 | + | assert.Equal(t, 4*time.Second, server.ReadHeaderTimeout) | |||
| 74 | + | assert.Equal(t, 8*time.Second, server.IdleTimeout) | |||
| 75 | + | assert.Zero(t, server.ReadTimeout) | |||
| 76 | + | assert.Zero(t, server.WriteTimeout) | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func TestSmartTCPStreamingResponseOutlivesTransferIdleTimeout(t *testing.T) { | |||
| 80 | + | const idle = 300 * time.Millisecond | |||
| 81 | + | manager := NewManager() | |||
| 82 | + | graph := smartTCPGraph(t, freeTCPAddress(t), nil, nil) | |||
| 83 | + | graph.Options.HTTP = domain.HTTPOptions{TransferIdleTimeout: idle} | |||
| 84 | + | manager.SetSmartTCPHTTPServer("edge", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 85 | + | silent := r.URL.Path == "/silent" | |||
| 86 | + | for i := 0; i < 12; i++ { | |||
| 87 | + | _, _ = w.Write([]byte("x")) | |||
| 88 | + | w.(http.Flusher).Flush() | |||
| 89 | + | if silent { | |||
| 90 | + | time.Sleep(3 * idle) | |||
| 91 | + | } else { | |||
| 92 | + | time.Sleep(50 * time.Millisecond) | |||
| 93 | + | } | |||
| 94 | + | } | |||
| 95 | + | }), nil) | |||
| 96 | + | require.NoError(t, manager.Apply(context.Background(), &graph)) | |||
| 97 | + | defer shutdownManager(t, manager) | |||
| 98 | + | ||||
| 99 | + | get := func(path string) (int, error) { | |||
| 100 | + | conn := dialTCP(t, graph.EntryPoints[0].Address) | |||
| 101 | + | defer conn.Close() | |||
| 102 | + | _, err := conn.Write([]byte("GET " + path + " HTTP/1.1\r\nHost: app.example.com\r\nConnection: close\r\n\r\n")) | |||
| 103 | + | require.NoError(t, err) | |||
| 104 | + | resp, err := http.ReadResponse(bufio.NewReader(conn), nil) | |||
| 105 | + | require.NoError(t, err) | |||
| 106 | + | defer resp.Body.Close() | |||
| 107 | + | body, err := io.ReadAll(resp.Body) | |||
| 108 | + | return len(body), err | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | n, err := get("/active") | |||
| 112 | + | require.NoError(t, err) | |||
| 113 | + | assert.Equal(t, 12, n, "active transfer longer than the idle timeout must complete") | |||
| 114 | + | ||||
| 115 | + | n, err = get("/silent") | |||
| 116 | + | require.Error(t, err, "silent transfer must be cut") | |||
| 117 | + | assert.Less(t, n, 12) | |||
| 118 | + | } | |||
M internal/adapters/in/traffic/smart_tcp.go +4 -2
| @@ -151,7 +151,8 @@ func (r *entryPointRuntime) replaceSmartTCPHTTPServer(entryPoint domain.EntryPoi | |||
| 151 | 151 | return | |
| 152 | 152 | } | |
| 153 | 153 | listener := newTLSHTTPListener(r.listener.Addr()) | |
| 154 | - | server := &http.Server{Handler: config.Handler, Protocols: config.Protocols, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 30 * time.Second} | |
| 154 | + | server := newEntryPointHTTPServer(config.Handler, r.manager.httpOptions()) | |
| 155 | + | server.Protocols = config.Protocols | |
| 155 | 156 | done := make(chan struct{}) | |
| 156 | 157 | r.mu.Lock() | |
| 157 | 158 | oldListener := r.smartHTTPListener | |
| @@ -182,7 +183,8 @@ func (r *entryPointRuntime) replaceSmartTCPTLSServer(entryPoint domain.EntryPoin | |||
| 182 | 183 | return | |
| 183 | 184 | } | |
| 184 | 185 | listener := newTLSHTTPListener(r.listener.Addr()) | |
| 185 | - | server := &http.Server{Handler: config.Handler, TLSConfig: config.TLSConfig.Clone(), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 30 * time.Second} | |
| 186 | + | server := newEntryPointHTTPServer(config.Handler, r.manager.httpOptions()) | |
| 187 | + | server.TLSConfig = config.TLSConfig.Clone() | |
| 186 | 188 | done := make(chan struct{}) | |
| 187 | 189 | r.mu.Lock() | |
| 188 | 190 | oldListener := r.smartTLSListener | |
| @@ -151,7 +151,8 @@ func (r *entryPointRuntime) replaceSmartTCPHTTPServer(entryPoint domain.EntryPoi | |||||
| 151 | return | 151 | return | ||
| 152 | } | 152 | } | ||
| 153 | listener := newTLSHTTPListener(r.listener.Addr()) | 153 | listener := newTLSHTTPListener(r.listener.Addr()) | ||
| 154 | - | server := &http.Server{Handler: config.Handler, Protocols: config.Protocols, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 30 * time.Second} | 154 | + | server := newEntryPointHTTPServer(config.Handler, r.manager.httpOptions()) |
| 155 | + | server.Protocols = config.Protocols | |||
| 155 | done := make(chan struct{}) | 156 | done := make(chan struct{}) | ||
| 156 | r.mu.Lock() | 157 | r.mu.Lock() | ||
| 157 | oldListener := r.smartHTTPListener | 158 | oldListener := r.smartHTTPListener | ||
| @@ -182,7 +183,8 @@ func (r *entryPointRuntime) replaceSmartTCPTLSServer(entryPoint domain.EntryPoin | |||||
| 182 | return | 183 | return | ||
| 183 | } | 184 | } | ||
| 184 | listener := newTLSHTTPListener(r.listener.Addr()) | 185 | listener := newTLSHTTPListener(r.listener.Addr()) | ||
| 185 | - | server := &http.Server{Handler: config.Handler, TLSConfig: config.TLSConfig.Clone(), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 30 * time.Second} | 186 | + | server := newEntryPointHTTPServer(config.Handler, r.manager.httpOptions()) |
| 187 | + | server.TLSConfig = config.TLSConfig.Clone() | |||
| 186 | done := make(chan struct{}) | 188 | done := make(chan struct{}) | ||
| 187 | r.mu.Lock() | 189 | r.mu.Lock() | ||
| 188 | oldListener := r.smartTLSListener | 190 | oldListener := r.smartTLSListener | ||
M internal/adapters/in/traffic/tcp.go +2 -8
| @@ -169,14 +169,8 @@ func (r *entryPointRuntime) replaceTLSHTTPServer(entryPoint domain.EntryPoint, c | |||
| 169 | 169 | return | |
| 170 | 170 | } | |
| 171 | 171 | listener := newTLSHTTPListener(r.listener.Addr()) | |
| 172 | - | server := &http.Server{ | |
| 173 | - | Handler: config.Handler, | |
| 174 | - | TLSConfig: config.TLSConfig.Clone(), | |
| 175 | - | ReadHeaderTimeout: 5 * time.Second, | |
| 176 | - | ReadTimeout: 10 * time.Second, | |
| 177 | - | WriteTimeout: 10 * time.Second, | |
| 178 | - | IdleTimeout: 30 * time.Second, | |
| 179 | - | } | |
| 172 | + | server := newEntryPointHTTPServer(config.Handler, r.manager.httpOptions()) | |
| 173 | + | server.TLSConfig = config.TLSConfig.Clone() | |
| 180 | 174 | done := make(chan struct{}) | |
| 181 | 175 | ||
| 182 | 176 | r.mu.Lock() | |
| @@ -169,14 +169,8 @@ func (r *entryPointRuntime) replaceTLSHTTPServer(entryPoint domain.EntryPoint, c | |||||
| 169 | return | 169 | return | ||
| 170 | } | 170 | } | ||
| 171 | listener := newTLSHTTPListener(r.listener.Addr()) | 171 | listener := newTLSHTTPListener(r.listener.Addr()) | ||
| 172 | - | server := &http.Server{ | 172 | + | server := newEntryPointHTTPServer(config.Handler, r.manager.httpOptions()) |
| 173 | - | Handler: config.Handler, | 173 | + | server.TLSConfig = config.TLSConfig.Clone() |
| 174 | - | TLSConfig: config.TLSConfig.Clone(), | |||
| 175 | - | ReadHeaderTimeout: 5 * time.Second, | |||
| 176 | - | ReadTimeout: 10 * time.Second, | |||
| 177 | - | WriteTimeout: 10 * time.Second, | |||
| 178 | - | IdleTimeout: 30 * time.Second, | |||
| 179 | - | } | |||
| 180 | done := make(chan struct{}) | 174 | done := make(chan struct{}) | ||
| 181 | 175 | ||||
| 182 | r.mu.Lock() | 176 | r.mu.Lock() | ||
M internal/app/config.go +79 -0
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "os" | |
| 6 | 6 | "path/filepath" | |
| 7 | 7 | "strings" | |
| 8 | + | "time" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/bnema/zerowrap" | |
| 10 | 11 | "github.com/spf13/viper" | |
| @@ -37,6 +38,14 @@ type Config struct { | |||
| 37 | 38 | RegistryAllowedIPs []string `mapstructure:"registry_allowed_ips"` | |
| 38 | 39 | ProxyAllowedIPs []string `mapstructure:"proxy_allowed_ips"` | |
| 39 | 40 | RegistryListenAddr string `mapstructure:"registry_listen_address"` | |
| 41 | + | ||
| 42 | + | // HTTP holds the registry HTTP server timeouts (Go duration strings). | |
| 43 | + | HTTP struct { | |
| 44 | + | ReadHeaderTimeout string `mapstructure:"read_header_timeout"` | |
| 45 | + | ReadTimeout string `mapstructure:"read_timeout"` | |
| 46 | + | WriteTimeout string `mapstructure:"write_timeout"` | |
| 47 | + | IdleTimeout string `mapstructure:"idle_timeout"` | |
| 48 | + | } `mapstructure:"http"` | |
| 40 | 49 | } `mapstructure:"server"` | |
| 41 | 50 | ||
| 42 | 51 | Apps struct { | |
| @@ -225,6 +234,9 @@ func initConfig(configPath string) (*viper.Viper, Config, error) { | |||
| 225 | 234 | if err := validateRetiredAppConfig(v); err != nil { | |
| 226 | 235 | return nil, Config{}, err | |
| 227 | 236 | } | |
| 237 | + | if err := validateHTTPTimeouts(cfg); err != nil { | |
| 238 | + | return nil, Config{}, err | |
| 239 | + | } | |
| 228 | 240 | if _, err := buildAppMountPolicies(cfg); err != nil { | |
| 229 | 241 | return nil, Config{}, err | |
| 230 | 242 | } | |
| @@ -439,6 +451,22 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||
| 439 | 451 | v.SetDefault("server.proxy_allowed_ips", []string{}) | |
| 440 | 452 | v.SetDefault("server.registry_listen_address", "") | |
| 441 | 453 | ||
| 454 | + | // HTTP timeouts. Every key needs a default so GORDON_* env overrides apply. | |
| 455 | + | v.SetDefault("server.http.read_header_timeout", domain.DefaultRegistryReadHeaderTimeout.String()) | |
| 456 | + | v.SetDefault("server.http.read_timeout", domain.DefaultRegistryReadTimeout.String()) | |
| 457 | + | v.SetDefault("server.http.write_timeout", domain.DefaultRegistryWriteTimeout.String()) | |
| 458 | + | v.SetDefault("server.http.idle_timeout", domain.DefaultRegistryIdleTimeout.String()) | |
| 459 | + | v.SetDefault("traffic.http.read_header_timeout", domain.DefaultEntryPointReadHeaderTimeout.String()) | |
| 460 | + | v.SetDefault("traffic.http.idle_timeout", domain.DefaultEntryPointIdleTimeout.String()) | |
| 461 | + | v.SetDefault("traffic.http.transfer_idle_timeout", domain.DefaultEntryPointTransferIdleTimeout.String()) | |
| 462 | + | v.SetDefault("traffic.tcp.dial_timeout", "10s") | |
| 463 | + | v.SetDefault("traffic.tcp.idle_timeout", "5m") | |
| 464 | + | v.SetDefault("traffic.tcp.drain_timeout", "30s") | |
| 465 | + | v.SetDefault("traffic.udp.idle_timeout", "30s") | |
| 466 | + | v.SetDefault("traffic.udp.drain_timeout", "30s") | |
| 467 | + | v.SetDefault("traffic.tcp.max_connections", 0) // 0 = runtime default | |
| 468 | + | v.SetDefault("traffic.udp.max_sessions", 0) // 0 = runtime default | |
| 469 | + | ||
| 442 | 470 | ConfigureViper(v, configPath) | |
| 443 | 471 | ||
| 444 | 472 | if err := v.ReadInConfig(); err != nil { | |
| @@ -453,3 +481,54 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||
| 453 | 481 | ||
| 454 | 482 | return nil | |
| 455 | 483 | } | |
| 484 | + | ||
| 485 | + | // validateHTTPTimeouts rejects malformed, zero, or negative HTTP timeouts at | |
| 486 | + | // config load, before any listener is started. | |
| 487 | + | func validateHTTPTimeouts(cfg Config) error { | |
| 488 | + | if _, err := buildRegistryHTTPTimeouts(cfg); err != nil { | |
| 489 | + | return err | |
| 490 | + | } | |
| 491 | + | if _, err := traffic.ParseHTTPOptions(cfg.Traffic.HTTP); err != nil { | |
| 492 | + | return err | |
| 493 | + | } | |
| 494 | + | return nil | |
| 495 | + | } | |
| 496 | + | ||
| 497 | + | // buildRegistryHTTPTimeouts parses server.http into validated registry server | |
| 498 | + | // timeouts. Empty values select the defaults; malformed, zero, or negative | |
| 499 | + | // values are rejected. | |
| 500 | + | func buildRegistryHTTPTimeouts(cfg Config) (domain.RegistryHTTPTimeouts, error) { | |
| 501 | + | defaults := domain.DefaultRegistryHTTPTimeouts() | |
| 502 | + | var ( | |
| 503 | + | t domain.RegistryHTTPTimeouts | |
| 504 | + | err error | |
| 505 | + | ) | |
| 506 | + | if t.ReadHeaderTimeout, err = parsePositiveDuration(cfg.Server.HTTP.ReadHeaderTimeout, defaults.ReadHeaderTimeout, "server.http.read_header_timeout"); err != nil { | |
| 507 | + | return t, err | |
| 508 | + | } | |
| 509 | + | if t.ReadTimeout, err = parsePositiveDuration(cfg.Server.HTTP.ReadTimeout, defaults.ReadTimeout, "server.http.read_timeout"); err != nil { | |
| 510 | + | return t, err | |
| 511 | + | } | |
| 512 | + | if t.WriteTimeout, err = parsePositiveDuration(cfg.Server.HTTP.WriteTimeout, defaults.WriteTimeout, "server.http.write_timeout"); err != nil { | |
| 513 | + | return t, err | |
| 514 | + | } | |
| 515 | + | if t.IdleTimeout, err = parsePositiveDuration(cfg.Server.HTTP.IdleTimeout, defaults.IdleTimeout, "server.http.idle_timeout"); err != nil { | |
| 516 | + | return t, err | |
| 517 | + | } | |
| 518 | + | return t, t.Validate() | |
| 519 | + | } | |
| 520 | + | ||
| 521 | + | func parsePositiveDuration(value string, fallback time.Duration, key string) (time.Duration, error) { | |
| 522 | + | value = strings.TrimSpace(value) | |
| 523 | + | if value == "" { | |
| 524 | + | return fallback, nil | |
| 525 | + | } | |
| 526 | + | d, err := time.ParseDuration(value) | |
| 527 | + | if err != nil { | |
| 528 | + | return 0, fmt.Errorf("invalid %s: %w", key, err) | |
| 529 | + | } | |
| 530 | + | if d <= 0 { | |
| 531 | + | return 0, fmt.Errorf("invalid %s: must be positive", key) | |
| 532 | + | } | |
| 533 | + | return d, nil | |
| 534 | + | } | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "os" | 5 | "os" | ||
| 6 | "path/filepath" | 6 | "path/filepath" | ||
| 7 | "strings" | 7 | "strings" | ||
| 8 | + | "time" | |||
| 8 | 9 | ||||
| 9 | "github.com/bnema/zerowrap" | 10 | "github.com/bnema/zerowrap" | ||
| 10 | "github.com/spf13/viper" | 11 | "github.com/spf13/viper" | ||
| @@ -37,6 +38,14 @@ type Config struct { | |||||
| 37 | RegistryAllowedIPs []string `mapstructure:"registry_allowed_ips"` | 38 | RegistryAllowedIPs []string `mapstructure:"registry_allowed_ips"` | ||
| 38 | ProxyAllowedIPs []string `mapstructure:"proxy_allowed_ips"` | 39 | ProxyAllowedIPs []string `mapstructure:"proxy_allowed_ips"` | ||
| 39 | RegistryListenAddr string `mapstructure:"registry_listen_address"` | 40 | RegistryListenAddr string `mapstructure:"registry_listen_address"` | ||
| 41 | + | ||||
| 42 | + | // HTTP holds the registry HTTP server timeouts (Go duration strings). | |||
| 43 | + | HTTP struct { | |||
| 44 | + | ReadHeaderTimeout string `mapstructure:"read_header_timeout"` | |||
| 45 | + | ReadTimeout string `mapstructure:"read_timeout"` | |||
| 46 | + | WriteTimeout string `mapstructure:"write_timeout"` | |||
| 47 | + | IdleTimeout string `mapstructure:"idle_timeout"` | |||
| 48 | + | } `mapstructure:"http"` | |||
| 40 | } `mapstructure:"server"` | 49 | } `mapstructure:"server"` | ||
| 41 | 50 | ||||
| 42 | Apps struct { | 51 | Apps struct { | ||
| @@ -225,6 +234,9 @@ func initConfig(configPath string) (*viper.Viper, Config, error) { | |||||
| 225 | if err := validateRetiredAppConfig(v); err != nil { | 234 | if err := validateRetiredAppConfig(v); err != nil { | ||
| 226 | return nil, Config{}, err | 235 | return nil, Config{}, err | ||
| 227 | } | 236 | } | ||
| 237 | + | if err := validateHTTPTimeouts(cfg); err != nil { | |||
| 238 | + | return nil, Config{}, err | |||
| 239 | + | } | |||
| 228 | if _, err := buildAppMountPolicies(cfg); err != nil { | 240 | if _, err := buildAppMountPolicies(cfg); err != nil { | ||
| 229 | return nil, Config{}, err | 241 | return nil, Config{}, err | ||
| 230 | } | 242 | } | ||
| @@ -439,6 +451,22 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||||
| 439 | v.SetDefault("server.proxy_allowed_ips", []string{}) | 451 | v.SetDefault("server.proxy_allowed_ips", []string{}) | ||
| 440 | v.SetDefault("server.registry_listen_address", "") | 452 | v.SetDefault("server.registry_listen_address", "") | ||
| 441 | 453 | ||||
| 454 | + | // HTTP timeouts. Every key needs a default so GORDON_* env overrides apply. | |||
| 455 | + | v.SetDefault("server.http.read_header_timeout", domain.DefaultRegistryReadHeaderTimeout.String()) | |||
| 456 | + | v.SetDefault("server.http.read_timeout", domain.DefaultRegistryReadTimeout.String()) | |||
| 457 | + | v.SetDefault("server.http.write_timeout", domain.DefaultRegistryWriteTimeout.String()) | |||
| 458 | + | v.SetDefault("server.http.idle_timeout", domain.DefaultRegistryIdleTimeout.String()) | |||
| 459 | + | v.SetDefault("traffic.http.read_header_timeout", domain.DefaultEntryPointReadHeaderTimeout.String()) | |||
| 460 | + | v.SetDefault("traffic.http.idle_timeout", domain.DefaultEntryPointIdleTimeout.String()) | |||
| 461 | + | v.SetDefault("traffic.http.transfer_idle_timeout", domain.DefaultEntryPointTransferIdleTimeout.String()) | |||
| 462 | + | v.SetDefault("traffic.tcp.dial_timeout", "10s") | |||
| 463 | + | v.SetDefault("traffic.tcp.idle_timeout", "5m") | |||
| 464 | + | v.SetDefault("traffic.tcp.drain_timeout", "30s") | |||
| 465 | + | v.SetDefault("traffic.udp.idle_timeout", "30s") | |||
| 466 | + | v.SetDefault("traffic.udp.drain_timeout", "30s") | |||
| 467 | + | v.SetDefault("traffic.tcp.max_connections", 0) // 0 = runtime default | |||
| 468 | + | v.SetDefault("traffic.udp.max_sessions", 0) // 0 = runtime default | |||
| 469 | + | ||||
| 442 | ConfigureViper(v, configPath) | 470 | ConfigureViper(v, configPath) | ||
| 443 | 471 | ||||
| 444 | if err := v.ReadInConfig(); err != nil { | 472 | if err := v.ReadInConfig(); err != nil { | ||
| @@ -453,3 +481,54 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||||
| 453 | 481 | ||||
| 454 | return nil | 482 | return nil | ||
| 455 | } | 483 | } | ||
| 484 | + | ||||
| 485 | + | // validateHTTPTimeouts rejects malformed, zero, or negative HTTP timeouts at | |||
| 486 | + | // config load, before any listener is started. | |||
| 487 | + | func validateHTTPTimeouts(cfg Config) error { | |||
| 488 | + | if _, err := buildRegistryHTTPTimeouts(cfg); err != nil { | |||
| 489 | + | return err | |||
| 490 | + | } | |||
| 491 | + | if _, err := traffic.ParseHTTPOptions(cfg.Traffic.HTTP); err != nil { | |||
| 492 | + | return err | |||
| 493 | + | } | |||
| 494 | + | return nil | |||
| 495 | + | } | |||
| 496 | + | ||||
| 497 | + | // buildRegistryHTTPTimeouts parses server.http into validated registry server | |||
| 498 | + | // timeouts. Empty values select the defaults; malformed, zero, or negative | |||
| 499 | + | // values are rejected. | |||
| 500 | + | func buildRegistryHTTPTimeouts(cfg Config) (domain.RegistryHTTPTimeouts, error) { | |||
| 501 | + | defaults := domain.DefaultRegistryHTTPTimeouts() | |||
| 502 | + | var ( | |||
| 503 | + | t domain.RegistryHTTPTimeouts | |||
| 504 | + | err error | |||
| 505 | + | ) | |||
| 506 | + | if t.ReadHeaderTimeout, err = parsePositiveDuration(cfg.Server.HTTP.ReadHeaderTimeout, defaults.ReadHeaderTimeout, "server.http.read_header_timeout"); err != nil { | |||
| 507 | + | return t, err | |||
| 508 | + | } | |||
| 509 | + | if t.ReadTimeout, err = parsePositiveDuration(cfg.Server.HTTP.ReadTimeout, defaults.ReadTimeout, "server.http.read_timeout"); err != nil { | |||
| 510 | + | return t, err | |||
| 511 | + | } | |||
| 512 | + | if t.WriteTimeout, err = parsePositiveDuration(cfg.Server.HTTP.WriteTimeout, defaults.WriteTimeout, "server.http.write_timeout"); err != nil { | |||
| 513 | + | return t, err | |||
| 514 | + | } | |||
| 515 | + | if t.IdleTimeout, err = parsePositiveDuration(cfg.Server.HTTP.IdleTimeout, defaults.IdleTimeout, "server.http.idle_timeout"); err != nil { | |||
| 516 | + | return t, err | |||
| 517 | + | } | |||
| 518 | + | return t, t.Validate() | |||
| 519 | + | } | |||
| 520 | + | ||||
| 521 | + | func parsePositiveDuration(value string, fallback time.Duration, key string) (time.Duration, error) { | |||
| 522 | + | value = strings.TrimSpace(value) | |||
| 523 | + | if value == "" { | |||
| 524 | + | return fallback, nil | |||
| 525 | + | } | |||
| 526 | + | d, err := time.ParseDuration(value) | |||
| 527 | + | if err != nil { | |||
| 528 | + | return 0, fmt.Errorf("invalid %s: %w", key, err) | |||
| 529 | + | } | |||
| 530 | + | if d <= 0 { | |||
| 531 | + | return 0, fmt.Errorf("invalid %s: must be positive", key) | |||
| 532 | + | } | |||
| 533 | + | return d, nil | |||
| 534 | + | } | |||
A internal/app/http_timeouts_config_test.go +173 -0
| @@ -0,0 +1,173 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "net/http" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/zerowrap" | |
| 11 | + | "github.com/stretchr/testify/assert" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/gordon/internal/domain" | |
| 15 | + | "github.com/bnema/gordon/internal/usecase/traffic" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | func writeTimeoutConfig(t *testing.T, body string) string { | |
| 19 | + | t.Helper() | |
| 20 | + | path := filepath.Join(t.TempDir(), "gordon.toml") | |
| 21 | + | require.NoError(t, os.WriteFile(path, []byte(body), 0o600)) | |
| 22 | + | return path | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func TestInitConfigHTTPTimeoutDefaults(t *testing.T) { | |
| 26 | + | _, cfg, err := initConfig(writeTimeoutConfig(t, "")) | |
| 27 | + | require.NoError(t, err) | |
| 28 | + | ||
| 29 | + | registry, err := buildRegistryHTTPTimeouts(cfg) | |
| 30 | + | require.NoError(t, err) | |
| 31 | + | assert.Equal(t, domain.RegistryHTTPTimeouts{ | |
| 32 | + | ReadHeaderTimeout: 10 * time.Second, | |
| 33 | + | ReadTimeout: 5 * time.Minute, | |
| 34 | + | WriteTimeout: 5 * time.Minute, | |
| 35 | + | IdleTimeout: 120 * time.Second, | |
| 36 | + | }, registry) | |
| 37 | + | ||
| 38 | + | entry, err := traffic.ParseHTTPOptions(cfg.Traffic.HTTP) | |
| 39 | + | require.NoError(t, err) | |
| 40 | + | assert.Equal(t, domain.HTTPOptions{ | |
| 41 | + | ReadHeaderTimeout: 5 * time.Second, | |
| 42 | + | IdleTimeout: 30 * time.Second, | |
| 43 | + | TransferIdleTimeout: 2 * time.Minute, | |
| 44 | + | }, entry) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func TestInitConfigHTTPTimeoutFileOverride(t *testing.T) { | |
| 48 | + | path := writeTimeoutConfig(t, ` | |
| 49 | + | [server.http] | |
| 50 | + | read_header_timeout = "3s" | |
| 51 | + | read_timeout = "1m" | |
| 52 | + | write_timeout = "2m" | |
| 53 | + | idle_timeout = "45s" | |
| 54 | + | ||
| 55 | + | [traffic.http] | |
| 56 | + | read_header_timeout = "4s" | |
| 57 | + | idle_timeout = "15s" | |
| 58 | + | transfer_idle_timeout = "10m" | |
| 59 | + | `) | |
| 60 | + | _, cfg, err := initConfig(path) | |
| 61 | + | require.NoError(t, err) | |
| 62 | + | ||
| 63 | + | registry, err := buildRegistryHTTPTimeouts(cfg) | |
| 64 | + | require.NoError(t, err) | |
| 65 | + | assert.Equal(t, 3*time.Second, registry.ReadHeaderTimeout) | |
| 66 | + | assert.Equal(t, time.Minute, registry.ReadTimeout) | |
| 67 | + | assert.Equal(t, 2*time.Minute, registry.WriteTimeout) | |
| 68 | + | assert.Equal(t, 45*time.Second, registry.IdleTimeout) | |
| 69 | + | ||
| 70 | + | entry, err := traffic.ParseHTTPOptions(cfg.Traffic.HTTP) | |
| 71 | + | require.NoError(t, err) | |
| 72 | + | assert.Equal(t, 4*time.Second, entry.ReadHeaderTimeout) | |
| 73 | + | assert.Equal(t, 15*time.Second, entry.IdleTimeout) | |
| 74 | + | assert.Equal(t, 10*time.Minute, entry.TransferIdleTimeout) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func TestInitConfigHTTPTimeoutEnvOverride(t *testing.T) { | |
| 78 | + | t.Setenv("GORDON_TRAFFIC_HTTP_TRANSFER_IDLE_TIMEOUT", "7m") | |
| 79 | + | t.Setenv("GORDON_TRAFFIC_HTTP_READ_HEADER_TIMEOUT", "6s") | |
| 80 | + | t.Setenv("GORDON_TRAFFIC_HTTP_IDLE_TIMEOUT", "11s") | |
| 81 | + | t.Setenv("GORDON_SERVER_HTTP_WRITE_TIMEOUT", "9m") | |
| 82 | + | t.Setenv("GORDON_SERVER_HTTP_READ_TIMEOUT", "8m") | |
| 83 | + | t.Setenv("GORDON_SERVER_HTTP_READ_HEADER_TIMEOUT", "12s") | |
| 84 | + | t.Setenv("GORDON_SERVER_HTTP_IDLE_TIMEOUT", "50s") | |
| 85 | + | t.Setenv("GORDON_TRAFFIC_TCP_DIAL_TIMEOUT", "3s") | |
| 86 | + | ||
| 87 | + | path := writeTimeoutConfig(t, "[traffic.http]\ntransfer_idle_timeout = \"1m\"\n") | |
| 88 | + | _, cfg, err := initConfig(path) | |
| 89 | + | require.NoError(t, err) | |
| 90 | + | ||
| 91 | + | entry, err := traffic.ParseHTTPOptions(cfg.Traffic.HTTP) | |
| 92 | + | require.NoError(t, err) | |
| 93 | + | assert.Equal(t, 7*time.Minute, entry.TransferIdleTimeout, "env wins over file") | |
| 94 | + | assert.Equal(t, 6*time.Second, entry.ReadHeaderTimeout) | |
| 95 | + | assert.Equal(t, 11*time.Second, entry.IdleTimeout) | |
| 96 | + | ||
| 97 | + | registry, err := buildRegistryHTTPTimeouts(cfg) | |
| 98 | + | require.NoError(t, err) | |
| 99 | + | assert.Equal(t, 9*time.Minute, registry.WriteTimeout) | |
| 100 | + | assert.Equal(t, 8*time.Minute, registry.ReadTimeout) | |
| 101 | + | assert.Equal(t, 12*time.Second, registry.ReadHeaderTimeout) | |
| 102 | + | assert.Equal(t, 50*time.Second, registry.IdleTimeout) | |
| 103 | + | ||
| 104 | + | // Existing traffic.tcp keys are overridable too (they need a default). | |
| 105 | + | assert.Equal(t, "3s", cfg.Traffic.TCP.DialTimeout) | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func TestInitConfigRejectsInvalidHTTPTimeouts(t *testing.T) { | |
| 109 | + | tests := []struct { | |
| 110 | + | name string | |
| 111 | + | body string | |
| 112 | + | key string | |
| 113 | + | }{ | |
| 114 | + | {"malformed registry", "[server.http]\nread_timeout = \"soon\"\n", "server.http.read_timeout"}, | |
| 115 | + | {"negative registry", "[server.http]\nwrite_timeout = \"-5s\"\n", "server.http.write_timeout"}, | |
| 116 | + | {"zero registry", "[server.http]\nidle_timeout = \"0s\"\n", "server.http.idle_timeout"}, | |
| 117 | + | {"malformed traffic", "[traffic.http]\nread_header_timeout = \"x\"\n", "traffic.http.read_header_timeout"}, | |
| 118 | + | {"negative traffic", "[traffic.http]\ntransfer_idle_timeout = \"-1m\"\n", "traffic.http.transfer_idle_timeout"}, | |
| 119 | + | {"zero traffic", "[traffic.http]\nidle_timeout = \"0\"\n", "traffic.http.idle_timeout"}, | |
| 120 | + | } | |
| 121 | + | for _, tt := range tests { | |
| 122 | + | t.Run(tt.name, func(t *testing.T) { | |
| 123 | + | _, _, err := initConfig(writeTimeoutConfig(t, tt.body)) | |
| 124 | + | require.Error(t, err) | |
| 125 | + | assert.ErrorContains(t, err, tt.key) | |
| 126 | + | }) | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func TestInitConfigRejectsInvalidHTTPTimeoutFromEnv(t *testing.T) { | |
| 131 | + | t.Setenv("GORDON_TRAFFIC_HTTP_TRANSFER_IDLE_TIMEOUT", "-3s") | |
| 132 | + | _, _, err := initConfig(writeTimeoutConfig(t, "")) | |
| 133 | + | require.ErrorContains(t, err, "traffic.http.transfer_idle_timeout") | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | func TestStartServerAppliesConfiguredTimeouts(t *testing.T) { | |
| 137 | + | timeouts := domain.RegistryHTTPTimeouts{ | |
| 138 | + | ReadHeaderTimeout: 1 * time.Second, | |
| 139 | + | ReadTimeout: 2 * time.Second, | |
| 140 | + | WriteTimeout: 3 * time.Second, | |
| 141 | + | IdleTimeout: 4 * time.Second, | |
| 142 | + | } | |
| 143 | + | errChan := make(chan error, 1) | |
| 144 | + | srv, ready := startServer("127.0.0.1:0", http.NotFoundHandler(), "test", nil, timeouts, errChan, zerowrap.Default()) | |
| 145 | + | <-ready | |
| 146 | + | defer func() { _ = srv.Close() }() | |
| 147 | + | ||
| 148 | + | assert.Equal(t, timeouts.ReadHeaderTimeout, srv.ReadHeaderTimeout) | |
| 149 | + | assert.Equal(t, timeouts.ReadTimeout, srv.ReadTimeout) | |
| 150 | + | assert.Equal(t, timeouts.WriteTimeout, srv.WriteTimeout) | |
| 151 | + | assert.Equal(t, timeouts.IdleTimeout, srv.IdleTimeout) | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | func TestStartServerFallsBackToDefaultTimeouts(t *testing.T) { | |
| 155 | + | errChan := make(chan error, 1) | |
| 156 | + | srv, ready := startServer("127.0.0.1:0", http.NotFoundHandler(), "test", nil, domain.RegistryHTTPTimeouts{}, errChan, zerowrap.Default()) | |
| 157 | + | <-ready | |
| 158 | + | defer func() { _ = srv.Close() }() | |
| 159 | + | ||
| 160 | + | assert.Equal(t, domain.DefaultRegistryHTTPTimeouts(), domain.RegistryHTTPTimeouts{ | |
| 161 | + | ReadHeaderTimeout: srv.ReadHeaderTimeout, | |
| 162 | + | ReadTimeout: srv.ReadTimeout, | |
| 163 | + | WriteTimeout: srv.WriteTimeout, | |
| 164 | + | IdleTimeout: srv.IdleTimeout, | |
| 165 | + | }) | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | func TestStartRegistryServersRejectsInvalidTimeouts(t *testing.T) { | |
| 169 | + | cfg := Config{} | |
| 170 | + | cfg.Server.HTTP.ReadTimeout = "-1s" | |
| 171 | + | _, _, _, _, err := startRegistryServers(cfg, http.NotFoundHandler(), make(chan error, 1), zerowrap.Default()) | |
| 172 | + | require.ErrorContains(t, err, "server.http.read_timeout") | |
| 173 | + | } | |
| @@ -0,0 +1,173 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "net/http" | |||
| 5 | + | "os" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/zerowrap" | |||
| 11 | + | "github.com/stretchr/testify/assert" | |||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ||||
| 14 | + | "github.com/bnema/gordon/internal/domain" | |||
| 15 | + | "github.com/bnema/gordon/internal/usecase/traffic" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | func writeTimeoutConfig(t *testing.T, body string) string { | |||
| 19 | + | t.Helper() | |||
| 20 | + | path := filepath.Join(t.TempDir(), "gordon.toml") | |||
| 21 | + | require.NoError(t, os.WriteFile(path, []byte(body), 0o600)) | |||
| 22 | + | return path | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func TestInitConfigHTTPTimeoutDefaults(t *testing.T) { | |||
| 26 | + | _, cfg, err := initConfig(writeTimeoutConfig(t, "")) | |||
| 27 | + | require.NoError(t, err) | |||
| 28 | + | ||||
| 29 | + | registry, err := buildRegistryHTTPTimeouts(cfg) | |||
| 30 | + | require.NoError(t, err) | |||
| 31 | + | assert.Equal(t, domain.RegistryHTTPTimeouts{ | |||
| 32 | + | ReadHeaderTimeout: 10 * time.Second, | |||
| 33 | + | ReadTimeout: 5 * time.Minute, | |||
| 34 | + | WriteTimeout: 5 * time.Minute, | |||
| 35 | + | IdleTimeout: 120 * time.Second, | |||
| 36 | + | }, registry) | |||
| 37 | + | ||||
| 38 | + | entry, err := traffic.ParseHTTPOptions(cfg.Traffic.HTTP) | |||
| 39 | + | require.NoError(t, err) | |||
| 40 | + | assert.Equal(t, domain.HTTPOptions{ | |||
| 41 | + | ReadHeaderTimeout: 5 * time.Second, | |||
| 42 | + | IdleTimeout: 30 * time.Second, | |||
| 43 | + | TransferIdleTimeout: 2 * time.Minute, | |||
| 44 | + | }, entry) | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func TestInitConfigHTTPTimeoutFileOverride(t *testing.T) { | |||
| 48 | + | path := writeTimeoutConfig(t, ` | |||
| 49 | + | [server.http] | |||
| 50 | + | read_header_timeout = "3s" | |||
| 51 | + | read_timeout = "1m" | |||
| 52 | + | write_timeout = "2m" | |||
| 53 | + | idle_timeout = "45s" | |||
| 54 | + | ||||
| 55 | + | [traffic.http] | |||
| 56 | + | read_header_timeout = "4s" | |||
| 57 | + | idle_timeout = "15s" | |||
| 58 | + | transfer_idle_timeout = "10m" | |||
| 59 | + | `) | |||
| 60 | + | _, cfg, err := initConfig(path) | |||
| 61 | + | require.NoError(t, err) | |||
| 62 | + | ||||
| 63 | + | registry, err := buildRegistryHTTPTimeouts(cfg) | |||
| 64 | + | require.NoError(t, err) | |||
| 65 | + | assert.Equal(t, 3*time.Second, registry.ReadHeaderTimeout) | |||
| 66 | + | assert.Equal(t, time.Minute, registry.ReadTimeout) | |||
| 67 | + | assert.Equal(t, 2*time.Minute, registry.WriteTimeout) | |||
| 68 | + | assert.Equal(t, 45*time.Second, registry.IdleTimeout) | |||
| 69 | + | ||||
| 70 | + | entry, err := traffic.ParseHTTPOptions(cfg.Traffic.HTTP) | |||
| 71 | + | require.NoError(t, err) | |||
| 72 | + | assert.Equal(t, 4*time.Second, entry.ReadHeaderTimeout) | |||
| 73 | + | assert.Equal(t, 15*time.Second, entry.IdleTimeout) | |||
| 74 | + | assert.Equal(t, 10*time.Minute, entry.TransferIdleTimeout) | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func TestInitConfigHTTPTimeoutEnvOverride(t *testing.T) { | |||
| 78 | + | t.Setenv("GORDON_TRAFFIC_HTTP_TRANSFER_IDLE_TIMEOUT", "7m") | |||
| 79 | + | t.Setenv("GORDON_TRAFFIC_HTTP_READ_HEADER_TIMEOUT", "6s") | |||
| 80 | + | t.Setenv("GORDON_TRAFFIC_HTTP_IDLE_TIMEOUT", "11s") | |||
| 81 | + | t.Setenv("GORDON_SERVER_HTTP_WRITE_TIMEOUT", "9m") | |||
| 82 | + | t.Setenv("GORDON_SERVER_HTTP_READ_TIMEOUT", "8m") | |||
| 83 | + | t.Setenv("GORDON_SERVER_HTTP_READ_HEADER_TIMEOUT", "12s") | |||
| 84 | + | t.Setenv("GORDON_SERVER_HTTP_IDLE_TIMEOUT", "50s") | |||
| 85 | + | t.Setenv("GORDON_TRAFFIC_TCP_DIAL_TIMEOUT", "3s") | |||
| 86 | + | ||||
| 87 | + | path := writeTimeoutConfig(t, "[traffic.http]\ntransfer_idle_timeout = \"1m\"\n") | |||
| 88 | + | _, cfg, err := initConfig(path) | |||
| 89 | + | require.NoError(t, err) | |||
| 90 | + | ||||
| 91 | + | entry, err := traffic.ParseHTTPOptions(cfg.Traffic.HTTP) | |||
| 92 | + | require.NoError(t, err) | |||
| 93 | + | assert.Equal(t, 7*time.Minute, entry.TransferIdleTimeout, "env wins over file") | |||
| 94 | + | assert.Equal(t, 6*time.Second, entry.ReadHeaderTimeout) | |||
| 95 | + | assert.Equal(t, 11*time.Second, entry.IdleTimeout) | |||
| 96 | + | ||||
| 97 | + | registry, err := buildRegistryHTTPTimeouts(cfg) | |||
| 98 | + | require.NoError(t, err) | |||
| 99 | + | assert.Equal(t, 9*time.Minute, registry.WriteTimeout) | |||
| 100 | + | assert.Equal(t, 8*time.Minute, registry.ReadTimeout) | |||
| 101 | + | assert.Equal(t, 12*time.Second, registry.ReadHeaderTimeout) | |||
| 102 | + | assert.Equal(t, 50*time.Second, registry.IdleTimeout) | |||
| 103 | + | ||||
| 104 | + | // Existing traffic.tcp keys are overridable too (they need a default). | |||
| 105 | + | assert.Equal(t, "3s", cfg.Traffic.TCP.DialTimeout) | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | func TestInitConfigRejectsInvalidHTTPTimeouts(t *testing.T) { | |||
| 109 | + | tests := []struct { | |||
| 110 | + | name string | |||
| 111 | + | body string | |||
| 112 | + | key string | |||
| 113 | + | }{ | |||
| 114 | + | {"malformed registry", "[server.http]\nread_timeout = \"soon\"\n", "server.http.read_timeout"}, | |||
| 115 | + | {"negative registry", "[server.http]\nwrite_timeout = \"-5s\"\n", "server.http.write_timeout"}, | |||
| 116 | + | {"zero registry", "[server.http]\nidle_timeout = \"0s\"\n", "server.http.idle_timeout"}, | |||
| 117 | + | {"malformed traffic", "[traffic.http]\nread_header_timeout = \"x\"\n", "traffic.http.read_header_timeout"}, | |||
| 118 | + | {"negative traffic", "[traffic.http]\ntransfer_idle_timeout = \"-1m\"\n", "traffic.http.transfer_idle_timeout"}, | |||
| 119 | + | {"zero traffic", "[traffic.http]\nidle_timeout = \"0\"\n", "traffic.http.idle_timeout"}, | |||
| 120 | + | } | |||
| 121 | + | for _, tt := range tests { | |||
| 122 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 123 | + | _, _, err := initConfig(writeTimeoutConfig(t, tt.body)) | |||
| 124 | + | require.Error(t, err) | |||
| 125 | + | assert.ErrorContains(t, err, tt.key) | |||
| 126 | + | }) | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | func TestInitConfigRejectsInvalidHTTPTimeoutFromEnv(t *testing.T) { | |||
| 131 | + | t.Setenv("GORDON_TRAFFIC_HTTP_TRANSFER_IDLE_TIMEOUT", "-3s") | |||
| 132 | + | _, _, err := initConfig(writeTimeoutConfig(t, "")) | |||
| 133 | + | require.ErrorContains(t, err, "traffic.http.transfer_idle_timeout") | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | func TestStartServerAppliesConfiguredTimeouts(t *testing.T) { | |||
| 137 | + | timeouts := domain.RegistryHTTPTimeouts{ | |||
| 138 | + | ReadHeaderTimeout: 1 * time.Second, | |||
| 139 | + | ReadTimeout: 2 * time.Second, | |||
| 140 | + | WriteTimeout: 3 * time.Second, | |||
| 141 | + | IdleTimeout: 4 * time.Second, | |||
| 142 | + | } | |||
| 143 | + | errChan := make(chan error, 1) | |||
| 144 | + | srv, ready := startServer("127.0.0.1:0", http.NotFoundHandler(), "test", nil, timeouts, errChan, zerowrap.Default()) | |||
| 145 | + | <-ready | |||
| 146 | + | defer func() { _ = srv.Close() }() | |||
| 147 | + | ||||
| 148 | + | assert.Equal(t, timeouts.ReadHeaderTimeout, srv.ReadHeaderTimeout) | |||
| 149 | + | assert.Equal(t, timeouts.ReadTimeout, srv.ReadTimeout) | |||
| 150 | + | assert.Equal(t, timeouts.WriteTimeout, srv.WriteTimeout) | |||
| 151 | + | assert.Equal(t, timeouts.IdleTimeout, srv.IdleTimeout) | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | func TestStartServerFallsBackToDefaultTimeouts(t *testing.T) { | |||
| 155 | + | errChan := make(chan error, 1) | |||
| 156 | + | srv, ready := startServer("127.0.0.1:0", http.NotFoundHandler(), "test", nil, domain.RegistryHTTPTimeouts{}, errChan, zerowrap.Default()) | |||
| 157 | + | <-ready | |||
| 158 | + | defer func() { _ = srv.Close() }() | |||
| 159 | + | ||||
| 160 | + | assert.Equal(t, domain.DefaultRegistryHTTPTimeouts(), domain.RegistryHTTPTimeouts{ | |||
| 161 | + | ReadHeaderTimeout: srv.ReadHeaderTimeout, | |||
| 162 | + | ReadTimeout: srv.ReadTimeout, | |||
| 163 | + | WriteTimeout: srv.WriteTimeout, | |||
| 164 | + | IdleTimeout: srv.IdleTimeout, | |||
| 165 | + | }) | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | func TestStartRegistryServersRejectsInvalidTimeouts(t *testing.T) { | |||
| 169 | + | cfg := Config{} | |||
| 170 | + | cfg.Server.HTTP.ReadTimeout = "-1s" | |||
| 171 | + | _, _, _, _, err := startRegistryServers(cfg, http.NotFoundHandler(), make(chan error, 1), zerowrap.Default()) | |||
| 172 | + | require.ErrorContains(t, err, "server.http.read_timeout") | |||
| 173 | + | } | |||
M internal/app/listeners.go +16 -12
| @@ -8,7 +8,6 @@ import ( | |||
| 8 | 8 | "net/http" | |
| 9 | 9 | "strconv" | |
| 10 | 10 | "strings" | |
| 11 | - | "time" | |
| 12 | 11 | ||
| 13 | 12 | "github.com/bnema/zerowrap" | |
| 14 | 13 | ||
| @@ -353,17 +352,21 @@ func tlsMuxHTTPServerNames(cfg Config) map[string]struct{} { | |||
| 353 | 352 | return names | |
| 354 | 353 | } | |
| 355 | 354 | ||
| 356 | - | func startRegistryServers(cfg Config, handler http.Handler, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}, *http.Server, <-chan struct{}) { | |
| 355 | + | func startRegistryServers(cfg Config, handler http.Handler, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}, *http.Server, <-chan struct{}, error) { | |
| 356 | + | timeouts, err := buildRegistryHTTPTimeouts(cfg) | |
| 357 | + | if err != nil { | |
| 358 | + | return nil, nil, nil, nil, err | |
| 359 | + | } | |
| 357 | 360 | port := strconv.Itoa(cfg.Server.RegistryPort) | |
| 358 | 361 | externalAddr := net.JoinHostPort(cfg.Server.RegistryListenAddr, port) | |
| 359 | - | externalSrv, externalReady := startServer(externalAddr, handler, "registry", nil, errChan, log) | |
| 362 | + | externalSrv, externalReady := startServer(externalAddr, handler, "registry", nil, timeouts, errChan, log) | |
| 360 | 363 | internalReady := closedReadyChannel() | |
| 361 | 364 | if !needsInternalRegistryListener(cfg.Server.RegistryListenAddr) { | |
| 362 | - | return externalSrv, externalReady, nil, internalReady | |
| 365 | + | return externalSrv, externalReady, nil, internalReady, nil | |
| 363 | 366 | } | |
| 364 | 367 | internalAddr := net.JoinHostPort("127.0.0.1", port) | |
| 365 | - | internalSrv, internalReady := startServer(internalAddr, handler, "internal registry", nil, errChan, log) | |
| 366 | - | return externalSrv, externalReady, internalSrv, internalReady | |
| 368 | + | internalSrv, internalReady := startServer(internalAddr, handler, "internal registry", nil, timeouts, errChan, log) | |
| 369 | + | return externalSrv, externalReady, internalSrv, internalReady, nil | |
| 367 | 370 | } | |
| 368 | 371 | ||
| 369 | 372 | func closedReadyChannel() <-chan struct{} { | |
| @@ -384,18 +387,19 @@ func needsInternalRegistryListener(listenAddr string) bool { | |||
| 384 | 387 | // that closes once the listening socket is bound. This lets callers wait for the | |
| 385 | 388 | // port to be ready before taking actions that depend on it (e.g. auto-start | |
| 386 | 389 | // pulling from the local registry). The returned *http.Server can be used for | |
| 387 | - | // graceful shutdown. | |
| 388 | - | func startServer(addr string, handler http.Handler, name string, protocols *http.Protocols, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}) { | |
| 390 | + | // graceful shutdown. Timeouts are the configured server.http values. | |
| 391 | + | func startServer(addr string, handler http.Handler, name string, protocols *http.Protocols, timeouts domain.RegistryHTTPTimeouts, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}) { | |
| 392 | + | timeouts = timeouts.WithDefaults() | |
| 389 | 393 | ready := make(chan struct{}) | |
| 390 | 394 | ||
| 391 | 395 | server := &http.Server{ | |
| 392 | 396 | Addr: addr, | |
| 393 | 397 | Handler: handler, | |
| 394 | 398 | Protocols: protocols, | |
| 395 | - | ReadHeaderTimeout: 10 * time.Second, | |
| 396 | - | ReadTimeout: 5 * time.Minute, | |
| 397 | - | WriteTimeout: 5 * time.Minute, | |
| 398 | - | IdleTimeout: 120 * time.Second, | |
| 399 | + | ReadHeaderTimeout: timeouts.ReadHeaderTimeout, | |
| 400 | + | ReadTimeout: timeouts.ReadTimeout, | |
| 401 | + | WriteTimeout: timeouts.WriteTimeout, | |
| 402 | + | IdleTimeout: timeouts.IdleTimeout, | |
| 399 | 403 | MaxHeaderBytes: 1 << 20, | |
| 400 | 404 | } | |
| 401 | 405 | ||
| @@ -8,7 +8,6 @@ import ( | |||||
| 8 | "net/http" | 8 | "net/http" | ||
| 9 | "strconv" | 9 | "strconv" | ||
| 10 | "strings" | 10 | "strings" | ||
| 11 | - | "time" | |||
| 12 | 11 | ||||
| 13 | "github.com/bnema/zerowrap" | 12 | "github.com/bnema/zerowrap" | ||
| 14 | 13 | ||||
| @@ -353,17 +352,21 @@ func tlsMuxHTTPServerNames(cfg Config) map[string]struct{} { | |||||
| 353 | return names | 352 | return names | ||
| 354 | } | 353 | } | ||
| 355 | 354 | ||||
| 356 | - | func startRegistryServers(cfg Config, handler http.Handler, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}, *http.Server, <-chan struct{}) { | 355 | + | func startRegistryServers(cfg Config, handler http.Handler, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}, *http.Server, <-chan struct{}, error) { |
| 356 | + | timeouts, err := buildRegistryHTTPTimeouts(cfg) | |||
| 357 | + | if err != nil { | |||
| 358 | + | return nil, nil, nil, nil, err | |||
| 359 | + | } | |||
| 357 | port := strconv.Itoa(cfg.Server.RegistryPort) | 360 | port := strconv.Itoa(cfg.Server.RegistryPort) | ||
| 358 | externalAddr := net.JoinHostPort(cfg.Server.RegistryListenAddr, port) | 361 | externalAddr := net.JoinHostPort(cfg.Server.RegistryListenAddr, port) | ||
| 359 | - | externalSrv, externalReady := startServer(externalAddr, handler, "registry", nil, errChan, log) | 362 | + | externalSrv, externalReady := startServer(externalAddr, handler, "registry", nil, timeouts, errChan, log) |
| 360 | internalReady := closedReadyChannel() | 363 | internalReady := closedReadyChannel() | ||
| 361 | if !needsInternalRegistryListener(cfg.Server.RegistryListenAddr) { | 364 | if !needsInternalRegistryListener(cfg.Server.RegistryListenAddr) { | ||
| 362 | - | return externalSrv, externalReady, nil, internalReady | 365 | + | return externalSrv, externalReady, nil, internalReady, nil |
| 363 | } | 366 | } | ||
| 364 | internalAddr := net.JoinHostPort("127.0.0.1", port) | 367 | internalAddr := net.JoinHostPort("127.0.0.1", port) | ||
| 365 | - | internalSrv, internalReady := startServer(internalAddr, handler, "internal registry", nil, errChan, log) | 368 | + | internalSrv, internalReady := startServer(internalAddr, handler, "internal registry", nil, timeouts, errChan, log) |
| 366 | - | return externalSrv, externalReady, internalSrv, internalReady | 369 | + | return externalSrv, externalReady, internalSrv, internalReady, nil |
| 367 | } | 370 | } | ||
| 368 | 371 | ||||
| 369 | func closedReadyChannel() <-chan struct{} { | 372 | func closedReadyChannel() <-chan struct{} { | ||
| @@ -384,18 +387,19 @@ func needsInternalRegistryListener(listenAddr string) bool { | |||||
| 384 | // that closes once the listening socket is bound. This lets callers wait for the | 387 | // that closes once the listening socket is bound. This lets callers wait for the | ||
| 385 | // port to be ready before taking actions that depend on it (e.g. auto-start | 388 | // port to be ready before taking actions that depend on it (e.g. auto-start | ||
| 386 | // pulling from the local registry). The returned *http.Server can be used for | 389 | // pulling from the local registry). The returned *http.Server can be used for | ||
| 387 | - | // graceful shutdown. | 390 | + | // graceful shutdown. Timeouts are the configured server.http values. |
| 388 | - | func startServer(addr string, handler http.Handler, name string, protocols *http.Protocols, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}) { | 391 | + | func startServer(addr string, handler http.Handler, name string, protocols *http.Protocols, timeouts domain.RegistryHTTPTimeouts, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}) { |
| 392 | + | timeouts = timeouts.WithDefaults() | |||
| 389 | ready := make(chan struct{}) | 393 | ready := make(chan struct{}) | ||
| 390 | 394 | ||||
| 391 | server := &http.Server{ | 395 | server := &http.Server{ | ||
| 392 | Addr: addr, | 396 | Addr: addr, | ||
| 393 | Handler: handler, | 397 | Handler: handler, | ||
| 394 | Protocols: protocols, | 398 | Protocols: protocols, | ||
| 395 | - | ReadHeaderTimeout: 10 * time.Second, | 399 | + | ReadHeaderTimeout: timeouts.ReadHeaderTimeout, |
| 396 | - | ReadTimeout: 5 * time.Minute, | 400 | + | ReadTimeout: timeouts.ReadTimeout, |
| 397 | - | WriteTimeout: 5 * time.Minute, | 401 | + | WriteTimeout: timeouts.WriteTimeout, |
| 398 | - | IdleTimeout: 120 * time.Second, | 402 | + | IdleTimeout: timeouts.IdleTimeout, |
| 399 | MaxHeaderBytes: 1 << 20, | 403 | MaxHeaderBytes: 1 << 20, | ||
| 400 | } | 404 | } | ||
| 401 | 405 | ||||
M internal/app/run.go +25 -14
| @@ -195,22 +195,11 @@ func initAccessLog(cfg Config, log zerowrap.Logger) (*accesslog.Writer, error) { | |||
| 195 | 195 | // The deferred signal.Stop calls ensure signal handlers are properly | |
| 196 | 196 | // cleaned up before program exit, preventing signal handler leaks. | |
| 197 | 197 | func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services, reload reloadTrigger, cleanupHandlers func(), log zerowrap.Logger) error { | |
| 198 | - | // Initialize access log writer. Kept here (not in Run) to keep Run's cyclomatic | |
| 199 | - | // complexity within the project limit of 15. | |
| 200 | - | accessWriterConcrete, err := initAccessLog(cfg, log) | |
| 198 | + | accessWriter, closeAccessLog, err := openAccessLog(svc, cfg, log) | |
| 201 | 199 | if err != nil { | |
| 202 | 200 | return err | |
| 203 | 201 | } | |
| 204 | - | if accessWriterConcrete != nil { | |
| 205 | - | defer accessWriterConcrete.Close() | |
| 206 | - | } | |
| 207 | - | // Convert to interface only when non-nil to avoid the Go nil-interface pitfall | |
| 208 | - | // where a typed nil pointer becomes a non-nil interface value. | |
| 209 | - | var accessWriter out.AccessLogWriter | |
| 210 | - | if accessWriterConcrete != nil { | |
| 211 | - | accessWriter = accessWriterConcrete | |
| 212 | - | } | |
| 213 | - | accessWriter = withAccessLogExport(svc, accessWriter) | |
| 202 | + | defer closeAccessLog() | |
| 214 | 203 | ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) | |
| 215 | 204 | defer cancel() | |
| 216 | 205 | ||
| @@ -227,7 +216,10 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services, | |||
| 227 | 216 | svc.httpProxyHandler = httpProxyHandler | |
| 228 | 217 | svc.httpsProxyHandler = httpsProxyHandler | |
| 229 | 218 | ||
| 230 | - | registrySrv, registryReady, internalRegistrySrv, internalRegistryReady := startRegistryServers(cfg, registryHandler, errChan, log) | |
| 219 | + | registrySrv, registryReady, internalRegistrySrv, internalRegistryReady, err := startRegistryServers(cfg, registryHandler, errChan, log) | |
| 220 | + | if err != nil { | |
| 221 | + | return err | |
| 222 | + | } | |
| 231 | 223 | ||
| 232 | 224 | // closeStarted shuts down any servers that were started before an error occurred, | |
| 233 | 225 | // preventing leaked listeners during partial startup failures. | |
| @@ -383,3 +375,22 @@ func waitForServerReady(ctx context.Context, ready <-chan struct{}, errChan <-ch | |||
| 383 | 375 | return ctx.Err() | |
| 384 | 376 | } | |
| 385 | 377 | } | |
| 378 | + | ||
| 379 | + | // openAccessLog initializes the access log writer and returns the writer to use | |
| 380 | + | // (nil when disabled) with its close function. Kept out of runServers to keep | |
| 381 | + | // its cyclomatic complexity within the project limit of 15. | |
| 382 | + | func openAccessLog(svc *services, cfg Config, log zerowrap.Logger) (out.AccessLogWriter, func(), error) { | |
| 383 | + | accessWriterConcrete, err := initAccessLog(cfg, log) | |
| 384 | + | if err != nil { | |
| 385 | + | return nil, func() {}, err | |
| 386 | + | } | |
| 387 | + | closeFn := func() {} | |
| 388 | + | // Convert to interface only when non-nil to avoid the Go nil-interface pitfall | |
| 389 | + | // where a typed nil pointer becomes a non-nil interface value. | |
| 390 | + | var accessWriter out.AccessLogWriter | |
| 391 | + | if accessWriterConcrete != nil { | |
| 392 | + | accessWriter = accessWriterConcrete | |
| 393 | + | closeFn = func() { accessWriterConcrete.Close() } | |
| 394 | + | } | |
| 395 | + | return withAccessLogExport(svc, accessWriter), closeFn, nil | |
| 396 | + | } | |
| @@ -195,22 +195,11 @@ func initAccessLog(cfg Config, log zerowrap.Logger) (*accesslog.Writer, error) { | |||||
| 195 | // The deferred signal.Stop calls ensure signal handlers are properly | 195 | // The deferred signal.Stop calls ensure signal handlers are properly | ||
| 196 | // cleaned up before program exit, preventing signal handler leaks. | 196 | // cleaned up before program exit, preventing signal handler leaks. | ||
| 197 | func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services, reload reloadTrigger, cleanupHandlers func(), log zerowrap.Logger) error { | 197 | func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services, reload reloadTrigger, cleanupHandlers func(), log zerowrap.Logger) error { | ||
| 198 | - | // Initialize access log writer. Kept here (not in Run) to keep Run's cyclomatic | 198 | + | accessWriter, closeAccessLog, err := openAccessLog(svc, cfg, log) |
| 199 | - | // complexity within the project limit of 15. | |||
| 200 | - | accessWriterConcrete, err := initAccessLog(cfg, log) | |||
| 201 | if err != nil { | 199 | if err != nil { | ||
| 202 | return err | 200 | return err | ||
| 203 | } | 201 | } | ||
| 204 | - | if accessWriterConcrete != nil { | 202 | + | defer closeAccessLog() |
| 205 | - | defer accessWriterConcrete.Close() | |||
| 206 | - | } | |||
| 207 | - | // Convert to interface only when non-nil to avoid the Go nil-interface pitfall | |||
| 208 | - | // where a typed nil pointer becomes a non-nil interface value. | |||
| 209 | - | var accessWriter out.AccessLogWriter | |||
| 210 | - | if accessWriterConcrete != nil { | |||
| 211 | - | accessWriter = accessWriterConcrete | |||
| 212 | - | } | |||
| 213 | - | accessWriter = withAccessLogExport(svc, accessWriter) | |||
| 214 | ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) | 203 | ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) | ||
| 215 | defer cancel() | 204 | defer cancel() | ||
| 216 | 205 | ||||
| @@ -227,7 +216,10 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services, | |||||
| 227 | svc.httpProxyHandler = httpProxyHandler | 216 | svc.httpProxyHandler = httpProxyHandler | ||
| 228 | svc.httpsProxyHandler = httpsProxyHandler | 217 | svc.httpsProxyHandler = httpsProxyHandler | ||
| 229 | 218 | ||||
| 230 | - | registrySrv, registryReady, internalRegistrySrv, internalRegistryReady := startRegistryServers(cfg, registryHandler, errChan, log) | 219 | + | registrySrv, registryReady, internalRegistrySrv, internalRegistryReady, err := startRegistryServers(cfg, registryHandler, errChan, log) |
| 220 | + | if err != nil { | |||
| 221 | + | return err | |||
| 222 | + | } | |||
| 231 | 223 | ||||
| 232 | // closeStarted shuts down any servers that were started before an error occurred, | 224 | // closeStarted shuts down any servers that were started before an error occurred, | ||
| 233 | // preventing leaked listeners during partial startup failures. | 225 | // preventing leaked listeners during partial startup failures. | ||
| @@ -383,3 +375,22 @@ func waitForServerReady(ctx context.Context, ready <-chan struct{}, errChan <-ch | |||||
| 383 | return ctx.Err() | 375 | return ctx.Err() | ||
| 384 | } | 376 | } | ||
| 385 | } | 377 | } | ||
| 378 | + | ||||
| 379 | + | // openAccessLog initializes the access log writer and returns the writer to use | |||
| 380 | + | // (nil when disabled) with its close function. Kept out of runServers to keep | |||
| 381 | + | // its cyclomatic complexity within the project limit of 15. | |||
| 382 | + | func openAccessLog(svc *services, cfg Config, log zerowrap.Logger) (out.AccessLogWriter, func(), error) { | |||
| 383 | + | accessWriterConcrete, err := initAccessLog(cfg, log) | |||
| 384 | + | if err != nil { | |||
| 385 | + | return nil, func() {}, err | |||
| 386 | + | } | |||
| 387 | + | closeFn := func() {} | |||
| 388 | + | // Convert to interface only when non-nil to avoid the Go nil-interface pitfall | |||
| 389 | + | // where a typed nil pointer becomes a non-nil interface value. | |||
| 390 | + | var accessWriter out.AccessLogWriter | |||
| 391 | + | if accessWriterConcrete != nil { | |||
| 392 | + | accessWriter = accessWriterConcrete | |||
| 393 | + | closeFn = func() { accessWriterConcrete.Close() } | |||
| 394 | + | } | |||
| 395 | + | return withAccessLogExport(svc, accessWriter), closeFn, nil | |||
| 396 | + | } | |||
A internal/domain/http_timeouts.go +121 -0
| @@ -0,0 +1,121 @@ | |||
| 1 | + | package domain | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "time" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // Default HTTP server timeouts. | |
| 9 | + | const ( | |
| 10 | + | DefaultRegistryReadHeaderTimeout = 10 * time.Second | |
| 11 | + | DefaultRegistryReadTimeout = 5 * time.Minute | |
| 12 | + | DefaultRegistryWriteTimeout = 5 * time.Minute | |
| 13 | + | DefaultRegistryIdleTimeout = 120 * time.Second | |
| 14 | + | ||
| 15 | + | DefaultEntryPointReadHeaderTimeout = 5 * time.Second | |
| 16 | + | DefaultEntryPointIdleTimeout = 30 * time.Second | |
| 17 | + | DefaultEntryPointTransferIdleTimeout = 2 * time.Minute | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // RegistryHTTPTimeouts holds the fixed timeouts of the registry HTTP servers | |
| 21 | + | // (config section server.http). Read and Write timeouts bound the whole | |
| 22 | + | // request and response, which suits registry API calls. | |
| 23 | + | type RegistryHTTPTimeouts struct { | |
| 24 | + | ReadHeaderTimeout time.Duration | |
| 25 | + | ReadTimeout time.Duration | |
| 26 | + | WriteTimeout time.Duration | |
| 27 | + | IdleTimeout time.Duration | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // DefaultRegistryHTTPTimeouts returns the default registry server timeouts. | |
| 31 | + | func DefaultRegistryHTTPTimeouts() RegistryHTTPTimeouts { | |
| 32 | + | return RegistryHTTPTimeouts{ | |
| 33 | + | ReadHeaderTimeout: DefaultRegistryReadHeaderTimeout, | |
| 34 | + | ReadTimeout: DefaultRegistryReadTimeout, | |
| 35 | + | WriteTimeout: DefaultRegistryWriteTimeout, | |
| 36 | + | IdleTimeout: DefaultRegistryIdleTimeout, | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // WithDefaults replaces zero values with the defaults. | |
| 41 | + | func (t RegistryHTTPTimeouts) WithDefaults() RegistryHTTPTimeouts { | |
| 42 | + | defaults := DefaultRegistryHTTPTimeouts() | |
| 43 | + | if t.ReadHeaderTimeout == 0 { | |
| 44 | + | t.ReadHeaderTimeout = defaults.ReadHeaderTimeout | |
| 45 | + | } | |
| 46 | + | if t.ReadTimeout == 0 { | |
| 47 | + | t.ReadTimeout = defaults.ReadTimeout | |
| 48 | + | } | |
| 49 | + | if t.WriteTimeout == 0 { | |
| 50 | + | t.WriteTimeout = defaults.WriteTimeout | |
| 51 | + | } | |
| 52 | + | if t.IdleTimeout == 0 { | |
| 53 | + | t.IdleTimeout = defaults.IdleTimeout | |
| 54 | + | } | |
| 55 | + | return t | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // Validate rejects negative durations. Zero values mean "use the default". | |
| 59 | + | func (t RegistryHTTPTimeouts) Validate() error { | |
| 60 | + | return validateNonNegativeDurations(map[string]time.Duration{ | |
| 61 | + | "server.http.read_header_timeout": t.ReadHeaderTimeout, | |
| 62 | + | "server.http.read_timeout": t.ReadTimeout, | |
| 63 | + | "server.http.write_timeout": t.WriteTimeout, | |
| 64 | + | "server.http.idle_timeout": t.IdleTimeout, | |
| 65 | + | }) | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // HTTPOptions holds the timeouts of the HTTP servers behind traffic | |
| 69 | + | // entrypoints (config section traffic.http). | |
| 70 | + | // | |
| 71 | + | // There is deliberately no fixed read or write timeout: ReadHeaderTimeout | |
| 72 | + | // bounds the request header, IdleTimeout bounds keep-alive connections, and | |
| 73 | + | // TransferIdleTimeout cuts a transfer only after that long without data in | |
| 74 | + | // the direction being waited on. | |
| 75 | + | type HTTPOptions struct { | |
| 76 | + | ReadHeaderTimeout time.Duration | |
| 77 | + | IdleTimeout time.Duration | |
| 78 | + | TransferIdleTimeout time.Duration | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // DefaultHTTPOptions returns the default entrypoint HTTP timeouts. | |
| 82 | + | func DefaultHTTPOptions() HTTPOptions { | |
| 83 | + | return HTTPOptions{ | |
| 84 | + | ReadHeaderTimeout: DefaultEntryPointReadHeaderTimeout, | |
| 85 | + | IdleTimeout: DefaultEntryPointIdleTimeout, | |
| 86 | + | TransferIdleTimeout: DefaultEntryPointTransferIdleTimeout, | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | // WithDefaults replaces zero values with the defaults. | |
| 91 | + | func (o HTTPOptions) WithDefaults() HTTPOptions { | |
| 92 | + | defaults := DefaultHTTPOptions() | |
| 93 | + | if o.ReadHeaderTimeout == 0 { | |
| 94 | + | o.ReadHeaderTimeout = defaults.ReadHeaderTimeout | |
| 95 | + | } | |
| 96 | + | if o.IdleTimeout == 0 { | |
| 97 | + | o.IdleTimeout = defaults.IdleTimeout | |
| 98 | + | } | |
| 99 | + | if o.TransferIdleTimeout == 0 { | |
| 100 | + | o.TransferIdleTimeout = defaults.TransferIdleTimeout | |
| 101 | + | } | |
| 102 | + | return o | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | // Validate rejects negative durations. Zero values mean "use the default". | |
| 106 | + | func (o HTTPOptions) Validate() error { | |
| 107 | + | return validateNonNegativeDurations(map[string]time.Duration{ | |
| 108 | + | "traffic.http.read_header_timeout": o.ReadHeaderTimeout, | |
| 109 | + | "traffic.http.idle_timeout": o.IdleTimeout, | |
| 110 | + | "traffic.http.transfer_idle_timeout": o.TransferIdleTimeout, | |
| 111 | + | }) | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func validateNonNegativeDurations(values map[string]time.Duration) error { | |
| 115 | + | for name, value := range values { | |
| 116 | + | if value < 0 { | |
| 117 | + | return fmt.Errorf("%s must be positive", name) | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | return nil | |
| 121 | + | } | |
| @@ -0,0 +1,121 @@ | |||||
| 1 | + | package domain | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "time" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // Default HTTP server timeouts. | |||
| 9 | + | const ( | |||
| 10 | + | DefaultRegistryReadHeaderTimeout = 10 * time.Second | |||
| 11 | + | DefaultRegistryReadTimeout = 5 * time.Minute | |||
| 12 | + | DefaultRegistryWriteTimeout = 5 * time.Minute | |||
| 13 | + | DefaultRegistryIdleTimeout = 120 * time.Second | |||
| 14 | + | ||||
| 15 | + | DefaultEntryPointReadHeaderTimeout = 5 * time.Second | |||
| 16 | + | DefaultEntryPointIdleTimeout = 30 * time.Second | |||
| 17 | + | DefaultEntryPointTransferIdleTimeout = 2 * time.Minute | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | // RegistryHTTPTimeouts holds the fixed timeouts of the registry HTTP servers | |||
| 21 | + | // (config section server.http). Read and Write timeouts bound the whole | |||
| 22 | + | // request and response, which suits registry API calls. | |||
| 23 | + | type RegistryHTTPTimeouts struct { | |||
| 24 | + | ReadHeaderTimeout time.Duration | |||
| 25 | + | ReadTimeout time.Duration | |||
| 26 | + | WriteTimeout time.Duration | |||
| 27 | + | IdleTimeout time.Duration | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | // DefaultRegistryHTTPTimeouts returns the default registry server timeouts. | |||
| 31 | + | func DefaultRegistryHTTPTimeouts() RegistryHTTPTimeouts { | |||
| 32 | + | return RegistryHTTPTimeouts{ | |||
| 33 | + | ReadHeaderTimeout: DefaultRegistryReadHeaderTimeout, | |||
| 34 | + | ReadTimeout: DefaultRegistryReadTimeout, | |||
| 35 | + | WriteTimeout: DefaultRegistryWriteTimeout, | |||
| 36 | + | IdleTimeout: DefaultRegistryIdleTimeout, | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // WithDefaults replaces zero values with the defaults. | |||
| 41 | + | func (t RegistryHTTPTimeouts) WithDefaults() RegistryHTTPTimeouts { | |||
| 42 | + | defaults := DefaultRegistryHTTPTimeouts() | |||
| 43 | + | if t.ReadHeaderTimeout == 0 { | |||
| 44 | + | t.ReadHeaderTimeout = defaults.ReadHeaderTimeout | |||
| 45 | + | } | |||
| 46 | + | if t.ReadTimeout == 0 { | |||
| 47 | + | t.ReadTimeout = defaults.ReadTimeout | |||
| 48 | + | } | |||
| 49 | + | if t.WriteTimeout == 0 { | |||
| 50 | + | t.WriteTimeout = defaults.WriteTimeout | |||
| 51 | + | } | |||
| 52 | + | if t.IdleTimeout == 0 { | |||
| 53 | + | t.IdleTimeout = defaults.IdleTimeout | |||
| 54 | + | } | |||
| 55 | + | return t | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // Validate rejects negative durations. Zero values mean "use the default". | |||
| 59 | + | func (t RegistryHTTPTimeouts) Validate() error { | |||
| 60 | + | return validateNonNegativeDurations(map[string]time.Duration{ | |||
| 61 | + | "server.http.read_header_timeout": t.ReadHeaderTimeout, | |||
| 62 | + | "server.http.read_timeout": t.ReadTimeout, | |||
| 63 | + | "server.http.write_timeout": t.WriteTimeout, | |||
| 64 | + | "server.http.idle_timeout": t.IdleTimeout, | |||
| 65 | + | }) | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // HTTPOptions holds the timeouts of the HTTP servers behind traffic | |||
| 69 | + | // entrypoints (config section traffic.http). | |||
| 70 | + | // | |||
| 71 | + | // There is deliberately no fixed read or write timeout: ReadHeaderTimeout | |||
| 72 | + | // bounds the request header, IdleTimeout bounds keep-alive connections, and | |||
| 73 | + | // TransferIdleTimeout cuts a transfer only after that long without data in | |||
| 74 | + | // the direction being waited on. | |||
| 75 | + | type HTTPOptions struct { | |||
| 76 | + | ReadHeaderTimeout time.Duration | |||
| 77 | + | IdleTimeout time.Duration | |||
| 78 | + | TransferIdleTimeout time.Duration | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // DefaultHTTPOptions returns the default entrypoint HTTP timeouts. | |||
| 82 | + | func DefaultHTTPOptions() HTTPOptions { | |||
| 83 | + | return HTTPOptions{ | |||
| 84 | + | ReadHeaderTimeout: DefaultEntryPointReadHeaderTimeout, | |||
| 85 | + | IdleTimeout: DefaultEntryPointIdleTimeout, | |||
| 86 | + | TransferIdleTimeout: DefaultEntryPointTransferIdleTimeout, | |||
| 87 | + | } | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | // WithDefaults replaces zero values with the defaults. | |||
| 91 | + | func (o HTTPOptions) WithDefaults() HTTPOptions { | |||
| 92 | + | defaults := DefaultHTTPOptions() | |||
| 93 | + | if o.ReadHeaderTimeout == 0 { | |||
| 94 | + | o.ReadHeaderTimeout = defaults.ReadHeaderTimeout | |||
| 95 | + | } | |||
| 96 | + | if o.IdleTimeout == 0 { | |||
| 97 | + | o.IdleTimeout = defaults.IdleTimeout | |||
| 98 | + | } | |||
| 99 | + | if o.TransferIdleTimeout == 0 { | |||
| 100 | + | o.TransferIdleTimeout = defaults.TransferIdleTimeout | |||
| 101 | + | } | |||
| 102 | + | return o | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | // Validate rejects negative durations. Zero values mean "use the default". | |||
| 106 | + | func (o HTTPOptions) Validate() error { | |||
| 107 | + | return validateNonNegativeDurations(map[string]time.Duration{ | |||
| 108 | + | "traffic.http.read_header_timeout": o.ReadHeaderTimeout, | |||
| 109 | + | "traffic.http.idle_timeout": o.IdleTimeout, | |||
| 110 | + | "traffic.http.transfer_idle_timeout": o.TransferIdleTimeout, | |||
| 111 | + | }) | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | func validateNonNegativeDurations(values map[string]time.Duration) error { | |||
| 115 | + | for name, value := range values { | |||
| 116 | + | if value < 0 { | |||
| 117 | + | return fmt.Errorf("%s must be positive", name) | |||
| 118 | + | } | |||
| 119 | + | } | |||
| 120 | + | return nil | |||
| 121 | + | } | |||
A internal/domain/http_timeouts_test.go +55 -0
| @@ -0,0 +1,55 @@ | |||
| 1 | + | package domain | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/assert" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestDefaultHTTPOptions(t *testing.T) { | |
| 12 | + | assert.Equal(t, HTTPOptions{ | |
| 13 | + | ReadHeaderTimeout: 5 * time.Second, | |
| 14 | + | IdleTimeout: 30 * time.Second, | |
| 15 | + | TransferIdleTimeout: 2 * time.Minute, | |
| 16 | + | }, DefaultHTTPOptions()) | |
| 17 | + | assert.NoError(t, DefaultHTTPOptions().Validate()) | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func TestHTTPOptionsWithDefaults(t *testing.T) { | |
| 21 | + | got := HTTPOptions{TransferIdleTimeout: time.Hour}.WithDefaults() | |
| 22 | + | assert.Equal(t, 5*time.Second, got.ReadHeaderTimeout) | |
| 23 | + | assert.Equal(t, 30*time.Second, got.IdleTimeout) | |
| 24 | + | assert.Equal(t, time.Hour, got.TransferIdleTimeout) | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func TestHTTPOptionsValidateRejectsNegative(t *testing.T) { | |
| 28 | + | require.ErrorContains(t, HTTPOptions{ReadHeaderTimeout: -1}.Validate(), "traffic.http.read_header_timeout") | |
| 29 | + | require.ErrorContains(t, HTTPOptions{IdleTimeout: -1}.Validate(), "traffic.http.idle_timeout") | |
| 30 | + | require.ErrorContains(t, HTTPOptions{TransferIdleTimeout: -1}.Validate(), "traffic.http.transfer_idle_timeout") | |
| 31 | + | assert.NoError(t, HTTPOptions{}.Validate(), "zero selects the default") | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func TestTrafficGraphRejectsNegativeHTTPOptions(t *testing.T) { | |
| 35 | + | graph := TrafficGraph{Options: TrafficOptions{HTTP: HTTPOptions{TransferIdleTimeout: -time.Second}}} | |
| 36 | + | require.ErrorContains(t, graph.Validate(), "traffic.http.transfer_idle_timeout") | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func TestDefaultRegistryHTTPTimeouts(t *testing.T) { | |
| 40 | + | assert.Equal(t, RegistryHTTPTimeouts{ | |
| 41 | + | ReadHeaderTimeout: 10 * time.Second, | |
| 42 | + | ReadTimeout: 5 * time.Minute, | |
| 43 | + | WriteTimeout: 5 * time.Minute, | |
| 44 | + | IdleTimeout: 120 * time.Second, | |
| 45 | + | }, DefaultRegistryHTTPTimeouts()) | |
| 46 | + | assert.Equal(t, DefaultRegistryHTTPTimeouts(), RegistryHTTPTimeouts{}.WithDefaults()) | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func TestRegistryHTTPTimeoutsValidate(t *testing.T) { | |
| 50 | + | assert.NoError(t, DefaultRegistryHTTPTimeouts().Validate()) | |
| 51 | + | require.ErrorContains(t, RegistryHTTPTimeouts{ReadHeaderTimeout: -1}.Validate(), "server.http.read_header_timeout") | |
| 52 | + | require.ErrorContains(t, RegistryHTTPTimeouts{ReadTimeout: -1}.Validate(), "server.http.read_timeout") | |
| 53 | + | require.ErrorContains(t, RegistryHTTPTimeouts{WriteTimeout: -1}.Validate(), "server.http.write_timeout") | |
| 54 | + | require.ErrorContains(t, RegistryHTTPTimeouts{IdleTimeout: -1}.Validate(), "server.http.idle_timeout") | |
| 55 | + | } | |
| @@ -0,0 +1,55 @@ | |||||
| 1 | + | package domain | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/assert" | |||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestDefaultHTTPOptions(t *testing.T) { | |||
| 12 | + | assert.Equal(t, HTTPOptions{ | |||
| 13 | + | ReadHeaderTimeout: 5 * time.Second, | |||
| 14 | + | IdleTimeout: 30 * time.Second, | |||
| 15 | + | TransferIdleTimeout: 2 * time.Minute, | |||
| 16 | + | }, DefaultHTTPOptions()) | |||
| 17 | + | assert.NoError(t, DefaultHTTPOptions().Validate()) | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | func TestHTTPOptionsWithDefaults(t *testing.T) { | |||
| 21 | + | got := HTTPOptions{TransferIdleTimeout: time.Hour}.WithDefaults() | |||
| 22 | + | assert.Equal(t, 5*time.Second, got.ReadHeaderTimeout) | |||
| 23 | + | assert.Equal(t, 30*time.Second, got.IdleTimeout) | |||
| 24 | + | assert.Equal(t, time.Hour, got.TransferIdleTimeout) | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func TestHTTPOptionsValidateRejectsNegative(t *testing.T) { | |||
| 28 | + | require.ErrorContains(t, HTTPOptions{ReadHeaderTimeout: -1}.Validate(), "traffic.http.read_header_timeout") | |||
| 29 | + | require.ErrorContains(t, HTTPOptions{IdleTimeout: -1}.Validate(), "traffic.http.idle_timeout") | |||
| 30 | + | require.ErrorContains(t, HTTPOptions{TransferIdleTimeout: -1}.Validate(), "traffic.http.transfer_idle_timeout") | |||
| 31 | + | assert.NoError(t, HTTPOptions{}.Validate(), "zero selects the default") | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func TestTrafficGraphRejectsNegativeHTTPOptions(t *testing.T) { | |||
| 35 | + | graph := TrafficGraph{Options: TrafficOptions{HTTP: HTTPOptions{TransferIdleTimeout: -time.Second}}} | |||
| 36 | + | require.ErrorContains(t, graph.Validate(), "traffic.http.transfer_idle_timeout") | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func TestDefaultRegistryHTTPTimeouts(t *testing.T) { | |||
| 40 | + | assert.Equal(t, RegistryHTTPTimeouts{ | |||
| 41 | + | ReadHeaderTimeout: 10 * time.Second, | |||
| 42 | + | ReadTimeout: 5 * time.Minute, | |||
| 43 | + | WriteTimeout: 5 * time.Minute, | |||
| 44 | + | IdleTimeout: 120 * time.Second, | |||
| 45 | + | }, DefaultRegistryHTTPTimeouts()) | |||
| 46 | + | assert.Equal(t, DefaultRegistryHTTPTimeouts(), RegistryHTTPTimeouts{}.WithDefaults()) | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | func TestRegistryHTTPTimeoutsValidate(t *testing.T) { | |||
| 50 | + | assert.NoError(t, DefaultRegistryHTTPTimeouts().Validate()) | |||
| 51 | + | require.ErrorContains(t, RegistryHTTPTimeouts{ReadHeaderTimeout: -1}.Validate(), "server.http.read_header_timeout") | |||
| 52 | + | require.ErrorContains(t, RegistryHTTPTimeouts{ReadTimeout: -1}.Validate(), "server.http.read_timeout") | |||
| 53 | + | require.ErrorContains(t, RegistryHTTPTimeouts{WriteTimeout: -1}.Validate(), "server.http.write_timeout") | |||
| 54 | + | require.ErrorContains(t, RegistryHTTPTimeouts{IdleTimeout: -1}.Validate(), "server.http.idle_timeout") | |||
| 55 | + | } | |||
M internal/domain/traffic.go +6 -2
| @@ -42,8 +42,9 @@ type TrafficGraph struct { | |||
| 42 | 42 | } | |
| 43 | 43 | ||
| 44 | 44 | type TrafficOptions struct { | |
| 45 | - | TCP TCPOptions | |
| 46 | - | UDP UDPOptions | |
| 45 | + | TCP TCPOptions | |
| 46 | + | UDP UDPOptions | |
| 47 | + | HTTP HTTPOptions | |
| 47 | 48 | } | |
| 48 | 49 | ||
| 49 | 50 | type TCPOptions struct { | |
| @@ -230,6 +231,9 @@ func (g TrafficGraph) Validate() error { | |||
| 230 | 231 | } | |
| 231 | 232 | ||
| 232 | 233 | func validateTrafficOptions(options TrafficOptions) error { | |
| 234 | + | if err := options.HTTP.Validate(); err != nil { | |
| 235 | + | return err | |
| 236 | + | } | |
| 233 | 237 | if err := validateOptionalPositiveDuration(options.TCP.DialTimeout, "traffic.tcp.dial_timeout"); err != nil { | |
| 234 | 238 | return err | |
| 235 | 239 | } | |
| @@ -42,8 +42,9 @@ type TrafficGraph struct { | |||||
| 42 | } | 42 | } | ||
| 43 | 43 | ||||
| 44 | type TrafficOptions struct { | 44 | type TrafficOptions struct { | ||
| 45 | - | TCP TCPOptions | 45 | + | TCP TCPOptions |
| 46 | - | UDP UDPOptions | 46 | + | UDP UDPOptions |
| 47 | + | HTTP HTTPOptions | |||
| 47 | } | 48 | } | ||
| 48 | 49 | ||||
| 49 | type TCPOptions struct { | 50 | type TCPOptions struct { | ||
| @@ -230,6 +231,9 @@ func (g TrafficGraph) Validate() error { | |||||
| 230 | } | 231 | } | ||
| 231 | 232 | ||||
| 232 | func validateTrafficOptions(options TrafficOptions) error { | 233 | func validateTrafficOptions(options TrafficOptions) error { | ||
| 234 | + | if err := options.HTTP.Validate(); err != nil { | |||
| 235 | + | return err | |||
| 236 | + | } | |||
| 233 | if err := validateOptionalPositiveDuration(options.TCP.DialTimeout, "traffic.tcp.dial_timeout"); err != nil { | 237 | if err := validateOptionalPositiveDuration(options.TCP.DialTimeout, "traffic.tcp.dial_timeout"); err != nil { | ||
| 234 | return err | 238 | return err | ||
| 235 | } | 239 | } | ||
M internal/usecase/traffic/builder.go +34 -1
| @@ -38,6 +38,15 @@ type Config struct { | |||
| 38 | 38 | TCP TCPConfig `mapstructure:"tcp"` | |
| 39 | 39 | UDP UDPConfig `mapstructure:"udp"` | |
| 40 | 40 | TLS TLSConfig `mapstructure:"tls"` | |
| 41 | + | // HTTP configures the HTTP servers behind traffic entrypoints. | |
| 42 | + | HTTP HTTPConfig `mapstructure:"http"` | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // HTTPConfig holds traffic.http duration strings (Go duration syntax). | |
| 46 | + | type HTTPConfig struct { | |
| 47 | + | ReadHeaderTimeout string `mapstructure:"read_header_timeout"` | |
| 48 | + | IdleTimeout string `mapstructure:"idle_timeout"` | |
| 49 | + | TransferIdleTimeout string `mapstructure:"transfer_idle_timeout"` | |
| 41 | 50 | } | |
| 42 | 51 | ||
| 43 | 52 | type TCPConfig struct { | |
| @@ -476,7 +485,31 @@ func buildOptions(cfg Config) (domain.TrafficOptions, error) { | |||
| 476 | 485 | if err != nil { | |
| 477 | 486 | return domain.TrafficOptions{}, err | |
| 478 | 487 | } | |
| 479 | - | return domain.TrafficOptions{TCP: domain.TCPOptions{DialTimeout: tcpDial, IdleTimeout: tcpIdle, DrainTimeout: tcpDrain, MaxConnections: cfg.TCP.MaxConnections}, UDP: domain.UDPOptions{IdleTimeout: udpIdle, DrainTimeout: udpDrain, MaxSessions: cfg.UDP.MaxSessions}}, nil | |
| 488 | + | httpOptions, err := ParseHTTPOptions(cfg.HTTP) | |
| 489 | + | if err != nil { | |
| 490 | + | return domain.TrafficOptions{}, err | |
| 491 | + | } | |
| 492 | + | return domain.TrafficOptions{TCP: domain.TCPOptions{DialTimeout: tcpDial, IdleTimeout: tcpIdle, DrainTimeout: tcpDrain, MaxConnections: cfg.TCP.MaxConnections}, UDP: domain.UDPOptions{IdleTimeout: udpIdle, DrainTimeout: udpDrain, MaxSessions: cfg.UDP.MaxSessions}, HTTP: httpOptions}, nil | |
| 493 | + | } | |
| 494 | + | ||
| 495 | + | // ParseHTTPOptions converts traffic.http duration strings into validated | |
| 496 | + | // options. Empty values select the defaults; non-positive or malformed values | |
| 497 | + | // are rejected. | |
| 498 | + | func ParseHTTPOptions(cfg HTTPConfig) (domain.HTTPOptions, error) { | |
| 499 | + | defaults := domain.DefaultHTTPOptions() | |
| 500 | + | readHeader, err := parsePositiveDurationDefault(cfg.ReadHeaderTimeout, defaults.ReadHeaderTimeout, "traffic.http.read_header_timeout") | |
| 501 | + | if err != nil { | |
| 502 | + | return domain.HTTPOptions{}, err | |
| 503 | + | } | |
| 504 | + | idle, err := parsePositiveDurationDefault(cfg.IdleTimeout, defaults.IdleTimeout, "traffic.http.idle_timeout") | |
| 505 | + | if err != nil { | |
| 506 | + | return domain.HTTPOptions{}, err | |
| 507 | + | } | |
| 508 | + | transferIdle, err := parsePositiveDurationDefault(cfg.TransferIdleTimeout, defaults.TransferIdleTimeout, "traffic.http.transfer_idle_timeout") | |
| 509 | + | if err != nil { | |
| 510 | + | return domain.HTTPOptions{}, err | |
| 511 | + | } | |
| 512 | + | return domain.HTTPOptions{ReadHeaderTimeout: readHeader, IdleTimeout: idle, TransferIdleTimeout: transferIdle}, nil | |
| 480 | 513 | } | |
| 481 | 514 | ||
| 482 | 515 | func parsePositiveDurationDefault(value string, fallback time.Duration, field string) (time.Duration, error) { | |
| @@ -38,6 +38,15 @@ type Config struct { | |||||
| 38 | TCP TCPConfig `mapstructure:"tcp"` | 38 | TCP TCPConfig `mapstructure:"tcp"` | ||
| 39 | UDP UDPConfig `mapstructure:"udp"` | 39 | UDP UDPConfig `mapstructure:"udp"` | ||
| 40 | TLS TLSConfig `mapstructure:"tls"` | 40 | TLS TLSConfig `mapstructure:"tls"` | ||
| 41 | + | // HTTP configures the HTTP servers behind traffic entrypoints. | |||
| 42 | + | HTTP HTTPConfig `mapstructure:"http"` | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | // HTTPConfig holds traffic.http duration strings (Go duration syntax). | |||
| 46 | + | type HTTPConfig struct { | |||
| 47 | + | ReadHeaderTimeout string `mapstructure:"read_header_timeout"` | |||
| 48 | + | IdleTimeout string `mapstructure:"idle_timeout"` | |||
| 49 | + | TransferIdleTimeout string `mapstructure:"transfer_idle_timeout"` | |||
| 41 | } | 50 | } | ||
| 42 | 51 | ||||
| 43 | type TCPConfig struct { | 52 | type TCPConfig struct { | ||
| @@ -476,7 +485,31 @@ func buildOptions(cfg Config) (domain.TrafficOptions, error) { | |||||
| 476 | if err != nil { | 485 | if err != nil { | ||
| 477 | return domain.TrafficOptions{}, err | 486 | return domain.TrafficOptions{}, err | ||
| 478 | } | 487 | } | ||
| 479 | - | return domain.TrafficOptions{TCP: domain.TCPOptions{DialTimeout: tcpDial, IdleTimeout: tcpIdle, DrainTimeout: tcpDrain, MaxConnections: cfg.TCP.MaxConnections}, UDP: domain.UDPOptions{IdleTimeout: udpIdle, DrainTimeout: udpDrain, MaxSessions: cfg.UDP.MaxSessions}}, nil | 488 | + | httpOptions, err := ParseHTTPOptions(cfg.HTTP) |
| 489 | + | if err != nil { | |||
| 490 | + | return domain.TrafficOptions{}, err | |||
| 491 | + | } | |||
| 492 | + | return domain.TrafficOptions{TCP: domain.TCPOptions{DialTimeout: tcpDial, IdleTimeout: tcpIdle, DrainTimeout: tcpDrain, MaxConnections: cfg.TCP.MaxConnections}, UDP: domain.UDPOptions{IdleTimeout: udpIdle, DrainTimeout: udpDrain, MaxSessions: cfg.UDP.MaxSessions}, HTTP: httpOptions}, nil | |||
| 493 | + | } | |||
| 494 | + | ||||
| 495 | + | // ParseHTTPOptions converts traffic.http duration strings into validated | |||
| 496 | + | // options. Empty values select the defaults; non-positive or malformed values | |||
| 497 | + | // are rejected. | |||
| 498 | + | func ParseHTTPOptions(cfg HTTPConfig) (domain.HTTPOptions, error) { | |||
| 499 | + | defaults := domain.DefaultHTTPOptions() | |||
| 500 | + | readHeader, err := parsePositiveDurationDefault(cfg.ReadHeaderTimeout, defaults.ReadHeaderTimeout, "traffic.http.read_header_timeout") | |||
| 501 | + | if err != nil { | |||
| 502 | + | return domain.HTTPOptions{}, err | |||
| 503 | + | } | |||
| 504 | + | idle, err := parsePositiveDurationDefault(cfg.IdleTimeout, defaults.IdleTimeout, "traffic.http.idle_timeout") | |||
| 505 | + | if err != nil { | |||
| 506 | + | return domain.HTTPOptions{}, err | |||
| 507 | + | } | |||
| 508 | + | transferIdle, err := parsePositiveDurationDefault(cfg.TransferIdleTimeout, defaults.TransferIdleTimeout, "traffic.http.transfer_idle_timeout") | |||
| 509 | + | if err != nil { | |||
| 510 | + | return domain.HTTPOptions{}, err | |||
| 511 | + | } | |||
| 512 | + | return domain.HTTPOptions{ReadHeaderTimeout: readHeader, IdleTimeout: idle, TransferIdleTimeout: transferIdle}, nil | |||
| 480 | } | 513 | } | ||
| 481 | 514 | ||||
| 482 | func parsePositiveDurationDefault(value string, fallback time.Duration, field string) (time.Duration, error) { | 515 | func parsePositiveDurationDefault(value string, fallback time.Duration, field string) (time.Duration, error) { | ||
M internal/usecase/traffic/builder_test.go +15 -0
| @@ -173,6 +173,21 @@ func TestBuildOptionsParsing(t *testing.T) { | |||
| 173 | 173 | require.ErrorContains(t, err, "traffic.tcp.dial_timeout") | |
| 174 | 174 | } | |
| 175 | 175 | ||
| 176 | + | func TestBuildHTTPOptions(t *testing.T) { | |
| 177 | + | defaults, err := Build(Input{}) | |
| 178 | + | require.NoError(t, err) | |
| 179 | + | require.Equal(t, domain.DefaultHTTPOptions(), defaults.Options.HTTP) | |
| 180 | + | ||
| 181 | + | graph, err := Build(Input{Traffic: Config{HTTP: HTTPConfig{ReadHeaderTimeout: "1s", IdleTimeout: "2s", TransferIdleTimeout: "3m"}}}) | |
| 182 | + | require.NoError(t, err) | |
| 183 | + | require.Equal(t, domain.HTTPOptions{ReadHeaderTimeout: time.Second, IdleTimeout: 2 * time.Second, TransferIdleTimeout: 3 * time.Minute}, graph.Options.HTTP) | |
| 184 | + | ||
| 185 | + | for _, value := range []string{"bogus", "-1s", "0s"} { | |
| 186 | + | _, err = Build(Input{Traffic: Config{HTTP: HTTPConfig{TransferIdleTimeout: value}}}) | |
| 187 | + | require.ErrorContains(t, err, "traffic.http.transfer_idle_timeout", value) | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | ||
| 176 | 191 | func TestBuildRejectsAmbiguousNetworkServices(t *testing.T) { | |
| 177 | 192 | t.Run("duplicate service name", func(t *testing.T) { | |
| 178 | 193 | _, err := Build(Input{NetworkServices: []NetworkServiceConfig{ | |
| @@ -173,6 +173,21 @@ func TestBuildOptionsParsing(t *testing.T) { | |||||
| 173 | require.ErrorContains(t, err, "traffic.tcp.dial_timeout") | 173 | require.ErrorContains(t, err, "traffic.tcp.dial_timeout") | ||
| 174 | } | 174 | } | ||
| 175 | 175 | ||||
| 176 | + | func TestBuildHTTPOptions(t *testing.T) { | |||
| 177 | + | defaults, err := Build(Input{}) | |||
| 178 | + | require.NoError(t, err) | |||
| 179 | + | require.Equal(t, domain.DefaultHTTPOptions(), defaults.Options.HTTP) | |||
| 180 | + | ||||
| 181 | + | graph, err := Build(Input{Traffic: Config{HTTP: HTTPConfig{ReadHeaderTimeout: "1s", IdleTimeout: "2s", TransferIdleTimeout: "3m"}}}) | |||
| 182 | + | require.NoError(t, err) | |||
| 183 | + | require.Equal(t, domain.HTTPOptions{ReadHeaderTimeout: time.Second, IdleTimeout: 2 * time.Second, TransferIdleTimeout: 3 * time.Minute}, graph.Options.HTTP) | |||
| 184 | + | ||||
| 185 | + | for _, value := range []string{"bogus", "-1s", "0s"} { | |||
| 186 | + | _, err = Build(Input{Traffic: Config{HTTP: HTTPConfig{TransferIdleTimeout: value}}}) | |||
| 187 | + | require.ErrorContains(t, err, "traffic.http.transfer_idle_timeout", value) | |||
| 188 | + | } | |||
| 189 | + | } | |||
| 190 | + | ||||
| 176 | func TestBuildRejectsAmbiguousNetworkServices(t *testing.T) { | 191 | func TestBuildRejectsAmbiguousNetworkServices(t *testing.T) { | ||
| 177 | t.Run("duplicate service name", func(t *testing.T) { | 192 | t.Run("duplicate service name", func(t *testing.T) { | ||
| 178 | _, err := Build(Input{NetworkServices: []NetworkServiceConfig{ | 193 | _, err := Build(Input{NetworkServices: []NetworkServiceConfig{ | ||