[management] Return the reason when custom domain validation fails (#8122)

* [management] Return the reason when custom domain validation fails

The validate endpoint returned an empty 202 and ran the check in a
goroutine, so a failed verification only reached the management log.
Run the check in the request and return 204 or 412 with a classified
reason. Resolver errors are not forwarded.

* [management] Address review feedback on custom domain validation

Pass the request context to the lookup and store calls now that the
check runs in the request, compare the self-resolved name
case-insensitively, document the 412 like the other entries, and
check the returned error in the existing tests.

* [management] Match custom domain CNAME targets case-insensitively

DNS names are case-insensitive, so a CNAME pointing at the cluster in
different casing should validate instead of failing as a mismatch.

* [management] Add validation timeout

---------

Co-authored-by: DevBot NetBird <91386968+netbirddev@users.noreply.github.com>
This commit is contained in:
Edward
2026-10-19 13:58:14 +02:00
committed by GitHub
co-authored by DevBot NetBird
parent cf01711971
commit 279adb8fdd
10 changed files with 331 additions and 64 deletions
@@ -8,6 +8,6 @@ type Manager interface {
GetDomains(ctx context.Context, accountID, userID string) ([]*Domain, error)
CreateDomain(ctx context.Context, accountID, userID, domainName, targetCluster string) (*Domain, error)
DeleteDomain(ctx context.Context, accountID, userID, domainID string) error
ValidateDomain(ctx context.Context, accountID, userID, domainID string)
ValidateDomain(ctx context.Context, accountID, userID, domainID string) error
GetClusterDomains() []string
}
@@ -134,7 +134,10 @@ func (h *handler) triggerCustomDomainValidation(w http.ResponseWriter, r *http.R
return
}
go h.manager.ValidateDomain(r.Context(), userAuth.AccountId, userAuth.UserId, domainID)
if err := h.manager.ValidateDomain(r.Context(), userAuth.AccountId, userAuth.UserId, domainID); err != nil {
util.WriteError(r.Context(), err, w)
return
}
w.WriteHeader(http.StatusAccepted)
w.WriteHeader(http.StatusNoContent)
}
@@ -29,7 +29,7 @@ func TestValidateDomain_ExpiredRegistration(t *testing.T) {
Update("validation_expires_at", expiresAt).Error)
env.resolver.set("validation.expired.example.com", testCluster)
env.manager.ValidateDomain(ctx, accountA, accountAUser, d.ID)
assert.Error(t, env.manager.ValidateDomain(ctx, accountA, accountAUser, d.ID), "an expired registration must be refused")
stored := storedDomain(t, env.store, accountA, d.Domain)
require.NotNil(t, stored)
@@ -47,7 +47,7 @@ func TestCreateDomain_ValidationDeadline(t *testing.T) {
assert.Equal(t, createdAt.Add(48*time.Hour), *d.ValidationExpiresAt, "new registrations get 48 hours")
time.Sleep(time.Hour)
env.manager.ValidateDomain(ctx, accountA, accountAUser, d.ID)
assert.Error(t, env.manager.ValidateDomain(ctx, accountA, accountAUser, d.ID), "a missing CNAME must fail validation")
stored := storedDomain(t, env.store, accountA, d.Domain)
require.NotNil(t, stored)
require.NotNil(t, stored.ValidationExpiresAt)
@@ -198,9 +198,10 @@ func TestValidateDomain_DeadlinePassesDuringLookup(t *testing.T) {
resolver := blockingDomainResolver{started: make(chan struct{}), release: make(chan struct{})}
env.manager.validator.Resolver = resolver
done := make(chan struct{})
var validateErr error
go func() {
defer close(done)
env.manager.ValidateDomain(ctx, accountA, accountAUser, d.ID)
validateErr = env.manager.ValidateDomain(ctx, accountA, accountAUser, d.ID)
}()
<-resolver.started
time.Sleep(48 * time.Hour)
@@ -211,6 +212,7 @@ func TestValidateDomain_DeadlinePassesDuringLookup(t *testing.T) {
}
close(resolver.release)
<-done
assert.Error(t, validateErr, "a validation finishing after the deadline must be refused")
owner := accountA
if cleanup {
assert.Nil(t, storedDomain(t, env.store, accountA, d.Domain), "late validation must not restore the old claim")
@@ -227,22 +227,19 @@ func (m Manager) DeleteDomain(ctx context.Context, accountID, userID, domainID s
return nil
}
func (m Manager) ValidateDomain(ctx context.Context, accountID, userID, domainID string) {
ok, _, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Create)
// validationTimeout bounds the DNS check so it ends, and reports why, before a proxy in
// front of management times the request out.
const validationTimeout = 10 * time.Second
// ValidateDomain checks the domain's validation CNAME and marks it validated. A failure
// the user can act on is returned as PreconditionFailed carrying the reason.
func (m Manager) ValidateDomain(ctx context.Context, accountID, userID, domainID string) error {
ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Create)
if err != nil {
log.WithFields(log.Fields{
"accountID": accountID,
"domainID": domainID,
}).WithError(err).Error("validate domain")
return
return status.NewPermissionValidationError(err)
}
if !ok {
log.WithFields(log.Fields{
"accountID": accountID,
"domainID": domainID,
"userID": userID,
}).Error("validate domain: permission denied")
return
return status.NewPermissionDeniedError()
}
log.WithFields(log.Fields{
@@ -250,32 +247,23 @@ func (m Manager) ValidateDomain(ctx context.Context, accountID, userID, domainID
"domainID": domainID,
}).Info("starting domain validation")
d, err := m.store.GetCustomDomain(context.Background(), accountID, domainID)
d, err := m.store.GetCustomDomain(ctx, accountID, domainID)
if err != nil {
log.WithFields(log.Fields{
"accountID": accountID,
"domainID": domainID,
}).WithError(err).Error("get custom domain from store")
return
return fmt.Errorf("get domain from store: %w", err)
}
if d.Validated {
return
return nil
}
if d.ValidationExpiresAt == nil || !time.Now().Before(*d.ValidationExpiresAt) {
log.WithFields(log.Fields{"accountID": accountID, "domainID": domainID}).
Debug("custom domain validation window has expired")
return
return validationFailed(domain.NewValidationError(domain.ValidationReasonExpired,
"custom domain %s validation window has expired; delete and add the domain again", d.Domain))
}
// Validate only against the domain's target cluster
targetCluster := d.TargetCluster
if targetCluster == "" {
log.WithFields(log.Fields{
"accountID": accountID,
"domainID": domainID,
"domain": d.Domain,
}).Warn("domain has no target cluster set, skipping validation")
return
return validationFailed(domain.NewValidationError(domain.ValidationReasonNoTargetCluster,
"custom domain %s has no target cluster", d.Domain))
}
log.WithFields(log.Fields{
@@ -285,32 +273,35 @@ 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}) {
lookupCtx, cancel := context.WithTimeout(ctx, validationTimeout)
_, err = m.validator.Validate(lookupCtx, d.Domain, []string{targetCluster})
cancel()
if err == nil {
d.Validated = true
if _, err := m.store.UpdateCustomDomain(context.Background(), accountID, d); err != nil {
entry := log.WithFields(log.Fields{
"accountID": accountID,
"domainID": domainID,
}).WithError(err)
if sErr, ok := status.FromError(err); ok && sErr.Type() == status.PreconditionFailed {
entry.Debug("custom domain registration is no longer pending validation")
return
if _, err := m.store.UpdateCustomDomain(ctx, accountID, d); err != nil {
// A concurrent request may have validated the domain first.
if current, getErr := m.store.GetCustomDomain(ctx, accountID, domainID); getErr == nil && current.Validated {
return nil
}
entry.Error("update custom domain in store")
return
return fmt.Errorf("update domain in store: %w", err)
}
log.WithFields(log.Fields{"accountID": accountID, "domainID": domainID}).
Info("custom domain validated successfully")
m.accountManager.StoreEvent(context.Background(), userID, domainID, accountID, activity.DomainValidated, d.EventMeta())
} else {
log.WithFields(log.Fields{
"accountID": accountID,
"domainID": domainID,
"domain": d.Domain,
"targetCluster": targetCluster,
}).Warn("domain validation failed - CNAME does not match target cluster")
m.accountManager.StoreEvent(ctx, userID, domainID, accountID, activity.DomainValidated, d.EventMeta())
return nil
}
return validationFailed(err)
}
// validationFailed shows a classified failure to the user and keeps anything else internal.
func validationFailed(err error) error {
var vErr *domain.ValidationError
if errors.As(err, &vErr) {
return status.Errorf(status.PreconditionFailed, "%s", vErr.Message)
}
return fmt.Errorf("validate domain: %w", err)
}
// GetClusterDomains returns a list of proxy cluster domains.
@@ -229,7 +229,7 @@ func TestValidateDomain_UnlocksClusterDerivation(t *testing.T) {
require.Error(t, err)
env.resolver.set("validation.later.example.com", testCluster)
env.manager.ValidateDomain(ctx, accountA, accountAUser, created.ID)
require.NoError(t, env.manager.ValidateDomain(ctx, accountA, accountAUser, created.ID))
require.True(t, storedDomain(t, env.store, accountA, "later.example.com").Validated)
@@ -286,7 +286,7 @@ func TestValidateDomain_PermissionDeniedDoesNotValidate(t *testing.T) {
// a validated domain is the permission check.
env.resolver.set("validation.guarded.example.com", testCluster)
env.manager.ValidateDomain(ctx, accountA, accountAMember, created.ID)
assert.Error(t, env.manager.ValidateDomain(ctx, accountA, accountAMember, created.ID), "a caller without permission must be refused")
stored := storedDomain(t, env.store, accountA, "guarded.example.com")
require.NotNil(t, stored)
@@ -52,7 +52,7 @@ func TestCreateDomain_NormalizedNameCanValidateLater(t *testing.T) {
require.False(t, created.Validated, "a missing CNAME must leave the normalized registration pending")
env.resolver.set("validation.apps.example.com", testCluster)
env.manager.ValidateDomain(ctx, accountA, accountAUser, created.ID)
require.NoError(t, env.manager.ValidateDomain(ctx, accountA, accountAUser, created.ID))
stored, err := env.store.GetCustomDomain(ctx, accountA, created.ID)
require.NoError(t, err)
assert.Equal(t, "apps.example.com", stored.Domain, "retrying validation must retain the normalized name")
@@ -0,0 +1,151 @@
package manager
import (
"context"
"testing"
"testing/synctest"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
nbstore "github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/shared/management/status"
)
// The validator tests cover each DNS outcome. These cover what the manager adds on top:
// the status type the API turns into a response code, and the checks before the lookup.
func TestValidateDomain_ReturnsReason(t *testing.T) {
ctx := context.Background()
tests := []struct {
name string
setup func(t *testing.T, env *domainTestEnv, d *domain.Domain)
user string
want status.Type
message string
}{
{
name: "cname mismatch",
setup: func(_ *testing.T, env *domainTestEnv, d *domain.Domain) {
env.resolver.set("validation."+d.Domain, "other.example.net")
},
want: status.PreconditionFailed,
message: "CNAME record validation.test.example.com points to other.example.net; point it to " + testCluster,
},
{
name: "expired window",
setup: func(t *testing.T, env *domainTestEnv, d *domain.Domain) {
setDomainColumn(t, env, d, "validation_expires_at", time.Now().Add(-time.Second))
},
want: status.PreconditionFailed,
message: "custom domain test.example.com validation window has expired; delete and add the domain again",
},
{
name: "no target cluster",
setup: func(t *testing.T, env *domainTestEnv, d *domain.Domain) {
setDomainColumn(t, env, d, "target_cluster", "")
},
want: status.PreconditionFailed,
message: "custom domain test.example.com has no target cluster",
},
{
name: "permission denied",
setup: func(_ *testing.T, env *domainTestEnv, d *domain.Domain) {
env.resolver.set("validation."+d.Domain, testCluster)
},
user: accountAMember,
want: status.PermissionDenied,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
env := setupDomainTest(t)
d, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "test.example.com", testCluster)
require.NoError(t, err)
tt.setup(t, env, d)
user := accountAUser
if tt.user != "" {
user = tt.user
}
err = env.manager.ValidateDomain(ctx, accountA, user, d.ID)
sErr, ok := status.FromError(err)
require.True(t, ok, "expected a status error, got %v", err)
assert.Equal(t, tt.want, sErr.Type(), "status type")
if tt.message != "" {
assert.Equal(t, tt.message, sErr.Message, "user-facing message")
}
assert.False(t, storedDomain(t, env.store, accountA, d.Domain).Validated, "a failed check must not validate the domain")
})
}
}
func TestValidateDomain_SuccessIsIdempotent(t *testing.T) {
ctx := context.Background()
env := setupDomainTest(t)
d, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "ok.example.com", testCluster)
require.NoError(t, err)
env.resolver.set("validation.ok.example.com", testCluster)
require.NoError(t, env.manager.ValidateDomain(ctx, accountA, accountAUser, d.ID))
assert.True(t, storedDomain(t, env.store, accountA, d.Domain).Validated, "domain should be validated")
assert.NoError(t, env.manager.ValidateDomain(ctx, accountA, accountAUser, d.ID), "an already validated domain is a success")
}
func setDomainColumn(t *testing.T, env *domainTestEnv, d *domain.Domain, column string, value any) {
t.Helper()
db := env.store.(*nbstore.SqlStore).GetDB()
require.NoError(t, db.Model(&domain.Domain{}).Where("id = ?", d.ID).Update(column, value).Error)
}
// Two verify requests can both read the domain as pending. The one that loses the store
// update must still report success when the other one validated the domain.
func TestValidateDomain_ConcurrentValidationSucceeds(t *testing.T) {
ctx := context.Background()
env := setupDomainTest(t)
d, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "twice.example.com", testCluster)
require.NoError(t, err)
resolver := blockingDomainResolver{started: make(chan struct{}), release: make(chan struct{})}
env.manager.validator.Resolver = resolver
done := make(chan error, 1)
go func() {
done <- env.manager.ValidateDomain(ctx, accountA, accountAUser, d.ID)
}()
<-resolver.started
// The other request completes while this one is still looking up the CNAME.
setDomainColumn(t, env, d, "validated", true)
close(resolver.release)
assert.NoError(t, <-done, "a domain validated by a concurrent request is a success")
}
// hangingResolver never answers, so only a deadline on the context ends the lookup.
type hangingResolver struct{}
func (hangingResolver) LookupCNAME(ctx context.Context, _ string) (string, error) {
<-ctx.Done()
return "", ctx.Err()
}
// A lookup that hangs must end on the validation timeout and still report the reason,
// rather than leaving the request to whatever timeout sits in front of management.
func TestValidateDomain_LookupTimeout(t *testing.T) {
env := setupClockDomainTest(t)
synctest.Test(t, func(t *testing.T) {
ctx := context.Background()
d, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "slow.example.com", testCluster)
require.NoError(t, err)
env.manager.validator.Resolver = hangingResolver{}
start := time.Now()
err = env.manager.ValidateDomain(ctx, accountA, accountAUser, d.ID)
sErr, ok := status.FromError(err)
require.True(t, ok, "expected a status error, got %v", err)
assert.Equal(t, status.PreconditionFailed, sErr.Type(), "status type")
assert.Equal(t, "DNS lookup for validation.slow.example.com failed; retry the validation", sErr.Message, "user-facing message")
assert.Equal(t, validationTimeout, time.Since(start), "the lookup must end on the validation timeout")
})
}
@@ -2,6 +2,8 @@ package domain
import (
"context"
"errors"
"fmt"
"net"
"strings"
@@ -16,6 +18,33 @@ type Validator struct {
Resolver resolver
}
// ValidationReason classifies why a custom domain failed validation.
type ValidationReason string
const (
ValidationReasonCNAMENotFound ValidationReason = "cname_not_found"
ValidationReasonCNAMEMismatch ValidationReason = "cname_mismatch"
ValidationReasonLookupFailed ValidationReason = "lookup_failed"
ValidationReasonExpired ValidationReason = "validation_expired"
ValidationReasonNoTargetCluster ValidationReason = "no_target_cluster"
)
// ValidationError is a validation failure whose message is safe to show to the user.
type ValidationError struct {
Reason ValidationReason
Message string
}
// Error returns the user-facing message.
func (e *ValidationError) Error() string {
return e.Message
}
// NewValidationError returns a ValidationError with a formatted message.
func NewValidationError(reason ValidationReason, format string, args ...any) *ValidationError {
return &ValidationError{Reason: reason, Message: fmt.Sprintf(format, args...)}
}
// NewValidator initializes a validator with a specific DNS Resolver.
// If a Validator is used without specifying a Resolver, then it will
// use the net.DefaultResolver.
@@ -39,6 +68,13 @@ func (v *Validator) IsValid(ctx context.Context, domain string, accept []string)
// ValidateWithCluster validates a custom domain and returns the matched cluster address.
// Returns the cluster address and true if valid, or empty string and false if invalid.
func (v *Validator) ValidateWithCluster(ctx context.Context, domain string, accept []string) (string, bool) {
cluster, err := v.Validate(ctx, domain, accept)
return cluster, err == nil
}
// Validate validates a custom domain and returns the matched cluster address.
// On failure it returns a *ValidationError whose message is safe to show to the user.
func (v *Validator) Validate(ctx context.Context, domain string, accept []string) (string, error) {
if v.Resolver == nil {
v.Resolver = net.DefaultResolver
}
@@ -56,10 +92,19 @@ func (v *Validator) ValidateWithCluster(ctx context.Context, domain string, acce
"domain": domain,
"lookupDomain": lookupDomain,
}).WithError(err).Warn("CNAME lookup failed for domain validation")
return "", false
// Never forward the resolver error: its text includes the resolver address.
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) && dnsErr.IsNotFound {
return "", cnameNotFound(lookupDomain, accept)
}
return "", NewValidationError(ValidationReasonLookupFailed, "DNS lookup for %s failed; retry the validation", lookupDomain)
}
nakedCNAME := strings.TrimSuffix(cname, ".")
// A name without a CNAME record resolves to itself.
if strings.EqualFold(nakedCNAME, lookupDomain) {
return "", cnameNotFound(lookupDomain, accept)
}
log.WithFields(log.Fields{
"domain": domain,
"cname": cname,
@@ -69,13 +114,13 @@ func (v *Validator) ValidateWithCluster(ctx context.Context, domain string, acce
for _, acceptDomain := range accept {
normalizedAccept := strings.TrimSuffix(acceptDomain, ".")
if nakedCNAME == normalizedAccept {
if strings.EqualFold(nakedCNAME, normalizedAccept) {
log.WithFields(log.Fields{
"domain": domain,
"cname": nakedCNAME,
"cluster": acceptDomain,
}).Info("domain CNAME matched cluster")
return acceptDomain, true
return acceptDomain, nil
}
}
@@ -84,5 +129,11 @@ func (v *Validator) ValidateWithCluster(ctx context.Context, domain string, acce
"cname": nakedCNAME,
"acceptList": accept,
}).Warn("domain CNAME does not match any accepted cluster")
return "", false
return "", NewValidationError(ValidationReasonCNAMEMismatch, "CNAME record %s points to %s; point it to %s",
lookupDomain, nakedCNAME, strings.Join(accept, ", "))
}
func cnameNotFound(lookupDomain string, accept []string) *ValidationError {
return NewValidationError(ValidationReasonCNAMENotFound, "no CNAME record found for %s; point it to %s",
lookupDomain, strings.Join(accept, ", "))
}
@@ -2,6 +2,8 @@ package domain_test
import (
"context"
"errors"
"net"
"testing"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
@@ -36,6 +38,12 @@ func TestIsValid(t *testing.T) {
accept: []string{"bar.example.com"},
expect: false,
},
"match in other case": {
resolver: resolver{"Bar.Example.COM."},
domain: "foo.example.com",
accept: []string{"bar.example.com"},
expect: true,
},
"accept trailing dot": {
resolver: resolver{"bar.example.com."},
domain: "foo.example.com",
@@ -54,3 +62,61 @@ func TestIsValid(t *testing.T) {
})
}
}
type errResolver struct {
err error
}
func (r errResolver) LookupCNAME(context.Context, string) (string, error) {
return "", r.err
}
func TestValidate_Reason(t *testing.T) {
const notFound = "no CNAME record found for validation.foo.example.com; point it to eu.proxy.example.com"
tests := map[string]struct {
resolver interface {
LookupCNAME(context.Context, string) (string, error)
}
reason domain.ValidationReason
message string
}{
"mismatch": {
resolver: resolver{"other.example.net."},
reason: domain.ValidationReasonCNAMEMismatch,
message: "CNAME record validation.foo.example.com points to other.example.net; point it to eu.proxy.example.com",
},
"no cname resolves to itself": {
resolver: resolver{"validation.foo.example.com."},
reason: domain.ValidationReasonCNAMENotFound,
message: notFound,
},
"no cname resolves to itself in other case": {
resolver: resolver{"Validation.Foo.example.com."},
reason: domain.ValidationReasonCNAMENotFound,
message: notFound,
},
"not found": {
resolver: errResolver{&net.DNSError{Err: "no such host", Name: "validation.foo.example.com", Server: "10.0.0.2:53", IsNotFound: true}},
reason: domain.ValidationReasonCNAMENotFound,
message: notFound,
},
"other error is not forwarded": {
resolver: errResolver{errors.New("dial udp 10.0.0.2:53: connect: network is unreachable")},
reason: domain.ValidationReasonLookupFailed,
message: "DNS lookup for validation.foo.example.com failed; retry the validation",
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
_, err := domain.NewValidator(test.resolver).Validate(t.Context(), "foo.example.com", []string{"eu.proxy.example.com"})
var vErr *domain.ValidationError
if !errors.As(err, &vErr) {
t.Fatalf("expected a *domain.ValidationError, got %v", err)
}
if vErr.Reason != test.reason || vErr.Message != test.message {
t.Errorf("expect %q %q, actual %q %q", test.reason, test.message, vErr.Reason, vErr.Message)
}
})
}
}