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:
parent
68683fa920
commit
c58e5f8655
2 changed files with 125 additions and 235 deletions
43
README.md
43
README.md
|
|
@ -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
|
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue