detached 78a4426

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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
44
55 ## Entrypoints
66
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`.
88
99 Custom entrypoints use the top-level `entrypoints` table:
1010
@@ -78,7 +78,7 @@ UDP sessions are keyed by client address and expire after `idle_timeout`. If `ma
7878
7979 ## TLS Passthrough
8080
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.
8282
8383 ```toml
8484 [entrypoints.rawtls]
@@ -4,7 +4,7 @@ Gordon models network listeners as a traffic graph: entrypoints receive connecti
4 4
5 ## Entrypoints5 ## 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 Passthrough79 ## 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 ```toml83 ```toml
84 [entrypoints.rawtls]84 [entrypoints.rawtls]
M internal/adapters/dto/traffic.go +13 -32
@@ -26,19 +26,12 @@ type TrafficEntryPointStatus struct {
2626 }
2727
2828 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"`
4235 }
4336
4437 type TrafficServiceStatus struct {
@@ -53,18 +46,11 @@ type TrafficRule struct {
5346 }
5447
5548 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"`
6854 }
6955
7056 type TrafficCounters struct {
@@ -105,10 +91,8 @@ func trafficRoutersFromDomain(values []domain.TrafficRouterStatus) []TrafficRout
10591 out := make([]TrafficRouterStatus, 0, len(values))
10692 for _, value := range values {
10793 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,
11296 })
11397 }
11498 return out
@@ -127,9 +111,6 @@ func trafficBackendsFromDomain(values []domain.TrafficBackendStatus) []TrafficBa
127111 for _, value := range values {
128112 out = append(out, TrafficBackendStatus{
129113 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,
133114 })
134115 }
135116 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 out98 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 out116 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
705705 func (c *Client) GetTrafficStatus(ctx context.Context) (*dto.TrafficStatusResponse, error) {
706706 resp, err := c.request(ctx, http.MethodGet, "/traffic/status", nil)
707707 if err != nil {
708- return nil, err
708+ return nil, fmt.Errorf("request traffic status: %w", err)
709709 }
710710
711711 var status dto.TrafficStatusResponse
712712 if err := parseResponse(resp, &status); err != nil {
713- return nil, err
713+ return nil, fmt.Errorf("parse traffic status response: %w", err)
714714 }
715715
716716 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, err708+ return nil, fmt.Errorf("request traffic status: %w", err)
709 }709 }
710 710
711 var status dto.TrafficStatusResponse711 var status dto.TrafficStatusResponse
712 if err := parseResponse(resp, &status); err != nil {712 if err := parseResponse(resp, &status); err != nil {
713- return nil, err713+ return nil, fmt.Errorf("parse traffic status response: %w", err)
714 }714 }
715 715
716 return &status, nil716 return &status, nil
M internal/adapters/in/cli/root.go +2 -2
@@ -154,11 +154,11 @@ Commands are organized by where they run:
154154 tlsCmd.GroupID = groupManage
155155 rootCmd.AddCommand(tlsCmd)
156156
157+ // Client-only commands (no server needed)
157158 trafficCmd := newTrafficCmd()
158- trafficCmd.GroupID = groupManage
159+ trafficCmd.GroupID = groupClient
159160 rootCmd.AddCommand(trafficCmd)
160161
161- // Client-only commands (no server needed)
162162 remotesCmd := newRemotesCmd()
163163 remotesCmd.GroupID = groupClient
164164 rootCmd.AddCommand(remotesCmd)
@@ -154,11 +154,11 @@ Commands are organized by where they run:
154 tlsCmd.GroupID = groupManage154 tlsCmd.GroupID = groupManage
155 rootCmd.AddCommand(tlsCmd)155 rootCmd.AddCommand(tlsCmd)
156 156
157+ // Client-only commands (no server needed)
157 trafficCmd := newTrafficCmd()158 trafficCmd := newTrafficCmd()
158- trafficCmd.GroupID = groupManage159+ 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 = groupClient163 remotesCmd.GroupID = groupClient
164 rootCmd.AddCommand(remotesCmd)164 rootCmd.AddCommand(remotesCmd)
M internal/adapters/in/cli/traffic.go +2 -130
@@ -1,140 +1,12 @@
11 package cli
22
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"
114
125 func newTrafficCmd() *cobra.Command {
136 cmd := &cobra.Command{
147 Use: "traffic",
15- Short: "Inspect traffic plane status",
8+ Short: "Inspect remote traffic plane status",
169 }
1710 cmd.AddCommand(newTrafficStatusCmd())
1811 return cmd
1912 }
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 cli1 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 cmd11 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) {
3434 }},
3535 Routers: []dto.TrafficRouterStatus{{
3636 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,
3838 }},
3939 Services: []dto.TrafficServiceStatus{{
4040 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 (
99
1010 func (h *Handler) handleTrafficStatus(w http.ResponseWriter, r *http.Request) {
1111 ctx := r.Context()
12- if r.Method != http.MethodGet {
13- h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
14- return
15- }
1612 if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
1713 h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
1814 return
1915 }
16+ if r.Method != http.MethodGet {
17+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
18+ return
19+ }
2020 if h.trafficSvc == nil {
2121 h.sendJSON(w, http.StatusOK, dto.TrafficStatusResponse{LastReloadStatus: "unavailable"})
2222 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 return14 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 return22 return
M internal/adapters/in/http/admin/handler_traffic_test.go +14 -0
@@ -52,3 +52,17 @@ func TestHandler_TrafficStatusRequiresStatusRead(t *testing.T) {
5252 defer resp.Body.Close()
5353 assert.Equal(t, http.StatusForbidden, resp.StatusCode)
5454 }
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
33
44 import (
55 "context"
6- "errors"
76 "fmt"
87 "net"
98 "sort"
@@ -71,35 +70,42 @@ func NewManager() *Manager {
7170 // Apply validates and applies a new traffic graph snapshot.
7271 func (m *Manager) Apply(ctx context.Context, graph *domain.TrafficGraph) error {
7372 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))
7574 }
7675 if err := graph.Validate(); err != nil {
77- return m.recordReloadError(ctx, err)
76+ return m.recordReloadError(ctx, fmt.Errorf("validate traffic graph: %w", err))
7877 }
7978 trafficDebug(ctx).Int("entrypoints", len(graph.EntryPoints)).Int("routers", len(graph.Routers)).Int("services", len(graph.Services)).Msg("applying traffic graph")
8079
8180 nextGraph := cloneTrafficGraph(*graph)
8281 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)
8683 if err != nil {
8784 m.lastReloadStatus = reloadStatusError
8885 m.lastReloadError = err.Error()
86+ m.mu.Unlock()
8987 trafficWarn(ctx).Err(err).Msg("failed to prepare tcp traffic listeners")
9088 return err
9189 }
92- newUDPListeners, createdUDPListeners, err := m.prepareUDPListeners(ctx, &nextGraph)
90+ newUDPListeners, createdUDPListeners, udpUpdates, err := m.prepareUDPListeners(ctx, &nextGraph)
9391 if err != nil {
92+ m.lastReloadStatus = reloadStatusError
93+ m.lastReloadError = err.Error()
94+ m.mu.Unlock()
9495 for _, runtime := range createdListeners {
9596 runtime.stop(ctx, effectiveTCPOptions(snapshotTCPOptions(&nextGraph)).DrainTimeout)
9697 }
97- m.lastReloadStatus = reloadStatusError
98- m.lastReloadError = err.Error()
9998 trafficWarn(ctx).Err(err).Msg("failed to prepare udp traffic listeners")
10099 return err
101100 }
102101
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+
103109 oldListeners := m.listeners
104110 oldUDPListeners := m.udpListeners
105111 m.listeners = newListeners
@@ -113,22 +119,21 @@ func (m *Manager) Apply(ctx context.Context, graph *domain.TrafficGraph) error {
113119 }
114120 m.lastReloadStatus = reloadStatusOK
115121 m.lastReloadError = ""
122+ m.mu.Unlock()
116123
117124 tcpDrainTimeout := effectiveTCPOptions(snapshotTCPOptions(&nextGraph)).DrainTimeout
118125 stoppedTCP := 0
119- for name, runtime := range oldListeners {
120- if newListeners[name] == runtime {
126+ for _, runtime := range oldListeners {
127+ if tcpRuntimeRetained(newListeners, runtime) {
121128 continue
122129 }
123- if runtime.shouldStopWith(newListeners[name]) {
124- stoppedTCP++
125- runtime.stop(ctx, tcpDrainTimeout)
126- }
130+ stoppedTCP++
131+ runtime.stop(ctx, tcpDrainTimeout)
127132 }
128133 udpDrainTimeout := effectiveUDPOptions(snapshotUDPOptions(&nextGraph)).DrainTimeout
129134 stoppedUDP := 0
130- for name, runtime := range oldUDPListeners {
131- if newUDPListeners[name] == runtime {
135+ for _, runtime := range oldUDPListeners {
136+ if udpRuntimeRetained(newUDPListeners, runtime) {
132137 if _, ok := runtime.resolveUDPBackend(); !ok {
133138 runtime.drainSessionsAfter(udpDrainTimeout)
134139 }
@@ -141,7 +146,24 @@ func (m *Manager) Apply(ctx context.Context, graph *domain.TrafficGraph) error {
141146 return nil
142147 }
143148
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+
145167 func logAppliedTrafficGraph(
146168 ctx context.Context,
147169 newListeners map[string]*entryPointRuntime,
@@ -165,6 +187,7 @@ func logAppliedTrafficGraph(
165187 Msg("traffic graph applied")
166188 }
167189
190+// Shutdown stops all listeners and active TCP streams.
168191 func (m *Manager) Shutdown(ctx context.Context) error {
169192 trafficInfo(ctx).Msg("shutting down traffic manager")
170193 m.mu.Lock()
@@ -228,7 +251,19 @@ func (m *Manager) recordReloadError(ctx context.Context, err error) error {
228251 return err
229252 }
230253
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) {
232267 current := make(map[string]*entryPointRuntime, len(m.listeners))
233268 for name, runtime := range m.listeners {
234269 current[name] = runtime
@@ -236,30 +271,49 @@ func (m *Manager) prepareTCPListeners(ctx context.Context, graph *domain.Traffic
236271
237272 next := make(map[string]*entryPointRuntime, len(current))
238273 created := []*entryPointRuntime{}
274+ updates := []tcpRuntimeUpdate{}
239275 for _, entryPoint := range graph.EntryPoints {
240276 if !isTCPListenerProtocol(entryPoint.Protocol) {
241277 continue
242278 }
243279 if runtime := current[entryPoint.Name]; runtime != nil && runtime.matches(entryPoint) {
244280 next[entryPoint.Name] = runtime
281+ delete(current, entryPoint.Name)
245282 continue
246283 }
247284 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
251295 }
252296 runtime, err := m.bindTCPEntryPoint(ctx, entryPoint)
253297 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
258300 }
259301 next[entryPoint.Name] = runtime
260302 created = append(created, runtime)
261303 }
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+ }
263317 }
264318
265319 func isTCPListenerProtocol(protocol domain.EntryPointProtocol) bool {
@@ -268,7 +322,7 @@ func isTCPListenerProtocol(protocol domain.EntryPointProtocol) bool {
268322
269323 func conflictingTCPRuntime(current map[string]*entryPointRuntime, entryPoint domain.EntryPoint) *entryPointRuntime {
270324 for _, runtime := range current {
271- if runtime.entryPoint.Address == entryPoint.Address {
325+ if runtime.sameAddress(entryPoint) {
272326 return runtime
273327 }
274328 }
@@ -290,7 +344,7 @@ func (m *Manager) bindTCPEntryPoint(ctx context.Context, entryPoint domain.Entry
290344 return runtime, nil
291345 }
292346
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) {
294348 current := make(map[string]*udpEntryPointRuntime, len(m.udpListeners))
295349 for name, runtime := range m.udpListeners {
296350 current[name] = runtime
@@ -298,35 +352,42 @@ func (m *Manager) prepareUDPListeners(ctx context.Context, graph *domain.Traffic
298352
299353 next := make(map[string]*udpEntryPointRuntime, len(current))
300354 created := []*udpEntryPointRuntime{}
355+ updates := []udpRuntimeUpdate{}
301356 for _, entryPoint := range graph.EntryPoints {
302357 if entryPoint.Protocol != domain.EntryPointProtocolUDP {
303358 continue
304359 }
305360 if runtime := current[entryPoint.Name]; runtime != nil && runtime.matches(entryPoint) {
306361 next[entryPoint.Name] = runtime
362+ delete(current, entryPoint.Name)
307363 continue
308364 }
309365 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
313376 }
314377 runtime, err := m.bindUDPEntryPoint(ctx, entryPoint)
315378 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
320381 }
321382 next[entryPoint.Name] = runtime
322383 created = append(created, runtime)
323384 }
324- return next, created, nil
385+ return next, created, updates, nil
325386 }
326387
327388 func conflictingUDPRuntime(current map[string]*udpEntryPointRuntime, entryPoint domain.EntryPoint) *udpEntryPointRuntime {
328389 for _, runtime := range current {
329- if runtime.entryPoint.Address == entryPoint.Address {
390+ if runtime.sameAddress(entryPoint) {
330391 return runtime
331392 }
332393 }
@@ -438,26 +499,12 @@ func entryPointStatuses(entries []domain.EntryPoint, listeners map[string]*entry
438499 func routerStatuses(routers []domain.TrafficRouter, listeners map[string]*entryPointRuntime, udpListeners map[string]*udpEntryPointRuntime) []domain.TrafficRouterStatus {
439500 statuses := make([]domain.TrafficRouterStatus, 0, len(routers))
440501 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+ })
461508 }
462509 sort.Slice(statuses, func(i, j int) bool { return statuses[i].Name < statuses[j].Name })
463510 return statuses
@@ -493,6 +540,9 @@ func aggregateCounters(entries []domain.EntryPointStatus) domain.TrafficCounters
493540 func cloneTrafficGraph(graph domain.TrafficGraph) domain.TrafficGraph {
494541 clone := graph
495542 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+ }
496546 clone.Routers = append([]domain.TrafficRouter{}, graph.Routers...)
497547 clone.Services = append([]domain.TrafficService{}, graph.Services...)
498548 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 = reloadStatusError84 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 err88 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 err99 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.listeners109 oldListeners := m.listeners
104 oldUDPListeners := m.udpListeners110 oldUDPListeners := m.udpListeners
105 m.listeners = newListeners111 m.listeners = newListeners
@@ -113,22 +119,21 @@ func (m *Manager) Apply(ctx context.Context, graph *domain.TrafficGraph) error {
113 }119 }
114 m.lastReloadStatus = reloadStatusOK120 m.lastReloadStatus = reloadStatusOK
115 m.lastReloadError = ""121 m.lastReloadError = ""
122+ m.mu.Unlock()
116 123
117 tcpDrainTimeout := effectiveTCPOptions(snapshotTCPOptions(&nextGraph)).DrainTimeout124 tcpDrainTimeout := effectiveTCPOptions(snapshotTCPOptions(&nextGraph)).DrainTimeout
118 stoppedTCP := 0125 stoppedTCP := 0
119- for name, runtime := range oldListeners {126+ for _, runtime := range oldListeners {
120- if newListeners[name] == runtime {127+ if tcpRuntimeRetained(newListeners, runtime) {
121 continue128 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)).DrainTimeout133 udpDrainTimeout := effectiveUDPOptions(snapshotUDPOptions(&nextGraph)).DrainTimeout
129 stoppedUDP := 0134 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 nil146 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 err251 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] = runtime269 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 continue277 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] = runtime280 next[entryPoint.Name] = runtime
281+ delete(current, entryPoint.Name)
245 continue282 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] = runtime301 next[entryPoint.Name] = runtime
260 created = append(created, runtime)302 created = append(created, runtime)
261 }303 }
262- return next, created, nil304+ 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 runtime326 return runtime
273 }327 }
274 }328 }
@@ -290,7 +344,7 @@ func (m *Manager) bindTCPEntryPoint(ctx context.Context, entryPoint domain.Entry
290 return runtime, nil344 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] = runtime350 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 continue358 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] = runtime361 next[entryPoint.Name] = runtime
362+ delete(current, entryPoint.Name)
307 continue363 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] = runtime382 next[entryPoint.Name] = runtime
322 created = append(created, runtime)383 created = append(created, runtime)
323 }384 }
324- return next, created, nil385+ 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 runtime391 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.ActiveTCPConnections505+ Name: router.Name, EntryPoint: router.EntryPoint, Protocol: router.Protocol,
445- status.TotalAccepted = counters.TotalAccepted506+ Rule: router.Rule, Service: router.Service, Active: tcpActive || udpActive,
446- status.TotalRefused = counters.TotalRefused507+ })
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 statuses510 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 := graph541 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() {
5858 if !r.started.CompareAndSwap(false, true) {
5959 return
6060 }
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")
6263 go r.acceptLoop()
6364 }
6465
@@ -71,13 +72,19 @@ func (r *entryPointRuntime) acceptLoop() {
7172 return
7273 }
7374 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() {
7577 continue
7678 }
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
7986 }
80- if !trustedRemoteAddr(r.trusted, conn.RemoteAddr()) {
87+ if !trustedRemoteAddr(r.trustedSnapshot(), conn.RemoteAddr()) {
8188 r.counters.totalRefused.Add(1)
8289 _ = conn.Close()
8390 continue
@@ -102,7 +109,7 @@ func (r *entryPointRuntime) handleTCPConn(client net.Conn) {
102109 r.track(tracked)
103110 defer r.untrack(tracked)
104111
105- switch r.entryPoint.Protocol {
112+ switch r.entryPointSnapshot().Protocol {
106113 case domain.EntryPointProtocolTCP:
107114 r.handlePlainTCP(tracked, options)
108115 case domain.EntryPointProtocolTLSMux:
@@ -134,7 +141,7 @@ func (r *entryPointRuntime) handleTLSMux(tracked *trackedTCPConn, options domain
134141 r.proxyToBackend(tracked, peeked.conn, backend, options)
135142 return
136143 }
137- if fallback := r.manager.tlsFallback(r.entryPoint.Name); fallback != nil {
144+ if fallback := r.manager.tlsFallback(r.entryPointSnapshot().Name); fallback != nil {
138145 r.counters.totalAccepted.Add(1)
139146 fallback(r.ctx, peeked.conn)
140147 return
@@ -145,14 +152,14 @@ func (r *entryPointRuntime) handleTLSMux(tracked *trackedTCPConn, options domain
145152
146153 func (r *entryPointRuntime) peekTLSClientHello(client net.Conn, options domain.TCPOptions) (peekedTLSConn, error) {
147154 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)
149156 }
150157 sni, replayed, err := peekClientHelloSNI(client)
151158 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)
153160 }
154161 if err != nil {
155- return peekedTLSConn{}, err
162+ return peekedTLSConn{}, fmt.Errorf("peek client hello from %s: %w", client.RemoteAddr(), err)
156163 }
157164 return peekedTLSConn{sni: sni, conn: replayed}, nil
158165 }
@@ -180,7 +187,7 @@ func (r *entryPointRuntime) proxyToBackend(tracked *trackedTCPConn, client net.C
180187 return
181188 }
182189
183- tracked.backend = backendConn
190+ r.setBackend(tracked, backendConn)
184191 r.counters.totalAccepted.Add(1)
185192 r.proxyTCP(client, backendConn, options.IdleTimeout)
186193 }
@@ -190,9 +197,10 @@ func (r *entryPointRuntime) resolveTCPBackend() (domain.TrafficRouter, domain.Tr
190197 if graph == nil {
191198 return domain.TrafficRouter{}, domain.TrafficBackend{}, false
192199 }
200+ entryPoint := r.entryPointSnapshot()
193201 var router domain.TrafficRouter
194202 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 {
196204 if router.Name != "" {
197205 return domain.TrafficRouter{}, domain.TrafficBackend{}, false
198206 }
@@ -217,6 +225,7 @@ func (r *entryPointRuntime) resolveTCPBackend() (domain.TrafficRouter, domain.Tr
217225
218226 func (r *entryPointRuntime) resolveTLSBackend(sni string) (domain.TrafficBackend, bool) {
219227 graph := r.manager.snapshot.Load()
228+ sni = normalizeTLSName(sni)
220229 if graph == nil || sni == "" {
221230 return domain.TrafficBackend{}, false
222231 }
@@ -230,8 +239,9 @@ func (r *entryPointRuntime) resolveTLSBackend(sni string) (domain.TrafficBackend
230239 }
231240
232241 func (r *entryPointRuntime) findExactTLSRouter(graph *domain.TrafficGraph, sni string) (domain.TrafficRouter, bool) {
242+ entryPoint := r.entryPointSnapshot()
233243 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 {
235245 return router, true
236246 }
237247 }
@@ -239,9 +249,10 @@ func (r *entryPointRuntime) findExactTLSRouter(graph *domain.TrafficGraph, sni s
239249 }
240250
241251 func (r *entryPointRuntime) findWildcardTLSRouter(graph *domain.TrafficGraph, sni string) (domain.TrafficRouter, bool) {
252+ entryPoint := r.entryPointSnapshot()
242253 for _, router := range graph.Routers {
243254 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) {
245256 return router, true
246257 }
247258 }
@@ -262,7 +273,8 @@ func hostMatchesTLSWildcard(host string, wildcard string) bool {
262273 if !ok || host == suffix {
263274 return false
264275 }
265- return strings.HasSuffix(host, "."+suffix)
276+ prefix, ok := strings.CutSuffix(host, "."+suffix)
277+ return ok && prefix != "" && !strings.Contains(prefix, ".")
266278 }
267279
268280 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)
296308 if idleTimeout <= 0 {
297309 return io.Copy(dst, src)
298310 }
299- return io.Copy(dst, idleConn{Conn: src, idleTimeout: idleTimeout})
311+ return io.Copy(deadlineWriter{Conn: dst, idleTimeout: idleTimeout}, idleConn{Conn: src, idleTimeout: idleTimeout})
300312 }
301313
302314 type idleConn struct {
@@ -311,6 +323,18 @@ func (c idleConn) Read(p []byte) (int, error) {
311323 return c.Conn.Read(p)
312324 }
313325
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+
314338 func closeWrite(conn net.Conn) {
315339 if writeCloser, ok := conn.(interface{ CloseWrite() error }); ok {
316340 _ = writeCloser.CloseWrite()
@@ -347,6 +371,12 @@ func (r *entryPointRuntime) track(conn *trackedTCPConn) {
347371 r.mu.Unlock()
348372 }
349373
374+func (r *entryPointRuntime) setBackend(conn *trackedTCPConn, backend net.Conn) {
375+ r.mu.Lock()
376+ conn.backend = backend
377+ r.mu.Unlock()
378+}
379+
350380 func (r *entryPointRuntime) untrack(conn *trackedTCPConn) {
351381 r.mu.Lock()
352382 delete(r.activeConns, conn)
@@ -355,7 +385,8 @@ func (r *entryPointRuntime) untrack(conn *trackedTCPConn) {
355385
356386 func (r *entryPointRuntime) stop(ctx context.Context, drainTimeout time.Duration) {
357387 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")
359390 r.cancel()
360391 _ = r.listener.Close()
361392 if !r.started.Load() {
@@ -368,14 +399,14 @@ func (r *entryPointRuntime) stop(ctx context.Context, drainTimeout time.Duration
368399 return
369400 }
370401 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")
372403 return
373404 }
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")
375406 r.closeActiveConns()
376407 select {
377408 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")
379410 case <-ctx.Done():
380411 }
381412 }
@@ -419,11 +450,31 @@ func (r *entryPointRuntime) closeActiveConns() {
419450 }
420451
421452 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()
423466 }
424467
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
427478 }
428479
429480 func (r *entryPointRuntime) isClosed() bool {
@@ -433,11 +484,11 @@ func (r *entryPointRuntime) isClosed() bool {
433484 func backendFromAddress(name string, address string) (domain.TrafficBackend, error) {
434485 host, portValue, err := net.SplitHostPort(address)
435486 if err != nil {
436- return domain.TrafficBackend{}, err
487+ return domain.TrafficBackend{}, fmt.Errorf("split backend address %q: %w", address, err)
437488 }
438489 port, err := strconv.Atoi(portValue)
439490 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)
441492 }
442493 return domain.TrafficBackend{Name: name, Host: host, Port: port, Protocol: domain.NetworkProtocolTCP}, nil
443494 }
@@ -58,7 +58,8 @@ func (r *entryPointRuntime) start() {
58 if !r.started.CompareAndSwap(false, true) {58 if !r.started.CompareAndSwap(false, true) {
59 return59 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 return72 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 continue77 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- return80+ 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 continue90 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 return142 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 return147 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{}, err155+ 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{}, clearErr159+ 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{}, err162+ return peekedTLSConn{}, fmt.Errorf("peek client hello from %s: %w", client.RemoteAddr(), err)
156 }163 }
157 return peekedTLSConn{sni: sni, conn: replayed}, nil164 return peekedTLSConn{sni: sni, conn: replayed}, nil
158 }165 }
@@ -180,7 +187,7 @@ func (r *entryPointRuntime) proxyToBackend(tracked *trackedTCPConn, client net.C
180 return187 return
181 }188 }
182 189
183- tracked.backend = backendConn190+ 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{}, false198 return domain.TrafficRouter{}, domain.TrafficBackend{}, false
192 }199 }
200+ entryPoint := r.entryPointSnapshot()
193 var router domain.TrafficRouter201 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{}, false205 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{}, false230 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, true245 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, true256 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 false274 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 return399 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 return403 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 != r469+ 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{}, err487+ 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{}, err491+ 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}, nil493 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
33 import (
44 "bufio"
55 "context"
6+ "errors"
67 "io"
78 "net"
89 "testing"
@@ -113,10 +114,11 @@ func assertTCPRejected(t *testing.T, manager *Manager, address string, refused i
113114 t.Helper()
114115 conn := dialTCP(t, address)
115116 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+ }
120122 assert.Eventually(t, func() bool { return manager.Status().Counters.TotalRefused == refused }, time.Second, 10*time.Millisecond)
121123 }
122124
@@ -142,10 +144,11 @@ func TestTCPPassthroughUnknownRouterCloses(t *testing.T) {
142144
143145 conn := dialTCP(t, graph.EntryPoints[0].Address)
144146 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+ }
149152 assert.Eventually(t, func() bool { return manager.Status().Counters.TotalRefused == 1 }, time.Second, 10*time.Millisecond)
150153 }
151154
@@ -158,10 +161,11 @@ func TestTCPPassthroughBackendDialFailure(t *testing.T) {
158161
159162 conn := dialTCP(t, graph.EntryPoints[0].Address)
160163 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+ }
165169 assert.Eventually(t, func() bool { return manager.Status().Counters.TotalErrors == 1 }, time.Second, 10*time.Millisecond)
166170 }
167171
@@ -186,9 +190,13 @@ func TestTCPPassthroughIdleTimeoutClosesConnection(t *testing.T) {
186190
187191 conn := dialTCP(t, graph.EntryPoints[0].Address)
188192 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()
190195 _, err := bufio.NewReader(conn).ReadByte()
191196 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)
192200 }
193201
194202 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) {
5959 buf = append(buf, tmp[:n]...)
6060 sni, complete, parseErr := parseClientHelloSNI(buf)
6161 if parseErr != nil {
62- return "", nil, parseErr
62+ return "", nil, fmt.Errorf("parse client hello sni: %w", parseErr)
6363 }
6464 if complete {
6565 return sni, replayConn{Conn: conn, reader: bytes.NewReader(buf)}, nil
6666 }
6767 }
6868 if err != nil {
69- return "", nil, err
69+ return "", nil, fmt.Errorf("read client hello: %w", err)
7070 }
7171 }
7272 return "", nil, fmt.Errorf("client hello exceeds %d bytes", maxClientHelloBytes)
@@ -83,15 +83,23 @@ func parseClientHelloSNI(data []byte) (string, bool, error) {
8383 if err != nil || !complete {
8484 return "", complete, err
8585 }
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
8897 }
89- if record[0] != 1 {
98+ if handshake[0] != 1 {
9099 return "", true, fmt.Errorf("tls handshake is not client hello")
91100 }
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 {
95103 if len(data) < consumed+5 {
96104 return "", false, nil
97105 }
@@ -102,7 +110,7 @@ func parseClientHelloSNI(data []byte) (string, bool, error) {
102110 handshake = append(handshake, next...)
103111 consumed = nextConsumed
104112 }
105- sni, err := parseClientHelloExtensions(handshake[:handshakeLen])
113+ sni, err := parseClientHelloExtensions(handshake[4 : 4+handshakeLen])
106114 return sni, true, err
107115 }
108116
@@ -255,9 +263,15 @@ func (c replayConn) Read(p []byte) (int, error) {
255263
256264 // ServeHTTPSFallback serves a single TLS connection with the provided HTTP handler.
257265 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+ }
258272 tracked := newTrackedCloseConn(tls.Server(conn, tlsConfig))
259273 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}
261275 errCh := make(chan error, 1)
262276 go func() { errCh <- server.Serve(listener) }()
263277
@@ -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, parseErr62+ 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)}, nil65 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, err69+ 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, err84 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, nil104 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 = nextConsumed111 consumed = nextConsumed
104 }112 }
105- sni, err := parseClientHelloExtensions(handshake[:handshakeLen])113+ sni, err := parseClientHelloExtensions(handshake[4 : 4+handshakeLen])
106 return sni, true, err114 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) {
6969 })
7070
7171 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)
7373 client, server := net.Pipe()
7474 go func() {
7575 _, _ = client.Write(hello)
@@ -83,15 +83,30 @@ func TestClientHelloPeek(t *testing.T) {
8383 require.NoError(t, err)
8484 assert.Equal(t, hello, buf)
8585 })
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+ })
86102 }
87103
88-func splitClientHelloAcrossRecords(t *testing.T, hello []byte) []byte {
104+func splitClientHelloAcrossRecords(t *testing.T, hello []byte, firstPayloadLen int) []byte {
89105 t.Helper()
90106 require.GreaterOrEqual(t, len(hello), 15)
91107 require.Equal(t, byte(22), hello[0])
92108 recordLen := int(hello[3])<<8 | int(hello[4])
93- require.Greater(t, recordLen, 10)
94- firstPayloadLen := 10
109+ require.Greater(t, recordLen, firstPayloadLen)
95110 first := append([]byte{}, hello[:5]...)
96111 first[3] = byte(firstPayloadLen >> 8)
97112 first[4] = byte(firstPayloadLen)
@@ -319,6 +334,7 @@ func testCertificate(commonName string) (tls.Certificate, error) {
319334 func TestHostMatchesTLSWildcard(t *testing.T) {
320335 assert.True(t, hostMatchesTLSWildcard("api.example.com", "*.example.com"))
321336 assert.False(t, hostMatchesTLSWildcard("example.com", "*.example.com"))
337+ assert.False(t, hostMatchesTLSWildcard("v1.api.example.com", "*.example.com"))
322338 assert.False(t, hostMatchesTLSWildcard("api.other.com", "*.example.com"))
323339 assert.True(t, strings.EqualFold(normalizeTLSName("API.EXAMPLE.COM."), "api.example.com"))
324340 }
@@ -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 (
1212 "github.com/bnema/gordon/internal/domain"
1313 )
1414
15-const udpBufferSize = 64 << 10
15+const (
16+ udpBufferSize = 64 << 10
17+ udpWorkerCount = 16
18+ udpDatagramBacklog = 1024
19+)
1620
1721 type udpEntryPointRuntime struct {
1822 manager *Manager
@@ -29,10 +33,17 @@ type udpEntryPointRuntime struct {
2933 done chan struct{}
3034 doneOnce sync.Once
3135
36+ datagrams chan udpDatagram
37+
3238 mu sync.Mutex
3339 sessions map[string]*udpSession
3440 }
3541
42+type udpDatagram struct {
43+ clientAddr net.Addr
44+ packet []byte
45+}
46+
3647 type udpSession struct {
3748 clientAddr net.Addr
3849 backend net.Conn
@@ -51,6 +62,7 @@ func newUDPEntryPointRuntime(parentCtx context.Context, manager *Manager, entryP
5162 ctx: ctx,
5263 cancel: cancel,
5364 done: make(chan struct{}),
65+ datagrams: make(chan udpDatagram, udpDatagramBacklog),
5466 sessions: map[string]*udpSession{},
5567 }
5668 }
@@ -59,7 +71,11 @@ func (r *udpEntryPointRuntime) start() {
5971 if !r.started.CompareAndSwap(false, true) {
6072 return
6173 }
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+ }
6379 go r.readLoop()
6480 go r.expireLoop()
6581 }
@@ -76,12 +92,29 @@ func (r *udpEntryPointRuntime) readLoop() {
7692 r.counters.totalErrors.Add(1)
7793 continue
7894 }
79- if !trustedRemoteAddr(r.trusted, clientAddr) {
95+ if !trustedRemoteAddr(r.trustedSnapshot(), clientAddr) {
8096 r.counters.totalRefused.Add(1)
8197 continue
8298 }
8399 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+ }
85118 }
86119 }
87120
@@ -91,7 +124,6 @@ func (r *udpEntryPointRuntime) handleDatagram(clientAddr net.Addr, packet []byte
91124 if !ok {
92125 return
93126 }
94- session.touch()
95127 n, err := session.backend.Write(packet)
96128 r.counters.bytesIn.Add(int64(n))
97129 if err != nil {
@@ -103,6 +135,7 @@ func (r *udpEntryPointRuntime) handleDatagram(clientAddr net.Addr, packet []byte
103135 func (r *udpEntryPointRuntime) session(key string, clientAddr net.Addr, options domain.UDPOptions) (*udpSession, bool) {
104136 r.mu.Lock()
105137 if session := r.sessions[key]; session != nil {
138+ session.touch()
106139 r.mu.Unlock()
107140 return session, true
108141 }
@@ -118,7 +151,9 @@ func (r *udpEntryPointRuntime) session(key string, clientAddr net.Addr, options
118151 r.counters.totalRefused.Add(1)
119152 return nil, false
120153 }
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()
122157 if err != nil {
123158 r.counters.totalErrors.Add(1)
124159 return nil, false
@@ -147,6 +182,13 @@ func (r *udpEntryPointRuntime) session(key string, clientAddr net.Addr, options
147182 return session, true
148183 }
149184
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+
150192 func (r *udpEntryPointRuntime) backendLoop(key string, session *udpSession) {
151193 defer r.removeSession(key)
152194 buf := make([]byte, udpBufferSize)
@@ -187,16 +229,18 @@ func (r *udpEntryPointRuntime) expireIdleSessions(idleTimeout time.Duration) {
187229 idleTimeout = defaultUDPOptions().IdleTimeout
188230 }
189231 cutoff := time.Now().Add(-idleTimeout).UnixNano()
232+ expired := []*udpSession{}
190233 r.mu.Lock()
191- keys := make([]string, 0)
192234 for key, session := range r.sessions {
193235 if session.lastSeen.Load() <= cutoff {
194- keys = append(keys, key)
236+ delete(r.sessions, key)
237+ expired = append(expired, session)
195238 }
196239 }
197240 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)
200244 }
201245 }
202246
@@ -219,9 +263,10 @@ func (r *udpEntryPointRuntime) resolveUDPBackend() (domain.TrafficBackend, bool)
219263 if graph == nil {
220264 return domain.TrafficBackend{}, false
221265 }
266+ entryPoint := r.entryPointSnapshot()
222267 var router domain.TrafficRouter
223268 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 {
225270 if router.Name != "" {
226271 return domain.TrafficBackend{}, false
227272 }
@@ -241,7 +286,8 @@ func (r *udpEntryPointRuntime) resolveUDPBackend() (domain.TrafficBackend, bool)
241286
242287 func (r *udpEntryPointRuntime) stop(ctx context.Context, drainTimeout time.Duration) {
243288 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")
245291 r.cancel()
246292 _ = r.packetConn.Close()
247293 if !r.started.Load() {
@@ -251,23 +297,24 @@ func (r *udpEntryPointRuntime) stop(ctx context.Context, drainTimeout time.Durat
251297 select {
252298 case <-r.done:
253299 case <-ctx.Done():
300+ r.closeSessions()
254301 return
255302 }
256303 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")
258305 return
259306 }
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")
261308 r.closeSessions()
262309 select {
263310 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")
265312 case <-ctx.Done():
266313 }
267314 }
268315
269316 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")
271318 go func() {
272319 if drainTimeout <= 0 {
273320 drainTimeout = defaultUDPOptions().DrainTimeout
@@ -328,7 +375,31 @@ func (r *udpEntryPointRuntime) closeSessions() {
328375 }
329376
330377 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
332403 }
333404
334405 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 << 1015+const (
16+ udpBufferSize = 64 << 10
17+ udpWorkerCount = 16
18+ udpDatagramBacklog = 1024
19+)
16 20
17 type udpEntryPointRuntime struct {21 type udpEntryPointRuntime struct {
18 manager *Manager22 manager *Manager
@@ -29,10 +33,17 @@ type udpEntryPointRuntime struct {
29 done chan struct{}33 done chan struct{}
30 doneOnce sync.Once34 doneOnce sync.Once
31 35
36+ datagrams chan udpDatagram
37+
32 mu sync.Mutex38 mu sync.Mutex
33 sessions map[string]*udpSession39 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.Addr48 clientAddr net.Addr
38 backend net.Conn49 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 return72 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 continue93 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 continue97 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 return125 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, true140 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, false152 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, false159 return nil, false
@@ -147,6 +182,13 @@ func (r *udpEntryPointRuntime) session(key string, clientAddr net.Addr, options
147 return session, true182 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().IdleTimeout229 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{}, false264 return domain.TrafficBackend{}, false
221 }265 }
266+ entryPoint := r.entryPointSnapshot()
222 var router domain.TrafficRouter267 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{}, false271 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 return301 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 return305 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().DrainTimeout320 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,
28232823 }
28242824 }
28252825
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)
28272827 if err != nil {
28282828 closeStarted(registrySrv)
28292829 return err
@@ -3265,7 +3265,7 @@ func matchingStaticCert(certs []tls.Certificate, serverName string) *tls.Certifi
32653265 return matchingPreparedStaticCert(prepareStaticTLSCertificates(certs), serverName)
32663266 }
32673267
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) {
32693269 // HTTP listener (Cloudflare proxy + onboarding for direct clients)
32703270 var httpProtos http.Protocols
32713271 httpProtos.SetHTTP1(true)
@@ -3311,6 +3311,14 @@ func startProxyServers(cfg Config, httpHandler, httpsHandler http.Handler, pkiSv
33113311 GetCertificate: selector.GetCertificate,
33123312 NextProtos: []string{"h2", "http/1.1"},
33133313 }
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+
33143322 tlsSrv, tlsReady := startTLSServerWithConfig(
33153323 fmt.Sprintf(":%d", cfg.Server.TLSPort),
33163324 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 err2829 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.Protocols3270 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
2626 NetworkServices: cfg.NetworkServices,
2727 })
2828 if err != nil {
29- return err
29+ return fmt.Errorf("build traffic graph: %w", err)
3030 }
3131 owned, err := trafficRuntimeGraph(graph)
3232 if err != nil {
33- return err
33+ return fmt.Errorf("filter traffic graph for runtime ownership: %w", err)
3434 }
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
3639 }
3740
3841 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 err29+ 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 err33+ 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 (
4949 ErrPreviewNotFound = errors.New("preview not found")
5050
5151 // Config errors
52+ ErrTrafficGraphRequired = errors.New("traffic graph is required")
5253 ErrConfigNotFound = errors.New("configuration not found")
5354 ErrInvalidConfig = errors.New("invalid configuration")
5455 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 errors51 // 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 {
114114 }
115115
116116 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
130123 }
131124
132125 type TrafficServiceStatus struct {
@@ -136,18 +129,11 @@ type TrafficServiceStatus struct {
136129 }
137130
138131 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
151137 }
152138
153139 type TrafficCounters struct {
@@ -290,6 +276,9 @@ func validateTrafficService(service TrafficService, existing map[string]TrafficS
290276 }
291277
292278 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+ }
293282 if backend.Port < 1 || backend.Port > 65535 {
294283 return fmt.Errorf("invalid backend port %d for service %q", backend.Port, serviceName)
295284 }
@@ -114,19 +114,12 @@ type EntryPointStatus struct {
114 }114 }
115 115
116 type TrafficRouterStatus struct {116 type TrafficRouterStatus struct {
117- Name string117+ Name string
118- EntryPoint string118+ EntryPoint string
119- Protocol RouterProtocol119+ Protocol RouterProtocol
120- Rule TrafficRule120+ Rule TrafficRule
121- Service string121+ Service string
122- Active bool122+ 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 string132+ Name string
140- Host string133+ Host string
141- Port int134+ Port int
142- Protocol NetworkProtocol135+ Protocol NetworkProtocol
143- Active bool136+ 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) {
346346 services []TrafficService
347347 wantErr string
348348 }{
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+ },
349355 {
350356 name: "backend ports outside range rejected low",
351357 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}}}},
353359 wantErr: "invalid backend port",
354360 },
355361 {
356362 name: "backend ports outside range rejected high",
357363 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}}}},
359365 wantErr: "invalid backend port",
360366 },
361367 {
362368 name: "l4 routers reject services with more than one backend",
363369 router: TrafficRouter{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "network_service:app:db"},
364370 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},
367373 }}},
368374 wantErr: "l4 router service must have exactly one backend",
369375 },
370376 {
371377 name: "tcp router requires tcp network backend",
372378 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}}}},
374380 wantErr: "backend protocol udp does not match router protocol tcp",
375381 },
376382 {
377383 name: "udp router requires udp network backend",
378384 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}}}},
380386 wantErr: "backend protocol tcp does not match router protocol udp",
381387 },
382388 {
383389 name: "tls passthrough requires tcp network backend",
384390 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}}}},
386392 wantErr: "backend protocol udp does not match router protocol tls_passthrough",
387393 },
388394 {
389395 name: "l4 router rejects route ref",
390396 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}}}},
392398 wantErr: "requires network_service service ref",
393399 },
394400 {
395401 name: "static ref parses but graph validation rejects unsupported",
396402 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}}}},
398404 wantErr: "static traffic service ref \"static:maintenance\" is unsupported",
399405 },
400406 }
@@ -517,14 +523,14 @@ func TestTrafficDomainTypesMatchSpecFields(t *testing.T) {
517523 LastReloadStatus: "ok",
518524 LastReloadError: "",
519525 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}}}},
522528 Counters: TrafficCounters{ActiveTCPConnections: 1, ActiveUDPSessions: 2, TotalAccepted: 3, TotalRefused: 4, TotalErrors: 5, BytesIn: 6, BytesOut: 7},
523529 }
524530 require.Equal(t, "ok", status.LastReloadStatus)
525531 require.Empty(t, status.LastReloadError)
526532 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)
529535 require.Equal(t, int64(7), status.Counters.BytesOut)
530536 }
@@ -346,55 +346,61 @@ func TestTrafficGraphValidateBackendsAndL4Routers(t *testing.T) {
346 services []TrafficService346 services []TrafficService
347 wantErr string347 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) {
9090
9191 options, err := buildOptions(input.Traffic)
9292 if err != nil {
93- return domain.TrafficGraph{}, err
93+ return domain.TrafficGraph{}, fmt.Errorf("build traffic options: %w", err)
9494 }
9595 if err := validateNetworkServices(input.NetworkServices); err != nil {
96- return domain.TrafficGraph{}, err
96+ return domain.TrafficGraph{}, fmt.Errorf("validate network services: %w", err)
9797 }
9898 graph.Options = options
9999 graph.EntryPoints = b.buildEntryPoints()
100100
101101 if err := b.addHTTPRoutes(&graph); err != nil {
102- return domain.TrafficGraph{}, err
102+ return domain.TrafficGraph{}, fmt.Errorf("add HTTP routes: %w", err)
103103 }
104104 if err := b.addExternalRoutes(&graph); err != nil {
105- return domain.TrafficGraph{}, err
105+ return domain.TrafficGraph{}, fmt.Errorf("add external routes: %w", err)
106106 }
107107 if err := b.addExplicitRouters(&graph); err != nil {
108- return domain.TrafficGraph{}, err
108+ return domain.TrafficGraph{}, fmt.Errorf("add explicit traffic routers: %w", err)
109109 }
110110
111111 graph.Services = make([]domain.TrafficService, 0, len(b.services))
@@ -114,7 +114,7 @@ func Build(input Input) (domain.TrafficGraph, error) {
114114 }
115115 sort.Slice(graph.Services, func(i, j int) bool { return graph.Services[i].Name < graph.Services[j].Name })
116116 if err := graph.Validate(); err != nil {
117- return domain.TrafficGraph{}, err
117+ return domain.TrafficGraph{}, fmt.Errorf("validate traffic graph: %w", err)
118118 }
119119 return graph, nil
120120 }
@@ -246,11 +246,26 @@ func validateNetworkServicePorts(service NetworkServiceConfig) error {
246246 if _, exists := seen[port.Name]; exists {
247247 return fmt.Errorf("duplicate port %q on network service %q", port.Name, service.Name)
248248 }
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+ }
249255 seen[port.Name] = struct{}{}
250256 }
251257 return nil
252258 }
253259
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+
254269 func (b *builder) networkService(name string) (NetworkServiceConfig, bool) {
255270 for _, service := range b.input.NetworkServices {
256271 if service.Name == name {
@@ -276,29 +291,40 @@ func (b *builder) addService(service domain.TrafficService) {
276291 }
277292
278293 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")
280295 if err != nil {
281296 return domain.TrafficOptions{}, err
282297 }
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")
284299 if err != nil {
285300 return domain.TrafficOptions{}, err
286301 }
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")
288303 if err != nil {
289304 return domain.TrafficOptions{}, err
290305 }
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")
292307 if err != nil {
293308 return domain.TrafficOptions{}, err
294309 }
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")
296311 if err != nil {
297312 return domain.TrafficOptions{}, err
298313 }
299314 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
300315 }
301316
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+
302328 func parseDurationDefault(value string, fallback time.Duration, field string) (time.Duration, error) {
303329 if strings.TrimSpace(value) == "" {
304330 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{}, err93+ 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{}, err96+ return domain.TrafficGraph{}, fmt.Errorf("validate network services: %w", err)
97 }97 }
98 graph.Options = options98 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{}, err102+ 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{}, err105+ 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{}, err108+ 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{}, err117+ return domain.TrafficGraph{}, fmt.Errorf("validate traffic graph: %w", err)
118 }118 }
119 return graph, nil119 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 nil257 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{}, err296 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{}, err300 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{}, err304 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{}, err308 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{}, err312 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}}, nil314 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, nil330 return fallback, nil
M internal/usecase/traffic/builder_test.go +14 -0
@@ -113,6 +113,20 @@ func TestBuildRejectsAmbiguousNetworkServices(t *testing.T) {
113113 }})
114114 require.ErrorContains(t, err, "duplicate port")
115115 })
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+ })
116130 }
117131
118132 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) {

Keyboard shortcuts

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

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

Branches 7

Tags 106

↑↓ moveenter openesc close