[management,proxy] Enforce custom domain validation and scope claims per account

A custom domain row was created and bound to a live service whether or not
its CNAME ownership check ever succeeded. Validated was computed once, stored,
and read only by the dashboard listing, so an account that never proved DNS
control still had its hostname routed and a certificate ordered for it. The
domain string was also claimed globally, with no expiry, so an abandoned
unvalidated row blocked that name for every other account permanently.

Cluster derivation now matches a custom domain only when it is validated, and
reports "domain is not validated" so the API tells the caller what to fix.
Service updates no longer fall back to the previously derived cluster when
derivation fails, which was a way around the same check.

Claims carry a claimed_at date and are evicted after 24h without validation.
Uniqueness is now per (account_id, domain); the global constraint is dropped
and replaced by a manager-side rule that only one account may hold a validated
row for a name. That matches what the check actually proves: control of the
zone, not the identity of the requester, so the first account to validate owns
the name and a later one is refused. Creation pre-checks both cases and returns
AlreadyExists without naming the holding account.

An hourly job re-checks validated domains and, after three consecutive failed
lookups, marks them unvalidated and restarts their claim window, so a domain
whose DNS moved away stops being served and eventually frees the name. Custom
domains are capped per account because each one becomes an ACME order against
issuance rate limits shared by every account on the cluster.

ProxyMapping carries the verdict and the proxy refuses to set up routes, auth,
or certificate management without it, so the domain cannot be served if the
management-side gate is ever regressed. This makes management the required
first upgrade: a proxy on this version drops mappings from an older management
that does not set the field.

