commit 014ca4db8cbb8652593ff9e9eee4f5b8569c4d02 Author: Eddy Date: Sat May 16 01:46:51 2026 +0200 Initial commit: Mullvad exit node manager for Tailscale CLI diff --git a/mullvad-ts b/mullvad-ts new file mode 100644 index 0000000..1306c53 --- /dev/null +++ b/mullvad-ts @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +"""Mullvad exit node manager for Tailscale on macOS. + +Usage: + mullvad on - connect to Mullvad exit node + mullvad off - disconnect Mullvad exit node + mullvad status - show current Mullvad status +""" + +import subprocess +import sys +import re +import random + + +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 + + +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) + + +def get_exit_nodes(): + """Get all available exit nodes including Mullvad ones.""" + output = _run_tailscale(["exit-node", "list"]) + nodes = [] + + 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 + + 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 + + +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 + + if ( + location_lower in node["city"].lower() + or location_lower in node["country"].lower() + or location_lower in node["hostname"].lower() + ): + matches.append(node) + + return matches + + +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 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"] + + 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"] + ) + print(f"Mullvad: ON -> {hostname} ({node['country']}, {node['city']})") + + +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: mullvad on | mullvad off | mullvad status") + sys.exit(1) + + cmd = sys.argv[1].lower() + + if cmd == "on": + if len(sys.argv) < 3: + print("Usage: mullvad on ") + print(" e.g. mullvad on amsterdam") + print(" e.g. 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: mullvad on | mullvad off | mullvad status") + sys.exit(1) + + +if __name__ == "__main__": + main()