Last active
June 3, 2026 23:35
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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