Tunnel specific applications through openvpn
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-07-30 16:46:16 -04:00
bin detachable 2026-07-30 16:46:16 -04:00
packaging Initial commit 2026-07-30 11:39:27 -04:00
share/vpnns working 2026-07-30 16:25:26 -04:00
.gitignore Initial commit 2026-07-30 11:39:27 -04:00
install.sh Initial commit 2026-07-30 11:39:27 -04:00
README.md detachable 2026-07-30 16:46:16 -04:00
uninstall.sh Initial commit 2026-07-30 11:39:27 -04:00

vpnns

Run selected Linux applications through a Pritunl-provided OpenVPN connection while the rest of the host keeps using the normal network - no system-wide VPN routing, no VPN client per app.

vpnns start ~/vpn/profile.ovpn   # bring up the VPN namespace
vpnns run mattermost-desktop     # runs through the VPN
firefox                          # everything else keeps using your normal connection
vpnns stop                       # tear everything down

How it works

Host namespace                     "vpnns-<name>" network namespace
-----------------------------      -------------------------------------
normal apps, normal routing        veth (namespace side)
        |                                  |
  veth (host side) <------------------>  default route
        |                                  |
  NAT (iptables MASQUERADE)          openvpn client -> tun0
        |                                  |
  normal default route                     v
        |                            VPN server / private network
        v
     Internet

