Rewrite as bash script with ipify verification

Replace Python implementation with simpler bash script that:
- Uses tailscale set --exit-node instead of tailscale down/up
- Verifies IP change via api.ipify.org before and after switching
- Shows clear status with public IP
This commit is contained in:
Eddy 2026-05-16 10:33:53 +02:00
commit c58e5f8655
2 changed files with 125 additions and 235 deletions

View file

@ -1,60 +1,55 @@
# Tailscale-Mullvad # Tailscale-Mullvad
> 🤖 Gevibecode met **OpenCode 1.14.50** en **Deepseek v4 Pro**
CLI-tool om Mullvad exit nodes te gebruiken via Tailscale op macOS en Linux. CLI-tool om Mullvad exit nodes te gebruiken via Tailscale op macOS en Linux.
## Wat het doet ## Wat het doet
Schakelt Mullvad exit nodes in en uit via de Tailscale CLI. Al het internetverkeer loopt dan via een Mullvad VPN-server, terwijl Tailscale-peers direct bereikbaar blijven. Toont voor en na activatie het externe IP-adres. Zoekt een Mullvad exit node op basis van stad/land/hostname, activeert deze via
`tailscale set --exit-node=...` en verifieert met een IP-check via api.ipify.org.
Bij uitzetten wordt de exit node verwijderd en het IP eveneens geverifieerd.
## Installatie ## Installatie
```bash ```bash
git clone https://git.eddydevink.nl/eddy/tailscale-mullvad.git git clone ssh://git@nextcloud:2222/eddy/tailscale-mullvad.git
cd tailscale-mullvad cd tailscale-mullvad
chmod +x tailscale-mullvad chmod +x tailscale-mullvad
sudo cp tailscale-mullvad /usr/local/bin/ sudo cp tailscale-mullvad /usr/local/bin/
``` ```
Geen compilatie nodig — het is een Python 3 script.
**Vereisten:** **Vereisten:**
- Python 3 - Bash
- Tailscale CLI (`tailscale`) - Tailscale CLI (`tailscale`)
- Mullvad VPN add-on geactiveerd in je Tailscale admin console - Mullvad VPN add-on geactiveerd in je Tailscale admin console
- Jouw device toegevoegd aan Mullvad VPN in de admin console - Je device toegevoegd aan Mullvad VPN in de admin console
## Gebruik ## Gebruik
```bash ```bash
tailscale-mullvad on <stad of land> # Verbind met Mullvad exit node tailscale-mullvad on <query> # Verbind met Mullvad exit node
tailscale-mullvad off # Verbreek Mullvad exit node tailscale-mullvad off # Verbreek Mullvad exit node
tailscale-mullvad status # Toon huidige status tailscale-mullvad status # Toon huidige status
``` ```
Matching is **case-insensitive** op stad of land. Bij meerdere nodes in dezelfde regio wordt er willekeurig één gekozen. De query is case-insensitive en zoekt op hostname, land of stad in de exit
node lijst (`tailscale exit-node list`).
### Voorbeelden ### Voorbeelden
```bash ```bash
$ tailscale-mullvad on amsterdam $ tailscale-mullvad on amsterdam
Current IP: 94.157.20.52 Huidig IP: 94.157.20.52
Connecting to Mullvad: Netherlands - Amsterdam (nl-ams-wg-201.mullvad.ts.net) Verbinden met nl-ams-wg-201.mullvad.ts.net...
... Nieuw IP: 169.150.196.11
Mullvad: ON -> nl-ams-wg-201.mullvad.ts.net Exit node actief -- IP gewijzigd
IP: 94.157.20.52 -> 169.150.196.4
$ tailscale-mullvad status $ tailscale-mullvad status
Mullvad: ON -> nl-ams-wg-201.mullvad.ts.net Exit node actief
Publiek IP: 169.150.196.11
$ tailscale-mullvad off $ tailscale-mullvad off
Mullvad: OFF Huidig IP: 169.150.196.11
Exit node uitzetten...
$ tailscale-mullvad on japan Nieuw IP: 94.157.20.52
Current IP: 169.150.196.4 Exit node uit -- IP gewijzigd
Connecting to Mullvad: Japan - Tokyo (jp-tyo-wg-001.mullvad.ts.net)
...
Mullvad: ON -> jp-tyo-wg-001.mullvad.ts.net
IP: 169.150.196.4 -> 146.70.45.83
``` ```

View file

@ -1,209 +1,104 @@
#!/usr/bin/env python3 #!/bin/bash
"""Mullvad exit node manager for Tailscale on macOS. set -euo pipefail
Usage: RED='\033[0;31m'
mullvad on <city or country> - connect to Mullvad exit node GREEN='\033[0;32m'
mullvad off - disconnect Mullvad exit node YELLOW='\033[1;33m'
mullvad status - show current Mullvad status CYAN='\033[0;36m'
""" BOLD='\033[1m'
NC='\033[0m'
import subprocess get_ip() {
import sys curl -s --max-time 5 https://api.ipify.org 2>/dev/null || echo "onbekend"
import re }
import random
import time
cmd_status() {
local ip
ip=$(get_ip)
if [[ "$ip" != "94.157.20.52" && "$ip" != "onbekend" ]]; then
echo -e "${GREEN}Exit node actief${NC}"
else
echo -e "${YELLOW}Geen exit node actief${NC}"
fi
echo -e " Publiek IP: ${BOLD}${ip}${NC}"
}
def _run_tailscale(args, timeout=30): cmd_on() {
"""Run a tailscale command, suppressing version mismatch warnings.""" local query="${1:-}"
result = subprocess.run( if [[ -z "$query" ]]; then
["tailscale"] + args, echo -e "${YELLOW}Geef een exit node op, bv:${NC}"
capture_output=True, echo " mullvad on nl-ams-wg-201"
text=True, echo " mullvad on amsterdam"
timeout=timeout, exit 1
) fi
output = result.stdout
if result.returncode != 0:
if result.stderr:
print(result.stderr.strip(), file=sys.stderr)
result.check_returncode()
return output
local ip_before
ip_before=$(get_ip)
echo -e "Huidig IP: ${BOLD}${ip_before}${NC}"
def _run_tailscale_live(args, timeout=30): local node
"""Run tailscale command with live output (for up/down).""" node=$(tailscale exit-node list 2>/dev/null | grep -i "$query" | head -1 | awk '{print $2}')
result = subprocess.run( if [[ -z "$node" ]]; then
["tailscale"] + args, echo -e "${RED}Geen exit node gevonden voor '${query}'${NC}"
timeout=timeout, exit 1
) fi
if result.returncode != 0:
sys.exit(result.returncode)
echo -e "Verbinden met ${BOLD}${node}${NC}..."
tailscale set --exit-node="$node" 2>&1 | grep -v "Warning:" || true
sleep 2
def get_exit_nodes(): local ip_after
"""Get all available exit nodes including Mullvad ones.""" ip_after=$(get_ip)
output = _run_tailscale(["exit-node", "list"]) echo -e "Nieuw IP: ${BOLD}${ip_after}${NC}"
nodes = []
for line in output.split("\n"): if [[ "$ip_after" != "$ip_before" && "$ip_after" != "onbekend" ]]; then
line = line.strip() echo -e "${GREEN}Exit node actief -- IP gewijzigd${NC}"
if not line: else
continue echo -e "${YELLOW}Exit node ingesteld maar IP lijkt niet gewijzigd${NC}"
if line.startswith("#") or line.startswith("Warning"): fi
continue }
if line.startswith("IP "):
continue
match = re.match( cmd_off() {
r"^(\S+)\s+(\S+\.ts\.net)\.?\s+(.+?)\s{2,}(.+?)\s{2,}(\S+)$", local ip_before
line, ip_before=$(get_ip)
) echo -e "Huidig IP: ${BOLD}${ip_before}${NC}"
if match: echo -e "Exit node uitzetten..."
ip, hostname, country, city, status = match.groups()
hostname = hostname.rstrip(".")
nodes.append(
{
"ip": ip,
"hostname": hostname,
"country": country.strip(),
"city": city.strip(),
"status": status,
}
)
return nodes
tailscale set --exit-node= 2>&1 | grep -v "Warning:" || true
sleep 2
def find_nodes(nodes, location): local ip_after
"""Find Mullvad nodes matching location (case-insensitive on city/country/hostname).""" ip_after=$(get_ip)
location_lower = location.lower() echo -e "Nieuw IP: ${BOLD}${ip_after}${NC}"
matches = []
for node in nodes:
is_mullvad = "mullvad" in node["hostname"].lower()
if not is_mullvad:
continue
if ( if [[ "$ip_after" != "$ip_before" ]]; then
location_lower in node["city"].lower() echo -e "${GREEN}Exit node uit -- IP gewijzigd${NC}"
or location_lower in node["country"].lower() else
or location_lower in node["hostname"].lower() echo -e "${GREEN}Exit node uit${NC}"
): fi
matches.append(node) }
return matches usage() {
echo -e "${BOLD}mullvad${NC} - Exit node manager"
echo ""
echo " mullvad Toon status"
echo " mullvad on <query> Exit node aan (zoekt op hostname/land/stad)"
echo " mullvad off Exit node uit"
echo ""
echo -e "${BOLD}Voorbeelden:${NC}"
echo " mullvad on amsterdam"
echo " mullvad on nl-ams"
echo " mullvad off"
}
case "${1:-}" in
def get_current_exit_node(): on) cmd_on "${2:-}" ;;
"""Get the currently active exit node hostname, or None.""" off) cmd_off ;;
try: status|"") cmd_status ;;
output = _run_tailscale(["exit-node", "list"], timeout=10) -h|--help|help) usage ;;
except subprocess.CalledProcessError: *)
return None echo -e "${RED}Onbekend commando: $1${NC}"
for line in output.split("\n"): usage
if "selected" in line: exit 1
parts = line.split() ;;
if len(parts) >= 2: esac
return parts[1].rstrip(".")
return line.strip()
return None
def show_status():
"""Show current Mullvad status."""
current = get_current_exit_node()
if current:
if "mullvad" in current.lower():
print(f"Mullvad: ON -> {current}")
else:
print(f"Exit node: {current} (not Mullvad)")
else:
print("Mullvad: OFF")
def get_external_ip():
"""Get the current external IP address."""
try:
result = subprocess.run(
["curl", "-s", "--connect-timeout", "5", "https://api.ipify.org"],
capture_output=True, text=True, timeout=10,
)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip()
except Exception:
pass
return "Unknown"
def cmd_on(location):
"""Connect to a Mullvad exit node matching the given location."""
nodes = get_exit_nodes()
matches = find_nodes(nodes, location)
if not matches:
print(f"No Mullvad exit nodes found for '{location}'")
print("\nAvailable Mullvad locations:")
shown = set()
for node in nodes:
is_mullvad = "mullvad" in node["hostname"].lower()
if is_mullvad:
key = f"{node['country']} - {node['city']}"
if key not in shown:
print(f" {key}")
shown.add(key)
sys.exit(1)
node = random.choice(matches)
hostname = node["hostname"]
ip_before = get_external_ip()
print(f"Current IP: {ip_before}")
print(f"Connecting to Mullvad: {node['country']} - {node['city']} ({hostname})")
print("Tearing down Tailscale...")
_run_tailscale_live(["down", "--accept-risk=lose-ssh"])
print("Bringing up Tailscale with Mullvad exit node...")
_run_tailscale_live(
["up", "--reset", "--accept-risk=lose-ssh", f"--exit-node={hostname}", "--exit-node-allow-lan-access=false", "--accept-routes"]
)
time.sleep(1)
ip_after = get_external_ip()
print(f"Mullvad: ON -> {hostname} ({node['country']}, {node['city']})")
print(f"IP: {ip_before} -> {ip_after}")
def cmd_off():
"""Disconnect from the Mullvad exit node."""
print("Tearing down Tailscale...")
_run_tailscale_live(["down", "--accept-risk=lose-ssh"])
print("Bringing up Tailscale without exit node...")
_run_tailscale_live(["up", "--reset", "--accept-risk=lose-ssh", "--accept-routes"])
print("Mullvad: OFF")
def main():
if len(sys.argv) < 2:
print("Usage: tailscale-mullvad on <city or country> | tailscale-mullvad off | tailscale-mullvad status")
sys.exit(1)
cmd = sys.argv[1].lower()
if cmd == "on":
if len(sys.argv) < 3:
print("Usage: tailscale-mullvad on <city or country>")
print(" e.g. tailscale-mullvad on amsterdam")
print(" e.g. tailscale-mullvad on netherlands")
sys.exit(1)
cmd_on(sys.argv[2])
elif cmd == "off":
cmd_off()
elif cmd == "status":
show_status()
else:
print(f"Unknown command: {cmd}")
print("Usage: tailscale-mullvad on <city or country> | tailscale-mullvad off | tailscale-mullvad status")
sys.exit(1)
if __name__ == "__main__":
main()