Skip to content

Commit b550a2f

Browse files
authored
[management, proxy] Add require_subdomain capability for proxy clusters (#5628)
1 parent ab77508 commit b550a2f

19 files changed

Lines changed: 413 additions & 46 deletions

File tree

management/internals/modules/reverseproxy/domain/domain.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ type Domain struct {
1717
// SupportsCustomPorts is populated at query time for free domains from the
1818
// proxy cluster capabilities. Not persisted.
1919
SupportsCustomPorts *bool `gorm:"-"`
20+
// RequireSubdomain is populated at query time. When true, the domain
21+
// cannot be used bare and a subdomain label must be prepended. Not persisted.
22+
RequireSubdomain *bool `gorm:"-"`
2023
}
2124

2225
// EventMeta returns activity event metadata for a domain

management/internals/modules/reverseproxy/domain/manager/api.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ func domainToApi(d *domain.Domain) api.ReverseProxyDomain {
4747
Type: domainTypeToApi(d.Type),
4848
Validated: d.Validated,
4949
SupportsCustomPorts: d.SupportsCustomPorts,
50+
RequireSubdomain: d.RequireSubdomain,
5051
}
5152
if d.TargetCluster != "" {
5253
resp.TargetCluster = &d.TargetCluster
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
package manager
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
8+
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
9+
)
10+
11+
func TestExtractClusterFromFreeDomain(t *testing.T) {
12+
clusters := []string{"eu1.proxy.netbird.io", "us1.proxy.netbird.io"}
13+
14+
tests := []struct {
15+
name string
16+
domain string
17+
wantOK bool
18+
wantVal string
19+
}{
20+
{
21+
name: "subdomain of cluster matches",
22+
domain: "myapp.eu1.proxy.netbird.io",
23+
wantOK: true,
24+
wantVal: "eu1.proxy.netbird.io",
25+
},
26+
{
27+
name: "deep subdomain of cluster matches",
28+
domain: "foo.bar.eu1.proxy.netbird.io",
29+
wantOK: true,
30+
wantVal: "eu1.proxy.netbird.io",
31+
},
32+
{
33+
name: "bare cluster domain matches",
34+
domain: "eu1.proxy.netbird.io",
35+
wantOK: true,
36+
wantVal: "eu1.proxy.netbird.io",
37+
},
38+
{
39+
name: "unrelated domain does not match",
40+
domain: "example.com",
41+
wantOK: false,
42+
},
43+
{
44+
name: "partial suffix does not match",
45+
domain: "fakeu1.proxy.netbird.io",
46+
wantOK: false,
47+
},
48+
{
49+
name: "second cluster matches",
50+
domain: "app.us1.proxy.netbird.io",
51+
wantOK: true,
52+
wantVal: "us1.proxy.netbird.io",
53+
},
54+
}
55+
56+
for _, tc := range tests {
57+
t.Run(tc.name, func(t *testing.T) {
58+
cluster, ok := ExtractClusterFromFreeDomain(tc.domain, clusters)
59+
assert.Equal(t, tc.wantOK, ok)
60+
if ok {
61+
assert.Equal(t, tc.wantVal, cluster)
62+
}
63+
})
64+
}
65+
}
66+
67+
func TestExtractClusterFromCustomDomains(t *testing.T) {
68+
customDomains := []*domain.Domain{
69+
{Domain: "example.com", TargetCluster: "eu1.proxy.netbird.io"},
70+
{Domain: "proxy.corp.io", TargetCluster: "us1.proxy.netbird.io"},
71+
}
72+
73+
tests := []struct {
74+
name string
75+
domain string
76+
wantOK bool
77+
wantVal string
78+
}{
79+
{
80+
name: "subdomain of custom domain matches",
81+
domain: "app.example.com",
82+
wantOK: true,
83+
wantVal: "eu1.proxy.netbird.io",
84+
},
85+
{
86+
name: "bare custom domain matches",
87+
domain: "example.com",
88+
wantOK: true,
89+
wantVal: "eu1.proxy.netbird.io",
90+
},
91+
{
92+
name: "deep subdomain of custom domain matches",
93+
domain: "a.b.example.com",
94+
wantOK: true,
95+
wantVal: "eu1.proxy.netbird.io",
96+
},
97+
{
98+
name: "subdomain of multi-level custom domain matches",
99+
domain: "app.proxy.corp.io",
100+
wantOK: true,
101+
wantVal: "us1.proxy.netbird.io",
102+
},
103+
{
104+
name: "bare multi-level custom domain matches",
105+
domain: "proxy.corp.io",
106+
wantOK: true,
107+
wantVal: "us1.proxy.netbird.io",
108+
},
109+
{
110+
name: "unrelated domain does not match",
111+
domain: "other.com",
112+
wantOK: false,
113+
},
114+
{
115+
name: "partial suffix does not match custom domain",
116+
domain: "fakeexample.com",
117+
wantOK: false,
118+
},
119+
}
120+
121+
for _, tc := range tests {
122+
t.Run(tc.name, func(t *testing.T) {
123+
cluster, ok := extractClusterFromCustomDomains(tc.domain, customDomains)
124+
assert.Equal(t, tc.wantOK, ok)
125+
if ok {
126+
assert.Equal(t, tc.wantVal, cluster)
127+
}
128+
})
129+
}
130+
}
131+
132+
func TestExtractClusterFromCustomDomains_OverlappingDomains(t *testing.T) {
133+
customDomains := []*domain.Domain{
134+
{Domain: "example.com", TargetCluster: "cluster-generic"},
135+
{Domain: "app.example.com", TargetCluster: "cluster-app"},
136+
}
137+
138+
tests := []struct {
139+
name string
140+
domain string
141+
wantVal string
142+
}{
143+
{
144+
name: "exact match on more specific domain",
145+
domain: "app.example.com",
146+
wantVal: "cluster-app",
147+
},
148+
{
149+
name: "subdomain of more specific domain",
150+
domain: "api.app.example.com",
151+
wantVal: "cluster-app",
152+
},
153+
{
154+
name: "subdomain of generic domain",
155+
domain: "other.example.com",
156+
wantVal: "cluster-generic",
157+
},
158+
{
159+
name: "bare generic domain",
160+
domain: "example.com",
161+
wantVal: "cluster-generic",
162+
},
163+
}
164+
165+
for _, tc := range tests {
166+
t.Run(tc.name, func(t *testing.T) {
167+
cluster, ok := extractClusterFromCustomDomains(tc.domain, customDomains)
168+
assert.True(t, ok)
169+
assert.Equal(t, tc.wantVal, cluster)
170+
})
171+
}
172+
}

management/internals/modules/reverseproxy/domain/manager/manager.go

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ type proxyManager interface {
3535

3636
type clusterCapabilities interface {
3737
ClusterSupportsCustomPorts(clusterAddr string) *bool
38+
ClusterRequireSubdomain(clusterAddr string) *bool
3839
}
3940

4041
type Manager struct {
@@ -98,6 +99,7 @@ func (m Manager) GetDomains(ctx context.Context, accountID, userID string) ([]*d
9899
}
99100
if m.clusterCapabilities != nil {
100101
d.SupportsCustomPorts = m.clusterCapabilities.ClusterSupportsCustomPorts(cluster)
102+
d.RequireSubdomain = m.clusterCapabilities.ClusterRequireSubdomain(cluster)
101103
}
102104
ret = append(ret, d)
103105
}
@@ -115,6 +117,8 @@ func (m Manager) GetDomains(ctx context.Context, accountID, userID string) ([]*d
115117
if m.clusterCapabilities != nil && d.TargetCluster != "" {
116118
cd.SupportsCustomPorts = m.clusterCapabilities.ClusterSupportsCustomPorts(d.TargetCluster)
117119
}
120+
// Custom domains never require a subdomain by default since
121+
// the account owns them and should be able to use the bare domain.
118122
ret = append(ret, cd)
119123
}
120124

@@ -302,13 +306,19 @@ func (m Manager) DeriveClusterFromDomain(ctx context.Context, accountID, domain
302306
return "", fmt.Errorf("domain %s does not match any available proxy cluster", domain)
303307
}
304308

305-
func extractClusterFromCustomDomains(domain string, customDomains []*domain.Domain) (string, bool) {
306-
for _, customDomain := range customDomains {
307-
if strings.HasSuffix(domain, "."+customDomain.Domain) {
308-
return customDomain.TargetCluster, true
309+
func extractClusterFromCustomDomains(serviceDomain string, customDomains []*domain.Domain) (string, bool) {
310+
bestCluster := ""
311+
bestLen := -1
312+
for _, cd := range customDomains {
313+
if serviceDomain != cd.Domain && !strings.HasSuffix(serviceDomain, "."+cd.Domain) {
314+
continue
315+
}
316+
if l := len(cd.Domain); l > bestLen {
317+
bestLen = l
318+
bestCluster = cd.TargetCluster
309319
}
310320
}
311-
return "", false
321+
return bestCluster, bestLen >= 0
312322
}
313323

314324
// ExtractClusterFromFreeDomain extracts the cluster address from a free domain.

management/internals/modules/reverseproxy/proxy/manager.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,5 @@ type Controller interface {
3535
UnregisterProxyFromCluster(ctx context.Context, clusterAddr, proxyID string) error
3636
GetProxiesForCluster(clusterAddr string) []string
3737
ClusterSupportsCustomPorts(clusterAddr string) *bool
38+
ClusterRequireSubdomain(clusterAddr string) *bool
3839
}

management/internals/modules/reverseproxy/proxy/manager/controller.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,12 @@ func (c *GRPCController) ClusterSupportsCustomPorts(clusterAddr string) *bool {
7777
return c.proxyGRPCServer.ClusterSupportsCustomPorts(clusterAddr)
7878
}
7979

80+
// ClusterRequireSubdomain returns whether the cluster requires a subdomain label.
81+
// Returns nil when no proxy has reported the capability (defaults to false).
82+
func (c *GRPCController) ClusterRequireSubdomain(clusterAddr string) *bool {
83+
return c.proxyGRPCServer.ClusterRequireSubdomain(clusterAddr)
84+
}
85+
8086
// GetProxiesForCluster returns all proxy IDs registered for a specific cluster.
8187
func (c *GRPCController) GetProxiesForCluster(clusterAddr string) []string {
8288
proxySet, ok := c.clusterProxies.Load(clusterAddr)

management/internals/modules/reverseproxy/proxy/manager_mock.go

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

management/internals/modules/reverseproxy/service/manager/l4_port_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ func setupL4Test(t *testing.T, customPortsSupported *bool) (*Manager, store.Stor
7676

7777
mockCtrl := proxy.NewMockController(ctrl)
7878
mockCtrl.EXPECT().ClusterSupportsCustomPorts(gomock.Any()).Return(customPortsSupported).AnyTimes()
79+
mockCtrl.EXPECT().ClusterRequireSubdomain(gomock.Any()).Return((*bool)(nil)).AnyTimes()
7980
mockCtrl.EXPECT().SendServiceUpdateToCluster(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
8081
mockCtrl.EXPECT().GetOIDCValidationConfig().Return(proxy.OIDCValidationConfig{}).AnyTimes()
8182

0 commit comments

Comments
 (0)