Skip to content

Allow Proxying Domain without Wildcard#5468

Open
kritgrover wants to merge 10 commits intonetbirdio:mainfrom
kritgrover:main
Open

Allow Proxying Domain without Wildcard#5468
kritgrover wants to merge 10 commits intonetbirdio:mainfrom
kritgrover:main

Conversation

@kritgrover
Copy link
Copy Markdown

@kritgrover kritgrover commented Feb 27, 2026

Describe your changes

  • Custom-domain validation: Updated the reverse proxy domain validator to resolve CNAMEs for both wildcard and non-wildcard custom domains by always querying validation. (e.g. validation.example.com for example.com and *.example.com), and normalizing DNS responses/accept lists.
  • Cluster derivation for custom domains: Reworked extractClusterFromCustomDomains to support exact (non-wildcard) and wildcard custom domains with deterministic precedence: exact non-wildcard matches win first, then among wildcard and non-wildcard suffix matches the longest matching suffix is selected.
  • Service creation: Ensured custom domains are validated using the shared IsValidDomain helper when creating domains, allowing both wildcard and non-wildcard inputs while rejecting invalid hostnames.
  • Tests: Added unit tests for the new cluster-derivation logic and for wildcard/non-wildcard CNAME validation behavior (including lookup domain and CNAME normalization).
  • Docs: Expanded proxy/README.md with a “Custom Domains” section describing how to configure non-wildcard vs wildcard domains, the validation. CNAME requirement, and DNS setup at the apex vs subdomains.

Issue ticket number and link

Closes #5417
Link: #5417

Stack

This PR is standalone and not part of a stacked series.

Checklist

  • Is it a bug fix
  • Is a typo/documentation fix
  • Is a feature enhancement
  • It is a refactor
  • Created tests that fail without the change (if possible)

By submitting this pull request, you confirm that you have read and agree to the terms of the Contributor License Agreement.

Documentation

Select exactly one:

  • I added/updated documentation for this change
  • Documentation is not needed for this change (explain why)

Docs PR URL (required if "docs added" is checked)

Paste the PR link from https://github.com/netbirdio/docs here:

https://github.com/netbirdio/docs/pull/__

Summary by CodeRabbit

  • New Features

    • Enhanced custom domain routing with exact, wildcard, and subdomain matching; prioritizes exact matches and longest matching suffix for wildcards
    • Improved domain name validation with trimming, lowercasing, and normalization
    • Adjusted DNS validation for wildcard domains to normalize lookup targets
  • Tests

    • Added comprehensive tests covering exact, wildcard, apex, subdomain, normalization, case-insensitivity, and negative cases
  • Documentation

    • Updated README documenting custom domain support, DNS validation, and matching precedence

@CLAassistant
Copy link
Copy Markdown

CLAassistant commented Feb 27, 2026

CLA assistant check
All committers have signed the CLA.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 27, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 87a37445-7141-44ee-bb9b-21055eb92f57

📥 Commits

Reviewing files that changed from the base of the PR and between e1638e6 and 52fc680.

📒 Files selected for processing (1)
  • management/internals/modules/reverseproxy/domain/manager/manager.go

📝 Walkthrough

Walkthrough

Adds domain normalization and validation, refactors custom-domain matching to prefer exact non-wildcard matches then longest-suffix wildcard/subdomain matches, updates DNS validation for wildcard inputs, adds unit tests for matching logic, and documents custom-domain behavior.

Changes

Cohort / File(s) Summary
Domain Manager & Matching
management/internals/modules/reverseproxy/domain/manager/manager.go
Adds trimming/lowercasing and domain validation in CreateDomain; replaces custom-domain extraction with host-based matcher that prioritizes exact non-wildcard matches, then selects wildcard/subdomain matches by longest matching suffix.
DNS Validation
management/internals/modules/reverseproxy/domain/validator.go
For wildcard inputs, strips leading *. to derive base domain and constructs validation.<base> for DNS/CNAME lookup; non-wildcard behavior unchanged.
Tests
management/internals/modules/reverseproxy/domain/manager/manager_test.go
Adds TestExtractClusterFromCustomDomains covering exact, wildcard, apex wildcard, subdomain, trailing-dot and case normalization, prioritization (exact > wildcard, longest suffix wins), and negative cases.
Documentation
proxy/README.md
Adds "Custom Domains" section describing non-wildcard and wildcard support, DNS validation expectations, apex vs subdomain handling, and precedence rules.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client
    participant Manager as Manager
    participant ClusterStore as ClusterStore
    participant Validator as Validator
    participant DNS as DNS

    Client->>Manager: Request route for host
    Manager->>Manager: Normalize host (trim, lowercase)
    Manager->>ClusterStore: Query custom domain rules
    alt Exact non-wildcard match
        ClusterStore-->>Manager: Exact cluster
    else No exact match
        ClusterStore-->>Manager: Wildcard/subdomain candidates
        Manager->>Validator: Normalize wildcard candidates (strip "*.")
        Validator->>DNS: Resolve validation.<base> for candidates
        DNS-->>Validator: CNAME/response
        Validator-->>Manager: Validation results
        Manager->>ClusterStore: Select candidate with longest matching suffix
        ClusterStore-->>Manager: Selected cluster
    end
    Manager-->>Client: Return cluster or not found
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • mlsmaycon
  • pascal-fischer

