Skip to content

Add dual-stack IPv6 networking support - #30

Open
struanb wants to merge 8 commits into
mainfrom
claude/runcvm-ipv6-support-iwotel
Open

Add dual-stack IPv6 networking support#30
struanb wants to merge 8 commits into
mainfrom
claude/runcvm-ipv6-support-iwotel

Conversation

@struanb

@struanb struanb commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

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_IPV6 environment variable.

Key Changes

  • IPv6 address capture and configuration: Modified runcvm-ctr-entrypoint to capture global IPv6 addresses from container interfaces and store them separately in /.runcvm/network/devices6/ for use by the VM
  • VM-side IPv6 initialization: Updated runcvm-vm-init to:
    • Disable IPv6 globally at boot to prevent unwanted SLAAC/DAD on all interfaces
    • Selectively re-enable IPv6 only on interfaces with captured addresses
    • Configure IPv6 addresses with DAD disabled for immediate usability
    • Add IPv6 default routes and supplementary routes from /.runcvm/network/routes6
  • IPv6 route handling: Added support for capturing and restoring non-kernel IPv6 routes (excluding kernel-derived connected routes and link-local addresses)
  • Kernel command line control: Modified runcvm-ctr-qemu to conditionally disable IPv6 at kernel boot time based on whether any interface has a global IPv6 address, with override capability via RUNCVM_IPV6 environment variable
  • Helper function library: Removed unused IPv4-specific CIDR/netmask calculation functions from runcvm-ip-functions (now handled by jq in the entrypoint)
  • New helper function: Added load_network6() to runcvm-ctr-defaults for loading IPv6 configuration per-interface
  • Comprehensive test suite: Added tests/04-ipv6/test covering:
    • Multi-interface container boot (regression test for default-gateway sentinel bug)
    • IPv4 address assignment verification
    • IPv6 address assignment verification
    • IPv6 default route configuration
    • IPv6 connectivity between containers by DNS name
  • Documentation updates: Updated README to reflect IPv6 support status and limitations

Notable Implementation Details

  • IPv6 is disabled globally at VM boot time, then selectively re-enabled per-interface to prevent unwanted multicast group joins and address autoconfiguration
  • DAD (Duplicate Address Detection) is skipped for Docker-assigned IPv6 addresses since they are guaranteed unique and need immediate usability
  • The existing IPv4-only DNS/exec helper channel (dnsmasq in container over IPv4 loopback) is reused for IPv6 containers, avoiding the need for IPv6-specific DNS infrastructure
  • IPv6-only networks are not supported; a working IPv4 leg is required for the helper channel
  • The test gracefully skips if the Docker daemon doesn't have IPv6 enabled, rather than failing

https://claude.ai/code/session_01SkBvaHzPUEuAbddFVfErPQ

claude added 8 commits August 3, 2026 20:59
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
struanb marked this pull request as ready for review August 3, 2026 23:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants