Skip to content

feat(customvpn): generic VPN_TYPE=custom running a user-provided VPN client binary - #3434

Open
poka-IT wants to merge 5 commits into
passteque:masterfrom
poka-IT:custom-vpn-type
Open

feat(customvpn): generic VPN_TYPE=custom running a user-provided VPN client binary#3434
poka-IT wants to merge 5 commits into
passteque:masterfrom
poka-IT:custom-vpn-type

Conversation

@poka-IT

@poka-IT poka-IT commented Aug 14, 2026

Copy link
Copy Markdown

Type

Please tick which one the following applies to your pull request:

  • it is AI generated 🤖 and I did review it 👨👩
  • it is humanly written like the good old days 👨‍🎨👩‍🎨
  • it is AI generated 🤖 I did not review it 💤

Description

This adds a generic VPN_TYPE=custom which runs a user-provided VPN client binary inside the gluetun container, while gluetun keeps providing everything around it: the killswitch firewall, DNS over TLS, the HTTP and Shadowsocks proxies, the healthcheck and the public IP lookup.

Why

Recurring requests ask gluetun to embed one more VPN client: Tailscale (#1854), OpenConnect/AnyConnect (#3139), Cloudflare WARP (#1738), and others. Each of those means building, shipping and maintaining another client and its quirks forever. A generic exec-based type turns all of them into a user-side concern: mount the binary into the container, point CUSTOM_VPN_BINARY at it, and gluetun supervises it the same way it supervises OpenVPN today, without carrying any client-specific code.

How it works

  • New internal/customvpn runner cloned from the OpenVPN exec pattern: exec.CommandContext with Setpgid, stdout and stderr lines streamed to the logger, and process death forwarded to the VPN loop, which restarts it with the usual backoff.
  • Readiness is signaled exactly once: either when an output line matches the CUSTOM_VPN_READY_LINE regular expression, or, when that variable is empty, when the tunnel network interface exists with at least one address assigned, checked every 200ms.
  • The firewall VPN hole is built from CUSTOM_VPN_ENDPOINT_IP, CUSTOM_VPN_ENDPOINT_PORT and CUSTOM_VPN_ENDPOINT_PROTOCOL (default udp), like the connection of the other VPN types.
  • The binary owns the whole tunnel setup: it must create its tunnel network interface (CUSTOM_VPN_INTERFACE, default tun0) and install the in-container default route through it. Gluetun creates /dev/net/tun for it but performs no other network setup for this type. This is documented on the settings doc strings.
  • Only VPN_SERVICE_PROVIDER=custom is valid for this type, mirroring AmneziaWG.

All new environment variables are inert unless VPN_TYPE=custom: CUSTOM_VPN_BINARY (required), CUSTOM_VPN_ARGS (split following shell word splitting rules, reusing the existing splitter now exported as command.Split), CUSTOM_VPN_INTERFACE, CUSTOM_VPN_READY_LINE, CUSTOM_VPN_ENDPOINT_IP (required), CUSTOM_VPN_ENDPOINT_PORT (required) and CUSTOM_VPN_ENDPOINT_PROTOCOL. No existing behavior changes.

Port forwarding and path MTU discovery

Both were left out of the first push and both are in, after @qdm12's review said they should not be:

  • Port forwarding works. Nothing in it is bound to the VPN protocol: PIA needs the gateway, the server name and the credentials, ProtonVPN needs the gateway for NAT-PMP, PrivateVPN needs the assigned IP, and all of those come from routing.VPNLocalGatewayIP / AssignedIP on the tunnel interface. What blocked it was this PR's own wiring, which built a models.Connection with PortForward false and an empty ServerName; it now mirrors custom.getWireguardConnection (PIA panics on an empty server name). PORT_FORWARD_PROVIDER selects the implementation exactly as it does for the custom provider.
  • Path MTU discovery runs. MaxTheoreticalVPNMTU is the ceiling of the binary search, not a computed MTU, so it does not have to match the tunnel's real overhead. The custom case now subtracts no VPN header at all, leaving the ceiling at physical link minus IP header minus UDP or TCP header, and the search probes for the truth. The floor is already MinIPv4MTU 68 and MinIPv6MTU 1280.

One constraint that needs documenting rather than fixing: routing.VPNLocalGatewayIP recognises two route shapes, a default route with a gateway (OpenVPN) and the local-table single-address heuristic (WireGuard). A binary installing default dev tun0 with no gateway resolves to neither, so it gets no port forwarding.

Readiness waits for the route

When CUSTOM_VPN_READY_LINE is empty, readiness used to fire as soon as the interface carried an address. Both of the things the tunnel-up path does next need the route as well: path MTU discovery lists the interface's routes to set their MSS, and port forwarding reads the VPN gateway out of them. A binary that adds its address and installs its route a moment later was declared ready in between. The poll now requires a route through the interface too.

Changes

  • internal/constants/vpn: new custom type constant.
  • internal/configuration/settings/customvpn.go: settings with the usual gosettings read, validate, defaults, copy, override and toLinesNode, wired into settings/vpn.go, settings/provider.go and settings/serverselection.go.
  • internal/customvpn: the runner (start, line streaming, interface polling).
  • internal/vpn: dispatch case, setupCustomVPN building the firewall connection, the interface name for the down command, and IPv6 detection reusing the OpenVPN link inspection (extracted into a shared helper).
  • internal/command: Split exported to reuse the existing shell word splitting for CUSTOM_VPN_ARGS.
  • Dockerfile: environment defaults block.
  • Tests: table-driven settings validation (missing binary, binary not found, bad arguments, bad interface name, bad ready line regex, missing endpoint IP or port, bad protocol, valid cases, defaults), and runner tests covering ready line matching with single signaling, process exit before ready, interface polling readiness, start failure and argument splitting failure.

go build ./..., go test ./... and golangci-lint run pass on the touched packages. This has not yet been validated end to end inside a container against a real third-party VPN binary; @qdm12 offered to try it with openvpn, which is also the proof that the runner can be shared with internal/openvpn (CUSTOM_VPN_BINARY=/usr/sbin/openvpn2.6, CUSTOM_VPN_ARGS=--config /gluetun/target.ovpn, CUSTOM_VPN_READY_LINE=Initialization Sequence Completed).

Still open from the review: moving internal/openvpn onto this runner. The two differ in three places only (argv, the processLogLine level and filtering pass, and ready signalled on every Initialization Sequence Completed rather than once). Making those injectable turns OpenVPN into the generic runner plus its own hooks. Waiting on whether that belongs in this PR or a follow-up.

This pull request was authored by an AI coding agent under human direction and review, hence the ticked checkbox above.

Issue (optional)

Would resolve the recurring "add VPN client X" class of requests, notably #1854 (Tailscale), #3139 (OpenConnect/AnyConnect) and #1738 (Cloudflare WARP), without gluetun carrying any of their code.

Gluetun wiki associated pull request (optional)

None yet. Happy to write a wiki page documenting VPN_TYPE=custom if this approach is accepted.

@qdm12

qdm12 commented Aug 16, 2026

Copy link
Copy Markdown
Member

(didn't review code yet) a few ideas crossing my mind :

  • we can/should reuse the customvpn code for openvpn
  • "Tailscale (Feature request: Tailscale exit nodes #1854), OpenConnect/AnyConnect (VPN provider support: Cisco Anyconnect Secure Client #3139), Cloudflare WARP (Feature request: cloudflare warp #1738), and others" : definitely for closed source and non Go programs ! for go OSS programs it can be used in the meantime although a native support would be preferable. Still a good alternative solution until then.
  • "This has not yet been validated end to end inside a container against a real third-party VPN binary" - can be tried with openvpn on my end 😉
  • "Port forwarding stays provider-specific and is unavailable for the custom type." Why not ? It should be independent of the VPN protocol (and if not it should), and they can already be used with the custom vpn service provider
  • "Path MTU discovery is disabled for the custom type, since the tunnel overhead of an arbitrary binary is unknown. MaxTheoreticalVPNMTU still gets a defensive case using the largest known VPN header overhead." I think we can still use it, just with a lower min (like 900) and a max of the physical link - ip header length - udp or tcp header length. It may take a bit longer to find it but that's fine

@poka-IT

poka-IT commented Aug 18, 2026

Copy link
Copy Markdown
Author

Checked each point against the code.

Reuse for OpenVPN: agreed. internal/openvpn/{run,start,stream}.go and internal/customvpn differ in three places only: OpenVPN builds its own argv (version-selected binary plus --config), it runs every line through processLogLine for level and filtering, and it signals ready on every Initialization Sequence Completed rather than once. Making those three injectable (argv from the caller, a line processor, a ready predicate with a once flag) turns OpenVPN into the generic runner plus its own hooks. I can do it as a third commit here, or as a follow-up once this one is settled, whichever you prefer.

Trying it with openvpn: that doubles as the validation of the point above. CUSTOM_VPN_BINARY=/usr/sbin/openvpn2.6, CUSTOM_VPN_ARGS=--config /gluetun/target.ovpn, CUSTOM_VPN_READY_LINE=Initialization Sequence Completed, plus the three endpoint variables for the firewall hole.

Port forwarding: you are right, nothing in it is bound to the VPN protocol. PIA needs the gateway, the server name and the credentials, ProtonVPN needs the gateway for NAT-PMP, PrivateVPN needs the assigned IP, and all of those come from routing.VPNLocalGatewayIP / AssignedIP on the tunnel interface. What blocks it is my own wiring: setupCustomVPN builds a models.Connection with PortForward false and an empty ServerName, where custom.getWireguardConnection sets PortForward: true and copies selection.Names[0] (PIA panics on an empty server name). I will mirror that, and PORT_FORWARD_PROVIDER then selects the implementation exactly as it does for the custom provider.

One constraint that will need documenting: VPNLocalGatewayIP recognises two route shapes, a default route with a gateway (OpenVPN) and the local-table single-address heuristic (WireGuard). A binary installing default dev tun0 with no gateway resolves to neither.

PMTUD: agreed, MaxTheoreticalVPNMTU is the upper bound of the binary search, so it does not have to match the real overhead. I will drop the custom case's OpenVPN subtraction, leaving the ceiling at physical link minus IP header minus UDP or TCP header, and let the search converge. The floor is already MinIPv4MTU 68 and MinIPv6MTU 1280 in pmtud/constants, low enough to cover any custom overhead; I can pin it at 900 for the custom type if you want a shorter search.

One thing I noticed while checking that: the interface polling signals ready as soon as the interface carries an address, which can be before the binary installs its default route, and both PMTUD (VPNRoutes) and port forwarding need that route. I will poll for the default route through the interface instead.

@poka-IT

poka-IT commented Aug 18, 2026

Copy link
Copy Markdown
Author

Pushed the three things I said I would, description updated.

  • 93b4582 port forwarding: PortForward: true and selection.Names[0] into ServerName, mirroring custom.getWireguardConnection. The connection builder is split out of setupCustomVPN so it is testable without a tun device, and Test_customVPNConnection covers both cases.
  • fa5fece PMTUD: the vpn.Custom case subtracts no VPN header, so the ceiling is physical link minus IP minus L4, and the search probes for the rest. The disable in internal/vpn/run.go is gone.
  • 3e45664 readiness: the interface poll now also requires a route through the interface. Test_Runner_Run_interface_polling_waits_for_the_route fails without it, checked by reverting the condition once.

go build ./... and go test ./... pass except three packages this PR does not touch, which need a Linux netlink socket or outbound network and fail the same way on a clean tree here (internal/netlink, internal/dns, internal/natpmp).

The openvpn reuse is the one thing I have not touched, since I would rather know whether you want it in this PR or after it lands.

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