detached 3c7cade

feat(traffic): route l4 services from standalone config

commit
3c7cade11a4c6690298e286a9ceb61425b46ef88
author
brice · · 2026-06-30 15:51 UTC
parent
8d2c54f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
16 files · +528 -23
M internal/adapters/in/cli/controlplane.go +1 -0
@@ -41,6 +41,7 @@ type ControlPlane interface {
4141
4242 GetStatus(ctx context.Context) (*remote.Status, error)
4343 GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error)
44+ GetTrafficStatus(ctx context.Context) (*dto.TrafficStatusResponse, error)
4445 Reload(ctx context.Context) error
4546 ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error)
4647 GetConfig(ctx context.Context) (*remote.Config, error)
@@ -41,6 +41,7 @@ type ControlPlane interface {
41 41
42 GetStatus(ctx context.Context) (*remote.Status, error)42 GetStatus(ctx context.Context) (*remote.Status, error)
43 GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error)43 GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error)
44+ GetTrafficStatus(ctx context.Context) (*dto.TrafficStatusResponse, error)
44 Reload(ctx context.Context) error45 Reload(ctx context.Context) error
45 ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error)46 ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error)
46 GetConfig(ctx context.Context) (*remote.Config, error)47 GetConfig(ctx context.Context) (*remote.Config, error)
M internal/adapters/in/cli/controlplane_local.go +4 -0
@@ -445,6 +445,10 @@ func (l *localControlPlane) GetTLSStatus(ctx context.Context) (*dto.TLSStatusRes
445445 return &result, nil
446446 }
447447
448+func (l *localControlPlane) GetTrafficStatus(_ context.Context) (*dto.TrafficStatusResponse, error) {
449+ return nil, fmt.Errorf("local traffic status is unavailable from the in-process CLI control plane; query the running Gordon daemon with --remote or set GORDON_REMOTE to its admin URL")
450+}
451+
448452 func (l *localControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) {
449453 if l.configSvc == nil {
450454 return nil, fmt.Errorf("local config service unavailable")
@@ -445,6 +445,10 @@ func (l *localControlPlane) GetTLSStatus(ctx context.Context) (*dto.TLSStatusRes
445 return &result, nil445 return &result, nil
446 }446 }
447 447
448+func (l *localControlPlane) GetTrafficStatus(_ context.Context) (*dto.TrafficStatusResponse, error) {
449+ return nil, fmt.Errorf("local traffic status is unavailable from the in-process CLI control plane; query the running Gordon daemon with --remote or set GORDON_REMOTE to its admin URL")
450+}
451+
448 func (l *localControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) {452 func (l *localControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) {
449 if l.configSvc == nil {453 if l.configSvc == nil {
450 return nil, fmt.Errorf("local config service unavailable")454 return nil, fmt.Errorf("local config service unavailable")
M internal/adapters/in/cli/controlplane_remote.go +4 -0
@@ -126,6 +126,10 @@ func (r *remoteControlPlane) GetTLSStatus(ctx context.Context) (*dto.TLSStatusRe
126126 return r.client.GetTLSStatus(ctx)
127127 }
128128
129+func (r *remoteControlPlane) GetTrafficStatus(ctx context.Context) (*dto.TrafficStatusResponse, error) {
130+ return r.client.GetTrafficStatus(ctx)
131+}
132+
129133 func (r *remoteControlPlane) Reload(ctx context.Context) error {
130134 return r.client.Reload(ctx)
131135 }
@@ -126,6 +126,10 @@ func (r *remoteControlPlane) GetTLSStatus(ctx context.Context) (*dto.TLSStatusRe
126 return r.client.GetTLSStatus(ctx)126 return r.client.GetTLSStatus(ctx)
127 }127 }
128 128
129+func (r *remoteControlPlane) GetTrafficStatus(ctx context.Context) (*dto.TrafficStatusResponse, error) {
130+ return r.client.GetTrafficStatus(ctx)
131+}
132+
129 func (r *remoteControlPlane) Reload(ctx context.Context) error {133 func (r *remoteControlPlane) Reload(ctx context.Context) error {
130 return r.client.Reload(ctx)134 return r.client.Reload(ctx)
131 }135 }
M internal/adapters/in/cli/mocks/mock_control_plane.go +62 -0
@@ -1532,6 +1532,68 @@ func (_c *MockControlPlane_GetTLSStatus_Call) RunAndReturn(run func(ctx context.
15321532 return _c
15331533 }
15341534
1535+// GetTrafficStatus provides a mock function for the type MockControlPlane
1536+func (_mock *MockControlPlane) GetTrafficStatus(ctx context.Context) (*dto.TrafficStatusResponse, error) {
1537+ ret := _mock.Called(ctx)
1538+
1539+ if len(ret) == 0 {
1540+ panic("no return value specified for GetTrafficStatus")
1541+ }
1542+
1543+ var r0 *dto.TrafficStatusResponse
1544+ var r1 error
1545+ if returnFunc, ok := ret.Get(0).(func(context.Context) (*dto.TrafficStatusResponse, error)); ok {
1546+ return returnFunc(ctx)
1547+ }
1548+ if returnFunc, ok := ret.Get(0).(func(context.Context) *dto.TrafficStatusResponse); ok {
1549+ r0 = returnFunc(ctx)
1550+ } else {
1551+ if ret.Get(0) != nil {
1552+ r0 = ret.Get(0).(*dto.TrafficStatusResponse)
1553+ }
1554+ }
1555+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
1556+ r1 = returnFunc(ctx)
1557+ } else {
1558+ r1 = ret.Error(1)
1559+ }
1560+ return r0, r1
1561+}
1562+
1563+// MockControlPlane_GetTrafficStatus_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetTrafficStatus'
1564+type MockControlPlane_GetTrafficStatus_Call struct {
1565+ *mock.Call
1566+}
1567+
1568+// GetTrafficStatus is a helper method to define mock.On call
1569+// - ctx context.Context
1570+func (_e *MockControlPlane_Expecter) GetTrafficStatus(ctx any) *MockControlPlane_GetTrafficStatus_Call {
1571+ return &MockControlPlane_GetTrafficStatus_Call{Call: _e.mock.On("GetTrafficStatus", ctx)}
1572+}
1573+
1574+func (_c *MockControlPlane_GetTrafficStatus_Call) Run(run func(ctx context.Context)) *MockControlPlane_GetTrafficStatus_Call {
1575+ _c.Call.Run(func(args mock.Arguments) {
1576+ var arg0 context.Context
1577+ if args[0] != nil {
1578+ arg0 = args[0].(context.Context)
1579+ }
1580+ run(
1581+ arg0,
1582+ )
1583+ })
1584+ return _c
1585+}
1586+
1587+func (_c *MockControlPlane_GetTrafficStatus_Call) Return(trafficStatusResponse *dto.TrafficStatusResponse, err error) *MockControlPlane_GetTrafficStatus_Call {
1588+ _c.Call.Return(trafficStatusResponse, err)
1589+ return _c
1590+}
1591+
1592+func (_c *MockControlPlane_GetTrafficStatus_Call) RunAndReturn(run func(ctx context.Context) (*dto.TrafficStatusResponse, error)) *MockControlPlane_GetTrafficStatus_Call {
1593+ _c.Call.Return(run)
1594+ return _c
1595+}
1596+
15351597 // ListBackups provides a mock function for the type MockControlPlane
15361598 func (_mock *MockControlPlane) ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error) {
15371599 ret := _mock.Called(ctx, backupDomain)
@@ -1532,6 +1532,68 @@ func (_c *MockControlPlane_GetTLSStatus_Call) RunAndReturn(run func(ctx context.
1532 return _c1532 return _c
1533 }1533 }
1534 1534
1535+// GetTrafficStatus provides a mock function for the type MockControlPlane
1536+func (_mock *MockControlPlane) GetTrafficStatus(ctx context.Context) (*dto.TrafficStatusResponse, error) {
1537+ ret := _mock.Called(ctx)
1538+
1539+ if len(ret) == 0 {
1540+ panic("no return value specified for GetTrafficStatus")
1541+ }
1542+
1543+ var r0 *dto.TrafficStatusResponse
1544+ var r1 error
1545+ if returnFunc, ok := ret.Get(0).(func(context.Context) (*dto.TrafficStatusResponse, error)); ok {
1546+ return returnFunc(ctx)
1547+ }
1548+ if returnFunc, ok := ret.Get(0).(func(context.Context) *dto.TrafficStatusResponse); ok {
1549+ r0 = returnFunc(ctx)
1550+ } else {
1551+ if ret.Get(0) != nil {
1552+ r0 = ret.Get(0).(*dto.TrafficStatusResponse)
1553+ }
1554+ }
1555+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
1556+ r1 = returnFunc(ctx)
1557+ } else {
1558+ r1 = ret.Error(1)
1559+ }
1560+ return r0, r1
1561+}
1562+
1563+// MockControlPlane_GetTrafficStatus_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetTrafficStatus'
1564+type MockControlPlane_GetTrafficStatus_Call struct {
1565+ *mock.Call
1566+}
1567+
1568+// GetTrafficStatus is a helper method to define mock.On call
1569+// - ctx context.Context
1570+func (_e *MockControlPlane_Expecter) GetTrafficStatus(ctx any) *MockControlPlane_GetTrafficStatus_Call {
1571+ return &MockControlPlane_GetTrafficStatus_Call{Call: _e.mock.On("GetTrafficStatus", ctx)}
1572+}
1573+
1574+func (_c *MockControlPlane_GetTrafficStatus_Call) Run(run func(ctx context.Context)) *MockControlPlane_GetTrafficStatus_Call {
1575+ _c.Call.Run(func(args mock.Arguments) {
1576+ var arg0 context.Context
1577+ if args[0] != nil {
1578+ arg0 = args[0].(context.Context)
1579+ }
1580+ run(
1581+ arg0,
1582+ )
1583+ })
1584+ return _c
1585+}
1586+
1587+func (_c *MockControlPlane_GetTrafficStatus_Call) Return(trafficStatusResponse *dto.TrafficStatusResponse, err error) *MockControlPlane_GetTrafficStatus_Call {
1588+ _c.Call.Return(trafficStatusResponse, err)
1589+ return _c
1590+}
1591+
1592+func (_c *MockControlPlane_GetTrafficStatus_Call) RunAndReturn(run func(ctx context.Context) (*dto.TrafficStatusResponse, error)) *MockControlPlane_GetTrafficStatus_Call {
1593+ _c.Call.Return(run)
1594+ return _c
1595+}
1596+
1535 // ListBackups provides a mock function for the type MockControlPlane1597 // ListBackups provides a mock function for the type MockControlPlane
1536 func (_mock *MockControlPlane) ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error) {1598 func (_mock *MockControlPlane) ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error) {
1537 ret := _mock.Called(ctx, backupDomain)1599 ret := _mock.Called(ctx, backupDomain)
M internal/adapters/in/cli/traffic_status.go +11 -11
@@ -11,28 +11,28 @@ import (
1111 "github.com/bnema/gordon/internal/adapters/dto"
1212 )
1313
14+var trafficResolveControlPlane = resolveControlPlane
15+
1416 func newTrafficStatusCmd() *cobra.Command {
1517 var jsonOut bool
1618 cmd := &cobra.Command{
1719 Use: "status",
18- Short: "Show remote traffic entrypoint, router, and counter status",
20+ Short: "Show traffic entrypoint, router, and counter status",
1921 RunE: func(cmd *cobra.Command, args []string) error {
20- return runTrafficStatus(cmd.Context(), cmd.OutOrStdout(), jsonOut)
22+ handle, err := trafficResolveControlPlane(configPath)
23+ if err != nil {
24+ return err
25+ }
26+ defer handle.close()
27+ return runTrafficStatus(cmd.Context(), handle.plane, cmd.OutOrStdout(), jsonOut)
2128 },
2229 }
2330 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output JSON")
2431 return cmd
2532 }
2633
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)
34+func runTrafficStatus(ctx context.Context, cp ControlPlane, out io.Writer, jsonOut bool) error {
35+ status, err := cp.GetTrafficStatus(ctx)
3636 if err != nil {
3737 return fmt.Errorf("fetch traffic status: %w", err)
3838 }
@@ -11,28 +11,28 @@ import (
11 "github.com/bnema/gordon/internal/adapters/dto"11 "github.com/bnema/gordon/internal/adapters/dto"
12 )12 )
13 13
14+var trafficResolveControlPlane = resolveControlPlane
15+
14 func newTrafficStatusCmd() *cobra.Command {16 func newTrafficStatusCmd() *cobra.Command {
15 var jsonOut bool17 var jsonOut bool
16 cmd := &cobra.Command{18 cmd := &cobra.Command{
17 Use: "status",19 Use: "status",
18- Short: "Show remote traffic entrypoint, router, and counter status",20+ Short: "Show traffic entrypoint, router, and counter status",
19 RunE: func(cmd *cobra.Command, args []string) error {21 RunE: func(cmd *cobra.Command, args []string) error {
20- return runTrafficStatus(cmd.Context(), cmd.OutOrStdout(), jsonOut)22+ handle, err := trafficResolveControlPlane(configPath)
23+ if err != nil {
24+ return err
25+ }
26+ defer handle.close()
27+ return runTrafficStatus(cmd.Context(), handle.plane, cmd.OutOrStdout(), jsonOut)
21 },28 },
22 }29 }
23 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output JSON")30 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output JSON")
24 return cmd31 return cmd
25 }32 }
26 33
27-func runTrafficStatus(ctx context.Context, out io.Writer, jsonOut bool) error {34+func runTrafficStatus(ctx context.Context, cp ControlPlane, out io.Writer, jsonOut bool) error {
28- client, isRemote, err := GetRemoteClient()35+ status, err := cp.GetTrafficStatus(ctx)
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 {36 if err != nil {
37 return fmt.Errorf("fetch traffic status: %w", err)37 return fmt.Errorf("fetch traffic status: %w", err)
38 }38 }
M internal/adapters/in/cli/traffic_test.go +54 -0
@@ -2,13 +2,17 @@ package cli
22
33 import (
44 "bytes"
5+ "context"
56 "encoding/json"
7+ "net/http"
8+ "net/http/httptest"
69 "testing"
710
811 "github.com/stretchr/testify/assert"
912 "github.com/stretchr/testify/require"
1013
1114 "github.com/bnema/gordon/internal/adapters/dto"
15+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
1216 "github.com/bnema/gordon/internal/domain"
1317 )
1418
@@ -68,3 +72,53 @@ func TestTrafficStatusJSONOutput(t *testing.T) {
6872 assert.Equal(t, int64(3), got.Counters.ActiveUDPSessions)
6973 assert.Equal(t, int64(1), got.Counters.SmartTCP.SniffTimeout)
7074 }
75+
76+func TestRunTrafficStatusUsesControlPlane(t *testing.T) {
77+ status := &dto.TrafficStatusResponse{LastReloadStatus: "ok"}
78+ cp := &trafficStatusPlane{status: status}
79+
80+ var buf bytes.Buffer
81+ require.NoError(t, runTrafficStatus(context.Background(), cp, &buf, true))
82+
83+ assert.True(t, cp.called)
84+ assert.Contains(t, buf.String(), `"last_reload_status": "ok"`)
85+}
86+
87+func TestRemoteTrafficStatusControlPlaneStillRenders(t *testing.T) {
88+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
89+ require.Equal(t, "/admin/traffic/status", r.URL.Path)
90+ _, err := w.Write([]byte(`{"last_reload_status":"ok","counters":{"active_udp_sessions":3}}`))
91+ require.NoError(t, err)
92+ }))
93+ defer srv.Close()
94+
95+ cp := NewRemoteControlPlane(remote.NewClient(srv.URL))
96+ var buf bytes.Buffer
97+ require.NoError(t, runTrafficStatus(context.Background(), cp, &buf, false))
98+
99+ output := buf.String()
100+ assert.Contains(t, output, "Traffic Status")
101+ assert.Contains(t, output, "Reload")
102+ assert.Contains(t, output, "ok")
103+ assert.Contains(t, output, "udp=3")
104+}
105+
106+func TestLocalTrafficStatusReturnsActionableDaemonGuidance(t *testing.T) {
107+ _, err := (&localControlPlane{}).GetTrafficStatus(context.Background())
108+ require.Error(t, err)
109+ assert.Contains(t, err.Error(), "in-process CLI control plane")
110+ assert.Contains(t, err.Error(), "running Gordon daemon")
111+ assert.Contains(t, err.Error(), "--remote")
112+ assert.Contains(t, err.Error(), "GORDON_REMOTE")
113+}
114+
115+type trafficStatusPlane struct {
116+ ControlPlane
117+ status *dto.TrafficStatusResponse
118+ called bool
119+}
120+
121+func (p *trafficStatusPlane) GetTrafficStatus(context.Context) (*dto.TrafficStatusResponse, error) {
122+ p.called = true
123+ return p.status, nil
124+}
@@ -2,13 +2,17 @@ package cli
2 2
3 import (3 import (
4 "bytes"4 "bytes"
5+ "context"
5 "encoding/json"6 "encoding/json"
7+ "net/http"
8+ "net/http/httptest"
6 "testing"9 "testing"
7 10
8 "github.com/stretchr/testify/assert"11 "github.com/stretchr/testify/assert"
9 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
10 13
11 "github.com/bnema/gordon/internal/adapters/dto"14 "github.com/bnema/gordon/internal/adapters/dto"
15+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
12 "github.com/bnema/gordon/internal/domain"16 "github.com/bnema/gordon/internal/domain"
13 )17 )
14 18
@@ -68,3 +72,53 @@ func TestTrafficStatusJSONOutput(t *testing.T) {
68 assert.Equal(t, int64(3), got.Counters.ActiveUDPSessions)72 assert.Equal(t, int64(3), got.Counters.ActiveUDPSessions)
69 assert.Equal(t, int64(1), got.Counters.SmartTCP.SniffTimeout)73 assert.Equal(t, int64(1), got.Counters.SmartTCP.SniffTimeout)
70 }74 }
75+
76+func TestRunTrafficStatusUsesControlPlane(t *testing.T) {
77+ status := &dto.TrafficStatusResponse{LastReloadStatus: "ok"}
78+ cp := &trafficStatusPlane{status: status}
79+
80+ var buf bytes.Buffer
81+ require.NoError(t, runTrafficStatus(context.Background(), cp, &buf, true))
82+
83+ assert.True(t, cp.called)
84+ assert.Contains(t, buf.String(), `"last_reload_status": "ok"`)
85+}
86+
87+func TestRemoteTrafficStatusControlPlaneStillRenders(t *testing.T) {
88+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
89+ require.Equal(t, "/admin/traffic/status", r.URL.Path)
90+ _, err := w.Write([]byte(`{"last_reload_status":"ok","counters":{"active_udp_sessions":3}}`))
91+ require.NoError(t, err)
92+ }))
93+ defer srv.Close()
94+
95+ cp := NewRemoteControlPlane(remote.NewClient(srv.URL))
96+ var buf bytes.Buffer
97+ require.NoError(t, runTrafficStatus(context.Background(), cp, &buf, false))
98+
99+ output := buf.String()
100+ assert.Contains(t, output, "Traffic Status")
101+ assert.Contains(t, output, "Reload")
102+ assert.Contains(t, output, "ok")
103+ assert.Contains(t, output, "udp=3")
104+}
105+
106+func TestLocalTrafficStatusReturnsActionableDaemonGuidance(t *testing.T) {
107+ _, err := (&localControlPlane{}).GetTrafficStatus(context.Background())
108+ require.Error(t, err)
109+ assert.Contains(t, err.Error(), "in-process CLI control plane")
110+ assert.Contains(t, err.Error(), "running Gordon daemon")
111+ assert.Contains(t, err.Error(), "--remote")
112+ assert.Contains(t, err.Error(), "GORDON_REMOTE")
113+}
114+
115+type trafficStatusPlane struct {
116+ ControlPlane
117+ status *dto.TrafficStatusResponse
118+ called bool
119+}
120+
121+func (p *trafficStatusPlane) GetTrafficStatus(context.Context) (*dto.TrafficStatusResponse, error) {
122+ p.called = true
123+ return p.status, nil
124+}
M internal/app/traffic.go +6 -0
@@ -7,6 +7,7 @@ import (
77 trafficadapter "github.com/bnema/gordon/internal/adapters/in/traffic"
88 "github.com/bnema/gordon/internal/boundaries/in"
99 "github.com/bnema/gordon/internal/domain"
10+ servicecfg "github.com/bnema/gordon/internal/usecase/services"
1011 trafficbuilder "github.com/bnema/gordon/internal/usecase/traffic"
1112 )
1213
@@ -14,12 +15,17 @@ func applyTrafficRuntimeConfig(ctx context.Context, manager *trafficadapter.Mana
1415 if manager == nil || configSvc == nil {
1516 return nil
1617 }
18+ standaloneServices, err := servicecfg.ToDomain(cfg.Services)
19+ if err != nil {
20+ return fmt.Errorf("convert standalone service config: %w", err)
21+ }
1722 graph, err := trafficbuilder.Build(trafficbuilder.Input{
1823 EntryPoints: cfg.EntryPoints,
1924 Traffic: cfg.Traffic,
2025 Routes: configSvc.GetRoutes(ctx),
2126 ExternalRoutes: configSvc.GetExternalRoutes(),
2227 NetworkServices: cfg.NetworkServices,
28+ Services: standaloneServices,
2329 })
2430 if err != nil {
2531 return fmt.Errorf("build traffic graph: %w", err)
@@ -7,6 +7,7 @@ import (
7 trafficadapter "github.com/bnema/gordon/internal/adapters/in/traffic"7 trafficadapter "github.com/bnema/gordon/internal/adapters/in/traffic"
8 "github.com/bnema/gordon/internal/boundaries/in"8 "github.com/bnema/gordon/internal/boundaries/in"
9 "github.com/bnema/gordon/internal/domain"9 "github.com/bnema/gordon/internal/domain"
10+ servicecfg "github.com/bnema/gordon/internal/usecase/services"
10 trafficbuilder "github.com/bnema/gordon/internal/usecase/traffic"11 trafficbuilder "github.com/bnema/gordon/internal/usecase/traffic"
11 )12 )
12 13
@@ -14,12 +15,17 @@ func applyTrafficRuntimeConfig(ctx context.Context, manager *trafficadapter.Mana
14 if manager == nil || configSvc == nil {15 if manager == nil || configSvc == nil {
15 return nil16 return nil
16 }17 }
18+ standaloneServices, err := servicecfg.ToDomain(cfg.Services)
19+ if err != nil {
20+ return fmt.Errorf("convert standalone service config: %w", err)
21+ }
17 graph, err := trafficbuilder.Build(trafficbuilder.Input{22 graph, err := trafficbuilder.Build(trafficbuilder.Input{
18 EntryPoints: cfg.EntryPoints,23 EntryPoints: cfg.EntryPoints,
19 Traffic: cfg.Traffic,24 Traffic: cfg.Traffic,
20 Routes: configSvc.GetRoutes(ctx),25 Routes: configSvc.GetRoutes(ctx),
21 ExternalRoutes: configSvc.GetExternalRoutes(),26 ExternalRoutes: configSvc.GetExternalRoutes(),
22 NetworkServices: cfg.NetworkServices,27 NetworkServices: cfg.NetworkServices,
28+ Services: standaloneServices,
23 })29 })
24 if err != nil {30 if err != nil {
25 return fmt.Errorf("build traffic graph: %w", err)31 return fmt.Errorf("build traffic graph: %w", err)
M internal/app/traffic_test.go +37 -0
@@ -11,6 +11,7 @@ import (
1111 trafficadapter "github.com/bnema/gordon/internal/adapters/in/traffic"
1212 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
1313 "github.com/bnema/gordon/internal/domain"
14+ servicecfg "github.com/bnema/gordon/internal/usecase/services"
1415 "github.com/bnema/gordon/internal/usecase/traffic"
1516 )
1617
@@ -117,6 +118,33 @@ func TestApplyTrafficRuntimeConfigAppliesSmartTCPEntrypointWithRawFallbackPolicy
117118 assert.Equal(t, domain.EntryPointProtocolSmartTCP, status.EntryPoints[0].Protocol)
118119 }
119120
121+func TestApplyTrafficRuntimeConfigPassesStandaloneServicesToBuilder(t *testing.T) {
122+ manager := trafficadapter.NewManager()
123+ defer func() { require.NoError(t, manager.Shutdown(context.Background())) }()
124+ cfg := Config{
125+ EntryPoints: map[string]traffic.EntryPointConfig{
126+ "rust": {Address: freeUDPAddress(t), Protocol: domain.EntryPointProtocolUDP},
127+ },
128+ Services: []servicecfg.Config{{
129+ Name: "rust",
130+ Image: "localhost/rust:latest",
131+ Enabled: true,
132+ Ports: []servicecfg.PortConfig{{Name: "game", Container: 28015, Protocol: domain.NetworkProtocolUDP, Publish: "127.0.0.1:38015"}},
133+ }},
134+ }
135+ cfg.Traffic.UDP.Routers = []traffic.RouterConfig{{Name: "rust-game", EntryPoint: "rust", Service: "service:rust:game"}}
136+
137+ configSvc := inmocks.NewMockConfigService(t)
138+ configSvc.EXPECT().GetRoutes(context.Background()).Return(nil)
139+ configSvc.EXPECT().GetExternalRoutes().Return(nil)
140+
141+ require.NoError(t, applyTrafficRuntimeConfig(context.Background(), manager, cfg, configSvc))
142+ status := manager.Status()
143+ require.Len(t, status.EntryPoints, 1)
144+ assert.Equal(t, "rust", status.EntryPoints[0].Name)
145+ assert.True(t, status.EntryPoints[0].Active)
146+}
147+
120148 func TestApplyTrafficRuntimeConfigAppliesCustomL4Entrypoint(t *testing.T) {
121149 manager := trafficadapter.NewManager()
122150 defer func() { require.NoError(t, manager.Shutdown(context.Background())) }()
@@ -174,3 +202,12 @@ func freeTCPAddress(t *testing.T) string {
174202 require.NoError(t, listener.Close())
175203 return address
176204 }
205+
206+func freeUDPAddress(t *testing.T) string {
207+ t.Helper()
208+ packetConn, err := net.ListenPacket("udp", "127.0.0.1:0")
209+ require.NoError(t, err)
210+ address := packetConn.LocalAddr().String()
211+ require.NoError(t, packetConn.Close())
212+ return address
213+}
@@ -11,6 +11,7 @@ import (
11 trafficadapter "github.com/bnema/gordon/internal/adapters/in/traffic"11 trafficadapter "github.com/bnema/gordon/internal/adapters/in/traffic"
12 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"12 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
13 "github.com/bnema/gordon/internal/domain"13 "github.com/bnema/gordon/internal/domain"
14+ servicecfg "github.com/bnema/gordon/internal/usecase/services"
14 "github.com/bnema/gordon/internal/usecase/traffic"15 "github.com/bnema/gordon/internal/usecase/traffic"
15 )16 )
16 17
@@ -117,6 +118,33 @@ func TestApplyTrafficRuntimeConfigAppliesSmartTCPEntrypointWithRawFallbackPolicy
117 assert.Equal(t, domain.EntryPointProtocolSmartTCP, status.EntryPoints[0].Protocol)118 assert.Equal(t, domain.EntryPointProtocolSmartTCP, status.EntryPoints[0].Protocol)
118 }119 }
119 120
121+func TestApplyTrafficRuntimeConfigPassesStandaloneServicesToBuilder(t *testing.T) {
122+ manager := trafficadapter.NewManager()
123+ defer func() { require.NoError(t, manager.Shutdown(context.Background())) }()
124+ cfg := Config{
125+ EntryPoints: map[string]traffic.EntryPointConfig{
126+ "rust": {Address: freeUDPAddress(t), Protocol: domain.EntryPointProtocolUDP},
127+ },
128+ Services: []servicecfg.Config{{
129+ Name: "rust",
130+ Image: "localhost/rust:latest",
131+ Enabled: true,
132+ Ports: []servicecfg.PortConfig{{Name: "game", Container: 28015, Protocol: domain.NetworkProtocolUDP, Publish: "127.0.0.1:38015"}},
133+ }},
134+ }
135+ cfg.Traffic.UDP.Routers = []traffic.RouterConfig{{Name: "rust-game", EntryPoint: "rust", Service: "service:rust:game"}}
136+
137+ configSvc := inmocks.NewMockConfigService(t)
138+ configSvc.EXPECT().GetRoutes(context.Background()).Return(nil)
139+ configSvc.EXPECT().GetExternalRoutes().Return(nil)
140+
141+ require.NoError(t, applyTrafficRuntimeConfig(context.Background(), manager, cfg, configSvc))
142+ status := manager.Status()
143+ require.Len(t, status.EntryPoints, 1)
144+ assert.Equal(t, "rust", status.EntryPoints[0].Name)
145+ assert.True(t, status.EntryPoints[0].Active)
146+}
147+
120 func TestApplyTrafficRuntimeConfigAppliesCustomL4Entrypoint(t *testing.T) {148 func TestApplyTrafficRuntimeConfigAppliesCustomL4Entrypoint(t *testing.T) {
121 manager := trafficadapter.NewManager()149 manager := trafficadapter.NewManager()
122 defer func() { require.NoError(t, manager.Shutdown(context.Background())) }()150 defer func() { require.NoError(t, manager.Shutdown(context.Background())) }()
@@ -174,3 +202,12 @@ func freeTCPAddress(t *testing.T) string {
174 require.NoError(t, listener.Close())202 require.NoError(t, listener.Close())
175 return address203 return address
176 }204 }
205+
206+func freeUDPAddress(t *testing.T) string {
207+ t.Helper()
208+ packetConn, err := net.ListenPacket("udp", "127.0.0.1:0")
209+ require.NoError(t, err)
210+ address := packetConn.LocalAddr().String()
211+ require.NoError(t, packetConn.Close())
212+ return address
213+}
M internal/domain/service.go +4 -0
@@ -42,6 +42,7 @@ type StandaloneServicePort struct {
4242 Protocol NetworkProtocol
4343 Publish string
4444 Private bool
45+ Public bool
4546 TrustedCIDRs []string
4647 }
4748
@@ -124,6 +125,9 @@ func validateStandaloneServicePorts(s StandaloneService) error {
124125 if port.Protocol != NetworkProtocolTCP && port.Protocol != NetworkProtocolUDP {
125126 return fmt.Errorf("standalone service %q port %q protocol must be tcp or udp", s.Name, name)
126127 }
128+ if port.Private && port.Public {
129+ return fmt.Errorf("standalone service %q port %q cannot be both private and public", s.Name, name)
130+ }
127131 if err := validateStandaloneServicePublish(s.Name, name, port.Publish); err != nil {
128132 return err
129133 }
@@ -42,6 +42,7 @@ type StandaloneServicePort struct {
42 Protocol NetworkProtocol42 Protocol NetworkProtocol
43 Publish string43 Publish string
44 Private bool44 Private bool
45+ Public bool
45 TrustedCIDRs []string46 TrustedCIDRs []string
46 }47 }
47 48
@@ -124,6 +125,9 @@ func validateStandaloneServicePorts(s StandaloneService) error {
124 if port.Protocol != NetworkProtocolTCP && port.Protocol != NetworkProtocolUDP {125 if port.Protocol != NetworkProtocolTCP && port.Protocol != NetworkProtocolUDP {
125 return fmt.Errorf("standalone service %q port %q protocol must be tcp or udp", s.Name, name)126 return fmt.Errorf("standalone service %q port %q protocol must be tcp or udp", s.Name, name)
126 }127 }
128+ if port.Private && port.Public {
129+ return fmt.Errorf("standalone service %q port %q cannot be both private and public", s.Name, name)
130+ }
127 if err := validateStandaloneServicePublish(s.Name, name, port.Publish); err != nil {131 if err := validateStandaloneServicePublish(s.Name, name, port.Publish); err != nil {
128 return err132 return err
129 }133 }
M internal/domain/service_test.go +4 -0
@@ -43,6 +43,10 @@ func TestStandaloneServiceValidatePorts(t *testing.T) {
4343 badPublish := valid
4444 badPublish.Ports = []StandaloneServicePort{{Name: "game", Container: 28015, Protocol: NetworkProtocolUDP, Publish: "127.0.0.1:not-a-port"}}
4545 require.ErrorContains(t, badPublish.Validate(), "valid port")
46+
47+ conflictingVisibility := valid
48+ conflictingVisibility.Ports = []StandaloneServicePort{{Name: "rcon", Container: 28016, Protocol: NetworkProtocolTCP, Private: true, Public: true}}
49+ require.ErrorContains(t, conflictingVisibility.Validate(), "cannot be both private and public")
4650 }
4751
4852 func TestStandaloneServiceValidateVolumesAndEnvFile(t *testing.T) {
@@ -43,6 +43,10 @@ func TestStandaloneServiceValidatePorts(t *testing.T) {
43 badPublish := valid43 badPublish := valid
44 badPublish.Ports = []StandaloneServicePort{{Name: "game", Container: 28015, Protocol: NetworkProtocolUDP, Publish: "127.0.0.1:not-a-port"}}44 badPublish.Ports = []StandaloneServicePort{{Name: "game", Container: 28015, Protocol: NetworkProtocolUDP, Publish: "127.0.0.1:not-a-port"}}
45 require.ErrorContains(t, badPublish.Validate(), "valid port")45 require.ErrorContains(t, badPublish.Validate(), "valid port")
46+
47+ conflictingVisibility := valid
48+ conflictingVisibility.Ports = []StandaloneServicePort{{Name: "rcon", Container: 28016, Protocol: NetworkProtocolTCP, Private: true, Public: true}}
49+ require.ErrorContains(t, conflictingVisibility.Validate(), "cannot be both private and public")
46 }50 }
47 51
48 func TestStandaloneServiceValidateVolumesAndEnvFile(t *testing.T) {52 func TestStandaloneServiceValidateVolumesAndEnvFile(t *testing.T) {
M internal/domain/traffic.go +9 -2
@@ -173,6 +173,7 @@ const (
173173 TrafficServiceRefRoute TrafficServiceRefKind = "route"
174174 TrafficServiceRefExternalRoute TrafficServiceRefKind = "external_route"
175175 TrafficServiceRefNetworkService TrafficServiceRefKind = "network_service"
176+ TrafficServiceRefService TrafficServiceRefKind = "service"
176177 TrafficServiceRefStatic TrafficServiceRefKind = "static"
177178 )
178179
@@ -200,6 +201,12 @@ func ParseTrafficServiceRef(value string) (TrafficServiceRef, error) {
200201 return TrafficServiceRef{}, fmt.Errorf("invalid network service ref %q", value)
201202 }
202203 return TrafficServiceRef{Kind: TrafficServiceRefNetworkService, Name: name, PortName: portName}, nil
204+ case TrafficServiceRefService:
205+ name, portName, ok := strings.Cut(rest, ":")
206+ if !ok || name == "" || portName == "" || strings.Contains(portName, ":") {
207+ return TrafficServiceRef{}, fmt.Errorf("invalid service ref %q", value)
208+ }
209+ return TrafficServiceRef{Kind: TrafficServiceRefService, Name: name, PortName: portName}, nil
203210 case TrafficServiceRefStatic:
204211 return TrafficServiceRef{Kind: TrafficServiceRefStatic, Name: rest, Reserved: true}, nil
205212 default:
@@ -550,11 +557,11 @@ func validateL4RouterService(router TrafficRouter, ref TrafficServiceRef, servic
550557 if !ok {
551558 return nil
552559 }
553- if ref.Kind != TrafficServiceRefNetworkService {
560+ if ref.Kind != TrafficServiceRefNetworkService && ref.Kind != TrafficServiceRefService {
554561 if ref.Kind == TrafficServiceRefStatic {
555562 return fmt.Errorf("static traffic service ref %q is unsupported in traffic graph validation", router.Service)
556563 }
557- return fmt.Errorf("l4 router %q requires network_service service ref", router.Name)
564+ return fmt.Errorf("l4 router %q requires network_service or service service ref", router.Name)
558565 }
559566 if len(service.Backends) != 1 {
560567 return fmt.Errorf("l4 router service must have exactly one backend for router %q", router.Name)
@@ -173,6 +173,7 @@ const (
173 TrafficServiceRefRoute TrafficServiceRefKind = "route"173 TrafficServiceRefRoute TrafficServiceRefKind = "route"
174 TrafficServiceRefExternalRoute TrafficServiceRefKind = "external_route"174 TrafficServiceRefExternalRoute TrafficServiceRefKind = "external_route"
175 TrafficServiceRefNetworkService TrafficServiceRefKind = "network_service"175 TrafficServiceRefNetworkService TrafficServiceRefKind = "network_service"
176+ TrafficServiceRefService TrafficServiceRefKind = "service"
176 TrafficServiceRefStatic TrafficServiceRefKind = "static"177 TrafficServiceRefStatic TrafficServiceRefKind = "static"
177 )178 )
178 179
@@ -200,6 +201,12 @@ func ParseTrafficServiceRef(value string) (TrafficServiceRef, error) {
200 return TrafficServiceRef{}, fmt.Errorf("invalid network service ref %q", value)201 return TrafficServiceRef{}, fmt.Errorf("invalid network service ref %q", value)
201 }202 }
202 return TrafficServiceRef{Kind: TrafficServiceRefNetworkService, Name: name, PortName: portName}, nil203 return TrafficServiceRef{Kind: TrafficServiceRefNetworkService, Name: name, PortName: portName}, nil
204+ case TrafficServiceRefService:
205+ name, portName, ok := strings.Cut(rest, ":")
206+ if !ok || name == "" || portName == "" || strings.Contains(portName, ":") {
207+ return TrafficServiceRef{}, fmt.Errorf("invalid service ref %q", value)
208+ }
209+ return TrafficServiceRef{Kind: TrafficServiceRefService, Name: name, PortName: portName}, nil
203 case TrafficServiceRefStatic:210 case TrafficServiceRefStatic:
204 return TrafficServiceRef{Kind: TrafficServiceRefStatic, Name: rest, Reserved: true}, nil211 return TrafficServiceRef{Kind: TrafficServiceRefStatic, Name: rest, Reserved: true}, nil
205 default:212 default:
@@ -550,11 +557,11 @@ func validateL4RouterService(router TrafficRouter, ref TrafficServiceRef, servic
550 if !ok {557 if !ok {
551 return nil558 return nil
552 }559 }
553- if ref.Kind != TrafficServiceRefNetworkService {560+ if ref.Kind != TrafficServiceRefNetworkService && ref.Kind != TrafficServiceRefService {
554 if ref.Kind == TrafficServiceRefStatic {561 if ref.Kind == TrafficServiceRefStatic {
555 return fmt.Errorf("static traffic service ref %q is unsupported in traffic graph validation", router.Service)562 return fmt.Errorf("static traffic service ref %q is unsupported in traffic graph validation", router.Service)
556 }563 }
557- return fmt.Errorf("l4 router %q requires network_service service ref", router.Name)564+ return fmt.Errorf("l4 router %q requires network_service or service service ref", router.Name)
558 }565 }
559 if len(service.Backends) != 1 {566 if len(service.Backends) != 1 {
560 return fmt.Errorf("l4 router service must have exactly one backend for router %q", router.Name)567 return fmt.Errorf("l4 router service must have exactly one backend for router %q", router.Name)
M internal/domain/traffic_test.go +47 -2
@@ -483,7 +483,7 @@ func TestTrafficGraphValidateBackendsAndL4Routers(t *testing.T) {
483483 name: "l4 router rejects route ref",
484484 router: TrafficRouter{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "route:app.example.com"},
485485 services: []TrafficService{{Name: "route:app.example.com", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 443, Protocol: NetworkProtocolTCP}}}},
486- wantErr: "requires network_service service ref",
486+ wantErr: "requires network_service or service service ref",
487487 },
488488 {
489489 name: "static ref parses but graph validation rejects unsupported",
@@ -509,6 +509,49 @@ func TestTrafficGraphValidateBackendsAndL4Routers(t *testing.T) {
509509 }
510510 }
511511
512+func TestTrafficGraphValidateL4ServiceRefs(t *testing.T) {
513+ tests := []struct {
514+ name string
515+ routerProtocol RouterProtocol
516+ entryPoint EntryPoint
517+ backend TrafficBackend
518+ }{
519+ {
520+ name: "tcp allows service ref with tcp backend",
521+ routerProtocol: RouterProtocolTCP,
522+ entryPoint: EntryPoint{Name: "tcp", Address: ":25565", Protocol: EntryPointProtocolTCP},
523+ backend: TrafficBackend{Name: "rust", Host: "10.0.0.2", Port: 25565, Protocol: NetworkProtocolTCP},
524+ },
525+ {
526+ name: "udp allows service ref with udp backend",
527+ routerProtocol: RouterProtocolUDP,
528+ entryPoint: EntryPoint{Name: "udp", Address: ":25565", Protocol: EntryPointProtocolUDP},
529+ backend: TrafficBackend{Name: "rust", Host: "10.0.0.2", Port: 25565, Protocol: NetworkProtocolUDP},
530+ },
531+ {
532+ name: "tls passthrough allows service ref with tcp backend",
533+ routerProtocol: RouterProtocolTLSPassthrough,
534+ entryPoint: EntryPoint{Name: "tls", Address: ":443", Protocol: EntryPointProtocolTLSMux},
535+ backend: TrafficBackend{Name: "rust", Host: "10.0.0.2", Port: 443, Protocol: NetworkProtocolTCP},
536+ },
537+ }
538+
539+ for _, tt := range tests {
540+ t.Run(tt.name, func(t *testing.T) {
541+ router := TrafficRouter{Name: "router", EntryPoint: tt.entryPoint.Name, Protocol: tt.routerProtocol, Service: "service:rust:game"}
542+ if tt.routerProtocol == RouterProtocolTLSPassthrough {
543+ router.Rule = TrafficRule{SNI: "rust.example.com"}
544+ }
545+ graph := TrafficGraph{
546+ EntryPoints: []EntryPoint{tt.entryPoint},
547+ Routers: []TrafficRouter{router},
548+ Services: []TrafficService{{Name: "service:rust:game", Backends: []TrafficBackend{tt.backend}}},
549+ }
550+ require.NoError(t, graph.Validate())
551+ })
552+ }
553+}
554+
512555 func TestTrafficGraphValidateHTTPServiceRefs(t *testing.T) {
513556 tests := []struct {
514557 name string
@@ -518,6 +561,7 @@ func TestTrafficGraphValidateHTTPServiceRefs(t *testing.T) {
518561 {name: "http allows route", service: "route:app.example.com"},
519562 {name: "http allows external_route", service: "external_route:api.example.com"},
520563 {name: "http rejects network_service", service: "network_service:app:http", wantErr: "does not support service ref kind network_service"},
564+ {name: "http rejects service", service: "service:app:http", wantErr: "does not support service ref kind service"},
521565 {name: "http rejects static", service: "static:maintenance", wantErr: "static traffic service ref \"static:maintenance\" is unsupported"},
522566 }
523567
@@ -575,6 +619,7 @@ func TestParseTrafficServiceRef(t *testing.T) {
575619 {input: "network_service:postgres:db", want: TrafficServiceRef{Kind: TrafficServiceRefNetworkService, Name: "postgres", PortName: "db"}},
576620 {input: "network_service:web:http", want: TrafficServiceRef{Kind: TrafficServiceRefNetworkService, Name: "web", PortName: "http"}},
577621 {input: "network_service:game:game", want: TrafficServiceRef{Kind: TrafficServiceRefNetworkService, Name: "game", PortName: "game"}},
622+ {input: "service:rust:game", want: TrafficServiceRef{Kind: TrafficServiceRefService, Name: "rust", PortName: "game"}},
578623 {input: "static:maintenance", want: TrafficServiceRef{Kind: TrafficServiceRefStatic, Name: "maintenance", Reserved: true}},
579624 }
580625
@@ -586,7 +631,7 @@ func TestParseTrafficServiceRef(t *testing.T) {
586631 })
587632 }
588633
589- invalid := []string{"", "route:", "external_route:", "network_service:", "network_service:postgres", "network_service:postgres:", "network_service::db", "network_service:postgres:db:extra", "static:", "unknown:value"}
634+ invalid := []string{"", "route:", "external_route:", "network_service:", "network_service:postgres", "network_service:postgres:", "network_service::db", "network_service:postgres:db:extra", "service:", "service:rust", "service:rust:", "service::game", "service:rust:game:extra", "static:", "unknown:value"}
590635 for _, input := range invalid {
591636 t.Run("invalid "+input, func(t *testing.T) {
592637 _, err := ParseTrafficServiceRef(input)
@@ -483,7 +483,7 @@ func TestTrafficGraphValidateBackendsAndL4Routers(t *testing.T) {
483 name: "l4 router rejects route ref",483 name: "l4 router rejects route ref",
484 router: TrafficRouter{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "route:app.example.com"},484 router: TrafficRouter{Name: "tcp", EntryPoint: "tcp", Protocol: RouterProtocolTCP, Service: "route:app.example.com"},
485 services: []TrafficService{{Name: "route:app.example.com", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 443, Protocol: NetworkProtocolTCP}}}},485 services: []TrafficService{{Name: "route:app.example.com", Backends: []TrafficBackend{{Name: "app", Host: "10.0.0.2", Port: 443, Protocol: NetworkProtocolTCP}}}},
486- wantErr: "requires network_service service ref",486+ wantErr: "requires network_service or service service ref",
487 },487 },
488 {488 {
489 name: "static ref parses but graph validation rejects unsupported",489 name: "static ref parses but graph validation rejects unsupported",
@@ -509,6 +509,49 @@ func TestTrafficGraphValidateBackendsAndL4Routers(t *testing.T) {
509 }509 }
510 }510 }
511 511
512+func TestTrafficGraphValidateL4ServiceRefs(t *testing.T) {
513+ tests := []struct {
514+ name string
515+ routerProtocol RouterProtocol
516+ entryPoint EntryPoint
517+ backend TrafficBackend
518+ }{
519+ {
520+ name: "tcp allows service ref with tcp backend",
521+ routerProtocol: RouterProtocolTCP,
522+ entryPoint: EntryPoint{Name: "tcp", Address: ":25565", Protocol: EntryPointProtocolTCP},
523+ backend: TrafficBackend{Name: "rust", Host: "10.0.0.2", Port: 25565, Protocol: NetworkProtocolTCP},
524+ },
525+ {
526+ name: "udp allows service ref with udp backend",
527+ routerProtocol: RouterProtocolUDP,
528+ entryPoint: EntryPoint{Name: "udp", Address: ":25565", Protocol: EntryPointProtocolUDP},
529+ backend: TrafficBackend{Name: "rust", Host: "10.0.0.2", Port: 25565, Protocol: NetworkProtocolUDP},
530+ },
531+ {
532+ name: "tls passthrough allows service ref with tcp backend",
533+ routerProtocol: RouterProtocolTLSPassthrough,
534+ entryPoint: EntryPoint{Name: "tls", Address: ":443", Protocol: EntryPointProtocolTLSMux},
535+ backend: TrafficBackend{Name: "rust", Host: "10.0.0.2", Port: 443, Protocol: NetworkProtocolTCP},
536+ },
537+ }
538+
539+ for _, tt := range tests {
540+ t.Run(tt.name, func(t *testing.T) {
541+ router := TrafficRouter{Name: "router", EntryPoint: tt.entryPoint.Name, Protocol: tt.routerProtocol, Service: "service:rust:game"}
542+ if tt.routerProtocol == RouterProtocolTLSPassthrough {
543+ router.Rule = TrafficRule{SNI: "rust.example.com"}
544+ }
545+ graph := TrafficGraph{
546+ EntryPoints: []EntryPoint{tt.entryPoint},
547+ Routers: []TrafficRouter{router},
548+ Services: []TrafficService{{Name: "service:rust:game", Backends: []TrafficBackend{tt.backend}}},
549+ }
550+ require.NoError(t, graph.Validate())
551+ })
552+ }
553+}
554+
512 func TestTrafficGraphValidateHTTPServiceRefs(t *testing.T) {555 func TestTrafficGraphValidateHTTPServiceRefs(t *testing.T) {
513 tests := []struct {556 tests := []struct {
514 name string557 name string
@@ -518,6 +561,7 @@ func TestTrafficGraphValidateHTTPServiceRefs(t *testing.T) {
518 {name: "http allows route", service: "route:app.example.com"},561 {name: "http allows route", service: "route:app.example.com"},
519 {name: "http allows external_route", service: "external_route:api.example.com"},562 {name: "http allows external_route", service: "external_route:api.example.com"},
520 {name: "http rejects network_service", service: "network_service:app:http", wantErr: "does not support service ref kind network_service"},563 {name: "http rejects network_service", service: "network_service:app:http", wantErr: "does not support service ref kind network_service"},
564+ {name: "http rejects service", service: "service:app:http", wantErr: "does not support service ref kind service"},
521 {name: "http rejects static", service: "static:maintenance", wantErr: "static traffic service ref \"static:maintenance\" is unsupported"},565 {name: "http rejects static", service: "static:maintenance", wantErr: "static traffic service ref \"static:maintenance\" is unsupported"},
522 }566 }
523 567
@@ -575,6 +619,7 @@ func TestParseTrafficServiceRef(t *testing.T) {
575 {input: "network_service:postgres:db", want: TrafficServiceRef{Kind: TrafficServiceRefNetworkService, Name: "postgres", PortName: "db"}},619 {input: "network_service:postgres:db", want: TrafficServiceRef{Kind: TrafficServiceRefNetworkService, Name: "postgres", PortName: "db"}},
576 {input: "network_service:web:http", want: TrafficServiceRef{Kind: TrafficServiceRefNetworkService, Name: "web", PortName: "http"}},620 {input: "network_service:web:http", want: TrafficServiceRef{Kind: TrafficServiceRefNetworkService, Name: "web", PortName: "http"}},
577 {input: "network_service:game:game", want: TrafficServiceRef{Kind: TrafficServiceRefNetworkService, Name: "game", PortName: "game"}},621 {input: "network_service:game:game", want: TrafficServiceRef{Kind: TrafficServiceRefNetworkService, Name: "game", PortName: "game"}},
622+ {input: "service:rust:game", want: TrafficServiceRef{Kind: TrafficServiceRefService, Name: "rust", PortName: "game"}},
578 {input: "static:maintenance", want: TrafficServiceRef{Kind: TrafficServiceRefStatic, Name: "maintenance", Reserved: true}},623 {input: "static:maintenance", want: TrafficServiceRef{Kind: TrafficServiceRefStatic, Name: "maintenance", Reserved: true}},
579 }624 }
580 625
@@ -586,7 +631,7 @@ func TestParseTrafficServiceRef(t *testing.T) {
586 })631 })
587 }632 }
588 633
589- invalid := []string{"", "route:", "external_route:", "network_service:", "network_service:postgres", "network_service:postgres:", "network_service::db", "network_service:postgres:db:extra", "static:", "unknown:value"}634+ invalid := []string{"", "route:", "external_route:", "network_service:", "network_service:postgres", "network_service:postgres:", "network_service::db", "network_service:postgres:db:extra", "service:", "service:rust", "service:rust:", "service::game", "service:rust:game:extra", "static:", "unknown:value"}
590 for _, input := range invalid {635 for _, input := range invalid {
591 t.Run("invalid "+input, func(t *testing.T) {636 t.Run("invalid "+input, func(t *testing.T) {
592 _, err := ParseTrafficServiceRef(input)637 _, err := ParseTrafficServiceRef(input)
M internal/usecase/services/config.go +2 -0
@@ -27,6 +27,7 @@ type PortConfig struct {
2727 Protocol domain.NetworkProtocol `mapstructure:"protocol"`
2828 Publish string `mapstructure:"publish"`
2929 Private bool `mapstructure:"private"`
30+ Public bool `mapstructure:"public"`
3031 TrustedCIDRs []string `mapstructure:"trusted_cidrs"`
3132 }
3233
@@ -141,6 +142,7 @@ func portsToDomain(configs []PortConfig) []domain.StandaloneServicePort {
141142 Protocol: cfg.Protocol,
142143 Publish: cfg.Publish,
143144 Private: cfg.Private,
145+ Public: cfg.Public,
144146 TrustedCIDRs: append([]string(nil), cfg.TrustedCIDRs...),
145147 })
146148 }
@@ -27,6 +27,7 @@ type PortConfig struct {
27 Protocol domain.NetworkProtocol `mapstructure:"protocol"`27 Protocol domain.NetworkProtocol `mapstructure:"protocol"`
28 Publish string `mapstructure:"publish"`28 Publish string `mapstructure:"publish"`
29 Private bool `mapstructure:"private"`29 Private bool `mapstructure:"private"`
30+ Public bool `mapstructure:"public"`
30 TrustedCIDRs []string `mapstructure:"trusted_cidrs"`31 TrustedCIDRs []string `mapstructure:"trusted_cidrs"`
31 }32 }
32 33
@@ -141,6 +142,7 @@ func portsToDomain(configs []PortConfig) []domain.StandaloneServicePort {
141 Protocol: cfg.Protocol,142 Protocol: cfg.Protocol,
142 Publish: cfg.Publish,143 Publish: cfg.Publish,
143 Private: cfg.Private,144 Private: cfg.Private,
145+ Public: cfg.Public,
144 TrustedCIDRs: append([]string(nil), cfg.TrustedCIDRs...),146 TrustedCIDRs: append([]string(nil), cfg.TrustedCIDRs...),
145 })147 })
146 }148 }
M internal/usecase/services/config_test.go +21 -0
@@ -41,6 +41,27 @@ func TestConfigToDomainRejectsNonPositiveReadinessTimeout(t *testing.T) {
4141 }
4242 }
4343
44+func TestConfigToDomainMapsExplicitPublicPorts(t *testing.T) {
45+ cfg := Config{
46+ Name: "game",
47+ Image: "game:latest",
48+ Enabled: true,
49+ Ports: []PortConfig{{
50+ Name: "rcon",
51+ Container: 28016,
52+ Protocol: domain.NetworkProtocolTCP,
53+ Publish: "127.0.0.1:38016",
54+ Public: true,
55+ }},
56+ }
57+
58+ svc, err := cfg.ToDomain()
59+
60+ require.NoError(t, err)
61+ require.Len(t, svc.Ports, 1)
62+ assert.True(t, svc.Ports[0].Public)
63+}
64+
4465 func TestResolveVolumeMountsUsesExplicitVolumesAsIs(t *testing.T) {
4566 explicit := []domain.StandaloneServiceVolume{
4667 {Source: "rust-data", Target: "/steamcmd/rust", ReadOnly: false},
@@ -41,6 +41,27 @@ func TestConfigToDomainRejectsNonPositiveReadinessTimeout(t *testing.T) {
41 }41 }
42 }42 }
43 43
44+func TestConfigToDomainMapsExplicitPublicPorts(t *testing.T) {
45+ cfg := Config{
46+ Name: "game",
47+ Image: "game:latest",
48+ Enabled: true,
49+ Ports: []PortConfig{{
50+ Name: "rcon",
51+ Container: 28016,
52+ Protocol: domain.NetworkProtocolTCP,
53+ Publish: "127.0.0.1:38016",
54+ Public: true,
55+ }},
56+ }
57+
58+ svc, err := cfg.ToDomain()
59+
60+ require.NoError(t, err)
61+ require.Len(t, svc.Ports, 1)
62+ assert.True(t, svc.Ports[0].Public)
63+}
64+
44 func TestResolveVolumeMountsUsesExplicitVolumesAsIs(t *testing.T) {65 func TestResolveVolumeMountsUsesExplicitVolumesAsIs(t *testing.T) {
45 explicit := []domain.StandaloneServiceVolume{66 explicit := []domain.StandaloneServiceVolume{
46 {Source: "rust-data", Target: "/steamcmd/rust", ReadOnly: false},67 {Source: "rust-data", Target: "/steamcmd/rust", ReadOnly: false},
M internal/usecase/traffic/builder.go +118 -8
@@ -20,6 +20,7 @@ type Input struct {
2020 Routes []domain.Route
2121 ExternalRoutes map[string]string
2222 NetworkServices []NetworkServiceConfig
23+ Services []domain.StandaloneService
2324 }
2425
2526 type EntryPointConfig struct {
@@ -89,6 +90,9 @@ func Build(input Input) (domain.TrafficGraph, error) {
8990 if err := validateNetworkServices(input.NetworkServices); err != nil {
9091 return domain.TrafficGraph{}, fmt.Errorf("validate network services: %w", err)
9192 }
93+ if err := validateStandaloneServices(input.Services); err != nil {
94+ return domain.TrafficGraph{}, fmt.Errorf("validate standalone services: %w", err)
95+ }
9296 graph.Options = options
9397 graph.EntryPoints = b.buildEntryPoints()
9498
@@ -219,7 +223,7 @@ func (b *builder) addL4Routers(graph *domain.TrafficGraph, routers []RouterConfi
219223 ordered := append([]RouterConfig{}, routers...)
220224 sort.Slice(ordered, func(i, j int) bool { return ordered[i].Name < ordered[j].Name })
221225 for _, cfg := range ordered {
222- service, err := b.resolveService(cfg.Service, backendProtocol)
226+ service, err := b.resolveService(cfg, backendProtocol)
223227 if err != nil {
224228 return fmt.Errorf("router %q: %w", cfg.Name, err)
225229 }
@@ -229,17 +233,24 @@ func (b *builder) addL4Routers(graph *domain.TrafficGraph, routers []RouterConfi
229233 return nil
230234 }
231235
232-func (b *builder) resolveService(refValue string, protocol domain.NetworkProtocol) (domain.TrafficService, error) {
233- ref, err := domain.ParseTrafficServiceRef(refValue)
236+func (b *builder) resolveService(router RouterConfig, protocol domain.NetworkProtocol) (domain.TrafficService, error) {
237+ ref, err := domain.ParseTrafficServiceRef(router.Service)
234238 if err != nil {
235239 return domain.TrafficService{}, err
236240 }
237- if ref.Kind == domain.TrafficServiceRefStatic {
238- return domain.TrafficService{}, fmt.Errorf("static service ref %q is unsupported", refValue)
239- }
240- if ref.Kind != domain.TrafficServiceRefNetworkService {
241- return domain.TrafficService{}, fmt.Errorf("service ref %q must be network_service:<name>:<port-name>", refValue)
241+ switch ref.Kind {
242+ case domain.TrafficServiceRefNetworkService:
243+ return b.resolveNetworkService(router.Service, ref, protocol)
244+ case domain.TrafficServiceRefService:
245+ return b.resolveStandaloneService(router, ref, protocol)
246+ case domain.TrafficServiceRefStatic:
247+ return domain.TrafficService{}, fmt.Errorf("static service ref %q is unsupported", router.Service)
248+ default:
249+ return domain.TrafficService{}, fmt.Errorf("service ref %q must be network_service:<name>:<port-name> or service:<name>:<port-name>", router.Service)
242250 }
251+}
252+
253+func (b *builder) resolveNetworkService(refValue string, ref domain.TrafficServiceRef, protocol domain.NetworkProtocol) (domain.TrafficService, error) {
243254 ns, ok := b.networkService(ref.Name)
244255 if !ok {
245256 return domain.TrafficService{}, fmt.Errorf("unknown network service %q", ref.Name)
@@ -257,6 +268,47 @@ func (b *builder) resolveService(refValue string, protocol domain.NetworkProtoco
257268 return domain.TrafficService{Name: refValue, Backends: []domain.TrafficBackend{{Name: ref.Name + ":" + ref.PortName, Host: ref.Name, Port: port.Container, Protocol: port.Protocol}}}, nil
258269 }
259270
271+func (b *builder) resolveStandaloneService(router RouterConfig, ref domain.TrafficServiceRef, protocol domain.NetworkProtocol) (domain.TrafficService, error) {
272+ svc, ok := b.standaloneService(ref.Name)
273+ if !ok {
274+ return domain.TrafficService{}, fmt.Errorf("unknown service %q", ref.Name)
275+ }
276+ port, ok := standaloneServicePort(svc, ref.PortName)
277+ if !ok {
278+ return domain.TrafficService{}, fmt.Errorf("unknown port %q on service %q", ref.PortName, ref.Name)
279+ }
280+ if port.Protocol != protocol {
281+ return domain.TrafficService{}, fmt.Errorf("service %q port %q protocol %s does not match router protocol %s", ref.Name, ref.PortName, port.Protocol, protocol)
282+ }
283+ if isPrivateStandalonePort(port) {
284+ if err := b.validatePrivateStandalonePortRoute(router, svc, port); err != nil {
285+ return domain.TrafficService{}, err
286+ }
287+ }
288+ host, backendPort, err := parseBackendAddress(port.Publish)
289+ if err != nil {
290+ return domain.TrafficService{}, fmt.Errorf("service %q port %q publish address %q is invalid: %w", ref.Name, ref.PortName, port.Publish, err)
291+ }
292+ return domain.TrafficService{Name: router.Service, Backends: []domain.TrafficBackend{{Name: ref.Name + ":" + ref.PortName, Host: host, Port: backendPort, Protocol: port.Protocol}}}, nil
293+}
294+
295+func validateStandaloneServices(services []domain.StandaloneService) error {
296+ seen := map[string]struct{}{}
297+ for _, service := range services {
298+ if service.Name == "" {
299+ return fmt.Errorf("standalone service name is required")
300+ }
301+ if _, exists := seen[service.Name]; exists {
302+ return fmt.Errorf("duplicate standalone service %q", service.Name)
303+ }
304+ seen[service.Name] = struct{}{}
305+ if err := service.Validate(); err != nil {
306+ return err
307+ }
308+ }
309+ return nil
310+}
311+
260312 func validateNetworkServices(services []NetworkServiceConfig) error {
261313 seen := map[string]struct{}{}
262314 for _, service := range services {
@@ -312,6 +364,15 @@ func (b *builder) networkService(name string) (NetworkServiceConfig, bool) {
312364 return NetworkServiceConfig{}, false
313365 }
314366
367+func (b *builder) standaloneService(name string) (domain.StandaloneService, bool) {
368+ for _, service := range b.input.Services {
369+ if service.Name == name {
370+ return service, true
371+ }
372+ }
373+ return domain.StandaloneService{}, false
374+}
375+
315376 func networkServicePort(service NetworkServiceConfig, name string) (PortConfig, bool) {
316377 for _, port := range service.Ports {
317378 if port.Name == name {
@@ -321,6 +382,55 @@ func networkServicePort(service NetworkServiceConfig, name string) (PortConfig,
321382 return PortConfig{}, false
322383 }
323384
385+func standaloneServicePort(service domain.StandaloneService, name string) (domain.StandaloneServicePort, bool) {
386+ for _, port := range service.Ports {
387+ if port.Name == name {
388+ return port, true
389+ }
390+ }
391+ return domain.StandaloneServicePort{}, false
392+}
393+
394+func isPrivateStandalonePort(port domain.StandaloneServicePort) bool {
395+ if port.Public {
396+ return false
397+ }
398+ return port.Private || strings.EqualFold(port.Name, "rcon")
399+}
400+
401+func (b *builder) validatePrivateStandalonePortRoute(router RouterConfig, service domain.StandaloneService, port domain.StandaloneServicePort) error {
402+ entryPoint, ok := b.input.EntryPoints[router.EntryPoint]
403+ if !ok {
404+ return fmt.Errorf("private service %q port %q router %q references unknown entrypoint %q", service.Name, port.Name, router.Name, router.EntryPoint)
405+ }
406+ if len(port.TrustedCIDRs) == 0 {
407+ return fmt.Errorf("private service %q port %q router %q requires non-empty service port trusted_cidrs", service.Name, port.Name, router.Name)
408+ }
409+ if len(entryPoint.TrustedCIDRs) == 0 {
410+ return fmt.Errorf("private service %q port %q router %q entrypoint %q requires non-empty trusted_cidrs", service.Name, port.Name, router.Name, router.EntryPoint)
411+ }
412+ if !sameCIDRSet(port.TrustedCIDRs, entryPoint.TrustedCIDRs) {
413+ return fmt.Errorf("private service %q port %q router %q entrypoint %q trusted_cidrs must exactly match service port trusted_cidrs", service.Name, port.Name, router.Name, router.EntryPoint)
414+ }
415+ return nil
416+}
417+
418+func sameCIDRSet(a, b []string) bool {
419+ if len(a) != len(b) {
420+ return false
421+ }
422+ left := append([]string(nil), a...)
423+ right := append([]string(nil), b...)
424+ sort.Strings(left)
425+ sort.Strings(right)
426+ for i := range left {
427+ if left[i] != right[i] {
428+ return false
429+ }
430+ }
431+ return true
432+}
433+
324434 func (b *builder) addService(service domain.TrafficService) {
325435 if _, exists := b.services[service.Name]; !exists {
326436 b.services[service.Name] = service
@@ -20,6 +20,7 @@ type Input struct {
20 Routes []domain.Route20 Routes []domain.Route
21 ExternalRoutes map[string]string21 ExternalRoutes map[string]string
22 NetworkServices []NetworkServiceConfig22 NetworkServices []NetworkServiceConfig
23+ Services []domain.StandaloneService
23 }24 }
24 25
25 type EntryPointConfig struct {26 type EntryPointConfig struct {
@@ -89,6 +90,9 @@ func Build(input Input) (domain.TrafficGraph, error) {
89 if err := validateNetworkServices(input.NetworkServices); err != nil {90 if err := validateNetworkServices(input.NetworkServices); err != nil {
90 return domain.TrafficGraph{}, fmt.Errorf("validate network services: %w", err)91 return domain.TrafficGraph{}, fmt.Errorf("validate network services: %w", err)
91 }92 }
93+ if err := validateStandaloneServices(input.Services); err != nil {
94+ return domain.TrafficGraph{}, fmt.Errorf("validate standalone services: %w", err)
95+ }
92 graph.Options = options96 graph.Options = options
93 graph.EntryPoints = b.buildEntryPoints()97 graph.EntryPoints = b.buildEntryPoints()
94 98
@@ -219,7 +223,7 @@ func (b *builder) addL4Routers(graph *domain.TrafficGraph, routers []RouterConfi
219 ordered := append([]RouterConfig{}, routers...)223 ordered := append([]RouterConfig{}, routers...)
220 sort.Slice(ordered, func(i, j int) bool { return ordered[i].Name < ordered[j].Name })224 sort.Slice(ordered, func(i, j int) bool { return ordered[i].Name < ordered[j].Name })
221 for _, cfg := range ordered {225 for _, cfg := range ordered {
222- service, err := b.resolveService(cfg.Service, backendProtocol)226+ service, err := b.resolveService(cfg, backendProtocol)
223 if err != nil {227 if err != nil {
224 return fmt.Errorf("router %q: %w", cfg.Name, err)228 return fmt.Errorf("router %q: %w", cfg.Name, err)
225 }229 }
@@ -229,17 +233,24 @@ func (b *builder) addL4Routers(graph *domain.TrafficGraph, routers []RouterConfi
229 return nil233 return nil
230 }234 }
231 235
232-func (b *builder) resolveService(refValue string, protocol domain.NetworkProtocol) (domain.TrafficService, error) {236+func (b *builder) resolveService(router RouterConfig, protocol domain.NetworkProtocol) (domain.TrafficService, error) {
233- ref, err := domain.ParseTrafficServiceRef(refValue)237+ ref, err := domain.ParseTrafficServiceRef(router.Service)
234 if err != nil {238 if err != nil {
235 return domain.TrafficService{}, err239 return domain.TrafficService{}, err
236 }240 }
237- if ref.Kind == domain.TrafficServiceRefStatic {241+ switch ref.Kind {
238- return domain.TrafficService{}, fmt.Errorf("static service ref %q is unsupported", refValue)242+ case domain.TrafficServiceRefNetworkService:
239- }243+ return b.resolveNetworkService(router.Service, ref, protocol)
240- if ref.Kind != domain.TrafficServiceRefNetworkService {244+ case domain.TrafficServiceRefService:
241- return domain.TrafficService{}, fmt.Errorf("service ref %q must be network_service:<name>:<port-name>", refValue)245+ return b.resolveStandaloneService(router, ref, protocol)
246+ case domain.TrafficServiceRefStatic:
247+ return domain.TrafficService{}, fmt.Errorf("static service ref %q is unsupported", router.Service)
248+ default:
249+ return domain.TrafficService{}, fmt.Errorf("service ref %q must be network_service:<name>:<port-name> or service:<name>:<port-name>", router.Service)
242 }250 }
251+}
252+
253+func (b *builder) resolveNetworkService(refValue string, ref domain.TrafficServiceRef, protocol domain.NetworkProtocol) (domain.TrafficService, error) {
243 ns, ok := b.networkService(ref.Name)254 ns, ok := b.networkService(ref.Name)
244 if !ok {255 if !ok {
245 return domain.TrafficService{}, fmt.Errorf("unknown network service %q", ref.Name)256 return domain.TrafficService{}, fmt.Errorf("unknown network service %q", ref.Name)
@@ -257,6 +268,47 @@ func (b *builder) resolveService(refValue string, protocol domain.NetworkProtoco
257 return domain.TrafficService{Name: refValue, Backends: []domain.TrafficBackend{{Name: ref.Name + ":" + ref.PortName, Host: ref.Name, Port: port.Container, Protocol: port.Protocol}}}, nil268 return domain.TrafficService{Name: refValue, Backends: []domain.TrafficBackend{{Name: ref.Name + ":" + ref.PortName, Host: ref.Name, Port: port.Container, Protocol: port.Protocol}}}, nil
258 }269 }
259 270
271+func (b *builder) resolveStandaloneService(router RouterConfig, ref domain.TrafficServiceRef, protocol domain.NetworkProtocol) (domain.TrafficService, error) {
272+ svc, ok := b.standaloneService(ref.Name)
273+ if !ok {
274+ return domain.TrafficService{}, fmt.Errorf("unknown service %q", ref.Name)
275+ }
276+ port, ok := standaloneServicePort(svc, ref.PortName)
277+ if !ok {
278+ return domain.TrafficService{}, fmt.Errorf("unknown port %q on service %q", ref.PortName, ref.Name)
279+ }
280+ if port.Protocol != protocol {
281+ return domain.TrafficService{}, fmt.Errorf("service %q port %q protocol %s does not match router protocol %s", ref.Name, ref.PortName, port.Protocol, protocol)
282+ }
283+ if isPrivateStandalonePort(port) {
284+ if err := b.validatePrivateStandalonePortRoute(router, svc, port); err != nil {
285+ return domain.TrafficService{}, err
286+ }
287+ }
288+ host, backendPort, err := parseBackendAddress(port.Publish)
289+ if err != nil {
290+ return domain.TrafficService{}, fmt.Errorf("service %q port %q publish address %q is invalid: %w", ref.Name, ref.PortName, port.Publish, err)
291+ }
292+ return domain.TrafficService{Name: router.Service, Backends: []domain.TrafficBackend{{Name: ref.Name + ":" + ref.PortName, Host: host, Port: backendPort, Protocol: port.Protocol}}}, nil
293+}
294+
295+func validateStandaloneServices(services []domain.StandaloneService) error {
296+ seen := map[string]struct{}{}
297+ for _, service := range services {
298+ if service.Name == "" {
299+ return fmt.Errorf("standalone service name is required")
300+ }
301+ if _, exists := seen[service.Name]; exists {
302+ return fmt.Errorf("duplicate standalone service %q", service.Name)
303+ }
304+ seen[service.Name] = struct{}{}
305+ if err := service.Validate(); err != nil {
306+ return err
307+ }
308+ }
309+ return nil
310+}
311+
260 func validateNetworkServices(services []NetworkServiceConfig) error {312 func validateNetworkServices(services []NetworkServiceConfig) error {
261 seen := map[string]struct{}{}313 seen := map[string]struct{}{}
262 for _, service := range services {314 for _, service := range services {
@@ -312,6 +364,15 @@ func (b *builder) networkService(name string) (NetworkServiceConfig, bool) {
312 return NetworkServiceConfig{}, false364 return NetworkServiceConfig{}, false
313 }365 }
314 366
367+func (b *builder) standaloneService(name string) (domain.StandaloneService, bool) {
368+ for _, service := range b.input.Services {
369+ if service.Name == name {
370+ return service, true
371+ }
372+ }
373+ return domain.StandaloneService{}, false
374+}
375+
315 func networkServicePort(service NetworkServiceConfig, name string) (PortConfig, bool) {376 func networkServicePort(service NetworkServiceConfig, name string) (PortConfig, bool) {
316 for _, port := range service.Ports {377 for _, port := range service.Ports {
317 if port.Name == name {378 if port.Name == name {
@@ -321,6 +382,55 @@ func networkServicePort(service NetworkServiceConfig, name string) (PortConfig,
321 return PortConfig{}, false382 return PortConfig{}, false
322 }383 }
323 384
385+func standaloneServicePort(service domain.StandaloneService, name string) (domain.StandaloneServicePort, bool) {
386+ for _, port := range service.Ports {
387+ if port.Name == name {
388+ return port, true
389+ }
390+ }
391+ return domain.StandaloneServicePort{}, false
392+}
393+
394+func isPrivateStandalonePort(port domain.StandaloneServicePort) bool {
395+ if port.Public {
396+ return false
397+ }
398+ return port.Private || strings.EqualFold(port.Name, "rcon")
399+}
400+
401+func (b *builder) validatePrivateStandalonePortRoute(router RouterConfig, service domain.StandaloneService, port domain.StandaloneServicePort) error {
402+ entryPoint, ok := b.input.EntryPoints[router.EntryPoint]
403+ if !ok {
404+ return fmt.Errorf("private service %q port %q router %q references unknown entrypoint %q", service.Name, port.Name, router.Name, router.EntryPoint)
405+ }
406+ if len(port.TrustedCIDRs) == 0 {
407+ return fmt.Errorf("private service %q port %q router %q requires non-empty service port trusted_cidrs", service.Name, port.Name, router.Name)
408+ }
409+ if len(entryPoint.TrustedCIDRs) == 0 {
410+ return fmt.Errorf("private service %q port %q router %q entrypoint %q requires non-empty trusted_cidrs", service.Name, port.Name, router.Name, router.EntryPoint)
411+ }
412+ if !sameCIDRSet(port.TrustedCIDRs, entryPoint.TrustedCIDRs) {
413+ return fmt.Errorf("private service %q port %q router %q entrypoint %q trusted_cidrs must exactly match service port trusted_cidrs", service.Name, port.Name, router.Name, router.EntryPoint)
414+ }
415+ return nil
416+}
417+
418+func sameCIDRSet(a, b []string) bool {
419+ if len(a) != len(b) {
420+ return false
421+ }
422+ left := append([]string(nil), a...)
423+ right := append([]string(nil), b...)
424+ sort.Strings(left)
425+ sort.Strings(right)
426+ for i := range left {
427+ if left[i] != right[i] {
428+ return false
429+ }
430+ }
431+ return true
432+}
433+
324 func (b *builder) addService(service domain.TrafficService) {434 func (b *builder) addService(service domain.TrafficService) {
325 if _, exists := b.services[service.Name]; !exists {435 if _, exists := b.services[service.Name]; !exists {
326 b.services[service.Name] = service436 b.services[service.Name] = service
M internal/usecase/traffic/builder_test.go +144 -0
@@ -207,6 +207,150 @@ func TestBuildRejectsAmbiguousNetworkServices(t *testing.T) {
207207 })
208208 }
209209
210+func TestBuildResolvesStandaloneServiceBackend(t *testing.T) {
211+ graph, err := Build(Input{
212+ EntryPoints: map[string]EntryPointConfig{"udp": {Address: ":28015", Protocol: domain.EntryPointProtocolUDP}},
213+ Traffic: Config{UDP: UDPConfig{Routers: []RouterConfig{{Name: "rust-game", EntryPoint: "udp", Service: "service:rust:game"}}}},
214+ Services: []domain.StandaloneService{{
215+ Name: "rust",
216+ Image: "localhost/rust:latest",
217+ Enabled: true,
218+ Ports: []domain.StandaloneServicePort{{Name: "game", Container: 28015, Protocol: domain.NetworkProtocolUDP, Publish: "127.0.0.1:38015"}},
219+ }},
220+ })
221+ require.NoError(t, err)
222+ require.Contains(t, graph.Services, domain.TrafficService{Name: "service:rust:game", Backends: []domain.TrafficBackend{{Name: "rust:game", Host: "127.0.0.1", Port: 38015, Protocol: domain.NetworkProtocolUDP}}})
223+}
224+
225+func TestBuildRejectsInvalidStandaloneServiceRefs(t *testing.T) {
226+ base := Input{
227+ EntryPoints: map[string]EntryPointConfig{"tcp": {Address: ":1234", Protocol: domain.EntryPointProtocolTCP}},
228+ Services: []domain.StandaloneService{{
229+ Name: "rust",
230+ Image: "localhost/rust:latest",
231+ Enabled: true,
232+ Ports: []domain.StandaloneServicePort{{Name: "game", Container: 28015, Protocol: domain.NetworkProtocolUDP, Publish: "127.0.0.1:38015"}, {Name: "rcon", Container: 28016, Protocol: domain.NetworkProtocolTCP, Publish: "127.0.0.1:38016", Private: true, TrustedCIDRs: []string{"100.64.0.0/10"}}},
233+ }},
234+ }
235+
236+ t.Run("unknown service", func(t *testing.T) {
237+ input := base
238+ input.Traffic = Config{TCP: TCPConfig{Routers: []RouterConfig{{Name: "bad", EntryPoint: "tcp", Service: "service:missing:rcon"}}}}
239+ _, err := Build(input)
240+ require.ErrorContains(t, err, "router \"bad\": unknown service \"missing\"")
241+ })
242+
243+ t.Run("unknown port", func(t *testing.T) {
244+ input := base
245+ input.Traffic = Config{TCP: TCPConfig{Routers: []RouterConfig{{Name: "bad", EntryPoint: "tcp", Service: "service:rust:missing"}}}}
246+ _, err := Build(input)
247+ require.ErrorContains(t, err, "unknown port \"missing\" on service \"rust\"")
248+ })
249+
250+ t.Run("protocol mismatch", func(t *testing.T) {
251+ input := base
252+ input.Traffic = Config{TCP: TCPConfig{Routers: []RouterConfig{{Name: "bad", EntryPoint: "tcp", Service: "service:rust:game"}}}}
253+ _, err := Build(input)
254+ require.ErrorContains(t, err, "service \"rust\" port \"game\" protocol udp does not match router protocol tcp")
255+ })
256+
257+ t.Run("missing publish", func(t *testing.T) {
258+ input := base
259+ input.EntryPoints = map[string]EntryPointConfig{"tcp": {Address: ":1234", Protocol: domain.EntryPointProtocolTCP, TrustedCIDRs: []string{"100.64.0.0/10"}}}
260+ input.Services[0].Ports[1].Publish = ""
261+ input.Traffic = Config{TCP: TCPConfig{Routers: []RouterConfig{{Name: "bad", EntryPoint: "tcp", Service: "service:rust:rcon"}}}}
262+ _, err := Build(input)
263+ require.ErrorContains(t, err, "service \"rust\" port \"rcon\" publish address")
264+ })
265+
266+ t.Run("invalid publish", func(t *testing.T) {
267+ input := base
268+ input.Services[0].Ports[1].Publish = "127.0.0.1:notaport"
269+ _, err := Build(input)
270+ require.ErrorContains(t, err, "publish address")
271+ })
272+}
273+
274+func TestBuildEnforcesPrivateStandaloneServicePortRouting(t *testing.T) {
275+ base := func() Input {
276+ return Input{
277+ EntryPoints: map[string]EntryPointConfig{"tcp": {Address: ":28016", Protocol: domain.EntryPointProtocolTCP, TrustedCIDRs: []string{"100.64.0.0/10"}}},
278+ Traffic: Config{TCP: TCPConfig{Routers: []RouterConfig{{Name: "rust-rcon", EntryPoint: "tcp", Service: "service:rust:rcon"}}}},
279+ Services: []domain.StandaloneService{{
280+ Name: "rust",
281+ Image: "localhost/rust:latest",
282+ Enabled: true,
283+ Ports: []domain.StandaloneServicePort{{Name: "rcon", Container: 28016, Protocol: domain.NetworkProtocolTCP, Publish: "127.0.0.1:38016", TrustedCIDRs: []string{"100.64.0.0/10"}}},
284+ }},
285+ }
286+ }
287+
288+ t.Run("rcon defaults private and allows exact trusted cidrs", func(t *testing.T) {
289+ _, err := Build(base())
290+ require.NoError(t, err)
291+ })
292+
293+ t.Run("private port rejects service port without trusted cidrs", func(t *testing.T) {
294+ input := base()
295+ input.Services[0].Ports[0].TrustedCIDRs = nil
296+ _, err := Build(input)
297+ require.ErrorContains(t, err, "requires non-empty service port trusted_cidrs")
298+ })
299+
300+ t.Run("rcon rejects both service and entrypoint without trusted cidrs", func(t *testing.T) {
301+ input := base()
302+ input.EntryPoints = map[string]EntryPointConfig{"tcp": {Address: ":28016", Protocol: domain.EntryPointProtocolTCP}}
303+ input.Services[0].Ports[0].TrustedCIDRs = nil
304+ _, err := Build(input)
305+ require.ErrorContains(t, err, "requires non-empty service port trusted_cidrs")
306+ })
307+
308+ t.Run("explicit private non-rcon rejects service port without trusted cidrs", func(t *testing.T) {
309+ input := base()
310+ input.Services[0].Ports[0].Name = "admin"
311+ input.Services[0].Ports[0].Private = true
312+ input.Services[0].Ports[0].TrustedCIDRs = nil
313+ input.Traffic.TCP.Routers[0].Service = "service:rust:admin"
314+ _, err := Build(input)
315+ require.ErrorContains(t, err, "requires non-empty service port trusted_cidrs")
316+ })
317+
318+ t.Run("private port rejects entrypoint without trusted cidrs", func(t *testing.T) {
319+ input := base()
320+ input.EntryPoints = map[string]EntryPointConfig{"tcp": {Address: ":28016", Protocol: domain.EntryPointProtocolTCP}}
321+ _, err := Build(input)
322+ require.ErrorContains(t, err, "entrypoint \"tcp\" requires non-empty trusted_cidrs")
323+ })
324+
325+ t.Run("private port rejects non-matching trusted cidrs", func(t *testing.T) {
326+ input := base()
327+ input.EntryPoints = map[string]EntryPointConfig{"tcp": {Address: ":28016", Protocol: domain.EntryPointProtocolTCP, TrustedCIDRs: []string{"10.0.0.0/8"}}}
328+ _, err := Build(input)
329+ require.ErrorContains(t, err, "must exactly match")
330+ })
331+
332+ t.Run("explicit public rcon is allowed without trusted cidrs", func(t *testing.T) {
333+ input := base()
334+ input.EntryPoints = map[string]EntryPointConfig{"tcp": {Address: ":28016", Protocol: domain.EntryPointProtocolTCP}}
335+ input.Services[0].Ports[0].Public = true
336+ input.Services[0].Ports[0].TrustedCIDRs = nil
337+ _, err := Build(input)
338+ require.NoError(t, err)
339+ })
340+
341+ t.Run("explicit public non-rcon is allowed without trusted cidrs", func(t *testing.T) {
342+ input := base()
343+ input.EntryPoints = map[string]EntryPointConfig{"tcp": {Address: ":28016", Protocol: domain.EntryPointProtocolTCP}}
344+ input.Services[0].Ports[0].Name = "admin"
345+ input.Services[0].Ports[0].Private = false
346+ input.Services[0].Ports[0].Public = true
347+ input.Services[0].Ports[0].TrustedCIDRs = nil
348+ input.Traffic.TCP.Routers[0].Service = "service:rust:admin"
349+ _, err := Build(input)
350+ require.NoError(t, err)
351+ })
352+}
353+
210354 func TestBuildRejectsInvalidServiceRefs(t *testing.T) {
211355 base := Input{
212356 EntryPoints: map[string]EntryPointConfig{"tcp": {Address: ":1234", Protocol: domain.EntryPointProtocolTCP}},
@@ -207,6 +207,150 @@ func TestBuildRejectsAmbiguousNetworkServices(t *testing.T) {
207 })207 })
208 }208 }
209 209
210+func TestBuildResolvesStandaloneServiceBackend(t *testing.T) {
211+ graph, err := Build(Input{
212+ EntryPoints: map[string]EntryPointConfig{"udp": {Address: ":28015", Protocol: domain.EntryPointProtocolUDP}},
213+ Traffic: Config{UDP: UDPConfig{Routers: []RouterConfig{{Name: "rust-game", EntryPoint: "udp", Service: "service:rust:game"}}}},
214+ Services: []domain.StandaloneService{{
215+ Name: "rust",
216+ Image: "localhost/rust:latest",
217+ Enabled: true,
218+ Ports: []domain.StandaloneServicePort{{Name: "game", Container: 28015, Protocol: domain.NetworkProtocolUDP, Publish: "127.0.0.1:38015"}},
219+ }},
220+ })
221+ require.NoError(t, err)
222+ require.Contains(t, graph.Services, domain.TrafficService{Name: "service:rust:game", Backends: []domain.TrafficBackend{{Name: "rust:game", Host: "127.0.0.1", Port: 38015, Protocol: domain.NetworkProtocolUDP}}})
223+}
224+
225+func TestBuildRejectsInvalidStandaloneServiceRefs(t *testing.T) {
226+ base := Input{
227+ EntryPoints: map[string]EntryPointConfig{"tcp": {Address: ":1234", Protocol: domain.EntryPointProtocolTCP}},
228+ Services: []domain.StandaloneService{{
229+ Name: "rust",
230+ Image: "localhost/rust:latest",
231+ Enabled: true,
232+ Ports: []domain.StandaloneServicePort{{Name: "game", Container: 28015, Protocol: domain.NetworkProtocolUDP, Publish: "127.0.0.1:38015"}, {Name: "rcon", Container: 28016, Protocol: domain.NetworkProtocolTCP, Publish: "127.0.0.1:38016", Private: true, TrustedCIDRs: []string{"100.64.0.0/10"}}},
233+ }},
234+ }
235+
236+ t.Run("unknown service", func(t *testing.T) {
237+ input := base
238+ input.Traffic = Config{TCP: TCPConfig{Routers: []RouterConfig{{Name: "bad", EntryPoint: "tcp", Service: "service:missing:rcon"}}}}
239+ _, err := Build(input)
240+ require.ErrorContains(t, err, "router \"bad\": unknown service \"missing\"")
241+ })
242+
243+ t.Run("unknown port", func(t *testing.T) {
244+ input := base
245+ input.Traffic = Config{TCP: TCPConfig{Routers: []RouterConfig{{Name: "bad", EntryPoint: "tcp", Service: "service:rust:missing"}}}}
246+ _, err := Build(input)
247+ require.ErrorContains(t, err, "unknown port \"missing\" on service \"rust\"")
248+ })
249+
250+ t.Run("protocol mismatch", func(t *testing.T) {
251+ input := base
252+ input.Traffic = Config{TCP: TCPConfig{Routers: []RouterConfig{{Name: "bad", EntryPoint: "tcp", Service: "service:rust:game"}}}}
253+ _, err := Build(input)
254+ require.ErrorContains(t, err, "service \"rust\" port \"game\" protocol udp does not match router protocol tcp")
255+ })
256+
257+ t.Run("missing publish", func(t *testing.T) {
258+ input := base
259+ input.EntryPoints = map[string]EntryPointConfig{"tcp": {Address: ":1234", Protocol: domain.EntryPointProtocolTCP, TrustedCIDRs: []string{"100.64.0.0/10"}}}
260+ input.Services[0].Ports[1].Publish = ""
261+ input.Traffic = Config{TCP: TCPConfig{Routers: []RouterConfig{{Name: "bad", EntryPoint: "tcp", Service: "service:rust:rcon"}}}}
262+ _, err := Build(input)
263+ require.ErrorContains(t, err, "service \"rust\" port \"rcon\" publish address")
264+ })
265+
266+ t.Run("invalid publish", func(t *testing.T) {
267+ input := base
268+ input.Services[0].Ports[1].Publish = "127.0.0.1:notaport"
269+ _, err := Build(input)
270+ require.ErrorContains(t, err, "publish address")
271+ })
272+}
273+
274+func TestBuildEnforcesPrivateStandaloneServicePortRouting(t *testing.T) {
275+ base := func() Input {
276+ return Input{
277+ EntryPoints: map[string]EntryPointConfig{"tcp": {Address: ":28016", Protocol: domain.EntryPointProtocolTCP, TrustedCIDRs: []string{"100.64.0.0/10"}}},
278+ Traffic: Config{TCP: TCPConfig{Routers: []RouterConfig{{Name: "rust-rcon", EntryPoint: "tcp", Service: "service:rust:rcon"}}}},
279+ Services: []domain.StandaloneService{{
280+ Name: "rust",
281+ Image: "localhost/rust:latest",
282+ Enabled: true,
283+ Ports: []domain.StandaloneServicePort{{Name: "rcon", Container: 28016, Protocol: domain.NetworkProtocolTCP, Publish: "127.0.0.1:38016", TrustedCIDRs: []string{"100.64.0.0/10"}}},
284+ }},
285+ }
286+ }
287+
288+ t.Run("rcon defaults private and allows exact trusted cidrs", func(t *testing.T) {
289+ _, err := Build(base())
290+ require.NoError(t, err)
291+ })
292+
293+ t.Run("private port rejects service port without trusted cidrs", func(t *testing.T) {
294+ input := base()
295+ input.Services[0].Ports[0].TrustedCIDRs = nil
296+ _, err := Build(input)
297+ require.ErrorContains(t, err, "requires non-empty service port trusted_cidrs")
298+ })
299+
300+ t.Run("rcon rejects both service and entrypoint without trusted cidrs", func(t *testing.T) {
301+ input := base()
302+ input.EntryPoints = map[string]EntryPointConfig{"tcp": {Address: ":28016", Protocol: domain.EntryPointProtocolTCP}}
303+ input.Services[0].Ports[0].TrustedCIDRs = nil
304+ _, err := Build(input)
305+ require.ErrorContains(t, err, "requires non-empty service port trusted_cidrs")
306+ })
307+
308+ t.Run("explicit private non-rcon rejects service port without trusted cidrs", func(t *testing.T) {
309+ input := base()
310+ input.Services[0].Ports[0].Name = "admin"
311+ input.Services[0].Ports[0].Private = true
312+ input.Services[0].Ports[0].TrustedCIDRs = nil
313+ input.Traffic.TCP.Routers[0].Service = "service:rust:admin"
314+ _, err := Build(input)
315+ require.ErrorContains(t, err, "requires non-empty service port trusted_cidrs")
316+ })
317+
318+ t.Run("private port rejects entrypoint without trusted cidrs", func(t *testing.T) {
319+ input := base()
320+ input.EntryPoints = map[string]EntryPointConfig{"tcp": {Address: ":28016", Protocol: domain.EntryPointProtocolTCP}}
321+ _, err := Build(input)
322+ require.ErrorContains(t, err, "entrypoint \"tcp\" requires non-empty trusted_cidrs")
323+ })
324+
325+ t.Run("private port rejects non-matching trusted cidrs", func(t *testing.T) {
326+ input := base()
327+ input.EntryPoints = map[string]EntryPointConfig{"tcp": {Address: ":28016", Protocol: domain.EntryPointProtocolTCP, TrustedCIDRs: []string{"10.0.0.0/8"}}}
328+ _, err := Build(input)
329+ require.ErrorContains(t, err, "must exactly match")
330+ })
331+
332+ t.Run("explicit public rcon is allowed without trusted cidrs", func(t *testing.T) {
333+ input := base()
334+ input.EntryPoints = map[string]EntryPointConfig{"tcp": {Address: ":28016", Protocol: domain.EntryPointProtocolTCP}}
335+ input.Services[0].Ports[0].Public = true
336+ input.Services[0].Ports[0].TrustedCIDRs = nil
337+ _, err := Build(input)
338+ require.NoError(t, err)
339+ })
340+
341+ t.Run("explicit public non-rcon is allowed without trusted cidrs", func(t *testing.T) {
342+ input := base()
343+ input.EntryPoints = map[string]EntryPointConfig{"tcp": {Address: ":28016", Protocol: domain.EntryPointProtocolTCP}}
344+ input.Services[0].Ports[0].Name = "admin"
345+ input.Services[0].Ports[0].Private = false
346+ input.Services[0].Ports[0].Public = true
347+ input.Services[0].Ports[0].TrustedCIDRs = nil
348+ input.Traffic.TCP.Routers[0].Service = "service:rust:admin"
349+ _, err := Build(input)
350+ require.NoError(t, err)
351+ })
352+}
353+
210 func TestBuildRejectsInvalidServiceRefs(t *testing.T) {354 func TestBuildRejectsInvalidServiceRefs(t *testing.T) {
211 base := Input{355 base := Input{
212 EntryPoints: map[string]EntryPointConfig{"tcp": {Address: ":1234", Protocol: domain.EntryPointProtocolTCP}},356 EntryPoints: map[string]EntryPointConfig{"tcp": {Address: ":1234", Protocol: domain.EntryPointProtocolTCP}},

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