detached 8a0e9d5

fix(traffic): resolve final review regressions

commit
8a0e9d549a6b9468d4f37a54fe711645f22f9fa2
author
brice · · 2026-06-30 17:32 UTC
parent
3489c65
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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
350350 - `insecure_tls = true` on the remote — skips certificate verification entirely
351351 - `sudo gordon ca install` — installs the root CA into system, Firefox, and Java trust stores
352352 - `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
354354
355355 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.
356356
@@ -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 entirely350 - `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 stores351 - `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 installation352 - `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 Android353+- 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
314314 | `volumes.auto_create` | `true` | Auto-create volumes |
315315 | `volumes.prefix` | `"gordon"` | Volume prefix |
316316 | `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 |
318318 | `services[].image` | none | Container image for enabled standalone services |
319319 | `services[].enabled` | `false` | Whether Gordon creates, starts, and reconciles the service container |
320320 | `services[].env` | `[]` | Inline `KEY=value` environment entries |
@@ -322,7 +322,7 @@ keep_last = 3 # Keep N newest tags per repository
322322 | `services[].ports[].name` | none | Port name used by traffic service refs |
323323 | `services[].ports[].container` | none | Container port number |
324324 | `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 |
326326 | `services[].ports[].private` | `false` | Require matching service and entrypoint `trusted_cidrs` for this port |
327327 | `services[].ports[].public` | `false` | Explicit public opt-out for admin port names such as `rcon` |
328328 | `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"
3333 trusted_cidrs = ["100.64.0.0/10"]
3434 ```
3535
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.
3737
3838 ## Traffic routing
3939
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:
4141
4242 ```toml
4343 [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 routing38 ## 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 ```toml42 ```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
446446 }
447447
448448 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)
450450 }
451451
452452 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 (
44 "bytes"
55 "context"
66 "encoding/json"
7+ "errors"
78 "net/http"
89 "net/http/httptest"
910 "testing"
@@ -92,6 +93,7 @@ func TestRunTrafficStatusUsesControlPlane(t *testing.T) {
9293 func TestRemoteTrafficStatusControlPlaneStillRenders(t *testing.T) {
9394 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
9495 require.Equal(t, "/admin/traffic/status", r.URL.Path)
96+ require.Equal(t, http.MethodGet, r.Method)
9597 _, err := w.Write([]byte(`{"last_reload_status":"ok","counters":{"active_udp_sessions":3}}`))
9698 require.NoError(t, err)
9799 }))
@@ -111,6 +113,7 @@ func TestRemoteTrafficStatusControlPlaneStillRenders(t *testing.T) {
111113 func TestLocalTrafficStatusReturnsActionableDaemonGuidance(t *testing.T) {
112114 _, err := (&localControlPlane{}).GetTrafficStatus(context.Background())
113115 require.Error(t, err)
116+ assert.True(t, errors.Is(err, domain.ErrTrafficStatusUnavailable))
114117 assert.Contains(t, err.Error(), "in-process CLI control plane")
115118 assert.Contains(t, err.Error(), "running Gordon daemon")
116119 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
257257 }
258258 select {
259259 case <-done:
260- case <-ctx.Done():
260+ case <-shutdownCtx.Done():
261261 }
262262 }
@@ -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 {
2828 }
2929
3030 const (
31- maxSmartTCPSniffBytes = 128
31+ maxSmartTCPSniffBytes = 4096
3232 h2cPreface = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"
3333 proxyV2Signature = "\r\n\r\n\x00\r\nQUIT\n"
3434 )
@@ -28,7 +28,7 @@ type smartTCPSniffResult struct {
28 }28 }
29 29
30 const (30 const (
31- maxSmartTCPSniffBytes = 12831+ 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 (
44 "errors"
55 "io"
66 "net"
7+ "strings"
78 "testing"
89 "time"
910
@@ -108,18 +109,18 @@ func TestSniffSmartTCPUnknownIsImmediate(t *testing.T) {
108109 defer client.Close()
109110
110111 start := time.Now()
111- done := make(chan smartTCPSniffResult, 1)
112+ done := make(chan sniffOutcome, 1)
112113 go func() {
113114 result, err := sniffSmartTCP(server, time.Second)
114- require.NoError(t, err)
115- done <- result
115+ done <- sniffOutcome{result: result, err: err}
116116 }()
117117
118118 _, err := client.Write([]byte{0})
119119 require.NoError(t, err)
120120 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)
123124 assert.Less(t, time.Since(start), 500*time.Millisecond)
124125 case <-time.After(500 * time.Millisecond):
125126 t.Fatal("sniff did not classify unknown bytes immediately")
@@ -152,15 +153,16 @@ func TestSniffSmartTCPReplayReturnsSniffedBytesBeforeUnderlyingBytes(t *testing.
152153 client, server := net.Pipe()
153154 defer client.Close()
154155
155- done := make(chan smartTCPSniffResult, 1)
156+ done := make(chan sniffOutcome, 1)
156157 go func() {
157158 result, err := sniffSmartTCP(server, time.Second)
158- require.NoError(t, err)
159- done <- result
159+ done <- sniffOutcome{result: result, err: err}
160160 }()
161161 _, err := client.Write([]byte("GET / HTTP/1.1\r\n"))
162162 require.NoError(t, err)
163- result := <-done
163+ outcome := <-done
164+ require.NoError(t, outcome.err)
165+ result := outcome.result
164166 writeDone := make(chan error, 1)
165167 go func() {
166168 _, err := client.Write([]byte("Host: example.com\r\n\r\n"))
@@ -174,6 +176,23 @@ func TestSniffSmartTCPReplayReturnsSniffedBytesBeforeUnderlyingBytes(t *testing.
174176 require.NoError(t, <-writeDone)
175177 }
176178
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+
177196 func TestSniffSmartTCPClearsReadDeadlineBeforeHandoff(t *testing.T) {
178197 conn := &deadlineConn{Conn: pipeConnWithData(t, []byte("GET / HTTP/1.1\r\n"))}
179198 result, err := sniffSmartTCP(conn, time.Second)
@@ -215,6 +234,11 @@ func assertReplayPrefix(t *testing.T, conn net.Conn, want []byte) {
215234 assert.Equal(t, want, buf)
216235 }
217236
237+type sniffOutcome struct {
238+ result smartTCPSniffResult
239+ err error
240+}
241+
218242 type deadlineConn struct {
219243 net.Conn
220244 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 := <-done163+ 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.Conn243 net.Conn
220 sawNonZeroReadDeadline bool244 sawNonZeroReadDeadline bool
M internal/adapters/in/traffic/tcp.go +3 -0
@@ -919,6 +919,9 @@ func backendFromAddress(name string, address string) (domain.TrafficBackend, err
919919 if err != nil {
920920 return domain.TrafficBackend{}, fmt.Errorf("parse backend port %q from address %q: %w", portValue, address, err)
921921 }
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+ }
922925 return domain.TrafficBackend{Name: name, Host: host, Port: port, Protocol: domain.NetworkProtocolTCP}, nil
923926 }
924927
@@ -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}, nil925 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) {
2525 assert.Equal(t, 4096, effectiveUDPOptions(domain.UDPOptions{}).MaxSessions)
2626 }
2727
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+
2834 func TestSmartTCPDispatch(t *testing.T) {
2935 t.Run("cleartext HTTP request reaches HTTP handler", func(t *testing.T) {
3036 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 {
301301 }
302302
303303 func (l *tlsHTTPListener) Accept() (net.Conn, error) {
304+ select {
305+ case <-l.done:
306+ return nil, net.ErrClosed
307+ default:
308+ }
304309 select {
305310 case conn := <-l.conns:
311+ if l.isClosed() {
312+ _ = conn.Close()
313+ return nil, net.ErrClosed
314+ }
306315 return conn, nil
307316 case <-l.done:
308317 return nil, net.ErrClosed
@@ -315,6 +324,14 @@ func (l *tlsHTTPListener) Close() error {
315324 l.closed = true
316325 l.mu.Unlock()
317326 close(l.done)
327+ for {
328+ select {
329+ case conn := <-l.conns:
330+ _ = conn.Close()
331+ default:
332+ return
333+ }
334+ }
318335 })
319336 return nil
320337 }
@@ -334,3 +351,9 @@ func (l *tlsHTTPListener) serve(conn net.Conn) bool {
334351 return false
335352 }
336353 }
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, nil315 return conn, nil
307 case <-l.done:316 case <-l.done:
308 return nil, net.ErrClosed317 return nil, net.ErrClosed
@@ -315,6 +324,14 @@ func (l *tlsHTTPListener) Close() error {
315 l.closed = true324 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 nil336 return nil
320 }337 }
@@ -334,3 +351,9 @@ func (l *tlsHTTPListener) serve(conn net.Conn) bool {
334 return false351 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 (
1111 "crypto/x509/pkix"
1212 "encoding/pem"
1313 "errors"
14+ "fmt"
1415 "io"
1516 "math/big"
1617 "net"
@@ -213,6 +214,20 @@ func TestTLSHTTPListenerServeRejectsAfterClose(t *testing.T) {
213214 }
214215 }
215216
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+
216231 func TestTLSMuxHTTPSRoute(t *testing.T) {
217232 backend := startTLSGreetingServer(t, "raw-backend", "raw\n")
218233 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 {
378393 func testCertificate(commonName string) (tls.Certificate, error) {
379394 key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
380395 if err != nil {
381- return tls.Certificate{}, err
396+ return tls.Certificate{}, fmt.Errorf("generate test certificate key: %w", err)
382397 }
383398 serial, err := rand.Int(rand.Reader, big.NewInt(1<<62))
384399 if err != nil {
385- return tls.Certificate{}, err
400+ return tls.Certificate{}, fmt.Errorf("generate test certificate serial: %w", err)
386401 }
387402 template := x509.Certificate{
388403 SerialNumber: serial,
@@ -395,15 +410,19 @@ func testCertificate(commonName string) (tls.Certificate, error) {
395410 }
396411 der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
397412 if err != nil {
398- return tls.Certificate{}, err
413+ return tls.Certificate{}, fmt.Errorf("create test certificate: %w", err)
399414 }
400415 certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
401416 keyBytes, err := x509.MarshalECPrivateKey(key)
402417 if err != nil {
403- return tls.Certificate{}, err
418+ return tls.Certificate{}, fmt.Errorf("marshal test certificate key: %w", err)
404419 }
405420 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
407426 }
408427
409428 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{}, err396+ 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{}, err400+ 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{}, err413+ 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{}, err418+ 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() {
9797 r.counters.totalRefused.Add(1)
9898 continue
9999 }
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+ }
100109 packet := append([]byte(nil), buf[:n]...)
101110 select {
102111 case r.datagrams <- udpDatagram{clientAddr: clientAddr, packet: packet}:
@@ -114,6 +123,11 @@ func (r *udpEntryPointRuntime) datagramWorker() {
114123 case <-r.ctx.Done():
115124 return
116125 case datagram := <-r.datagrams:
126+ select {
127+ case <-r.ctx.Done():
128+ return
129+ default:
130+ }
117131 r.handleDatagram(datagram.clientAddr, datagram.packet)
118132 }
119133 }
@@ -97,6 +97,15 @@ func (r *udpEntryPointRuntime) readLoop() {
97 r.counters.totalRefused.Add(1)97 r.counters.totalRefused.Add(1)
98 continue98 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 return124 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) {
3232 cfg.TLS.ACME.Challenge = string(domain.ACMEChallengeCloudflareDNS01)
3333
3434 require.NoError(t, validatePublicTLSReadiness(cfg))
35- assert.Equal(t, 443, effectivePublicTLSPort(cfg))
35+ assert.Equal(t, 0, effectivePublicTLSPort(cfg))
3636 }
3737
3838 func TestPublicTLSReadinessRejectsAutoResolvedHTTP01WithoutBoundExternalPort80(t *testing.T) {
@@ -63,7 +63,7 @@ func TestEffectivePublicTLSPortPrefersTLSCapableEntrypointOverLegacyTLSPort(t *t
6363 assert.Equal(t, 443, effectivePublicTLSPort(cfg))
6464 }
6565
66-func TestEffectivePublicTLSPortDeterministicWithMultipleTLSCapableEntrypoints(t *testing.T) {
66+func TestEffectivePublicTLSPortPrefersDefaultEdgeWithMultipleTLSCapableEntrypoints(t *testing.T) {
6767 cfg := Config{}
6868 cfg.EntryPoints = map[string]traffic.EntryPointConfig{
6969 traffic.DefaultEdgeEntryPointName: {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP},
@@ -85,7 +85,16 @@ func TestEffectivePublicTLSPortUsesSoleNonEdgeTLSCapableEntrypoint(t *testing.T)
8585 assert.Equal(t, 8443, effectivePublicTLSPort(cfg))
8686 }
8787
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) {
8998 cfg := Config{}
9099 cfg.EntryPoints = map[string]traffic.EntryPointConfig{
91100 "public-a": {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP},
@@ -93,7 +102,7 @@ func TestEffectivePublicTLSPortFallsBackWhenAmbiguousWithoutEdge(t *testing.T) {
93102 }
94103
95104 for range 20 {
96- assert.Equal(t, 443, effectivePublicTLSPort(cfg))
105+ assert.Equal(t, 0, effectivePublicTLSPort(cfg))
97106 }
98107 }
99108
@@ -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() {
787787 return tlsErr
788788 }
789789 }
790+ if err := applyTrafficRuntimeConfig(reloadCtx, si.svc.trafficManager, reloadCfg, si.svc.configSvc); err != nil {
791+ return err
792+ }
790793 si.svc.tlsHTTPEntryPoints = registerTLSMuxHTTPServers(si.svc.trafficManager, reloadCfg, si.svc.httpsProxyHandler, tlsConfig, si.svc.tlsHTTPEntryPoints)
791794 si.svc.smartHTTPEntryPoints = registerSmartTCPHTTPServers(si.svc.trafficManager, reloadCfg, si.svc.httpProxyHandler, si.svc.httpsProxyHandler, tlsConfig, si.svc.smartHTTPEntryPoints)
792795 if err := reconcileStandaloneServices(reloadCtx, si.svc.standaloneServiceSvc, reloadCfg); err != nil {
793796 return err
794797 }
795- if err := applyTrafficRuntimeConfig(reloadCtx, si.svc.trafficManager, reloadCfg, si.svc.configSvc); err != nil {
796- return err
797- }
798798 si.svc.containerSvc.UpdateConfig(containerCfg)
799799 return nil
800800 })
@@ -2931,10 +2931,10 @@ func waitForCoreProxyReadyAndApplyTraffic(ctx context.Context, cfg Config, svc *
29312931 if err := waitForServerReady(proxyReady, errChan); err != nil {
29322932 return err
29332933 }
2934- if err := reconcileStandaloneServices(ctx, svc.standaloneServiceSvc, cfg); err != nil {
2934+ if err := applyTrafficRuntimeConfig(ctx, svc.trafficManager, cfg, svc.configSvc); err != nil {
29352935 return err
29362936 }
2937- return applyTrafficRuntimeConfig(ctx, svc.trafficManager, cfg, svc.configSvc)
2937+ return reconcileStandaloneServices(ctx, svc.standaloneServiceSvc, cfg)
29382938 }
29392939
29402940 func reconcileStandaloneServices(ctx context.Context, serviceSvc in.StandaloneServiceService, cfg Config) error {
@@ -3400,40 +3400,21 @@ func hasTLSCapableEntrypoint(cfg Config) bool {
34003400 }
34013401
34023402 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)
34263404 }
34273405
34283406 func effectiveProxyTLSPort(cfg Config) int {
3429- if hasTLSCapableEntrypoint(cfg) {
3430- return effectivePublicTLSPort(cfg)
3431- }
3432- return cfg.Server.TLSPort
3407+ return effectivePublicTLSPort(cfg)
34333408 }
34343409
34353410 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) {
34373418 if port := portFromAddress(entryPoint.Address); port > 0 {
34383419 return port
34393420 }
@@ -3442,7 +3423,7 @@ func effectiveProxyHTTPPort(cfg Config) int {
34423423 var candidatePort int
34433424 candidates := 0
34443425 for name, entryPoint := range cfg.EntryPoints {
3445- if name == traffic.DefaultEdgeEntryPointName || entryPoint.Protocol != domain.EntryPointProtocolSmartTCP {
3426+ if name == traffic.DefaultEdgeEntryPointName || !match(entryPoint.Protocol) {
34463427 continue
34473428 }
34483429 port := portFromAddress(entryPoint.Address)
@@ -3455,7 +3436,7 @@ func effectiveProxyHTTPPort(cfg Config) int {
34553436 if candidates == 1 {
34563437 return candidatePort
34573438 }
3458- return cfg.Server.Port
3439+ return 0
34593440 }
34603441
34613442 func tlsCapableEntryPoint(protocol domain.EntryPointProtocol) bool {
@@ -787,14 +787,14 @@ func (si *serviceInit) registerReloadCoordinatorHooks() {
787 return tlsErr787 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 err796 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 nil799 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 err2932 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 err2935 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 port3419 return port
3439 }3420 }
@@ -3442,7 +3423,7 @@ func effectiveProxyHTTPPort(cfg Config) int {
3442 var candidatePort int3423 var candidatePort int
3443 candidates := 03424 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 continue3427 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 candidatePort3437 return candidatePort
3457 }3438 }
3458- return cfg.Server.Port3439+ 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 {
128128 cfg.Server.Port = 8088
129129 cfg.Server.TLSPort = 8443
130130 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+ }
131134 return cfg
132135 }
133136
@@ -449,7 +452,7 @@ func TestCreateHTTPHandlers_TLSDisabled_DoesNotServeHTTPOnboarding(t *testing.T)
449452 t.Parallel()
450453 ca := newTestCA(t)
451454 cfg := newOnboardingConfig()
452- cfg.Server.TLSPort = 0 // TLS disabled
455+ cfg.EntryPoints = nil // TLS disabled: no TLS-capable traffic entrypoint
453456 svc := &services{caAdapter: ca}
454457
455458 _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
@@ -128,6 +128,9 @@ func newOnboardingConfig() Config {
128 cfg.Server.Port = 8088128 cfg.Server.Port = 8088
129 cfg.Server.TLSPort = 8443129 cfg.Server.TLSPort = 8443
130 cfg.Server.ProxyAllowedIPs = []string{"173.245.48.0/20"} // Cloudflare sample130 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 cfg134 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 disabled455+ 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
382382 assert.True(t, sawSecretEnv)
383383 }
384384
385-func TestServiceInit_RegisterReloadCoordinatorHooks_ReconcilesStandaloneServicesBeforeTrafficApply(t *testing.T) {
385+func TestServiceInit_RegisterReloadCoordinatorHooks_AppliesTrafficBeforeStandaloneServices(t *testing.T) {
386386 ctx := context.Background()
387387 v := viper.New()
388388 v.Set("server.gordon_domain", "reload.example.com")
@@ -394,7 +394,7 @@ func TestServiceInit_RegisterReloadCoordinatorHooks_ReconcilesStandaloneServices
394394 defer func() { require.NoError(t, manager.Shutdown(ctx)) }()
395395
396396 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")
398398 }}
399399 si := &serviceInit{
400400 ctx: ctx,
@@ -428,7 +428,7 @@ func TestServiceInit_RegisterReloadCoordinatorHooks_ReconcilesStandaloneServices
428428 assert.NotEmpty(t, manager.Status().EntryPoints)
429429 }
430430
431-func TestWaitForCoreProxyReadyAndApplyTraffic_ReconcilesStandaloneServicesBeforeTrafficApply(t *testing.T) {
431+func TestWaitForCoreProxyReadyAndApplyTraffic_AppliesTrafficBeforeStandaloneServices(t *testing.T) {
432432 ctx := context.Background()
433433 v := viper.New()
434434 configSvc := cfgusecase.NewService(v, nil)
@@ -437,7 +437,7 @@ func TestWaitForCoreProxyReadyAndApplyTraffic_ReconcilesStandaloneServicesBefore
437437 defer func() { require.NoError(t, manager.Shutdown(ctx)) }()
438438
439439 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")
441441 }}
442442 cfg := Config{}
443443 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 (
106106 ErrRouteSourceRequired = errors.New("route source required")
107107 ErrHTTPChallengeSinkRequired = errors.New("http challenge sink required")
108108 ErrTLSRouteNotCovered = errors.New("tls route not covered by public certificate")
109+
110+ // Traffic errors
111+ ErrTrafficStatusUnavailable = errors.New("traffic status unavailable")
109112 )
@@ -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
33 import (
44 "fmt"
55 "net"
6- "path/filepath"
6+ "path"
77 "strconv"
88 "strings"
99 "time"
@@ -210,7 +210,7 @@ func validateStandaloneServiceVolumes(s StandaloneService) error {
210210 if target == "" {
211211 return fmt.Errorf("standalone service %q volume %d target is required", s.Name, i)
212212 }
213- if !filepath.IsAbs(target) {
213+ if !path.IsAbs(target) {
214214 return fmt.Errorf("standalone service %q volume %d target %q must be an absolute container path", s.Name, i, volume.Target)
215215 }
216216 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) {
7373 svc.Volumes = []StandaloneServiceVolume{{Source: "cache-data", Target: "var/lib/redis"}}
7474 require.ErrorContains(t, svc.Validate(), "absolute")
7575
76+ svc.Volumes = []StandaloneServiceVolume{{Source: "cache-data", Target: `C:\\data`}}
77+ require.ErrorContains(t, svc.Validate(), "absolute")
78+
7679 svc.Volumes = nil
7780 svc.EnvFile = " "
7881 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 = nil79 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
9494 if !svc.Enabled {
9595 return s.stopDisabled(ctx, svc.Name, cleanup, existing)
9696 }
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)
98102 if err != nil {
99103 return fmt.Errorf("hash standalone service %q config: %w", svc.Name, err)
100104 }
101105 if len(existing) == 0 {
102- return s.createAndStart(ctx, svc, hash)
106+ return s.createAndStart(ctx, svc, hash, env)
103107 }
104108 sort.SliceStable(existing, func(i, j int) bool {
105109 leftRunning := containerStatus(existing[i]) == domain.ContainerStatusRunning
@@ -111,7 +115,7 @@ func (s *Service) reconcileOne(ctx context.Context, svc domain.StandaloneService
111115 })
112116 current := existing[0]
113117 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 {
115119 return err
116120 }
117121 return nil
@@ -132,7 +136,7 @@ func (s *Service) reconcileOne(ctx context.Context, svc domain.StandaloneService
132136 return nil
133137 }
134138
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 {
136140 for _, container := range existing {
137141 if containerStatus(container) == domain.ContainerStatusRunning {
138142 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
144148 return fmt.Errorf("remove stale standalone service %q container: %w", svc.Name, err)
145149 }
146150 }
147- if err := s.removeManagedVolumes(ctx, svc.Name, cleanup, container); err != nil {
148- return err
149- }
150151 }
151- return s.createAndStart(ctx, svc, hash)
152+ return s.createAndStart(ctx, svc, hash, env)
152153 }
153154
154155 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
234235 return cleanup
235236 }
236237
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)
239240 if err != nil {
240241 return err
241242 }
@@ -252,7 +253,7 @@ func (s *Service) createAndStart(ctx context.Context, svc domain.StandaloneServi
252253 return nil
253254 }
254255
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) {
256257 var imageVolumes []string
257258 if len(svc.Volumes) == 0 {
258259 var err error
@@ -279,10 +280,6 @@ func (s *Service) containerConfig(ctx context.Context, svc domain.StandaloneServ
279280 if err != nil {
280281 return nil, err
281282 }
282- env, err := s.serviceEnv(ctx, svc)
283- if err != nil {
284- return nil, err
285- }
286283 return &domain.ContainerConfig{
287284 Image: svc.Image,
288285 Name: serviceContainerName(svc.Name),
@@ -354,6 +351,10 @@ func (s *Service) serviceConfigHash(ctx context.Context, svc domain.StandaloneSe
354351 if err != nil {
355352 return "", err
356353 }
354+ return serviceConfigHashWithEnv(svc, resolvedEnv)
355+}
356+
357+func serviceConfigHashWithEnv(svc domain.StandaloneService, resolvedEnv []string) (string, error) {
357358 payload := struct {
358359 Image string
359360 ResolvedEnv []string
@@ -361,7 +362,7 @@ func (s *Service) serviceConfigHash(ctx context.Context, svc domain.StandaloneSe
361362 Ports []domain.StandaloneServicePort
362363 Volumes []domain.StandaloneServiceVolume
363364 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)}
365366 bytes, err := json.Marshal(payload)
366367 if err != nil {
367368 return "", err
@@ -522,23 +523,34 @@ func tcpReadinessAddress(svc domain.StandaloneService) (string, error) {
522523 func (s *Service) waitLogReadiness(ctx context.Context, containerID string, svc domain.StandaloneService) error {
523524 ticker := time.NewTicker(10 * time.Millisecond)
524525 defer ticker.Stop()
526+ var lastErr error
525527 for {
526528 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)
528530 }
529531 found, err := s.logContains(ctx, containerID, svc.Readiness.Path, svc.Readiness.Contains)
530532 if err == nil && found {
531533 return nil
532534 }
535+ if err != nil {
536+ lastErr = err
537+ }
533538 // Continue polling until timeout; many services create the log after start.
534539 select {
535540 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)
537542 case <-ticker.C:
538543 }
539544 }
540545 }
541546
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+
542554 func (s *Service) logContains(ctx context.Context, containerID, path, contains string) (bool, error) {
543555 reader, err := s.runtime.CopyFromContainer(ctx, containerID, path)
544556 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.ContainerStatusRunning109 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 err119 return err
116 }120 }
117 return nil121 return nil
@@ -132,7 +136,7 @@ func (s *Service) reconcileOne(ctx context.Context, svc domain.StandaloneService
132 return nil136 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 cleanup235 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 err241 return err
241 }242 }
@@ -252,7 +253,7 @@ func (s *Service) createAndStart(ctx context.Context, svc domain.StandaloneServi
252 return nil253 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 []string257 var imageVolumes []string
257 if len(svc.Volumes) == 0 {258 if len(svc.Volumes) == 0 {
258 var err error259 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, err281 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 "", err352 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 string359 Image string
359 ResolvedEnv []string360 ResolvedEnv []string
@@ -361,7 +362,7 @@ func (s *Service) serviceConfigHash(ctx context.Context, svc domain.StandaloneSe
361 Ports []domain.StandaloneServicePort362 Ports []domain.StandaloneServicePort
362 Volumes []domain.StandaloneServiceVolume363 Volumes []domain.StandaloneServiceVolume
363 Cleanup domain.StandaloneServiceCleanup364 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 "", err368 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 nil533 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) {
9191 require.NoError(t, err)
9292 }
9393
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+
94113 func TestService_ReconcileRemovesDuplicateMatchingServiceContainers(t *testing.T) {
95114 rt := outmocks.NewMockContainerRuntime(t)
96115 svc := sampleService()
@@ -263,6 +282,28 @@ func TestServiceConfigHashIncludesResolvedEnvFileAndSecretValues(t *testing.T) {
263282 assert.NotEqual(t, first, second)
264283 }
265284
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+
266307 func TestServiceEnvResolvesServiceScopedSecretsWithoutLeakingValues(t *testing.T) {
267308 rt := outmocks.NewMockContainerRuntime(t)
268309 provider := outmocks.NewMockSecretProvider(t)
@@ -359,6 +400,20 @@ func TestWaitReadinessHonorsContextTimeout(t *testing.T) {
359400 assert.ErrorIs(t, err, context.DeadlineExceeded)
360401 }
361402
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+
362417 func TestNormalizeCleanupDefaultsPreserveVolumes(t *testing.T) {
363418 cleanup := normalizeCleanup(domain.StandaloneServiceCleanup{})
364419
@@ -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
33 import (
44 "fmt"
55 "net"
6+ "net/netip"
67 "sort"
78 "strconv"
89 "strings"
@@ -419,10 +420,14 @@ func sameCIDRSet(a, b []string) bool {
419420 if len(a) != len(b) {
420421 return false
421422 }
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+ }
426431 for i := range left {
427432 if left[i] != right[i] {
428433 return false
@@ -431,6 +436,19 @@ func sameCIDRSet(a, b []string) bool {
431436 return true
432437 }
433438
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+
434452 func (b *builder) addService(service domain.TrafficService) {
435453 if _, exists := b.services[service.Name]; !exists {
436454 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 false421 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 false433 return false
@@ -431,6 +436,19 @@ func sameCIDRSet(a, b []string) bool {
431 return true436 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] = service454 b.services[service.Name] = service
M internal/usecase/traffic/builder_test.go +7 -0
@@ -359,6 +359,13 @@ func TestBuildEnforcesPrivateStandaloneServicePortRouting(t *testing.T) {
359359 require.ErrorContains(t, err, "must exactly match")
360360 })
361361
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+
362369 t.Run("explicit public rcon is allowed without trusted cidrs", func(t *testing.T) {
363370 input := base()
364371 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}}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close