Last active
November 19, 2025 06:18
-
-
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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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