diff --git a/bootstrap.sh b/bootstrap.sh index 10da5b5..7a3ac7b 100755 --- a/bootstrap.sh +++ b/bootstrap.sh @@ -172,12 +172,18 @@ step "Linking dotfiles with stow" 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 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" if ! stow -d "$repo" -t "$HOME" --restow dotfiles; then echo "stow hit conflicts (listed above). Move those files aside, then run:" >&2 echo " stow -d \"$repo\" -t \"\$HOME\" dotfiles" >&2 fi +step "Enabling user services" +systemctl --user daemon-reload +systemctl --user enable vpn-notify.service + if [[ "$(getent passwd "$USER" | cut -d: -f7)" != */zsh ]]; then step "Setting zsh as login shell" chsh -s /usr/bin/zsh diff --git a/dotfiles/.config/systemd/user/vpn-notify.service b/dotfiles/.config/systemd/user/vpn-notify.service new file mode 100644 index 0000000..414a6e3 --- /dev/null +++ b/dotfiles/.config/systemd/user/vpn-notify.service @@ -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 diff --git a/dotfiles/.local/bin/vpn-notify b/dotfiles/.local/bin/vpn-notify new file mode 100755 index 0000000..8efd194 --- /dev/null +++ b/dotfiles/.local/bin/vpn-notify @@ -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 diff --git a/system/packages.txt b/system/packages.txt index a30f2c6..885c150 100644 --- a/system/packages.txt +++ b/system/packages.txt @@ -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) libgsf # Extensible I/O abstraction library for dealing with structured file formats libmatroska # Matroska library +libnotify # Library for sending desktop notifications (notify-send, used by vpn-notify) 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 linux # The Linux kernel and modules