Poem

🐰
I trim the dots and soften case,
Exact hops first to win the race.
Wildcards yield to longest tail,
A cozy route on every trail —
Hop, match, and forward, safe and ace!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Allow Proxying Domain without Wildcard' is concise, clear, and directly summarizes the main feature being added - support for non-wildcard domains in the reverse proxy.
Description check ✅ Passed The description follows the template structure with clear sections on changes, issue link, checklist completion, and documentation updates indicating a feature enhancement with tests and docs.
Linked Issues check ✅ Passed The PR fully implements the objectives from issue #5417: adds non-wildcard CNAME support, implements validation for apex domains, supports deterministic matching precedence (exact over wildcard, longest suffix), and includes comprehensive tests and documentation.
Out of Scope Changes check ✅ Passed All code changes are directly related to the linked issue objective: domain validation refactoring, custom domain extraction logic, CNAME resolution for both wildcard/non-wildcard domains, corresponding tests, and documentation - no extraneous changes detected.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Tip

You can disable poems in the walkthrough.

Disable the reviews.poem setting to disable the poems in the walkthrough.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
management/internals/modules/reverseproxy/domain/manager/manager_test.go (1)

108-113: Consider adding an empty host test case.

The tests cover empty customDomains but not an empty host. While unlikely in practice, adding this edge case would ensure the function handles it gracefully.

📝 Suggested test case
"empty host returns false": {
	host: "",
	customDomains: []*domain.Domain{
		{Domain: "example.com", TargetCluster: "cluster-a"},
	},
	wantCluster: "",
	wantOK:      false,
},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@management/internals/modules/reverseproxy/domain/manager/manager_test.go`
around lines 108 - 113, Add a new table-driven test case in manager_test.go
alongside the existing cases (e.g., next to "empty custom domains returns
false") named "empty host returns false" that calls the same function under test
(the manager lookup function used in the existing test) with host set to "" and
customDomains set to a slice containing one domain.Domain{Domain: "example.com",
TargetCluster: "cluster-a"}, and assert wantCluster == "" and wantOK == false so
the function correctly handles an empty host input.
proxy/README.md (1)

49-51: Add language specification to the fenced code block.

Per static analysis, the code block should have a language specified for proper syntax highlighting. Since this is DNS configuration, dns or text would be appropriate.

📝 Suggested fix
-```
+```text
 validation.example.com.  CNAME  <proxy-cluster-address>.
</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

Verify each finding against the current code and only fix it if needed.

In @proxy/README.md around lines 49 - 51, Update the fenced code block that
currently contains the DNS line "validation.example.com. CNAME
." to include a language specifier (e.g., text or dns) so the block becomes text (or dns) ... ``` for proper syntax
highlighting in the README; locate the block in proxy/README.md that wraps that
exact line and add the language token immediately after the opening backticks.


</details>

</blockquote></details>
<details>
<summary>management/internals/modules/reverseproxy/domain/manager/manager.go (1)</summary><blockquote>

`290-307`: **Minor: redundant normalization in the second loop.**

`normalizedCD` at line 291 re-normalizes domains that were already normalized in the first loop (line 280). While functionally correct, you could pre-normalize custom domains once and reuse the results. This is a minor optimization and acceptable to defer.

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against the current code and only fix it if needed.

In `@management/internals/modules/reverseproxy/domain/manager/manager.go` around
lines 290 - 307, customDomains are being normalized repeatedly inside the loop
via the normalizedCD variable; precompute and reuse normalized domain values to
avoid redundant work. Create a preprocessed collection (e.g., map or slice) that
stores each domain's normalized form
(strings.ToLower(strings.TrimSuffix(cd.Domain, "."))) alongside its
TargetCluster before the matching loop, then update the matching logic that uses
normalizedCD and TargetCluster (and compares to normalizedHost) to read from the
precomputed normalized value instead of recomputing it per iteration.
```

</details>

</blockquote></details>

</blockquote></details>

<details>
<summary>🤖 Prompt for all review comments with AI agents</summary>

Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In @management/internals/modules/reverseproxy/domain/manager/manager_test.go:

  • Around line 108-113: Add a new table-driven test case in manager_test.go
    alongside the existing cases (e.g., next to "empty custom domains returns
    false") named "empty host returns false" that calls the same function under test
    (the manager lookup function used in the existing test) with host set to "" and
    customDomains set to a slice containing one domain.Domain{Domain: "example.com",
    TargetCluster: "cluster-a"}, and assert wantCluster == "" and wantOK == false so
    the function correctly handles an empty host input.

In @management/internals/modules/reverseproxy/domain/manager/manager.go:

  • Around line 290-307: customDomains are being normalized repeatedly inside the
    loop via the normalizedCD variable; precompute and reuse normalized domain
    values to avoid redundant work. Create a preprocessed collection (e.g., map or
    slice) that stores each domain's normalized form
    (strings.ToLower(strings.TrimSuffix(cd.Domain, "."))) alongside its
    TargetCluster before the matching loop, then update the matching logic that uses
    normalizedCD and TargetCluster (and compares to normalizedHost) to read from the
    precomputed normalized value instead of recomputing it per iteration.

In @proxy/README.md:

  • Around line 49-51: Update the fenced code block that currently contains the
    DNS line "validation.example.com. CNAME ." to include a
    language specifier (e.g., text or dns) so the block becomes text (or dns) ... ``` for proper syntax highlighting in the README; locate the block
    in proxy/README.md that wraps that exact line and add the language token
    immediately after the opening backticks.

