Skip to content

Instantly share code, notes, and snippets.

@jordanisaacs
Last active November 19, 2025 06:18
Show Gist options
  • Select an option

  • Save jordanisaacs/b7276ff51d996752e4799838e9503ca0 to your computer and use it in GitHub Desktop.

Select an option

Save jordanisaacs/b7276ff51d996752e4799838e9503ca0 to your computer and use it in GitHub Desktop.
Script to sign all mullvad exit nodes for tailscale lock (written using Claude Code)
#!/usr/bin/env python3
"""
Script to sign all Mullvad exit nodes for Tailscale lock.
Parses tailscale lock output to find locked-out Mullvad nodes and signs them.
"""
import subprocess
import re
import sys
import argparse
from typing import List, Tuple
def run_command(cmd: List[str]) -> Tuple[bool, str, str]:
"""Run a command and return success status, stdout, and stderr."""
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
check=False
)
return result.returncode == 0, result.stdout, result.stderr
except Exception as e:
return False, "", str(e)
def get_lock_status() -> List[str]:
"""Get the tailscale lock status output."""
success, stdout, stderr = run_command(["tailscale", "lock"])
if not success:
print(f"Error getting tailscale lock status: {stderr}", file=sys.stderr)
sys.exit(1)
return stdout.splitlines()
def parse_mullvad_nodes(lock_lines: List[str]) -> List[Tuple[str, str]]:
"""
Parse Mullvad nodes from tailscale lock output.
Returns list of (node_id, hostname) tuples.
Expected format:
hostname\tIP_addresses\tshort_id\tnodekey:hex_key
"""
mullvad_nodes = []
in_locked_section = False
# Pattern to match nodekey in the line
nodekey_pattern = re.compile(r'nodekey:([a-f0-9]+)')
for line in lock_lines:
# Check if we've entered the locked nodes section
if 'following nodes are locked out' in line.lower():
in_locked_section = True
continue
# Stop parsing if we hit an empty line or new section
if in_locked_section and line.strip() == '':
break
# Parse lines in the locked section that contain mullvad
if in_locked_section and 'mullvad' in line.lower():
# Extract hostname and nodekey from the line
parts = line.strip().split('\t')
if len(parts) >= 4:
hostname = parts[0]
# Extract nodekey from the last part
nodekey_match = nodekey_pattern.search(parts[3])
if nodekey_match:
node_id = nodekey_match.group(1)
mullvad_nodes.append((node_id, hostname))
return mullvad_nodes
def sign_node(node_id: str, dry_run: bool = False) -> bool:
"""Sign a single node. Returns True if successful."""
if dry_run:
print(f"[DRY RUN] Would sign node: nodekey:{node_id}")
return True
# tailscale lock sign expects the full "nodekey:..." format
nodekey_full = f"nodekey:{node_id}"
success, stdout, stderr = run_command(["tailscale", "lock", "sign", nodekey_full])
if success:
print(f"✓ Successfully signed: {node_id}")
return True
else:
print(f"✗ Failed to sign {node_id}: {stderr}", file=sys.stderr)
return False
def main():
parser = argparse.ArgumentParser(
description="Sign all Mullvad exit nodes for Tailscale lock"
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would be signed without actually signing"
)
parser.add_argument(
"--verbose", "-v",
action="store_true",
help="Enable verbose output"
)
args = parser.parse_args()
print("Fetching tailscale lock status...")
lock_lines = get_lock_status()
if args.verbose:
print(f"Retrieved {len(lock_lines)} lines")
print("Parsing Mullvad nodes...")
mullvad_nodes = parse_mullvad_nodes(lock_lines)
if not mullvad_nodes:
print("No Mullvad nodes found that are locked out")
sys.exit(0)
print(f"\nFound {len(mullvad_nodes)} Mullvad node(s):")
for node_id, hostname in mullvad_nodes:
print(f" - {hostname} (nodekey:{node_id})")
if args.dry_run:
print("\n--- DRY RUN MODE ---")
print(f"\nSigning {len(mullvad_nodes)} node(s)...")
successful = 0
failed = 0
for node_id, hostname in mullvad_nodes:
print(f"\nSigning {hostname}...")
if sign_node(node_id, args.dry_run):
successful += 1
else:
failed += 1
print(f"\n{'='*50}")
print(f"Summary: {successful} signed, {failed} failed")
if failed > 0:
sys.exit(1)
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment