Created
April 6, 2025 07:44
-
-
Save wasdee/b68687af8243a5cf3d4155e9dc55dd42 to your computer and use it in GitHub Desktop.
ipv6 tailscale
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 -S uv run --script | |
# /// script | |
# dependencies = [ | |
# "ipaddress", | |
# ] | |
# /// | |
""" | |
IPv6 Finder - A utility to find IPv6 addresses for Tailscale 4via6 subnet routers. | |
This script helps generate IPv6 addresses for Tailscale 4via6 subnet routers given a site ID and IPv4 subnet. | |
It follows the format described in https://tailscale.com/kb/1201/4via6-subnets | |
Usage: | |
./ipv6_finder.py 12345 192.168.0.0/22 | grep .2.164 | |
./ipv6_finder.py fd7a:115c:a1e0:b1a:0:1234:c0a8:0/118 | grep .2.164 | |
""" | |
import argparse | |
import ipaddress | |
import json | |
import sys | |
from pathlib import Path | |
from typing import Tuple, Optional, List, Dict, Any | |
def ipv4_to_hex_parts(ipv4_address: str) -> Tuple[str, str]: | |
""" | |
Convert an IPv4 address to two 16-bit hex parts. | |
Args: | |
ipv4_address: IPv4 address as string (e.g., "10.1.1.0") | |
Returns: | |
Tuple of two hex strings representing the IPv4 address | |
""" | |
# Convert to IPv4Address object | |
ip = ipaddress.IPv4Address(ipv4_address) | |
# Convert to integer and then to hex, removing '0x' prefix | |
ip_int = int(ip) | |
# Split into two 16-bit parts | |
high_part = (ip_int >> 16) & 0xFFFF | |
low_part = ip_int & 0xFFFF | |
return f"{high_part:x}", f"{low_part:x}" | |
def hex_parts_to_ipv4(high_part: str, low_part: str) -> str: | |
""" | |
Convert two 16-bit hex parts back to an IPv4 address. | |
Args: | |
high_part: High 16 bits as hex string | |
low_part: Low 16 bits as hex string | |
Returns: | |
IPv4 address string | |
""" | |
high = int(high_part, 16) | |
low = int(low_part, 16) | |
ip_int = (high << 16) | low | |
return str(ipaddress.IPv4Address(ip_int)) | |
def decode_4via6_address(ipv6_address: str) -> Tuple[int, str]: | |
""" | |
Decode a Tailscale 4via6 IPv6 address back to site ID and IPv4 address. | |
Args: | |
ipv6_address: IPv6 address in Tailscale 4via6 format | |
Returns: | |
Tuple of (site_id, ipv4_address) | |
""" | |
# Split the IPv6 address into parts | |
parts = ipv6_address.split(':') | |
# Verify the prefix | |
prefix = ':'.join(parts[:4]) | |
if prefix != "fd7a:115c:a1e0:b1a": | |
raise ValueError("Not a valid Tailscale 4via6 address") | |
# Extract site ID parts | |
site_high = int(parts[4], 16) | |
site_low = int(parts[5], 16) | |
site_id = (site_high << 16) | site_low | |
# Extract IPv4 parts | |
ipv4 = hex_parts_to_ipv4(parts[6], parts[7]) | |
return site_id, ipv4 | |
def decode_4via6_subnet(ipv6_subnet: str) -> Tuple[int, str]: | |
""" | |
Decode a Tailscale 4via6 IPv6 subnet back to site ID and IPv4 subnet. | |
Args: | |
ipv6_subnet: IPv6 subnet in Tailscale 4via6 format | |
Returns: | |
Tuple of (site_id, ipv4_subnet) | |
""" | |
# Split the subnet into address and prefix length | |
if '/' in ipv6_subnet: | |
ipv6_address, prefix_length_str = ipv6_subnet.split('/') | |
prefix_length = int(prefix_length_str) | |
else: | |
ipv6_address = ipv6_subnet | |
prefix_length = 128 | |
# Calculate IPv4 prefix length | |
ipv4_prefix_length = prefix_length - 96 | |
# Decode the address | |
site_id, ipv4_address = decode_4via6_address(ipv6_address) | |
# Create IPv4 subnet | |
ipv4_network = ipaddress.IPv4Network(f"{ipv4_address}/{ipv4_prefix_length}", strict=False) | |
ipv4_subnet = str(ipv4_network) | |
return site_id, ipv4_subnet | |
def generate_4via6_address(site_id: int, ipv4_address: str) -> str: | |
""" | |
Generate a Tailscale 4via6 IPv6 address for a given site ID and IPv4 address. | |
Args: | |
site_id: Site ID (0-2^32-1) | |
ipv4_address: IPv4 address as string | |
Returns: | |
IPv6 address string in Tailscale 4via6 format | |
""" | |
if not 0 <= site_id < 2**32: | |
raise ValueError("Site ID must be between 0 and 2^32-1") | |
# Fixed prefix for Tailscale 4via6 | |
prefix = "fd7a:115c:a1e0:b1a" | |
# Convert site_id to two 16-bit hex parts | |
site_high = (site_id >> 16) & 0xFFFF | |
site_low = site_id & 0xFFFF | |
# Convert IPv4 to hex parts | |
high_part, low_part = ipv4_to_hex_parts(ipv4_address) | |
# Format the IPv6 address | |
return f"{prefix}:{site_high:x}:{site_low:x}:{high_part}:{low_part}" | |
def generate_4via6_subnet(site_id: int, ipv4_subnet: str) -> str: | |
""" | |
Generate a Tailscale 4via6 IPv6 subnet for a given site ID and IPv4 subnet. | |
Args: | |
site_id: Site ID (0-2^32-1) | |
ipv4_subnet: IPv4 subnet as string (e.g., "10.1.1.0/24") | |
Returns: | |
IPv6 subnet string in Tailscale 4via6 format | |
""" | |
# Parse the IPv4 subnet | |
subnet = ipaddress.IPv4Network(ipv4_subnet) | |
# Get the network address | |
network_address = str(subnet.network_address) | |
# Calculate the IPv6 prefix length | |
# IPv6 prefix length = 96 (fixed prefix + site ID) + IPv4 prefix length | |
ipv6_prefix_length = 96 + subnet.prefixlen | |
# Generate the IPv6 address for the network address | |
ipv6_address = generate_4via6_address(site_id, network_address) | |
return f"{ipv6_address}/{ipv6_prefix_length}" | |
def generate_device_addresses(site_id: int, ipv4_subnet: str, count: Optional[int] = None) -> List[Tuple[str, str, str]]: | |
""" | |
Generate a list of device addresses in both IPv4 and IPv6 formats. | |
Args: | |
site_id: Site ID (0-2^32-1) | |
ipv4_subnet: IPv4 subnet as string (e.g., "10.1.1.0/24") | |
count: Optional number of addresses to generate (default: all hosts in subnet) | |
Returns: | |
List of tuples (IPv4 address, IPv6 address, MagicDNS name) | |
""" | |
subnet = ipaddress.IPv4Network(ipv4_subnet) | |
# Get all host addresses in the subnet | |
hosts = list(subnet.hosts()) | |
# Limit to count if specified | |
if count is not None: | |
hosts = hosts[:min(count, len(hosts))] | |
result = [] | |
for host in hosts: | |
ipv4 = str(host) | |
ipv6 = generate_4via6_address(site_id, ipv4) | |
# Generate MagicDNS name (e.g., 10-1-1-16-via-site-id) | |
# For large site IDs, use hexadecimal representation | |
if site_id > 65535: | |
site_id_str = f"{site_id:x}" # Convert to hex string | |
else: | |
site_id_str = str(site_id) | |
magic_dns = f"{ipv4.replace('.', '-')}-via-{site_id_str}" | |
result.append((ipv4, ipv6, magic_dns)) | |
return result | |
def find_ipv6_for_site(site_id: int, subnet: str, list_hosts: bool = False, count: Optional[int] = None) -> Dict[str, Any]: | |
""" | |
Find IPv6 addresses for a site given its ID and subnet. | |
Args: | |
site_id: Site ID (0-65535) | |
subnet: IPv4 subnet (e.g., "10.1.1.0/24") | |
list_hosts: Whether to list all host addresses | |
count: Number of host addresses to list | |
Returns: | |
Dictionary with IPv6 subnet and optionally host addresses | |
""" | |
result = { | |
"ipv4_subnet": subnet, | |
"site_id": site_id, | |
"ipv6_subnet": generate_4via6_subnet(site_id, subnet), | |
} | |
if list_hosts: | |
hosts = generate_device_addresses(site_id, subnet, count) | |
result["hosts"] = [ | |
{"ipv4": ipv4, "ipv6": ipv6, "magic_dns": magic_dns} | |
for ipv4, ipv6, magic_dns in hosts | |
] | |
return result | |
def print_result(result: Dict[str, Any]) -> None: | |
""" | |
Print the result in a human-readable format. | |
Args: | |
result: Result dictionary from find_ipv6_for_site | |
""" | |
print(f"IPv4 Subnet: {result['ipv4_subnet']}") | |
print(f"Site ID: {result['site_id']}") | |
print(f"IPv6 Subnet: {result['ipv6_subnet']}") | |
print() | |
if "hosts" in result: | |
print("Host addresses:") | |
print("IPv4 Address IPv6 Address MagicDNS Name") | |
print("-" * 80) | |
for host in result["hosts"]: | |
print(f"{host['ipv4']:<16} {host['ipv6']:<42} {host['magic_dns']}") | |
def main(): | |
parser = argparse.ArgumentParser( | |
description="Generate Tailscale 4via6 IPv6 addresses for IPv4 subnets" | |
) | |
parser.add_argument("site_id", help="Site ID (0-2^32-1) or IPv6 address to decode") | |
parser.add_argument("subnet", nargs="?", help="IPv4 subnet (e.g., 10.1.1.0/24) or IPv6 subnet to decode") | |
parser.add_argument( | |
"--count", "-c", type=int, | |
help="Number of host addresses to list" | |
) | |
parser.add_argument( | |
"--json", "-j", action="store_true", | |
help="Output in JSON format" | |
) | |
parser.add_argument( | |
"--no-hosts", action="store_true", | |
help="Don't list host addresses" | |
) | |
args = parser.parse_args() | |
try: | |
# Check if we're in decode mode | |
if ':' in args.site_id: | |
# Decode mode | |
ipv6_subnet = args.site_id | |
if args.subnet: | |
print("Warning: Subnet argument ignored in decode mode", file=sys.stderr) | |
site_id, ipv4_subnet = decode_4via6_subnet(ipv6_subnet) | |
result = { | |
"ipv6_subnet": ipv6_subnet, | |
"site_id": site_id, | |
"ipv4_subnet": ipv4_subnet, | |
} | |
if not args.no_hosts: | |
hosts = generate_device_addresses(site_id, ipv4_subnet, args.count) | |
result["hosts"] = [ | |
{"ipv4": ipv4, "ipv6": ipv6, "magic_dns": magic_dns} | |
for ipv4, ipv6, magic_dns in hosts | |
] | |
else: | |
# Normal mode | |
if not args.subnet: | |
print("Error: Subnet argument required in normal mode", file=sys.stderr) | |
sys.exit(1) | |
site_id = int(args.site_id) | |
if not 0 <= site_id < 2**32: | |
print(f"Error: Site ID must be between 0 and 2^32-1, got {site_id}", file=sys.stderr) | |
sys.exit(1) | |
# Validate IPv4 subnet | |
try: | |
ipaddress.IPv4Network(args.subnet) | |
except ValueError as e: | |
print(f"Error: Invalid IPv4 subnet - {e}", file=sys.stderr) | |
sys.exit(1) | |
result = find_ipv6_for_site(site_id, args.subnet, not args.no_hosts, args.count) | |
if args.json: | |
print(json.dumps(result, indent=2)) | |
else: | |
print_result(result) | |
except Exception as e: | |
print(f"Error: {e}", file=sys.stderr) | |
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