Skip to content

feat(pia): native WireGuard support with port forwarding - #3393

Draft
neilcorp2kx wants to merge 3 commits into
passteque:masterfrom
neilcorp2kx:feat/pia-wireguard-native
Draft

feat(pia): native WireGuard support with port forwarding#3393
neilcorp2kx wants to merge 3 commits into
passteque:masterfrom
neilcorp2kx:feat/pia-wireguard-native

Conversation

@neilcorp2kx

Copy link
Copy Markdown

Summary

This adds native WireGuard support for Private Internet Access (PIA), including port forwarding, plus a small healthcheck robustness improvement that the native path depends on.

Today PIA is OpenVPN-only in gluetun, and PIA + WireGuard + port forwarding is a long-standing gap (#3070, #2147): the custom-provider workaround panics with server name cannot be empty, and PIA cannot be selected as a WireGuard provider at all. The underlying reason is that PIA does not hand out static WireGuard configs — it registers a fresh WireGuard key per connection through its own API, which does not fit gluetun's normally-static WireGuard model.

This PR implements that dynamic registration inside the PIA provider so the whole flow is just credentials + region:

environment:
  - VPN_SERVICE_PROVIDER=private internet access
  - VPN_TYPE=wireguard
  - OPENVPN_USER=...            # or _SECRETFILE
  - OPENVPN_PASSWORD=...        # or _SECRETFILE
  - SERVER_REGIONS=CA Vancouver
  - VPN_PORT_FORWARDING=on
  - VPN_PORT_FORWARDING_PROVIDER=private internet access

No externally-generated WireGuard config, no static keys, no helper scripts. Server selection and key rotation happen automatically on every (re)connect.

Closes #3070.

What's in it

Three focused commits:

  1. feat(firewall) — scoped temporary bootstrap connection allowances. A registry of narrowly-scoped (destination IP + protocol + port + firewall mark, physical interface) temporary outbound allowances needed before the tunnel is up, with retain-on-failure cleanup, idempotent deletion under a bounded non-cancelable context, and a sweep on shutdown / before reconnect. iptables gains -m mark --mark emit+parse so the rules are mark-scoped and delete symmetrically.
  2. feat(pia) — native PIA WireGuard + port forwarding. At connect time the provider fetches PIA's live server list, selects a WireGuard server for the requested region/name/hostname, obtains a token, generates an ephemeral Curve25519 key pair, registers the public key (addKey) over TLS pinned to the server CN via the bundled PIA CA, and builds the WireGuard connection from the response (re-registering each reconnect). Port forwarding works natively (gateway from the addKey server_vip).
  3. feat(healthcheck) — retry the startup TCP+TLS check within a 60s budget (2s backoff) instead of a single 6s attempt, so a tunnel whose DNS takes a few seconds to become ready doesn't trigger a restart loop. General improvement, not PIA-specific.

Design / safety notes

  • Killswitch integrity was the top priority. The pre-tunnel token / server-list / addKey calls are the only new outbound traffic before the tunnel exists. They go through the temporary, mark-tagged, exact-destination allowances above, resolved by a bootstrap dialer pinned to the physical default route, and are torn down before the tunnel comes up. Discovery/registration failure fails closed (the tunnel does not start). There is no fail-open to arbitrary destinations.
  • Secrets: the ephemeral private key, the auth token and account credentials are never logged (info logging shows only the derived public key, shortened) or persisted; the port-forward token file is kept 0600.
  • Malformed/unsupported provider data (invalid/unspecified/IPv6 server_ip/server_vip/peer_ip) is rejected with errors rather than panicking.

Testing

  • Unit tests added across the firewall (rollback, deletion-failure retry, idempotence, mark scoping, reconnect de-duplication, mark parsing edge cases), PIA WireGuard (server-list parsing, region/name/hostname selection, addKey request/response mapping), healthcheck retry, and settings validation.
  • Built and run end-to-end against a live PIA account: native config connects over WireGuard, obtains a forwarded port, passes healthchecks with 0 restarts, and DNS/egress resolve to the PIA endpoint. Post-startup firewall state was inspected to confirm every temporary bootstrap allowance is torn down (only the active WireGuard endpoint allowance remains).
  • It has been running on a real seedbox to soak before this is taken out of draft.

Known / deferred (pre-existing, not introduced here)

  • PIA WireGuard reuses OpenVPN.User/Password for credentials, which are serialized by the authenticated /v1/vpn/settings control endpoint like all other settings — a pre-existing exposure, called out here because the native path now depends on those fields. A redacted control DTO would be a separate change.
  • The addKey client trusts the bundled PIA CA additively on top of the system pool rather than pinning PIA-only. TLS name verification is enabled and there is no InsecureSkipVerify.

A note on how this was developed

This was built and hardened with heavy, deliberate use of AI tooling — a combination of OpenAI Codex and Anthropic Claude — for implementation, iterative debugging against a live PIA endpoint, and multiple adversarial code-review passes focused on killswitch/leak safety, with all findings driven to resolution and re-verified. Flagging this transparently; every change has been reviewed and validated by a human, and I'm happy to walk through any part of the design or rationale.


Opening as a draft intentionally — soaking on a live setup for a bit before marking it ready. Feedback on the approach (especially the bootstrap-firewall handling and whether a redacted control DTO should be bundled) very welcome.

Add a mechanism to open narrowly-scoped, temporary outbound firewall
allowances (destination IP + protocol + port + firewall mark, on the
physical interface) that are needed before the VPN tunnel is up, then
reliably torn down afterwards.

- temporary.go: central registry of temporary allowances with
  retain-on-failure cleanup, idempotent deletion under a bounded
  non-cancelable context, and a sweep on firewall shutdown and before
  re-adding on reconnect. Attempted interfaces are tracked before the
  iptables append so a rule applied during a cancellation race is never
  left untracked.
- iptables: emit and parse the '-m mark --mark <value>' match so the
  temporary ACCEPT rules are scoped to the bootstrap socket mark and can
  be deleted symmetrically. Reject malformed mark match forms instead of
  silently accepting a zero mark or panicking on a bad value.

Covered by unit tests for rollback, deletion-failure retry, idempotence,
mark scoping, reconnect de-duplication and mark parsing edge cases.
Private Internet Access previously only supported OpenVPN in gluetun
because PIA registers a fresh WireGuard key per connection via its own
API, which does not fit a static WireGuard config. This adds a
provider-driven dynamic WireGuard path for PIA.

At connection time, for VPN_SERVICE_PROVIDER=private internet access +
VPN_TYPE=wireguard, gluetun now:
- fetches PIA's live server list and selects a WireGuard server for the
  chosen region/name/hostname (honouring port-forwarding-only),
- obtains an auth token, generates an ephemeral Curve25519 key pair, and
  registers the public key with the selected server (addKey) over TLS
  pinned to the server CN using the bundled PIA CA,
- builds the WireGuard connection from the response (endpoint, peer key,
  interface address, DNS), re-registering on every reconnect,
- performs the pre-tunnel token/server-list/addKey calls through scoped,
  temporary, mark-tagged firewall allowances so the killswitch is never
  opened to arbitrary destinations, resolving via a bootstrap dialer
  pinned to the physical default route.

Port forwarding now works natively over WireGuard for PIA (gateway taken
from the addKey server_vip). The earlier custom-provider workaround env
VPN_PORT_FORWARDING_SERVER_NAME remains supported.

Invalid, unspecified or non-IPv4 server_ip/server_vip/peer_ip values are
rejected with errors rather than panicking, and the persisted
port-forward token file is written and kept 0600.

Config is just credentials + region, e.g.:
  VPN_SERVICE_PROVIDER=private internet access
  VPN_TYPE=wireguard
  SERVER_REGIONS=CA Vancouver
  VPN_PORT_FORWARDING=on
  VPN_PORT_FORWARDING_PROVIDER=private internet access

Closes passteque#3070.
The VPN startup healthcheck performed a single 6s TCP+TLS check. When a
tunnel's DNS server takes a few seconds to become ready after connect
(e.g. providers that do pre-tunnel work), that single attempt could fail
and trigger an endless VPN restart loop.

Retry the short (6s) parallel TCP+TLS attempts within a 60s total budget
with a 2s backoff, returning on the first success and preserving the
existing aggregated error on budget exhaustion. Periodic checks are
unchanged. This removes the need for any manual startup-grace tuning and
is a general robustness improvement for all providers.
@qdm12

qdm12 commented Jul 16, 2026

Copy link
Copy Markdown
Member

Finally a decent solution, thanks! Although the iptables marking mechanism is risky, given it's not always available on the user's kernel. Instead you should more simply use #3361 (reasoning behind this at #3358)

@unrealtournament

unrealtournament commented Jul 23, 2026

Copy link
Copy Markdown

I've been testing this for a day and it works but the connection died after around 24h and didn't come back automatically, started getting spammed in the logs with:

gluetun      | 2026-07-23T21:40:43+02:00 INFO [port forwarding] starting
gluetun      | 2026-07-23T21:40:43+02:00 ERROR [port forwarding] starting port forwarding service: getting VPN assigned IP address: network interface tun0 not found: route ip+net: no such network interface - retrying in 5s

Also after reconnecting manually (docker compose down + up) it seems like the saved forwarded port is not properly refreshed, I got connected to a different server in the same region but it tried to reuse the old port (which is obviously not valid for the new IP). I had to manually delete the piaportforward.json file and restart again.

I'm not too familiar with gluetun config so this last part might just be something you can fix with some config options, but I didn't find anything at a glance.

My config:

      - VPN_SERVICE_PROVIDER=private internet access
      - VPN_TYPE=wireguard
      - OPENVPN_USER=<redacted>
      - OPENVPN_PASSWORD=<redacted>
      - SERVER_REGIONS=France
      - VPN_PORT_FORWARDING=on
      - VPN_PORT_FORWARDING_PROVIDER=private internet access

Thank you for the PR! Will report back if it happens again and I have any extra info.

@unrealtournament

Copy link
Copy Markdown

More logs, this time it took 6 days for the same thing to happen:

gluetun      | 2026-07-23T22:32:34+02:00 INFO [ip getter] Public IP address is <redacted> - source: ipinfo+ifconfig.co+ip2location+cloudflare)
gluetun      | 2026-07-23T22:32:34+02:00 INFO [vpn] There is a new release v3.41.1 (v3.41.1) created 162 days ago
gluetun      | 2026-07-23T22:32:34+02:00 INFO [port forwarding] starting
gluetun      | 2026-07-23T22:32:40+02:00 INFO [port forwarding] Port forwarded data expires in 62 days
gluetun      | 2026-07-23T22:32:40+02:00 INFO [port forwarding] port forwarded is 46626
gluetun      | 2026-07-23T22:32:40+02:00 INFO [firewall] setting allowed input port 46626 through interface tun0...
gluetun      | 2026-07-23T22:32:40+02:00 INFO [port forwarding] writing port file /tmp/gluetun/forwarded_port
gluetun      | 2026-07-24T22:32:31+02:00 INFO [dns] downloading hostnames and IP block lists
gluetun      | 2026-07-25T22:32:32+02:00 INFO [dns] downloading hostnames and IP block lists
gluetun      | 2026-07-26T22:32:32+02:00 INFO [dns] downloading hostnames and IP block lists
gluetun      | 2026-07-27T22:32:32+02:00 INFO [dns] downloading hostnames and IP block lists
gluetun      | 2026-07-28T22:32:33+02:00 INFO [dns] downloading hostnames and IP block lists
gluetun      | 2026-07-29T10:19:07+02:00 WARN [vpn] restarting VPN because it failed to pass the healthcheck: small periodic check: all check tries failed:
gluetun      |  attempt 1 (5000ms): timed out waiting for ICMP echo reply from 1.1.1.1
gluetun      |  attempt 2 (5000ms): timed out waiting for ICMP echo reply from 8.8.8.8
gluetun      |  attempt 3 (5002ms): timed out waiting for ICMP echo reply from 1.1.1.1
gluetun      |  attempt 4 (10000ms): timed out waiting for ICMP echo reply from 8.8.8.8
gluetun      |  attempt 5 (10000ms): timed out waiting for ICMP echo reply from 1.1.1.1
gluetun      |  attempt 6 (10000ms): timed out waiting for ICMP echo reply from 8.8.8.8
gluetun      |  attempt 7 (15001ms): timed out waiting for ICMP echo reply from 1.1.1.1
gluetun      |  attempt 8 (15000ms): timed out waiting for ICMP echo reply from 8.8.8.8
gluetun      |  attempt 9 (15000ms): timed out waiting for ICMP echo reply from 1.1.1.1
gluetun      |  attempt 10 (30001ms): timed out waiting for ICMP echo reply from 8.8.8.8
gluetun      | 2026-07-29T10:19:07+02:00 INFO [vpn] 👉 See https://github.com/qdm12/gluetun-wiki/blob/main/faq/healthcheck.md
gluetun      | 2026-07-29T10:19:07+02:00 INFO [vpn] DO NOT OPEN AN ISSUE UNLESS YOU HAVE READ AND TRIED EVERY POSSIBLE SOLUTION
gluetun      | 2026-07-29T10:19:07+02:00 INFO [vpn] stopping
gluetun      | 2026-07-29T10:19:07+02:00 ERROR [vpn] stopping port forwarding: server name not set
gluetun      | 2026-07-29T10:19:07+02:00 INFO [vpn] starting
gluetun      | 2026-07-29T10:19:13+02:00 INFO [firewall] removing allowed port 46626...
gluetun      | 2026-07-29T10:19:13+02:00 INFO [port forwarding] clearing port file /tmp/gluetun/forwarded_port
gluetun      | 2026-07-29T10:19:13+02:00 ERROR [port forwarding] binding port: Get "https://10.29.0.1:19999/bindPort?payload=<payload>&signature=<signature>": context deadline exceed
ed
gluetun      | 2026-07-29T10:19:13+02:00 INFO [port forwarding] starting
gluetun      | 2026-07-29T10:19:13+02:00 ERROR [port forwarding] starting port forwarding service: getting VPN assigned IP address: network interface tun0 not found: route ip+net: no
 such network interface - retrying in 5s
gluetun      | 2026-07-29T10:19:17+02:00 ERROR [vpn] finding a VPN server: resolving PIA server list host: lookup serverlist.piaservers.net: i/o timeout
gluetun      | 2026-07-29T10:19:17+02:00 INFO [vpn] retrying in 15s
gluetun      | 2026-07-29T10:19:18+02:00 INFO [port forwarding] starting
gluetun      | 2026-07-29T10:19:18+02:00 ERROR [port forwarding] starting port forwarding service: getting VPN assigned IP address: network interface tun0 not found: route ip+net: no such network interface - retrying in 5s
...
(endless loop)

@Harland70

Copy link
Copy Markdown

I spent some time testing this branch against a real PIA account (CA Toronto, PORT_FORWARD_ONLY=true)
because I'd like to see PIA WireGuard land. It connects and forwards a port fine, but I hit a
reproducible failure and, while chasing it, three other things that look like they'd block a merge.
Sharing it all in case it's useful. (Analysis done with AI assistance. Every claim below is from a
run I did myself, and I've tried to flag the bits I could not prove.)

1. A killed tunnel never recovers

GetWireguardConnection resolves serverlist.piaservers.net on every connect. gluetun's DNS loop
only starts on tunnel-up, and its upstream socket is unmarked, so once the tunnel is down that
lookup can't succeed, and the reconnect needs it. It retries forever:

WARN  [vpn] restarting VPN because it failed to pass the healthcheck
ERROR [vpn] finding a VPN server: resolving PIA server list host:
            lookup serverlist.piaservers.net: i/o timeout

Reproduced with ip link delete tun0 and letting the healthcheck react. Still unhealthy, no
interface, no IP after 5+ minutes.

I think this is what @unrealtournament is reporting above. The trigger is whatever kills the
tunnel, but the reason it never comes back is this.

Control, to make sure I wasn't blaming the wrong thing: same tree, reverting only the
resolver, everything else identical. Never recovered through +320s. With a tunnel-independent
resolver wired in, it recovers. I'd treat "recovers vs never recovers" as the result. I can't
attribute the exact timing, since the 60s startup budget and the 15s-doubling backoff are both in
play.

2. restrictednet looks like the fix, and it's already merged

@qdm12's internal/restrictednet (#3361 / #3358) does DoH resolution and HTTPS through narrow
temporary firewall openings, i.e. exactly a lookup that doesn't depend on the tunnel. I wired
ResolveName in as the lookupNetIP passed to GetWireguardConnection and the recovery problem
goes away.

Worth noting this branch is based on 93cc5a4 (2026-06-29) and restrictednet merged after that
(78f6076), so it isn't available here yet. A rebase onto master would be needed regardless. That
would also address the fwmark concern raised above, since restrictednet is the source-port
approach described in #3358.

⚠️ My wiring is a proof of concept and not something I'd propose merging. It only replaces the
resolver, so bootstrapDialContext (SO_MARK) is still load-bearing for the serverlist fetch, the
token fetch and addKey, and OpenHTTPS/OpenHTTPSByHostname presumably want to cover those three
too. It also has real bugs: it drops the "ip4" argument, and serverlist.piaservers.net has AAAA
records; and it reads DNS.Providers without honouring UpstreamPlainAddresses, so it can
substitute Cloudflare for a user's own resolver. I mention it only as evidence the approach works.
Happy to share the diff if it's useful, but it needs doing properly.

3. Port forwarding isn't stopped when the tunnel drops

ERROR [vpn] stopping port forwarding: server name not set

stopPortForwarding sends Settings{VPNIsUp: false} with a zero Service struct, which now clears
ServerName. Validation runs against the pre-update VPNIsUp (still true), so it fails, the
settings are never applied, and service.Stop() isn't reached. KeepPortForward then keeps calling
bindPort through a dead tunnel for the whole outage.

A/B of Settings.updateWith against merge-base 93cc5a4:

branch result
93cc5a4 err=<nil>, serverName="vancouver439"
this branch err=server name not set, serverName=""

4. Saved port-forward data has no server identity

This is the other half of @unrealtournament's report (reconnecting to a different server reuses a
port that isn't valid, needing a manual piaportforward.json delete). Reproduced live. After a
reconnect:

INFO [port forwarding] Found saved forwarded port data for port 45511
INFO [port forwarding] port forwarded is 45511      <- different server than issued it

The saved file holds only ["expires_at","port","signature","token"], nothing identifying the
server or gateway. The reuse gate checks expiry only, and PIA's tokens last ~2 months, so it
practically never refreshes. A non-errPortBusy bind failure doesn't invalidate the file either, so
the loop replays the same stale payload every 5s indefinitely.

Adding ServerName/Gateway to the saved struct and rejecting a mismatch would cover it. Worth
noting this branch makes it much more likely, since selectWireguardServer re-picks from PIA's live
list on every connect, so "same region, different server" becomes normal rather than rare.

5. Smaller things

  • findAPIIP applies the OpenVPN gateway octet-rewrite (x.y.128.1, x.y.0.1) to the WireGuard
    server_vip. For PIA WireGuard the PF gateway is the VIP (their own port_forwarding.sh uses
    PF_GATEWAY=$WG_SERVER_VIP), so the VIP itself is never probed. The fixtures in this PR use
    server_vip: 10.13.161.1, which I think its own code wouldn't reach.
  • internal/provider/privateinternetaccess/updater/wireguard_test.go:152 doesn't parse
    (missing ',' before newline in argument list), so that package fails [setup failed], which
    also means Test_selectWireguardServer isn't running.
  • internal/vpn/wireguard_test.go uses new("...")/new(uint32(...)), which needs go1.26 while
    go.mod declares 1.25, so go vet ./... exits 1.
  • gofmt -l flags a few of the changed files.

