Skip to content

Instantly share code, notes, and snippets.

@polymorphm
Last active June 3, 2026 23:35
Show Gist options
  • Select an option

  • Save polymorphm/bda4ba8c60fbacb12a779ec4e8a458c2 to your computer and use it in GitHub Desktop.

Select an option

Save polymorphm/bda4ba8c60fbacb12a779ec4e8a458c2 to your computer and use it in GitHub Desktop.
Local runner for macOS/Arch/Linux. It SSHes into a fresh Linux VPS and turns it into a managed L3/L4 forwarder for Mullvad "Server IP Override". It does not create a new Mullvad server identity; it forwards traffic to a real Mullvad relay while the Mullvad app still uses the existing relay hostname/key.
#!/usr/bin/env bash
# mullvad-vps-relay.sh
#
# Local runner for macOS/Arch/Linux. It SSHes into a Linux VPS and turns it into
# a managed L3/L4 forwarder for Mullvad "Server IP Override". It does not create
# a new Mullvad server identity; it forwards traffic to a real Mullvad relay while
# the Mullvad app still uses the existing relay hostname/key.
#
# Quality invariants for future edits:
# - Treat every CLI value as untrusted: validate locally, validate again remotely,
# and use only canonical values in generated shell/nft/systemd content.
# - Keep install/reload idempotent and fail-closed: remove owned allow rules before
# loading new ones, enable forwarding only after narrow rules are active, and make
# reruns clean up stale owned temp files.
# - Publish remote files by staging, validating, and atomically renaming into place.
# - Keep ownership explicit: only remove files/rules tagged or named by this script,
# and restore global forwarding sysctls only when this script changed them.
set -euo pipefail
IFS=$'\n\t'
REMOTE=""; SSH_PORT="22"; SSH_KEY=""
HOSTNAME=""; VPS4=""; VPS6=""; REAL4=""; REAL6=""
UDP_PORTS="1-65535" # broad default for direct WG/random/LWO-style UDP ports
TCP_PORTS="80,443,5001" # Mullvad UDP-over-TCP ports
UDP_NFT=""; TCP_NFT=""
OUT=""; ACTION="install"; FORCE_SSH_TCP="0"; FIREWALL_COMPAT="1"
trap 'echo "Interrupted" >&2; exit 130' INT
trap 'echo "Terminated" >&2; exit 143' TERM
trap 'echo "Hangup" >&2; exit 129' HUP
usage() {
cat <<'EOF'
Usage:
./mullvad-vps-relay.sh --remote root@VPS \
--vps4 VPS_PUBLIC_IPV4 \
--hostname REAL_MULLVAD_HOSTNAME \
--real4 REAL_MULLVAD_IPV4 \
[-o overrides.json]
Dual-stack:
./mullvad-vps-relay.sh --remote root@203.0.113.10 \
--vps4 203.0.113.10 --vps6 2001:db8::10 \
--hostname se-got-wg-001 \
--real4 185.213.154.68 --real6 2a03:1b20:5:f011::a09f \
-o mullvad-overrides.json
Options:
--remote USER@HOST SSH target. Root is simplest.
--ssh-port PORT SSH port. Default: 22.
-i, --identity FILE SSH private key.
--hostname NAME Existing Mullvad relay hostname, e.g. se-got-wg-001.
--vps4 IP Public IPv4 of your VPS.
--vps6 IP Public IPv6 of your VPS.
--real4 IP Real IPv4 of the Mullvad relay.
--real6 IP Real IPv6 of the Mullvad relay.
--udp-ports LIST UDP ports/ranges to forward. Default: 1-65535.
Examples: 51820 or 53,443,51820 or none.
--tcp-ports LIST TCP ports/ranges to forward. Default: 80,443,5001.
Use none to disable TCP forwarding.
-o, --output FILE Write Mullvad JSON to file. Default: stdout.
--uninstall Remove all managed remote files/tables/service.
--no-firewall-compat Do not add managed allow rules to existing forward firewalls.
--force-tcp-ssh-port Allow forwarding TCP port equal to --ssh-port.
-h, --help Show help.
Remote resources managed by this script:
/etc/mullvad-ip-override-relay/
/usr/local/sbin/mullvad-ip-override-relay-apply
/etc/systemd/system/mullvad-ip-override-relay.service
nftables tables: mio_relay_filter, mio_relay_nat4, mio_relay_nat6
firewall compatibility rules tagged: mio_relay_forward_compat
legacy cleanup only: /etc/sysctl.d/99-mullvad-ip-override-relay.conf
EOF
}
log(){ printf '%s\n' "$*" >&2; }
die(){ printf 'ERROR: %s\n' "$*" >&2; exit 1; }
quote(){ printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; }
uint(){ case "${1:-}" in ''|*[!0-9]*) return 1;; *) return 0;; esac; }
need_arg(){
[ $# -ge 2 ] || die "$1 requires a value"
[ -n "$2" ] || die "$1 requires a non-empty value"
case "$2" in --*) die "$1 requires a value, got option-like token: $2";; esac
}
valid4(){
awk -v ip="$1" 'BEGIN {
n=split(ip,a,"."); if (n != 4) exit 1
for (i=1; i<=4; i++) if (a[i] !~ /^[0-9]+$/ || a[i] < 0 || a[i] > 255) exit 1
}'
}
valid6(){
case "${1:-}" in
*:*) case "$1" in *[!0-9A-Fa-f:.]*) return 1;; *:::*) return 1;; *) return 0;; esac;;
*) return 1;;
esac
}
safe_name(){ case "${1:-}" in ''|*[!A-Za-z0-9._-]*) return 1;; *) return 0;; esac; }
safe_ssh_target(){ case "${1:-}" in ''|-*|*[!A-Za-z0-9@._:%+\[\]-]*) return 1;; *) return 0;; esac; }
safe_bool(){ case "${1:-}" in 0|1) return 0;; *) return 1;; esac; }
validate_output_path(){
[ -n "${OUT:-}" ] || return 0
case "$OUT" in *$'\n'*|*$'\r'*) die "bad --output path";; esac
local dir
case "$OUT" in */*) dir=${OUT%/*};; *) dir=.;; esac
[ -d "$dir" ] || die "--output parent directory does not exist: $dir"
}
ports_nft(){
local raw old out p a b x
raw=$(printf '%s' "$1" | tr -d '[:space:]')
[ "$raw" = "none" ] && { printf ''; return 0; }
[ -n "$raw" ] || return 1
old=$IFS; IFS=','; set -- $raw; IFS=$old
out=""
for p in "$@"; do
case "$p" in
*-*)
a=${p%%-*}; b=${p#*-}
uint "$a" && uint "$b" || return 1
[ "$a" -ge 1 ] && [ "$b" -le 65535 ] && [ "$a" -le "$b" ] || return 1
x="$a-$b"
;;
*)
uint "$p" || return 1
[ "$p" -ge 1 ] && [ "$p" -le 65535 ] || return 1
x="$p"
;;
esac
[ -n "$out" ] && out="$out,$x" || out="$x"
done
printf '%s' "$out"
}
ports_have(){
local raw needle old p a b
raw=$(printf '%s' "$1" | tr -d '[:space:]'); needle=$2
[ -n "$raw" ] || return 1
old=$IFS; IFS=','; set -- $raw; IFS=$old
for p in "$@"; do
case "$p" in
*-*) a=${p%%-*}; b=${p#*-}; [ "$needle" -ge "$a" ] 2>/dev/null && [ "$needle" -le "$b" ] 2>/dev/null && return 0;;
*) [ "$p" = "$needle" ] && return 0;;
esac
done
return 1
}
emit_json(){
printf '{\n "relay_overrides": [\n {\n "hostname": "%s"' "$HOSTNAME"
[ -n "$VPS4" ] && printf ',\n "ipv4_addr_in": "%s"' "$VPS4"
[ -n "$VPS6" ] && printf ',\n "ipv6_addr_in": "%s"' "$VPS6"
printf '\n }\n ]\n}\n'
}
write_output(){
local dir base tmp
if [ -z "$OUT" ]; then
emit_json
return 0
fi
case "$OUT" in */*) dir=${OUT%/*}; base=${OUT##*/};; *) dir=.; base=$OUT;; esac
tmp=$(mktemp "$dir/.${base}.tmp.XXXXXX")
emit_json > "$tmp"
mv -f "$tmp" "$OUT"
log "Wrote $OUT"
}
parse(){
while [ $# -gt 0 ]; do
case "$1" in
--remote) need_arg "$1" "${2:-}"; REMOTE=$2; shift 2;;
--ssh-port) need_arg "$1" "${2:-}"; SSH_PORT=$2; shift 2;;
-i|--identity) need_arg "$1" "${2:-}"; SSH_KEY=$2; shift 2;;
--hostname|--mullvad-hostname) need_arg "$1" "${2:-}"; HOSTNAME=$2; shift 2;;
--vps4|--vps-ipv4) need_arg "$1" "${2:-}"; VPS4=$2; shift 2;;
--vps6|--vps-ipv6) need_arg "$1" "${2:-}"; VPS6=$2; shift 2;;
--real4|--mullvad-ipv4) need_arg "$1" "${2:-}"; REAL4=$2; shift 2;;
--real6|--mullvad-ipv6) need_arg "$1" "${2:-}"; REAL6=$2; shift 2;;
--udp-ports) need_arg "$1" "${2:-}"; UDP_PORTS=$2; shift 2;;
--tcp-ports) need_arg "$1" "${2:-}"; TCP_PORTS=$2; shift 2;;
-o|--output) need_arg "$1" "${2:-}"; OUT=$2; shift 2;;
--uninstall) ACTION="uninstall"; shift;;
--no-firewall-compat) FIREWALL_COMPAT="0"; shift;;
--force-tcp-ssh-port) FORCE_SSH_TCP="1"; shift;;
-h|--help) usage; exit 0;;
*) die "Unknown option: $1";;
esac
done
}
validate(){
[ -n "$REMOTE" ] || die "--remote is required"
safe_ssh_target "$REMOTE" || die "bad --remote"
uint "$SSH_PORT" && [ "$SSH_PORT" -ge 1 ] && [ "$SSH_PORT" -le 65535 ] || die "bad --ssh-port"
safe_bool "$FIREWALL_COMPAT" || die "bad firewall compatibility value"
[ -z "$SSH_KEY" ] || [ -r "$SSH_KEY" ] || die "identity file is not readable: $SSH_KEY"
validate_output_path
if [ "$ACTION" = "install" ]; then
[ -n "$HOSTNAME" ] && safe_name "$HOSTNAME" || die "bad/missing --hostname"
[ -n "$VPS4$VPS6" ] || die "provide --vps4 and/or --vps6"
[ -z "$VPS4" ] || valid4 "$VPS4" || die "bad --vps4"
[ -z "$VPS4" ] || [ -n "$REAL4" ] || die "--real4 required with --vps4"
[ -z "$REAL4" ] || valid4 "$REAL4" || die "bad --real4"
[ -z "$VPS6" ] || valid6 "$VPS6" || die "bad --vps6"
[ -z "$VPS6" ] || [ -n "$REAL6" ] || die "--real6 required with --vps6"
[ -z "$REAL6" ] || valid6 "$REAL6" || die "bad --real6"
UDP_NFT=$(ports_nft "$UDP_PORTS") || die "bad --udp-ports"
TCP_NFT=$(ports_nft "$TCP_PORTS") || die "bad --tcp-ports"
[ -n "$UDP_NFT$TCP_NFT" ] || die "at least one forwarded port list must be non-empty"
[ "$FORCE_SSH_TCP" = "1" ] || ! ports_have "$TCP_NFT" "$SSH_PORT" || die "--tcp-ports includes SSH port $SSH_PORT; use --force-tcp-ssh-port to override"
fi
}
remote_program(){ cat <<'REMOTE'
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
SVC="mullvad-ip-override-relay.service"
BASE="/etc/mullvad-ip-override-relay"
RULES="$BASE/rules.nft"
CONF="$BASE/config.env"
STATE="$BASE/state.env"
APPLY="/usr/local/sbin/mullvad-ip-override-relay-apply"
UNIT="/etc/systemd/system/$SVC"
LEGACY_SYSCTL="/etc/sysctl.d/99-mullvad-ip-override-relay.conf"
TF="mio_relay_filter"; T4="mio_relay_nat4"; T6="mio_relay_nat6"
FW_COMMENT="mio_relay_forward_compat"
STAGE=""
rlog(){ printf '[remote] %s\n' "$*" >&2; }
rdie(){ printf '[remote] ERROR: %s\n' "$*" >&2; exit 1; }
cleanup_stage(){
if [ -n "$STAGE" ] && [ -d "$STAGE" ]; then
rm -rf -- "$STAGE"
fi
return 0
}
on_signal(){ local code=$1 msg=$2; cleanup_stage; rlog "$msg"; exit "$code"; }
trap 'on_signal 130 Interrupted' INT
trap 'on_signal 143 Terminated' TERM
trap 'on_signal 129 Hangup' HUP
trap 'cleanup_stage' EXIT
root_or_die(){ [ "$(id -u)" -eq 0 ] || rdie "SSH as root or use a root account."; }
has_systemd(){ command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; }
uint(){ case "${1:-}" in ''|*[!0-9]*) return 1;; *) return 0;; esac; }
safe_name(){ case "${1:-}" in ''|*[!A-Za-z0-9._-]*) return 1;; *) return 0;; esac; }
safe_ports(){ case "${1:-}" in *[!0-9,-]*) return 1;; *) return 0;; esac; }
safe_bool(){ case "${1:-}" in 0|1) return 0;; *) return 1;; esac; }
valid4(){ awk -v ip="$1" 'BEGIN{n=split(ip,a,"."); if(n!=4) exit 1; for(i=1;i<=4;i++) if(a[i]!~/^[0-9]+$/||a[i]<0||a[i]>255) exit 1}' ; }
valid6(){ case "${1:-}" in *:*) case "$1" in *[!0-9A-Fa-f:.]*) return 1;; *:::*) return 1;; *) return 0;; esac;; *) return 1;; esac; }
require_nft_ident(){
case "${1:-}" in ''|*[!A-Za-z0-9_.:-]*) return 1;; *) printf '%s' "$1";; esac
}
remove_owned_paths(){
local p
for p in "$@"; do
[ -e "$p" ] || continue
rm -rf -- "$p"
done
}
cleanup_stale(){
[ -d "$BASE" ] && remove_owned_paths "$BASE"/.stage.* "$BASE"/.rules.nft.tmp.* "$BASE"/.config.env.tmp.* "$BASE"/.state.env.tmp.*
remove_owned_paths /usr/local/sbin/.mullvad-ip-override-relay-apply.tmp.* /etc/systemd/system/.mullvad-ip-override-relay.service.tmp.*
}
install_nft(){
command -v nft >/dev/null 2>&1 && return 0
rlog "Installing nftables"
if command -v apt-get >/dev/null 2>&1; then export DEBIAN_FRONTEND=noninteractive; apt-get update; apt-get install -y nftables ca-certificates
elif command -v dnf >/dev/null 2>&1; then dnf install -y nftables ca-certificates
elif command -v yum >/dev/null 2>&1; then yum install -y nftables ca-certificates
elif command -v pacman >/dev/null 2>&1; then pacman -Sy --noconfirm nftables ca-certificates
elif command -v zypper >/dev/null 2>&1; then zypper --non-interactive install nftables ca-certificates
else rdie "Unsupported package manager. Install nftables manually and rerun."
fi
}
validate_remote_config(){
safe_name "${MIO_HOSTNAME:-}" || rdie "bad hostname in remote config"
[ -z "${MIO_VPS4:-}" ] || valid4 "$MIO_VPS4" || rdie "bad VPS IPv4 in remote config"
[ -z "${MIO_REAL4:-}" ] || valid4 "$MIO_REAL4" || rdie "bad real IPv4 in remote config"
[ -z "${MIO_VPS6:-}" ] || valid6 "$MIO_VPS6" || rdie "bad VPS IPv6 in remote config"
[ -z "${MIO_REAL6:-}" ] || valid6 "$MIO_REAL6" || rdie "bad real IPv6 in remote config"
[ -n "${MIO_VPS4:-}${MIO_VPS6:-}" ] || rdie "missing VPS address in remote config"
[ -z "${MIO_VPS4:-}" ] || [ -n "${MIO_REAL4:-}" ] || rdie "missing real IPv4 in remote config"
[ -z "${MIO_VPS6:-}" ] || [ -n "${MIO_REAL6:-}" ] || rdie "missing real IPv6 in remote config"
safe_ports "${MIO_UDP:-}" || rdie "bad UDP ports in remote config"
safe_ports "${MIO_TCP:-}" || rdie "bad TCP ports in remote config"
[ -n "${MIO_UDP:-}${MIO_TCP:-}" ] || rdie "missing port sets in remote config"
safe_bool "${MIO_FW_COMPAT:-1}" || rdie "bad firewall compatibility value in remote config"
}
write_config_to(){
local dst=$1
validate_remote_config
{
printf 'MIO_HOSTNAME=%s\n' "${MIO_HOSTNAME:-}"
printf 'MIO_VPS4=%s\n' "${MIO_VPS4:-}"
printf 'MIO_VPS6=%s\n' "${MIO_VPS6:-}"
printf 'MIO_REAL4=%s\n' "${MIO_REAL4:-}"
printf 'MIO_REAL6=%s\n' "${MIO_REAL6:-}"
printf 'MIO_UDP=%s\n' "${MIO_UDP:-}"
printf 'MIO_TCP=%s\n' "${MIO_TCP:-}"
printf 'MIO_FW_COMPAT=%s\n' "${MIO_FW_COMPAT:-1}"
} > "$dst"
chmod 0644 "$dst"
}
write_rules_to(){
local dst=$1
: > "$dst"
{
echo "# Managed by mullvad-vps-relay.sh. Remove with --uninstall."
echo "# Intended Mullvad hostname: ${MIO_HOSTNAME:-unknown}"
echo "# Generated: $(date -u '+%Y-%m-%dT%H:%M:%SZ')"
echo
} >> "$dst"
{
echo "table inet $TF {"
[ -n "${MIO_UDP:-}" ] && echo " set udp_ports { type inet_service; flags interval; elements = { ${MIO_UDP} }; }"
[ -n "${MIO_TCP:-}" ] && echo " set tcp_ports { type inet_service; flags interval; elements = { ${MIO_TCP} }; }"
echo " chain forward { type filter hook forward priority -100; policy accept;"
echo " ct state established,related accept"
[ -n "${MIO_REAL4:-}" ] && [ -n "${MIO_UDP:-}" ] && echo " ip daddr ${MIO_REAL4} udp dport @udp_ports accept"
[ -n "${MIO_REAL4:-}" ] && [ -n "${MIO_UDP:-}" ] && echo " ip saddr ${MIO_REAL4} udp sport @udp_ports accept"
[ -n "${MIO_REAL4:-}" ] && [ -n "${MIO_TCP:-}" ] && echo " ip daddr ${MIO_REAL4} tcp dport @tcp_ports accept"
[ -n "${MIO_REAL4:-}" ] && [ -n "${MIO_TCP:-}" ] && echo " ip saddr ${MIO_REAL4} tcp sport @tcp_ports accept"
[ -n "${MIO_REAL6:-}" ] && [ -n "${MIO_UDP:-}" ] && echo " ip6 daddr ${MIO_REAL6} udp dport @udp_ports accept"
[ -n "${MIO_REAL6:-}" ] && [ -n "${MIO_UDP:-}" ] && echo " ip6 saddr ${MIO_REAL6} udp sport @udp_ports accept"
[ -n "${MIO_REAL6:-}" ] && [ -n "${MIO_TCP:-}" ] && echo " ip6 daddr ${MIO_REAL6} tcp dport @tcp_ports accept"
[ -n "${MIO_REAL6:-}" ] && [ -n "${MIO_TCP:-}" ] && echo " ip6 saddr ${MIO_REAL6} tcp sport @tcp_ports accept"
echo " }"
echo "}"
echo
} >> "$dst"
if [ -n "${MIO_VPS4:-}" ] && [ -n "${MIO_REAL4:-}" ]; then
{
echo "table ip $T4 {"
[ -n "${MIO_UDP:-}" ] && echo " set udp_ports { type inet_service; flags interval; elements = { ${MIO_UDP} }; }"
[ -n "${MIO_TCP:-}" ] && echo " set tcp_ports { type inet_service; flags interval; elements = { ${MIO_TCP} }; }"
echo " chain prerouting { type nat hook prerouting priority dstnat; policy accept;"
[ -n "${MIO_UDP:-}" ] && echo " ip daddr ${MIO_VPS4} udp dport @udp_ports dnat to ${MIO_REAL4}"
[ -n "${MIO_TCP:-}" ] && echo " ip daddr ${MIO_VPS4} tcp dport @tcp_ports dnat to ${MIO_REAL4}"
echo " }"
echo " chain postrouting { type nat hook postrouting priority srcnat; policy accept;"
[ -n "${MIO_UDP:-}" ] && echo " ip daddr ${MIO_REAL4} udp dport @udp_ports masquerade"
[ -n "${MIO_TCP:-}" ] && echo " ip daddr ${MIO_REAL4} tcp dport @tcp_ports masquerade"
echo " }"
echo "}"
echo
} >> "$dst"
fi
if [ -n "${MIO_VPS6:-}" ] && [ -n "${MIO_REAL6:-}" ]; then
{
echo "table ip6 $T6 {"
[ -n "${MIO_UDP:-}" ] && echo " set udp_ports { type inet_service; flags interval; elements = { ${MIO_UDP} }; }"
[ -n "${MIO_TCP:-}" ] && echo " set tcp_ports { type inet_service; flags interval; elements = { ${MIO_TCP} }; }"
echo " chain prerouting { type nat hook prerouting priority dstnat; policy accept;"
[ -n "${MIO_UDP:-}" ] && echo " ip6 daddr ${MIO_VPS6} udp dport @udp_ports dnat to ${MIO_REAL6}"
[ -n "${MIO_TCP:-}" ] && echo " ip6 daddr ${MIO_VPS6} tcp dport @tcp_ports dnat to ${MIO_REAL6}"
echo " }"
echo " chain postrouting { type nat hook postrouting priority srcnat; policy accept;"
[ -n "${MIO_UDP:-}" ] && echo " ip6 daddr ${MIO_REAL6} udp dport @udp_ports masquerade"
[ -n "${MIO_TCP:-}" ] && echo " ip6 daddr ${MIO_REAL6} tcp dport @tcp_ports masquerade"
echo " }"
echo "}"
echo
} >> "$dst"
fi
chmod 0644 "$dst"
}
write_apply_to(){
local dst=$1
cat > "$dst" <<'APPLYSH'
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
BASE="/etc/mullvad-ip-override-relay"
RULES="$BASE/rules.nft"
CONF="$BASE/config.env"
STATE="$BASE/state.env"
TF="mio_relay_filter"; T4="mio_relay_nat4"; T6="mio_relay_nat6"
FW_COMMENT="mio_relay_forward_compat"
rlog(){ printf '[remote] %s\n' "$*" >&2; }
uint(){ case "${1:-}" in ''|*[!0-9]*) return 1;; *) return 0;; esac; }
safe_ports(){ case "${1:-}" in *[!0-9,-]*) return 1;; *) return 0;; esac; }
safe_bool(){ case "${1:-}" in 0|1) return 0;; *) return 1;; esac; }
valid4(){ awk -v ip="$1" 'BEGIN{n=split(ip,a,"."); if(n!=4) exit 1; for(i=1;i<=4;i++) if(a[i]!~/^[0-9]+$/||a[i]<0||a[i]>255) exit 1}' ; }
valid6(){ case "${1:-}" in *:*) case "$1" in *[!0-9A-Fa-f:.]*) return 1;; *:::*) return 1;; *) return 0;; esac;; *) return 1;; esac; }
load_kv_file(){
local file=$1 key value
[ -r "$file" ] || return 1
while IFS='=' read -r key value || [ -n "${key:-}" ]; do
case "$key" in
''|\#*) continue;;
MIO_HOSTNAME|MIO_VPS4|MIO_VPS6|MIO_REAL4|MIO_REAL6|MIO_UDP|MIO_TCP|MIO_FW_COMPAT|MIO_STATE_VERSION|MIO_IPV4_OLD|MIO_IPV4_OWNED|MIO_IPV6_ALL_OLD|MIO_IPV6_ALL_OWNED|MIO_IPV6_DEFAULT_OLD|MIO_IPV6_DEFAULT_OWNED)
printf -v "$key" '%s' "${value:-}"
;;
*) return 1;;
esac
done < "$file"
}
validate_config(){
[ -z "${MIO_REAL4:-}" ] || valid4 "$MIO_REAL4" || return 1
[ -z "${MIO_REAL6:-}" ] || valid6 "$MIO_REAL6" || return 1
safe_ports "${MIO_UDP:-}" || return 1
safe_ports "${MIO_TCP:-}" || return 1
safe_bool "${MIO_FW_COMPAT:-1}" || return 1
[ -n "${MIO_REAL4:-}${MIO_REAL6:-}" ] || return 1
[ -n "${MIO_UDP:-}${MIO_TCP:-}" ] || return 1
}
nft_ident(){
case "${1:-}" in ''|*[!A-Za-z0-9_.:-]*) return 1;; *) printf '%s' "$1";; esac
}
sysctl_get(){ sysctl -n "$1" 2>/dev/null || printf '0'; }
sysctl_set(){ sysctl -w "$1=$2" >/dev/null; }
sysctl_restore(){ sysctl -w "$1=$2" >/dev/null 2>&1 || true; }
write_state(){
local tmp
tmp="$BASE/.state.env.tmp.$$"
{
printf 'MIO_STATE_VERSION=1\n'
printf 'MIO_IPV4_OLD=%s\n' "${MIO_IPV4_OLD:-0}"
printf 'MIO_IPV4_OWNED=%s\n' "${MIO_IPV4_OWNED:-0}"
printf 'MIO_IPV6_ALL_OLD=%s\n' "${MIO_IPV6_ALL_OLD:-0}"
printf 'MIO_IPV6_ALL_OWNED=%s\n' "${MIO_IPV6_ALL_OWNED:-0}"
printf 'MIO_IPV6_DEFAULT_OLD=%s\n' "${MIO_IPV6_DEFAULT_OLD:-0}"
printf 'MIO_IPV6_DEFAULT_OWNED=%s\n' "${MIO_IPV6_DEFAULT_OWNED:-0}"
} > "$tmp"
chmod 0644 "$tmp"
mv -f "$tmp" "$STATE"
}
load_state_or_default(){
MIO_STATE_VERSION=1
MIO_IPV4_OLD=0; MIO_IPV4_OWNED=0
MIO_IPV6_ALL_OLD=0; MIO_IPV6_ALL_OWNED=0
MIO_IPV6_DEFAULT_OLD=0; MIO_IPV6_DEFAULT_OWNED=0
[ -r "$STATE" ] && load_kv_file "$STATE" || true
}
ensure_state(){
load_state_or_default
if [ -r "$STATE" ]; then
return 0
fi
MIO_IPV4_OLD=$(sysctl_get net.ipv4.ip_forward)
MIO_IPV6_ALL_OLD=$(sysctl_get net.ipv6.conf.all.forwarding)
MIO_IPV6_DEFAULT_OLD=$(sysctl_get net.ipv6.conf.default.forwarding)
MIO_IPV4_OWNED=0
MIO_IPV6_ALL_OWNED=0
MIO_IPV6_DEFAULT_OWNED=0
write_state
}
enable_forwarding(){
ensure_state
if [ -n "${MIO_REAL4:-}" ] && [ "$(sysctl_get net.ipv4.ip_forward)" != "1" ]; then
sysctl_set net.ipv4.ip_forward 1
MIO_IPV4_OWNED=1
write_state
fi
if [ -n "${MIO_REAL6:-}" ] && [ "$(sysctl_get net.ipv6.conf.all.forwarding)" != "1" ]; then
sysctl_set net.ipv6.conf.all.forwarding 1
MIO_IPV6_ALL_OWNED=1
write_state
fi
if [ -n "${MIO_REAL6:-}" ] && [ "$(sysctl_get net.ipv6.conf.default.forwarding)" != "1" ]; then
sysctl_set net.ipv6.conf.default.forwarding 1
MIO_IPV6_DEFAULT_OWNED=1
write_state
fi
write_state
}
restore_forwarding(){
[ -r "$STATE" ] || return 0
load_state_or_default
[ "${MIO_IPV4_OWNED:-0}" = "1" ] && sysctl_restore net.ipv4.ip_forward "${MIO_IPV4_OLD:-0}"
[ "${MIO_IPV6_ALL_OWNED:-0}" = "1" ] && sysctl_restore net.ipv6.conf.all.forwarding "${MIO_IPV6_ALL_OLD:-0}"
[ "${MIO_IPV6_DEFAULT_OWNED:-0}" = "1" ] && sysctl_restore net.ipv6.conf.default.forwarding "${MIO_IPV6_DEFAULT_OLD:-0}"
MIO_IPV4_OWNED=0
MIO_IPV6_ALL_OWNED=0
MIO_IPV6_DEFAULT_OWNED=0
write_state
}
delete_nft_compat(){
command -v nft >/dev/null 2>&1 || return 0
local rows fam tab chain handle qtab qchain
rows=$(nft -a list ruleset 2>/dev/null | awk -v c="$FW_COMMENT" '
/^table[[:space:]]+(ip|ip6|inet)[[:space:]]+/ { fam=$2; tab=$3; next }
/^[[:space:]]*chain[[:space:]]+/ { chain=$2; next }
index($0, "comment \"" c "\"") {
for (i=1; i<NF; i++) if ($i == "handle") print fam "\t" tab "\t" chain "\t" $(i+1)
}
' || true)
[ -n "$rows" ] || return 0
while IFS=$'\t' read -r fam tab chain handle; do
[ -n "${fam:-}" ] && [ -n "${tab:-}" ] && [ -n "${chain:-}" ] && [ -n "${handle:-}" ] || continue
qtab=$(nft_ident "$tab") || continue
qchain=$(nft_ident "$chain") || continue
printf 'delete rule %s %s %s handle %s\n' "$fam" "$qtab" "$qchain" "$handle" | nft -f - 2>/dev/null || true
done <<< "$rows"
return 0
}
iptables_legacy(){
local cmd=$1
command -v "$cmd" >/dev/null 2>&1 || return 1
"$cmd" -V 2>/dev/null | grep -qi 'nf_tables' && return 1
return 0
}
delete_iptables_compat_one(){
local cmd=$1 line chain
iptables_legacy "$cmd" || return 0
while :; do
line=$("$cmd" -S FORWARD 2>/dev/null | grep -- "--comment $FW_COMMENT" | head -n 1 || true)
[ -n "$line" ] || break
set -- $line
[ "${1:-}" = "-A" ] || break
shift
chain=$1
shift
"$cmd" -D "$chain" "$@" || break
done
return 0
}
delete_firewall_compat(){
delete_nft_compat
delete_iptables_compat_one iptables
delete_iptables_compat_one ip6tables
return 0
}
delete_tables(){
command -v nft >/dev/null 2>&1 || return 0
nft list table inet "$TF" >/dev/null 2>&1 && nft delete table inet "$TF" || true
nft list table ip "$T4" >/dev/null 2>&1 && nft delete table ip "$T4" || true
nft list table ip6 "$T6" >/dev/null 2>&1 && nft delete table ip6 "$T6" || true
}
ports_chunks(){
local raw item chunk="" count=0
raw=$(printf '%s' "${1:-}" | tr -d '[:space:]')
[ -n "$raw" ] || return 0
IFS=',' read -r -a items <<< "$raw"
for item in "${items[@]}"; do
item=${item/-/:}
if [ "$count" -eq 15 ]; then
printf '%s\n' "$chunk"
chunk=""; count=0
fi
[ -n "$chunk" ] && chunk="$chunk,$item" || chunk="$item"
count=$((count + 1))
done
[ -n "$chunk" ] && printf '%s\n' "$chunk"
}
insert_iptables_rule(){
local cmd=$1
shift
"$cmd" -C FORWARD "$@" 2>/dev/null || "$cmd" -I FORWARD 1 "$@"
}
add_iptables_compat_for(){
local cmd=$1 ip=$2 proto=$3 ports=$4 chunk count=0
[ -n "$ip" ] && [ -n "$ports" ] || return 1
while IFS= read -r chunk; do
[ -n "$chunk" ] || continue
insert_iptables_rule "$cmd" -p "$proto" -d "$ip" -m multiport --dports "$chunk" -m comment --comment "$FW_COMMENT" -j ACCEPT
insert_iptables_rule "$cmd" -p "$proto" -s "$ip" -m multiport --sports "$chunk" -m comment --comment "$FW_COMMENT" -j ACCEPT
count=$((count + 2))
done < <(ports_chunks "$ports")
[ "$count" -gt 0 ]
}
add_iptables_compat(){
local count=0
if iptables_legacy iptables && [ -n "${MIO_REAL4:-}" ]; then
add_iptables_compat_for iptables "$MIO_REAL4" tcp "${MIO_TCP:-}" && count=$((count + 1))
add_iptables_compat_for iptables "$MIO_REAL4" udp "${MIO_UDP:-}" && count=$((count + 1))
fi
if iptables_legacy ip6tables && [ -n "${MIO_REAL6:-}" ]; then
add_iptables_compat_for ip6tables "$MIO_REAL6" tcp "${MIO_TCP:-}" && count=$((count + 1))
add_iptables_compat_for ip6tables "$MIO_REAL6" udp "${MIO_UDP:-}" && count=$((count + 1))
fi
[ "$count" -gt 0 ] && rlog "Installed firewall compatibility rules in legacy iptables FORWARD chains."
return 0
}
NFT_RULES=""
append_nft_rule(){
local fam=$1 qtab=$2 qchain=$3 af=$4 dir=$5 ip=$6 proto=$7 portkw=$8 ports=$9
[ -n "$ip" ] && [ -n "$ports" ] || return 0
NFT_RULES+=$(printf 'insert rule %s %s %s %s %s %s %s %s { %s } counter accept comment "%s"' "$fam" "$qtab" "$qchain" "$af" "$dir" "$ip" "$proto" "$portkw" "$ports" "$FW_COMMENT")
NFT_RULES+=$'\n'
}
add_nft_compat_to_chain(){
local fam=$1 tab=$2 chain=$3 qtab qchain
NFT_RULES=""
qtab=$(nft_ident "$tab") || return 1
qchain=$(nft_ident "$chain") || return 1
if [ "$fam" = "ip" ] || [ "$fam" = "inet" ]; then
append_nft_rule "$fam" "$qtab" "$qchain" ip daddr "${MIO_REAL4:-}" tcp dport "${MIO_TCP:-}"
append_nft_rule "$fam" "$qtab" "$qchain" ip saddr "${MIO_REAL4:-}" tcp sport "${MIO_TCP:-}"
append_nft_rule "$fam" "$qtab" "$qchain" ip daddr "${MIO_REAL4:-}" udp dport "${MIO_UDP:-}"
append_nft_rule "$fam" "$qtab" "$qchain" ip saddr "${MIO_REAL4:-}" udp sport "${MIO_UDP:-}"
fi
if [ "$fam" = "ip6" ] || [ "$fam" = "inet" ]; then
append_nft_rule "$fam" "$qtab" "$qchain" ip6 daddr "${MIO_REAL6:-}" tcp dport "${MIO_TCP:-}"
append_nft_rule "$fam" "$qtab" "$qchain" ip6 saddr "${MIO_REAL6:-}" tcp sport "${MIO_TCP:-}"
append_nft_rule "$fam" "$qtab" "$qchain" ip6 daddr "${MIO_REAL6:-}" udp dport "${MIO_UDP:-}"
append_nft_rule "$fam" "$qtab" "$qchain" ip6 saddr "${MIO_REAL6:-}" udp sport "${MIO_UDP:-}"
fi
[ -n "$NFT_RULES" ] || return 1
printf '%s' "$NFT_RULES" | nft -f -
}
add_nft_compat(){
command -v nft >/dev/null 2>&1 || return 0
local rows fam tab chain count=0
rows=$(nft -a list ruleset 2>/dev/null | awk '
/^table[[:space:]]+(ip|ip6|inet)[[:space:]]+/ { fam=$2; tab=$3; next }
/^[[:space:]]*chain[[:space:]]+/ { chain=$2; next }
/^[[:space:]]*type[[:space:]]+filter[[:space:]]+hook[[:space:]]+forward[[:space:]]+/ { print fam "\t" tab "\t" chain }
' || true)
[ -n "$rows" ] || return 0
while IFS=$'\t' read -r fam tab chain; do
[ -n "${fam:-}" ] && [ -n "${tab:-}" ] && [ -n "${chain:-}" ] || continue
case "$tab" in "$TF"|"$T4"|"$T6"|mio_relay_*) continue;; esac
add_nft_compat_to_chain "$fam" "$tab" "$chain" && count=$((count + 1)) || true
done <<< "$rows"
[ "$count" -gt 0 ] && rlog "Installed firewall compatibility rules in $count nft forward chain(s)."
return 0
}
add_firewall_compat(){
if [ "${MIO_FW_COMPAT:-1}" != "1" ]; then
rlog "Firewall compatibility rules skipped by --no-firewall-compat."
return 0
fi
add_nft_compat
add_iptables_compat
}
start_or_reload(){
[ -r "$CONF" ] || { echo "missing $CONF" >&2; exit 1; }
load_kv_file "$CONF" || { echo "bad $CONF" >&2; exit 1; }
validate_config || { echo "invalid $CONF" >&2; exit 1; }
restore_forwarding
delete_firewall_compat
delete_tables
nft -f "$RULES"
add_firewall_compat
enable_forwarding
}
stop_all(){
restore_forwarding
delete_firewall_compat
delete_tables
}
case "${1:-start}" in
start|reload) start_or_reload;;
stop) stop_all;;
*) echo "usage: $0 {start|reload|stop}" >&2; exit 2;;
esac
APPLYSH
chmod 0755 "$dst"
}
write_unit_to(){
local dst=$1
cat > "$dst" <<EOF
[Unit]
Description=Mullvad Server IP Override relay forwarder
Wants=network-online.target
After=network-online.target ufw.service firewalld.service nftables.service iptables.service ip6tables.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=$APPLY start
ExecReload=$APPLY reload
ExecStop=$APPLY stop
[Install]
WantedBy=multi-user.target
EOF
chmod 0644 "$dst"
}
publish_file(){
local src=$1 dst=$2 mode=$3 dir base tmp
dir=${dst%/*}; base=${dst##*/}
tmp="$dir/.$base.tmp.$$"
rm -f -- "$tmp"
command install -m "$mode" "$src" "$tmp"
mv -f "$tmp" "$dst"
}
publish_stage(){
publish_file "$STAGE/config.env" "$CONF" 0644
publish_file "$STAGE/rules.nft" "$RULES" 0644
publish_file "$STAGE/apply" "$APPLY" 0755
publish_file "$STAGE/unit" "$UNIT" 0644
}
del_tables(){
command -v nft >/dev/null 2>&1 || return 0
nft list table inet "$TF" >/dev/null 2>&1 && nft delete table inet "$TF" || true
nft list table ip "$T4" >/dev/null 2>&1 && nft delete table ip "$T4" || true
nft list table ip6 "$T6" >/dev/null 2>&1 && nft delete table ip6 "$T6" || true
}
uninstall(){
root_or_die
has_systemd && { systemctl stop "$SVC" >/dev/null 2>&1 || true; systemctl disable "$SVC" >/dev/null 2>&1 || true; }
[ -x "$APPLY" ] && "$APPLY" stop >/dev/null 2>&1 || true
command -v nft >/dev/null 2>&1 && del_tables
cleanup_stale
rm -f -- "$RULES" "$CONF" "$STATE" "$APPLY" "$UNIT" "$LEGACY_SYSCTL"
rmdir "$BASE" >/dev/null 2>&1 || true
has_systemd && { systemctl daemon-reload || true; systemctl reset-failed "$SVC" >/dev/null 2>&1 || true; }
rlog "Removed managed relay setup and restored script-owned forwarding sysctls."
}
install_relay(){
root_or_die; has_systemd || rdie "systemd is required for persistent setup."; install_nft
mkdir -p "$BASE"; chmod 0755 "$BASE"
cleanup_stale
STAGE=$(mktemp -d "$BASE/.stage.XXXXXX")
write_config_to "$STAGE/config.env"
write_rules_to "$STAGE/rules.nft"
write_apply_to "$STAGE/apply"
write_unit_to "$STAGE/unit"
bash -n "$STAGE/apply"
nft -c -f "$STAGE/rules.nft" >/dev/null
rm -f -- "$LEGACY_SYSCTL"
publish_stage
systemctl daemon-reload
systemctl enable "$SVC" >/dev/null
systemctl restart "$SVC"
cleanup_stage; STAGE=""
if [ "${MIO_FW_COMPAT:-1}" = "1" ]; then
rlog "Firewall compatibility mode enabled; managed rules are tagged mio_relay_forward_compat."
else
rlog "Firewall compatibility mode disabled by --no-firewall-compat."
fi
rlog "Installed/reconfigured $SVC"
rlog "Rules: $RULES"
}
root_or_die
case "${MIO_ACTION:-install}" in install) install_relay;; uninstall) uninstall;; *) rdie "bad action";; esac
REMOTE
}
main(){
parse "$@"
validate
local_cmd="MIO_ACTION=$(quote "$ACTION") MIO_HOSTNAME=$(quote "$HOSTNAME") MIO_VPS4=$(quote "$VPS4") MIO_VPS6=$(quote "$VPS6") MIO_REAL4=$(quote "$REAL4") MIO_REAL6=$(quote "$REAL6") MIO_UDP=$(quote "$UDP_NFT") MIO_TCP=$(quote "$TCP_NFT") MIO_FW_COMPAT=$(quote "$FIREWALL_COMPAT") bash -s"
ssh_args=(-o BatchMode=yes -p "$SSH_PORT")
[ -n "$SSH_KEY" ] && ssh_args+=( -i "$SSH_KEY" )
log "Connecting to $REMOTE ..."
remote_payload=$(remote_program)
ssh "${ssh_args[@]}" "$REMOTE" "$local_cmd" <<< "$remote_payload"
[ "$ACTION" = "install" ] && write_output || log "Uninstall complete"
}
main "$@"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment