Skip to content

[client] Add unicast UPnP discovery for port forwarding.#6650

Open
jnfrati wants to merge 3 commits into
mainfrom
fix/fail-to-create-upnp-port-mapping-on-opnsense-firewall
Open

[client] Add unicast UPnP discovery for port forwarding.#6650
jnfrati wants to merge 3 commits into
mainfrom
fix/fail-to-create-upnp-port-mapping-on-opnsense-firewall

Conversation

@jnfrati

@jnfrati jnfrati commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Describe your changes

Add a UPnP IGD discovery fallback that sends unicast SSDP M-SEARCH requests directly to the default gateway when PCP and multicast discovery do not find a gateway.

Issue ticket number and link

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.

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 not needed as this is a fix to existing implementation

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
    • Expanded port-forwarding gateway discovery to include unicast UPnP Internet Gateway Devices (in addition to existing methods).
    • Added UPnP-based NAT port mapping with automatic renewal that reuses the previously selected external port.
  • Bug Fixes
    • Improved fallback behavior when one discovery approach fails, increasing the likelihood of finding a compatible gateway.
    • Enhanced lease handling for routers that require a “permanent lease” retry behavior.
  • Tests
    • Added coverage for UPnP discovery, mapping renewal, fallback scenarios, and error/lease-code behavior.

Add a UPnP IGD discovery fallback that sends unicast SSDP M-SEARCH requests directly to the default gateway when PCP and multicast discovery do not find a gateway.
@jnfrati jnfrati requested a review from lixmal July 2, 2026 13:30
@jnfrati jnfrati self-assigned this Jul 2, 2026
@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 73d4f791-311b-43ec-9567-3f6b7e540117

📥 Commits

Reviewing files that changed from the base of the PR and between c0cb24b and 59a09b0.

📒 Files selected for processing (1)
  • client/internal/portforward/upnp/upnp.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • client/internal/portforward/upnp/upnp.go

📝 Walkthrough

Walkthrough

Adds unicast UPnP/IGD discovery and NAT port mapping, wires it into gateway discovery as a PCP fallback, and adds tests plus a direct goupnp dependency.

Changes

Unicast UPnP Discovery

Layer / File(s) Summary
Gateway discovery fallback wiring
client/internal/portforward/state.go
Runs unicast UPnP discovery concurrently with nat.DiscoverGateway after PCP lookup fails, and adds a package-level discovery hook for tests.
Unicast SSDP discovery and NAT construction
client/internal/portforward/upnp/upnp.go, client/internal/portforward/upnp/nat.go
Implements unicast SSDP search, default-gateway resolution, device-description lookup, WAN service selection, and the UPnP NAT wrapper with address and port-mapping methods.
Fake IGD tests and dependency update
client/internal/portforward/upnp/upnp_test.go, go.mod
Adds a fake IGD test server with SSDP and SOAP handling, tests discovery and mapping behavior, and updates go.mod to keep goupnp as a direct dependency.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

  • netbirdio/netbird#5202: Both PRs touch port-forward gateway discovery behavior in client/internal/portforward/state.go.
  • netbirdio/netbird#5219: Both PRs modify the PCP-first gateway discovery path and its fallback behavior in state.go.
  • netbirdio/netbird#5826: Both PRs involve UPnP permanent-lease handling and retry behavior around error code 725.

Suggested reviewers: pappz

Poem

I twitched my nose and listened near,
To SSDP whispers on the breeze;
A gateway map appeared quite clear,
With ports and leases at ease.
🐇 Hop hop — the burrow’s bright,
UPnP lit up the night.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and directly matches the main change: adding unicast UPnP discovery for port forwarding.
Description check ✅ Passed The description follows the template and covers the change, checklist, and docs note; only the issue ticket and stack fields are blank.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/fail-to-create-upnp-port-mapping-on-opnsense-firewall

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.

@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown

Release artifacts

Built for PR head 59a09b0 in workflow run #16498.

Artifact Link
All release artifacts Download
Linux packages Download
Windows packages Download
macOS packages Download
UI artifacts Download
UI macOS artifacts Download

GHCR images (amd64)

This comment is updated by the Release workflow. Artifact links expire according to the workflow retention policy.

