Skip to content

[client] Handle UPnP routers that only support permanent leases#5826

Merged
lixmal merged 2 commits intomainfrom
portforward-permanent-lease
Apr 8, 2026
Merged

[client] Handle UPnP routers that only support permanent leases#5826
lixmal merged 2 commits intomainfrom
portforward-permanent-lease

Conversation

@lixmal
Copy link
Copy Markdown
Collaborator

@lixmal lixmal commented Apr 8, 2026

Describe your changes

Some routers reject time-limited UPnP port mappings with SOAP error 725 (OnlyPermanentLeasesSupported). This adds fallback handling and cleans up dead code.

  • Detect UPnP error 725 via regex and retry with indefinite lease duration
  • Skip renewal loop for permanent mappings since they don't expire
  • Remove dead State/Cleanup code that was never wired up
  • Remove log-and-return anti-pattern from setup, let caller decide log level
  • Downgrade "no NAT found" from error to info since it's a normal case

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)

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)

Internal behavior change, no user-facing config or API changes.

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

    • Port mapping renewal timing now adjusts dynamically to the device lease duration.
    • Automatic fallback for devices that only accept permanent (non-expiring) leases.
  • Bug Fixes

    • Improved retry behavior and clearer reporting for port-forward setup and renewal.
    • Reduced noise for setup failures by lowering log severity.
  • Chores

    • Removed legacy port-forward state recovery code.
  • Tests

    • Added tests validating permanent-lease detection and fallback behavior.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 8, 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: ed6b0ae6-8e11-4013-a95d-b9502b96c2d6

📥 Commits

Reviewing files that changed from the base of the PR and between 501745e and a78e73a.

📒 Files selected for processing (3)
  • client/internal/portforward/manager.go
  • client/internal/portforward/manager_js.go
  • client/internal/portforward/manager_test.go
✅ Files skipped from review due to trivial changes (1)
  • client/internal/portforward/manager_js.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • client/internal/portforward/manager.go

📝 Walkthrough

Walkthrough

Port-forward manager now carries mapping TTL through setup and renewal; it falls back to permanent leases (TTL 0) on UPnP error 725 and adjusts renewal cadence to ttl/2 (permanent leases block until cancelled). The persisted portforward.State implementation was removed and related comments cleaned up.

Changes

Cohort / File(s) Summary
Manager TTL and renewal logic
client/internal/portforward/manager.go
Mapping gained TTL time.Duration; setup/createMapping set mapping TTL; Start/renewLoop accept TTL and use ttl/2 ticker; when ttl==0 renewal blocks until cancellation; detects UPnP error 725 and retries AddPortMapping with timeout=0; errors wrapped with contextual messages and setup failures logged at info.
Manager test coverage
client/internal/portforward/manager_test.go
mockNAT extended with onlyPermanentLeases and lastTimeout; AddPortMapping simulates UPnP fault 725 for non-zero timeouts and records successful timeout; tests added/updated to assert TTL behavior and permanent-lease fallback; new table-driven test validates isPermanentLeaseRequired detection across error shapes.
State removal (crash-recovery)
client/internal/portforward/state.go
Deleted file: removes exported State type, its Name() and Cleanup() methods, and discoverGateway variable used for cleanup.
State registration comments cleaned
client/server/state_generic.go, client/server/state_linux.go
Removed explanatory block comments about portforward.State registration; no functional changes.
JS/wasm Manager mapping struct
client/internal/portforward/manager_js.go
Mapping struct comment updated and TTL time.Duration field added (0 = permanent lease); Manager remains a stub for js build.

Sequence Diagram

sequenceDiagram
    actor Client
    participant Manager
    participant Gateway as NAT Gateway
    participant UPnP as UPnP Device

    Client->>Manager: CreateMapping(internalPort, timeout=defaultMappingTTL)
    Manager->>Gateway: DiscoverGateway()
    Gateway-->>Manager: gateway
    Manager->>UPnP: AddPortMapping(timeout=defaultMappingTTL)
    
    alt Error 725 (permanent lease required)
        UPnP-->>Manager: UPnP fault 725
        Manager->>Manager: Detect error 725 with regex
        Manager->>UPnP: AddPortMapping(timeout=0)
        UPnP-->>Manager: success (ttl=0)
        Manager-->>Client: (gateway, mapping.TTL=0)
    else Success
        UPnP-->>Manager: success (ttl=defaultMappingTTL)
        Manager-->>Client: (gateway, mapping.TTL=defaultMappingTTL)
    end
    
    alt ttl > 0 (temporary lease)
        Manager->>Manager: renewLoop with ticker(ttl/2)
        loop Every ttl/2
            Manager->>UPnP: AddPortMapping(timeout=ttl)
            UPnP-->>Manager: success
        end
    else ttl == 0 (permanent lease)
        Manager->>Manager: renewLoop blocks on cancellation
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested reviewers

  • pappz

