detached 3489c65

fix(traffic): harden final review edge cases

commit
3489c6589f9b98d9fb2e5ad1193909533851f418
author
brice · · 2026-06-30 17:03 UTC
parent
3011e4c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
21 files · +252 -73
M docs/config/index.md +1 -1
@@ -177,7 +177,7 @@ keep_last = 3
177177 | `[auto_route]` | Automatic route creation | [Auto Route](./auto-route.md) |
178178 | `[routes]` | Domain to image mapping | [Routes](./routes.md) |
179179 | `[external_routes]` | Non-containerized service proxying | [External Routes](./external-routes.md) |
180-| `[entrypoints]`, `[traffic]`, `[[network_services]]` | L4 and TLS passthrough traffic plane | [Traffic](./traffic.md) |
180+| `[entrypoints]`, `[traffic]`, `[[network_services]]`, `[[services]]` | L4 and TLS passthrough traffic plane | [Traffic](./traffic.md) |
181181 | `[network_groups]` | Shared service networks | [Network Groups](./network-groups.md) |
182182 | `[attachments]` | Service dependencies | [Attachments](./attachments.md) |
183183 | `[backups]` | Database backups | [Backups](./backups.md) |
@@ -177,7 +177,7 @@ keep_last = 3
177 | `[auto_route]` | Automatic route creation | [Auto Route](./auto-route.md) |177 | `[auto_route]` | Automatic route creation | [Auto Route](./auto-route.md) |
178 | `[routes]` | Domain to image mapping | [Routes](./routes.md) |178 | `[routes]` | Domain to image mapping | [Routes](./routes.md) |
179 | `[external_routes]` | Non-containerized service proxying | [External Routes](./external-routes.md) |179 | `[external_routes]` | Non-containerized service proxying | [External Routes](./external-routes.md) |
180-| `[entrypoints]`, `[traffic]`, `[[network_services]]` | L4 and TLS passthrough traffic plane | [Traffic](./traffic.md) |180+| `[entrypoints]`, `[traffic]`, `[[network_services]]`, `[[services]]` | L4 and TLS passthrough traffic plane | [Traffic](./traffic.md) |
181 | `[network_groups]` | Shared service networks | [Network Groups](./network-groups.md) |181 | `[network_groups]` | Shared service networks | [Network Groups](./network-groups.md) |
182 | `[attachments]` | Service dependencies | [Attachments](./attachments.md) |182 | `[attachments]` | Service dependencies | [Attachments](./attachments.md) |
183 | `[backups]` | Database backups | [Backups](./backups.md) |183 | `[backups]` | Database backups | [Backups](./backups.md) |
M docs/config/server.md +1 -1
@@ -116,7 +116,7 @@ The defaults avoid relying on host-local DNS. This matters on hosts using Tailsc
116116
117117 Because Gordon's ACME challenge mode is global, `cloudflare-dns-01` requires a Cloudflare token that can read zones and edit DNS records for every zone used by configured HTTPS routes. If that is not desirable, use `http-01` until Gordon supports per-route or per-zone challenge policy.
118118
119-#### Direct HTTP Onboarding
119+#### Direct HTTP CA onboarding paths (`/.well-known/gordon/ca`)
120120
121121 When Gordon is serving TLS-capable edge traffic, direct cleartext HTTP clients (those not arriving through a trusted proxy) are restricted to CA onboarding paths only:
122122
@@ -116,7 +116,7 @@ The defaults avoid relying on host-local DNS. This matters on hosts using Tailsc
116 116
117 Because Gordon's ACME challenge mode is global, `cloudflare-dns-01` requires a Cloudflare token that can read zones and edit DNS records for every zone used by configured HTTPS routes. If that is not desirable, use `http-01` until Gordon supports per-route or per-zone challenge policy.117 Because Gordon's ACME challenge mode is global, `cloudflare-dns-01` requires a Cloudflare token that can read zones and edit DNS records for every zone used by configured HTTPS routes. If that is not desirable, use `http-01` until Gordon supports per-route or per-zone challenge policy.
118 118
119-#### Direct HTTP Onboarding119+#### Direct HTTP CA onboarding paths (`/.well-known/gordon/ca`)
120 120
121 When Gordon is serving TLS-capable edge traffic, direct cleartext HTTP clients (those not arriving through a trusted proxy) are restricted to CA onboarding paths only:121 When Gordon is serving TLS-capable edge traffic, direct cleartext HTTP clients (those not arriving through a trusted proxy) are restricted to CA onboarding paths only:
122 122
M docs/installation.md +1 -1
@@ -263,7 +263,7 @@ Gordon serves public application traffic through HTTP-capable entrypoints such a
263263 Wrong mode causes: **521** (Cloudflare can't connect) or **525** (TLS handshake failed).
264264
265265 > **Important:** For Cloudflare-proxied HTTP paths, set `proxy_allowed_ips` with Cloudflare edge IPs — see [Proxy Origin Allowlist](#proxy-origin-allowlist) below.
266-
266+>
267267 > **Rootless note:** Unprivileged users can't bind privileged ports. Bind `entrypoints.edge.address` to a high port (for example `:9000`) and forward/map external ports to it via firewall or container settings.
268268
269269 ## Proxy Origin Allowlist
@@ -263,7 +263,7 @@ Gordon serves public application traffic through HTTP-capable entrypoints such a
263 Wrong mode causes: **521** (Cloudflare can't connect) or **525** (TLS handshake failed).263 Wrong mode causes: **521** (Cloudflare can't connect) or **525** (TLS handshake failed).
264 264
265 > **Important:** For Cloudflare-proxied HTTP paths, set `proxy_allowed_ips` with Cloudflare edge IPs — see [Proxy Origin Allowlist](#proxy-origin-allowlist) below.265 > **Important:** For Cloudflare-proxied HTTP paths, set `proxy_allowed_ips` with Cloudflare edge IPs — see [Proxy Origin Allowlist](#proxy-origin-allowlist) below.
266-266+>
267 > **Rootless note:** Unprivileged users can't bind privileged ports. Bind `entrypoints.edge.address` to a high port (for example `:9000`) and forward/map external ports to it via firewall or container settings.267 > **Rootless note:** Unprivileged users can't bind privileged ports. Bind `entrypoints.edge.address` to a high port (for example `:9000`) and forward/map external ports to it via firewall or container settings.
268 268
269 ## Proxy Origin Allowlist269 ## Proxy Origin Allowlist
M internal/adapters/in/cli/root.go +2 -2
@@ -154,11 +154,11 @@ Commands are organized by where they run:
154154 tlsCmd.GroupID = groupManage
155155 rootCmd.AddCommand(tlsCmd)
156156
157- // Client-only commands (no server needed)
158157 trafficCmd := newTrafficCmd()
159- trafficCmd.GroupID = groupClient
158+ trafficCmd.GroupID = groupManage
160159 rootCmd.AddCommand(trafficCmd)
161160
161+ // Client-only commands (no server needed)
162162 remotesCmd := newRemotesCmd()
163163 remotesCmd.GroupID = groupClient
164164 rootCmd.AddCommand(remotesCmd)
@@ -154,11 +154,11 @@ Commands are organized by where they run:
154 tlsCmd.GroupID = groupManage154 tlsCmd.GroupID = groupManage
155 rootCmd.AddCommand(tlsCmd)155 rootCmd.AddCommand(tlsCmd)
156 156
157- // Client-only commands (no server needed)
158 trafficCmd := newTrafficCmd()157 trafficCmd := newTrafficCmd()
159- trafficCmd.GroupID = groupClient158+ trafficCmd.GroupID = groupManage
160 rootCmd.AddCommand(trafficCmd)159 rootCmd.AddCommand(trafficCmd)
161 160
161+ // Client-only commands (no server needed)
162 remotesCmd := newRemotesCmd()162 remotesCmd := newRemotesCmd()
163 remotesCmd.GroupID = groupClient163 remotesCmd.GroupID = groupClient
164 rootCmd.AddCommand(remotesCmd)164 rootCmd.AddCommand(remotesCmd)
M internal/adapters/in/traffic/manager.go +4 -5
@@ -96,10 +96,8 @@ func (m *Manager) Apply(ctx context.Context, graph *domain.TrafficGraph) error {
9696 if err != nil {
9797 m.lastReloadStatus = reloadStatusError
9898 m.lastReloadError = err.Error()
99+ stopTCPRuntimes(ctx, createdListeners, effectiveTCPOptions(snapshotTCPOptions(&nextGraph)).DrainTimeout)
99100 m.mu.Unlock()
100- for _, runtime := range createdListeners {
101- runtime.stop(ctx, effectiveTCPOptions(snapshotTCPOptions(&nextGraph)).DrainTimeout)
102- }
103101 trafficWarn(ctx).Err(err).Msg("failed to prepare udp traffic listeners")
104102 return err
105103 }
@@ -346,12 +344,13 @@ func (m *Manager) bindTCPEntryPoint(ctx context.Context, entryPoint domain.Entry
346344 _ = listener.Close()
347345 return nil, fmt.Errorf("parse trusted cidrs for tcp entrypoint %q: %w", entryPoint.Name, err)
348346 }
349- if _, err := parseTrustedCIDRs(entryPoint.RawFallbackTrustedCIDRs); err != nil {
347+ rawTrusted, err := parseTrustedCIDRs(entryPoint.RawFallbackTrustedCIDRs)
348+ if err != nil {
350349 _ = listener.Close()
351350 return nil, fmt.Errorf("parse raw fallback trusted cidrs for tcp entrypoint %q: %w", entryPoint.Name, err)
352351 }
353352 trafficInfo(ctx).Str("entrypoint", entryPoint.Name).Str("address", entryPoint.Address).Str("protocol", string(entryPoint.Protocol)).Msg("bound tcp traffic entrypoint")
354- runtime := newEntryPointRuntime(ctx, m, entryPoint, listener, trusted)
353+ runtime := newEntryPointRuntime(ctx, m, entryPoint, listener, trusted, rawTrusted)
355354 return runtime, nil
356355 }
357356
@@ -96,10 +96,8 @@ func (m *Manager) Apply(ctx context.Context, graph *domain.TrafficGraph) error {
96 if err != nil {96 if err != nil {
97 m.lastReloadStatus = reloadStatusError97 m.lastReloadStatus = reloadStatusError
98 m.lastReloadError = err.Error()98 m.lastReloadError = err.Error()
99+ stopTCPRuntimes(ctx, createdListeners, effectiveTCPOptions(snapshotTCPOptions(&nextGraph)).DrainTimeout)
99 m.mu.Unlock()100 m.mu.Unlock()
100- for _, runtime := range createdListeners {
101- runtime.stop(ctx, effectiveTCPOptions(snapshotTCPOptions(&nextGraph)).DrainTimeout)
102- }
103 trafficWarn(ctx).Err(err).Msg("failed to prepare udp traffic listeners")101 trafficWarn(ctx).Err(err).Msg("failed to prepare udp traffic listeners")
104 return err102 return err
105 }103 }
@@ -346,12 +344,13 @@ func (m *Manager) bindTCPEntryPoint(ctx context.Context, entryPoint domain.Entry
346 _ = listener.Close()344 _ = listener.Close()
347 return nil, fmt.Errorf("parse trusted cidrs for tcp entrypoint %q: %w", entryPoint.Name, err)345 return nil, fmt.Errorf("parse trusted cidrs for tcp entrypoint %q: %w", entryPoint.Name, err)
348 }346 }
349- if _, err := parseTrustedCIDRs(entryPoint.RawFallbackTrustedCIDRs); err != nil {347+ rawTrusted, err := parseTrustedCIDRs(entryPoint.RawFallbackTrustedCIDRs)
348+ if err != nil {
350 _ = listener.Close()349 _ = listener.Close()
351 return nil, fmt.Errorf("parse raw fallback trusted cidrs for tcp entrypoint %q: %w", entryPoint.Name, err)350 return nil, fmt.Errorf("parse raw fallback trusted cidrs for tcp entrypoint %q: %w", entryPoint.Name, err)
352 }351 }
353 trafficInfo(ctx).Str("entrypoint", entryPoint.Name).Str("address", entryPoint.Address).Str("protocol", string(entryPoint.Protocol)).Msg("bound tcp traffic entrypoint")352 trafficInfo(ctx).Str("entrypoint", entryPoint.Name).Str("address", entryPoint.Address).Str("protocol", string(entryPoint.Protocol)).Msg("bound tcp traffic entrypoint")
354- runtime := newEntryPointRuntime(ctx, m, entryPoint, listener, trusted)353+ runtime := newEntryPointRuntime(ctx, m, entryPoint, listener, trusted, rawTrusted)
355 return runtime, nil354 return runtime, nil
356 }355 }
357 356
M internal/adapters/in/traffic/sniff.go +3 -1
@@ -41,8 +41,10 @@ func sniffSmartTCP(conn net.Conn, timeout time.Duration) (smartTCPSniffResult, e
4141 }
4242
4343 buf := make([]byte, 0, 32)
44- tmp := make([]byte, 1)
4544 for len(buf) < maxSmartTCPSniffBytes {
45+ remaining := maxSmartTCPSniffBytes - len(buf)
46+ readSize := min(32, remaining)
47+ tmp := make([]byte, readSize)
4648 n, err := conn.Read(tmp)
4749 if n > 0 {
4850 buf = append(buf, tmp[:n]...)
@@ -41,8 +41,10 @@ func sniffSmartTCP(conn net.Conn, timeout time.Duration) (smartTCPSniffResult, e
41 }41 }
42 42
43 buf := make([]byte, 0, 32)43 buf := make([]byte, 0, 32)
44- tmp := make([]byte, 1)
45 for len(buf) < maxSmartTCPSniffBytes {44 for len(buf) < maxSmartTCPSniffBytes {
45+ remaining := maxSmartTCPSniffBytes - len(buf)
46+ readSize := min(32, remaining)
47+ tmp := make([]byte, readSize)
46 n, err := conn.Read(tmp)48 n, err := conn.Read(tmp)
47 if n > 0 {49 if n > 0 {
48 buf = append(buf, tmp[:n]...)50 buf = append(buf, tmp[:n]...)
M internal/adapters/in/traffic/sniff_test.go +2 -3
@@ -234,7 +234,6 @@ func TestSniffSmartTCPDoesNotTreatClosedPipeAsSuccess(t *testing.T) {
234234 client, server := net.Pipe()
235235 _ = client.Close()
236236 _, err := sniffSmartTCP(server, time.Second)
237- if err == nil && !errors.Is(err, net.ErrClosed) {
238- t.Fatal("expected closed connection to return an error")
239- }
237+ require.Error(t, err)
238+ assert.True(t, errors.Is(err, net.ErrClosed) || errors.Is(err, io.ErrClosedPipe), "expected closed connection error, got %v", err)
240239 }
@@ -234,7 +234,6 @@ func TestSniffSmartTCPDoesNotTreatClosedPipeAsSuccess(t *testing.T) {
234 client, server := net.Pipe()234 client, server := net.Pipe()
235 _ = client.Close()235 _ = client.Close()
236 _, err := sniffSmartTCP(server, time.Second)236 _, err := sniffSmartTCP(server, time.Second)
237- if err == nil && !errors.Is(err, net.ErrClosed) {237+ require.Error(t, err)
238- t.Fatal("expected closed connection to return an error")238+ assert.True(t, errors.Is(err, net.ErrClosed) || errors.Is(err, io.ErrClosedPipe), "expected closed connection error, got %v", err)
239- }
240 }239 }
M internal/adapters/in/traffic/tcp.go +22 -7
@@ -106,9 +106,8 @@ func (c *trackedTCPConn) rawFallback() (string, bool) {
106106 return c.rawFallbackName, c.rawFallbackName != ""
107107 }
108108
109-func newEntryPointRuntime(parentCtx context.Context, manager *Manager, entryPoint domain.EntryPoint, listener net.Listener, trusted []*net.IPNet) *entryPointRuntime {
109+func newEntryPointRuntime(parentCtx context.Context, manager *Manager, entryPoint domain.EntryPoint, listener net.Listener, trusted []*net.IPNet, rawTrusted []*net.IPNet) *entryPointRuntime {
110110 ctx, cancel := context.WithCancel(context.WithoutCancel(parentCtx))
111- rawTrusted, _ := parseTrustedCIDRs(entryPoint.RawFallbackTrustedCIDRs)
112111 return &entryPointRuntime{
113112 manager: manager,
114113 entryPoint: entryPoint,
@@ -166,7 +165,6 @@ func (r *entryPointRuntime) refreshTLSHTTPServer(entryPointName string) {
166165 func (r *entryPointRuntime) replaceTLSHTTPServer(entryPoint domain.EntryPoint, config TLSHTTPServerConfig) {
167166 r.tlsHTTPReplaceMu.Lock()
168167 defer r.tlsHTTPReplaceMu.Unlock()
169- r.stopTLSHTTPServerLocked(r.ctx, 0)
170168 if r.isClosed() {
171169 return
172170 }
@@ -182,6 +180,9 @@ func (r *entryPointRuntime) replaceTLSHTTPServer(entryPoint domain.EntryPoint, c
182180 done := make(chan struct{})
183181
184182 r.mu.Lock()
183+ oldListener := r.tlsHTTPListener
184+ oldServer := r.tlsHTTPServer
185+ oldDone := r.tlsHTTPDone
185186 if r.isClosed() {
186187 r.mu.Unlock()
187188 _ = listener.Close()
@@ -198,6 +199,7 @@ func (r *entryPointRuntime) replaceTLSHTTPServer(entryPoint domain.EntryPoint, c
198199 trafficWarn(r.ctx).Err(err).Str("entrypoint", entryPoint.Name).Msg("tls mux https server stopped with error")
199200 }
200201 }()
202+ stopTLSHTTPServer(r.ctx, entryPoint.Name, oldListener, oldServer, oldDone, 0)
201203 }
202204
203205 func (r *entryPointRuntime) routeToHTTPS(tracked *trackedTCPConn, conn net.Conn) bool {
@@ -289,7 +291,12 @@ func (r *entryPointRuntime) handleTCPConn(client net.Conn) {
289291 r.releaseConnection()
290292 r.activeWG.Done()
291293 }
292- r.track(tracked)
294+ if !r.track(tracked) {
295+ r.releaseConnection()
296+ _ = client.Close()
297+ r.activeWG.Done()
298+ return
299+ }
293300 completeOnReturn := true
294301 defer func() {
295302 if completeOnReturn {
@@ -689,10 +696,14 @@ func (r *entryPointRuntime) releaseConnection() {
689696 r.counters.activeTCPConnections.Add(-1)
690697 }
691698
692-func (r *entryPointRuntime) track(conn *trackedTCPConn) {
699+func (r *entryPointRuntime) track(conn *trackedTCPConn) bool {
693700 r.mu.Lock()
701+ defer r.mu.Unlock()
702+ if r.isClosed() {
703+ return false
704+ }
694705 r.activeConns[conn] = struct{}{}
695- r.mu.Unlock()
706+ return true
696707 }
697708
698709 func (r *entryPointRuntime) setBackend(conn *trackedTCPConn, backend net.Conn) {
@@ -759,6 +770,10 @@ func (r *entryPointRuntime) stopTLSHTTPServerLocked(ctx context.Context, drainTi
759770 r.tlsHTTPDone = nil
760771 r.mu.Unlock()
761772
773+ stopTLSHTTPServer(ctx, r.entryPointSnapshot().Name, listener, server, done, drainTimeout)
774+}
775+
776+func stopTLSHTTPServer(ctx context.Context, entryPointName string, listener *tlsHTTPListener, server *http.Server, done chan struct{}, drainTimeout time.Duration) {
762777 if listener == nil || server == nil || done == nil {
763778 return
764779 }
@@ -769,7 +784,7 @@ func (r *entryPointRuntime) stopTLSHTTPServerLocked(ctx context.Context, drainTi
769784 shutdownCtx, cancel := context.WithTimeout(ctx, drainTimeout)
770785 defer cancel()
771786 if err := server.Shutdown(shutdownCtx); err != nil {
772- trafficDebug(ctx).Err(err).Str("entrypoint", r.entryPointSnapshot().Name).Msg("forcing tls mux https server close")
787+ trafficDebug(ctx).Err(err).Str("entrypoint", entryPointName).Msg("forcing tls mux https server close")
773788 _ = server.Close()
774789 }
775790 select {
@@ -106,9 +106,8 @@ func (c *trackedTCPConn) rawFallback() (string, bool) {
106 return c.rawFallbackName, c.rawFallbackName != ""106 return c.rawFallbackName, c.rawFallbackName != ""
107 }107 }
108 108
109-func newEntryPointRuntime(parentCtx context.Context, manager *Manager, entryPoint domain.EntryPoint, listener net.Listener, trusted []*net.IPNet) *entryPointRuntime {109+func newEntryPointRuntime(parentCtx context.Context, manager *Manager, entryPoint domain.EntryPoint, listener net.Listener, trusted []*net.IPNet, rawTrusted []*net.IPNet) *entryPointRuntime {
110 ctx, cancel := context.WithCancel(context.WithoutCancel(parentCtx))110 ctx, cancel := context.WithCancel(context.WithoutCancel(parentCtx))
111- rawTrusted, _ := parseTrustedCIDRs(entryPoint.RawFallbackTrustedCIDRs)
112 return &entryPointRuntime{111 return &entryPointRuntime{
113 manager: manager,112 manager: manager,
114 entryPoint: entryPoint,113 entryPoint: entryPoint,
@@ -166,7 +165,6 @@ func (r *entryPointRuntime) refreshTLSHTTPServer(entryPointName string) {
166 func (r *entryPointRuntime) replaceTLSHTTPServer(entryPoint domain.EntryPoint, config TLSHTTPServerConfig) {165 func (r *entryPointRuntime) replaceTLSHTTPServer(entryPoint domain.EntryPoint, config TLSHTTPServerConfig) {
167 r.tlsHTTPReplaceMu.Lock()166 r.tlsHTTPReplaceMu.Lock()
168 defer r.tlsHTTPReplaceMu.Unlock()167 defer r.tlsHTTPReplaceMu.Unlock()
169- r.stopTLSHTTPServerLocked(r.ctx, 0)
170 if r.isClosed() {168 if r.isClosed() {
171 return169 return
172 }170 }
@@ -182,6 +180,9 @@ func (r *entryPointRuntime) replaceTLSHTTPServer(entryPoint domain.EntryPoint, c
182 done := make(chan struct{})180 done := make(chan struct{})
183 181
184 r.mu.Lock()182 r.mu.Lock()
183+ oldListener := r.tlsHTTPListener
184+ oldServer := r.tlsHTTPServer
185+ oldDone := r.tlsHTTPDone
185 if r.isClosed() {186 if r.isClosed() {
186 r.mu.Unlock()187 r.mu.Unlock()
187 _ = listener.Close()188 _ = listener.Close()
@@ -198,6 +199,7 @@ func (r *entryPointRuntime) replaceTLSHTTPServer(entryPoint domain.EntryPoint, c
198 trafficWarn(r.ctx).Err(err).Str("entrypoint", entryPoint.Name).Msg("tls mux https server stopped with error")199 trafficWarn(r.ctx).Err(err).Str("entrypoint", entryPoint.Name).Msg("tls mux https server stopped with error")
199 }200 }
200 }()201 }()
202+ stopTLSHTTPServer(r.ctx, entryPoint.Name, oldListener, oldServer, oldDone, 0)
201 }203 }
202 204
203 func (r *entryPointRuntime) routeToHTTPS(tracked *trackedTCPConn, conn net.Conn) bool {205 func (r *entryPointRuntime) routeToHTTPS(tracked *trackedTCPConn, conn net.Conn) bool {
@@ -289,7 +291,12 @@ func (r *entryPointRuntime) handleTCPConn(client net.Conn) {
289 r.releaseConnection()291 r.releaseConnection()
290 r.activeWG.Done()292 r.activeWG.Done()
291 }293 }
292- r.track(tracked)294+ if !r.track(tracked) {
295+ r.releaseConnection()
296+ _ = client.Close()
297+ r.activeWG.Done()
298+ return
299+ }
293 completeOnReturn := true300 completeOnReturn := true
294 defer func() {301 defer func() {
295 if completeOnReturn {302 if completeOnReturn {
@@ -689,10 +696,14 @@ func (r *entryPointRuntime) releaseConnection() {
689 r.counters.activeTCPConnections.Add(-1)696 r.counters.activeTCPConnections.Add(-1)
690 }697 }
691 698
692-func (r *entryPointRuntime) track(conn *trackedTCPConn) {699+func (r *entryPointRuntime) track(conn *trackedTCPConn) bool {
693 r.mu.Lock()700 r.mu.Lock()
701+ defer r.mu.Unlock()
702+ if r.isClosed() {
703+ return false
704+ }
694 r.activeConns[conn] = struct{}{}705 r.activeConns[conn] = struct{}{}
695- r.mu.Unlock()706+ return true
696 }707 }
697 708
698 func (r *entryPointRuntime) setBackend(conn *trackedTCPConn, backend net.Conn) {709 func (r *entryPointRuntime) setBackend(conn *trackedTCPConn, backend net.Conn) {
@@ -759,6 +770,10 @@ func (r *entryPointRuntime) stopTLSHTTPServerLocked(ctx context.Context, drainTi
759 r.tlsHTTPDone = nil770 r.tlsHTTPDone = nil
760 r.mu.Unlock()771 r.mu.Unlock()
761 772
773+ stopTLSHTTPServer(ctx, r.entryPointSnapshot().Name, listener, server, done, drainTimeout)
774+}
775+
776+func stopTLSHTTPServer(ctx context.Context, entryPointName string, listener *tlsHTTPListener, server *http.Server, done chan struct{}, drainTimeout time.Duration) {
762 if listener == nil || server == nil || done == nil {777 if listener == nil || server == nil || done == nil {
763 return778 return
764 }779 }
@@ -769,7 +784,7 @@ func (r *entryPointRuntime) stopTLSHTTPServerLocked(ctx context.Context, drainTi
769 shutdownCtx, cancel := context.WithTimeout(ctx, drainTimeout)784 shutdownCtx, cancel := context.WithTimeout(ctx, drainTimeout)
770 defer cancel()785 defer cancel()
771 if err := server.Shutdown(shutdownCtx); err != nil {786 if err := server.Shutdown(shutdownCtx); err != nil {
772- trafficDebug(ctx).Err(err).Str("entrypoint", r.entryPointSnapshot().Name).Msg("forcing tls mux https server close")787+ trafficDebug(ctx).Err(err).Str("entrypoint", entryPointName).Msg("forcing tls mux https server close")
773 _ = server.Close()788 _ = server.Close()
774 }789 }
775 select {790 select {
M internal/adapters/in/traffic/tcp_test.go +2 -2
@@ -618,7 +618,7 @@ func TestRouteToHTTPListenerServeFailureReturnsFalseAndLeavesTrackedForCaller(t
618618 require.NoError(t, err)
619619 defer listener.Close()
620620
621- runtime := newEntryPointRuntime(context.Background(), NewManager(), domain.EntryPoint{Name: "edge"}, listener, nil)
621+ runtime := newEntryPointRuntime(context.Background(), NewManager(), domain.EntryPoint{Name: "edge"}, listener, nil, nil)
622622 httpListener := newTLSHTTPListener(listener.Addr())
623623 require.NoError(t, httpListener.Close())
624624
@@ -748,7 +748,7 @@ func TestTCPRuntimeDoesNotPublishTLSHTTPServerAfterClose(t *testing.T) {
748748 require.NoError(t, err)
749749 defer listener.Close()
750750 manager := NewManager()
751- runtime := newEntryPointRuntime(context.Background(), manager, domain.EntryPoint{Name: "websecure", Address: listener.Addr().String(), Protocol: domain.EntryPointProtocolTLSMux}, listener, nil)
751+ runtime := newEntryPointRuntime(context.Background(), manager, domain.EntryPoint{Name: "websecure", Address: listener.Addr().String(), Protocol: domain.EntryPointProtocolTLSMux}, listener, nil, nil)
752752 runtime.closed.Store(true)
753753
754754 runtime.replaceTLSHTTPServer(runtime.entryPointSnapshot(), TLSHTTPServerConfig{
@@ -618,7 +618,7 @@ func TestRouteToHTTPListenerServeFailureReturnsFalseAndLeavesTrackedForCaller(t
618 require.NoError(t, err)618 require.NoError(t, err)
619 defer listener.Close()619 defer listener.Close()
620 620
621- runtime := newEntryPointRuntime(context.Background(), NewManager(), domain.EntryPoint{Name: "edge"}, listener, nil)621+ runtime := newEntryPointRuntime(context.Background(), NewManager(), domain.EntryPoint{Name: "edge"}, listener, nil, nil)
622 httpListener := newTLSHTTPListener(listener.Addr())622 httpListener := newTLSHTTPListener(listener.Addr())
623 require.NoError(t, httpListener.Close())623 require.NoError(t, httpListener.Close())
624 624
@@ -748,7 +748,7 @@ func TestTCPRuntimeDoesNotPublishTLSHTTPServerAfterClose(t *testing.T) {
748 require.NoError(t, err)748 require.NoError(t, err)
749 defer listener.Close()749 defer listener.Close()
750 manager := NewManager()750 manager := NewManager()
751- runtime := newEntryPointRuntime(context.Background(), manager, domain.EntryPoint{Name: "websecure", Address: listener.Addr().String(), Protocol: domain.EntryPointProtocolTLSMux}, listener, nil)751+ runtime := newEntryPointRuntime(context.Background(), manager, domain.EntryPoint{Name: "websecure", Address: listener.Addr().String(), Protocol: domain.EntryPointProtocolTLSMux}, listener, nil, nil)
752 runtime.closed.Store(true)752 runtime.closed.Store(true)
753 753
754 runtime.replaceTLSHTTPServer(runtime.entryPointSnapshot(), TLSHTTPServerConfig{754 runtime.replaceTLSHTTPServer(runtime.entryPointSnapshot(), TLSHTTPServerConfig{
M internal/adapters/in/traffic/tls_mux.go +13 -2
@@ -9,11 +9,15 @@ import (
99 "net/http"
1010 "strings"
1111 "sync"
12+ "time"
1213
1314 "github.com/bnema/gordon/internal/domain"
1415 )
1516
16-const maxClientHelloBytes = 64 << 10
17+const (
18+ maxClientHelloBytes = 64 << 10
19+ clientHelloReadTimeout = 250 * time.Millisecond
20+)
1721
1822 // TLSHTTPServerConfig describes the HTTPS server attached to a tls_mux entrypoint.
1923 type TLSHTTPServerConfig struct {
@@ -66,9 +70,16 @@ func peekClientHelloSNI(conn net.Conn) (string, net.Conn, error) {
6670 }
6771
6872 func peekClientHelloSNIWithLimit(conn net.Conn, maxBytes int) (string, net.Conn, error) {
73+ if err := conn.SetReadDeadline(time.Now().Add(clientHelloReadTimeout)); err != nil {
74+ return "", nil, fmt.Errorf("set client hello read deadline: %w", err)
75+ }
76+ defer func() { _ = conn.SetReadDeadline(time.Time{}) }()
77+
6978 buf := make([]byte, 0, 4096)
70- tmp := make([]byte, 1024)
7179 for len(buf) < maxBytes {
80+ remaining := maxBytes - len(buf)
81+ readSize := min(1024, remaining)
82+ tmp := make([]byte, readSize)
7283 n, err := conn.Read(tmp)
7384 if n > 0 {
7485 buf = append(buf, tmp[:n]...)
@@ -9,11 +9,15 @@ import (
9 "net/http"9 "net/http"
10 "strings"10 "strings"
11 "sync"11 "sync"
12+ "time"
12 13
13 "github.com/bnema/gordon/internal/domain"14 "github.com/bnema/gordon/internal/domain"
14 )15 )
15 16
16-const maxClientHelloBytes = 64 << 1017+const (
18+ maxClientHelloBytes = 64 << 10
19+ clientHelloReadTimeout = 250 * time.Millisecond
20+)
17 21
18 // TLSHTTPServerConfig describes the HTTPS server attached to a tls_mux entrypoint.22 // TLSHTTPServerConfig describes the HTTPS server attached to a tls_mux entrypoint.
19 type TLSHTTPServerConfig struct {23 type TLSHTTPServerConfig struct {
@@ -66,9 +70,16 @@ func peekClientHelloSNI(conn net.Conn) (string, net.Conn, error) {
66 }70 }
67 71
68 func peekClientHelloSNIWithLimit(conn net.Conn, maxBytes int) (string, net.Conn, error) {72 func peekClientHelloSNIWithLimit(conn net.Conn, maxBytes int) (string, net.Conn, error) {
73+ if err := conn.SetReadDeadline(time.Now().Add(clientHelloReadTimeout)); err != nil {
74+ return "", nil, fmt.Errorf("set client hello read deadline: %w", err)
75+ }
76+ defer func() { _ = conn.SetReadDeadline(time.Time{}) }()
77+
69 buf := make([]byte, 0, 4096)78 buf := make([]byte, 0, 4096)
70- tmp := make([]byte, 1024)
71 for len(buf) < maxBytes {79 for len(buf) < maxBytes {
80+ remaining := maxBytes - len(buf)
81+ readSize := min(1024, remaining)
82+ tmp := make([]byte, readSize)
72 n, err := conn.Read(tmp)83 n, err := conn.Read(tmp)
73 if n > 0 {84 if n > 0 {
74 buf = append(buf, tmp[:n]...)85 buf = append(buf, tmp[:n]...)
M internal/adapters/in/traffic/udp.go +0 -13
@@ -171,13 +171,6 @@ func (r *udpEntryPointRuntime) session(key string, clientAddr net.Addr, options
171171 _ = backendConn.Close()
172172 return existing, true
173173 }
174- r.mu.Unlock()
175- if options.MaxSessions > 0 && r.sessionCount() >= options.MaxSessions {
176- _ = backendConn.Close()
177- r.counters.totalRefused.Add(1)
178- return nil, false
179- }
180- r.mu.Lock()
181174 if options.MaxSessions > 0 && len(r.sessions) >= options.MaxSessions {
182175 r.mu.Unlock()
183176 _ = backendConn.Close()
@@ -193,12 +186,6 @@ func (r *udpEntryPointRuntime) session(key string, clientAddr net.Addr, options
193186 return session, true
194187 }
195188
196-func (r *udpEntryPointRuntime) sessionCount() int {
197- r.mu.Lock()
198- defer r.mu.Unlock()
199- return len(r.sessions)
200-}
201-
202189 func trafficBackendEqual(left domain.TrafficBackend, right domain.TrafficBackend) bool {
203190 return left.Name == right.Name && left.Host == right.Host && left.Port == right.Port && left.Protocol == right.Protocol
204191 }
@@ -171,13 +171,6 @@ func (r *udpEntryPointRuntime) session(key string, clientAddr net.Addr, options
171 _ = backendConn.Close()171 _ = backendConn.Close()
172 return existing, true172 return existing, true
173 }173 }
174- r.mu.Unlock()
175- if options.MaxSessions > 0 && r.sessionCount() >= options.MaxSessions {
176- _ = backendConn.Close()
177- r.counters.totalRefused.Add(1)
178- return nil, false
179- }
180- r.mu.Lock()
181 if options.MaxSessions > 0 && len(r.sessions) >= options.MaxSessions {174 if options.MaxSessions > 0 && len(r.sessions) >= options.MaxSessions {
182 r.mu.Unlock()175 r.mu.Unlock()
183 _ = backendConn.Close()176 _ = backendConn.Close()
@@ -193,12 +186,6 @@ func (r *udpEntryPointRuntime) session(key string, clientAddr net.Addr, options
193 return session, true186 return session, true
194 }187 }
195 188
196-func (r *udpEntryPointRuntime) sessionCount() int {
197- r.mu.Lock()
198- defer r.mu.Unlock()
199- return len(r.sessions)
200-}
201-
202 func trafficBackendEqual(left domain.TrafficBackend, right domain.TrafficBackend) bool {189 func trafficBackendEqual(left domain.TrafficBackend, right domain.TrafficBackend) bool {
203 return left.Name == right.Name && left.Host == right.Host && left.Port == right.Port && left.Protocol == right.Protocol190 return left.Name == right.Name && left.Host == right.Host && left.Port == right.Port && left.Protocol == right.Protocol
204 }191 }
M internal/domain/service.go +60 -21
@@ -87,9 +87,13 @@ func (c StandaloneServiceCleanup) WithDefaults() StandaloneServiceCleanup {
8787
8888 func (s StandaloneService) Validate() error {
8989 s = s.WithDefaults()
90- if strings.TrimSpace(s.Name) == "" {
90+ name := strings.TrimSpace(s.Name)
91+ if name == "" {
9192 return fmt.Errorf("standalone service name is required")
9293 }
94+ if s.Name != name {
95+ return fmt.Errorf("standalone service name %q must not include leading or trailing whitespace", s.Name)
96+ }
9397 if s.Enabled && strings.TrimSpace(s.Image) == "" {
9498 return fmt.Errorf("standalone service %q image is required when enabled", s.Name)
9599 }
@@ -114,33 +118,56 @@ func (s StandaloneService) Validate() error {
114118 func validateStandaloneServicePorts(s StandaloneService) error {
115119 seen := make(map[string]struct{}, len(s.Ports))
116120 for i, port := range s.Ports {
117- name := strings.TrimSpace(port.Name)
118- if name == "" {
119- return fmt.Errorf("standalone service %q port %d name is required", s.Name, i)
121+ name, err := validateStandaloneServicePort(s.Name, i, port)
122+ if err != nil {
123+ return err
120124 }
121125 if _, ok := seen[name]; ok {
122126 return fmt.Errorf("standalone service %q duplicate port name %q", s.Name, name)
123127 }
124128 seen[name] = struct{}{}
125- if port.Container <= 0 || port.Container > 65535 {
126- return fmt.Errorf("standalone service %q port %q container port must be 1-65535", s.Name, name)
127- }
128- if port.Protocol != NetworkProtocolTCP && port.Protocol != NetworkProtocolUDP {
129- return fmt.Errorf("standalone service %q port %q protocol must be tcp or udp", s.Name, name)
130- }
131- if port.Private && port.Public {
132- return fmt.Errorf("standalone service %q port %q cannot be both private and public", s.Name, name)
129+ }
130+ return nil
131+}
132+
133+func validateStandaloneServicePort(serviceName string, index int, port StandaloneServicePort) (string, error) {
134+ name := strings.TrimSpace(port.Name)
135+ if name == "" {
136+ return "", fmt.Errorf("standalone service %q port %d name is required", serviceName, index)
137+ }
138+ if port.Name != name {
139+ return "", fmt.Errorf("standalone service %q port %q name must not include leading or trailing whitespace", serviceName, port.Name)
140+ }
141+ if port.Container <= 0 || port.Container > 65535 {
142+ return "", fmt.Errorf("standalone service %q port %q container port must be 1-65535", serviceName, name)
143+ }
144+ if port.Protocol != NetworkProtocolTCP && port.Protocol != NetworkProtocolUDP {
145+ return "", fmt.Errorf("standalone service %q port %q protocol must be tcp or udp", serviceName, name)
146+ }
147+ if port.Private && port.Public {
148+ return "", fmt.Errorf("standalone service %q port %q cannot be both private and public", serviceName, name)
149+ }
150+ if err := validateStandaloneServicePublish(serviceName, name, port.Publish); err != nil {
151+ return "", err
152+ }
153+ if port.Private && port.Publish != "" {
154+ if err := validatePrivateStandaloneServicePublish(serviceName, name, port.Publish); err != nil {
155+ return "", err
133156 }
134- if err := validateStandaloneServicePublish(s.Name, name, port.Publish); err != nil {
135- return err
157+ }
158+ if err := validateStandaloneServiceTrustedCIDRs(serviceName, name, port.TrustedCIDRs); err != nil {
159+ return "", err
160+ }
161+ return name, nil
162+}
163+
164+func validateStandaloneServiceTrustedCIDRs(serviceName, portName string, cidrs []string) error {
165+ for _, cidr := range cidrs {
166+ if strings.TrimSpace(cidr) == "" {
167+ return fmt.Errorf("standalone service %q port %q trusted_cidrs must not contain empty values", serviceName, portName)
136168 }
137- for _, cidr := range port.TrustedCIDRs {
138- if strings.TrimSpace(cidr) == "" {
139- return fmt.Errorf("standalone service %q port %q trusted_cidrs must not contain empty values", s.Name, name)
140- }
141- if _, _, err := net.ParseCIDR(cidr); err != nil {
142- return fmt.Errorf("standalone service %q port %q trusted_cidr %q is invalid: %w", s.Name, name, cidr, err)
143- }
169+ if _, _, err := net.ParseCIDR(cidr); err != nil {
170+ return fmt.Errorf("standalone service %q port %q trusted_cidr %q is invalid: %w", serviceName, portName, cidr, err)
144171 }
145172 }
146173 return nil
@@ -164,6 +191,18 @@ func validateStandaloneServicePublish(serviceName, portName, publish string) err
164191 return nil
165192 }
166193
194+func validatePrivateStandaloneServicePublish(serviceName, portName, publish string) error {
195+ host, _, err := net.SplitHostPort(publish)
196+ if err != nil {
197+ return err
198+ }
199+ ip := net.ParseIP(host)
200+ if ip == nil || !ip.IsLoopback() {
201+ return fmt.Errorf("standalone service %q private port %q publish host %q must be loopback", serviceName, portName, host)
202+ }
203+ return nil
204+}
205+
167206 func validateStandaloneServiceVolumes(s StandaloneService) error {
168207 seenTargets := make(map[string]struct{}, len(s.Volumes))
169208 for i, volume := range s.Volumes {
@@ -87,9 +87,13 @@ func (c StandaloneServiceCleanup) WithDefaults() StandaloneServiceCleanup {
87 87
88 func (s StandaloneService) Validate() error {88 func (s StandaloneService) Validate() error {
89 s = s.WithDefaults()89 s = s.WithDefaults()
90- if strings.TrimSpace(s.Name) == "" {90+ name := strings.TrimSpace(s.Name)
91+ if name == "" {
91 return fmt.Errorf("standalone service name is required")92 return fmt.Errorf("standalone service name is required")
92 }93 }
94+ if s.Name != name {
95+ return fmt.Errorf("standalone service name %q must not include leading or trailing whitespace", s.Name)
96+ }
93 if s.Enabled && strings.TrimSpace(s.Image) == "" {97 if s.Enabled && strings.TrimSpace(s.Image) == "" {
94 return fmt.Errorf("standalone service %q image is required when enabled", s.Name)98 return fmt.Errorf("standalone service %q image is required when enabled", s.Name)
95 }99 }
@@ -114,33 +118,56 @@ func (s StandaloneService) Validate() error {
114 func validateStandaloneServicePorts(s StandaloneService) error {118 func validateStandaloneServicePorts(s StandaloneService) error {
115 seen := make(map[string]struct{}, len(s.Ports))119 seen := make(map[string]struct{}, len(s.Ports))
116 for i, port := range s.Ports {120 for i, port := range s.Ports {
117- name := strings.TrimSpace(port.Name)121+ name, err := validateStandaloneServicePort(s.Name, i, port)
118- if name == "" {122+ if err != nil {
119- return fmt.Errorf("standalone service %q port %d name is required", s.Name, i)123+ return err
120 }124 }
121 if _, ok := seen[name]; ok {125 if _, ok := seen[name]; ok {
122 return fmt.Errorf("standalone service %q duplicate port name %q", s.Name, name)126 return fmt.Errorf("standalone service %q duplicate port name %q", s.Name, name)
123 }127 }
124 seen[name] = struct{}{}128 seen[name] = struct{}{}
125- if port.Container <= 0 || port.Container > 65535 {129+ }
126- return fmt.Errorf("standalone service %q port %q container port must be 1-65535", s.Name, name)130+ return nil
127- }131+}
128- if port.Protocol != NetworkProtocolTCP && port.Protocol != NetworkProtocolUDP {132+
129- return fmt.Errorf("standalone service %q port %q protocol must be tcp or udp", s.Name, name)133+func validateStandaloneServicePort(serviceName string, index int, port StandaloneServicePort) (string, error) {
130- }134+ name := strings.TrimSpace(port.Name)
131- if port.Private && port.Public {135+ if name == "" {
132- return fmt.Errorf("standalone service %q port %q cannot be both private and public", s.Name, name)136+ return "", fmt.Errorf("standalone service %q port %d name is required", serviceName, index)
137+ }
138+ if port.Name != name {
139+ return "", fmt.Errorf("standalone service %q port %q name must not include leading or trailing whitespace", serviceName, port.Name)
140+ }
141+ if port.Container <= 0 || port.Container > 65535 {
142+ return "", fmt.Errorf("standalone service %q port %q container port must be 1-65535", serviceName, name)
143+ }
144+ if port.Protocol != NetworkProtocolTCP && port.Protocol != NetworkProtocolUDP {
145+ return "", fmt.Errorf("standalone service %q port %q protocol must be tcp or udp", serviceName, name)
146+ }
147+ if port.Private && port.Public {
148+ return "", fmt.Errorf("standalone service %q port %q cannot be both private and public", serviceName, name)
149+ }
150+ if err := validateStandaloneServicePublish(serviceName, name, port.Publish); err != nil {
151+ return "", err
152+ }
153+ if port.Private && port.Publish != "" {
154+ if err := validatePrivateStandaloneServicePublish(serviceName, name, port.Publish); err != nil {
155+ return "", err
133 }156 }
134- if err := validateStandaloneServicePublish(s.Name, name, port.Publish); err != nil {157+ }
135- return err158+ if err := validateStandaloneServiceTrustedCIDRs(serviceName, name, port.TrustedCIDRs); err != nil {
159+ return "", err
160+ }
161+ return name, nil
162+}
163+
164+func validateStandaloneServiceTrustedCIDRs(serviceName, portName string, cidrs []string) error {
165+ for _, cidr := range cidrs {
166+ if strings.TrimSpace(cidr) == "" {
167+ return fmt.Errorf("standalone service %q port %q trusted_cidrs must not contain empty values", serviceName, portName)
136 }168 }
137- for _, cidr := range port.TrustedCIDRs {169+ if _, _, err := net.ParseCIDR(cidr); err != nil {
138- if strings.TrimSpace(cidr) == "" {170+ return fmt.Errorf("standalone service %q port %q trusted_cidr %q is invalid: %w", serviceName, portName, cidr, err)
139- return fmt.Errorf("standalone service %q port %q trusted_cidrs must not contain empty values", s.Name, name)
140- }
141- if _, _, err := net.ParseCIDR(cidr); err != nil {
142- return fmt.Errorf("standalone service %q port %q trusted_cidr %q is invalid: %w", s.Name, name, cidr, err)
143- }
144 }171 }
145 }172 }
146 return nil173 return nil
@@ -164,6 +191,18 @@ func validateStandaloneServicePublish(serviceName, portName, publish string) err
164 return nil191 return nil
165 }192 }
166 193
194+func validatePrivateStandaloneServicePublish(serviceName, portName, publish string) error {
195+ host, _, err := net.SplitHostPort(publish)
196+ if err != nil {
197+ return err
198+ }
199+ ip := net.ParseIP(host)
200+ if ip == nil || !ip.IsLoopback() {
201+ return fmt.Errorf("standalone service %q private port %q publish host %q must be loopback", serviceName, portName, host)
202+ }
203+ return nil
204+}
205+
167 func validateStandaloneServiceVolumes(s StandaloneService) error {206 func validateStandaloneServiceVolumes(s StandaloneService) error {
168 seenTargets := make(map[string]struct{}, len(s.Volumes))207 seenTargets := make(map[string]struct{}, len(s.Volumes))
169 for i, volume := range s.Volumes {208 for i, volume := range s.Volumes {
M internal/domain/service_test.go +17 -0
@@ -15,6 +15,19 @@ func TestStandaloneServiceValidateRequiresNameAndImageWhenEnabled(t *testing.T)
1515 require.ErrorContains(t, svc.Validate(), "image")
1616 }
1717
18+func TestStandaloneServiceValidateRejectsPaddedNames(t *testing.T) {
19+ svc := StandaloneService{Name: " cache ", Image: "redis:7", Enabled: true}
20+ require.ErrorContains(t, svc.Validate(), "leading or trailing whitespace")
21+
22+ svc = StandaloneService{
23+ Name: "cache",
24+ Image: "redis:7",
25+ Enabled: true,
26+ Ports: []StandaloneServicePort{{Name: " admin ", Container: 6379, Protocol: NetworkProtocolTCP}},
27+ }
28+ require.ErrorContains(t, svc.Validate(), "leading or trailing whitespace")
29+}
30+
1831 func TestStandaloneServiceValidateAllowsDisabledServiceWithoutImage(t *testing.T) {
1932 svc := StandaloneService{Name: "cache", Enabled: false}
2033 require.NoError(t, svc.Validate())
@@ -47,6 +60,10 @@ func TestStandaloneServiceValidatePorts(t *testing.T) {
4760 conflictingVisibility := valid
4861 conflictingVisibility.Ports = []StandaloneServicePort{{Name: "rcon", Container: 28016, Protocol: NetworkProtocolTCP, Private: true, Public: true}}
4962 require.ErrorContains(t, conflictingVisibility.Validate(), "cannot be both private and public")
63+
64+ privateNonLoopback := valid
65+ privateNonLoopback.Ports = []StandaloneServicePort{{Name: "rcon", Container: 28016, Protocol: NetworkProtocolTCP, Private: true, Publish: "0.0.0.0:28016"}}
66+ require.ErrorContains(t, privateNonLoopback.Validate(), "must be loopback")
5067 }
5168
5269 func TestStandaloneServiceValidateVolumesAndEnvFile(t *testing.T) {
@@ -15,6 +15,19 @@ func TestStandaloneServiceValidateRequiresNameAndImageWhenEnabled(t *testing.T)
15 require.ErrorContains(t, svc.Validate(), "image")15 require.ErrorContains(t, svc.Validate(), "image")
16 }16 }
17 17
18+func TestStandaloneServiceValidateRejectsPaddedNames(t *testing.T) {
19+ svc := StandaloneService{Name: " cache ", Image: "redis:7", Enabled: true}
20+ require.ErrorContains(t, svc.Validate(), "leading or trailing whitespace")
21+
22+ svc = StandaloneService{
23+ Name: "cache",
24+ Image: "redis:7",
25+ Enabled: true,
26+ Ports: []StandaloneServicePort{{Name: " admin ", Container: 6379, Protocol: NetworkProtocolTCP}},
27+ }
28+ require.ErrorContains(t, svc.Validate(), "leading or trailing whitespace")
29+}
30+
18 func TestStandaloneServiceValidateAllowsDisabledServiceWithoutImage(t *testing.T) {31 func TestStandaloneServiceValidateAllowsDisabledServiceWithoutImage(t *testing.T) {
19 svc := StandaloneService{Name: "cache", Enabled: false}32 svc := StandaloneService{Name: "cache", Enabled: false}
20 require.NoError(t, svc.Validate())33 require.NoError(t, svc.Validate())
@@ -47,6 +60,10 @@ func TestStandaloneServiceValidatePorts(t *testing.T) {
47 conflictingVisibility := valid60 conflictingVisibility := valid
48 conflictingVisibility.Ports = []StandaloneServicePort{{Name: "rcon", Container: 28016, Protocol: NetworkProtocolTCP, Private: true, Public: true}}61 conflictingVisibility.Ports = []StandaloneServicePort{{Name: "rcon", Container: 28016, Protocol: NetworkProtocolTCP, Private: true, Public: true}}
49 require.ErrorContains(t, conflictingVisibility.Validate(), "cannot be both private and public")62 require.ErrorContains(t, conflictingVisibility.Validate(), "cannot be both private and public")
63+
64+ privateNonLoopback := valid
65+ privateNonLoopback.Ports = []StandaloneServicePort{{Name: "rcon", Container: 28016, Protocol: NetworkProtocolTCP, Private: true, Publish: "0.0.0.0:28016"}}
66+ require.ErrorContains(t, privateNonLoopback.Validate(), "must be loopback")
50 }67 }
51 68
52 func TestStandaloneServiceValidateVolumesAndEnvFile(t *testing.T) {69 func TestStandaloneServiceValidateVolumesAndEnvFile(t *testing.T) {
M internal/domain/traffic.go +6 -0
@@ -591,6 +591,12 @@ func l4BackendProtocol(protocol RouterProtocol) (NetworkProtocol, bool) {
591591
592592 func validateRawFallbackEntrypoint(entryPoint EntryPoint) error {
593593 if entryPoint.Protocol == EntryPointProtocolSmartTCP {
594+ if entryPoint.RawFallback == "" && len(entryPoint.RawFallbackTrustedCIDRs) > 0 {
595+ return fmt.Errorf("raw_fallback_trusted_cidrs on entrypoint %q requires raw_fallback", entryPoint.Name)
596+ }
597+ if entryPoint.RawFallback == "" && entryPoint.AllowPublicRawFallback {
598+ return fmt.Errorf("allow_public_raw_fallback on entrypoint %q requires raw_fallback", entryPoint.Name)
599+ }
594600 return nil
595601 }
596602 if entryPoint.RawFallback != "" {
@@ -591,6 +591,12 @@ func l4BackendProtocol(protocol RouterProtocol) (NetworkProtocol, bool) {
591 591
592 func validateRawFallbackEntrypoint(entryPoint EntryPoint) error {592 func validateRawFallbackEntrypoint(entryPoint EntryPoint) error {
593 if entryPoint.Protocol == EntryPointProtocolSmartTCP {593 if entryPoint.Protocol == EntryPointProtocolSmartTCP {
594+ if entryPoint.RawFallback == "" && len(entryPoint.RawFallbackTrustedCIDRs) > 0 {
595+ return fmt.Errorf("raw_fallback_trusted_cidrs on entrypoint %q requires raw_fallback", entryPoint.Name)
596+ }
597+ if entryPoint.RawFallback == "" && entryPoint.AllowPublicRawFallback {
598+ return fmt.Errorf("allow_public_raw_fallback on entrypoint %q requires raw_fallback", entryPoint.Name)
599+ }
594 return nil600 return nil
595 }601 }
596 if entryPoint.RawFallback != "" {602 if entryPoint.RawFallback != "" {
M internal/domain/traffic_test.go +10 -0
@@ -48,6 +48,16 @@ func TestTrafficGraphSmartTCPRawFallbackValidation(t *testing.T) {
4848 routers: []TrafficRouter{{Name: "ssh-fallback", EntryPoint: "edge", Protocol: RouterProtocolTCP, Service: "network_service:ssh:ssh"}},
4949 wantErr: "raw fallback",
5050 },
51+ {
52+ name: "raw fallback cidrs require raw fallback on smart tcp",
53+ entryPoint: EntryPoint{Name: "edge", Address: ":443", Protocol: EntryPointProtocolSmartTCP, RawFallbackTrustedCIDRs: []string{"10.0.0.0/8"}},
54+ wantErr: "requires raw_fallback",
55+ },
56+ {
57+ name: "public raw fallback flag requires raw fallback on smart tcp",
58+ entryPoint: EntryPoint{Name: "edge", Address: ":443", Protocol: EntryPointProtocolSmartTCP, AllowPublicRawFallback: true},
59+ wantErr: "requires raw_fallback",
60+ },
5161 {
5262 name: "missing raw fallback router rejected",
5363 entryPoint: EntryPoint{Name: "edge", Address: ":443", Protocol: EntryPointProtocolSmartTCP, RawFallback: "missing", RawFallbackTrustedCIDRs: []string{"10.0.0.0/8"}},
@@ -48,6 +48,16 @@ func TestTrafficGraphSmartTCPRawFallbackValidation(t *testing.T) {
48 routers: []TrafficRouter{{Name: "ssh-fallback", EntryPoint: "edge", Protocol: RouterProtocolTCP, Service: "network_service:ssh:ssh"}},48 routers: []TrafficRouter{{Name: "ssh-fallback", EntryPoint: "edge", Protocol: RouterProtocolTCP, Service: "network_service:ssh:ssh"}},
49 wantErr: "raw fallback",49 wantErr: "raw fallback",
50 },50 },
51+ {
52+ name: "raw fallback cidrs require raw fallback on smart tcp",
53+ entryPoint: EntryPoint{Name: "edge", Address: ":443", Protocol: EntryPointProtocolSmartTCP, RawFallbackTrustedCIDRs: []string{"10.0.0.0/8"}},
54+ wantErr: "requires raw_fallback",
55+ },
56+ {
57+ name: "public raw fallback flag requires raw fallback on smart tcp",
58+ entryPoint: EntryPoint{Name: "edge", Address: ":443", Protocol: EntryPointProtocolSmartTCP, AllowPublicRawFallback: true},
59+ wantErr: "requires raw_fallback",
60+ },
51 {61 {
52 name: "missing raw fallback router rejected",62 name: "missing raw fallback router rejected",
53 entryPoint: EntryPoint{Name: "edge", Address: ":443", Protocol: EntryPointProtocolSmartTCP, RawFallback: "missing", RawFallbackTrustedCIDRs: []string{"10.0.0.0/8"}},63 entryPoint: EntryPoint{Name: "edge", Address: ":443", Protocol: EntryPointProtocolSmartTCP, RawFallback: "missing", RawFallbackTrustedCIDRs: []string{"10.0.0.0/8"}},
M internal/usecase/services/config.go +10 -0
@@ -64,6 +64,7 @@ type CleanupConfig struct {
6464 func ToDomain(configs []Config) ([]domain.StandaloneService, error) {
6565 services := make([]domain.StandaloneService, 0, len(configs))
6666 seenNames := make(map[string]struct{}, len(configs))
67+ seenRuntimeNames := make(map[string]string, len(configs))
6768 for i, cfg := range configs {
6869 svc, err := cfg.ToDomain()
6970 if err != nil {
@@ -73,7 +74,12 @@ func ToDomain(configs []Config) ([]domain.StandaloneService, error) {
7374 if _, ok := seenNames[name]; ok {
7475 return nil, fmt.Errorf("service config %d: duplicate service name %q", i, name)
7576 }
77+ runtimeName := serviceRuntimeIdentifier(name)
78+ if previous, ok := seenRuntimeNames[runtimeName]; ok {
79+ return nil, fmt.Errorf("service config %d: duplicate service name %q normalizes to same runtime identifier as %q", i, name, previous)
80+ }
7681 seenNames[name] = struct{}{}
82+ seenRuntimeNames[runtimeName] = name
7783 services = append(services, svc)
7884 }
7985 return services, nil
@@ -200,3 +206,7 @@ func ManagedServiceVolumeName(prefix, serviceName, volumePath string) string {
200206 strings.ReplaceAll(serviceName, ".", "-"),
201207 strings.ReplaceAll(strings.Trim(volumePath, "/"), "/", "-"))
202208 }
209+
210+func serviceRuntimeIdentifier(name string) string {
211+ return strings.NewReplacer(".", "-", "_", "-", "/", "-").Replace(name)
212+}
@@ -64,6 +64,7 @@ type CleanupConfig struct {
64 func ToDomain(configs []Config) ([]domain.StandaloneService, error) {64 func ToDomain(configs []Config) ([]domain.StandaloneService, error) {
65 services := make([]domain.StandaloneService, 0, len(configs))65 services := make([]domain.StandaloneService, 0, len(configs))
66 seenNames := make(map[string]struct{}, len(configs))66 seenNames := make(map[string]struct{}, len(configs))
67+ seenRuntimeNames := make(map[string]string, len(configs))
67 for i, cfg := range configs {68 for i, cfg := range configs {
68 svc, err := cfg.ToDomain()69 svc, err := cfg.ToDomain()
69 if err != nil {70 if err != nil {
@@ -73,7 +74,12 @@ func ToDomain(configs []Config) ([]domain.StandaloneService, error) {
73 if _, ok := seenNames[name]; ok {74 if _, ok := seenNames[name]; ok {
74 return nil, fmt.Errorf("service config %d: duplicate service name %q", i, name)75 return nil, fmt.Errorf("service config %d: duplicate service name %q", i, name)
75 }76 }
77+ runtimeName := serviceRuntimeIdentifier(name)
78+ if previous, ok := seenRuntimeNames[runtimeName]; ok {
79+ return nil, fmt.Errorf("service config %d: duplicate service name %q normalizes to same runtime identifier as %q", i, name, previous)
80+ }
76 seenNames[name] = struct{}{}81 seenNames[name] = struct{}{}
82+ seenRuntimeNames[runtimeName] = name
77 services = append(services, svc)83 services = append(services, svc)
78 }84 }
79 return services, nil85 return services, nil
@@ -200,3 +206,7 @@ func ManagedServiceVolumeName(prefix, serviceName, volumePath string) string {
200 strings.ReplaceAll(serviceName, ".", "-"),206 strings.ReplaceAll(serviceName, ".", "-"),
201 strings.ReplaceAll(strings.Trim(volumePath, "/"), "/", "-"))207 strings.ReplaceAll(strings.Trim(volumePath, "/"), "/", "-"))
202 }208 }
209+
210+func serviceRuntimeIdentifier(name string) string {
211+ return strings.NewReplacer(".", "-", "_", "-", "/", "-").Replace(name)
212+}
M internal/usecase/services/config_test.go +11 -0
@@ -20,6 +20,17 @@ func TestToDomainRejectsDuplicateServiceNames(t *testing.T) {
2020 require.ErrorContains(t, err, "duplicate service name")
2121 }
2222
23+func TestToDomainRejectsServiceNamesWithDuplicateRuntimeIdentifier(t *testing.T) {
24+ configs := []Config{
25+ {Name: "cache.api", Image: "redis:7", Enabled: true},
26+ {Name: "cache_api", Image: "redis:7", Enabled: true},
27+ }
28+
29+ _, err := ToDomain(configs)
30+
31+ require.ErrorContains(t, err, "runtime identifier")
32+}
33+
2334 func TestConfigToDomainRejectsNonPositiveReadinessTimeout(t *testing.T) {
2435 for _, timeout := range []string{"0s", "-1s"} {
2536 t.Run(timeout, func(t *testing.T) {
@@ -20,6 +20,17 @@ func TestToDomainRejectsDuplicateServiceNames(t *testing.T) {
20 require.ErrorContains(t, err, "duplicate service name")20 require.ErrorContains(t, err, "duplicate service name")
21 }21 }
22 22
23+func TestToDomainRejectsServiceNamesWithDuplicateRuntimeIdentifier(t *testing.T) {
24+ configs := []Config{
25+ {Name: "cache.api", Image: "redis:7", Enabled: true},
26+ {Name: "cache_api", Image: "redis:7", Enabled: true},
27+ }
28+
29+ _, err := ToDomain(configs)
30+
31+ require.ErrorContains(t, err, "runtime identifier")
32+}
33+
23 func TestConfigToDomainRejectsNonPositiveReadinessTimeout(t *testing.T) {34 func TestConfigToDomainRejectsNonPositiveReadinessTimeout(t *testing.T) {
24 for _, timeout := range []string{"0s", "-1s"} {35 for _, timeout := range []string{"0s", "-1s"} {
25 t.Run(timeout, func(t *testing.T) {36 t.Run(timeout, func(t *testing.T) {
M internal/usecase/services/service.go +28 -13
@@ -94,14 +94,21 @@ func (s *Service) reconcileOne(ctx context.Context, svc domain.StandaloneService
9494 if !svc.Enabled {
9595 return s.stopDisabled(ctx, svc.Name, cleanup, existing)
9696 }
97- hash, err := serviceConfigHash(svc)
97+ hash, err := s.serviceConfigHash(ctx, svc)
9898 if err != nil {
9999 return fmt.Errorf("hash standalone service %q config: %w", svc.Name, err)
100100 }
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 })
104+ sort.SliceStable(existing, func(i, j int) bool {
105+ leftRunning := containerStatus(existing[i]) == domain.ContainerStatusRunning
106+ rightRunning := containerStatus(existing[j]) == domain.ContainerStatusRunning
107+ if leftRunning != rightRunning {
108+ return leftRunning
109+ }
110+ return existing[i].ID < existing[j].ID
111+ })
105112 current := existing[0]
106113 if current.Labels[domain.LabelServiceConfigHash] != hash {
107114 if err := s.recreate(ctx, svc, cleanup, existing, hash); err != nil {
@@ -339,17 +346,22 @@ func portPublishes(svc domain.StandaloneService) ([]domain.ContainerPortPublish,
339346 }
340347
341348 func serviceConfigHash(svc domain.StandaloneService) (string, error) {
349+ return NewService(nil).serviceConfigHash(context.Background(), svc)
350+}
351+
352+func (s *Service) serviceConfigHash(ctx context.Context, svc domain.StandaloneService) (string, error) {
353+ resolvedEnv, err := s.serviceEnv(ctx, svc)
354+ if err != nil {
355+ return "", err
356+ }
342357 payload := struct {
343- Image string
344- Env []string
345- EnvFile string
346- Secrets []domain.StandaloneServiceSecretRef
347- Readiness domain.StandaloneServiceReadiness
348- Ports []domain.StandaloneServicePort
349- Volumes []domain.StandaloneServiceVolume
350- Cleanup domain.StandaloneServiceCleanup
351- }{svc.Image, append([]string(nil), svc.Env...), svc.EnvFile, append([]domain.StandaloneServiceSecretRef(nil), svc.Secrets...), svc.Readiness, append([]domain.StandaloneServicePort(nil), svc.Ports...), append([]domain.StandaloneServiceVolume(nil), svc.Volumes...), normalizeCleanup(svc.Cleanup)}
352- sort.Strings(payload.Env)
358+ Image string
359+ ResolvedEnv []string
360+ Readiness domain.StandaloneServiceReadiness
361+ Ports []domain.StandaloneServicePort
362+ Volumes []domain.StandaloneServiceVolume
363+ Cleanup domain.StandaloneServiceCleanup
364+ }{svc.Image, resolvedEnv, svc.Readiness, append([]domain.StandaloneServicePort(nil), svc.Ports...), append([]domain.StandaloneServiceVolume(nil), svc.Volumes...), normalizeCleanup(svc.Cleanup)}
353365 bytes, err := json.Marshal(payload)
354366 if err != nil {
355367 return "", err
@@ -496,7 +508,10 @@ func tcpReadinessAddress(svc domain.StandaloneService) (string, error) {
496508 if err != nil {
497509 return "", fmt.Errorf("parse standalone service %q tcp readiness publish: %w", svc.Name, err)
498510 }
499- if host == "0.0.0.0" || host == "::" || host == "" {
511+ switch host {
512+ case "::":
513+ host = "::1"
514+ case "0.0.0.0", "":
500515 host = "127.0.0.1"
501516 }
502517 return net.JoinHostPort(host, hostPort), nil
@@ -94,14 +94,21 @@ func (s *Service) reconcileOne(ctx context.Context, svc domain.StandaloneService
94 if !svc.Enabled {94 if !svc.Enabled {
95 return s.stopDisabled(ctx, svc.Name, cleanup, existing)95 return s.stopDisabled(ctx, svc.Name, cleanup, existing)
96 }96 }
97- hash, err := serviceConfigHash(svc)97+ hash, err := s.serviceConfigHash(ctx, svc)
98 if err != nil {98 if err != nil {
99 return fmt.Errorf("hash standalone service %q config: %w", svc.Name, err)99 return fmt.Errorf("hash standalone service %q config: %w", svc.Name, err)
100 }100 }
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+ sort.SliceStable(existing, func(i, j int) bool {
105+ leftRunning := containerStatus(existing[i]) == domain.ContainerStatusRunning
106+ rightRunning := containerStatus(existing[j]) == domain.ContainerStatusRunning
107+ if leftRunning != rightRunning {
108+ return leftRunning
109+ }
110+ return existing[i].ID < existing[j].ID
111+ })
105 current := existing[0]112 current := existing[0]
106 if current.Labels[domain.LabelServiceConfigHash] != hash {113 if current.Labels[domain.LabelServiceConfigHash] != hash {
107 if err := s.recreate(ctx, svc, cleanup, existing, hash); err != nil {114 if err := s.recreate(ctx, svc, cleanup, existing, hash); err != nil {
@@ -339,17 +346,22 @@ func portPublishes(svc domain.StandaloneService) ([]domain.ContainerPortPublish,
339 }346 }
340 347
341 func serviceConfigHash(svc domain.StandaloneService) (string, error) {348 func serviceConfigHash(svc domain.StandaloneService) (string, error) {
349+ return NewService(nil).serviceConfigHash(context.Background(), svc)
350+}
351+
352+func (s *Service) serviceConfigHash(ctx context.Context, svc domain.StandaloneService) (string, error) {
353+ resolvedEnv, err := s.serviceEnv(ctx, svc)
354+ if err != nil {
355+ return "", err
356+ }
342 payload := struct {357 payload := struct {
343- Image string358+ Image string
344- Env []string359+ ResolvedEnv []string
345- EnvFile string360+ Readiness domain.StandaloneServiceReadiness
346- Secrets []domain.StandaloneServiceSecretRef361+ Ports []domain.StandaloneServicePort
347- Readiness domain.StandaloneServiceReadiness362+ Volumes []domain.StandaloneServiceVolume
348- Ports []domain.StandaloneServicePort363+ Cleanup domain.StandaloneServiceCleanup
349- Volumes []domain.StandaloneServiceVolume364+ }{svc.Image, resolvedEnv, svc.Readiness, append([]domain.StandaloneServicePort(nil), svc.Ports...), append([]domain.StandaloneServiceVolume(nil), svc.Volumes...), normalizeCleanup(svc.Cleanup)}
350- Cleanup domain.StandaloneServiceCleanup
351- }{svc.Image, append([]string(nil), svc.Env...), svc.EnvFile, append([]domain.StandaloneServiceSecretRef(nil), svc.Secrets...), svc.Readiness, append([]domain.StandaloneServicePort(nil), svc.Ports...), append([]domain.StandaloneServiceVolume(nil), svc.Volumes...), normalizeCleanup(svc.Cleanup)}
352- sort.Strings(payload.Env)
353 bytes, err := json.Marshal(payload)365 bytes, err := json.Marshal(payload)
354 if err != nil {366 if err != nil {
355 return "", err367 return "", err
@@ -496,7 +508,10 @@ func tcpReadinessAddress(svc domain.StandaloneService) (string, error) {
496 if err != nil {508 if err != nil {
497 return "", fmt.Errorf("parse standalone service %q tcp readiness publish: %w", svc.Name, err)509 return "", fmt.Errorf("parse standalone service %q tcp readiness publish: %w", svc.Name, err)
498 }510 }
499- if host == "0.0.0.0" || host == "::" || host == "" {511+ switch host {
512+ case "::":
513+ host = "::1"
514+ case "0.0.0.0", "":
500 host = "127.0.0.1"515 host = "127.0.0.1"
501 }516 }
502 return net.JoinHostPort(host, hostPort), nil517 return net.JoinHostPort(host, hostPort), nil
M internal/usecase/services/service_test.go +43 -0
@@ -107,6 +107,21 @@ func TestService_ReconcileRemovesDuplicateMatchingServiceContainers(t *testing.T
107107 require.NoError(t, err)
108108 }
109109
110+func TestService_ReconcileKeepsRunningDuplicateBeforeLowerStaleID(t *testing.T) {
111+ rt := outmocks.NewMockContainerRuntime(t)
112+ svc := sampleService()
113+ hash, err := serviceConfigHash(svc)
114+ require.NoError(t, err)
115+ staleLowerID := managedContainer("existing-1", svc.Name, hash, "exited")
116+ runningPrimary := managedContainer("existing-2", svc.Name, hash, "running")
117+ rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{staleLowerID, runningPrimary}, nil).Once()
118+ rt.On("RemoveContainer", mock.Anything, "existing-1", true).Return(nil).Once()
119+
120+ err = NewService(rt).Reconcile(context.Background(), []domain.StandaloneService{svc})
121+
122+ require.NoError(t, err)
123+}
124+
110125 func TestService_ReconcileStopsAndRemovesDisabledService(t *testing.T) {
111126 rt := outmocks.NewMockContainerRuntime(t)
112127 svc := sampleService()
@@ -230,6 +245,24 @@ func TestServiceEnvLoadsEnvFileAndMergesInlineEnv(t *testing.T) {
230245 assert.Equal(t, []string{"FROM_FILE=file", "INLINE=value", "OVERRIDE=inline"}, env)
231246 }
232247
248+func TestServiceConfigHashIncludesResolvedEnvFileAndSecretValues(t *testing.T) {
249+ envFile := writeTempEnvFile(t, "FROM_FILE=one\n")
250+ provider := outmocks.NewMockSecretProvider(t)
251+ svc := sampleService()
252+ svc.EnvFile = envFile
253+ svc.Secrets = []domain.StandaloneServiceSecretRef{{Name: "rcon", Key: "RCON_PASSWORD"}}
254+ provider.On("GetSecret", mock.Anything, "service/game/rcon").Return("secret-one", nil).Once()
255+ first, err := NewServiceWithSecretProvider(nil, provider).serviceConfigHash(context.Background(), svc)
256+ require.NoError(t, err)
257+
258+ require.NoError(t, os.WriteFile(envFile, []byte("FROM_FILE=two\n"), 0o600))
259+ provider.On("GetSecret", mock.Anything, "service/game/rcon").Return("secret-two", nil).Once()
260+ second, err := NewServiceWithSecretProvider(nil, provider).serviceConfigHash(context.Background(), svc)
261+ require.NoError(t, err)
262+
263+ assert.NotEqual(t, first, second)
264+}
265+
233266 func TestServiceEnvResolvesServiceScopedSecretsWithoutLeakingValues(t *testing.T) {
234267 rt := outmocks.NewMockContainerRuntime(t)
235268 provider := outmocks.NewMockSecretProvider(t)
@@ -269,6 +302,16 @@ func TestServiceEnvUsesExplicitProviderSecretPaths(t *testing.T) {
269302 assert.Contains(t, env, "RCON_PASSWORD=secret-value")
270303 }
271304
305+func TestTCPReadinessAddressMapsIPv6WildcardToIPv6Loopback(t *testing.T) {
306+ svc := sampleService()
307+ svc.Ports = []domain.StandaloneServicePort{{Name: "admin", Container: 28016, Protocol: domain.NetworkProtocolTCP, Publish: "[::]:38016"}}
308+
309+ address, err := tcpReadinessAddress(svc)
310+
311+ require.NoError(t, err)
312+ assert.Equal(t, "[::1]:38016", address)
313+}
314+
272315 func TestWaitTCPReadinessDialsResolvedLoopbackPublish(t *testing.T) {
273316 listener, err := net.Listen("tcp", "127.0.0.1:0")
274317 require.NoError(t, err)
@@ -107,6 +107,21 @@ func TestService_ReconcileRemovesDuplicateMatchingServiceContainers(t *testing.T
107 require.NoError(t, err)107 require.NoError(t, err)
108 }108 }
109 109
110+func TestService_ReconcileKeepsRunningDuplicateBeforeLowerStaleID(t *testing.T) {
111+ rt := outmocks.NewMockContainerRuntime(t)
112+ svc := sampleService()
113+ hash, err := serviceConfigHash(svc)
114+ require.NoError(t, err)
115+ staleLowerID := managedContainer("existing-1", svc.Name, hash, "exited")
116+ runningPrimary := managedContainer("existing-2", svc.Name, hash, "running")
117+ rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{staleLowerID, runningPrimary}, nil).Once()
118+ rt.On("RemoveContainer", mock.Anything, "existing-1", true).Return(nil).Once()
119+
120+ err = NewService(rt).Reconcile(context.Background(), []domain.StandaloneService{svc})
121+
122+ require.NoError(t, err)
123+}
124+
110 func TestService_ReconcileStopsAndRemovesDisabledService(t *testing.T) {125 func TestService_ReconcileStopsAndRemovesDisabledService(t *testing.T) {
111 rt := outmocks.NewMockContainerRuntime(t)126 rt := outmocks.NewMockContainerRuntime(t)
112 svc := sampleService()127 svc := sampleService()
@@ -230,6 +245,24 @@ func TestServiceEnvLoadsEnvFileAndMergesInlineEnv(t *testing.T) {
230 assert.Equal(t, []string{"FROM_FILE=file", "INLINE=value", "OVERRIDE=inline"}, env)245 assert.Equal(t, []string{"FROM_FILE=file", "INLINE=value", "OVERRIDE=inline"}, env)
231 }246 }
232 247
248+func TestServiceConfigHashIncludesResolvedEnvFileAndSecretValues(t *testing.T) {
249+ envFile := writeTempEnvFile(t, "FROM_FILE=one\n")
250+ provider := outmocks.NewMockSecretProvider(t)
251+ svc := sampleService()
252+ svc.EnvFile = envFile
253+ svc.Secrets = []domain.StandaloneServiceSecretRef{{Name: "rcon", Key: "RCON_PASSWORD"}}
254+ provider.On("GetSecret", mock.Anything, "service/game/rcon").Return("secret-one", nil).Once()
255+ first, err := NewServiceWithSecretProvider(nil, provider).serviceConfigHash(context.Background(), svc)
256+ require.NoError(t, err)
257+
258+ require.NoError(t, os.WriteFile(envFile, []byte("FROM_FILE=two\n"), 0o600))
259+ provider.On("GetSecret", mock.Anything, "service/game/rcon").Return("secret-two", nil).Once()
260+ second, err := NewServiceWithSecretProvider(nil, provider).serviceConfigHash(context.Background(), svc)
261+ require.NoError(t, err)
262+
263+ assert.NotEqual(t, first, second)
264+}
265+
233 func TestServiceEnvResolvesServiceScopedSecretsWithoutLeakingValues(t *testing.T) {266 func TestServiceEnvResolvesServiceScopedSecretsWithoutLeakingValues(t *testing.T) {
234 rt := outmocks.NewMockContainerRuntime(t)267 rt := outmocks.NewMockContainerRuntime(t)
235 provider := outmocks.NewMockSecretProvider(t)268 provider := outmocks.NewMockSecretProvider(t)
@@ -269,6 +302,16 @@ func TestServiceEnvUsesExplicitProviderSecretPaths(t *testing.T) {
269 assert.Contains(t, env, "RCON_PASSWORD=secret-value")302 assert.Contains(t, env, "RCON_PASSWORD=secret-value")
270 }303 }
271 304
305+func TestTCPReadinessAddressMapsIPv6WildcardToIPv6Loopback(t *testing.T) {
306+ svc := sampleService()
307+ svc.Ports = []domain.StandaloneServicePort{{Name: "admin", Container: 28016, Protocol: domain.NetworkProtocolTCP, Publish: "[::]:38016"}}
308+
309+ address, err := tcpReadinessAddress(svc)
310+
311+ require.NoError(t, err)
312+ assert.Equal(t, "[::1]:38016", address)
313+}
314+
272 func TestWaitTCPReadinessDialsResolvedLoopbackPublish(t *testing.T) {315 func TestWaitTCPReadinessDialsResolvedLoopbackPublish(t *testing.T) {
273 listener, err := net.Listen("tcp", "127.0.0.1:0")316 listener, err := net.Listen("tcp", "127.0.0.1:0")
274 require.NoError(t, err)317 require.NoError(t, err)
M internal/usecase/traffic/builder.go +1 -1
@@ -303,7 +303,7 @@ func validateStandaloneServices(services []domain.StandaloneService) error {
303303 }
304304 seen[service.Name] = struct{}{}
305305 if err := service.Validate(); err != nil {
306- return err
306+ return fmt.Errorf("standalone service %q: %w", service.Name, err)
307307 }
308308 }
309309 return nil
@@ -303,7 +303,7 @@ func validateStandaloneServices(services []domain.StandaloneService) error {
303 }303 }
304 seen[service.Name] = struct{}{}304 seen[service.Name] = struct{}{}
305 if err := service.Validate(); err != nil {305 if err := service.Validate(); err != nil {
306- return err306+ return fmt.Errorf("standalone service %q: %w", service.Name, err)
307 }307 }
308 }308 }
309 return nil309 return nil
M internal/usecase/traffic/builder_test.go +15 -0
@@ -286,6 +286,21 @@ func TestBuildRejectsInvalidStandaloneServiceRefs(t *testing.T) {
286286 })
287287 }
288288
289+func TestBuildWrapsStandaloneServiceValidationErrorsWithServiceName(t *testing.T) {
290+ _, err := Build(Input{
291+ EntryPoints: map[string]EntryPointConfig{"tcp": {Address: ":1234", Protocol: domain.EntryPointProtocolTCP}},
292+ Services: []domain.StandaloneService{{
293+ Name: "rust",
294+ Image: "localhost/rust:latest",
295+ Enabled: true,
296+ Ports: []domain.StandaloneServicePort{{Name: " admin ", Container: 28016, Protocol: domain.NetworkProtocolTCP}},
297+ }},
298+ })
299+
300+ require.ErrorContains(t, err, "standalone service \"rust\"")
301+ require.ErrorContains(t, err, "leading or trailing whitespace")
302+}
303+
289304 func TestBuildEnforcesPrivateStandaloneServicePortRouting(t *testing.T) {
290305 base := func() Input {
291306 return Input{
@@ -286,6 +286,21 @@ func TestBuildRejectsInvalidStandaloneServiceRefs(t *testing.T) {
286 })286 })
287 }287 }
288 288
289+func TestBuildWrapsStandaloneServiceValidationErrorsWithServiceName(t *testing.T) {
290+ _, err := Build(Input{
291+ EntryPoints: map[string]EntryPointConfig{"tcp": {Address: ":1234", Protocol: domain.EntryPointProtocolTCP}},
292+ Services: []domain.StandaloneService{{
293+ Name: "rust",
294+ Image: "localhost/rust:latest",
295+ Enabled: true,
296+ Ports: []domain.StandaloneServicePort{{Name: " admin ", Container: 28016, Protocol: domain.NetworkProtocolTCP}},
297+ }},
298+ })
299+
300+ require.ErrorContains(t, err, "standalone service \"rust\"")
301+ require.ErrorContains(t, err, "leading or trailing whitespace")
302+}
303+
289 func TestBuildEnforcesPrivateStandaloneServicePortRouting(t *testing.T) {304 func TestBuildEnforcesPrivateStandaloneServicePortRouting(t *testing.T) {
290 base := func() Input {305 base := func() Input {
291 return Input{306 return Input{

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