You sit down at a coffee shop, click the Wi-Fi prompt, and accept the captive portal. Your laptop is now on a network where the person at the next table can run tcpdump on the SSID and see your unencrypted DNS queries, your unencrypted HTTP requests, your Slack heartbeats and Spotify metadata. Modern macOS and Windows mostly hide this with system-wide TLS, but on a Linux laptop you actually have to opt in — either via “always-on VPN” (annoying when you’re at home) or “VPN on suspicious networks only” (the right answer, if you can automate it).
NetworkManager has a hook system for exactly this: dispatcher.d scripts. Every time a connection comes up or down, NetworkManager runs every executable in /etc/NetworkManager/dispatcher.d/ and passes it the interface name and the action. You write one short shell script that says “if this Wi-Fi is on the trusted list, do nothing; otherwise turn on the VPN.”
The hook contract
NetworkManager invokes each dispatcher script with two arguments: the interface name (e.g. wlp3s0) and the action (up, down, vpn-up, vpn-down, pre-up, etc.). Environment variables include CONNECTION_ID (the SSID for Wi-Fi), CONNECTION_UUID, IP4_ADDRESS_0, etc.
The script must:
- Be executable.
- Be owned by root, with 755 perms.
- Not be world-writable (NetworkManager refuses to run it otherwise).
- Live in
/etc/NetworkManager/dispatcher.d/.
The script
# /etc/NetworkManager/dispatcher.d/50-auto-vpn
#!/usr/bin/env bash
set -eu
IFACE="$1"
ACTION="$2"
# Only act on Wi-Fi up/down; ignore wired and ignore VPN itself
[[ "$IFACE" == wl* ]] || exit 0
[[ "$ACTION" == "up" || "$ACTION" == "down" ]] || exit 0
# Trusted SSIDs — VPN stays off
TRUSTED=(
"home-2.4ghz"
"home-5ghz"
"office-corp-wifi"
"parents-house"
)
VPN_NAME="protonvpn-fastest" # NM connection ID for your VPN profile
is_trusted() {
local ssid="$1"
for t in "${TRUSTED[@]}"; do
[[ "$ssid" == "$t" ]] && return 0
done
return 1
}
case "$ACTION" in
up)
# CONNECTION_ID is the SSID for Wi-Fi NM connections
if is_trusted "${CONNECTION_ID:-}"; then
logger -t auto-vpn "trusted SSID '$CONNECTION_ID' — leaving VPN off"
nmcli connection down "$VPN_NAME" 2>/dev/null || true
else
logger -t auto-vpn "untrusted SSID '$CONNECTION_ID' — bringing VPN up"
# Wait briefly for DHCP/DNS to finish so the VPN can resolve its server
sleep 3
nmcli connection up "$VPN_NAME" || logger -t auto-vpn "VPN up FAILED"
fi
;;
down)
nmcli connection down "$VPN_NAME" 2>/dev/null || true
;;
esac
exit 0Install it:
sudo install -m 755 -o root -g root \
50-auto-vpn /etc/NetworkManager/dispatcher.d/50-auto-vpn
# Make sure the dispatcher service is running:
sudo systemctl enable --now NetworkManager-dispatcher.serviceNow open Wi-Fi settings, disconnect, reconnect to a known-trusted network — check journalctl -t auto-vpn; you should see “trusted SSID … leaving VPN off.” Then take the laptop somewhere else, connect to that café Wi-Fi, watch the log say “untrusted — bringing VPN up.” The VPN should appear in nmcli connection show --active within ~5 seconds.
Captive portals — the gotcha
Captive portals (the “accept terms to use Wi-Fi” pages) intercept HTTP traffic before they let you out. If your VPN brings up immediately on join, the VPN’s connect-handshake gets blocked by the portal. You see “VPN up FAILED” in the log.
NetworkManager has a built-in solution: connectivity check. If you let it complete the captive-portal flow before triggering the dispatcher’s up action, your VPN connects on a real network. Enable in /etc/NetworkManager/conf.d/connectivity.conf:
[connectivity]
enabled=true
uri=https://www.gstatic.com/generate_204
interval=300Restart NetworkManager. Now when you join a captive-portal network, NetworkManager pops up a notification: “Login required.” Click it, complete the portal flow, the connection state flips from “limited connectivity” to “full.” Only then does the up action fire and the VPN comes up.
Hardening: kill-switch on top
The dispatcher script is best-effort. If the VPN profile fails to connect (server down, network too restrictive), the script logs an error but your laptop is still on the untrusted Wi-Fi without a VPN. To make this fail-closed, add an iptables/nftables kill-switch that drops all non-VPN traffic when on untrusted SSIDs:
# Append to the script, in the "untrusted up" branch, BEFORE bringing the VPN up:
nft add rule inet filter output oifname "$IFACE" counter drop comment '"kill-switch"' \
2>/dev/null || true
# After VPN successfully comes up, allow only VPN traffic on the WiFi:
nft delete rule inet filter output handle <the-handle-from-above>
nft add rule inet filter output oifname "$IFACE" \
ip daddr 198.51.100.0 counter accept comment '"vpn-server"'The full kill-switch logic is more involved than fits in 50 lines — in practice I use the kill-switch built into the VPN client (ProtonVPN, Mullvad, NordVPN, etc.) rather than rolling my own. The dispatcher script just decides when to enable it; the client handles the how.
Edge cases worth handling
- Tethering off your phone — tethering creates an SSID like “iPhone” or “Pixel-AB12”. Add yours to the trusted list. Otherwise every tether triggers a VPN connect, which is unnecessary if you trust your phone’s connection.
- Wired connections — the script returns early on non-Wi-Fi interfaces, so a desk dock with Ethernet doesn’t trigger it. If you also want VPN on untrusted Ethernet (hotel wired network), broaden the interface check.
- SSID spoofing — an attacker can broadcast an SSID with the same name as your home Wi-Fi. The dispatcher script can’t tell. To harden: match on BSSID (the AP’s MAC) instead of SSID. The
CONNECTION_UUIDenv var pins the connection profile, which by default has the saved BSSID. - VPN profile must exist — create it once via NetworkManager’s GUI, or import a
.ovpn/.confvianmcli connection import. The script just activates the profile by name.
Result: a Linux laptop that automatically VPNs at airports, cafés, and hotels, and stays bare on home/office networks where you want LAN access. Once the script is in place, you stop thinking about “is the VPN on?” for years — the dispatcher does it for you every time you join a Wi-Fi.
Photo: Laptop on a cafe table by the window by Mikhail Nilov on Pexels.
