chore: update wireguard and mullvad settings
This commit is contained in:
parent
735a311946
commit
ef50a12528
4 changed files with 377 additions and 69 deletions
8
flake.lock
generated
8
flake.lock
generated
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue