Feature: Desktop notifications when a VPN connects or disconnects

Adds the vpn-notify script and its systemd user service, which notify on
WireGuard/tun/Tailscale changes and reconnect NetworkManager WireGuard
tunnels after a network switch. bootstrap.sh now enables the service and
packages.txt lists libnotify explicitly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 11:04:35 +01:00
parent 81b051c95d
commit d24141e2e0
4 changed files with 153 additions and 0 deletions

View File

@@ -172,12 +172,18 @@ step "Linking dotfiles with stow"
mkdir -p "$HOME/.config" "$HOME/.local/bin" "$HOME/.local/share/fonts" "$HOME/Pictures" mkdir -p "$HOME/.config" "$HOME/.local/bin" "$HOME/.local/share/fonts" "$HOME/Pictures"
# Sunshine keeps credentials and logs beside its config; those must stay out of git # Sunshine keeps credentials and logs beside its config; those must stay out of git
mkdir -p "$HOME/.config/sunshine" mkdir -p "$HOME/.config/sunshine"
# systemd keeps its own wants/ symlinks beside the stowed user units
mkdir -p "$HOME/.config/systemd/user"
mkdir -p -m 700 "$HOME/.ssh" mkdir -p -m 700 "$HOME/.ssh"
if ! stow -d "$repo" -t "$HOME" --restow dotfiles; then if ! stow -d "$repo" -t "$HOME" --restow dotfiles; then
echo "stow hit conflicts (listed above). Move those files aside, then run:" >&2 echo "stow hit conflicts (listed above). Move those files aside, then run:" >&2
echo " stow -d \"$repo\" -t \"\$HOME\" dotfiles" >&2 echo " stow -d \"$repo\" -t \"\$HOME\" dotfiles" >&2
fi fi
step "Enabling user services"
systemctl --user daemon-reload
systemctl --user enable vpn-notify.service
if [[ "$(getent passwd "$USER" | cut -d: -f7)" != */zsh ]]; then if [[ "$(getent passwd "$USER" | cut -d: -f7)" != */zsh ]]; then
step "Setting zsh as login shell" step "Setting zsh as login shell"
chsh -s /usr/bin/zsh chsh -s /usr/bin/zsh

View File

@@ -0,0 +1,12 @@
[Unit]
Description=Desktop notifications for VPN connect/disconnect
PartOf=graphical-session.target
After=graphical-session.target
[Service]
ExecStart=%h/.local/bin/vpn-notify
Restart=on-failure
RestartSec=5
[Install]
WantedBy=graphical-session.target

134
dotfiles/.local/bin/vpn-notify Executable file
View File

