- Shell 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| bin | ||
| packaging | ||
| share/vpnns | ||
| .gitignore | ||
| install.sh | ||
| README.md | ||
| uninstall.sh | ||
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
--upscript (share/vpnns/openvpn-up.sh) parses anydhcp-option DNS/dhcp-option DOMAINvalues 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
sudoaccess (vpnns shells out tosudo ip,sudo openvpn,sudo iptables, etc. per privileged operation - it does not need to be run as root itself, except optionally forvpnns runwhich handles the privilege drop internally).curlis 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 startwarns if it detects ufw is active. - Privilege model: vpnns currently calls
sudoper privileged operation rather than using a dedicated root-owned helper/service: simple and auditable, but eachsudoprompt (or a NOPASSWD sudoers entry, seepackaging/) 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-securitydirectives, 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