fix(traffic): address final review findings
- commit
- 78a44260f3afd27e0c0112ca72e21a24b3107eb4
- author
- brice · · 2026-06-30 03:43 UTC
- committer
- brice · · 2026-06-30 04:35 UTC
- parent
- 1f467a1
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
22 files · +617 -354
M docs/config/traffic.md +2 -2
| @@ -4,7 +4,7 @@ Gordon models network listeners as a traffic graph: entrypoints receive connecti | |||
| 4 | 4 | ||
| 5 | 5 | ## Entrypoints | |
| 6 | 6 | ||
| 7 | - | `server.port` creates the default HTTP entrypoint `web`. `server.tls_port` creates the default TLS multiplexer entrypoint `websecure` when non-zero. | |
| 7 | + | `server.port` creates the default HTTP entrypoint `web`. `server.tls_port` creates `websecure`, the default TLS entrypoint used by Gordon's legacy HTTPS termination path. L4 TLS passthrough must use a custom traffic-manager-owned `tls_mux` entrypoint instead of `websecure`. | |
| 8 | 8 | ||
| 9 | 9 | Custom entrypoints use the top-level `entrypoints` table: | |
| 10 | 10 | ||
| @@ -78,7 +78,7 @@ UDP sessions are keyed by client address and expire after `idle_timeout`. If `ma | |||
| 78 | 78 | ||
| 79 | 79 | ## TLS Passthrough | |
| 80 | 80 | ||
| 81 | - | TLS passthrough routers run on traffic-manager-owned `tls_mux` entrypoints and route by ClientHello SNI without terminating TLS. The default `websecure` entrypoint is reserved for Gordon's legacy HTTPS listener until full HTTP/HTTPS traffic-manager ownership is enabled; use a custom `tls_mux` entrypoint for passthrough. | |
| 81 | + | TLS passthrough routers run on traffic-manager-owned `tls_mux` entrypoints and route by ClientHello SNI without terminating TLS. The `websecure` entrypoint created by `server.tls_port` is reserved for Gordon's legacy HTTPS listener until full HTTP/HTTPS traffic-manager ownership is enabled; use a custom `tls_mux` entrypoint for passthrough. | |
| 82 | 82 | ||
| 83 | 83 | ```toml | |
| 84 | 84 | [entrypoints.rawtls] | |
| @@ -4,7 +4,7 @@ Gordon models network listeners as a traffic graph: entrypoints receive connecti | |||||
| 4 | 4 | ||||
| 5 | ## Entrypoints | 5 | ## Entrypoints | ||
| 6 | 6 | ||||
| 7 | - | `server.port` creates the default HTTP entrypoint `web`. `server.tls_port` creates the default TLS multiplexer entrypoint `websecure` when non-zero. | 7 | + | `server.port` creates the default HTTP entrypoint `web`. `server.tls_port` creates `websecure`, the default TLS entrypoint used by Gordon's legacy HTTPS termination path. L4 TLS passthrough must use a custom traffic-manager-owned `tls_mux` entrypoint instead of `websecure`. |
| 8 | 8 | ||||
| 9 | Custom entrypoints use the top-level `entrypoints` table: | 9 | Custom entrypoints use the top-level `entrypoints` table: | ||
| 10 | 10 | ||||
| @@ -78,7 +78,7 @@ UDP sessions are keyed by client address and expire after `idle_timeout`. If `ma | |||||
| 78 | 78 | ||||
| 79 | ## TLS Passthrough | 79 | ## TLS Passthrough | ||
| 80 | 80 | ||||
| 81 | - | TLS passthrough routers run on traffic-manager-owned `tls_mux` entrypoints and route by ClientHello SNI without terminating TLS. The default `websecure` entrypoint is reserved for Gordon's legacy HTTPS listener until full HTTP/HTTPS traffic-manager ownership is enabled; use a custom `tls_mux` entrypoint for passthrough. | 81 | + | TLS passthrough routers run on traffic-manager-owned `tls_mux` entrypoints and route by ClientHello SNI without terminating TLS. The `websecure` entrypoint created by `server.tls_port` is reserved for Gordon's legacy HTTPS listener until full HTTP/HTTPS traffic-manager ownership is enabled; use a custom `tls_mux` entrypoint for passthrough. |
| 82 | 82 | ||||
| 83 | ```toml | 83 | ```toml | ||
| 84 | [entrypoints.rawtls] | 84 | [entrypoints.rawtls] | ||
M internal/adapters/dto/traffic.go +13 -32
| @@ -26,19 +26,12 @@ type TrafficEntryPointStatus struct { | |||
| 26 | 26 | } | |
| 27 | 27 | ||
| 28 | 28 | type TrafficRouterStatus struct { | |
| 29 | - | Name string `json:"name"` | |
| 30 | - | EntryPoint string `json:"entrypoint"` | |
| 31 | - | Protocol domain.RouterProtocol `json:"protocol"` | |
| 32 | - | Rule TrafficRule `json:"rule"` | |
| 33 | - | Service string `json:"service"` | |
| 34 | - | Active bool `json:"active"` | |
| 35 | - | ActiveTCPConnections int64 `json:"active_tcp_connections"` | |
| 36 | - | ActiveUDPSessions int64 `json:"active_udp_sessions"` | |
| 37 | - | TotalAccepted int64 `json:"total_accepted"` | |
| 38 | - | TotalRefused int64 `json:"total_refused"` | |
| 39 | - | TotalErrors int64 `json:"total_errors"` | |
| 40 | - | BytesIn int64 `json:"bytes_in"` | |
| 41 | - | BytesOut int64 `json:"bytes_out"` | |
| 29 | + | Name string `json:"name"` | |
| 30 | + | EntryPoint string `json:"entrypoint"` | |
| 31 | + | Protocol domain.RouterProtocol `json:"protocol"` | |
| 32 | + | Rule TrafficRule `json:"rule"` | |
| 33 | + | Service string `json:"service"` | |
| 34 | + | Active bool `json:"active"` | |
| 42 | 35 | } | |
| 43 | 36 | ||
| 44 | 37 | type TrafficServiceStatus struct { | |
| @@ -53,18 +46,11 @@ type TrafficRule struct { | |||
| 53 | 46 | } | |
| 54 | 47 | ||
| 55 | 48 | type TrafficBackendStatus struct { | |
| 56 | - | Name string `json:"name"` | |
| 57 | - | Host string `json:"host"` | |
| 58 | - | Port int `json:"port"` | |
| 59 | - | Protocol domain.NetworkProtocol `json:"protocol"` | |
| 60 | - | Active bool `json:"active"` | |
| 61 | - | ActiveTCPConnections int64 `json:"active_tcp_connections"` | |
| 62 | - | ActiveUDPSessions int64 `json:"active_udp_sessions"` | |
| 63 | - | TotalAccepted int64 `json:"total_accepted"` | |
| 64 | - | TotalRefused int64 `json:"total_refused"` | |
| 65 | - | TotalErrors int64 `json:"total_errors"` | |
| 66 | - | BytesIn int64 `json:"bytes_in"` | |
| 67 | - | BytesOut int64 `json:"bytes_out"` | |
| 49 | + | Name string `json:"name"` | |
| 50 | + | Host string `json:"host"` | |
| 51 | + | Port int `json:"port"` | |
| 52 | + | Protocol domain.NetworkProtocol `json:"protocol"` | |
| 53 | + | Active bool `json:"active"` | |
| 68 | 54 | } | |
| 69 | 55 | ||
| 70 | 56 | type TrafficCounters struct { | |
| @@ -105,10 +91,8 @@ func trafficRoutersFromDomain(values []domain.TrafficRouterStatus) []TrafficRout | |||
| 105 | 91 | out := make([]TrafficRouterStatus, 0, len(values)) | |
| 106 | 92 | for _, value := range values { | |
| 107 | 93 | out = append(out, TrafficRouterStatus{ | |
| 108 | - | Name: value.Name, EntryPoint: value.EntryPoint, Protocol: value.Protocol, Rule: TrafficRule{Host: value.Rule.Host, SNI: value.Rule.SNI}, Service: value.Service, Active: value.Active, | |
| 109 | - | ActiveTCPConnections: value.ActiveTCPConnections, ActiveUDPSessions: value.ActiveUDPSessions, | |
| 110 | - | TotalAccepted: value.TotalAccepted, TotalRefused: value.TotalRefused, TotalErrors: value.TotalErrors, | |
| 111 | - | BytesIn: value.BytesIn, BytesOut: value.BytesOut, | |
| 94 | + | Name: value.Name, EntryPoint: value.EntryPoint, Protocol: value.Protocol, | |
| 95 | + | Rule: TrafficRule{Host: value.Rule.Host, SNI: value.Rule.SNI}, Service: value.Service, Active: value.Active, | |
| 112 | 96 | }) | |
| 113 | 97 | } | |
| 114 | 98 | return out | |
| @@ -127,9 +111,6 @@ func trafficBackendsFromDomain(values []domain.TrafficBackendStatus) []TrafficBa | |||
| 127 | 111 | for _, value := range values { | |
| 128 | 112 | out = append(out, TrafficBackendStatus{ | |
| 129 | 113 | Name: value.Name, Host: value.Host, Port: value.Port, Protocol: value.Protocol, Active: value.Active, | |
| 130 | - | ActiveTCPConnections: value.ActiveTCPConnections, ActiveUDPSessions: value.ActiveUDPSessions, | |
| 131 | - | TotalAccepted: value.TotalAccepted, TotalRefused: value.TotalRefused, TotalErrors: value.TotalErrors, | |
| 132 | - | BytesIn: value.BytesIn, BytesOut: value.BytesOut, | |
| 133 | 114 | }) | |
| 134 | 115 | } | |
| 135 | 116 | return out | |
| @@ -26,19 +26,12 @@ type TrafficEntryPointStatus struct { | |||||
| 26 | } | 26 | } | ||
| 27 | 27 | ||||
| 28 | type TrafficRouterStatus struct { | 28 | type TrafficRouterStatus struct { | ||
| 29 | - | Name string `json:"name"` | 29 | + | Name string `json:"name"` |
| 30 | - | EntryPoint string `json:"entrypoint"` | 30 | + | EntryPoint string `json:"entrypoint"` |
| 31 | - | Protocol domain.RouterProtocol `json:"protocol"` | 31 | + | Protocol domain.RouterProtocol `json:"protocol"` |
| 32 | - | Rule TrafficRule `json:"rule"` | 32 | + | Rule TrafficRule `json:"rule"` |
| 33 | - | Service string `json:"service"` | 33 | + | Service string `json:"service"` |
| 34 | - | Active bool `json:"active"` | 34 | + | Active bool `json:"active"` |
| 35 | - | ActiveTCPConnections int64 `json:"active_tcp_connections"` | |||
| 36 | - | ActiveUDPSessions int64 `json:"active_udp_sessions"` | |||
| 37 | - | TotalAccepted int64 `json:"total_accepted"` | |||
| 38 | - | TotalRefused int64 `json:"total_refused"` | |||
| 39 | - | TotalErrors int64 `json:"total_errors"` | |||
| 40 | - | BytesIn int64 `json:"bytes_in"` | |||
| 41 | - | BytesOut int64 `json:"bytes_out"` | |||
| 42 | } | 35 | } | ||
| 43 | 36 | ||||
| 44 | type TrafficServiceStatus struct { | 37 | type TrafficServiceStatus struct { | ||
| @@ -53,18 +46,11 @@ type TrafficRule struct { | |||||
| 53 | } | 46 | } | ||
| 54 | 47 | ||||
| 55 | type TrafficBackendStatus struct { | 48 | type TrafficBackendStatus struct { | ||
| 56 | - | Name string `json:"name"` | 49 | + | Name string `json:"name"` |
| 57 | - | Host string `json:"host"` | 50 | + | Host string `json:"host"` |
| 58 | - | Port int `json:"port"` | 51 | + | Port int `json:"port"` |
| 59 | - | Protocol domain.NetworkProtocol `json:"protocol"` | 52 | + | Protocol domain.NetworkProtocol `json:"protocol"` |
| 60 | - | Active bool `json:"active"` | 53 | + | Active bool `json:"active"` |
| 61 | - | ActiveTCPConnections int64 `json:"active_tcp_connections"` | |||
| 62 | - | ActiveUDPSessions int64 `json:"active_udp_sessions"` | |||
| 63 | - | TotalAccepted int64 `json:"total_accepted"` | |||
| 64 | - | TotalRefused int64 `json:"total_refused"` | |||
| 65 | - | TotalErrors int64 `json:"total_errors"` | |||
| 66 | - | BytesIn int64 `json:"bytes_in"` | |||
| 67 | - | BytesOut int64 `json:"bytes_out"` | |||
| 68 | } | 54 | } | ||
| 69 | 55 | ||||
| 70 | type TrafficCounters struct { | 56 | type TrafficCounters struct { | ||
| @@ -105,10 +91,8 @@ func trafficRoutersFromDomain(values []domain.TrafficRouterStatus) []TrafficRout | |||||
| 105 | out := make([]TrafficRouterStatus, 0, len(values)) | 91 | out := make([]TrafficRouterStatus, 0, len(values)) | ||
| 106 | for _, value := range values { | 92 | for _, value := range values { | ||
| 107 | out = append(out, TrafficRouterStatus{ | 93 | out = append(out, TrafficRouterStatus{ | ||
| 108 | - | Name: value.Name, EntryPoint: value.EntryPoint, Protocol: value.Protocol, Rule: TrafficRule{Host: value.Rule.Host, SNI: value.Rule.SNI}, Service: value.Service, Active: value.Active, | 94 | + | Name: value.Name, EntryPoint: value.EntryPoint, Protocol: value.Protocol, |
| 109 | - | ActiveTCPConnections: value.ActiveTCPConnections, ActiveUDPSessions: value.ActiveUDPSessions, | 95 | + | Rule: TrafficRule{Host: value.Rule.Host, SNI: value.Rule.SNI}, Service: value.Service, Active: value.Active, |
| 110 | - | TotalAccepted: value.TotalAccepted, TotalRefused: value.TotalRefused, TotalErrors: value.TotalErrors, | |||
| 111 | - | BytesIn: value.BytesIn, BytesOut: value.BytesOut, | |||
| 112 | }) | 96 | }) | ||
| 113 | } | 97 | } | ||
| 114 | return out | 98 | return out | ||
| @@ -127,9 +111,6 @@ func trafficBackendsFromDomain(values []domain.TrafficBackendStatus) []TrafficBa | |||||
| 127 | for _, value := range values { | 111 | for _, value := range values { | ||
| 128 | out = append(out, TrafficBackendStatus{ | 112 | out = append(out, TrafficBackendStatus{ | ||
| 129 | Name: value.Name, Host: value.Host, Port: value.Port, Protocol: value.Protocol, Active: value.Active, | 113 | Name: value.Name, Host: value.Host, Port: value.Port, Protocol: value.Protocol, Active: value.Active, | ||
| 130 | - | ActiveTCPConnections: value.ActiveTCPConnections, ActiveUDPSessions: value.ActiveUDPSessions, | |||
| 131 | - | TotalAccepted: value.TotalAccepted, TotalRefused: value.TotalRefused, TotalErrors: value.TotalErrors, | |||
| 132 | - | BytesIn: value.BytesIn, BytesOut: value.BytesOut, | |||
| 133 | }) | 114 | }) | ||
| 134 | } | 115 | } | ||
| 135 | return out | 116 | return out | ||
M internal/adapters/in/cli/remote/client.go +2 -2
| @@ -705,12 +705,12 @@ func (c *Client) GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, erro | |||
| 705 | 705 | func (c *Client) GetTrafficStatus(ctx context.Context) (*dto.TrafficStatusResponse, error) { | |
| 706 | 706 | resp, err := c.request(ctx, http.MethodGet, "/traffic/status", nil) | |
| 707 | 707 | if err != nil { | |
| 708 | - | return nil, err | |
| 708 | + | return nil, fmt.Errorf("request traffic status: %w", err) | |
| 709 | 709 | } | |
| 710 | 710 | ||
| 711 | 711 | var status dto.TrafficStatusResponse | |
| 712 | 712 | if err := parseResponse(resp, &status); err != nil { | |
| 713 | - | return nil, err | |
| 713 | + | return nil, fmt.Errorf("parse traffic status response: %w", err) | |
| 714 | 714 | } | |
| 715 | 715 | ||
| 716 | 716 | return &status, nil | |
| @@ -705,12 +705,12 @@ func (c *Client) GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, erro | |||||
| 705 | func (c *Client) GetTrafficStatus(ctx context.Context) (*dto.TrafficStatusResponse, error) { | 705 | func (c *Client) GetTrafficStatus(ctx context.Context) (*dto.TrafficStatusResponse, error) { | ||
| 706 | resp, err := c.request(ctx, http.MethodGet, "/traffic/status", nil) | 706 | resp, err := c.request(ctx, http.MethodGet, "/traffic/status", nil) | ||
| 707 | if err != nil { | 707 | if err != nil { | ||
| 708 | - | return nil, err | 708 | + | return nil, fmt.Errorf("request traffic status: %w", err) |
| 709 | } | 709 | } | ||
| 710 | 710 | ||||
| 711 | var status dto.TrafficStatusResponse | 711 | var status dto.TrafficStatusResponse | ||
| 712 | if err := parseResponse(resp, &status); err != nil { | 712 | if err := parseResponse(resp, &status); err != nil { | ||
| 713 | - | return nil, err | 713 | + | return nil, fmt.Errorf("parse traffic status response: %w", err) |
| 714 | } | 714 | } | ||
| 715 | 715 | ||||
| 716 | return &status, nil | 716 | return &status, nil | ||
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) | |
| 157 | 158 | trafficCmd := newTrafficCmd() | |
| 158 | - | trafficCmd.GroupID = groupManage | |
| 159 | + | trafficCmd.GroupID = groupClient | |
| 159 | 160 | rootCmd.AddCommand(trafficCmd) | |
| 160 | 161 | ||
| 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) | |||
| 157 | trafficCmd := newTrafficCmd() | 158 | trafficCmd := newTrafficCmd() | ||
| 158 | - | trafficCmd.GroupID = groupManage | 159 | + | trafficCmd.GroupID = groupClient |
| 159 | rootCmd.AddCommand(trafficCmd) | 160 | rootCmd.AddCommand(trafficCmd) | ||
| 160 | 161 | ||||
| 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/cli/traffic.go +2 -130
| @@ -1,140 +1,12 @@ | |||
| 1 | 1 | package cli | |
| 2 | 2 | ||
| 3 | - | import ( | |
| 4 | - | "fmt" | |
| 5 | - | "io" | |
| 6 | - | ||
| 7 | - | "github.com/spf13/cobra" | |
| 8 | - | ||
| 9 | - | "github.com/bnema/gordon/internal/adapters/dto" | |
| 10 | - | ) | |
| 3 | + | import "github.com/spf13/cobra" | |
| 11 | 4 | ||
| 12 | 5 | func newTrafficCmd() *cobra.Command { | |
| 13 | 6 | cmd := &cobra.Command{ | |
| 14 | 7 | Use: "traffic", | |
| 15 | - | Short: "Inspect traffic plane status", | |
| 8 | + | Short: "Inspect remote traffic plane status", | |
| 16 | 9 | } | |
| 17 | 10 | cmd.AddCommand(newTrafficStatusCmd()) | |
| 18 | 11 | return cmd | |
| 19 | 12 | } | |
| 20 | - | ||
| 21 | - | func newTrafficStatusCmd() *cobra.Command { | |
| 22 | - | var jsonOut bool | |
| 23 | - | cmd := &cobra.Command{ | |
| 24 | - | Use: "status", | |
| 25 | - | Short: "Show traffic entrypoint, router, and counter status", | |
| 26 | - | RunE: func(cmd *cobra.Command, args []string) error { | |
| 27 | - | client, isRemote, err := GetRemoteClient() | |
| 28 | - | if err != nil { | |
| 29 | - | return err | |
| 30 | - | } | |
| 31 | - | if !isRemote { | |
| 32 | - | return fmt.Errorf("traffic status currently requires --remote") | |
| 33 | - | } | |
| 34 | - | status, err := client.GetTrafficStatus(cmd.Context()) | |
| 35 | - | if err != nil { | |
| 36 | - | return err | |
| 37 | - | } | |
| 38 | - | return renderTrafficStatus(cmd.OutOrStdout(), status, jsonOut) | |
| 39 | - | }, | |
| 40 | - | } | |
| 41 | - | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output JSON") | |
| 42 | - | return cmd | |
| 43 | - | } | |
| 44 | - | ||
| 45 | - | func renderTrafficStatus(out io.Writer, status *dto.TrafficStatusResponse, jsonOut bool) error { | |
| 46 | - | if jsonOut { | |
| 47 | - | return writeJSON(out, status) | |
| 48 | - | } | |
| 49 | - | if err := cliWriteLine(out, cliRenderTitle("Traffic Status")); err != nil { | |
| 50 | - | return err | |
| 51 | - | } | |
| 52 | - | if err := cliWriteLine(out, cliRenderMeta("Reload", status.LastReloadStatus)); err != nil { | |
| 53 | - | return err | |
| 54 | - | } | |
| 55 | - | if status.LastReloadError != "" { | |
| 56 | - | if err := cliWriteLine(out, cliRenderWarning("Last reload error: "+status.LastReloadError)); err != nil { | |
| 57 | - | return err | |
| 58 | - | } | |
| 59 | - | } | |
| 60 | - | if err := cliWriteLine(out, ""); err != nil { | |
| 61 | - | return err | |
| 62 | - | } | |
| 63 | - | if err := cliWriteLine(out, "Entrypoints:"); err != nil { | |
| 64 | - | return err | |
| 65 | - | } | |
| 66 | - | if len(status.EntryPoints) == 0 { | |
| 67 | - | if err := cliWriteLine(out, cliRenderMuted(" none")); err != nil { | |
| 68 | - | return err | |
| 69 | - | } | |
| 70 | - | } else { | |
| 71 | - | for _, entry := range status.EntryPoints { | |
| 72 | - | if err := cliWritef(out, " %s %s %s active=%t tcp=%d udp=%d accepted=%d refused=%d errors=%d\n", | |
| 73 | - | entry.Name, entry.Protocol, entry.Address, entry.Active, entry.ActiveTCPConnections, entry.ActiveUDPSessions, | |
| 74 | - | entry.TotalAccepted, entry.TotalRefused, entry.TotalErrors); err != nil { | |
| 75 | - | return err | |
| 76 | - | } | |
| 77 | - | } | |
| 78 | - | } | |
| 79 | - | if err := renderTrafficRouters(out, status.Routers); err != nil { | |
| 80 | - | return err | |
| 81 | - | } | |
| 82 | - | if err := renderTrafficServices(out, status.Services); err != nil { | |
| 83 | - | return err | |
| 84 | - | } | |
| 85 | - | if err := cliWriteLine(out, ""); err != nil { | |
| 86 | - | return err | |
| 87 | - | } | |
| 88 | - | return cliWritef(out, "Totals: tcp=%d udp=%d accepted=%d refused=%d errors=%d bytes_in=%d bytes_out=%d\n", | |
| 89 | - | status.Counters.ActiveTCPConnections, status.Counters.ActiveUDPSessions, status.Counters.TotalAccepted, | |
| 90 | - | status.Counters.TotalRefused, status.Counters.TotalErrors, status.Counters.BytesIn, status.Counters.BytesOut) | |
| 91 | - | } | |
| 92 | - | ||
| 93 | - | func renderTrafficRouters(out io.Writer, routers []dto.TrafficRouterStatus) error { | |
| 94 | - | if err := cliWriteLine(out, ""); err != nil { | |
| 95 | - | return err | |
| 96 | - | } | |
| 97 | - | if err := cliWriteLine(out, "Routers:"); err != nil { | |
| 98 | - | return err | |
| 99 | - | } | |
| 100 | - | if len(routers) == 0 { | |
| 101 | - | return cliWriteLine(out, cliRenderMuted(" none")) | |
| 102 | - | } | |
| 103 | - | for _, router := range routers { | |
| 104 | - | rule := router.Rule.Host | |
| 105 | - | if rule == "" { | |
| 106 | - | rule = router.Rule.SNI | |
| 107 | - | } | |
| 108 | - | if err := cliWritef(out, " %s %s entrypoint=%s rule=%s service=%s active=%t accepted=%d refused=%d errors=%d\n", | |
| 109 | - | router.Name, router.Protocol, router.EntryPoint, rule, router.Service, router.Active, | |
| 110 | - | router.TotalAccepted, router.TotalRefused, router.TotalErrors); err != nil { | |
| 111 | - | return err | |
| 112 | - | } | |
| 113 | - | } | |
| 114 | - | return nil | |
| 115 | - | } | |
| 116 | - | ||
| 117 | - | func renderTrafficServices(out io.Writer, services []dto.TrafficServiceStatus) error { | |
| 118 | - | if err := cliWriteLine(out, ""); err != nil { | |
| 119 | - | return err | |
| 120 | - | } | |
| 121 | - | if err := cliWriteLine(out, "Services:"); err != nil { | |
| 122 | - | return err | |
| 123 | - | } | |
| 124 | - | if len(services) == 0 { | |
| 125 | - | return cliWriteLine(out, cliRenderMuted(" none")) | |
| 126 | - | } | |
| 127 | - | for _, service := range services { | |
| 128 | - | if err := cliWritef(out, " %s active=%t\n", service.Name, service.Active); err != nil { | |
| 129 | - | return err | |
| 130 | - | } | |
| 131 | - | for _, backend := range service.Backends { | |
| 132 | - | if err := cliWritef(out, " %s %s://%s:%d active=%t accepted=%d refused=%d errors=%d\n", | |
| 133 | - | backend.Name, backend.Protocol, backend.Host, backend.Port, backend.Active, | |
| 134 | - | backend.TotalAccepted, backend.TotalRefused, backend.TotalErrors); err != nil { | |
| 135 | - | return err | |
| 136 | - | } | |
| 137 | - | } | |
| 138 | - | } | |
| 139 | - | return nil | |
| 140 | - | } | |
| @@ -1,140 +1,12 @@ | |||||
| 1 | package cli | 1 | package cli | ||
| 2 | 2 | ||||
| 3 | - | import ( | 3 | + | import "github.com/spf13/cobra" |
| 4 | - | "fmt" | |||
| 5 | - | "io" | |||
| 6 | - | ||||
| 7 | - | "github.com/spf13/cobra" | |||
| 8 | - | ||||
| 9 | - | "github.com/bnema/gordon/internal/adapters/dto" | |||
| 10 | - | ) | |||
| 11 | 4 | ||||
| 12 | func newTrafficCmd() *cobra.Command { | 5 | func newTrafficCmd() *cobra.Command { | ||
| 13 | cmd := &cobra.Command{ | 6 | cmd := &cobra.Command{ | ||
| 14 | Use: "traffic", | 7 | Use: "traffic", | ||
| 15 | - | Short: "Inspect traffic plane status", | 8 | + | Short: "Inspect remote traffic plane status", |
| 16 | } | 9 | } | ||
| 17 | cmd.AddCommand(newTrafficStatusCmd()) | 10 | cmd.AddCommand(newTrafficStatusCmd()) | ||
| 18 | return cmd | 11 | return cmd | ||
| 19 | } | 12 | } | ||
| 20 | - | ||||
| 21 | - | func newTrafficStatusCmd() *cobra.Command { | |||
| 22 | - | var jsonOut bool | |||
| 23 | - | cmd := &cobra.Command{ | |||
| 24 | - | Use: "status", | |||
| 25 | - | Short: "Show traffic entrypoint, router, and counter status", | |||
| 26 | - | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 27 | - | client, isRemote, err := GetRemoteClient() | |||
| 28 | - | if err != nil { | |||
| 29 | - | return err | |||
| 30 | - | } | |||
| 31 | - | if !isRemote { | |||
| 32 | - | return fmt.Errorf("traffic status currently requires --remote") | |||
| 33 | - | } | |||
| 34 | - | status, err := client.GetTrafficStatus(cmd.Context()) | |||
| 35 | - | if err != nil { | |||
| 36 | - | return err | |||
| 37 | - | } | |||
| 38 | - | return renderTrafficStatus(cmd.OutOrStdout(), status, jsonOut) | |||
| 39 | - | }, | |||
| 40 | - | } | |||
| 41 | - | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output JSON") | |||
| 42 | - | return cmd | |||
| 43 | - | } | |||
| 44 | - | ||||
| 45 | - | func renderTrafficStatus(out io.Writer, status *dto.TrafficStatusResponse, jsonOut bool) error { | |||
| 46 | - | if jsonOut { | |||
| 47 | - | return writeJSON(out, status) | |||
| 48 | - | } | |||
| 49 | - | if err := cliWriteLine(out, cliRenderTitle("Traffic Status")); err != nil { | |||
| 50 | - | return err | |||
| 51 | - | } | |||
| 52 | - | if err := cliWriteLine(out, cliRenderMeta("Reload", status.LastReloadStatus)); err != nil { | |||
| 53 | - | return err | |||
| 54 | - | } | |||
| 55 | - | if status.LastReloadError != "" { | |||
| 56 | - | if err := cliWriteLine(out, cliRenderWarning("Last reload error: "+status.LastReloadError)); err != nil { | |||
| 57 | - | return err | |||
| 58 | - | } | |||
| 59 | - | } | |||
| 60 | - | if err := cliWriteLine(out, ""); err != nil { | |||
| 61 | - | return err | |||
| 62 | - | } | |||
| 63 | - | if err := cliWriteLine(out, "Entrypoints:"); err != nil { | |||
| 64 | - | return err | |||
| 65 | - | } | |||
| 66 | - | if len(status.EntryPoints) == 0 { | |||
| 67 | - | if err := cliWriteLine(out, cliRenderMuted(" none")); err != nil { | |||
| 68 | - | return err | |||
| 69 | - | } | |||
| 70 | - | } else { | |||
| 71 | - | for _, entry := range status.EntryPoints { | |||
| 72 | - | if err := cliWritef(out, " %s %s %s active=%t tcp=%d udp=%d accepted=%d refused=%d errors=%d\n", | |||
| 73 | - | entry.Name, entry.Protocol, entry.Address, entry.Active, entry.ActiveTCPConnections, entry.ActiveUDPSessions, | |||
| 74 | - | entry.TotalAccepted, entry.TotalRefused, entry.TotalErrors); err != nil { | |||
| 75 | - | return err | |||
| 76 | - | } | |||
| 77 | - | } | |||
| 78 | - | } | |||
| 79 | - | if err := renderTrafficRouters(out, status.Routers); err != nil { | |||
| 80 | - | return err | |||
| 81 | - | } | |||
| 82 | - | if err := renderTrafficServices(out, status.Services); err != nil { | |||
| 83 | - | return err | |||
| 84 | - | } | |||
| 85 | - | if err := cliWriteLine(out, ""); err != nil { | |||
| 86 | - | return err | |||
| 87 | - | } | |||
| 88 | - | return cliWritef(out, "Totals: tcp=%d udp=%d accepted=%d refused=%d errors=%d bytes_in=%d bytes_out=%d\n", | |||
| 89 | - | status.Counters.ActiveTCPConnections, status.Counters.ActiveUDPSessions, status.Counters.TotalAccepted, | |||
| 90 | - | status.Counters.TotalRefused, status.Counters.TotalErrors, status.Counters.BytesIn, status.Counters.BytesOut) | |||
| 91 | - | } | |||
| 92 | - | ||||
| 93 | - | func renderTrafficRouters(out io.Writer, routers []dto.TrafficRouterStatus) error { | |||
| 94 | - | if err := cliWriteLine(out, ""); err != nil { | |||
| 95 | - | return err | |||
| 96 | - | } | |||
| 97 | - | if err := cliWriteLine(out, "Routers:"); err != nil { | |||
| 98 | - | return err | |||
| 99 | - | } | |||
| 100 | - | if len(routers) == 0 { | |||
| 101 | - | return cliWriteLine(out, cliRenderMuted(" none")) | |||
| 102 | - | } | |||
| 103 | - | for _, router := range routers { | |||
| 104 | - | rule := router.Rule.Host | |||
| 105 | - | if rule == "" { | |||
| 106 | - | rule = router.Rule.SNI | |||
| 107 | - | } | |||
| 108 | - | if err := cliWritef(out, " %s %s entrypoint=%s rule=%s service=%s active=%t accepted=%d refused=%d errors=%d\n", | |||
| 109 | - | router.Name, router.Protocol, router.EntryPoint, rule, router.Service, router.Active, | |||
| 110 | - | router.TotalAccepted, router.TotalRefused, router.TotalErrors); err != nil { | |||
| 111 | - | return err | |||
| 112 | - | } | |||
| 113 | - | } | |||
| 114 | - | return nil | |||
| 115 | - | } | |||
| 116 | - | ||||
| 117 | - | func renderTrafficServices(out io.Writer, services []dto.TrafficServiceStatus) error { | |||
| 118 | - | if err := cliWriteLine(out, ""); err != nil { | |||
| 119 | - | return err | |||
| 120 | - | } | |||
| 121 | - | if err := cliWriteLine(out, "Services:"); err != nil { | |||
| 122 | - | return err | |||
| 123 | - | } | |||
| 124 | - | if len(services) == 0 { | |||
| 125 | - | return cliWriteLine(out, cliRenderMuted(" none")) | |||
| 126 | - | } | |||
| 127 | - | for _, service := range services { | |||
| 128 | - | if err := cliWritef(out, " %s active=%t\n", service.Name, service.Active); err != nil { | |||
| 129 | - | return err | |||
| 130 | - | } | |||
| 131 | - | for _, backend := range service.Backends { | |||
| 132 | - | if err := cliWritef(out, " %s %s://%s:%d active=%t accepted=%d refused=%d errors=%d\n", | |||
| 133 | - | backend.Name, backend.Protocol, backend.Host, backend.Port, backend.Active, | |||
| 134 | - | backend.TotalAccepted, backend.TotalRefused, backend.TotalErrors); err != nil { | |||
| 135 | - | return err | |||
| 136 | - | } | |||
| 137 | - | } | |||
| 138 | - | } | |||
| 139 | - | return nil | |||
| 140 | - | } | |||
A internal/adapters/in/cli/traffic_status.go +139 -0
| @@ -0,0 +1,139 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "io" | |
| 7 | + | "net" | |
| 8 | + | ||
| 9 | + | "github.com/spf13/cobra" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/gordon/internal/adapters/dto" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func newTrafficStatusCmd() *cobra.Command { | |
| 15 | + | var jsonOut bool | |
| 16 | + | cmd := &cobra.Command{ | |
| 17 | + | Use: "status", | |
| 18 | + | Short: "Show remote traffic entrypoint, router, and counter status", | |
| 19 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 20 | + | return runTrafficStatus(cmd.Context(), cmd.OutOrStdout(), jsonOut) | |
| 21 | + | }, | |
| 22 | + | } | |
| 23 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output JSON") | |
| 24 | + | return cmd | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func runTrafficStatus(ctx context.Context, out io.Writer, jsonOut bool) error { | |
| 28 | + | client, isRemote, err := GetRemoteClient() | |
| 29 | + | if err != nil { | |
| 30 | + | return fmt.Errorf("create remote client: %w", err) | |
| 31 | + | } | |
| 32 | + | if !isRemote { | |
| 33 | + | return fmt.Errorf("traffic status currently requires --remote") | |
| 34 | + | } | |
| 35 | + | status, err := client.GetTrafficStatus(ctx) | |
| 36 | + | if err != nil { | |
| 37 | + | return fmt.Errorf("fetch traffic status: %w", err) | |
| 38 | + | } | |
| 39 | + | if err := renderTrafficStatus(out, status, jsonOut); err != nil { | |
| 40 | + | return fmt.Errorf("render traffic status: %w", err) | |
| 41 | + | } | |
| 42 | + | return nil | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func renderTrafficStatus(out io.Writer, status *dto.TrafficStatusResponse, jsonOut bool) error { | |
| 46 | + | if jsonOut { | |
| 47 | + | return writeJSON(out, status) | |
| 48 | + | } | |
| 49 | + | if err := cliWriteLine(out, cliRenderTitle("Traffic Status")); err != nil { | |
| 50 | + | return err | |
| 51 | + | } | |
| 52 | + | if err := cliWriteLine(out, cliRenderMeta("Reload", status.LastReloadStatus)); err != nil { | |
| 53 | + | return err | |
| 54 | + | } | |
| 55 | + | if status.LastReloadError != "" { | |
| 56 | + | if err := cliWriteLine(out, cliRenderWarning("Last reload error: "+status.LastReloadError)); err != nil { | |
| 57 | + | return err | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | if err := cliWriteLine(out, ""); err != nil { | |
| 61 | + | return err | |
| 62 | + | } | |
| 63 | + | if err := cliWriteLine(out, "Entrypoints:"); err != nil { | |
| 64 | + | return err | |
| 65 | + | } | |
| 66 | + | if len(status.EntryPoints) == 0 { | |
| 67 | + | if err := cliWriteLine(out, cliRenderMuted(" none")); err != nil { | |
| 68 | + | return err | |
| 69 | + | } | |
| 70 | + | } else { | |
| 71 | + | for _, entry := range status.EntryPoints { | |
| 72 | + | if err := cliWritef(out, " %s %s %s active=%t tcp=%d udp=%d accepted=%d refused=%d errors=%d\n", | |
| 73 | + | entry.Name, entry.Protocol, entry.Address, entry.Active, entry.ActiveTCPConnections, entry.ActiveUDPSessions, | |
| 74 | + | entry.TotalAccepted, entry.TotalRefused, entry.TotalErrors); err != nil { | |
| 75 | + | return err | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | if err := renderTrafficRouters(out, status.Routers); err != nil { | |
| 80 | + | return err | |
| 81 | + | } | |
| 82 | + | if err := renderTrafficServices(out, status.Services); err != nil { | |
| 83 | + | return err | |
| 84 | + | } | |
| 85 | + | if err := cliWriteLine(out, ""); err != nil { | |
| 86 | + | return err | |
| 87 | + | } | |
| 88 | + | return cliWritef(out, "Totals: tcp=%d udp=%d accepted=%d refused=%d errors=%d bytes_in=%d bytes_out=%d\n", | |
| 89 | + | status.Counters.ActiveTCPConnections, status.Counters.ActiveUDPSessions, status.Counters.TotalAccepted, | |
| 90 | + | status.Counters.TotalRefused, status.Counters.TotalErrors, status.Counters.BytesIn, status.Counters.BytesOut) | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func renderTrafficRouters(out io.Writer, routers []dto.TrafficRouterStatus) error { | |
| 94 | + | if err := cliWriteLine(out, ""); err != nil { | |
| 95 | + | return err | |
| 96 | + | } | |
| 97 | + | if err := cliWriteLine(out, "Routers:"); err != nil { | |
| 98 | + | return err | |
| 99 | + | } | |
| 100 | + | if len(routers) == 0 { | |
| 101 | + | return cliWriteLine(out, cliRenderMuted(" none")) | |
| 102 | + | } | |
| 103 | + | for _, router := range routers { | |
| 104 | + | rule := router.Rule.Host | |
| 105 | + | if rule == "" { | |
| 106 | + | rule = router.Rule.SNI | |
| 107 | + | } | |
| 108 | + | if err := cliWritef(out, " %s %s entrypoint=%s rule=%s service=%s active=%t\n", | |
| 109 | + | router.Name, router.Protocol, router.EntryPoint, rule, router.Service, router.Active); err != nil { | |
| 110 | + | return err | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | return nil | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | func renderTrafficServices(out io.Writer, services []dto.TrafficServiceStatus) error { | |
| 117 | + | if err := cliWriteLine(out, ""); err != nil { | |
| 118 | + | return err | |
| 119 | + | } | |
| 120 | + | if err := cliWriteLine(out, "Services:"); err != nil { | |
| 121 | + | return err | |
| 122 | + | } | |
| 123 | + | if len(services) == 0 { | |
| 124 | + | return cliWriteLine(out, cliRenderMuted(" none")) | |
| 125 | + | } | |
| 126 | + | for _, service := range services { | |
| 127 | + | if err := cliWritef(out, " %s active=%t\n", service.Name, service.Active); err != nil { | |
| 128 | + | return err | |
| 129 | + | } | |
| 130 | + | for _, backend := range service.Backends { | |
| 131 | + | addr := net.JoinHostPort(backend.Host, fmt.Sprint(backend.Port)) | |
| 132 | + | if err := cliWritef(out, " %s %s://%s active=%t\n", | |
| 133 | + | backend.Name, backend.Protocol, addr, backend.Active); err != nil { | |
| 134 | + | return err | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | return nil | |
| 139 | + | } | |
| @@ -0,0 +1,139 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "io" | |||
| 7 | + | "net" | |||
| 8 | + | ||||
| 9 | + | "github.com/spf13/cobra" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/gordon/internal/adapters/dto" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func newTrafficStatusCmd() *cobra.Command { | |||
| 15 | + | var jsonOut bool | |||
| 16 | + | cmd := &cobra.Command{ | |||
| 17 | + | Use: "status", | |||
| 18 | + | Short: "Show remote traffic entrypoint, router, and counter status", | |||
| 19 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 20 | + | return runTrafficStatus(cmd.Context(), cmd.OutOrStdout(), jsonOut) | |||
| 21 | + | }, | |||
| 22 | + | } | |||
| 23 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output JSON") | |||
| 24 | + | return cmd | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func runTrafficStatus(ctx context.Context, out io.Writer, jsonOut bool) error { | |||
| 28 | + | client, isRemote, err := GetRemoteClient() | |||
| 29 | + | if err != nil { | |||
| 30 | + | return fmt.Errorf("create remote client: %w", err) | |||
| 31 | + | } | |||
| 32 | + | if !isRemote { | |||
| 33 | + | return fmt.Errorf("traffic status currently requires --remote") | |||
| 34 | + | } | |||
| 35 | + | status, err := client.GetTrafficStatus(ctx) | |||
| 36 | + | if err != nil { | |||
| 37 | + | return fmt.Errorf("fetch traffic status: %w", err) | |||
| 38 | + | } | |||
| 39 | + | if err := renderTrafficStatus(out, status, jsonOut); err != nil { | |||
| 40 | + | return fmt.Errorf("render traffic status: %w", err) | |||
| 41 | + | } | |||
| 42 | + | return nil | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func renderTrafficStatus(out io.Writer, status *dto.TrafficStatusResponse, jsonOut bool) error { | |||
| 46 | + | if jsonOut { | |||
| 47 | + | return writeJSON(out, status) | |||
| 48 | + | } | |||
| 49 | + | if err := cliWriteLine(out, cliRenderTitle("Traffic Status")); err != nil { | |||
| 50 | + | return err | |||
| 51 | + | } | |||
| 52 | + | if err := cliWriteLine(out, cliRenderMeta("Reload", status.LastReloadStatus)); err != nil { | |||
| 53 | + | return err | |||
| 54 | + | } | |||
| 55 | + | if status.LastReloadError != "" { | |||
| 56 | + | if err := cliWriteLine(out, cliRenderWarning("Last reload error: "+status.LastReloadError)); err != nil { | |||
| 57 | + | return err | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | if err := cliWriteLine(out, ""); err != nil { | |||
| 61 | + | return err | |||
| 62 | + | } | |||
| 63 | + | if err := cliWriteLine(out, "Entrypoints:"); err != nil { | |||
| 64 | + | return err | |||
| 65 | + | } | |||
| 66 | + | if len(status.EntryPoints) == 0 { | |||
| 67 | + | if err := cliWriteLine(out, cliRenderMuted(" none")); err != nil { | |||
| 68 | + | return err | |||
| 69 | + | } | |||
| 70 | + | } else { | |||
| 71 | + | for _, entry := range status.EntryPoints { | |||
| 72 | + | if err := cliWritef(out, " %s %s %s active=%t tcp=%d udp=%d accepted=%d refused=%d errors=%d\n", | |||
| 73 | + | entry.Name, entry.Protocol, entry.Address, entry.Active, entry.ActiveTCPConnections, entry.ActiveUDPSessions, | |||
| 74 | + | entry.TotalAccepted, entry.TotalRefused, entry.TotalErrors); err != nil { | |||
| 75 | + | return err | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | } | |||
| 79 | + | if err := renderTrafficRouters(out, status.Routers); err != nil { | |||
| 80 | + | return err | |||
| 81 | + | } | |||
| 82 | + | if err := renderTrafficServices(out, status.Services); err != nil { | |||
| 83 | + | return err | |||
| 84 | + | } | |||
| 85 | + | if err := cliWriteLine(out, ""); err != nil { | |||
| 86 | + | return err | |||
| 87 | + | } | |||
| 88 | + | return cliWritef(out, "Totals: tcp=%d udp=%d accepted=%d refused=%d errors=%d bytes_in=%d bytes_out=%d\n", | |||
| 89 | + | status.Counters.ActiveTCPConnections, status.Counters.ActiveUDPSessions, status.Counters.TotalAccepted, | |||
| 90 | + | status.Counters.TotalRefused, status.Counters.TotalErrors, status.Counters.BytesIn, status.Counters.BytesOut) | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func renderTrafficRouters(out io.Writer, routers []dto.TrafficRouterStatus) error { | |||
| 94 | + | if err := cliWriteLine(out, ""); err != nil { | |||
| 95 | + | return err | |||
| 96 | + | } | |||
| 97 | + | if err := cliWriteLine(out, "Routers:"); err != nil { | |||
| 98 | + | return err | |||
| 99 | + | } | |||
| 100 | + | if len(routers) == 0 { | |||
| 101 | + | return cliWriteLine(out, cliRenderMuted(" none")) | |||
| 102 | + | } | |||
| 103 | + | for _, router := range routers { | |||
| 104 | + | rule := router.Rule.Host | |||
| 105 | + | if rule == "" { | |||
| 106 | + | rule = router.Rule.SNI | |||
| 107 | + | } | |||
| 108 | + | if err := cliWritef(out, " %s %s entrypoint=%s rule=%s service=%s active=%t\n", | |||
| 109 | + | router.Name, router.Protocol, router.EntryPoint, rule, router.Service, router.Active); err != nil { | |||
| 110 | + | return err | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | return nil | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | func renderTrafficServices(out io.Writer, services []dto.TrafficServiceStatus) error { | |||
| 117 | + | if err := cliWriteLine(out, ""); err != nil { | |||
| 118 | + | return err | |||
| 119 | + | } | |||
| 120 | + | if err := cliWriteLine(out, "Services:"); err != nil { | |||
| 121 | + | return err | |||
| 122 | + | } | |||
| 123 | + | if len(services) == 0 { | |||
| 124 | + | return cliWriteLine(out, cliRenderMuted(" none")) | |||
| 125 | + | } | |||
| 126 | + | for _, service := range services { | |||
| 127 | + | if err := cliWritef(out, " %s active=%t\n", service.Name, service.Active); err != nil { | |||
| 128 | + | return err | |||
| 129 | + | } | |||
| 130 | + | for _, backend := range service.Backends { | |||
| 131 | + | addr := net.JoinHostPort(backend.Host, fmt.Sprint(backend.Port)) | |||
| 132 | + | if err := cliWritef(out, " %s %s://%s active=%t\n", | |||
| 133 | + | backend.Name, backend.Protocol, addr, backend.Active); err != nil { | |||
| 134 | + | return err | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 138 | + | return nil | |||
| 139 | + | } | |||
M internal/adapters/in/cli/traffic_test.go +1 -1
| @@ -34,7 +34,7 @@ func TestTrafficStatusHumanOutput(t *testing.T) { | |||
| 34 | 34 | }}, | |
| 35 | 35 | Routers: []dto.TrafficRouterStatus{{ | |
| 36 | 36 | Name: "pg-router", EntryPoint: "postgres", Protocol: domain.RouterProtocolTCP, | |
| 37 | - | Service: "network_service:postgres:db", Active: true, TotalAccepted: 2, | |
| 37 | + | Service: "network_service:postgres:db", Active: true, | |
| 38 | 38 | }}, | |
| 39 | 39 | Services: []dto.TrafficServiceStatus{{ | |
| 40 | 40 | Name: "network_service:postgres:db", Active: true, | |
| @@ -34,7 +34,7 @@ func TestTrafficStatusHumanOutput(t *testing.T) { | |||||
| 34 | }}, | 34 | }}, | ||
| 35 | Routers: []dto.TrafficRouterStatus{{ | 35 | Routers: []dto.TrafficRouterStatus{{ | ||
| 36 | Name: "pg-router", EntryPoint: "postgres", Protocol: domain.RouterProtocolTCP, | 36 | Name: "pg-router", EntryPoint: "postgres", Protocol: domain.RouterProtocolTCP, | ||
| 37 | - | Service: "network_service:postgres:db", Active: true, TotalAccepted: 2, | 37 | + | Service: "network_service:postgres:db", Active: true, |
| 38 | }}, | 38 | }}, | ||
| 39 | Services: []dto.TrafficServiceStatus{{ | 39 | Services: []dto.TrafficServiceStatus{{ | ||
| 40 | Name: "network_service:postgres:db", Active: true, | 40 | Name: "network_service:postgres:db", Active: true, | ||
M internal/adapters/in/http/admin/handler_traffic.go +4 -4
| @@ -9,14 +9,14 @@ import ( | |||
| 9 | 9 | ||
| 10 | 10 | func (h *Handler) handleTrafficStatus(w http.ResponseWriter, r *http.Request) { | |
| 11 | 11 | ctx := r.Context() | |
| 12 | - | if r.Method != http.MethodGet { | |
| 13 | - | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | |
| 14 | - | return | |
| 15 | - | } | |
| 16 | 12 | if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) { | |
| 17 | 13 | h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read") | |
| 18 | 14 | return | |
| 19 | 15 | } | |
| 16 | + | if r.Method != http.MethodGet { | |
| 17 | + | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | |
| 18 | + | return | |
| 19 | + | } | |
| 20 | 20 | if h.trafficSvc == nil { | |
| 21 | 21 | h.sendJSON(w, http.StatusOK, dto.TrafficStatusResponse{LastReloadStatus: "unavailable"}) | |
| 22 | 22 | return | |
| @@ -9,14 +9,14 @@ import ( | |||||
| 9 | 9 | ||||
| 10 | func (h *Handler) handleTrafficStatus(w http.ResponseWriter, r *http.Request) { | 10 | func (h *Handler) handleTrafficStatus(w http.ResponseWriter, r *http.Request) { | ||
| 11 | ctx := r.Context() | 11 | ctx := r.Context() | ||
| 12 | - | if r.Method != http.MethodGet { | |||
| 13 | - | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | |||
| 14 | - | return | |||
| 15 | - | } | |||
| 16 | if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) { | 12 | if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) { | ||
| 17 | h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read") | 13 | h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read") | ||
| 18 | return | 14 | return | ||
| 19 | } | 15 | } | ||
| 16 | + | if r.Method != http.MethodGet { | |||
| 17 | + | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | |||
| 18 | + | return | |||
| 19 | + | } | |||
| 20 | if h.trafficSvc == nil { | 20 | if h.trafficSvc == nil { | ||
| 21 | h.sendJSON(w, http.StatusOK, dto.TrafficStatusResponse{LastReloadStatus: "unavailable"}) | 21 | h.sendJSON(w, http.StatusOK, dto.TrafficStatusResponse{LastReloadStatus: "unavailable"}) | ||
| 22 | return | 22 | return | ||
M internal/adapters/in/http/admin/handler_traffic_test.go +14 -0
| @@ -52,3 +52,17 @@ func TestHandler_TrafficStatusRequiresStatusRead(t *testing.T) { | |||
| 52 | 52 | defer resp.Body.Close() | |
| 53 | 53 | assert.Equal(t, http.StatusForbidden, resp.StatusCode) | |
| 54 | 54 | } | |
| 55 | + | ||
| 56 | + | func TestHandler_TrafficStatusUnavailableWithoutService(t *testing.T) { | |
| 57 | + | handler := newTestHandler(t, func(d *HandlerDeps) { d.TrafficSvc = nil }) | |
| 58 | + | server := newScopedTestServer(t, handler, "admin:status:read") | |
| 59 | + | ||
| 60 | + | resp, err := http.Get(server.URL + "/admin/traffic/status") | |
| 61 | + | require.NoError(t, err) | |
| 62 | + | defer resp.Body.Close() | |
| 63 | + | require.Equal(t, http.StatusOK, resp.StatusCode) | |
| 64 | + | ||
| 65 | + | var body dto.TrafficStatusResponse | |
| 66 | + | require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) | |
| 67 | + | assert.Equal(t, "unavailable", body.LastReloadStatus) | |
| 68 | + | } | |
| @@ -52,3 +52,17 @@ func TestHandler_TrafficStatusRequiresStatusRead(t *testing.T) { | |||||
| 52 | defer resp.Body.Close() | 52 | defer resp.Body.Close() | ||
| 53 | assert.Equal(t, http.StatusForbidden, resp.StatusCode) | 53 | assert.Equal(t, http.StatusForbidden, resp.StatusCode) | ||
| 54 | } | 54 | } | ||
| 55 | + | ||||
| 56 | + | func TestHandler_TrafficStatusUnavailableWithoutService(t *testing.T) { | |||
| 57 | + | handler := newTestHandler(t, func(d *HandlerDeps) { d.TrafficSvc = nil }) | |||
| 58 | + | server := newScopedTestServer(t, handler, "admin:status:read") | |||
| 59 | + | ||||
| 60 | + | resp, err := http.Get(server.URL + "/admin/traffic/status") | |||
| 61 | + | require.NoError(t, err) | |||
| 62 | + | defer resp.Body.Close() | |||
| 63 | + | require.Equal(t, http.StatusOK, resp.StatusCode) | |||
| 64 | + | ||||
| 65 | + | var body dto.TrafficStatusResponse | |||
| 66 | + | require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) | |||
| 67 | + | assert.Equal(t, "unavailable", body.LastReloadStatus) | |||
| 68 | + | } | |||
M internal/adapters/in/traffic/manager.go +108 -58
| @@ -3,7 +3,6 @@ package traffic | |||
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | 5 | "context" | |
| 6 | - | "errors" | |
| 7 | 6 | "fmt" | |
| 8 | 7 | "net" | |
| 9 | 8 | "sort" | |
| @@ -71,35 +70,42 @@ func NewManager() *Manager { | |||
| 71 | 70 | // Apply validates and applies a new traffic graph snapshot. | |
| 72 | 71 | func (m *Manager) Apply(ctx context.Context, graph *domain.TrafficGraph) error { | |
| 73 | 72 | if graph == nil { | |
| 74 | - | return m.recordReloadError(ctx, errors.New("traffic graph is required")) | |
| 73 | + | return m.recordReloadError(ctx, fmt.Errorf("%w: traffic graph is required", domain.ErrTrafficGraphRequired)) | |
| 75 | 74 | } | |
| 76 | 75 | if err := graph.Validate(); err != nil { | |
| 77 | - | return m.recordReloadError(ctx, err) | |
| 76 | + | return m.recordReloadError(ctx, fmt.Errorf("validate traffic graph: %w", err)) | |
| 78 | 77 | } | |
| 79 | 78 | trafficDebug(ctx).Int("entrypoints", len(graph.EntryPoints)).Int("routers", len(graph.Routers)).Int("services", len(graph.Services)).Msg("applying traffic graph") | |
| 80 | 79 | ||
| 81 | 80 | nextGraph := cloneTrafficGraph(*graph) | |
| 82 | 81 | m.mu.Lock() | |
| 83 | - | defer m.mu.Unlock() | |
| 84 | - | ||
| 85 | - | newListeners, createdListeners, err := m.prepareTCPListeners(ctx, &nextGraph) | |
| 82 | + | newListeners, createdListeners, tcpUpdates, err := m.prepareTCPListeners(ctx, &nextGraph) | |
| 86 | 83 | if err != nil { | |
| 87 | 84 | m.lastReloadStatus = reloadStatusError | |
| 88 | 85 | m.lastReloadError = err.Error() | |
| 86 | + | m.mu.Unlock() | |
| 89 | 87 | trafficWarn(ctx).Err(err).Msg("failed to prepare tcp traffic listeners") | |
| 90 | 88 | return err | |
| 91 | 89 | } | |
| 92 | - | newUDPListeners, createdUDPListeners, err := m.prepareUDPListeners(ctx, &nextGraph) | |
| 90 | + | newUDPListeners, createdUDPListeners, udpUpdates, err := m.prepareUDPListeners(ctx, &nextGraph) | |
| 93 | 91 | if err != nil { | |
| 92 | + | m.lastReloadStatus = reloadStatusError | |
| 93 | + | m.lastReloadError = err.Error() | |
| 94 | + | m.mu.Unlock() | |
| 94 | 95 | for _, runtime := range createdListeners { | |
| 95 | 96 | runtime.stop(ctx, effectiveTCPOptions(snapshotTCPOptions(&nextGraph)).DrainTimeout) | |
| 96 | 97 | } | |
| 97 | - | m.lastReloadStatus = reloadStatusError | |
| 98 | - | m.lastReloadError = err.Error() | |
| 99 | 98 | trafficWarn(ctx).Err(err).Msg("failed to prepare udp traffic listeners") | |
| 100 | 99 | return err | |
| 101 | 100 | } | |
| 102 | 101 | ||
| 102 | + | for _, update := range tcpUpdates { | |
| 103 | + | update.runtime.updateEntryPoint(update.entryPoint, update.trusted) | |
| 104 | + | } | |
| 105 | + | for _, update := range udpUpdates { | |
| 106 | + | update.runtime.updateEntryPoint(update.entryPoint, update.trusted) | |
| 107 | + | } | |
| 108 | + | ||
| 103 | 109 | oldListeners := m.listeners | |
| 104 | 110 | oldUDPListeners := m.udpListeners | |
| 105 | 111 | m.listeners = newListeners | |
| @@ -113,22 +119,21 @@ func (m *Manager) Apply(ctx context.Context, graph *domain.TrafficGraph) error { | |||
| 113 | 119 | } | |
| 114 | 120 | m.lastReloadStatus = reloadStatusOK | |
| 115 | 121 | m.lastReloadError = "" | |
| 122 | + | m.mu.Unlock() | |
| 116 | 123 | ||
| 117 | 124 | tcpDrainTimeout := effectiveTCPOptions(snapshotTCPOptions(&nextGraph)).DrainTimeout | |
| 118 | 125 | stoppedTCP := 0 | |
| 119 | - | for name, runtime := range oldListeners { | |
| 120 | - | if newListeners[name] == runtime { | |
| 126 | + | for _, runtime := range oldListeners { | |
| 127 | + | if tcpRuntimeRetained(newListeners, runtime) { | |
| 121 | 128 | continue | |
| 122 | 129 | } | |
| 123 | - | if runtime.shouldStopWith(newListeners[name]) { | |
| 124 | - | stoppedTCP++ | |
| 125 | - | runtime.stop(ctx, tcpDrainTimeout) | |
| 126 | - | } | |
| 130 | + | stoppedTCP++ | |
| 131 | + | runtime.stop(ctx, tcpDrainTimeout) | |
| 127 | 132 | } | |
| 128 | 133 | udpDrainTimeout := effectiveUDPOptions(snapshotUDPOptions(&nextGraph)).DrainTimeout | |
| 129 | 134 | stoppedUDP := 0 | |
| 130 | - | for name, runtime := range oldUDPListeners { | |
| 131 | - | if newUDPListeners[name] == runtime { | |
| 135 | + | for _, runtime := range oldUDPListeners { | |
| 136 | + | if udpRuntimeRetained(newUDPListeners, runtime) { | |
| 132 | 137 | if _, ok := runtime.resolveUDPBackend(); !ok { | |
| 133 | 138 | runtime.drainSessionsAfter(udpDrainTimeout) | |
| 134 | 139 | } | |
| @@ -141,7 +146,24 @@ func (m *Manager) Apply(ctx context.Context, graph *domain.TrafficGraph) error { | |||
| 141 | 146 | return nil | |
| 142 | 147 | } | |
| 143 | 148 | ||
| 144 | - | // Shutdown stops all listeners and active TCP streams. | |
| 149 | + | func tcpRuntimeRetained(listeners map[string]*entryPointRuntime, runtime *entryPointRuntime) bool { | |
| 150 | + | for _, candidate := range listeners { | |
| 151 | + | if candidate == runtime { | |
| 152 | + | return true | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | return false | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | func udpRuntimeRetained(listeners map[string]*udpEntryPointRuntime, runtime *udpEntryPointRuntime) bool { | |
| 159 | + | for _, candidate := range listeners { | |
| 160 | + | if candidate == runtime { | |
| 161 | + | return true | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | return false | |
| 165 | + | } | |
| 166 | + | ||
| 145 | 167 | func logAppliedTrafficGraph( | |
| 146 | 168 | ctx context.Context, | |
| 147 | 169 | newListeners map[string]*entryPointRuntime, | |
| @@ -165,6 +187,7 @@ func logAppliedTrafficGraph( | |||
| 165 | 187 | Msg("traffic graph applied") | |
| 166 | 188 | } | |
| 167 | 189 | ||
| 190 | + | // Shutdown stops all listeners and active TCP streams. | |
| 168 | 191 | func (m *Manager) Shutdown(ctx context.Context) error { | |
| 169 | 192 | trafficInfo(ctx).Msg("shutting down traffic manager") | |
| 170 | 193 | m.mu.Lock() | |
| @@ -228,7 +251,19 @@ func (m *Manager) recordReloadError(ctx context.Context, err error) error { | |||
| 228 | 251 | return err | |
| 229 | 252 | } | |
| 230 | 253 | ||
| 231 | - | func (m *Manager) prepareTCPListeners(ctx context.Context, graph *domain.TrafficGraph) (map[string]*entryPointRuntime, []*entryPointRuntime, error) { | |
| 254 | + | type tcpRuntimeUpdate struct { | |
| 255 | + | runtime *entryPointRuntime | |
| 256 | + | entryPoint domain.EntryPoint | |
| 257 | + | trusted []*net.IPNet | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | type udpRuntimeUpdate struct { | |
| 261 | + | runtime *udpEntryPointRuntime | |
| 262 | + | entryPoint domain.EntryPoint | |
| 263 | + | trusted []*net.IPNet | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | func (m *Manager) prepareTCPListeners(ctx context.Context, graph *domain.TrafficGraph) (map[string]*entryPointRuntime, []*entryPointRuntime, []tcpRuntimeUpdate, error) { | |
| 232 | 267 | current := make(map[string]*entryPointRuntime, len(m.listeners)) | |
| 233 | 268 | for name, runtime := range m.listeners { | |
| 234 | 269 | current[name] = runtime | |
| @@ -236,30 +271,49 @@ func (m *Manager) prepareTCPListeners(ctx context.Context, graph *domain.Traffic | |||
| 236 | 271 | ||
| 237 | 272 | next := make(map[string]*entryPointRuntime, len(current)) | |
| 238 | 273 | created := []*entryPointRuntime{} | |
| 274 | + | updates := []tcpRuntimeUpdate{} | |
| 239 | 275 | for _, entryPoint := range graph.EntryPoints { | |
| 240 | 276 | if !isTCPListenerProtocol(entryPoint.Protocol) { | |
| 241 | 277 | continue | |
| 242 | 278 | } | |
| 243 | 279 | if runtime := current[entryPoint.Name]; runtime != nil && runtime.matches(entryPoint) { | |
| 244 | 280 | next[entryPoint.Name] = runtime | |
| 281 | + | delete(current, entryPoint.Name) | |
| 245 | 282 | continue | |
| 246 | 283 | } | |
| 247 | 284 | if runtime := conflictingTCPRuntime(current, entryPoint); runtime != nil { | |
| 248 | - | trafficInfo(ctx).Str("entrypoint", runtime.entryPoint.Name).Str("address", runtime.entryPoint.Address).Msg("stopping tcp traffic entrypoint before same-address replacement") | |
| 249 | - | runtime.stop(ctx, effectiveTCPOptions(graph.Options.TCP).DrainTimeout) | |
| 250 | - | delete(current, runtime.entryPoint.Name) | |
| 285 | + | trusted, err := parseTrustedCIDRs(entryPoint.TrustedCIDRs) | |
| 286 | + | if err != nil { | |
| 287 | + | stopTCPRuntimes(ctx, created, effectiveTCPOptions(graph.Options.TCP).DrainTimeout) | |
| 288 | + | return nil, nil, nil, fmt.Errorf("parse trusted cidrs for tcp entrypoint %q: %w", entryPoint.Name, err) | |
| 289 | + | } | |
| 290 | + | trafficDebug(ctx).Str("entrypoint", entryPoint.Name).Str("address", entryPoint.Address).Msg("reusing tcp traffic listener for same-address entrypoint update") | |
| 291 | + | updates = append(updates, tcpRuntimeUpdate{runtime: runtime, entryPoint: entryPoint, trusted: trusted}) | |
| 292 | + | next[entryPoint.Name] = runtime | |
| 293 | + | delete(current, runtime.entryPointSnapshot().Name) | |
| 294 | + | continue | |
| 251 | 295 | } | |
| 252 | 296 | runtime, err := m.bindTCPEntryPoint(ctx, entryPoint) | |
| 253 | 297 | if err != nil { | |
| 254 | - | for _, createdRuntime := range created { | |
| 255 | - | createdRuntime.stop(ctx, effectiveTCPOptions(graph.Options.TCP).DrainTimeout) | |
| 256 | - | } | |
| 257 | - | return nil, nil, err | |
| 298 | + | stopTCPRuntimes(ctx, created, effectiveTCPOptions(graph.Options.TCP).DrainTimeout) | |
| 299 | + | return nil, nil, nil, err | |
| 258 | 300 | } | |
| 259 | 301 | next[entryPoint.Name] = runtime | |
| 260 | 302 | created = append(created, runtime) | |
| 261 | 303 | } | |
| 262 | - | return next, created, nil | |
| 304 | + | return next, created, updates, nil | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | func stopTCPRuntimes(ctx context.Context, runtimes []*entryPointRuntime, drainTimeout time.Duration) { | |
| 308 | + | for _, runtime := range runtimes { | |
| 309 | + | runtime.stop(ctx, drainTimeout) | |
| 310 | + | } | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | func stopUDPRuntimes(ctx context.Context, runtimes []*udpEntryPointRuntime, drainTimeout time.Duration) { | |
| 314 | + | for _, runtime := range runtimes { | |
| 315 | + | runtime.stop(ctx, drainTimeout) | |
| 316 | + | } | |
| 263 | 317 | } | |
| 264 | 318 | ||
| 265 | 319 | func isTCPListenerProtocol(protocol domain.EntryPointProtocol) bool { | |
| @@ -268,7 +322,7 @@ func isTCPListenerProtocol(protocol domain.EntryPointProtocol) bool { | |||
| 268 | 322 | ||
| 269 | 323 | func conflictingTCPRuntime(current map[string]*entryPointRuntime, entryPoint domain.EntryPoint) *entryPointRuntime { | |
| 270 | 324 | for _, runtime := range current { | |
| 271 | - | if runtime.entryPoint.Address == entryPoint.Address { | |
| 325 | + | if runtime.sameAddress(entryPoint) { | |
| 272 | 326 | return runtime | |
| 273 | 327 | } | |
| 274 | 328 | } | |
| @@ -290,7 +344,7 @@ func (m *Manager) bindTCPEntryPoint(ctx context.Context, entryPoint domain.Entry | |||
| 290 | 344 | return runtime, nil | |
| 291 | 345 | } | |
| 292 | 346 | ||
| 293 | - | func (m *Manager) prepareUDPListeners(ctx context.Context, graph *domain.TrafficGraph) (map[string]*udpEntryPointRuntime, []*udpEntryPointRuntime, error) { | |
| 347 | + | func (m *Manager) prepareUDPListeners(ctx context.Context, graph *domain.TrafficGraph) (map[string]*udpEntryPointRuntime, []*udpEntryPointRuntime, []udpRuntimeUpdate, error) { | |
| 294 | 348 | current := make(map[string]*udpEntryPointRuntime, len(m.udpListeners)) | |
| 295 | 349 | for name, runtime := range m.udpListeners { | |
| 296 | 350 | current[name] = runtime | |
| @@ -298,35 +352,42 @@ func (m *Manager) prepareUDPListeners(ctx context.Context, graph *domain.Traffic | |||
| 298 | 352 | ||
| 299 | 353 | next := make(map[string]*udpEntryPointRuntime, len(current)) | |
| 300 | 354 | created := []*udpEntryPointRuntime{} | |
| 355 | + | updates := []udpRuntimeUpdate{} | |
| 301 | 356 | for _, entryPoint := range graph.EntryPoints { | |
| 302 | 357 | if entryPoint.Protocol != domain.EntryPointProtocolUDP { | |
| 303 | 358 | continue | |
| 304 | 359 | } | |
| 305 | 360 | if runtime := current[entryPoint.Name]; runtime != nil && runtime.matches(entryPoint) { | |
| 306 | 361 | next[entryPoint.Name] = runtime | |
| 362 | + | delete(current, entryPoint.Name) | |
| 307 | 363 | continue | |
| 308 | 364 | } | |
| 309 | 365 | if runtime := conflictingUDPRuntime(current, entryPoint); runtime != nil { | |
| 310 | - | trafficInfo(ctx).Str("entrypoint", runtime.entryPoint.Name).Str("address", runtime.entryPoint.Address).Msg("stopping udp traffic entrypoint before same-address replacement") | |
| 311 | - | runtime.stop(ctx, effectiveUDPOptions(graph.Options.UDP).DrainTimeout) | |
| 312 | - | delete(current, runtime.entryPoint.Name) | |
| 366 | + | trusted, err := parseTrustedCIDRs(entryPoint.TrustedCIDRs) | |
| 367 | + | if err != nil { | |
| 368 | + | stopUDPRuntimes(ctx, created, effectiveUDPOptions(graph.Options.UDP).DrainTimeout) | |
| 369 | + | return nil, nil, nil, fmt.Errorf("parse trusted cidrs for udp entrypoint %q: %w", entryPoint.Name, err) | |
| 370 | + | } | |
| 371 | + | trafficDebug(ctx).Str("entrypoint", entryPoint.Name).Str("address", entryPoint.Address).Msg("reusing udp traffic listener for same-address entrypoint update") | |
| 372 | + | updates = append(updates, udpRuntimeUpdate{runtime: runtime, entryPoint: entryPoint, trusted: trusted}) | |
| 373 | + | next[entryPoint.Name] = runtime | |
| 374 | + | delete(current, runtime.entryPointSnapshot().Name) | |
| 375 | + | continue | |
| 313 | 376 | } | |
| 314 | 377 | runtime, err := m.bindUDPEntryPoint(ctx, entryPoint) | |
| 315 | 378 | if err != nil { | |
| 316 | - | for _, createdRuntime := range created { | |
| 317 | - | createdRuntime.stop(ctx, effectiveUDPOptions(graph.Options.UDP).DrainTimeout) | |
| 318 | - | } | |
| 319 | - | return nil, nil, err | |
| 379 | + | stopUDPRuntimes(ctx, created, effectiveUDPOptions(graph.Options.UDP).DrainTimeout) | |
| 380 | + | return nil, nil, nil, err | |
| 320 | 381 | } | |
| 321 | 382 | next[entryPoint.Name] = runtime | |
| 322 | 383 | created = append(created, runtime) | |
| 323 | 384 | } | |
| 324 | - | return next, created, nil | |
| 385 | + | return next, created, updates, nil | |
| 325 | 386 | } | |
| 326 | 387 | ||
| 327 | 388 | func conflictingUDPRuntime(current map[string]*udpEntryPointRuntime, entryPoint domain.EntryPoint) *udpEntryPointRuntime { | |
| 328 | 389 | for _, runtime := range current { | |
| 329 | - | if runtime.entryPoint.Address == entryPoint.Address { | |
| 390 | + | if runtime.sameAddress(entryPoint) { | |
| 330 | 391 | return runtime | |
| 331 | 392 | } | |
| 332 | 393 | } | |
| @@ -438,26 +499,12 @@ func entryPointStatuses(entries []domain.EntryPoint, listeners map[string]*entry | |||
| 438 | 499 | func routerStatuses(routers []domain.TrafficRouter, listeners map[string]*entryPointRuntime, udpListeners map[string]*udpEntryPointRuntime) []domain.TrafficRouterStatus { | |
| 439 | 500 | statuses := make([]domain.TrafficRouterStatus, 0, len(routers)) | |
| 440 | 501 | for _, router := range routers { | |
| 441 | - | status := domain.TrafficRouterStatus{Name: router.Name, EntryPoint: router.EntryPoint, Protocol: router.Protocol, Rule: router.Rule, Service: router.Service, Active: true} | |
| 442 | - | if runtime := listeners[router.EntryPoint]; runtime != nil { | |
| 443 | - | counters := runtime.counters.snapshot() | |
| 444 | - | status.ActiveTCPConnections = counters.ActiveTCPConnections | |
| 445 | - | status.TotalAccepted = counters.TotalAccepted | |
| 446 | - | status.TotalRefused = counters.TotalRefused | |
| 447 | - | status.TotalErrors = counters.TotalErrors | |
| 448 | - | status.BytesIn = counters.BytesIn | |
| 449 | - | status.BytesOut = counters.BytesOut | |
| 450 | - | } | |
| 451 | - | if runtime := udpListeners[router.EntryPoint]; runtime != nil { | |
| 452 | - | counters := runtime.counters.snapshot() | |
| 453 | - | status.ActiveUDPSessions = counters.ActiveUDPSessions | |
| 454 | - | status.TotalAccepted = counters.TotalAccepted | |
| 455 | - | status.TotalRefused = counters.TotalRefused | |
| 456 | - | status.TotalErrors = counters.TotalErrors | |
| 457 | - | status.BytesIn = counters.BytesIn | |
| 458 | - | status.BytesOut = counters.BytesOut | |
| 459 | - | } | |
| 460 | - | statuses = append(statuses, status) | |
| 502 | + | _, tcpActive := listeners[router.EntryPoint] | |
| 503 | + | _, udpActive := udpListeners[router.EntryPoint] | |
| 504 | + | statuses = append(statuses, domain.TrafficRouterStatus{ | |
| 505 | + | Name: router.Name, EntryPoint: router.EntryPoint, Protocol: router.Protocol, | |
| 506 | + | Rule: router.Rule, Service: router.Service, Active: tcpActive || udpActive, | |
| 507 | + | }) | |
| 461 | 508 | } | |
| 462 | 509 | sort.Slice(statuses, func(i, j int) bool { return statuses[i].Name < statuses[j].Name }) | |
| 463 | 510 | return statuses | |
| @@ -493,6 +540,9 @@ func aggregateCounters(entries []domain.EntryPointStatus) domain.TrafficCounters | |||
| 493 | 540 | func cloneTrafficGraph(graph domain.TrafficGraph) domain.TrafficGraph { | |
| 494 | 541 | clone := graph | |
| 495 | 542 | clone.EntryPoints = append([]domain.EntryPoint{}, graph.EntryPoints...) | |
| 543 | + | for i := range clone.EntryPoints { | |
| 544 | + | clone.EntryPoints[i].TrustedCIDRs = append([]string(nil), graph.EntryPoints[i].TrustedCIDRs...) | |
| 545 | + | } | |
| 496 | 546 | clone.Routers = append([]domain.TrafficRouter{}, graph.Routers...) | |
| 497 | 547 | clone.Services = append([]domain.TrafficService{}, graph.Services...) | |
| 498 | 548 | for i := range clone.Services { | |
| @@ -3,7 +3,6 @@ package traffic | |||||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| 5 | "context" | 5 | "context" | ||
| 6 | - | "errors" | |||
| 7 | "fmt" | 6 | "fmt" | ||
| 8 | "net" | 7 | "net" | ||
| 9 | "sort" | 8 | "sort" | ||
| @@ -71,35 +70,42 @@ func NewManager() *Manager { | |||||
| 71 | // Apply validates and applies a new traffic graph snapshot. | 70 | // Apply validates and applies a new traffic graph snapshot. | ||
| 72 | func (m *Manager) Apply(ctx context.Context, graph *domain.TrafficGraph) error { | 71 | func (m *Manager) Apply(ctx context.Context, graph *domain.TrafficGraph) error { | ||
| 73 | if graph == nil { | 72 | if graph == nil { | ||
| 74 | - | return m.recordReloadError(ctx, errors.New("traffic graph is required")) | 73 | + | return m.recordReloadError(ctx, fmt.Errorf("%w: traffic graph is required", domain.ErrTrafficGraphRequired)) |
| 75 | } | 74 | } | ||
| 76 | if err := graph.Validate(); err != nil { | 75 | if err := graph.Validate(); err != nil { | ||
| 77 | - | return m.recordReloadError(ctx, err) | 76 | + | return m.recordReloadError(ctx, fmt.Errorf("validate traffic graph: %w", err)) |
| 78 | } | 77 | } | ||
| 79 | trafficDebug(ctx).Int("entrypoints", len(graph.EntryPoints)).Int("routers", len(graph.Routers)).Int("services", len(graph.Services)).Msg("applying traffic graph") | 78 | trafficDebug(ctx).Int("entrypoints", len(graph.EntryPoints)).Int("routers", len(graph.Routers)).Int("services", len(graph.Services)).Msg("applying traffic graph") | ||
| 80 | 79 | ||||
| 81 | nextGraph := cloneTrafficGraph(*graph) | 80 | nextGraph := cloneTrafficGraph(*graph) | ||
| 82 | m.mu.Lock() | 81 | m.mu.Lock() | ||
| 83 | - | defer m.mu.Unlock() | 82 | + | newListeners, createdListeners, tcpUpdates, err := m.prepareTCPListeners(ctx, &nextGraph) |
| 84 | - | ||||
| 85 | - | newListeners, createdListeners, err := m.prepareTCPListeners(ctx, &nextGraph) | |||
| 86 | if err != nil { | 83 | if err != nil { | ||
| 87 | m.lastReloadStatus = reloadStatusError | 84 | m.lastReloadStatus = reloadStatusError | ||
| 88 | m.lastReloadError = err.Error() | 85 | m.lastReloadError = err.Error() | ||
| 86 | + | m.mu.Unlock() | |||
| 89 | trafficWarn(ctx).Err(err).Msg("failed to prepare tcp traffic listeners") | 87 | trafficWarn(ctx).Err(err).Msg("failed to prepare tcp traffic listeners") | ||
| 90 | return err | 88 | return err | ||
| 91 | } | 89 | } | ||
| 92 | - | newUDPListeners, createdUDPListeners, err := m.prepareUDPListeners(ctx, &nextGraph) | 90 | + | newUDPListeners, createdUDPListeners, udpUpdates, err := m.prepareUDPListeners(ctx, &nextGraph) |
| 93 | if err != nil { | 91 | if err != nil { | ||
| 92 | + | m.lastReloadStatus = reloadStatusError | |||
| 93 | + | m.lastReloadError = err.Error() | |||
| 94 | + | m.mu.Unlock() | |||
| 94 | for _, runtime := range createdListeners { | 95 | for _, runtime := range createdListeners { | ||
| 95 | runtime.stop(ctx, effectiveTCPOptions(snapshotTCPOptions(&nextGraph)).DrainTimeout) | 96 | runtime.stop(ctx, effectiveTCPOptions(snapshotTCPOptions(&nextGraph)).DrainTimeout) | ||
| 96 | } | 97 | } | ||
| 97 | - | m.lastReloadStatus = reloadStatusError | |||
| 98 | - | m.lastReloadError = err.Error() | |||
| 99 | trafficWarn(ctx).Err(err).Msg("failed to prepare udp traffic listeners") | 98 | trafficWarn(ctx).Err(err).Msg("failed to prepare udp traffic listeners") | ||
| 100 | return err | 99 | return err | ||
| 101 | } | 100 | } | ||
| 102 | 101 | ||||
| 102 | + | for _, update := range tcpUpdates { | |||
| 103 | + | update.runtime.updateEntryPoint(update.entryPoint, update.trusted) | |||
| 104 | + | } | |||
| 105 | + | for _, update := range udpUpdates { | |||
| 106 | + | update.runtime.updateEntryPoint(update.entryPoint, update.trusted) | |||
| 107 | + | } | |||
| 108 | + | ||||
| 103 | oldListeners := m.listeners | 109 | oldListeners := m.listeners | ||
| 104 | oldUDPListeners := m.udpListeners | 110 | oldUDPListeners := m.udpListeners | ||
| 105 | m.listeners = newListeners | 111 | m.listeners = newListeners | ||
| @@ -113,22 +119,21 @@ func (m *Manager) Apply(ctx context.Context, graph *domain.TrafficGraph) error { | |||||
| 113 | } | 119 | } | ||
| 114 | m.lastReloadStatus = reloadStatusOK | 120 | m.lastReloadStatus = reloadStatusOK | ||
| 115 | m.lastReloadError = "" | 121 | m.lastReloadError = "" | ||
| 122 | + | m.mu.Unlock() | |||
| 116 | 123 | ||||
| 117 | tcpDrainTimeout := effectiveTCPOptions(snapshotTCPOptions(&nextGraph)).DrainTimeout | 124 | tcpDrainTimeout := effectiveTCPOptions(snapshotTCPOptions(&nextGraph)).DrainTimeout | ||
| 118 | stoppedTCP := 0 | 125 | stoppedTCP := 0 | ||
| 119 | - | for name, runtime := range oldListeners { | 126 | + | for _, runtime := range oldListeners { |
| 120 | - | if newListeners[name] == runtime { | 127 | + | if tcpRuntimeRetained(newListeners, runtime) { |
| 121 | continue | 128 | continue | ||
| 122 | } | 129 | } | ||
| 123 | - | if runtime.shouldStopWith(newListeners[name]) { | 130 | + | stoppedTCP++ |
| 124 | - | stoppedTCP++ | 131 | + | runtime.stop(ctx, tcpDrainTimeout) |
| 125 | - | runtime.stop(ctx, tcpDrainTimeout) | |||
| 126 | - | } | |||
| 127 | } | 132 | } | ||
| 128 | udpDrainTimeout := effectiveUDPOptions(snapshotUDPOptions(&nextGraph)).DrainTimeout | 133 | udpDrainTimeout := effectiveUDPOptions(snapshotUDPOptions(&nextGraph)).DrainTimeout | ||
| 129 | stoppedUDP := 0 | 134 | stoppedUDP := 0 | ||
| 130 | - | for name, runtime := range oldUDPListeners { | 135 | + | for _, runtime := range oldUDPListeners { |
| 131 | - | if newUDPListeners[name] == runtime { | 136 | + | if udpRuntimeRetained(newUDPListeners, runtime) { |
| 132 | if _, ok := runtime.resolveUDPBackend(); !ok { | 137 | if _, ok := runtime.resolveUDPBackend(); !ok { | ||
| 133 | runtime.drainSessionsAfter(udpDrainTimeout) | 138 | runtime.drainSessionsAfter(udpDrainTimeout) | ||
| 134 | } | 139 | } | ||
| @@ -141,7 +146,24 @@ func (m *Manager) Apply(ctx context.Context, graph *domain.TrafficGraph) error { | |||||
| 141 | return nil | 146 | return nil | ||
| 142 | } | 147 | } | ||
| 143 | 148 | ||||
| 144 | - | // Shutdown stops all listeners and active TCP streams. | 149 | + | func tcpRuntimeRetained(listeners map[string]*entryPointRuntime, runtime *entryPointRuntime) bool { |
| 150 | + | for _, candidate := range listeners { | |||
| 151 | + | if candidate == runtime { | |||
| 152 | + | return true | |||
| 153 | + | } | |||
| 154 | + | } | |||
| 155 | + | return false | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | func udpRuntimeRetained(listeners map[string]*udpEntryPointRuntime, runtime *udpEntryPointRuntime) bool { | |||
| 159 | + | for _, candidate := range listeners { | |||
| 160 | + | if candidate == runtime { | |||
| 161 | + | return true | |||
| 162 | + | } | |||
| 163 | + | } | |||
| 164 | + | return false | |||
| 165 | + | } | |||
| 166 | + | ||||
| 145 | func logAppliedTrafficGraph( | 167 | func logAppliedTrafficGraph( | ||
| 146 | ctx context.Context, | 168 | ctx context.Context, | ||
| 147 | newListeners map[string]*entryPointRuntime, | 169 | newListeners map[string]*entryPointRuntime, | ||
| @@ -165,6 +187,7 @@ func logAppliedTrafficGraph( | |||||
| 165 | Msg("traffic graph applied") | 187 | Msg("traffic graph applied") | ||
| 166 | } | 188 | } | ||
| 167 | 189 | ||||
| 190 | + | // Shutdown stops all listeners and active TCP streams. | |||
| 168 | func (m *Manager) Shutdown(ctx context.Context) error { | 191 | func (m *Manager) Shutdown(ctx context.Context) error { | ||
| 169 | trafficInfo(ctx).Msg("shutting down traffic manager") | 192 | trafficInfo(ctx).Msg("shutting down traffic manager") | ||
| 170 | m.mu.Lock() | 193 | m.mu.Lock() | ||
| @@ -228,7 +251,19 @@ func (m *Manager) recordReloadError(ctx context.Context, err error) error { | |||||
| 228 | return err | 251 | return err | ||
| 229 | } | 252 | } | ||
| 230 | 253 | ||||
| 231 | - | func (m *Manager) prepareTCPListeners(ctx context.Context, graph *domain.TrafficGraph) (map[string]*entryPointRuntime, []*entryPointRuntime, error) { | 254 | + | type tcpRuntimeUpdate struct { |
| 255 | + | runtime *entryPointRuntime | |||
| 256 | + | entryPoint domain.EntryPoint | |||
| 257 | + | trusted []*net.IPNet | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | type udpRuntimeUpdate struct { | |||
| 261 | + | runtime *udpEntryPointRuntime | |||
| 262 | + | entryPoint domain.EntryPoint | |||
| 263 | + | trusted []*net.IPNet | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | func (m *Manager) prepareTCPListeners(ctx context.Context, graph *domain.TrafficGraph) (map[string]*entryPointRuntime, []*entryPointRuntime, []tcpRuntimeUpdate, error) { | |||
| 232 | current := make(map[string]*entryPointRuntime, len(m.listeners)) | 267 | current := make(map[string]*entryPointRuntime, len(m.listeners)) | ||
| 233 | for name, runtime := range m.listeners { | 268 | for name, runtime := range m.listeners { | ||
| 234 | current[name] = runtime | 269 | current[name] = runtime | ||
| @@ -236,30 +271,49 @@ func (m *Manager) prepareTCPListeners(ctx context.Context, graph *domain.Traffic | |||||
| 236 | 271 | ||||
| 237 | next := make(map[string]*entryPointRuntime, len(current)) | 272 | next := make(map[string]*entryPointRuntime, len(current)) | ||
| 238 | created := []*entryPointRuntime{} | 273 | created := []*entryPointRuntime{} | ||
| 274 | + | updates := []tcpRuntimeUpdate{} | |||
| 239 | for _, entryPoint := range graph.EntryPoints { | 275 | for _, entryPoint := range graph.EntryPoints { | ||
| 240 | if !isTCPListenerProtocol(entryPoint.Protocol) { | 276 | if !isTCPListenerProtocol(entryPoint.Protocol) { | ||
| 241 | continue | 277 | continue | ||
| 242 | } | 278 | } | ||
| 243 | if runtime := current[entryPoint.Name]; runtime != nil && runtime.matches(entryPoint) { | 279 | if runtime := current[entryPoint.Name]; runtime != nil && runtime.matches(entryPoint) { | ||
| 244 | next[entryPoint.Name] = runtime | 280 | next[entryPoint.Name] = runtime | ||
| 281 | + | delete(current, entryPoint.Name) | |||
| 245 | continue | 282 | continue | ||
| 246 | } | 283 | } | ||
| 247 | if runtime := conflictingTCPRuntime(current, entryPoint); runtime != nil { | 284 | if runtime := conflictingTCPRuntime(current, entryPoint); runtime != nil { | ||
| 248 | - | trafficInfo(ctx).Str("entrypoint", runtime.entryPoint.Name).Str("address", runtime.entryPoint.Address).Msg("stopping tcp traffic entrypoint before same-address replacement") | 285 | + | trusted, err := parseTrustedCIDRs(entryPoint.TrustedCIDRs) |
| 249 | - | runtime.stop(ctx, effectiveTCPOptions(graph.Options.TCP).DrainTimeout) | 286 | + | if err != nil { |
| 250 | - | delete(current, runtime.entryPoint.Name) | 287 | + | stopTCPRuntimes(ctx, created, effectiveTCPOptions(graph.Options.TCP).DrainTimeout) |
| 288 | + | return nil, nil, nil, fmt.Errorf("parse trusted cidrs for tcp entrypoint %q: %w", entryPoint.Name, err) | |||
| 289 | + | } | |||
| 290 | + | trafficDebug(ctx).Str("entrypoint", entryPoint.Name).Str("address", entryPoint.Address).Msg("reusing tcp traffic listener for same-address entrypoint update") | |||
| 291 | + | updates = append(updates, tcpRuntimeUpdate{runtime: runtime, entryPoint: entryPoint, trusted: trusted}) | |||
| 292 | + | next[entryPoint.Name] = runtime | |||
| 293 | + | delete(current, runtime.entryPointSnapshot().Name) | |||
| 294 | + | continue | |||
| 251 | } | 295 | } | ||
| 252 | runtime, err := m.bindTCPEntryPoint(ctx, entryPoint) | 296 | runtime, err := m.bindTCPEntryPoint(ctx, entryPoint) | ||
| 253 | if err != nil { | 297 | if err != nil { | ||
| 254 | - | for _, createdRuntime := range created { | 298 | + | stopTCPRuntimes(ctx, created, effectiveTCPOptions(graph.Options.TCP).DrainTimeout) |
| 255 | - | createdRuntime.stop(ctx, effectiveTCPOptions(graph.Options.TCP).DrainTimeout) | 299 | + | return nil, nil, nil, err |
| 256 | - | } | |||
| 257 | - | return nil, nil, err | |||
| 258 | } | 300 | } | ||
| 259 | next[entryPoint.Name] = runtime | 301 | next[entryPoint.Name] = runtime | ||
| 260 | created = append(created, runtime) | 302 | created = append(created, runtime) | ||
| 261 | } | 303 | } | ||
| 262 | - | return next, created, nil | 304 | + | return next, created, updates, nil |
| 305 | + | } | |||
| 306 | + | ||||
| 307 | + | func stopTCPRuntimes(ctx context.Context, runtimes []*entryPointRuntime, drainTimeout time.Duration) { | |||
| 308 | + | for _, runtime := range runtimes { | |||
| 309 | + | runtime.stop(ctx, drainTimeout) | |||
| 310 | + | } | |||
| 311 | + | } | |||
| 312 | + | ||||
| 313 | + | func stopUDPRuntimes(ctx context.Context, runtimes []*udpEntryPointRuntime, drainTimeout time.Duration) { | |||
| 314 | + | for _, runtime := range runtimes { | |||
| 315 | + | runtime.stop(ctx, drainTimeout) | |||
| 316 | + | } | |||
| 263 | } | 317 | } | ||
| 264 | 318 | ||||
| 265 | func isTCPListenerProtocol(protocol domain.EntryPointProtocol) bool { | 319 | func isTCPListenerProtocol(protocol domain.EntryPointProtocol) bool { | ||
| @@ -268,7 +322,7 @@ func isTCPListenerProtocol(protocol domain.EntryPointProtocol) bool { | |||||
| 268 | 322 | ||||
| 269 | func conflictingTCPRuntime(current map[string]*entryPointRuntime, entryPoint domain.EntryPoint) *entryPointRuntime { | 323 | func conflictingTCPRuntime(current map[string]*entryPointRuntime, entryPoint domain.EntryPoint) *entryPointRuntime { | ||
| 270 | for _, runtime := range current { | 324 | for _, runtime := range current { | ||
| 271 | - | if runtime.entryPoint.Address == entryPoint.Address { | 325 | + | if runtime.sameAddress(entryPoint) { |
| 272 | return runtime | 326 | return runtime | ||
| 273 | } | 327 | } | ||
| 274 | } | 328 | } | ||
| @@ -290,7 +344,7 @@ func (m *Manager) bindTCPEntryPoint(ctx context.Context, entryPoint domain.Entry | |||||
| 290 | return runtime, nil | 344 | return runtime, nil | ||
| 291 | } | 345 | } | ||
| 292 | 346 | ||||
| 293 | - | func (m *Manager) prepareUDPListeners(ctx context.Context, graph *domain.TrafficGraph) (map[string]*udpEntryPointRuntime, []*udpEntryPointRuntime, error) { | 347 | + | func (m *Manager) prepareUDPListeners(ctx context.Context, graph *domain.TrafficGraph) (map[string]*udpEntryPointRuntime, []*udpEntryPointRuntime, []udpRuntimeUpdate, error) { |
| 294 | current := make(map[string]*udpEntryPointRuntime, len(m.udpListeners)) | 348 | current := make(map[string]*udpEntryPointRuntime, len(m.udpListeners)) | ||
| 295 | for name, runtime := range m.udpListeners { | 349 | for name, runtime := range m.udpListeners { | ||
| 296 | current[name] = runtime | 350 | current[name] = runtime | ||
| @@ -298,35 +352,42 @@ func (m *Manager) prepareUDPListeners(ctx context.Context, graph *domain.Traffic | |||||
| 298 | 352 | ||||
| 299 | next := make(map[string]*udpEntryPointRuntime, len(current)) | 353 | next := make(map[string]*udpEntryPointRuntime, len(current)) | ||
| 300 | created := []*udpEntryPointRuntime{} | 354 | created := []*udpEntryPointRuntime{} | ||
| 355 | + | updates := []udpRuntimeUpdate{} | |||
| 301 | for _, entryPoint := range graph.EntryPoints { | 356 | for _, entryPoint := range graph.EntryPoints { | ||
| 302 | if entryPoint.Protocol != domain.EntryPointProtocolUDP { | 357 | if entryPoint.Protocol != domain.EntryPointProtocolUDP { | ||
| 303 | continue | 358 | continue | ||
| 304 | } | 359 | } | ||
| 305 | if runtime := current[entryPoint.Name]; runtime != nil && runtime.matches(entryPoint) { | 360 | if runtime := current[entryPoint.Name]; runtime != nil && runtime.matches(entryPoint) { | ||
| 306 | next[entryPoint.Name] = runtime | 361 | next[entryPoint.Name] = runtime | ||
| 362 | + | delete(current, entryPoint.Name) | |||
| 307 | continue | 363 | continue | ||
| 308 | } | 364 | } | ||
| 309 | if runtime := conflictingUDPRuntime(current, entryPoint); runtime != nil { | 365 | if runtime := conflictingUDPRuntime(current, entryPoint); runtime != nil { | ||
| 310 | - | trafficInfo(ctx).Str("entrypoint", runtime.entryPoint.Name).Str("address", runtime.entryPoint.Address).Msg("stopping udp traffic entrypoint before same-address replacement") | 366 | + | trusted, err := parseTrustedCIDRs(entryPoint.TrustedCIDRs) |
| 311 | - | runtime.stop(ctx, effectiveUDPOptions(graph.Options.UDP).DrainTimeout) | 367 | + | if err != nil { |
| 312 | - | delete(current, runtime.entryPoint.Name) | 368 | + | stopUDPRuntimes(ctx, created, effectiveUDPOptions(graph.Options.UDP).DrainTimeout) |
| 369 | + | return nil, nil, nil, fmt.Errorf("parse trusted cidrs for udp entrypoint %q: %w", entryPoint.Name, err) | |||
| 370 | + | } | |||
| 371 | + | trafficDebug(ctx).Str("entrypoint", entryPoint.Name).Str("address", entryPoint.Address).Msg("reusing udp traffic listener for same-address entrypoint update") | |||
| 372 | + | updates = append(updates, udpRuntimeUpdate{runtime: runtime, entryPoint: entryPoint, trusted: trusted}) | |||
| 373 | + | next[entryPoint.Name] = runtime | |||
| 374 | + | delete(current, runtime.entryPointSnapshot().Name) | |||
| 375 | + | continue | |||
| 313 | } | 376 | } | ||
| 314 | runtime, err := m.bindUDPEntryPoint(ctx, entryPoint) | 377 | runtime, err := m.bindUDPEntryPoint(ctx, entryPoint) | ||
| 315 | if err != nil { | 378 | if err != nil { | ||
| 316 | - | for _, createdRuntime := range created { | 379 | + | stopUDPRuntimes(ctx, created, effectiveUDPOptions(graph.Options.UDP).DrainTimeout) |
| 317 | - | createdRuntime.stop(ctx, effectiveUDPOptions(graph.Options.UDP).DrainTimeout) | 380 | + | return nil, nil, nil, err |
| 318 | - | } | |||
| 319 | - | return nil, nil, err | |||
| 320 | } | 381 | } | ||
| 321 | next[entryPoint.Name] = runtime | 382 | next[entryPoint.Name] = runtime | ||
| 322 | created = append(created, runtime) | 383 | created = append(created, runtime) | ||
| 323 | } | 384 | } | ||
| 324 | - | return next, created, nil | 385 | + | return next, created, updates, nil |
| 325 | } | 386 | } | ||
| 326 | 387 | ||||
| 327 | func conflictingUDPRuntime(current map[string]*udpEntryPointRuntime, entryPoint domain.EntryPoint) *udpEntryPointRuntime { | 388 | func conflictingUDPRuntime(current map[string]*udpEntryPointRuntime, entryPoint domain.EntryPoint) *udpEntryPointRuntime { | ||
| 328 | for _, runtime := range current { | 389 | for _, runtime := range current { | ||
| 329 | - | if runtime.entryPoint.Address == entryPoint.Address { | 390 | + | if runtime.sameAddress(entryPoint) { |
| 330 | return runtime | 391 | return runtime | ||
| 331 | } | 392 | } | ||
| 332 | } | 393 | } | ||
| @@ -438,26 +499,12 @@ func entryPointStatuses(entries []domain.EntryPoint, listeners map[string]*entry | |||||
| 438 | func routerStatuses(routers []domain.TrafficRouter, listeners map[string]*entryPointRuntime, udpListeners map[string]*udpEntryPointRuntime) []domain.TrafficRouterStatus { | 499 | func routerStatuses(routers []domain.TrafficRouter, listeners map[string]*entryPointRuntime, udpListeners map[string]*udpEntryPointRuntime) []domain.TrafficRouterStatus { | ||
| 439 | statuses := make([]domain.TrafficRouterStatus, 0, len(routers)) | 500 | statuses := make([]domain.TrafficRouterStatus, 0, len(routers)) | ||
| 440 | for _, router := range routers { | 501 | for _, router := range routers { | ||
| 441 | - | status := domain.TrafficRouterStatus{Name: router.Name, EntryPoint: router.EntryPoint, Protocol: router.Protocol, Rule: router.Rule, Service: router.Service, Active: true} | 502 | + | _, tcpActive := listeners[router.EntryPoint] |
| 442 | - | if runtime := listeners[router.EntryPoint]; runtime != nil { | 503 | + | _, udpActive := udpListeners[router.EntryPoint] |
| 443 | - | counters := runtime.counters.snapshot() | 504 | + | statuses = append(statuses, domain.TrafficRouterStatus{ |
| 444 | - | status.ActiveTCPConnections = counters.ActiveTCPConnections | 505 | + | Name: router.Name, EntryPoint: router.EntryPoint, Protocol: router.Protocol, |
| 445 | - | status.TotalAccepted = counters.TotalAccepted | 506 | + | Rule: router.Rule, Service: router.Service, Active: tcpActive || udpActive, |
| 446 | - | status.TotalRefused = counters.TotalRefused | 507 | + | }) |
| 447 | - | status.TotalErrors = counters.TotalErrors | |||
| 448 | - | status.BytesIn = counters.BytesIn | |||
| 449 | - | status.BytesOut = counters.BytesOut | |||
| 450 | - | } | |||
| 451 | - | if runtime := udpListeners[router.EntryPoint]; runtime != nil { | |||
| 452 | - | counters := runtime.counters.snapshot() | |||
| 453 | - | status.ActiveUDPSessions = counters.ActiveUDPSessions | |||
| 454 | - | status.TotalAccepted = counters.TotalAccepted | |||
| 455 | - | status.TotalRefused = counters.TotalRefused | |||
| 456 | - | status.TotalErrors = counters.TotalErrors | |||
| 457 | - | status.BytesIn = counters.BytesIn | |||
| 458 | - | status.BytesOut = counters.BytesOut | |||
| 459 | - | } | |||
| 460 | - | statuses = append(statuses, status) | |||
| 461 | } | 508 | } | ||
| 462 | sort.Slice(statuses, func(i, j int) bool { return statuses[i].Name < statuses[j].Name }) | 509 | sort.Slice(statuses, func(i, j int) bool { return statuses[i].Name < statuses[j].Name }) | ||
| 463 | return statuses | 510 | return statuses | ||
| @@ -493,6 +540,9 @@ func aggregateCounters(entries []domain.EntryPointStatus) domain.TrafficCounters | |||||
| 493 | func cloneTrafficGraph(graph domain.TrafficGraph) domain.TrafficGraph { | 540 | func cloneTrafficGraph(graph domain.TrafficGraph) domain.TrafficGraph { | ||
| 494 | clone := graph | 541 | clone := graph | ||
| 495 | clone.EntryPoints = append([]domain.EntryPoint{}, graph.EntryPoints...) | 542 | clone.EntryPoints = append([]domain.EntryPoint{}, graph.EntryPoints...) | ||
| 543 | + | for i := range clone.EntryPoints { | |||
| 544 | + | clone.EntryPoints[i].TrustedCIDRs = append([]string(nil), graph.EntryPoints[i].TrustedCIDRs...) | |||
| 545 | + | } | |||
| 496 | clone.Routers = append([]domain.TrafficRouter{}, graph.Routers...) | 546 | clone.Routers = append([]domain.TrafficRouter{}, graph.Routers...) | ||
| 497 | clone.Services = append([]domain.TrafficService{}, graph.Services...) | 547 | clone.Services = append([]domain.TrafficService{}, graph.Services...) | ||
| 498 | for i := range clone.Services { | 548 | for i := range clone.Services { | ||
M internal/adapters/in/traffic/tcp.go +76 -25
| @@ -58,7 +58,8 @@ func (r *entryPointRuntime) start() { | |||
| 58 | 58 | if !r.started.CompareAndSwap(false, true) { | |
| 59 | 59 | return | |
| 60 | 60 | } | |
| 61 | - | trafficInfo(r.ctx).Str("entrypoint", r.entryPoint.Name).Str("address", r.entryPoint.Address).Str("protocol", string(r.entryPoint.Protocol)).Msg("started tcp traffic entrypoint") | |
| 61 | + | entryPoint := r.entryPointSnapshot() | |
| 62 | + | trafficInfo(r.ctx).Str("entrypoint", entryPoint.Name).Str("address", entryPoint.Address).Str("protocol", string(entryPoint.Protocol)).Msg("started tcp traffic entrypoint") | |
| 62 | 63 | go r.acceptLoop() | |
| 63 | 64 | } | |
| 64 | 65 | ||
| @@ -71,13 +72,19 @@ func (r *entryPointRuntime) acceptLoop() { | |||
| 71 | 72 | return | |
| 72 | 73 | } | |
| 73 | 74 | r.counters.totalErrors.Add(1) | |
| 74 | - | if temporary, ok := err.(interface{ Temporary() bool }); ok && temporary.Temporary() { | |
| 75 | + | var netErr net.Error | |
| 76 | + | if errors.As(err, &netErr) && netErr.Timeout() { | |
| 75 | 77 | continue | |
| 76 | 78 | } | |
| 77 | - | trafficWarn(r.ctx).Err(err).Str("entrypoint", r.entryPoint.Name).Msg("tcp traffic accept loop stopped") | |
| 78 | - | return | |
| 79 | + | trafficWarn(r.ctx).Err(err).Str("entrypoint", r.entryPointSnapshot().Name).Msg("tcp traffic accept failed; retrying") | |
| 80 | + | select { | |
| 81 | + | case <-time.After(100 * time.Millisecond): | |
| 82 | + | case <-r.ctx.Done(): | |
| 83 | + | return | |
| 84 | + | } | |
| 85 | + | continue | |
| 79 | 86 | } | |
| 80 | - | if !trustedRemoteAddr(r.trusted, conn.RemoteAddr()) { | |
| 87 | + | if !trustedRemoteAddr(r.trustedSnapshot(), conn.RemoteAddr()) { | |
| 81 | 88 | r.counters.totalRefused.Add(1) | |
| 82 | 89 | _ = conn.Close() | |
| 83 | 90 | continue | |
| @@ -102,7 +109,7 @@ func (r *entryPointRuntime) handleTCPConn(client net.Conn) { | |||
| 102 | 109 | r.track(tracked) | |
| 103 | 110 | defer r.untrack(tracked) | |
| 104 | 111 | ||
| 105 | - | switch r.entryPoint.Protocol { | |
| 112 | + | switch r.entryPointSnapshot().Protocol { | |
| 106 | 113 | case domain.EntryPointProtocolTCP: | |
| 107 | 114 | r.handlePlainTCP(tracked, options) | |
| 108 | 115 | case domain.EntryPointProtocolTLSMux: | |
| @@ -134,7 +141,7 @@ func (r *entryPointRuntime) handleTLSMux(tracked *trackedTCPConn, options domain | |||
| 134 | 141 | r.proxyToBackend(tracked, peeked.conn, backend, options) | |
| 135 | 142 | return | |
| 136 | 143 | } | |
| 137 | - | if fallback := r.manager.tlsFallback(r.entryPoint.Name); fallback != nil { | |
| 144 | + | if fallback := r.manager.tlsFallback(r.entryPointSnapshot().Name); fallback != nil { | |
| 138 | 145 | r.counters.totalAccepted.Add(1) | |
| 139 | 146 | fallback(r.ctx, peeked.conn) | |
| 140 | 147 | return | |
| @@ -145,14 +152,14 @@ func (r *entryPointRuntime) handleTLSMux(tracked *trackedTCPConn, options domain | |||
| 145 | 152 | ||
| 146 | 153 | func (r *entryPointRuntime) peekTLSClientHello(client net.Conn, options domain.TCPOptions) (peekedTLSConn, error) { | |
| 147 | 154 | if err := client.SetReadDeadline(time.Now().Add(clientHelloTimeout(options))); err != nil { | |
| 148 | - | return peekedTLSConn{}, err | |
| 155 | + | return peekedTLSConn{}, fmt.Errorf("set client hello read deadline for %s: %w", client.RemoteAddr(), err) | |
| 149 | 156 | } | |
| 150 | 157 | sni, replayed, err := peekClientHelloSNI(client) | |
| 151 | 158 | if clearErr := client.SetReadDeadline(time.Time{}); err == nil && clearErr != nil { | |
| 152 | - | return peekedTLSConn{}, clearErr | |
| 159 | + | return peekedTLSConn{}, fmt.Errorf("clear client hello read deadline for %s: %w", client.RemoteAddr(), clearErr) | |
| 153 | 160 | } | |
| 154 | 161 | if err != nil { | |
| 155 | - | return peekedTLSConn{}, err | |
| 162 | + | return peekedTLSConn{}, fmt.Errorf("peek client hello from %s: %w", client.RemoteAddr(), err) | |
| 156 | 163 | } | |
| 157 | 164 | return peekedTLSConn{sni: sni, conn: replayed}, nil | |
| 158 | 165 | } | |
| @@ -180,7 +187,7 @@ func (r *entryPointRuntime) proxyToBackend(tracked *trackedTCPConn, client net.C | |||
| 180 | 187 | return | |
| 181 | 188 | } | |
| 182 | 189 | ||
| 183 | - | tracked.backend = backendConn | |
| 190 | + | r.setBackend(tracked, backendConn) | |
| 184 | 191 | r.counters.totalAccepted.Add(1) | |
| 185 | 192 | r.proxyTCP(client, backendConn, options.IdleTimeout) | |
| 186 | 193 | } | |
| @@ -190,9 +197,10 @@ func (r *entryPointRuntime) resolveTCPBackend() (domain.TrafficRouter, domain.Tr | |||
| 190 | 197 | if graph == nil { | |
| 191 | 198 | return domain.TrafficRouter{}, domain.TrafficBackend{}, false | |
| 192 | 199 | } | |
| 200 | + | entryPoint := r.entryPointSnapshot() | |
| 193 | 201 | var router domain.TrafficRouter | |
| 194 | 202 | for _, candidate := range graph.Routers { | |
| 195 | - | if candidate.EntryPoint == r.entryPoint.Name && candidate.Protocol == domain.RouterProtocolTCP { | |
| 203 | + | if candidate.EntryPoint == entryPoint.Name && candidate.Protocol == domain.RouterProtocolTCP { | |
| 196 | 204 | if router.Name != "" { | |
| 197 | 205 | return domain.TrafficRouter{}, domain.TrafficBackend{}, false | |
| 198 | 206 | } | |
| @@ -217,6 +225,7 @@ func (r *entryPointRuntime) resolveTCPBackend() (domain.TrafficRouter, domain.Tr | |||
| 217 | 225 | ||
| 218 | 226 | func (r *entryPointRuntime) resolveTLSBackend(sni string) (domain.TrafficBackend, bool) { | |
| 219 | 227 | graph := r.manager.snapshot.Load() | |
| 228 | + | sni = normalizeTLSName(sni) | |
| 220 | 229 | if graph == nil || sni == "" { | |
| 221 | 230 | return domain.TrafficBackend{}, false | |
| 222 | 231 | } | |
| @@ -230,8 +239,9 @@ func (r *entryPointRuntime) resolveTLSBackend(sni string) (domain.TrafficBackend | |||
| 230 | 239 | } | |
| 231 | 240 | ||
| 232 | 241 | func (r *entryPointRuntime) findExactTLSRouter(graph *domain.TrafficGraph, sni string) (domain.TrafficRouter, bool) { | |
| 242 | + | entryPoint := r.entryPointSnapshot() | |
| 233 | 243 | for _, router := range graph.Routers { | |
| 234 | - | if router.EntryPoint == r.entryPoint.Name && router.Protocol == domain.RouterProtocolTLSPassthrough && normalizeTLSName(router.Rule.SNI) == sni { | |
| 244 | + | if router.EntryPoint == entryPoint.Name && router.Protocol == domain.RouterProtocolTLSPassthrough && normalizeTLSName(router.Rule.SNI) == sni { | |
| 235 | 245 | return router, true | |
| 236 | 246 | } | |
| 237 | 247 | } | |
| @@ -239,9 +249,10 @@ func (r *entryPointRuntime) findExactTLSRouter(graph *domain.TrafficGraph, sni s | |||
| 239 | 249 | } | |
| 240 | 250 | ||
| 241 | 251 | func (r *entryPointRuntime) findWildcardTLSRouter(graph *domain.TrafficGraph, sni string) (domain.TrafficRouter, bool) { | |
| 252 | + | entryPoint := r.entryPointSnapshot() | |
| 242 | 253 | for _, router := range graph.Routers { | |
| 243 | 254 | wildcard := normalizeTLSName(router.Rule.SNI) | |
| 244 | - | if router.EntryPoint == r.entryPoint.Name && router.Protocol == domain.RouterProtocolTLSPassthrough && hostMatchesTLSWildcard(sni, wildcard) { | |
| 255 | + | if router.EntryPoint == entryPoint.Name && router.Protocol == domain.RouterProtocolTLSPassthrough && hostMatchesTLSWildcard(sni, wildcard) { | |
| 245 | 256 | return router, true | |
| 246 | 257 | } | |
| 247 | 258 | } | |
| @@ -262,7 +273,8 @@ func hostMatchesTLSWildcard(host string, wildcard string) bool { | |||
| 262 | 273 | if !ok || host == suffix { | |
| 263 | 274 | return false | |
| 264 | 275 | } | |
| 265 | - | return strings.HasSuffix(host, "."+suffix) | |
| 276 | + | prefix, ok := strings.CutSuffix(host, "."+suffix) | |
| 277 | + | return ok && prefix != "" && !strings.Contains(prefix, ".") | |
| 266 | 278 | } | |
| 267 | 279 | ||
| 268 | 280 | func (r *entryPointRuntime) proxyTCP(client net.Conn, backend net.Conn, idleTimeout time.Duration) { | |
| @@ -296,7 +308,7 @@ func copyWithIdleTimeout(dst net.Conn, src net.Conn, idleTimeout time.Duration) | |||
| 296 | 308 | if idleTimeout <= 0 { | |
| 297 | 309 | return io.Copy(dst, src) | |
| 298 | 310 | } | |
| 299 | - | return io.Copy(dst, idleConn{Conn: src, idleTimeout: idleTimeout}) | |
| 311 | + | return io.Copy(deadlineWriter{Conn: dst, idleTimeout: idleTimeout}, idleConn{Conn: src, idleTimeout: idleTimeout}) | |
| 300 | 312 | } | |
| 301 | 313 | ||
| 302 | 314 | type idleConn struct { | |
| @@ -311,6 +323,18 @@ func (c idleConn) Read(p []byte) (int, error) { | |||
| 311 | 323 | return c.Conn.Read(p) | |
| 312 | 324 | } | |
| 313 | 325 | ||
| 326 | + | type deadlineWriter struct { | |
| 327 | + | net.Conn | |
| 328 | + | idleTimeout time.Duration | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | func (c deadlineWriter) Write(p []byte) (int, error) { | |
| 332 | + | if err := c.SetWriteDeadline(time.Now().Add(c.idleTimeout)); err != nil { | |
| 333 | + | return 0, err | |
| 334 | + | } | |
| 335 | + | return c.Conn.Write(p) | |
| 336 | + | } | |
| 337 | + | ||
| 314 | 338 | func closeWrite(conn net.Conn) { | |
| 315 | 339 | if writeCloser, ok := conn.(interface{ CloseWrite() error }); ok { | |
| 316 | 340 | _ = writeCloser.CloseWrite() | |
| @@ -347,6 +371,12 @@ func (r *entryPointRuntime) track(conn *trackedTCPConn) { | |||
| 347 | 371 | r.mu.Unlock() | |
| 348 | 372 | } | |
| 349 | 373 | ||
| 374 | + | func (r *entryPointRuntime) setBackend(conn *trackedTCPConn, backend net.Conn) { | |
| 375 | + | r.mu.Lock() | |
| 376 | + | conn.backend = backend | |
| 377 | + | r.mu.Unlock() | |
| 378 | + | } | |
| 379 | + | ||
| 350 | 380 | func (r *entryPointRuntime) untrack(conn *trackedTCPConn) { | |
| 351 | 381 | r.mu.Lock() | |
| 352 | 382 | delete(r.activeConns, conn) | |
| @@ -355,7 +385,8 @@ func (r *entryPointRuntime) untrack(conn *trackedTCPConn) { | |||
| 355 | 385 | ||
| 356 | 386 | func (r *entryPointRuntime) stop(ctx context.Context, drainTimeout time.Duration) { | |
| 357 | 387 | if r.closed.CompareAndSwap(false, true) { | |
| 358 | - | trafficInfo(ctx).Str("entrypoint", r.entryPoint.Name).Str("address", r.entryPoint.Address).Msg("stopping tcp traffic entrypoint") | |
| 388 | + | entryPoint := r.entryPointSnapshot() | |
| 389 | + | trafficInfo(ctx).Str("entrypoint", entryPoint.Name).Str("address", entryPoint.Address).Msg("stopping tcp traffic entrypoint") | |
| 359 | 390 | r.cancel() | |
| 360 | 391 | _ = r.listener.Close() | |
| 361 | 392 | if !r.started.Load() { | |
| @@ -368,14 +399,14 @@ func (r *entryPointRuntime) stop(ctx context.Context, drainTimeout time.Duration | |||
| 368 | 399 | return | |
| 369 | 400 | } | |
| 370 | 401 | if r.waitActive(ctx, drainTimeout) { | |
| 371 | - | trafficInfo(ctx).Str("entrypoint", r.entryPoint.Name).Msg("stopped tcp traffic entrypoint") | |
| 402 | + | trafficInfo(ctx).Str("entrypoint", r.entryPointSnapshot().Name).Msg("stopped tcp traffic entrypoint") | |
| 372 | 403 | return | |
| 373 | 404 | } | |
| 374 | - | trafficDebug(ctx).Str("entrypoint", r.entryPoint.Name).Dur("drain_timeout", drainTimeout).Msg("forcing tcp traffic entrypoint drain") | |
| 405 | + | trafficDebug(ctx).Str("entrypoint", r.entryPointSnapshot().Name).Dur("drain_timeout", drainTimeout).Msg("forcing tcp traffic entrypoint drain") | |
| 375 | 406 | r.closeActiveConns() | |
| 376 | 407 | select { | |
| 377 | 408 | case <-r.activeDone(): | |
| 378 | - | trafficInfo(ctx).Str("entrypoint", r.entryPoint.Name).Msg("stopped tcp traffic entrypoint") | |
| 409 | + | trafficInfo(ctx).Str("entrypoint", r.entryPointSnapshot().Name).Msg("stopped tcp traffic entrypoint") | |
| 379 | 410 | case <-ctx.Done(): | |
| 380 | 411 | } | |
| 381 | 412 | } | |
| @@ -419,11 +450,31 @@ func (r *entryPointRuntime) closeActiveConns() { | |||
| 419 | 450 | } | |
| 420 | 451 | ||
| 421 | 452 | func (r *entryPointRuntime) matches(entryPoint domain.EntryPoint) bool { | |
| 422 | - | return r.entryPoint.Name == entryPoint.Name && r.entryPoint.Address == entryPoint.Address && r.entryPoint.Protocol == entryPoint.Protocol && trustedCIDRsEqual(r.entryPoint.TrustedCIDRs, entryPoint.TrustedCIDRs) | |
| 453 | + | current := r.entryPointSnapshot() | |
| 454 | + | return current.Name == entryPoint.Name && current.Address == entryPoint.Address && current.Protocol == entryPoint.Protocol && trustedCIDRsEqual(current.TrustedCIDRs, entryPoint.TrustedCIDRs) | |
| 455 | + | } | |
| 456 | + | ||
| 457 | + | func (r *entryPointRuntime) sameAddress(entryPoint domain.EntryPoint) bool { | |
| 458 | + | return r.entryPointSnapshot().Address == entryPoint.Address | |
| 459 | + | } | |
| 460 | + | ||
| 461 | + | func (r *entryPointRuntime) updateEntryPoint(entryPoint domain.EntryPoint, trusted []*net.IPNet) { | |
| 462 | + | r.mu.Lock() | |
| 463 | + | r.entryPoint = entryPoint | |
| 464 | + | r.trusted = trusted | |
| 465 | + | r.mu.Unlock() | |
| 423 | 466 | } | |
| 424 | 467 | ||
| 425 | - | func (r *entryPointRuntime) shouldStopWith(next *entryPointRuntime) bool { | |
| 426 | - | return next == nil || next != r | |
| 468 | + | func (r *entryPointRuntime) entryPointSnapshot() domain.EntryPoint { | |
| 469 | + | r.mu.Lock() | |
| 470 | + | defer r.mu.Unlock() | |
| 471 | + | return r.entryPoint | |
| 472 | + | } | |
| 473 | + | ||
| 474 | + | func (r *entryPointRuntime) trustedSnapshot() []*net.IPNet { | |
| 475 | + | r.mu.Lock() | |
| 476 | + | defer r.mu.Unlock() | |
| 477 | + | return r.trusted | |
| 427 | 478 | } | |
| 428 | 479 | ||
| 429 | 480 | func (r *entryPointRuntime) isClosed() bool { | |
| @@ -433,11 +484,11 @@ func (r *entryPointRuntime) isClosed() bool { | |||
| 433 | 484 | func backendFromAddress(name string, address string) (domain.TrafficBackend, error) { | |
| 434 | 485 | host, portValue, err := net.SplitHostPort(address) | |
| 435 | 486 | if err != nil { | |
| 436 | - | return domain.TrafficBackend{}, err | |
| 487 | + | return domain.TrafficBackend{}, fmt.Errorf("split backend address %q: %w", address, err) | |
| 437 | 488 | } | |
| 438 | 489 | port, err := strconv.Atoi(portValue) | |
| 439 | 490 | if err != nil { | |
| 440 | - | return domain.TrafficBackend{}, err | |
| 491 | + | return domain.TrafficBackend{}, fmt.Errorf("parse backend port %q from address %q: %w", portValue, address, err) | |
| 441 | 492 | } | |
| 442 | 493 | return domain.TrafficBackend{Name: name, Host: host, Port: port, Protocol: domain.NetworkProtocolTCP}, nil | |
| 443 | 494 | } | |
| @@ -58,7 +58,8 @@ func (r *entryPointRuntime) start() { | |||||
| 58 | if !r.started.CompareAndSwap(false, true) { | 58 | if !r.started.CompareAndSwap(false, true) { | ||
| 59 | return | 59 | return | ||
| 60 | } | 60 | } | ||
| 61 | - | trafficInfo(r.ctx).Str("entrypoint", r.entryPoint.Name).Str("address", r.entryPoint.Address).Str("protocol", string(r.entryPoint.Protocol)).Msg("started tcp traffic entrypoint") | 61 | + | entryPoint := r.entryPointSnapshot() |
| 62 | + | trafficInfo(r.ctx).Str("entrypoint", entryPoint.Name).Str("address", entryPoint.Address).Str("protocol", string(entryPoint.Protocol)).Msg("started tcp traffic entrypoint") | |||
| 62 | go r.acceptLoop() | 63 | go r.acceptLoop() | ||
| 63 | } | 64 | } | ||
| 64 | 65 | ||||
| @@ -71,13 +72,19 @@ func (r *entryPointRuntime) acceptLoop() { | |||||
| 71 | return | 72 | return | ||
| 72 | } | 73 | } | ||
| 73 | r.counters.totalErrors.Add(1) | 74 | r.counters.totalErrors.Add(1) | ||
| 74 | - | if temporary, ok := err.(interface{ Temporary() bool }); ok && temporary.Temporary() { | 75 | + | var netErr net.Error |
| 76 | + | if errors.As(err, &netErr) && netErr.Timeout() { | |||
| 75 | continue | 77 | continue | ||
| 76 | } | 78 | } | ||
| 77 | - | trafficWarn(r.ctx).Err(err).Str("entrypoint", r.entryPoint.Name).Msg("tcp traffic accept loop stopped") | 79 | + | trafficWarn(r.ctx).Err(err).Str("entrypoint", r.entryPointSnapshot().Name).Msg("tcp traffic accept failed; retrying") |
| 78 | - | return | 80 | + | select { |
| 81 | + | case <-time.After(100 * time.Millisecond): | |||
| 82 | + | case <-r.ctx.Done(): | |||
| 83 | + | return | |||
| 84 | + | } | |||
| 85 | + | continue | |||
| 79 | } | 86 | } | ||
| 80 | - | if !trustedRemoteAddr(r.trusted, conn.RemoteAddr()) { | 87 | + | if !trustedRemoteAddr(r.trustedSnapshot(), conn.RemoteAddr()) { |
| 81 | r.counters.totalRefused.Add(1) | 88 | r.counters.totalRefused.Add(1) | ||
| 82 | _ = conn.Close() | 89 | _ = conn.Close() | ||
| 83 | continue | 90 | continue | ||
| @@ -102,7 +109,7 @@ func (r *entryPointRuntime) handleTCPConn(client net.Conn) { | |||||
| 102 | r.track(tracked) | 109 | r.track(tracked) | ||
| 103 | defer r.untrack(tracked) | 110 | defer r.untrack(tracked) | ||
| 104 | 111 | ||||
| 105 | - | switch r.entryPoint.Protocol { | 112 | + | switch r.entryPointSnapshot().Protocol { |
| 106 | case domain.EntryPointProtocolTCP: | 113 | case domain.EntryPointProtocolTCP: | ||
| 107 | r.handlePlainTCP(tracked, options) | 114 | r.handlePlainTCP(tracked, options) | ||
| 108 | case domain.EntryPointProtocolTLSMux: | 115 | case domain.EntryPointProtocolTLSMux: | ||
| @@ -134,7 +141,7 @@ func (r *entryPointRuntime) handleTLSMux(tracked *trackedTCPConn, options domain | |||||
| 134 | r.proxyToBackend(tracked, peeked.conn, backend, options) | 141 | r.proxyToBackend(tracked, peeked.conn, backend, options) | ||
| 135 | return | 142 | return | ||
| 136 | } | 143 | } | ||
| 137 | - | if fallback := r.manager.tlsFallback(r.entryPoint.Name); fallback != nil { | 144 | + | if fallback := r.manager.tlsFallback(r.entryPointSnapshot().Name); fallback != nil { |
| 138 | r.counters.totalAccepted.Add(1) | 145 | r.counters.totalAccepted.Add(1) | ||
| 139 | fallback(r.ctx, peeked.conn) | 146 | fallback(r.ctx, peeked.conn) | ||
| 140 | return | 147 | return | ||
| @@ -145,14 +152,14 @@ func (r *entryPointRuntime) handleTLSMux(tracked *trackedTCPConn, options domain | |||||
| 145 | 152 | ||||
| 146 | func (r *entryPointRuntime) peekTLSClientHello(client net.Conn, options domain.TCPOptions) (peekedTLSConn, error) { | 153 | func (r *entryPointRuntime) peekTLSClientHello(client net.Conn, options domain.TCPOptions) (peekedTLSConn, error) { | ||
| 147 | if err := client.SetReadDeadline(time.Now().Add(clientHelloTimeout(options))); err != nil { | 154 | if err := client.SetReadDeadline(time.Now().Add(clientHelloTimeout(options))); err != nil { | ||
| 148 | - | return peekedTLSConn{}, err | 155 | + | return peekedTLSConn{}, fmt.Errorf("set client hello read deadline for %s: %w", client.RemoteAddr(), err) |
| 149 | } | 156 | } | ||
| 150 | sni, replayed, err := peekClientHelloSNI(client) | 157 | sni, replayed, err := peekClientHelloSNI(client) | ||
| 151 | if clearErr := client.SetReadDeadline(time.Time{}); err == nil && clearErr != nil { | 158 | if clearErr := client.SetReadDeadline(time.Time{}); err == nil && clearErr != nil { | ||
| 152 | - | return peekedTLSConn{}, clearErr | 159 | + | return peekedTLSConn{}, fmt.Errorf("clear client hello read deadline for %s: %w", client.RemoteAddr(), clearErr) |
| 153 | } | 160 | } | ||
| 154 | if err != nil { | 161 | if err != nil { | ||
| 155 | - | return peekedTLSConn{}, err | 162 | + | return peekedTLSConn{}, fmt.Errorf("peek client hello from %s: %w", client.RemoteAddr(), err) |
| 156 | } | 163 | } | ||
| 157 | return peekedTLSConn{sni: sni, conn: replayed}, nil | 164 | return peekedTLSConn{sni: sni, conn: replayed}, nil | ||
| 158 | } | 165 | } | ||
| @@ -180,7 +187,7 @@ func (r *entryPointRuntime) proxyToBackend(tracked *trackedTCPConn, client net.C | |||||
| 180 | return | 187 | return | ||
| 181 | } | 188 | } | ||
| 182 | 189 | ||||
| 183 | - | tracked.backend = backendConn | 190 | + | r.setBackend(tracked, backendConn) |
| 184 | r.counters.totalAccepted.Add(1) | 191 | r.counters.totalAccepted.Add(1) | ||
| 185 | r.proxyTCP(client, backendConn, options.IdleTimeout) | 192 | r.proxyTCP(client, backendConn, options.IdleTimeout) | ||
| 186 | } | 193 | } | ||
| @@ -190,9 +197,10 @@ func (r *entryPointRuntime) resolveTCPBackend() (domain.TrafficRouter, domain.Tr | |||||
| 190 | if graph == nil { | 197 | if graph == nil { | ||
| 191 | return domain.TrafficRouter{}, domain.TrafficBackend{}, false | 198 | return domain.TrafficRouter{}, domain.TrafficBackend{}, false | ||
| 192 | } | 199 | } | ||
| 200 | + | entryPoint := r.entryPointSnapshot() | |||
| 193 | var router domain.TrafficRouter | 201 | var router domain.TrafficRouter | ||
| 194 | for _, candidate := range graph.Routers { | 202 | for _, candidate := range graph.Routers { | ||
| 195 | - | if candidate.EntryPoint == r.entryPoint.Name && candidate.Protocol == domain.RouterProtocolTCP { | 203 | + | if candidate.EntryPoint == entryPoint.Name && candidate.Protocol == domain.RouterProtocolTCP { |
| 196 | if router.Name != "" { | 204 | if router.Name != "" { | ||
| 197 | return domain.TrafficRouter{}, domain.TrafficBackend{}, false | 205 | return domain.TrafficRouter{}, domain.TrafficBackend{}, false | ||
| 198 | } | 206 | } | ||
| @@ -217,6 +225,7 @@ func (r *entryPointRuntime) resolveTCPBackend() (domain.TrafficRouter, domain.Tr | |||||
| 217 | 225 | ||||
| 218 | func (r *entryPointRuntime) resolveTLSBackend(sni string) (domain.TrafficBackend, bool) { | 226 | func (r *entryPointRuntime) resolveTLSBackend(sni string) (domain.TrafficBackend, bool) { | ||
| 219 | graph := r.manager.snapshot.Load() | 227 | graph := r.manager.snapshot.Load() | ||
| 228 | + | sni = normalizeTLSName(sni) | |||
| 220 | if graph == nil || sni == "" { | 229 | if graph == nil || sni == "" { | ||
| 221 | return domain.TrafficBackend{}, false | 230 | return domain.TrafficBackend{}, false | ||
| 222 | } | 231 | } | ||
| @@ -230,8 +239,9 @@ func (r *entryPointRuntime) resolveTLSBackend(sni string) (domain.TrafficBackend | |||||
| 230 | } | 239 | } | ||
| 231 | 240 | ||||
| 232 | func (r *entryPointRuntime) findExactTLSRouter(graph *domain.TrafficGraph, sni string) (domain.TrafficRouter, bool) { | 241 | func (r *entryPointRuntime) findExactTLSRouter(graph *domain.TrafficGraph, sni string) (domain.TrafficRouter, bool) { | ||
| 242 | + | entryPoint := r.entryPointSnapshot() | |||
| 233 | for _, router := range graph.Routers { | 243 | for _, router := range graph.Routers { | ||
| 234 | - | if router.EntryPoint == r.entryPoint.Name && router.Protocol == domain.RouterProtocolTLSPassthrough && normalizeTLSName(router.Rule.SNI) == sni { | 244 | + | if router.EntryPoint == entryPoint.Name && router.Protocol == domain.RouterProtocolTLSPassthrough && normalizeTLSName(router.Rule.SNI) == sni { |
| 235 | return router, true | 245 | return router, true | ||
| 236 | } | 246 | } | ||
| 237 | } | 247 | } | ||
| @@ -239,9 +249,10 @@ func (r *entryPointRuntime) findExactTLSRouter(graph *domain.TrafficGraph, sni s | |||||
| 239 | } | 249 | } | ||
| 240 | 250 | ||||
| 241 | func (r *entryPointRuntime) findWildcardTLSRouter(graph *domain.TrafficGraph, sni string) (domain.TrafficRouter, bool) { | 251 | func (r *entryPointRuntime) findWildcardTLSRouter(graph *domain.TrafficGraph, sni string) (domain.TrafficRouter, bool) { | ||
| 252 | + | entryPoint := r.entryPointSnapshot() | |||
| 242 | for _, router := range graph.Routers { | 253 | for _, router := range graph.Routers { | ||
| 243 | wildcard := normalizeTLSName(router.Rule.SNI) | 254 | wildcard := normalizeTLSName(router.Rule.SNI) | ||
| 244 | - | if router.EntryPoint == r.entryPoint.Name && router.Protocol == domain.RouterProtocolTLSPassthrough && hostMatchesTLSWildcard(sni, wildcard) { | 255 | + | if router.EntryPoint == entryPoint.Name && router.Protocol == domain.RouterProtocolTLSPassthrough && hostMatchesTLSWildcard(sni, wildcard) { |
| 245 | return router, true | 256 | return router, true | ||
| 246 | } | 257 | } | ||
| 247 | } | 258 | } | ||
| @@ -262,7 +273,8 @@ func hostMatchesTLSWildcard(host string, wildcard string) bool { | |||||
| 262 | if !ok || host == suffix { | 273 | if !ok || host == suffix { | ||
| 263 | return false | 274 | return false | ||
| 264 | } | 275 | } | ||
| 265 | - | return strings.HasSuffix(host, "."+suffix) | 276 | + | prefix, ok := strings.CutSuffix(host, "."+suffix) |
| 277 | + | return ok && prefix != "" && !strings.Contains(prefix, ".") | |||
| 266 | } | 278 | } | ||
| 267 | 279 | ||||
| 268 | func (r *entryPointRuntime) proxyTCP(client net.Conn, backend net.Conn, idleTimeout time.Duration) { | 280 | func (r *entryPointRuntime) proxyTCP(client net.Conn, backend net.Conn, idleTimeout time.Duration) { | ||
| @@ -296,7 +308,7 @@ func copyWithIdleTimeout(dst net.Conn, src net.Conn, idleTimeout time.Duration) | |||||
| 296 | if idleTimeout <= 0 { | 308 | if idleTimeout <= 0 { | ||
| 297 | return io.Copy(dst, src) | 309 | return io.Copy(dst, src) | ||
| 298 | } | 310 | } | ||
| 299 | - | return io.Copy(dst, idleConn{Conn: src, idleTimeout: idleTimeout}) | 311 | + | return io.Copy(deadlineWriter{Conn: dst, idleTimeout: idleTimeout}, idleConn{Conn: src, idleTimeout: idleTimeout}) |
| 300 | } | 312 | } | ||
| 301 | 313 | ||||
| 302 | type idleConn struct { | 314 | type idleConn struct { | ||
| @@ -311,6 +323,18 @@ func (c idleConn) Read(p []byte) (int, error) { | |||||
| 311 | return c.Conn.Read(p) | 323 | return c.Conn.Read(p) | ||
| 312 | } | 324 | } | ||
| 313 | 325 | ||||
| 326 | + | type deadlineWriter struct { | |||
| 327 | + | net.Conn | |||
| 328 | + | idleTimeout time.Duration | |||
| 329 | + | } | |||
| 330 | + | ||||
| 331 | + | func (c deadlineWriter) Write(p []byte) (int, error) { | |||
| 332 | + | if err := c.SetWriteDeadline(time.Now().Add(c.idleTimeout)); err != nil { | |||
| 333 | + | return 0, err | |||
| 334 | + | } | |||
| 335 | + | return c.Conn.Write(p) | |||
| 336 | + | } | |||
| 337 | + | ||||
| 314 | func closeWrite(conn net.Conn) { | 338 | func closeWrite(conn net.Conn) { | ||
| 315 | if writeCloser, ok := conn.(interface{ CloseWrite() error }); ok { | 339 | if writeCloser, ok := conn.(interface{ CloseWrite() error }); ok { | ||
| 316 | _ = writeCloser.CloseWrite() | 340 | _ = writeCloser.CloseWrite() | ||
| @@ -347,6 +371,12 @@ func (r *entryPointRuntime) track(conn *trackedTCPConn) { | |||||
| 347 | r.mu.Unlock() | 371 | r.mu.Unlock() | ||
| 348 | } | 372 | } | ||
| 349 | 373 | ||||
| 374 | + | func (r *entryPointRuntime) setBackend(conn *trackedTCPConn, backend net.Conn) { | |||
| 375 | + | r.mu.Lock() | |||
| 376 | + | conn.backend = backend | |||
| 377 | + | r.mu.Unlock() | |||
| 378 | + | } | |||
| 379 | + | ||||
| 350 | func (r *entryPointRuntime) untrack(conn *trackedTCPConn) { | 380 | func (r *entryPointRuntime) untrack(conn *trackedTCPConn) { | ||
| 351 | r.mu.Lock() | 381 | r.mu.Lock() | ||
| 352 | delete(r.activeConns, conn) | 382 | delete(r.activeConns, conn) | ||
| @@ -355,7 +385,8 @@ func (r *entryPointRuntime) untrack(conn *trackedTCPConn) { | |||||
| 355 | 385 | ||||
| 356 | func (r *entryPointRuntime) stop(ctx context.Context, drainTimeout time.Duration) { | 386 | func (r *entryPointRuntime) stop(ctx context.Context, drainTimeout time.Duration) { | ||
| 357 | if r.closed.CompareAndSwap(false, true) { | 387 | if r.closed.CompareAndSwap(false, true) { | ||
| 358 | - | trafficInfo(ctx).Str("entrypoint", r.entryPoint.Name).Str("address", r.entryPoint.Address).Msg("stopping tcp traffic entrypoint") | 388 | + | entryPoint := r.entryPointSnapshot() |
| 389 | + | trafficInfo(ctx).Str("entrypoint", entryPoint.Name).Str("address", entryPoint.Address).Msg("stopping tcp traffic entrypoint") | |||
| 359 | r.cancel() | 390 | r.cancel() | ||
| 360 | _ = r.listener.Close() | 391 | _ = r.listener.Close() | ||
| 361 | if !r.started.Load() { | 392 | if !r.started.Load() { | ||
| @@ -368,14 +399,14 @@ func (r *entryPointRuntime) stop(ctx context.Context, drainTimeout time.Duration | |||||
| 368 | return | 399 | return | ||
| 369 | } | 400 | } | ||
| 370 | if r.waitActive(ctx, drainTimeout) { | 401 | if r.waitActive(ctx, drainTimeout) { | ||
| 371 | - | trafficInfo(ctx).Str("entrypoint", r.entryPoint.Name).Msg("stopped tcp traffic entrypoint") | 402 | + | trafficInfo(ctx).Str("entrypoint", r.entryPointSnapshot().Name).Msg("stopped tcp traffic entrypoint") |
| 372 | return | 403 | return | ||
| 373 | } | 404 | } | ||
| 374 | - | trafficDebug(ctx).Str("entrypoint", r.entryPoint.Name).Dur("drain_timeout", drainTimeout).Msg("forcing tcp traffic entrypoint drain") | 405 | + | trafficDebug(ctx).Str("entrypoint", r.entryPointSnapshot().Name).Dur("drain_timeout", drainTimeout).Msg("forcing tcp traffic entrypoint drain") |
| 375 | r.closeActiveConns() | 406 | r.closeActiveConns() | ||
| 376 | select { | 407 | select { | ||
| 377 | case <-r.activeDone(): | 408 | case <-r.activeDone(): | ||
| 378 | - | trafficInfo(ctx).Str("entrypoint", r.entryPoint.Name).Msg("stopped tcp traffic entrypoint") | 409 | + | trafficInfo(ctx).Str("entrypoint", r.entryPointSnapshot().Name).Msg("stopped tcp traffic entrypoint") |
| 379 | case <-ctx.Done(): | 410 | case <-ctx.Done(): | ||
| 380 | } | 411 | } | ||
| 381 | } | 412 | } | ||
| @@ -419,11 +450,31 @@ func (r *entryPointRuntime) closeActiveConns() { | |||||
| 419 | } | 450 | } | ||
| 420 | 451 | ||||
| 421 | func (r *entryPointRuntime) matches(entryPoint domain.EntryPoint) bool { | 452 | func (r *entryPointRuntime) matches(entryPoint domain.EntryPoint) bool { | ||
| 422 | - | return r.entryPoint.Name == entryPoint.Name && r.entryPoint.Address == entryPoint.Address && r.entryPoint.Protocol == entryPoint.Protocol && trustedCIDRsEqual(r.entryPoint.TrustedCIDRs, entryPoint.TrustedCIDRs) | 453 | + | current := r.entryPointSnapshot() |
| 454 | + | return current.Name == entryPoint.Name && current.Address == entryPoint.Address && current.Protocol == entryPoint.Protocol && trustedCIDRsEqual(current.TrustedCIDRs, entryPoint.TrustedCIDRs) | |||
| 455 | + | } | |||
| 456 | + | ||||
| 457 | + | func (r *entryPointRuntime) sameAddress(entryPoint domain.EntryPoint) bool { | |||
| 458 | + | return r.entryPointSnapshot().Address == entryPoint.Address | |||
| 459 | + | } | |||
| 460 | + | ||||
| 461 | + | func (r *entryPointRuntime) updateEntryPoint(entryPoint domain.EntryPoint, trusted []*net.IPNet) { | |||
| 462 | + | r.mu.Lock() | |||
| 463 | + | r.entryPoint = entryPoint | |||
| 464 | + | r.trusted = trusted | |||
| 465 | + | r.mu.Unlock() | |||
| 423 | } | 466 | } | ||
| 424 | 467 | ||||
| 425 | - | func (r *entryPointRuntime) shouldStopWith(next *entryPointRuntime) bool { | 468 | + | func (r *entryPointRuntime) entryPointSnapshot() domain.EntryPoint { |
| 426 | - | return next == nil || next != r | 469 | + | r.mu.Lock() |
| 470 | + | defer r.mu.Unlock() | |||
| 471 | + | return r.entryPoint | |||
| 472 | + | } | |||
| 473 | + | ||||
| 474 | + | func (r *entryPointRuntime) trustedSnapshot() []*net.IPNet { | |||
| 475 | + | r.mu.Lock() | |||
| 476 | + | defer r.mu.Unlock() | |||
| 477 | + | return r.trusted | |||
| 427 | } | 478 | } | ||
| 428 | 479 | ||||
| 429 | func (r *entryPointRuntime) isClosed() bool { | 480 | func (r *entryPointRuntime) isClosed() bool { | ||
| @@ -433,11 +484,11 @@ func (r *entryPointRuntime) isClosed() bool { | |||||
| 433 | func backendFromAddress(name string, address string) (domain.TrafficBackend, error) { | 484 | func backendFromAddress(name string, address string) (domain.TrafficBackend, error) { | ||
| 434 | host, portValue, err := net.SplitHostPort(address) | 485 | host, portValue, err := net.SplitHostPort(address) | ||
| 435 | if err != nil { | 486 | if err != nil { | ||
| 436 | - | return domain.TrafficBackend{}, err | 487 | + | return domain.TrafficBackend{}, fmt.Errorf("split backend address %q: %w", address, err) |
| 437 | } | 488 | } | ||
| 438 | port, err := strconv.Atoi(portValue) | 489 | port, err := strconv.Atoi(portValue) | ||
| 439 | if err != nil { | 490 | if err != nil { | ||
| 440 | - | return domain.TrafficBackend{}, err | 491 | + | return domain.TrafficBackend{}, fmt.Errorf("parse backend port %q from address %q: %w", portValue, address, err) |
| 441 | } | 492 | } | ||
| 442 | return domain.TrafficBackend{Name: name, Host: host, Port: port, Protocol: domain.NetworkProtocolTCP}, nil | 493 | return domain.TrafficBackend{Name: name, Host: host, Port: port, Protocol: domain.NetworkProtocolTCP}, nil | ||
| 443 | } | 494 | } | ||
M internal/adapters/in/traffic/tcp_test.go +21 -13
| @@ -3,6 +3,7 @@ package traffic | |||
| 3 | 3 | import ( | |
| 4 | 4 | "bufio" | |
| 5 | 5 | "context" | |
| 6 | + | "errors" | |
| 6 | 7 | "io" | |
| 7 | 8 | "net" | |
| 8 | 9 | "testing" | |
| @@ -113,10 +114,11 @@ func assertTCPRejected(t *testing.T, manager *Manager, address string, refused i | |||
| 113 | 114 | t.Helper() | |
| 114 | 115 | conn := dialTCP(t, address) | |
| 115 | 116 | defer conn.Close() | |
| 116 | - | _, err := conn.Write([]byte("blocked")) | |
| 117 | - | require.NoError(t, err) | |
| 118 | - | _, err = bufio.NewReader(conn).ReadByte() | |
| 119 | - | require.Error(t, err) | |
| 117 | + | _, writeErr := conn.Write([]byte("blocked")) | |
| 118 | + | if writeErr == nil { | |
| 119 | + | _, readErr := bufio.NewReader(conn).ReadByte() | |
| 120 | + | require.Error(t, readErr) | |
| 121 | + | } | |
| 120 | 122 | assert.Eventually(t, func() bool { return manager.Status().Counters.TotalRefused == refused }, time.Second, 10*time.Millisecond) | |
| 121 | 123 | } | |
| 122 | 124 | ||
| @@ -142,10 +144,11 @@ func TestTCPPassthroughUnknownRouterCloses(t *testing.T) { | |||
| 142 | 144 | ||
| 143 | 145 | conn := dialTCP(t, graph.EntryPoints[0].Address) | |
| 144 | 146 | defer conn.Close() | |
| 145 | - | _, err := conn.Write([]byte("hello")) | |
| 146 | - | require.NoError(t, err) | |
| 147 | - | _, err = bufio.NewReader(conn).ReadByte() | |
| 148 | - | require.Error(t, err) | |
| 147 | + | _, writeErr := conn.Write([]byte("hello")) | |
| 148 | + | if writeErr == nil { | |
| 149 | + | _, readErr := bufio.NewReader(conn).ReadByte() | |
| 150 | + | require.Error(t, readErr) | |
| 151 | + | } | |
| 149 | 152 | assert.Eventually(t, func() bool { return manager.Status().Counters.TotalRefused == 1 }, time.Second, 10*time.Millisecond) | |
| 150 | 153 | } | |
| 151 | 154 | ||
| @@ -158,10 +161,11 @@ func TestTCPPassthroughBackendDialFailure(t *testing.T) { | |||
| 158 | 161 | ||
| 159 | 162 | conn := dialTCP(t, graph.EntryPoints[0].Address) | |
| 160 | 163 | defer conn.Close() | |
| 161 | - | _, err := conn.Write([]byte("hello")) | |
| 162 | - | require.NoError(t, err) | |
| 163 | - | _, err = bufio.NewReader(conn).ReadByte() | |
| 164 | - | require.Error(t, err) | |
| 164 | + | _, writeErr := conn.Write([]byte("hello")) | |
| 165 | + | if writeErr == nil { | |
| 166 | + | _, readErr := bufio.NewReader(conn).ReadByte() | |
| 167 | + | require.Error(t, readErr) | |
| 168 | + | } | |
| 165 | 169 | assert.Eventually(t, func() bool { return manager.Status().Counters.TotalErrors == 1 }, time.Second, 10*time.Millisecond) | |
| 166 | 170 | } | |
| 167 | 171 | ||
| @@ -186,9 +190,13 @@ func TestTCPPassthroughIdleTimeoutClosesConnection(t *testing.T) { | |||
| 186 | 190 | ||
| 187 | 191 | conn := dialTCP(t, graph.EntryPoints[0].Address) | |
| 188 | 192 | defer conn.Close() | |
| 189 | - | require.NoError(t, conn.SetReadDeadline(time.Now().Add(time.Second))) | |
| 193 | + | require.NoError(t, conn.SetReadDeadline(time.Now().Add(250*time.Millisecond))) | |
| 194 | + | started := time.Now() | |
| 190 | 195 | _, err := bufio.NewReader(conn).ReadByte() | |
| 191 | 196 | require.Error(t, err) | |
| 197 | + | var netErr net.Error | |
| 198 | + | require.False(t, errors.As(err, &netErr) && netErr.Timeout(), "read hit the client deadline instead of the proxy idle timeout") | |
| 199 | + | assert.Less(t, time.Since(started), 250*time.Millisecond) | |
| 192 | 200 | } | |
| 193 | 201 | ||
| 194 | 202 | func TestTCPPassthroughMaxConnectionsRejectsOverflow(t *testing.T) { | |
| @@ -3,6 +3,7 @@ package traffic | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bufio" | 4 | "bufio" | ||
| 5 | "context" | 5 | "context" | ||
| 6 | + | "errors" | |||
| 6 | "io" | 7 | "io" | ||
| 7 | "net" | 8 | "net" | ||
| 8 | "testing" | 9 | "testing" | ||
| @@ -113,10 +114,11 @@ func assertTCPRejected(t *testing.T, manager *Manager, address string, refused i | |||||
| 113 | t.Helper() | 114 | t.Helper() | ||
| 114 | conn := dialTCP(t, address) | 115 | conn := dialTCP(t, address) | ||
| 115 | defer conn.Close() | 116 | defer conn.Close() | ||
| 116 | - | _, err := conn.Write([]byte("blocked")) | 117 | + | _, writeErr := conn.Write([]byte("blocked")) |
| 117 | - | require.NoError(t, err) | 118 | + | if writeErr == nil { |
| 118 | - | _, err = bufio.NewReader(conn).ReadByte() | 119 | + | _, readErr := bufio.NewReader(conn).ReadByte() |
| 119 | - | require.Error(t, err) | 120 | + | require.Error(t, readErr) |
| 121 | + | } | |||
| 120 | assert.Eventually(t, func() bool { return manager.Status().Counters.TotalRefused == refused }, time.Second, 10*time.Millisecond) | 122 | assert.Eventually(t, func() bool { return manager.Status().Counters.TotalRefused == refused }, time.Second, 10*time.Millisecond) | ||
| 121 | } | 123 | } | ||
| 122 | 124 | ||||
| @@ -142,10 +144,11 @@ func TestTCPPassthroughUnknownRouterCloses(t *testing.T) { | |||||
| 142 | 144 | ||||
| 143 | conn := dialTCP(t, graph.EntryPoints[0].Address) | 145 | conn := dialTCP(t, graph.EntryPoints[0].Address) | ||
| 144 | defer conn.Close() | 146 | defer conn.Close() | ||
| 145 | - | _, err := conn.Write([]byte("hello")) | 147 | + | _, writeErr := conn.Write([]byte("hello")) |
| 146 | - | require.NoError(t, err) | 148 | + | if writeErr == nil { |
| 147 | - | _, err = bufio.NewReader(conn).ReadByte() | 149 | + | _, readErr := bufio.NewReader(conn).ReadByte() |
| 148 | - | require.Error(t, err) | 150 | + | require.Error(t, readErr) |
| 151 | + | } | |||
| 149 | assert.Eventually(t, func() bool { return manager.Status().Counters.TotalRefused == 1 }, time.Second, 10*time.Millisecond) | 152 | assert.Eventually(t, func() bool { return manager.Status().Counters.TotalRefused == 1 }, time.Second, 10*time.Millisecond) | ||
| 150 | } | 153 | } | ||
| 151 | 154 | ||||
| @@ -158,10 +161,11 @@ func TestTCPPassthroughBackendDialFailure(t *testing.T) { | |||||
| 158 | 161 | ||||
| 159 | conn := dialTCP(t, graph.EntryPoints[0].Address) | 162 | conn := dialTCP(t, graph.EntryPoints[0].Address) | ||
| 160 | defer conn.Close() | 163 | defer conn.Close() | ||
| 161 | - | _, err := conn.Write([]byte("hello")) | 164 | + | _, writeErr := conn.Write([]byte("hello")) |
| 162 | - | require.NoError(t, err) | 165 | + | if writeErr == nil { |
| 163 | - | _, err = bufio.NewReader(conn).ReadByte() | 166 | + | _, readErr := bufio.NewReader(conn).ReadByte() |
| 164 | - | require.Error(t, err) | 167 | + | require.Error(t, readErr) |
| 168 | + | } | |||
| 165 | assert.Eventually(t, func() bool { return manager.Status().Counters.TotalErrors == 1 }, time.Second, 10*time.Millisecond) | 169 | assert.Eventually(t, func() bool { return manager.Status().Counters.TotalErrors == 1 }, time.Second, 10*time.Millisecond) | ||
| 166 | } | 170 | } | ||
| 167 | 171 | ||||
| @@ -186,9 +190,13 @@ func TestTCPPassthroughIdleTimeoutClosesConnection(t *testing.T) { | |||||
| 186 | 190 | ||||
| 187 | conn := dialTCP(t, graph.EntryPoints[0].Address) | 191 | conn := dialTCP(t, graph.EntryPoints[0].Address) | ||
| 188 | defer conn.Close() | 192 | defer conn.Close() | ||
| 189 | - | require.NoError(t, conn.SetReadDeadline(time.Now().Add(time.Second))) | 193 | + | require.NoError(t, conn.SetReadDeadline(time.Now().Add(250*time.Millisecond))) |
| 194 | + | started := time.Now() | |||
| 190 | _, err := bufio.NewReader(conn).ReadByte() | 195 | _, err := bufio.NewReader(conn).ReadByte() | ||
| 191 | require.Error(t, err) | 196 | require.Error(t, err) | ||
| 197 | + | var netErr net.Error | |||
| 198 | + | require.False(t, errors.As(err, &netErr) && netErr.Timeout(), "read hit the client deadline instead of the proxy idle timeout") | |||
| 199 | + | assert.Less(t, time.Since(started), 250*time.Millisecond) | |||
| 192 | } | 200 | } | ||
| 193 | 201 | ||||
| 194 | func TestTCPPassthroughMaxConnectionsRejectsOverflow(t *testing.T) { | 202 | func TestTCPPassthroughMaxConnectionsRejectsOverflow(t *testing.T) { | ||
R internal/adapters/in/traffic/tlsmux.go → internal/adapters/in/traffic/tls_mux.go +24 -10 renamed, 88% similar
| @@ -59,14 +59,14 @@ func peekClientHelloSNI(conn net.Conn) (string, net.Conn, error) { | |||
| 59 | 59 | buf = append(buf, tmp[:n]...) | |
| 60 | 60 | sni, complete, parseErr := parseClientHelloSNI(buf) | |
| 61 | 61 | if parseErr != nil { | |
| 62 | - | return "", nil, parseErr | |
| 62 | + | return "", nil, fmt.Errorf("parse client hello sni: %w", parseErr) | |
| 63 | 63 | } | |
| 64 | 64 | if complete { | |
| 65 | 65 | return sni, replayConn{Conn: conn, reader: bytes.NewReader(buf)}, nil | |
| 66 | 66 | } | |
| 67 | 67 | } | |
| 68 | 68 | if err != nil { | |
| 69 | - | return "", nil, err | |
| 69 | + | return "", nil, fmt.Errorf("read client hello: %w", err) | |
| 70 | 70 | } | |
| 71 | 71 | } | |
| 72 | 72 | return "", nil, fmt.Errorf("client hello exceeds %d bytes", maxClientHelloBytes) | |
| @@ -83,15 +83,23 @@ func parseClientHelloSNI(data []byte) (string, bool, error) { | |||
| 83 | 83 | if err != nil || !complete { | |
| 84 | 84 | return "", complete, err | |
| 85 | 85 | } | |
| 86 | - | if len(record) < 4 { | |
| 87 | - | return "", true, fmt.Errorf("tls handshake is truncated") | |
| 86 | + | handshake := append([]byte(nil), record...) | |
| 87 | + | for len(handshake) < 4 { | |
| 88 | + | if len(data) < consumed+5 { | |
| 89 | + | return "", false, nil | |
| 90 | + | } | |
| 91 | + | next, nextConsumed, nextComplete, err := tlsRecordPayload(data, consumed) | |
| 92 | + | if err != nil || !nextComplete { | |
| 93 | + | return "", nextComplete, err | |
| 94 | + | } | |
| 95 | + | handshake = append(handshake, next...) | |
| 96 | + | consumed = nextConsumed | |
| 88 | 97 | } | |
| 89 | - | if record[0] != 1 { | |
| 98 | + | if handshake[0] != 1 { | |
| 90 | 99 | return "", true, fmt.Errorf("tls handshake is not client hello") | |
| 91 | 100 | } | |
| 92 | - | handshakeLen := int(record[1])<<16 | int(record[2])<<8 | int(record[3]) | |
| 93 | - | handshake := append([]byte(nil), record[4:]...) | |
| 94 | - | for len(handshake) < handshakeLen { | |
| 101 | + | handshakeLen := int(handshake[1])<<16 | int(handshake[2])<<8 | int(handshake[3]) | |
| 102 | + | for len(handshake)-4 < handshakeLen { | |
| 95 | 103 | if len(data) < consumed+5 { | |
| 96 | 104 | return "", false, nil | |
| 97 | 105 | } | |
| @@ -102,7 +110,7 @@ func parseClientHelloSNI(data []byte) (string, bool, error) { | |||
| 102 | 110 | handshake = append(handshake, next...) | |
| 103 | 111 | consumed = nextConsumed | |
| 104 | 112 | } | |
| 105 | - | sni, err := parseClientHelloExtensions(handshake[:handshakeLen]) | |
| 113 | + | sni, err := parseClientHelloExtensions(handshake[4 : 4+handshakeLen]) | |
| 106 | 114 | return sni, true, err | |
| 107 | 115 | } | |
| 108 | 116 | ||
| @@ -255,9 +263,15 @@ func (c replayConn) Read(p []byte) (int, error) { | |||
| 255 | 263 | ||
| 256 | 264 | // ServeHTTPSFallback serves a single TLS connection with the provided HTTP handler. | |
| 257 | 265 | func ServeHTTPSFallback(ctx context.Context, conn net.Conn, handler http.Handler, tlsConfig *tls.Config) error { | |
| 266 | + | if handler == nil { | |
| 267 | + | return fmt.Errorf("https fallback handler is required") | |
| 268 | + | } | |
| 269 | + | if tlsConfig == nil { | |
| 270 | + | return fmt.Errorf("https fallback tls config is required") | |
| 271 | + | } | |
| 258 | 272 | tracked := newTrackedCloseConn(tls.Server(conn, tlsConfig)) | |
| 259 | 273 | listener := newSingleConnListener(tracked) | |
| 260 | - | server := &http.Server{Handler: handler, ReadHeaderTimeout: 5 * time.Second} | |
| 274 | + | server := &http.Server{Handler: handler, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 30 * time.Second} | |
| 261 | 275 | errCh := make(chan error, 1) | |
| 262 | 276 | go func() { errCh <- server.Serve(listener) }() | |
| 263 | 277 | ||
| @@ -59,14 +59,14 @@ func peekClientHelloSNI(conn net.Conn) (string, net.Conn, error) { | |||||
| 59 | buf = append(buf, tmp[:n]...) | 59 | buf = append(buf, tmp[:n]...) | ||
| 60 | sni, complete, parseErr := parseClientHelloSNI(buf) | 60 | sni, complete, parseErr := parseClientHelloSNI(buf) | ||
| 61 | if parseErr != nil { | 61 | if parseErr != nil { | ||
| 62 | - | return "", nil, parseErr | 62 | + | return "", nil, fmt.Errorf("parse client hello sni: %w", parseErr) |
| 63 | } | 63 | } | ||
| 64 | if complete { | 64 | if complete { | ||
| 65 | return sni, replayConn{Conn: conn, reader: bytes.NewReader(buf)}, nil | 65 | return sni, replayConn{Conn: conn, reader: bytes.NewReader(buf)}, nil | ||
| 66 | } | 66 | } | ||
| 67 | } | 67 | } | ||
| 68 | if err != nil { | 68 | if err != nil { | ||
| 69 | - | return "", nil, err | 69 | + | return "", nil, fmt.Errorf("read client hello: %w", err) |
| 70 | } | 70 | } | ||
| 71 | } | 71 | } | ||
| 72 | return "", nil, fmt.Errorf("client hello exceeds %d bytes", maxClientHelloBytes) | 72 | return "", nil, fmt.Errorf("client hello exceeds %d bytes", maxClientHelloBytes) | ||
| @@ -83,15 +83,23 @@ func parseClientHelloSNI(data []byte) (string, bool, error) { | |||||
| 83 | if err != nil || !complete { | 83 | if err != nil || !complete { | ||
| 84 | return "", complete, err | 84 | return "", complete, err | ||
| 85 | } | 85 | } | ||
| 86 | - | if len(record) < 4 { | 86 | + | handshake := append([]byte(nil), record...) |
| 87 | - | return "", true, fmt.Errorf("tls handshake is truncated") | 87 | + | for len(handshake) < 4 { |
| 88 | + | if len(data) < consumed+5 { | |||
| 89 | + | return "", false, nil | |||
| 90 | + | } | |||
| 91 | + | next, nextConsumed, nextComplete, err := tlsRecordPayload(data, consumed) | |||
| 92 | + | if err != nil || !nextComplete { | |||
| 93 | + | return "", nextComplete, err | |||
| 94 | + | } | |||
| 95 | + | handshake = append(handshake, next...) | |||
| 96 | + | consumed = nextConsumed | |||
| 88 | } | 97 | } | ||
| 89 | - | if record[0] != 1 { | 98 | + | if handshake[0] != 1 { |
| 90 | return "", true, fmt.Errorf("tls handshake is not client hello") | 99 | return "", true, fmt.Errorf("tls handshake is not client hello") | ||
| 91 | } | 100 | } | ||
| 92 | - | handshakeLen := int(record[1])<<16 | int(record[2])<<8 | int(record[3]) | 101 | + | handshakeLen := int(handshake[1])<<16 | int(handshake[2])<<8 | int(handshake[3]) |
| 93 | - | handshake := append([]byte(nil), record[4:]...) | 102 | + | for len(handshake)-4 < handshakeLen { |
| 94 | - | for len(handshake) < handshakeLen { | |||
| 95 | if len(data) < consumed+5 { | 103 | if len(data) < consumed+5 { | ||
| 96 | return "", false, nil | 104 | return "", false, nil | ||
| 97 | } | 105 | } | ||
| @@ -102,7 +110,7 @@ func parseClientHelloSNI(data []byte) (string, bool, error) { | |||||
| 102 | handshake = append(handshake, next...) | 110 | handshake = append(handshake, next...) | ||
| 103 | consumed = nextConsumed | 111 | consumed = nextConsumed | ||
| 104 | } | 112 | } | ||
| 105 | - | sni, err := parseClientHelloExtensions(handshake[:handshakeLen]) | 113 | + | sni, err := parseClientHelloExtensions(handshake[4 : 4+handshakeLen]) |
| 106 | return sni, true, err | 114 | return sni, true, err | ||
| 107 | } | 115 | } | ||
| 108 | 116 | ||||
| @@ -255,9 +263,15 @@ func (c replayConn) Read(p []byte) (int, error) { | |||||
| 255 | 263 | ||||
| 256 | // ServeHTTPSFallback serves a single TLS connection with the provided HTTP handler. | 264 | // ServeHTTPSFallback serves a single TLS connection with the provided HTTP handler. | ||
| 257 | func ServeHTTPSFallback(ctx context.Context, conn net.Conn, handler http.Handler, tlsConfig *tls.Config) error { | 265 | func ServeHTTPSFallback(ctx context.Context, conn net.Conn, handler http.Handler, tlsConfig *tls.Config) error { | ||
| 266 | + | if handler == nil { | |||
| 267 | + | return fmt.Errorf("https fallback handler is required") | |||
| 268 | + | } | |||
| 269 | + | if tlsConfig == nil { | |||
| 270 | + | return fmt.Errorf("https fallback tls config is required") | |||
| 271 | + | } | |||
| 258 | tracked := newTrackedCloseConn(tls.Server(conn, tlsConfig)) | 272 | tracked := newTrackedCloseConn(tls.Server(conn, tlsConfig)) | ||
| 259 | listener := newSingleConnListener(tracked) | 273 | listener := newSingleConnListener(tracked) | ||
| 260 | - | server := &http.Server{Handler: handler, ReadHeaderTimeout: 5 * time.Second} | 274 | + | server := &http.Server{Handler: handler, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 30 * time.Second} |
| 261 | errCh := make(chan error, 1) | 275 | errCh := make(chan error, 1) | ||
| 262 | go func() { errCh <- server.Serve(listener) }() | 276 | go func() { errCh <- server.Serve(listener) }() | ||
| 263 | 277 | ||||
R internal/adapters/in/traffic/tlsmux_test.go → internal/adapters/in/traffic/tls_mux_test.go +20 -4 renamed, 93% similar
| @@ -69,7 +69,7 @@ func TestClientHelloPeek(t *testing.T) { | |||
| 69 | 69 | }) | |
| 70 | 70 | ||
| 71 | 71 | t.Run("client_hello_across_multiple_tls_records", func(t *testing.T) { | |
| 72 | - | hello := splitClientHelloAcrossRecords(t, clientHelloBytes(t, "multi-record.example.com")) | |
| 72 | + | hello := splitClientHelloAcrossRecords(t, clientHelloBytes(t, "multi-record.example.com"), 10) | |
| 73 | 73 | client, server := net.Pipe() | |
| 74 | 74 | go func() { | |
| 75 | 75 | _, _ = client.Write(hello) | |
| @@ -83,15 +83,30 @@ func TestClientHelloPeek(t *testing.T) { | |||
| 83 | 83 | require.NoError(t, err) | |
| 84 | 84 | assert.Equal(t, hello, buf) | |
| 85 | 85 | }) | |
| 86 | + | ||
| 87 | + | t.Run("client_hello_header_across_multiple_tls_records", func(t *testing.T) { | |
| 88 | + | hello := splitClientHelloAcrossRecords(t, clientHelloBytes(t, "split-header.example.com"), 2) | |
| 89 | + | client, server := net.Pipe() | |
| 90 | + | go func() { | |
| 91 | + | _, _ = client.Write(hello) | |
| 92 | + | _ = client.Close() | |
| 93 | + | }() | |
| 94 | + | sni, replayed, err := peekClientHelloSNI(server) | |
| 95 | + | require.NoError(t, err) | |
| 96 | + | assert.Equal(t, "split-header.example.com", sni) | |
| 97 | + | buf := make([]byte, len(hello)) | |
| 98 | + | _, err = io.ReadFull(replayed, buf) | |
| 99 | + | require.NoError(t, err) | |
| 100 | + | assert.Equal(t, hello, buf) | |
| 101 | + | }) | |
| 86 | 102 | } | |
| 87 | 103 | ||
| 88 | - | func splitClientHelloAcrossRecords(t *testing.T, hello []byte) []byte { | |
| 104 | + | func splitClientHelloAcrossRecords(t *testing.T, hello []byte, firstPayloadLen int) []byte { | |
| 89 | 105 | t.Helper() | |
| 90 | 106 | require.GreaterOrEqual(t, len(hello), 15) | |
| 91 | 107 | require.Equal(t, byte(22), hello[0]) | |
| 92 | 108 | recordLen := int(hello[3])<<8 | int(hello[4]) | |
| 93 | - | require.Greater(t, recordLen, 10) | |
| 94 | - | firstPayloadLen := 10 | |
| 109 | + | require.Greater(t, recordLen, firstPayloadLen) | |
| 95 | 110 | first := append([]byte{}, hello[:5]...) | |
| 96 | 111 | first[3] = byte(firstPayloadLen >> 8) | |
| 97 | 112 | first[4] = byte(firstPayloadLen) | |
| @@ -319,6 +334,7 @@ func testCertificate(commonName string) (tls.Certificate, error) { | |||
| 319 | 334 | func TestHostMatchesTLSWildcard(t *testing.T) { | |
| 320 | 335 | assert.True(t, hostMatchesTLSWildcard("api.example.com", "*.example.com")) | |
| 321 | 336 | assert.False(t, hostMatchesTLSWildcard("example.com", "*.example.com")) | |
| 337 | + | assert.False(t, hostMatchesTLSWildcard("v1.api.example.com", "*.example.com")) | |
| 322 | 338 | assert.False(t, hostMatchesTLSWildcard("api.other.com", "*.example.com")) | |
| 323 | 339 | assert.True(t, strings.EqualFold(normalizeTLSName("API.EXAMPLE.COM."), "api.example.com")) | |
| 324 | 340 | } | |
| @@ -69,7 +69,7 @@ func TestClientHelloPeek(t *testing.T) { | |||||
| 69 | }) | 69 | }) | ||
| 70 | 70 | ||||
| 71 | t.Run("client_hello_across_multiple_tls_records", func(t *testing.T) { | 71 | t.Run("client_hello_across_multiple_tls_records", func(t *testing.T) { | ||
| 72 | - | hello := splitClientHelloAcrossRecords(t, clientHelloBytes(t, "multi-record.example.com")) | 72 | + | hello := splitClientHelloAcrossRecords(t, clientHelloBytes(t, "multi-record.example.com"), 10) |
| 73 | client, server := net.Pipe() | 73 | client, server := net.Pipe() | ||
| 74 | go func() { | 74 | go func() { | ||
| 75 | _, _ = client.Write(hello) | 75 | _, _ = client.Write(hello) | ||
| @@ -83,15 +83,30 @@ func TestClientHelloPeek(t *testing.T) { | |||||
| 83 | require.NoError(t, err) | 83 | require.NoError(t, err) | ||
| 84 | assert.Equal(t, hello, buf) | 84 | assert.Equal(t, hello, buf) | ||
| 85 | }) | 85 | }) | ||
| 86 | + | ||||
| 87 | + | t.Run("client_hello_header_across_multiple_tls_records", func(t *testing.T) { | |||
| 88 | + | hello := splitClientHelloAcrossRecords(t, clientHelloBytes(t, "split-header.example.com"), 2) | |||
| 89 | + | client, server := net.Pipe() | |||
| 90 | + | go func() { | |||
| 91 | + | _, _ = client.Write(hello) | |||
| 92 | + | _ = client.Close() | |||
| 93 | + | }() | |||
| 94 | + | sni, replayed, err := peekClientHelloSNI(server) | |||
| 95 | + | require.NoError(t, err) | |||
| 96 | + | assert.Equal(t, "split-header.example.com", sni) | |||
| 97 | + | buf := make([]byte, len(hello)) | |||
| 98 | + | _, err = io.ReadFull(replayed, buf) | |||
| 99 | + | require.NoError(t, err) | |||
| 100 | + | assert.Equal(t, hello, buf) | |||
| 101 | + | }) | |||
| 86 | } | 102 | } | ||
| 87 | 103 | ||||
| 88 | - | func splitClientHelloAcrossRecords(t *testing.T, hello []byte) []byte { | 104 | + | func splitClientHelloAcrossRecords(t *testing.T, hello []byte, firstPayloadLen int) []byte { |
| 89 | t.Helper() | 105 | t.Helper() | ||
| 90 | require.GreaterOrEqual(t, len(hello), 15) | 106 | require.GreaterOrEqual(t, len(hello), 15) | ||
| 91 | require.Equal(t, byte(22), hello[0]) | 107 | require.Equal(t, byte(22), hello[0]) | ||
| 92 | recordLen := int(hello[3])<<8 | int(hello[4]) | 108 | recordLen := int(hello[3])<<8 | int(hello[4]) | ||
| 93 | - | require.Greater(t, recordLen, 10) | 109 | + | require.Greater(t, recordLen, firstPayloadLen) |
| 94 | - | firstPayloadLen := 10 | |||
| 95 | first := append([]byte{}, hello[:5]...) | 110 | first := append([]byte{}, hello[:5]...) | ||
| 96 | first[3] = byte(firstPayloadLen >> 8) | 111 | first[3] = byte(firstPayloadLen >> 8) | ||
| 97 | first[4] = byte(firstPayloadLen) | 112 | first[4] = byte(firstPayloadLen) | ||
| @@ -319,6 +334,7 @@ func testCertificate(commonName string) (tls.Certificate, error) { | |||||
| 319 | func TestHostMatchesTLSWildcard(t *testing.T) { | 334 | func TestHostMatchesTLSWildcard(t *testing.T) { | ||
| 320 | assert.True(t, hostMatchesTLSWildcard("api.example.com", "*.example.com")) | 335 | assert.True(t, hostMatchesTLSWildcard("api.example.com", "*.example.com")) | ||
| 321 | assert.False(t, hostMatchesTLSWildcard("example.com", "*.example.com")) | 336 | assert.False(t, hostMatchesTLSWildcard("example.com", "*.example.com")) | ||
| 337 | + | assert.False(t, hostMatchesTLSWildcard("v1.api.example.com", "*.example.com")) | |||
| 322 | assert.False(t, hostMatchesTLSWildcard("api.other.com", "*.example.com")) | 338 | assert.False(t, hostMatchesTLSWildcard("api.other.com", "*.example.com")) | ||
| 323 | assert.True(t, strings.EqualFold(normalizeTLSName("API.EXAMPLE.COM."), "api.example.com")) | 339 | assert.True(t, strings.EqualFold(normalizeTLSName("API.EXAMPLE.COM."), "api.example.com")) | ||
| 324 | } | 340 | } | ||
M internal/adapters/in/traffic/udp.go +88 -17
| @@ -12,7 +12,11 @@ import ( | |||
| 12 | 12 | "github.com/bnema/gordon/internal/domain" | |
| 13 | 13 | ) | |
| 14 | 14 | ||
| 15 | - | const udpBufferSize = 64 << 10 | |
| 15 | + | const ( | |
| 16 | + | udpBufferSize = 64 << 10 | |
| 17 | + | udpWorkerCount = 16 | |
| 18 | + | udpDatagramBacklog = 1024 | |
| 19 | + | ) | |
| 16 | 20 | ||
| 17 | 21 | type udpEntryPointRuntime struct { | |
| 18 | 22 | manager *Manager | |
| @@ -29,10 +33,17 @@ type udpEntryPointRuntime struct { | |||
| 29 | 33 | done chan struct{} | |
| 30 | 34 | doneOnce sync.Once | |
| 31 | 35 | ||
| 36 | + | datagrams chan udpDatagram | |
| 37 | + | ||
| 32 | 38 | mu sync.Mutex | |
| 33 | 39 | sessions map[string]*udpSession | |
| 34 | 40 | } | |
| 35 | 41 | ||
| 42 | + | type udpDatagram struct { | |
| 43 | + | clientAddr net.Addr | |
| 44 | + | packet []byte | |
| 45 | + | } | |
| 46 | + | ||
| 36 | 47 | type udpSession struct { | |
| 37 | 48 | clientAddr net.Addr | |
| 38 | 49 | backend net.Conn | |
| @@ -51,6 +62,7 @@ func newUDPEntryPointRuntime(parentCtx context.Context, manager *Manager, entryP | |||
| 51 | 62 | ctx: ctx, | |
| 52 | 63 | cancel: cancel, | |
| 53 | 64 | done: make(chan struct{}), | |
| 65 | + | datagrams: make(chan udpDatagram, udpDatagramBacklog), | |
| 54 | 66 | sessions: map[string]*udpSession{}, | |
| 55 | 67 | } | |
| 56 | 68 | } | |
| @@ -59,7 +71,11 @@ func (r *udpEntryPointRuntime) start() { | |||
| 59 | 71 | if !r.started.CompareAndSwap(false, true) { | |
| 60 | 72 | return | |
| 61 | 73 | } | |
| 62 | - | trafficInfo(r.ctx).Str("entrypoint", r.entryPoint.Name).Str("address", r.entryPoint.Address).Str("protocol", string(r.entryPoint.Protocol)).Msg("started udp traffic entrypoint") | |
| 74 | + | entryPoint := r.entryPointSnapshot() | |
| 75 | + | trafficInfo(r.ctx).Str("entrypoint", entryPoint.Name).Str("address", entryPoint.Address).Str("protocol", string(entryPoint.Protocol)).Msg("started udp traffic entrypoint") | |
| 76 | + | for i := 0; i < udpWorkerCount; i++ { | |
| 77 | + | go r.datagramWorker() | |
| 78 | + | } | |
| 63 | 79 | go r.readLoop() | |
| 64 | 80 | go r.expireLoop() | |
| 65 | 81 | } | |
| @@ -76,12 +92,29 @@ func (r *udpEntryPointRuntime) readLoop() { | |||
| 76 | 92 | r.counters.totalErrors.Add(1) | |
| 77 | 93 | continue | |
| 78 | 94 | } | |
| 79 | - | if !trustedRemoteAddr(r.trusted, clientAddr) { | |
| 95 | + | if !trustedRemoteAddr(r.trustedSnapshot(), clientAddr) { | |
| 80 | 96 | r.counters.totalRefused.Add(1) | |
| 81 | 97 | continue | |
| 82 | 98 | } | |
| 83 | 99 | packet := append([]byte(nil), buf[:n]...) | |
| 84 | - | r.handleDatagram(clientAddr, packet) | |
| 100 | + | select { | |
| 101 | + | case r.datagrams <- udpDatagram{clientAddr: clientAddr, packet: packet}: | |
| 102 | + | case <-r.ctx.Done(): | |
| 103 | + | return | |
| 104 | + | default: | |
| 105 | + | r.counters.totalRefused.Add(1) | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func (r *udpEntryPointRuntime) datagramWorker() { | |
| 111 | + | for { | |
| 112 | + | select { | |
| 113 | + | case <-r.ctx.Done(): | |
| 114 | + | return | |
| 115 | + | case datagram := <-r.datagrams: | |
| 116 | + | r.handleDatagram(datagram.clientAddr, datagram.packet) | |
| 117 | + | } | |
| 85 | 118 | } | |
| 86 | 119 | } | |
| 87 | 120 | ||
| @@ -91,7 +124,6 @@ func (r *udpEntryPointRuntime) handleDatagram(clientAddr net.Addr, packet []byte | |||
| 91 | 124 | if !ok { | |
| 92 | 125 | return | |
| 93 | 126 | } | |
| 94 | - | session.touch() | |
| 95 | 127 | n, err := session.backend.Write(packet) | |
| 96 | 128 | r.counters.bytesIn.Add(int64(n)) | |
| 97 | 129 | if err != nil { | |
| @@ -103,6 +135,7 @@ func (r *udpEntryPointRuntime) handleDatagram(clientAddr net.Addr, packet []byte | |||
| 103 | 135 | func (r *udpEntryPointRuntime) session(key string, clientAddr net.Addr, options domain.UDPOptions) (*udpSession, bool) { | |
| 104 | 136 | r.mu.Lock() | |
| 105 | 137 | if session := r.sessions[key]; session != nil { | |
| 138 | + | session.touch() | |
| 106 | 139 | r.mu.Unlock() | |
| 107 | 140 | return session, true | |
| 108 | 141 | } | |
| @@ -118,7 +151,9 @@ func (r *udpEntryPointRuntime) session(key string, clientAddr net.Addr, options | |||
| 118 | 151 | r.counters.totalRefused.Add(1) | |
| 119 | 152 | return nil, false | |
| 120 | 153 | } | |
| 121 | - | backendConn, err := net.Dial("udp", net.JoinHostPort(backend.Host, strconv.Itoa(backend.Port))) | |
| 154 | + | dialCtx, cancel := context.WithTimeout(r.ctx, udpDialTimeout(options)) | |
| 155 | + | backendConn, err := (&net.Dialer{}).DialContext(dialCtx, "udp", net.JoinHostPort(backend.Host, strconv.Itoa(backend.Port))) | |
| 156 | + | cancel() | |
| 122 | 157 | if err != nil { | |
| 123 | 158 | r.counters.totalErrors.Add(1) | |
| 124 | 159 | return nil, false | |
| @@ -147,6 +182,13 @@ func (r *udpEntryPointRuntime) session(key string, clientAddr net.Addr, options | |||
| 147 | 182 | return session, true | |
| 148 | 183 | } | |
| 149 | 184 | ||
| 185 | + | func udpDialTimeout(options domain.UDPOptions) time.Duration { | |
| 186 | + | if options.IdleTimeout > 0 && options.IdleTimeout < 5*time.Second { | |
| 187 | + | return options.IdleTimeout | |
| 188 | + | } | |
| 189 | + | return 5 * time.Second | |
| 190 | + | } | |
| 191 | + | ||
| 150 | 192 | func (r *udpEntryPointRuntime) backendLoop(key string, session *udpSession) { | |
| 151 | 193 | defer r.removeSession(key) | |
| 152 | 194 | buf := make([]byte, udpBufferSize) | |
| @@ -187,16 +229,18 @@ func (r *udpEntryPointRuntime) expireIdleSessions(idleTimeout time.Duration) { | |||
| 187 | 229 | idleTimeout = defaultUDPOptions().IdleTimeout | |
| 188 | 230 | } | |
| 189 | 231 | cutoff := time.Now().Add(-idleTimeout).UnixNano() | |
| 232 | + | expired := []*udpSession{} | |
| 190 | 233 | r.mu.Lock() | |
| 191 | - | keys := make([]string, 0) | |
| 192 | 234 | for key, session := range r.sessions { | |
| 193 | 235 | if session.lastSeen.Load() <= cutoff { | |
| 194 | - | keys = append(keys, key) | |
| 236 | + | delete(r.sessions, key) | |
| 237 | + | expired = append(expired, session) | |
| 195 | 238 | } | |
| 196 | 239 | } | |
| 197 | 240 | r.mu.Unlock() | |
| 198 | - | for _, key := range keys { | |
| 199 | - | r.removeSession(key) | |
| 241 | + | for _, session := range expired { | |
| 242 | + | session.close() | |
| 243 | + | r.counters.activeUDPSessions.Add(-1) | |
| 200 | 244 | } | |
| 201 | 245 | } | |
| 202 | 246 | ||
| @@ -219,9 +263,10 @@ func (r *udpEntryPointRuntime) resolveUDPBackend() (domain.TrafficBackend, bool) | |||
| 219 | 263 | if graph == nil { | |
| 220 | 264 | return domain.TrafficBackend{}, false | |
| 221 | 265 | } | |
| 266 | + | entryPoint := r.entryPointSnapshot() | |
| 222 | 267 | var router domain.TrafficRouter | |
| 223 | 268 | for _, candidate := range graph.Routers { | |
| 224 | - | if candidate.EntryPoint == r.entryPoint.Name && candidate.Protocol == domain.RouterProtocolUDP { | |
| 269 | + | if candidate.EntryPoint == entryPoint.Name && candidate.Protocol == domain.RouterProtocolUDP { | |
| 225 | 270 | if router.Name != "" { | |
| 226 | 271 | return domain.TrafficBackend{}, false | |
| 227 | 272 | } | |
| @@ -241,7 +286,8 @@ func (r *udpEntryPointRuntime) resolveUDPBackend() (domain.TrafficBackend, bool) | |||
| 241 | 286 | ||
| 242 | 287 | func (r *udpEntryPointRuntime) stop(ctx context.Context, drainTimeout time.Duration) { | |
| 243 | 288 | if r.closed.CompareAndSwap(false, true) { | |
| 244 | - | trafficInfo(ctx).Str("entrypoint", r.entryPoint.Name).Str("address", r.entryPoint.Address).Msg("stopping udp traffic entrypoint") | |
| 289 | + | entryPoint := r.entryPointSnapshot() | |
| 290 | + | trafficInfo(ctx).Str("entrypoint", entryPoint.Name).Str("address", entryPoint.Address).Msg("stopping udp traffic entrypoint") | |
| 245 | 291 | r.cancel() | |
| 246 | 292 | _ = r.packetConn.Close() | |
| 247 | 293 | if !r.started.Load() { | |
| @@ -251,23 +297,24 @@ func (r *udpEntryPointRuntime) stop(ctx context.Context, drainTimeout time.Durat | |||
| 251 | 297 | select { | |
| 252 | 298 | case <-r.done: | |
| 253 | 299 | case <-ctx.Done(): | |
| 300 | + | r.closeSessions() | |
| 254 | 301 | return | |
| 255 | 302 | } | |
| 256 | 303 | if r.waitSessions(ctx, drainTimeout) { | |
| 257 | - | trafficInfo(ctx).Str("entrypoint", r.entryPoint.Name).Msg("stopped udp traffic entrypoint") | |
| 304 | + | trafficInfo(ctx).Str("entrypoint", r.entryPointSnapshot().Name).Msg("stopped udp traffic entrypoint") | |
| 258 | 305 | return | |
| 259 | 306 | } | |
| 260 | - | trafficDebug(ctx).Str("entrypoint", r.entryPoint.Name).Dur("drain_timeout", drainTimeout).Msg("forcing udp traffic entrypoint drain") | |
| 307 | + | trafficDebug(ctx).Str("entrypoint", r.entryPointSnapshot().Name).Dur("drain_timeout", drainTimeout).Msg("forcing udp traffic entrypoint drain") | |
| 261 | 308 | r.closeSessions() | |
| 262 | 309 | select { | |
| 263 | 310 | case <-r.sessionsDone(): | |
| 264 | - | trafficInfo(ctx).Str("entrypoint", r.entryPoint.Name).Msg("stopped udp traffic entrypoint") | |
| 311 | + | trafficInfo(ctx).Str("entrypoint", r.entryPointSnapshot().Name).Msg("stopped udp traffic entrypoint") | |
| 265 | 312 | case <-ctx.Done(): | |
| 266 | 313 | } | |
| 267 | 314 | } | |
| 268 | 315 | ||
| 269 | 316 | func (r *udpEntryPointRuntime) drainSessionsAfter(drainTimeout time.Duration) { | |
| 270 | - | trafficDebug(r.ctx).Str("entrypoint", r.entryPoint.Name).Dur("drain_timeout", drainTimeout).Msg("scheduled udp session drain after router removal") | |
| 317 | + | trafficDebug(r.ctx).Str("entrypoint", r.entryPointSnapshot().Name).Dur("drain_timeout", drainTimeout).Msg("scheduled udp session drain after router removal") | |
| 271 | 318 | go func() { | |
| 272 | 319 | if drainTimeout <= 0 { | |
| 273 | 320 | drainTimeout = defaultUDPOptions().DrainTimeout | |
| @@ -328,7 +375,31 @@ func (r *udpEntryPointRuntime) closeSessions() { | |||
| 328 | 375 | } | |
| 329 | 376 | ||
| 330 | 377 | func (r *udpEntryPointRuntime) matches(entryPoint domain.EntryPoint) bool { | |
| 331 | - | return r.entryPoint.Name == entryPoint.Name && r.entryPoint.Address == entryPoint.Address && r.entryPoint.Protocol == entryPoint.Protocol && trustedCIDRsEqual(r.entryPoint.TrustedCIDRs, entryPoint.TrustedCIDRs) | |
| 378 | + | current := r.entryPointSnapshot() | |
| 379 | + | return current.Name == entryPoint.Name && current.Address == entryPoint.Address && current.Protocol == entryPoint.Protocol && trustedCIDRsEqual(current.TrustedCIDRs, entryPoint.TrustedCIDRs) | |
| 380 | + | } | |
| 381 | + | ||
| 382 | + | func (r *udpEntryPointRuntime) sameAddress(entryPoint domain.EntryPoint) bool { | |
| 383 | + | return r.entryPointSnapshot().Address == entryPoint.Address | |
| 384 | + | } | |
| 385 | + | ||
| 386 | + | func (r *udpEntryPointRuntime) updateEntryPoint(entryPoint domain.EntryPoint, trusted []*net.IPNet) { | |
| 387 | + | r.mu.Lock() | |
| 388 | + | r.entryPoint = entryPoint | |
| 389 | + | r.trusted = trusted | |
| 390 | + | r.mu.Unlock() | |
| 391 | + | } | |
| 392 | + | ||
| 393 | + | func (r *udpEntryPointRuntime) entryPointSnapshot() domain.EntryPoint { | |
| 394 | + | r.mu.Lock() | |
| 395 | + | defer r.mu.Unlock() | |
| 396 | + | return r.entryPoint | |
| 397 | + | } | |
| 398 | + | ||
| 399 | + | func (r *udpEntryPointRuntime) trustedSnapshot() []*net.IPNet { | |
| 400 | + | r.mu.Lock() | |
| 401 | + | defer r.mu.Unlock() | |
| 402 | + | return r.trusted | |
| 332 | 403 | } | |
| 333 | 404 | ||
| 334 | 405 | func (r *udpEntryPointRuntime) isClosed() bool { return r.closed.Load() } | |
| @@ -12,7 +12,11 @@ import ( | |||||
| 12 | "github.com/bnema/gordon/internal/domain" | 12 | "github.com/bnema/gordon/internal/domain" | ||
| 13 | ) | 13 | ) | ||
| 14 | 14 | ||||
| 15 | - | const udpBufferSize = 64 << 10 | 15 | + | const ( |
| 16 | + | udpBufferSize = 64 << 10 | |||
| 17 | + | udpWorkerCount = 16 | |||
| 18 | + | udpDatagramBacklog = 1024 | |||
| 19 | + | ) | |||
| 16 | 20 | ||||
| 17 | type udpEntryPointRuntime struct { | 21 | type udpEntryPointRuntime struct { | ||
| 18 | manager *Manager | 22 | manager *Manager | ||
| @@ -29,10 +33,17 @@ type udpEntryPointRuntime struct { | |||||
| 29 | done chan struct{} | 33 | done chan struct{} | ||
| 30 | doneOnce sync.Once | 34 | doneOnce sync.Once | ||
| 31 | 35 | ||||
| 36 | + | datagrams chan udpDatagram | |||
| 37 | + | ||||
| 32 | mu sync.Mutex | 38 | mu sync.Mutex | ||
| 33 | sessions map[string]*udpSession | 39 | sessions map[string]*udpSession | ||
| 34 | } | 40 | } | ||
| 35 | 41 | ||||
| 42 | + | type udpDatagram struct { | |||
| 43 | + | clientAddr net.Addr | |||
| 44 | + | packet []byte | |||
| 45 | + | } | |||
| 46 | + | ||||
| 36 | type udpSession struct { | 47 | type udpSession struct { | ||
| 37 | clientAddr net.Addr | 48 | clientAddr net.Addr | ||
| 38 | backend net.Conn | 49 | backend net.Conn | ||
| @@ -51,6 +62,7 @@ func newUDPEntryPointRuntime(parentCtx context.Context, manager *Manager, entryP | |||||
| 51 | ctx: ctx, | 62 | ctx: ctx, | ||
| 52 | cancel: cancel, | 63 | cancel: cancel, | ||
| 53 | done: make(chan struct{}), | 64 | done: make(chan struct{}), | ||
| 65 | + | datagrams: make(chan udpDatagram, udpDatagramBacklog), | |||
| 54 | sessions: map[string]*udpSession{}, | 66 | sessions: map[string]*udpSession{}, | ||
| 55 | } | 67 | } | ||
| 56 | } | 68 | } | ||
| @@ -59,7 +71,11 @@ func (r *udpEntryPointRuntime) start() { | |||||
| 59 | if !r.started.CompareAndSwap(false, true) { | 71 | if !r.started.CompareAndSwap(false, true) { | ||
| 60 | return | 72 | return | ||
| 61 | } | 73 | } | ||
| 62 | - | trafficInfo(r.ctx).Str("entrypoint", r.entryPoint.Name).Str("address", r.entryPoint.Address).Str("protocol", string(r.entryPoint.Protocol)).Msg("started udp traffic entrypoint") | 74 | + | entryPoint := r.entryPointSnapshot() |
| 75 | + | trafficInfo(r.ctx).Str("entrypoint", entryPoint.Name).Str("address", entryPoint.Address).Str("protocol", string(entryPoint.Protocol)).Msg("started udp traffic entrypoint") | |||
| 76 | + | for i := 0; i < udpWorkerCount; i++ { | |||
| 77 | + | go r.datagramWorker() | |||
| 78 | + | } | |||
| 63 | go r.readLoop() | 79 | go r.readLoop() | ||
| 64 | go r.expireLoop() | 80 | go r.expireLoop() | ||
| 65 | } | 81 | } | ||
| @@ -76,12 +92,29 @@ func (r *udpEntryPointRuntime) readLoop() { | |||||
| 76 | r.counters.totalErrors.Add(1) | 92 | r.counters.totalErrors.Add(1) | ||
| 77 | continue | 93 | continue | ||
| 78 | } | 94 | } | ||
| 79 | - | if !trustedRemoteAddr(r.trusted, clientAddr) { | 95 | + | if !trustedRemoteAddr(r.trustedSnapshot(), clientAddr) { |
| 80 | r.counters.totalRefused.Add(1) | 96 | r.counters.totalRefused.Add(1) | ||
| 81 | continue | 97 | continue | ||
| 82 | } | 98 | } | ||
| 83 | packet := append([]byte(nil), buf[:n]...) | 99 | packet := append([]byte(nil), buf[:n]...) | ||
| 84 | - | r.handleDatagram(clientAddr, packet) | 100 | + | select { |
| 101 | + | case r.datagrams <- udpDatagram{clientAddr: clientAddr, packet: packet}: | |||
| 102 | + | case <-r.ctx.Done(): | |||
| 103 | + | return | |||
| 104 | + | default: | |||
| 105 | + | r.counters.totalRefused.Add(1) | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | func (r *udpEntryPointRuntime) datagramWorker() { | |||
| 111 | + | for { | |||
| 112 | + | select { | |||
| 113 | + | case <-r.ctx.Done(): | |||
| 114 | + | return | |||
| 115 | + | case datagram := <-r.datagrams: | |||
| 116 | + | r.handleDatagram(datagram.clientAddr, datagram.packet) | |||
| 117 | + | } | |||
| 85 | } | 118 | } | ||
| 86 | } | 119 | } | ||
| 87 | 120 | ||||
| @@ -91,7 +124,6 @@ func (r *udpEntryPointRuntime) handleDatagram(clientAddr net.Addr, packet []byte | |||||
| 91 | if !ok { | 124 | if !ok { | ||
| 92 | return | 125 | return | ||
| 93 | } | 126 | } | ||
| 94 | - | session.touch() | |||
| 95 | n, err := session.backend.Write(packet) | 127 | n, err := session.backend.Write(packet) | ||
| 96 | r.counters.bytesIn.Add(int64(n)) | 128 | r.counters.bytesIn.Add(int64(n)) | ||
| 97 | if err != nil { | 129 | if err != nil { | ||
| @@ -103,6 +135,7 @@ func (r *udpEntryPointRuntime) handleDatagram(clientAddr net.Addr, packet []byte | |||||
| 103 | func (r *udpEntryPointRuntime) session(key string, clientAddr net.Addr, options domain.UDPOptions) (*udpSession, bool) { | 135 | func (r *udpEntryPointRuntime) session(key string, clientAddr net.Addr, options domain.UDPOptions) (*udpSession, bool) { | ||
| 104 | r.mu.Lock() | 136 | r.mu.Lock() | ||
| 105 | if session := r.sessions[key]; session != nil { | 137 | if session := r.sessions[key]; session != nil { | ||
| 138 | + | session.touch() | |||
| 106 | r.mu.Unlock() | 139 | r.mu.Unlock() | ||
| 107 | return session, true | 140 | return session, true | ||
| 108 | } | 141 | } | ||
| @@ -118,7 +151,9 @@ func (r *udpEntryPointRuntime) session(key string, clientAddr net.Addr, options | |||||
| 118 | r.counters.totalRefused.Add(1) | 151 | r.counters.totalRefused.Add(1) | ||
| 119 | return nil, false | 152 | return nil, false | ||
| 120 | } | 153 | } | ||
| 121 | - | backendConn, err := net.Dial("udp", net.JoinHostPort(backend.Host, strconv.Itoa(backend.Port))) | 154 | + | dialCtx, cancel := context.WithTimeout(r.ctx, udpDialTimeout(options)) |
| 155 | + | backendConn, err := (&net.Dialer{}).DialContext(dialCtx, "udp", net.JoinHostPort(backend.Host, strconv.Itoa(backend.Port))) | |||
| 156 | + | cancel() | |||
| 122 | if err != nil { | 157 | if err != nil { | ||
| 123 | r.counters.totalErrors.Add(1) | 158 | r.counters.totalErrors.Add(1) | ||
| 124 | return nil, false | 159 | return nil, false | ||
| @@ -147,6 +182,13 @@ func (r *udpEntryPointRuntime) session(key string, clientAddr net.Addr, options | |||||
| 147 | return session, true | 182 | return session, true | ||
| 148 | } | 183 | } | ||
| 149 | 184 | ||||
| 185 | + | func udpDialTimeout(options domain.UDPOptions) time.Duration { | |||
| 186 | + | if options.IdleTimeout > 0 && options.IdleTimeout < 5*time.Second { | |||
| 187 | + | return options.IdleTimeout | |||
| 188 | + | } | |||
| 189 | + | return 5 * time.Second | |||
| 190 | + | } | |||
| 191 | + | ||||
| 150 | func (r *udpEntryPointRuntime) backendLoop(key string, session *udpSession) { | 192 | func (r *udpEntryPointRuntime) backendLoop(key string, session *udpSession) { | ||
| 151 | defer r.removeSession(key) | 193 | defer r.removeSession(key) | ||
| 152 | buf := make([]byte, udpBufferSize) | 194 | buf := make([]byte, udpBufferSize) | ||
| @@ -187,16 +229,18 @@ func (r *udpEntryPointRuntime) expireIdleSessions(idleTimeout time.Duration) { | |||||
| 187 | idleTimeout = defaultUDPOptions().IdleTimeout | 229 | idleTimeout = defaultUDPOptions().IdleTimeout | ||
| 188 | } | 230 | } | ||
| 189 | cutoff := time.Now().Add(-idleTimeout).UnixNano() | 231 | cutoff := time.Now().Add(-idleTimeout).UnixNano() | ||
| 232 | + | expired := []*udpSession{} | |||
| 190 | r.mu.Lock() | 233 | r.mu.Lock() | ||
| 191 | - | keys := make([]string, 0) | |||
| 192 | for key, session := range r.sessions { | 234 | for key, session := range r.sessions { | ||
| 193 | if session.lastSeen.Load() <= cutoff { | 235 | if session.lastSeen.Load() <= cutoff { | ||
| 194 | - | keys = append(keys, key) | 236 | + | delete(r.sessions, key) |
| 237 | + | expired = append(expired, session) | |||
| 195 | } | 238 | } | ||
| 196 | } | 239 | } | ||
| 197 | r.mu.Unlock() | 240 | r.mu.Unlock() | ||
| 198 | - | for _, key := range keys { | 241 | + | for _, session := range expired { |
| 199 | - | r.removeSession(key) | 242 | + | session.close() |
| 243 | + | r.counters.activeUDPSessions.Add(-1) | |||
| 200 | } | 244 | } | ||
| 201 | } | 245 | } | ||
| 202 | 246 | ||||
| @@ -219,9 +263,10 @@ func (r *udpEntryPointRuntime) resolveUDPBackend() (domain.TrafficBackend, bool) | |||||
| 219 | if graph == nil { | 263 | if graph == nil { | ||
| 220 | return domain.TrafficBackend{}, false | 264 | return domain.TrafficBackend{}, false | ||
| 221 | } | 265 | } | ||
| 266 | + | entryPoint := r.entryPointSnapshot() | |||
| 222 | var router domain.TrafficRouter | 267 | var router domain.TrafficRouter | ||
| 223 | for _, candidate := range graph.Routers { | 268 | for _, candidate := range graph.Routers { | ||
| 224 | - | if candidate.EntryPoint == r.entryPoint.Name && candidate.Protocol == domain.RouterProtocolUDP { | 269 | + | if candidate.EntryPoint == entryPoint.Name && candidate.Protocol == domain.RouterProtocolUDP { |
| 225 | if router.Name != "" { | 270 | if router.Name != "" { | ||
| 226 | return domain.TrafficBackend{}, false | 271 | return domain.TrafficBackend{}, false | ||
| 227 | } | 272 | } | ||
| @@ -241,7 +286,8 @@ func (r *udpEntryPointRuntime) resolveUDPBackend() (domain.TrafficBackend, bool) | |||||
| 241 | 286 | ||||
| 242 | func (r *udpEntryPointRuntime) stop(ctx context.Context, drainTimeout time.Duration) { | 287 | func (r *udpEntryPointRuntime) stop(ctx context.Context, drainTimeout time.Duration) { | ||
| 243 | if r.closed.CompareAndSwap(false, true) { | 288 | if r.closed.CompareAndSwap(false, true) { | ||
| 244 | - | trafficInfo(ctx).Str("entrypoint", r.entryPoint.Name).Str("address", r.entryPoint.Address).Msg("stopping udp traffic entrypoint") | 289 | + | entryPoint := r.entryPointSnapshot() |
| 290 | + | trafficInfo(ctx).Str("entrypoint", entryPoint.Name).Str("address", entryPoint.Address).Msg("stopping udp traffic entrypoint") | |||
| 245 | r.cancel() | 291 | r.cancel() | ||
| 246 | _ = r.packetConn.Close() | 292 | _ = r.packetConn.Close() | ||
| 247 | if !r.started.Load() { | 293 | if !r.started.Load() { | ||
| @@ -251,23 +297,24 @@ func (r *udpEntryPointRuntime) stop(ctx context.Context, drainTimeout time.Durat | |||||
| 251 | select { | 297 | select { | ||
| 252 | case <-r.done: | 298 | case <-r.done: | ||
| 253 | case <-ctx.Done(): | 299 | case <-ctx.Done(): | ||
| 300 | + | r.closeSessions() | |||
| 254 | return | 301 | return | ||
| 255 | } | 302 | } | ||
| 256 | if r.waitSessions(ctx, drainTimeout) { | 303 | if r.waitSessions(ctx, drainTimeout) { | ||
| 257 | - | trafficInfo(ctx).Str("entrypoint", r.entryPoint.Name).Msg("stopped udp traffic entrypoint") | 304 | + | trafficInfo(ctx).Str("entrypoint", r.entryPointSnapshot().Name).Msg("stopped udp traffic entrypoint") |
| 258 | return | 305 | return | ||
| 259 | } | 306 | } | ||
| 260 | - | trafficDebug(ctx).Str("entrypoint", r.entryPoint.Name).Dur("drain_timeout", drainTimeout).Msg("forcing udp traffic entrypoint drain") | 307 | + | trafficDebug(ctx).Str("entrypoint", r.entryPointSnapshot().Name).Dur("drain_timeout", drainTimeout).Msg("forcing udp traffic entrypoint drain") |
| 261 | r.closeSessions() | 308 | r.closeSessions() | ||
| 262 | select { | 309 | select { | ||
| 263 | case <-r.sessionsDone(): | 310 | case <-r.sessionsDone(): | ||
| 264 | - | trafficInfo(ctx).Str("entrypoint", r.entryPoint.Name).Msg("stopped udp traffic entrypoint") | 311 | + | trafficInfo(ctx).Str("entrypoint", r.entryPointSnapshot().Name).Msg("stopped udp traffic entrypoint") |
| 265 | case <-ctx.Done(): | 312 | case <-ctx.Done(): | ||
| 266 | } | 313 | } | ||
| 267 | } | 314 | } | ||
| 268 | 315 | ||||
| 269 | func (r *udpEntryPointRuntime) drainSessionsAfter(drainTimeout time.Duration) { | 316 | func (r *udpEntryPointRuntime) drainSessionsAfter(drainTimeout time.Duration) { | ||
| 270 | - | trafficDebug(r.ctx).Str("entrypoint", r.entryPoint.Name).Dur("drain_timeout", drainTimeout).Msg("scheduled udp session drain after router removal") | 317 | + | trafficDebug(r.ctx).Str("entrypoint", r.entryPointSnapshot().Name).Dur("drain_timeout", drainTimeout).Msg("scheduled udp session drain after router removal") |
| 271 | go func() { | 318 | go func() { | ||
| 272 | if drainTimeout <= 0 { | 319 | if drainTimeout <= 0 { | ||
| 273 | drainTimeout = defaultUDPOptions().DrainTimeout | 320 | drainTimeout = defaultUDPOptions().DrainTimeout | ||
| @@ -328,7 +375,31 @@ func (r *udpEntryPointRuntime) closeSessions() { | |||||
| 328 | } | 375 | } | ||
| 329 | 376 | ||||
| 330 | func (r *udpEntryPointRuntime) matches(entryPoint domain.EntryPoint) bool { | 377 | func (r *udpEntryPointRuntime) matches(entryPoint domain.EntryPoint) bool { | ||
| 331 | - | return r.entryPoint.Name == entryPoint.Name && r.entryPoint.Address == entryPoint.Address && r.entryPoint.Protocol == entryPoint.Protocol && trustedCIDRsEqual(r.entryPoint.TrustedCIDRs, entryPoint.TrustedCIDRs) | 378 | + | current := r.entryPointSnapshot() |
| 379 | + | return current.Name == entryPoint.Name && current.Address == entryPoint.Address && current.Protocol == entryPoint.Protocol && trustedCIDRsEqual(current.TrustedCIDRs, entryPoint.TrustedCIDRs) | |||
| 380 | + | } | |||
| 381 | + | ||||
| 382 | + | func (r *udpEntryPointRuntime) sameAddress(entryPoint domain.EntryPoint) bool { | |||
| 383 | + | return r.entryPointSnapshot().Address == entryPoint.Address | |||
| 384 | + | } | |||
| 385 | + | ||||
| 386 | + | func (r *udpEntryPointRuntime) updateEntryPoint(entryPoint domain.EntryPoint, trusted []*net.IPNet) { | |||
| 387 | + | r.mu.Lock() | |||
| 388 | + | r.entryPoint = entryPoint | |||
| 389 | + | r.trusted = trusted | |||
| 390 | + | r.mu.Unlock() | |||
| 391 | + | } | |||
| 392 | + | ||||
| 393 | + | func (r *udpEntryPointRuntime) entryPointSnapshot() domain.EntryPoint { | |||
| 394 | + | r.mu.Lock() | |||
| 395 | + | defer r.mu.Unlock() | |||
| 396 | + | return r.entryPoint | |||
| 397 | + | } | |||
| 398 | + | ||||
| 399 | + | func (r *udpEntryPointRuntime) trustedSnapshot() []*net.IPNet { | |||
| 400 | + | r.mu.Lock() | |||
| 401 | + | defer r.mu.Unlock() | |||
| 402 | + | return r.trusted | |||
| 332 | } | 403 | } | ||
| 333 | 404 | ||||
| 334 | func (r *udpEntryPointRuntime) isClosed() bool { return r.closed.Load() } | 405 | func (r *udpEntryPointRuntime) isClosed() bool { return r.closed.Load() } | ||
M internal/app/run.go +10 -2
| @@ -2823,7 +2823,7 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services, | |||
| 2823 | 2823 | } | |
| 2824 | 2824 | } | |
| 2825 | 2825 | ||
| 2826 | - | proxySrv, proxyReady, tlsSrv, tlsReady, err := startProxyServers(cfg, httpProxyHandler, httpsProxyHandler, svc.pkiSvc, svc.publicTLSSvc, errChan, log) | |
| 2826 | + | proxySrv, proxyReady, tlsSrv, tlsReady, err := startProxyServers(cfg, httpProxyHandler, httpsProxyHandler, svc.pkiSvc, svc.publicTLSSvc, svc.trafficManager, errChan, log) | |
| 2827 | 2827 | if err != nil { | |
| 2828 | 2828 | closeStarted(registrySrv) | |
| 2829 | 2829 | return err | |
| @@ -3265,7 +3265,7 @@ func matchingStaticCert(certs []tls.Certificate, serverName string) *tls.Certifi | |||
| 3265 | 3265 | return matchingPreparedStaticCert(prepareStaticTLSCertificates(certs), serverName) | |
| 3266 | 3266 | } | |
| 3267 | 3267 | ||
| 3268 | - | func startProxyServers(cfg Config, httpHandler, httpsHandler http.Handler, pkiSvc *pkiusecase.Service, publicTLS in.PublicTLSService, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}, *http.Server, <-chan struct{}, error) { | |
| 3268 | + | func startProxyServers(cfg Config, httpHandler, httpsHandler http.Handler, pkiSvc *pkiusecase.Service, publicTLS in.PublicTLSService, trafficManager *trafficadapter.Manager, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}, *http.Server, <-chan struct{}, error) { | |
| 3269 | 3269 | // HTTP listener (Cloudflare proxy + onboarding for direct clients) | |
| 3270 | 3270 | var httpProtos http.Protocols | |
| 3271 | 3271 | httpProtos.SetHTTP1(true) | |
| @@ -3311,6 +3311,14 @@ func startProxyServers(cfg Config, httpHandler, httpsHandler http.Handler, pkiSv | |||
| 3311 | 3311 | GetCertificate: selector.GetCertificate, | |
| 3312 | 3312 | NextProtos: []string{"h2", "http/1.1"}, | |
| 3313 | 3313 | } | |
| 3314 | + | if trafficManager != nil { | |
| 3315 | + | trafficManager.SetTLSFallback(traffic.DefaultTLSEntryPointName, func(ctx context.Context, conn net.Conn) { | |
| 3316 | + | if err := trafficadapter.ServeHTTPSFallback(ctx, conn, httpsHandler, tlsConfig.Clone()); err != nil { | |
| 3317 | + | log.Warn().Err(err).Msg("https fallback connection failed") | |
| 3318 | + | } | |
| 3319 | + | }) | |
| 3320 | + | } | |
| 3321 | + | ||
| 3314 | 3322 | tlsSrv, tlsReady := startTLSServerWithConfig( | |
| 3315 | 3323 | fmt.Sprintf(":%d", cfg.Server.TLSPort), | |
| 3316 | 3324 | httpsHandler, | |
| @@ -2823,7 +2823,7 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services, | |||||
| 2823 | } | 2823 | } | ||
| 2824 | } | 2824 | } | ||
| 2825 | 2825 | ||||
| 2826 | - | proxySrv, proxyReady, tlsSrv, tlsReady, err := startProxyServers(cfg, httpProxyHandler, httpsProxyHandler, svc.pkiSvc, svc.publicTLSSvc, errChan, log) | 2826 | + | proxySrv, proxyReady, tlsSrv, tlsReady, err := startProxyServers(cfg, httpProxyHandler, httpsProxyHandler, svc.pkiSvc, svc.publicTLSSvc, svc.trafficManager, errChan, log) |
| 2827 | if err != nil { | 2827 | if err != nil { | ||
| 2828 | closeStarted(registrySrv) | 2828 | closeStarted(registrySrv) | ||
| 2829 | return err | 2829 | return err | ||
| @@ -3265,7 +3265,7 @@ func matchingStaticCert(certs []tls.Certificate, serverName string) *tls.Certifi | |||||
| 3265 | return matchingPreparedStaticCert(prepareStaticTLSCertificates(certs), serverName) | 3265 | return matchingPreparedStaticCert(prepareStaticTLSCertificates(certs), serverName) | ||
| 3266 | } | 3266 | } | ||
| 3267 | 3267 | ||||
| 3268 | - | func startProxyServers(cfg Config, httpHandler, httpsHandler http.Handler, pkiSvc *pkiusecase.Service, publicTLS in.PublicTLSService, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}, *http.Server, <-chan struct{}, error) { | 3268 | + | func startProxyServers(cfg Config, httpHandler, httpsHandler http.Handler, pkiSvc *pkiusecase.Service, publicTLS in.PublicTLSService, trafficManager *trafficadapter.Manager, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}, *http.Server, <-chan struct{}, error) { |
| 3269 | // HTTP listener (Cloudflare proxy + onboarding for direct clients) | 3269 | // HTTP listener (Cloudflare proxy + onboarding for direct clients) | ||
| 3270 | var httpProtos http.Protocols | 3270 | var httpProtos http.Protocols | ||
| 3271 | httpProtos.SetHTTP1(true) | 3271 | httpProtos.SetHTTP1(true) | ||
| @@ -3311,6 +3311,14 @@ func startProxyServers(cfg Config, httpHandler, httpsHandler http.Handler, pkiSv | |||||
| 3311 | GetCertificate: selector.GetCertificate, | 3311 | GetCertificate: selector.GetCertificate, | ||
| 3312 | NextProtos: []string{"h2", "http/1.1"}, | 3312 | NextProtos: []string{"h2", "http/1.1"}, | ||
| 3313 | } | 3313 | } | ||
| 3314 | + | if trafficManager != nil { | |||
| 3315 | + | trafficManager.SetTLSFallback(traffic.DefaultTLSEntryPointName, func(ctx context.Context, conn net.Conn) { | |||
| 3316 | + | if err := trafficadapter.ServeHTTPSFallback(ctx, conn, httpsHandler, tlsConfig.Clone()); err != nil { | |||
| 3317 | + | log.Warn().Err(err).Msg("https fallback connection failed") | |||
| 3318 | + | } | |||
| 3319 | + | }) | |||
| 3320 | + | } | |||
| 3321 | + | ||||
| 3314 | tlsSrv, tlsReady := startTLSServerWithConfig( | 3322 | tlsSrv, tlsReady := startTLSServerWithConfig( | ||
| 3315 | fmt.Sprintf(":%d", cfg.Server.TLSPort), | 3323 | fmt.Sprintf(":%d", cfg.Server.TLSPort), | ||
| 3316 | httpsHandler, | 3324 | httpsHandler, | ||
M internal/app/traffic.go +6 -3
| @@ -26,13 +26,16 @@ func applyTrafficRuntimeConfig(ctx context.Context, manager *trafficadapter.Mana | |||
| 26 | 26 | NetworkServices: cfg.NetworkServices, | |
| 27 | 27 | }) | |
| 28 | 28 | if err != nil { | |
| 29 | - | return err | |
| 29 | + | return fmt.Errorf("build traffic graph: %w", err) | |
| 30 | 30 | } | |
| 31 | 31 | owned, err := trafficRuntimeGraph(graph) | |
| 32 | 32 | if err != nil { | |
| 33 | - | return err | |
| 33 | + | return fmt.Errorf("filter traffic graph for runtime ownership: %w", err) | |
| 34 | 34 | } | |
| 35 | - | return manager.Apply(ctx, &owned) | |
| 35 | + | if err := manager.Apply(ctx, &owned); err != nil { | |
| 36 | + | return fmt.Errorf("apply traffic graph: %w", err) | |
| 37 | + | } | |
| 38 | + | return nil | |
| 36 | 39 | } | |
| 37 | 40 | ||
| 38 | 41 | func trafficRuntimeGraph(graph domain.TrafficGraph) (domain.TrafficGraph, error) { | |
| @@ -26,13 +26,16 @@ func applyTrafficRuntimeConfig(ctx context.Context, manager *trafficadapter.Mana | |||||
| 26 | NetworkServices: cfg.NetworkServices, | 26 | NetworkServices: cfg.NetworkServices, | ||
| 27 | }) | 27 | }) | ||
| 28 | if err != nil { | 28 | if err != nil { | ||
| 29 | - | return err | 29 | + | return fmt.Errorf("build traffic graph: %w", err) |
| 30 | } | 30 | } | ||
| 31 | owned, err := trafficRuntimeGraph(graph) | 31 | owned, err := trafficRuntimeGraph(graph) | ||
| 32 | if err != nil { | 32 | if err != nil { | ||
| 33 | - | return err | 33 | + | return fmt.Errorf("filter traffic graph for runtime ownership: %w", err) |
| 34 | } | 34 | } | ||
| 35 | - | return manager.Apply(ctx, &owned) | 35 | + | if err := manager.Apply(ctx, &owned); err != nil { |
| 36 | + | return fmt.Errorf("apply traffic graph: %w", err) | |||
| 37 | + | } | |||
| 38 | + | return nil | |||
| 36 | } | 39 | } | ||
| 37 | 40 | ||||
| 38 | func trafficRuntimeGraph(graph domain.TrafficGraph) (domain.TrafficGraph, error) { | 41 | func trafficRuntimeGraph(graph domain.TrafficGraph) (domain.TrafficGraph, error) { | ||
M internal/domain/errors.go +1 -0
| @@ -49,6 +49,7 @@ var ( | |||
| 49 | 49 | ErrPreviewNotFound = errors.New("preview not found") | |
| 50 | 50 | ||
| 51 | 51 | // Config errors | |
| 52 | + | ErrTrafficGraphRequired = errors.New("traffic graph is required") | |
| 52 | 53 | ErrConfigNotFound = errors.New("configuration not found") | |
| 53 | 54 | ErrInvalidConfig = errors.New("invalid configuration") | |
| 54 | 55 | ErrConfigLoadFailed = errors.New("failed to load configuration") | |
| @@ -49,6 +49,7 @@ var ( | |||||
| 49 | ErrPreviewNotFound = errors.New("preview not found") | 49 | ErrPreviewNotFound = errors.New("preview not found") | ||
| 50 | 50 | ||||
| 51 | // Config errors | 51 | // Config errors | ||
| 52 | + | ErrTrafficGraphRequired = errors.New("traffic graph is required") | |||
| 52 | ErrConfigNotFound = errors.New("configuration not found") | 53 | ErrConfigNotFound = errors.New("configuration not found") | ||
| 53 | ErrInvalidConfig = errors.New("invalid configuration") | 54 | ErrInvalidConfig = errors.New("invalid configuration") | ||
| 54 | ErrConfigLoadFailed = errors.New("failed to load configuration") | 55 | ErrConfigLoadFailed = errors.New("failed to load configuration") | ||
M internal/domain/traffic.go +14 -25
| @@ -114,19 +114,12 @@ type EntryPointStatus struct { | |||
| 114 | 114 | } | |
| 115 | 115 | ||
| 116 | 116 | type TrafficRouterStatus struct { | |
| 117 | - | Name string | |
| 118 | - | EntryPoint string | |
| 119 | - | Protocol RouterProtocol | |
| 120 | - | Rule TrafficRule | |
| 121 | - | Service string | |
| 122 | - | Active bool | |
| 123 | - | ActiveTCPConnections int64 | |
| 124 | - | ActiveUDPSessions int64 | |
| 125 | - | TotalAccepted int64 | |
| 126 | - | TotalRefused int64 | |
| 127 | - | TotalErrors int64 | |
| 128 | - | BytesIn int64 | |
| 129 | - | BytesOut int64 | |
| 117 | + | Name string | |
| 118 | + | EntryPoint string | |
| 119 | + | Protocol RouterProtocol | |
| 120 | + | Rule TrafficRule | |
| 121 | + | Service string | |
| 122 | + | Active bool | |
| 130 | 123 | } | |
| 131 | 124 | ||
| 132 | 125 | type TrafficServiceStatus struct { | |
| @@ -136,18 +129,11 @@ type TrafficServiceStatus struct { | |||
| 136 | 129 | } | |
| 137 | 130 | ||
| 138 | 131 | type TrafficBackendStatus struct { | |
| 139 | - | Name string | |
| 140 | - | Host string | |
| 141 | - | Port int | |
| 142 | - | Protocol NetworkProtocol | |
| 143 | - | Active bool | |
| 144 | - | ActiveTCPConnections int64 | |
| 145 | - | ActiveUDPSessions int64 | |
| 146 | - | TotalAccepted int64 | |
| 147 | - | TotalRefused int64 | |
| 148 | - | TotalErrors int64 | |
| 149 | - | BytesIn int64 | |
| 150 | - | BytesOut int64 | |
| 132 | + | Name string | |
| 133 | + | Host string | |
| 134 | + | Port int | |
| 135 | + | Protocol NetworkProtocol | |
| 136 | + | Active bool | |
| 151 | 137 | } | |
| 152 | 138 | ||
| 153 | 139 | type TrafficCounters struct { | |
| @@ -290,6 +276,9 @@ func validateTrafficService(service TrafficService, existing map[string]TrafficS | |||
| 290 | 276 | } | |
| 291 | 277 | ||
| 292 | 278 | func validateTrafficBackend(serviceName string, backend TrafficBackend) error { | |
| 279 | + | if strings.TrimSpace(backend.Host) == "" { | |
| 280 | + | return fmt.Errorf("backend host is required for service %q", serviceName) | |
| 281 | + | } | |
| 293 | 282 | if backend.Port < 1 || backend.Port > 65535 { | |
| 294 | 283 | return fmt.Errorf("invalid backend port %d for service %q", backend.Port, serviceName) | |
| 295 | 284 | } | |
| @@ -114,19 +114,12 @@ type EntryPointStatus struct { | |||||
| 114 | } | 114 | } | ||
| 115 | 115 | ||||
| 116 | type TrafficRouterStatus struct { | 116 | type TrafficRouterStatus struct { | ||
| 117 | - | Name string | 117 | + | Name string |
| 118 | - | EntryPoint string | 118 | + | EntryPoint string |
| 119 | - | Protocol RouterProtocol | 119 | + | Protocol RouterProtocol |
| 120 | - | Rule TrafficRule | 120 | + | Rule TrafficRule |
| 121 | - | Service string | 121 | + | Service string |
| 122 | - | Active bool | 122 | + | Active bool |
| 123 | - | ActiveTCPConnections int64 | |||
| 124 | - | ActiveUDPSessions int64 | |||
| 125 | - | TotalAccepted int64 | |||
| 126 | - | TotalRefused int64 | |||
| 127 | - | TotalErrors int64 | |||
| 128 | - | BytesIn int64 | |||
| 129 | - | BytesOut int64 | |||
| 130 | } | 123 | } | ||
| 131 | 124 | ||||
| 132 | type TrafficServiceStatus struct { | 125 | type TrafficServiceStatus struct { | ||
| @@ -136,18 +129,11 @@ type TrafficServiceStatus struct { | |||||
| 136 | } | 129 | } | ||
| 137 | 130 | ||||
| 138 | type TrafficBackendStatus struct { | 131 | type TrafficBackendStatus struct { | ||
| 139 | - | Name string | 132 | + | Name string |
| 140 | - | Host string | 133 | + | Host string |
| 141 | - | Port int | 134 | + | Port int |
| 142 | - | Protocol NetworkProtocol | 135 | + | Protocol NetworkProtocol |
| 143 | - | Active bool | 136 | + | Active bool |
| 144 | - | ActiveTCPConnections int64 | |||
| 145 | - | ActiveUDPSessions int64 | |||
| 146 | - | TotalAccepted int64 | |||
| 147 | - | TotalRefused int64 | |||
| 148 | - | TotalErrors int64 | |||
| 149 | - | BytesIn int64 | |||
| 150 | - | BytesOut int64 | |||
| 151 | } | 137 | } | ||
| 152 | 138 | ||||
| 153 | type TrafficCounters struct { | 139 | type TrafficCounters struct { | ||
| @@ -290,6 +276,9 @@ func validateTrafficService(service TrafficService, existing map[string]TrafficS | |||||
| 290 | } | 276 | } | ||
| 291 | 277 | ||||
| 292 | func validateTrafficBackend(serviceName string, backend TrafficBackend) error { | 278 | func validateTrafficBackend(serviceName string, backend TrafficBackend) error { | ||
| 279 | + | if strings.TrimSpace(backend.Host) == "" { | |||
| 280 | + | return fmt.Errorf("backend host is required for service %q", serviceName) | |||
| 281 | + | } | |||
| 293 | if backend.Port < 1 || backend.Port > 65535 { | 282 | if backend.Port < 1 || backend.Port > 65535 { | ||
| 294 | return fmt.Errorf("invalid backend port %d for service %q", backend.Port, serviceName) | 283 | return fmt.Errorf("invalid backend port %d for service %q", backend.Port, serviceName) | ||
| 295 | } | 284 | } | ||
M internal/domain/traffic_test.go +19 -13
| @@ -346,55 +346,61 @@ func TestTrafficGraphValidateBackendsAndL4Routers(t *testing.T) { | |||
| 346 | 346 | services []TrafficService | |
| 347 | 347 | wantErr string | |
| 348 | 348 | }{ | |
| 349 | + | { | |
| 350 | + | name: "backend host required", | |
| 351 | + | router: TrafficRouter{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "network_service:app:db"}, | |
| 352 | + | services: []TrafficService{{Name: "network_service:app:db", Backends: []TrafficBackend{{Name: "app", Port: 5432, Protocol: NetworkProtocolTCP}}}}, | |
| 353 | + | wantErr: "backend host is required", | |
| 354 | + | }, | |
| 349 | 355 | { | |
| 350 | 356 | name: "backend ports outside range rejected low", | |
| 351 | 357 | router: TrafficRouter{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "network_service:app:db"}, | |
| 352 | - | services: []TrafficService{{Name: "network_service:app:db", Backends: []TrafficBackend{{Name: "app", Port: 0, Protocol: NetworkProtocolTCP}}}}, | |
| 358 | + | services: []TrafficService{{Name: "network_service:app:db", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 0, Protocol: NetworkProtocolTCP}}}}, | |
| 353 | 359 | wantErr: "invalid backend port", | |
| 354 | 360 | }, | |
| 355 | 361 | { | |
| 356 | 362 | name: "backend ports outside range rejected high", | |
| 357 | 363 | router: TrafficRouter{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "network_service:app:db"}, | |
| 358 | - | services: []TrafficService{{Name: "network_service:app:db", Backends: []TrafficBackend{{Name: "app", Port: 65536, Protocol: NetworkProtocolTCP}}}}, | |
| 364 | + | services: []TrafficService{{Name: "network_service:app:db", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 65536, Protocol: NetworkProtocolTCP}}}}, | |
| 359 | 365 | wantErr: "invalid backend port", | |
| 360 | 366 | }, | |
| 361 | 367 | { | |
| 362 | 368 | name: "l4 routers reject services with more than one backend", | |
| 363 | 369 | router: TrafficRouter{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "network_service:app:db"}, | |
| 364 | 370 | services: []TrafficService{{Name: "network_service:app:db", Backends: []TrafficBackend{ | |
| 365 | - | {Name: "app", Port: 5432, Protocol: NetworkProtocolTCP}, | |
| 366 | - | {Name: "app2", Port: 5432, Protocol: NetworkProtocolTCP}, | |
| 371 | + | {Name: "app", Host: "10.0.0.2", Port: 5432, Protocol: NetworkProtocolTCP}, | |
| 372 | + | {Name: "app2", Host: "10.0.0.3", Port: 5432, Protocol: NetworkProtocolTCP}, | |
| 367 | 373 | }}}, | |
| 368 | 374 | wantErr: "l4 router service must have exactly one backend", | |
| 369 | 375 | }, | |
| 370 | 376 | { | |
| 371 | 377 | name: "tcp router requires tcp network backend", | |
| 372 | 378 | router: TrafficRouter{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "network_service:app:db"}, | |
| 373 | - | services: []TrafficService{{Name: "network_service:app:db", Backends: []TrafficBackend{{Name: "app", Port: 53, Protocol: NetworkProtocolUDP}}}}, | |
| 379 | + | services: []TrafficService{{Name: "network_service:app:db", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 53, Protocol: NetworkProtocolUDP}}}}, | |
| 374 | 380 | wantErr: "backend protocol udp does not match router protocol tcp", | |
| 375 | 381 | }, | |
| 376 | 382 | { | |
| 377 | 383 | name: "udp router requires udp network backend", | |
| 378 | 384 | router: TrafficRouter{Name: "udp", EntryPoint: "udp", Protocol: RouterProtocolUDP, Service: "network_service:app:game"}, | |
| 379 | - | services: []TrafficService{{Name: "network_service:app:game", Backends: []TrafficBackend{{Name: "app", Port: 53, Protocol: NetworkProtocolTCP}}}}, | |
| 385 | + | services: []TrafficService{{Name: "network_service:app:game", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 53, Protocol: NetworkProtocolTCP}}}}, | |
| 380 | 386 | wantErr: "backend protocol tcp does not match router protocol udp", | |
| 381 | 387 | }, | |
| 382 | 388 | { | |
| 383 | 389 | name: "tls passthrough requires tcp network backend", | |
| 384 | 390 | router: TrafficRouter{Name: "tls", EntryPoint: "tls", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "db.example.com"}, Service: "network_service:app:https"}, | |
| 385 | - | services: []TrafficService{{Name: "network_service:app:https", Backends: []TrafficBackend{{Name: "app", Port: 443, Protocol: NetworkProtocolUDP}}}}, | |
| 391 | + | services: []TrafficService{{Name: "network_service:app:https", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 443, Protocol: NetworkProtocolUDP}}}}, | |
| 386 | 392 | wantErr: "backend protocol udp does not match router protocol tls_passthrough", | |
| 387 | 393 | }, | |
| 388 | 394 | { | |
| 389 | 395 | name: "l4 router rejects route ref", | |
| 390 | 396 | router: TrafficRouter{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "route:app.example.com"}, | |
| 391 | - | services: []TrafficService{{Name: "route:app.example.com", Backends: []TrafficBackend{{Name: "app", Port: 443, Protocol: NetworkProtocolTCP}}}}, | |
| 397 | + | services: []TrafficService{{Name: "route:app.example.com", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 443, Protocol: NetworkProtocolTCP}}}}, | |
| 392 | 398 | wantErr: "requires network_service service ref", | |
| 393 | 399 | }, | |
| 394 | 400 | { | |
| 395 | 401 | name: "static ref parses but graph validation rejects unsupported", | |
| 396 | 402 | router: TrafficRouter{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "static:maintenance"}, | |
| 397 | - | services: []TrafficService{{Name: "static:maintenance", Backends: []TrafficBackend{{Name: "maintenance", Port: 443, Protocol: NetworkProtocolTCP}}}}, | |
| 403 | + | services: []TrafficService{{Name: "static:maintenance", Backends: []TrafficBackend{{Name: "maintenance", Host: "10.0.0.2", Port: 443, Protocol: NetworkProtocolTCP}}}}, | |
| 398 | 404 | wantErr: "static traffic service ref \"static:maintenance\" is unsupported", | |
| 399 | 405 | }, | |
| 400 | 406 | } | |
| @@ -517,14 +523,14 @@ func TestTrafficDomainTypesMatchSpecFields(t *testing.T) { | |||
| 517 | 523 | LastReloadStatus: "ok", | |
| 518 | 524 | LastReloadError: "", | |
| 519 | 525 | EntryPoints: []EntryPointStatus{{Name: "tcp", ActiveTCPConnections: 1, ActiveUDPSessions: 2, TotalAccepted: 3, TotalRefused: 4, TotalErrors: 5, BytesIn: 6, BytesOut: 7}}, | |
| 520 | - | Routers: []TrafficRouterStatus{{Name: "tcp", ActiveTCPConnections: 1, ActiveUDPSessions: 2, TotalAccepted: 3, TotalRefused: 4, TotalErrors: 5, BytesIn: 6, BytesOut: 7}}, | |
| 521 | - | Services: []TrafficServiceStatus{{Name: "network_service:app:game", Backends: []TrafficBackendStatus{{Name: "app", ActiveTCPConnections: 1, ActiveUDPSessions: 2, TotalAccepted: 3, TotalRefused: 4, TotalErrors: 5, BytesIn: 6, BytesOut: 7}}}}, | |
| 526 | + | Routers: []TrafficRouterStatus{{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "network_service:app:game", Active: true}}, | |
| 527 | + | Services: []TrafficServiceStatus{{Name: "network_service:app:game", Backends: []TrafficBackendStatus{{Name: "app", Host: "10.0.0.2", Port: 1234, Protocol: NetworkProtocolTCP, Active: true}}}}, | |
| 522 | 528 | Counters: TrafficCounters{ActiveTCPConnections: 1, ActiveUDPSessions: 2, TotalAccepted: 3, TotalRefused: 4, TotalErrors: 5, BytesIn: 6, BytesOut: 7}, | |
| 523 | 529 | } | |
| 524 | 530 | require.Equal(t, "ok", status.LastReloadStatus) | |
| 525 | 531 | require.Empty(t, status.LastReloadError) | |
| 526 | 532 | require.Equal(t, int64(7), status.EntryPoints[0].BytesOut) | |
| 527 | - | require.Equal(t, int64(7), status.Routers[0].BytesOut) | |
| 528 | - | require.Equal(t, int64(7), status.Services[0].Backends[0].BytesOut) | |
| 533 | + | require.True(t, status.Routers[0].Active) | |
| 534 | + | require.True(t, status.Services[0].Backends[0].Active) | |
| 529 | 535 | require.Equal(t, int64(7), status.Counters.BytesOut) | |
| 530 | 536 | } | |
| @@ -346,55 +346,61 @@ func TestTrafficGraphValidateBackendsAndL4Routers(t *testing.T) { | |||||
| 346 | services []TrafficService | 346 | services []TrafficService | ||
| 347 | wantErr string | 347 | wantErr string | ||
| 348 | }{ | 348 | }{ | ||
| 349 | + | { | |||
| 350 | + | name: "backend host required", | |||
| 351 | + | router: TrafficRouter{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "network_service:app:db"}, | |||
| 352 | + | services: []TrafficService{{Name: "network_service:app:db", Backends: []TrafficBackend{{Name: "app", Port: 5432, Protocol: NetworkProtocolTCP}}}}, | |||
| 353 | + | wantErr: "backend host is required", | |||
| 354 | + | }, | |||
| 349 | { | 355 | { | ||
| 350 | name: "backend ports outside range rejected low", | 356 | name: "backend ports outside range rejected low", | ||
| 351 | router: TrafficRouter{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "network_service:app:db"}, | 357 | router: TrafficRouter{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "network_service:app:db"}, | ||
| 352 | - | services: []TrafficService{{Name: "network_service:app:db", Backends: []TrafficBackend{{Name: "app", Port: 0, Protocol: NetworkProtocolTCP}}}}, | 358 | + | services: []TrafficService{{Name: "network_service:app:db", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 0, Protocol: NetworkProtocolTCP}}}}, |
| 353 | wantErr: "invalid backend port", | 359 | wantErr: "invalid backend port", | ||
| 354 | }, | 360 | }, | ||
| 355 | { | 361 | { | ||
| 356 | name: "backend ports outside range rejected high", | 362 | name: "backend ports outside range rejected high", | ||
| 357 | router: TrafficRouter{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "network_service:app:db"}, | 363 | router: TrafficRouter{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "network_service:app:db"}, | ||
| 358 | - | services: []TrafficService{{Name: "network_service:app:db", Backends: []TrafficBackend{{Name: "app", Port: 65536, Protocol: NetworkProtocolTCP}}}}, | 364 | + | services: []TrafficService{{Name: "network_service:app:db", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 65536, Protocol: NetworkProtocolTCP}}}}, |
| 359 | wantErr: "invalid backend port", | 365 | wantErr: "invalid backend port", | ||
| 360 | }, | 366 | }, | ||
| 361 | { | 367 | { | ||
| 362 | name: "l4 routers reject services with more than one backend", | 368 | name: "l4 routers reject services with more than one backend", | ||
| 363 | router: TrafficRouter{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "network_service:app:db"}, | 369 | router: TrafficRouter{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "network_service:app:db"}, | ||
| 364 | services: []TrafficService{{Name: "network_service:app:db", Backends: []TrafficBackend{ | 370 | services: []TrafficService{{Name: "network_service:app:db", Backends: []TrafficBackend{ | ||
| 365 | - | {Name: "app", Port: 5432, Protocol: NetworkProtocolTCP}, | 371 | + | {Name: "app", Host: "10.0.0.2", Port: 5432, Protocol: NetworkProtocolTCP}, |
| 366 | - | {Name: "app2", Port: 5432, Protocol: NetworkProtocolTCP}, | 372 | + | {Name: "app2", Host: "10.0.0.3", Port: 5432, Protocol: NetworkProtocolTCP}, |
| 367 | }}}, | 373 | }}}, | ||
| 368 | wantErr: "l4 router service must have exactly one backend", | 374 | wantErr: "l4 router service must have exactly one backend", | ||
| 369 | }, | 375 | }, | ||
| 370 | { | 376 | { | ||
| 371 | name: "tcp router requires tcp network backend", | 377 | name: "tcp router requires tcp network backend", | ||
| 372 | router: TrafficRouter{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "network_service:app:db"}, | 378 | router: TrafficRouter{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "network_service:app:db"}, | ||
| 373 | - | services: []TrafficService{{Name: "network_service:app:db", Backends: []TrafficBackend{{Name: "app", Port: 53, Protocol: NetworkProtocolUDP}}}}, | 379 | + | services: []TrafficService{{Name: "network_service:app:db", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 53, Protocol: NetworkProtocolUDP}}}}, |
| 374 | wantErr: "backend protocol udp does not match router protocol tcp", | 380 | wantErr: "backend protocol udp does not match router protocol tcp", | ||
| 375 | }, | 381 | }, | ||
| 376 | { | 382 | { | ||
| 377 | name: "udp router requires udp network backend", | 383 | name: "udp router requires udp network backend", | ||
| 378 | router: TrafficRouter{Name: "udp", EntryPoint: "udp", Protocol: RouterProtocolUDP, Service: "network_service:app:game"}, | 384 | router: TrafficRouter{Name: "udp", EntryPoint: "udp", Protocol: RouterProtocolUDP, Service: "network_service:app:game"}, | ||
| 379 | - | services: []TrafficService{{Name: "network_service:app:game", Backends: []TrafficBackend{{Name: "app", Port: 53, Protocol: NetworkProtocolTCP}}}}, | 385 | + | services: []TrafficService{{Name: "network_service:app:game", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 53, Protocol: NetworkProtocolTCP}}}}, |
| 380 | wantErr: "backend protocol tcp does not match router protocol udp", | 386 | wantErr: "backend protocol tcp does not match router protocol udp", | ||
| 381 | }, | 387 | }, | ||
| 382 | { | 388 | { | ||
| 383 | name: "tls passthrough requires tcp network backend", | 389 | name: "tls passthrough requires tcp network backend", | ||
| 384 | router: TrafficRouter{Name: "tls", EntryPoint: "tls", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "db.example.com"}, Service: "network_service:app:https"}, | 390 | router: TrafficRouter{Name: "tls", EntryPoint: "tls", Protocol: RouterProtocolTLSPassthrough, Rule: TrafficRule{SNI: "db.example.com"}, Service: "network_service:app:https"}, | ||
| 385 | - | services: []TrafficService{{Name: "network_service:app:https", Backends: []TrafficBackend{{Name: "app", Port: 443, Protocol: NetworkProtocolUDP}}}}, | 391 | + | services: []TrafficService{{Name: "network_service:app:https", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 443, Protocol: NetworkProtocolUDP}}}}, |
| 386 | wantErr: "backend protocol udp does not match router protocol tls_passthrough", | 392 | wantErr: "backend protocol udp does not match router protocol tls_passthrough", | ||
| 387 | }, | 393 | }, | ||
| 388 | { | 394 | { | ||
| 389 | name: "l4 router rejects route ref", | 395 | name: "l4 router rejects route ref", | ||
| 390 | router: TrafficRouter{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "route:app.example.com"}, | 396 | router: TrafficRouter{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "route:app.example.com"}, | ||
| 391 | - | services: []TrafficService{{Name: "route:app.example.com", Backends: []TrafficBackend{{Name: "app", Port: 443, Protocol: NetworkProtocolTCP}}}}, | 397 | + | services: []TrafficService{{Name: "route:app.example.com", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 443, Protocol: NetworkProtocolTCP}}}}, |
| 392 | wantErr: "requires network_service service ref", | 398 | wantErr: "requires network_service service ref", | ||
| 393 | }, | 399 | }, | ||
| 394 | { | 400 | { | ||
| 395 | name: "static ref parses but graph validation rejects unsupported", | 401 | name: "static ref parses but graph validation rejects unsupported", | ||
| 396 | router: TrafficRouter{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "static:maintenance"}, | 402 | router: TrafficRouter{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "static:maintenance"}, | ||
| 397 | - | services: []TrafficService{{Name: "static:maintenance", Backends: []TrafficBackend{{Name: "maintenance", Port: 443, Protocol: NetworkProtocolTCP}}}}, | 403 | + | services: []TrafficService{{Name: "static:maintenance", Backends: []TrafficBackend{{Name: "maintenance", Host: "10.0.0.2", Port: 443, Protocol: NetworkProtocolTCP}}}}, |
| 398 | wantErr: "static traffic service ref \"static:maintenance\" is unsupported", | 404 | wantErr: "static traffic service ref \"static:maintenance\" is unsupported", | ||
| 399 | }, | 405 | }, | ||
| 400 | } | 406 | } | ||
| @@ -517,14 +523,14 @@ func TestTrafficDomainTypesMatchSpecFields(t *testing.T) { | |||||
| 517 | LastReloadStatus: "ok", | 523 | LastReloadStatus: "ok", | ||
| 518 | LastReloadError: "", | 524 | LastReloadError: "", | ||
| 519 | EntryPoints: []EntryPointStatus{{Name: "tcp", ActiveTCPConnections: 1, ActiveUDPSessions: 2, TotalAccepted: 3, TotalRefused: 4, TotalErrors: 5, BytesIn: 6, BytesOut: 7}}, | 525 | EntryPoints: []EntryPointStatus{{Name: "tcp", ActiveTCPConnections: 1, ActiveUDPSessions: 2, TotalAccepted: 3, TotalRefused: 4, TotalErrors: 5, BytesIn: 6, BytesOut: 7}}, | ||
| 520 | - | Routers: []TrafficRouterStatus{{Name: "tcp", ActiveTCPConnections: 1, ActiveUDPSessions: 2, TotalAccepted: 3, TotalRefused: 4, TotalErrors: 5, BytesIn: 6, BytesOut: 7}}, | 526 | + | Routers: []TrafficRouterStatus{{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "network_service:app:game", Active: true}}, |
| 521 | - | Services: []TrafficServiceStatus{{Name: "network_service:app:game", Backends: []TrafficBackendStatus{{Name: "app", ActiveTCPConnections: 1, ActiveUDPSessions: 2, TotalAccepted: 3, TotalRefused: 4, TotalErrors: 5, BytesIn: 6, BytesOut: 7}}}}, | 527 | + | Services: []TrafficServiceStatus{{Name: "network_service:app:game", Backends: []TrafficBackendStatus{{Name: "app", Host: "10.0.0.2", Port: 1234, Protocol: NetworkProtocolTCP, Active: true}}}}, |
| 522 | Counters: TrafficCounters{ActiveTCPConnections: 1, ActiveUDPSessions: 2, TotalAccepted: 3, TotalRefused: 4, TotalErrors: 5, BytesIn: 6, BytesOut: 7}, | 528 | Counters: TrafficCounters{ActiveTCPConnections: 1, ActiveUDPSessions: 2, TotalAccepted: 3, TotalRefused: 4, TotalErrors: 5, BytesIn: 6, BytesOut: 7}, | ||
| 523 | } | 529 | } | ||
| 524 | require.Equal(t, "ok", status.LastReloadStatus) | 530 | require.Equal(t, "ok", status.LastReloadStatus) | ||
| 525 | require.Empty(t, status.LastReloadError) | 531 | require.Empty(t, status.LastReloadError) | ||
| 526 | require.Equal(t, int64(7), status.EntryPoints[0].BytesOut) | 532 | require.Equal(t, int64(7), status.EntryPoints[0].BytesOut) | ||
| 527 | - | require.Equal(t, int64(7), status.Routers[0].BytesOut) | 533 | + | require.True(t, status.Routers[0].Active) |
| 528 | - | require.Equal(t, int64(7), status.Services[0].Backends[0].BytesOut) | 534 | + | require.True(t, status.Services[0].Backends[0].Active) |
| 529 | require.Equal(t, int64(7), status.Counters.BytesOut) | 535 | require.Equal(t, int64(7), status.Counters.BytesOut) | ||
| 530 | } | 536 | } | ||
M internal/usecase/traffic/builder.go +37 -11
| @@ -90,22 +90,22 @@ func Build(input Input) (domain.TrafficGraph, error) { | |||
| 90 | 90 | ||
| 91 | 91 | options, err := buildOptions(input.Traffic) | |
| 92 | 92 | if err != nil { | |
| 93 | - | return domain.TrafficGraph{}, err | |
| 93 | + | return domain.TrafficGraph{}, fmt.Errorf("build traffic options: %w", err) | |
| 94 | 94 | } | |
| 95 | 95 | if err := validateNetworkServices(input.NetworkServices); err != nil { | |
| 96 | - | return domain.TrafficGraph{}, err | |
| 96 | + | return domain.TrafficGraph{}, fmt.Errorf("validate network services: %w", err) | |
| 97 | 97 | } | |
| 98 | 98 | graph.Options = options | |
| 99 | 99 | graph.EntryPoints = b.buildEntryPoints() | |
| 100 | 100 | ||
| 101 | 101 | if err := b.addHTTPRoutes(&graph); err != nil { | |
| 102 | - | return domain.TrafficGraph{}, err | |
| 102 | + | return domain.TrafficGraph{}, fmt.Errorf("add HTTP routes: %w", err) | |
| 103 | 103 | } | |
| 104 | 104 | if err := b.addExternalRoutes(&graph); err != nil { | |
| 105 | - | return domain.TrafficGraph{}, err | |
| 105 | + | return domain.TrafficGraph{}, fmt.Errorf("add external routes: %w", err) | |
| 106 | 106 | } | |
| 107 | 107 | if err := b.addExplicitRouters(&graph); err != nil { | |
| 108 | - | return domain.TrafficGraph{}, err | |
| 108 | + | return domain.TrafficGraph{}, fmt.Errorf("add explicit traffic routers: %w", err) | |
| 109 | 109 | } | |
| 110 | 110 | ||
| 111 | 111 | graph.Services = make([]domain.TrafficService, 0, len(b.services)) | |
| @@ -114,7 +114,7 @@ func Build(input Input) (domain.TrafficGraph, error) { | |||
| 114 | 114 | } | |
| 115 | 115 | sort.Slice(graph.Services, func(i, j int) bool { return graph.Services[i].Name < graph.Services[j].Name }) | |
| 116 | 116 | if err := graph.Validate(); err != nil { | |
| 117 | - | return domain.TrafficGraph{}, err | |
| 117 | + | return domain.TrafficGraph{}, fmt.Errorf("validate traffic graph: %w", err) | |
| 118 | 118 | } | |
| 119 | 119 | return graph, nil | |
| 120 | 120 | } | |
| @@ -246,11 +246,26 @@ func validateNetworkServicePorts(service NetworkServiceConfig) error { | |||
| 246 | 246 | if _, exists := seen[port.Name]; exists { | |
| 247 | 247 | return fmt.Errorf("duplicate port %q on network service %q", port.Name, service.Name) | |
| 248 | 248 | } | |
| 249 | + | if port.Container < 1 || port.Container > 65535 { | |
| 250 | + | return fmt.Errorf("invalid container port %d on network service %q port %q", port.Container, service.Name, port.Name) | |
| 251 | + | } | |
| 252 | + | if err := validateNetworkServicePortProtocol(port.Protocol); err != nil { | |
| 253 | + | return fmt.Errorf("invalid protocol for network service %q port %q: %w", service.Name, port.Name, err) | |
| 254 | + | } | |
| 249 | 255 | seen[port.Name] = struct{}{} | |
| 250 | 256 | } | |
| 251 | 257 | return nil | |
| 252 | 258 | } | |
| 253 | 259 | ||
| 260 | + | func validateNetworkServicePortProtocol(protocol domain.NetworkProtocol) error { | |
| 261 | + | switch protocol { | |
| 262 | + | case domain.NetworkProtocolTCP, domain.NetworkProtocolUDP: | |
| 263 | + | return nil | |
| 264 | + | default: | |
| 265 | + | return fmt.Errorf("%q", protocol) | |
| 266 | + | } | |
| 267 | + | } | |
| 268 | + | ||
| 254 | 269 | func (b *builder) networkService(name string) (NetworkServiceConfig, bool) { | |
| 255 | 270 | for _, service := range b.input.NetworkServices { | |
| 256 | 271 | if service.Name == name { | |
| @@ -276,29 +291,40 @@ func (b *builder) addService(service domain.TrafficService) { | |||
| 276 | 291 | } | |
| 277 | 292 | ||
| 278 | 293 | func buildOptions(cfg Config) (domain.TrafficOptions, error) { | |
| 279 | - | tcpDial, err := parseDurationDefault(cfg.TCP.DialTimeout, 10*time.Second, "traffic.tcp.dial_timeout") | |
| 294 | + | tcpDial, err := parsePositiveDurationDefault(cfg.TCP.DialTimeout, 10*time.Second, "traffic.tcp.dial_timeout") | |
| 280 | 295 | if err != nil { | |
| 281 | 296 | return domain.TrafficOptions{}, err | |
| 282 | 297 | } | |
| 283 | - | tcpIdle, err := parseDurationDefault(cfg.TCP.IdleTimeout, 5*time.Minute, "traffic.tcp.idle_timeout") | |
| 298 | + | tcpIdle, err := parsePositiveDurationDefault(cfg.TCP.IdleTimeout, 5*time.Minute, "traffic.tcp.idle_timeout") | |
| 284 | 299 | if err != nil { | |
| 285 | 300 | return domain.TrafficOptions{}, err | |
| 286 | 301 | } | |
| 287 | - | tcpDrain, err := parseDurationDefault(cfg.TCP.DrainTimeout, 30*time.Second, "traffic.tcp.drain_timeout") | |
| 302 | + | tcpDrain, err := parsePositiveDurationDefault(cfg.TCP.DrainTimeout, 30*time.Second, "traffic.tcp.drain_timeout") | |
| 288 | 303 | if err != nil { | |
| 289 | 304 | return domain.TrafficOptions{}, err | |
| 290 | 305 | } | |
| 291 | - | udpIdle, err := parseDurationDefault(cfg.UDP.IdleTimeout, 30*time.Second, "traffic.udp.idle_timeout") | |
| 306 | + | udpIdle, err := parsePositiveDurationDefault(cfg.UDP.IdleTimeout, 30*time.Second, "traffic.udp.idle_timeout") | |
| 292 | 307 | if err != nil { | |
| 293 | 308 | return domain.TrafficOptions{}, err | |
| 294 | 309 | } | |
| 295 | - | udpDrain, err := parseDurationDefault(cfg.UDP.DrainTimeout, 30*time.Second, "traffic.udp.drain_timeout") | |
| 310 | + | udpDrain, err := parsePositiveDurationDefault(cfg.UDP.DrainTimeout, 30*time.Second, "traffic.udp.drain_timeout") | |
| 296 | 311 | if err != nil { | |
| 297 | 312 | return domain.TrafficOptions{}, err | |
| 298 | 313 | } | |
| 299 | 314 | return domain.TrafficOptions{TCP: domain.TCPOptions{DialTimeout: tcpDial, IdleTimeout: tcpIdle, DrainTimeout: tcpDrain, MaxConnections: cfg.TCP.MaxConnections}, UDP: domain.UDPOptions{IdleTimeout: udpIdle, DrainTimeout: udpDrain, MaxSessions: cfg.UDP.MaxSessions}}, nil | |
| 300 | 315 | } | |
| 301 | 316 | ||
| 317 | + | func parsePositiveDurationDefault(value string, fallback time.Duration, field string) (time.Duration, error) { | |
| 318 | + | d, err := parseDurationDefault(value, fallback, field) | |
| 319 | + | if err != nil { | |
| 320 | + | return 0, err | |
| 321 | + | } | |
| 322 | + | if d <= 0 { | |
| 323 | + | return 0, fmt.Errorf("invalid %s: must be positive", field) | |
| 324 | + | } | |
| 325 | + | return d, nil | |
| 326 | + | } | |
| 327 | + | ||
| 302 | 328 | func parseDurationDefault(value string, fallback time.Duration, field string) (time.Duration, error) { | |
| 303 | 329 | if strings.TrimSpace(value) == "" { | |
| 304 | 330 | return fallback, nil | |
| @@ -90,22 +90,22 @@ func Build(input Input) (domain.TrafficGraph, error) { | |||||
| 90 | 90 | ||||
| 91 | options, err := buildOptions(input.Traffic) | 91 | options, err := buildOptions(input.Traffic) | ||
| 92 | if err != nil { | 92 | if err != nil { | ||
| 93 | - | return domain.TrafficGraph{}, err | 93 | + | return domain.TrafficGraph{}, fmt.Errorf("build traffic options: %w", err) |
| 94 | } | 94 | } | ||
| 95 | if err := validateNetworkServices(input.NetworkServices); err != nil { | 95 | if err := validateNetworkServices(input.NetworkServices); err != nil { | ||
| 96 | - | return domain.TrafficGraph{}, err | 96 | + | return domain.TrafficGraph{}, fmt.Errorf("validate network services: %w", err) |
| 97 | } | 97 | } | ||
| 98 | graph.Options = options | 98 | graph.Options = options | ||
| 99 | graph.EntryPoints = b.buildEntryPoints() | 99 | graph.EntryPoints = b.buildEntryPoints() | ||
| 100 | 100 | ||||
| 101 | if err := b.addHTTPRoutes(&graph); err != nil { | 101 | if err := b.addHTTPRoutes(&graph); err != nil { | ||
| 102 | - | return domain.TrafficGraph{}, err | 102 | + | return domain.TrafficGraph{}, fmt.Errorf("add HTTP routes: %w", err) |
| 103 | } | 103 | } | ||
| 104 | if err := b.addExternalRoutes(&graph); err != nil { | 104 | if err := b.addExternalRoutes(&graph); err != nil { | ||
| 105 | - | return domain.TrafficGraph{}, err | 105 | + | return domain.TrafficGraph{}, fmt.Errorf("add external routes: %w", err) |
| 106 | } | 106 | } | ||
| 107 | if err := b.addExplicitRouters(&graph); err != nil { | 107 | if err := b.addExplicitRouters(&graph); err != nil { | ||
| 108 | - | return domain.TrafficGraph{}, err | 108 | + | return domain.TrafficGraph{}, fmt.Errorf("add explicit traffic routers: %w", err) |
| 109 | } | 109 | } | ||
| 110 | 110 | ||||
| 111 | graph.Services = make([]domain.TrafficService, 0, len(b.services)) | 111 | graph.Services = make([]domain.TrafficService, 0, len(b.services)) | ||
| @@ -114,7 +114,7 @@ func Build(input Input) (domain.TrafficGraph, error) { | |||||
| 114 | } | 114 | } | ||
| 115 | sort.Slice(graph.Services, func(i, j int) bool { return graph.Services[i].Name < graph.Services[j].Name }) | 115 | sort.Slice(graph.Services, func(i, j int) bool { return graph.Services[i].Name < graph.Services[j].Name }) | ||
| 116 | if err := graph.Validate(); err != nil { | 116 | if err := graph.Validate(); err != nil { | ||
| 117 | - | return domain.TrafficGraph{}, err | 117 | + | return domain.TrafficGraph{}, fmt.Errorf("validate traffic graph: %w", err) |
| 118 | } | 118 | } | ||
| 119 | return graph, nil | 119 | return graph, nil | ||
| 120 | } | 120 | } | ||
| @@ -246,11 +246,26 @@ func validateNetworkServicePorts(service NetworkServiceConfig) error { | |||||
| 246 | if _, exists := seen[port.Name]; exists { | 246 | if _, exists := seen[port.Name]; exists { | ||
| 247 | return fmt.Errorf("duplicate port %q on network service %q", port.Name, service.Name) | 247 | return fmt.Errorf("duplicate port %q on network service %q", port.Name, service.Name) | ||
| 248 | } | 248 | } | ||
| 249 | + | if port.Container < 1 || port.Container > 65535 { | |||
| 250 | + | return fmt.Errorf("invalid container port %d on network service %q port %q", port.Container, service.Name, port.Name) | |||
| 251 | + | } | |||
| 252 | + | if err := validateNetworkServicePortProtocol(port.Protocol); err != nil { | |||
| 253 | + | return fmt.Errorf("invalid protocol for network service %q port %q: %w", service.Name, port.Name, err) | |||
| 254 | + | } | |||
| 249 | seen[port.Name] = struct{}{} | 255 | seen[port.Name] = struct{}{} | ||
| 250 | } | 256 | } | ||
| 251 | return nil | 257 | return nil | ||
| 252 | } | 258 | } | ||
| 253 | 259 | ||||
| 260 | + | func validateNetworkServicePortProtocol(protocol domain.NetworkProtocol) error { | |||
| 261 | + | switch protocol { | |||
| 262 | + | case domain.NetworkProtocolTCP, domain.NetworkProtocolUDP: | |||
| 263 | + | return nil | |||
| 264 | + | default: | |||
| 265 | + | return fmt.Errorf("%q", protocol) | |||
| 266 | + | } | |||
| 267 | + | } | |||
| 268 | + | ||||
| 254 | func (b *builder) networkService(name string) (NetworkServiceConfig, bool) { | 269 | func (b *builder) networkService(name string) (NetworkServiceConfig, bool) { | ||
| 255 | for _, service := range b.input.NetworkServices { | 270 | for _, service := range b.input.NetworkServices { | ||
| 256 | if service.Name == name { | 271 | if service.Name == name { | ||
| @@ -276,29 +291,40 @@ func (b *builder) addService(service domain.TrafficService) { | |||||
| 276 | } | 291 | } | ||
| 277 | 292 | ||||
| 278 | func buildOptions(cfg Config) (domain.TrafficOptions, error) { | 293 | func buildOptions(cfg Config) (domain.TrafficOptions, error) { | ||
| 279 | - | tcpDial, err := parseDurationDefault(cfg.TCP.DialTimeout, 10*time.Second, "traffic.tcp.dial_timeout") | 294 | + | tcpDial, err := parsePositiveDurationDefault(cfg.TCP.DialTimeout, 10*time.Second, "traffic.tcp.dial_timeout") |
| 280 | if err != nil { | 295 | if err != nil { | ||
| 281 | return domain.TrafficOptions{}, err | 296 | return domain.TrafficOptions{}, err | ||
| 282 | } | 297 | } | ||
| 283 | - | tcpIdle, err := parseDurationDefault(cfg.TCP.IdleTimeout, 5*time.Minute, "traffic.tcp.idle_timeout") | 298 | + | tcpIdle, err := parsePositiveDurationDefault(cfg.TCP.IdleTimeout, 5*time.Minute, "traffic.tcp.idle_timeout") |
| 284 | if err != nil { | 299 | if err != nil { | ||
| 285 | return domain.TrafficOptions{}, err | 300 | return domain.TrafficOptions{}, err | ||
| 286 | } | 301 | } | ||
| 287 | - | tcpDrain, err := parseDurationDefault(cfg.TCP.DrainTimeout, 30*time.Second, "traffic.tcp.drain_timeout") | 302 | + | tcpDrain, err := parsePositiveDurationDefault(cfg.TCP.DrainTimeout, 30*time.Second, "traffic.tcp.drain_timeout") |
| 288 | if err != nil { | 303 | if err != nil { | ||
| 289 | return domain.TrafficOptions{}, err | 304 | return domain.TrafficOptions{}, err | ||
| 290 | } | 305 | } | ||
| 291 | - | udpIdle, err := parseDurationDefault(cfg.UDP.IdleTimeout, 30*time.Second, "traffic.udp.idle_timeout") | 306 | + | udpIdle, err := parsePositiveDurationDefault(cfg.UDP.IdleTimeout, 30*time.Second, "traffic.udp.idle_timeout") |
| 292 | if err != nil { | 307 | if err != nil { | ||
| 293 | return domain.TrafficOptions{}, err | 308 | return domain.TrafficOptions{}, err | ||
| 294 | } | 309 | } | ||
| 295 | - | udpDrain, err := parseDurationDefault(cfg.UDP.DrainTimeout, 30*time.Second, "traffic.udp.drain_timeout") | 310 | + | udpDrain, err := parsePositiveDurationDefault(cfg.UDP.DrainTimeout, 30*time.Second, "traffic.udp.drain_timeout") |
| 296 | if err != nil { | 311 | if err != nil { | ||
| 297 | return domain.TrafficOptions{}, err | 312 | return domain.TrafficOptions{}, err | ||
| 298 | } | 313 | } | ||
| 299 | return domain.TrafficOptions{TCP: domain.TCPOptions{DialTimeout: tcpDial, IdleTimeout: tcpIdle, DrainTimeout: tcpDrain, MaxConnections: cfg.TCP.MaxConnections}, UDP: domain.UDPOptions{IdleTimeout: udpIdle, DrainTimeout: udpDrain, MaxSessions: cfg.UDP.MaxSessions}}, nil | 314 | return domain.TrafficOptions{TCP: domain.TCPOptions{DialTimeout: tcpDial, IdleTimeout: tcpIdle, DrainTimeout: tcpDrain, MaxConnections: cfg.TCP.MaxConnections}, UDP: domain.UDPOptions{IdleTimeout: udpIdle, DrainTimeout: udpDrain, MaxSessions: cfg.UDP.MaxSessions}}, nil | ||
| 300 | } | 315 | } | ||
| 301 | 316 | ||||
| 317 | + | func parsePositiveDurationDefault(value string, fallback time.Duration, field string) (time.Duration, error) { | |||
| 318 | + | d, err := parseDurationDefault(value, fallback, field) | |||
| 319 | + | if err != nil { | |||
| 320 | + | return 0, err | |||
| 321 | + | } | |||
| 322 | + | if d <= 0 { | |||
| 323 | + | return 0, fmt.Errorf("invalid %s: must be positive", field) | |||
| 324 | + | } | |||
| 325 | + | return d, nil | |||
| 326 | + | } | |||
| 327 | + | ||||
| 302 | func parseDurationDefault(value string, fallback time.Duration, field string) (time.Duration, error) { | 328 | func parseDurationDefault(value string, fallback time.Duration, field string) (time.Duration, error) { | ||
| 303 | if strings.TrimSpace(value) == "" { | 329 | if strings.TrimSpace(value) == "" { | ||
| 304 | return fallback, nil | 330 | return fallback, nil | ||
M internal/usecase/traffic/builder_test.go +14 -0
| @@ -113,6 +113,20 @@ func TestBuildRejectsAmbiguousNetworkServices(t *testing.T) { | |||
| 113 | 113 | }}) | |
| 114 | 114 | require.ErrorContains(t, err, "duplicate port") | |
| 115 | 115 | }) | |
| 116 | + | ||
| 117 | + | t.Run("unused invalid port rejected", func(t *testing.T) { | |
| 118 | + | _, err := Build(Input{NetworkServices: []NetworkServiceConfig{ | |
| 119 | + | {Name: "db", Ports: []PortConfig{{Name: "unused", Container: 0, Protocol: domain.NetworkProtocolTCP}}}, | |
| 120 | + | }}) | |
| 121 | + | require.ErrorContains(t, err, "invalid container port") | |
| 122 | + | }) | |
| 123 | + | ||
| 124 | + | t.Run("unused invalid protocol rejected", func(t *testing.T) { | |
| 125 | + | _, err := Build(Input{NetworkServices: []NetworkServiceConfig{ | |
| 126 | + | {Name: "db", Ports: []PortConfig{{Name: "unused", Container: 5432, Protocol: domain.NetworkProtocol("sctp")}}}, | |
| 127 | + | }}) | |
| 128 | + | require.ErrorContains(t, err, "invalid protocol") | |
| 129 | + | }) | |
| 116 | 130 | } | |
| 117 | 131 | ||
| 118 | 132 | func TestBuildRejectsInvalidServiceRefs(t *testing.T) { | |
| @@ -113,6 +113,20 @@ func TestBuildRejectsAmbiguousNetworkServices(t *testing.T) { | |||||
| 113 | }}) | 113 | }}) | ||
| 114 | require.ErrorContains(t, err, "duplicate port") | 114 | require.ErrorContains(t, err, "duplicate port") | ||
| 115 | }) | 115 | }) | ||
| 116 | + | ||||
| 117 | + | t.Run("unused invalid port rejected", func(t *testing.T) { | |||
| 118 | + | _, err := Build(Input{NetworkServices: []NetworkServiceConfig{ | |||
| 119 | + | {Name: "db", Ports: []PortConfig{{Name: "unused", Container: 0, Protocol: domain.NetworkProtocolTCP}}}, | |||
| 120 | + | }}) | |||
| 121 | + | require.ErrorContains(t, err, "invalid container port") | |||
| 122 | + | }) | |||
| 123 | + | ||||
| 124 | + | t.Run("unused invalid protocol rejected", func(t *testing.T) { | |||
| 125 | + | _, err := Build(Input{NetworkServices: []NetworkServiceConfig{ | |||
| 126 | + | {Name: "db", Ports: []PortConfig{{Name: "unused", Container: 5432, Protocol: domain.NetworkProtocol("sctp")}}}, | |||
| 127 | + | }}) | |||
| 128 | + | require.ErrorContains(t, err, "invalid protocol") | |||
| 129 | + | }) | |||
| 116 | } | 130 | } | ||
| 117 | 131 | ||||
| 118 | func TestBuildRejectsInvalidServiceRefs(t *testing.T) { | 132 | func TestBuildRejectsInvalidServiceRefs(t *testing.T) { | ||