diff --git a/management/internals/modules/reverseproxy/domain/domain.go b/management/internals/modules/reverseproxy/domain/domain.go index 861d026a7..859f1c5b2 100644 --- a/management/internals/modules/reverseproxy/domain/domain.go +++ b/management/internals/modules/reverseproxy/domain/domain.go @@ -17,6 +17,9 @@ type Domain struct { // SupportsCustomPorts is populated at query time for free domains from the // proxy cluster capabilities. Not persisted. 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 diff --git a/management/internals/modules/reverseproxy/domain/manager/api.go b/management/internals/modules/reverseproxy/domain/manager/api.go index d26a6a418..640ab28a5 100644 --- a/management/internals/modules/reverseproxy/domain/manager/api.go +++ b/management/internals/modules/reverseproxy/domain/manager/api.go @@ -47,6 +47,7 @@ func domainToApi(d *domain.Domain) api.ReverseProxyDomain { Type: domainTypeToApi(d.Type), Validated: d.Validated, SupportsCustomPorts: d.SupportsCustomPorts, + RequireSubdomain: d.RequireSubdomain, } if d.TargetCluster != "" { resp.TargetCluster = &d.TargetCluster diff --git a/management/internals/modules/reverseproxy/domain/manager/domain_test.go b/management/internals/modules/reverseproxy/domain/manager/domain_test.go new file mode 100644 index 000000000..523920a99 --- /dev/null +++ b/management/internals/modules/reverseproxy/domain/manager/domain_test.go @@ -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) + }) + } +} diff --git a/management/internals/modules/reverseproxy/domain/manager/manager.go b/management/internals/modules/reverseproxy/domain/manager/manager.go index 813027ea2..901cdf0e3 100644 --- a/management/internals/modules/reverseproxy/domain/manager/manager.go +++ b/management/internals/modules/reverseproxy/domain/manager/manager.go @@ -35,6 +35,7 @@ type proxyManager interface { type clusterCapabilities interface { ClusterSupportsCustomPorts(clusterAddr string) *bool + ClusterRequireSubdomain(clusterAddr string) *bool } type Manager struct { @@ -98,6 +99,7 @@ func (m Manager) GetDomains(ctx context.Context, accountID, userID string) ([]*d } if m.clusterCapabilities != nil { d.SupportsCustomPorts = m.clusterCapabilities.ClusterSupportsCustomPorts(cluster) + d.RequireSubdomain = m.clusterCapabilities.ClusterRequireSubdomain(cluster) } 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 != "" { 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) } @@ -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) } -func extractClusterFromCustomDomains(domain string, customDomains []*domain.Domain) (string, bool) { - for _, customDomain := range customDomains { - if strings.HasSuffix(domain, "."+customDomain.Domain) { - return customDomain.TargetCluster, true +func extractClusterFromCustomDomains(serviceDomain string, customDomains []*domain.Domain) (string, bool) { + bestCluster := "" + bestLen := -1 + 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. diff --git a/management/internals/modules/reverseproxy/proxy/manager.go b/management/internals/modules/reverseproxy/proxy/manager.go index 5b13cb0a2..9b0de53b4 100644 --- a/management/internals/modules/reverseproxy/proxy/manager.go +++ b/management/internals/modules/reverseproxy/proxy/manager.go @@ -35,4 +35,5 @@ type Controller interface { UnregisterProxyFromCluster(ctx context.Context, clusterAddr, proxyID string) error GetProxiesForCluster(clusterAddr string) []string ClusterSupportsCustomPorts(clusterAddr string) *bool + ClusterRequireSubdomain(clusterAddr string) *bool } diff --git a/management/internals/modules/reverseproxy/proxy/manager/controller.go b/management/internals/modules/reverseproxy/proxy/manager/controller.go index acb49c45b..05a0c9048 100644 --- a/management/internals/modules/reverseproxy/proxy/manager/controller.go +++ b/management/internals/modules/reverseproxy/proxy/manager/controller.go @@ -77,6 +77,12 @@ func (c *GRPCController) ClusterSupportsCustomPorts(clusterAddr string) *bool { 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. func (c *GRPCController) GetProxiesForCluster(clusterAddr string) []string { proxySet, ok := c.clusterProxies.Load(clusterAddr) diff --git a/management/internals/modules/reverseproxy/proxy/manager_mock.go b/management/internals/modules/reverseproxy/proxy/manager_mock.go index ec67aaedc..da3df12a2 100644 --- a/management/internals/modules/reverseproxy/proxy/manager_mock.go +++ b/management/internals/modules/reverseproxy/proxy/manager_mock.go @@ -159,6 +159,20 @@ func (mr *MockControllerMockRecorder) ClusterSupportsCustomPorts(clusterAddr int 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. func (m *MockController) GetOIDCValidationConfig() OIDCValidationConfig { m.ctrl.T.Helper() diff --git a/management/internals/modules/reverseproxy/service/manager/l4_port_test.go b/management/internals/modules/reverseproxy/service/manager/l4_port_test.go index c7a61ddcf..8b652c7e1 100644 --- a/management/internals/modules/reverseproxy/service/manager/l4_port_test.go +++ b/management/internals/modules/reverseproxy/service/manager/l4_port_test.go @@ -76,6 +76,7 @@ func setupL4Test(t *testing.T, customPortsSupported *bool) (*Manager, store.Stor mockCtrl := proxy.NewMockController(ctrl) 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().GetOIDCValidationConfig().Return(proxy.OIDCValidationConfig{}).AnyTimes() diff --git a/management/internals/modules/reverseproxy/service/manager/manager.go b/management/internals/modules/reverseproxy/service/manager/manager.go index a7173a131..808fdaf60 100644 --- a/management/internals/modules/reverseproxy/service/manager/manager.go +++ b/management/internals/modules/reverseproxy/service/manager/manager.go @@ -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) } service.ProxyCluster = proxyCluster + + if err := m.validateSubdomainRequirement(service.Domain, proxyCluster); err != nil { + return err + } } service.AccountID = accountID @@ -261,6 +265,20 @@ func (m *Manager) initializeServiceForCreate(ctx context.Context, accountID stri 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 { return m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { if svc.Domain != "" { @@ -489,53 +507,61 @@ func (m *Manager) persistServiceUpdate(ctx context.Context, accountID string, se var updateInfo serviceUpdateInfo err := m.store.ExecuteInTransaction(ctx, func(transaction store.Store) 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 - } - - 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 m.executeServiceUpdate(ctx, transaction, accountID, service, &updateInfo) }) 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 { if err := m.checkDomainAvailable(ctx, transaction, svc.Domain, svc.ID); err != nil { return err diff --git a/management/internals/modules/reverseproxy/service/manager/manager_test.go b/management/internals/modules/reverseproxy/service/manager/manager_test.go index 0c34f81a2..18e1be26e 100644 --- a/management/internals/modules/reverseproxy/service/manager/manager_test.go +++ b/management/internals/modules/reverseproxy/service/manager/manager_test.go @@ -1272,3 +1272,69 @@ func TestValidateTargetReferences_PeerValid(t *testing.T) { } 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) + } + }) + } +} diff --git a/management/internals/shared/grpc/proxy.go b/management/internals/shared/grpc/proxy.go index 01c52b138..5fa382af0 100644 --- a/management/internals/shared/grpc/proxy.go +++ b/management/internals/shared/grpc/proxy.go @@ -537,6 +537,35 @@ func (s *ProxyServiceServer) ClusterSupportsCustomPorts(clusterAddr string) *boo 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) { service, err := s.serviceManager.GetServiceByID(ctx, req.GetAccountId(), req.GetId()) if err != nil { diff --git a/management/internals/shared/grpc/proxy_test.go b/management/internals/shared/grpc/proxy_test.go index 1a4ea3330..83c99020d 100644 --- a/management/internals/shared/grpc/proxy_test.go +++ b/management/internals/shared/grpc/proxy_test.go @@ -57,6 +57,10 @@ func (c *testProxyController) ClusterSupportsCustomPorts(_ string) *bool { return ptr(true) } +func (c *testProxyController) ClusterRequireSubdomain(_ string) *bool { + return nil +} + func (c *testProxyController) GetProxiesForCluster(clusterAddr string) []string { c.mu.Lock() defer c.mu.Unlock() diff --git a/proxy/cmd/proxy/cmd/root.go b/proxy/cmd/proxy/cmd/root.go index a2252cc20..1c36ee334 100644 --- a/proxy/cmd/proxy/cmd/root.go +++ b/proxy/cmd/proxy/cmd/root.go @@ -62,6 +62,7 @@ var ( proxyProtocol bool preSharedKey string supportsCustomPorts bool + requireSubdomain bool 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().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(&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(&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)") @@ -181,6 +183,7 @@ func runServer(cmd *cobra.Command, args []string) error { ProxyProtocol: proxyProtocol, PreSharedKey: preSharedKey, SupportsCustomPorts: supportsCustomPorts, + RequireSubdomain: requireSubdomain, MaxDialTimeout: maxDialTimeout, MaxSessionIdleTimeout: maxSessionIdleTimeout, GeoDataDir: geoDataDir, diff --git a/proxy/management_integration_test.go b/proxy/management_integration_test.go index b3f0b2989..c30234b5a 100644 --- a/proxy/management_integration_test.go +++ b/proxy/management_integration_test.go @@ -251,6 +251,10 @@ func (c *testProxyController) ClusterSupportsCustomPorts(_ string) *bool { return nil } +func (c *testProxyController) ClusterRequireSubdomain(_ string) *bool { + return nil +} + // storeBackedServiceManager reads directly from the real store. type storeBackedServiceManager struct { store store.Store diff --git a/proxy/server.go b/proxy/server.go index c4d12859b..acfe3c12d 100644 --- a/proxy/server.go +++ b/proxy/server.go @@ -163,6 +163,10 @@ type Server struct { // SupportsCustomPorts indicates whether the proxy can bind arbitrary // ports for TCP/UDP/TLS services. 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. // 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. @@ -919,6 +923,7 @@ func (s *Server) newManagementMappingWorker(ctx context.Context, client proto.Pr Address: s.ProxyURL, Capabilities: &proto.ProxyCapabilities{ SupportsCustomPorts: &s.SupportsCustomPorts, + RequireSubdomain: &s.RequireSubdomain, }, }) if err != nil { diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index 66f39b92f..6b766731c 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -3336,6 +3336,10 @@ components: type: boolean description: Whether the cluster supports binding arbitrary TCP/UDP ports 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: - id - domain diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index 693449d14..067cc4093 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -3406,6 +3406,9 @@ type ReverseProxyDomain struct { // Id Domain 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 *bool `json:"supports_custom_ports,omitempty"` diff --git a/shared/management/proto/proxy_service.pb.go b/shared/management/proto/proxy_service.pb.go index e5a2d6a98..93295e857 100644 --- a/shared/management/proto/proxy_service.pb.go +++ b/shared/management/proto/proxy_service.pb.go @@ -181,8 +181,11 @@ type ProxyCapabilities struct { state protoimpl.MessageState `protogen:"open.v1"` // 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"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Whether the proxy requires a subdomain label in front of its cluster domain. + // 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() { @@ -222,6 +225,13 @@ func (x *ProxyCapabilities) GetSupportsCustomPorts() bool { 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. type GetMappingUpdateRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1872,10 +1882,12 @@ var File_proxy_service_proto protoreflect.FileDescriptor const file_proxy_service_proto_rawDesc = "" + "\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" + - "\x15supports_custom_ports\x18\x01 \x01(\bH\x00R\x13supportsCustomPorts\x88\x01\x01B\x18\n" + - "\x16_supports_custom_ports\"\xe6\x01\n" + + "\x15supports_custom_ports\x18\x01 \x01(\bH\x00R\x13supportsCustomPorts\x88\x01\x01\x120\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" + "\bproxy_id\x18\x01 \x01(\tR\aproxyId\x12\x18\n" + "\aversion\x18\x02 \x01(\tR\aversion\x129\n" + diff --git a/shared/management/proto/proxy_service.proto b/shared/management/proto/proxy_service.proto index 2d7bed548..f77071eb0 100644 --- a/shared/management/proto/proxy_service.proto +++ b/shared/management/proto/proxy_service.proto @@ -31,6 +31,9 @@ service ProxyService { message ProxyCapabilities { // Whether the proxy can bind arbitrary ports for TCP/UDP/TLS services. 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.