Thanks for putting this together. Happy to re-test any revision.

@m4r1k

m4r1k commented Aug 24, 2026

Copy link
Copy Markdown

Hey there!

I reviewed the current head of this PR and prepared a follow-up branch against neilcorp2kx:feat/pia-wireguard-native, PR at neilcorp2kx#1

The follow-up keeps this PR as the implementation base, but changes the bootstrap and reconnect path to use the internal/restrictednet package that has since merged into master. It also addresses the failures reported earlier:

  • removes the custom SO_MARK/marked-iptables bootstrap implementation
  • resolves the PIA server list and opens the server-list, token, and addKey HTTPS connections through source-port-scoped restricted networking
  • fixes the port-forwarding stop transition so VPNIsUp=false is validated after applying the new state and the keepalive service is actually stopped
  • cycles through all matching live WireGuard servers rather than remaining on the first API result
  • tries the addKey server_vip directly before the legacy PIA gateway address rewrites
  • rejects static private keys, pre-shared keys, and interface addresses for PIA's dynamic registration, and disables unsupported IPv6 routes
  • synchronizes the work with current master, repairs the broken updater test syntax and Go-version mismatch, and removes the unrelated global healthcheck retry change

The saved port-forward payload does not need a server identity. PIA documents payload/signature reuse across servers. The authenticated reconnect test confirmed this behavior: the first Vancouver endpoint issued port 44241; after deleting tun0, gluetun stopped port forwarding, registered against a different Vancouver endpoint, reused port 44241, and the port was externally reachable again.

Live verification covered the original recovery failure directly:

  1. Native PIA WireGuard connected in kernelspace with the firewall enabled.
  2. PIA egress and the forwarded port were verified from outside the tunnel container.
  3. tun0 was deliberately deleted.
  4. The healthcheck failed as expected and triggered a restart.
  5. Port forwarding stopped without the previous server name not set error.
  6. Server discovery, token retrieval, and addKey succeeded while the old tunnel was down.
  7. A fresh tunnel came up on a different endpoint, public egress returned, and the same forwarded port was externally reachable.
  8. No temporary bootstrap rules remained in iptables OUTPUT after either connection.

Lint, generated-mock checks, cross-compilation, focused race tests, restrictednet integration tests, and the final image build pass. The full test-container run has the same netlink/PMTUD failures on current master and the follow-up branch on this SELinux host; audit logs show denied module auto-load requests for rtnl-link-wireguard and ipt_mark.

I also ran the traditional PIA OpenVPN path with the same image. OpenVPN initialized normally, public egress worked, port forwarding produced an externally reachable TCP port, and the firewall contained only the expected OpenVPN endpoint and tunnel rules.

AI was used for analysis and code generation. I reviewed the resulting changes and verification output.

Please @neilcorp2kx take a look at the improvments made, likewise @qdm12 hopefully we can get this in before 3.42 🤞

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: PIA Port Forwarding fails with VPN_SERVICE_PROVIDER=custom (WireGuard): Panic or Region Validation Error

5 participants