From 9c1a77812f54b9bead1d7ee6e383c44002ed859f Mon Sep 17 00:00:00 2001 From: Brett Eisenberg Date: Fri, 10 Jul 2026 11:52:18 -0700 Subject: [PATCH 1/4] feat: optional IPv6 egress via NAT66 (NETWORK_IPV6=nat) Add an opt-in IPv6 path for VMs. By default a migrant VM still has no routable IPv6 (dropped at the FORWARD chain); setting NETWORK_IPV6=nat in a Migrantfile gives the guest a ULA and masquerades it to the host's IPv6 uplink, mirroring the IPv4 NAT. Network isolation still blocks the host and LAN over IPv6. - migrant ip -6 and migrant status surface the VM's IPv6 (ULA) address - refused alongside WireGuard (fwmark routing is IPv4-only, so IPv6 would bypass the tunnel) - NETWORK_IPV6 is validated to nat/off; sample Migrantfiles set off --- CLAUDE.md | 5 +- README.md | 94 +++++++++++++++++++++++++++++---- arch/Migrantfile | 8 +++ debian/Migrantfile | 8 +++ migrant | 127 ++++++++++++++++++++++++++++++++++++++++++--- nixos/Migrantfile | 8 +++ ubuntu/Migrantfile | 8 +++ 7 files changed, 238 insertions(+), 20 deletions(-) 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..c336050 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,45 @@ 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 an INPUT accept + for this; keep it 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 +738,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 +786,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 +951,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..0a4057a 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 @@ -974,6 +1012,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() { @@ -1338,11 +1379,42 @@ 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): masquerade the VM's ULA to the host's + # IPv6 uplink so it can reach IPv6-only endpoints, mirroring the IPv4 NAT. + # Requires the host to have working IPv6 egress. The ULA MUST match the + # block in the migrant network XML. The MASQUERADE matches + # the whole /64 (shared by all IPv6 VMs) and is left in place on teardown; + # the -C guard keeps re-adding it idempotent. + sysctl -w net.ipv6.conf.all.forwarding=1 >/dev/null 2>&1 || true + ip6tables -t nat -C POSTROUTING -s fdca:6d16:2b1a::/64 ! -o virbr-migrant -j MASQUERADE 2>/dev/null \ + || ip6tables -t nat -A POSTROUTING -s fdca:6d16:2b1a::/64 ! -o virbr-migrant -j MASQUERADE + # Permit ICMPv6 on the migrant bridge. The VM's on-link gateway is our ULA + # (fdca:6d16:2b1a::1), so neighbor discovery between host and guest uses + # ULA (global-scope) source addresses, not link-local. A host INPUT firewall + # that only accepts ICMPv6 from fe80::/10 (a very common default) REJECTs + # those NS/NA — the host then can't resolve the guest's ULA, so NAT66 reply + # traffic dies with "address unreachable" while egress (forwarded, never + # INPUT) still works. This mirrors the rule libvirt adds for its own managed + # IPv6 networks; migrant hand-rolls NAT66, so libvirt never adds it. Inserted + # ahead of the host's REJECTs, shared across IPv6 VMs, left in place on + # teardown like the MASQUERADE; the -C guard keeps re-adding it idempotent. + ip6tables -C INPUT -i virbr-migrant -p ipv6-icmp -j ACCEPT 2>/dev/null \ + || ip6tables -I INPUT -i virbr-migrant -p ipv6-icmp -j ACCEPT + if [[ "$HAS_NETWORK_ISOLATION" == true ]]; then + # Mirror the IPv4 RFC1918 REJECTs for IPv6: block the VM from reaching the + # host and other VMs/LAN (all ULA incl. our own subnet, and link-local), + # while letting global-scope internet traffic fall 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 + 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 +1471,14 @@ 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 + # NAT66 MASQUERADE and the bridge ICMPv6 accept are shared across IPv6 VMs + # — intentionally left in place. + 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 +1674,19 @@ MIGRANT_NETWORK_EOF + + + + + + NET_EOF virsh net-define "$net_xml" @@ -1641,7 +1733,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 +2084,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 +2322,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/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 From ba5d24e559afcb04c247260dd500a956e5246bc0 Mon Sep 17 00:00:00 2001 From: Brett Eisenberg Date: Tue, 21 Jul 2026 20:38:08 -0700 Subject: [PATCH 2/4] fix: close guest->host IPv6 holes in NAT66 isolation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NETWORK_IPV6=nat mirrored the IPv4 RFC1918 rejects in FORWARD but left guest->host over IPv6 open: the host's on-link ULA is locally delivered, so it hits INPUT, not FORWARD. Every host IPv6 service was reachable from the guest, and the blanket ICMPv6 accept answered echo too — both looser than the IPv4 path. - add a per-VM ip6tables INPUT reject (CHAIN6), after LIBVIRT_INP so DHCPv6/DNS to dnsmasq still work - settle ICMPv6 at the top of INPUT: accept only NDP (133-136) and PMTUD error types (1-4), reject the rest (echo included), scoped per-tap - reference-count the shared MASQUERADE and forwarding sysctl so they are reverted on the last NAT66 VM instead of persisting after teardown - apply NAT66 masquerade even with NETWORK_ISOLATION=false (was skipped by the early-return guard) netcheck.py gains --ipv6-nat/--ipv6-host-port and NAT66-aware IPv6 tests (egress must work; host must stay unreachable over TCP and ICMPv6). New ipv6/ VM boots with NETWORK_IPV6=nat and runs the checks from post-up. Verified live: egress works, host blocked, teardown reverts cleanly. --- .gitignore | 1 + CLAUDE.md | 8 ++ README.md | 7 +- ipv6/Migrantfile | 86 +++++++++++++++++++++ ipv6/cloud-init.yml | 36 +++++++++ ipv6/hooks/post-down | 12 +++ ipv6/hooks/post-up | 8 ++ ipv6/hooks/pre-down | 14 ++++ ipv6/hooks/pre-up | 29 +++++++ ipv6/playbook.yml | 35 +++++++++ migrant | 131 +++++++++++++++++++++++++------- tools/README.md | 8 +- tools/netcheck.py | 167 +++++++++++++++++++++++++++++------------ tools/test_netcheck.py | 25 ++++++ 14 files changed, 489 insertions(+), 78 deletions(-) create mode 100644 ipv6/Migrantfile create mode 100644 ipv6/cloud-init.yml create mode 100755 ipv6/hooks/post-down create mode 100755 ipv6/hooks/post-up create mode 100755 ipv6/hooks/pre-down create mode 100755 ipv6/hooks/pre-up create mode 100644 ipv6/playbook.yml diff --git a/.gitignore b/.gitignore index d3e4c13..c51feea 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ *.img */wireguard.conf */workspace/ +*/.listener*.pid .worktrees/ diff --git a/CLAUDE.md b/CLAUDE.md index 02cc430..c8b9a9f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,6 +110,14 @@ Keep `arch/`, `ubuntu/`, and `debian/` in parity — apply equivalent changes to three. Distro-specific differences (package manager, unit names) are expected; structural or behavioural divergence is not. +`ipv6/` is a special-purpose NAT66 verification VM derived from `arch/` (same base +image and cloud-init), not a distro variant: it sets `NETWORK_IPV6=nat`, uses a +minimal test-only playbook (just uv + staging `netcheck.py`, no agent tooling), +and carries a host-isolation test harness (pre-up/pre-down manage a host IPv6 +listener; post-up runs `netcheck.py --ipv6-nat`). It is not part of the +arch/ubuntu/debian parity set — do not mirror its hooks, playbook, or NAT66 +setting into them. + Known parity exceptions: - **tmp.mount masked** (`debian/playbook.yml` only): Debian 13 uses tmpfs for `/tmp`; Ubuntu and Arch do not. diff --git a/README.md b/README.md index c336050..4e3871b 100644 --- a/README.md +++ b/README.md @@ -595,8 +595,11 @@ Requirements and caveats: - **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 an INPUT accept - for this; keep it in place if you manage the host firewall out-of-band. + *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`. diff --git a/ipv6/Migrantfile b/ipv6/Migrantfile new file mode 100644 index 0000000..93837c9 --- /dev/null +++ b/ipv6/Migrantfile @@ -0,0 +1,86 @@ +# VM identity +VM_NAME="ipv6" +# OS variant hint passed to virt-install for guest tuning (machine type, firmware, +# virtio drivers, etc.). Should match the guest OS. Run 'osinfo-query os | grep arch' +# to find the right value when upgrading. +OS_VARIANT="archlinux" + +# Resources +RAM_MB=4096 +VCPUS=2 +DISK_GB=20 + +# Base image +IMAGE_URL="https://geo.mirror.pkgbuild.com/images/latest/Arch-Linux-x86_64-cloudimg.qcow2" + +# Shared folders — format is "host_absolute_or_relative_path:guest_tag" +# The guest_tag is what you mount inside the VM, e.g.: +# sudo mount -t virtiofs workspace /home/migrant/workspace +SHARED_FOLDERS=( + "workspace:workspace" +) + +# Network — one entry per NIC, using virt-install --network syntax +NETWORKS=( + "network=migrant" +) + +# Shared folder loop image size in gigabytes. A sparse ext4 image of this size +# is created the first time 'migrant up' or 'migrant mount' is run. The +# image starts at ~67 MB on disk and grows with contents up to this cap. +# Add workspace.img (or *.img) to .gitignore to avoid committing it. +SHARED_FOLDER_SIZE_GB=10 + +# Set to false to share a plain host directory instead of a loop image. +# The loop image is on by default: it caps how much the VM can write to the +# shared folder and prevents host processes from following symlinks the VM +# planted inside the share to reach files elsewhere on the host. Turn it +# off only if you trust the VM's workload. +# Changing this after VM creation requires 'migrant destroy && migrant up'. +#SHARED_FOLDER_ISOLATION=false + +# Set to "uefi" to use UEFI firmware instead of the default BIOS. Not needed +# for Arch — the archlinux osinfo-db entry already specifies UEFI and q35, +# so virt-install selects them automatically. +# See debian/Migrantfile for the full rationale. +#BOOT_FIRMWARE=uefi + +# Block the VM from initiating connections to the host and from reaching other +# hosts on the local network. Enabled by default; requires 'migrant setup' +# 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=nat + +# Optional: automatically connect after 'migrant up' finishes. +# Unset by default. Set to "ssh" or "console" to enable. +# Left unset here: this is a test VM whose post-up hook runs netcheck and exits. +#AUTOCONNECT=ssh + +# Host access rules — exceptions to NETWORK_ISOLATION. Each entry is a +# directive applied by the libvirt hook alongside the isolation rules. +# Has no effect when network isolation is disabled (NETWORK_ISOLATION=false). +#HOST_ACCESS=( +# "allow-host-port tcp/8080" # VM can reach host:8080 +# "allow-lan-host 192.168.1.50" # VM can reach a specific LAN host +#) + +# Local SSH port-forwards opened by `migrant tunnel`. Each entry is a port +# number; the host's 127.0.0.1:PORT routes to the VM's 127.0.0.1:PORT for +# the lifetime of the SSH session. +# +#TUNNEL_PORTS=(3000 5432 6379) + +# Lifecycle hooks — executable scripts in hooks/ run at VM state transitions: +# hooks/pre-up before VM starts (can abort) +# hooks/post-up after VM is fully ready +# hooks/pre-down before VM shuts down (can abort halt/snapshot, not destroy) +# hooks/post-down after VM has stopped +# Hooks fire from every code path that changes VM state, including snapshot and +# reset. See README.md for environment variables and examples. diff --git a/ipv6/cloud-init.yml b/ipv6/cloud-init.yml new file mode 100644 index 0000000..97430eb --- /dev/null +++ b/ipv6/cloud-init.yml @@ -0,0 +1,36 @@ +#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 installation and software setup are handled by Ansible (playbook.yml). +# To provision without Ansible, remove playbook.yml and uncomment the blocks below. +# package_update: true +# packages: +# - git +# - unzip + +runcmd: + # Bootstrap: create shared folder mount point. + # Runs as root before SSH is available and cannot be done by Ansible. + - mkdir -p /home/migrant/workspace + - echo "workspace /home/migrant/workspace virtiofs defaults 0 0" >> /etc/fstab + - mount /home/migrant/workspace + +# Without Ansible, also add these runcmd entries: +# # write_files runs before users-groups, so any file it writes to /home/migrant +# # ends up owned by root. Fix ownership before running anything as migrant. +# - chown -R migrant:migrant /home/migrant +# - echo 'export PATH="$HOME/.local/bin:$PATH"' > /etc/profile.d/local-bin.sh +# - sudo -u migrant bash -c 'curl -fsSL https://claude.ai/install.sh | bash' +# - sudo -u migrant bash -c 'echo "alias c=\"cd ~/workspace && claude --dangerously-skip-permissions\"" >> ~/.bash_aliases' +# - sudo -u migrant bash -c 'echo "alias cc=\"claude --dangerously-skip-permissions\"" > ~/.bash_aliases' +# - sudo -u migrant bash -c 'echo "alias ccc=\"claude --dangerously-skip-permissions --continue\"" > ~/.bash_aliases' +# - sudo -u migrant bash -c 'echo "alias ccr=\"claude --dangerously-skip-permissions --resume\"" > ~/.bash_aliases' +# - curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh diff --git a/ipv6/hooks/post-down b/ipv6/hooks/post-down new file mode 100755 index 0000000..c662171 --- /dev/null +++ b/ipv6/hooks/post-down @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Example post-down hook: log VM shutdown with timestamp. +# Runs after the VM has fully stopped. Fires from halt, snapshot, destroy, and reset. +# +# Available environment variables: +# MIGRANT_VM_NAME — VM name from the Migrantfile +# MIGRANT_VM_DIR — absolute path to the VM directory +# MIGRANT_HOOK — hook name (post-down) +# MIGRANT_TRIGGER — command that caused shutdown (halt, snapshot, destroy, reset) +# MIGRANT_VM_IP — empty (VM is stopped) + +echo "[$(date '+%Y-%m-%d %H:%M:%S')] ${MIGRANT_VM_NAME} stopped (trigger: ${MIGRANT_TRIGGER})" diff --git a/ipv6/hooks/post-up b/ipv6/hooks/post-up new file mode 100755 index 0000000..ebfe089 --- /dev/null +++ b/ipv6/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/ipv6/hooks/pre-down b/ipv6/hooks/pre-down new file mode 100755 index 0000000..0635195 --- /dev/null +++ b/ipv6/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/ipv6/hooks/pre-up b/ipv6/hooks/pre-up new file mode 100755 index 0000000..17f3e61 --- /dev/null +++ b/ipv6/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/ipv6/playbook.yml b/ipv6/playbook.yml new file mode 100644 index 0000000..0de812f --- /dev/null +++ b/ipv6/playbook.yml @@ -0,0 +1,35 @@ +--- +# Minimal provisioning for the NAT66 verification VM. This is a test fixture, not +# a coding-agent VM, so it deliberately skips arch/'s heavy tooling (base-devel, +# yay/AUR, claude, opencode) — that install is slow and depends on the Arch +# mirrors, which adds failure modes irrelevant to a firewall test. All it needs is +# uv (to run the probe) and the probe itself; ping ships in the base cloud image +# and netcheck skips any other optional tool that is absent. +- name: Provision the NAT66 verification VM + hosts: all + become: true + + tasks: + - name: Download uv installer + ansible.builtin.get_url: + url: https://astral.sh/uv/install.sh + dest: /tmp/uv-install.sh + mode: "0755" + force: false + + - name: Install uv + ansible.builtin.command: + cmd: /tmp/uv-install.sh + creates: /usr/local/bin/uv + environment: + UV_INSTALL_DIR: /usr/local/bin + + # Stage the connectivity probe so the post-up hook can verify NAT66 egress + # works and the host stays unreachable over IPv6. + - name: Copy netcheck.py to VM home + ansible.builtin.copy: + src: "{{ playbook_dir }}/../tools/netcheck.py" + dest: /home/migrant/netcheck.py + mode: "0755" + become: true + become_user: migrant diff --git a/migrant b/migrant index 0a4057a..001c515 100755 --- a/migrant +++ b/migrant @@ -986,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. @@ -1282,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. @@ -1381,33 +1430,49 @@ apply_rules() { # IPv6 egress policy for this VM. if [[ "${HAS_NETWORK_IPV6:-false}" == true ]]; then - # Opt-in NAT66 (NETWORK_IPV6=nat): masquerade the VM's ULA to the host's - # IPv6 uplink so it can reach IPv6-only endpoints, mirroring the IPv4 NAT. - # Requires the host to have working IPv6 egress. The ULA MUST match the - # block in the migrant network XML. The MASQUERADE matches - # the whole /64 (shared by all IPv6 VMs) and is left in place on teardown; - # the -C guard keeps re-adding it idempotent. - sysctl -w net.ipv6.conf.all.forwarding=1 >/dev/null 2>&1 || true - ip6tables -t nat -C POSTROUTING -s fdca:6d16:2b1a::/64 ! -o virbr-migrant -j MASQUERADE 2>/dev/null \ - || ip6tables -t nat -A POSTROUTING -s fdca:6d16:2b1a::/64 ! -o virbr-migrant -j MASQUERADE - # Permit ICMPv6 on the migrant bridge. The VM's on-link gateway is our ULA - # (fdca:6d16:2b1a::1), so neighbor discovery between host and guest uses - # ULA (global-scope) source addresses, not link-local. A host INPUT firewall - # that only accepts ICMPv6 from fe80::/10 (a very common default) REJECTs - # those NS/NA — the host then can't resolve the guest's ULA, so NAT66 reply - # traffic dies with "address unreachable" while egress (forwarded, never - # INPUT) still works. This mirrors the rule libvirt adds for its own managed - # IPv6 networks; migrant hand-rolls NAT66, so libvirt never adds it. Inserted - # ahead of the host's REJECTs, shared across IPv6 VMs, left in place on - # teardown like the MASQUERADE; the -C guard keeps re-adding it idempotent. - ip6tables -C INPUT -i virbr-migrant -p ipv6-icmp -j ACCEPT 2>/dev/null \ - || ip6tables -I INPUT -i virbr-migrant -p ipv6-icmp -j ACCEPT + # 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 for IPv6: block the VM from reaching the - # host and other VMs/LAN (all ULA incl. our own subnet, and link-local), - # while letting global-scope internet traffic fall through. + # 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. @@ -1474,8 +1539,20 @@ remove_rules() { 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 - # NAT66 MASQUERADE and the bridge ICMPv6 accept are shared across IPv6 VMs - # — intentionally left in place. + + 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 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 From 174d2068dd1a3fa27e79a7cae6e6eb1fd090b50e Mon Sep 17 00:00:00 2001 From: Brett Eisenberg Date: Wed, 22 Jul 2026 14:00:16 -0700 Subject: [PATCH 3/4] refactor: relocate NAT66 test VM to test/ipv6-nat and align with test configs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The NAT66 verification VM lived in the project root as ipv6/, next to the example VMs (arch/, ubuntu/, debian/), but it is a network-isolation test in the same mold as test/isolation-only/, test/lan-host/, etc. — self-contained dir, netcheck.py staged by a playbook, host-side bait listener, verify-and-exit. Move it to test/ipv6-nat/ and make it a true sibling of the other test configs: - cloud-init: use the shared test/cloud-init.yml (python3 + uv) instead of a copy of arch/'s agent cloud-init - Migrantfile: drop the unused workspace shared folder and loop-image config; trim to the terse form the other test configs use; VM_NAME -> test-ipv6-nat - playbook: drop the now-redundant uv install (cloud-init supplies it); reduce to the one-task netcheck staging the siblings use - hooks: drop the logging-only post-down for parity (pre-up/post-up/pre-down) - .gitignore: make the workspace/, wireguard.conf, and .listener*.pid patterns depth-agnostic (**/) so they cover configs nested under test/ — also closes a latent gap for the existing tcp/udp-host-port listener pidfiles - docs: update test/README.md and the CLAUDE.md example-VM note accordingly Verified end-to-end on KVM: netcheck passes — NAT66 egress works and the host stays unreachable over IPv6. --- .gitignore | 6 +- CLAUDE.md | 13 ++-- ipv6/Migrantfile | 86 -------------------------- ipv6/cloud-init.yml | 36 ----------- ipv6/hooks/post-down | 12 ---- ipv6/playbook.yml | 35 ----------- test/README.md | 1 + test/ipv6-nat/Migrantfile | 19 ++++++ test/ipv6-nat/cloud-init.yml | 15 +++++ {ipv6 => test/ipv6-nat}/hooks/post-up | 0 {ipv6 => test/ipv6-nat}/hooks/pre-down | 0 {ipv6 => test/ipv6-nat}/hooks/pre-up | 0 test/ipv6-nat/playbook.yml | 10 +++ 13 files changed, 54 insertions(+), 179 deletions(-) delete mode 100644 ipv6/Migrantfile delete mode 100644 ipv6/cloud-init.yml delete mode 100755 ipv6/hooks/post-down delete mode 100644 ipv6/playbook.yml create mode 100644 test/ipv6-nat/Migrantfile create mode 100644 test/ipv6-nat/cloud-init.yml rename {ipv6 => test/ipv6-nat}/hooks/post-up (100%) rename {ipv6 => test/ipv6-nat}/hooks/pre-down (100%) rename {ipv6 => test/ipv6-nat}/hooks/pre-up (100%) create mode 100644 test/ipv6-nat/playbook.yml diff --git a/.gitignore b/.gitignore index c51feea..4bbcd35 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ *.img -*/wireguard.conf -*/workspace/ -*/.listener*.pid +**/wireguard.conf +**/workspace/ +**/.listener*.pid .worktrees/ diff --git a/CLAUDE.md b/CLAUDE.md index c8b9a9f..c0507f6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,13 +110,12 @@ Keep `arch/`, `ubuntu/`, and `debian/` in parity — apply equivalent changes to three. Distro-specific differences (package manager, unit names) are expected; structural or behavioural divergence is not. -`ipv6/` is a special-purpose NAT66 verification VM derived from `arch/` (same base -image and cloud-init), not a distro variant: it sets `NETWORK_IPV6=nat`, uses a -minimal test-only playbook (just uv + staging `netcheck.py`, no agent tooling), -and carries a host-isolation test harness (pre-up/pre-down manage a host IPv6 -listener; post-up runs `netcheck.py --ipv6-nat`). It is not part of the -arch/ubuntu/debian parity set — do not mirror its hooks, playbook, or NAT66 -setting into them. +`test/ipv6-nat/` is a NAT66 verification config, not an example VM. Like the +other `test/` configs it uses `test/cloud-init.yml` and a one-task netcheck +playbook; it sets `NETWORK_IPV6=nat` and its hooks run a host-side IPv6 listener +that `netcheck.py --ipv6-nat` must not be able to reach. It is not part of the +arch/ubuntu/debian parity set — do not mirror its hooks or NAT66 setting into +them. Known parity exceptions: - **tmp.mount masked** (`debian/playbook.yml` only): Debian 13 uses tmpfs for `/tmp`; Ubuntu and Arch do not. diff --git a/ipv6/Migrantfile b/ipv6/Migrantfile deleted file mode 100644 index 93837c9..0000000 --- a/ipv6/Migrantfile +++ /dev/null @@ -1,86 +0,0 @@ -# VM identity -VM_NAME="ipv6" -# OS variant hint passed to virt-install for guest tuning (machine type, firmware, -# virtio drivers, etc.). Should match the guest OS. Run 'osinfo-query os | grep arch' -# to find the right value when upgrading. -OS_VARIANT="archlinux" - -# Resources -RAM_MB=4096 -VCPUS=2 -DISK_GB=20 - -# Base image -IMAGE_URL="https://geo.mirror.pkgbuild.com/images/latest/Arch-Linux-x86_64-cloudimg.qcow2" - -# Shared folders — format is "host_absolute_or_relative_path:guest_tag" -# The guest_tag is what you mount inside the VM, e.g.: -# sudo mount -t virtiofs workspace /home/migrant/workspace -SHARED_FOLDERS=( - "workspace:workspace" -) - -# Network — one entry per NIC, using virt-install --network syntax -NETWORKS=( - "network=migrant" -) - -# Shared folder loop image size in gigabytes. A sparse ext4 image of this size -# is created the first time 'migrant up' or 'migrant mount' is run. The -# image starts at ~67 MB on disk and grows with contents up to this cap. -# Add workspace.img (or *.img) to .gitignore to avoid committing it. -SHARED_FOLDER_SIZE_GB=10 - -# Set to false to share a plain host directory instead of a loop image. -# The loop image is on by default: it caps how much the VM can write to the -# shared folder and prevents host processes from following symlinks the VM -# planted inside the share to reach files elsewhere on the host. Turn it -# off only if you trust the VM's workload. -# Changing this after VM creation requires 'migrant destroy && migrant up'. -#SHARED_FOLDER_ISOLATION=false - -# Set to "uefi" to use UEFI firmware instead of the default BIOS. Not needed -# for Arch — the archlinux osinfo-db entry already specifies UEFI and q35, -# so virt-install selects them automatically. -# See debian/Migrantfile for the full rationale. -#BOOT_FIRMWARE=uefi - -# Block the VM from initiating connections to the host and from reaching other -# hosts on the local network. Enabled by default; requires 'migrant setup' -# 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=nat - -# Optional: automatically connect after 'migrant up' finishes. -# Unset by default. Set to "ssh" or "console" to enable. -# Left unset here: this is a test VM whose post-up hook runs netcheck and exits. -#AUTOCONNECT=ssh - -# Host access rules — exceptions to NETWORK_ISOLATION. Each entry is a -# directive applied by the libvirt hook alongside the isolation rules. -# Has no effect when network isolation is disabled (NETWORK_ISOLATION=false). -#HOST_ACCESS=( -# "allow-host-port tcp/8080" # VM can reach host:8080 -# "allow-lan-host 192.168.1.50" # VM can reach a specific LAN host -#) - -# Local SSH port-forwards opened by `migrant tunnel`. Each entry is a port -# number; the host's 127.0.0.1:PORT routes to the VM's 127.0.0.1:PORT for -# the lifetime of the SSH session. -# -#TUNNEL_PORTS=(3000 5432 6379) - -# Lifecycle hooks — executable scripts in hooks/ run at VM state transitions: -# hooks/pre-up before VM starts (can abort) -# hooks/post-up after VM is fully ready -# hooks/pre-down before VM shuts down (can abort halt/snapshot, not destroy) -# hooks/post-down after VM has stopped -# Hooks fire from every code path that changes VM state, including snapshot and -# reset. See README.md for environment variables and examples. diff --git a/ipv6/cloud-init.yml b/ipv6/cloud-init.yml deleted file mode 100644 index 97430eb..0000000 --- a/ipv6/cloud-init.yml +++ /dev/null @@ -1,36 +0,0 @@ -#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 installation and software setup are handled by Ansible (playbook.yml). -# To provision without Ansible, remove playbook.yml and uncomment the blocks below. -# package_update: true -# packages: -# - git -# - unzip - -runcmd: - # Bootstrap: create shared folder mount point. - # Runs as root before SSH is available and cannot be done by Ansible. - - mkdir -p /home/migrant/workspace - - echo "workspace /home/migrant/workspace virtiofs defaults 0 0" >> /etc/fstab - - mount /home/migrant/workspace - -# Without Ansible, also add these runcmd entries: -# # write_files runs before users-groups, so any file it writes to /home/migrant -# # ends up owned by root. Fix ownership before running anything as migrant. -# - chown -R migrant:migrant /home/migrant -# - echo 'export PATH="$HOME/.local/bin:$PATH"' > /etc/profile.d/local-bin.sh -# - sudo -u migrant bash -c 'curl -fsSL https://claude.ai/install.sh | bash' -# - sudo -u migrant bash -c 'echo "alias c=\"cd ~/workspace && claude --dangerously-skip-permissions\"" >> ~/.bash_aliases' -# - sudo -u migrant bash -c 'echo "alias cc=\"claude --dangerously-skip-permissions\"" > ~/.bash_aliases' -# - sudo -u migrant bash -c 'echo "alias ccc=\"claude --dangerously-skip-permissions --continue\"" > ~/.bash_aliases' -# - sudo -u migrant bash -c 'echo "alias ccr=\"claude --dangerously-skip-permissions --resume\"" > ~/.bash_aliases' -# - curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh diff --git a/ipv6/hooks/post-down b/ipv6/hooks/post-down deleted file mode 100755 index c662171..0000000 --- a/ipv6/hooks/post-down +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env bash -# Example post-down hook: log VM shutdown with timestamp. -# Runs after the VM has fully stopped. Fires from halt, snapshot, destroy, and reset. -# -# Available environment variables: -# MIGRANT_VM_NAME — VM name from the Migrantfile -# MIGRANT_VM_DIR — absolute path to the VM directory -# MIGRANT_HOOK — hook name (post-down) -# MIGRANT_TRIGGER — command that caused shutdown (halt, snapshot, destroy, reset) -# MIGRANT_VM_IP — empty (VM is stopped) - -echo "[$(date '+%Y-%m-%d %H:%M:%S')] ${MIGRANT_VM_NAME} stopped (trigger: ${MIGRANT_TRIGGER})" diff --git a/ipv6/playbook.yml b/ipv6/playbook.yml deleted file mode 100644 index 0de812f..0000000 --- a/ipv6/playbook.yml +++ /dev/null @@ -1,35 +0,0 @@ ---- -# Minimal provisioning for the NAT66 verification VM. This is a test fixture, not -# a coding-agent VM, so it deliberately skips arch/'s heavy tooling (base-devel, -# yay/AUR, claude, opencode) — that install is slow and depends on the Arch -# mirrors, which adds failure modes irrelevant to a firewall test. All it needs is -# uv (to run the probe) and the probe itself; ping ships in the base cloud image -# and netcheck skips any other optional tool that is absent. -- name: Provision the NAT66 verification VM - hosts: all - become: true - - tasks: - - name: Download uv installer - ansible.builtin.get_url: - url: https://astral.sh/uv/install.sh - dest: /tmp/uv-install.sh - mode: "0755" - force: false - - - name: Install uv - ansible.builtin.command: - cmd: /tmp/uv-install.sh - creates: /usr/local/bin/uv - environment: - UV_INSTALL_DIR: /usr/local/bin - - # Stage the connectivity probe so the post-up hook can verify NAT66 egress - # works and the host stays unreachable over IPv6. - - name: Copy netcheck.py to VM home - ansible.builtin.copy: - src: "{{ playbook_dir }}/../tools/netcheck.py" - dest: /home/migrant/netcheck.py - mode: "0755" - become: true - become_user: migrant 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/ipv6/hooks/post-up b/test/ipv6-nat/hooks/post-up similarity index 100% rename from ipv6/hooks/post-up rename to test/ipv6-nat/hooks/post-up diff --git a/ipv6/hooks/pre-down b/test/ipv6-nat/hooks/pre-down similarity index 100% rename from ipv6/hooks/pre-down rename to test/ipv6-nat/hooks/pre-down diff --git a/ipv6/hooks/pre-up b/test/ipv6-nat/hooks/pre-up similarity index 100% rename from ipv6/hooks/pre-up rename to test/ipv6-nat/hooks/pre-up 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" From 4656bdd09e9c37ad6e5ab757cd7101aa13e4add0 Mon Sep 17 00:00:00 2001 From: Brett Eisenberg Date: Wed, 22 Jul 2026 20:07:01 -0700 Subject: [PATCH 4/4] docs: remove test/ipv6-nat note from CLAUDE.md, leftover from before it moved under test/ --- CLAUDE.md | 7 ------- 1 file changed, 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c0507f6..02cc430 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,13 +110,6 @@ Keep `arch/`, `ubuntu/`, and `debian/` in parity — apply equivalent changes to three. Distro-specific differences (package manager, unit names) are expected; structural or behavioural divergence is not. -`test/ipv6-nat/` is a NAT66 verification config, not an example VM. Like the -other `test/` configs it uses `test/cloud-init.yml` and a one-task netcheck -playbook; it sets `NETWORK_IPV6=nat` and its hooks run a host-side IPv6 listener -that `netcheck.py --ipv6-nat` must not be able to reach. It is not part of the -arch/ubuntu/debian parity set — do not mirror its hooks or NAT66 setting into -them. - Known parity exceptions: - **tmp.mount masked** (`debian/playbook.yml` only): Debian 13 uses tmpfs for `/tmp`; Ubuntu and Arch do not.