detached db9a8a3

feat(proxy): add external routes for non-containerized services

Add support for proxying to external (non-container) services via a new
[external_routes] configuration section. This allows Gordon to reverse
proxy to services running directly on the host or network, such as
Docker registries, database admin tools, or legacy applications.

Configuration example:
  [external_routes]
  "reg.bnema.dev" = "localhost:5000"
commit
db9a8a3f8de30d318a6be6cdc044e68b22961a02
author
brice · · 2026-01-14 22:09 UTC
committer
brice · · 2026-01-14 19:55 UTC
parent
683bc85
signature
unsigned
10 files · +320 -0
A docs/config/external-routes.md +92 -0
@@ -0,0 +1,92 @@
1+# External Routes Configuration
2+
3+External routes allow proxying to non-containerized services running on the host or network.
4+
5+## Configuration
6+
7+```toml
8+[external_routes]
9+"service.mydomain.com" = "localhost:5000"
10+"cache.mydomain.com" = "192.168.1.100:6379"
11+```
12+
13+## Syntax
14+
15+```toml
16+[external_routes]
17+"<domain>" = "<host>:<port>"
18+```
19+
20+| Component | Description |
21+|-----------|-------------|
22+| `domain` | Fully qualified domain name |
23+| `host` | Target hostname or IP address |
24+| `port` | Target port number |
25+
26+## Use Cases
27+
28+### Docker Registry
29+
30+Proxy to a Docker registry running outside of Gordon:
31+
32+```toml
33+[external_routes]
34+"registry.mydomain.com" = "localhost:5000"
35+```
36+
37+### Database Admin Tools
38+
39+Proxy to database admin interfaces:
40+
41+```toml
42+[external_routes]
43+"pgadmin.mydomain.com" = "localhost:5050"
44+"redis-commander.mydomain.com" = "localhost:8081"
45+```
46+
47+### Legacy Services
48+
49+Proxy to services that can't be containerized:
50+
51+```toml
52+[external_routes]
53+"legacy-api.mydomain.com" = "192.168.1.50:8080"
54+```
55+
56+## How It Works
57+
58+1. Request arrives for `service.mydomain.com`
59+2. Gordon checks if domain matches an external route
60+3. If matched, proxies directly to the configured `host:port`
61+4. No container lookup is performed
62+
63+```
64+Client ─> Gordon Proxy ─> External Service
65+ (port 80) (configured host:port)
66+```
67+
68+## Limitations
69+
70+- HTTP only (no HTTPS upstream)
71+- No health checks
72+- No load balancing
73+
74+## Hot Reload
75+
76+External routes reload automatically when the config file changes:
77+
78+```bash
79+# Edit config
80+vim ~/.config/gordon/gordon.toml
81+
82+# Add external route
83+[external_routes]
84+"newservice.mydomain.com" = "localhost:9000"
85+
86+# Save - Gordon reloads automatically
87+```
88+
89+## Related
90+
91+- [Routes Configuration](./routes.md)
92+- [Configuration Overview](./index.md)
@@ -0,0 +1,92 @@
1+# External Routes Configuration
2+
3+External routes allow proxying to non-containerized services running on the host or network.
4+
5+## Configuration
6+
7+```toml
8+[external_routes]
9+"service.mydomain.com" = "localhost:5000"
10+"cache.mydomain.com" = "192.168.1.100:6379"
11+```
12+
13+## Syntax
14+
15+```toml
16+[external_routes]
17+"<domain>" = "<host>:<port>"
18+```
19+
20+| Component | Description |
21+|-----------|-------------|
22+| `domain` | Fully qualified domain name |
23+| `host` | Target hostname or IP address |
24+| `port` | Target port number |
25+
26+## Use Cases
27+
28+### Docker Registry
29+
30+Proxy to a Docker registry running outside of Gordon:
31+
32+```toml
33+[external_routes]
34+"registry.mydomain.com" = "localhost:5000"
35+```
36+
37+### Database Admin Tools
38+
39+Proxy to database admin interfaces:
40+
41+```toml
42+[external_routes]
43+"pgadmin.mydomain.com" = "localhost:5050"
44+"redis-commander.mydomain.com" = "localhost:8081"
45+```
46+
47+### Legacy Services
48+
49+Proxy to services that can't be containerized:
50+
51+```toml
52+[external_routes]
53+"legacy-api.mydomain.com" = "192.168.1.50:8080"
54+```
55+
56+## How It Works
57+
58+1. Request arrives for `service.mydomain.com`
59+2. Gordon checks if domain matches an external route
60+3. If matched, proxies directly to the configured `host:port`
61+4. No container lookup is performed
62+
63+```
64+Client ─> Gordon Proxy ─> External Service
65+ (port 80) (configured host:port)
66+```
67+
68+## Limitations
69+
70+- HTTP only (no HTTPS upstream)
71+- No health checks
72+- No load balancing
73+
74+## Hot Reload
75+
76+External routes reload automatically when the config file changes:
77+
78+```bash
79+# Edit config
80+vim ~/.config/gordon/gordon.toml
81+
82+# Add external route
83+[external_routes]
84+"newservice.mydomain.com" = "localhost:9000"
85+
86+# Save - Gordon reloads automatically
87+```
88+
89+## Related
90+
91+- [Routes Configuration](./routes.md)
92+- [Configuration Overview](./index.md)
M docs/config/index.md +2 -0
@@ -115,6 +115,7 @@ enabled = false # Auto-create routes from image names
115115 | `[network_isolation]` | Network isolation settings | [Network Isolation](./network-isolation.md) |
116116 | `[auto_route]` | Automatic route creation | [Auto Route](./auto-route.md) |
117117 | `[routes]` | Domain to image mapping | [Routes](./routes.md) |
118+| `[external_routes]` | Non-containerized service proxying | [External Routes](./external-routes.md) |
118119 | `[network_groups]` | Shared service networks | [Network Groups](./network-groups.md) |
119120 | `[attachments]` | Service dependencies | [Attachments](./attachments.md) |
120121
@@ -173,4 +174,5 @@ Pattern: `GORDON_SECTION_KEY` (uppercase, underscores instead of dots)
173174
174175 - [Server Configuration](./server.md)
175176 - [Routes Configuration](./routes.md)
177+- [External Routes](./external-routes.md)
176178 - [Registry Authentication](./registry-auth.md)
@@ -115,6 +115,7 @@ enabled = false # Auto-create routes from image names
115 | `[network_isolation]` | Network isolation settings | [Network Isolation](./network-isolation.md) |115 | `[network_isolation]` | Network isolation settings | [Network Isolation](./network-isolation.md) |
116 | `[auto_route]` | Automatic route creation | [Auto Route](./auto-route.md) |116 | `[auto_route]` | Automatic route creation | [Auto Route](./auto-route.md) |
117 | `[routes]` | Domain to image mapping | [Routes](./routes.md) |117 | `[routes]` | Domain to image mapping | [Routes](./routes.md) |
118+| `[external_routes]` | Non-containerized service proxying | [External Routes](./external-routes.md) |
118 | `[network_groups]` | Shared service networks | [Network Groups](./network-groups.md) |119 | `[network_groups]` | Shared service networks | [Network Groups](./network-groups.md) |
119 | `[attachments]` | Service dependencies | [Attachments](./attachments.md) |120 | `[attachments]` | Service dependencies | [Attachments](./attachments.md) |
120 121
@@ -173,4 +174,5 @@ Pattern: `GORDON_SECTION_KEY` (uppercase, underscores instead of dots)
173 174
174 - [Server Configuration](./server.md)175 - [Server Configuration](./server.md)
175 - [Routes Configuration](./routes.md)176 - [Routes Configuration](./routes.md)
177+- [External Routes](./external-routes.md)
176 - [Registry Authentication](./registry-auth.md)178 - [Registry Authentication](./registry-auth.md)
M docs/config/routes.md +5 -0
@@ -194,8 +194,13 @@ Add to `/etc/hosts`:
194194 "portal.acme-corp.com" = "saas-app:v2.1.0"
195195 ```
196196
197+## External Services
198+
199+For non-containerized services (databases, legacy apps, etc.), see [External Routes](./external-routes.md).
200+
197201 ## Related
198202
199203 - [Configuration Overview](./index.md)
200204 - [Auto Route](./auto-route.md)
201205 - [Attachments](./attachments.md)
206+- [External Routes](./external-routes.md)
@@ -194,8 +194,13 @@ Add to `/etc/hosts`:
194 "portal.acme-corp.com" = "saas-app:v2.1.0"194 "portal.acme-corp.com" = "saas-app:v2.1.0"
195 ```195 ```
196 196
197+## External Services
198+
199+For non-containerized services (databases, legacy apps, etc.), see [External Routes](./external-routes.md).
200+
197 ## Related201 ## Related
198 202
199 - [Configuration Overview](./index.md)203 - [Configuration Overview](./index.md)
200 - [Auto Route](./auto-route.md)204 - [Auto Route](./auto-route.md)
201 - [Attachments](./attachments.md)205 - [Attachments](./attachments.md)
206+- [External Routes](./external-routes.md)
M internal/boundaries/in/config.go +3 -0
@@ -47,4 +47,7 @@ type ConfigService interface {
4747
4848 // GetNetworkPrefix returns the prefix for created networks.
4949 GetNetworkPrefix() string
50+
51+ // GetExternalRoutes returns all configured external routes.
52+ GetExternalRoutes() map[string]string
5053 }
@@ -47,4 +47,7 @@ type ConfigService interface {
47 47
48 // GetNetworkPrefix returns the prefix for created networks.48 // GetNetworkPrefix returns the prefix for created networks.
49 GetNetworkPrefix() string49 GetNetworkPrefix() string
50+
51+ // GetExternalRoutes returns all configured external routes.
52+ GetExternalRoutes() map[string]string
50 }53 }
M internal/boundaries/in/mocks/mock_config_service.go +46 -0
@@ -139,6 +139,52 @@ func (_c *MockConfigService_GetDataDir_Call) RunAndReturn(run func() string) *Mo
139139 return _c
140140 }
141141
142+// GetExternalRoutes provides a mock function for the type MockConfigService
143+func (_mock *MockConfigService) GetExternalRoutes() map[string]string {
144+ ret := _mock.Called()
145+
146+ if len(ret) == 0 {
147+ panic("no return value specified for GetExternalRoutes")
148+ }
149+
150+ var r0 map[string]string
151+ if returnFunc, ok := ret.Get(0).(func() map[string]string); ok {
152+ r0 = returnFunc()
153+ } else {
154+ if ret.Get(0) != nil {
155+ r0 = ret.Get(0).(map[string]string)
156+ }
157+ }
158+ return r0
159+}
160+
161+// MockConfigService_GetExternalRoutes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetExternalRoutes'
162+type MockConfigService_GetExternalRoutes_Call struct {
163+ *mock.Call
164+}
165+
166+// GetExternalRoutes is a helper method to define mock.On call
167+func (_e *MockConfigService_Expecter) GetExternalRoutes() *MockConfigService_GetExternalRoutes_Call {
168+ return &MockConfigService_GetExternalRoutes_Call{Call: _e.mock.On("GetExternalRoutes")}
169+}
170+
171+func (_c *MockConfigService_GetExternalRoutes_Call) Run(run func()) *MockConfigService_GetExternalRoutes_Call {
172+ _c.Call.Run(func(args mock.Arguments) {
173+ run()
174+ })
175+ return _c
176+}
177+
178+func (_c *MockConfigService_GetExternalRoutes_Call) Return(stringToString map[string]string) *MockConfigService_GetExternalRoutes_Call {
179+ _c.Call.Return(stringToString)
180+ return _c
181+}
182+
183+func (_c *MockConfigService_GetExternalRoutes_Call) RunAndReturn(run func() map[string]string) *MockConfigService_GetExternalRoutes_Call {
184+ _c.Call.Return(run)
185+ return _c
186+}
187+
142188 // GetNetworkPrefix provides a mock function for the type MockConfigService
143189 func (_mock *MockConfigService) GetNetworkPrefix() string {
144190 ret := _mock.Called()
@@ -139,6 +139,52 @@ func (_c *MockConfigService_GetDataDir_Call) RunAndReturn(run func() string) *Mo
139 return _c139 return _c
140 }140 }
141 141
142+// GetExternalRoutes provides a mock function for the type MockConfigService
143+func (_mock *MockConfigService) GetExternalRoutes() map[string]string {
144+ ret := _mock.Called()
145+
146+ if len(ret) == 0 {
147+ panic("no return value specified for GetExternalRoutes")
148+ }
149+
150+ var r0 map[string]string
151+ if returnFunc, ok := ret.Get(0).(func() map[string]string); ok {
152+ r0 = returnFunc()
153+ } else {
154+ if ret.Get(0) != nil {
155+ r0 = ret.Get(0).(map[string]string)
156+ }
157+ }
158+ return r0
159+}
160+
161+// MockConfigService_GetExternalRoutes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetExternalRoutes'
162+type MockConfigService_GetExternalRoutes_Call struct {
163+ *mock.Call
164+}
165+
166+// GetExternalRoutes is a helper method to define mock.On call
167+func (_e *MockConfigService_Expecter) GetExternalRoutes() *MockConfigService_GetExternalRoutes_Call {
168+ return &MockConfigService_GetExternalRoutes_Call{Call: _e.mock.On("GetExternalRoutes")}
169+}
170+
171+func (_c *MockConfigService_GetExternalRoutes_Call) Run(run func()) *MockConfigService_GetExternalRoutes_Call {
172+ _c.Call.Run(func(args mock.Arguments) {
173+ run()
174+ })
175+ return _c
176+}
177+
178+func (_c *MockConfigService_GetExternalRoutes_Call) Return(stringToString map[string]string) *MockConfigService_GetExternalRoutes_Call {
179+ _c.Call.Return(stringToString)
180+ return _c
181+}
182+
183+func (_c *MockConfigService_GetExternalRoutes_Call) RunAndReturn(run func() map[string]string) *MockConfigService_GetExternalRoutes_Call {
184+ _c.Call.Return(run)
185+ return _c
186+}
187+
142 // GetNetworkPrefix provides a mock function for the type MockConfigService188 // GetNetworkPrefix provides a mock function for the type MockConfigService
143 func (_mock *MockConfigService) GetNetworkPrefix() string {189 func (_mock *MockConfigService) GetNetworkPrefix() string {
144 ret := _mock.Called()190 ret := _mock.Called()
M internal/domain/route.go +7 -0
@@ -21,3 +21,10 @@ type RouteMatch struct {
2121 Container *Container
2222 Target *ProxyTarget
2323 }
24+
25+// ExternalRoute represents a mapping from a domain to an external (non-container) service.
26+type ExternalRoute struct {
27+ Domain string // e.g., "reg.bnema.dev"
28+ Host string // e.g., "localhost"
29+ Port int // e.g., 5000
30+}
@@ -21,3 +21,10 @@ type RouteMatch struct {
21 Container *Container21 Container *Container
22 Target *ProxyTarget22 Target *ProxyTarget
23 }23 }
24+
25+// ExternalRoute represents a mapping from a domain to an external (non-container) service.
26+type ExternalRoute struct {
27+ Domain string // e.g., "reg.bnema.dev"
28+ Host string // e.g., "localhost"
29+ Port int // e.g., 5000
30+}
M internal/usecase/config/service.go +15 -0
@@ -24,6 +24,7 @@ type Config struct {
2424 NetworkIsolation bool
2525 NetworkPrefix string
2626 Routes map[string]string
27+ ExternalRoutes map[string]string // domain -> "host:port"
2728 RegistryAuthEnabled bool
2829 RegistryAuthUsername string
2930 RegistryAuthPassword string
@@ -66,6 +67,7 @@ func (s *Service) Load(ctx context.Context) error {
6667
6768 // Load complex nested structures
6869 s.config.Routes = loadStringMap(s.viper.Get("routes"))
70+ s.config.ExternalRoutes = loadStringMap(s.viper.Get("external_routes"))
6971 s.config.NetworkGroups = loadStringArrayMap(s.viper.Get("network_groups"))
7072 s.config.Attachments = loadStringArrayMap(s.viper.Get("attachments"))
7173
@@ -95,6 +97,7 @@ func (s *Service) loadConfigValues() Config {
9597 VolumePrefix: s.viper.GetString("volumes.prefix"),
9698 VolumePreserve: s.viper.GetBool("volumes.preserve"),
9799 Routes: make(map[string]string),
100+ ExternalRoutes: make(map[string]string),
98101 NetworkGroups: make(map[string][]string),
99102 Attachments: make(map[string][]string),
100103 }
@@ -365,6 +368,18 @@ func (s *Service) GetAttachments() map[string][]string {
365368 return result
366369 }
367370
371+// GetExternalRoutes returns all configured external routes.
372+func (s *Service) GetExternalRoutes() map[string]string {
373+ s.mu.RLock()
374+ defer s.mu.RUnlock()
375+
376+ result := make(map[string]string)
377+ for k, v := range s.config.ExternalRoutes {
378+ result[k] = v
379+ }
380+ return result
381+}
382+
368383 // ExtractDomainFromImageName extracts domain from image names like "myapp.bamen.dev:latest".
369384 func ExtractDomainFromImageName(imageName string) (string, bool) {
370385 parts := strings.Split(imageName, ":")
@@ -24,6 +24,7 @@ type Config struct {
24 NetworkIsolation bool24 NetworkIsolation bool
25 NetworkPrefix string25 NetworkPrefix string
26 Routes map[string]string26 Routes map[string]string
27+ ExternalRoutes map[string]string // domain -> "host:port"
27 RegistryAuthEnabled bool28 RegistryAuthEnabled bool
28 RegistryAuthUsername string29 RegistryAuthUsername string
29 RegistryAuthPassword string30 RegistryAuthPassword string
@@ -66,6 +67,7 @@ func (s *Service) Load(ctx context.Context) error {
66 67
67 // Load complex nested structures68 // Load complex nested structures
68 s.config.Routes = loadStringMap(s.viper.Get("routes"))69 s.config.Routes = loadStringMap(s.viper.Get("routes"))
70+ s.config.ExternalRoutes = loadStringMap(s.viper.Get("external_routes"))
69 s.config.NetworkGroups = loadStringArrayMap(s.viper.Get("network_groups"))71 s.config.NetworkGroups = loadStringArrayMap(s.viper.Get("network_groups"))
70 s.config.Attachments = loadStringArrayMap(s.viper.Get("attachments"))72 s.config.Attachments = loadStringArrayMap(s.viper.Get("attachments"))
71 73
@@ -95,6 +97,7 @@ func (s *Service) loadConfigValues() Config {
95 VolumePrefix: s.viper.GetString("volumes.prefix"),97 VolumePrefix: s.viper.GetString("volumes.prefix"),
96 VolumePreserve: s.viper.GetBool("volumes.preserve"),98 VolumePreserve: s.viper.GetBool("volumes.preserve"),
97 Routes: make(map[string]string),99 Routes: make(map[string]string),
100+ ExternalRoutes: make(map[string]string),
98 NetworkGroups: make(map[string][]string),101 NetworkGroups: make(map[string][]string),
99 Attachments: make(map[string][]string),102 Attachments: make(map[string][]string),
100 }103 }
@@ -365,6 +368,18 @@ func (s *Service) GetAttachments() map[string][]string {
365 return result368 return result
366 }369 }
367 370
371+// GetExternalRoutes returns all configured external routes.
372+func (s *Service) GetExternalRoutes() map[string]string {
373+ s.mu.RLock()
374+ defer s.mu.RUnlock()
375+
376+ result := make(map[string]string)
377+ for k, v := range s.config.ExternalRoutes {
378+ result[k] = v
379+ }
380+ return result
381+}
382+
368 // ExtractDomainFromImageName extracts domain from image names like "myapp.bamen.dev:latest".383 // ExtractDomainFromImageName extracts domain from image names like "myapp.bamen.dev:latest".
369 func ExtractDomainFromImageName(imageName string) (string, bool) {384 func ExtractDomainFromImageName(imageName string) (string, bool) {
370 parts := strings.Split(imageName, ":")385 parts := strings.Split(imageName, ":")
M internal/usecase/config/service_test.go +34 -0
@@ -232,6 +232,40 @@ func TestService_GetAttachments(t *testing.T) {
232232 assert.ElementsMatch(t, []string{"redis:latest", "postgres:15"}, attachments["app.example.com"])
233233 }
234234
235+func TestService_GetExternalRoutes(t *testing.T) {
236+ v := viper.New()
237+ v.Set("external_routes", map[string]interface{}{
238+ "reg.example.com": "localhost:5000",
239+ "cache.example.com": "127.0.0.1:6379",
240+ })
241+
242+ eventBus := mocks.NewMockEventPublisher(t)
243+ svc := NewService(v, eventBus)
244+ ctx := testContext()
245+
246+ _ = svc.Load(ctx)
247+
248+ routes := svc.GetExternalRoutes()
249+
250+ assert.Len(t, routes, 2)
251+ assert.Equal(t, "localhost:5000", routes["reg.example.com"])
252+ assert.Equal(t, "127.0.0.1:6379", routes["cache.example.com"])
253+}
254+
255+func TestService_GetExternalRoutes_Empty(t *testing.T) {
256+ v := viper.New()
257+
258+ eventBus := mocks.NewMockEventPublisher(t)
259+ svc := NewService(v, eventBus)
260+ ctx := testContext()
261+
262+ _ = svc.Load(ctx)
263+
264+ routes := svc.GetExternalRoutes()
265+
266+ assert.Empty(t, routes)
267+}
268+
235269 func TestExtractDomainFromImageName(t *testing.T) {
236270 tests := []struct {
237271 name string
@@ -232,6 +232,40 @@ func TestService_GetAttachments(t *testing.T) {
232 assert.ElementsMatch(t, []string{"redis:latest", "postgres:15"}, attachments["app.example.com"])232 assert.ElementsMatch(t, []string{"redis:latest", "postgres:15"}, attachments["app.example.com"])
233 }233 }
234 234
235+func TestService_GetExternalRoutes(t *testing.T) {
236+ v := viper.New()
237+ v.Set("external_routes", map[string]interface{}{
238+ "reg.example.com": "localhost:5000",
239+ "cache.example.com": "127.0.0.1:6379",
240+ })
241+
242+ eventBus := mocks.NewMockEventPublisher(t)
243+ svc := NewService(v, eventBus)
244+ ctx := testContext()
245+
246+ _ = svc.Load(ctx)
247+
248+ routes := svc.GetExternalRoutes()
249+
250+ assert.Len(t, routes, 2)
251+ assert.Equal(t, "localhost:5000", routes["reg.example.com"])
252+ assert.Equal(t, "127.0.0.1:6379", routes["cache.example.com"])
253+}
254+
255+func TestService_GetExternalRoutes_Empty(t *testing.T) {
256+ v := viper.New()
257+
258+ eventBus := mocks.NewMockEventPublisher(t)
259+ svc := NewService(v, eventBus)
260+ ctx := testContext()
261+
262+ _ = svc.Load(ctx)
263+
264+ routes := svc.GetExternalRoutes()
265+
266+ assert.Empty(t, routes)
267+}
268+
235 func TestExtractDomainFromImageName(t *testing.T) {269 func TestExtractDomainFromImageName(t *testing.T) {
236 tests := []struct {270 tests := []struct {
237 name string271 name string
M internal/usecase/proxy/service.go +31 -0
@@ -127,6 +127,37 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.Pro
127127 }
128128 s.mu.RUnlock()
129129
130+ // Check if this is an external route
131+ externalRoutes := s.configSvc.GetExternalRoutes()
132+ if targetAddr, ok := externalRoutes[domainName]; ok {
133+ host, portStr, err := net.SplitHostPort(targetAddr)
134+ if err != nil {
135+ return nil, log.WrapErrWithFields(err, "invalid external route target", map[string]any{"target": targetAddr})
136+ }
137+ port, err := strconv.Atoi(portStr)
138+ if err != nil {
139+ return nil, log.WrapErrWithFields(err, "invalid port in external route", map[string]any{"target": targetAddr})
140+ }
141+
142+ target := &domain.ProxyTarget{
143+ Host: host,
144+ Port: port,
145+ ContainerID: "", // Not a container
146+ Scheme: "http",
147+ }
148+
149+ // Cache external route target
150+ s.mu.Lock()
151+ s.targets[domainName] = target
152+ s.mu.Unlock()
153+
154+ log.Debug().
155+ Str("host", host).
156+ Int("port", port).
157+ Msg("using external route target")
158+ return target, nil
159+ }
160+
130161 // Get container for this domain
131162 container, exists := s.containerSvc.Get(ctx, domainName)
132163 if !exists {
@@ -127,6 +127,37 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.Pro
127 }127 }
128 s.mu.RUnlock()128 s.mu.RUnlock()
129 129
130+ // Check if this is an external route
131+ externalRoutes := s.configSvc.GetExternalRoutes()
132+ if targetAddr, ok := externalRoutes[domainName]; ok {
133+ host, portStr, err := net.SplitHostPort(targetAddr)
134+ if err != nil {
135+ return nil, log.WrapErrWithFields(err, "invalid external route target", map[string]any{"target": targetAddr})
136+ }
137+ port, err := strconv.Atoi(portStr)
138+ if err != nil {
139+ return nil, log.WrapErrWithFields(err, "invalid port in external route", map[string]any{"target": targetAddr})
140+ }
141+
142+ target := &domain.ProxyTarget{
143+ Host: host,
144+ Port: port,
145+ ContainerID: "", // Not a container
146+ Scheme: "http",
147+ }
148+
149+ // Cache external route target
150+ s.mu.Lock()
151+ s.targets[domainName] = target
152+ s.mu.Unlock()
153+
154+ log.Debug().
155+ Str("host", host).
156+ Int("port", port).
157+ Msg("using external route target")
158+ return target, nil
159+ }
160+
130 // Get container for this domain161 // Get container for this domain
131 container, exists := s.containerSvc.Get(ctx, domainName)162 container, exists := s.containerSvc.Get(ctx, domainName)
132 if !exists {163 if !exists {
M internal/usecase/proxy/service_test.go +85 -0
@@ -55,6 +55,8 @@ func TestService_GetTarget_ContainerNotFound(t *testing.T) {
5555 svc := NewService(runtime, containerSvc, configSvc, config)
5656 ctx := testContext()
5757
58+ // Mock external routes (empty - no match)
59+ configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
5860 containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(nil, false)
5961
6062 result, err := svc.GetTarget(ctx, "app.example.com")
@@ -63,6 +65,89 @@ func TestService_GetTarget_ContainerNotFound(t *testing.T) {
6365 assert.Nil(t, result)
6466 }
6567
68+func TestService_GetTarget_ExternalRoute(t *testing.T) {
69+ runtime := outmocks.NewMockContainerRuntime(t)
70+ containerSvc := inmocks.NewMockContainerService(t)
71+ configSvc := inmocks.NewMockConfigService(t)
72+
73+ svc := NewService(runtime, containerSvc, configSvc, Config{})
74+ ctx := testContext()
75+
76+ // Mock external routes
77+ configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
78+ "reg.example.com": "localhost:5000",
79+ })
80+
81+ result, err := svc.GetTarget(ctx, "reg.example.com")
82+
83+ assert.NoError(t, err)
84+ assert.Equal(t, "localhost", result.Host)
85+ assert.Equal(t, 5000, result.Port)
86+ assert.Equal(t, "", result.ContainerID)
87+ assert.Equal(t, "http", result.Scheme)
88+}
89+
90+func TestService_GetTarget_ExternalRoute_Cached(t *testing.T) {
91+ runtime := outmocks.NewMockContainerRuntime(t)
92+ containerSvc := inmocks.NewMockContainerService(t)
93+ configSvc := inmocks.NewMockConfigService(t)
94+
95+ svc := NewService(runtime, containerSvc, configSvc, Config{})
96+ ctx := testContext()
97+
98+ // First call - should resolve external route
99+ configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
100+ "reg.example.com": "localhost:5000",
101+ }).Once()
102+
103+ result1, err := svc.GetTarget(ctx, "reg.example.com")
104+ assert.NoError(t, err)
105+ assert.Equal(t, "localhost", result1.Host)
106+
107+ // Second call - should return from cache (no mock call needed)
108+ result2, err := svc.GetTarget(ctx, "reg.example.com")
109+ assert.NoError(t, err)
110+ assert.Equal(t, result1, result2)
111+}
112+
113+func TestService_GetTarget_ExternalRoute_InvalidTarget(t *testing.T) {
114+ runtime := outmocks.NewMockContainerRuntime(t)
115+ containerSvc := inmocks.NewMockContainerService(t)
116+ configSvc := inmocks.NewMockConfigService(t)
117+
118+ svc := NewService(runtime, containerSvc, configSvc, Config{})
119+ ctx := testContext()
120+
121+ // Mock external routes with invalid format (missing port)
122+ configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
123+ "invalid.example.com": "not-valid-format",
124+ })
125+
126+ result, err := svc.GetTarget(ctx, "invalid.example.com")
127+
128+ assert.Error(t, err)
129+ assert.Nil(t, result)
130+}
131+
132+func TestService_GetTarget_ExternalRoute_InvalidPort(t *testing.T) {
133+ runtime := outmocks.NewMockContainerRuntime(t)
134+ containerSvc := inmocks.NewMockContainerService(t)
135+ configSvc := inmocks.NewMockConfigService(t)
136+
137+ svc := NewService(runtime, containerSvc, configSvc, Config{})
138+ ctx := testContext()
139+
140+ // Mock external routes with invalid port
141+ configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
142+ "invalid.example.com": "localhost:abc",
143+ })
144+
145+ result, err := svc.GetTarget(ctx, "invalid.example.com")
146+
147+ assert.Error(t, err)
148+ assert.Nil(t, result)
149+}
150+
66151 func TestService_RegisterTarget(t *testing.T) {
67152 runtime := outmocks.NewMockContainerRuntime(t)
68153 containerSvc := inmocks.NewMockContainerService(t)
@@ -55,6 +55,8 @@ func TestService_GetTarget_ContainerNotFound(t *testing.T) {
55 svc := NewService(runtime, containerSvc, configSvc, config)55 svc := NewService(runtime, containerSvc, configSvc, config)
56 ctx := testContext()56 ctx := testContext()
57 57
58+ // Mock external routes (empty - no match)
59+ configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
58 containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(nil, false)60 containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(nil, false)
59 61
60 result, err := svc.GetTarget(ctx, "app.example.com")62 result, err := svc.GetTarget(ctx, "app.example.com")
@@ -63,6 +65,89 @@ func TestService_GetTarget_ContainerNotFound(t *testing.T) {
63 assert.Nil(t, result)65 assert.Nil(t, result)
64 }66 }
65 67
68+func TestService_GetTarget_ExternalRoute(t *testing.T) {
69+ runtime := outmocks.NewMockContainerRuntime(t)
70+ containerSvc := inmocks.NewMockContainerService(t)
71+ configSvc := inmocks.NewMockConfigService(t)
72+
73+ svc := NewService(runtime, containerSvc, configSvc, Config{})
74+ ctx := testContext()
75+
76+ // Mock external routes
77+ configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
78+ "reg.example.com": "localhost:5000",
79+ })
80+
81+ result, err := svc.GetTarget(ctx, "reg.example.com")
82+
83+ assert.NoError(t, err)
84+ assert.Equal(t, "localhost", result.Host)
85+ assert.Equal(t, 5000, result.Port)
86+ assert.Equal(t, "", result.ContainerID)
87+ assert.Equal(t, "http", result.Scheme)
88+}
89+
90+func TestService_GetTarget_ExternalRoute_Cached(t *testing.T) {
91+ runtime := outmocks.NewMockContainerRuntime(t)
92+ containerSvc := inmocks.NewMockContainerService(t)
93+ configSvc := inmocks.NewMockConfigService(t)
94+
95+ svc := NewService(runtime, containerSvc, configSvc, Config{})
96+ ctx := testContext()
97+
98+ // First call - should resolve external route
99+ configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
100+ "reg.example.com": "localhost:5000",
101+ }).Once()
102+
103+ result1, err := svc.GetTarget(ctx, "reg.example.com")
104+ assert.NoError(t, err)
105+ assert.Equal(t, "localhost", result1.Host)
106+
107+ // Second call - should return from cache (no mock call needed)
108+ result2, err := svc.GetTarget(ctx, "reg.example.com")
109+ assert.NoError(t, err)
110+ assert.Equal(t, result1, result2)
111+}
112+
113+func TestService_GetTarget_ExternalRoute_InvalidTarget(t *testing.T) {
114+ runtime := outmocks.NewMockContainerRuntime(t)
115+ containerSvc := inmocks.NewMockContainerService(t)
116+ configSvc := inmocks.NewMockConfigService(t)
117+
118+ svc := NewService(runtime, containerSvc, configSvc, Config{})
119+ ctx := testContext()
120+
121+ // Mock external routes with invalid format (missing port)
122+ configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
123+ "invalid.example.com": "not-valid-format",
124+ })
125+
126+ result, err := svc.GetTarget(ctx, "invalid.example.com")
127+
128+ assert.Error(t, err)
129+ assert.Nil(t, result)
130+}
131+
132+func TestService_GetTarget_ExternalRoute_InvalidPort(t *testing.T) {
133+ runtime := outmocks.NewMockContainerRuntime(t)
134+ containerSvc := inmocks.NewMockContainerService(t)
135+ configSvc := inmocks.NewMockConfigService(t)
136+
137+ svc := NewService(runtime, containerSvc, configSvc, Config{})
138+ ctx := testContext()
139+
140+ // Mock external routes with invalid port
141+ configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
142+ "invalid.example.com": "localhost:abc",
143+ })
144+
145+ result, err := svc.GetTarget(ctx, "invalid.example.com")
146+
147+ assert.Error(t, err)
148+ assert.Nil(t, result)
149+}
150+
66 func TestService_RegisterTarget(t *testing.T) {151 func TestService_RegisterTarget(t *testing.T) {
67 runtime := outmocks.NewMockContainerRuntime(t)152 runtime := outmocks.NewMockContainerRuntime(t)
68 containerSvc := inmocks.NewMockContainerService(t)153 containerSvc := inmocks.NewMockContainerService(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