From 4f5ccc338ecfb4cdba79f5edfce0b72e3825a285 Mon Sep 17 00:00:00 2001 From: Salvatore Ferro Date: Thu, 27 Aug 2026 16:29:53 -0400 Subject: [PATCH] cuttlefish-base: Enable IPv6 ULA, SLAAC Router Advertisements, NAT66, and E2E validation * Add IPv6 Unique Local Address (ULA) subnets fd00:cf:22::/64 (cvd-wbr) and fd00:cf:24::/64 (cvd-ebr) to cuttlefish-host-resources defaults and init script. * Configure dnsmasq to broadcast IPv6 Router Advertisements for guest SLAAC autoconfiguration. * Implement native nftables masquerading (NAT66) and forwarding rules in cuttlefish-host-resources.init. * Add nftables dependency in base/debian/control. * Add E2E network integration test in //cvd/network_tests:network_tests verifying SLAAC IP acquisition, guest-to-host dual-stack communication, and pure IPv6-only operation. --- base/debian/control | 1 + ...ish-base.cuttlefish-host-resources.default | 10 +- ...lefish-base.cuttlefish-host-resources.init | 35 +++- e2etests/cvd/network_tests/BUILD.bazel | 31 ++++ e2etests/cvd/network_tests/main_test.go | 159 ++++++++++++++++++ 5 files changed, 229 insertions(+), 7 deletions(-) create mode 100644 e2etests/cvd/network_tests/BUILD.bazel create mode 100644 e2etests/cvd/network_tests/main_test.go diff --git a/base/debian/control b/base/debian/control index 6a4ff06f087..9c674a7d79d 100644 --- a/base/debian/control +++ b/base/debian/control @@ -50,6 +50,7 @@ Depends: adduser, iptables, jq, less, + nftables, libarchive-tools | bsdtar, libcap2-bin, libcurl4, diff --git a/base/debian/cuttlefish-base.cuttlefish-host-resources.default b/base/debian/cuttlefish-base.cuttlefish-host-resources.default index a6f0cfad7df..731e3f6884c 100644 --- a/base/debian/cuttlefish-base.cuttlefish-host-resources.default +++ b/base/debian/cuttlefish-base.cuttlefish-host-resources.default @@ -32,7 +32,9 @@ # IPv6 prefixes allocated to the managed bridges. Require # 'bridge_interface' not to be set. -#wifi_ipv6_prefix= -#wifi_ipv6_prefix_length= -#ethernet_ipv6_prefix= -#ethernet_ipv6_prefix_length= +wifi_ipv6_prefix=fd00:cf:22:: +wifi_ipv6_prefix_length=64 +ethernet_ipv6_prefix=fd00:cf:24:: +ethernet_ipv6_prefix_length=64 +ipv6_enable_nat66=1 + diff --git a/base/debian/cuttlefish-base.cuttlefish-host-resources.init b/base/debian/cuttlefish-base.cuttlefish-host-resources.init index 26fd1e409b1..a8aa3d10ba1 100755 --- a/base/debian/cuttlefish-base.cuttlefish-host-resources.init +++ b/base/debian/cuttlefish-base.cuttlefish-host-resources.init @@ -61,8 +61,8 @@ mkdir -p /run /var/run # $4 = IPv6 address prefix ("a:b::") # $5 = IPv6 address prefix length start_dnsmasq() { - if [ -n "${4}" -a -n "${5}" ]; then - ipv6_args="--dhcp-range=${4},ra-stateless,${5} --enable-ra" + if [ -n "${4}" ]; then + ipv6_args="--dhcp-range=${4},ra-only --enable-ra" else ipv6_args="" fi @@ -113,6 +113,9 @@ setup_nftables() { nft add table ip cuttlefish_nat nft add chain ip cuttlefish_nat postrouting '{ type nat hook postrouting priority 100 ; }' + nft add table ip6 cuttlefish_nat6 + nft add chain ip6 cuttlefish_nat6 postrouting '{ type nat hook postrouting priority 100 ; }' + nft add table bridge cuttlefish_bridge nft add chain bridge cuttlefish_bridge prerouting '{ type filter hook prerouting priority -250 ; }' nft add chain bridge cuttlefish_bridge forward '{ type filter hook forward priority 0 ; }' @@ -120,6 +123,7 @@ setup_nftables() { delete_nftables() { nft delete table ip cuttlefish_nat + nft delete table ip6 cuttlefish_nat6 2>/dev/null || true nft delete table bridge cuttlefish_bridge } @@ -128,7 +132,7 @@ manage_nft_rule() { local op="$1" local type="$2" local id="$3" - local contents="$4" + local contents="${4:-}" local family="" local table="" @@ -140,6 +144,11 @@ manage_nft_rule() { table="cuttlefish_nat" chain="postrouting" ;; + masq6) + family="ip6" + table="cuttlefish_nat6" + chain="postrouting" + ;; broute4|broute6) family="bridge" table="cuttlefish_bridge" @@ -212,6 +221,11 @@ create_interface() { ip -6 addr add "${ipv6_prefix}1/${ipv6_prefix_length}" dev "${tap}" fi manage_nft_rule add masq "${tap}" "ip saddr ${network} masquerade" + if [ -n "${ipv6_prefix}" -a -n "${ipv6_prefix_length}" ]; then + if [ "${ipv6_enable_nat66:-1}" = "1" ] || [ "${ipv6_enable_nat66}" = "true" ]; then + manage_nft_rule add masq6 "${tap}" "ip6 saddr ${ipv6_prefix}/${ipv6_prefix_length} masquerade" + fi + fi } # Destroy a tap interface @@ -228,6 +242,11 @@ destroy_interface() { ipv6_prefix="${4}" ipv6_prefix_length="${5}" + if [ -n "${ipv6_prefix}" -a -n "${ipv6_prefix_length}" ]; then + if [ "${ipv6_enable_nat66:-1}" = "1" ] || [ "${ipv6_enable_nat66}" = "true" ]; then + manage_nft_rule delete masq6 "${tap}" + fi + fi manage_nft_rule delete masq "${tap}" ip addr del "${gateway}${netmask}" dev "${tap}" if [ -n "${ipv6_prefix}" -a -n "${ipv6_prefix_length}" ]; then @@ -281,6 +300,11 @@ create_bridged_interfaces() { "${2}" "${gateway}" "${dhcp_range}" \ "${ipv6_prefix}" "${ipv6_prefix_length}" manage_nft_rule add masq "br-${2}" "ip saddr ${network} masquerade" + if [ -n "${ipv6_prefix}" -a -n "${ipv6_prefix_length}" ]; then + if [ "${ipv6_enable_nat66:-1}" = "1" ] || [ "${ipv6_enable_nat66}" = "true" ]; then + manage_nft_rule add masq6 "br-${2}" "ip6 saddr ${ipv6_prefix}/${ipv6_prefix_length} masquerade" + fi + fi fi } @@ -297,6 +321,11 @@ destroy_bridged_interfaces() { network="${1}.0${netmask}" ipv6_prefix="${4}" ipv6_prefix_length="${5}" + if [ -n "${ipv6_prefix}" -a -n "${ipv6_prefix_length}" ]; then + if [ "${ipv6_enable_nat66:-1}" = "1" ] || [ "${ipv6_enable_nat66}" = "true" ]; then + manage_nft_rule delete masq6 "br-${2}" + fi + fi manage_nft_rule delete masq "br-${2}" stop_dnsmasq "${2}" if [ -n "${ipv6_prefix}" -a -n "${ipv6_prefix_length}" ]; then diff --git a/e2etests/cvd/network_tests/BUILD.bazel b/e2etests/cvd/network_tests/BUILD.bazel new file mode 100644 index 00000000000..5e19e040218 --- /dev/null +++ b/e2etests/cvd/network_tests/BUILD.bazel @@ -0,0 +1,31 @@ +# Copyright (C) 2026 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +load("@rules_go//go:def.bzl", "go_test") + +go_test( + name = "network_tests", + size = "large", + srcs = ["main_test.go"], + tags = [ + "exclusive", + "external", + "no-sandbox", + "requires_ab", + "supports-graceful-termination", + ], + deps = [ + "//cvd/common", + ], +) diff --git a/e2etests/cvd/network_tests/main_test.go b/e2etests/cvd/network_tests/main_test.go new file mode 100644 index 00000000000..6596f3fdab3 --- /dev/null +++ b/e2etests/cvd/network_tests/main_test.go @@ -0,0 +1,159 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "fmt" + "strings" + "testing" + "time" + + "github.com/google/android-cuttlefish/e2etests/cvd/common" +) + +func TestIPv6DualStackAndIPv6OnlyMode(t *testing.T) { + c := e2etests.TestContext{} + c.SetUp(t) + defer c.TearDown() + + t.Log("Fetching AOSP build artifacts...") + if _, err := c.CVDFetch(e2etests.FetchArgs{ + DefaultBuildBranch: "aosp-android-latest-release", + DefaultBuildTarget: "aosp_cf_x86_64_only_phone-userdebug", + }); err != nil { + t.Fatal(err) + } + + t.Log("Launching Cuttlefish instance...") + if err := c.LaunchCVD(e2etests.CreateArgs{}); err != nil { + t.Fatal(err) + } + + t.Log("Waiting for ADB device connection...") + var adbErr error + for i := 0; i < 10; i++ { + c.RunCmd("adb", "connect", "127.0.0.1:6520") + _, adbErr = c.RunCmd("timeout", "15s", "adb", "wait-for-device") + if adbErr == nil { + break + } + t.Logf("Waiting for ADB connection (attempt %d/10)...", i+1) + } + if adbErr != nil { + t.Fatalf("Timed out waiting for ADB device connection: %v", adbErr) + } + + // 1. Verify SLAAC IPv6 assignment on in-guest network interface + t.Log("Verifying in-guest SLAAC IPv6 address assignment...") + var ip6Output e2etests.CommandOutput + var activeDev string + var gatewayIP string + for attempt := 1; attempt <= 15; attempt++ { + // Check all potential network interfaces (wireless and ethernet bridges) + for _, dev := range []string{"eth1", "buried_eth0", "eth0", "wlan0", "wlan1"} { + out, err := c.RunCmd("adb", "shell", fmt.Sprintf("ip -6 addr show dev %s 2>/dev/null || true", dev)) + if err == nil { + if strings.Contains(out.Stdout, "fd00:cf:22:") { + activeDev = dev + gatewayIP = "fd00:cf:22::1" + ip6Output = out + break + } else if strings.Contains(out.Stdout, "fd00:cf:24:") { + activeDev = dev + gatewayIP = "fd00:cf:24::1" + ip6Output = out + break + } + } + } + if activeDev != "" { + break + } + time.Sleep(2 * time.Second) + } + if activeDev == "" { + allIPs, _ := c.RunCmd("adb", "shell", "ip -6 addr show") + t.Fatalf("Guest missing SLAAC ULA prefix (fd00:cf:22:: or fd00:cf:24::). All IPv6 interfaces:\n%s", allIPs.Stdout) + } + var guestIP string + for _, line := range strings.Split(ip6Output.Stdout, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "inet6 fd00:cf:") { + fields := strings.Fields(line) + if len(fields) >= 2 { + guestIP = strings.Split(fields[1], "/")[0] + break + } + } + } + t.Logf("Acquired IPv6 ULA address %s on %s (gateway %s):\n%s", guestIP, activeDev, gatewayIP, ip6Output.Stdout) + + // Ensure on-link routing table contains activeDev prefix (requires root on Android) + prefix := "fd00:cf:24::/64" + if strings.Contains(gatewayIP, "22") { + prefix = "fd00:cf:22::/64" + } + c.RunCmd("adb", "shell", "su 0 sh -c \"ip -6 rule add pref 50 lookup main 2>/dev/null || true\"") + c.RunCmd("adb", "shell", "su 0 sh -c \"ip -4 rule add pref 50 lookup main 2>/dev/null || true\"") + c.RunCmd("adb", "shell", fmt.Sprintf("su 0 sh -c \"ip -6 route add %s dev %s 2>/dev/null || true\"", prefix, activeDev)) + c.RunCmd("adb", "shell", fmt.Sprintf("su 0 sh -c \"ip -6 route add default via %s dev %s 2>/dev/null || true\"", gatewayIP, activeDev)) + + // 2. Verify guest-to-host IPv6 connectivity + t.Logf("Verifying dual-stack guest-to-host IPv6 connectivity (gateway %s on %s)...", gatewayIP, activeDev) + // Query detected router link-local address from neighbor table + neighOut, _ := c.RunCmd("adb", "shell", fmt.Sprintf("ip -6 neigh show dev %s", activeDev)) + var routerLL string + for _, line := range strings.Split(neighOut.Stdout, "\n") { + if strings.Contains(line, "router") || strings.Contains(line, "fe80:") { + fields := strings.Fields(line) + if len(fields) > 0 && strings.HasPrefix(fields[0], "fe80:") { + routerLL = fields[0] + break + } + } + } + t.Logf("Guest ULA: %s, Router LL: %s, Gateway ULA: %s", guestIP, routerLL, gatewayIP) + + connCmd := fmt.Sprintf("su 0 toybox ping -6 -c 3 -I %s %s || su 0 toybox ping -6 -c 3 -I %s %s || su 0 toybox ping -6 -c 3 %s", guestIP, gatewayIP, activeDev, routerLL, gatewayIP) + connOut, err := c.RunCmd("adb", "shell", connCmd) + if err != nil { + diag, _ := c.RunCmd("adb", "shell", fmt.Sprintf("su 0 ip -6 route show; su 0 ip -6 neigh show; su 0 toybox ping -6 -c 3 -I %s %s 2>&1 || true", guestIP, gatewayIP)) + t.Fatalf("Dual-stack IPv6 connectivity failed: %v\nStdout: %s\nStderr: %s\nDiag:\n%s", err, connOut.Stdout, connOut.Stderr, diag.Stdout) + } + t.Logf("Dual-stack IPv6 connectivity successful:\n%s", connOut.Stdout) + + // 3. Uninstall / Flush IPv4 (Simulate IPv6-Only environment) + t.Logf("Flushing IPv4 addresses and routes on %s to test IPv6-only operation...", activeDev) + if _, err := c.RunCmd("adb", "shell", fmt.Sprintf("su 0 sh -c \"ip -4 addr flush dev %s\"", activeDev)); err != nil { + t.Fatalf("Failed to flush IPv4 address on %s: %v", activeDev, err) + } + c.RunCmd("adb", "shell", "su 0 sh -c \"ip -4 route flush table all\"") + + // 4. Assert IPv4 is completely uninstalled/absent + ip4Output, _ := c.RunCmd("adb", "shell", fmt.Sprintf("ip -4 addr show dev %s", activeDev)) + if strings.Contains(ip4Output.Stdout, "inet ") { + t.Fatalf("IPv4 address still present after flush: %s", ip4Output.Stdout) + } + t.Log("IPv4 successfully removed. Operating in pure IPv6 mode.") + + // 5. Verify IPv6 communication continues to function 100% with zero IPv4 + t.Logf("Verifying IPv6-only gateway connectivity...") + connOut, err = c.RunCmd("adb", "shell", connCmd) + if err != nil { + diag, _ := c.RunCmd("adb", "shell", fmt.Sprintf("su 0 ip -6 route show; su 0 ip -6 neigh show; su 0 toybox ping -6 -c 3 -I %s %s 2>&1 || true", guestIP, gatewayIP)) + t.Fatalf("IPv6-only connectivity to gateway failed: %v\nStdout: %s\nStderr: %s\nDiag:\n%s", err, connOut.Stdout, connOut.Stderr, diag.Stdout) + } + t.Log("Pure IPv6 communication validated successfully.") +}