mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-20 04:19:08 +02:00
* [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>
140 lines
4.7 KiB
Go
140 lines
4.7 KiB
Go
package domain
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"strings"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
)
|
|
|
|
type resolver interface {
|
|
LookupCNAME(context.Context, string) (string, error)
|
|
}
|
|
|
|
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.
|
|
func NewValidator(resolver resolver) *Validator {
|
|
return &Validator{
|
|
Resolver: resolver,
|
|
}
|
|
}
|
|
|
|
// IsValid looks up the CNAME record for the passed domain with a prefix
|
|
// and compares it against the acceptable domains.
|
|
// If the returned CNAME matches any accepted domain, it will return true,
|
|
// otherwise, including in the event of a DNS error, it will return false.
|
|
// The comparison is very simple, so wildcards will not match if included
|
|
// in the acceptable domain list.
|
|
func (v *Validator) IsValid(ctx context.Context, domain string, accept []string) bool {
|
|
_, valid := v.ValidateWithCluster(ctx, domain, accept)
|
|
return valid
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
lookupDomain := "validation." + domain
|
|
log.WithFields(log.Fields{
|
|
"domain": domain,
|
|
"lookupDomain": lookupDomain,
|
|
"acceptList": accept,
|
|
}).Debug("looking up CNAME for domain validation")
|
|
|
|
cname, err := v.Resolver.LookupCNAME(ctx, lookupDomain)
|
|
if err != nil {
|
|
log.WithFields(log.Fields{
|
|
"domain": domain,
|
|
"lookupDomain": lookupDomain,
|
|
}).WithError(err).Warn("CNAME lookup failed for domain validation")
|
|
// 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,
|
|
"nakedCNAME": nakedCNAME,
|
|
"acceptList": accept,
|
|
}).Debug("CNAME lookup result for domain validation")
|
|
|
|
for _, acceptDomain := range accept {
|
|
normalizedAccept := strings.TrimSuffix(acceptDomain, ".")
|
|
if strings.EqualFold(nakedCNAME, normalizedAccept) {
|
|
log.WithFields(log.Fields{
|
|
"domain": domain,
|
|
"cname": nakedCNAME,
|
|
"cluster": acceptDomain,
|
|
}).Info("domain CNAME matched cluster")
|
|
return acceptDomain, nil
|
|
}
|
|
}
|
|
|
|
log.WithFields(log.Fields{
|
|
"domain": domain,
|
|
"cname": nakedCNAME,
|
|
"acceptList": accept,
|
|
}).Warn("domain CNAME does not match any accepted cluster")
|
|
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, ", "))
|
|
}
|