Skip to content

[management, proxy] Support multiple L4 port mappings per reverse-proxy domain - #6842

Draft
heywander wants to merge 7 commits into
netbirdio:mainfrom
free-netbird:codex/multiport-pr
Draft

[management, proxy] Support multiple L4 port mappings per reverse-proxy domain#6842
heywander wants to merge 7 commits into
netbirdio:mainfrom
free-netbird:codex/multiport-pr

Conversation

@heywander

@heywander heywander commented Jul 21, 2026

Copy link
Copy Markdown

Describe your changes

This draft implements the multi-port L4 reverse-proxy design requested in #5821 and is being opened for maintainer confirmation of the API/model and hostname-conflict behavior.

  • Add multiple TCP and UDP listener-to-target port mappings to one reverse-proxy service.
  • Support individual ports, contiguous ranges, and listener-to-target translation with matching range sizes.
  • Permit TCP and UDP to reuse the same numeric port.
  • Reject invalid ports, reversed or mismatched ranges, same-protocol overlaps, and conflicting listener ownership across services.
  • Migrate existing single-port services into one-element mapping collections while preserving service IDs and configuration.
  • Generate and distribute every mapping through management, ACL, and proxy-runtime configuration.
  • Allow compatible HTTPS and raw TCP/UDP services to share a hostname when their listeners do not conflict.
  • Preserve HTTP/HTTPS behavior and keep TLS passthrough hostname-exclusive.
  • Preserve legacy single-port API fields for existing clients while exposing the new mapping collection.

Validation

  • Focused management, migration, store, API, ACL, TCP, UDP, and proxy-runtime tests.
  • SQLite, PostgreSQL, and MySQL migration coverage.
  • Race testing on changed management and proxy packages.
  • Go vet and golangci-lint on changed packages.
  • OpenAPI/protobuf generated-output verification.
  • Dashboard production build and coordinated local/VPS Docker smoke testing were completed in the companion fork.

Issue ticket number and link

Closes #5821

Stack

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)
  • This change does not modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — OR I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See CONTRIBUTING.md.

The design discussion is continuing in #5821 and this draft. This checkbox will be updated after maintainer confirmation.

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)

netbirdio/docs#867

Summary by CodeRabbit

  • New Features

    • Added ordered multi-port L4 support for reverse-proxy services via port_mappings, plus capability reporting (supports_port_mappings) and HTTP-domain aware service lookup.
    • Added HTTP-domain scoped service lookup and ephemeral resolution by peer+domain for OIDC/session flows.
  • Bug Fixes

    • Canonicalized domains/hosts end-to-end (service resolution, auth, SNI routing, and OIDC redirects) to prevent mismatches and ownership edge cases.
    • Strengthened port conflict handling, deterministic port assignment, and multi-port update validation.
  • Migration

    • Added migrations to canonicalize shared domains, backfill legacy services into port_mappings, and update ownership/overlap safety checks.

@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Reverse-proxy services gain canonical domain handling, shared HTTP/L4 ownership rules, persistent multi-port mappings, capability-aware delivery, migration support, and runtime routing updates. Related management lookups, proxy lifecycle cleanup, policy generation, and tests are updated accordingly.

Changes

Reverse-proxy service contracts

