mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-25 05:49:06 +01:00
[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:
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user