diff --git a/.gitignore b/.gitignore index d3e4c13..4bbcd35 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ *.img -*/wireguard.conf -*/workspace/ +**/wireguard.conf +**/workspace/ +**/.listener*.pid .worktrees/ diff --git a/CLAUDE.md b/CLAUDE.md index e82b065..02cc430 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,6 +13,7 @@ careful consideration. Key containment properties to preserve: - KVM hypervisor boundary between guest and host - Network isolation (on by default) blocks the VM from reaching the host or LAN; set `NETWORK_ISOLATION=false` to opt out +- IPv6 has no routable path by default (dropped at the FORWARD chain); `NETWORK_IPV6=nat` opts a VM in to NAT66 egress and is refused alongside WireGuard (IPv6 is not tunnel-routed) - The shared folder is the only intentional host↔guest data channel; its scope should remain narrow - The VM is designed to be destroyed and rebuilt, not patched in place @@ -134,8 +135,8 @@ do that work unconditionally in `pre-up`; the args file is only for initial `/etc/migrant/${VM_NAME}/` is the data channel between unprivileged migrant and the privileged qemu/loop hooks. `sync_managed_config()` validates and writes -all behavioral config (network isolation flag, shared folder isolation flag, -HOST_ACCESS rules, WireGuard files) before the VM starts. The hooks read these +all behavioral config (network isolation flag, IPv6/NAT66 flag, shared folder +isolation flag, HOST_ACCESS rules, WireGuard files) before the VM starts. The hooks read these files at runtime. The VM description tag carries only identity (`managed-by=migrant`). All diff --git a/README.md b/README.md index 22310ca..4e3871b 100644 --- a/README.md +++ b/README.md @@ -252,7 +252,7 @@ migrant unmount # Unmount the shared folder loop image migrant ssh [-- cmd...] # SSH into the VM as the configured user; optionally run a remote command (e.g. migrant ssh -- sudo cloud-init status) migrant tunnel [PORT...] # Open SSH local-forwards from host to VM. Without args, uses TUNNEL_PORTS from Migrantfile. migrant console # Open a serial console session (exit with Ctrl+]) -migrant ip # Print the VM's IP address +migrant ip [-6] # Print the VM's IPv4 address (what SSH uses). With -6, print the IPv6 (ULA) address when NETWORK_IPV6=nat is set migrant pubkey # Generate the managed SSH key if needed and print its public key migrant tz [zone] # Sync the host timezone to the VM, or set an explicit zone (e.g. America/New_York); defaults to the host timezone @@ -466,8 +466,10 @@ migrant ssh -- sudo cloud-init status --wait migrant ssh -- sudo tail -f /var/log/cloud-init-output.log ``` -`migrant ip` prints the VM's IP address, which is useful for -scripting or for connecting with tools other than SSH. +`migrant ip` prints the VM's IPv4 address (the one SSH uses), which is +useful for scripting or connecting with tools other than SSH. When +`NETWORK_IPV6=nat` is set, `migrant ip -6` prints the VM's IPv6 (ULA) +address, and `migrant status` shows an `ipv6:` line alongside `ip:`. ### storage @@ -563,14 +565,48 @@ in a `Migrantfile` to opt out. When active, iptables rules are added that: existing connections) - Block the VM from reaching RFC 1918 addresses on the local network, other than the libvirt subnet itself (192.168.200.0/24) -- Drop all IPv6 from the VM at the `FORWARD` chain (the libvirt network - provides no routable IPv6 to VMs; this makes that de-facto limitation - explicit) +- Drop all IPv6 from the VM at the `FORWARD` chain by default (the libvirt + network provides no routable IPv6 to VMs). Opt a VM in to IPv6 egress with + `NETWORK_IPV6=nat` — see [IPv6 (NAT66)](#ipv6-nat66) below The rules are removed automatically when the VM stops or is destroyed. This requires `migrant setup` to have been run to install the libvirt hook. +### IPv6 (NAT66) + +By default a migrant VM has no routable IPv6 — the `migrant` network is IPv4 +NAT only, and the qemu hook drops the VM's IPv6 at the `FORWARD` chain. Set +`NETWORK_IPV6=nat` in a `Migrantfile` to opt a VM in to IPv6 egress. When +enabled, the VM receives a ULA address (`fdca:6d16:2b1a::/64`) and the hook +masquerades it to the host's IPv6 uplink, mirroring the IPv4 NAT. With +`NETWORK_ISOLATION` on (the default), the VM still cannot reach the host or +other VMs/LAN over IPv6 — only the internet. + +Once the VM is up, `migrant status` shows an `ipv6:` line and `migrant ip -6` +prints the ULA address (best-effort — it is read from the DHCPv6 lease, so it +may be blank if the guest configured a SLAAC-only address). + +Requirements and caveats: + +- **The host must have working IPv6 egress.** NAT66 masquerades to the host's + own IPv6 route; if the host is IPv4-only there is nothing to NAT to. Check on + the host with `ip -6 route get 2620:fe::fe`. +- **The host firewall must permit ICMPv6 on the `virbr-migrant` bridge.** + Neighbor discovery to the VM's ULA gateway is global-scoped, so a firewall + that accepts ICMPv6 only from `fe80::/10` (a common default) breaks NAT66 + *reply* traffic while egress still works. The qemu hook adds a per-VM INPUT + accept for exactly the needed ICMPv6 (neighbor discovery, router + solicit/advert, and PMTUD errors) — echo and everything else stay blocked, so + the guest cannot ping or otherwise reach the host over IPv6. Keep this rule in + place if you manage the host firewall out-of-band. +- **Incompatible with WireGuard.** IPv6 is not routed through the tunnel + (fwmark policy routing is IPv4-only), so `migrant up` refuses to start a VM + that sets both `NETWORK_IPV6=nat` and a `wireguard.conf`. +- **Existing installs must recreate the `migrant` network** to gain the IPv6 + subnet — re-running `migrant setup` alone won't add it. See + [Migrating an existing VM to IPv6 (NAT66)](#migrating-an-existing-vm-to-ipv6-nat66). + ### Host access rules The `HOST_ACCESS` array in a `Migrantfile` declares exceptions to network @@ -705,9 +741,11 @@ where the only route is `default dev mg-wg-`. The result: all VM traffic exits the host via the encrypted WireGuard tunnel, regardless of what the VM itself does. -IPv6 from the VM is dropped at the `FORWARD` chain (shared with the -network isolation rule). The fwmark routing is IPv4-only; without -this rule IPv6 would bypass the tunnel. +IPv6 from the VM is dropped at the `FORWARD` chain. The fwmark routing is +IPv4-only, so without this IPv6 would bypass the tunnel — which is why +`NETWORK_IPV6=nat` is refused alongside WireGuard (see +[IPv6 (NAT66)](#ipv6-nat66)). A WireGuard VM therefore always has its IPv6 +dropped. `migrant up` verifies the tunnel is active before returning. If the WireGuard interface or routing rule is missing, or the marking rule was @@ -751,7 +789,7 @@ The VM cannot bypass it: port-53 traffic is rewritten. - If the tunnel fails to come up, `migrant up` halts the VM rather than letting it run un-tunneled. -- IPv6 is blocked at the FORWARD chain (shared with the network isolation rule) so there is no IPv6 leak path. +- IPv6 is dropped at the FORWARD chain, so there is no IPv6 leak path. `NETWORK_IPV6=nat` (which opens IPv6 egress) is refused on WireGuard VMs, so a tunneled VM always has its IPv6 dropped. This does not prevent the VM from sending traffic to other hosts on the VPN once the tunnel is active. Network isolation is enabled by default @@ -916,3 +954,42 @@ cp -a ~/workspace-backup/. workspace/ migrant unmount migrant up ``` + +## Migrating an existing VM to IPv6 (NAT66) + +`NETWORK_IPV6=nat` needs the `migrant` network to carry an IPv6 (ULA) +subnet. `migrant setup` only *creates* the network when it is missing, so +an existing install keeps its IPv4-only network and re-running `setup` +alone will **not** add IPv6 — you have to recreate the network once. + +Recreating it restarts the shared `virbr-migrant` bridge, which interrupts +**every** VM attached to the `migrant` network, so shut those down first. +`net-destroy` stops the network only — it does not touch any VM's disk or +definition — but a running VM loses connectivity until it is restarted. + +```bash +# 1. Halt every VM on the migrant network (repeat per project if you run several) +migrant halt + +# 2. Tear down the old IPv4-only network +virsh net-destroy migrant +virsh net-undefine migrant + +# 3. Recreate it — now dual-stack — and reinstall the hooks +migrant setup + +# 4. Set NETWORK_IPV6=nat in the VM's Migrantfile, then bring it back up +migrant up +``` + +Then confirm inside the VM that it picked up a ULA and can reach IPv6: + +```bash +ip -6 addr show scope global # expect an fdca:6d16:2b1a::/64 address +curl -6 -sS https://ifconfig.co # returns an IPv6 address +``` + +This requires the **host** to have working IPv6 egress +(`ip -6 route get 2620:fe::fe` on the host); NAT66 has nothing to +masquerade to otherwise. `NETWORK_IPV6=nat` is refused on a VM that also +has a `wireguard.conf`. diff --git a/arch/Migrantfile b/arch/Migrantfile index 2ecde0c..9facabd 100644 --- a/arch/Migrantfile +++ b/arch/Migrantfile @@ -50,6 +50,14 @@ SHARED_FOLDER_SIZE_GB=10 # to have been run. Set to false to opt out. #NETWORK_ISOLATION=false +# IPv6 egress policy. 'off' (the default) gives the VM no IPv6 egress. Set to +# 'nat' to route the VM's IPv6 via NAT66: the guest gets a ULA and the host +# masquerades it to its own IPv6 uplink. 'nat' requires the host to have working +# IPv6, and is refused alongside a wireguard.conf (IPv6 would bypass the tunnel). +# Existing installs must recreate the migrant network to use 'nat' — see the +# README "IPv6 (NAT66)" section. +NETWORK_IPV6=off + # Optional: automatically connect after 'migrant up' finishes. # Unset by default. Set to "ssh" or "console" to enable. AUTOCONNECT=ssh diff --git a/debian/Migrantfile b/debian/Migrantfile index e4c8b9c..caf3dfa 100644 --- a/debian/Migrantfile +++ b/debian/Migrantfile @@ -53,6 +53,14 @@ BOOT_FIRMWARE=uefi # to have been run. Set to false to opt out. #NETWORK_ISOLATION=false +# IPv6 egress policy. 'off' (the default) gives the VM no IPv6 egress. Set to +# 'nat' to route the VM's IPv6 via NAT66: the guest gets a ULA and the host +# masquerades it to its own IPv6 uplink. 'nat' requires the host to have working +# IPv6, and is refused alongside a wireguard.conf (IPv6 would bypass the tunnel). +# Existing installs must recreate the migrant network to use 'nat' — see the +# README "IPv6 (NAT66)" section. +NETWORK_IPV6=off + # Optional: automatically connect after 'migrant up' finishes. # Unset by default. Set to "ssh" or "console" to enable. AUTOCONNECT=ssh diff --git a/migrant b/migrant index 04c77ef..001c515 100755 --- a/migrant +++ b/migrant @@ -58,7 +58,8 @@ Access: tunnel [PORT...] Open SSH local-forwards from host to VM. Without args, uses TUNNEL_PORTS from Migrantfile. console Open a serial console session (exit with Ctrl+]) - ip Print the VM's IP address + ip [-6] Print the VM's IPv4 address (what SSH uses). With -6, + print the IPv6 (ULA) address when NETWORK_IPV6=nat is set pubkey Generate the managed SSH key if needed and print its public key tz [zone] Sync the host timezone to the VM, or set an explicit zone (e.g. America/New_York); defaults to the host timezone @@ -160,6 +161,15 @@ get_vm_ip() { virsh domifaddr "$VM_NAME" | awk '/ipv4/ { split($4, a, "/"); print a[1]; exit }' } +# Best-effort global IPv6 (ULA) address, for NETWORK_IPV6=nat VMs. Reads the +# DHCPv6 lease via domifaddr and filters to the migrant ULA prefix (skipping +# link-local). May be empty if the guest configured a SLAAC-only address, which +# does not appear in the lease table. SSH and get_vm_ip stay on IPv4. +get_vm_ip6() { + virsh domifaddr "$VM_NAME" 2>/dev/null \ + | awk '$3 == "ipv6" && $4 ~ /^fdca:6d16:2b1a:/ { split($4, a, "/"); print a[1]; exit }' +} + get_vm_ip_or_die() { local ip ip=$(get_vm_ip) @@ -364,9 +374,30 @@ sync_managed_config() { local has_host_access=false (( ${#HOST_ACCESS[@]} > 0 )) && has_host_access=true + local has_network_ipv6=false + case "${NETWORK_IPV6:-off}" in + nat) has_network_ipv6=true ;; + off) ;; + *) + echo "[ERROR] invalid NETWORK_IPV6: ${NETWORK_IPV6} (expected 'nat' or 'off')." >&2 + exit 65 # EX_DATAERR + ;; + esac + + # NAT66 IPv6 egress is not routed through the WireGuard tunnel (fwmark policy + # routing is IPv4-only), so allowing it alongside WireGuard would leak the + # VM's IPv6 traffic outside the VPN. Refuse the combination. + if [[ "$has_network_ipv6" == true && "$has_wireguard" == true ]]; then + echo "[ERROR] NETWORK_IPV6=nat is incompatible with WireGuard." >&2 + echo " IPv6 egress would bypass the tunnel (fwmark routing is IPv4-only)." >&2 + echo " Remove wireguard.conf or unset NETWORK_IPV6." >&2 + exit 65 # EX_DATAERR + fi + [[ "$has_network_isolation" == true \ || "$has_shared_folder_isolation_disabled" == true \ || "$has_wireguard" == true \ + || "$has_network_ipv6" == true \ || "$has_host_access" == true ]] && needs_dir=true if [[ "$needs_dir" == false ]]; then @@ -387,6 +418,13 @@ sync_managed_config() { rm -f "$managed_dir/network-isolation" fi + # --- IPv6 (NAT66) flag --- + if [[ "$has_network_ipv6" == true ]]; then + touch "$managed_dir/network-ipv6" + else + rm -f "$managed_dir/network-ipv6" + fi + # --- shared folder isolation flag --- # Presence of this file means isolation is DISABLED (opt-out). if [[ "$has_shared_folder_isolation_disabled" == true ]]; then @@ -948,6 +986,9 @@ OPERATION="$2" # iptables chain names are limited to 29 characters; hash the VM name so # the chain name stays within that limit regardless of VM name length. CHAIN="MIGRANT_$(printf '%s' "$VM_NAME" | md5sum | head -c8)" +# Separate ip6tables chain for the NAT66 per-VM INPUT reject (see apply_rules). +# Distinct prefix so the v4 and v6 per-VM chains never collide. +CHAIN6="MIGRANT6_$(printf '%s' "$VM_NAME" | md5sum | head -c8)" # All stderr goes to a persistent log file. libvirtd does not reliably forward # hook stderr to the journal, so this is the only reliable debug channel. @@ -974,6 +1015,9 @@ echo "$xml" | grep -q "managed-by=migrant" || exit 0 HAS_NETWORK_ISOLATION=false [[ -f "/etc/migrant/${VM_NAME}/network-isolation" ]] && HAS_NETWORK_ISOLATION=true +HAS_NETWORK_IPV6=false +[[ -f "/etc/migrant/${VM_NAME}/network-ipv6" ]] && HAS_NETWORK_IPV6=true + VM_MAC=$(echo "$xml" | grep -o "mac address='[^']*'" | head -1 | cut -d"'" -f2) wg_iface_and_table() { @@ -1241,13 +1285,59 @@ wg_teardown() { hook_log "$vm" "WireGuard torn down: $WG_IFACE" } +# The ULA MASQUERADE (matches the whole /64) and IPv6 forwarding are host-global, +# shared by every NAT66 VM and can't be scoped to a tap. Reference-count their +# users via sentinel files so they are added on the first VM and reverted on the +# last — otherwise they outlive the sandbox and leave the host more permissive. +NAT66_ULA="fdca:6d16:2b1a::/64" +NAT66_REFDIR="/run/migrant/nat66" + +nat66_shared_setup() { + local vm="$1" + mkdir -p "$NAT66_REFDIR" + + # Save the host's prior forwarding value once so the last teardown restores it + # exactly, rather than blindly zeroing a host that legitimately forwards IPv6. + if [[ ! -f "$NAT66_REFDIR/.fwd-prev" ]]; then + sysctl -n net.ipv6.conf.all.forwarding > "$NAT66_REFDIR/.fwd-prev" 2>/dev/null \ + || echo 0 > "$NAT66_REFDIR/.fwd-prev" + fi + sysctl -w net.ipv6.conf.all.forwarding=1 >/dev/null 2>&1 || true + + # -C guard keeps the shared MASQUERADE idempotent across VMs. + ip6tables -t nat -C POSTROUTING -s "$NAT66_ULA" ! -o virbr-migrant -j MASQUERADE 2>/dev/null \ + || ip6tables -t nat -A POSTROUTING -s "$NAT66_ULA" ! -o virbr-migrant -j MASQUERADE + + touch "$NAT66_REFDIR/$vm" +} + +nat66_shared_teardown() { + local vm="$1" + rm -f "$NAT66_REFDIR/$vm" + + # Revert only once no NAT66 VM remains (sentinels are VM names; .fwd-prev is a + # dotfile, excluded here). + local remaining + remaining=$(find "$NAT66_REFDIR" -mindepth 1 -maxdepth 1 -type f ! -name '.*' 2>/dev/null | head -1) || true + [[ -n "$remaining" ]] && return 0 + + ip6tables -t nat -D POSTROUTING -s "$NAT66_ULA" ! -o virbr-migrant -j MASQUERADE 2>/dev/null || true + local prev + prev=$(cat "$NAT66_REFDIR/.fwd-prev" 2>/dev/null) || prev=0 + sysctl -w "net.ipv6.conf.all.forwarding=${prev:-0}" >/dev/null 2>&1 || true + rm -f "$NAT66_REFDIR/.fwd-prev" +} + apply_rules() { local vm="$1" hook_log "$vm" "apply_rules: enter" - # No work needed if this VM uses neither network isolation nor WireGuard. + # No work needed if this VM uses no network isolation, WireGuard, nor NAT66 + # (NAT66 needs its masquerade even when isolation is off — egress still routes + # through the hook, not libvirt's IPv4-only NAT). [[ "$HAS_NETWORK_ISOLATION" == true ]] \ || [[ -f "/etc/migrant/${vm}/wireguard.conf" ]] \ + || [[ "${HAS_NETWORK_IPV6:-false}" == true ]] \ || return 0 # Locate the tap interface for this VM without calling virsh. @@ -1338,11 +1428,58 @@ apply_rules() { fi fi - # Drop all IPv6 from this VM. The libvirt network provides no routable IPv6 - # to VMs; this rule makes the de-facto limitation explicit. When WireGuard is - # in use, fwmark routing is IPv4-only and without this rule IPv6 traffic would - # bypass the tunnel and exit via the host's default IPv6 path. - ip6tables -I FORWARD -m physdev --physdev-in "$iface" -j DROP + # IPv6 egress policy for this VM. + if [[ "${HAS_NETWORK_IPV6:-false}" == true ]]; then + # Opt-in NAT66 (NETWORK_IPV6=nat): give the VM IPv6 egress via the host's + # uplink, mirroring the IPv4 NAT. Requires the host to have working IPv6. + # The ULA MUST match the block in the migrant network XML. + nat66_shared_setup "$vm" + + # ICMPv6 policy, decided at the top of INPUT. libvirt's LIBVIRT_INP accepts + # ICMPv6 wholesale (as it does ICMP for v4), so — like the IPv4 ICMP reject — + # it must be settled above LIBVIRT_INP, not in the per-VM CHAIN6 below (which + # LIBVIRT_INP pre-empts). Accept only what the VM needs on-link: neighbor + # discovery (133-136, using ULA not link-local source here) and PMTUD/error + # types (1-4); reject the rest — notably echo, matching the IPv4 side that + # rejects ping to the host. Insert the catch-all reject first so the type + # ACCEPTs, inserted after, stack above it. Per-tap physdev, so nothing leaks + # bridge-wide. + ip6tables -C INPUT -m physdev --physdev-in "$iface" -p ipv6-icmp -j REJECT 2>/dev/null \ + || ip6tables -I INPUT -m physdev --physdev-in "$iface" -p ipv6-icmp -j REJECT + local icmp6_type + for icmp6_type in 133 134 135 136 1 2 3 4; do + ip6tables -C INPUT -m physdev --physdev-in "$iface" -p ipv6-icmp --icmpv6-type "$icmp6_type" -j ACCEPT 2>/dev/null \ + || ip6tables -I INPUT -m physdev --physdev-in "$iface" -p ipv6-icmp --icmpv6-type "$icmp6_type" -j ACCEPT + done + + if [[ "$HAS_NETWORK_ISOLATION" == true ]]; then + # Mirror the IPv4 RFC1918 REJECTs: block the VM from other VMs/LAN (all ULA + # incl. our subnet, and link-local) in FORWARD; internet falls through. + ip6tables -I FORWARD -m physdev --physdev-in "$iface" -d fc00::/7 -j REJECT + ip6tables -I FORWARD -m physdev --physdev-in "$iface" -d fe80::/10 -j REJECT + + # Block guest->host: the host's on-link ULA is locally delivered, so it hits + # INPUT and never the FORWARD rejects above — without this NAT66 leaves every + # host IPv6 service reachable, unlike IPv4. Mirrors the IPv4 per-VM CHAIN: + # reject NEW, after LIBVIRT_INP so DHCPv6/DNS to dnsmasq still work. + ip6tables -N "$CHAIN6" 2>/dev/null || ip6tables -F "$CHAIN6" + ip6tables -A "$CHAIN6" -m conntrack --ctstate NEW -j REJECT + local inp6_pos + inp6_pos=$(ip6tables -L INPUT --line-numbers -n \ + | awk '/LIBVIRT_INP/{print $1; exit}') || true + if [[ -n "$inp6_pos" ]]; then + ip6tables -I INPUT "$((inp6_pos + 1))" \ + -m physdev --physdev-in "$iface" -j "$CHAIN6" + else + ip6tables -A INPUT -m physdev --physdev-in "$iface" -j "$CHAIN6" + fi + fi + else + # Default: the migrant network provides no routable IPv6 to VMs, so drop it. + # This also stops IPv6 from bypassing NAT/WireGuard via the host's default + # IPv6 path (WireGuard fwmark policy routing is IPv4-only). + ip6tables -I FORWARD -m physdev --physdev-in "$iface" -j DROP + fi wg_setup_rules "$vm" "$iface" @@ -1399,7 +1536,26 @@ remove_rules() { iptables -D FORWARD -m physdev --physdev-in "$iface" -d 192.168.0.0/16 -j REJECT 2>/dev/null || true fi - ip6tables -D FORWARD -m physdev --physdev-in "$iface" -j DROP 2>/dev/null || true + if [[ "${HAS_NETWORK_IPV6:-false}" == true ]]; then + ip6tables -D FORWARD -m physdev --physdev-in "$iface" -d fc00::/7 -j REJECT 2>/dev/null || true + ip6tables -D FORWARD -m physdev --physdev-in "$iface" -d fe80::/10 -j REJECT 2>/dev/null || true + + local icmp6_type + for icmp6_type in 133 134 135 136 1 2 3 4; do + ip6tables -D INPUT -m physdev --physdev-in "$iface" -p ipv6-icmp --icmpv6-type "$icmp6_type" -j ACCEPT 2>/dev/null || true + done + ip6tables -D INPUT -m physdev --physdev-in "$iface" -p ipv6-icmp -j REJECT 2>/dev/null || true + + ip6tables -D INPUT -m physdev --physdev-in "$iface" -j "$CHAIN6" 2>/dev/null || true + ip6tables -F "$CHAIN6" 2>/dev/null || true + ip6tables -X "$CHAIN6" 2>/dev/null || true + + # Drop the shared MASQUERADE and restore forwarding once the last NAT66 VM is + # gone (the per-VM rules above are always removed). + nat66_shared_teardown "$vm" + else + ip6tables -D FORWARD -m physdev --physdev-in "$iface" -j DROP 2>/dev/null || true + fi local WG_IFACE WG_TABLE wg_iface_and_table "$vm" @@ -1595,6 +1751,19 @@ MIGRANT_NETWORK_EOF + + + + + + NET_EOF virsh net-define "$net_xml" @@ -1641,7 +1810,7 @@ _migrant() { "ssh:SSH into the VM; optionally run a remote command" "tunnel:Open SSH local-forwards from host to VM" "console:Open a serial console session" - "ip:Print the VM's IP address" + "ip:Print the VM's IPv4 address, or (-6) its IPv6 address" "pubkey:Generate the managed SSH key if needed and print its public key" "tz:Sync the host timezone to the VM, or set an explicit zone" "storage:List IMAGES_DIR contents grouped by base images and VMs" @@ -1992,6 +2161,20 @@ do_autoconnect() { cmd_ip() { require_running + if [[ "${1:-}" == "-6" || "${1:-}" == "--ipv6" ]]; then + if [[ "${NETWORK_IPV6:-off}" != "nat" ]]; then + echo "[ERROR] '$VM_NAME' does not have NETWORK_IPV6=nat set." >&2 + exit 1 + fi + local ip6 + ip6=$(get_vm_ip6) + if [[ -z "$ip6" ]]; then + echo "No IPv6 address found for '$VM_NAME' (the guest may still be configuring, or took a SLAAC-only address not in the lease table)." >&2 + exit 1 + fi + echo "$ip6" + return + fi get_vm_ip_or_die } @@ -2216,6 +2399,11 @@ cmd_status() { else printf '%-*s%s\n' "$w" 'ip:' 'pending [WARNING]' fi + if [[ "${NETWORK_IPV6:-off}" == "nat" ]]; then + local ip6 + ip6=$(get_vm_ip6) + printf '%-*s%s\n' "$w" 'ipv6:' "${ip6:-pending}" + fi ;; shut\ off) printf '%-*s%s\n' "$w" 'state:' 'shut off' diff --git a/nixos/Migrantfile b/nixos/Migrantfile index 6ffd7d6..d3b7856 100644 --- a/nixos/Migrantfile +++ b/nixos/Migrantfile @@ -35,6 +35,14 @@ NETWORKS=( # to have been run. Set to false to opt out. #NETWORK_ISOLATION=false +# IPv6 egress policy. 'off' (the default) gives the VM no IPv6 egress. Set to +# 'nat' to route the VM's IPv6 via NAT66: the guest gets a ULA and the host +# masquerades it to its own IPv6 uplink. 'nat' requires the host to have working +# IPv6, and is refused alongside a wireguard.conf (IPv6 would bypass the tunnel). +# Existing installs must recreate the migrant network to use 'nat' — see the +# README "IPv6 (NAT66)" section. +NETWORK_IPV6=off + # Cloud-init handles user creation and SSH key injection from the seed ISO. CLOUD_INIT_WAIT=true diff --git a/test/README.md b/test/README.md index 7426059..d1ae4b9 100644 --- a/test/README.md +++ b/test/README.md @@ -41,6 +41,7 @@ cd test/ | `multi-rule/` | Combined `allow-host-port tcp/9999` + `allow-lan-host` in a single config | | `isolation-only/` | Default isolation with no HOST_ACCESS — verifies the VM cannot reach the host | | `no-isolation/` | `NETWORK_ISOLATION=false` — verifies the VM can reach the host freely | +| `ipv6-nat/` | `NETWORK_IPV6=nat` — verifies NAT66 egress works while the host stays unreachable over IPv6 | ### Hook pattern diff --git a/test/ipv6-nat/Migrantfile b/test/ipv6-nat/Migrantfile new file mode 100644 index 0000000..91473bc --- /dev/null +++ b/test/ipv6-nat/Migrantfile @@ -0,0 +1,19 @@ +# VM identity +VM_NAME="test-ipv6-nat" +OS_VARIANT="archlinux" + +# Resources +RAM_MB=2048 +VCPUS=2 +DISK_GB=10 + +# Base image +IMAGE_URL="https://geo.mirror.pkgbuild.com/images/latest/Arch-Linux-x86_64-cloudimg.qcow2" + +# Network +NETWORKS=( + "network=migrant" +) + +# IPv6 egress via NAT66 — the property under test +NETWORK_IPV6=nat diff --git a/test/ipv6-nat/cloud-init.yml b/test/ipv6-nat/cloud-init.yml new file mode 100644 index 0000000..f6f35f8 --- /dev/null +++ b/test/ipv6-nat/cloud-init.yml @@ -0,0 +1,15 @@ +#cloud-config +users: + - name: migrant + groups: [wheel] + sudo: "ALL=(ALL) NOPASSWD:ALL" + shell: /bin/bash + lock_passwd: false + plain_text_passwd: "migrant" + ssh_authorized_keys: + - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH/11hxpeV5+xvp0dpMbLF2yGAdc8wDwETCWIeiMsFoZ migrant + +package_update: true +packages: + - python3 + - uv diff --git a/test/ipv6-nat/hooks/post-up b/test/ipv6-nat/hooks/post-up new file mode 100755 index 0000000..ebfe089 --- /dev/null +++ b/test/ipv6-nat/hooks/post-up @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Verify the NAT66 VM: IPv6 egress should work, and the host must stay unreachable +# over IPv6 (the pre-up listener on tcp/19999 is the bait). netcheck exits non-zero +# if any expectation is unmet, which fails the hook and the 'up'. +echo "[$(date '+%Y-%m-%d %H:%M:%S')] ${MIGRANT_VM_NAME} ready at ${MIGRANT_VM_IP}; running netcheck" +migrant ssh -- 'uv run ~/netcheck.py --no-interactive --ipv6-nat --ipv6-host-port tcp/19999' diff --git a/test/ipv6-nat/hooks/pre-down b/test/ipv6-nat/hooks/pre-down new file mode 100755 index 0000000..0635195 --- /dev/null +++ b/test/ipv6-nat/hooks/pre-down @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Stop the host-side IPv6 listener started in pre-up. +PIDFILE="${MIGRANT_VM_DIR}/.listener6.pid" + +if [[ -f "$PIDFILE" ]]; then + pid=$(<"$PIDFILE") + if kill -0 "$pid" 2>/dev/null; then + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + fi + rm -f "$PIDFILE" +fi diff --git a/test/ipv6-nat/hooks/pre-up b/test/ipv6-nat/hooks/pre-up new file mode 100755 index 0000000..17f3e61 --- /dev/null +++ b/test/ipv6-nat/hooks/pre-up @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Start a host-side IPv6 listener the guest must NOT be able to reach. netcheck's +# --ipv6-host-port check connects to the host ULA gateway on this port: under +# working isolation the connection is blocked; a CONNECTED result is the hole. +# Bound to :: so it answers on the migrant ULA (fdca:6d16:2b1a::1) too. +PIDFILE="${MIGRANT_VM_DIR}/.listener6.pid" + +if [[ -f "$PIDFILE" ]]; then + pid=$(<"$PIDFILE") + if kill -0 "$pid" 2>/dev/null; then + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + fi + rm -f "$PIDFILE" +fi + +python3 -c " +import socket, socketserver +class Server(socketserver.TCPServer): + address_family = socket.AF_INET6 +class H(socketserver.StreamRequestHandler): + def handle(self): + self.wfile.write(b'hello from host over IPv6\n') +Server(('::', 19999), H).serve_forever() +" & + +echo $! > "$PIDFILE" diff --git a/test/ipv6-nat/playbook.yml b/test/ipv6-nat/playbook.yml new file mode 100644 index 0000000..c6b158f --- /dev/null +++ b/test/ipv6-nat/playbook.yml @@ -0,0 +1,10 @@ +--- +- name: Stage netcheck.py for VM-side connectivity tests + hosts: all + become: false + tasks: + - name: Copy netcheck.py to VM home + ansible.builtin.copy: + src: "{{ playbook_dir }}/../../tools/netcheck.py" + dest: /home/migrant/netcheck.py + mode: "0755" diff --git a/tools/README.md b/tools/README.md index af518b4..53729f5 100644 --- a/tools/README.md +++ b/tools/README.md @@ -37,6 +37,10 @@ Or add a task to your Ansible playbook to provision it automatically. # Supply optional inputs via flags ./netcheck.py --lan-ip 192.168.1.50 --host-port tcp/9999 --peer-ip 192.168.200.5 + +# Verify a NETWORK_IPV6=nat VM: expect IPv6 egress to work, and confirm the host +# is still unreachable over IPv6 (needs a host listener on the given port). +./netcheck.py --no-interactive --ipv6-nat --ipv6-host-port tcp/19999 ``` ### What it checks @@ -45,8 +49,8 @@ Or add a task to your Ansible playbook to provision it automatically. | ----------- | --------------------------------------------------------------------------------------------- | | Inventory | Interfaces, routing tables, DNS resolvers, gateway | | DNS | Default resolver (A/AAAA), direct queries to 8.8.8.8/1.1.1.1, interception detection, whoami | -| Internet | HTTP/HTTPS reachability, IPv6 probe, public IP/VPN info, traceroute, MTU | -| Isolation | Gateway ping/TCP, RFC1918 TCP probes, IPv6 external | +| Internet | HTTP/HTTPS reachability, IPv6 egress probe, public IP/VPN info, traceroute, MTU | +| Isolation | Gateway ping/TCP, RFC1918 TCP probes, IPv6 host reachability (ULA gateway, `--ipv6-nat`) | | Host access | TCP/UDP connect to a host port (requires `allow-host-port` in Migrantfile) | | LAN / peer | Ping and TCP to a LAN host, peer VM ping | diff --git a/tools/netcheck.py b/tools/netcheck.py index 16b03df..a70ba44 100755 --- a/tools/netcheck.py +++ b/tools/netcheck.py @@ -129,6 +129,12 @@ def build_parser() -> argparse.ArgumentParser: help="Host port, e.g. tcp/9999 (skips interactive prompt)") p.add_argument("--peer-ip", metavar="IP", help="Another migrant VM IP to probe (skips interactive prompt)") + p.add_argument("--ipv6-nat", action="store_true", + help="VM has NETWORK_IPV6=nat: expect IPv6 egress to work, and " + "verify the host and LAN are still blocked over IPv6") + p.add_argument("--ipv6-host-port", metavar="SPEC", + help="Host port with a known IPv6 listener the VM must NOT be " + "able to reach, e.g. tcp/19999 (host-isolation check)") p.add_argument("--no-interactive", action="store_true", help="Skip all interactive prompts; run only automatic tests") p.add_argument("--check-tools", action="store_true", @@ -651,6 +657,50 @@ def _tcp_probe(host: str, port: int, timeout: float = 3.0) -> str: return f"ERROR:{exc.errno}" +def _tcp_probe6(host: str, port: int, timeout: float = 3.0) -> str: + """AF_INET6 counterpart of _tcp_probe. A firewall REJECT surfaces as an + ICMPv6 admin-prohibited error (ERROR:13 / NOROUTE), a DROP as TIMEOUT; only + CONNECTED means the guest actually reached the host over IPv6.""" + try: + with socket.socket(socket.AF_INET6, socket.SOCK_STREAM) as sock: + sock.settimeout(timeout) + sock.connect((host, port)) + return "CONNECTED" + except ConnectionRefusedError: + return "REJECTED" + except socket.timeout: + return "TIMEOUT" + except OSError as exc: + if exc.errno == 111: # ECONNREFUSED + return "REJECTED" + if exc.errno == 113: # EHOSTUNREACH — no route (or REJECT --reject-with) + return "NOROUTE" + return f"ERROR:{exc.errno}" + + +def _vm_global_v6(ctx: Context) -> str | None: + """The guest's routable IPv6 (ULA under NAT66), or None. Skips loopback and + link-local, which never carry egress or host-isolation traffic.""" + for ips in ctx.vm_ips.values(): + for ip in ips: + if ":" in ip and ip != "::1" and not ip.startswith("fe80"): + return ip + return None + + +def _ula_gateway(ctx: Context) -> str | None: + """The host's on-link ULA (the migrant IPv6 gateway), derived as ::1 of the + guest ULA's /64. This is the address host-directed guest traffic targets.""" + guest = _vm_global_v6(ctx) + if not guest: + return None + try: + net = ipaddress.IPv6Interface(f"{guest}/64").network + except ValueError: + return None + return str(net.network_address + 1) + + # --------------------------------------------------------------------------- # Tests: 3. Internet connectivity # --------------------------------------------------------------------------- @@ -687,31 +737,29 @@ def _inet_https(ctx: Context) -> Result: return Result("FAIL", str(exc)[:120]) -@test("Internet", "IPv6 external reachability", expect="FAIL") +@test("Internet", "IPv6 external reachability", expect="PASS") def _inet_ipv6(ctx: Context) -> Result: - """Always blocked at FORWARD regardless of NETWORK_ISOLATION setting. - A PASS result here is a security finding. + """The one authoritative IPv6 egress assertion. The desired outcome flips with + the mode, so this test decides PASS/FAIL itself: without --ipv6-nat, egress + MUST be blocked (a reachable internet host is a security finding); with + --ipv6-nat, egress MUST work (that is the feature). Isolation of the host/LAN + over IPv6 is checked separately in the Isolation section. """ - has_global_v6 = any( - ip != "::1" and ":" in ip and not ip.startswith("fe80") - for ips in ctx.vm_ips.values() - for ip in ips - ) - if not has_global_v6: - return Result("FAIL", "no global IPv6 address assigned (expected — IPv6 not configured)") + nat = ctx.args.ipv6_nat + guest_v6 = _vm_global_v6(ctx) + if not guest_v6: + if nat: + return Result("FAIL", "no global/ULA IPv6 assigned — NAT66 guest never got its address") + return Result("PASS", "no global IPv6 assigned (expected — IPv6 egress off)") target_ip = "2606:4700:4700::1111" # Cloudflare IPv6 DNS - try: - with socket.socket(socket.AF_INET6, socket.SOCK_STREAM) as sock: - sock.settimeout(5) - sock.connect((target_ip, 53)) - return Result( - "PASS", - f"Connected to [{target_ip}]:53 — UNEXPECTED; verify ip6tables FORWARD rule", - ) - except socket.timeout: - return Result("FAIL", "timeout (blocked at FORWARD or no route — expected)") - except OSError as exc: - return Result("FAIL", f"blocked (expected): {exc}") + outcome = _tcp_probe6(target_ip, 53, timeout=5.0) + if outcome == "CONNECTED": + if nat: + return Result("PASS", f"reached [{target_ip}]:53 — NAT66 egress working") + return Result("FAIL", f"reached [{target_ip}]:53 — UNEXPECTED; verify ip6tables FORWARD rule") + if nat: + return Result("FAIL", f"{outcome} — NAT66 egress not working (host IPv6 uplink up?)") + return Result("PASS", f"{outcome} — blocked (expected, IPv6 egress off)") @test("Internet", "Public IP / tunnel info", expect=None) @@ -831,33 +879,51 @@ def _iso_rfc1918_192(ctx: Context) -> Result: return Result("INFO", f"{outcome} — {_ISOLATION_NOTES.get(outcome, outcome)}") -@test("Isolation", "IPv6 external reachability", expect="FAIL") -def _iso_ipv6_external(ctx: Context) -> Result: - """Always blocked at ip6tables FORWARD regardless of NETWORK_ISOLATION setting. - A PASS result here is a security finding. - Note: this test also appears in the Internet section; it is included here - explicitly as an isolation verification. +@test("Isolation", "IPv6 host reachability (ULA gateway TCP)", expect="PASS") +def _iso_ipv6_host_tcp(ctx: Context) -> Result: + """NAT66's real risk: the host's on-link ULA is locally delivered, so guest-> + host traffic hits the host INPUT chain, not the FORWARD rejects. Probe a host + port that is deliberately listening (started by the VM's pre-up hook and + passed via --ipv6-host-port). CONNECTED proves the guest reached a host + service over IPv6 — the isolation hole. Any blocked outcome is PASS. A known + listener is required so a plain ECONNREFUSED (reached host, nothing bound) + can't masquerade as 'safely blocked'. """ - has_global_v6 = any( - ip != "::1" and ":" in ip and not ip.startswith("fe80") - for ips in ctx.vm_ips.values() - for ip in ips - ) - if not has_global_v6: - return Result("FAIL", "no global IPv6 address assigned (expected — IPv6 not configured)") - target_ip = "2606:4700:4700::1111" # Cloudflare IPv6 DNS + if not ctx.args.ipv6_nat: + return Result("SKIP", "requires --ipv6-nat (no ULA without NAT66)") + if not ctx.args.ipv6_host_port: + return Result("SKIP", "no --ipv6-host-port specified (skipped)") try: - with socket.socket(socket.AF_INET6, socket.SOCK_STREAM) as sock: - sock.settimeout(5) - sock.connect((target_ip, 53)) - return Result( - "PASS", - f"Connected to [{target_ip}]:53 — UNEXPECTED; verify ip6tables FORWARD rule", - ) - except socket.timeout: - return Result("FAIL", "timeout (blocked at FORWARD or no route — expected)") - except OSError as exc: - return Result("FAIL", f"blocked (expected): {exc}") + proto, port = parse_host_port(ctx.args.ipv6_host_port) + except ValueError as exc: + return Result("SKIP", str(exc)) + if proto != "tcp": + return Result("SKIP", "only tcp is probed for host isolation") + gw = _ula_gateway(ctx) + if not gw: + return Result("FAIL", "no ULA gateway derivable — NAT66 guest has no ULA") + outcome = _tcp_probe6(gw, port, timeout=5.0) + if outcome == "CONNECTED": + return Result("FAIL", f"reached host [{gw}]:{port} over IPv6 — isolation hole") + return Result("PASS", f"{outcome} — host [{gw}]:{port} blocked (expected)") + + +@test("Isolation", "IPv6 host reachability (ICMPv6 echo)", expect="PASS") +def _iso_ipv6_host_icmp(ctx: Context) -> Result: + """The IPv4 side rejects ping to the host; NAT66 must not be looser. Echo to + the ULA gateway must be blocked (only NDP/error ICMPv6 is permitted on-link). + """ + if not ctx.args.ipv6_nat: + return Result("SKIP", "requires --ipv6-nat (no ULA without NAT66)") + gw = _ula_gateway(ctx) + if not gw: + return Result("FAIL", "no ULA gateway derivable — NAT66 guest has no ULA") + r = run_tool("ping", ["-6", "-c", "1", "-W", "3", gw], timeout=8) + if not r.available: + return Result("SKIP", "ping not found") + if r.returncode == 0: + return Result("FAIL", f"host {gw} answered ICMPv6 echo — looser than the IPv4 path") + return Result("PASS", f"host {gw} did not answer ICMPv6 echo (expected)") _GATEWAY_SURVEY_PORTS: list[tuple[int, str]] = [ @@ -1145,6 +1211,13 @@ def main() -> None: sys.stderr.write(f"Error: --host-port: {exc}\n") sys.exit(2) + if args.ipv6_host_port: + try: + parse_host_port(args.ipv6_host_port) + except ValueError as exc: + sys.stderr.write(f"Error: --ipv6-host-port: {exc}\n") + sys.exit(2) + _warn_missing_tools() try: diff --git a/tools/test_netcheck.py b/tools/test_netcheck.py index 071ebd6..debd015 100644 --- a/tools/test_netcheck.py +++ b/tools/test_netcheck.py @@ -121,3 +121,28 @@ def test_parse_vm_ips_veth_peer_suffix(): result = nc.parse_vm_ips(output) assert "veth0" in result assert "10.0.0.1" in result["veth0"] + + +def _ctx(vm_ips): + args = nc.build_parser().parse_args(["--no-interactive"]) + return nc.Context(args=args, gateway="192.168.200.1", resolvers=[], vm_ips=vm_ips) + + +def test_vm_global_v6_prefers_ula_over_linklocal(): + ctx = _ctx({"lo": ["::1"], "eth0": ["fe80::5054:ff:feab:cdef", "fdca:6d16:2b1a::abcd"]}) + assert nc._vm_global_v6(ctx) == "fdca:6d16:2b1a::abcd" + + +def test_vm_global_v6_none_when_only_linklocal(): + ctx = _ctx({"lo": ["::1"], "eth0": ["fe80::5054:ff:feab:cdef"]}) + assert nc._vm_global_v6(ctx) is None + + +def test_ula_gateway_is_prefix_colon_one(): + ctx = _ctx({"eth0": ["fdca:6d16:2b1a::abcd"]}) + assert nc._ula_gateway(ctx) == "fdca:6d16:2b1a::1" + + +def test_ula_gateway_none_without_global_v6(): + ctx = _ctx({"eth0": ["fe80::1"]}) + assert nc._ula_gateway(ctx) is None diff --git a/ubuntu/Migrantfile b/ubuntu/Migrantfile index 6624633..b16708b 100644 --- a/ubuntu/Migrantfile +++ b/ubuntu/Migrantfile @@ -49,6 +49,14 @@ SHARED_FOLDER_SIZE_GB=10 # to have been run. Set to false to opt out. #NETWORK_ISOLATION=false +# IPv6 egress policy. 'off' (the default) gives the VM no IPv6 egress. Set to +# 'nat' to route the VM's IPv6 via NAT66: the guest gets a ULA and the host +# masquerades it to its own IPv6 uplink. 'nat' requires the host to have working +# IPv6, and is refused alongside a wireguard.conf (IPv6 would bypass the tunnel). +# Existing installs must recreate the migrant network to use 'nat' — see the +# README "IPv6 (NAT66)" section. +NETWORK_IPV6=off + # Optional: automatically connect after 'migrant up' finishes. # Unset by default. Set to "ssh" or "console" to enable. AUTOCONNECT=ssh