fix(traffic): harden final review edge cases
- commit
- 3489c6589f9b98d9fb2e5ad1193909533851f418
- author
- brice · · 2026-06-30 17:03 UTC
- parent
- 3011e4c
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 177 | 177 | | `[auto_route]` | Automatic route creation | [Auto Route](./auto-route.md) | | |
| 178 | 178 | | `[routes]` | Domain to image mapping | [Routes](./routes.md) | | |
| 179 | 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 | 181 | | `[network_groups]` | Shared service networks | [Network Groups](./network-groups.md) | | |
| 182 | 182 | | `[attachments]` | Service dependencies | [Attachments](./attachments.md) | | |
| 183 | 183 | | `[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 | |||
| 116 | 116 | ||
| 117 | 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 Onboarding | |
| 119 | + | #### Direct HTTP CA onboarding paths (`/.well-known/gordon/ca`) | |
| 120 | 120 | ||
| 121 | 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 | ||
| @@ -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 Onboarding | 119 | + | #### 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 | |||
| 263 | 263 | Wrong mode causes: **521** (Cloudflare can't connect) or **525** (TLS handshake failed). | |
| 264 | 264 | ||
| 265 | 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 | 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 | 269 | ## 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 Allowlist | 269 | ## Proxy Origin Allowlist | ||
M internal/adapters/in/cli/root.go +2 -2
| @@ -154,11 +154,11 @@ Commands are organized by where they run: | |||
| 154 | 154 | tlsCmd.GroupID = groupManage | |
| 155 | 155 | rootCmd.AddCommand(tlsCmd) | |
| 156 | 156 | ||
| 157 | - | // Client-only commands (no server needed) | |
| 158 | 157 | trafficCmd := newTrafficCmd() | |
| 159 | - | trafficCmd.GroupID = groupClient | |
| 158 | + | trafficCmd.GroupID = groupManage | |
| 160 | 159 | rootCmd.AddCommand(trafficCmd) | |
| 161 | 160 | ||
| 161 | + | // Client-only commands (no server needed) | |
| 162 | 162 | remotesCmd := newRemotesCmd() | |
| 163 | 163 | remotesCmd.GroupID = groupClient | |
| 164 | 164 | rootCmd.AddCommand(remotesCmd) | |
| @@ -154,11 +154,11 @@ Commands are organized by where they run: | |||||
| 154 | tlsCmd.GroupID = groupManage | 154 | 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 = groupClient | 158 | + | 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 = groupClient | 163 | 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 { | |||
| 96 | 96 | if err != nil { | |
| 97 | 97 | m.lastReloadStatus = reloadStatusError | |
| 98 | 98 | m.lastReloadError = err.Error() | |
| 99 | + | stopTCPRuntimes(ctx, createdListeners, effectiveTCPOptions(snapshotTCPOptions(&nextGraph)).DrainTimeout) | |
| 99 | 100 | m.mu.Unlock() | |
| 100 | - | for _, runtime := range createdListeners { | |
| 101 | - | runtime.stop(ctx, effectiveTCPOptions(snapshotTCPOptions(&nextGraph)).DrainTimeout) | |
| 102 | - | } | |
| 103 | 101 | trafficWarn(ctx).Err(err).Msg("failed to prepare udp traffic listeners") | |
| 104 | 102 | return err | |
| 105 | 103 | } | |
| @@ -346,12 +344,13 @@ func (m *Manager) bindTCPEntryPoint(ctx context.Context, entryPoint domain.Entry | |||
| 346 | 344 | _ = listener.Close() | |
| 347 | 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 | 349 | _ = listener.Close() | |
| 351 | 350 | return nil, fmt.Errorf("parse raw fallback trusted cidrs for tcp entrypoint %q: %w", entryPoint.Name, err) | |
| 352 | 351 | } | |
| 353 | 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 | 354 | return runtime, nil | |
| 356 | 355 | } | |
| 357 | 356 | ||
| @@ -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 = reloadStatusError | 97 | 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 err | 102 | 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, nil | 354 | 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 | |||
| 41 | 41 | } | |
| 42 | 42 | ||
| 43 | 43 | buf := make([]byte, 0, 32) | |
| 44 | - | tmp := make([]byte, 1) | |
| 45 | 44 | for len(buf) < maxSmartTCPSniffBytes { | |
| 45 | + | remaining := maxSmartTCPSniffBytes - len(buf) | |
| 46 | + | readSize := min(32, remaining) | |
| 47 | + | tmp := make([]byte, readSize) | |
| 46 | 48 | n, err := conn.Read(tmp) | |
| 47 | 49 | if n > 0 { | |
| 48 | 50 | 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) { | |||
| 234 | 234 | client, server := net.Pipe() | |
| 235 | 235 | _ = client.Close() | |
| 236 | 236 | _, 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) | |
| 240 | 239 | } | |
| @@ -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) { | |||
| 106 | 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 | 110 | ctx, cancel := context.WithCancel(context.WithoutCancel(parentCtx)) | |
| 111 | - | rawTrusted, _ := parseTrustedCIDRs(entryPoint.RawFallbackTrustedCIDRs) | |
| 112 | 111 | return &entryPointRuntime{ | |
| 113 | 112 | manager: manager, | |
| 114 | 113 | entryPoint: entryPoint, | |
| @@ -166,7 +165,6 @@ func (r *entryPointRuntime) refreshTLSHTTPServer(entryPointName string) { | |||
| 166 | 165 | func (r *entryPointRuntime) replaceTLSHTTPServer(entryPoint domain.EntryPoint, config TLSHTTPServerConfig) { | |
| 167 | 166 | r.tlsHTTPReplaceMu.Lock() | |
| 168 | 167 | defer r.tlsHTTPReplaceMu.Unlock() | |
| 169 | - | r.stopTLSHTTPServerLocked(r.ctx, 0) | |
| 170 | 168 | if r.isClosed() { | |
| 171 | 169 | return | |
| 172 | 170 | } | |
| @@ -182,6 +180,9 @@ func (r *entryPointRuntime) replaceTLSHTTPServer(entryPoint domain.EntryPoint, c | |||
| 182 | 180 | done := make(chan struct{}) | |
| 183 | 181 | ||
| 184 | 182 | r.mu.Lock() | |
| 183 | + | oldListener := r.tlsHTTPListener | |
| 184 | + | oldServer := r.tlsHTTPServer | |
| 185 | + | oldDone := r.tlsHTTPDone | |
| 185 | 186 | if r.isClosed() { | |
| 186 | 187 | r.mu.Unlock() | |
| 187 | 188 | _ = listener.Close() | |
| @@ -198,6 +199,7 @@ func (r *entryPointRuntime) replaceTLSHTTPServer(entryPoint domain.EntryPoint, c | |||
| 198 | 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 | 205 | func (r *entryPointRuntime) routeToHTTPS(tracked *trackedTCPConn, conn net.Conn) bool { | |
| @@ -289,7 +291,12 @@ func (r *entryPointRuntime) handleTCPConn(client net.Conn) { | |||
| 289 | 291 | r.releaseConnection() | |
| 290 | 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 | 300 | completeOnReturn := true | |
| 294 | 301 | defer func() { | |
| 295 | 302 | if completeOnReturn { | |
| @@ -689,10 +696,14 @@ func (r *entryPointRuntime) releaseConnection() { | |||
| 689 | 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 | 700 | r.mu.Lock() | |
| 701 | + | defer r.mu.Unlock() | |
| 702 | + | if r.isClosed() { | |
| 703 | + | return false | |
| 704 | + | } | |
| 694 | 705 | r.activeConns[conn] = struct{}{} | |
| 695 | - | r.mu.Unlock() | |
| 706 | + | return true | |
| 696 | 707 | } | |
| 697 | 708 | ||
| 698 | 709 | func (r *entryPointRuntime) setBackend(conn *trackedTCPConn, backend net.Conn) { | |
| @@ -759,6 +770,10 @@ func (r *entryPointRuntime) stopTLSHTTPServerLocked(ctx context.Context, drainTi | |||
| 759 | 770 | r.tlsHTTPDone = nil | |
| 760 | 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 | 777 | if listener == nil || server == nil || done == nil { | |
| 763 | 778 | return | |
| 764 | 779 | } | |
| @@ -769,7 +784,7 @@ func (r *entryPointRuntime) stopTLSHTTPServerLocked(ctx context.Context, drainTi | |||
| 769 | 784 | shutdownCtx, cancel := context.WithTimeout(ctx, drainTimeout) | |
| 770 | 785 | defer cancel() | |
| 771 | 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 | 788 | _ = server.Close() | |
| 774 | 789 | } | |
| 775 | 790 | 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 | return | 169 | 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 := true | 300 | 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 = nil | 770 | 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 | return | 778 | 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 | |||
| 618 | 618 | require.NoError(t, err) | |
| 619 | 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 | 622 | httpListener := newTLSHTTPListener(listener.Addr()) | |
| 623 | 623 | require.NoError(t, httpListener.Close()) | |
| 624 | 624 | ||
| @@ -748,7 +748,7 @@ func TestTCPRuntimeDoesNotPublishTLSHTTPServerAfterClose(t *testing.T) { | |||
| 748 | 748 | require.NoError(t, err) | |
| 749 | 749 | defer listener.Close() | |
| 750 | 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 | 752 | runtime.closed.Store(true) | |
| 753 | 753 | ||
| 754 | 754 | 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 ( | |||
| 9 | 9 | "net/http" | |
| 10 | 10 | "strings" | |
| 11 | 11 | "sync" | |
| 12 | + | "time" | |
| 12 | 13 | ||
| 13 | 14 | "github.com/bnema/gordon/internal/domain" | |
| 14 | 15 | ) | |
| 15 | 16 | ||
| 16 | - | const maxClientHelloBytes = 64 << 10 | |
| 17 | + | const ( | |
| 18 | + | maxClientHelloBytes = 64 << 10 | |
| 19 | + | clientHelloReadTimeout = 250 * time.Millisecond | |
| 20 | + | ) | |
| 17 | 21 | ||
| 18 | 22 | // TLSHTTPServerConfig describes the HTTPS server attached to a tls_mux entrypoint. | |
| 19 | 23 | type TLSHTTPServerConfig struct { | |
| @@ -66,9 +70,16 @@ func peekClientHelloSNI(conn net.Conn) (string, net.Conn, error) { | |||
| 66 | 70 | } | |
| 67 | 71 | ||
| 68 | 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 | 78 | buf := make([]byte, 0, 4096) | |
| 70 | - | tmp := make([]byte, 1024) | |
| 71 | 79 | for len(buf) < maxBytes { | |
| 80 | + | remaining := maxBytes - len(buf) | |
| 81 | + | readSize := min(1024, remaining) | |
| 82 | + | tmp := make([]byte, readSize) | |
| 72 | 83 | n, err := conn.Read(tmp) | |
| 73 | 84 | if n > 0 { | |
| 74 | 85 | 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 << 10 | 17 | + | 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 | |||
| 171 | 171 | _ = backendConn.Close() | |
| 172 | 172 | 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 | 174 | if options.MaxSessions > 0 && len(r.sessions) >= options.MaxSessions { | |
| 182 | 175 | r.mu.Unlock() | |
| 183 | 176 | _ = backendConn.Close() | |
| @@ -193,12 +186,6 @@ func (r *udpEntryPointRuntime) session(key string, clientAddr net.Addr, options | |||
| 193 | 186 | 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 | 189 | func trafficBackendEqual(left domain.TrafficBackend, right domain.TrafficBackend) bool { | |
| 203 | 190 | return left.Name == right.Name && left.Host == right.Host && left.Port == right.Port && left.Protocol == right.Protocol | |
| 204 | 191 | } | |
| @@ -171,13 +171,6 @@ func (r *udpEntryPointRuntime) session(key string, clientAddr net.Addr, options | |||||
| 171 | _ = backendConn.Close() | 171 | _ = backendConn.Close() | ||
| 172 | return existing, true | 172 | 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, true | 186 | 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.Protocol | 190 | 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 { | |||
| 87 | 87 | ||
| 88 | 88 | func (s StandaloneService) Validate() error { | |
| 89 | 89 | s = s.WithDefaults() | |
| 90 | - | if strings.TrimSpace(s.Name) == "" { | |
| 90 | + | name := strings.TrimSpace(s.Name) | |
| 91 | + | if name == "" { | |
| 91 | 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 | 97 | if s.Enabled && strings.TrimSpace(s.Image) == "" { | |
| 94 | 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 | 118 | func validateStandaloneServicePorts(s StandaloneService) error { | |
| 115 | 119 | seen := make(map[string]struct{}, len(s.Ports)) | |
| 116 | 120 | 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 | |
| 120 | 124 | } | |
| 121 | 125 | if _, ok := seen[name]; ok { | |
| 122 | 126 | return fmt.Errorf("standalone service %q duplicate port name %q", s.Name, name) | |
| 123 | 127 | } | |
| 124 | 128 | 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 | |
| 133 | 156 | } | |
| 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) | |
| 136 | 168 | } | |
| 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) | |
| 144 | 171 | } | |
| 145 | 172 | } | |
| 146 | 173 | return nil | |
| @@ -164,6 +191,18 @@ func validateStandaloneServicePublish(serviceName, portName, publish string) err | |||
| 164 | 191 | 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 | 206 | func validateStandaloneServiceVolumes(s StandaloneService) error { | |
| 168 | 207 | seenTargets := make(map[string]struct{}, len(s.Volumes)) | |
| 169 | 208 | 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 err | 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) | |||
| 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 nil | 173 | return nil | ||
| @@ -164,6 +191,18 @@ func validateStandaloneServicePublish(serviceName, portName, publish string) err | |||||
| 164 | return nil | 191 | 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) | |||
| 15 | 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 | 31 | func TestStandaloneServiceValidateAllowsDisabledServiceWithoutImage(t *testing.T) { | |
| 19 | 32 | svc := StandaloneService{Name: "cache", Enabled: false} | |
| 20 | 33 | require.NoError(t, svc.Validate()) | |
| @@ -47,6 +60,10 @@ func TestStandaloneServiceValidatePorts(t *testing.T) { | |||
| 47 | 60 | conflictingVisibility := valid | |
| 48 | 61 | conflictingVisibility.Ports = []StandaloneServicePort{{Name: "rcon", Container: 28016, Protocol: NetworkProtocolTCP, Private: true, Public: true}} | |
| 49 | 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 | 69 | 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 := valid | 60 | 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) { | |||
| 591 | 591 | ||
| 592 | 592 | func validateRawFallbackEntrypoint(entryPoint EntryPoint) error { | |
| 593 | 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 | 600 | return nil | |
| 595 | 601 | } | |
| 596 | 602 | 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 nil | 600 | 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) { | |||
| 48 | 48 | routers: []TrafficRouter{{Name: "ssh-fallback", EntryPoint: "edge", Protocol: RouterProtocolTCP, Service: "network_service:ssh:ssh"}}, | |
| 49 | 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 | 62 | name: "missing raw fallback router rejected", | |
| 53 | 63 | 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 { | |||
| 64 | 64 | func ToDomain(configs []Config) ([]domain.StandaloneService, error) { | |
| 65 | 65 | services := make([]domain.StandaloneService, 0, len(configs)) | |
| 66 | 66 | seenNames := make(map[string]struct{}, len(configs)) | |
| 67 | + | seenRuntimeNames := make(map[string]string, len(configs)) | |
| 67 | 68 | for i, cfg := range configs { | |
| 68 | 69 | svc, err := cfg.ToDomain() | |
| 69 | 70 | if err != nil { | |
| @@ -73,7 +74,12 @@ func ToDomain(configs []Config) ([]domain.StandaloneService, error) { | |||
| 73 | 74 | if _, ok := seenNames[name]; ok { | |
| 74 | 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 | 81 | seenNames[name] = struct{}{} | |
| 82 | + | seenRuntimeNames[runtimeName] = name | |
| 77 | 83 | services = append(services, svc) | |
| 78 | 84 | } | |
| 79 | 85 | return services, nil | |
| @@ -200,3 +206,7 @@ func ManagedServiceVolumeName(prefix, serviceName, volumePath string) string { | |||
| 200 | 206 | strings.ReplaceAll(serviceName, ".", "-"), | |
| 201 | 207 | strings.ReplaceAll(strings.Trim(volumePath, "/"), "/", "-")) | |
| 202 | 208 | } | |
| 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, nil | 85 | 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) { | |||
| 20 | 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 | 34 | func TestConfigToDomainRejectsNonPositiveReadinessTimeout(t *testing.T) { | |
| 24 | 35 | for _, timeout := range []string{"0s", "-1s"} { | |
| 25 | 36 | 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 | |||
| 94 | 94 | if !svc.Enabled { | |
| 95 | 95 | return s.stopDisabled(ctx, svc.Name, cleanup, existing) | |
| 96 | 96 | } | |
| 97 | - | hash, err := serviceConfigHash(svc) | |
| 97 | + | hash, err := s.serviceConfigHash(ctx, svc) | |
| 98 | 98 | if err != nil { | |
| 99 | 99 | return fmt.Errorf("hash standalone service %q config: %w", svc.Name, err) | |
| 100 | 100 | } | |
| 101 | 101 | if len(existing) == 0 { | |
| 102 | 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 | 112 | current := existing[0] | |
| 106 | 113 | if current.Labels[domain.LabelServiceConfigHash] != hash { | |
| 107 | 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 | 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 | 357 | 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)} | |
| 353 | 365 | bytes, err := json.Marshal(payload) | |
| 354 | 366 | if err != nil { | |
| 355 | 367 | return "", err | |
| @@ -496,7 +508,10 @@ func tcpReadinessAddress(svc domain.StandaloneService) (string, error) { | |||
| 496 | 508 | if err != nil { | |
| 497 | 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 | 515 | host = "127.0.0.1" | |
| 501 | 516 | } | |
| 502 | 517 | 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 string | 358 | + | Image string |
| 344 | - | Env []string | 359 | + | ResolvedEnv []string |
| 345 | - | EnvFile string | 360 | + | Readiness domain.StandaloneServiceReadiness |
| 346 | - | Secrets []domain.StandaloneServiceSecretRef | 361 | + | Ports []domain.StandaloneServicePort |
| 347 | - | Readiness domain.StandaloneServiceReadiness | 362 | + | Volumes []domain.StandaloneServiceVolume |
| 348 | - | Ports []domain.StandaloneServicePort | 363 | + | Cleanup domain.StandaloneServiceCleanup |
| 349 | - | Volumes []domain.StandaloneServiceVolume | 364 | + | }{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 "", err | 367 | 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), nil | 517 | 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 | |||
| 107 | 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 | 125 | func TestService_ReconcileStopsAndRemovesDisabledService(t *testing.T) { | |
| 111 | 126 | rt := outmocks.NewMockContainerRuntime(t) | |
| 112 | 127 | svc := sampleService() | |
| @@ -230,6 +245,24 @@ func TestServiceEnvLoadsEnvFileAndMergesInlineEnv(t *testing.T) { | |||
| 230 | 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 | 266 | func TestServiceEnvResolvesServiceScopedSecretsWithoutLeakingValues(t *testing.T) { | |
| 234 | 267 | rt := outmocks.NewMockContainerRuntime(t) | |
| 235 | 268 | provider := outmocks.NewMockSecretProvider(t) | |
| @@ -269,6 +302,16 @@ func TestServiceEnvUsesExplicitProviderSecretPaths(t *testing.T) { | |||
| 269 | 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 | 315 | func TestWaitTCPReadinessDialsResolvedLoopbackPublish(t *testing.T) { | |
| 273 | 316 | listener, err := net.Listen("tcp", "127.0.0.1:0") | |
| 274 | 317 | 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 { | |||
| 303 | 303 | } | |
| 304 | 304 | seen[service.Name] = struct{}{} | |
| 305 | 305 | if err := service.Validate(); err != nil { | |
| 306 | - | return err | |
| 306 | + | return fmt.Errorf("standalone service %q: %w", service.Name, err) | |
| 307 | 307 | } | |
| 308 | 308 | } | |
| 309 | 309 | 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 err | 306 | + | return fmt.Errorf("standalone service %q: %w", service.Name, err) |
| 307 | } | 307 | } | ||
| 308 | } | 308 | } | ||
| 309 | return nil | 309 | return nil | ||
M internal/usecase/traffic/builder_test.go +15 -0
| @@ -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 | 304 | func TestBuildEnforcesPrivateStandaloneServicePortRouting(t *testing.T) { | |
| 290 | 305 | base := func() Input { | |
| 291 | 306 | 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{ | ||