Skip to content

Instantly share code, notes, and snippets.

@wasdee
Created April 6, 2025 07:44
Show Gist options
  • Save wasdee/b68687af8243a5cf3d4155e9dc55dd42 to your computer and use it in GitHub Desktop.
Save wasdee/b68687af8243a5cf3d4155e9dc55dd42 to your computer and use it in GitHub Desktop.
ipv6 tailscale
#!/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