chore: update wireguard and mullvad settings

This commit is contained in:
Bogdan Buduroiu 2026-08-11 12:35:16 +03:00
commit ef50a12528
Signed by: bogdan
GPG key ID: F71EF75A4C09A095
4 changed files with 377 additions and 69 deletions

8
flake.lock generated
View file

@ -1764,11 +1764,11 @@
},
"secrets": {
"locked": {
"lastModified": 1785311671,
"narHash": "sha256-6hAUNQtWI40MuxtUtYRTaP/kR23vhTiOVOBvmOiIGVs=",
"lastModified": 1786439965,
"narHash": "sha256-GZIZd7GJ1EujtklyaT3i0/npdUUmvLbPReLKE+bBawE=",
"ref": "refs/heads/main",
"rev": "979ad08bb555f52acc02f665c28f1792354b2016",
"revCount": 13,
"rev": "cfa82bba4006b17d701d87cfa47fd48182c870d6",
"revCount": 14,
"type": "git",
"url": "ssh://forgejo@git.oolongtechnologies.io/bogdan/secrets.git"
},

View file

@ -195,14 +195,15 @@ in {
# are split across the tunnels (all in a terminal, interactive sudo — no
# extra sudoers rule needed):
# left → live `wg show` of both
# right → show mullvad tunnel options
# right → fzf relay picker; the choice is pinned and re-resolved
# against Mullvad's live catalog on every interface start
"custom/wireguard" = {
format = "{}";
return-type = "json";
exec = "${wireguard-indicator}/bin/waybar-wireguard-indicator";
interval = 10;
on-click = "ghostty -e watch -n1 sudo wg show";
on-click-right = "ghostty -e mullvad-jump list";
on-click-right = "ghostty -e mullvad-jump pick";
};
# visible only while a submap (resize/launch/place) is active

View file

@ -5,89 +5,334 @@
jq,
wireguard-tools,
util-linux,
coreutils,
fzf,
gawk,
gnugrep,
iproute2,
systemd,
}:
# Live Mullvad relay switcher for the wg-quick "wg-mullvad" interface
# (see system/network/wireguard.nix). Switches are runtime-only: the
# kill switch / policy routing operate on the interface, not the peer,
# so a swap is leak-free; a wrong relay just fails closed. Persist a
# choice by editing mullvadEndpoint/mullvadPublicKey in the nix module.
# Relay manager for the wg-quick "wg-mullvad" interface (see
# system/network/wireguard.nix).
#
# WHY THIS EXISTS IN THIS SHAPE
#
# Mullvad decommissions relays and rotates their WireGuard keys on their own
# schedule. Pinning an endpoint IP + public key in Nix therefore stores a
# value that decays: when the relay goes away, wg-mullvad comes up perfectly,
# handshakes with nobody, and — because the split tunnel fails OPEN — you keep
# browsing with your real IP. Silent, and the worst possible failure mode.
#
# So the pin here is SYMBOLIC, not concrete: a hostname prefix
# ("se", "se-got", "se-got-wg-001") kept in /var/lib/mullvad-jump/pin. The
# concrete endpoint+key are resolved from Mullvad's live catalog at every
# interface start (`apply`, called from the interface's postUp) and at every
# jump. A retired relay can then only cost one resolution, never a silent leak.
#
# The v1 catalog carries no "active" field — Mullvad simply drops dead relays
# from the feed — so PRESENCE IN THE CATALOG IS THE LIVENESS SIGNAL, and a
# resolve that returns nothing is itself the "this relay is gone" alarm.
writeShellApplication {
name = "mullvad-jump";
runtimeInputs = [ curl jq wireguard-tools util-linux ];
# EVERY external command must be listed. writeShellApplication PREPENDS these
# to the inherited $PATH rather than replacing it, so a missing entry still
# resolves in an interactive shell and only fails where PATH is minimal —
# i.e. under systemd, which is exactly where postUp runs. ShellCheck cannot
# catch it either. awk/ip/systemctl/grep were missing on the first cut and
# broke relay resolution at boot while working perfectly by hand.
runtimeInputs = [
curl # catalog fetch
jq # catalog parsing
wireguard-tools # wg set/show
util-linux # column
coreutils # cat cut date head mkdir mktemp mv rm sleep sort tr
fzf # pick
gawk # candidates, uplink_dev, handshake_age
gnugrep # list/pick filters
iproute2 # ip route (uplink detection)
systemd # systemctl restart
];
text = ''
RELAY_API="https://api.mullvad.net/public/relays/wireguard/v1"
IFACE="wg-mullvad"
STATE=/var/lib/mullvad-jump
PIN="$STATE/pin"
CACHE="$STATE/relays.tsv"
# Candidates tried before giving up. A relay can be listed but sick, so a
# pin that resolves to several ("se-got") self-heals onto the next one.
MAX_TRIES=3
HS_WAIT=4 # seconds to wait for a handshake before trying the next
usage() {
cat <<EOF
usage: mullvad-jump <hostname> switch live to relay (e.g. de-fra-wg-001)
mullvad-jump list [filter] list relays, optionally filtered (city/country/hostname)
mullvad-jump status current relay + exit verification
mullvad-jump reset restart tunnel back to the nix-configured relay
usage: mullvad-jump <prefix> jump to a relay and persist the choice
mullvad-jump pick [filter] fuzzy-pick a relay, then jump
mullvad-jump list [filter] list relays (city/country/hostname)
mullvad-jump status pin, live relay, handshake, exit check
mullvad-jump reset forget the pin, revert to the Nix default
mullvad-jump apply [default] resolve the pin and configure the peer
(run from wg-mullvad's postUp)
<prefix> is any leading part of a relay hostname, so it selects at three
granularities "se" (country), "se-got" (city), "se-got-wg-001" (exact).
Broader pins survive relay churn better and allow failover between tries.
EOF
exit 1
}
relays() {
curl -sf --max-time 15 "$RELAY_API" | jq -r '
.countries[] as $c | $c.cities[] as $ci | $ci.relays[]
| [.hostname, $c.name, $ci.name, .ipv4_addr_in, .public_key] | @tsv'
need_root() { [ "$(id -u)" -eq 0 ] || exec sudo "$0" "$@"; }
# Physical default route, i.e. egress ignoring the tunnel. Read from table
# main specifically: wg-mullvad is Table=off, so its default never appears
# there — main is by definition the uplink.
uplink_dev() {
ip -4 route show default table main 2>/dev/null \
| awk '{for (i = 1; i < NF; i++) if ($i == "dev") { print $(i + 1); exit }}'
}
# hostname \t country \t city \t ipv4 \t pubkey
# Network first, cache second. The cache is what makes an offline boot (or
# a boot where the fetch is slow) configure a peer at all, rather than
# leaving the interface up and mute.
catalog() {
local tmp dev
local -a bind=()
tmp=$(mktemp)
# Pin the fetch to the physical uplink when we have the privilege for
# SO_BINDTODEVICE. Without it, an unbound socket obeys the RPDB: if the
# egress rules are live and the peer is stale, the request for the data
# needed to REPAIR the tunnel is itself routed into that broken tunnel.
# Best-effort by design — unprivileged callers fall back to the cache.
if [ "$(id -u)" -eq 0 ]; then
dev=$(uplink_dev)
[ -n "$dev" ] && bind=(--interface "$dev")
fi
if curl -sf --max-time 8 "''${bind[@]}" "$RELAY_API" 2>/dev/null | jq -r '
.countries[] as $c | $c.cities[] as $ci | $ci.relays[]
| [.hostname, $c.name, $ci.name, .ipv4_addr_in, .public_key] | @tsv
' > "$tmp" 2>/dev/null && [ -s "$tmp" ]; then
mkdir -p "$STATE" 2>/dev/null || true
mv "$tmp" "$CACHE" 2>/dev/null || { cat "$tmp"; rm -f "$tmp"; return 0; }
cat "$CACHE"
return 0
fi
rm -f "$tmp"
if [ -s "$CACHE" ]; then
echo "warning: relay catalog unreachable, using cache from $(date -r "$CACHE" '+%Y-%m-%d %H:%M')" >&2
cat "$CACHE"
return 0
fi
echo "error: relay catalog unreachable and no cache at $CACHE" >&2
return 1
}
# Matches an exact hostname or a dash-delimited prefix, so "se" cannot
# accidentally match "sea-...". Sorted for deterministic, explainable picks.
candidates() {
local pref=$1
awk -F'\t' -v p="$pref" '$1 == p || index($1, p "-") == 1' | sort
}
current_peer() { wg show "$IFACE" peers 2>/dev/null | head -1; }
handshake_age() {
local hs now
hs=$(wg show "$IFACE" latest-handshakes 2>/dev/null | awk 'NR==1 {print $2}')
[ -n "''${hs:-}" ] && [ "$hs" -gt 0 ] || return 1
now=$(date +%s)
echo $(( now - hs ))
}
# persistent-keepalive is what makes verification possible at all: without
# it WireGuard only handshakes once something routes into the tunnel, so a
# freshly-configured peer would sit silent and look identical to a dead
# one. It also keeps the NAT mapping open and makes waybar's freshness
# check meaningful on an idle tunnel.
configure_peer() {
local pk=$1 ip=$2 old
old=$(current_peer)
[ -n "$old" ] && wg set "$IFACE" peer "$old" remove 2>/dev/null || true
wg set "$IFACE" peer "$pk" \
allowed-ips 0.0.0.0/0,::/0 \
endpoint "$ip:51820" \
persistent-keepalive 25
}
await_handshake() {
local waited=0
while [ "$waited" -lt "$((HS_WAIT * 2))" ]; do
handshake_age >/dev/null 2>&1 && return 0
sleep 0.5
waited=$((waited + 1))
done
return 1
}
# Walk the candidates for a pin until one actually handshakes.
# Returns 0 and echoes the chosen hostname; 1 if none answered.
select_relay() {
local pref=$1 quiet=''${2:-} tries=0 host country city ip pk
local list feed try
feed=$(catalog) || return 1
# An exact pin is the most churn-prone kind: when that one relay retires
# it resolves to nothing, and "nothing" would leave the dead peer in
# place — the silent leak again, one level up. So widen the pin a
# dash-segment at a time (se-got-wg-001 → se-got → se) rather than fail.
try=$pref
while :; do
list=$(candidates "$try" <<< "$feed")
[ -n "$list" ] && break
case "$try" in
*-*)
echo "warning: no relay matches \"$try\" (retired?), broadening to \"''${try%-*}\"" >&2
try=''${try%-*}
;;
*)
echo "error: no relay matches \"$pref\" at any granularity (try: mullvad-jump list)" >&2
return 1
;;
esac
done
while IFS=$'\t' read -r host country city ip pk; do
[ -n "$host" ] || continue
tries=$((tries + 1))
[ "$tries" -le "$MAX_TRIES" ] || break
configure_peer "$pk" "$ip" || continue
if await_handshake; then
[ -n "$quiet" ] || echo "on $host ($city, $country)"
return 0
fi
echo "warning: $host did not handshake within ''${HS_WAIT}s, trying next" >&2
done <<< "$list"
echo "error: no relay matching \"$pref\" completed a handshake" >&2
return 1
}
verify_exit() {
sleep 2
curl -sf --max-time 15 https://am.i.mullvad.net/json \
| jq -r '"exit: \(.mullvad_exit_ip_hostname // "NOT MULLVAD") (\(.ip), \(.city))"' \
|| echo "exit check failed tunnel may still be handshaking, try: mullvad-jump status"
|| echo "exit check failed tunnel may still be handshaking"
}
cmd="''${1:-usage}"
case "$cmd" in
list)
if [ -n "''${2:-}" ]; then
relays | { grep -i -- "$2" || true; } | cut -f1-4 | column -t
catalog | { grep -i -- "$2" || true; } | cut -f1-3 | column -t
else
relays | cut -f1-4 | column -t
catalog | cut -f1-3 | column -t
fi
;;
pick)
# Interactive path: choose, then hand off to the jump path so the
# choice is persisted and verified exactly like a typed prefix.
feed=$(catalog) || exit 1
[ -n "''${2:-}" ] && feed=$(grep -i -- "$2" <<< "$feed" || true)
sel=$(cut -f1-3 <<< "$feed" | column -t \
| fzf --prompt='mullvad relay > ' --height=40% --reverse) || exit 1
[ -n "$sel" ] || exit 1
exec "$0" "$(awk '{print $1}' <<<"$sel")"
;;
apply)
# Boot/restart path, run from wg-mullvad's postUp BEFORE the egress
# rules are installed. The exit status is the contract: postUp installs
# the policy only on success, so "no verified relay" degrades to the
# physical uplink instead of blackholing every route into a tunnel that
# cannot carry traffic.
need_root "$@"
mkdir -p "$STATE" 2>/dev/null || true
pref=""
[ -s "$PIN" ] && pref=$(tr -d ' \n' < "$PIN")
[ -n "$pref" ] || pref="''${2:-}"
if [ -z "$pref" ]; then
echo "mullvad-jump: no pin and no default relay egress policy not installed" >&2
exit 1
fi
select_relay "$pref"
;;
status)
[ "$(id -u)" -eq 0 ] || exec sudo "$0" "$@"
wg show "$IFACE" endpoints | awk '{print "peer:", $1, "endpoint:", $2}'
wg show "$IFACE" latest-handshakes | awk '{print "last handshake:", (systime()-$2), "s ago"}'
need_root "$@"
pin=$([ -s "$PIN" ] && cat "$PIN" || echo "(none using the Nix default)")
echo "pin: $pin"
pk=$(current_peer)
if [ -z "$pk" ]; then
echo "peer: NONE interface has no peer configured"
else
# "absent from the catalog" and "could not consult the catalog" are
# different facts and must not collapse into one message: reporting a
# failed lookup as a retired relay sends you hunting the wrong bug.
feed=$(catalog 2>/dev/null) || feed=""
if [ -z "$feed" ]; then
echo "peer: UNKNOWN catalog unavailable, cannot classify this peer"
else
host=$(awk -F'\t' -v k="$pk" '$5 == k {print $1" ("$3", "$2")"; exit}' <<< "$feed")
if [ -n "$host" ]; then
echo "peer: $host"
else
echo "peer: NOT IN CATALOG relay retired or key rotated;"
echo " this tunnel can never handshake. Run: mullvad-jump <prefix>"
fi
fi
fi
if age=$(handshake_age); then
echo "last handshake: ''${age}s ago"
else
echo "last handshake: NEVER"
fi
verify_exit
;;
reset)
[ "$(id -u)" -eq 0 ] || exec sudo "$0" "$@"
need_root "$@"
rm -f "$PIN"
echo "pin cleared reverting to the Nix default relay"
systemctl restart wg-quick-"$IFACE"
echo "restarted $IFACE (nix-configured relay)"
verify_exit
;;
usage|-h|--help)
usage
;;
*)
[ "$(id -u)" -eq 0 ] || exec sudo "$0" "$@"
# No early-exit in awk: closing the pipe mid-stream SIGPIPEs
# curl/jq, and pipefail+errexit then kills the script silently.
line=$(relays | awk -F'\t' -v h="$cmd" '$1 == h {print}')
[ -n "$line" ] || { echo "unknown relay: $cmd (try: mullvad-jump list)"; exit 1; }
ip=$(cut -f4 <<<"$line"); pk=$(cut -f5 <<<"$line")
old=$(wg show "$IFACE" peers)
[ "$pk" = "$old" ] && { echo "already on $cmd"; exit 0; }
wg set "$IFACE" peer "$old" remove
wg set "$IFACE" peer "$pk" allowed-ips 0.0.0.0/0,::/0 endpoint "$ip:51820"
echo "switched to $cmd ($ip) live only; persist via mullvadEndpoint/mullvadPublicKey in wireguard.nix"
verify_exit
need_root "$@"
mkdir -p "$STATE" 2>/dev/null || true
# Resolve BEFORE touching the interface. Bringing wg-mullvad up
# installs the egress rules, and if its peer is stale that blackholes
# the connectivity this very fetch depends on — the deadlock that made
# a cold-start jump impossible. Fetching first also warms the cache
# while the uplink is known-good.
feed=$(catalog) || exit 1
if [ -z "$(candidates "$cmd" <<< "$feed")" ]; then
echo "error: no relay matches \"$cmd\" (try: mullvad-jump list)" >&2
exit 1
fi
echo "$cmd" > "$PIN"
echo "pinned \"$cmd\" survives restarts and reboots, re-resolved live each time"
# postUp is the single place that resolves, verifies and installs the
# egress policy; restarting the unit is how a pin takes effect. Doing
# the wg-set here as well would be a second, divergent copy of that.
systemctl restart wg-quick-"$IFACE"
if age=$(handshake_age); then
echo "tunnel up handshake ''${age}s ago"
verify_exit
else
echo "warning: no handshake after restart egress left on the physical" >&2
echo "uplink (fail open). See: mullvad-jump status" >&2
exit 1
fi
;;
esac
'';
meta = {
description = "Live Mullvad relay switcher for the wg-mullvad split tunnel";
description = "Live Mullvad relay switcher and resolver for the wg-mullvad split tunnel";
license = lib.licenses.mit;
mainProgram = "mullvad-jump";
platforms = lib.platforms.linux;

View file

@ -35,6 +35,18 @@
# Waybar reads handshake freshness via `wg show wg-mullvad dump`; the
# passwordless sudo rule for exactly that command is defined below.
#
# Relay selection — symbolic, resolved at every interface start:
# Mullvad retires relays and rotates their keys, so a pinned endpoint+key is
# a value that decays. Combined with fail-open that is the nastiest failure
# available: the interface comes up, handshakes with nobody, and traffic
# quietly takes the physical default route. What is stored instead is a
# relay-hostname PREFIX ("se", "se-got", "se-got-wg-001") — in
# /var/lib/mullvad-jump/pin, or `vars.wireguard.mullvad.relay` as the
# per-host default — which postUp resolves against the live catalog via
# `mullvad-jump apply`, trying successive relays until one handshakes.
# Switch and persist with `mullvad-jump <prefix>` (or `pick`); inspect with
# `mullvad-jump status`, which names a retired relay explicitly.
#
# Required secrets (inputs.secrets: secrets/<hostname>/wireguard.yaml, via sops):
# wg-private-key: <WireGuard private key for wg0>
# mullvad-private-key: <Mullvad WireGuard private key>
@ -60,9 +72,25 @@ let
hubNetworks = pvars.hub.networks;
# ── Mullvad config ──────────────────────────────────────────────────────────
# Pick a region from https://mullvad.net/en/servers/
mullvadEndpoint = pvars.mullvad.endpoint;
mullvadPublicKey = pvars.mullvad.publicKey;
# OPTIONAL, AND BETTER LEFT UNSET. A relay's endpoint and public key are
# Mullvad's to change: keys rotate while the IP stays put, so a pinned pair
# can be half-correct and permanently dead. Declaring them here also means
# every relay change costs two commits across two repos. Omit both and the
# interface starts with NO peer at all, leaving `mullvad-jump apply` in
# postUp as the single source of peer identity — switching exits is then one
# command, no commit and no rebuild.
mullvadEndpoint = pvars.mullvad.endpoint or null;
mullvadPublicKey = pvars.mullvad.publicKey or null;
# Symbolic relay preference — a relay-hostname prefix at country ("se"),
# city ("se-got") or exact ("se-got-wg-001") granularity, resolved against
# Mullvad's live catalog at every interface start. Optional in the private
# flake: without it the pin in /var/lib/mullvad-jump/pin (written by
# `mullvad-jump <prefix>`) is the only source, and failing that the bootstrap
# peer above is left in place.
defaultRelay = pvars.mullvad.relay or "";
mullvadJump = "${pkgs.mullvad-jump}/bin/mullvad-jump";
# In-tunnel resolver: no specific route in main → rule 5403 → tunnel.
mullvadDNS = pvars.mullvad.dns;
@ -172,27 +200,52 @@ in {
# restarts don't stack duplicates.
postUp = ''
${pkgs.wireguard-tools}/bin/wg set wg-mullvad fwmark ${toString wgFwmark}
# Stale-rule teardown runs BEFORE the resolver, not paired with each
# add below, and the order is load-bearing: `mullvad-jump apply` has
# to reach api.mullvad.net, and if rules from a crashed run survived,
# pref 5403 would route its fetch into the very tunnel it is trying
# to configure — a deadlock that resolves only by timeout. Clearing
# first guarantees the resolver always sees the physical uplink.
for fam in -4 -6; do
${ip} $fam rule del pref 5401 2>/dev/null || true
${ip} $fam rule add pref 5401 fwmark ${toString wgFwmark} lookup main
${ip} $fam rule del pref 5402 2>/dev/null || true
${ip} $fam rule add pref 5402 lookup main suppress_prefixlength 0
${ip} $fam rule del pref 5403 2>/dev/null || true
${ip} $fam rule add pref 5403 iif lo lookup ${toString tableId}
done
${ip} route replace default dev wg-mullvad metric 50 table ${toString tableId}
${ip} -6 route replace default dev wg-mullvad metric 50 table ${toString tableId}
# DNS: pin the Mullvad resolver to THIS link and claim the ~.
# routing domain. resolved routes each query to the scope with the
# best matching domain; ~. outranks the NM uplinks' bare
# DefaultRoute flag, so their DHCP (ISP) resolvers stop receiving
# queries while the tunnel is up. Without ~., resolved fans every
# query out to ALL default-route scopes in parallel — global DNS=
# does not exclude per-link servers, which is exactly the leak.
# Per-link resolved state dies with the interface (crash included),
# so DNS fails open together with the routes above.
${resolvectl} dns wg-mullvad ${mullvadDNS}
${resolvectl} domain wg-mullvad "~."
# Replace the bootstrap peer with one resolved from Mullvad's live
# catalog, so a retired relay or rotated key self-heals on restart.
#
# The exit status GATES everything below, and that gate is the whole
# point: rules 5401-5403 pull every route into this interface, so
# installing them around a peer that cannot handshake does not "fail
# open" — it blackholes the machine completely. Fail-open has to mean
# "no verified relay ⇒ no egress policy", not merely "no interface ⇒
# no egress policy". Guarding with `if` also keeps a nonzero status
# from tripping wg-quick's `set -e` and tearing the interface down.
if ${mullvadJump} apply ${lib.escapeShellArg defaultRelay}; then
for fam in -4 -6; do
${ip} $fam rule add pref 5401 fwmark ${toString wgFwmark} lookup main
${ip} $fam rule add pref 5402 lookup main suppress_prefixlength 0
${ip} $fam rule add pref 5403 iif lo lookup ${toString tableId}
done
${ip} route replace default dev wg-mullvad metric 50 table ${toString tableId}
${ip} -6 route replace default dev wg-mullvad metric 50 table ${toString tableId}
# DNS: pin the Mullvad resolver to THIS link and claim the ~.
# routing domain. resolved routes each query to the scope with the
# best matching domain; ~. outranks the NM uplinks' bare
# DefaultRoute flag, so their DHCP (ISP) resolvers stop receiving
# queries while the tunnel is up. Without ~., resolved fans every
# query out to ALL default-route scopes in parallel — global DNS=
# does not exclude per-link servers, which is exactly the leak.
# Per-link resolved state dies with the interface (crash included),
# so DNS fails open together with the routes above.
${resolvectl} dns wg-mullvad ${mullvadDNS}
${resolvectl} domain wg-mullvad "~."
else
echo "wg-mullvad: no verified relay egress policy NOT installed;" >&2
echo "traffic stays on the physical uplink. Fix: mullvad-jump <prefix>" >&2
fi
'';
preDown = ''
${resolvectl} revert wg-mullvad 2>/dev/null || true
@ -203,13 +256,22 @@ in {
${ip} $fam route flush table ${toString tableId} 2>/dev/null || true
done
'';
peers = [
{
publicKey = mullvadPublicKey;
allowedIPs = [ "0.0.0.0/0" "::/0" ];
endpoint = mullvadEndpoint;
}
];
# Empty unless the private flake still pins a relay. An interface with
# no peers comes up fine and simply carries nothing until postUp
# resolves one — which is strictly better than starting with a peer
# that is probably stale, because "no peer" cannot masquerade as a
# working tunnel.
peers = lib.optional (mullvadEndpoint != null && mullvadPublicKey != null) {
publicKey = mullvadPublicKey;
allowedIPs = [ "0.0.0.0/0" "::/0" ];
endpoint = mullvadEndpoint;
# Without a keepalive the tunnel only handshakes once something
# routes into it, so "idle" and "dead relay" look identical — both
# to waybar and to mullvad-jump's post-configure verification.
# 25s also keeps the NAT mapping open. mullvad-jump sets the same
# value on every peer it installs.
persistentKeepalive = 25;
};
};
};