Add dual-stack IPv6 networking support - #30
Open
struanb wants to merge 8 commits into
Open
Conversation
Since 55a7439, non-default-gateway interfaces are recorded with a literal '-' placeholder in the gateway field of /.runcvm/network/devices/<if>, rather than an empty field. The route-restore check in runcvm-vm-init only tested for a non-empty value, so it now attempts `ip route add default via -` on those interfaces. Under `bash -e` this aborts the boot script and kills the VM's PID 1.
ip_prefix_to_network() and its helpers computed the connected-route prefix using 32-bit dotted-quad integer arithmetic, which cannot be extended to 128-bit IPv6 prefixes. The kernel already exposes this value directly as the .dst of the interface's on-link route, so capture it from there instead (before the address is flushed, since flushing an interface's address also removes the route derived from it). This removes the last user of the IPv4-only prefix arithmetic, which is deleted; ip_prefix_to_bcast and its helpers were already unused dead code and are removed too. is_natural_int(), still used for the VNC display check, is kept.
Extends runcvm-ctr-entrypoint to discover the IPv6 default gateway, capture a global IPv6 address per interface (filtered on family and scope, rather than blindly taking addr_info[0]), and save supplementary IPv6 routes to a new routes6 file, mirroring the existing IPv4 capture. Captured IPv6 addressing is written to a new sidecar /.runcvm/network/devices6/<if> file per interface, rather than widening the existing 6-field devices/<if> format: load_network() does a fixed 6-variable `read -r`, and appending fields there would silently corrupt DOCKER_IF_IP_GW when read collapses the surplus fields into it. Absence of a devices6/<if> file for an interface is the sole signal that it has no IPv6, so no sentinel values are needed. A new load_network6() in runcvm-ctr-defaults loads it, mirroring load_network(). No container-side IPv6 address or route is restored (unlike IPv4, where the container keeps a private address on the bridge for the DNS/exec helper channel): the VM is the sole intended owner of the captured IPv6 address, and giving the container reachability of its own would require NAT66/conntrack machinery that is out of scope for initial dual-stack support. Nothing yet consumes the captured data; the VM continues to boot with IPv6 disabled until a later change wires up replay and makes activation conditional. Also adds RUNCVM_IPV6 to clean_env() in preparation for a later --env=RUNCVM_IPV6=0|1 override.
Extends runcvm-vm-init to restore IPv6 addressing per-interface, mirroring the existing IPv4 replay: address plus prefix, default route (if this is the IPv6 default-gateway interface), and supplementary routes from the new routes6 file. IPv6 is disabled on every interface by default at early boot, before any renaming takes place, so no interface can perform SLAAC/DAD or join a multicast group using its unwanted kernel-assigned EUI-64 address. This replaces a previous single-interface guess (`ls /sys/class/net | grep -vE '^(lo|docker)' | head -n 1`) with a sweep of every interface, since disabling only a guessed "primary" one left any additional interface unaddressed. Once an interface has been renamed to its final Docker-facing name and a captured IPv6 address is found for it (via the new load_network6(), added to runcvm-ctr-defaults), IPv6 is selectively re-enabled on that interface only; accept_ra and autoconf stay off throughout, since Docker addresses IPv6 statically and runs no RA daemon on its bridge networks. The address is added with `nodad`: it is definitionally the VM's, since the container released it before enslaving the interface, and skipping Duplicate Address Detection avoids a boot-order hazard where the default route add would otherwise intermittently fail against a still-tentative source address. load_network6() is fixed to unconditionally clear DOCKER_IF6_* on every call before its existence check, not just on success: unlike load_network(), it is routinely called for interfaces that turn out to have no IPv6, and a stale value surviving from a previous interface in the same loop would otherwise be mistaken for this one's address. IPv6 remains inert: nothing yet makes activation conditional, so the VM still boots with ipv6.disable=1 on the kernel command line regardless of what was captured.
do_networks() now sets HAS_IPV6=1 if load_network6() finds a captured IPv6 address for any container interface. The kernel command line's ipv6.disable=1 is now conditional on this, so containers with no IPv6 continue to boot exactly as before (VM's IPv6 stack fully disabled at kernel boot), while a container with a global IPv6 address boots with a working IPv6 stack, which runcvm-vm-init then selectively enables per-interface and configures. --env=RUNCVM_IPV6=1 forces IPv6 on regardless of what was captured, and --env=RUNCVM_IPV6=0 forces it off, for cases where the automatic detection isn't what's wanted. This is the change that actually activates everything captured and replayed by the two preceding commits.
The per-interface IPv6 capture added in 6de0290 reads from a jq select() that legitimately produces no output at all when an interface has no global IPv6 address - the common case for every interface in an IPv4-only container. Under this script's `set -e`, that made the bare `read` abort the entrypoint outright as soon as it reached any such interface, breaking basically all existing IPv4-only usage. Confirmed empirically (this is the same class of bug fixed in 45ae456): a standalone failing command aborts under errexit, but a `read` that clears its variables via a genuinely empty input stream is exactly this case, unlike a `while read` loop condition or an `&&`/`||` chain, which are exempt. Append `|| true`, matching the guard already used throughout these scripts for reads/commands that are expected to legitimately fail in the common case.
Follows the tests/framework.sh pattern used by 02-user-workdir and 03-env. Creates a dual-stack Docker network (docker network create --ipv6) and skips cleanly, rather than failing, if dockerd doesn't support it - dual-stack RunCVM requires the host and dockerd to already be IPv6-configured, which this test cannot itself provide. Asserts, across two RunCVM containers on that network: - a container attached to a second network at create time still boots and accepts 'docker exec' (regression cover for the default-gateway sentinel bug fixed in 45ae456; no existing test exercises a multi-interface container) - the VM's IPv4 address, still matches Docker's, unaffected by this change - the VM picks up the IPv6 address and default route Docker assigned to the container - one VM reaches the other by container name over IPv6, exercising both Docker DNS returning an AAAA record (over the unchanged IPv4-only helper channel) and the IPv6 datapath across both containers' hub bridges Not run in this environment: no Docker daemon or /dev/kvm is reachable here, so this has been syntax-checked (bash -n) and the individual shell parsing/formatting expressions it relies on (Go-template network inspection, ip/awk/cut field extraction, the exit-code-capturing command substitutions) verified against representative sample output, but not exercised end-to-end against a real dual-stack Docker network.
Updates the project aims, feature/limitation table, rp_filter section, option reference and internals walkthrough to reflect automatic dual-stack support, its two remaining limitations (IPv6-only networks, and --dns=<IPv6 address>), and the new --env=RUNCVM_IPV6=<0|1> override.
struanb
marked this pull request as ready for review
August 3, 2026 23:34
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR adds comprehensive IPv6 support to RunCVM, enabling containers to use dual-stack (IPv4 and IPv6) Docker networks. IPv6 is automatically enabled when a global IPv6 address is assigned to the container, and can be manually controlled via the
RUNCVM_IPV6environment variable.Key Changes
runcvm-ctr-entrypointto capture global IPv6 addresses from container interfaces and store them separately in/.runcvm/network/devices6/for use by the VMruncvm-vm-initto:/.runcvm/network/routes6runcvm-ctr-qemuto conditionally disable IPv6 at kernel boot time based on whether any interface has a global IPv6 address, with override capability viaRUNCVM_IPV6environment variableruncvm-ip-functions(now handled byjqin the entrypoint)load_network6()toruncvm-ctr-defaultsfor loading IPv6 configuration per-interfacetests/04-ipv6/testcovering:Notable Implementation Details
https://claude.ai/code/session_01SkBvaHzPUEuAbddFVfErPQ