From c58e5f865551b5b234416d49e9d4703bfb14223e Mon Sep 17 00:00:00 2001 From: Eddy Date: Sat, 16 May 2026 10:33:53 +0200 Subject: [PATCH] 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 --- README.md | 47 ++++---- tailscale-mullvad | 283 +++++++++++++++------------------------------- 2 files changed, 110 insertions(+), 220 deletions(-) diff --git a/README.md b/README.md index f0d9f77..478997c 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,55 @@ # 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. ## 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 ```bash -git clone https://git.eddydevink.nl/eddy/tailscale-mullvad.git +git clone ssh://git@nextcloud:2222/eddy/tailscale-mullvad.git cd tailscale-mullvad chmod +x tailscale-mullvad sudo cp tailscale-mullvad /usr/local/bin/ ``` -Geen compilatie nodig — het is een Python 3 script. - **Vereisten:** -- Python 3 +- Bash - Tailscale CLI (`tailscale`) - 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 ```bash -tailscale-mullvad on # Verbind met Mullvad exit node -tailscale-mullvad off # Verbreek Mullvad exit node -tailscale-mullvad status # Toon huidige status +tailscale-mullvad on # Verbind met Mullvad exit node +tailscale-mullvad off # Verbreek Mullvad exit node +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 ```bash $ tailscale-mullvad on amsterdam -Current IP: 94.157.20.52 -Connecting to Mullvad: Netherlands - Amsterdam (nl-ams-wg-201.mullvad.ts.net) -... -Mullvad: ON -> nl-ams-wg-201.mullvad.ts.net -IP: 94.157.20.52 -> 169.150.196.4 +Huidig IP: 94.157.20.52 +Verbinden met nl-ams-wg-201.mullvad.ts.net... +Nieuw IP: 169.150.196.11 +Exit node actief -- IP gewijzigd $ tailscale-mullvad status -Mullvad: ON -> nl-ams-wg-201.mullvad.ts.net +Exit node actief + Publiek IP: 169.150.196.11 $ tailscale-mullvad off -Mullvad: OFF - -$ tailscale-mullvad on japan -Current IP: 169.150.196.4 -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 +Huidig IP: 169.150.196.11 +Exit node uitzetten... +Nieuw IP: 94.157.20.52 +Exit node uit -- IP gewijzigd ``` diff --git a/tailscale-mullvad b/tailscale-mullvad index 12317ee..970f3f7 100644 --- a/tailscale-mullvad +++ b/tailscale-mullvad @@ -1,209 +1,104 @@ -#!/usr/bin/env python3 -"""Mullvad exit node manager for Tailscale on macOS. +#!/bin/bash +set -euo pipefail -Usage: - mullvad on - connect to Mullvad exit node - mullvad off - disconnect Mullvad exit node - mullvad status - show current Mullvad status -""" +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +BOLD='\033[1m' +NC='\033[0m' -import subprocess -import sys -import re -import random -import time +get_ip() { + curl -s --max-time 5 https://api.ipify.org 2>/dev/null || echo "onbekend" +} +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): - """Run a tailscale command, suppressing version mismatch warnings.""" - result = subprocess.run( - ["tailscale"] + args, - capture_output=True, - text=True, - timeout=timeout, - ) - output = result.stdout - if result.returncode != 0: - if result.stderr: - print(result.stderr.strip(), file=sys.stderr) - result.check_returncode() - return output +cmd_on() { + local query="${1:-}" + if [[ -z "$query" ]]; then + echo -e "${YELLOW}Geef een exit node op, bv:${NC}" + echo " mullvad on nl-ams-wg-201" + echo " mullvad on amsterdam" + exit 1 + fi + local ip_before + ip_before=$(get_ip) + echo -e "Huidig IP: ${BOLD}${ip_before}${NC}" -def _run_tailscale_live(args, timeout=30): - """Run tailscale command with live output (for up/down).""" - result = subprocess.run( - ["tailscale"] + args, - timeout=timeout, - ) - if result.returncode != 0: - sys.exit(result.returncode) + local node + node=$(tailscale exit-node list 2>/dev/null | grep -i "$query" | head -1 | awk '{print $2}') + if [[ -z "$node" ]]; then + echo -e "${RED}Geen exit node gevonden voor '${query}'${NC}" + exit 1 + fi + echo -e "Verbinden met ${BOLD}${node}${NC}..." + tailscale set --exit-node="$node" 2>&1 | grep -v "Warning:" || true + sleep 2 -def get_exit_nodes(): - """Get all available exit nodes including Mullvad ones.""" - output = _run_tailscale(["exit-node", "list"]) - nodes = [] + local ip_after + ip_after=$(get_ip) + echo -e "Nieuw IP: ${BOLD}${ip_after}${NC}" - for line in output.split("\n"): - line = line.strip() - if not line: - continue - if line.startswith("#") or line.startswith("Warning"): - continue - if line.startswith("IP "): - continue + if [[ "$ip_after" != "$ip_before" && "$ip_after" != "onbekend" ]]; then + echo -e "${GREEN}Exit node actief -- IP gewijzigd${NC}" + else + echo -e "${YELLOW}Exit node ingesteld maar IP lijkt niet gewijzigd${NC}" + fi +} - match = re.match( - r"^(\S+)\s+(\S+\.ts\.net)\.?\s+(.+?)\s{2,}(.+?)\s{2,}(\S+)$", - line, - ) - if match: - 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 +cmd_off() { + local ip_before + ip_before=$(get_ip) + echo -e "Huidig IP: ${BOLD}${ip_before}${NC}" + echo -e "Exit node uitzetten..." + tailscale set --exit-node= 2>&1 | grep -v "Warning:" || true + sleep 2 -def find_nodes(nodes, location): - """Find Mullvad nodes matching location (case-insensitive on city/country/hostname).""" - location_lower = location.lower() - matches = [] - for node in nodes: - is_mullvad = "mullvad" in node["hostname"].lower() - if not is_mullvad: - continue + local ip_after + ip_after=$(get_ip) + echo -e "Nieuw IP: ${BOLD}${ip_after}${NC}" - if ( - location_lower in node["city"].lower() - or location_lower in node["country"].lower() - or location_lower in node["hostname"].lower() - ): - matches.append(node) + if [[ "$ip_after" != "$ip_before" ]]; then + echo -e "${GREEN}Exit node uit -- IP gewijzigd${NC}" + else + echo -e "${GREEN}Exit node uit${NC}" + fi +} - return matches +usage() { + echo -e "${BOLD}mullvad${NC} - Exit node manager" + echo "" + echo " mullvad Toon status" + echo " mullvad on 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" +} - -def get_current_exit_node(): - """Get the currently active exit node hostname, or None.""" - try: - output = _run_tailscale(["exit-node", "list"], timeout=10) - except subprocess.CalledProcessError: - return None - for line in output.split("\n"): - if "selected" in line: - parts = line.split() - if len(parts) >= 2: - 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 | 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 ") - 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 | tailscale-mullvad off | tailscale-mullvad status") - sys.exit(1) - - -if __name__ == "__main__": - main() +case "${1:-}" in + on) cmd_on "${2:-}" ;; + off) cmd_off ;; + status|"") cmd_status ;; + -h|--help|help) usage ;; + *) + echo -e "${RED}Onbekend commando: $1${NC}" + usage + exit 1 + ;; +esac