Layer / File(s) Summary
Service model and API contracts
management/internals/modules/reverseproxy/service/*, shared/management/http/api/*, shared/management/proto/*
Adds canonical domain ownership, ordered port mappings, validation, API/protobuf representations, legacy compatibility, and deep-copy behavior.
Storage and migrations
management/server/store/*, management/server/migration/*
Persists port mappings and domain locks, canonicalizes lookups, backfills legacy mappings, and validates shared-domain migrations.
Management validation and delivery
management/internals/modules/reverseproxy/service/manager/*, management/internals/shared/grpc/*, management/server/types/account.go
Adds locked domain checks, range conflict detection, deterministic port assignment, HTTP-specific lookup, capability filtering, and multi-rule policy generation.
Proxy runtime routing
proxy/server.go, proxy/internal/*
Expands port ranges, normalizes hosts and SNI, enforces service-scoped ownership, keys UDP relays by service and port, and updates snapshot and cleanup behavior.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Management
  participant Store
  participant Proxy
  Client->>Management: submit service with port_mappings
  Management->>Store: validate and persist mappings
  Store-->>Management: service mapping
  Management->>Proxy: send capability-filtered mapping
  Proxy->>Proxy: expand listener and target ranges
  Proxy-->>Management: install or remove runtime routes
Loading

Suggested reviewers: jnfrati

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.74% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes implement multi-port/range mappings, conflict rules, shared-hostname behavior, migration, and runtime distribution required by #5821.
Out of Scope Changes check ✅ Passed No clearly unrelated changes stand out; the auth, ownership, and canonicalization work supports the new shared-domain mapping behavior.
Title check ✅ Passed The title clearly states the main change: supporting multiple L4 port mappings per reverse-proxy domain.
Description check ✅ Passed The description follows the template and includes the change summary, issue, stack, checklist, tests, and docs link.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@CLAassistant

CLAassistant commented Jul 21, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
proxy/internal/tcp/router.go (1)

218-231: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Conditionally cancel the service context to prevent dropping connections on stale removals.

If RemoveRoute is called redundantly or processes a stale removal for a route that no longer exists on this host, slices.DeleteFunc safely ignores the missing route, but r.cancelServiceLocked(svcID) is executed unconditionally.

This will abruptly cancel the shared context for svcID, unintentionally dropping active connections for the service if it had been re-established or if it operates as a fallback route. Compare the length of the slice before and after deletion to ensure the service context is only canceled when a route is actually removed.

🔒️ Proposed fix to conditionally cancel
 func (r *Router) RemoveRoute(host SNIHost, svcID types.ServiceID) {
 	host = SNIHost(netutil.NormalizeHost(string(host)))
 
 	r.mu.Lock()
 	defer r.mu.Unlock()
 
+	initialLen := len(r.routes[host])
 	r.routes[host] = slices.DeleteFunc(r.routes[host], func(route Route) bool {
 		return route.ServiceID == svcID
 	})
 	if len(r.routes[host]) == 0 {
 		delete(r.routes, host)
 	}
-	r.cancelServiceLocked(svcID)
+	if initialLen != len(r.routes[host]) {
+		r.cancelServiceLocked(svcID)
+	}
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@proxy/internal/tcp/router.go` around lines 218 - 231, Update
Router.RemoveRoute to record the host route slice length before
slices.DeleteFunc and cancel svcID via cancelServiceLocked only when deletion
reduces that length; preserve the existing host cleanup behavior.
🧹 Nitpick comments (3)
management/server/store/sql_store_service_test.go (1)

207-268: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Timing-based blocking assertion relies on a fixed 100ms window.

The case <-time.After(100 * time.Millisecond): branch (line 254) asserts non-blocking behavior indirectly by absence of completion within a fixed window. This is a common lock-test pattern but can be flaky under heavy CI load if the first transaction's lock acquisition is slow to actually contend.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@management/server/store/sql_store_service_test.go` around lines 207 - 268,
Replace the fixed 100ms time.After assertion in
TestSqlStore_ServiceDomainLockSerializesAbsentHostname with a deterministic
synchronization mechanism that confirms the second transaction cannot complete
before releaseFirst is closed. Preserve the existing checks that the first
transaction holds the lock, the second completes only after release, and exactly
one canonical domain-lock row is created.
proxy/server.go (1)

1752-1791: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reduce mappingsOwnSameRuntime cognitive complexity to satisfy the failing SonarCloud gate.

SonarCloud reports this method at cognitive complexity 26 (limit 25), which is failing the check. Extracting the HTTP-vs-L4 coexistence branch (the !aL4 || !bL4 block) and the L4-vs-L4 listener match into small helpers would drop it below the threshold without behavior change.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@proxy/server.go` around lines 1752 - 1791, Reduce cognitive complexity in
mappingsOwnSameRuntime by extracting the !aL4 || !bL4 coexistence logic and the
L4-vs-L4 listener matching loop into focused helper methods. Keep
mappingsOwnSameRuntime responsible for classification and delegating to these
helpers, preserving all existing host, listener, fallback, network, and port
matching behavior.

Source: Linters/SAST tools

management/internals/modules/reverseproxy/service/manager/manager.go (1)

417-499: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reduce checkPortConflict cognitive complexity (SonarCloud: 31 vs 25 allowed).

Consider extracting the legacy single-port branch and the multi-port branch into two separate helper methods to bring this under the threshold.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@management/internals/modules/reverseproxy/service/manager/manager.go` around
lines 417 - 499, Reduce the cognitive complexity of checkPortConflict by
extracting its legacy single-port handling and multi-port mapping handling into
separate Manager helper methods. Keep the existing validation, store queries,
conflict rules, error messages, and self-service exclusions unchanged, with
checkPortConflict retaining only shared setup and branch delegation.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@management/internals/modules/reverseproxy/service/manager/manager.go`:
- Around line 841-864: Update samePortBasedListeners to compare filtered
port-based mappings order-independently by sorting both left and right
collections using their protocol and listen-port boundaries before the existing
equality comparison. Preserve nil and non-port-based filtering and all compared
fields.

---

Outside diff comments:
In `@proxy/internal/tcp/router.go`:
- Around line 218-231: Update Router.RemoveRoute to record the host route slice
length before slices.DeleteFunc and cancel svcID via cancelServiceLocked only
when deletion reduces that length; preserve the existing host cleanup behavior.

---

Nitpick comments:
In `@management/internals/modules/reverseproxy/service/manager/manager.go`:
- Around line 417-499: Reduce the cognitive complexity of checkPortConflict by
extracting its legacy single-port handling and multi-port mapping handling into
separate Manager helper methods. Keep the existing validation, store queries,
conflict rules, error messages, and self-service exclusions unchanged, with
checkPortConflict retaining only shared setup and branch delegation.

In `@management/server/store/sql_store_service_test.go`:
- Around line 207-268: Replace the fixed 100ms time.After assertion in
TestSqlStore_ServiceDomainLockSerializesAbsentHostname with a deterministic
synchronization mechanism that confirms the second transaction cannot complete
before releaseFirst is closed. Preserve the existing checks that the first
transaction holds the lock, the second completes only after release, and exactly
one canonical domain-lock row is created.

In `@proxy/server.go`:
- Around line 1752-1791: Reduce cognitive complexity in mappingsOwnSameRuntime
by extracting the !aL4 || !bL4 coexistence logic and the L4-vs-L4 listener
matching loop into focused helper methods. Keep mappingsOwnSameRuntime
responsible for classification and delegating to these helpers, preserving all
existing host, listener, fallback, network, and port matching behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: a9a1714e-6cab-4d9a-8e31-24da0a0c62e2

📥 Commits

Reviewing files that changed from the base of the PR and between 51f17bf and 314c298.

⛔ Files ignored due to path filters (1)
  • shared/management/proto/proxy_service.pb.go is excluded by !**/*.pb.go