@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: 3

🤖 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 `@client/internal/portforward/upnp/nat.go`:
- Around line 124-140: DeletePortMapping currently relies on NAT.ports cache
state, so a freshly rediscovered gateway in Cleanup() will no-op and leave the
router mapping behind. Update the cleanup flow so the external port is preserved
outside the ephemeral NAT instance, likely on State alongside InternalPort and
Protocol, or make Cleanup() reuse the same gateway/client used by
AddPortMapping. Then adjust NAT.DeletePortMapping to use the persisted external
port instead of returning nil when n.ports[internalPort] is empty.
- Around line 44-51: GetDeviceAddress and GetInternalAddress should not resolve
n.root.URLBase.Host through DNS, since that host comes from untrusted IGD XML
and can block indefinitely. Update these paths to avoid name resolution entirely
by parsing only literal IPs from URLBase.Host (for example, extracting the host
component and validating it as an IP) and reject or handle non-literal hosts
explicitly. Keep the same safety approach in both NAT methods so they behave
consistently with GetExternalAddress’s bounded network behavior.

In `@client/internal/portforward/upnp/upnp_test.go`:
- Around line 162-165: The SOAP request body handling in fakeIGD.handleSOAP can
truncate or panic because it assumes r.ContentLength is reliable and uses a
single Body.Read call. Replace this with a full read of the request body in
handleSOAP so the test always captures the complete SOAP payload, and avoid
allocating based directly on ContentLength. This will keep the later
mappingBody/deleteBody assertions accurate even when the request is split across
reads or uses chunked transfer.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 44f7b234-dd1a-4b7c-9532-5a160edc1b86

📥 Commits

Reviewing files that changed from the base of the PR and between 859fe19 and c0cb24b.

📒 Files selected for processing (5)
  • client/internal/portforward/state.go
  • client/internal/portforward/upnp/nat.go
  • client/internal/portforward/upnp/upnp.go
  • client/internal/portforward/upnp/upnp_test.go
  • go.mod

Comment on lines +44 to +51
// GetDeviceAddress returns the internal address of the gateway device.
func (n *NAT) GetDeviceAddress() (net.IP, error) {
addr, err := net.ResolveUDPAddr("udp4", n.root.URLBase.Host)
if err != nil {
return nil, err
}
return addr.IP, nil
}

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Unbounded host resolution on untrusted IGD data.

GetDeviceAddress/GetInternalAddress resolve n.root.URLBase.Host via net.ResolveUDPAddr/net.Dial with no timeout, unlike GetExternalAddress which wraps its call in a 10s context. URLBase.Host comes from the untrusted device-description XML returned by whatever answered the SSDP search, so if it's a hostname (not a literal IP) this can trigger an unbounded DNS lookup.

🛡️ Suggested fix avoiding DNS resolution entirely
 func (n *NAT) GetDeviceAddress() (net.IP, error) {
-	addr, err := net.ResolveUDPAddr("udp4", n.root.URLBase.Host)
+	host, _, err := net.SplitHostPort(n.root.URLBase.Host)
 	if err != nil {
 		return nil, err
 	}
-	return addr.IP, nil
+	ip := net.ParseIP(host)
+	if ip == nil {
+		return nil, fmt.Errorf("device URLBase host is not an IP: %s", host)
+	}
+	return ip, nil
 }

Also applies to: 71-85

🤖 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 `@client/internal/portforward/upnp/nat.go` around lines 44 - 51,
GetDeviceAddress and GetInternalAddress should not resolve n.root.URLBase.Host
through DNS, since that host comes from untrusted IGD XML and can block
indefinitely. Update these paths to avoid name resolution entirely by parsing
only literal IPs from URLBase.Host (for example, extracting the host component
and validating it as an IP) and reject or handle non-literal hosts explicitly.
Keep the same safety approach in both NAT methods so they behave consistently
with GetExternalAddress’s bounded network behavior.

