mirror of
https://github.com/netbirdio/netbird.git
synced 2026-04-16 07:16:38 +00:00
[management, proxy] Add require_subdomain capability for proxy clusters (#5628)
This commit is contained in:
@@ -17,6 +17,9 @@ type Domain struct {
|
|||||||
// SupportsCustomPorts is populated at query time for free domains from the
|
// SupportsCustomPorts is populated at query time for free domains from the
|
||||||
// proxy cluster capabilities. Not persisted.
|
// proxy cluster capabilities. Not persisted.
|
||||||
SupportsCustomPorts *bool `gorm:"-"`
|
SupportsCustomPorts *bool `gorm:"-"`
|
||||||
|
// RequireSubdomain is populated at query time. When true, the domain
|
||||||
|
// cannot be used bare and a subdomain label must be prepended. Not persisted.
|
||||||
|
RequireSubdomain *bool `gorm:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// EventMeta returns activity event metadata for a domain
|
// EventMeta returns activity event metadata for a domain
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ func domainToApi(d *domain.Domain) api.ReverseProxyDomain {
|
|||||||
Type: domainTypeToApi(d.Type),
|
Type: domainTypeToApi(d.Type),
|
||||||
Validated: d.Validated,
|
Validated: d.Validated,
|
||||||
SupportsCustomPorts: d.SupportsCustomPorts,
|
SupportsCustomPorts: d.SupportsCustomPorts,
|
||||||
|
RequireSubdomain: d.RequireSubdomain,
|
||||||
}
|
}
|
||||||
if d.TargetCluster != "" {
|
if d.TargetCluster != "" {
|
||||||
resp.TargetCluster = &d.TargetCluster
|
resp.TargetCluster = &d.TargetCluster
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package manager
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExtractClusterFromFreeDomain(t *testing.T) {
|
||||||
|
clusters := []string{"eu1.proxy.netbird.io", "us1.proxy.netbird.io"}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
domain string
|
||||||
|
wantOK bool
|
||||||
|
wantVal string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "subdomain of cluster matches",
|
||||||
|
domain: "myapp.eu1.proxy.netbird.io",
|
||||||
|
wantOK: true,
|
||||||
|
wantVal: "eu1.proxy.netbird.io",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "deep subdomain of cluster matches",
|
||||||
|
domain: "foo.bar.eu1.proxy.netbird.io",
|
||||||
|
wantOK: true,
|
||||||
|
wantVal: "eu1.proxy.netbird.io",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bare cluster domain matches",
|
||||||
|
domain: "eu1.proxy.netbird.io",
|
||||||
|
wantOK: true,
|
||||||
|
wantVal: "eu1.proxy.netbird.io",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unrelated domain does not match",
|
||||||
|
domain: "example.com",
|
||||||
|
wantOK: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "partial suffix does not match",
|
||||||
|
domain: "fakeu1.proxy.netbird.io",
|
||||||
|
wantOK: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "second cluster matches",
|
||||||
|
domain: "app.us1.proxy.netbird.io",
|
||||||
|
wantOK: true,
|
||||||
|
wantVal: "us1.proxy.netbird.io",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
cluster, ok := ExtractClusterFromFreeDomain(tc.domain, clusters)
|
||||||
|
assert.Equal(t, tc.wantOK, ok)
|
||||||
|
if ok {
|
||||||
|
assert.Equal(t, tc.wantVal, cluster)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractClusterFromCustomDomains(t *testing.T) {
|
||||||
|
customDomains := []*domain.Domain{
|
||||||
|
{Domain: "example.com", TargetCluster: "eu1.proxy.netbird.io"},
|
||||||
|
{Domain: "proxy.corp.io", TargetCluster: "us1.proxy.netbird.io"},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
domain string
|
||||||
|
wantOK bool
|
||||||
|
wantVal string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "subdomain of custom domain matches",
|
||||||
|
domain: "app.example.com",
|
||||||
|
wantOK: true,
|
||||||
|
wantVal: "eu1.proxy.netbird.io",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bare custom domain matches",
|
||||||
|
domain: "example.com",
|
||||||
|
wantOK: true,
|
||||||
|
wantVal: "eu1.proxy.netbird.io",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "deep subdomain of custom domain matches",
|
||||||
|
domain: "a.b.example.com",
|
||||||
|
wantOK: true,
|
||||||
|
wantVal: "eu1.proxy.netbird.io",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "subdomain of multi-level custom domain matches",
|
||||||
|
domain: "app.proxy.corp.io",
|
||||||
|
wantOK: true,
|
||||||
|
wantVal: "us1.proxy.netbird.io",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bare multi-level custom domain matches",
|
||||||
|
domain: "proxy.corp.io",
|
||||||
|
wantOK: true,
|
||||||
|
wantVal: "us1.proxy.netbird.io",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unrelated domain does not match",
|
||||||
|
domain: "other.com",
|
||||||
|
wantOK: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "partial suffix does not match custom domain",
|
||||||
|
domain: "fakeexample.com",
|
||||||
|
wantOK: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
cluster, ok := extractClusterFromCustomDomains(tc.domain, customDomains)
|
||||||
|
assert.Equal(t, tc.wantOK, ok)
|
||||||
|
if ok {
|
||||||
|
assert.Equal(t, tc.wantVal, cluster)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractClusterFromCustomDomains_OverlappingDomains(t *testing.T) {
|
||||||
|
customDomains := []*domain.Domain{
|
||||||
|
{Domain: "example.com", TargetCluster: "cluster-generic"},
|
||||||
|
{Domain: "app.example.com", TargetCluster: "cluster-app"},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
domain string
|
||||||
|
wantVal string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "exact match on more specific domain",
|
||||||
|
domain: "app.example.com",
|
||||||
|
wantVal: "cluster-app",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "subdomain of more specific domain",
|
||||||
|
domain: "api.app.example.com",
|
||||||
|
wantVal: "cluster-app",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "subdomain of generic domain",
|
||||||
|
domain: "other.example.com",
|
||||||
|
wantVal: "cluster-generic",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bare generic domain",
|
||||||
|
domain: "example.com",
|
||||||
|
wantVal: "cluster-generic",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
cluster, ok := extractClusterFromCustomDomains(tc.domain, customDomains)
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, tc.wantVal, cluster)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ type proxyManager interface {
|
|||||||
|
|
||||||
type clusterCapabilities interface {
|
type clusterCapabilities interface {
|
||||||
ClusterSupportsCustomPorts(clusterAddr string) *bool
|
ClusterSupportsCustomPorts(clusterAddr string) *bool
|
||||||
|
ClusterRequireSubdomain(clusterAddr string) *bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
@@ -98,6 +99,7 @@ func (m Manager) GetDomains(ctx context.Context, accountID, userID string) ([]*d
|
|||||||
}
|
}
|
||||||
if m.clusterCapabilities != nil {
|
if m.clusterCapabilities != nil {
|
||||||
d.SupportsCustomPorts = m.clusterCapabilities.ClusterSupportsCustomPorts(cluster)
|
d.SupportsCustomPorts = m.clusterCapabilities.ClusterSupportsCustomPorts(cluster)
|
||||||
|
d.RequireSubdomain = m.clusterCapabilities.ClusterRequireSubdomain(cluster)
|
||||||
}
|
}
|
||||||
ret = append(ret, d)
|
ret = append(ret, d)
|
||||||
}
|
}
|
||||||
@@ -115,6 +117,8 @@ func (m Manager) GetDomains(ctx context.Context, accountID, userID string) ([]*d
|
|||||||
if m.clusterCapabilities != nil && d.TargetCluster != "" {
|
if m.clusterCapabilities != nil && d.TargetCluster != "" {
|
||||||
cd.SupportsCustomPorts = m.clusterCapabilities.ClusterSupportsCustomPorts(d.TargetCluster)
|
cd.SupportsCustomPorts = m.clusterCapabilities.ClusterSupportsCustomPorts(d.TargetCluster)
|
||||||
}
|
}
|
||||||
|
// Custom domains never require a subdomain by default since
|
||||||
|
// the account owns them and should be able to use the bare domain.
|
||||||
ret = append(ret, cd)
|
ret = append(ret, cd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,13 +306,19 @@ func (m Manager) DeriveClusterFromDomain(ctx context.Context, accountID, domain
|
|||||||
return "", fmt.Errorf("domain %s does not match any available proxy cluster", domain)
|
return "", fmt.Errorf("domain %s does not match any available proxy cluster", domain)
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractClusterFromCustomDomains(domain string, customDomains []*domain.Domain) (string, bool) {
|
func extractClusterFromCustomDomains(serviceDomain string, customDomains []*domain.Domain) (string, bool) {
|
||||||
for _, customDomain := range customDomains {
|
bestCluster := ""
|
||||||
if strings.HasSuffix(domain, "."+customDomain.Domain) {
|
bestLen := -1
|
||||||
return customDomain.TargetCluster, true
|
for _, cd := range customDomains {
|
||||||
|
if serviceDomain != cd.Domain && !strings.HasSuffix(serviceDomain, "."+cd.Domain) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if l := len(cd.Domain); l > bestLen {
|
||||||
|
bestLen = l
|
||||||
|
bestCluster = cd.TargetCluster
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "", false
|
return bestCluster, bestLen >= 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtractClusterFromFreeDomain extracts the cluster address from a free domain.
|
// ExtractClusterFromFreeDomain extracts the cluster address from a free domain.
|
||||||
|
|||||||
@@ -35,4 +35,5 @@ type Controller interface {
|
|||||||
UnregisterProxyFromCluster(ctx context.Context, clusterAddr, proxyID string) error
|
UnregisterProxyFromCluster(ctx context.Context, clusterAddr, proxyID string) error
|
||||||
GetProxiesForCluster(clusterAddr string) []string
|
GetProxiesForCluster(clusterAddr string) []string
|
||||||
ClusterSupportsCustomPorts(clusterAddr string) *bool
|
ClusterSupportsCustomPorts(clusterAddr string) *bool
|
||||||
|
ClusterRequireSubdomain(clusterAddr string) *bool
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,12 @@ func (c *GRPCController) ClusterSupportsCustomPorts(clusterAddr string) *bool {
|
|||||||
return c.proxyGRPCServer.ClusterSupportsCustomPorts(clusterAddr)
|
return c.proxyGRPCServer.ClusterSupportsCustomPorts(clusterAddr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ClusterRequireSubdomain returns whether the cluster requires a subdomain label.
|
||||||
|
// Returns nil when no proxy has reported the capability (defaults to false).
|
||||||
|
func (c *GRPCController) ClusterRequireSubdomain(clusterAddr string) *bool {
|
||||||
|
return c.proxyGRPCServer.ClusterRequireSubdomain(clusterAddr)
|
||||||
|
}
|
||||||
|
|
||||||
// GetProxiesForCluster returns all proxy IDs registered for a specific cluster.
|
// GetProxiesForCluster returns all proxy IDs registered for a specific cluster.
|
||||||
func (c *GRPCController) GetProxiesForCluster(clusterAddr string) []string {
|
func (c *GRPCController) GetProxiesForCluster(clusterAddr string) []string {
|
||||||
proxySet, ok := c.clusterProxies.Load(clusterAddr)
|
proxySet, ok := c.clusterProxies.Load(clusterAddr)
|
||||||
|
|||||||
@@ -159,6 +159,20 @@ func (mr *MockControllerMockRecorder) ClusterSupportsCustomPorts(clusterAddr int
|
|||||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterSupportsCustomPorts", reflect.TypeOf((*MockController)(nil).ClusterSupportsCustomPorts), clusterAddr)
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterSupportsCustomPorts", reflect.TypeOf((*MockController)(nil).ClusterSupportsCustomPorts), clusterAddr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ClusterRequireSubdomain mocks base method.
|
||||||
|
func (m *MockController) ClusterRequireSubdomain(clusterAddr string) *bool {
|
||||||
|
m.ctrl.T.Helper()
|
||||||
|
ret := m.ctrl.Call(m, "ClusterRequireSubdomain", clusterAddr)
|
||||||
|
ret0, _ := ret[0].(*bool)
|
||||||
|
return ret0
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClusterRequireSubdomain indicates an expected call of ClusterRequireSubdomain.
|
||||||
|
func (mr *MockControllerMockRecorder) ClusterRequireSubdomain(clusterAddr interface{}) *gomock.Call {
|
||||||
|
mr.mock.ctrl.T.Helper()
|
||||||
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterRequireSubdomain", reflect.TypeOf((*MockController)(nil).ClusterRequireSubdomain), clusterAddr)
|
||||||
|
}
|
||||||
|
|
||||||
// GetOIDCValidationConfig mocks base method.
|
// GetOIDCValidationConfig mocks base method.
|
||||||
func (m *MockController) GetOIDCValidationConfig() OIDCValidationConfig {
|
func (m *MockController) GetOIDCValidationConfig() OIDCValidationConfig {
|
||||||
m.ctrl.T.Helper()
|
m.ctrl.T.Helper()
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ func setupL4Test(t *testing.T, customPortsSupported *bool) (*Manager, store.Stor
|
|||||||
|
|
||||||
mockCtrl := proxy.NewMockController(ctrl)
|
mockCtrl := proxy.NewMockController(ctrl)
|
||||||
mockCtrl.EXPECT().ClusterSupportsCustomPorts(gomock.Any()).Return(customPortsSupported).AnyTimes()
|
mockCtrl.EXPECT().ClusterSupportsCustomPorts(gomock.Any()).Return(customPortsSupported).AnyTimes()
|
||||||
|
mockCtrl.EXPECT().ClusterRequireSubdomain(gomock.Any()).Return((*bool)(nil)).AnyTimes()
|
||||||
mockCtrl.EXPECT().SendServiceUpdateToCluster(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
|
mockCtrl.EXPECT().SendServiceUpdateToCluster(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
|
||||||
mockCtrl.EXPECT().GetOIDCValidationConfig().Return(proxy.OIDCValidationConfig{}).AnyTimes()
|
mockCtrl.EXPECT().GetOIDCValidationConfig().Return(proxy.OIDCValidationConfig{}).AnyTimes()
|
||||||
|
|
||||||
|
|||||||
@@ -236,6 +236,10 @@ func (m *Manager) initializeServiceForCreate(ctx context.Context, accountID stri
|
|||||||
return status.Errorf(status.PreconditionFailed, "could not derive cluster from domain %s: %v", service.Domain, err)
|
return status.Errorf(status.PreconditionFailed, "could not derive cluster from domain %s: %v", service.Domain, err)
|
||||||
}
|
}
|
||||||
service.ProxyCluster = proxyCluster
|
service.ProxyCluster = proxyCluster
|
||||||
|
|
||||||
|
if err := m.validateSubdomainRequirement(service.Domain, proxyCluster); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
service.AccountID = accountID
|
service.AccountID = accountID
|
||||||
@@ -261,6 +265,20 @@ func (m *Manager) initializeServiceForCreate(ctx context.Context, accountID stri
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validateSubdomainRequirement checks whether the domain can be used bare
|
||||||
|
// (without a subdomain label) on the given cluster. If the cluster reports
|
||||||
|
// require_subdomain=true and the domain equals the cluster domain, it rejects.
|
||||||
|
func (m *Manager) validateSubdomainRequirement(domain, cluster string) error {
|
||||||
|
if domain != cluster {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
requireSub := m.proxyController.ClusterRequireSubdomain(cluster)
|
||||||
|
if requireSub != nil && *requireSub {
|
||||||
|
return status.Errorf(status.InvalidArgument, "domain %s requires a subdomain label", domain)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) persistNewService(ctx context.Context, accountID string, svc *service.Service) error {
|
func (m *Manager) persistNewService(ctx context.Context, accountID string, svc *service.Service) error {
|
||||||
return m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
|
return m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
|
||||||
if svc.Domain != "" {
|
if svc.Domain != "" {
|
||||||
@@ -489,53 +507,61 @@ func (m *Manager) persistServiceUpdate(ctx context.Context, accountID string, se
|
|||||||
var updateInfo serviceUpdateInfo
|
var updateInfo serviceUpdateInfo
|
||||||
|
|
||||||
err := m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
|
err := m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
|
||||||
existingService, err := transaction.GetServiceByID(ctx, store.LockingStrengthUpdate, accountID, service.ID)
|
return m.executeServiceUpdate(ctx, transaction, accountID, service, &updateInfo)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := validateProtocolChange(existingService.Mode, service.Mode); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
updateInfo.oldCluster = existingService.ProxyCluster
|
|
||||||
updateInfo.domainChanged = existingService.Domain != service.Domain
|
|
||||||
|
|
||||||
if updateInfo.domainChanged {
|
|
||||||
if err := m.handleDomainChange(ctx, transaction, accountID, service); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
service.ProxyCluster = existingService.ProxyCluster
|
|
||||||
}
|
|
||||||
|
|
||||||
m.preserveExistingAuthSecrets(service, existingService)
|
|
||||||
if err := validateHeaderAuthValues(service.Auth.HeaderAuths); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
m.preserveServiceMetadata(service, existingService)
|
|
||||||
m.preserveListenPort(service, existingService)
|
|
||||||
updateInfo.serviceEnabledChanged = existingService.Enabled != service.Enabled
|
|
||||||
|
|
||||||
if err := m.ensureL4Port(ctx, transaction, service); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := m.checkPortConflict(ctx, transaction, service); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := validateTargetReferences(ctx, transaction, accountID, service.Targets); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := transaction.UpdateService(ctx, service); err != nil {
|
|
||||||
return fmt.Errorf("update service: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return &updateInfo, err
|
return &updateInfo, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) executeServiceUpdate(ctx context.Context, transaction store.Store, accountID string, service *service.Service, updateInfo *serviceUpdateInfo) error {
|
||||||
|
existingService, err := transaction.GetServiceByID(ctx, store.LockingStrengthUpdate, accountID, service.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateProtocolChange(existingService.Mode, service.Mode); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
updateInfo.oldCluster = existingService.ProxyCluster
|
||||||
|
updateInfo.domainChanged = existingService.Domain != service.Domain
|
||||||
|
|
||||||
|
if updateInfo.domainChanged {
|
||||||
|
if err := m.handleDomainChange(ctx, transaction, accountID, service); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
service.ProxyCluster = existingService.ProxyCluster
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.validateSubdomainRequirement(service.Domain, service.ProxyCluster); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
m.preserveExistingAuthSecrets(service, existingService)
|
||||||
|
if err := validateHeaderAuthValues(service.Auth.HeaderAuths); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.preserveServiceMetadata(service, existingService)
|
||||||
|
m.preserveListenPort(service, existingService)
|
||||||
|
updateInfo.serviceEnabledChanged = existingService.Enabled != service.Enabled
|
||||||
|
|
||||||
|
if err := m.ensureL4Port(ctx, transaction, service); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := m.checkPortConflict(ctx, transaction, service); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validateTargetReferences(ctx, transaction, accountID, service.Targets); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := transaction.UpdateService(ctx, service); err != nil {
|
||||||
|
return fmt.Errorf("update service: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) handleDomainChange(ctx context.Context, transaction store.Store, accountID string, svc *service.Service) error {
|
func (m *Manager) handleDomainChange(ctx context.Context, transaction store.Store, accountID string, svc *service.Service) error {
|
||||||
if err := m.checkDomainAvailable(ctx, transaction, svc.Domain, svc.ID); err != nil {
|
if err := m.checkDomainAvailable(ctx, transaction, svc.Domain, svc.ID); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -1272,3 +1272,69 @@ func TestValidateTargetReferences_PeerValid(t *testing.T) {
|
|||||||
}
|
}
|
||||||
require.NoError(t, validateTargetReferences(ctx, mockStore, accountID, targets))
|
require.NoError(t, validateTargetReferences(ctx, mockStore, accountID, targets))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateSubdomainRequirement(t *testing.T) {
|
||||||
|
ptrBool := func(b bool) *bool { return &b }
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
domain string
|
||||||
|
cluster string
|
||||||
|
requireSubdomain *bool
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "subdomain present, require_subdomain true",
|
||||||
|
domain: "app.eu1.proxy.netbird.io",
|
||||||
|
cluster: "eu1.proxy.netbird.io",
|
||||||
|
requireSubdomain: ptrBool(true),
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bare cluster domain, require_subdomain true",
|
||||||
|
domain: "eu1.proxy.netbird.io",
|
||||||
|
cluster: "eu1.proxy.netbird.io",
|
||||||
|
requireSubdomain: ptrBool(true),
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bare cluster domain, require_subdomain false",
|
||||||
|
domain: "eu1.proxy.netbird.io",
|
||||||
|
cluster: "eu1.proxy.netbird.io",
|
||||||
|
requireSubdomain: ptrBool(false),
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bare cluster domain, require_subdomain nil (default)",
|
||||||
|
domain: "eu1.proxy.netbird.io",
|
||||||
|
cluster: "eu1.proxy.netbird.io",
|
||||||
|
requireSubdomain: nil,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "custom domain apex is not the cluster",
|
||||||
|
domain: "example.com",
|
||||||
|
cluster: "eu1.proxy.netbird.io",
|
||||||
|
requireSubdomain: ptrBool(true),
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
ctrl := gomock.NewController(t)
|
||||||
|
|
||||||
|
mockCtrl := proxy.NewMockController(ctrl)
|
||||||
|
mockCtrl.EXPECT().ClusterRequireSubdomain(tc.cluster).Return(tc.requireSubdomain).AnyTimes()
|
||||||
|
|
||||||
|
mgr := &Manager{proxyController: mockCtrl}
|
||||||
|
err := mgr.validateSubdomainRequirement(tc.domain, tc.cluster)
|
||||||
|
if tc.wantErr {
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "requires a subdomain label")
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -537,6 +537,35 @@ func (s *ProxyServiceServer) ClusterSupportsCustomPorts(clusterAddr string) *boo
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ClusterRequireSubdomain returns whether any connected proxy in the given
|
||||||
|
// cluster reports that a subdomain is required. Returns nil if no proxy has
|
||||||
|
// reported the capability (defaults to not required).
|
||||||
|
func (s *ProxyServiceServer) ClusterRequireSubdomain(clusterAddr string) *bool {
|
||||||
|
if s.proxyController == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasCapabilities bool
|
||||||
|
for _, pid := range s.proxyController.GetProxiesForCluster(clusterAddr) {
|
||||||
|
connVal, ok := s.connectedProxies.Load(pid)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
conn := connVal.(*proxyConnection)
|
||||||
|
if conn.capabilities == nil || conn.capabilities.RequireSubdomain == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if *conn.capabilities.RequireSubdomain {
|
||||||
|
return ptr(true)
|
||||||
|
}
|
||||||
|
hasCapabilities = true
|
||||||
|
}
|
||||||
|
if hasCapabilities {
|
||||||
|
return ptr(false)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ProxyServiceServer) Authenticate(ctx context.Context, req *proto.AuthenticateRequest) (*proto.AuthenticateResponse, error) {
|
func (s *ProxyServiceServer) Authenticate(ctx context.Context, req *proto.AuthenticateRequest) (*proto.AuthenticateResponse, error) {
|
||||||
service, err := s.serviceManager.GetServiceByID(ctx, req.GetAccountId(), req.GetId())
|
service, err := s.serviceManager.GetServiceByID(ctx, req.GetAccountId(), req.GetId())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ func (c *testProxyController) ClusterSupportsCustomPorts(_ string) *bool {
|
|||||||
return ptr(true)
|
return ptr(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *testProxyController) ClusterRequireSubdomain(_ string) *bool {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *testProxyController) GetProxiesForCluster(clusterAddr string) []string {
|
func (c *testProxyController) GetProxiesForCluster(clusterAddr string) []string {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ var (
|
|||||||
proxyProtocol bool
|
proxyProtocol bool
|
||||||
preSharedKey string
|
preSharedKey string
|
||||||
supportsCustomPorts bool
|
supportsCustomPorts bool
|
||||||
|
requireSubdomain bool
|
||||||
geoDataDir string
|
geoDataDir string
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -101,6 +102,7 @@ func init() {
|
|||||||
rootCmd.Flags().BoolVar(&proxyProtocol, "proxy-protocol", envBoolOrDefault("NB_PROXY_PROXY_PROTOCOL", false), "Enable PROXY protocol on TCP listeners to preserve client IPs behind L4 proxies")
|
rootCmd.Flags().BoolVar(&proxyProtocol, "proxy-protocol", envBoolOrDefault("NB_PROXY_PROXY_PROTOCOL", false), "Enable PROXY protocol on TCP listeners to preserve client IPs behind L4 proxies")
|
||||||
rootCmd.Flags().StringVar(&preSharedKey, "preshared-key", envStringOrDefault("NB_PROXY_PRESHARED_KEY", ""), "Define a pre-shared key for the tunnel between proxy and peers")
|
rootCmd.Flags().StringVar(&preSharedKey, "preshared-key", envStringOrDefault("NB_PROXY_PRESHARED_KEY", ""), "Define a pre-shared key for the tunnel between proxy and peers")
|
||||||
rootCmd.Flags().BoolVar(&supportsCustomPorts, "supports-custom-ports", envBoolOrDefault("NB_PROXY_SUPPORTS_CUSTOM_PORTS", true), "Whether the proxy can bind arbitrary ports for UDP/TCP passthrough")
|
rootCmd.Flags().BoolVar(&supportsCustomPorts, "supports-custom-ports", envBoolOrDefault("NB_PROXY_SUPPORTS_CUSTOM_PORTS", true), "Whether the proxy can bind arbitrary ports for UDP/TCP passthrough")
|
||||||
|
rootCmd.Flags().BoolVar(&requireSubdomain, "require-subdomain", envBoolOrDefault("NB_PROXY_REQUIRE_SUBDOMAIN", false), "Require a subdomain label in front of the cluster domain")
|
||||||
rootCmd.Flags().DurationVar(&maxDialTimeout, "max-dial-timeout", envDurationOrDefault("NB_PROXY_MAX_DIAL_TIMEOUT", 0), "Cap per-service backend dial timeout (0 = no cap)")
|
rootCmd.Flags().DurationVar(&maxDialTimeout, "max-dial-timeout", envDurationOrDefault("NB_PROXY_MAX_DIAL_TIMEOUT", 0), "Cap per-service backend dial timeout (0 = no cap)")
|
||||||
rootCmd.Flags().DurationVar(&maxSessionIdleTimeout, "max-session-idle-timeout", envDurationOrDefault("NB_PROXY_MAX_SESSION_IDLE_TIMEOUT", 0), "Cap per-service session idle timeout (0 = no cap)")
|
rootCmd.Flags().DurationVar(&maxSessionIdleTimeout, "max-session-idle-timeout", envDurationOrDefault("NB_PROXY_MAX_SESSION_IDLE_TIMEOUT", 0), "Cap per-service session idle timeout (0 = no cap)")
|
||||||
rootCmd.Flags().StringVar(&geoDataDir, "geo-data-dir", envStringOrDefault("NB_PROXY_GEO_DATA_DIR", "/var/lib/netbird/geolocation"), "Directory for the GeoLite2 MMDB file (auto-downloaded if missing)")
|
rootCmd.Flags().StringVar(&geoDataDir, "geo-data-dir", envStringOrDefault("NB_PROXY_GEO_DATA_DIR", "/var/lib/netbird/geolocation"), "Directory for the GeoLite2 MMDB file (auto-downloaded if missing)")
|
||||||
@@ -181,6 +183,7 @@ func runServer(cmd *cobra.Command, args []string) error {
|
|||||||
ProxyProtocol: proxyProtocol,
|
ProxyProtocol: proxyProtocol,
|
||||||
PreSharedKey: preSharedKey,
|
PreSharedKey: preSharedKey,
|
||||||
SupportsCustomPorts: supportsCustomPorts,
|
SupportsCustomPorts: supportsCustomPorts,
|
||||||
|
RequireSubdomain: requireSubdomain,
|
||||||
MaxDialTimeout: maxDialTimeout,
|
MaxDialTimeout: maxDialTimeout,
|
||||||
MaxSessionIdleTimeout: maxSessionIdleTimeout,
|
MaxSessionIdleTimeout: maxSessionIdleTimeout,
|
||||||
GeoDataDir: geoDataDir,
|
GeoDataDir: geoDataDir,
|
||||||
|
|||||||
@@ -251,6 +251,10 @@ func (c *testProxyController) ClusterSupportsCustomPorts(_ string) *bool {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *testProxyController) ClusterRequireSubdomain(_ string) *bool {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// storeBackedServiceManager reads directly from the real store.
|
// storeBackedServiceManager reads directly from the real store.
|
||||||
type storeBackedServiceManager struct {
|
type storeBackedServiceManager struct {
|
||||||
store store.Store
|
store store.Store
|
||||||
|
|||||||
@@ -163,6 +163,10 @@ type Server struct {
|
|||||||
// SupportsCustomPorts indicates whether the proxy can bind arbitrary
|
// SupportsCustomPorts indicates whether the proxy can bind arbitrary
|
||||||
// ports for TCP/UDP/TLS services.
|
// ports for TCP/UDP/TLS services.
|
||||||
SupportsCustomPorts bool
|
SupportsCustomPorts bool
|
||||||
|
// RequireSubdomain indicates whether a subdomain label is required
|
||||||
|
// in front of this proxy's cluster domain. When true, accounts cannot
|
||||||
|
// create services on the bare cluster domain.
|
||||||
|
RequireSubdomain bool
|
||||||
// MaxDialTimeout caps the per-service backend dial timeout.
|
// MaxDialTimeout caps the per-service backend dial timeout.
|
||||||
// When the API sends a timeout, it is clamped to this value.
|
// When the API sends a timeout, it is clamped to this value.
|
||||||
// When the API sends no timeout, this value is used as the default.
|
// When the API sends no timeout, this value is used as the default.
|
||||||
@@ -919,6 +923,7 @@ func (s *Server) newManagementMappingWorker(ctx context.Context, client proto.Pr
|
|||||||
Address: s.ProxyURL,
|
Address: s.ProxyURL,
|
||||||
Capabilities: &proto.ProxyCapabilities{
|
Capabilities: &proto.ProxyCapabilities{
|
||||||
SupportsCustomPorts: &s.SupportsCustomPorts,
|
SupportsCustomPorts: &s.SupportsCustomPorts,
|
||||||
|
RequireSubdomain: &s.RequireSubdomain,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -3336,6 +3336,10 @@ components:
|
|||||||
type: boolean
|
type: boolean
|
||||||
description: Whether the cluster supports binding arbitrary TCP/UDP ports
|
description: Whether the cluster supports binding arbitrary TCP/UDP ports
|
||||||
example: true
|
example: true
|
||||||
|
require_subdomain:
|
||||||
|
type: boolean
|
||||||
|
description: Whether a subdomain label is required in front of this domain. When true, the domain cannot be used bare.
|
||||||
|
example: false
|
||||||
required:
|
required:
|
||||||
- id
|
- id
|
||||||
- domain
|
- domain
|
||||||
|
|||||||
@@ -3406,6 +3406,9 @@ type ReverseProxyDomain struct {
|
|||||||
// Id Domain ID
|
// Id Domain ID
|
||||||
Id string `json:"id"`
|
Id string `json:"id"`
|
||||||
|
|
||||||
|
// RequireSubdomain Whether a subdomain label is required in front of this domain. When true, the domain cannot be used bare.
|
||||||
|
RequireSubdomain *bool `json:"require_subdomain,omitempty"`
|
||||||
|
|
||||||
// SupportsCustomPorts Whether the cluster supports binding arbitrary TCP/UDP ports
|
// SupportsCustomPorts Whether the cluster supports binding arbitrary TCP/UDP ports
|
||||||
SupportsCustomPorts *bool `json:"supports_custom_ports,omitempty"`
|
SupportsCustomPorts *bool `json:"supports_custom_ports,omitempty"`
|
||||||
|
|
||||||
|
|||||||
@@ -181,8 +181,11 @@ type ProxyCapabilities struct {
|
|||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
// Whether the proxy can bind arbitrary ports for TCP/UDP/TLS services.
|
// Whether the proxy can bind arbitrary ports for TCP/UDP/TLS services.
|
||||||
SupportsCustomPorts *bool `protobuf:"varint,1,opt,name=supports_custom_ports,json=supportsCustomPorts,proto3,oneof" json:"supports_custom_ports,omitempty"`
|
SupportsCustomPorts *bool `protobuf:"varint,1,opt,name=supports_custom_ports,json=supportsCustomPorts,proto3,oneof" json:"supports_custom_ports,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
// Whether the proxy requires a subdomain label in front of its cluster domain.
|
||||||
sizeCache protoimpl.SizeCache
|
// When true, tenants cannot use the cluster domain bare.
|
||||||
|
RequireSubdomain *bool `protobuf:"varint,2,opt,name=require_subdomain,json=requireSubdomain,proto3,oneof" json:"require_subdomain,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *ProxyCapabilities) Reset() {
|
func (x *ProxyCapabilities) Reset() {
|
||||||
@@ -222,6 +225,13 @@ func (x *ProxyCapabilities) GetSupportsCustomPorts() bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *ProxyCapabilities) GetRequireSubdomain() bool {
|
||||||
|
if x != nil && x.RequireSubdomain != nil {
|
||||||
|
return *x.RequireSubdomain
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// GetMappingUpdateRequest is sent to initialise a mapping stream.
|
// GetMappingUpdateRequest is sent to initialise a mapping stream.
|
||||||
type GetMappingUpdateRequest struct {
|
type GetMappingUpdateRequest struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
@@ -1872,10 +1882,12 @@ var File_proxy_service_proto protoreflect.FileDescriptor
|
|||||||
const file_proxy_service_proto_rawDesc = "" +
|
const file_proxy_service_proto_rawDesc = "" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"\x13proxy_service.proto\x12\n" +
|
"\x13proxy_service.proto\x12\n" +
|
||||||
"management\x1a\x1egoogle/protobuf/duration.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"f\n" +
|
"management\x1a\x1egoogle/protobuf/duration.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xae\x01\n" +
|
||||||
"\x11ProxyCapabilities\x127\n" +
|
"\x11ProxyCapabilities\x127\n" +
|
||||||
"\x15supports_custom_ports\x18\x01 \x01(\bH\x00R\x13supportsCustomPorts\x88\x01\x01B\x18\n" +
|
"\x15supports_custom_ports\x18\x01 \x01(\bH\x00R\x13supportsCustomPorts\x88\x01\x01\x120\n" +
|
||||||
"\x16_supports_custom_ports\"\xe6\x01\n" +
|
"\x11require_subdomain\x18\x02 \x01(\bH\x01R\x10requireSubdomain\x88\x01\x01B\x18\n" +
|
||||||
|
"\x16_supports_custom_portsB\x14\n" +
|
||||||
|
"\x12_require_subdomain\"\xe6\x01\n" +
|
||||||
"\x17GetMappingUpdateRequest\x12\x19\n" +
|
"\x17GetMappingUpdateRequest\x12\x19\n" +
|
||||||
"\bproxy_id\x18\x01 \x01(\tR\aproxyId\x12\x18\n" +
|
"\bproxy_id\x18\x01 \x01(\tR\aproxyId\x12\x18\n" +
|
||||||
"\aversion\x18\x02 \x01(\tR\aversion\x129\n" +
|
"\aversion\x18\x02 \x01(\tR\aversion\x129\n" +
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ service ProxyService {
|
|||||||
message ProxyCapabilities {
|
message ProxyCapabilities {
|
||||||
// Whether the proxy can bind arbitrary ports for TCP/UDP/TLS services.
|
// Whether the proxy can bind arbitrary ports for TCP/UDP/TLS services.
|
||||||
optional bool supports_custom_ports = 1;
|
optional bool supports_custom_ports = 1;
|
||||||
|
// Whether the proxy requires a subdomain label in front of its cluster domain.
|
||||||
|
// When true, accounts cannot use the cluster domain bare.
|
||||||
|
optional bool require_subdomain = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMappingUpdateRequest is sent to initialise a mapping stream.
|
// GetMappingUpdateRequest is sent to initialise a mapping stream.
|
||||||
|
|||||||
Reference in New Issue
Block a user