Skip to content

fix(protonvpn): adopt reassigned forwarded ports instead of restarting port forwarding - #3409

Open
quadseven wants to merge 1 commit into
passteque:masterfrom
quadseven:fix/protonvpn-port-reassignment
Open

fix(protonvpn): adopt reassigned forwarded ports instead of restarting port forwarding#3409
quadseven wants to merge 1 commit into
passteque:masterfrom
quadseven:fix/protonvpn-port-reassignment

Conversation

@quadseven

Copy link
Copy Markdown

Fixes #3408.

Why

The ProtonVPN gateway may hand back a different external port when an existing NAT-PMP mapping is refreshed. addPortMappingTCPUDP treated that as an error:

} else if externalPort != 0 && externalPort != 1 && externalPort != assignedExternalPort {
    return 0, 0, fmt.Errorf("%s external port requested as %d but received %d", ...)
}

That error propagated out of KeepPortForward, so Start's goroutine ran cleanup() and pushed the error to the loop, which tore down and rebuilt the whole port forwarding service. Observed effect on a live setup:

  • the firewall rules and port file were dropped on every reassignment
  • VPN_PORT_FORWARDING_UP_COMMAND had already run for the previous port and did not run again for the replacement, so the downstream client stayed on a port that was no longer forwarded
  • GET /v1/portforward ended up reporting 0 while the port file and the client still held the stale port

Worth noting that the external-port check could only ever fire on the refresh path. PortForward calls addPortMappingTCPUDP with externalPort of 1 (first, symmetric mapping) or 0 (extra mappings), and both are excluded by the condition. So the check existed solely to reject the case this PR now handles.

Size: S

What changed

  • utils.PortForwardObjects gains an optional OnPortsChanged callback so a provider can hand newly assigned ports back to the port forwarding service.
  • Service.onPortsChanged applies them in place: it calls the existing cleanup() to close the firewall for the previous ports, then the existing onNewPorts() to allow the new ones, rewrite the port file and re-run the up command. This is the same sequence SetPortsForwarded already uses, so no new firewall logic is introduced.
  • protonvpn.KeepPortForward compares each refreshed mapping against the previous one, logs any reassignment, and calls OnPortsChanged once per refresh if anything moved.
  • addPortMappingTCPUDP no longer rejects a changed external port; the caller now decides. The internal-port check and the TCP/UDP consistency check are unchanged.
  • When OnPortsChanged is nil, KeepPortForward returns the new ErrPortsReassigned instead, preserving today's restart behaviour rather than silently forwarding a port the client is not listening on.

Ordering and the firewall

onPortsChanged deliberately removes the old allowed port before adding the new one, so a reassignment cannot leave a hole open on a port that is no longer forwarded. Test_Service_onPortsChanged asserts that ordering with gomock.InOrder. Nothing outside the forwarded-port rules is touched, so the kill switch and outbound-subnet handling are unaffected.

onPortsChanged takes only portMutex, never startStopMutex. It is invoked from the KeepPortForward goroutine, and Stop holds startStopMutex while waiting for that goroutine to return, so taking it here would deadlock. This is noted in a comment on the method.

Acceptance criteria

  • A reassigned external port is adopted without restarting the port forwarding service.
  • The firewall closes the previous port before opening the new one.
  • The port file and GET /v1/portforward report the newly assigned port.
  • The up command runs for the newly assigned port.
  • A provider whose service does not supply the callback still returns an error.
  • Every other qBittorrent-facing behaviour is unchanged when no reassignment occurs.

Test plan

  • go build ./... clean.
  • go vet ./internal/portforward/... ./internal/provider/... clean.
  • New Test_Service_onPortsChanged and Test_Service_onPortsChanged_keepsPreviousPortsOnFailure, covering the adopt path, the firewall ordering, the port file contents, and the failure path where the new port cannot be allowed.
  • New Test_portsMapToString covering no-change, single reassignment, multiple ports, partial change and unknown internal port.
  • mockgen directive extended from Logger to Logger,PortAllower; mocks_test.go regenerated with the same command.
  • go test ./internal/portforward/... ./internal/provider/... all pass.

Two notes on the local run, for transparency:

  • internal/netlink, internal/tun and internal/natpmp fail for me, but they fail identically on an unmodified checkout of master (verified 5/5 runs for Test_Client_AddPortMapping/rpc_error, which expects an i/o timeout and gets a connection refused). These look macOS-specific and unrelated.
  • I could not add a unit test for the reassignment branch of KeepPortForward itself, because it constructs natpmp.New() internally and the client is not injectable. Happy to add one if you would like that refactored as part of this PR.

Out of scope

  • Why the gateway reassigns the port. In my case successive NAT-PMP requests reported different gateway external addresses, which suggests the endpoint is load balanced, but this PR only makes gluetun handle the reassignment correctly.
  • The other providers' KeepPortForward implementations, which do not renew a port mapping this way.

The ProtonVPN gateway may hand back a different external port when an
existing NAT-PMP mapping is refreshed. addPortMappingTCPUDP treated that
as an error, which propagated out of KeepPortForward and tore down the
whole port forwarding service: the firewall rules were removed, the port
file was cleared and the service was rebuilt from scratch. The up command
only ran for the port obtained before the reassignment, so a downstream
client was left listening on a port that was no longer forwarded.

Add an optional OnPortsChanged callback to PortForwardObjects so a
provider can hand newly assigned ports back to the service. The service
closes the firewall for the previous ports before opening the new ones,
rewrites the port file and re-runs the up command, reusing the same path
as SetPortsForwarded. Providers whose service does not supply the
callback keep returning an error rather than silently forwarding a port
the client is not listening on.
quadseven added a commit to quadseven/grug that referenced this pull request Jul 26, 2026
The enforcement-gap monitor paged on quadseven/gluetun an hour after the repo
was forked on 2026-07-26. It was red on four repos; three of them - gluetun,
flood, vibetunnel - are forks of third-party OSS with ZERO PRs each.

We fork upstream projects only to carry a patch branch that goes back
upstream. The merge target for that work is the PARENT repo, so there is no
first-party PR for a DoR check to gate, and requiring a DoR body on an
upstream-sync merge would be meaningless. gluetun exists purely so
infra's production/docker/gluetun-portfix can build from a patch branch
(upstream passteque/gluetun#3409).

Excludes forks from the denominator, mirroring the archived-repo skip
directly above it. This differs from that case in one way worth stating: grug
COULD write a ruleset to a fork, so this is a deliberate policy call that
forks are out of scope, not a capability limit.

It narrows the denominator without hiding anything, and the fourth repo in
that same alert proves it: `yuzu-yard-sale` is NOT a fork, is genuinely
unenforced, and stays counted. A test asserts exactly that.

Tests: 3 added (fork excluded, real gap survives the exclusion, missing
`fork` key counts as first-party). 49/49 pass.


Claude-Session: https://claude.ai/code/session_01CMFMgGqrxJ1a1gy7nUqzpq

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

Bug: ProtonVPN port rotation leaves port forwarding permanently at 0 and the client on a stale port

1 participant