📒 Files selected for processing (49)
  • management/internals/modules/agentnetwork/synthesizer.go
  • management/internals/modules/agentnetwork/synthesizer_test.go
  • management/internals/modules/reverseproxy/proxy/proxy.go
  • management/internals/modules/reverseproxy/service/interface.go
  • management/internals/modules/reverseproxy/service/interface_mock.go
  • management/internals/modules/reverseproxy/service/manager/l4_port_test.go
  • management/internals/modules/reverseproxy/service/manager/manager.go
  • management/internals/modules/reverseproxy/service/manager/manager_test.go
  • management/internals/modules/reverseproxy/service/multiport_test.go
  • management/internals/modules/reverseproxy/service/service.go
  • management/internals/modules/reverseproxy/service/service_test.go
  • management/internals/shared/grpc/expose_service.go
  • management/internals/shared/grpc/proxy.go
  • management/internals/shared/grpc/proxy_group_access_test.go
  • management/internals/shared/grpc/proxy_test.go
  • management/internals/shared/grpc/validate_session_test.go
  • management/server/http/handlers/proxy/auth_callback_integration_test.go
  • management/server/migration/reverse_proxy_port_mappings.go
  • management/server/migration/reverse_proxy_port_mappings_test.go
  • management/server/migration/reverse_proxy_shared_domains.go
  • management/server/migration/reverse_proxy_shared_domains_test.go
  • management/server/store/sql_store.go
  • management/server/store/sql_store_service_test.go
  • management/server/store/store.go
  • management/server/store/store_mock.go
  • management/server/types/account.go
  • management/server/types/account_proxy_multiport_test.go
  • proxy/internal/acme/manager.go
  • proxy/internal/acme/manager_test.go
  • proxy/internal/auth/middleware.go
  • proxy/internal/auth/middleware_test.go
  • proxy/internal/auth/oidc.go
  • proxy/internal/auth/oidc_test.go
  • proxy/internal/conntrack/hijacked.go
  • proxy/internal/conntrack/hijacked_test.go
  • proxy/internal/netutil/host.go
  • proxy/internal/netutil/host_test.go
  • proxy/internal/proxy/reverseproxy_test.go
  • proxy/internal/proxy/servicemapping.go
  • proxy/internal/roundtrip/netbird.go
  • proxy/internal/tcp/router.go
  • proxy/internal/tcp/router_test.go
  • proxy/management_integration_test.go
  • proxy/process_mappings_bench_test.go
  • proxy/server.go
  • proxy/server_test.go
  • shared/management/http/api/openapi.yml
  • shared/management/http/api/types.gen.go
  • shared/management/proto/proxy_service.proto

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@management/internals/modules/reverseproxy/service/service_api.go`:
- Around line 74-93: Update servicePortMappingsToAPI so response conversion does
not mutate the received Service. Replace the call to
PopulatePortMappingsFromLegacy on service with equivalent population into a
local mappings source or perform it on an owned copy, while preserving legacy
mapping conversion and nil-entry handling.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 48e69c2f-9e6a-457b-a2ce-5376bbeff8b5

📥 Commits

Reviewing files that changed from the base of the PR and between 314c298 and 1f94b3f.

📒 Files selected for processing (6)
  • management/internals/modules/agentnetwork/synthesizer.go
  • management/internals/modules/reverseproxy/service/manager/manager.go
  • management/internals/modules/reverseproxy/service/service.go
  • management/internals/modules/reverseproxy/service/service_api.go
  • management/server/store/sql_store.go
  • proxy/server.go
🚧 Files skipped from review as they are similar to previous changes (3)
  • management/internals/modules/agentnetwork/synthesizer.go
  • management/internals/modules/reverseproxy/service/manager/manager.go
  • management/server/store/sql_store.go

Comment thread management/internals/modules/reverseproxy/service/service_api.go
@heywander
heywander force-pushed the codex/multiport-pr branch from 20077a5 to 54ca8e1 Compare July 21, 2026 17:16
@sonarqubecloud

Copy link
Copy Markdown

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.

L4-Reverse Proxy: Allow forwarding of multiple ports/ranges for one subdomain.

2 participants