The migration drops the old constraint before AutoMigrate (dropping it rebuilds
the table on SQLite, discarding its indexes) and backfills claimed_at from the
upgrade time, so pre-existing unvalidated rows get a full window to validate
before they expire.
This commit is contained in:
mlsmaycon
2026-08-08 04:47:49 +00:00
parent 6526fc2bec
commit 0f777152d9
34 changed files with 1800 additions and 503 deletions
@@ -1,5 +1,7 @@
package domain
import "time"
type Type string
const (
@@ -8,12 +10,18 @@ const (
)
type Domain struct {
ID string `gorm:"unique;primaryKey;autoIncrement"`
Domain string `gorm:"unique"` // Domain records must be unique, this avoids domain reuse across accounts.
AccountID string `gorm:"index"`
ID string `gorm:"unique;primaryKey;autoIncrement"`
// A claim is unique per account only. Global uniqueness applies once a row
// is validated and is enforced by the manager, because proving control of
// the DNS zone is what decides which account may serve the hostname.
Domain string `gorm:"uniqueIndex:idx_domains_account_id_domain,priority:2"`
AccountID string `gorm:"index;uniqueIndex:idx_domains_account_id_domain,priority:1"`
TargetCluster string // The proxy cluster this domain should be validated against
Type Type `gorm:"-"`
Validated bool
// ClaimedAt is when the account last staked its claim on the domain. It
// bounds how long an unvalidated row may hold the name.
ClaimedAt time.Time
// SupportsCustomPorts is populated at query time for free domains from the
// proxy cluster capabilities. Not persisted.
SupportsCustomPorts *bool `gorm:"-"`
@@ -66,8 +66,8 @@ func TestExtractClusterFromFreeDomain(t *testing.T) {
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"},
{Domain: "example.com", TargetCluster: "eu1.proxy.netbird.io", Validated: true},
{Domain: "proxy.corp.io", TargetCluster: "us1.proxy.netbird.io", Validated: true},
}
tests := []struct {
@@ -120,19 +120,50 @@ func TestExtractClusterFromCustomDomains(t *testing.T) {
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)
cluster, match := extractClusterFromCustomDomains(tc.domain, customDomains)
if !tc.wantOK {
assert.Equal(t, customDomainNoMatch, match, "unrelated domain should not match any custom domain")
return
}
assert.Equal(t, customDomainValidated, match, "validated custom domain should resolve a cluster")
assert.Equal(t, tc.wantVal, cluster)
})
}
}
// An unvalidated row must never yield a cluster: the account has not proven it
// controls the name, so nothing may be routed for it.
func TestExtractClusterFromCustomDomains_UnvalidatedDomainRefused(t *testing.T) {
customDomains := []*domain.Domain{
{Domain: "example.com", TargetCluster: "eu1.proxy.netbird.io", Validated: false},
}
for _, serviceDomain := range []string{"example.com", "app.example.com"} {
t.Run(serviceDomain, func(t *testing.T) {
cluster, match := extractClusterFromCustomDomains(serviceDomain, customDomains)
assert.Equal(t, customDomainUnvalidated, match, "unvalidated row must be reported as such")
assert.Empty(t, cluster, "unvalidated row must not resolve a cluster")
})
}
}
// A more specific unvalidated row must not shadow a validated parent domain the
// account already proved it owns.
func TestExtractClusterFromCustomDomains_ValidatedParentWinsOverUnvalidatedChild(t *testing.T) {
customDomains := []*domain.Domain{
{Domain: "example.com", TargetCluster: "cluster-generic", Validated: true},
{Domain: "app.example.com", TargetCluster: "cluster-app", Validated: false},
}
cluster, match := extractClusterFromCustomDomains("app.example.com", customDomains)
assert.Equal(t, customDomainValidated, match)
assert.Equal(t, "cluster-generic", cluster, "validated parent domain should provide the cluster")
}
func TestExtractClusterFromCustomDomains_OverlappingDomains(t *testing.T) {
customDomains := []*domain.Domain{
{Domain: "example.com", TargetCluster: "cluster-generic"},
{Domain: "app.example.com", TargetCluster: "cluster-app"},
{Domain: "example.com", TargetCluster: "cluster-generic", Validated: true},
{Domain: "app.example.com", TargetCluster: "cluster-app", Validated: true},
}
tests := []struct {
@@ -164,8 +195,8 @@ func TestExtractClusterFromCustomDomains_OverlappingDomains(t *testing.T) {
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cluster, ok := extractClusterFromCustomDomains(tc.domain, customDomains)
assert.True(t, ok)
cluster, match := extractClusterFromCustomDomains(tc.domain, customDomains)
assert.Equal(t, customDomainValidated, match)
assert.Equal(t, tc.wantVal, cluster)
})
}
@@ -5,6 +5,8 @@ import (
"fmt"
"net"
"strings"
"sync"
"time"
log "github.com/sirupsen/logrus"
@@ -18,13 +20,36 @@ import (
"github.com/netbirdio/netbird/shared/management/status"
)
const (
// claimTTL bounds how long a domain may be held by a row that has never
// reached Validated. Past it the claim is evicted and the name is free again.
claimTTL = 24 * time.Hour
// maxCustomDomainsPerAccount caps how many custom domains an account may
// hold. Every domain turns into a certificate order against the cluster's
// ACME issuance rate limit, which is shared by every account on it.
maxCustomDomainsPerAccount = 100
// maintenanceInterval is how often claims are expired and validated
// domains are re-checked against their target cluster.
maintenanceInterval = time.Hour
// revalidationFailureThreshold is how many consecutive failed re-checks a
// validated domain must accumulate before it is flipped back. A single
// failed lookup is far more often a transient resolver problem than a
// deliberate DNS change.
revalidationFailureThreshold = 3
)
type store interface {
GetAccount(ctx context.Context, accountID string) (*types.Account, error)
GetCustomDomain(ctx context.Context, accountID string, domainID string) (*domain.Domain, error)
ListFreeDomains(ctx context.Context, accountID string) ([]string, error)
ListCustomDomains(ctx context.Context, accountID string) ([]*domain.Domain, error)
CreateCustomDomain(ctx context.Context, accountID string, domainName string, targetCluster string, validated bool) (*domain.Domain, error)
ListCustomDomainsByName(ctx context.Context, domainName string) ([]*domain.Domain, error)
ListAllCustomDomains(ctx context.Context) ([]*domain.Domain, error)
CreateCustomDomain(ctx context.Context, accountID string, domainName string, targetCluster string, validated bool, claimedAt time.Time) (*domain.Domain, error)
UpdateCustomDomain(ctx context.Context, accountID string, d *domain.Domain) (*domain.Domain, error)
DeleteCustomDomain(ctx context.Context, accountID string, domainID string) error
}
@@ -38,12 +63,42 @@ type proxyManager interface {
ClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool
}
// failureTracker counts consecutive revalidation failures per domain ID. It is
// held by pointer so copies of the value-typed Manager share one instance.
type failureTracker struct {
mu sync.Mutex
counts map[string]int
}
// record increments the failure count for a domain and returns the new total.
func (f *failureTracker) record(domainID string) int {
if f == nil {
return 1
}
f.mu.Lock()
defer f.mu.Unlock()
f.counts[domainID]++
return f.counts[domainID]
}
// clear forgets the failure history of a domain.
func (f *failureTracker) clear(domainID string) {
if f == nil {
return
}
f.mu.Lock()
defer f.mu.Unlock()
delete(f.counts, domainID)
}
type Manager struct {
store store
validator domain.Validator
proxyManager proxyManager
permissionsManager permissions.Manager
accountManager account.Manager
now func() time.Time
revalidationFails *failureTracker
}
func NewManager(store store, proxyMgr proxyManager, permissionsManager permissions.Manager, accountManager account.Manager) Manager {
@@ -53,9 +108,20 @@ func NewManager(store store, proxyMgr proxyManager, permissionsManager permissio
validator: domain.Validator{Resolver: net.DefaultResolver},
permissionsManager: permissionsManager,
accountManager: accountManager,
now: time.Now,
revalidationFails: &failureTracker{counts: make(map[string]int)},
}
}
// currentTime returns the manager's clock, tolerating a zero-valued Manager
// built directly in tests.
func (m Manager) currentTime() time.Time {
if m.now == nil {
return time.Now()
}
return m.now().UTC()
}
func (m Manager) GetDomains(ctx context.Context, accountID, userID string) ([]*domain.Domain, error) {
ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read)
if err != nil {
@@ -146,15 +212,23 @@ func (m Manager) CreateDomain(ctx context.Context, accountID, userID, domainName
return nil, fmt.Errorf("target cluster %s is not available", targetCluster)
}
if err := m.checkAccountQuota(ctx, accountID, domainName); err != nil {
return nil, err
}
if err := m.checkDomainClaimable(ctx, accountID, domainName); err != nil {
return nil, err
}
// Attempt an initial validation against the specified cluster only
var validated bool
if m.validator.IsValid(ctx, domainName, []string{targetCluster}) {
validated = true
}
d, err := m.store.CreateCustomDomain(ctx, accountID, domainName, targetCluster, validated)
d, err := m.store.CreateCustomDomain(ctx, accountID, domainName, targetCluster, validated, m.currentTime())
if err != nil {
return d, fmt.Errorf("create domain in store: %w", err)
return nil, fmt.Errorf("create domain in store: %w", err)
}
m.accountManager.StoreEvent(ctx, userID, d.ID, accountID, activity.DomainAdded, d.EventMeta())
@@ -162,6 +236,46 @@ func (m Manager) CreateDomain(ctx context.Context, accountID, userID, domainName
return d, nil
}
// checkAccountQuota rejects a new claim once the account already holds the
// maximum number of custom domains, or already holds this one.
func (m Manager) checkAccountQuota(ctx context.Context, accountID, domainName string) error {
owned, err := m.store.ListCustomDomains(ctx, accountID)
if err != nil {
return fmt.Errorf("list custom domains: %w", err)
}
for _, d := range owned {
if d.Domain == domainName {
return status.Errorf(status.AlreadyExists, "domain %s is already registered", domainName)
}
}
if len(owned) >= maxCustomDomainsPerAccount {
return status.Errorf(status.PreconditionFailed, "account has reached the maximum of %d custom domains", maxCustomDomainsPerAccount)
}
return nil
}
// checkDomainClaimable rejects a claim on a domain another account already
// serves. Unvalidated claims by other accounts do not block: proving control of
// the DNS zone is what settles ownership, and until then nobody has. The error
// deliberately says nothing about who holds the domain.
func (m Manager) checkDomainClaimable(ctx context.Context, accountID, domainName string) error {
holders, err := m.store.ListCustomDomainsByName(ctx, domainName)
if err != nil {
return fmt.Errorf("list domain holders: %w", err)
}
for _, d := range holders {
if d.AccountID != accountID && d.Validated {
return status.Errorf(status.AlreadyExists, "domain %s is already in use", domainName)
}
}
return nil
}
func (m Manager) DeleteDomain(ctx context.Context, accountID, userID, domainID string) error {
ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Delete)
if err != nil {
@@ -199,7 +313,8 @@ func (m Manager) ValidateDomain(ctx context.Context, accountID, userID, domainID
log.WithFields(log.Fields{
"accountID": accountID,
"domainID": domainID,
}).WithError(err).Error("validate domain")
}).Error("validate domain: permission denied")
return
}
log.WithFields(log.Fields{
@@ -234,31 +349,46 @@ func (m Manager) ValidateDomain(ctx context.Context, accountID, userID, domainID
"targetCluster": targetCluster,
}).Info("validating domain against target cluster")
if m.validator.IsValid(context.Background(), d.Domain, []string{targetCluster}) {
log.WithFields(log.Fields{
"accountID": accountID,
"domainID": domainID,
"domain": d.Domain,
}).Info("domain validated successfully")
d.Validated = true
if _, err := m.store.UpdateCustomDomain(context.Background(), accountID, d); err != nil {
log.WithFields(log.Fields{
"accountID": accountID,
"domainID": domainID,
"domain": d.Domain,
}).WithError(err).Error("update custom domain in store")
return
}
m.accountManager.StoreEvent(context.Background(), userID, domainID, accountID, activity.DomainValidated, d.EventMeta())
} else {
if !m.validator.IsValid(context.Background(), d.Domain, []string{targetCluster}) {
log.WithFields(log.Fields{
"accountID": accountID,
"domainID": domainID,
"domain": d.Domain,
"targetCluster": targetCluster,
}).Warn("domain validation failed - CNAME does not match target cluster")
return
}
// The CNAME proves control of the zone but says nothing about which account
// asked, so an account that is late to validate a name another one already
// serves must not take it over.
if err := m.checkDomainClaimable(context.Background(), accountID, d.Domain); err != nil {
log.WithFields(log.Fields{
"accountID": accountID,
"domainID": domainID,
"domain": d.Domain,
}).WithError(err).Warn("domain validation refused")
return
}
log.WithFields(log.Fields{
"accountID": accountID,
"domainID": domainID,
"domain": d.Domain,
}).Info("domain validated successfully")
d.Validated = true
if _, err := m.store.UpdateCustomDomain(context.Background(), accountID, d); err != nil {
log.WithFields(log.Fields{
"accountID": accountID,
"domainID": domainID,
"domain": d.Domain,
}).WithError(err).Error("update custom domain in store")
return
}
m.revalidationFails.clear(domainID)
m.accountManager.StoreEvent(context.Background(), userID, domainID, accountID, activity.DomainValidated, d.EventMeta())
}
// GetClusterDomains returns a list of proxy cluster domains.
@@ -294,14 +424,31 @@ func (m Manager) DeriveClusterFromDomain(ctx context.Context, accountID, domain
return "", fmt.Errorf("list custom domains: %w", err)
}
targetCluster, valid := extractClusterFromCustomDomains(domain, customDomains)
if valid {
targetCluster, match := extractClusterFromCustomDomains(domain, customDomains)
switch match {
case customDomainValidated:
return targetCluster, nil
case customDomainUnvalidated:
return "", status.Errorf(status.PreconditionFailed, "domain %s is not validated", domain)
}
return "", fmt.Errorf("domain %s does not match any available proxy cluster", domain)
}
// IsDomainValidated reports whether the account is currently allowed to serve
// the domain: free cluster domains always are, custom domains only once their
// CNAME validation has succeeded. Any lookup failure denies.
func (m Manager) IsDomainValidated(ctx context.Context, accountID, domain string) bool {
if _, err := m.DeriveClusterFromDomain(ctx, accountID, domain); err != nil {
log.WithContext(ctx).WithFields(log.Fields{
"accountID": accountID,
"domain": domain,
}).WithError(err).Debug("domain is not servable")
return false
}
return true
}
func (m Manager) getClusterAllowList(ctx context.Context, accountID string) ([]string, error) {
byopAddresses, err := m.proxyManager.GetActiveClusterAddressesForAccount(ctx, accountID)
if err != nil {
@@ -330,19 +477,158 @@ func (m Manager) getClusterAllowList(ctx context.Context, accountID string) ([]s
return merged, nil
}
func extractClusterFromCustomDomains(serviceDomain string, customDomains []*domain.Domain) (string, bool) {
// customDomainMatch describes how a service domain relates to the account's
// custom domain rows.
type customDomainMatch int
const (
customDomainNoMatch customDomainMatch = iota
customDomainUnvalidated
customDomainValidated
)
// extractClusterFromCustomDomains finds the longest custom domain covering the
// service domain and reports its target cluster. Only a validated row yields a
// cluster: a row whose CNAME check has never succeeded proves no control over
// the name, so serving traffic for it would route a hostname the account has
// not shown it owns.
func extractClusterFromCustomDomains(serviceDomain string, customDomains []*domain.Domain) (string, customDomainMatch) {
bestCluster := ""
bestLen := -1
matched := false
for _, cd := range customDomains {
if serviceDomain != cd.Domain && !strings.HasSuffix(serviceDomain, "."+cd.Domain) {
continue
}
matched = true
if !cd.Validated {
continue
}
if l := len(cd.Domain); l > bestLen {
bestLen = l
bestCluster = cd.TargetCluster
}
}
return bestCluster, bestLen >= 0
switch {
case bestLen >= 0:
return bestCluster, customDomainValidated
case matched:
return "", customDomainUnvalidated
default:
return "", customDomainNoMatch
}
}
// StartMaintenance runs claim expiry and revalidation until the context is
// cancelled. A first pass runs immediately so a restart does not leave stale
// claims parked for a whole interval.
func (m Manager) StartMaintenance(ctx context.Context) {
go func() {
ticker := time.NewTicker(maintenanceInterval)
defer ticker.Stop()
m.RunMaintenance(ctx)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
m.RunMaintenance(ctx)
}
}
}()
}
// RunMaintenance evicts expired claims and re-checks validated domains once.
func (m Manager) RunMaintenance(ctx context.Context) {
domains, err := m.store.ListAllCustomDomains(ctx)
if err != nil {
log.WithContext(ctx).WithError(err).Error("list custom domains for maintenance")
return
}
for _, d := range domains {
if !d.Validated {
m.evictExpiredClaim(ctx, d)
continue
}
m.revalidate(ctx, d)
}
}
// evictExpiredClaim deletes an unvalidated row that has held its name for
// longer than claimTTL. A row with no claim date is left alone: it predates the
// column and the migration backfill dates it from the upgrade.
func (m Manager) evictExpiredClaim(ctx context.Context, d *domain.Domain) {
if d.ClaimedAt.IsZero() || m.currentTime().Sub(d.ClaimedAt) <= claimTTL {
return
}
if err := m.store.DeleteCustomDomain(ctx, d.AccountID, d.ID); err != nil {
log.WithContext(ctx).WithFields(log.Fields{
"accountID": d.AccountID,
"domainID": d.ID,
"domain": d.Domain,
}).WithError(err).Error("evict expired domain claim")
return
}
m.revalidationFails.clear(d.ID)
log.WithContext(ctx).WithFields(log.Fields{
"accountID": d.AccountID,
"domainID": d.ID,
"domain": d.Domain,
}).Info("evicted domain claim that was never validated")
m.accountManager.StoreEvent(ctx, activity.SystemInitiator, d.ID, d.AccountID, activity.DomainDeleted, d.EventMeta())
}
// revalidate re-checks a validated domain against its target cluster and, once
// the check has failed revalidationFailureThreshold times in a row, marks it
// unvalidated and restarts its claim window. Without this an account that moves
// its DNS elsewhere keeps the name reserved forever.
func (m Manager) revalidate(ctx context.Context, d *domain.Domain) {
if d.TargetCluster == "" {
return
}
if m.validator.IsValid(ctx, d.Domain, []string{d.TargetCluster}) {
m.revalidationFails.clear(d.ID)
return
}
failures := m.revalidationFails.record(d.ID)
if failures < revalidationFailureThreshold {
log.WithContext(ctx).WithFields(log.Fields{
"accountID": d.AccountID,
"domainID": d.ID,
"domain": d.Domain,
"failures": failures,
}).Warn("domain revalidation failed, waiting for confirmation")
return
}
d.Validated = false
d.ClaimedAt = m.currentTime()
if _, err := m.store.UpdateCustomDomain(ctx, d.AccountID, d); err != nil {
log.WithContext(ctx).WithFields(log.Fields{
"accountID": d.AccountID,
"domainID": d.ID,
"domain": d.Domain,
}).WithError(err).Error("mark domain unvalidated after revalidation failure")
return
}
m.revalidationFails.clear(d.ID)
log.WithContext(ctx).WithFields(log.Fields{
"accountID": d.AccountID,
"domainID": d.ID,
"domain": d.Domain,
"targetCluster": d.TargetCluster,
}).Warn("domain marked unvalidated, CNAME no longer points at the target cluster")
m.accountManager.StoreEvent(ctx, activity.SystemInitiator, d.ID, d.AccountID, activity.DomainInvalidated, d.EventMeta())
}
// ExtractClusterFromFreeDomain extracts the cluster address from a free domain.
@@ -0,0 +1,407 @@
package manager
import (
"context"
"fmt"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel/metric/noop"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
proxymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy/manager"
"github.com/netbirdio/netbird/management/server/activity"
"github.com/netbirdio/netbird/management/server/mock_server"
"github.com/netbirdio/netbird/management/server/permissions"
nbstore "github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/shared/management/status"
)
const (
testCluster = "eu.proxy.test"
accountA = "account-a"
accountAUser = "account-a-admin"
accountB = "account-b"
accountBUser = "account-b-admin"
)
// stubResolver answers CNAME lookups from a table the test controls, so DNS
// state can change between calls without touching a real resolver.
type stubResolver struct {
mu sync.Mutex
cnames map[string]string
}
func (r *stubResolver) LookupCNAME(_ context.Context, host string) (string, error) {
r.mu.Lock()
defer r.mu.Unlock()
cname, ok := r.cnames[host]
if !ok {
return "", fmt.Errorf("lookup %s: no such host", host)
}
return cname + ".", nil
}
func (r *stubResolver) set(host, cname string) {
r.mu.Lock()
defer r.mu.Unlock()
r.cnames[host] = cname
}
func (r *stubResolver) remove(host string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.cnames, host)
}
// fakeClock drives claim expiry without sleeping.
type fakeClock struct {
mu sync.Mutex
now time.Time
}
func (c *fakeClock) Now() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.now
}
func (c *fakeClock) advance(d time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
c.now = c.now.Add(d)
}
type domainTestEnv struct {
manager Manager
store nbstore.Store
resolver *stubResolver
clock *fakeClock
}
// setupDomainTest builds the domain manager on a real SQLite store with two
// accounts and one active public proxy cluster.
func setupDomainTest(t *testing.T) *domainTestEnv {
t.Helper()
ctx := context.Background()
testStore, cleanup, err := nbstore.NewTestStoreFromSQL(ctx, "", t.TempDir())
require.NoError(t, err)
t.Cleanup(cleanup)
for accountID, userID := range map[string]string{accountA: accountAUser, accountB: accountBUser} {
require.NoError(t, testStore.SaveAccount(ctx, &types.Account{
Id: accountID,
CreatedBy: userID,
Settings: &types.Settings{},
Users: map[string]*types.User{
userID: {
Id: userID,
AccountID: accountID,
Role: types.UserRoleAdmin,
},
},
}))
}
proxyMgr, err := proxymanager.NewManager(testStore, noop.NewMeterProvider().Meter(""))
require.NoError(t, err)
_, err = proxyMgr.Connect(ctx, "proxy-1", "session-1", testCluster, "127.0.0.1", nil, nil)
require.NoError(t, err)
resolver := &stubResolver{cnames: make(map[string]string)}
clock := &fakeClock{now: time.Now().UTC()}
mgr := Manager{
store: testStore,
proxyManager: proxyMgr,
validator: domain.Validator{Resolver: resolver},
permissionsManager: permissions.NewManager(testStore),
accountManager: &mock_server.MockAccountManager{
StoreEventFunc: func(context.Context, string, string, string, activity.ActivityDescriber, map[string]any) {},
},
now: clock.Now,
revalidationFails: &failureTracker{counts: make(map[string]int)},
}
return &domainTestEnv{manager: mgr, store: testStore, resolver: resolver, clock: clock}
}
// storedDomain reads a domain row back through the store so assertions are made
// on what was persisted rather than on the value the manager returned.
func storedDomain(t *testing.T, s nbstore.Store, accountID, domainName string) *domain.Domain {
t.Helper()
domains, err := s.ListCustomDomains(context.Background(), accountID)
require.NoError(t, err)
for _, d := range domains {
if d.Domain == domainName {
return d
}
}
return nil
}
// A domain whose CNAME check fails must be stored unvalidated and must not be
// usable to derive a proxy cluster, which is what service creation gates on.
func TestCreateDomain_FailedLookupIsNotServable(t *testing.T) {
ctx := context.Background()
env := setupDomainTest(t)
created, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "apps.example.com", testCluster)
require.NoError(t, err)
assert.False(t, created.Validated, "a domain whose CNAME lookup fails must not be created validated")
stored := storedDomain(t, env.store, accountA, "apps.example.com")
require.NotNil(t, stored, "domain row should exist")
assert.False(t, stored.Validated, "persisted row must be unvalidated")
assert.False(t, stored.ClaimedAt.IsZero(), "claim must be dated so it can expire")
cluster, err := env.manager.DeriveClusterFromDomain(ctx, accountA, "apps.example.com")
require.Error(t, err, "an unvalidated domain must not resolve a cluster")
assert.Empty(t, cluster)
assert.Contains(t, err.Error(), "not validated", "error should tell the caller what to fix")
sErr, ok := status.FromError(err)
require.True(t, ok, "error should be a typed status error")
assert.Equal(t, status.PreconditionFailed, sErr.Type())
assert.False(t, env.manager.IsDomainValidated(ctx, accountA, "apps.example.com"))
assert.False(t, env.manager.IsDomainValidated(ctx, accountA, "sub.apps.example.com"),
"subdomains of an unvalidated custom domain are not servable either")
}
// Two accounts may both request a name nobody has proven they own. The second
// request must be an ordinary success, never a database-level failure.
func TestCreateDomain_UnvalidatedClaimDoesNotBlockOtherAccounts(t *testing.T) {
ctx := context.Background()
env := setupDomainTest(t)
_, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "shared.example.com", testCluster)
require.NoError(t, err)
second, err := env.manager.CreateDomain(ctx, accountB, accountBUser, "shared.example.com", testCluster)
require.NoError(t, err, "an unvalidated claim by another account must not block the request")
assert.False(t, second.Validated)
assert.NotNil(t, storedDomain(t, env.store, accountA, "shared.example.com"))
assert.NotNil(t, storedDomain(t, env.store, accountB, "shared.example.com"))
}
// Once an account serves a name, another account asking for it gets a clean
// conflict that says nothing about who holds it.
func TestCreateDomain_ValidatedDomainConflictsForOtherAccounts(t *testing.T) {
ctx := context.Background()
env := setupDomainTest(t)
env.resolver.set("validation.owned.example.com", testCluster)
first, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "owned.example.com", testCluster)
require.NoError(t, err)
require.True(t, first.Validated, "matching CNAME should validate on create")
_, err = env.manager.CreateDomain(ctx, accountB, accountBUser, "owned.example.com", testCluster)
require.Error(t, err)
sErr, ok := status.FromError(err)
require.True(t, ok, "conflict must be a typed status error, not a raw database error")
assert.Equal(t, status.AlreadyExists, sErr.Type(), "conflict should map to 409, not 500")
assert.NotContains(t, sErr.Message, accountA, "the response must not reveal the holding account")
assert.Nil(t, storedDomain(t, env.store, accountB, "owned.example.com"), "no row should be written on conflict")
}
// Re-requesting a domain the account already holds is a conflict too.
func TestCreateDomain_SameAccountDuplicateConflicts(t *testing.T) {
ctx := context.Background()
env := setupDomainTest(t)
_, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "dup.example.com", testCluster)
require.NoError(t, err)
_, err = env.manager.CreateDomain(ctx, accountA, accountAUser, "dup.example.com", testCluster)
require.Error(t, err)
sErr, ok := status.FromError(err)
require.True(t, ok)
assert.Equal(t, status.AlreadyExists, sErr.Type())
}
// The negative control: a domain that genuinely validates still derives its
// cluster exactly as before.
func TestCreateDomain_ValidatedDomainDerivesCluster(t *testing.T) {
ctx := context.Background()
env := setupDomainTest(t)
env.resolver.set("validation.valid.example.com", testCluster)
created, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "valid.example.com", testCluster)
require.NoError(t, err)
require.True(t, created.Validated)
cluster, err := env.manager.DeriveClusterFromDomain(ctx, accountA, "valid.example.com")
require.NoError(t, err)
assert.Equal(t, testCluster, cluster)
cluster, err = env.manager.DeriveClusterFromDomain(ctx, accountA, "app.valid.example.com")
require.NoError(t, err, "subdomains of a validated custom domain resolve too")
assert.Equal(t, testCluster, cluster)
assert.True(t, env.manager.IsDomainValidated(ctx, accountA, "valid.example.com"))
}
// Free cluster domains never need a custom domain row.
func TestIsDomainValidated_FreeDomain(t *testing.T) {
ctx := context.Background()
env := setupDomainTest(t)
assert.True(t, env.manager.IsDomainValidated(ctx, accountA, "myapp.abc123."+testCluster))
assert.False(t, env.manager.IsDomainValidated(ctx, accountA, "myapp.unknown.test"))
}
// An account that never proves control loses the name after claimTTL, freeing
// it instead of parking it forever.
func TestRunMaintenance_EvictsExpiredClaim(t *testing.T) {
ctx := context.Background()
env := setupDomainTest(t)
_, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "stale.example.com", testCluster)
require.NoError(t, err)
env.clock.advance(claimTTL - time.Minute)
env.manager.RunMaintenance(ctx)
assert.NotNil(t, storedDomain(t, env.store, accountA, "stale.example.com"),
"claim must survive while it is still inside the window")
env.clock.advance(2 * time.Minute)
env.manager.RunMaintenance(ctx)
assert.Nil(t, storedDomain(t, env.store, accountA, "stale.example.com"),
"claim must be evicted once it has outlived the window")
}
// A validated domain is never evicted, however old the claim.
func TestRunMaintenance_KeepsValidatedDomain(t *testing.T) {
ctx := context.Background()
env := setupDomainTest(t)
env.resolver.set("validation.live.example.com", testCluster)
_, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "live.example.com", testCluster)
require.NoError(t, err)
env.clock.advance(30 * 24 * time.Hour)
env.manager.RunMaintenance(ctx)
stored := storedDomain(t, env.store, accountA, "live.example.com")
require.NotNil(t, stored, "a validated domain must not be evicted")
assert.True(t, stored.Validated)
}
// Moving DNS away flips the domain back to unvalidated and restarts its claim
// window, so the name eventually becomes available again.
func TestRunMaintenance_RevalidationFlipsAndThenEvicts(t *testing.T) {
ctx := context.Background()
env := setupDomainTest(t)
env.resolver.set("validation.moved.example.com", testCluster)
_, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "moved.example.com", testCluster)
require.NoError(t, err)
require.True(t, storedDomain(t, env.store, accountA, "moved.example.com").Validated)
env.resolver.remove("validation.moved.example.com")
// A single failed lookup is treated as a transient resolver problem.
env.manager.RunMaintenance(ctx)
assert.True(t, storedDomain(t, env.store, accountA, "moved.example.com").Validated,
"one failed lookup must not unvalidate a live domain")
for i := 1; i < revalidationFailureThreshold; i++ {
env.manager.RunMaintenance(ctx)
}
flipped := storedDomain(t, env.store, accountA, "moved.example.com")
require.NotNil(t, flipped)
assert.False(t, flipped.Validated, "repeated failures must unvalidate the domain")
assert.Equal(t, env.clock.Now(), flipped.ClaimedAt.UTC(), "the claim window restarts on the flip")
_, err = env.manager.DeriveClusterFromDomain(ctx, accountA, "moved.example.com")
assert.Error(t, err, "an unvalidated domain stops resolving a cluster")
env.clock.advance(claimTTL + time.Minute)
env.manager.RunMaintenance(ctx)
assert.Nil(t, storedDomain(t, env.store, accountA, "moved.example.com"),
"the flipped claim expires like any other unvalidated claim")
}
// DNS coming back before the threshold clears the failure history.
func TestRunMaintenance_RecoveredLookupResetsFailures(t *testing.T) {
ctx := context.Background()
env := setupDomainTest(t)
env.resolver.set("validation.flaky.example.com", testCluster)
_, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "flaky.example.com", testCluster)
require.NoError(t, err)
env.resolver.remove("validation.flaky.example.com")
for i := 1; i < revalidationFailureThreshold; i++ {
env.manager.RunMaintenance(ctx)
}
env.resolver.set("validation.flaky.example.com", testCluster)
for i := 0; i < revalidationFailureThreshold; i++ {
env.manager.RunMaintenance(ctx)
}
stored := storedDomain(t, env.store, accountA, "flaky.example.com")
require.NotNil(t, stored)
assert.True(t, stored.Validated, "a recovered lookup must not carry stale failure counts")
}
// A validated domain another account already serves cannot be taken over, even
// when the second account's CNAME check passes: the check proves zone control,
// not who asked.
func TestValidateDomain_DoesNotStealValidatedDomain(t *testing.T) {
ctx := context.Background()
env := setupDomainTest(t)
_, err := env.manager.CreateDomain(ctx, accountB, accountBUser, "contested.example.com", testCluster)
require.NoError(t, err)
env.resolver.set("validation.contested.example.com", testCluster)
first, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "contested.example.com", testCluster)
require.NoError(t, err)
require.True(t, first.Validated)
claim := storedDomain(t, env.store, accountB, "contested.example.com")
require.NotNil(t, claim)
env.manager.ValidateDomain(ctx, accountB, accountBUser, claim.ID)
assert.False(t, storedDomain(t, env.store, accountB, "contested.example.com").Validated,
"the late claimant must not validate a name another account already serves")
assert.True(t, storedDomain(t, env.store, accountA, "contested.example.com").Validated)
}
// Domains are bounded per account: every one of them becomes an ACME order
// against an issuance rate limit shared with every other account on the cluster.
func TestCreateDomain_EnforcesPerAccountQuota(t *testing.T) {
ctx := context.Background()
env := setupDomainTest(t)
for i := 0; i < maxCustomDomainsPerAccount; i++ {
_, err := env.manager.CreateDomain(ctx, accountA, accountAUser, fmt.Sprintf("app-%d.example.com", i), testCluster)
require.NoError(t, err)
}
_, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "one-too-many.example.com", testCluster)
require.Error(t, err)
sErr, ok := status.FromError(err)
require.True(t, ok)
assert.Equal(t, status.PreconditionFailed, sErr.Type())
_, err = env.manager.CreateDomain(ctx, accountB, accountBUser, "other-account.example.com", testCluster)
assert.NoError(t, err, "the quota is per account")
}
@@ -0,0 +1,170 @@
package manager
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel/metric/noop"
domainmanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain/manager"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
proxymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy/manager"
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
"github.com/netbirdio/netbird/management/server/activity"
"github.com/netbirdio/netbird/management/server/mock_server"
"github.com/netbirdio/netbird/management/server/permissions"
"github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/shared/management/status"
)
const validationTestCluster = "eu.proxy.test"
// withRealDomainManager swaps the stub cluster deriver for the real domain
// manager backed by the same store, so service creation is gated by the actual
// domain rows rather than by a test double that always agrees.
func withRealDomainManager(t *testing.T, mgr *Manager, testStore store.Store) {
t.Helper()
ctx := context.Background()
proxyMgr, err := proxymanager.NewManager(testStore, noop.NewMeterProvider().Meter(""))
require.NoError(t, err)
_, err = proxyMgr.Connect(ctx, "proxy-1", "session-1", validationTestCluster, "127.0.0.1", nil, nil)
require.NoError(t, err)
accountMgr := &mock_server.MockAccountManager{
StoreEventFunc: func(context.Context, string, string, string, activity.ActivityDescriber, map[string]any) {},
}
domainMgr := domainmanager.NewManager(testStore, proxyMgr, permissions.NewManager(testStore), accountMgr)
mgr.clusterDeriver = domainMgr
}
func newTestService(domain string) *rpservice.Service {
return &rpservice.Service{
Name: "test-service",
Domain: domain,
Enabled: true,
Mode: rpservice.ModeHTTP,
Targets: []*rpservice.Target{{
Host: "10.0.0.1",
Port: 8080,
Protocol: "http",
TargetId: testPeerID,
TargetType: "peer",
Enabled: true,
}},
}
}
// A service must not bind to a domain the account never proved it owns, and
// nothing may be persisted for the attempt.
func TestCreateService_RefusesUnvalidatedDomain(t *testing.T) {
ctx := context.Background()
mgr, testStore := setupIntegrationTest(t)
withRealDomainManager(t, mgr, testStore)
_, err := testStore.CreateCustomDomain(ctx, testAccountID, "unproven.example.com", validationTestCluster, false, time.Now().UTC())
require.NoError(t, err)
_, err = mgr.CreateService(ctx, testAccountID, testUserID, newTestService("unproven.example.com"))
require.Error(t, err, "an unvalidated domain must not bind a service")
assert.Contains(t, err.Error(), "not validated", "the API error should name the actual problem")
sErr, ok := status.FromError(err)
require.True(t, ok, "error should be a typed status error")
assert.Equal(t, status.PreconditionFailed, sErr.Type())
services, err := testStore.GetAccountServices(ctx, store.LockingStrengthNone, testAccountID)
require.NoError(t, err)
assert.Empty(t, services, "no service row should be written for a refused domain")
}
// The negative control: a validated domain still binds a service and derives
// its cluster exactly as before.
func TestCreateService_ValidatedDomainBindsService(t *testing.T) {
ctx := context.Background()
mgr, testStore := setupIntegrationTest(t)
withRealDomainManager(t, mgr, testStore)
_, err := testStore.CreateCustomDomain(ctx, testAccountID, "proven.example.com", validationTestCluster, true, time.Now().UTC())
require.NoError(t, err)
created, err := mgr.CreateService(ctx, testAccountID, testUserID, newTestService("app.proven.example.com"))
require.NoError(t, err)
assert.Equal(t, validationTestCluster, created.ProxyCluster, "service should bind to the domain's target cluster")
services, err := testStore.GetAccountServices(ctx, store.LockingStrengthNone, testAccountID)
require.NoError(t, err)
require.Len(t, services, 1, "the service should be persisted")
assert.Equal(t, "app.proven.example.com", services[0].Domain)
}
// The mapping the proxy receives carries management's verdict, so the proxy can
// refuse the route on its own if this gate is ever regressed.
func TestProtoMapping_CarriesDomainValidation(t *testing.T) {
ctx := context.Background()
mgr, testStore := setupIntegrationTest(t)
withRealDomainManager(t, mgr, testStore)
_, err := testStore.CreateCustomDomain(ctx, testAccountID, "proven.example.com", validationTestCluster, true, time.Now().UTC())
require.NoError(t, err)
_, err = testStore.CreateCustomDomain(ctx, testAccountID, "unproven.example.com", validationTestCluster, false, time.Now().UTC())
require.NoError(t, err)
validated := newTestService("proven.example.com")
validated.AccountID = testAccountID
mapping := mgr.protoMapping(ctx, rpservice.Create, validated, proxy.OIDCValidationConfig{})
assert.True(t, mapping.GetDomainValidated(), "a validated domain should be advertised as such")
unvalidated := newTestService("unproven.example.com")
unvalidated.AccountID = testAccountID
mapping = mgr.protoMapping(ctx, rpservice.Create, unvalidated, proxy.OIDCValidationConfig{})
assert.False(t, mapping.GetDomainValidated(), "an unvalidated domain must be advertised as unvalidated")
free := newTestService("myapp.abc123." + validationTestCluster)
free.AccountID = testAccountID
mapping = mgr.protoMapping(ctx, rpservice.Create, free, proxy.OIDCValidationConfig{})
assert.True(t, mapping.GetDomainValidated(), "free cluster domains need no ownership proof")
}
// Without a cluster deriver there is nothing that can answer the ownership
// question, so the mapping must be advertised as unvalidated.
func TestProtoMapping_DeniesWithoutClusterDeriver(t *testing.T) {
mgr := &Manager{}
svc := newTestService("apps.example.com")
svc.AccountID = testAccountID
mapping := mgr.protoMapping(context.Background(), rpservice.Create, svc, proxy.OIDCValidationConfig{})
assert.False(t, mapping.GetDomainValidated())
}
// An update must not be a way around the creation gate: moving a live service
// onto an unvalidated domain has to fail rather than silently keep the old
// cluster and start serving the new hostname.
func TestUpdateService_RefusesMoveToUnvalidatedDomain(t *testing.T) {
ctx := context.Background()
mgr, testStore := setupIntegrationTest(t)
withRealDomainManager(t, mgr, testStore)
_, err := testStore.CreateCustomDomain(ctx, testAccountID, "proven.example.com", validationTestCluster, true, time.Now().UTC())
require.NoError(t, err)
_, err = testStore.CreateCustomDomain(ctx, testAccountID, "unproven.example.com", validationTestCluster, false, time.Now().UTC())
require.NoError(t, err)
created, err := mgr.CreateService(ctx, testAccountID, testUserID, newTestService("app.proven.example.com"))
require.NoError(t, err)
moved := *created
moved.Domain = "app.unproven.example.com"
_, err = mgr.UpdateService(ctx, testAccountID, testUserID, &moved)
require.Error(t, err, "moving to an unvalidated domain must fail")
assert.Contains(t, err.Error(), "not validated")
stored, err := testStore.GetServiceByID(ctx, store.LockingStrengthNone, testAccountID, created.ID)
require.NoError(t, err)
assert.Equal(t, "app.proven.example.com", stored.Domain, "the service must keep its original domain")
}
@@ -27,6 +27,7 @@ import (
"github.com/netbirdio/netbird/management/server/permissions/operations"
"github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/shared/management/proto"
"github.com/netbirdio/netbird/shared/management/status"
)
@@ -71,10 +72,12 @@ func portFromEnv(key string, fallback uint16) uint16 {
const unknownHostPlaceholder = "unknown"
// ClusterDeriver derives the proxy cluster from a domain.
// ClusterDeriver derives the proxy cluster from a domain and reports whether
// the account is allowed to serve it.
type ClusterDeriver interface {
DeriveClusterFromDomain(ctx context.Context, accountID, domain string) (string, error)
GetClusterDomains() []string
IsDomainValidated(ctx context.Context, accountID, domain string) bool
}
// CapabilityProvider queries proxy cluster capabilities from the database.
@@ -266,13 +269,28 @@ func (m *Manager) CreateService(ctx context.Context, accountID, userID string, s
return nil, fmt.Errorf("failed to replace host by lookup for service %s: %w", s.ID, err)
}
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, s.ToProtoMapping(service.Create, "", m.proxyController.GetOIDCValidationConfig()), s.ProxyCluster)
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, m.protoMapping(ctx, service.Create, s, m.proxyController.GetOIDCValidationConfig()), s.ProxyCluster)
m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceService, Operation: types.UpdateOperationCreate})
return s, nil
}
// protoMapping renders a service into the wire form the proxy consumes,
// resolving whether the account may currently serve the service's domain.
func (m *Manager) protoMapping(ctx context.Context, operation service.Operation, s *service.Service, oidcCfg proxy.OIDCValidationConfig) *proto.ProxyMapping {
return s.ToProtoMapping(operation, "", oidcCfg, m.domainValidated(ctx, s))
}
// domainValidated reports whether the service's domain may be served. Without a
// cluster deriver there is nothing that can answer the question, so it denies.
func (m *Manager) domainValidated(ctx context.Context, s *service.Service) bool {
if m.clusterDeriver == nil {
return false
}
return m.clusterDeriver.IsDomainValidated(ctx, s.AccountID, s.Domain)
}
func (m *Manager) initializeServiceForCreate(ctx context.Context, accountID string, service *service.Service) error {
if m.clusterDeriver != nil {
proxyCluster, err := m.clusterDeriver.DeriveClusterFromDomain(ctx, accountID, service.Domain)
@@ -606,16 +624,19 @@ func (m *Manager) resolveEffectiveCluster(ctx context.Context, accountID string,
return existing.ProxyCluster, nil
}
if m.clusterDeriver != nil {
derived, err := m.clusterDeriver.DeriveClusterFromDomain(ctx, accountID, svc.Domain)
if err != nil {
log.WithError(err).Warnf("could not derive cluster from domain %s", svc.Domain)
} else {
return derived, nil
}
if m.clusterDeriver == nil {
return existing.ProxyCluster, nil
}
return existing.ProxyCluster, nil
// Falling back to the old cluster here would let an update move a service
// onto a domain the account has not proven it owns, bypassing the same
// check creation makes.
derived, err := m.clusterDeriver.DeriveClusterFromDomain(ctx, accountID, svc.Domain)
if err != nil {
return "", status.Errorf(status.PreconditionFailed, "could not derive cluster from domain %s: %v", svc.Domain, err)
}
return derived, nil
}
func (m *Manager) executeServiceUpdate(ctx context.Context, transaction store.Store, accountID string, service *service.Service, updateInfo *serviceUpdateInfo, customPorts *bool, effectiveCluster string) error {
@@ -786,14 +807,14 @@ func (m *Manager) sendServiceUpdateNotifications(ctx context.Context, accountID
switch {
case updateInfo.domainChanged || updateInfo.oldCluster != s.ProxyCluster:
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, s.ToProtoMapping(service.Delete, "", oidcCfg), updateInfo.oldCluster)
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, s.ToProtoMapping(service.Create, "", oidcCfg), s.ProxyCluster)
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, m.protoMapping(ctx, service.Delete, s, oidcCfg), updateInfo.oldCluster)
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, m.protoMapping(ctx, service.Create, s, oidcCfg), s.ProxyCluster)
case !s.Enabled && updateInfo.serviceEnabledChanged:
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, s.ToProtoMapping(service.Delete, "", oidcCfg), s.ProxyCluster)
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, m.protoMapping(ctx, service.Delete, s, oidcCfg), s.ProxyCluster)
case s.Enabled && updateInfo.serviceEnabledChanged:
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, s.ToProtoMapping(service.Create, "", oidcCfg), s.ProxyCluster)
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, m.protoMapping(ctx, service.Create, s, oidcCfg), s.ProxyCluster)
default:
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, s.ToProtoMapping(service.Update, "", oidcCfg), s.ProxyCluster)
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, m.protoMapping(ctx, service.Update, s, oidcCfg), s.ProxyCluster)
}
}
@@ -893,7 +914,7 @@ func (m *Manager) DeleteService(ctx context.Context, accountID, userID, serviceI
m.accountManager.StoreEvent(ctx, userID, serviceID, accountID, activity.ServiceDeleted, s.EventMeta())
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, s.ToProtoMapping(service.Delete, "", m.proxyController.GetOIDCValidationConfig()), s.ProxyCluster)
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, m.protoMapping(ctx, service.Delete, s, m.proxyController.GetOIDCValidationConfig()), s.ProxyCluster)
m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceService, Operation: types.UpdateOperationDelete})
@@ -937,7 +958,7 @@ func (m *Manager) DeleteAllServices(ctx context.Context, accountID, userID strin
for _, svc := range services {
m.accountManager.StoreEvent(ctx, userID, svc.ID, accountID, activity.ServiceDeleted, svc.EventMeta())
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, svc.ToProtoMapping(service.Delete, "", oidcCfg), svc.ProxyCluster)
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, m.protoMapping(ctx, service.Delete, svc, oidcCfg), svc.ProxyCluster)
}
m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceService, Operation: types.UpdateOperationDelete})
@@ -994,7 +1015,7 @@ func (m *Manager) ReloadService(ctx context.Context, accountID, serviceID string
return fmt.Errorf("failed to replace host by lookup for service %s: %w", s.ID, err)
}
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, s.ToProtoMapping(service.Update, "", m.proxyController.GetOIDCValidationConfig()), s.ProxyCluster)
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, m.protoMapping(ctx, service.Update, s, m.proxyController.GetOIDCValidationConfig()), s.ProxyCluster)
m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceService, Operation: types.UpdateOperationUpdate})
@@ -1014,7 +1035,7 @@ func (m *Manager) ReloadAllServicesForAccount(ctx context.Context, accountID str
if err != nil {
return fmt.Errorf("failed to replace host by lookup for service %s: %w", s.ID, err)
}
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, s.ToProtoMapping(service.Update, "", oidcCfg), s.ProxyCluster)
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, m.protoMapping(ctx, service.Update, s, oidcCfg), s.ProxyCluster)
}
return nil
@@ -1183,7 +1204,7 @@ func (m *Manager) CreateServiceFromPeer(ctx context.Context, accountID, peerID s
return nil, fmt.Errorf("replace host by lookup for service %s: %w", svc.ID, err)
}
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, svc.ToProtoMapping(service.Create, "", m.proxyController.GetOIDCValidationConfig()), svc.ProxyCluster)
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, m.protoMapping(ctx, service.Create, svc, m.proxyController.GetOIDCValidationConfig()), svc.ProxyCluster)
m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceService, Operation: types.UpdateOperationCreate})
serviceURL := "https://" + svc.Domain
@@ -1298,7 +1319,7 @@ func (m *Manager) deletePeerService(ctx context.Context, accountID, peerID, serv
m.accountManager.StoreEvent(ctx, peerID, serviceID, accountID, activityCode, meta)
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, svc.ToProtoMapping(service.Delete, "", m.proxyController.GetOIDCValidationConfig()), svc.ProxyCluster)
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, m.protoMapping(ctx, service.Delete, svc, m.proxyController.GetOIDCValidationConfig()), svc.ProxyCluster)
m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceService, Operation: types.UpdateOperationDelete})
@@ -1354,7 +1375,7 @@ func (m *Manager) deleteExpiredPeerService(ctx context.Context, accountID, peerI
meta := addPeerInfoToEventMeta(svc.EventMeta(), peer)
m.accountManager.StoreEvent(ctx, peerID, serviceID, accountID, activity.PeerServiceExposeExpired, meta)
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, svc.ToProtoMapping(service.Delete, "", m.proxyController.GetOIDCValidationConfig()), svc.ProxyCluster)
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, m.protoMapping(ctx, service.Delete, svc, m.proxyController.GetOIDCValidationConfig()), svc.ProxyCluster)
m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceService, Operation: types.UpdateOperationDelete})
return nil
@@ -655,6 +655,10 @@ func (d *testClusterDeriver) GetClusterDomains() []string {
return d.domains
}
func (d *testClusterDeriver) IsDomainValidated(_ context.Context, _, _ string) bool {
return true
}
const (
testAccountID = "test-account"
testPeerID = "test-peer-1"
@@ -370,7 +370,10 @@ func (s *Service) ToAPIResponse() *api.Service {
}
// ToProtoMapping converts the service into the wire format the proxy consumes.
func (s *Service) ToProtoMapping(operation Operation, authToken string, oidcConfig proxy.OIDCValidationConfig) *proto.ProxyMapping {
// domainValidated tells the proxy whether the account is allowed to serve the
// service's domain; it is a required argument rather than a struct field so
// every producer of a mapping has to answer the question.
func (s *Service) ToProtoMapping(operation Operation, authToken string, oidcConfig proxy.OIDCValidationConfig, domainValidated bool) *proto.ProxyMapping {
pathMappings := s.buildPathMappings()
auth := &proto.Authentication{
@@ -412,6 +415,7 @@ func (s *Service) ToProtoMapping(operation Operation, authToken string, oidcConf
Mode: s.Mode,
ListenPort: int32(s.ListenPort), //nolint:gosec
Private: s.Private,
DomainValidated: domainValidated,
}
if r := restrictionsToProto(s.Restrictions); r != nil {
@@ -238,7 +238,7 @@ func TestToProtoMapping_TargetOptions(t *testing.T) {
},
},
}
pm := rp.ToProtoMapping(Create, "token", proxy.OIDCValidationConfig{})
pm := rp.ToProtoMapping(Create, "token", proxy.OIDCValidationConfig{}, true)
require.Len(t, pm.Path, 1)
opts := pm.Path[0].Options
@@ -266,7 +266,7 @@ func TestToProtoMapping_NoOptionsWhenDefault(t *testing.T) {
},
},
}
pm := rp.ToProtoMapping(Create, "token", proxy.OIDCValidationConfig{})
pm := rp.ToProtoMapping(Create, "token", proxy.OIDCValidationConfig{}, true)
require.Len(t, pm.Path, 1)
assert.Nil(t, pm.Path[0].Options, "options should be nil when all defaults")
}
@@ -447,7 +447,7 @@ func TestToProtoMapping_PortInTargetURL(t *testing.T) {
},
},
}
pm := rp.ToProtoMapping(Create, "token", oidcConfig)
pm := rp.ToProtoMapping(Create, "token", oidcConfig, true)
require.Len(t, pm.Path, 1, "should have one path mapping")
assert.Equal(t, tt.wantTarget, pm.Path[0].Target)
})
@@ -464,7 +464,7 @@ func TestToProtoMapping_DisabledTargetSkipped(t *testing.T) {
{TargetId: "peer-2", TargetType: TargetTypePeer, Host: "10.0.0.2", Port: 9090, Protocol: "http", Enabled: true},
},
}
pm := rp.ToProtoMapping(Create, "token", proxy.OIDCValidationConfig{})
pm := rp.ToProtoMapping(Create, "token", proxy.OIDCValidationConfig{}, true)
require.Len(t, pm.Path, 1)
assert.Equal(t, "http://10.0.0.2:9090/", pm.Path[0].Target)
}
@@ -481,7 +481,7 @@ func TestToProtoMapping_OperationTypes(t *testing.T) {
}
for _, tt := range tests {
t.Run(string(tt.op), func(t *testing.T) {
pm := rp.ToProtoMapping(tt.op, "", proxy.OIDCValidationConfig{})
pm := rp.ToProtoMapping(tt.op, "", proxy.OIDCValidationConfig{}, true)
assert.Equal(t, tt.want, pm.Type)
})
}