</details>

---

<details>
<summary>ℹ️ Review info</summary>

**Configuration used**: defaults

**Review profile**: CHILL

**Plan**: Pro

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 333e0450993354323c5d181ee45730b9e7e361f1 and 8c92e70ac3371cfad7d86812c2e2f334ea57dffe.

</details>

<details>
<summary>📒 Files selected for processing (4)</summary>

* `management/internals/modules/reverseproxy/domain/manager/manager.go`
* `management/internals/modules/reverseproxy/domain/manager/manager_test.go`
* `management/internals/modules/reverseproxy/domain/validator.go`
* `proxy/README.md`

</details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->

@mlsmaycon
Copy link
Copy Markdown
Collaborator

@kritgrover thanks for your contribution. We are waiting for a refactor on the proxy code from #5472, which caused some merge conflicts. After that, we can review this one.

@kritgrover
Copy link
Copy Markdown
Author

@mlsmaycon sounds good!

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
management/internals/modules/reverseproxy/domain/manager/manager.go (1)

279-317: Implementation is correct and deterministic.

The logic correctly handles all matching scenarios:

  1. Exact non-wildcard matches are prioritized and return immediately
  2. The fallback uses the longest-suffix matching strategy for wildcards and subdomains
  3. Normalization is consistent across host and domain processing

The comparison operator > (strictly greater-than) ensures deterministic behavior: when multiple custom domains match with identical suffix lengths, the first-processed domain is selected. This is consistent with the documented behavior ("the longest matching suffix wins").

One optional enhancement: add a test case explicitly covering mixed wildcard and non-wildcard domains with equal suffix lengths (e.g., host "sub.example.com" matching both "*.example.com" and "example.com") to clarify this edge-case behavior for future maintainers.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@management/internals/modules/reverseproxy/domain/manager/manager.go` around
lines 279 - 317, Add a unit test for extractClusterFromCustomDomains that covers
the mixed wildcard/non-wildcard edge case: create customDomains containing both
a non-wildcard "example.com" and a wildcard "*.example.com" (with different
TargetCluster values), call extractClusterFromCustomDomains with host
"sub.example.com" (and variants with trailing dot and mixed case) and assert the
function returns the deterministic winner (the first-processed domain when
suffix lengths are equal) and true; include assertions for normalization (case
and trailing dot) to prevent regressions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@management/internals/modules/reverseproxy/domain/manager/manager.go`:
- Around line 279-317: Add a unit test for extractClusterFromCustomDomains that
covers the mixed wildcard/non-wildcard edge case: create customDomains
containing both a non-wildcard "example.com" and a wildcard "*.example.com"
(with different TargetCluster values), call extractClusterFromCustomDomains with
host "sub.example.com" (and variants with trailing dot and mixed case) and
assert the function returns the deterministic winner (the first-processed domain
when suffix lengths are equal) and true; include assertions for normalization
(case and trailing dot) to prevent regressions.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8c92e70 and e1638e6.

📒 Files selected for processing (1)
  • management/internals/modules/reverseproxy/domain/manager/manager.go

@kritgrover
Copy link
Copy Markdown
Author

We are waiting for a refactor on the proxy code from #5472, which caused some merge conflicts. After that, we can review this one.

@mlsmaycon any update on this?

@Shaalan15
Copy link
Copy Markdown

Hey! I see that this PR is still out while #5472 was closed. If there's any help needed on this PR, please let me know, I can dedicate some good time to this (personal interest lol).

@sonarqubecloud
Copy link
Copy Markdown

@kunumigab
Copy link
Copy Markdown

Any news on this pr?

@bmachek
Copy link
Copy Markdown

bmachek commented Mar 28, 2026

+1

@kunumigab
Copy link
Copy Markdown

kunumigab commented Mar 28, 2026

For now i was able to proxy www and 301 root domain to it in order to expose my site, but will be better to have the option.

Btw, i'm using netbird cloud not selfhost

@jsardev
Copy link
Copy Markdown

jsardev commented Apr 19, 2026

Would be great to have this merged 🙏

@palandri
Copy link
Copy Markdown

Yep, we are looking for this feature too.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Allow proxying domain without wildcard

8 participants