"I hop and map each port with glee,
TTL or forever, as routers decree,
If 725 whispers 'forever be',
I retry with zero, and rest easy.
Hooray for tidy renewals and deleted state!" 🐇✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title directly addresses the main change: handling UPnP routers that only support permanent leases, which is a clear and specific summary of the core modification.
Description check ✅ Passed The description covers all key changes, includes issue context, completes the template checklist with explicit selections (bug fix, feature enhancement, tests added), and clearly explains why documentation is not needed.

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

✨ 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 portforward-permanent-lease

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.

@lixmal lixmal force-pushed the portforward-permanent-lease branch from eb5352d to 501745e Compare April 8, 2026 11:55
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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@client/internal/engine.go`:
- Line 279: The JS build fails because portforward.NewManager is called with one
argument (services.StateManager) but the JS stub in manager_js.go currently has
signature func NewManager() *Manager causing a mismatch; update the JS stub
signature in manager_js.go to accept the same parameter type as the real
implementation (e.g., func NewManager(stateManager *statemanager.Manager)
*Manager) and return &Manager{} so calls to
portforward.NewManager(services.StateManager) compile, you can ignore the
parameter inside the stub.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0d548c25-6985-4eb6-80ae-16bf929a51aa

📥 Commits

Reviewing files that changed from the base of the PR and between 332c624 and eb5352d.

📒 Files selected for processing (3)
  • client/internal/engine.go
  • client/internal/portforward/manager.go
  • client/internal/portforward/manager_test.go

Comment thread client/internal/engine.go Outdated
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.

Actionable comments posted: 1

🧹 Nitpick comments (1)
client/internal/portforward/manager.go (1)

87-90: Keep unexpected setup failures above info level.

This now logs every setup error at info, so real create port mapping failures get the same treatment as the expected “no NAT found” case. Consider branching on the wrapped error source here and reserving info for the benign discover/no-gateway path.

Also applies to: 150-163

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

In `@client/internal/portforward/manager.go` around lines 87 - 90, The current
handling after calling m.setup(ctx) logs all setup failures at Info level;
change it to detect the benign "no gateway/no NAT found" error (e.g. by
errors.Is/unwrap against the setup's sentinel error like ErrNoGateway or the
underlying NAT discovery error) and log that specific case at Info, while
logging any other unexpected errors at Error (or higher) and returning; apply
the same branching logic to the other setup-result check around the mapping
teardown block referenced (the similar handling at lines ~150-163) so only the
expected no-gateway path remains Info-level and all real setup/mapping failures
are logged as Error-level.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@client/internal/portforward/manager.go`:
- Around line 35-38: The permanent-lease code currently falls back to ttl=0
without persisting state, which can leave stale router rules across crashes;
update the permanent-lease path in manager.go to persist the mapping in the
State manager before enabling the ttl=0 fallback (or gate the retry until
persistence/State.Cleanup support exists). Concretely, in the code paths that
create a permanent mapping (refer to the function/method that sets ttl=0 and the
Stop() code that calls DeletePortMapping), call the existing State persistence
API to record the mapping (so Stop() can intentionally leave state on
DeletePortMapping failure and Startup can run State.Cleanup), and ensure the
retry/gating logic checks that the mapping was persisted before allowing the
ttl=0 fallback.

---

Nitpick comments:
In `@client/internal/portforward/manager.go`:
- Around line 87-90: The current handling after calling m.setup(ctx) logs all
setup failures at Info level; change it to detect the benign "no gateway/no NAT
found" error (e.g. by errors.Is/unwrap against the setup's sentinel error like
ErrNoGateway or the underlying NAT discovery error) and log that specific case
at Info, while logging any other unexpected errors at Error (or higher) and
returning; apply the same branching logic to the other setup-result check around
the mapping teardown block referenced (the similar handling at lines ~150-163)
so only the expected no-gateway path remains Info-level and all real
setup/mapping failures are logged as Error-level.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: f1fe6bde-210c-43b7-9085-c243e667ed04

📥 Commits

Reviewing files that changed from the base of the PR and between eb5352d and 501745e.

📒 Files selected for processing (5)
  • client/internal/portforward/manager.go
  • client/internal/portforward/manager_test.go
  • client/internal/portforward/state.go
  • client/server/state_generic.go
  • client/server/state_linux.go
💤 Files with no reviewable changes (3)
  • client/server/state_generic.go
  • client/server/state_linux.go
  • client/internal/portforward/state.go

Comment thread client/internal/portforward/manager.go
@sonarqubecloud
Copy link
Copy Markdown

sonarqubecloud Bot commented Apr 8, 2026

@lixmal lixmal merged commit 94a36cb into main Apr 8, 2026
42 checks passed
@lixmal lixmal deleted the portforward-permanent-lease branch April 8, 2026 16:00
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.

2 participants