detached 3011e4c

fix(traffic): address final review findings

commit
3011e4cf693ac543afb0bf9bb36c39058eec9581
author
brice · · 2026-06-30 16:37 UTC
parent
ed415c1
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
28 files · +622 -61
M .mockery.yaml +1 -0
@@ -46,6 +46,7 @@ packages:
4646 VolumeService:
4747 PublicTLSService:
4848 TrafficStatusService:
49+ StandaloneServiceService:
4950 # Exception: pushImageOps is a CLI-local interface, not a boundary port.
5051 # Mocked here because it abstracts Docker SDK calls that require a running
5152 # daemon, making unit/integration tests impractical without a test double.
@@ -46,6 +46,7 @@ packages:
46 VolumeService:46 VolumeService:
47 PublicTLSService:47 PublicTLSService:
48 TrafficStatusService:48 TrafficStatusService:
49+ StandaloneServiceService:
49 # Exception: pushImageOps is a CLI-local interface, not a boundary port.50 # Exception: pushImageOps is a CLI-local interface, not a boundary port.
50 # Mocked here because it abstracts Docker SDK calls that require a running51 # Mocked here because it abstracts Docker SDK calls that require a running
51 # daemon, making unit/integration tests impractical without a test double.52 # daemon, making unit/integration tests impractical without a test double.
M internal/adapters/in/cli/traffic_status.go +1 -0
@@ -18,6 +18,7 @@ func newTrafficStatusCmd() *cobra.Command {
1818 cmd := &cobra.Command{
1919 Use: "status",
2020 Short: "Show traffic entrypoint, router, and counter status",
21+ Args: cobra.NoArgs,
2122 RunE: func(cmd *cobra.Command, args []string) error {
2223 handle, err := trafficResolveControlPlane(configPath)
2324 if err != nil {
@@ -18,6 +18,7 @@ func newTrafficStatusCmd() *cobra.Command {
18 cmd := &cobra.Command{18 cmd := &cobra.Command{
19 Use: "status",19 Use: "status",
20 Short: "Show traffic entrypoint, router, and counter status",20 Short: "Show traffic entrypoint, router, and counter status",
21+ Args: cobra.NoArgs,
21 RunE: func(cmd *cobra.Command, args []string) error {22 RunE: func(cmd *cobra.Command, args []string) error {
22 handle, err := trafficResolveControlPlane(configPath)23 handle, err := trafficResolveControlPlane(configPath)
23 if err != nil {24 if err != nil {
M internal/adapters/in/cli/traffic_test.go +5 -0
@@ -29,6 +29,11 @@ func TestTrafficCommandExists(t *testing.T) {
2929 assert.Equal(t, "status", status.Name())
3030 }
3131
32+func TestTrafficStatusRejectsArgs(t *testing.T) {
33+ cmd := newTrafficStatusCmd()
34+ require.Error(t, cmd.Args(cmd, []string{"extra"}))
35+}
36+
3237 func TestTrafficStatusHumanOutput(t *testing.T) {
3338 status := &dto.TrafficStatusResponse{
3439 LastReloadStatus: "ok",
@@ -29,6 +29,11 @@ func TestTrafficCommandExists(t *testing.T) {
29 assert.Equal(t, "status", status.Name())29 assert.Equal(t, "status", status.Name())
30 }30 }
31 31
32+func TestTrafficStatusRejectsArgs(t *testing.T) {
33+ cmd := newTrafficStatusCmd()
34+ require.Error(t, cmd.Args(cmd, []string{"extra"}))
35+}
36+
32 func TestTrafficStatusHumanOutput(t *testing.T) {37 func TestTrafficStatusHumanOutput(t *testing.T) {
33 status := &dto.TrafficStatusResponse{38 status := &dto.TrafficStatusResponse{
34 LastReloadStatus: "ok",39 LastReloadStatus: "ok",
M internal/adapters/in/traffic/manager.go +11 -3
@@ -139,7 +139,9 @@ func (m *Manager) Apply(ctx context.Context, graph *domain.TrafficGraph) error {
139139 stoppedUDP := 0
140140 for _, runtime := range oldUDPListeners {
141141 if udpRuntimeRetained(newUDPListeners, runtime) {
142- if _, ok := runtime.resolveUDPBackend(); !ok {
142+ if backend, ok := runtime.resolveUDPBackend(); ok {
143+ runtime.drainSessionsNotMatchingAfter(backend, udpDrainTimeout)
144+ } else {
143145 runtime.drainSessionsAfter(udpDrainTimeout)
144146 }
145147 continue
@@ -507,8 +509,14 @@ func entryPointStatuses(entries []domain.EntryPoint, listeners map[string]*entry
507509 func routerStatuses(routers []domain.TrafficRouter, listeners map[string]*entryPointRuntime, udpListeners map[string]*udpEntryPointRuntime) []domain.TrafficRouterStatus {
508510 statuses := make([]domain.TrafficRouterStatus, 0, len(routers))
509511 for _, router := range routers {
510- _, tcpActive := listeners[router.EntryPoint]
511- _, udpActive := udpListeners[router.EntryPoint]
512+ tcpActive := false
513+ if runtime := listeners[router.EntryPoint]; runtime != nil {
514+ tcpActive = !runtime.isClosed()
515+ }
516+ udpActive := false
517+ if runtime := udpListeners[router.EntryPoint]; runtime != nil {
518+ udpActive = !runtime.isClosed()
519+ }
512520 statuses = append(statuses, domain.TrafficRouterStatus{
513521 Name: router.Name, EntryPoint: router.EntryPoint, Protocol: router.Protocol,
514522 Rule: router.Rule, Service: router.Service, Active: tcpActive || udpActive,
@@ -139,7 +139,9 @@ func (m *Manager) Apply(ctx context.Context, graph *domain.TrafficGraph) error {
139 stoppedUDP := 0139 stoppedUDP := 0
140 for _, runtime := range oldUDPListeners {140 for _, runtime := range oldUDPListeners {
141 if udpRuntimeRetained(newUDPListeners, runtime) {141 if udpRuntimeRetained(newUDPListeners, runtime) {
142- if _, ok := runtime.resolveUDPBackend(); !ok {142+ if backend, ok := runtime.resolveUDPBackend(); ok {
143+ runtime.drainSessionsNotMatchingAfter(backend, udpDrainTimeout)
144+ } else {
143 runtime.drainSessionsAfter(udpDrainTimeout)145 runtime.drainSessionsAfter(udpDrainTimeout)
144 }146 }
145 continue147 continue
@@ -507,8 +509,14 @@ func entryPointStatuses(entries []domain.EntryPoint, listeners map[string]*entry
507 func routerStatuses(routers []domain.TrafficRouter, listeners map[string]*entryPointRuntime, udpListeners map[string]*udpEntryPointRuntime) []domain.TrafficRouterStatus {509 func routerStatuses(routers []domain.TrafficRouter, listeners map[string]*entryPointRuntime, udpListeners map[string]*udpEntryPointRuntime) []domain.TrafficRouterStatus {
508 statuses := make([]domain.TrafficRouterStatus, 0, len(routers))510 statuses := make([]domain.TrafficRouterStatus, 0, len(routers))
509 for _, router := range routers {511 for _, router := range routers {
510- _, tcpActive := listeners[router.EntryPoint]512+ tcpActive := false
511- _, udpActive := udpListeners[router.EntryPoint]513+ if runtime := listeners[router.EntryPoint]; runtime != nil {
514+ tcpActive = !runtime.isClosed()
515+ }
516+ udpActive := false
517+ if runtime := udpListeners[router.EntryPoint]; runtime != nil {
518+ udpActive = !runtime.isClosed()
519+ }
512 statuses = append(statuses, domain.TrafficRouterStatus{520 statuses = append(statuses, domain.TrafficRouterStatus{
513 Name: router.Name, EntryPoint: router.EntryPoint, Protocol: router.Protocol,521 Name: router.Name, EntryPoint: router.EntryPoint, Protocol: router.Protocol,
514 Rule: router.Rule, Service: router.Service, Active: tcpActive || udpActive,522 Rule: router.Rule, Service: router.Service, Active: tcpActive || udpActive,
M internal/adapters/in/traffic/smart_tcp.go +17 -3
@@ -33,7 +33,7 @@ func (m *Manager) SetSmartTCPHTTPServer(entryPoint string, handler http.Handler,
3333 if handler == nil {
3434 delete(next, entryPoint)
3535 } else {
36- next[entryPoint] = SmartTCPHTTPServerConfig{Handler: handler, Protocols: protocols}
36+ next[entryPoint] = SmartTCPHTTPServerConfig{Handler: handler, Protocols: cloneHTTPProtocols(protocols)}
3737 }
3838 m.smartHTTPServers.Store(next)
3939 runtimes := m.smartTCPRuntimesLocked(entryPoint)
@@ -43,6 +43,14 @@ func (m *Manager) SetSmartTCPHTTPServer(entryPoint string, handler http.Handler,
4343 }
4444 }
4545
46+func cloneHTTPProtocols(protocols *http.Protocols) *http.Protocols {
47+ if protocols == nil {
48+ return nil
49+ }
50+ clone := *protocols
51+ return &clone
52+}
53+
4654 func (m *Manager) SetSmartTCPTLSServer(entryPoint string, handler http.Handler, tlsConfig *tls.Config) {
4755 m.mu.Lock()
4856 current := m.loadSmartTLSServers()
@@ -139,7 +147,6 @@ func (r *entryPointRuntime) refreshSmartTCPTLSServer(entryPointName string) {
139147 func (r *entryPointRuntime) replaceSmartTCPHTTPServer(entryPoint domain.EntryPoint, config SmartTCPHTTPServerConfig) {
140148 r.smartHTTPReplaceMu.Lock()
141149 defer r.smartHTTPReplaceMu.Unlock()
142- r.stopSmartTCPHTTPServerLocked(r.ctx, 0)
143150 if r.isClosed() {
144151 return
145152 }
@@ -147,6 +154,9 @@ func (r *entryPointRuntime) replaceSmartTCPHTTPServer(entryPoint domain.EntryPoi
147154 server := &http.Server{Handler: config.Handler, Protocols: config.Protocols, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 30 * time.Second}
148155 done := make(chan struct{})
149156 r.mu.Lock()
157+ oldListener := r.smartHTTPListener
158+ oldServer := r.smartHTTPServer
159+ oldDone := r.smartHTTPDone
150160 if r.isClosed() {
151161 r.mu.Unlock()
152162 _ = listener.Close()
@@ -162,12 +172,12 @@ func (r *entryPointRuntime) replaceSmartTCPHTTPServer(entryPoint domain.EntryPoi
162172 trafficWarn(r.ctx).Err(err).Str("entrypoint", entryPoint.Name).Msg("smart tcp http server stopped with error")
163173 }
164174 }()
175+ stopInternalHTTPServer(r.ctx, oldListener, oldServer, oldDone, 0)
165176 }
166177
167178 func (r *entryPointRuntime) replaceSmartTCPTLSServer(entryPoint domain.EntryPoint, config SmartTCPTLSServerConfig) {
168179 r.smartTLSReplaceMu.Lock()
169180 defer r.smartTLSReplaceMu.Unlock()
170- r.stopSmartTCPTLSServerLocked(r.ctx, 0)
171181 if r.isClosed() {
172182 return
173183 }
@@ -175,6 +185,9 @@ func (r *entryPointRuntime) replaceSmartTCPTLSServer(entryPoint domain.EntryPoin
175185 server := &http.Server{Handler: config.Handler, TLSConfig: config.TLSConfig.Clone(), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 30 * time.Second}
176186 done := make(chan struct{})
177187 r.mu.Lock()
188+ oldListener := r.smartTLSListener
189+ oldServer := r.smartTLSServer
190+ oldDone := r.smartTLSDone
178191 if r.isClosed() {
179192 r.mu.Unlock()
180193 _ = listener.Close()
@@ -190,6 +203,7 @@ func (r *entryPointRuntime) replaceSmartTCPTLSServer(entryPoint domain.EntryPoin
190203 trafficWarn(r.ctx).Err(err).Str("entrypoint", entryPoint.Name).Msg("smart tcp https server stopped with error")
191204 }
192205 }()
206+ stopInternalHTTPServer(r.ctx, oldListener, oldServer, oldDone, 0)
193207 }
194208
195209 func (r *entryPointRuntime) stopSmartTCPHTTPServer(ctx context.Context, drainTimeout time.Duration) {
@@ -33,7 +33,7 @@ func (m *Manager) SetSmartTCPHTTPServer(entryPoint string, handler http.Handler,
33 if handler == nil {33 if handler == nil {
34 delete(next, entryPoint)34 delete(next, entryPoint)
35 } else {35 } else {
36- next[entryPoint] = SmartTCPHTTPServerConfig{Handler: handler, Protocols: protocols}36+ next[entryPoint] = SmartTCPHTTPServerConfig{Handler: handler, Protocols: cloneHTTPProtocols(protocols)}
37 }37 }
38 m.smartHTTPServers.Store(next)38 m.smartHTTPServers.Store(next)
39 runtimes := m.smartTCPRuntimesLocked(entryPoint)39 runtimes := m.smartTCPRuntimesLocked(entryPoint)
@@ -43,6 +43,14 @@ func (m *Manager) SetSmartTCPHTTPServer(entryPoint string, handler http.Handler,
43 }43 }
44 }44 }
45 45
46+func cloneHTTPProtocols(protocols *http.Protocols) *http.Protocols {
47+ if protocols == nil {
48+ return nil
49+ }
50+ clone := *protocols
51+ return &clone
52+}
53+
46 func (m *Manager) SetSmartTCPTLSServer(entryPoint string, handler http.Handler, tlsConfig *tls.Config) {54 func (m *Manager) SetSmartTCPTLSServer(entryPoint string, handler http.Handler, tlsConfig *tls.Config) {
47 m.mu.Lock()55 m.mu.Lock()
48 current := m.loadSmartTLSServers()56 current := m.loadSmartTLSServers()
@@ -139,7 +147,6 @@ func (r *entryPointRuntime) refreshSmartTCPTLSServer(entryPointName string) {
139 func (r *entryPointRuntime) replaceSmartTCPHTTPServer(entryPoint domain.EntryPoint, config SmartTCPHTTPServerConfig) {147 func (r *entryPointRuntime) replaceSmartTCPHTTPServer(entryPoint domain.EntryPoint, config SmartTCPHTTPServerConfig) {
140 r.smartHTTPReplaceMu.Lock()148 r.smartHTTPReplaceMu.Lock()
141 defer r.smartHTTPReplaceMu.Unlock()149 defer r.smartHTTPReplaceMu.Unlock()
142- r.stopSmartTCPHTTPServerLocked(r.ctx, 0)
143 if r.isClosed() {150 if r.isClosed() {
144 return151 return
145 }152 }
@@ -147,6 +154,9 @@ func (r *entryPointRuntime) replaceSmartTCPHTTPServer(entryPoint domain.EntryPoi
147 server := &http.Server{Handler: config.Handler, Protocols: config.Protocols, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 30 * time.Second}154 server := &http.Server{Handler: config.Handler, Protocols: config.Protocols, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 30 * time.Second}
148 done := make(chan struct{})155 done := make(chan struct{})
149 r.mu.Lock()156 r.mu.Lock()
157+ oldListener := r.smartHTTPListener
158+ oldServer := r.smartHTTPServer
159+ oldDone := r.smartHTTPDone
150 if r.isClosed() {160 if r.isClosed() {
151 r.mu.Unlock()161 r.mu.Unlock()
152 _ = listener.Close()162 _ = listener.Close()
@@ -162,12 +172,12 @@ func (r *entryPointRuntime) replaceSmartTCPHTTPServer(entryPoint domain.EntryPoi
162 trafficWarn(r.ctx).Err(err).Str("entrypoint", entryPoint.Name).Msg("smart tcp http server stopped with error")172 trafficWarn(r.ctx).Err(err).Str("entrypoint", entryPoint.Name).Msg("smart tcp http server stopped with error")
163 }173 }
164 }()174 }()
175+ stopInternalHTTPServer(r.ctx, oldListener, oldServer, oldDone, 0)
165 }176 }
166 177
167 func (r *entryPointRuntime) replaceSmartTCPTLSServer(entryPoint domain.EntryPoint, config SmartTCPTLSServerConfig) {178 func (r *entryPointRuntime) replaceSmartTCPTLSServer(entryPoint domain.EntryPoint, config SmartTCPTLSServerConfig) {
168 r.smartTLSReplaceMu.Lock()179 r.smartTLSReplaceMu.Lock()
169 defer r.smartTLSReplaceMu.Unlock()180 defer r.smartTLSReplaceMu.Unlock()
170- r.stopSmartTCPTLSServerLocked(r.ctx, 0)
171 if r.isClosed() {181 if r.isClosed() {
172 return182 return
173 }183 }
@@ -175,6 +185,9 @@ func (r *entryPointRuntime) replaceSmartTCPTLSServer(entryPoint domain.EntryPoin
175 server := &http.Server{Handler: config.Handler, TLSConfig: config.TLSConfig.Clone(), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 30 * time.Second}185 server := &http.Server{Handler: config.Handler, TLSConfig: config.TLSConfig.Clone(), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 30 * time.Second}
176 done := make(chan struct{})186 done := make(chan struct{})
177 r.mu.Lock()187 r.mu.Lock()
188+ oldListener := r.smartTLSListener
189+ oldServer := r.smartTLSServer
190+ oldDone := r.smartTLSDone
178 if r.isClosed() {191 if r.isClosed() {
179 r.mu.Unlock()192 r.mu.Unlock()
180 _ = listener.Close()193 _ = listener.Close()
@@ -190,6 +203,7 @@ func (r *entryPointRuntime) replaceSmartTCPTLSServer(entryPoint domain.EntryPoin
190 trafficWarn(r.ctx).Err(err).Str("entrypoint", entryPoint.Name).Msg("smart tcp https server stopped with error")203 trafficWarn(r.ctx).Err(err).Str("entrypoint", entryPoint.Name).Msg("smart tcp https server stopped with error")
191 }204 }
192 }()205 }()
206+ stopInternalHTTPServer(r.ctx, oldListener, oldServer, oldDone, 0)
193 }207 }
194 208
195 func (r *entryPointRuntime) stopSmartTCPHTTPServer(ctx context.Context, drainTimeout time.Duration) {209 func (r *entryPointRuntime) stopSmartTCPHTTPServer(ctx context.Context, drainTimeout time.Duration) {
M internal/adapters/in/traffic/sniff.go +10 -1
@@ -160,7 +160,16 @@ func isMalformedHTTPLookingPrefix(buf []byte) bool {
160160 return false
161161 }
162162 method, _, _ := bytes.Cut(lineBytes, []byte(" "))
163- return isHTTPToken(string(method))
163+ return isKnownHTTPMethod(string(method))
164+}
165+
166+func isKnownHTTPMethod(method string) bool {
167+ switch method {
168+ case "GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "CONNECT", "OPTIONS", "TRACE", "PROPFIND", "MKCOL":
169+ return true
170+ default:
171+ return false
172+ }
164173 }
165174
166175 func couldBecomeHTTPRequest(buf []byte) bool {
@@ -160,7 +160,16 @@ func isMalformedHTTPLookingPrefix(buf []byte) bool {
160 return false160 return false
161 }161 }
162 method, _, _ := bytes.Cut(lineBytes, []byte(" "))162 method, _, _ := bytes.Cut(lineBytes, []byte(" "))
163- return isHTTPToken(string(method))163+ return isKnownHTTPMethod(string(method))
164+}
165+
166+func isKnownHTTPMethod(method string) bool {
167+ switch method {
168+ case "GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "CONNECT", "OPTIONS", "TRACE", "PROPFIND", "MKCOL":
169+ return true
170+ default:
171+ return false
172+ }
164 }173 }
165 174
166 func couldBecomeHTTPRequest(buf []byte) bool {175 func couldBecomeHTTPRequest(buf []byte) bool {
M internal/adapters/in/traffic/sniff_test.go +11 -0
@@ -76,6 +76,17 @@ func TestSniffSmartTCPRejectsMalformedHTTPLookingRequestLine(t *testing.T) {
7676 assertReplayPrefix(t, result.conn, data)
7777 }
7878
79+func TestSniffSmartTCPClassifiesTokenSpacePlaintextAsUnknown(t *testing.T) {
80+ for _, data := range [][]byte{
81+ []byte("USER anonymous\r\n"),
82+ []byte("MAIL FROM:<a@example.com>\r\n"),
83+ } {
84+ result := sniffBytes(t, data, time.Second)
85+ assert.Equal(t, dispatchUnknown, result.kind)
86+ assertReplayPrefix(t, result.conn, data)
87+ }
88+}
89+
7990 func TestSniffSmartTCPRejectsPROXY(t *testing.T) {
8091 for _, tc := range []struct {
8192 name string
@@ -76,6 +76,17 @@ func TestSniffSmartTCPRejectsMalformedHTTPLookingRequestLine(t *testing.T) {
76 assertReplayPrefix(t, result.conn, data)76 assertReplayPrefix(t, result.conn, data)
77 }77 }
78 78
79+func TestSniffSmartTCPClassifiesTokenSpacePlaintextAsUnknown(t *testing.T) {
80+ for _, data := range [][]byte{
81+ []byte("USER anonymous\r\n"),
82+ []byte("MAIL FROM:<a@example.com>\r\n"),
83+ } {
84+ result := sniffBytes(t, data, time.Second)
85+ assert.Equal(t, dispatchUnknown, result.kind)
86+ assertReplayPrefix(t, result.conn, data)
87+ }
88+}
89+
79 func TestSniffSmartTCPRejectsPROXY(t *testing.T) {90 func TestSniffSmartTCPRejectsPROXY(t *testing.T) {
80 for _, tc := range []struct {91 for _, tc := range []struct {
81 name string92 name string
M internal/adapters/in/traffic/tcp.go +14 -5
@@ -370,8 +370,9 @@ func (r *entryPointRuntime) handleSmartTCPTLS(tracked *trackedTCPConn, conn net.
370370 return r.handleSmartTCPTLSPeekError(conn, err)
371371 }
372372 if backend, ok := r.resolveTLSBackend(peeked.sni); ok {
373- r.counters.smartTCP.tlsPassthroughAccepted.Add(1)
374- r.proxyToBackend(tracked, peeked.conn, backend, options)
373+ r.proxyToBackendAfterDial(tracked, peeked.conn, backend, options, func() {
374+ r.counters.smartTCP.tlsPassthroughAccepted.Add(1)
375+ })
375376 return false
376377 }
377378 if r.routeToSmartHTTPS(tracked, peeked.conn) {
@@ -397,8 +398,9 @@ func (r *entryPointRuntime) handleSmartTCPUnknown(tracked *trackedTCPConn, conn
397398 backend, rawFallbackName, ok, rawCIDRRefused := r.resolveRawFallbackBackendDetailed(conn.RemoteAddr())
398399 if ok {
399400 tracked.markRawFallback(rawFallbackName)
400- r.counters.smartTCP.rawFallbackAccepted.Add(1)
401- r.proxyToBackend(tracked, conn, backend, options)
401+ r.proxyToBackendAfterDial(tracked, conn, backend, options, func() {
402+ r.counters.smartTCP.rawFallbackAccepted.Add(1)
403+ })
402404 return false
403405 }
404406 if rawCIDRRefused {
@@ -451,7 +453,7 @@ func (r *entryPointRuntime) peekTLSClientHello(client net.Conn, options domain.T
451453 }
452454
453455 func isClientHelloTooLargeError(err error) bool {
454- return strings.Contains(err.Error(), "client hello exceeds")
456+ return errors.Is(err, domain.ErrClientHelloTooLarge)
455457 }
456458
457459 func clientHelloTimeout(options domain.TCPOptions) time.Duration {
@@ -468,6 +470,10 @@ type peekedTLSConn struct {
468470 }
469471
470472 func (r *entryPointRuntime) proxyToBackend(tracked *trackedTCPConn, client net.Conn, backend domain.TrafficBackend, options domain.TCPOptions) bool {
473+ return r.proxyToBackendAfterDial(tracked, client, backend, options, nil)
474+}
475+
476+func (r *entryPointRuntime) proxyToBackendAfterDial(tracked *trackedTCPConn, client net.Conn, backend domain.TrafficBackend, options domain.TCPOptions, afterDial func()) bool {
471477 dialCtx, cancel := context.WithTimeout(r.ctx, options.DialTimeout)
472478 defer cancel()
473479 backendConn, err := (&net.Dialer{}).DialContext(dialCtx, "tcp", net.JoinHostPort(backend.Host, strconv.Itoa(backend.Port)))
@@ -479,6 +485,9 @@ func (r *entryPointRuntime) proxyToBackend(tracked *trackedTCPConn, client net.C
479485
480486 r.setBackend(tracked, backendConn)
481487 r.counters.totalAccepted.Add(1)
488+ if afterDial != nil {
489+ afterDial()
490+ }
482491 r.proxyTCP(client, backendConn, options.IdleTimeout)
483492 return true
484493 }
@@ -370,8 +370,9 @@ func (r *entryPointRuntime) handleSmartTCPTLS(tracked *trackedTCPConn, conn net.
370 return r.handleSmartTCPTLSPeekError(conn, err)370 return r.handleSmartTCPTLSPeekError(conn, err)
371 }371 }
372 if backend, ok := r.resolveTLSBackend(peeked.sni); ok {372 if backend, ok := r.resolveTLSBackend(peeked.sni); ok {
373- r.counters.smartTCP.tlsPassthroughAccepted.Add(1)373+ r.proxyToBackendAfterDial(tracked, peeked.conn, backend, options, func() {
374- r.proxyToBackend(tracked, peeked.conn, backend, options)374+ r.counters.smartTCP.tlsPassthroughAccepted.Add(1)
375+ })
375 return false376 return false
376 }377 }
377 if r.routeToSmartHTTPS(tracked, peeked.conn) {378 if r.routeToSmartHTTPS(tracked, peeked.conn) {
@@ -397,8 +398,9 @@ func (r *entryPointRuntime) handleSmartTCPUnknown(tracked *trackedTCPConn, conn
397 backend, rawFallbackName, ok, rawCIDRRefused := r.resolveRawFallbackBackendDetailed(conn.RemoteAddr())398 backend, rawFallbackName, ok, rawCIDRRefused := r.resolveRawFallbackBackendDetailed(conn.RemoteAddr())
398 if ok {399 if ok {
399 tracked.markRawFallback(rawFallbackName)400 tracked.markRawFallback(rawFallbackName)
400- r.counters.smartTCP.rawFallbackAccepted.Add(1)401+ r.proxyToBackendAfterDial(tracked, conn, backend, options, func() {
401- r.proxyToBackend(tracked, conn, backend, options)402+ r.counters.smartTCP.rawFallbackAccepted.Add(1)
403+ })
402 return false404 return false
403 }405 }
404 if rawCIDRRefused {406 if rawCIDRRefused {
@@ -451,7 +453,7 @@ func (r *entryPointRuntime) peekTLSClientHello(client net.Conn, options domain.T
451 }453 }
452 454
453 func isClientHelloTooLargeError(err error) bool {455 func isClientHelloTooLargeError(err error) bool {
454- return strings.Contains(err.Error(), "client hello exceeds")456+ return errors.Is(err, domain.ErrClientHelloTooLarge)
455 }457 }
456 458
457 func clientHelloTimeout(options domain.TCPOptions) time.Duration {459 func clientHelloTimeout(options domain.TCPOptions) time.Duration {
@@ -468,6 +470,10 @@ type peekedTLSConn struct {
468 }470 }
469 471
470 func (r *entryPointRuntime) proxyToBackend(tracked *trackedTCPConn, client net.Conn, backend domain.TrafficBackend, options domain.TCPOptions) bool {472 func (r *entryPointRuntime) proxyToBackend(tracked *trackedTCPConn, client net.Conn, backend domain.TrafficBackend, options domain.TCPOptions) bool {
473+ return r.proxyToBackendAfterDial(tracked, client, backend, options, nil)
474+}
475+
476+func (r *entryPointRuntime) proxyToBackendAfterDial(tracked *trackedTCPConn, client net.Conn, backend domain.TrafficBackend, options domain.TCPOptions, afterDial func()) bool {
471 dialCtx, cancel := context.WithTimeout(r.ctx, options.DialTimeout)477 dialCtx, cancel := context.WithTimeout(r.ctx, options.DialTimeout)
472 defer cancel()478 defer cancel()
473 backendConn, err := (&net.Dialer{}).DialContext(dialCtx, "tcp", net.JoinHostPort(backend.Host, strconv.Itoa(backend.Port)))479 backendConn, err := (&net.Dialer{}).DialContext(dialCtx, "tcp", net.JoinHostPort(backend.Host, strconv.Itoa(backend.Port)))
@@ -479,6 +485,9 @@ func (r *entryPointRuntime) proxyToBackend(tracked *trackedTCPConn, client net.C
479 485
480 r.setBackend(tracked, backendConn)486 r.setBackend(tracked, backendConn)
481 r.counters.totalAccepted.Add(1)487 r.counters.totalAccepted.Add(1)
488+ if afterDial != nil {
489+ afterDial()
490+ }
482 r.proxyTCP(client, backendConn, options.IdleTimeout)491 r.proxyTCP(client, backendConn, options.IdleTimeout)
483 return true492 return true
484 }493 }
M internal/adapters/in/traffic/tcp_test.go +29 -1
@@ -200,6 +200,21 @@ func TestSmartTCPDispatch(t *testing.T) {
200200 })
201201 }
202202
203+func TestSetSmartTCPHTTPServerCopiesProtocols(t *testing.T) {
204+ manager := NewManager()
205+ protocols := new(http.Protocols)
206+ protocols.SetHTTP1(true)
207+ manager.SetSmartTCPHTTPServer("edge", http.NotFoundHandler(), protocols)
208+
209+ protocols.SetHTTP1(false)
210+ protocols.SetUnencryptedHTTP2(true)
211+
212+ config, ok := manager.smartHTTPServer("edge")
213+ require.True(t, ok)
214+ assert.True(t, config.Protocols.HTTP1())
215+ assert.False(t, config.Protocols.UnencryptedHTTP2())
216+}
217+
203218 func TestSmartTCPStatusCounters(t *testing.T) {
204219 t.Run("HTTP accepted", func(t *testing.T) {
205220 manager, graph, _ := startSmartTCP(t, nil, nil)
@@ -250,6 +265,16 @@ func TestSmartTCPStatusCounters(t *testing.T) {
250265 assertSmartTCPStatus(t, manager, func(c domain.SmartTCPCounters) bool { return c.RawFallbackAccepted == 1 })
251266 })
252267
268+ t.Run("raw fallback backend dial failure is not accepted", func(t *testing.T) {
269+ manager, graph, _ := startSmartTCP(t, nil, &smartTCPRoute{backendAddress: freeTCPAddress(t), allowPublic: true})
270+ defer shutdownManager(t, manager)
271+ assertSmartTCPWriteRejected(t, graph.EntryPoints[0].Address, []byte{0})
272+ assertSmartTCPStatus(t, manager, func(c domain.SmartTCPCounters) bool { return c.RawFallbackAccepted == 0 })
273+ assert.Eventually(t, func() bool {
274+ return manager.Status().Counters.TotalAccepted == 0 && manager.Status().Counters.TotalErrors == 1
275+ }, time.Second, 10*time.Millisecond)
276+ })
277+
253278 t.Run("refused and rejected classes", func(t *testing.T) {
254279 backend := startTCPEchoServer(t, 0)
255280 manager, graph, _ := startSmartTCP(t, nil, &smartTCPRoute{backendAddress: backend.address, trustedCIDRs: []string{"192.0.2.0/24"}})
@@ -339,6 +364,7 @@ func TestSmartTCPLifecycleHardening(t *testing.T) {
339364 graph := smartTCPGraph(t, freeTCPAddress(t), nil, nil)
340365 graph.Options.TCP.MaxConnections = 1
341366 hijacked := make(chan struct{})
367+ hijackedClosed := &atomic.Bool{}
342368 manager.SetSmartTCPHTTPServer("edge", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
343369 hj, ok := w.(http.Hijacker)
344370 require.True(t, ok)
@@ -347,7 +373,9 @@ func TestSmartTCPLifecycleHardening(t *testing.T) {
347373 defer conn.Close()
348374 _, _ = rw.WriteString("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\nready\n")
349375 _ = rw.Flush()
350- close(hijacked)
376+ if hijackedClosed.CompareAndSwap(false, true) {
377+ close(hijacked)
378+ }
351379 _, _ = rw.ReadString('\n')
352380 }), nil)
353381 manager.SetSmartTCPTLSServer("edge", http.NotFoundHandler(), testTLSConfig(t, "app.example.com"))
@@ -200,6 +200,21 @@ func TestSmartTCPDispatch(t *testing.T) {
200 })200 })
201 }201 }
202 202
203+func TestSetSmartTCPHTTPServerCopiesProtocols(t *testing.T) {
204+ manager := NewManager()
205+ protocols := new(http.Protocols)
206+ protocols.SetHTTP1(true)
207+ manager.SetSmartTCPHTTPServer("edge", http.NotFoundHandler(), protocols)
208+
209+ protocols.SetHTTP1(false)
210+ protocols.SetUnencryptedHTTP2(true)
211+
212+ config, ok := manager.smartHTTPServer("edge")
213+ require.True(t, ok)
214+ assert.True(t, config.Protocols.HTTP1())
215+ assert.False(t, config.Protocols.UnencryptedHTTP2())
216+}
217+
203 func TestSmartTCPStatusCounters(t *testing.T) {218 func TestSmartTCPStatusCounters(t *testing.T) {
204 t.Run("HTTP accepted", func(t *testing.T) {219 t.Run("HTTP accepted", func(t *testing.T) {
205 manager, graph, _ := startSmartTCP(t, nil, nil)220 manager, graph, _ := startSmartTCP(t, nil, nil)
@@ -250,6 +265,16 @@ func TestSmartTCPStatusCounters(t *testing.T) {
250 assertSmartTCPStatus(t, manager, func(c domain.SmartTCPCounters) bool { return c.RawFallbackAccepted == 1 })265 assertSmartTCPStatus(t, manager, func(c domain.SmartTCPCounters) bool { return c.RawFallbackAccepted == 1 })
251 })266 })
252 267
268+ t.Run("raw fallback backend dial failure is not accepted", func(t *testing.T) {
269+ manager, graph, _ := startSmartTCP(t, nil, &smartTCPRoute{backendAddress: freeTCPAddress(t), allowPublic: true})
270+ defer shutdownManager(t, manager)
271+ assertSmartTCPWriteRejected(t, graph.EntryPoints[0].Address, []byte{0})
272+ assertSmartTCPStatus(t, manager, func(c domain.SmartTCPCounters) bool { return c.RawFallbackAccepted == 0 })
273+ assert.Eventually(t, func() bool {
274+ return manager.Status().Counters.TotalAccepted == 0 && manager.Status().Counters.TotalErrors == 1
275+ }, time.Second, 10*time.Millisecond)
276+ })
277+
253 t.Run("refused and rejected classes", func(t *testing.T) {278 t.Run("refused and rejected classes", func(t *testing.T) {
254 backend := startTCPEchoServer(t, 0)279 backend := startTCPEchoServer(t, 0)
255 manager, graph, _ := startSmartTCP(t, nil, &smartTCPRoute{backendAddress: backend.address, trustedCIDRs: []string{"192.0.2.0/24"}})280 manager, graph, _ := startSmartTCP(t, nil, &smartTCPRoute{backendAddress: backend.address, trustedCIDRs: []string{"192.0.2.0/24"}})
@@ -339,6 +364,7 @@ func TestSmartTCPLifecycleHardening(t *testing.T) {
339 graph := smartTCPGraph(t, freeTCPAddress(t), nil, nil)364 graph := smartTCPGraph(t, freeTCPAddress(t), nil, nil)
340 graph.Options.TCP.MaxConnections = 1365 graph.Options.TCP.MaxConnections = 1
341 hijacked := make(chan struct{})366 hijacked := make(chan struct{})
367+ hijackedClosed := &atomic.Bool{}
342 manager.SetSmartTCPHTTPServer("edge", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {368 manager.SetSmartTCPHTTPServer("edge", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
343 hj, ok := w.(http.Hijacker)369 hj, ok := w.(http.Hijacker)
344 require.True(t, ok)370 require.True(t, ok)
@@ -347,7 +373,9 @@ func TestSmartTCPLifecycleHardening(t *testing.T) {
347 defer conn.Close()373 defer conn.Close()
348 _, _ = rw.WriteString("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\nready\n")374 _, _ = rw.WriteString("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\nready\n")
349 _ = rw.Flush()375 _ = rw.Flush()
350- close(hijacked)376+ if hijackedClosed.CompareAndSwap(false, true) {
377+ close(hijacked)
378+ }
351 _, _ = rw.ReadString('\n')379 _, _ = rw.ReadString('\n')
352 }), nil)380 }), nil)
353 manager.SetSmartTCPTLSServer("edge", http.NotFoundHandler(), testTLSConfig(t, "app.example.com"))381 manager.SetSmartTCPTLSServer("edge", http.NotFoundHandler(), testTLSConfig(t, "app.example.com"))
M internal/adapters/in/traffic/tls_mux.go +3 -1
@@ -9,6 +9,8 @@ import (
99 "net/http"
1010 "strings"
1111 "sync"
12+
13+ "github.com/bnema/gordon/internal/domain"
1214 )
1315
1416 const maxClientHelloBytes = 64 << 10
@@ -82,7 +84,7 @@ func peekClientHelloSNIWithLimit(conn net.Conn, maxBytes int) (string, net.Conn,
8284 return "", nil, fmt.Errorf("read client hello: %w", err)
8385 }
8486 }
85- return "", nil, fmt.Errorf("client hello exceeds %d bytes", maxBytes)
87+ return "", nil, fmt.Errorf("%w: %d bytes", domain.ErrClientHelloTooLarge, maxBytes)
8688 }
8789
8890 func parseClientHelloSNI(data []byte) (string, bool, error) {
@@ -9,6 +9,8 @@ import (
9 "net/http"9 "net/http"
10 "strings"10 "strings"
11 "sync"11 "sync"
12+
13+ "github.com/bnema/gordon/internal/domain"
12 )14 )
13 15
14 const maxClientHelloBytes = 64 << 1016 const maxClientHelloBytes = 64 << 10
@@ -82,7 +84,7 @@ func peekClientHelloSNIWithLimit(conn net.Conn, maxBytes int) (string, net.Conn,
82 return "", nil, fmt.Errorf("read client hello: %w", err)84 return "", nil, fmt.Errorf("read client hello: %w", err)
83 }85 }
84 }86 }
85- return "", nil, fmt.Errorf("client hello exceeds %d bytes", maxBytes)87+ return "", nil, fmt.Errorf("%w: %d bytes", domain.ErrClientHelloTooLarge, maxBytes)
86 }88 }
87 89
88 func parseClientHelloSNI(data []byte) (string, bool, error) {90 func parseClientHelloSNI(data []byte) (string, bool, error) {
M internal/adapters/in/traffic/tls_mux_test.go +13 -0
@@ -10,6 +10,7 @@ import (
1010 "crypto/x509"
1111 "crypto/x509/pkix"
1212 "encoding/pem"
13+ "errors"
1314 "io"
1415 "math/big"
1516 "net"
@@ -24,6 +25,18 @@ import (
2425 "github.com/bnema/gordon/internal/domain"
2526 )
2627
28+func TestClientHelloTooLargeUsesDomainSentinel(t *testing.T) {
29+ client, server := net.Pipe()
30+ go func() {
31+ _, _ = client.Write(clientHelloBytes(t, "large.example.com"))
32+ _ = client.Close()
33+ }()
34+ _, _, err := peekClientHelloSNIWithLimit(server, 1)
35+ require.Error(t, err)
36+ assert.True(t, errors.Is(err, domain.ErrClientHelloTooLarge))
37+ _ = server.Close()
38+}
39+
2740 func TestClientHelloPeek(t *testing.T) {
2841 t.Run("valid sni returns replayable bytes", func(t *testing.T) {
2942 sni, replayed := peekHelloFromTLSClient(t, "raw.example.com")
@@ -10,6 +10,7 @@ import (
10 "crypto/x509"10 "crypto/x509"
11 "crypto/x509/pkix"11 "crypto/x509/pkix"
12 "encoding/pem"12 "encoding/pem"
13+ "errors"
13 "io"14 "io"
14 "math/big"15 "math/big"
15 "net"16 "net"
@@ -24,6 +25,18 @@ import (
24 "github.com/bnema/gordon/internal/domain"25 "github.com/bnema/gordon/internal/domain"
25 )26 )
26 27
28+func TestClientHelloTooLargeUsesDomainSentinel(t *testing.T) {
29+ client, server := net.Pipe()
30+ go func() {
31+ _, _ = client.Write(clientHelloBytes(t, "large.example.com"))
32+ _ = client.Close()
33+ }()
34+ _, _, err := peekClientHelloSNIWithLimit(server, 1)
35+ require.Error(t, err)
36+ assert.True(t, errors.Is(err, domain.ErrClientHelloTooLarge))
37+ _ = server.Close()
38+}
39+
27 func TestClientHelloPeek(t *testing.T) {40 func TestClientHelloPeek(t *testing.T) {
28 t.Run("valid sni returns replayable bytes", func(t *testing.T) {41 t.Run("valid sni returns replayable bytes", func(t *testing.T) {
29 sni, replayed := peekHelloFromTLSClient(t, "raw.example.com")42 sni, replayed := peekHelloFromTLSClient(t, "raw.example.com")
M internal/adapters/in/traffic/udp.go +29 -28
@@ -134,27 +134,20 @@ func (r *udpEntryPointRuntime) handleDatagram(clientAddr net.Addr, packet []byte
134134 }
135135
136136 func (r *udpEntryPointRuntime) session(key string, clientAddr net.Addr, options domain.UDPOptions) (*udpSession, bool) {
137+ r.mu.Lock()
138+ if session := r.sessions[key]; session != nil {
139+ session.touch()
140+ r.mu.Unlock()
141+ return session, true
142+ }
143+ r.mu.Unlock()
144+
137145 backend, ok := r.resolveUDPBackend()
138146 if !ok {
139147 r.counters.totalRefused.Add(1)
140148 return nil, false
141149 }
142150
143- r.mu.Lock()
144- if session := r.sessions[key]; session != nil {
145- if trafficBackendEqual(session.backendRef, backend) {
146- session.touch()
147- r.mu.Unlock()
148- return session, true
149- }
150- delete(r.sessions, key)
151- r.mu.Unlock()
152- session.close()
153- r.counters.activeUDPSessions.Add(-1)
154- } else {
155- r.mu.Unlock()
156- }
157-
158151 r.mu.Lock()
159152 if options.MaxSessions > 0 && len(r.sessions) >= options.MaxSessions {
160153 r.mu.Unlock()
@@ -174,18 +167,11 @@ func (r *udpEntryPointRuntime) session(key string, clientAddr net.Addr, options
174167
175168 r.mu.Lock()
176169 if existing := r.sessions[key]; existing != nil {
177- if trafficBackendEqual(existing.backendRef, backend) {
178- r.mu.Unlock()
179- _ = backendConn.Close()
180- return existing, true
181- }
182- delete(r.sessions, key)
183- r.mu.Unlock()
184- existing.close()
185- r.counters.activeUDPSessions.Add(-1)
186- } else {
187170 r.mu.Unlock()
171+ _ = backendConn.Close()
172+ return existing, true
188173 }
174+ r.mu.Unlock()
189175 if options.MaxSessions > 0 && r.sessionCount() >= options.MaxSessions {
190176 _ = backendConn.Close()
191177 r.counters.totalRefused.Add(1)
@@ -350,13 +336,22 @@ func (r *udpEntryPointRuntime) stop(ctx context.Context, drainTimeout time.Durat
350336
351337 func (r *udpEntryPointRuntime) drainSessionsAfter(drainTimeout time.Duration) {
352338 trafficDebug(r.ctx).Str("entrypoint", r.entryPointSnapshot().Name).Dur("drain_timeout", drainTimeout).Msg("scheduled udp session drain after router removal")
339+ r.drainSessionsMatchingAfter(func(*udpSession) bool { return true }, drainTimeout)
340+}
341+
342+func (r *udpEntryPointRuntime) drainSessionsNotMatchingAfter(backend domain.TrafficBackend, drainTimeout time.Duration) {
343+ trafficDebug(r.ctx).Str("entrypoint", r.entryPointSnapshot().Name).Dur("drain_timeout", drainTimeout).Msg("scheduled stale udp session drain after backend update")
344+ r.drainSessionsMatchingAfter(func(session *udpSession) bool { return !trafficBackendEqual(session.backendRef, backend) }, drainTimeout)
345+}
346+
347+func (r *udpEntryPointRuntime) drainSessionsMatchingAfter(match func(*udpSession) bool, drainTimeout time.Duration) {
353348 go func() {
354349 if drainTimeout <= 0 {
355350 drainTimeout = defaultUDPOptions().DrainTimeout
356351 }
357352 select {
358353 case <-time.After(drainTimeout):
359- r.closeSessions()
354+ r.closeSessionsMatching(match)
360355 case <-r.ctx.Done():
361356 }
362357 }()
@@ -398,10 +393,16 @@ func (r *udpEntryPointRuntime) sessionsDone() <-chan struct{} {
398393 }
399394
400395 func (r *udpEntryPointRuntime) closeSessions() {
396+ r.closeSessionsMatching(func(*udpSession) bool { return true })
397+}
398+
399+func (r *udpEntryPointRuntime) closeSessionsMatching(match func(*udpSession) bool) {
401400 r.mu.Lock()
402401 keys := make([]string, 0, len(r.sessions))
403- for key := range r.sessions {
404- keys = append(keys, key)
402+ for key, session := range r.sessions {
403+ if match(session) {
404+ keys = append(keys, key)
405+ }
405406 }
406407 r.mu.Unlock()
407408 for _, key := range keys {
@@ -134,27 +134,20 @@ func (r *udpEntryPointRuntime) handleDatagram(clientAddr net.Addr, packet []byte
134 }134 }
135 135
136 func (r *udpEntryPointRuntime) session(key string, clientAddr net.Addr, options domain.UDPOptions) (*udpSession, bool) {136 func (r *udpEntryPointRuntime) session(key string, clientAddr net.Addr, options domain.UDPOptions) (*udpSession, bool) {
137+ r.mu.Lock()
138+ if session := r.sessions[key]; session != nil {
139+ session.touch()
140+ r.mu.Unlock()
141+ return session, true
142+ }
143+ r.mu.Unlock()
144+
137 backend, ok := r.resolveUDPBackend()145 backend, ok := r.resolveUDPBackend()
138 if !ok {146 if !ok {
139 r.counters.totalRefused.Add(1)147 r.counters.totalRefused.Add(1)
140 return nil, false148 return nil, false
141 }149 }
142 150
143- r.mu.Lock()
144- if session := r.sessions[key]; session != nil {
145- if trafficBackendEqual(session.backendRef, backend) {
146- session.touch()
147- r.mu.Unlock()
148- return session, true
149- }
150- delete(r.sessions, key)
151- r.mu.Unlock()
152- session.close()
153- r.counters.activeUDPSessions.Add(-1)
154- } else {
155- r.mu.Unlock()
156- }
157-
158 r.mu.Lock()151 r.mu.Lock()
159 if options.MaxSessions > 0 && len(r.sessions) >= options.MaxSessions {152 if options.MaxSessions > 0 && len(r.sessions) >= options.MaxSessions {
160 r.mu.Unlock()153 r.mu.Unlock()
@@ -174,18 +167,11 @@ func (r *udpEntryPointRuntime) session(key string, clientAddr net.Addr, options
174 167
175 r.mu.Lock()168 r.mu.Lock()
176 if existing := r.sessions[key]; existing != nil {169 if existing := r.sessions[key]; existing != nil {
177- if trafficBackendEqual(existing.backendRef, backend) {
178- r.mu.Unlock()
179- _ = backendConn.Close()
180- return existing, true
181- }
182- delete(r.sessions, key)
183- r.mu.Unlock()
184- existing.close()
185- r.counters.activeUDPSessions.Add(-1)
186- } else {
187 r.mu.Unlock()170 r.mu.Unlock()
171+ _ = backendConn.Close()
172+ return existing, true
188 }173 }
174+ r.mu.Unlock()
189 if options.MaxSessions > 0 && r.sessionCount() >= options.MaxSessions {175 if options.MaxSessions > 0 && r.sessionCount() >= options.MaxSessions {
190 _ = backendConn.Close()176 _ = backendConn.Close()
191 r.counters.totalRefused.Add(1)177 r.counters.totalRefused.Add(1)
@@ -350,13 +336,22 @@ func (r *udpEntryPointRuntime) stop(ctx context.Context, drainTimeout time.Durat
350 336
351 func (r *udpEntryPointRuntime) drainSessionsAfter(drainTimeout time.Duration) {337 func (r *udpEntryPointRuntime) drainSessionsAfter(drainTimeout time.Duration) {
352 trafficDebug(r.ctx).Str("entrypoint", r.entryPointSnapshot().Name).Dur("drain_timeout", drainTimeout).Msg("scheduled udp session drain after router removal")338 trafficDebug(r.ctx).Str("entrypoint", r.entryPointSnapshot().Name).Dur("drain_timeout", drainTimeout).Msg("scheduled udp session drain after router removal")
339+ r.drainSessionsMatchingAfter(func(*udpSession) bool { return true }, drainTimeout)
340+}
341+
342+func (r *udpEntryPointRuntime) drainSessionsNotMatchingAfter(backend domain.TrafficBackend, drainTimeout time.Duration) {
343+ trafficDebug(r.ctx).Str("entrypoint", r.entryPointSnapshot().Name).Dur("drain_timeout", drainTimeout).Msg("scheduled stale udp session drain after backend update")
344+ r.drainSessionsMatchingAfter(func(session *udpSession) bool { return !trafficBackendEqual(session.backendRef, backend) }, drainTimeout)
345+}
346+
347+func (r *udpEntryPointRuntime) drainSessionsMatchingAfter(match func(*udpSession) bool, drainTimeout time.Duration) {
353 go func() {348 go func() {
354 if drainTimeout <= 0 {349 if drainTimeout <= 0 {
355 drainTimeout = defaultUDPOptions().DrainTimeout350 drainTimeout = defaultUDPOptions().DrainTimeout
356 }351 }
357 select {352 select {
358 case <-time.After(drainTimeout):353 case <-time.After(drainTimeout):
359- r.closeSessions()354+ r.closeSessionsMatching(match)
360 case <-r.ctx.Done():355 case <-r.ctx.Done():
361 }356 }
362 }()357 }()
@@ -398,10 +393,16 @@ func (r *udpEntryPointRuntime) sessionsDone() <-chan struct{} {
398 }393 }
399 394
400 func (r *udpEntryPointRuntime) closeSessions() {395 func (r *udpEntryPointRuntime) closeSessions() {
396+ r.closeSessionsMatching(func(*udpSession) bool { return true })
397+}
398+
399+func (r *udpEntryPointRuntime) closeSessionsMatching(match func(*udpSession) bool) {
401 r.mu.Lock()400 r.mu.Lock()
402 keys := make([]string, 0, len(r.sessions))401 keys := make([]string, 0, len(r.sessions))
403- for key := range r.sessions {402+ for key, session := range r.sessions {
404- keys = append(keys, key)403+ if match(session) {
404+ keys = append(keys, key)
405+ }
405 }406 }
406 r.mu.Unlock()407 r.mu.Unlock()
407 for _, key := range keys {408 for _, key := range keys {
M internal/adapters/in/traffic/udp_test.go +31 -1
@@ -98,7 +98,12 @@ func TestUDPReloadReplacesSessionWhenBackendChanges(t *testing.T) {
9898 updated.Services[0].Backends = []domain.TrafficBackend{backend}
9999 require.NoError(t, manager.Apply(context.Background(), &updated))
100100
101- assertUDPRoundTripWant(t, conn, "second", "b:second")
101+ assertUDPRoundTripWant(t, conn, "second", "a:second")
102+ assert.Eventually(t, func() bool { return manager.Status().Counters.ActiveUDPSessions == 0 }, time.Second, 10*time.Millisecond)
103+
104+ newConn := dialUDP(t, graph.EntryPoints[0].Address)
105+ defer newConn.Close()
106+ assertUDPRoundTripWant(t, newConn, "third", "b:third")
102107 }
103108
104109 func assertUDPRejected(t *testing.T, manager *Manager, address string, refused int64) {
@@ -183,6 +188,7 @@ func TestUDPRemovedRouterWithRetainedEntryPointDrainsSession(t *testing.T) {
183188 next.Routers = nil
184189 next.Services = nil
185190 require.NoError(t, manager.Apply(context.Background(), &next))
191+ assertUDPRoundTrip(t, conn, "during-drain")
186192 assert.Eventually(t, func() bool { return manager.Status().Counters.ActiveUDPSessions == 0 }, time.Second, 10*time.Millisecond)
187193
188194 _, err := conn.Write([]byte("after"))
@@ -193,6 +199,30 @@ func TestUDPRemovedRouterWithRetainedEntryPointDrainsSession(t *testing.T) {
193199 require.Error(t, err)
194200 }
195201
202+func TestUDPBackendChangeDrainsExistingSession(t *testing.T) {
203+ oldBackend := startUDPEchoServerWithPrefix(t, "old:")
204+ newBackend := startUDPEchoServerWithPrefix(t, "new:")
205+ graph := udpGraph(t, freeUDPAddress(t), oldBackend.address)
206+ graph.Options.UDP.DrainTimeout = 50 * time.Millisecond
207+ manager := NewManager()
208+ require.NoError(t, manager.Apply(context.Background(), &graph))
209+ defer shutdownManager(t, manager)
210+
211+ oldConn := dialUDP(t, graph.EntryPoints[0].Address)
212+ defer oldConn.Close()
213+ assertUDPRoundTripWant(t, oldConn, "held", "old:held")
214+
215+ next := udpGraph(t, graph.EntryPoints[0].Address, newBackend.address)
216+ next.Options = graph.Options
217+ require.NoError(t, manager.Apply(context.Background(), &next))
218+ assertUDPRoundTripWant(t, oldConn, "during-drain", "old:during-drain")
219+ assert.Eventually(t, func() bool { return manager.Status().Counters.ActiveUDPSessions == 0 }, time.Second, 10*time.Millisecond)
220+
221+ newConn := dialUDP(t, graph.EntryPoints[0].Address)
222+ defer newConn.Close()
223+ assertUDPRoundTripWant(t, newConn, "after", "new:after")
224+}
225+
196226 func TestUDPRemovedRouterDrainsThenClosesSessions(t *testing.T) {
197227 backend := startUDPEchoServer(t)
198228 graph := udpGraph(t, freeUDPAddress(t), backend.address)
@@ -98,7 +98,12 @@ func TestUDPReloadReplacesSessionWhenBackendChanges(t *testing.T) {
98 updated.Services[0].Backends = []domain.TrafficBackend{backend}98 updated.Services[0].Backends = []domain.TrafficBackend{backend}
99 require.NoError(t, manager.Apply(context.Background(), &updated))99 require.NoError(t, manager.Apply(context.Background(), &updated))
100 100
101- assertUDPRoundTripWant(t, conn, "second", "b:second")101+ assertUDPRoundTripWant(t, conn, "second", "a:second")
102+ assert.Eventually(t, func() bool { return manager.Status().Counters.ActiveUDPSessions == 0 }, time.Second, 10*time.Millisecond)
103+
104+ newConn := dialUDP(t, graph.EntryPoints[0].Address)
105+ defer newConn.Close()
106+ assertUDPRoundTripWant(t, newConn, "third", "b:third")
102 }107 }
103 108
104 func assertUDPRejected(t *testing.T, manager *Manager, address string, refused int64) {109 func assertUDPRejected(t *testing.T, manager *Manager, address string, refused int64) {
@@ -183,6 +188,7 @@ func TestUDPRemovedRouterWithRetainedEntryPointDrainsSession(t *testing.T) {
183 next.Routers = nil188 next.Routers = nil
184 next.Services = nil189 next.Services = nil
185 require.NoError(t, manager.Apply(context.Background(), &next))190 require.NoError(t, manager.Apply(context.Background(), &next))
191+ assertUDPRoundTrip(t, conn, "during-drain")
186 assert.Eventually(t, func() bool { return manager.Status().Counters.ActiveUDPSessions == 0 }, time.Second, 10*time.Millisecond)192 assert.Eventually(t, func() bool { return manager.Status().Counters.ActiveUDPSessions == 0 }, time.Second, 10*time.Millisecond)
187 193
188 _, err := conn.Write([]byte("after"))194 _, err := conn.Write([]byte("after"))
@@ -193,6 +199,30 @@ func TestUDPRemovedRouterWithRetainedEntryPointDrainsSession(t *testing.T) {
193 require.Error(t, err)199 require.Error(t, err)
194 }200 }
195 201
202+func TestUDPBackendChangeDrainsExistingSession(t *testing.T) {
203+ oldBackend := startUDPEchoServerWithPrefix(t, "old:")
204+ newBackend := startUDPEchoServerWithPrefix(t, "new:")
205+ graph := udpGraph(t, freeUDPAddress(t), oldBackend.address)
206+ graph.Options.UDP.DrainTimeout = 50 * time.Millisecond
207+ manager := NewManager()
208+ require.NoError(t, manager.Apply(context.Background(), &graph))
209+ defer shutdownManager(t, manager)
210+
211+ oldConn := dialUDP(t, graph.EntryPoints[0].Address)
212+ defer oldConn.Close()
213+ assertUDPRoundTripWant(t, oldConn, "held", "old:held")
214+
215+ next := udpGraph(t, graph.EntryPoints[0].Address, newBackend.address)
216+ next.Options = graph.Options
217+ require.NoError(t, manager.Apply(context.Background(), &next))
218+ assertUDPRoundTripWant(t, oldConn, "during-drain", "old:during-drain")
219+ assert.Eventually(t, func() bool { return manager.Status().Counters.ActiveUDPSessions == 0 }, time.Second, 10*time.Millisecond)
220+
221+ newConn := dialUDP(t, graph.EntryPoints[0].Address)
222+ defer newConn.Close()
223+ assertUDPRoundTripWant(t, newConn, "after", "new:after")
224+}
225+
196 func TestUDPRemovedRouterDrainsThenClosesSessions(t *testing.T) {226 func TestUDPRemovedRouterDrainsThenClosesSessions(t *testing.T) {
197 backend := startUDPEchoServer(t)227 backend := startUDPEchoServer(t)
198 graph := udpGraph(t, freeUDPAddress(t), backend.address)228 graph := udpGraph(t, freeUDPAddress(t), backend.address)
M internal/app/public_tls_wiring_test.go +34 -0
@@ -63,6 +63,40 @@ func TestEffectivePublicTLSPortPrefersTLSCapableEntrypointOverLegacyTLSPort(t *t
6363 assert.Equal(t, 443, effectivePublicTLSPort(cfg))
6464 }
6565
66+func TestEffectivePublicTLSPortDeterministicWithMultipleTLSCapableEntrypoints(t *testing.T) {
67+ cfg := Config{}
68+ cfg.EntryPoints = map[string]traffic.EntryPointConfig{
69+ traffic.DefaultEdgeEntryPointName: {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP},
70+ "public": {Address: ":8443", Protocol: domain.EntryPointProtocolTLSMux},
71+ }
72+
73+ for range 20 {
74+ assert.Equal(t, 443, effectivePublicTLSPort(cfg))
75+ }
76+}
77+
78+func TestEffectivePublicTLSPortUsesSoleNonEdgeTLSCapableEntrypoint(t *testing.T) {
79+ cfg := Config{}
80+ cfg.EntryPoints = map[string]traffic.EntryPointConfig{
81+ "public": {Address: ":8443", Protocol: domain.EntryPointProtocolTLSMux},
82+ "ssh": {Address: ":22", Protocol: domain.EntryPointProtocolTCP},
83+ }
84+
85+ assert.Equal(t, 8443, effectivePublicTLSPort(cfg))
86+}
87+
88+func TestEffectivePublicTLSPortFallsBackWhenAmbiguousWithoutEdge(t *testing.T) {
89+ cfg := Config{}
90+ cfg.EntryPoints = map[string]traffic.EntryPointConfig{
91+ "public-a": {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP},
92+ "public-b": {Address: ":8443", Protocol: domain.EntryPointProtocolTLSMux},
93+ }
94+
95+ for range 20 {
96+ assert.Equal(t, 443, effectivePublicTLSPort(cfg))
97+ }
98+}
99+
66100 func TestPublicTLSReadinessRequiresHTTP01ActuallyBoundExternalPort80(t *testing.T) {
67101 t.Run("rejects http entrypoint when smart tcp suppresses standalone http", func(t *testing.T) {
68102 cfg := Config{}
@@ -63,6 +63,40 @@ 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) {
67+ cfg := Config{}
68+ cfg.EntryPoints = map[string]traffic.EntryPointConfig{
69+ traffic.DefaultEdgeEntryPointName: {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP},
70+ "public": {Address: ":8443", Protocol: domain.EntryPointProtocolTLSMux},
71+ }
72+
73+ for range 20 {
74+ assert.Equal(t, 443, effectivePublicTLSPort(cfg))
75+ }
76+}
77+
78+func TestEffectivePublicTLSPortUsesSoleNonEdgeTLSCapableEntrypoint(t *testing.T) {
79+ cfg := Config{}
80+ cfg.EntryPoints = map[string]traffic.EntryPointConfig{
81+ "public": {Address: ":8443", Protocol: domain.EntryPointProtocolTLSMux},
82+ "ssh": {Address: ":22", Protocol: domain.EntryPointProtocolTCP},
83+ }
84+
85+ assert.Equal(t, 8443, effectivePublicTLSPort(cfg))
86+}
87+
88+func TestEffectivePublicTLSPortFallsBackWhenAmbiguousWithoutEdge(t *testing.T) {
89+ cfg := Config{}
90+ cfg.EntryPoints = map[string]traffic.EntryPointConfig{
91+ "public-a": {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP},
92+ "public-b": {Address: ":8443", Protocol: domain.EntryPointProtocolTLSMux},
93+ }
94+
95+ for range 20 {
96+ assert.Equal(t, 443, effectivePublicTLSPort(cfg))
97+ }
98+}
99+
66 func TestPublicTLSReadinessRequiresHTTP01ActuallyBoundExternalPort80(t *testing.T) {100 func TestPublicTLSReadinessRequiresHTTP01ActuallyBoundExternalPort80(t *testing.T) {
67 t.Run("rejects http entrypoint when smart tcp suppresses standalone http", func(t *testing.T) {101 t.Run("rejects http entrypoint when smart tcp suppresses standalone http", func(t *testing.T) {
68 cfg := Config{}102 cfg := Config{}
M internal/app/run.go +66 -10
@@ -2415,7 +2415,7 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr
24152415 middleware.PanicRecovery(log),
24162416 middleware.RequestLogger(log, trustedNets),
24172417 middleware.SecurityHeaders,
2418- middleware.HTTPSRedirect(proxyAllowedNets, cfg.Server.Port, cfg.Server.TLSPort, cfg.Server.ForceHTTPSRedirect, log, func(host string) bool {
2418+ middleware.HTTPSRedirect(proxyAllowedNets, effectiveProxyHTTPPort(cfg), effectiveProxyTLSPort(cfg), cfg.Server.ForceHTTPSRedirect, log, func(host string) bool {
24192419 return svc.proxySvc.IsKnownHost(context.Background(), host)
24202420 }),
24212421 }
@@ -2430,7 +2430,7 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr
24302430
24312431 // Build the onboarding handler once if internal CA is available and TLS is enabled.
24322432 var obHandler *onboarding.Handler
2433- if svc.caAdapter != nil && cfg.Server.TLSPort != 0 {
2433+ if svc.caAdapter != nil && effectiveProxyTLSPort(cfg) != 0 {
24342434 mobileconfigBytes := pkiadapter.GenerateMobileconfig(
24352435 svc.caAdapter.RootCertificateDER(),
24362436 svc.caAdapter.RootCommonName(),
@@ -2439,8 +2439,8 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr
24392439 svc.caAdapter.RootCertificate(),
24402440 mobileconfigBytes,
24412441 svc.caAdapter.RootFingerprint(),
2442- cfg.Server.Port,
2443- cfg.Server.TLSPort,
2442+ effectiveProxyHTTPPort(cfg),
2443+ effectiveProxyTLSPort(cfg),
24442444 )
24452445 }
24462446
@@ -3400,17 +3400,73 @@ func hasTLSCapableEntrypoint(cfg Config) bool {
34003400 }
34013401
34023402 func effectivePublicTLSPort(cfg Config) int {
3403- for _, entryPoint := range cfg.EntryPoints {
3404- switch entryPoint.Protocol {
3405- case domain.EntryPointProtocolSmartTCP, domain.EntryPointProtocolTLSMux:
3406- if port := portFromAddress(entryPoint.Address); port > 0 {
3407- return port
3408- }
3403+ if entryPoint, ok := cfg.EntryPoints[traffic.DefaultEdgeEntryPointName]; ok && tlsCapableEntryPoint(entryPoint.Protocol) {
3404+ if port := portFromAddress(entryPoint.Address); port > 0 {
3405+ return port
34093406 }
34103407 }
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+ }
34113425 return 443
34123426 }
34133427
3428+func effectiveProxyTLSPort(cfg Config) int {
3429+ if hasTLSCapableEntrypoint(cfg) {
3430+ return effectivePublicTLSPort(cfg)
3431+ }
3432+ return cfg.Server.TLSPort
3433+}
3434+
3435+func effectiveProxyHTTPPort(cfg Config) int {
3436+ if entryPoint, ok := cfg.EntryPoints[traffic.DefaultEdgeEntryPointName]; ok && entryPoint.Protocol == domain.EntryPointProtocolSmartTCP {
3437+ if port := portFromAddress(entryPoint.Address); port > 0 {
3438+ return port
3439+ }
3440+ }
3441+
3442+ var candidatePort int
3443+ candidates := 0
3444+ for name, entryPoint := range cfg.EntryPoints {
3445+ if name == traffic.DefaultEdgeEntryPointName || entryPoint.Protocol != domain.EntryPointProtocolSmartTCP {
3446+ continue
3447+ }
3448+ port := portFromAddress(entryPoint.Address)
3449+ if port == 0 {
3450+ continue
3451+ }
3452+ candidatePort = port
3453+ candidates++
3454+ }
3455+ if candidates == 1 {
3456+ return candidatePort
3457+ }
3458+ return cfg.Server.Port
3459+}
3460+
3461+func tlsCapableEntryPoint(protocol domain.EntryPointProtocol) bool {
3462+ switch protocol {
3463+ case domain.EntryPointProtocolSmartTCP, domain.EntryPointProtocolTLSMux:
3464+ return true
3465+ default:
3466+ return false
3467+ }
3468+}
3469+
34143470 func effectiveHTTP01Port(cfg Config) int {
34153471 if hasSmartTCPHTTP01Entrypoint(cfg) {
34163472 return 80
@@ -2415,7 +2415,7 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr
2415 middleware.PanicRecovery(log),2415 middleware.PanicRecovery(log),
2416 middleware.RequestLogger(log, trustedNets),2416 middleware.RequestLogger(log, trustedNets),
2417 middleware.SecurityHeaders,2417 middleware.SecurityHeaders,
2418- middleware.HTTPSRedirect(proxyAllowedNets, cfg.Server.Port, cfg.Server.TLSPort, cfg.Server.ForceHTTPSRedirect, log, func(host string) bool {2418+ middleware.HTTPSRedirect(proxyAllowedNets, effectiveProxyHTTPPort(cfg), effectiveProxyTLSPort(cfg), cfg.Server.ForceHTTPSRedirect, log, func(host string) bool {
2419 return svc.proxySvc.IsKnownHost(context.Background(), host)2419 return svc.proxySvc.IsKnownHost(context.Background(), host)
2420 }),2420 }),
2421 }2421 }
@@ -2430,7 +2430,7 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr
2430 2430
2431 // Build the onboarding handler once if internal CA is available and TLS is enabled.2431 // Build the onboarding handler once if internal CA is available and TLS is enabled.
2432 var obHandler *onboarding.Handler2432 var obHandler *onboarding.Handler
2433- if svc.caAdapter != nil && cfg.Server.TLSPort != 0 {2433+ if svc.caAdapter != nil && effectiveProxyTLSPort(cfg) != 0 {
2434 mobileconfigBytes := pkiadapter.GenerateMobileconfig(2434 mobileconfigBytes := pkiadapter.GenerateMobileconfig(
2435 svc.caAdapter.RootCertificateDER(),2435 svc.caAdapter.RootCertificateDER(),
2436 svc.caAdapter.RootCommonName(),2436 svc.caAdapter.RootCommonName(),
@@ -2439,8 +2439,8 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr
2439 svc.caAdapter.RootCertificate(),2439 svc.caAdapter.RootCertificate(),
2440 mobileconfigBytes,2440 mobileconfigBytes,
2441 svc.caAdapter.RootFingerprint(),2441 svc.caAdapter.RootFingerprint(),
2442- cfg.Server.Port,2442+ effectiveProxyHTTPPort(cfg),
2443- cfg.Server.TLSPort,2443+ effectiveProxyTLSPort(cfg),
2444 )2444 )
2445 }2445 }
2446 2446
@@ -3400,17 +3400,73 @@ func hasTLSCapableEntrypoint(cfg Config) bool {
3400 }3400 }
3401 3401
3402 func effectivePublicTLSPort(cfg Config) int {3402 func effectivePublicTLSPort(cfg Config) int {
3403- for _, entryPoint := range cfg.EntryPoints {3403+ if entryPoint, ok := cfg.EntryPoints[traffic.DefaultEdgeEntryPointName]; ok && tlsCapableEntryPoint(entryPoint.Protocol) {
3404- switch entryPoint.Protocol {3404+ if port := portFromAddress(entryPoint.Address); port > 0 {
3405- case domain.EntryPointProtocolSmartTCP, domain.EntryPointProtocolTLSMux:3405+ return port
3406- if port := portFromAddress(entryPoint.Address); port > 0 {
3407- return port
3408- }
3409 }3406 }
3410 }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+ }
3411 return 4433425 return 443
3412 }3426 }
3413 3427
3428+func effectiveProxyTLSPort(cfg Config) int {
3429+ if hasTLSCapableEntrypoint(cfg) {
3430+ return effectivePublicTLSPort(cfg)
3431+ }
3432+ return cfg.Server.TLSPort
3433+}
3434+
3435+func effectiveProxyHTTPPort(cfg Config) int {
3436+ if entryPoint, ok := cfg.EntryPoints[traffic.DefaultEdgeEntryPointName]; ok && entryPoint.Protocol == domain.EntryPointProtocolSmartTCP {
3437+ if port := portFromAddress(entryPoint.Address); port > 0 {
3438+ return port
3439+ }
3440+ }
3441+
3442+ var candidatePort int
3443+ candidates := 0
3444+ for name, entryPoint := range cfg.EntryPoints {
3445+ if name == traffic.DefaultEdgeEntryPointName || entryPoint.Protocol != domain.EntryPointProtocolSmartTCP {
3446+ continue
3447+ }
3448+ port := portFromAddress(entryPoint.Address)
3449+ if port == 0 {
3450+ continue
3451+ }
3452+ candidatePort = port
3453+ candidates++
3454+ }
3455+ if candidates == 1 {
3456+ return candidatePort
3457+ }
3458+ return cfg.Server.Port
3459+}
3460+
3461+func tlsCapableEntryPoint(protocol domain.EntryPointProtocol) bool {
3462+ switch protocol {
3463+ case domain.EntryPointProtocolSmartTCP, domain.EntryPointProtocolTLSMux:
3464+ return true
3465+ default:
3466+ return false
3467+ }
3468+}
3469+
3414 func effectiveHTTP01Port(cfg Config) int {3470 func effectiveHTTP01Port(cfg Config) int {
3415 if hasSmartTCPHTTP01Entrypoint(cfg) {3471 if hasSmartTCPHTTP01Entrypoint(cfg) {
3416 return 803472 return 80
M internal/app/run_http_handlers_test.go +46 -0
@@ -242,6 +242,52 @@ func TestCreateHTTPHandlers_ForceHTTPSRedirect_DoesNotBypassDirectOnboarding(t *
242242 assert.Contains(t, rec.Body.String(), "Trust CA Certificate")
243243 }
244244
245+func TestCreateHTTPHandlers_RedirectUsesSelectedEntrypointPorts(t *testing.T) {
246+ t.Parallel()
247+ cfg := Config{}
248+ cfg.Server.Port = 8088
249+ cfg.Server.TLSPort = 8443
250+ cfg.Server.ForceHTTPSRedirect = true
251+ cfg.EntryPoints = map[string]traffic.EntryPointConfig{
252+ traffic.DefaultEdgeEntryPointName: {Address: ":9443", Protocol: domain.EntryPointProtocolSmartTCP},
253+ }
254+ configSvc := inmocks.NewMockConfigService(t)
255+ configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{Domain: "app.example.com"}, nil)
256+ svc := &services{proxySvc: proxyusecase.NewService(nil, nil, configSvc, proxyusecase.Config{})}
257+
258+ _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
259+
260+ req := httptest.NewRequest(http.MethodGet, "/path", nil)
261+ req.RemoteAddr = directAddr
262+ req.Host = "app.example.com:9443"
263+ rec := httptest.NewRecorder()
264+ httpHandler.ServeHTTP(rec, req)
265+
266+ assert.Equal(t, http.StatusPermanentRedirect, rec.Code)
267+ assert.Equal(t, "https://app.example.com:9443/path", rec.Header().Get("Location"))
268+}
269+
270+func TestCreateHTTPHandlers_OnboardingUsesSelectedEntrypointPorts(t *testing.T) {
271+ t.Parallel()
272+ ca := newTestCA(t)
273+ cfg := newOnboardingConfig()
274+ cfg.EntryPoints = map[string]traffic.EntryPointConfig{
275+ "public": {Address: ":9443", Protocol: domain.EntryPointProtocolSmartTCP},
276+ }
277+ svc := &services{caAdapter: ca}
278+
279+ _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
280+
281+ req := httptest.NewRequest(http.MethodGet, "/.well-known/gordon/", nil)
282+ req.RemoteAddr = directAddr
283+ req.Host = "o2.bnema.dev:9443"
284+ rec := httptest.NewRecorder()
285+ httpHandler.ServeHTTP(rec, req)
286+
287+ assert.Equal(t, http.StatusOK, rec.Code)
288+ assert.Contains(t, rec.Body.String(), "https://o2.bnema.dev:9443/")
289+}
290+
245291 func TestCreateHTTPHandlers_HTTPSOnboarding_RemainsAvailableOnGordonDomain(t *testing.T) {
246292 t.Parallel()
247293 ca := newTestCA(t)
@@ -242,6 +242,52 @@ func TestCreateHTTPHandlers_ForceHTTPSRedirect_DoesNotBypassDirectOnboarding(t *
242 assert.Contains(t, rec.Body.String(), "Trust CA Certificate")242 assert.Contains(t, rec.Body.String(), "Trust CA Certificate")
243 }243 }
244 244
245+func TestCreateHTTPHandlers_RedirectUsesSelectedEntrypointPorts(t *testing.T) {
246+ t.Parallel()
247+ cfg := Config{}
248+ cfg.Server.Port = 8088
249+ cfg.Server.TLSPort = 8443
250+ cfg.Server.ForceHTTPSRedirect = true
251+ cfg.EntryPoints = map[string]traffic.EntryPointConfig{
252+ traffic.DefaultEdgeEntryPointName: {Address: ":9443", Protocol: domain.EntryPointProtocolSmartTCP},
253+ }
254+ configSvc := inmocks.NewMockConfigService(t)
255+ configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{Domain: "app.example.com"}, nil)
256+ svc := &services{proxySvc: proxyusecase.NewService(nil, nil, configSvc, proxyusecase.Config{})}
257+
258+ _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
259+
260+ req := httptest.NewRequest(http.MethodGet, "/path", nil)
261+ req.RemoteAddr = directAddr
262+ req.Host = "app.example.com:9443"
263+ rec := httptest.NewRecorder()
264+ httpHandler.ServeHTTP(rec, req)
265+
266+ assert.Equal(t, http.StatusPermanentRedirect, rec.Code)
267+ assert.Equal(t, "https://app.example.com:9443/path", rec.Header().Get("Location"))
268+}
269+
270+func TestCreateHTTPHandlers_OnboardingUsesSelectedEntrypointPorts(t *testing.T) {
271+ t.Parallel()
272+ ca := newTestCA(t)
273+ cfg := newOnboardingConfig()
274+ cfg.EntryPoints = map[string]traffic.EntryPointConfig{
275+ "public": {Address: ":9443", Protocol: domain.EntryPointProtocolSmartTCP},
276+ }
277+ svc := &services{caAdapter: ca}
278+
279+ _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
280+
281+ req := httptest.NewRequest(http.MethodGet, "/.well-known/gordon/", nil)
282+ req.RemoteAddr = directAddr
283+ req.Host = "o2.bnema.dev:9443"
284+ rec := httptest.NewRecorder()
285+ httpHandler.ServeHTTP(rec, req)
286+
287+ assert.Equal(t, http.StatusOK, rec.Code)
288+ assert.Contains(t, rec.Body.String(), "https://o2.bnema.dev:9443/")
289+}
290+
245 func TestCreateHTTPHandlers_HTTPSOnboarding_RemainsAvailableOnGordonDomain(t *testing.T) {291 func TestCreateHTTPHandlers_HTTPSOnboarding_RemainsAvailableOnGordonDomain(t *testing.T) {
246 t.Parallel()292 t.Parallel()
247 ca := newTestCA(t)293 ca := newTestCA(t)
A internal/boundaries/in/mocks/mock_standalone_service_service.go +158 -0
@@ -0,0 +1,158 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "github.com/bnema/gordon/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMockStandaloneServiceService creates a new instance of MockStandaloneServiceService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMockStandaloneServiceService(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MockStandaloneServiceService {
20+ mock := &MockStandaloneServiceService{}
21+ mock.Mock.Test(t)
22+
23+ t.Cleanup(func() { mock.AssertExpectations(t) })
24+
25+ return mock
26+}
27+
28+// MockStandaloneServiceService is an autogenerated mock type for the StandaloneServiceService type
29+type MockStandaloneServiceService struct {
30+ mock.Mock
31+}
32+
33+type MockStandaloneServiceService_Expecter struct {
34+ mock *mock.Mock
35+}
36+
37+func (_m *MockStandaloneServiceService) EXPECT() *MockStandaloneServiceService_Expecter {
38+ return &MockStandaloneServiceService_Expecter{mock: &_m.Mock}
39+}
40+
41+// Reconcile provides a mock function for the type MockStandaloneServiceService
42+func (_mock *MockStandaloneServiceService) Reconcile(ctx context.Context, services []domain.StandaloneService) error {
43+ ret := _mock.Called(ctx, services)
44+
45+ if len(ret) == 0 {
46+ panic("no return value specified for Reconcile")
47+ }
48+
49+ var r0 error
50+ if returnFunc, ok := ret.Get(0).(func(context.Context, []domain.StandaloneService) error); ok {
51+ r0 = returnFunc(ctx, services)
52+ } else {
53+ r0 = ret.Error(0)
54+ }
55+ return r0
56+}
57+
58+// MockStandaloneServiceService_Reconcile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Reconcile'
59+type MockStandaloneServiceService_Reconcile_Call struct {
60+ *mock.Call
61+}
62+
63+// Reconcile is a helper method to define mock.On call
64+// - ctx context.Context
65+// - services []domain.StandaloneService
66+func (_e *MockStandaloneServiceService_Expecter) Reconcile(ctx any, services any) *MockStandaloneServiceService_Reconcile_Call {
67+ return &MockStandaloneServiceService_Reconcile_Call{Call: _e.mock.On("Reconcile", ctx, services)}
68+}
69+
70+func (_c *MockStandaloneServiceService_Reconcile_Call) Run(run func(ctx context.Context, services []domain.StandaloneService)) *MockStandaloneServiceService_Reconcile_Call {
71+ _c.Call.Run(func(args mock.Arguments) {
72+ var arg0 context.Context
73+ if args[0] != nil {
74+ arg0 = args[0].(context.Context)
75+ }
76+ var arg1 []domain.StandaloneService
77+ if args[1] != nil {
78+ arg1 = args[1].([]domain.StandaloneService)
79+ }
80+ run(
81+ arg0,
82+ arg1,
83+ )
84+ })
85+ return _c
86+}
87+
88+func (_c *MockStandaloneServiceService_Reconcile_Call) Return(err error) *MockStandaloneServiceService_Reconcile_Call {
89+ _c.Call.Return(err)
90+ return _c
91+}
92+
93+func (_c *MockStandaloneServiceService_Reconcile_Call) RunAndReturn(run func(ctx context.Context, services []domain.StandaloneService) error) *MockStandaloneServiceService_Reconcile_Call {
94+ _c.Call.Return(run)
95+ return _c
96+}
97+
98+// Status provides a mock function for the type MockStandaloneServiceService
99+func (_mock *MockStandaloneServiceService) Status(ctx context.Context) ([]domain.StandaloneServiceStatus, error) {
100+ ret := _mock.Called(ctx)
101+
102+ if len(ret) == 0 {
103+ panic("no return value specified for Status")
104+ }
105+
106+ var r0 []domain.StandaloneServiceStatus
107+ var r1 error
108+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]domain.StandaloneServiceStatus, error)); ok {
109+ return returnFunc(ctx)
110+ }
111+ if returnFunc, ok := ret.Get(0).(func(context.Context) []domain.StandaloneServiceStatus); ok {
112+ r0 = returnFunc(ctx)
113+ } else {
114+ if ret.Get(0) != nil {
115+ r0 = ret.Get(0).([]domain.StandaloneServiceStatus)
116+ }
117+ }
118+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
119+ r1 = returnFunc(ctx)
120+ } else {
121+ r1 = ret.Error(1)
122+ }
123+ return r0, r1
124+}
125+
126+// MockStandaloneServiceService_Status_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Status'
127+type MockStandaloneServiceService_Status_Call struct {
128+ *mock.Call
129+}
130+
131+// Status is a helper method to define mock.On call
132+// - ctx context.Context
133+func (_e *MockStandaloneServiceService_Expecter) Status(ctx any) *MockStandaloneServiceService_Status_Call {
134+ return &MockStandaloneServiceService_Status_Call{Call: _e.mock.On("Status", ctx)}
135+}
136+
137+func (_c *MockStandaloneServiceService_Status_Call) Run(run func(ctx context.Context)) *MockStandaloneServiceService_Status_Call {
138+ _c.Call.Run(func(args mock.Arguments) {
139+ var arg0 context.Context
140+ if args[0] != nil {
141+ arg0 = args[0].(context.Context)
142+ }
143+ run(
144+ arg0,
145+ )
146+ })
147+ return _c
148+}
149+
150+func (_c *MockStandaloneServiceService_Status_Call) Return(standaloneServiceStatuss []domain.StandaloneServiceStatus, err error) *MockStandaloneServiceService_Status_Call {
151+ _c.Call.Return(standaloneServiceStatuss, err)
152+ return _c
153+}
154+
155+func (_c *MockStandaloneServiceService_Status_Call) RunAndReturn(run func(ctx context.Context) ([]domain.StandaloneServiceStatus, error)) *MockStandaloneServiceService_Status_Call {
156+ _c.Call.Return(run)
157+ return _c
158+}
@@ -0,0 +1,158 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "github.com/bnema/gordon/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMockStandaloneServiceService creates a new instance of MockStandaloneServiceService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMockStandaloneServiceService(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MockStandaloneServiceService {
20+ mock := &MockStandaloneServiceService{}
21+ mock.Mock.Test(t)
22+
23+ t.Cleanup(func() { mock.AssertExpectations(t) })
24+
25+ return mock
26+}
27+
28+// MockStandaloneServiceService is an autogenerated mock type for the StandaloneServiceService type
29+type MockStandaloneServiceService struct {
30+ mock.Mock
31+}
32+
33+type MockStandaloneServiceService_Expecter struct {
34+ mock *mock.Mock
35+}
36+
37+func (_m *MockStandaloneServiceService) EXPECT() *MockStandaloneServiceService_Expecter {
38+ return &MockStandaloneServiceService_Expecter{mock: &_m.Mock}
39+}
40+
41+// Reconcile provides a mock function for the type MockStandaloneServiceService
42+func (_mock *MockStandaloneServiceService) Reconcile(ctx context.Context, services []domain.StandaloneService) error {
43+ ret := _mock.Called(ctx, services)
44+
45+ if len(ret) == 0 {
46+ panic("no return value specified for Reconcile")
47+ }
48+
49+ var r0 error
50+ if returnFunc, ok := ret.Get(0).(func(context.Context, []domain.StandaloneService) error); ok {
51+ r0 = returnFunc(ctx, services)
52+ } else {
53+ r0 = ret.Error(0)
54+ }
55+ return r0
56+}
57+
58+// MockStandaloneServiceService_Reconcile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Reconcile'
59+type MockStandaloneServiceService_Reconcile_Call struct {
60+ *mock.Call
61+}
62+
63+// Reconcile is a helper method to define mock.On call
64+// - ctx context.Context
65+// - services []domain.StandaloneService
66+func (_e *MockStandaloneServiceService_Expecter) Reconcile(ctx any, services any) *MockStandaloneServiceService_Reconcile_Call {
67+ return &MockStandaloneServiceService_Reconcile_Call{Call: _e.mock.On("Reconcile", ctx, services)}
68+}
69+
70+func (_c *MockStandaloneServiceService_Reconcile_Call) Run(run func(ctx context.Context, services []domain.StandaloneService)) *MockStandaloneServiceService_Reconcile_Call {
71+ _c.Call.Run(func(args mock.Arguments) {
72+ var arg0 context.Context
73+ if args[0] != nil {
74+ arg0 = args[0].(context.Context)
75+ }
76+ var arg1 []domain.StandaloneService
77+ if args[1] != nil {
78+ arg1 = args[1].([]domain.StandaloneService)
79+ }
80+ run(
81+ arg0,
82+ arg1,
83+ )
84+ })
85+ return _c
86+}
87+
88+func (_c *MockStandaloneServiceService_Reconcile_Call) Return(err error) *MockStandaloneServiceService_Reconcile_Call {
89+ _c.Call.Return(err)
90+ return _c
91+}
92+
93+func (_c *MockStandaloneServiceService_Reconcile_Call) RunAndReturn(run func(ctx context.Context, services []domain.StandaloneService) error) *MockStandaloneServiceService_Reconcile_Call {
94+ _c.Call.Return(run)
95+ return _c
96+}
97+
98+// Status provides a mock function for the type MockStandaloneServiceService
99+func (_mock *MockStandaloneServiceService) Status(ctx context.Context) ([]domain.StandaloneServiceStatus, error) {
100+ ret := _mock.Called(ctx)
101+
102+ if len(ret) == 0 {
103+ panic("no return value specified for Status")
104+ }
105+
106+ var r0 []domain.StandaloneServiceStatus
107+ var r1 error
108+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]domain.StandaloneServiceStatus, error)); ok {
109+ return returnFunc(ctx)
110+ }
111+ if returnFunc, ok := ret.Get(0).(func(context.Context) []domain.StandaloneServiceStatus); ok {
112+ r0 = returnFunc(ctx)
113+ } else {
114+ if ret.Get(0) != nil {
115+ r0 = ret.Get(0).([]domain.StandaloneServiceStatus)
116+ }
117+ }
118+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
119+ r1 = returnFunc(ctx)
120+ } else {
121+ r1 = ret.Error(1)
122+ }
123+ return r0, r1
124+}
125+
126+// MockStandaloneServiceService_Status_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Status'
127+type MockStandaloneServiceService_Status_Call struct {
128+ *mock.Call
129+}
130+
131+// Status is a helper method to define mock.On call
132+// - ctx context.Context
133+func (_e *MockStandaloneServiceService_Expecter) Status(ctx any) *MockStandaloneServiceService_Status_Call {
134+ return &MockStandaloneServiceService_Status_Call{Call: _e.mock.On("Status", ctx)}
135+}
136+
137+func (_c *MockStandaloneServiceService_Status_Call) Run(run func(ctx context.Context)) *MockStandaloneServiceService_Status_Call {
138+ _c.Call.Run(func(args mock.Arguments) {
139+ var arg0 context.Context
140+ if args[0] != nil {
141+ arg0 = args[0].(context.Context)
142+ }
143+ run(
144+ arg0,
145+ )
146+ })
147+ return _c
148+}
149+
150+func (_c *MockStandaloneServiceService_Status_Call) Return(standaloneServiceStatuss []domain.StandaloneServiceStatus, err error) *MockStandaloneServiceService_Status_Call {
151+ _c.Call.Return(standaloneServiceStatuss, err)
152+ return _c
153+}
154+
155+func (_c *MockStandaloneServiceService_Status_Call) RunAndReturn(run func(ctx context.Context) ([]domain.StandaloneServiceStatus, error)) *MockStandaloneServiceService_Status_Call {
156+ _c.Call.Return(run)
157+ return _c
158+}
M internal/domain/errors.go +1 -0
@@ -50,6 +50,7 @@ var (
5050
5151 // Config errors
5252 ErrTrafficGraphRequired = errors.New("traffic graph is required")
53+ ErrClientHelloTooLarge = errors.New("client hello exceeds maximum")
5354 ErrConfigNotFound = errors.New("configuration not found")
5455 ErrInvalidConfig = errors.New("invalid configuration")
5556 ErrConfigLoadFailed = errors.New("failed to load configuration")
@@ -50,6 +50,7 @@ var (
50 50
51 // Config errors51 // Config errors
52 ErrTrafficGraphRequired = errors.New("traffic graph is required")52 ErrTrafficGraphRequired = errors.New("traffic graph is required")
53+ ErrClientHelloTooLarge = errors.New("client hello exceeds maximum")
53 ErrConfigNotFound = errors.New("configuration not found")54 ErrConfigNotFound = errors.New("configuration not found")
54 ErrInvalidConfig = errors.New("invalid configuration")55 ErrInvalidConfig = errors.New("invalid configuration")
55 ErrConfigLoadFailed = errors.New("failed to load configuration")56 ErrConfigLoadFailed = errors.New("failed to load configuration")
M internal/domain/service.go +12 -3
@@ -79,7 +79,10 @@ func (s StandaloneService) WithDefaults() StandaloneService {
7979 }
8080
8181 func (c StandaloneServiceCleanup) WithDefaults() StandaloneServiceCleanup {
82- return StandaloneServiceCleanup{PreserveVolumes: true, RemoveContainer: true}
82+ if !c.PreserveVolumes && !c.RemoveContainer {
83+ return StandaloneServiceCleanup{PreserveVolumes: true, RemoveContainer: true}
84+ }
85+ return c
8386 }
8487
8588 func (s StandaloneService) Validate() error {
@@ -162,13 +165,19 @@ func validateStandaloneServicePublish(serviceName, portName, publish string) err
162165 }
163166
164167 func validateStandaloneServiceVolumes(s StandaloneService) error {
168+ seenTargets := make(map[string]struct{}, len(s.Volumes))
165169 for i, volume := range s.Volumes {
166- if strings.TrimSpace(volume.Target) == "" {
170+ target := strings.TrimSpace(volume.Target)
171+ if target == "" {
167172 return fmt.Errorf("standalone service %q volume %d target is required", s.Name, i)
168173 }
169- if !filepath.IsAbs(volume.Target) {
174+ if !filepath.IsAbs(target) {
170175 return fmt.Errorf("standalone service %q volume %d target %q must be an absolute container path", s.Name, i, volume.Target)
171176 }
177+ if _, ok := seenTargets[target]; ok {
178+ return fmt.Errorf("standalone service %q duplicate volume target %q", s.Name, target)
179+ }
180+ seenTargets[target] = struct{}{}
172181 }
173182 return nil
174183 }
@@ -79,7 +79,10 @@ func (s StandaloneService) WithDefaults() StandaloneService {
79 }79 }
80 80
81 func (c StandaloneServiceCleanup) WithDefaults() StandaloneServiceCleanup {81 func (c StandaloneServiceCleanup) WithDefaults() StandaloneServiceCleanup {
82- return StandaloneServiceCleanup{PreserveVolumes: true, RemoveContainer: true}82+ if !c.PreserveVolumes && !c.RemoveContainer {
83+ return StandaloneServiceCleanup{PreserveVolumes: true, RemoveContainer: true}
84+ }
85+ return c
83 }86 }
84 87
85 func (s StandaloneService) Validate() error {88 func (s StandaloneService) Validate() error {
@@ -162,13 +165,19 @@ func validateStandaloneServicePublish(serviceName, portName, publish string) err
162 }165 }
163 166
164 func validateStandaloneServiceVolumes(s StandaloneService) error {167 func validateStandaloneServiceVolumes(s StandaloneService) error {
168+ seenTargets := make(map[string]struct{}, len(s.Volumes))
165 for i, volume := range s.Volumes {169 for i, volume := range s.Volumes {
166- if strings.TrimSpace(volume.Target) == "" {170+ target := strings.TrimSpace(volume.Target)
171+ if target == "" {
167 return fmt.Errorf("standalone service %q volume %d target is required", s.Name, i)172 return fmt.Errorf("standalone service %q volume %d target is required", s.Name, i)
168 }173 }
169- if !filepath.IsAbs(volume.Target) {174+ if !filepath.IsAbs(target) {
170 return fmt.Errorf("standalone service %q volume %d target %q must be an absolute container path", s.Name, i, volume.Target)175 return fmt.Errorf("standalone service %q volume %d target %q must be an absolute container path", s.Name, i, volume.Target)
171 }176 }
177+ if _, ok := seenTargets[target]; ok {
178+ return fmt.Errorf("standalone service %q duplicate volume target %q", s.Name, target)
179+ }
180+ seenTargets[target] = struct{}{}
172 }181 }
173 return nil182 return nil
174 }183 }
M internal/domain/service_test.go +21 -0
@@ -91,4 +91,25 @@ func TestStandaloneServiceWithDefaultsCleanup(t *testing.T) {
9191 require.True(t, svc.Cleanup.PreserveVolumes)
9292 require.True(t, svc.Cleanup.RemoveContainer)
9393 require.NoError(t, svc.Validate())
94+
95+ svc = StandaloneService{Name: "game", Image: "game:latest", Enabled: true, Cleanup: StandaloneServiceCleanup{RemoveContainer: true}}.WithDefaults()
96+ require.False(t, svc.Cleanup.PreserveVolumes)
97+ require.True(t, svc.Cleanup.RemoveContainer)
98+
99+ svc = StandaloneService{Name: "game", Image: "game:latest", Enabled: true, Cleanup: StandaloneServiceCleanup{PreserveVolumes: true}}.WithDefaults()
100+ require.True(t, svc.Cleanup.PreserveVolumes)
101+ require.False(t, svc.Cleanup.RemoveContainer)
102+}
103+
104+func TestStandaloneServiceValidateRejectsDuplicateVolumeTargets(t *testing.T) {
105+ svc := StandaloneService{
106+ Name: "cache",
107+ Image: "redis:7",
108+ Enabled: true,
109+ Volumes: []StandaloneServiceVolume{
110+ {Source: "cache-data", Target: "/data"},
111+ {Source: "cache-other", Target: "/data"},
112+ },
113+ }
114+ require.ErrorContains(t, svc.Validate(), "duplicate volume target")
94115 }
@@ -91,4 +91,25 @@ func TestStandaloneServiceWithDefaultsCleanup(t *testing.T) {
91 require.True(t, svc.Cleanup.PreserveVolumes)91 require.True(t, svc.Cleanup.PreserveVolumes)
92 require.True(t, svc.Cleanup.RemoveContainer)92 require.True(t, svc.Cleanup.RemoveContainer)
93 require.NoError(t, svc.Validate())93 require.NoError(t, svc.Validate())
94+
95+ svc = StandaloneService{Name: "game", Image: "game:latest", Enabled: true, Cleanup: StandaloneServiceCleanup{RemoveContainer: true}}.WithDefaults()
96+ require.False(t, svc.Cleanup.PreserveVolumes)
97+ require.True(t, svc.Cleanup.RemoveContainer)
98+
99+ svc = StandaloneService{Name: "game", Image: "game:latest", Enabled: true, Cleanup: StandaloneServiceCleanup{PreserveVolumes: true}}.WithDefaults()
100+ require.True(t, svc.Cleanup.PreserveVolumes)
101+ require.False(t, svc.Cleanup.RemoveContainer)
102+}
103+
104+func TestStandaloneServiceValidateRejectsDuplicateVolumeTargets(t *testing.T) {
105+ svc := StandaloneService{
106+ Name: "cache",
107+ Image: "redis:7",
108+ Enabled: true,
109+ Volumes: []StandaloneServiceVolume{
110+ {Source: "cache-data", Target: "/data"},
111+ {Source: "cache-other", Target: "/data"},
112+ },
113+ }
114+ require.ErrorContains(t, svc.Validate(), "duplicate volume target")
94 }115 }
M internal/domain/traffic.go +19 -0
@@ -299,6 +299,9 @@ func validateTrafficEntryPoint(entryPoint EntryPoint, existing map[string]EntryP
299299 if err := validateEntryPointProtocol(entryPoint.Protocol); err != nil {
300300 return fmt.Errorf("invalid entrypoint protocol for %q: %w", entryPoint.Name, err)
301301 }
302+ if err := validateRawFallbackEntrypoint(entryPoint); err != nil {
303+ return err
304+ }
302305 if err := validateTrustedCIDRs(entryPoint); err != nil {
303306 return err
304307 }
@@ -586,6 +589,22 @@ func l4BackendProtocol(protocol RouterProtocol) (NetworkProtocol, bool) {
586589 }
587590 }
588591
592+func validateRawFallbackEntrypoint(entryPoint EntryPoint) error {
593+ if entryPoint.Protocol == EntryPointProtocolSmartTCP {
594+ return nil
595+ }
596+ if entryPoint.RawFallback != "" {
597+ return fmt.Errorf("raw_fallback is only supported on smart_tcp entrypoint %q", entryPoint.Name)
598+ }
599+ if len(entryPoint.RawFallbackTrustedCIDRs) > 0 {
600+ return fmt.Errorf("raw_fallback_trusted_cidrs is only supported on smart_tcp entrypoint %q", entryPoint.Name)
601+ }
602+ if entryPoint.AllowPublicRawFallback {
603+ return fmt.Errorf("allow_public_raw_fallback is only supported on smart_tcp entrypoint %q", entryPoint.Name)
604+ }
605+ return nil
606+}
607+
589608 func validateTrustedCIDRs(entryPoint EntryPoint) error {
590609 for _, cidr := range entryPoint.TrustedCIDRs {
591610 if _, _, err := net.ParseCIDR(strings.TrimSpace(cidr)); err != nil {
@@ -299,6 +299,9 @@ func validateTrafficEntryPoint(entryPoint EntryPoint, existing map[string]EntryP
299 if err := validateEntryPointProtocol(entryPoint.Protocol); err != nil {299 if err := validateEntryPointProtocol(entryPoint.Protocol); err != nil {
300 return fmt.Errorf("invalid entrypoint protocol for %q: %w", entryPoint.Name, err)300 return fmt.Errorf("invalid entrypoint protocol for %q: %w", entryPoint.Name, err)
301 }301 }
302+ if err := validateRawFallbackEntrypoint(entryPoint); err != nil {
303+ return err
304+ }
302 if err := validateTrustedCIDRs(entryPoint); err != nil {305 if err := validateTrustedCIDRs(entryPoint); err != nil {
303 return err306 return err
304 }307 }
@@ -586,6 +589,22 @@ func l4BackendProtocol(protocol RouterProtocol) (NetworkProtocol, bool) {
586 }589 }
587 }590 }
588 591
592+func validateRawFallbackEntrypoint(entryPoint EntryPoint) error {
593+ if entryPoint.Protocol == EntryPointProtocolSmartTCP {
594+ return nil
595+ }
596+ if entryPoint.RawFallback != "" {
597+ return fmt.Errorf("raw_fallback is only supported on smart_tcp entrypoint %q", entryPoint.Name)
598+ }
599+ if len(entryPoint.RawFallbackTrustedCIDRs) > 0 {
600+ return fmt.Errorf("raw_fallback_trusted_cidrs is only supported on smart_tcp entrypoint %q", entryPoint.Name)
601+ }
602+ if entryPoint.AllowPublicRawFallback {
603+ return fmt.Errorf("allow_public_raw_fallback is only supported on smart_tcp entrypoint %q", entryPoint.Name)
604+ }
605+ return nil
606+}
607+
589 func validateTrustedCIDRs(entryPoint EntryPoint) error {608 func validateTrustedCIDRs(entryPoint EntryPoint) error {
590 for _, cidr := range entryPoint.TrustedCIDRs {609 for _, cidr := range entryPoint.TrustedCIDRs {
591 if _, _, err := net.ParseCIDR(strings.TrimSpace(cidr)); err != nil {610 if _, _, err := net.ParseCIDR(strings.TrimSpace(cidr)); err != nil {
M internal/domain/traffic_test.go +16 -0
@@ -68,6 +68,22 @@ func TestTrafficGraphSmartTCPRawFallbackValidation(t *testing.T) {
6868 routers: []TrafficRouter{{Name: "ssh-fallback", EntryPoint: "edge", Protocol: RouterProtocolTCP, Service: "network_service:ssh:ssh"}},
6969 wantErr: "invalid raw_fallback_trusted_cidrs",
7070 },
71+ {
72+ name: "raw fallback rejected on tcp entrypoint",
73+ entryPoint: EntryPoint{Name: "edge", Address: ":443", Protocol: EntryPointProtocolTCP, RawFallback: "ssh-fallback", RawFallbackTrustedCIDRs: []string{"10.0.0.0/8"}},
74+ routers: []TrafficRouter{{Name: "ssh-fallback", EntryPoint: "edge", Protocol: RouterProtocolTCP, Service: "network_service:ssh:ssh"}},
75+ wantErr: "raw_fallback is only supported on smart_tcp",
76+ },
77+ {
78+ name: "raw fallback cidrs rejected on tls mux entrypoint",
79+ entryPoint: EntryPoint{Name: "edge", Address: ":443", Protocol: EntryPointProtocolTLSMux, RawFallbackTrustedCIDRs: []string{"10.0.0.0/8"}},
80+ wantErr: "raw_fallback_trusted_cidrs is only supported on smart_tcp",
81+ },
82+ {
83+ name: "public raw fallback flag rejected on udp entrypoint",
84+ entryPoint: EntryPoint{Name: "edge", Address: ":443", Protocol: EntryPointProtocolUDP, AllowPublicRawFallback: true},
85+ wantErr: "allow_public_raw_fallback is only supported on smart_tcp",
86+ },
7187 }
7288
7389 for _, tt := range tests {
@@ -68,6 +68,22 @@ func TestTrafficGraphSmartTCPRawFallbackValidation(t *testing.T) {
68 routers: []TrafficRouter{{Name: "ssh-fallback", EntryPoint: "edge", Protocol: RouterProtocolTCP, Service: "network_service:ssh:ssh"}},68 routers: []TrafficRouter{{Name: "ssh-fallback", EntryPoint: "edge", Protocol: RouterProtocolTCP, Service: "network_service:ssh:ssh"}},
69 wantErr: "invalid raw_fallback_trusted_cidrs",69 wantErr: "invalid raw_fallback_trusted_cidrs",
70 },70 },
71+ {
72+ name: "raw fallback rejected on tcp entrypoint",
73+ entryPoint: EntryPoint{Name: "edge", Address: ":443", Protocol: EntryPointProtocolTCP, RawFallback: "ssh-fallback", RawFallbackTrustedCIDRs: []string{"10.0.0.0/8"}},
74+ routers: []TrafficRouter{{Name: "ssh-fallback", EntryPoint: "edge", Protocol: RouterProtocolTCP, Service: "network_service:ssh:ssh"}},
75+ wantErr: "raw_fallback is only supported on smart_tcp",
76+ },
77+ {
78+ name: "raw fallback cidrs rejected on tls mux entrypoint",
79+ entryPoint: EntryPoint{Name: "edge", Address: ":443", Protocol: EntryPointProtocolTLSMux, RawFallbackTrustedCIDRs: []string{"10.0.0.0/8"}},
80+ wantErr: "raw_fallback_trusted_cidrs is only supported on smart_tcp",
81+ },
82+ {
83+ name: "public raw fallback flag rejected on udp entrypoint",
84+ entryPoint: EntryPoint{Name: "edge", Address: ":443", Protocol: EntryPointProtocolUDP, AllowPublicRawFallback: true},
85+ wantErr: "allow_public_raw_fallback is only supported on smart_tcp",
86+ },
71 }87 }
72 88
73 for _, tt := range tests {89 for _, tt := range tests {
M internal/usecase/services/config.go +13 -0
@@ -80,6 +80,9 @@ func ToDomain(configs []Config) ([]domain.StandaloneService, error) {
8080 }
8181
8282 func (c Config) ToDomain() (domain.StandaloneService, error) {
83+ if err := c.Cleanup.validate(); err != nil {
84+ return domain.StandaloneService{}, err
85+ }
8386 readiness, err := c.readinessToDomain()
8487 if err != nil {
8588 return domain.StandaloneService{}, err
@@ -133,6 +136,16 @@ func (c CleanupConfig) toDomain() domain.StandaloneServiceCleanup {
133136 return cleanup
134137 }
135138
139+func (c CleanupConfig) validate() error {
140+ if c.PreserveVolumes == nil || c.RemoveContainer == nil {
141+ return nil
142+ }
143+ if !*c.PreserveVolumes && !*c.RemoveContainer {
144+ return fmt.Errorf("cleanup cannot set preserve_volumes=false with remove_container=false")
145+ }
146+ return nil
147+}
148+
136149 func portsToDomain(configs []PortConfig) []domain.StandaloneServicePort {
137150 ports := make([]domain.StandaloneServicePort, 0, len(configs))
138151 for _, cfg := range configs {
@@ -80,6 +80,9 @@ func ToDomain(configs []Config) ([]domain.StandaloneService, error) {
80 }80 }
81 81
82 func (c Config) ToDomain() (domain.StandaloneService, error) {82 func (c Config) ToDomain() (domain.StandaloneService, error) {
83+ if err := c.Cleanup.validate(); err != nil {
84+ return domain.StandaloneService{}, err
85+ }
83 readiness, err := c.readinessToDomain()86 readiness, err := c.readinessToDomain()
84 if err != nil {87 if err != nil {
85 return domain.StandaloneService{}, err88 return domain.StandaloneService{}, err
@@ -133,6 +136,16 @@ func (c CleanupConfig) toDomain() domain.StandaloneServiceCleanup {
133 return cleanup136 return cleanup
134 }137 }
135 138
139+func (c CleanupConfig) validate() error {
140+ if c.PreserveVolumes == nil || c.RemoveContainer == nil {
141+ return nil
142+ }
143+ if !*c.PreserveVolumes && !*c.RemoveContainer {
144+ return fmt.Errorf("cleanup cannot set preserve_volumes=false with remove_container=false")
145+ }
146+ return nil
147+}
148+
136 func portsToDomain(configs []PortConfig) []domain.StandaloneServicePort {149 func portsToDomain(configs []PortConfig) []domain.StandaloneServicePort {
137 ports := make([]domain.StandaloneServicePort, 0, len(configs))150 ports := make([]domain.StandaloneServicePort, 0, len(configs))
138 for _, cfg := range configs {151 for _, cfg := range configs {
M internal/usecase/services/config_test.go +16 -0
@@ -41,6 +41,22 @@ func TestConfigToDomainRejectsNonPositiveReadinessTimeout(t *testing.T) {
4141 }
4242 }
4343
44+func TestConfigToDomainRejectsExplicitNoCleanupAction(t *testing.T) {
45+ preserveVolumes := false
46+ removeContainer := false
47+ cfg := Config{
48+ Name: "cache",
49+ Image: "redis:7",
50+ Enabled: true,
51+ Cleanup: CleanupConfig{PreserveVolumes: &preserveVolumes, RemoveContainer: &removeContainer},
52+ }
53+
54+ _, err := cfg.ToDomain()
55+
56+ require.ErrorContains(t, err, "preserve_volumes=false")
57+ require.ErrorContains(t, err, "remove_container=false")
58+}
59+
4460 func TestConfigToDomainMapsExplicitPublicPorts(t *testing.T) {
4561 cfg := Config{
4662 Name: "game",
@@ -41,6 +41,22 @@ func TestConfigToDomainRejectsNonPositiveReadinessTimeout(t *testing.T) {
41 }41 }
42 }42 }
43 43
44+func TestConfigToDomainRejectsExplicitNoCleanupAction(t *testing.T) {
45+ preserveVolumes := false
46+ removeContainer := false
47+ cfg := Config{
48+ Name: "cache",
49+ Image: "redis:7",
50+ Enabled: true,
51+ Cleanup: CleanupConfig{PreserveVolumes: &preserveVolumes, RemoveContainer: &removeContainer},
52+ }
53+
54+ _, err := cfg.ToDomain()
55+
56+ require.ErrorContains(t, err, "preserve_volumes=false")
57+ require.ErrorContains(t, err, "remove_container=false")
58+}
59+
44 func TestConfigToDomainMapsExplicitPublicPorts(t *testing.T) {60 func TestConfigToDomainMapsExplicitPublicPorts(t *testing.T) {
45 cfg := Config{61 cfg := Config{
46 Name: "game",62 Name: "game",
M internal/usecase/services/service.go +13 -3
@@ -101,6 +101,7 @@ func (s *Service) reconcileOne(ctx context.Context, svc domain.StandaloneService
101101 if len(existing) == 0 {
102102 return s.createAndStart(ctx, svc, hash)
103103 }
104+ sort.SliceStable(existing, func(i, j int) bool { return existing[i].ID < existing[j].ID })
104105 current := existing[0]
105106 if current.Labels[domain.LabelServiceConfigHash] != hash {
106107 if err := s.recreate(ctx, svc, cleanup, existing, hash); err != nil {
@@ -108,6 +109,11 @@ func (s *Service) reconcileOne(ctx context.Context, svc domain.StandaloneService
108109 }
109110 return nil
110111 }
112+ for _, duplicate := range existing[1:] {
113+ if err := s.cleanupContainer(ctx, svc.Name, "duplicate", cleanup, duplicate); err != nil {
114+ return err
115+ }
116+ }
111117 if containerStatus(current) != domain.ContainerStatusRunning {
112118 if err := s.runtime.StartContainer(ctx, current.ID); err != nil {
113119 return fmt.Errorf("start standalone service %q container: %w", svc.Name, err)
@@ -240,9 +246,13 @@ func (s *Service) createAndStart(ctx context.Context, svc domain.StandaloneServi
240246 }
241247
242248 func (s *Service) containerConfig(ctx context.Context, svc domain.StandaloneService, hash string) (*domain.ContainerConfig, error) {
243- imageVolumes, err := s.runtime.InspectImageVolumes(ctx, svc.Image)
244- if err != nil {
245- return nil, fmt.Errorf("inspect standalone service %q image volumes: %w", svc.Name, err)
249+ var imageVolumes []string
250+ if len(svc.Volumes) == 0 {
251+ var err error
252+ imageVolumes, err = s.runtime.InspectImageVolumes(ctx, svc.Image)
253+ if err != nil {
254+ return nil, fmt.Errorf("inspect standalone service %q image volumes: %w", svc.Name, err)
255+ }
246256 }
247257 mounts := ResolveVolumeMounts(s.volumePrefix, svc.Name, svc.Volumes, imageVolumes)
248258 volumes := make(map[string]string)
@@ -101,6 +101,7 @@ func (s *Service) reconcileOne(ctx context.Context, svc domain.StandaloneService
101 if len(existing) == 0 {101 if len(existing) == 0 {
102 return s.createAndStart(ctx, svc, hash)102 return s.createAndStart(ctx, svc, hash)
103 }103 }
104+ sort.SliceStable(existing, func(i, j int) bool { return existing[i].ID < existing[j].ID })
104 current := existing[0]105 current := existing[0]
105 if current.Labels[domain.LabelServiceConfigHash] != hash {106 if current.Labels[domain.LabelServiceConfigHash] != hash {
106 if err := s.recreate(ctx, svc, cleanup, existing, hash); err != nil {107 if err := s.recreate(ctx, svc, cleanup, existing, hash); err != nil {
@@ -108,6 +109,11 @@ func (s *Service) reconcileOne(ctx context.Context, svc domain.StandaloneService
108 }109 }
109 return nil110 return nil
110 }111 }
112+ for _, duplicate := range existing[1:] {
113+ if err := s.cleanupContainer(ctx, svc.Name, "duplicate", cleanup, duplicate); err != nil {
114+ return err
115+ }
116+ }
111 if containerStatus(current) != domain.ContainerStatusRunning {117 if containerStatus(current) != domain.ContainerStatusRunning {
112 if err := s.runtime.StartContainer(ctx, current.ID); err != nil {118 if err := s.runtime.StartContainer(ctx, current.ID); err != nil {
113 return fmt.Errorf("start standalone service %q container: %w", svc.Name, err)119 return fmt.Errorf("start standalone service %q container: %w", svc.Name, err)
@@ -240,9 +246,13 @@ func (s *Service) createAndStart(ctx context.Context, svc domain.StandaloneServi
240 }246 }
241 247
242 func (s *Service) containerConfig(ctx context.Context, svc domain.StandaloneService, hash string) (*domain.ContainerConfig, error) {248 func (s *Service) containerConfig(ctx context.Context, svc domain.StandaloneService, hash string) (*domain.ContainerConfig, error) {
243- imageVolumes, err := s.runtime.InspectImageVolumes(ctx, svc.Image)249+ var imageVolumes []string
244- if err != nil {250+ if len(svc.Volumes) == 0 {
245- return nil, fmt.Errorf("inspect standalone service %q image volumes: %w", svc.Name, err)251+ var err error
252+ imageVolumes, err = s.runtime.InspectImageVolumes(ctx, svc.Image)
253+ if err != nil {
254+ return nil, fmt.Errorf("inspect standalone service %q image volumes: %w", svc.Name, err)
255+ }
246 }256 }
247 mounts := ResolveVolumeMounts(s.volumePrefix, svc.Name, svc.Volumes, imageVolumes)257 mounts := ResolveVolumeMounts(s.volumePrefix, svc.Name, svc.Volumes, imageVolumes)
248 volumes := make(map[string]string)258 volumes := make(map[string]string)
M internal/usecase/services/service_test.go +16 -1
@@ -91,6 +91,22 @@ func TestService_ReconcileRecreatesStaleService(t *testing.T) {
9191 require.NoError(t, err)
9292 }
9393
94+func TestService_ReconcileRemovesDuplicateMatchingServiceContainers(t *testing.T) {
95+ rt := outmocks.NewMockContainerRuntime(t)
96+ svc := sampleService()
97+ hash, err := serviceConfigHash(svc)
98+ require.NoError(t, err)
99+ older := managedContainer("existing-1", svc.Name, hash, "running")
100+ duplicate := managedContainer("existing-2", svc.Name, hash, "running")
101+ rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{duplicate, older}, nil).Once()
102+ rt.On("StopContainer", mock.Anything, "existing-2").Return(nil).Once()
103+ rt.On("RemoveContainer", mock.Anything, "existing-2", true).Return(nil).Once()
104+
105+ err = NewService(rt).Reconcile(context.Background(), []domain.StandaloneService{svc})
106+
107+ require.NoError(t, err)
108+}
109+
94110 func TestService_ReconcileStopsAndRemovesDisabledService(t *testing.T) {
95111 rt := outmocks.NewMockContainerRuntime(t)
96112 svc := sampleService()
@@ -173,7 +189,6 @@ func TestService_ReconcileUsesExplicitReadOnlyVolumes(t *testing.T) {
173189 svc.Volumes = []domain.StandaloneServiceVolume{{Source: "cfg", Target: "/cfg", ReadOnly: true}}
174190 var created *domain.ContainerConfig
175191 rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{}, nil).Once()
176- rt.On("InspectImageVolumes", mock.Anything, svc.Image).Return([]string{"/ignored"}, nil).Once()
177192 rt.On("CreateContainer", mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Run(func(args mock.Arguments) {
178193 created = args.Get(1).(*domain.ContainerConfig)
179194 }).Return(&domain.Container{ID: "created-1"}, nil).Once()
@@ -91,6 +91,22 @@ func TestService_ReconcileRecreatesStaleService(t *testing.T) {
91 require.NoError(t, err)91 require.NoError(t, err)
92 }92 }
93 93
94+func TestService_ReconcileRemovesDuplicateMatchingServiceContainers(t *testing.T) {
95+ rt := outmocks.NewMockContainerRuntime(t)
96+ svc := sampleService()
97+ hash, err := serviceConfigHash(svc)
98+ require.NoError(t, err)
99+ older := managedContainer("existing-1", svc.Name, hash, "running")
100+ duplicate := managedContainer("existing-2", svc.Name, hash, "running")
101+ rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{duplicate, older}, nil).Once()
102+ rt.On("StopContainer", mock.Anything, "existing-2").Return(nil).Once()
103+ rt.On("RemoveContainer", mock.Anything, "existing-2", true).Return(nil).Once()
104+
105+ err = NewService(rt).Reconcile(context.Background(), []domain.StandaloneService{svc})
106+
107+ require.NoError(t, err)
108+}
109+
94 func TestService_ReconcileStopsAndRemovesDisabledService(t *testing.T) {110 func TestService_ReconcileStopsAndRemovesDisabledService(t *testing.T) {
95 rt := outmocks.NewMockContainerRuntime(t)111 rt := outmocks.NewMockContainerRuntime(t)
96 svc := sampleService()112 svc := sampleService()
@@ -173,7 +189,6 @@ func TestService_ReconcileUsesExplicitReadOnlyVolumes(t *testing.T) {
173 svc.Volumes = []domain.StandaloneServiceVolume{{Source: "cfg", Target: "/cfg", ReadOnly: true}}189 svc.Volumes = []domain.StandaloneServiceVolume{{Source: "cfg", Target: "/cfg", ReadOnly: true}}
174 var created *domain.ContainerConfig190 var created *domain.ContainerConfig
175 rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{}, nil).Once()191 rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{}, nil).Once()
176- rt.On("InspectImageVolumes", mock.Anything, svc.Image).Return([]string{"/ignored"}, nil).Once()
177 rt.On("CreateContainer", mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Run(func(args mock.Arguments) {192 rt.On("CreateContainer", mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Run(func(args mock.Arguments) {
178 created = args.Get(1).(*domain.ContainerConfig)193 created = args.Get(1).(*domain.ContainerConfig)
179 }).Return(&domain.Container{ID: "created-1"}, nil).Once()194 }).Return(&domain.Container{ID: "created-1"}, nil).Once()
M internal/usecase/traffic/builder.go +1 -1
@@ -270,7 +270,7 @@ func (b *builder) resolveNetworkService(refValue string, ref domain.TrafficServi
270270
271271 func (b *builder) resolveStandaloneService(router RouterConfig, ref domain.TrafficServiceRef, protocol domain.NetworkProtocol) (domain.TrafficService, error) {
272272 svc, ok := b.standaloneService(ref.Name)
273- if !ok {
273+ if !ok || !svc.Enabled {
274274 return domain.TrafficService{}, fmt.Errorf("unknown service %q", ref.Name)
275275 }
276276 port, ok := standaloneServicePort(svc, ref.PortName)
@@ -270,7 +270,7 @@ func (b *builder) resolveNetworkService(refValue string, ref domain.TrafficServi
270 270
271 func (b *builder) resolveStandaloneService(router RouterConfig, ref domain.TrafficServiceRef, protocol domain.NetworkProtocol) (domain.TrafficService, error) {271 func (b *builder) resolveStandaloneService(router RouterConfig, ref domain.TrafficServiceRef, protocol domain.NetworkProtocol) (domain.TrafficService, error) {
272 svc, ok := b.standaloneService(ref.Name)272 svc, ok := b.standaloneService(ref.Name)
273- if !ok {273+ if !ok || !svc.Enabled {
274 return domain.TrafficService{}, fmt.Errorf("unknown service %q", ref.Name)274 return domain.TrafficService{}, fmt.Errorf("unknown service %q", ref.Name)
275 }275 }
276 port, ok := standaloneServicePort(svc, ref.PortName)276 port, ok := standaloneServicePort(svc, ref.PortName)
M internal/usecase/traffic/builder_test.go +15 -0
@@ -247,6 +247,15 @@ func TestBuildRejectsInvalidStandaloneServiceRefs(t *testing.T) {
247247 require.ErrorContains(t, err, "unknown port \"missing\" on service \"rust\"")
248248 })
249249
250+ t.Run("disabled service", func(t *testing.T) {
251+ input := base
252+ input.Services = append([]domain.StandaloneService(nil), base.Services...)
253+ input.Services[0].Enabled = false
254+ input.Traffic = Config{TCP: TCPConfig{Routers: []RouterConfig{{Name: "bad", EntryPoint: "tcp", Service: "service:rust:rcon"}}}}
255+ _, err := Build(input)
256+ require.ErrorContains(t, err, "unknown service \"rust\"")
257+ })
258+
250259 t.Run("protocol mismatch", func(t *testing.T) {
251260 input := base
252261 input.Traffic = Config{TCP: TCPConfig{Routers: []RouterConfig{{Name: "bad", EntryPoint: "tcp", Service: "service:rust:game"}}}}
@@ -257,6 +266,8 @@ func TestBuildRejectsInvalidStandaloneServiceRefs(t *testing.T) {
257266 t.Run("missing publish", func(t *testing.T) {
258267 input := base
259268 input.EntryPoints = map[string]EntryPointConfig{"tcp": {Address: ":1234", Protocol: domain.EntryPointProtocolTCP, TrustedCIDRs: []string{"100.64.0.0/10"}}}
269+ input.Services = append([]domain.StandaloneService(nil), base.Services...)
270+ input.Services[0].Ports = append([]domain.StandaloneServicePort(nil), base.Services[0].Ports...)
260271 input.Services[0].Ports[1].Publish = ""
261272 input.Traffic = Config{TCP: TCPConfig{Routers: []RouterConfig{{Name: "bad", EntryPoint: "tcp", Service: "service:rust:rcon"}}}}
262273 _, err := Build(input)
@@ -265,7 +276,11 @@ func TestBuildRejectsInvalidStandaloneServiceRefs(t *testing.T) {
265276
266277 t.Run("invalid publish", func(t *testing.T) {
267278 input := base
279+ input.EntryPoints = map[string]EntryPointConfig{"tcp": {Address: ":1234", Protocol: domain.EntryPointProtocolTCP, TrustedCIDRs: []string{"100.64.0.0/10"}}}
280+ input.Services = append([]domain.StandaloneService(nil), base.Services...)
281+ input.Services[0].Ports = append([]domain.StandaloneServicePort(nil), base.Services[0].Ports...)
268282 input.Services[0].Ports[1].Publish = "127.0.0.1:notaport"
283+ input.Traffic = Config{TCP: TCPConfig{Routers: []RouterConfig{{Name: "bad", EntryPoint: "tcp", Service: "service:rust:rcon"}}}}
269284 _, err := Build(input)
270285 require.ErrorContains(t, err, "publish address")
271286 })
@@ -247,6 +247,15 @@ func TestBuildRejectsInvalidStandaloneServiceRefs(t *testing.T) {
247 require.ErrorContains(t, err, "unknown port \"missing\" on service \"rust\"")247 require.ErrorContains(t, err, "unknown port \"missing\" on service \"rust\"")
248 })248 })
249 249
250+ t.Run("disabled service", func(t *testing.T) {
251+ input := base
252+ input.Services = append([]domain.StandaloneService(nil), base.Services...)
253+ input.Services[0].Enabled = false
254+ input.Traffic = Config{TCP: TCPConfig{Routers: []RouterConfig{{Name: "bad", EntryPoint: "tcp", Service: "service:rust:rcon"}}}}
255+ _, err := Build(input)
256+ require.ErrorContains(t, err, "unknown service \"rust\"")
257+ })
258+
250 t.Run("protocol mismatch", func(t *testing.T) {259 t.Run("protocol mismatch", func(t *testing.T) {
251 input := base260 input := base
252 input.Traffic = Config{TCP: TCPConfig{Routers: []RouterConfig{{Name: "bad", EntryPoint: "tcp", Service: "service:rust:game"}}}}261 input.Traffic = Config{TCP: TCPConfig{Routers: []RouterConfig{{Name: "bad", EntryPoint: "tcp", Service: "service:rust:game"}}}}
@@ -257,6 +266,8 @@ func TestBuildRejectsInvalidStandaloneServiceRefs(t *testing.T) {
257 t.Run("missing publish", func(t *testing.T) {266 t.Run("missing publish", func(t *testing.T) {
258 input := base267 input := base
259 input.EntryPoints = map[string]EntryPointConfig{"tcp": {Address: ":1234", Protocol: domain.EntryPointProtocolTCP, TrustedCIDRs: []string{"100.64.0.0/10"}}}268 input.EntryPoints = map[string]EntryPointConfig{"tcp": {Address: ":1234", Protocol: domain.EntryPointProtocolTCP, TrustedCIDRs: []string{"100.64.0.0/10"}}}
269+ input.Services = append([]domain.StandaloneService(nil), base.Services...)
270+ input.Services[0].Ports = append([]domain.StandaloneServicePort(nil), base.Services[0].Ports...)
260 input.Services[0].Ports[1].Publish = ""271 input.Services[0].Ports[1].Publish = ""
261 input.Traffic = Config{TCP: TCPConfig{Routers: []RouterConfig{{Name: "bad", EntryPoint: "tcp", Service: "service:rust:rcon"}}}}272 input.Traffic = Config{TCP: TCPConfig{Routers: []RouterConfig{{Name: "bad", EntryPoint: "tcp", Service: "service:rust:rcon"}}}}
262 _, err := Build(input)273 _, err := Build(input)
@@ -265,7 +276,11 @@ func TestBuildRejectsInvalidStandaloneServiceRefs(t *testing.T) {
265 276
266 t.Run("invalid publish", func(t *testing.T) {277 t.Run("invalid publish", func(t *testing.T) {
267 input := base278 input := base
279+ input.EntryPoints = map[string]EntryPointConfig{"tcp": {Address: ":1234", Protocol: domain.EntryPointProtocolTCP, TrustedCIDRs: []string{"100.64.0.0/10"}}}
280+ input.Services = append([]domain.StandaloneService(nil), base.Services...)
281+ input.Services[0].Ports = append([]domain.StandaloneServicePort(nil), base.Services[0].Ports...)
268 input.Services[0].Ports[1].Publish = "127.0.0.1:notaport"282 input.Services[0].Ports[1].Publish = "127.0.0.1:notaport"
283+ input.Traffic = Config{TCP: TCPConfig{Routers: []RouterConfig{{Name: "bad", EntryPoint: "tcp", Service: "service:rust:rcon"}}}}
269 _, err := Build(input)284 _, err := Build(input)
270 require.ErrorContains(t, err, "publish address")285 require.ErrorContains(t, err, "publish address")
271 })286 })

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