Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/netbirdio/netbird/management/server/permissions/modules"
"github.com/netbirdio/netbird/management/server/permissions/operations"
"github.com/netbirdio/netbird/management/server/types"
nbdomain "github.com/netbirdio/netbird/shared/management/domain"
"github.com/netbirdio/netbird/shared/management/status"
)

Expand Down Expand Up @@ -112,6 +113,12 @@ func (m Manager) CreateDomain(ctx context.Context, accountID, userID, domainName
return nil, status.NewPermissionDeniedError()
}

// Validate the domain name (allows wildcard and non-wildcard)
domainName = strings.ToLower(strings.TrimSpace(domainName))
if !nbdomain.IsValidDomain(domainName) {
return nil, status.Errorf(status.InvalidArgument, "invalid domain name: %s", domainName)
}

// Verify the target cluster is in the available clusters
allowList, err := m.proxyManager.GetActiveClusterAddresses(ctx)
if err != nil {
Expand Down Expand Up @@ -284,13 +291,44 @@ func (m Manager) DeriveClusterFromDomain(ctx context.Context, accountID, domain
return "", fmt.Errorf("domain %s does not match any available proxy cluster", domain)
}

func extractClusterFromCustomDomains(domain string, customDomains []*domain.Domain) (string, bool) {
for _, customDomain := range customDomains {
if strings.HasSuffix(domain, "."+customDomain.Domain) {
return customDomain.TargetCluster, true
// extractClusterFromCustomDomains extracts the cluster address from a custom domain.
// Supports both non-wildcard and wildcard custom domains.
// An exact non-wildcard match always takes priority. Among other matches
// (wildcard or subdomain of non-wildcard), the longest matching suffix wins.
func extractClusterFromCustomDomains(host string, customDomains []*domain.Domain) (string, bool) {
normalizedHost := strings.ToLower(strings.TrimSuffix(host, "."))

// Exact non-wildcard match always wins — check first.
for _, cd := range customDomains {
normalizedCD := strings.ToLower(strings.TrimSuffix(cd.Domain, "."))
if !strings.HasPrefix(normalizedCD, "*.") && normalizedHost == normalizedCD {
return cd.TargetCluster, true
}
}
return "", false

// Fall back to the longest wildcard or non-wildcard subdomain match.
var bestCluster string
bestLen := -1

for _, cd := range customDomains {
normalizedCD := strings.ToLower(strings.TrimSuffix(cd.Domain, "."))

if strings.HasPrefix(normalizedCD, "*.") {
suffix := normalizedCD[2:]
if normalizedHost == suffix || strings.HasSuffix(normalizedHost, "."+suffix) {
if len(suffix) > bestLen {
bestCluster = cd.TargetCluster
bestLen = len(suffix)
}
}
} else if strings.HasSuffix(normalizedHost, "."+normalizedCD) {
if len(normalizedCD) > bestLen {
bestCluster = cd.TargetCluster
bestLen = len(normalizedCD)
}
}
}
return bestCluster, bestLen >= 0
}

// ExtractClusterFromFreeDomain extracts the cluster address from a free domain.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package manager

import (
"testing"

"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
)

func TestExtractClusterFromCustomDomains(t *testing.T) {
tests := map[string]struct {
host string
customDomains []*domain.Domain
wantCluster string
wantOK bool
}{
"exact non-wildcard match": {
host: "example.com",
customDomains: []*domain.Domain{
{Domain: "example.com", TargetCluster: "cluster-a"},
},
wantCluster: "cluster-a",
wantOK: true,
},
"wildcard matches subdomain": {
host: "app.example.com",
customDomains: []*domain.Domain{
{Domain: "*.example.com", TargetCluster: "cluster-a"},
},
wantCluster: "cluster-a",
wantOK: true,
},
"wildcard matches apex": {
host: "example.com",
customDomains: []*domain.Domain{
{Domain: "*.example.com", TargetCluster: "cluster-a"},
},
wantCluster: "cluster-a",
wantOK: true,
},
"non-wildcard matches subdomain": {
host: "app.example.com",
customDomains: []*domain.Domain{
{Domain: "example.com", TargetCluster: "cluster-a"},
},
wantCluster: "cluster-a",
wantOK: true,
},
"exact non-wildcard beats wildcard": {
host: "example.com",
customDomains: []*domain.Domain{
{Domain: "*.example.com", TargetCluster: "cluster-wild"},
{Domain: "example.com", TargetCluster: "cluster-exact"},
},
wantCluster: "cluster-exact",
wantOK: true,
},
"longest wildcard suffix wins": {
host: "app.sub.example.com",
customDomains: []*domain.Domain{
{Domain: "*.example.com", TargetCluster: "cluster-short"},
{Domain: "*.sub.example.com", TargetCluster: "cluster-long"},
},
wantCluster: "cluster-long",
wantOK: true,
},
"longest non-wildcard suffix wins": {
host: "app.sub.example.com",
customDomains: []*domain.Domain{
{Domain: "example.com", TargetCluster: "cluster-short"},
{Domain: "sub.example.com", TargetCluster: "cluster-long"},
},
wantCluster: "cluster-long",
wantOK: true,
},
"trailing dot on host is normalized": {
host: "example.com.",
customDomains: []*domain.Domain{
{Domain: "example.com", TargetCluster: "cluster-a"},
},
wantCluster: "cluster-a",
wantOK: true,
},
"trailing dot on custom domain is normalized": {
host: "example.com",
customDomains: []*domain.Domain{
{Domain: "example.com.", TargetCluster: "cluster-a"},
},
wantCluster: "cluster-a",
wantOK: true,
},
"case insensitive match": {
host: "APP.Example.COM",
customDomains: []*domain.Domain{
{Domain: "*.example.com", TargetCluster: "cluster-a"},
},
wantCluster: "cluster-a",
wantOK: true,
},
"no match returns false": {
host: "other.com",
customDomains: []*domain.Domain{
{Domain: "example.com", TargetCluster: "cluster-a"},
{Domain: "*.example.com", TargetCluster: "cluster-b"},
},
wantCluster: "",
wantOK: false,
},
"empty custom domains returns false": {
host: "example.com",
customDomains: nil,
wantCluster: "",
wantOK: false,
},
"partial suffix does not match": {
host: "notexample.com",
customDomains: []*domain.Domain{
{Domain: "example.com", TargetCluster: "cluster-a"},
},
wantCluster: "",
wantOK: false,
},
"wildcard does not match partial suffix": {
host: "notexample.com",
customDomains: []*domain.Domain{
{Domain: "*.example.com", TargetCluster: "cluster-a"},
},
wantCluster: "",
wantOK: false,
},
}

for name, tc := range tests {
t.Run(name, func(t *testing.T) {
cluster, ok := extractClusterFromCustomDomains(tc.host, tc.customDomains)
if ok != tc.wantOK {
t.Errorf("ok: got %v, want %v", ok, tc.wantOK)
}
if cluster != tc.wantCluster {
t.Errorf("cluster: got %q, want %q", cluster, tc.wantCluster)
}
})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,12 @@ func (v *Validator) ValidateWithCluster(ctx context.Context, domain string, acce
v.Resolver = net.DefaultResolver
}

lookupDomain := "validation." + domain
// For wildcard domains (e.g. *.example.com), validate at the apex (validation.example.com)
validationBase := domain
if strings.HasPrefix(domain, "*.") {
validationBase = domain[2:]
}
lookupDomain := "validation." + validationBase
log.WithFields(log.Fields{
"domain": domain,
"lookupDomain": lookupDomain,
Expand Down
28 changes: 28 additions & 0 deletions proxy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,34 @@ If any authentication methods are registered for the Host domain, then the Proxy
If the user is successfully authenticated, their request will be forwarded through to the Proxy to be proxied to the relevant Peer.
Successful authentication does not guarantee a successful forwarding of the request as there may be failures behind the Proxy, such as with Peer connectivity or the underlying resource.

## Custom Domains

Custom domains allow services to be reached at your own domain name instead of a proxy-assigned subdomain.
Both wildcard and non-wildcard custom domains are supported:

- **Non-wildcard:** Register `example.com` and expose at the apex (`example.com`) or subdomains (`app.example.com`, `api.example.com`, etc.).
- **Wildcard:** Register `*.example.com` to match any subdomain of `example.com` (e.g. `app.example.com`, `api.example.com`). When both a non-wildcard and a wildcard could match (e.g. `example.com` and `*.example.com`), the non-wildcard (exact) match is used.

### DNS Setup for Custom Domains

Ownership of a custom domain is verified via a CNAME record on the `validation` subdomain.
For any custom domain (including apex and wildcard), create:

```
validation.example.com. CNAME <proxy-cluster-address>.
```

For a wildcard custom domain like `*.example.com`, use the same validation record at the apex: `validation.example.com` → `<proxy-cluster-address>`.

To route traffic to the proxy, configure DNS for the service domain:

- **Subdomains** (e.g., `app.example.com`): Create a CNAME record pointing to the proxy cluster address.
- **Apex domains** (e.g., `example.com`): CNAME records at the zone apex are not permitted. Instead, use one of:
- An `A` / `AAAA` record pointing to the proxy's IP address.
- An `ALIAS` or `ANAME` record (if your DNS provider supports it) pointing to the proxy cluster address.

When multiple custom domains could match a service domain (e.g., both `example.com` and `*.example.com`, or both `example.com` and `app.example.com`), the most specific match is used: exact (non-wildcard) matches take precedence over wildcard matches for the same apex, and longer suffixes win otherwise.

## TLS

Due to the authentication provided, the Proxy uses HTTPS for its endpoint, even if the underlying service is HTTP.
Expand Down