Comment on lines +124 to +140
// DeletePortMapping removes a port mapping.
func (n *NAT) DeletePortMapping(ctx context.Context, protocol string, internalPort int) error {
proto, err := mapProtocol(protocol)
if err != nil {
return err
}

n.mu.Lock()
externalPort := n.ports[internalPort]
delete(n.ports, internalPort)
n.mu.Unlock()

if externalPort == 0 {
return nil
}
return n.client.DeletePortMappingCtx(ctx, "", uint16(externalPort), proto)
}

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.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

DeletePortMapping silently no-ops when the external port isn't cached — breaks cleanup on freshly-discovered gateways.

DeletePortMapping only calls DeletePortMappingCtx when n.ports[internalPort] > 0; otherwise it returns nil (success) without touching the gateway. Since state.go's Cleanup() calls discoverGateway(ctx) fresh, and natFromService always constructs a brand-new &NAT{ports: make(map[int]int)} (line 215), the ports map at cleanup time is always empty for the unicast UPnP path — meaning the actual router-side port mapping is never deleted, and the caller has no way to know cleanup silently failed. The stale mapping only disappears once its own UPnP lease expires.

Consider persisting the external port outside the ephemeral NAT instance (e.g. on the State struct alongside InternalPort/Protocol) so it survives rediscovery, or have Cleanup() reuse the same gateway instance used for the original AddPortMapping.

🧰 Tools
🪛 ast-grep (0.44.0)

[warning] 138-138: Narrowing a non-constant integer to a smaller fixed-width type (int8/int16/int32, uint8/uint16/uint32) can silently overflow or wrap, yielding negative or truncated values that are dangerous in size, length, or index logic. Validate the source value is within the target type's range before converting (e.g. bounds-check, or use a checked helper), and avoid narrowing untrusted or len()/parsed values.
Context: uint16(externalPort)
Note: [CWE-190] Integer Overflow or Wraparound.

(integer-overflow-narrowing-conversion-go)

🤖 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 `@client/internal/portforward/upnp/nat.go` around lines 124 - 140,
DeletePortMapping currently relies on NAT.ports cache state, so a freshly
rediscovered gateway in Cleanup() will no-op and leave the router mapping
behind. Update the cleanup flow so the external port is preserved outside the
ephemeral NAT instance, likely on State alongside InternalPort and Protocol, or
make Cleanup() reuse the same gateway/client used by AddPortMapping. Then adjust
NAT.DeletePortMapping to use the persisted external port instead of returning
nil when n.ports[internalPort] is empty.

Comment on lines +162 to +165
func (f *fakeIGD) handleSOAP(w http.ResponseWriter, r *http.Request) {
body := make([]byte, r.ContentLength)
_, _ = r.Body.Read(body)

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Body read can silently truncate the SOAP payload.

r.Body.Read(body) performs a single Read call, but io.Reader may return fewer bytes than requested even when more data is available (this is explicitly allowed by the interface contract). Combined with make([]byte, r.ContentLength), this can leave body incomplete (padded with zero bytes) if the request arrives in multiple TCP reads, which would silently corrupt the assert.Contains checks on mappingBody/deleteBody later in the tests. It would also panic with a negative-length slice if ContentLength is ever -1 (e.g., chunked transfer).

🐛 Proposed fix using io.ReadAll
+	"io"
+
 func (f *fakeIGD) handleSOAP(w http.ResponseWriter, r *http.Request) {
-	body := make([]byte, r.ContentLength)
-	_, _ = r.Body.Read(body)
+	body, _ := io.ReadAll(r.Body)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func (f *fakeIGD) handleSOAP(w http.ResponseWriter, r *http.Request) {
body := make([]byte, r.ContentLength)
_, _ = r.Body.Read(body)
func (f *fakeIGD) handleSOAP(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
🤖 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 `@client/internal/portforward/upnp/upnp_test.go` around lines 162 - 165, The
SOAP request body handling in fakeIGD.handleSOAP can truncate or panic because
it assumes r.ContentLength is reliable and uses a single Body.Read call. Replace
this with a full read of the request body in handleSOAP so the test always
captures the complete SOAP payload, and avoid allocating based directly on
ContentLength. This will keep the later mappingBody/deleteBody assertions
accurate even when the request is split across reads or uses chunked transfer.

@sonarqubecloud

sonarqubecloud Bot commented Jul 6, 2026

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.

1 participant