[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) GetDomains(ctx context.Context, accountID, userID string) ([]*Domain, error)
CreateDomain(ctx context.Context, accountID, userID, domainName, targetCluster string) (*Domain, error) CreateDomain(ctx context.Context, accountID, userID, domainName, targetCluster string) (*Domain, error)
DeleteDomain(ctx context.Context, accountID, userID, domainID string) 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 GetClusterDomains() []string
} }
@@ -134,7 +134,10 @@ func (h *handler) triggerCustomDomainValidation(w http.ResponseWriter, r *http.R
return 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) Update("validation_expires_at", expiresAt).Error)
env.resolver.set("validation.expired.example.com", testCluster) 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) stored := storedDomain(t, env.store, accountA, d.Domain)
require.NotNil(t, stored) 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") assert.Equal(t, createdAt.Add(48*time.Hour), *d.ValidationExpiresAt, "new registrations get 48 hours")
time.Sleep(time.Hour) 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) stored := storedDomain(t, env.store, accountA, d.Domain)
require.NotNil(t, stored) require.NotNil(t, stored)
require.NotNil(t, stored.ValidationExpiresAt) 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{})} resolver := blockingDomainResolver{started: make(chan struct{}), release: make(chan struct{})}
env.manager.validator.Resolver = resolver env.manager.validator.Resolver = resolver
done := make(chan struct{}) done := make(chan struct{})
var validateErr error
go func() { go func() {
defer close(done) defer close(done)
env.manager.ValidateDomain(ctx, accountA, accountAUser, d.ID) validateErr = env.manager.ValidateDomain(ctx, accountA, accountAUser, d.ID)
}() }()
<-resolver.started <-resolver.started
time.Sleep(48 * time.Hour) time.Sleep(48 * time.Hour)
@@ -211,6 +212,7 @@ func TestValidateDomain_DeadlinePassesDuringLookup(t *testing.T) {
} }
close(resolver.release) close(resolver.release)
<-done <-done
assert.Error(t, validateErr, "a validation finishing after the deadline must be refused")
owner := accountA owner := accountA
if cleanup { if cleanup {
assert.Nil(t, storedDomain(t, env.store, accountA, d.Domain), "late validation must not restore the old claim") 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 return nil
} }
func (m Manager) ValidateDomain(ctx context.Context, accountID, userID, domainID string) { // validationTimeout bounds the DNS check so it ends, and reports why, before a proxy in
ok, _, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Create) // 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 { if err != nil {
log.WithFields(log.Fields{ return status.NewPermissionValidationError(err)
"accountID": accountID,
"domainID": domainID,
}).WithError(err).Error("validate domain")
return
} }
if !ok { if !ok {
log.WithFields(log.Fields{ return status.NewPermissionDeniedError()
"accountID": accountID,
"domainID": domainID,
"userID": userID,
}).Error("validate domain: permission denied")
return
} }
log.WithFields(log.Fields{ log.WithFields(log.Fields{
@@ -250,32 +247,23 @@ func (m Manager) ValidateDomain(ctx context.Context, accountID, userID, domainID
"domainID": domainID, "domainID": domainID,
}).Info("starting domain validation") }).Info("starting domain validation")
d, err := m.store.GetCustomDomain(context.Background(), accountID, domainID) d, err := m.store.GetCustomDomain(ctx, accountID, domainID)
if err != nil { if err != nil {
log.WithFields(log.Fields{ return fmt.Errorf("get domain from store: %w", err)
"accountID": accountID,
"domainID": domainID,
}).WithError(err).Error("get custom domain from store")
return
} }
if d.Validated { if d.Validated {
return return nil
} }
if d.ValidationExpiresAt == nil || !time.Now().Before(*d.ValidationExpiresAt) { if d.ValidationExpiresAt == nil || !time.Now().Before(*d.ValidationExpiresAt) {
log.WithFields(log.Fields{"accountID": accountID, "domainID": domainID}). return validationFailed(domain.NewValidationError(domain.ValidationReasonExpired,
Debug("custom domain validation window has expired") "custom domain %s validation window has expired; delete and add the domain again", d.Domain))
return
} }
// Validate only against the domain's target cluster // Validate only against the domain's target cluster
targetCluster := d.TargetCluster targetCluster := d.TargetCluster
if targetCluster == "" { if targetCluster == "" {
log.WithFields(log.Fields{ return validationFailed(domain.NewValidationError(domain.ValidationReasonNoTargetCluster,
"accountID": accountID, "custom domain %s has no target cluster", d.Domain))
"domainID": domainID,
"domain": d.Domain,
}).Warn("domain has no target cluster set, skipping validation")
return
} }
log.WithFields(log.Fields{ log.WithFields(log.Fields{
@@ -285,32 +273,35 @@ func (m Manager) ValidateDomain(ctx context.Context, accountID, userID, domainID
"targetCluster": targetCluster, "targetCluster": targetCluster,
}).Info("validating domain against target cluster") }).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 d.Validated = true
if _, err := m.store.UpdateCustomDomain(context.Background(), accountID, d); err != nil { if _, err := m.store.UpdateCustomDomain(ctx, accountID, d); err != nil {
entry := log.WithFields(log.Fields{ // A concurrent request may have validated the domain first.
"accountID": accountID, if current, getErr := m.store.GetCustomDomain(ctx, accountID, domainID); getErr == nil && current.Validated {
"domainID": domainID, return nil
}).WithError(err)
if sErr, ok := status.FromError(err); ok && sErr.Type() == status.PreconditionFailed {
entry.Debug("custom domain registration is no longer pending validation")
return
} }
entry.Error("update custom domain in store") return fmt.Errorf("update domain in store: %w", err)
return
} }
log.WithFields(log.Fields{"accountID": accountID, "domainID": domainID}). log.WithFields(log.Fields{"accountID": accountID, "domainID": domainID}).
Info("custom domain validated successfully") Info("custom domain validated successfully")
m.accountManager.StoreEvent(context.Background(), userID, domainID, accountID, activity.DomainValidated, d.EventMeta()) m.accountManager.StoreEvent(ctx, userID, domainID, accountID, activity.DomainValidated, d.EventMeta())
} else { return nil
log.WithFields(log.Fields{
"accountID": accountID,
"domainID": domainID,
"domain": d.Domain,
"targetCluster": targetCluster,
}).Warn("domain validation failed - CNAME does not match target cluster")
} }
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. // GetClusterDomains returns a list of proxy cluster domains.
@@ -229,7 +229,7 @@ func TestValidateDomain_UnlocksClusterDerivation(t *testing.T) {
require.Error(t, err) require.Error(t, err)
env.resolver.set("validation.later.example.com", testCluster) 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) 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. // a validated domain is the permission check.
env.resolver.set("validation.guarded.example.com", testCluster) 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") stored := storedDomain(t, env.store, accountA, "guarded.example.com")
require.NotNil(t, stored) 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") require.False(t, created.Validated, "a missing CNAME must leave the normalized registration pending")
env.resolver.set("validation.apps.example.com", testCluster) 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) stored, err := env.store.GetCustomDomain(ctx, accountA, created.ID)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "apps.example.com", stored.Domain, "retrying validation must retain the normalized name") 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 ( import (
"context" "context"
"errors"
"fmt"
"net" "net"
"strings" "strings"
@@ -16,6 +18,33 @@ type Validator struct {
Resolver resolver 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. // NewValidator initializes a validator with a specific DNS Resolver.
// If a Validator is used without specifying a Resolver, then it will // If a Validator is used without specifying a Resolver, then it will
// use the net.DefaultResolver. // 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. // 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. // 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) { 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 { if v.Resolver == nil {
v.Resolver = net.DefaultResolver v.Resolver = net.DefaultResolver
} }
@@ -56,10 +92,19 @@ func (v *Validator) ValidateWithCluster(ctx context.Context, domain string, acce
"domain": domain, "domain": domain,
"lookupDomain": lookupDomain, "lookupDomain": lookupDomain,
}).WithError(err).Warn("CNAME lookup failed for domain validation") }).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, ".") 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{ log.WithFields(log.Fields{
"domain": domain, "domain": domain,
"cname": cname, "cname": cname,
@@ -69,13 +114,13 @@ func (v *Validator) ValidateWithCluster(ctx context.Context, domain string, acce
for _, acceptDomain := range accept { for _, acceptDomain := range accept {
normalizedAccept := strings.TrimSuffix(acceptDomain, ".") normalizedAccept := strings.TrimSuffix(acceptDomain, ".")
if nakedCNAME == normalizedAccept { if strings.EqualFold(nakedCNAME, normalizedAccept) {
log.WithFields(log.Fields{ log.WithFields(log.Fields{
"domain": domain, "domain": domain,
"cname": nakedCNAME, "cname": nakedCNAME,
"cluster": acceptDomain, "cluster": acceptDomain,
}).Info("domain CNAME matched cluster") }).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, "cname": nakedCNAME,
"acceptList": accept, "acceptList": accept,
}).Warn("domain CNAME does not match any accepted cluster") }).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 ( import (
"context" "context"
"errors"
"net"
"testing" "testing"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
@@ -36,6 +38,12 @@ func TestIsValid(t *testing.T) {
accept: []string{"bar.example.com"}, accept: []string{"bar.example.com"},
expect: false, 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": { "accept trailing dot": {
resolver: resolver{"bar.example.com."}, resolver: resolver{"bar.example.com."},
domain: "foo.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)
}
})
}
}
+6 -3
View File
@@ -12895,7 +12895,7 @@ paths:
/api/reverse-proxies/domains/{domainId}/validate: /api/reverse-proxies/domains/{domainId}/validate:
get: get:
summary: Validate a custom domain summary: Validate a custom domain
description: Trigger domain ownership validation for a custom domain description: Validate domain ownership for a custom domain
tags: [ Services ] tags: [ Services ]
security: security:
- BearerAuth: [ ] - BearerAuth: [ ]
@@ -12908,8 +12908,8 @@ paths:
type: string type: string
description: The custom domain ID description: The custom domain ID
responses: responses:
'202': '204':
description: Reverse proxy custom domain validation triggered description: Reverse proxy custom domain validated
'400': '400':
"$ref": "#/components/responses/bad_request" "$ref": "#/components/responses/bad_request"
'401': '401':
@@ -12918,6 +12918,9 @@ paths:
"$ref": "#/components/responses/forbidden" "$ref": "#/components/responses/forbidden"
'404': '404':
"$ref": "#/components/responses/not_found" "$ref": "#/components/responses/not_found"
'412':
description: Precondition failed - the custom domain could not be validated
content: { }
'500': '500':
"$ref": "#/components/responses/internal_error" "$ref": "#/components/responses/internal_error"
/api/integrations/notifications/types: /api/integrations/notifications/types: