fix(traffic): resolve final review regressions
- commit
- 8a0e9d549a6b9468d4f37a54fe711645f22f9fa2
- author
- brice · · 2026-06-30 17:32 UTC
- parent
- 3489c65
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
24 files · +273 -90
M docs/cli/remotes.md +1 -1
| @@ -350,7 +350,7 @@ A common Tailscale setup is to point your domain's DNS at the machine's Tailscal | |||
| 350 | 350 | - `insecure_tls = true` on the remote — skips certificate verification entirely | |
| 351 | 351 | - `sudo gordon ca install` — installs the root CA into system, Firefox, and Java trust stores | |
| 352 | 352 | - `gordon ca export --out gordon-ca.crt` — exports the root CA PEM for manual installation | |
| 353 | - | - Visit `https://<gordon-host>/ca` (or your mapped edge address) in a browser — onboarding page with downloads for macOS, Linux, Windows, iOS, and Android | |
| 353 | + | - Visit `https://<gordon-host>/.well-known/gordon/ca` (or your mapped edge address) in a browser — onboarding page with downloads for macOS, Linux, Windows, iOS, and Android | |
| 354 | 354 | ||
| 355 | 355 | If you have your own certificate for the domain (e.g. from a corporate CA), you can provide it via `tls_cert_file`/`tls_key_file` — see [Server Configuration](../config/server.md#custom-certificates). The static cert is served for SNI-matching domains; everything else falls through to the internal CA. | |
| 356 | 356 | ||
| @@ -350,7 +350,7 @@ A common Tailscale setup is to point your domain's DNS at the machine's Tailscal | |||||
| 350 | - `insecure_tls = true` on the remote — skips certificate verification entirely | 350 | - `insecure_tls = true` on the remote — skips certificate verification entirely | ||
| 351 | - `sudo gordon ca install` — installs the root CA into system, Firefox, and Java trust stores | 351 | - `sudo gordon ca install` — installs the root CA into system, Firefox, and Java trust stores | ||
| 352 | - `gordon ca export --out gordon-ca.crt` — exports the root CA PEM for manual installation | 352 | - `gordon ca export --out gordon-ca.crt` — exports the root CA PEM for manual installation | ||
| 353 | - | - Visit `https://<gordon-host>/ca` (or your mapped edge address) in a browser — onboarding page with downloads for macOS, Linux, Windows, iOS, and Android | 353 | + | - Visit `https://<gordon-host>/.well-known/gordon/ca` (or your mapped edge address) in a browser — onboarding page with downloads for macOS, Linux, Windows, iOS, and Android |
| 354 | 354 | ||||
| 355 | If you have your own certificate for the domain (e.g. from a corporate CA), you can provide it via `tls_cert_file`/`tls_key_file` — see [Server Configuration](../config/server.md#custom-certificates). The static cert is served for SNI-matching domains; everything else falls through to the internal CA. | 355 | If you have your own certificate for the domain (e.g. from a corporate CA), you can provide it via `tls_cert_file`/`tls_key_file` — see [Server Configuration](../config/server.md#custom-certificates). The static cert is served for SNI-matching domains; everything else falls through to the internal CA. | ||
| 356 | 356 | ||||
M docs/config/reference.md +2 -2
| @@ -314,7 +314,7 @@ keep_last = 3 # Keep N newest tags per repository | |||
| 314 | 314 | | `volumes.auto_create` | `true` | Auto-create volumes | | |
| 315 | 315 | | `volumes.prefix` | `"gordon"` | Volume prefix | | |
| 316 | 316 | | `volumes.preserve` | `true` | Keep volumes | | |
| 317 | - | | `services[].name` | none | Standalone service name used by `service:<name>:<port>` traffic refs | | |
| 317 | + | | `services[].name` | none | Standalone service name used by `service:<service>:<port-name>` traffic refs | | |
| 318 | 318 | | `services[].image` | none | Container image for enabled standalone services | | |
| 319 | 319 | | `services[].enabled` | `false` | Whether Gordon creates, starts, and reconciles the service container | | |
| 320 | 320 | | `services[].env` | `[]` | Inline `KEY=value` environment entries | | |
| @@ -322,7 +322,7 @@ keep_last = 3 # Keep N newest tags per repository | |||
| 322 | 322 | | `services[].ports[].name` | none | Port name used by traffic service refs | | |
| 323 | 323 | | `services[].ports[].container` | none | Container port number | | |
| 324 | 324 | | `services[].ports[].protocol` | none | `tcp` or `udp` | | |
| 325 | - | | `services[].ports[].publish` | `""` | Host-side publish address, usually loopback for traffic-manager reachability | | |
| 325 | + | | `services[].ports[].publish` | `""` | Host-side bind address, usually loopback, that the traffic manager dials | | |
| 326 | 326 | | `services[].ports[].private` | `false` | Require matching service and entrypoint `trusted_cidrs` for this port | | |
| 327 | 327 | | `services[].ports[].public` | `false` | Explicit public opt-out for admin port names such as `rcon` | | |
| 328 | 328 | | `services[].ports[].trusted_cidrs` | `[]` | CIDRs allowed for private port routing; must match the target entrypoint | | |
| @@ -314,7 +314,7 @@ keep_last = 3 # Keep N newest tags per repository | |||||
| 314 | | `volumes.auto_create` | `true` | Auto-create volumes | | 314 | | `volumes.auto_create` | `true` | Auto-create volumes | | ||
| 315 | | `volumes.prefix` | `"gordon"` | Volume prefix | | 315 | | `volumes.prefix` | `"gordon"` | Volume prefix | | ||
| 316 | | `volumes.preserve` | `true` | Keep volumes | | 316 | | `volumes.preserve` | `true` | Keep volumes | | ||
| 317 | - | | `services[].name` | none | Standalone service name used by `service:<name>:<port>` traffic refs | | 317 | + | | `services[].name` | none | Standalone service name used by `service:<service>:<port-name>` traffic refs | |
| 318 | | `services[].image` | none | Container image for enabled standalone services | | 318 | | `services[].image` | none | Container image for enabled standalone services | | ||
| 319 | | `services[].enabled` | `false` | Whether Gordon creates, starts, and reconciles the service container | | 319 | | `services[].enabled` | `false` | Whether Gordon creates, starts, and reconciles the service container | | ||
| 320 | | `services[].env` | `[]` | Inline `KEY=value` environment entries | | 320 | | `services[].env` | `[]` | Inline `KEY=value` environment entries | | ||
| @@ -322,7 +322,7 @@ keep_last = 3 # Keep N newest tags per repository | |||||
| 322 | | `services[].ports[].name` | none | Port name used by traffic service refs | | 322 | | `services[].ports[].name` | none | Port name used by traffic service refs | | ||
| 323 | | `services[].ports[].container` | none | Container port number | | 323 | | `services[].ports[].container` | none | Container port number | | ||
| 324 | | `services[].ports[].protocol` | none | `tcp` or `udp` | | 324 | | `services[].ports[].protocol` | none | `tcp` or `udp` | | ||
| 325 | - | | `services[].ports[].publish` | `""` | Host-side publish address, usually loopback for traffic-manager reachability | | 325 | + | | `services[].ports[].publish` | `""` | Host-side bind address, usually loopback, that the traffic manager dials | |
| 326 | | `services[].ports[].private` | `false` | Require matching service and entrypoint `trusted_cidrs` for this port | | 326 | | `services[].ports[].private` | `false` | Require matching service and entrypoint `trusted_cidrs` for this port | | ||
| 327 | | `services[].ports[].public` | `false` | Explicit public opt-out for admin port names such as `rcon` | | 327 | | `services[].ports[].public` | `false` | Explicit public opt-out for admin port names such as `rcon` | | ||
| 328 | | `services[].ports[].trusted_cidrs` | `[]` | CIDRs allowed for private port routing; must match the target entrypoint | | 328 | | `services[].ports[].trusted_cidrs` | `[]` | CIDRs allowed for private port routing; must match the target entrypoint | | ||
M docs/config/services.md +2 -2
| @@ -33,11 +33,11 @@ publish = "127.0.0.1:38016" | |||
| 33 | 33 | trusted_cidrs = ["100.64.0.0/10"] | |
| 34 | 34 | ``` | |
| 35 | 35 | ||
| 36 | - | The `publish` address is the host-side loopback address that Gordon's traffic manager dials. Expose it through `[traffic]` routers instead of binding the service container directly to a public interface. | |
| 36 | + | The `publish` address is the host-side bind address that Gordon's traffic manager dials; use loopback for private backends. Expose services through `[traffic]` routers instead of binding service containers directly to a public interface. | |
| 37 | 37 | ||
| 38 | 38 | ## Traffic routing | |
| 39 | 39 | ||
| 40 | - | Use `service:<service-name>:<port-name>` from TCP, UDP, or TLS passthrough routers: | |
| 40 | + | Use `service:<service>:<port-name>` from TCP, UDP, or TLS passthrough routers: | |
| 41 | 41 | ||
| 42 | 42 | ```toml | |
| 43 | 43 | [entrypoints.rust] | |
| @@ -33,11 +33,11 @@ publish = "127.0.0.1:38016" | |||||
| 33 | trusted_cidrs = ["100.64.0.0/10"] | 33 | trusted_cidrs = ["100.64.0.0/10"] | ||
| 34 | ``` | 34 | ``` | ||
| 35 | 35 | ||||
| 36 | - | The `publish` address is the host-side loopback address that Gordon's traffic manager dials. Expose it through `[traffic]` routers instead of binding the service container directly to a public interface. | 36 | + | The `publish` address is the host-side bind address that Gordon's traffic manager dials; use loopback for private backends. Expose services through `[traffic]` routers instead of binding service containers directly to a public interface. |
| 37 | 37 | ||||
| 38 | ## Traffic routing | 38 | ## Traffic routing | ||
| 39 | 39 | ||||
| 40 | - | Use `service:<service-name>:<port-name>` from TCP, UDP, or TLS passthrough routers: | 40 | + | Use `service:<service>:<port-name>` from TCP, UDP, or TLS passthrough routers: |
| 41 | 41 | ||||
| 42 | ```toml | 42 | ```toml | ||
| 43 | [entrypoints.rust] | 43 | [entrypoints.rust] | ||
M internal/adapters/in/cli/controlplane_local.go +1 -1
| @@ -446,7 +446,7 @@ func (l *localControlPlane) GetTLSStatus(ctx context.Context) (*dto.TLSStatusRes | |||
| 446 | 446 | } | |
| 447 | 447 | ||
| 448 | 448 | func (l *localControlPlane) GetTrafficStatus(_ context.Context) (*dto.TrafficStatusResponse, error) { | |
| 449 | - | return nil, fmt.Errorf("local traffic status is unavailable from the in-process CLI control plane; query the running Gordon daemon with --remote or set GORDON_REMOTE to its admin URL") | |
| 449 | + | return nil, fmt.Errorf("local traffic status is unavailable from the in-process CLI control plane; query the running Gordon daemon with --remote or set GORDON_REMOTE to its admin URL: %w", domain.ErrTrafficStatusUnavailable) | |
| 450 | 450 | } | |
| 451 | 451 | ||
| 452 | 452 | func (l *localControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) { | |
| @@ -446,7 +446,7 @@ func (l *localControlPlane) GetTLSStatus(ctx context.Context) (*dto.TLSStatusRes | |||||
| 446 | } | 446 | } | ||
| 447 | 447 | ||||
| 448 | func (l *localControlPlane) GetTrafficStatus(_ context.Context) (*dto.TrafficStatusResponse, error) { | 448 | func (l *localControlPlane) GetTrafficStatus(_ context.Context) (*dto.TrafficStatusResponse, error) { | ||
| 449 | - | return nil, fmt.Errorf("local traffic status is unavailable from the in-process CLI control plane; query the running Gordon daemon with --remote or set GORDON_REMOTE to its admin URL") | 449 | + | return nil, fmt.Errorf("local traffic status is unavailable from the in-process CLI control plane; query the running Gordon daemon with --remote or set GORDON_REMOTE to its admin URL: %w", domain.ErrTrafficStatusUnavailable) |
| 450 | } | 450 | } | ||
| 451 | 451 | ||||
| 452 | func (l *localControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) { | 452 | func (l *localControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) { | ||
M internal/adapters/in/cli/traffic_test.go +3 -0
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "bytes" | |
| 5 | 5 | "context" | |
| 6 | 6 | "encoding/json" | |
| 7 | + | "errors" | |
| 7 | 8 | "net/http" | |
| 8 | 9 | "net/http/httptest" | |
| 9 | 10 | "testing" | |
| @@ -92,6 +93,7 @@ func TestRunTrafficStatusUsesControlPlane(t *testing.T) { | |||
| 92 | 93 | func TestRemoteTrafficStatusControlPlaneStillRenders(t *testing.T) { | |
| 93 | 94 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 94 | 95 | require.Equal(t, "/admin/traffic/status", r.URL.Path) | |
| 96 | + | require.Equal(t, http.MethodGet, r.Method) | |
| 95 | 97 | _, err := w.Write([]byte(`{"last_reload_status":"ok","counters":{"active_udp_sessions":3}}`)) | |
| 96 | 98 | require.NoError(t, err) | |
| 97 | 99 | })) | |
| @@ -111,6 +113,7 @@ func TestRemoteTrafficStatusControlPlaneStillRenders(t *testing.T) { | |||
| 111 | 113 | func TestLocalTrafficStatusReturnsActionableDaemonGuidance(t *testing.T) { | |
| 112 | 114 | _, err := (&localControlPlane{}).GetTrafficStatus(context.Background()) | |
| 113 | 115 | require.Error(t, err) | |
| 116 | + | assert.True(t, errors.Is(err, domain.ErrTrafficStatusUnavailable)) | |
| 114 | 117 | assert.Contains(t, err.Error(), "in-process CLI control plane") | |
| 115 | 118 | assert.Contains(t, err.Error(), "running Gordon daemon") | |
| 116 | 119 | assert.Contains(t, err.Error(), "--remote") | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | "context" | 5 | "context" | ||
| 6 | "encoding/json" | 6 | "encoding/json" | ||
| 7 | + | "errors" | |||
| 7 | "net/http" | 8 | "net/http" | ||
| 8 | "net/http/httptest" | 9 | "net/http/httptest" | ||
| 9 | "testing" | 10 | "testing" | ||
| @@ -92,6 +93,7 @@ func TestRunTrafficStatusUsesControlPlane(t *testing.T) { | |||||
| 92 | func TestRemoteTrafficStatusControlPlaneStillRenders(t *testing.T) { | 93 | func TestRemoteTrafficStatusControlPlaneStillRenders(t *testing.T) { | ||
| 93 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 94 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 94 | require.Equal(t, "/admin/traffic/status", r.URL.Path) | 95 | require.Equal(t, "/admin/traffic/status", r.URL.Path) | ||
| 96 | + | require.Equal(t, http.MethodGet, r.Method) | |||
| 95 | _, err := w.Write([]byte(`{"last_reload_status":"ok","counters":{"active_udp_sessions":3}}`)) | 97 | _, err := w.Write([]byte(`{"last_reload_status":"ok","counters":{"active_udp_sessions":3}}`)) | ||
| 96 | require.NoError(t, err) | 98 | require.NoError(t, err) | ||
| 97 | })) | 99 | })) | ||
| @@ -111,6 +113,7 @@ func TestRemoteTrafficStatusControlPlaneStillRenders(t *testing.T) { | |||||
| 111 | func TestLocalTrafficStatusReturnsActionableDaemonGuidance(t *testing.T) { | 113 | func TestLocalTrafficStatusReturnsActionableDaemonGuidance(t *testing.T) { | ||
| 112 | _, err := (&localControlPlane{}).GetTrafficStatus(context.Background()) | 114 | _, err := (&localControlPlane{}).GetTrafficStatus(context.Background()) | ||
| 113 | require.Error(t, err) | 115 | require.Error(t, err) | ||
| 116 | + | assert.True(t, errors.Is(err, domain.ErrTrafficStatusUnavailable)) | |||
| 114 | assert.Contains(t, err.Error(), "in-process CLI control plane") | 117 | assert.Contains(t, err.Error(), "in-process CLI control plane") | ||
| 115 | assert.Contains(t, err.Error(), "running Gordon daemon") | 118 | assert.Contains(t, err.Error(), "running Gordon daemon") | ||
| 116 | assert.Contains(t, err.Error(), "--remote") | 119 | assert.Contains(t, err.Error(), "--remote") | ||
M internal/adapters/in/traffic/smart_tcp.go +1 -1
| @@ -257,6 +257,6 @@ func stopInternalHTTPServer(ctx context.Context, listener *tlsHTTPListener, serv | |||
| 257 | 257 | } | |
| 258 | 258 | select { | |
| 259 | 259 | case <-done: | |
| 260 | - | case <-ctx.Done(): | |
| 260 | + | case <-shutdownCtx.Done(): | |
| 261 | 261 | } | |
| 262 | 262 | } | |
| @@ -257,6 +257,6 @@ func stopInternalHTTPServer(ctx context.Context, listener *tlsHTTPListener, serv | |||||
| 257 | } | 257 | } | ||
| 258 | select { | 258 | select { | ||
| 259 | case <-done: | 259 | case <-done: | ||
| 260 | - | case <-ctx.Done(): | 260 | + | case <-shutdownCtx.Done(): |
| 261 | } | 261 | } | ||
| 262 | } | 262 | } | ||
M internal/adapters/in/traffic/sniff.go +1 -1
| @@ -28,7 +28,7 @@ type smartTCPSniffResult struct { | |||
| 28 | 28 | } | |
| 29 | 29 | ||
| 30 | 30 | const ( | |
| 31 | - | maxSmartTCPSniffBytes = 128 | |
| 31 | + | maxSmartTCPSniffBytes = 4096 | |
| 32 | 32 | h2cPreface = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n" | |
| 33 | 33 | proxyV2Signature = "\r\n\r\n\x00\r\nQUIT\n" | |
| 34 | 34 | ) | |
| @@ -28,7 +28,7 @@ type smartTCPSniffResult struct { | |||||
| 28 | } | 28 | } | ||
| 29 | 29 | ||||
| 30 | const ( | 30 | const ( | ||
| 31 | - | maxSmartTCPSniffBytes = 128 | 31 | + | maxSmartTCPSniffBytes = 4096 |
| 32 | h2cPreface = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n" | 32 | h2cPreface = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n" | ||
| 33 | proxyV2Signature = "\r\n\r\n\x00\r\nQUIT\n" | 33 | proxyV2Signature = "\r\n\r\n\x00\r\nQUIT\n" | ||
| 34 | ) | 34 | ) | ||
M internal/adapters/in/traffic/sniff_test.go +33 -9
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "errors" | |
| 5 | 5 | "io" | |
| 6 | 6 | "net" | |
| 7 | + | "strings" | |
| 7 | 8 | "testing" | |
| 8 | 9 | "time" | |
| 9 | 10 | ||
| @@ -108,18 +109,18 @@ func TestSniffSmartTCPUnknownIsImmediate(t *testing.T) { | |||
| 108 | 109 | defer client.Close() | |
| 109 | 110 | ||
| 110 | 111 | start := time.Now() | |
| 111 | - | done := make(chan smartTCPSniffResult, 1) | |
| 112 | + | done := make(chan sniffOutcome, 1) | |
| 112 | 113 | go func() { | |
| 113 | 114 | result, err := sniffSmartTCP(server, time.Second) | |
| 114 | - | require.NoError(t, err) | |
| 115 | - | done <- result | |
| 115 | + | done <- sniffOutcome{result: result, err: err} | |
| 116 | 116 | }() | |
| 117 | 117 | ||
| 118 | 118 | _, err := client.Write([]byte{0}) | |
| 119 | 119 | require.NoError(t, err) | |
| 120 | 120 | select { | |
| 121 | - | case result := <-done: | |
| 122 | - | assert.Equal(t, dispatchUnknown, result.kind) | |
| 121 | + | case outcome := <-done: | |
| 122 | + | require.NoError(t, outcome.err) | |
| 123 | + | assert.Equal(t, dispatchUnknown, outcome.result.kind) | |
| 123 | 124 | assert.Less(t, time.Since(start), 500*time.Millisecond) | |
| 124 | 125 | case <-time.After(500 * time.Millisecond): | |
| 125 | 126 | t.Fatal("sniff did not classify unknown bytes immediately") | |
| @@ -152,15 +153,16 @@ func TestSniffSmartTCPReplayReturnsSniffedBytesBeforeUnderlyingBytes(t *testing. | |||
| 152 | 153 | client, server := net.Pipe() | |
| 153 | 154 | defer client.Close() | |
| 154 | 155 | ||
| 155 | - | done := make(chan smartTCPSniffResult, 1) | |
| 156 | + | done := make(chan sniffOutcome, 1) | |
| 156 | 157 | go func() { | |
| 157 | 158 | result, err := sniffSmartTCP(server, time.Second) | |
| 158 | - | require.NoError(t, err) | |
| 159 | - | done <- result | |
| 159 | + | done <- sniffOutcome{result: result, err: err} | |
| 160 | 160 | }() | |
| 161 | 161 | _, err := client.Write([]byte("GET / HTTP/1.1\r\n")) | |
| 162 | 162 | require.NoError(t, err) | |
| 163 | - | result := <-done | |
| 163 | + | outcome := <-done | |
| 164 | + | require.NoError(t, outcome.err) | |
| 165 | + | result := outcome.result | |
| 164 | 166 | writeDone := make(chan error, 1) | |
| 165 | 167 | go func() { | |
| 166 | 168 | _, err := client.Write([]byte("Host: example.com\r\n\r\n")) | |
| @@ -174,6 +176,23 @@ func TestSniffSmartTCPReplayReturnsSniffedBytesBeforeUnderlyingBytes(t *testing. | |||
| 174 | 176 | require.NoError(t, <-writeDone) | |
| 175 | 177 | } | |
| 176 | 178 | ||
| 179 | + | func TestSniffSmartTCPHandlesLongHTTPRequestLine(t *testing.T) { | |
| 180 | + | client, server := net.Pipe() | |
| 181 | + | defer client.Close() | |
| 182 | + | ||
| 183 | + | longRequest := "GET /" + strings.Repeat("a", 512) + " HTTP/1.1\r\n" | |
| 184 | + | writeDone := make(chan error, 1) | |
| 185 | + | go func() { | |
| 186 | + | _, err := client.Write([]byte(longRequest)) | |
| 187 | + | writeDone <- err | |
| 188 | + | }() | |
| 189 | + | result, err := sniffSmartTCP(server, time.Second) | |
| 190 | + | require.NoError(t, err) | |
| 191 | + | assert.Equal(t, dispatchHTTP, result.kind) | |
| 192 | + | assertReplayPrefix(t, result.conn, []byte(longRequest)) | |
| 193 | + | require.NoError(t, <-writeDone) | |
| 194 | + | } | |
| 195 | + | ||
| 177 | 196 | func TestSniffSmartTCPClearsReadDeadlineBeforeHandoff(t *testing.T) { | |
| 178 | 197 | conn := &deadlineConn{Conn: pipeConnWithData(t, []byte("GET / HTTP/1.1\r\n"))} | |
| 179 | 198 | result, err := sniffSmartTCP(conn, time.Second) | |
| @@ -215,6 +234,11 @@ func assertReplayPrefix(t *testing.T, conn net.Conn, want []byte) { | |||
| 215 | 234 | assert.Equal(t, want, buf) | |
| 216 | 235 | } | |
| 217 | 236 | ||
| 237 | + | type sniffOutcome struct { | |
| 238 | + | result smartTCPSniffResult | |
| 239 | + | err error | |
| 240 | + | } | |
| 241 | + | ||
| 218 | 242 | type deadlineConn struct { | |
| 219 | 243 | net.Conn | |
| 220 | 244 | sawNonZeroReadDeadline bool | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "errors" | 4 | "errors" | ||
| 5 | "io" | 5 | "io" | ||
| 6 | "net" | 6 | "net" | ||
| 7 | + | "strings" | |||
| 7 | "testing" | 8 | "testing" | ||
| 8 | "time" | 9 | "time" | ||
| 9 | 10 | ||||
| @@ -108,18 +109,18 @@ func TestSniffSmartTCPUnknownIsImmediate(t *testing.T) { | |||||
| 108 | defer client.Close() | 109 | defer client.Close() | ||
| 109 | 110 | ||||
| 110 | start := time.Now() | 111 | start := time.Now() | ||
| 111 | - | done := make(chan smartTCPSniffResult, 1) | 112 | + | done := make(chan sniffOutcome, 1) |
| 112 | go func() { | 113 | go func() { | ||
| 113 | result, err := sniffSmartTCP(server, time.Second) | 114 | result, err := sniffSmartTCP(server, time.Second) | ||
| 114 | - | require.NoError(t, err) | 115 | + | done <- sniffOutcome{result: result, err: err} |
| 115 | - | done <- result | |||
| 116 | }() | 116 | }() | ||
| 117 | 117 | ||||
| 118 | _, err := client.Write([]byte{0}) | 118 | _, err := client.Write([]byte{0}) | ||
| 119 | require.NoError(t, err) | 119 | require.NoError(t, err) | ||
| 120 | select { | 120 | select { | ||
| 121 | - | case result := <-done: | 121 | + | case outcome := <-done: |
| 122 | - | assert.Equal(t, dispatchUnknown, result.kind) | 122 | + | require.NoError(t, outcome.err) |
| 123 | + | assert.Equal(t, dispatchUnknown, outcome.result.kind) | |||
| 123 | assert.Less(t, time.Since(start), 500*time.Millisecond) | 124 | assert.Less(t, time.Since(start), 500*time.Millisecond) | ||
| 124 | case <-time.After(500 * time.Millisecond): | 125 | case <-time.After(500 * time.Millisecond): | ||
| 125 | t.Fatal("sniff did not classify unknown bytes immediately") | 126 | t.Fatal("sniff did not classify unknown bytes immediately") | ||
| @@ -152,15 +153,16 @@ func TestSniffSmartTCPReplayReturnsSniffedBytesBeforeUnderlyingBytes(t *testing. | |||||
| 152 | client, server := net.Pipe() | 153 | client, server := net.Pipe() | ||
| 153 | defer client.Close() | 154 | defer client.Close() | ||
| 154 | 155 | ||||
| 155 | - | done := make(chan smartTCPSniffResult, 1) | 156 | + | done := make(chan sniffOutcome, 1) |
| 156 | go func() { | 157 | go func() { | ||
| 157 | result, err := sniffSmartTCP(server, time.Second) | 158 | result, err := sniffSmartTCP(server, time.Second) | ||
| 158 | - | require.NoError(t, err) | 159 | + | done <- sniffOutcome{result: result, err: err} |
| 159 | - | done <- result | |||
| 160 | }() | 160 | }() | ||
| 161 | _, err := client.Write([]byte("GET / HTTP/1.1\r\n")) | 161 | _, err := client.Write([]byte("GET / HTTP/1.1\r\n")) | ||
| 162 | require.NoError(t, err) | 162 | require.NoError(t, err) | ||
| 163 | - | result := <-done | 163 | + | outcome := <-done |
| 164 | + | require.NoError(t, outcome.err) | |||
| 165 | + | result := outcome.result | |||
| 164 | writeDone := make(chan error, 1) | 166 | writeDone := make(chan error, 1) | ||
| 165 | go func() { | 167 | go func() { | ||
| 166 | _, err := client.Write([]byte("Host: example.com\r\n\r\n")) | 168 | _, err := client.Write([]byte("Host: example.com\r\n\r\n")) | ||
| @@ -174,6 +176,23 @@ func TestSniffSmartTCPReplayReturnsSniffedBytesBeforeUnderlyingBytes(t *testing. | |||||
| 174 | require.NoError(t, <-writeDone) | 176 | require.NoError(t, <-writeDone) | ||
| 175 | } | 177 | } | ||
| 176 | 178 | ||||
| 179 | + | func TestSniffSmartTCPHandlesLongHTTPRequestLine(t *testing.T) { | |||
| 180 | + | client, server := net.Pipe() | |||
| 181 | + | defer client.Close() | |||
| 182 | + | ||||
| 183 | + | longRequest := "GET /" + strings.Repeat("a", 512) + " HTTP/1.1\r\n" | |||
| 184 | + | writeDone := make(chan error, 1) | |||
| 185 | + | go func() { | |||
| 186 | + | _, err := client.Write([]byte(longRequest)) | |||
| 187 | + | writeDone <- err | |||
| 188 | + | }() | |||
| 189 | + | result, err := sniffSmartTCP(server, time.Second) | |||
| 190 | + | require.NoError(t, err) | |||
| 191 | + | assert.Equal(t, dispatchHTTP, result.kind) | |||
| 192 | + | assertReplayPrefix(t, result.conn, []byte(longRequest)) | |||
| 193 | + | require.NoError(t, <-writeDone) | |||
| 194 | + | } | |||
| 195 | + | ||||
| 177 | func TestSniffSmartTCPClearsReadDeadlineBeforeHandoff(t *testing.T) { | 196 | func TestSniffSmartTCPClearsReadDeadlineBeforeHandoff(t *testing.T) { | ||
| 178 | conn := &deadlineConn{Conn: pipeConnWithData(t, []byte("GET / HTTP/1.1\r\n"))} | 197 | conn := &deadlineConn{Conn: pipeConnWithData(t, []byte("GET / HTTP/1.1\r\n"))} | ||
| 179 | result, err := sniffSmartTCP(conn, time.Second) | 198 | result, err := sniffSmartTCP(conn, time.Second) | ||
| @@ -215,6 +234,11 @@ func assertReplayPrefix(t *testing.T, conn net.Conn, want []byte) { | |||||
| 215 | assert.Equal(t, want, buf) | 234 | assert.Equal(t, want, buf) | ||
| 216 | } | 235 | } | ||
| 217 | 236 | ||||
| 237 | + | type sniffOutcome struct { | |||
| 238 | + | result smartTCPSniffResult | |||
| 239 | + | err error | |||
| 240 | + | } | |||
| 241 | + | ||||
| 218 | type deadlineConn struct { | 242 | type deadlineConn struct { | ||
| 219 | net.Conn | 243 | net.Conn | ||
| 220 | sawNonZeroReadDeadline bool | 244 | sawNonZeroReadDeadline bool | ||
M internal/adapters/in/traffic/tcp.go +3 -0
| @@ -919,6 +919,9 @@ func backendFromAddress(name string, address string) (domain.TrafficBackend, err | |||
| 919 | 919 | if err != nil { | |
| 920 | 920 | return domain.TrafficBackend{}, fmt.Errorf("parse backend port %q from address %q: %w", portValue, address, err) | |
| 921 | 921 | } | |
| 922 | + | if port < 1 || port > 65535 { | |
| 923 | + | return domain.TrafficBackend{}, fmt.Errorf("backend port %q from address %q is outside 1-65535", portValue, address) | |
| 924 | + | } | |
| 922 | 925 | return domain.TrafficBackend{Name: name, Host: host, Port: port, Protocol: domain.NetworkProtocolTCP}, nil | |
| 923 | 926 | } | |
| 924 | 927 | ||
| @@ -919,6 +919,9 @@ func backendFromAddress(name string, address string) (domain.TrafficBackend, err | |||||
| 919 | if err != nil { | 919 | if err != nil { | ||
| 920 | return domain.TrafficBackend{}, fmt.Errorf("parse backend port %q from address %q: %w", portValue, address, err) | 920 | return domain.TrafficBackend{}, fmt.Errorf("parse backend port %q from address %q: %w", portValue, address, err) | ||
| 921 | } | 921 | } | ||
| 922 | + | if port < 1 || port > 65535 { | |||
| 923 | + | return domain.TrafficBackend{}, fmt.Errorf("backend port %q from address %q is outside 1-65535", portValue, address) | |||
| 924 | + | } | |||
| 922 | return domain.TrafficBackend{Name: name, Host: host, Port: port, Protocol: domain.NetworkProtocolTCP}, nil | 925 | return domain.TrafficBackend{Name: name, Host: host, Port: port, Protocol: domain.NetworkProtocolTCP}, nil | ||
| 923 | } | 926 | } | ||
| 924 | 927 | ||||
M internal/adapters/in/traffic/tcp_test.go +6 -0
| @@ -25,6 +25,12 @@ func TestTrafficRuntimeDefaultsApplySafeLimits(t *testing.T) { | |||
| 25 | 25 | assert.Equal(t, 4096, effectiveUDPOptions(domain.UDPOptions{}).MaxSessions) | |
| 26 | 26 | } | |
| 27 | 27 | ||
| 28 | + | func TestBackendFromAddressRejectsOutOfRangePort(t *testing.T) { | |
| 29 | + | _, err := backendFromAddress("bad:tcp", "127.0.0.1:70000") | |
| 30 | + | require.Error(t, err) | |
| 31 | + | assert.Contains(t, err.Error(), "outside 1-65535") | |
| 32 | + | } | |
| 33 | + | ||
| 28 | 34 | func TestSmartTCPDispatch(t *testing.T) { | |
| 29 | 35 | t.Run("cleartext HTTP request reaches HTTP handler", func(t *testing.T) { | |
| 30 | 36 | manager, graph, hits := startSmartTCP(t, nil, nil) | |
| @@ -25,6 +25,12 @@ func TestTrafficRuntimeDefaultsApplySafeLimits(t *testing.T) { | |||||
| 25 | assert.Equal(t, 4096, effectiveUDPOptions(domain.UDPOptions{}).MaxSessions) | 25 | assert.Equal(t, 4096, effectiveUDPOptions(domain.UDPOptions{}).MaxSessions) | ||
| 26 | } | 26 | } | ||
| 27 | 27 | ||||
| 28 | + | func TestBackendFromAddressRejectsOutOfRangePort(t *testing.T) { | |||
| 29 | + | _, err := backendFromAddress("bad:tcp", "127.0.0.1:70000") | |||
| 30 | + | require.Error(t, err) | |||
| 31 | + | assert.Contains(t, err.Error(), "outside 1-65535") | |||
| 32 | + | } | |||
| 33 | + | ||||
| 28 | func TestSmartTCPDispatch(t *testing.T) { | 34 | func TestSmartTCPDispatch(t *testing.T) { | ||
| 29 | t.Run("cleartext HTTP request reaches HTTP handler", func(t *testing.T) { | 35 | t.Run("cleartext HTTP request reaches HTTP handler", func(t *testing.T) { | ||
| 30 | manager, graph, hits := startSmartTCP(t, nil, nil) | 36 | manager, graph, hits := startSmartTCP(t, nil, nil) | ||
M internal/adapters/in/traffic/tls_mux.go +23 -0
| @@ -301,8 +301,17 @@ func newTLSHTTPListener(addr net.Addr) *tlsHTTPListener { | |||
| 301 | 301 | } | |
| 302 | 302 | ||
| 303 | 303 | func (l *tlsHTTPListener) Accept() (net.Conn, error) { | |
| 304 | + | select { | |
| 305 | + | case <-l.done: | |
| 306 | + | return nil, net.ErrClosed | |
| 307 | + | default: | |
| 308 | + | } | |
| 304 | 309 | select { | |
| 305 | 310 | case conn := <-l.conns: | |
| 311 | + | if l.isClosed() { | |
| 312 | + | _ = conn.Close() | |
| 313 | + | return nil, net.ErrClosed | |
| 314 | + | } | |
| 306 | 315 | return conn, nil | |
| 307 | 316 | case <-l.done: | |
| 308 | 317 | return nil, net.ErrClosed | |
| @@ -315,6 +324,14 @@ func (l *tlsHTTPListener) Close() error { | |||
| 315 | 324 | l.closed = true | |
| 316 | 325 | l.mu.Unlock() | |
| 317 | 326 | close(l.done) | |
| 327 | + | for { | |
| 328 | + | select { | |
| 329 | + | case conn := <-l.conns: | |
| 330 | + | _ = conn.Close() | |
| 331 | + | default: | |
| 332 | + | return | |
| 333 | + | } | |
| 334 | + | } | |
| 318 | 335 | }) | |
| 319 | 336 | return nil | |
| 320 | 337 | } | |
| @@ -334,3 +351,9 @@ func (l *tlsHTTPListener) serve(conn net.Conn) bool { | |||
| 334 | 351 | return false | |
| 335 | 352 | } | |
| 336 | 353 | } | |
| 354 | + | ||
| 355 | + | func (l *tlsHTTPListener) isClosed() bool { | |
| 356 | + | l.mu.Lock() | |
| 357 | + | defer l.mu.Unlock() | |
| 358 | + | return l.closed | |
| 359 | + | } | |
| @@ -301,8 +301,17 @@ func newTLSHTTPListener(addr net.Addr) *tlsHTTPListener { | |||||
| 301 | } | 301 | } | ||
| 302 | 302 | ||||
| 303 | func (l *tlsHTTPListener) Accept() (net.Conn, error) { | 303 | func (l *tlsHTTPListener) Accept() (net.Conn, error) { | ||
| 304 | + | select { | |||
| 305 | + | case <-l.done: | |||
| 306 | + | return nil, net.ErrClosed | |||
| 307 | + | default: | |||
| 308 | + | } | |||
| 304 | select { | 309 | select { | ||
| 305 | case conn := <-l.conns: | 310 | case conn := <-l.conns: | ||
| 311 | + | if l.isClosed() { | |||
| 312 | + | _ = conn.Close() | |||
| 313 | + | return nil, net.ErrClosed | |||
| 314 | + | } | |||
| 306 | return conn, nil | 315 | return conn, nil | ||
| 307 | case <-l.done: | 316 | case <-l.done: | ||
| 308 | return nil, net.ErrClosed | 317 | return nil, net.ErrClosed | ||
| @@ -315,6 +324,14 @@ func (l *tlsHTTPListener) Close() error { | |||||
| 315 | l.closed = true | 324 | l.closed = true | ||
| 316 | l.mu.Unlock() | 325 | l.mu.Unlock() | ||
| 317 | close(l.done) | 326 | close(l.done) | ||
| 327 | + | for { | |||
| 328 | + | select { | |||
| 329 | + | case conn := <-l.conns: | |||
| 330 | + | _ = conn.Close() | |||
| 331 | + | default: | |||
| 332 | + | return | |||
| 333 | + | } | |||
| 334 | + | } | |||
| 318 | }) | 335 | }) | ||
| 319 | return nil | 336 | return nil | ||
| 320 | } | 337 | } | ||
| @@ -334,3 +351,9 @@ func (l *tlsHTTPListener) serve(conn net.Conn) bool { | |||||
| 334 | return false | 351 | return false | ||
| 335 | } | 352 | } | ||
| 336 | } | 353 | } | ||
| 354 | + | ||||
| 355 | + | func (l *tlsHTTPListener) isClosed() bool { | |||
| 356 | + | l.mu.Lock() | |||
| 357 | + | defer l.mu.Unlock() | |||
| 358 | + | return l.closed | |||
| 359 | + | } | |||
M internal/adapters/in/traffic/tls_mux_test.go +24 -5
| @@ -11,6 +11,7 @@ import ( | |||
| 11 | 11 | "crypto/x509/pkix" | |
| 12 | 12 | "encoding/pem" | |
| 13 | 13 | "errors" | |
| 14 | + | "fmt" | |
| 14 | 15 | "io" | |
| 15 | 16 | "math/big" | |
| 16 | 17 | "net" | |
| @@ -213,6 +214,20 @@ func TestTLSHTTPListenerServeRejectsAfterClose(t *testing.T) { | |||
| 213 | 214 | } | |
| 214 | 215 | } | |
| 215 | 216 | ||
| 217 | + | func TestTLSHTTPListenerCloseDrainsQueuedConnections(t *testing.T) { | |
| 218 | + | addr, err := net.ResolveTCPAddr("tcp", "127.0.0.1:443") | |
| 219 | + | require.NoError(t, err) | |
| 220 | + | listener := newTLSHTTPListener(addr) | |
| 221 | + | client, server := net.Pipe() | |
| 222 | + | defer client.Close() | |
| 223 | + | require.True(t, listener.serve(server)) | |
| 224 | + | require.NoError(t, listener.Close()) | |
| 225 | + | _, err = listener.Accept() | |
| 226 | + | require.ErrorIs(t, err, net.ErrClosed) | |
| 227 | + | _, err = server.Write([]byte("closed")) | |
| 228 | + | require.Error(t, err) | |
| 229 | + | } | |
| 230 | + | ||
| 216 | 231 | func TestTLSMuxHTTPSRoute(t *testing.T) { | |
| 217 | 232 | backend := startTLSGreetingServer(t, "raw-backend", "raw\n") | |
| 218 | 233 | graph := tlsGraph(t, freeTCPAddress(t), []tlsRoute{{name: "raw", sni: "raw.example.com", service: "raw", backend: backend.address}}) | |
| @@ -378,11 +393,11 @@ func testTLSConfig(t *testing.T, commonName string) *tls.Config { | |||
| 378 | 393 | func testCertificate(commonName string) (tls.Certificate, error) { | |
| 379 | 394 | key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) | |
| 380 | 395 | if err != nil { | |
| 381 | - | return tls.Certificate{}, err | |
| 396 | + | return tls.Certificate{}, fmt.Errorf("generate test certificate key: %w", err) | |
| 382 | 397 | } | |
| 383 | 398 | serial, err := rand.Int(rand.Reader, big.NewInt(1<<62)) | |
| 384 | 399 | if err != nil { | |
| 385 | - | return tls.Certificate{}, err | |
| 400 | + | return tls.Certificate{}, fmt.Errorf("generate test certificate serial: %w", err) | |
| 386 | 401 | } | |
| 387 | 402 | template := x509.Certificate{ | |
| 388 | 403 | SerialNumber: serial, | |
| @@ -395,15 +410,19 @@ func testCertificate(commonName string) (tls.Certificate, error) { | |||
| 395 | 410 | } | |
| 396 | 411 | der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key) | |
| 397 | 412 | if err != nil { | |
| 398 | - | return tls.Certificate{}, err | |
| 413 | + | return tls.Certificate{}, fmt.Errorf("create test certificate: %w", err) | |
| 399 | 414 | } | |
| 400 | 415 | certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) | |
| 401 | 416 | keyBytes, err := x509.MarshalECPrivateKey(key) | |
| 402 | 417 | if err != nil { | |
| 403 | - | return tls.Certificate{}, err | |
| 418 | + | return tls.Certificate{}, fmt.Errorf("marshal test certificate key: %w", err) | |
| 404 | 419 | } | |
| 405 | 420 | keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes}) | |
| 406 | - | return tls.X509KeyPair(certPEM, keyPEM) | |
| 421 | + | cert, err := tls.X509KeyPair(certPEM, keyPEM) | |
| 422 | + | if err != nil { | |
| 423 | + | return tls.Certificate{}, fmt.Errorf("parse test certificate key pair: %w", err) | |
| 424 | + | } | |
| 425 | + | return cert, nil | |
| 407 | 426 | } | |
| 408 | 427 | ||
| 409 | 428 | func TestHostMatchesTLSWildcard(t *testing.T) { | |
| @@ -11,6 +11,7 @@ import ( | |||||
| 11 | "crypto/x509/pkix" | 11 | "crypto/x509/pkix" | ||
| 12 | "encoding/pem" | 12 | "encoding/pem" | ||
| 13 | "errors" | 13 | "errors" | ||
| 14 | + | "fmt" | |||
| 14 | "io" | 15 | "io" | ||
| 15 | "math/big" | 16 | "math/big" | ||
| 16 | "net" | 17 | "net" | ||
| @@ -213,6 +214,20 @@ func TestTLSHTTPListenerServeRejectsAfterClose(t *testing.T) { | |||||
| 213 | } | 214 | } | ||
| 214 | } | 215 | } | ||
| 215 | 216 | ||||
| 217 | + | func TestTLSHTTPListenerCloseDrainsQueuedConnections(t *testing.T) { | |||
| 218 | + | addr, err := net.ResolveTCPAddr("tcp", "127.0.0.1:443") | |||
| 219 | + | require.NoError(t, err) | |||
| 220 | + | listener := newTLSHTTPListener(addr) | |||
| 221 | + | client, server := net.Pipe() | |||
| 222 | + | defer client.Close() | |||
| 223 | + | require.True(t, listener.serve(server)) | |||
| 224 | + | require.NoError(t, listener.Close()) | |||
| 225 | + | _, err = listener.Accept() | |||
| 226 | + | require.ErrorIs(t, err, net.ErrClosed) | |||
| 227 | + | _, err = server.Write([]byte("closed")) | |||
| 228 | + | require.Error(t, err) | |||
| 229 | + | } | |||
| 230 | + | ||||
| 216 | func TestTLSMuxHTTPSRoute(t *testing.T) { | 231 | func TestTLSMuxHTTPSRoute(t *testing.T) { | ||
| 217 | backend := startTLSGreetingServer(t, "raw-backend", "raw\n") | 232 | backend := startTLSGreetingServer(t, "raw-backend", "raw\n") | ||
| 218 | graph := tlsGraph(t, freeTCPAddress(t), []tlsRoute{{name: "raw", sni: "raw.example.com", service: "raw", backend: backend.address}}) | 233 | graph := tlsGraph(t, freeTCPAddress(t), []tlsRoute{{name: "raw", sni: "raw.example.com", service: "raw", backend: backend.address}}) | ||
| @@ -378,11 +393,11 @@ func testTLSConfig(t *testing.T, commonName string) *tls.Config { | |||||
| 378 | func testCertificate(commonName string) (tls.Certificate, error) { | 393 | func testCertificate(commonName string) (tls.Certificate, error) { | ||
| 379 | key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) | 394 | key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) | ||
| 380 | if err != nil { | 395 | if err != nil { | ||
| 381 | - | return tls.Certificate{}, err | 396 | + | return tls.Certificate{}, fmt.Errorf("generate test certificate key: %w", err) |
| 382 | } | 397 | } | ||
| 383 | serial, err := rand.Int(rand.Reader, big.NewInt(1<<62)) | 398 | serial, err := rand.Int(rand.Reader, big.NewInt(1<<62)) | ||
| 384 | if err != nil { | 399 | if err != nil { | ||
| 385 | - | return tls.Certificate{}, err | 400 | + | return tls.Certificate{}, fmt.Errorf("generate test certificate serial: %w", err) |
| 386 | } | 401 | } | ||
| 387 | template := x509.Certificate{ | 402 | template := x509.Certificate{ | ||
| 388 | SerialNumber: serial, | 403 | SerialNumber: serial, | ||
| @@ -395,15 +410,19 @@ func testCertificate(commonName string) (tls.Certificate, error) { | |||||
| 395 | } | 410 | } | ||
| 396 | der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key) | 411 | der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key) | ||
| 397 | if err != nil { | 412 | if err != nil { | ||
| 398 | - | return tls.Certificate{}, err | 413 | + | return tls.Certificate{}, fmt.Errorf("create test certificate: %w", err) |
| 399 | } | 414 | } | ||
| 400 | certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) | 415 | certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) | ||
| 401 | keyBytes, err := x509.MarshalECPrivateKey(key) | 416 | keyBytes, err := x509.MarshalECPrivateKey(key) | ||
| 402 | if err != nil { | 417 | if err != nil { | ||
| 403 | - | return tls.Certificate{}, err | 418 | + | return tls.Certificate{}, fmt.Errorf("marshal test certificate key: %w", err) |
| 404 | } | 419 | } | ||
| 405 | keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes}) | 420 | keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes}) | ||
| 406 | - | return tls.X509KeyPair(certPEM, keyPEM) | 421 | + | cert, err := tls.X509KeyPair(certPEM, keyPEM) |
| 422 | + | if err != nil { | |||
| 423 | + | return tls.Certificate{}, fmt.Errorf("parse test certificate key pair: %w", err) | |||
| 424 | + | } | |||
| 425 | + | return cert, nil | |||
| 407 | } | 426 | } | ||
| 408 | 427 | ||||
| 409 | func TestHostMatchesTLSWildcard(t *testing.T) { | 428 | func TestHostMatchesTLSWildcard(t *testing.T) { | ||
M internal/adapters/in/traffic/udp.go +14 -0
| @@ -97,6 +97,15 @@ func (r *udpEntryPointRuntime) readLoop() { | |||
| 97 | 97 | r.counters.totalRefused.Add(1) | |
| 98 | 98 | continue | |
| 99 | 99 | } | |
| 100 | + | select { | |
| 101 | + | case <-r.ctx.Done(): | |
| 102 | + | return | |
| 103 | + | default: | |
| 104 | + | } | |
| 105 | + | if len(r.datagrams) >= cap(r.datagrams) { | |
| 106 | + | r.counters.totalRefused.Add(1) | |
| 107 | + | continue | |
| 108 | + | } | |
| 100 | 109 | packet := append([]byte(nil), buf[:n]...) | |
| 101 | 110 | select { | |
| 102 | 111 | case r.datagrams <- udpDatagram{clientAddr: clientAddr, packet: packet}: | |
| @@ -114,6 +123,11 @@ func (r *udpEntryPointRuntime) datagramWorker() { | |||
| 114 | 123 | case <-r.ctx.Done(): | |
| 115 | 124 | return | |
| 116 | 125 | case datagram := <-r.datagrams: | |
| 126 | + | select { | |
| 127 | + | case <-r.ctx.Done(): | |
| 128 | + | return | |
| 129 | + | default: | |
| 130 | + | } | |
| 117 | 131 | r.handleDatagram(datagram.clientAddr, datagram.packet) | |
| 118 | 132 | } | |
| 119 | 133 | } | |
| @@ -97,6 +97,15 @@ func (r *udpEntryPointRuntime) readLoop() { | |||||
| 97 | r.counters.totalRefused.Add(1) | 97 | r.counters.totalRefused.Add(1) | ||
| 98 | continue | 98 | continue | ||
| 99 | } | 99 | } | ||
| 100 | + | select { | |||
| 101 | + | case <-r.ctx.Done(): | |||
| 102 | + | return | |||
| 103 | + | default: | |||
| 104 | + | } | |||
| 105 | + | if len(r.datagrams) >= cap(r.datagrams) { | |||
| 106 | + | r.counters.totalRefused.Add(1) | |||
| 107 | + | continue | |||
| 108 | + | } | |||
| 100 | packet := append([]byte(nil), buf[:n]...) | 109 | packet := append([]byte(nil), buf[:n]...) | ||
| 101 | select { | 110 | select { | ||
| 102 | case r.datagrams <- udpDatagram{clientAddr: clientAddr, packet: packet}: | 111 | case r.datagrams <- udpDatagram{clientAddr: clientAddr, packet: packet}: | ||
| @@ -114,6 +123,11 @@ func (r *udpEntryPointRuntime) datagramWorker() { | |||||
| 114 | case <-r.ctx.Done(): | 123 | case <-r.ctx.Done(): | ||
| 115 | return | 124 | return | ||
| 116 | case datagram := <-r.datagrams: | 125 | case datagram := <-r.datagrams: | ||
| 126 | + | select { | |||
| 127 | + | case <-r.ctx.Done(): | |||
| 128 | + | return | |||
| 129 | + | default: | |||
| 130 | + | } | |||
| 117 | r.handleDatagram(datagram.clientAddr, datagram.packet) | 131 | r.handleDatagram(datagram.clientAddr, datagram.packet) | ||
| 118 | } | 132 | } | ||
| 119 | } | 133 | } | ||
M internal/app/public_tls_wiring_test.go +13 -4
| @@ -32,7 +32,7 @@ func TestPublicTLSReadinessAllowsDNS01WithoutEdgePort(t *testing.T) { | |||
| 32 | 32 | cfg.TLS.ACME.Challenge = string(domain.ACMEChallengeCloudflareDNS01) | |
| 33 | 33 | ||
| 34 | 34 | require.NoError(t, validatePublicTLSReadiness(cfg)) | |
| 35 | - | assert.Equal(t, 443, effectivePublicTLSPort(cfg)) | |
| 35 | + | assert.Equal(t, 0, effectivePublicTLSPort(cfg)) | |
| 36 | 36 | } | |
| 37 | 37 | ||
| 38 | 38 | func TestPublicTLSReadinessRejectsAutoResolvedHTTP01WithoutBoundExternalPort80(t *testing.T) { | |
| @@ -63,7 +63,7 @@ func TestEffectivePublicTLSPortPrefersTLSCapableEntrypointOverLegacyTLSPort(t *t | |||
| 63 | 63 | assert.Equal(t, 443, effectivePublicTLSPort(cfg)) | |
| 64 | 64 | } | |
| 65 | 65 | ||
| 66 | - | func TestEffectivePublicTLSPortDeterministicWithMultipleTLSCapableEntrypoints(t *testing.T) { | |
| 66 | + | func TestEffectivePublicTLSPortPrefersDefaultEdgeWithMultipleTLSCapableEntrypoints(t *testing.T) { | |
| 67 | 67 | cfg := Config{} | |
| 68 | 68 | cfg.EntryPoints = map[string]traffic.EntryPointConfig{ | |
| 69 | 69 | traffic.DefaultEdgeEntryPointName: {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP}, | |
| @@ -85,7 +85,16 @@ func TestEffectivePublicTLSPortUsesSoleNonEdgeTLSCapableEntrypoint(t *testing.T) | |||
| 85 | 85 | assert.Equal(t, 8443, effectivePublicTLSPort(cfg)) | |
| 86 | 86 | } | |
| 87 | 87 | ||
| 88 | - | func TestEffectivePublicTLSPortFallsBackWhenAmbiguousWithoutEdge(t *testing.T) { | |
| 88 | + | func TestEffectiveProxyPortsIgnoreLegacyServerPortsWithoutEntrypoints(t *testing.T) { | |
| 89 | + | cfg := Config{} | |
| 90 | + | cfg.Server.Port = 8080 | |
| 91 | + | cfg.Server.TLSPort = 8443 | |
| 92 | + | ||
| 93 | + | assert.Equal(t, 0, effectiveProxyHTTPPort(cfg)) | |
| 94 | + | assert.Equal(t, 0, effectiveProxyTLSPort(cfg)) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func TestEffectivePublicTLSPortReturnsZeroWhenAmbiguousWithoutEdge(t *testing.T) { | |
| 89 | 98 | cfg := Config{} | |
| 90 | 99 | cfg.EntryPoints = map[string]traffic.EntryPointConfig{ | |
| 91 | 100 | "public-a": {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP}, | |
| @@ -93,7 +102,7 @@ func TestEffectivePublicTLSPortFallsBackWhenAmbiguousWithoutEdge(t *testing.T) { | |||
| 93 | 102 | } | |
| 94 | 103 | ||
| 95 | 104 | for range 20 { | |
| 96 | - | assert.Equal(t, 443, effectivePublicTLSPort(cfg)) | |
| 105 | + | assert.Equal(t, 0, effectivePublicTLSPort(cfg)) | |
| 97 | 106 | } | |
| 98 | 107 | } | |
| 99 | 108 | ||
| @@ -32,7 +32,7 @@ func TestPublicTLSReadinessAllowsDNS01WithoutEdgePort(t *testing.T) { | |||||
| 32 | cfg.TLS.ACME.Challenge = string(domain.ACMEChallengeCloudflareDNS01) | 32 | cfg.TLS.ACME.Challenge = string(domain.ACMEChallengeCloudflareDNS01) | ||
| 33 | 33 | ||||
| 34 | require.NoError(t, validatePublicTLSReadiness(cfg)) | 34 | require.NoError(t, validatePublicTLSReadiness(cfg)) | ||
| 35 | - | assert.Equal(t, 443, effectivePublicTLSPort(cfg)) | 35 | + | assert.Equal(t, 0, effectivePublicTLSPort(cfg)) |
| 36 | } | 36 | } | ||
| 37 | 37 | ||||
| 38 | func TestPublicTLSReadinessRejectsAutoResolvedHTTP01WithoutBoundExternalPort80(t *testing.T) { | 38 | func TestPublicTLSReadinessRejectsAutoResolvedHTTP01WithoutBoundExternalPort80(t *testing.T) { | ||
| @@ -63,7 +63,7 @@ func TestEffectivePublicTLSPortPrefersTLSCapableEntrypointOverLegacyTLSPort(t *t | |||||
| 63 | assert.Equal(t, 443, effectivePublicTLSPort(cfg)) | 63 | assert.Equal(t, 443, effectivePublicTLSPort(cfg)) | ||
| 64 | } | 64 | } | ||
| 65 | 65 | ||||
| 66 | - | func TestEffectivePublicTLSPortDeterministicWithMultipleTLSCapableEntrypoints(t *testing.T) { | 66 | + | func TestEffectivePublicTLSPortPrefersDefaultEdgeWithMultipleTLSCapableEntrypoints(t *testing.T) { |
| 67 | cfg := Config{} | 67 | cfg := Config{} | ||
| 68 | cfg.EntryPoints = map[string]traffic.EntryPointConfig{ | 68 | cfg.EntryPoints = map[string]traffic.EntryPointConfig{ | ||
| 69 | traffic.DefaultEdgeEntryPointName: {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP}, | 69 | traffic.DefaultEdgeEntryPointName: {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP}, | ||
| @@ -85,7 +85,16 @@ func TestEffectivePublicTLSPortUsesSoleNonEdgeTLSCapableEntrypoint(t *testing.T) | |||||
| 85 | assert.Equal(t, 8443, effectivePublicTLSPort(cfg)) | 85 | assert.Equal(t, 8443, effectivePublicTLSPort(cfg)) | ||
| 86 | } | 86 | } | ||
| 87 | 87 | ||||
| 88 | - | func TestEffectivePublicTLSPortFallsBackWhenAmbiguousWithoutEdge(t *testing.T) { | 88 | + | func TestEffectiveProxyPortsIgnoreLegacyServerPortsWithoutEntrypoints(t *testing.T) { |
| 89 | + | cfg := Config{} | |||
| 90 | + | cfg.Server.Port = 8080 | |||
| 91 | + | cfg.Server.TLSPort = 8443 | |||
| 92 | + | ||||
| 93 | + | assert.Equal(t, 0, effectiveProxyHTTPPort(cfg)) | |||
| 94 | + | assert.Equal(t, 0, effectiveProxyTLSPort(cfg)) | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func TestEffectivePublicTLSPortReturnsZeroWhenAmbiguousWithoutEdge(t *testing.T) { | |||
| 89 | cfg := Config{} | 98 | cfg := Config{} | ||
| 90 | cfg.EntryPoints = map[string]traffic.EntryPointConfig{ | 99 | cfg.EntryPoints = map[string]traffic.EntryPointConfig{ | ||
| 91 | "public-a": {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP}, | 100 | "public-a": {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP}, | ||
| @@ -93,7 +102,7 @@ func TestEffectivePublicTLSPortFallsBackWhenAmbiguousWithoutEdge(t *testing.T) { | |||||
| 93 | } | 102 | } | ||
| 94 | 103 | ||||
| 95 | for range 20 { | 104 | for range 20 { | ||
| 96 | - | assert.Equal(t, 443, effectivePublicTLSPort(cfg)) | 105 | + | assert.Equal(t, 0, effectivePublicTLSPort(cfg)) |
| 97 | } | 106 | } | ||
| 98 | } | 107 | } | ||
| 99 | 108 | ||||
M internal/app/run.go +16 -35
| @@ -787,14 +787,14 @@ func (si *serviceInit) registerReloadCoordinatorHooks() { | |||
| 787 | 787 | return tlsErr | |
| 788 | 788 | } | |
| 789 | 789 | } | |
| 790 | + | if err := applyTrafficRuntimeConfig(reloadCtx, si.svc.trafficManager, reloadCfg, si.svc.configSvc); err != nil { | |
| 791 | + | return err | |
| 792 | + | } | |
| 790 | 793 | si.svc.tlsHTTPEntryPoints = registerTLSMuxHTTPServers(si.svc.trafficManager, reloadCfg, si.svc.httpsProxyHandler, tlsConfig, si.svc.tlsHTTPEntryPoints) | |
| 791 | 794 | si.svc.smartHTTPEntryPoints = registerSmartTCPHTTPServers(si.svc.trafficManager, reloadCfg, si.svc.httpProxyHandler, si.svc.httpsProxyHandler, tlsConfig, si.svc.smartHTTPEntryPoints) | |
| 792 | 795 | if err := reconcileStandaloneServices(reloadCtx, si.svc.standaloneServiceSvc, reloadCfg); err != nil { | |
| 793 | 796 | return err | |
| 794 | 797 | } | |
| 795 | - | if err := applyTrafficRuntimeConfig(reloadCtx, si.svc.trafficManager, reloadCfg, si.svc.configSvc); err != nil { | |
| 796 | - | return err | |
| 797 | - | } | |
| 798 | 798 | si.svc.containerSvc.UpdateConfig(containerCfg) | |
| 799 | 799 | return nil | |
| 800 | 800 | }) | |
| @@ -2931,10 +2931,10 @@ func waitForCoreProxyReadyAndApplyTraffic(ctx context.Context, cfg Config, svc * | |||
| 2931 | 2931 | if err := waitForServerReady(proxyReady, errChan); err != nil { | |
| 2932 | 2932 | return err | |
| 2933 | 2933 | } | |
| 2934 | - | if err := reconcileStandaloneServices(ctx, svc.standaloneServiceSvc, cfg); err != nil { | |
| 2934 | + | if err := applyTrafficRuntimeConfig(ctx, svc.trafficManager, cfg, svc.configSvc); err != nil { | |
| 2935 | 2935 | return err | |
| 2936 | 2936 | } | |
| 2937 | - | return applyTrafficRuntimeConfig(ctx, svc.trafficManager, cfg, svc.configSvc) | |
| 2937 | + | return reconcileStandaloneServices(ctx, svc.standaloneServiceSvc, cfg) | |
| 2938 | 2938 | } | |
| 2939 | 2939 | ||
| 2940 | 2940 | func reconcileStandaloneServices(ctx context.Context, serviceSvc in.StandaloneServiceService, cfg Config) error { | |
| @@ -3400,40 +3400,21 @@ func hasTLSCapableEntrypoint(cfg Config) bool { | |||
| 3400 | 3400 | } | |
| 3401 | 3401 | ||
| 3402 | 3402 | func effectivePublicTLSPort(cfg Config) int { | |
| 3403 | - | if entryPoint, ok := cfg.EntryPoints[traffic.DefaultEdgeEntryPointName]; ok && tlsCapableEntryPoint(entryPoint.Protocol) { | |
| 3404 | - | if port := portFromAddress(entryPoint.Address); port > 0 { | |
| 3405 | - | return port | |
| 3406 | - | } | |
| 3407 | - | } | |
| 3408 | - | ||
| 3409 | - | var candidatePort int | |
| 3410 | - | candidates := 0 | |
| 3411 | - | for name, entryPoint := range cfg.EntryPoints { | |
| 3412 | - | if name == traffic.DefaultEdgeEntryPointName || !tlsCapableEntryPoint(entryPoint.Protocol) { | |
| 3413 | - | continue | |
| 3414 | - | } | |
| 3415 | - | port := portFromAddress(entryPoint.Address) | |
| 3416 | - | if port == 0 { | |
| 3417 | - | continue | |
| 3418 | - | } | |
| 3419 | - | candidatePort = port | |
| 3420 | - | candidates++ | |
| 3421 | - | } | |
| 3422 | - | if candidates == 1 { | |
| 3423 | - | return candidatePort | |
| 3424 | - | } | |
| 3425 | - | return 443 | |
| 3403 | + | return effectiveEntrypointPort(cfg, tlsCapableEntryPoint) | |
| 3426 | 3404 | } | |
| 3427 | 3405 | ||
| 3428 | 3406 | func effectiveProxyTLSPort(cfg Config) int { | |
| 3429 | - | if hasTLSCapableEntrypoint(cfg) { | |
| 3430 | - | return effectivePublicTLSPort(cfg) | |
| 3431 | - | } | |
| 3432 | - | return cfg.Server.TLSPort | |
| 3407 | + | return effectivePublicTLSPort(cfg) | |
| 3433 | 3408 | } | |
| 3434 | 3409 | ||
| 3435 | 3410 | func effectiveProxyHTTPPort(cfg Config) int { | |
| 3436 | - | if entryPoint, ok := cfg.EntryPoints[traffic.DefaultEdgeEntryPointName]; ok && entryPoint.Protocol == domain.EntryPointProtocolSmartTCP { | |
| 3411 | + | return effectiveEntrypointPort(cfg, func(protocol domain.EntryPointProtocol) bool { | |
| 3412 | + | return protocol == domain.EntryPointProtocolSmartTCP | |
| 3413 | + | }) | |
| 3414 | + | } | |
| 3415 | + | ||
| 3416 | + | func effectiveEntrypointPort(cfg Config, match func(domain.EntryPointProtocol) bool) int { | |
| 3417 | + | if entryPoint, ok := cfg.EntryPoints[traffic.DefaultEdgeEntryPointName]; ok && match(entryPoint.Protocol) { | |
| 3437 | 3418 | if port := portFromAddress(entryPoint.Address); port > 0 { | |
| 3438 | 3419 | return port | |
| 3439 | 3420 | } | |
| @@ -3442,7 +3423,7 @@ func effectiveProxyHTTPPort(cfg Config) int { | |||
| 3442 | 3423 | var candidatePort int | |
| 3443 | 3424 | candidates := 0 | |
| 3444 | 3425 | for name, entryPoint := range cfg.EntryPoints { | |
| 3445 | - | if name == traffic.DefaultEdgeEntryPointName || entryPoint.Protocol != domain.EntryPointProtocolSmartTCP { | |
| 3426 | + | if name == traffic.DefaultEdgeEntryPointName || !match(entryPoint.Protocol) { | |
| 3446 | 3427 | continue | |
| 3447 | 3428 | } | |
| 3448 | 3429 | port := portFromAddress(entryPoint.Address) | |
| @@ -3455,7 +3436,7 @@ func effectiveProxyHTTPPort(cfg Config) int { | |||
| 3455 | 3436 | if candidates == 1 { | |
| 3456 | 3437 | return candidatePort | |
| 3457 | 3438 | } | |
| 3458 | - | return cfg.Server.Port | |
| 3439 | + | return 0 | |
| 3459 | 3440 | } | |
| 3460 | 3441 | ||
| 3461 | 3442 | func tlsCapableEntryPoint(protocol domain.EntryPointProtocol) bool { | |
| @@ -787,14 +787,14 @@ func (si *serviceInit) registerReloadCoordinatorHooks() { | |||||
| 787 | return tlsErr | 787 | return tlsErr | ||
| 788 | } | 788 | } | ||
| 789 | } | 789 | } | ||
| 790 | + | if err := applyTrafficRuntimeConfig(reloadCtx, si.svc.trafficManager, reloadCfg, si.svc.configSvc); err != nil { | |||
| 791 | + | return err | |||
| 792 | + | } | |||
| 790 | si.svc.tlsHTTPEntryPoints = registerTLSMuxHTTPServers(si.svc.trafficManager, reloadCfg, si.svc.httpsProxyHandler, tlsConfig, si.svc.tlsHTTPEntryPoints) | 793 | si.svc.tlsHTTPEntryPoints = registerTLSMuxHTTPServers(si.svc.trafficManager, reloadCfg, si.svc.httpsProxyHandler, tlsConfig, si.svc.tlsHTTPEntryPoints) | ||
| 791 | si.svc.smartHTTPEntryPoints = registerSmartTCPHTTPServers(si.svc.trafficManager, reloadCfg, si.svc.httpProxyHandler, si.svc.httpsProxyHandler, tlsConfig, si.svc.smartHTTPEntryPoints) | 794 | si.svc.smartHTTPEntryPoints = registerSmartTCPHTTPServers(si.svc.trafficManager, reloadCfg, si.svc.httpProxyHandler, si.svc.httpsProxyHandler, tlsConfig, si.svc.smartHTTPEntryPoints) | ||
| 792 | if err := reconcileStandaloneServices(reloadCtx, si.svc.standaloneServiceSvc, reloadCfg); err != nil { | 795 | if err := reconcileStandaloneServices(reloadCtx, si.svc.standaloneServiceSvc, reloadCfg); err != nil { | ||
| 793 | return err | 796 | return err | ||
| 794 | } | 797 | } | ||
| 795 | - | if err := applyTrafficRuntimeConfig(reloadCtx, si.svc.trafficManager, reloadCfg, si.svc.configSvc); err != nil { | |||
| 796 | - | return err | |||
| 797 | - | } | |||
| 798 | si.svc.containerSvc.UpdateConfig(containerCfg) | 798 | si.svc.containerSvc.UpdateConfig(containerCfg) | ||
| 799 | return nil | 799 | return nil | ||
| 800 | }) | 800 | }) | ||
| @@ -2931,10 +2931,10 @@ func waitForCoreProxyReadyAndApplyTraffic(ctx context.Context, cfg Config, svc * | |||||
| 2931 | if err := waitForServerReady(proxyReady, errChan); err != nil { | 2931 | if err := waitForServerReady(proxyReady, errChan); err != nil { | ||
| 2932 | return err | 2932 | return err | ||
| 2933 | } | 2933 | } | ||
| 2934 | - | if err := reconcileStandaloneServices(ctx, svc.standaloneServiceSvc, cfg); err != nil { | 2934 | + | if err := applyTrafficRuntimeConfig(ctx, svc.trafficManager, cfg, svc.configSvc); err != nil { |
| 2935 | return err | 2935 | return err | ||
| 2936 | } | 2936 | } | ||
| 2937 | - | return applyTrafficRuntimeConfig(ctx, svc.trafficManager, cfg, svc.configSvc) | 2937 | + | return reconcileStandaloneServices(ctx, svc.standaloneServiceSvc, cfg) |
| 2938 | } | 2938 | } | ||
| 2939 | 2939 | ||||
| 2940 | func reconcileStandaloneServices(ctx context.Context, serviceSvc in.StandaloneServiceService, cfg Config) error { | 2940 | func reconcileStandaloneServices(ctx context.Context, serviceSvc in.StandaloneServiceService, cfg Config) error { | ||
| @@ -3400,40 +3400,21 @@ func hasTLSCapableEntrypoint(cfg Config) bool { | |||||
| 3400 | } | 3400 | } | ||
| 3401 | 3401 | ||||
| 3402 | func effectivePublicTLSPort(cfg Config) int { | 3402 | func effectivePublicTLSPort(cfg Config) int { | ||
| 3403 | - | if entryPoint, ok := cfg.EntryPoints[traffic.DefaultEdgeEntryPointName]; ok && tlsCapableEntryPoint(entryPoint.Protocol) { | 3403 | + | return effectiveEntrypointPort(cfg, tlsCapableEntryPoint) |
| 3404 | - | if port := portFromAddress(entryPoint.Address); port > 0 { | |||
| 3405 | - | return port | |||
| 3406 | - | } | |||
| 3407 | - | } | |||
| 3408 | - | ||||
| 3409 | - | var candidatePort int | |||
| 3410 | - | candidates := 0 | |||
| 3411 | - | for name, entryPoint := range cfg.EntryPoints { | |||
| 3412 | - | if name == traffic.DefaultEdgeEntryPointName || !tlsCapableEntryPoint(entryPoint.Protocol) { | |||
| 3413 | - | continue | |||
| 3414 | - | } | |||
| 3415 | - | port := portFromAddress(entryPoint.Address) | |||
| 3416 | - | if port == 0 { | |||
| 3417 | - | continue | |||
| 3418 | - | } | |||
| 3419 | - | candidatePort = port | |||
| 3420 | - | candidates++ | |||
| 3421 | - | } | |||
| 3422 | - | if candidates == 1 { | |||
| 3423 | - | return candidatePort | |||
| 3424 | - | } | |||
| 3425 | - | return 443 | |||
| 3426 | } | 3404 | } | ||
| 3427 | 3405 | ||||
| 3428 | func effectiveProxyTLSPort(cfg Config) int { | 3406 | func effectiveProxyTLSPort(cfg Config) int { | ||
| 3429 | - | if hasTLSCapableEntrypoint(cfg) { | 3407 | + | return effectivePublicTLSPort(cfg) |
| 3430 | - | return effectivePublicTLSPort(cfg) | |||
| 3431 | - | } | |||
| 3432 | - | return cfg.Server.TLSPort | |||
| 3433 | } | 3408 | } | ||
| 3434 | 3409 | ||||
| 3435 | func effectiveProxyHTTPPort(cfg Config) int { | 3410 | func effectiveProxyHTTPPort(cfg Config) int { | ||
| 3436 | - | if entryPoint, ok := cfg.EntryPoints[traffic.DefaultEdgeEntryPointName]; ok && entryPoint.Protocol == domain.EntryPointProtocolSmartTCP { | 3411 | + | return effectiveEntrypointPort(cfg, func(protocol domain.EntryPointProtocol) bool { |
| 3412 | + | return protocol == domain.EntryPointProtocolSmartTCP | |||
| 3413 | + | }) | |||
| 3414 | + | } | |||
| 3415 | + | ||||
| 3416 | + | func effectiveEntrypointPort(cfg Config, match func(domain.EntryPointProtocol) bool) int { | |||
| 3417 | + | if entryPoint, ok := cfg.EntryPoints[traffic.DefaultEdgeEntryPointName]; ok && match(entryPoint.Protocol) { | |||
| 3437 | if port := portFromAddress(entryPoint.Address); port > 0 { | 3418 | if port := portFromAddress(entryPoint.Address); port > 0 { | ||
| 3438 | return port | 3419 | return port | ||
| 3439 | } | 3420 | } | ||
| @@ -3442,7 +3423,7 @@ func effectiveProxyHTTPPort(cfg Config) int { | |||||
| 3442 | var candidatePort int | 3423 | var candidatePort int | ||
| 3443 | candidates := 0 | 3424 | candidates := 0 | ||
| 3444 | for name, entryPoint := range cfg.EntryPoints { | 3425 | for name, entryPoint := range cfg.EntryPoints { | ||
| 3445 | - | if name == traffic.DefaultEdgeEntryPointName || entryPoint.Protocol != domain.EntryPointProtocolSmartTCP { | 3426 | + | if name == traffic.DefaultEdgeEntryPointName || !match(entryPoint.Protocol) { |
| 3446 | continue | 3427 | continue | ||
| 3447 | } | 3428 | } | ||
| 3448 | port := portFromAddress(entryPoint.Address) | 3429 | port := portFromAddress(entryPoint.Address) | ||
| @@ -3455,7 +3436,7 @@ func effectiveProxyHTTPPort(cfg Config) int { | |||||
| 3455 | if candidates == 1 { | 3436 | if candidates == 1 { | ||
| 3456 | return candidatePort | 3437 | return candidatePort | ||
| 3457 | } | 3438 | } | ||
| 3458 | - | return cfg.Server.Port | 3439 | + | return 0 |
| 3459 | } | 3440 | } | ||
| 3460 | 3441 | ||||
| 3461 | func tlsCapableEntryPoint(protocol domain.EntryPointProtocol) bool { | 3442 | func tlsCapableEntryPoint(protocol domain.EntryPointProtocol) bool { | ||
M internal/app/run_http_handlers_test.go +4 -1
| @@ -128,6 +128,9 @@ func newOnboardingConfig() Config { | |||
| 128 | 128 | cfg.Server.Port = 8088 | |
| 129 | 129 | cfg.Server.TLSPort = 8443 | |
| 130 | 130 | cfg.Server.ProxyAllowedIPs = []string{"173.245.48.0/20"} // Cloudflare sample | |
| 131 | + | cfg.EntryPoints = map[string]traffic.EntryPointConfig{ | |
| 132 | + | traffic.DefaultEdgeEntryPointName: {Address: ":8443", Protocol: domain.EntryPointProtocolSmartTCP}, | |
| 133 | + | } | |
| 131 | 134 | return cfg | |
| 132 | 135 | } | |
| 133 | 136 | ||
| @@ -449,7 +452,7 @@ func TestCreateHTTPHandlers_TLSDisabled_DoesNotServeHTTPOnboarding(t *testing.T) | |||
| 449 | 452 | t.Parallel() | |
| 450 | 453 | ca := newTestCA(t) | |
| 451 | 454 | cfg := newOnboardingConfig() | |
| 452 | - | cfg.Server.TLSPort = 0 // TLS disabled | |
| 455 | + | cfg.EntryPoints = nil // TLS disabled: no TLS-capable traffic entrypoint | |
| 453 | 456 | svc := &services{caAdapter: ca} | |
| 454 | 457 | ||
| 455 | 458 | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) | |
| @@ -128,6 +128,9 @@ func newOnboardingConfig() Config { | |||||
| 128 | cfg.Server.Port = 8088 | 128 | cfg.Server.Port = 8088 | ||
| 129 | cfg.Server.TLSPort = 8443 | 129 | cfg.Server.TLSPort = 8443 | ||
| 130 | cfg.Server.ProxyAllowedIPs = []string{"173.245.48.0/20"} // Cloudflare sample | 130 | cfg.Server.ProxyAllowedIPs = []string{"173.245.48.0/20"} // Cloudflare sample | ||
| 131 | + | cfg.EntryPoints = map[string]traffic.EntryPointConfig{ | |||
| 132 | + | traffic.DefaultEdgeEntryPointName: {Address: ":8443", Protocol: domain.EntryPointProtocolSmartTCP}, | |||
| 133 | + | } | |||
| 131 | return cfg | 134 | return cfg | ||
| 132 | } | 135 | } | ||
| 133 | 136 | ||||
| @@ -449,7 +452,7 @@ func TestCreateHTTPHandlers_TLSDisabled_DoesNotServeHTTPOnboarding(t *testing.T) | |||||
| 449 | t.Parallel() | 452 | t.Parallel() | ||
| 450 | ca := newTestCA(t) | 453 | ca := newTestCA(t) | ||
| 451 | cfg := newOnboardingConfig() | 454 | cfg := newOnboardingConfig() | ||
| 452 | - | cfg.Server.TLSPort = 0 // TLS disabled | 455 | + | cfg.EntryPoints = nil // TLS disabled: no TLS-capable traffic entrypoint |
| 453 | svc := &services{caAdapter: ca} | 456 | svc := &services{caAdapter: ca} | ||
| 454 | 457 | ||||
| 455 | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) | 458 | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) | ||
M internal/app/run_reload_test.go +4 -4
| @@ -382,7 +382,7 @@ func TestStandaloneServiceSecretProviderUnsafeBackendReconcilesServiceSecrets(t | |||
| 382 | 382 | assert.True(t, sawSecretEnv) | |
| 383 | 383 | } | |
| 384 | 384 | ||
| 385 | - | func TestServiceInit_RegisterReloadCoordinatorHooks_ReconcilesStandaloneServicesBeforeTrafficApply(t *testing.T) { | |
| 385 | + | func TestServiceInit_RegisterReloadCoordinatorHooks_AppliesTrafficBeforeStandaloneServices(t *testing.T) { | |
| 386 | 386 | ctx := context.Background() | |
| 387 | 387 | v := viper.New() | |
| 388 | 388 | v.Set("server.gordon_domain", "reload.example.com") | |
| @@ -394,7 +394,7 @@ func TestServiceInit_RegisterReloadCoordinatorHooks_ReconcilesStandaloneServices | |||
| 394 | 394 | defer func() { require.NoError(t, manager.Shutdown(ctx)) }() | |
| 395 | 395 | ||
| 396 | 396 | reconciler := &standaloneServiceRecorder{onReconcile: func() { | |
| 397 | - | assert.Empty(t, manager.Status().EntryPoints, "traffic graph applied before standalone services reconciled") | |
| 397 | + | assert.NotEmpty(t, manager.Status().EntryPoints, "traffic graph should apply before standalone services reconcile") | |
| 398 | 398 | }} | |
| 399 | 399 | si := &serviceInit{ | |
| 400 | 400 | ctx: ctx, | |
| @@ -428,7 +428,7 @@ func TestServiceInit_RegisterReloadCoordinatorHooks_ReconcilesStandaloneServices | |||
| 428 | 428 | assert.NotEmpty(t, manager.Status().EntryPoints) | |
| 429 | 429 | } | |
| 430 | 430 | ||
| 431 | - | func TestWaitForCoreProxyReadyAndApplyTraffic_ReconcilesStandaloneServicesBeforeTrafficApply(t *testing.T) { | |
| 431 | + | func TestWaitForCoreProxyReadyAndApplyTraffic_AppliesTrafficBeforeStandaloneServices(t *testing.T) { | |
| 432 | 432 | ctx := context.Background() | |
| 433 | 433 | v := viper.New() | |
| 434 | 434 | configSvc := cfgusecase.NewService(v, nil) | |
| @@ -437,7 +437,7 @@ func TestWaitForCoreProxyReadyAndApplyTraffic_ReconcilesStandaloneServicesBefore | |||
| 437 | 437 | defer func() { require.NoError(t, manager.Shutdown(ctx)) }() | |
| 438 | 438 | ||
| 439 | 439 | reconciler := &standaloneServiceRecorder{onReconcile: func() { | |
| 440 | - | assert.Empty(t, manager.Status().EntryPoints, "traffic graph applied before standalone services reconciled") | |
| 440 | + | assert.NotEmpty(t, manager.Status().EntryPoints, "traffic graph should apply before standalone services reconcile") | |
| 441 | 441 | }} | |
| 442 | 442 | cfg := Config{} | |
| 443 | 443 | cfg.Services = []servicecfg.Config{{Name: "game", Image: "game:latest", Enabled: true}} | |
| @@ -382,7 +382,7 @@ func TestStandaloneServiceSecretProviderUnsafeBackendReconcilesServiceSecrets(t | |||||
| 382 | assert.True(t, sawSecretEnv) | 382 | assert.True(t, sawSecretEnv) | ||
| 383 | } | 383 | } | ||
| 384 | 384 | ||||
| 385 | - | func TestServiceInit_RegisterReloadCoordinatorHooks_ReconcilesStandaloneServicesBeforeTrafficApply(t *testing.T) { | 385 | + | func TestServiceInit_RegisterReloadCoordinatorHooks_AppliesTrafficBeforeStandaloneServices(t *testing.T) { |
| 386 | ctx := context.Background() | 386 | ctx := context.Background() | ||
| 387 | v := viper.New() | 387 | v := viper.New() | ||
| 388 | v.Set("server.gordon_domain", "reload.example.com") | 388 | v.Set("server.gordon_domain", "reload.example.com") | ||
| @@ -394,7 +394,7 @@ func TestServiceInit_RegisterReloadCoordinatorHooks_ReconcilesStandaloneServices | |||||
| 394 | defer func() { require.NoError(t, manager.Shutdown(ctx)) }() | 394 | defer func() { require.NoError(t, manager.Shutdown(ctx)) }() | ||
| 395 | 395 | ||||
| 396 | reconciler := &standaloneServiceRecorder{onReconcile: func() { | 396 | reconciler := &standaloneServiceRecorder{onReconcile: func() { | ||
| 397 | - | assert.Empty(t, manager.Status().EntryPoints, "traffic graph applied before standalone services reconciled") | 397 | + | assert.NotEmpty(t, manager.Status().EntryPoints, "traffic graph should apply before standalone services reconcile") |
| 398 | }} | 398 | }} | ||
| 399 | si := &serviceInit{ | 399 | si := &serviceInit{ | ||
| 400 | ctx: ctx, | 400 | ctx: ctx, | ||
| @@ -428,7 +428,7 @@ func TestServiceInit_RegisterReloadCoordinatorHooks_ReconcilesStandaloneServices | |||||
| 428 | assert.NotEmpty(t, manager.Status().EntryPoints) | 428 | assert.NotEmpty(t, manager.Status().EntryPoints) | ||
| 429 | } | 429 | } | ||
| 430 | 430 | ||||
| 431 | - | func TestWaitForCoreProxyReadyAndApplyTraffic_ReconcilesStandaloneServicesBeforeTrafficApply(t *testing.T) { | 431 | + | func TestWaitForCoreProxyReadyAndApplyTraffic_AppliesTrafficBeforeStandaloneServices(t *testing.T) { |
| 432 | ctx := context.Background() | 432 | ctx := context.Background() | ||
| 433 | v := viper.New() | 433 | v := viper.New() | ||
| 434 | configSvc := cfgusecase.NewService(v, nil) | 434 | configSvc := cfgusecase.NewService(v, nil) | ||
| @@ -437,7 +437,7 @@ func TestWaitForCoreProxyReadyAndApplyTraffic_ReconcilesStandaloneServicesBefore | |||||
| 437 | defer func() { require.NoError(t, manager.Shutdown(ctx)) }() | 437 | defer func() { require.NoError(t, manager.Shutdown(ctx)) }() | ||
| 438 | 438 | ||||
| 439 | reconciler := &standaloneServiceRecorder{onReconcile: func() { | 439 | reconciler := &standaloneServiceRecorder{onReconcile: func() { | ||
| 440 | - | assert.Empty(t, manager.Status().EntryPoints, "traffic graph applied before standalone services reconciled") | 440 | + | assert.NotEmpty(t, manager.Status().EntryPoints, "traffic graph should apply before standalone services reconcile") |
| 441 | }} | 441 | }} | ||
| 442 | cfg := Config{} | 442 | cfg := Config{} | ||
| 443 | cfg.Services = []servicecfg.Config{{Name: "game", Image: "game:latest", Enabled: true}} | 443 | cfg.Services = []servicecfg.Config{{Name: "game", Image: "game:latest", Enabled: true}} | ||
M internal/domain/errors.go +3 -0
| @@ -106,4 +106,7 @@ var ( | |||
| 106 | 106 | ErrRouteSourceRequired = errors.New("route source required") | |
| 107 | 107 | ErrHTTPChallengeSinkRequired = errors.New("http challenge sink required") | |
| 108 | 108 | ErrTLSRouteNotCovered = errors.New("tls route not covered by public certificate") | |
| 109 | + | ||
| 110 | + | // Traffic errors | |
| 111 | + | ErrTrafficStatusUnavailable = errors.New("traffic status unavailable") | |
| 109 | 112 | ) | |
| @@ -106,4 +106,7 @@ var ( | |||||
| 106 | ErrRouteSourceRequired = errors.New("route source required") | 106 | ErrRouteSourceRequired = errors.New("route source required") | ||
| 107 | ErrHTTPChallengeSinkRequired = errors.New("http challenge sink required") | 107 | ErrHTTPChallengeSinkRequired = errors.New("http challenge sink required") | ||
| 108 | ErrTLSRouteNotCovered = errors.New("tls route not covered by public certificate") | 108 | ErrTLSRouteNotCovered = errors.New("tls route not covered by public certificate") | ||
| 109 | + | ||||
| 110 | + | // Traffic errors | |||
| 111 | + | ErrTrafficStatusUnavailable = errors.New("traffic status unavailable") | |||
| 109 | ) | 112 | ) | ||
M internal/domain/service.go +2 -2
| @@ -3,7 +3,7 @@ package domain | |||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | 5 | "net" | |
| 6 | - | "path/filepath" | |
| 6 | + | "path" | |
| 7 | 7 | "strconv" | |
| 8 | 8 | "strings" | |
| 9 | 9 | "time" | |
| @@ -210,7 +210,7 @@ func validateStandaloneServiceVolumes(s StandaloneService) error { | |||
| 210 | 210 | if target == "" { | |
| 211 | 211 | return fmt.Errorf("standalone service %q volume %d target is required", s.Name, i) | |
| 212 | 212 | } | |
| 213 | - | if !filepath.IsAbs(target) { | |
| 213 | + | if !path.IsAbs(target) { | |
| 214 | 214 | return fmt.Errorf("standalone service %q volume %d target %q must be an absolute container path", s.Name, i, volume.Target) | |
| 215 | 215 | } | |
| 216 | 216 | if _, ok := seenTargets[target]; ok { | |
| @@ -3,7 +3,7 @@ package domain | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | "net" | 5 | "net" | ||
| 6 | - | "path/filepath" | 6 | + | "path" |
| 7 | "strconv" | 7 | "strconv" | ||
| 8 | "strings" | 8 | "strings" | ||
| 9 | "time" | 9 | "time" | ||
| @@ -210,7 +210,7 @@ func validateStandaloneServiceVolumes(s StandaloneService) error { | |||||
| 210 | if target == "" { | 210 | if target == "" { | ||
| 211 | return fmt.Errorf("standalone service %q volume %d target is required", s.Name, i) | 211 | return fmt.Errorf("standalone service %q volume %d target is required", s.Name, i) | ||
| 212 | } | 212 | } | ||
| 213 | - | if !filepath.IsAbs(target) { | 213 | + | if !path.IsAbs(target) { |
| 214 | return fmt.Errorf("standalone service %q volume %d target %q must be an absolute container path", s.Name, i, volume.Target) | 214 | return fmt.Errorf("standalone service %q volume %d target %q must be an absolute container path", s.Name, i, volume.Target) | ||
| 215 | } | 215 | } | ||
| 216 | if _, ok := seenTargets[target]; ok { | 216 | if _, ok := seenTargets[target]; ok { | ||
M internal/domain/service_test.go +3 -0
| @@ -73,6 +73,9 @@ func TestStandaloneServiceValidateVolumesAndEnvFile(t *testing.T) { | |||
| 73 | 73 | svc.Volumes = []StandaloneServiceVolume{{Source: "cache-data", Target: "var/lib/redis"}} | |
| 74 | 74 | require.ErrorContains(t, svc.Validate(), "absolute") | |
| 75 | 75 | ||
| 76 | + | svc.Volumes = []StandaloneServiceVolume{{Source: "cache-data", Target: `C:\\data`}} | |
| 77 | + | require.ErrorContains(t, svc.Validate(), "absolute") | |
| 78 | + | ||
| 76 | 79 | svc.Volumes = nil | |
| 77 | 80 | svc.EnvFile = " " | |
| 78 | 81 | require.ErrorContains(t, svc.Validate(), "env_file") | |
| @@ -73,6 +73,9 @@ func TestStandaloneServiceValidateVolumesAndEnvFile(t *testing.T) { | |||||
| 73 | svc.Volumes = []StandaloneServiceVolume{{Source: "cache-data", Target: "var/lib/redis"}} | 73 | svc.Volumes = []StandaloneServiceVolume{{Source: "cache-data", Target: "var/lib/redis"}} | ||
| 74 | require.ErrorContains(t, svc.Validate(), "absolute") | 74 | require.ErrorContains(t, svc.Validate(), "absolute") | ||
| 75 | 75 | ||||
| 76 | + | svc.Volumes = []StandaloneServiceVolume{{Source: "cache-data", Target: `C:\\data`}} | |||
| 77 | + | require.ErrorContains(t, svc.Validate(), "absolute") | |||
| 78 | + | ||||
| 76 | svc.Volumes = nil | 79 | svc.Volumes = nil | ||
| 77 | svc.EnvFile = " " | 80 | svc.EnvFile = " " | ||
| 78 | require.ErrorContains(t, svc.Validate(), "env_file") | 81 | require.ErrorContains(t, svc.Validate(), "env_file") | ||
M internal/usecase/services/service.go +30 -18
| @@ -94,12 +94,16 @@ func (s *Service) reconcileOne(ctx context.Context, svc domain.StandaloneService | |||
| 94 | 94 | if !svc.Enabled { | |
| 95 | 95 | return s.stopDisabled(ctx, svc.Name, cleanup, existing) | |
| 96 | 96 | } | |
| 97 | - | hash, err := s.serviceConfigHash(ctx, svc) | |
| 97 | + | env, err := s.serviceEnv(ctx, svc) | |
| 98 | + | if err != nil { | |
| 99 | + | return fmt.Errorf("resolve standalone service %q environment: %w", svc.Name, err) | |
| 100 | + | } | |
| 101 | + | hash, err := serviceConfigHashWithEnv(svc, env) | |
| 98 | 102 | if err != nil { | |
| 99 | 103 | return fmt.Errorf("hash standalone service %q config: %w", svc.Name, err) | |
| 100 | 104 | } | |
| 101 | 105 | if len(existing) == 0 { | |
| 102 | - | return s.createAndStart(ctx, svc, hash) | |
| 106 | + | return s.createAndStart(ctx, svc, hash, env) | |
| 103 | 107 | } | |
| 104 | 108 | sort.SliceStable(existing, func(i, j int) bool { | |
| 105 | 109 | leftRunning := containerStatus(existing[i]) == domain.ContainerStatusRunning | |
| @@ -111,7 +115,7 @@ func (s *Service) reconcileOne(ctx context.Context, svc domain.StandaloneService | |||
| 111 | 115 | }) | |
| 112 | 116 | current := existing[0] | |
| 113 | 117 | if current.Labels[domain.LabelServiceConfigHash] != hash { | |
| 114 | - | if err := s.recreate(ctx, svc, cleanup, existing, hash); err != nil { | |
| 118 | + | if err := s.recreate(ctx, svc, cleanup, existing, hash, env); err != nil { | |
| 115 | 119 | return err | |
| 116 | 120 | } | |
| 117 | 121 | return nil | |
| @@ -132,7 +136,7 @@ func (s *Service) reconcileOne(ctx context.Context, svc domain.StandaloneService | |||
| 132 | 136 | return nil | |
| 133 | 137 | } | |
| 134 | 138 | ||
| 135 | - | func (s *Service) recreate(ctx context.Context, svc domain.StandaloneService, cleanup domain.StandaloneServiceCleanup, existing []*domain.Container, hash string) error { | |
| 139 | + | func (s *Service) recreate(ctx context.Context, svc domain.StandaloneService, cleanup domain.StandaloneServiceCleanup, existing []*domain.Container, hash string, env []string) error { | |
| 136 | 140 | for _, container := range existing { | |
| 137 | 141 | if containerStatus(container) == domain.ContainerStatusRunning { | |
| 138 | 142 | if err := s.runtime.StopContainer(ctx, container.ID); err != nil { | |
| @@ -144,11 +148,8 @@ func (s *Service) recreate(ctx context.Context, svc domain.StandaloneService, cl | |||
| 144 | 148 | return fmt.Errorf("remove stale standalone service %q container: %w", svc.Name, err) | |
| 145 | 149 | } | |
| 146 | 150 | } | |
| 147 | - | if err := s.removeManagedVolumes(ctx, svc.Name, cleanup, container); err != nil { | |
| 148 | - | return err | |
| 149 | - | } | |
| 150 | 151 | } | |
| 151 | - | return s.createAndStart(ctx, svc, hash) | |
| 152 | + | return s.createAndStart(ctx, svc, hash, env) | |
| 152 | 153 | } | |
| 153 | 154 | ||
| 154 | 155 | func (s *Service) stopDisabled(ctx context.Context, name string, cleanup domain.StandaloneServiceCleanup, existing []*domain.Container) error { | |
| @@ -234,8 +235,8 @@ func cleanupFromLabels(labels map[string]string) domain.StandaloneServiceCleanup | |||
| 234 | 235 | return cleanup | |
| 235 | 236 | } | |
| 236 | 237 | ||
| 237 | - | func (s *Service) createAndStart(ctx context.Context, svc domain.StandaloneService, hash string) error { | |
| 238 | - | config, err := s.containerConfig(ctx, svc, hash) | |
| 238 | + | func (s *Service) createAndStart(ctx context.Context, svc domain.StandaloneService, hash string, env []string) error { | |
| 239 | + | config, err := s.containerConfig(ctx, svc, hash, env) | |
| 239 | 240 | if err != nil { | |
| 240 | 241 | return err | |
| 241 | 242 | } | |
| @@ -252,7 +253,7 @@ func (s *Service) createAndStart(ctx context.Context, svc domain.StandaloneServi | |||
| 252 | 253 | return nil | |
| 253 | 254 | } | |
| 254 | 255 | ||
| 255 | - | func (s *Service) containerConfig(ctx context.Context, svc domain.StandaloneService, hash string) (*domain.ContainerConfig, error) { | |
| 256 | + | func (s *Service) containerConfig(ctx context.Context, svc domain.StandaloneService, hash string, env []string) (*domain.ContainerConfig, error) { | |
| 256 | 257 | var imageVolumes []string | |
| 257 | 258 | if len(svc.Volumes) == 0 { | |
| 258 | 259 | var err error | |
| @@ -279,10 +280,6 @@ func (s *Service) containerConfig(ctx context.Context, svc domain.StandaloneServ | |||
| 279 | 280 | if err != nil { | |
| 280 | 281 | return nil, err | |
| 281 | 282 | } | |
| 282 | - | env, err := s.serviceEnv(ctx, svc) | |
| 283 | - | if err != nil { | |
| 284 | - | return nil, err | |
| 285 | - | } | |
| 286 | 283 | return &domain.ContainerConfig{ | |
| 287 | 284 | Image: svc.Image, | |
| 288 | 285 | Name: serviceContainerName(svc.Name), | |
| @@ -354,6 +351,10 @@ func (s *Service) serviceConfigHash(ctx context.Context, svc domain.StandaloneSe | |||
| 354 | 351 | if err != nil { | |
| 355 | 352 | return "", err | |
| 356 | 353 | } | |
| 354 | + | return serviceConfigHashWithEnv(svc, resolvedEnv) | |
| 355 | + | } | |
| 356 | + | ||
| 357 | + | func serviceConfigHashWithEnv(svc domain.StandaloneService, resolvedEnv []string) (string, error) { | |
| 357 | 358 | payload := struct { | |
| 358 | 359 | Image string | |
| 359 | 360 | ResolvedEnv []string | |
| @@ -361,7 +362,7 @@ func (s *Service) serviceConfigHash(ctx context.Context, svc domain.StandaloneSe | |||
| 361 | 362 | Ports []domain.StandaloneServicePort | |
| 362 | 363 | Volumes []domain.StandaloneServiceVolume | |
| 363 | 364 | Cleanup domain.StandaloneServiceCleanup | |
| 364 | - | }{svc.Image, resolvedEnv, svc.Readiness, append([]domain.StandaloneServicePort(nil), svc.Ports...), append([]domain.StandaloneServiceVolume(nil), svc.Volumes...), normalizeCleanup(svc.Cleanup)} | |
| 365 | + | }{svc.Image, append([]string(nil), resolvedEnv...), svc.Readiness, append([]domain.StandaloneServicePort(nil), svc.Ports...), append([]domain.StandaloneServiceVolume(nil), svc.Volumes...), normalizeCleanup(svc.Cleanup)} | |
| 365 | 366 | bytes, err := json.Marshal(payload) | |
| 366 | 367 | if err != nil { | |
| 367 | 368 | return "", err | |
| @@ -522,23 +523,34 @@ func tcpReadinessAddress(svc domain.StandaloneService) (string, error) { | |||
| 522 | 523 | func (s *Service) waitLogReadiness(ctx context.Context, containerID string, svc domain.StandaloneService) error { | |
| 523 | 524 | ticker := time.NewTicker(10 * time.Millisecond) | |
| 524 | 525 | defer ticker.Stop() | |
| 526 | + | var lastErr error | |
| 525 | 527 | for { | |
| 526 | 528 | if err := ctx.Err(); err != nil { | |
| 527 | - | return fmt.Errorf("standalone service %q log readiness timed out: %w", svc.Name, err) | |
| 529 | + | return logReadinessTimeoutError(svc.Name, err, lastErr) | |
| 528 | 530 | } | |
| 529 | 531 | found, err := s.logContains(ctx, containerID, svc.Readiness.Path, svc.Readiness.Contains) | |
| 530 | 532 | if err == nil && found { | |
| 531 | 533 | return nil | |
| 532 | 534 | } | |
| 535 | + | if err != nil { | |
| 536 | + | lastErr = err | |
| 537 | + | } | |
| 533 | 538 | // Continue polling until timeout; many services create the log after start. | |
| 534 | 539 | select { | |
| 535 | 540 | case <-ctx.Done(): | |
| 536 | - | return fmt.Errorf("standalone service %q log readiness timed out: %w", svc.Name, ctx.Err()) | |
| 541 | + | return logReadinessTimeoutError(svc.Name, ctx.Err(), lastErr) | |
| 537 | 542 | case <-ticker.C: | |
| 538 | 543 | } | |
| 539 | 544 | } | |
| 540 | 545 | } | |
| 541 | 546 | ||
| 547 | + | func logReadinessTimeoutError(serviceName string, timeoutErr, lastErr error) error { | |
| 548 | + | if lastErr != nil { | |
| 549 | + | return fmt.Errorf("standalone service %q log readiness timed out: %w; last read error: %w", serviceName, timeoutErr, lastErr) | |
| 550 | + | } | |
| 551 | + | return fmt.Errorf("standalone service %q log readiness timed out: %w", serviceName, timeoutErr) | |
| 552 | + | } | |
| 553 | + | ||
| 542 | 554 | func (s *Service) logContains(ctx context.Context, containerID, path, contains string) (bool, error) { | |
| 543 | 555 | reader, err := s.runtime.CopyFromContainer(ctx, containerID, path) | |
| 544 | 556 | if err != nil { | |
| @@ -94,12 +94,16 @@ func (s *Service) reconcileOne(ctx context.Context, svc domain.StandaloneService | |||||
| 94 | if !svc.Enabled { | 94 | if !svc.Enabled { | ||
| 95 | return s.stopDisabled(ctx, svc.Name, cleanup, existing) | 95 | return s.stopDisabled(ctx, svc.Name, cleanup, existing) | ||
| 96 | } | 96 | } | ||
| 97 | - | hash, err := s.serviceConfigHash(ctx, svc) | 97 | + | env, err := s.serviceEnv(ctx, svc) |
| 98 | + | if err != nil { | |||
| 99 | + | return fmt.Errorf("resolve standalone service %q environment: %w", svc.Name, err) | |||
| 100 | + | } | |||
| 101 | + | hash, err := serviceConfigHashWithEnv(svc, env) | |||
| 98 | if err != nil { | 102 | if err != nil { | ||
| 99 | return fmt.Errorf("hash standalone service %q config: %w", svc.Name, err) | 103 | return fmt.Errorf("hash standalone service %q config: %w", svc.Name, err) | ||
| 100 | } | 104 | } | ||
| 101 | if len(existing) == 0 { | 105 | if len(existing) == 0 { | ||
| 102 | - | return s.createAndStart(ctx, svc, hash) | 106 | + | return s.createAndStart(ctx, svc, hash, env) |
| 103 | } | 107 | } | ||
| 104 | sort.SliceStable(existing, func(i, j int) bool { | 108 | sort.SliceStable(existing, func(i, j int) bool { | ||
| 105 | leftRunning := containerStatus(existing[i]) == domain.ContainerStatusRunning | 109 | leftRunning := containerStatus(existing[i]) == domain.ContainerStatusRunning | ||
| @@ -111,7 +115,7 @@ func (s *Service) reconcileOne(ctx context.Context, svc domain.StandaloneService | |||||
| 111 | }) | 115 | }) | ||
| 112 | current := existing[0] | 116 | current := existing[0] | ||
| 113 | if current.Labels[domain.LabelServiceConfigHash] != hash { | 117 | if current.Labels[domain.LabelServiceConfigHash] != hash { | ||
| 114 | - | if err := s.recreate(ctx, svc, cleanup, existing, hash); err != nil { | 118 | + | if err := s.recreate(ctx, svc, cleanup, existing, hash, env); err != nil { |
| 115 | return err | 119 | return err | ||
| 116 | } | 120 | } | ||
| 117 | return nil | 121 | return nil | ||
| @@ -132,7 +136,7 @@ func (s *Service) reconcileOne(ctx context.Context, svc domain.StandaloneService | |||||
| 132 | return nil | 136 | return nil | ||
| 133 | } | 137 | } | ||
| 134 | 138 | ||||
| 135 | - | func (s *Service) recreate(ctx context.Context, svc domain.StandaloneService, cleanup domain.StandaloneServiceCleanup, existing []*domain.Container, hash string) error { | 139 | + | func (s *Service) recreate(ctx context.Context, svc domain.StandaloneService, cleanup domain.StandaloneServiceCleanup, existing []*domain.Container, hash string, env []string) error { |
| 136 | for _, container := range existing { | 140 | for _, container := range existing { | ||
| 137 | if containerStatus(container) == domain.ContainerStatusRunning { | 141 | if containerStatus(container) == domain.ContainerStatusRunning { | ||
| 138 | if err := s.runtime.StopContainer(ctx, container.ID); err != nil { | 142 | if err := s.runtime.StopContainer(ctx, container.ID); err != nil { | ||
| @@ -144,11 +148,8 @@ func (s *Service) recreate(ctx context.Context, svc domain.StandaloneService, cl | |||||
| 144 | return fmt.Errorf("remove stale standalone service %q container: %w", svc.Name, err) | 148 | return fmt.Errorf("remove stale standalone service %q container: %w", svc.Name, err) | ||
| 145 | } | 149 | } | ||
| 146 | } | 150 | } | ||
| 147 | - | if err := s.removeManagedVolumes(ctx, svc.Name, cleanup, container); err != nil { | |||
| 148 | - | return err | |||
| 149 | - | } | |||
| 150 | } | 151 | } | ||
| 151 | - | return s.createAndStart(ctx, svc, hash) | 152 | + | return s.createAndStart(ctx, svc, hash, env) |
| 152 | } | 153 | } | ||
| 153 | 154 | ||||
| 154 | func (s *Service) stopDisabled(ctx context.Context, name string, cleanup domain.StandaloneServiceCleanup, existing []*domain.Container) error { | 155 | func (s *Service) stopDisabled(ctx context.Context, name string, cleanup domain.StandaloneServiceCleanup, existing []*domain.Container) error { | ||
| @@ -234,8 +235,8 @@ func cleanupFromLabels(labels map[string]string) domain.StandaloneServiceCleanup | |||||
| 234 | return cleanup | 235 | return cleanup | ||
| 235 | } | 236 | } | ||
| 236 | 237 | ||||
| 237 | - | func (s *Service) createAndStart(ctx context.Context, svc domain.StandaloneService, hash string) error { | 238 | + | func (s *Service) createAndStart(ctx context.Context, svc domain.StandaloneService, hash string, env []string) error { |
| 238 | - | config, err := s.containerConfig(ctx, svc, hash) | 239 | + | config, err := s.containerConfig(ctx, svc, hash, env) |
| 239 | if err != nil { | 240 | if err != nil { | ||
| 240 | return err | 241 | return err | ||
| 241 | } | 242 | } | ||
| @@ -252,7 +253,7 @@ func (s *Service) createAndStart(ctx context.Context, svc domain.StandaloneServi | |||||
| 252 | return nil | 253 | return nil | ||
| 253 | } | 254 | } | ||
| 254 | 255 | ||||
| 255 | - | func (s *Service) containerConfig(ctx context.Context, svc domain.StandaloneService, hash string) (*domain.ContainerConfig, error) { | 256 | + | func (s *Service) containerConfig(ctx context.Context, svc domain.StandaloneService, hash string, env []string) (*domain.ContainerConfig, error) { |
| 256 | var imageVolumes []string | 257 | var imageVolumes []string | ||
| 257 | if len(svc.Volumes) == 0 { | 258 | if len(svc.Volumes) == 0 { | ||
| 258 | var err error | 259 | var err error | ||
| @@ -279,10 +280,6 @@ func (s *Service) containerConfig(ctx context.Context, svc domain.StandaloneServ | |||||
| 279 | if err != nil { | 280 | if err != nil { | ||
| 280 | return nil, err | 281 | return nil, err | ||
| 281 | } | 282 | } | ||
| 282 | - | env, err := s.serviceEnv(ctx, svc) | |||
| 283 | - | if err != nil { | |||
| 284 | - | return nil, err | |||
| 285 | - | } | |||
| 286 | return &domain.ContainerConfig{ | 283 | return &domain.ContainerConfig{ | ||
| 287 | Image: svc.Image, | 284 | Image: svc.Image, | ||
| 288 | Name: serviceContainerName(svc.Name), | 285 | Name: serviceContainerName(svc.Name), | ||
| @@ -354,6 +351,10 @@ func (s *Service) serviceConfigHash(ctx context.Context, svc domain.StandaloneSe | |||||
| 354 | if err != nil { | 351 | if err != nil { | ||
| 355 | return "", err | 352 | return "", err | ||
| 356 | } | 353 | } | ||
| 354 | + | return serviceConfigHashWithEnv(svc, resolvedEnv) | |||
| 355 | + | } | |||
| 356 | + | ||||
| 357 | + | func serviceConfigHashWithEnv(svc domain.StandaloneService, resolvedEnv []string) (string, error) { | |||
| 357 | payload := struct { | 358 | payload := struct { | ||
| 358 | Image string | 359 | Image string | ||
| 359 | ResolvedEnv []string | 360 | ResolvedEnv []string | ||
| @@ -361,7 +362,7 @@ func (s *Service) serviceConfigHash(ctx context.Context, svc domain.StandaloneSe | |||||
| 361 | Ports []domain.StandaloneServicePort | 362 | Ports []domain.StandaloneServicePort | ||
| 362 | Volumes []domain.StandaloneServiceVolume | 363 | Volumes []domain.StandaloneServiceVolume | ||
| 363 | Cleanup domain.StandaloneServiceCleanup | 364 | Cleanup domain.StandaloneServiceCleanup | ||
| 364 | - | }{svc.Image, resolvedEnv, svc.Readiness, append([]domain.StandaloneServicePort(nil), svc.Ports...), append([]domain.StandaloneServiceVolume(nil), svc.Volumes...), normalizeCleanup(svc.Cleanup)} | 365 | + | }{svc.Image, append([]string(nil), resolvedEnv...), svc.Readiness, append([]domain.StandaloneServicePort(nil), svc.Ports...), append([]domain.StandaloneServiceVolume(nil), svc.Volumes...), normalizeCleanup(svc.Cleanup)} |
| 365 | bytes, err := json.Marshal(payload) | 366 | bytes, err := json.Marshal(payload) | ||
| 366 | if err != nil { | 367 | if err != nil { | ||
| 367 | return "", err | 368 | return "", err | ||
| @@ -522,23 +523,34 @@ func tcpReadinessAddress(svc domain.StandaloneService) (string, error) { | |||||
| 522 | func (s *Service) waitLogReadiness(ctx context.Context, containerID string, svc domain.StandaloneService) error { | 523 | func (s *Service) waitLogReadiness(ctx context.Context, containerID string, svc domain.StandaloneService) error { | ||
| 523 | ticker := time.NewTicker(10 * time.Millisecond) | 524 | ticker := time.NewTicker(10 * time.Millisecond) | ||
| 524 | defer ticker.Stop() | 525 | defer ticker.Stop() | ||
| 526 | + | var lastErr error | |||
| 525 | for { | 527 | for { | ||
| 526 | if err := ctx.Err(); err != nil { | 528 | if err := ctx.Err(); err != nil { | ||
| 527 | - | return fmt.Errorf("standalone service %q log readiness timed out: %w", svc.Name, err) | 529 | + | return logReadinessTimeoutError(svc.Name, err, lastErr) |
| 528 | } | 530 | } | ||
| 529 | found, err := s.logContains(ctx, containerID, svc.Readiness.Path, svc.Readiness.Contains) | 531 | found, err := s.logContains(ctx, containerID, svc.Readiness.Path, svc.Readiness.Contains) | ||
| 530 | if err == nil && found { | 532 | if err == nil && found { | ||
| 531 | return nil | 533 | return nil | ||
| 532 | } | 534 | } | ||
| 535 | + | if err != nil { | |||
| 536 | + | lastErr = err | |||
| 537 | + | } | |||
| 533 | // Continue polling until timeout; many services create the log after start. | 538 | // Continue polling until timeout; many services create the log after start. | ||
| 534 | select { | 539 | select { | ||
| 535 | case <-ctx.Done(): | 540 | case <-ctx.Done(): | ||
| 536 | - | return fmt.Errorf("standalone service %q log readiness timed out: %w", svc.Name, ctx.Err()) | 541 | + | return logReadinessTimeoutError(svc.Name, ctx.Err(), lastErr) |
| 537 | case <-ticker.C: | 542 | case <-ticker.C: | ||
| 538 | } | 543 | } | ||
| 539 | } | 544 | } | ||
| 540 | } | 545 | } | ||
| 541 | 546 | ||||
| 547 | + | func logReadinessTimeoutError(serviceName string, timeoutErr, lastErr error) error { | |||
| 548 | + | if lastErr != nil { | |||
| 549 | + | return fmt.Errorf("standalone service %q log readiness timed out: %w; last read error: %w", serviceName, timeoutErr, lastErr) | |||
| 550 | + | } | |||
| 551 | + | return fmt.Errorf("standalone service %q log readiness timed out: %w", serviceName, timeoutErr) | |||
| 552 | + | } | |||
| 553 | + | ||||
| 542 | func (s *Service) logContains(ctx context.Context, containerID, path, contains string) (bool, error) { | 554 | func (s *Service) logContains(ctx context.Context, containerID, path, contains string) (bool, error) { | ||
| 543 | reader, err := s.runtime.CopyFromContainer(ctx, containerID, path) | 555 | reader, err := s.runtime.CopyFromContainer(ctx, containerID, path) | ||
| 544 | if err != nil { | 556 | if err != nil { | ||
M internal/usecase/services/service_test.go +55 -0
| @@ -91,6 +91,25 @@ func TestService_ReconcileRecreatesStaleService(t *testing.T) { | |||
| 91 | 91 | require.NoError(t, err) | |
| 92 | 92 | } | |
| 93 | 93 | ||
| 94 | + | func TestService_ReconcileRecreateDoesNotRemoveManagedVolumes(t *testing.T) { | |
| 95 | + | rt := outmocks.NewMockContainerRuntime(t) | |
| 96 | + | svc := sampleService() | |
| 97 | + | svc.Cleanup = domain.StandaloneServiceCleanup{PreserveVolumes: false, RemoveContainer: true} | |
| 98 | + | old := managedContainer("old-1", svc.Name, "old-hash", "running") | |
| 99 | + | old.Labels[domain.LabelServiceManagedVolumes] = "gordon-service-game-data" | |
| 100 | + | old.VolumeMounts = []domain.ContainerVolumeMount{{Name: "gordon-service-game-data", Type: "volume", Destination: "/data"}} | |
| 101 | + | rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{old}, nil).Once() | |
| 102 | + | rt.On("StopContainer", mock.Anything, "old-1").Return(nil).Once() | |
| 103 | + | rt.On("RemoveContainer", mock.Anything, "old-1", true).Return(nil).Once() | |
| 104 | + | rt.On("InspectImageVolumes", mock.Anything, svc.Image).Return([]string{}, nil).Once() | |
| 105 | + | rt.On("CreateContainer", mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{ID: "new-1"}, nil).Once() | |
| 106 | + | rt.On("StartContainer", mock.Anything, "new-1").Return(nil).Once() | |
| 107 | + | ||
| 108 | + | err := NewService(rt).Reconcile(context.Background(), []domain.StandaloneService{svc}) | |
| 109 | + | ||
| 110 | + | require.NoError(t, err) | |
| 111 | + | } | |
| 112 | + | ||
| 94 | 113 | func TestService_ReconcileRemovesDuplicateMatchingServiceContainers(t *testing.T) { | |
| 95 | 114 | rt := outmocks.NewMockContainerRuntime(t) | |
| 96 | 115 | svc := sampleService() | |
| @@ -263,6 +282,28 @@ func TestServiceConfigHashIncludesResolvedEnvFileAndSecretValues(t *testing.T) { | |||
| 263 | 282 | assert.NotEqual(t, first, second) | |
| 264 | 283 | } | |
| 265 | 284 | ||
| 285 | + | func TestService_ReconcileResolvesSecretEnvOnceForHashAndContainer(t *testing.T) { | |
| 286 | + | rt := outmocks.NewMockContainerRuntime(t) | |
| 287 | + | provider := outmocks.NewMockSecretProvider(t) | |
| 288 | + | svc := sampleService() | |
| 289 | + | svc.Secrets = []domain.StandaloneServiceSecretRef{{Name: "rcon", Key: "RCON_PASSWORD"}} | |
| 290 | + | var created *domain.ContainerConfig | |
| 291 | + | provider.On("GetSecret", mock.Anything, "service/game/rcon").Return("secret-value", nil).Once() | |
| 292 | + | rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{}, nil).Once() | |
| 293 | + | rt.On("InspectImageVolumes", mock.Anything, svc.Image).Return([]string{}, nil).Once() | |
| 294 | + | rt.On("CreateContainer", mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Run(func(args mock.Arguments) { | |
| 295 | + | created = args.Get(1).(*domain.ContainerConfig) | |
| 296 | + | }).Return(&domain.Container{ID: "created-1"}, nil).Once() | |
| 297 | + | rt.On("StartContainer", mock.Anything, "created-1").Return(nil).Once() | |
| 298 | + | ||
| 299 | + | err := NewServiceWithSecretProvider(rt, provider).Reconcile(context.Background(), []domain.StandaloneService{svc}) | |
| 300 | + | ||
| 301 | + | require.NoError(t, err) | |
| 302 | + | require.NotNil(t, created) | |
| 303 | + | assert.Contains(t, created.Env, "RCON_PASSWORD=secret-value") | |
| 304 | + | assert.NotEmpty(t, created.Labels[domain.LabelServiceConfigHash]) | |
| 305 | + | } | |
| 306 | + | ||
| 266 | 307 | func TestServiceEnvResolvesServiceScopedSecretsWithoutLeakingValues(t *testing.T) { | |
| 267 | 308 | rt := outmocks.NewMockContainerRuntime(t) | |
| 268 | 309 | provider := outmocks.NewMockSecretProvider(t) | |
| @@ -359,6 +400,20 @@ func TestWaitReadinessHonorsContextTimeout(t *testing.T) { | |||
| 359 | 400 | assert.ErrorIs(t, err, context.DeadlineExceeded) | |
| 360 | 401 | } | |
| 361 | 402 | ||
| 403 | + | func TestWaitLogReadinessPreservesLastReadErrorOnTimeout(t *testing.T) { | |
| 404 | + | rt := outmocks.NewMockContainerRuntime(t) | |
| 405 | + | svc := sampleService() | |
| 406 | + | svc.Readiness = domain.StandaloneServiceReadiness{Type: domain.StandaloneServiceReadinessLog, Path: "/logs/server.log", Contains: "ready", Timeout: 25 * time.Millisecond} | |
| 407 | + | readErr := errors.New("copy failed") | |
| 408 | + | rt.On("CopyFromContainer", mock.Anything, "container-1", "/logs/server.log").Return(nil, readErr) | |
| 409 | + | ||
| 410 | + | err := NewService(rt).waitReadiness(context.Background(), "container-1", svc) | |
| 411 | + | ||
| 412 | + | require.Error(t, err) | |
| 413 | + | assert.ErrorIs(t, err, readErr) | |
| 414 | + | assert.ErrorIs(t, err, context.DeadlineExceeded) | |
| 415 | + | } | |
| 416 | + | ||
| 362 | 417 | func TestNormalizeCleanupDefaultsPreserveVolumes(t *testing.T) { | |
| 363 | 418 | cleanup := normalizeCleanup(domain.StandaloneServiceCleanup{}) | |
| 364 | 419 | ||
| @@ -91,6 +91,25 @@ func TestService_ReconcileRecreatesStaleService(t *testing.T) { | |||||
| 91 | require.NoError(t, err) | 91 | require.NoError(t, err) | ||
| 92 | } | 92 | } | ||
| 93 | 93 | ||||
| 94 | + | func TestService_ReconcileRecreateDoesNotRemoveManagedVolumes(t *testing.T) { | |||
| 95 | + | rt := outmocks.NewMockContainerRuntime(t) | |||
| 96 | + | svc := sampleService() | |||
| 97 | + | svc.Cleanup = domain.StandaloneServiceCleanup{PreserveVolumes: false, RemoveContainer: true} | |||
| 98 | + | old := managedContainer("old-1", svc.Name, "old-hash", "running") | |||
| 99 | + | old.Labels[domain.LabelServiceManagedVolumes] = "gordon-service-game-data" | |||
| 100 | + | old.VolumeMounts = []domain.ContainerVolumeMount{{Name: "gordon-service-game-data", Type: "volume", Destination: "/data"}} | |||
| 101 | + | rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{old}, nil).Once() | |||
| 102 | + | rt.On("StopContainer", mock.Anything, "old-1").Return(nil).Once() | |||
| 103 | + | rt.On("RemoveContainer", mock.Anything, "old-1", true).Return(nil).Once() | |||
| 104 | + | rt.On("InspectImageVolumes", mock.Anything, svc.Image).Return([]string{}, nil).Once() | |||
| 105 | + | rt.On("CreateContainer", mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{ID: "new-1"}, nil).Once() | |||
| 106 | + | rt.On("StartContainer", mock.Anything, "new-1").Return(nil).Once() | |||
| 107 | + | ||||
| 108 | + | err := NewService(rt).Reconcile(context.Background(), []domain.StandaloneService{svc}) | |||
| 109 | + | ||||
| 110 | + | require.NoError(t, err) | |||
| 111 | + | } | |||
| 112 | + | ||||
| 94 | func TestService_ReconcileRemovesDuplicateMatchingServiceContainers(t *testing.T) { | 113 | func TestService_ReconcileRemovesDuplicateMatchingServiceContainers(t *testing.T) { | ||
| 95 | rt := outmocks.NewMockContainerRuntime(t) | 114 | rt := outmocks.NewMockContainerRuntime(t) | ||
| 96 | svc := sampleService() | 115 | svc := sampleService() | ||
| @@ -263,6 +282,28 @@ func TestServiceConfigHashIncludesResolvedEnvFileAndSecretValues(t *testing.T) { | |||||
| 263 | assert.NotEqual(t, first, second) | 282 | assert.NotEqual(t, first, second) | ||
| 264 | } | 283 | } | ||
| 265 | 284 | ||||
| 285 | + | func TestService_ReconcileResolvesSecretEnvOnceForHashAndContainer(t *testing.T) { | |||
| 286 | + | rt := outmocks.NewMockContainerRuntime(t) | |||
| 287 | + | provider := outmocks.NewMockSecretProvider(t) | |||
| 288 | + | svc := sampleService() | |||
| 289 | + | svc.Secrets = []domain.StandaloneServiceSecretRef{{Name: "rcon", Key: "RCON_PASSWORD"}} | |||
| 290 | + | var created *domain.ContainerConfig | |||
| 291 | + | provider.On("GetSecret", mock.Anything, "service/game/rcon").Return("secret-value", nil).Once() | |||
| 292 | + | rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{}, nil).Once() | |||
| 293 | + | rt.On("InspectImageVolumes", mock.Anything, svc.Image).Return([]string{}, nil).Once() | |||
| 294 | + | rt.On("CreateContainer", mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Run(func(args mock.Arguments) { | |||
| 295 | + | created = args.Get(1).(*domain.ContainerConfig) | |||
| 296 | + | }).Return(&domain.Container{ID: "created-1"}, nil).Once() | |||
| 297 | + | rt.On("StartContainer", mock.Anything, "created-1").Return(nil).Once() | |||
| 298 | + | ||||
| 299 | + | err := NewServiceWithSecretProvider(rt, provider).Reconcile(context.Background(), []domain.StandaloneService{svc}) | |||
| 300 | + | ||||
| 301 | + | require.NoError(t, err) | |||
| 302 | + | require.NotNil(t, created) | |||
| 303 | + | assert.Contains(t, created.Env, "RCON_PASSWORD=secret-value") | |||
| 304 | + | assert.NotEmpty(t, created.Labels[domain.LabelServiceConfigHash]) | |||
| 305 | + | } | |||
| 306 | + | ||||
| 266 | func TestServiceEnvResolvesServiceScopedSecretsWithoutLeakingValues(t *testing.T) { | 307 | func TestServiceEnvResolvesServiceScopedSecretsWithoutLeakingValues(t *testing.T) { | ||
| 267 | rt := outmocks.NewMockContainerRuntime(t) | 308 | rt := outmocks.NewMockContainerRuntime(t) | ||
| 268 | provider := outmocks.NewMockSecretProvider(t) | 309 | provider := outmocks.NewMockSecretProvider(t) | ||
| @@ -359,6 +400,20 @@ func TestWaitReadinessHonorsContextTimeout(t *testing.T) { | |||||
| 359 | assert.ErrorIs(t, err, context.DeadlineExceeded) | 400 | assert.ErrorIs(t, err, context.DeadlineExceeded) | ||
| 360 | } | 401 | } | ||
| 361 | 402 | ||||
| 403 | + | func TestWaitLogReadinessPreservesLastReadErrorOnTimeout(t *testing.T) { | |||
| 404 | + | rt := outmocks.NewMockContainerRuntime(t) | |||
| 405 | + | svc := sampleService() | |||
| 406 | + | svc.Readiness = domain.StandaloneServiceReadiness{Type: domain.StandaloneServiceReadinessLog, Path: "/logs/server.log", Contains: "ready", Timeout: 25 * time.Millisecond} | |||
| 407 | + | readErr := errors.New("copy failed") | |||
| 408 | + | rt.On("CopyFromContainer", mock.Anything, "container-1", "/logs/server.log").Return(nil, readErr) | |||
| 409 | + | ||||
| 410 | + | err := NewService(rt).waitReadiness(context.Background(), "container-1", svc) | |||
| 411 | + | ||||
| 412 | + | require.Error(t, err) | |||
| 413 | + | assert.ErrorIs(t, err, readErr) | |||
| 414 | + | assert.ErrorIs(t, err, context.DeadlineExceeded) | |||
| 415 | + | } | |||
| 416 | + | ||||
| 362 | func TestNormalizeCleanupDefaultsPreserveVolumes(t *testing.T) { | 417 | func TestNormalizeCleanupDefaultsPreserveVolumes(t *testing.T) { | ||
| 363 | cleanup := normalizeCleanup(domain.StandaloneServiceCleanup{}) | 418 | cleanup := normalizeCleanup(domain.StandaloneServiceCleanup{}) | ||
| 364 | 419 | ||||
M internal/usecase/traffic/builder.go +22 -4
| @@ -3,6 +3,7 @@ package traffic | |||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | 5 | "net" | |
| 6 | + | "net/netip" | |
| 6 | 7 | "sort" | |
| 7 | 8 | "strconv" | |
| 8 | 9 | "strings" | |
| @@ -419,10 +420,14 @@ func sameCIDRSet(a, b []string) bool { | |||
| 419 | 420 | if len(a) != len(b) { | |
| 420 | 421 | return false | |
| 421 | 422 | } | |
| 422 | - | left := append([]string(nil), a...) | |
| 423 | - | right := append([]string(nil), b...) | |
| 424 | - | sort.Strings(left) | |
| 425 | - | sort.Strings(right) | |
| 423 | + | left, ok := canonicalCIDRs(a) | |
| 424 | + | if !ok { | |
| 425 | + | return false | |
| 426 | + | } | |
| 427 | + | right, ok := canonicalCIDRs(b) | |
| 428 | + | if !ok { | |
| 429 | + | return false | |
| 430 | + | } | |
| 426 | 431 | for i := range left { | |
| 427 | 432 | if left[i] != right[i] { | |
| 428 | 433 | return false | |
| @@ -431,6 +436,19 @@ func sameCIDRSet(a, b []string) bool { | |||
| 431 | 436 | return true | |
| 432 | 437 | } | |
| 433 | 438 | ||
| 439 | + | func canonicalCIDRs(values []string) ([]string, bool) { | |
| 440 | + | canonical := make([]string, 0, len(values)) | |
| 441 | + | for _, value := range values { | |
| 442 | + | prefix, err := netip.ParsePrefix(value) | |
| 443 | + | if err != nil { | |
| 444 | + | return nil, false | |
| 445 | + | } | |
| 446 | + | canonical = append(canonical, prefix.Masked().String()) | |
| 447 | + | } | |
| 448 | + | sort.Strings(canonical) | |
| 449 | + | return canonical, true | |
| 450 | + | } | |
| 451 | + | ||
| 434 | 452 | func (b *builder) addService(service domain.TrafficService) { | |
| 435 | 453 | if _, exists := b.services[service.Name]; !exists { | |
| 436 | 454 | b.services[service.Name] = service | |
| @@ -3,6 +3,7 @@ package traffic | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | "net" | 5 | "net" | ||
| 6 | + | "net/netip" | |||
| 6 | "sort" | 7 | "sort" | ||
| 7 | "strconv" | 8 | "strconv" | ||
| 8 | "strings" | 9 | "strings" | ||
| @@ -419,10 +420,14 @@ func sameCIDRSet(a, b []string) bool { | |||||
| 419 | if len(a) != len(b) { | 420 | if len(a) != len(b) { | ||
| 420 | return false | 421 | return false | ||
| 421 | } | 422 | } | ||
| 422 | - | left := append([]string(nil), a...) | 423 | + | left, ok := canonicalCIDRs(a) |
| 423 | - | right := append([]string(nil), b...) | 424 | + | if !ok { |
| 424 | - | sort.Strings(left) | 425 | + | return false |
| 425 | - | sort.Strings(right) | 426 | + | } |
| 427 | + | right, ok := canonicalCIDRs(b) | |||
| 428 | + | if !ok { | |||
| 429 | + | return false | |||
| 430 | + | } | |||
| 426 | for i := range left { | 431 | for i := range left { | ||
| 427 | if left[i] != right[i] { | 432 | if left[i] != right[i] { | ||
| 428 | return false | 433 | return false | ||
| @@ -431,6 +436,19 @@ func sameCIDRSet(a, b []string) bool { | |||||
| 431 | return true | 436 | return true | ||
| 432 | } | 437 | } | ||
| 433 | 438 | ||||
| 439 | + | func canonicalCIDRs(values []string) ([]string, bool) { | |||
| 440 | + | canonical := make([]string, 0, len(values)) | |||
| 441 | + | for _, value := range values { | |||
| 442 | + | prefix, err := netip.ParsePrefix(value) | |||
| 443 | + | if err != nil { | |||
| 444 | + | return nil, false | |||
| 445 | + | } | |||
| 446 | + | canonical = append(canonical, prefix.Masked().String()) | |||
| 447 | + | } | |||
| 448 | + | sort.Strings(canonical) | |||
| 449 | + | return canonical, true | |||
| 450 | + | } | |||
| 451 | + | ||||
| 434 | func (b *builder) addService(service domain.TrafficService) { | 452 | func (b *builder) addService(service domain.TrafficService) { | ||
| 435 | if _, exists := b.services[service.Name]; !exists { | 453 | if _, exists := b.services[service.Name]; !exists { | ||
| 436 | b.services[service.Name] = service | 454 | b.services[service.Name] = service | ||
M internal/usecase/traffic/builder_test.go +7 -0
| @@ -359,6 +359,13 @@ func TestBuildEnforcesPrivateStandaloneServicePortRouting(t *testing.T) { | |||
| 359 | 359 | require.ErrorContains(t, err, "must exactly match") | |
| 360 | 360 | }) | |
| 361 | 361 | ||
| 362 | + | t.Run("private port compares canonical trusted cidrs", func(t *testing.T) { | |
| 363 | + | input := base() | |
| 364 | + | input.EntryPoints = map[string]EntryPointConfig{"tcp": {Address: ":28016", Protocol: domain.EntryPointProtocolTCP, TrustedCIDRs: []string{"100.64.0.1/10"}}} | |
| 365 | + | _, err := Build(input) | |
| 366 | + | require.NoError(t, err) | |
| 367 | + | }) | |
| 368 | + | ||
| 362 | 369 | t.Run("explicit public rcon is allowed without trusted cidrs", func(t *testing.T) { | |
| 363 | 370 | input := base() | |
| 364 | 371 | input.EntryPoints = map[string]EntryPointConfig{"tcp": {Address: ":28016", Protocol: domain.EntryPointProtocolTCP}} | |
| @@ -359,6 +359,13 @@ func TestBuildEnforcesPrivateStandaloneServicePortRouting(t *testing.T) { | |||||
| 359 | require.ErrorContains(t, err, "must exactly match") | 359 | require.ErrorContains(t, err, "must exactly match") | ||
| 360 | }) | 360 | }) | ||
| 361 | 361 | ||||
| 362 | + | t.Run("private port compares canonical trusted cidrs", func(t *testing.T) { | |||
| 363 | + | input := base() | |||
| 364 | + | input.EntryPoints = map[string]EntryPointConfig{"tcp": {Address: ":28016", Protocol: domain.EntryPointProtocolTCP, TrustedCIDRs: []string{"100.64.0.1/10"}}} | |||
| 365 | + | _, err := Build(input) | |||
| 366 | + | require.NoError(t, err) | |||
| 367 | + | }) | |||
| 368 | + | ||||
| 362 | t.Run("explicit public rcon is allowed without trusted cidrs", func(t *testing.T) { | 369 | t.Run("explicit public rcon is allowed without trusted cidrs", func(t *testing.T) { | ||
| 363 | input := base() | 370 | input := base() | ||
| 364 | input.EntryPoints = map[string]EntryPointConfig{"tcp": {Address: ":28016", Protocol: domain.EntryPointProtocolTCP}} | 371 | input.EntryPoints = map[string]EntryPointConfig{"tcp": {Address: ":28016", Protocol: domain.EntryPointProtocolTCP}} | ||