vpnns start creates a network namespace (vpnns-<name>, vpnns-default if you don't pass --name), connects it to the host with a veth pair, and NATs the namespace's private subnet out through the host's normal uplink - just enough connectivity for OpenVPN to reach the Pritunl server and complete the handshake. Once the tunnel is up, OpenVPN's own redirect-gateway (present in standard Pritunl exports) replaces the namespace's default route with tun0, so all namespace traffic prefers the tunnel from then on. Profiles that don't set redirect-gateway stay split-tunnel instead: only the routes the server explicitly pushes go through tun0, everything else keeps using the veth/NAT path.

vpnns run <app> is a thin wrapper around ip netns exec vpnns-<name> <app> that drops back from root to your own user and passes through DISPLAY/WAYLAND_DISPLAY/XAUTHORITY/XDG_RUNTIME_DIR/ DBUS_SESSION_BUS_ADDRESS/PULSE_SERVER, so GUI apps, audio, and notifications work the same as running them normally.

The namespace and OpenVPN process persist between vpnns run calls - start once, run as many applications as you like, vpnns stop when done.

Killswitch

By default, vpnns start also applies a default-deny iptables policy inside the namespace: only loopback, established/related connections, traffic to the VPN server's own IP(s), DNS (port 53), and traffic over tun* are allowed. Without this, if the tunnel drops, OpenVPN's route replacement leaves the original default route (via the veth/NAT path) intact underneath, and namespace traffic would silently fall back to the normal internet instead of failing closed. Disable with --no-killswitch if you'd rather have that fallback behavior.

DNS (port 53) is deliberately exempted from the deny-by-default policy, both to resolve the VPN server's hostname before the tunnel exists and to survive --up-restart reconnects. That means DNS queries can leak outside the tunnel; everything else cannot.

This also means manual connectivity tests will be blocked while troubleshooting. If start fails and you go poking around with ip netns exec <ns> ping ... or curl to some arbitrary host, they'll fail even if NAT/routing/DNS are all actually fine, simply because that traffic isn't the VPN server, DNS, or tun*. Re-run start with --no-killswitch before debugging connectivity by hand, or you'll end up chasing a phantom networking bug that's really just the killswitch doing its job.

DNS

vpnns start writes /etc/netns/vpnns-<name>/resolv.conf, which ip netns exec vpnns-<name> ... automatically bind-mounts over /etc/resolv.conf for anything run inside the namespace (standard iproute2 behavior - this is why namespaced DNS does not interfere with the host's systemd-resolved setup). Before the tunnel exists, this file is seeded with the host's own currently-configured DNS servers (via resolvectl dns, falling back to /run/systemd/resolve/resolv.conf, then /etc/resolv.conf)

  • not hardcoded public resolvers like 1.1.1.1/8.8.8.8. Some networks (corporate, hotel, public wifi) silently block outbound queries to arbitrary DNS servers while still allowing their own DHCP-assigned ones; hardcoding a public resolver there would make every namespace DNS lookup - including resolving the VPN server's own hostname - fail with no obvious cause, even though the network itself works fine. If the host's own servers can't be determined, it falls back to 1.1.1.1 / 8.8.8.8 as a last resort. Once the tunnel is up, an OpenVPN --up script (share/vpnns/openvpn-up.sh) parses any dhcp-option DNS / dhcp-option DOMAIN values the server pushes and replaces this file with those; if the server pushes nothing, the same host-servers-first fallback applies.

GUI apps and snap packages

Entering the namespace unshares the mount namespace (needed for the resolv.conf bind-mount above), which has a side effect: it hides everything normally mounted under the host's /sys - cgroup2, securityfs, and a few others. vpnns run remounts those before dropping privileges to your user, because without them snap-confined and AppArmor-confined apps fail outright (cannot find tracking cgroup, aa_is_enabled() failed).

That fixes launching snap apps, but not DNS inside them: snapd bind-mounts a snap's /etc/resolv.conf from the host's systemd-resolved stub address (127.0.0.53) regardless of the namespace's own DNS setup, and 127.0.0.53 is a loopback address - unreachable from inside vpnns's own network namespace. There's no fix for that built in yet, so snap apps that need DNS (e.g. Firefox) will fail to resolve anything under vpnns run. Installing the non-snap build of the app instead (e.g. Firefox's official .deb, or a non-snap PPA) works around it cleanly.

Requirements

Ubuntu (or any distro with a modern iproute2/iptables/util-linux), and:

sudo apt install openvpn iproute2 iptables util-linux curl
  • sudo access (vpnns shells out to sudo ip, sudo openvpn, sudo iptables, etc. per privileged operation - it does not need to be run as root itself, except optionally for vpnns run which handles the privilege drop internally).
  • curl is optional, used only for the post-connect exit-IP check.

Install

git clone <this repo> vpnns && cd vpnns
./install.sh

Installs vpnns to /usr/local/bin/vpnns and its OpenVPN up/down helper scripts to /usr/local/share/vpnns/. vpnns also works directly from a checkout without installing (./bin/vpnns ...) - it locates the helper scripts relative to its own path.

To avoid a sudo password prompt on every privileged step, see packaging/vpnns.sudoers.example (read the caveat in that file first - it grants close to NOPASSWD root).

Getting a Pritunl profile

In the Pritunl web UI: your profile -> Export (or download from your organization's Pritunl server) to get a .ovpn file. Some Pritunl servers require a username/password (optionally with a one-time code appended to the password) - vpnns start detects a bare auth-user-pass directive in the profile and prompts for credentials interactively before launching OpenVPN.

*.ovpn and config.env are already gitignored in this repo so you can drop profiles in without risking a commit.

Usage

vpnns start <profile.ovpn> [<profile2.ovpn> ...] [--name NAME]
                           [--subnet A.B.C.0/24] [--no-killswitch]
                           [--verify-timeout SECONDS]
vpnns run [--name NAME] [--detach] <command> [args...]
vpnns stop [--name NAME]
vpnns status [--name NAME]
vpnns list

Add --detach (-d) to run to launch <command> in its own session (via setsid --fork) and return immediately instead of holding the terminal open - useful for GUI apps you want to launch and walk away from:

vpnns run --detach mattermost-desktop
# launched detached; output logged to /run/vpnns/default/run-logs/run.XXXXXX.log
# check it's running with: sudo ip netns exec vpnns-default ps -ef

Since there's no terminal to inherit, its stdout/stderr go to a log file under /run/vpnns/<name>/run-logs/ instead (path printed when it launches). Any sudo password prompt happens synchronously, before detaching - once detached there's no terminal left to prompt through, so if you're not using passwordless sudo (see below), expect the prompt right when you run the command, not later.

--name defaults to default, so a single VPN namespace needs no --name anywhere. Pass --name work / --name personal etc. to run multiple independent VPN namespaces side by side - each gets its own namespace, veth pair, subnet (auto-derived from the name, or set explicitly with --subnet), and state directory, so they don't collide.

vpnns start ~/vpn/work.ovpn --name work
vpnns start ~/vpn/personal.ovpn --name personal
vpnns run --name work mattermost-desktop
vpnns run --name personal firefox
vpnns stop --name work
vpnns stop --name personal

Runtime state lives under /run/vpnns/<name>/ (tmpfs, cleared on reboot): openvpn-N.log, openvpn-N.pid, state.env (one N-suffixed pair per profile - see below). Check openvpn-N.log first if start times out waiting for the tunnel(s).

Multiple VPN servers in one namespace

Give start more than one profile to connect to several VPN servers at the same time, all reachable from the same namespace - e.g. one server that provides DNS and general routing, another that only routes a handful of extra service subnets:

vpnns start ~/vpn/primary.ovpn ~/vpn/services.ovpn --name work
vpnns run --name work mattermost-desktop   # can reach subnets from either VPN

Each profile gets its own OpenVPN process and tun device inside the same namespace ("slot" 1, 2, ... in start order); apps launched with run see routes from all of them at once. The first profile given is DNS-authoritative - its pushed dhcp-option DNS/DOMAIN values become the namespace's resolv.conf, and later profiles' pushed DNS options are ignored so they can't race or clobber it. The killswitch (see above) allowlists every profile's VPN server(s), not just the first.

This is for two independent VPN servers reachable side by side, not a chained/double-hop VPN - if only one profile sets redirect-gateway, that's fine (it becomes the default route and the other stays split-tunnel); if more than one does, start warns that they'll fight over the namespace's default route, since only one can win.

To use two VPN servers as fully separate, non-overlapping setups instead (different apps, no shared routing) use two independent --name namespaces as described above rather than passing multiple profiles to one start.

Desktop integration

For quick launching, wrap an app in a .desktop file's Exec= line, e.g. Exec=vpnns run --name work mattermost-desktop, and drop it in ~/.local/share/applications/.

Known limitations / roadmap

  • Kill switch DNS exception: see above - DNS queries are not tunneled through the killswitch's default-deny policy.
  • Snap-packaged GUI apps: DNS inside a snap's sandbox doesn't respect the namespace's resolv.conf (see "GUI apps and snap packages" above) - use a non-snap build of the app instead.
  • ufw: if ufw is active with a default-deny forward policy, the host-side NAT path may need sudo ufw route allow in on <host-veth> out on <uplink>; vpnns start warns if it detects ufw is active.
  • Privilege model: vpnns currently calls sudo per privileged operation rather than using a dedicated root-owned helper/service: simple and auditable, but each sudo prompt (or a NOPASSWD sudoers entry, see packaging/) is close to full root for the commands involved.
  • Non-Pritunl profiles: profiles that don't push redirect-gateway, or that set their own --up/--down/script-security directives, aren't specifically handled and may need manual adjustment.
  • Not yet implemented: deeper systemd-resolved integration (split DNS beyond the resolv.conf swap), and a non-sudo privileged helper daemon.

Uninstall

vpnns stop            # for every --name you've started, first
./uninstall.sh