@@ -0,0 +1,134 @@
#!/usr/bin/env bash
# Sends a desktop notification whenever a VPN connects or disconnects.
# Covers WireGuard/tun interfaces (NetworkManager, wg-quick, Proton, OpenVPN)
# and Tailscale (whose tailscale0 interface stays up even when stopped).
#
# NetworkManager WireGuard connections go stale when the underlying network
# changes (wifi -> ethernet, wifi -> wifi), so when that happens any that were
# up get restarted, checked, retried if needed, and reported as reconnected.
INTERVAL=2
RETRIES=5
ICON_ON=/usr/share/icons/hicolor/scalable/apps/nm-vpn-active-lock-symbolic.svg
ICON_OFF=/usr/share/icons/hicolor/scalable/apps/nm-vpn-standalone-lock-symbolic.svg
# Friendly name for an interface: NetworkManager connection name if it has one
vpn_name() {
local name
name=$(nmcli -g GENERAL.CONNECTION device show "$1" 2>/dev/null)
echo "${name:-$1}"
}
active_vpns() {
ip -d -o link show up 2>/dev/null \
| awk -F': ' '/ (wireguard|tun) / { sub(/@.*/, "", $2); print $2 }' \
| grep -vx tailscale0 \
| while read -r iface; do vpn_name "$iface"; done
if command -v tailscale >/dev/null &&
[[ $(tailscale status --json 2>/dev/null | jq -r .BackendState) == Running ]]; then
echo Tailscale
fi
}
# Active NetworkManager connections, as UUID:TYPE:NAME
nm_active() {
nmcli -t -f UUID,TYPE,NAME connection show --active 2>/dev/null
}
# UUIDs of the physical networks we're on (wifi, ethernet, tethering, ...)
underlying_networks() {
nm_active | awk -F: '$2 !~ /^(wireguard|tun|vpn|loopback|bridge)$/ { print $1 }' | sort
}
# UUIDs of active NetworkManager WireGuard connections
nm_wireguard() {
nm_active | awk -F: '$2 == "wireguard" { print $1 }' | sort
}
current_network_name() {
nm_active | awk -F: '$2 !~ /^(wireguard|tun|vpn|loopback|bridge)$/ { sub(/^[^:]*:[^:]*:/, ""); print; exit }'
}
notify() {
notify-send -a "VPN" -i "$2" "${@:4}" "$1" "$3"
}
# Confirm traffic flows through the tunnel by pinging its DNS server
tunnel_works() {
local uuid=$1 iface target
iface=$(nmcli -g connection.interface-name connection show "$uuid" 2>/dev/null)
target=$(nmcli -g ipv4.dns connection show "$uuid" 2>/dev/null | cut -d, -f1)
[[ -z $target ]] && return 0
ping -c 3 -W 2 -I "$iface" "$target" >/dev/null 2>&1
}
reconnect() {
local uuid=$1 name attempt
name=$(nmcli -g connection.id connection show "$uuid" 2>/dev/null)
for ((attempt = 1; attempt <= RETRIES; attempt++)); do
nmcli connection down "$uuid" >/dev/null 2>&1
if nmcli -w 20 connection up "$uuid" >/dev/null 2>&1 && sleep 2 && tunnel_works "$uuid"; then
notify "🔄 VPN reconnected" "$ICON_ON" \
"“$name” was reconnected after switching to “$(current_network_name)”."
return 0
fi
sleep $((attempt * 3))
done
notify "⚠️ VPN reconnect failed" "$ICON_OFF" \
"Couldn't reconnect “$name” after $RETRIES attempts." -u critical
return 1
}
prev=$(active_vpns | sort)
prev_net=$(underlying_networks)
prev_wg=$(nm_wireguard)
pending=""
while sleep "$INTERVAL"; do
cur_net=$(underlying_networks)
cur_wg=$(nm_wireguard)
# Network changed: queue WireGuard connections that were up for a restart
if [[ $cur_net != "$prev_net" ]]; then
pending=$(printf '%s\n%s\n' "$pending" "$prev_wg" | grep -v '^$' | sort -u)
fi
# Drop any the user switched off themselves while waiting for a network
if [[ -n $pending && $cur_net == "$prev_net" ]]; then
switched_off=$(comm -23 <(echo "$prev_wg") <(echo "$cur_wg"))
pending=$(comm -23 <(echo "$pending") <(echo "$switched_off") | grep -v '^$')
fi
# Reconnect once we're on a network again (give DHCP/routes a moment)
if [[ -n $pending && -n $cur_net ]]; then
sleep 3
while read -r uuid; do
[[ -n $uuid ]] && reconnect "$uuid"
done <<<"$pending"
pending=""
prev=$(active_vpns | sort)
prev_net=$(underlying_networks)
prev_wg=$(nm_wireguard)
continue
fi
prev_net=$cur_net
prev_wg=$cur_wg
cur=$(active_vpns | sort)
[[ $cur == "$prev" ]] && continue
while read -r vpn; do
[[ -n $vpn ]] && notify "🔒 VPN connected" "$ICON_ON" "You are now connected to “$vpn”."
done < <(comm -13 <(echo "$prev") <(echo "$cur"))
while read -r vpn; do
[[ -n $vpn ]] && notify "🔓 VPN disconnected" "$ICON_OFF" "“$vpn” has been disconnected."
done < <(comm -23 <(echo "$prev") <(echo "$cur"))
prev=$cur
done

View File

@@ -57,6 +57,7 @@ jq # Command-line JSON processor
# kitty # A modern, hackable, featureful, OpenGL-based terminal emulator !! you use foot as terminal (kitty config not in dotfiles) # kitty # A modern, hackable, featureful, OpenGL-based terminal emulator !! you use foot as terminal (kitty config not in dotfiles)
libgsf # Extensible I/O abstraction library for dealing with structured file formats libgsf # Extensible I/O abstraction library for dealing with structured file formats
libmatroska # Matroska library libmatroska # Matroska library
libnotify # Library for sending desktop notifications (notify-send, used by vpn-notify)
libpulse # A featureful, general-purpose sound server (client library) libpulse # A featureful, general-purpose sound server (client library)
# libva-nvidia-driver # VA-API implementation that uses NVDEC as a backend -- auto-detected by bootstrap.sh # libva-nvidia-driver # VA-API implementation that uses NVDEC as a backend -- auto-detected by bootstrap.sh
linux # The Linux kernel and modules linux # The Linux kernel and modules