diff --git a/.github/workflows/presubmit.yaml b/.github/workflows/presubmit.yaml index 9ff36b29ba7..bc2db30b25e 100644 --- a/.github/workflows/presubmit.yaml +++ b/.github/workflows/presubmit.yaml @@ -469,6 +469,10 @@ jobs: sudo podman exec tester sh -c 'echo "build_api_credentials_use_gce_metadata=true" >> /etc/default/cuttlefish-host_orchestrator && service cuttlefish-host_orchestrator restart' sudo podman exec --user=testrunner tester bazel --output_user_root=/tmp/cw_bazel/output test //orchestration/create_with_gce_metadata_credentials_test:create_with_gce_metadata_credentials_test_test sudo podman rm -f tester + # Run cvd/networking_tests hermetic helper suite (static_resources_init_test runs in run-cvd-unit-tests) + sudo podman run --name tester -d --privileged --pids-limit=8192 -v /tmp/cw_bazel:/tmp/cw_bazel -v .:/src/workspace -w /src/workspace/e2etests android-cuttlefish-e2etest:latest + sudo podman exec --user=testrunner tester bazel --output_user_root=/tmp/cw_bazel/output test //cvd/networking_tests:networking_tests --test_arg=-test.run='^Test(IPv6Helper|RoutedEchoHelper)' + sudo podman rm -f tester - name: Upload test logs if: always() uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 diff --git a/base/cvd/cuttlefish/host/commands/assemble_cvd/BUILD.bazel b/base/cvd/cuttlefish/host/commands/assemble_cvd/BUILD.bazel index f7240601827..4f7f2287c0a 100644 --- a/base/cvd/cuttlefish/host/commands/assemble_cvd/BUILD.bazel +++ b/base/cvd/cuttlefish/host/commands/assemble_cvd/BUILD.bazel @@ -556,6 +556,14 @@ cf_cc_library( ], ) +cf_cc_test( + name = "network_flags_test", + srcs = ["network_flags_test.cc"], + deps = [ + "//cuttlefish/host/commands/assemble_cvd:network_flags", + ], +) + cf_cc_library( name = "required_directories", srcs = ["required_directories.cc"], diff --git a/base/cvd/cuttlefish/host/commands/assemble_cvd/network_flags.cpp b/base/cvd/cuttlefish/host/commands/assemble_cvd/network_flags.cpp index febef16ca59..e122de5bfba 100644 --- a/base/cvd/cuttlefish/host/commands/assemble_cvd/network_flags.cpp +++ b/base/cvd/cuttlefish/host/commands/assemble_cvd/network_flags.cpp @@ -23,10 +23,20 @@ #include #include +#include +#include +#include #include +#include +#include +#include #include "absl/log/log.h" +#include "absl/strings/ascii.h" #include "absl/strings/numbers.h" +#include "absl/strings/str_join.h" +#include "absl/strings/str_split.h" +#include "absl/strings/strip.h" #include "cuttlefish/host/commands/cvdalloc/interface.h" #include "cuttlefish/host/libs/config/cuttlefish_config.h" @@ -137,8 +147,205 @@ class NetConfig { } }; +constexpr char kHostResourcesDefaultsPath[] = + "/etc/default/cuttlefish-host-resources"; +constexpr char kIpv6EgressMarkerPath[] = "/run/cuttlefish/ipv6-egress"; +constexpr char kDefaultMobileIpv6Dns[] = + "2001:4860:4860::8888,2001:4860:4860::8844"; + +std::optional ParseSettingFromDefaults(std::string_view contents, + std::string_view key) { + std::optional raw_value; + std::string prefix = std::string(key) + "="; + for (std::string_view line : absl::StrSplit(contents, '\n')) { + line = absl::StripAsciiWhitespace(line); + if (line.empty() || line.front() == '#') { + continue; + } + if (!absl::ConsumePrefix(&line, prefix)) { + continue; + } + if (size_t hash = line.find('#'); hash != std::string_view::npos) { + line = absl::StripAsciiWhitespace(line.substr(0, hash)); + } + if (line.size() >= 2 && ((line.front() == '"' && line.back() == '"') || + (line.front() == '\'' && line.back() == '\''))) { + line = line.substr(1, line.size() - 2); + } + raw_value = std::string(line); + } + return raw_value; +} + +std::string ObtainMobileIpv6Dns() { + std::string contents; + std::ifstream in(kHostResourcesDefaultsPath); + if (in.is_open()) { + std::ostringstream ss; + ss << in.rdbuf(); + contents = ss.str(); + } + bool has_egress = std::ifstream(kIpv6EgressMarkerPath).good(); + std::string dns = ResolveMobileIpv6Dns(contents, has_egress); + if (dns.empty()) { + LOG(INFO) << "No host IPv6 egress (" << kIpv6EgressMarkerPath + << " absent); omitting ril_ipv6_dns while keeping mobile ULA " + "addressing."; + } + return dns; +} + +uint8_t Ipv6PrefixLength(const in6_addr& netmask) { + uint8_t ret = 0; + for (uint8_t byte : netmask.s6_addr) { + ret += number_of_ones(byte); + } + return ret; +} + +// Adds one to a big-endian 128-bit address. +void IncrementIpv6Address(in6_addr& addr) { + for (int i = 15; i >= 0; --i) { + if (++addr.s6_addr[i] != 0) { + return; + } + } +} + +bool Ipv6AddressInPrefix(const in6_addr& addr, const in6_addr& network, + const in6_addr& netmask) { + for (int i = 0; i < 16; ++i) { + if ((addr.s6_addr[i] & netmask.s6_addr[i]) != network.s6_addr[i]) { + return false; + } + } + return true; +} + +std::optional Ipv6AddressToString(const in6_addr& addr) { + char buf[INET6_ADDRSTRLEN]; + if (inet_ntop(AF_INET6, &addr, buf, sizeof(buf)) == nullptr) { + return std::nullopt; + } + return std::string(buf); +} + +// Uses the first global IPv6 address of the interface. Link-local addresses +// are skipped: they cannot be handed to the guest as a data call address. +std::optional ObtainMobileIpv6Config( + const std::string& interface) { + struct ifaddrs* ifa_list = nullptr; + if (getifaddrs(&ifa_list) != 0) { + return std::nullopt; + } + std::optional ret; + for (struct ifaddrs* ifa = ifa_list; ifa; ifa = ifa->ifa_next) { + if (strcmp(ifa->ifa_name, interface.c_str()) != 0 || + ifa->ifa_addr == nullptr || ifa->ifa_netmask == nullptr || + ifa->ifa_addr->sa_family != AF_INET6) { + continue; + } + const in6_addr& addr = + reinterpret_cast(ifa->ifa_addr)->sin6_addr; + if (IN6_IS_ADDR_LINKLOCAL(&addr) || IN6_IS_ADDR_LOOPBACK(&addr) || + IN6_IS_ADDR_MULTICAST(&addr)) { + continue; + } + const in6_addr& netmask = + reinterpret_cast(ifa->ifa_netmask)->sin6_addr; + ret = MobileIpv6ConfigFromHostAddress(addr, netmask); + if (ret) { + break; + } + } + freeifaddrs(ifa_list); + return ret; +} + } // namespace +std::string ParseDns6ServersFromDefaults(std::string_view contents) { + std::optional raw_value = + ParseSettingFromDefaults(contents, "dns6_servers"); + if (!raw_value || raw_value->empty()) { + return kDefaultMobileIpv6Dns; + } + std::vector valid_addrs; + for (std::string_view token : absl::StrSplit(*raw_value, ',')) { + token = absl::StripAsciiWhitespace(token); + if (token.empty()) { + continue; + } + std::string addr_str(token); + in6_addr dummy{}; + if (inet_pton(AF_INET6, addr_str.c_str(), &dummy) == 1) { + valid_addrs.push_back(std::move(addr_str)); + } else { + LOG(WARNING) << "Ignoring invalid IPv6 DNS server '" << addr_str + << "' in dns6_servers from " << kHostResourcesDefaultsPath; + } + } + if (valid_addrs.empty()) { + LOG(WARNING) << "Invalid dns6_servers='" << *raw_value << "' in " + << kHostResourcesDefaultsPath << "; falling back to " + << kDefaultMobileIpv6Dns; + return kDefaultMobileIpv6Dns; + } + return absl::StrJoin(valid_addrs, ","); +} + +std::string ResolveMobileIpv6Dns(std::string_view defaults_contents, + bool has_ipv6_egress) { + std::optional egress_override = + ParseSettingFromDefaults(defaults_contents, "ipv6_egress"); + if (egress_override == "0") { + return ""; + } + if (egress_override == "1") { + has_ipv6_egress = true; + } + std::optional explicit_dns6 = + ParseSettingFromDefaults(defaults_contents, "dns6_servers"); + if (explicit_dns6 && !explicit_dns6->empty()) { + return ParseDns6ServersFromDefaults(defaults_contents); + } + std::optional routed_prefix = + ParseSettingFromDefaults(defaults_contents, "ipv6_routed_prefix"); + if (routed_prefix && !routed_prefix->empty()) { + has_ipv6_egress = true; + } + if (!has_ipv6_egress) { + return ""; + } + return ParseDns6ServersFromDefaults(defaults_contents); +} + +std::optional MobileIpv6ConfigFromHostAddress( + const in6_addr& host_addr, const in6_addr& netmask) { + in6_addr network; + for (int i = 0; i < 16; ++i) { + network.s6_addr[i] = host_addr.s6_addr[i] & netmask.s6_addr[i]; + } + in6_addr guest_addr = network; + IncrementIpv6Address(guest_addr); + if (memcmp(&guest_addr, &host_addr, sizeof(in6_addr)) == 0) { + IncrementIpv6Address(guest_addr); + } + if (!Ipv6AddressInPrefix(guest_addr, network, netmask)) { + return std::nullopt; + } + std::optional ipaddr = Ipv6AddressToString(guest_addr); + std::optional gateway = Ipv6AddressToString(host_addr); + if (!ipaddr || !gateway) { + return std::nullopt; + } + return MobileIpv6Config{ + .ipaddr = *ipaddr, + .gateway = *gateway, + .prefixlen = Ipv6PrefixLength(netmask), + }; +} + Result ConfigureNetworkSettings( const std::string& ril_dns_arg, const CuttlefishConfig& config, const CuttlefishConfig::InstanceSpecific& const_instance, @@ -184,6 +391,30 @@ Result ConfigureNetworkSettings( instance.set_ril_ipaddr(netconfig.ril_ipaddr); instance.set_ril_prefixlen(netconfig.ril_prefixlen); + // IPv6 is optional and independent of IPv4. The host init script assigns + // fd00:cf:21:::1/64 to cvd-mtap-; without a global IPv6 address the + // ril_ipv6_* values stay empty and the modem simulator is IPv4-only. + std::optional ipv6 = + ObtainMobileIpv6Config(const_instance.mobile_bridge_name()); + if (!ipv6) { + ipv6 = ObtainMobileIpv6Config(const_instance.mobile_tap_name()); + } + if (ipv6) { + std::string ipv6_dns = ObtainMobileIpv6Dns(); + VLOG(0) << "Mobile IPv6 config: ipaddr = " << ipv6->ipaddr + << ", gateway = " << ipv6->gateway << ", dns = " << ipv6_dns + << ", prefix length = " << static_cast(ipv6->prefixlen); + instance.set_ril_ipv6_ipaddr(ipv6->ipaddr); + instance.set_ril_ipv6_gateway(ipv6->gateway); + if (!ipv6_dns.empty()) { + instance.set_ril_ipv6_dns(ipv6_dns); + } + instance.set_ril_ipv6_prefixlen(ipv6->prefixlen); + } else { + VLOG(0) << "No global IPv6 address on the mobile interface; the mobile " + "network is IPv4-only."; + } + return {}; } diff --git a/base/cvd/cuttlefish/host/commands/assemble_cvd/network_flags.h b/base/cvd/cuttlefish/host/commands/assemble_cvd/network_flags.h index 50234a297b7..f5e0f90580a 100644 --- a/base/cvd/cuttlefish/host/commands/assemble_cvd/network_flags.h +++ b/base/cvd/cuttlefish/host/commands/assemble_cvd/network_flags.h @@ -15,11 +15,48 @@ */ #pragma once +#include +#include + +#include +#include +#include + #include "cuttlefish/host/libs/config/cuttlefish_config.h" #include "cuttlefish/result/result.h" namespace cuttlefish { +// IPv6 parameters the modem simulator hands to the guest RIL for the mobile +// network. +struct MobileIpv6Config { + std::string ipaddr; + std::string gateway; + uint8_t prefixlen = 0; +}; + +// Derives the guest's IPv6 parameters from the host's address on a routed +// mobile tap, the same way the IPv4 parameters are derived: the host address +// is the gateway and the guest gets the lowest other address in the prefix +// (prefix::2 when the host has prefix::1). Returns nullopt when the prefix has +// no room for a guest address. +std::optional MobileIpv6ConfigFromHostAddress( + const in6_addr& host_addr, const in6_addr& netmask); + +// Parses the dns6_servers setting from /etc/default/cuttlefish-host-resources +// content, validating each comma-separated IPv6 address with inet_pton. +// Falls back to "2001:4860:4860::8888,2001:4860:4860::8844" when unset or +// invalid. +std::string ParseDns6ServersFromDefaults(std::string_view contents); + +// Resolves the IPv6 DNS server list for the mobile network given the contents +// of /etc/default/cuttlefish-host-resources and whether the host has IPv6 +// egress (/run/cuttlefish/ipv6-egress). When the host has no IPv6 egress and +// dns6_servers is not explicitly configured in defaults, returns an empty +// string so RIL does not advertise unreachable IPv6 DNS servers. +std::string ResolveMobileIpv6Dns(std::string_view defaults_contents, + bool has_ipv6_egress); + Result ConfigureNetworkSettings( const std::string& ril_dns_arg, const CuttlefishConfig& config, const CuttlefishConfig::InstanceSpecific& const_instance, diff --git a/base/cvd/cuttlefish/host/commands/assemble_cvd/network_flags_test.cc b/base/cvd/cuttlefish/host/commands/assemble_cvd/network_flags_test.cc new file mode 100644 index 00000000000..ca7688659c9 --- /dev/null +++ b/base/cvd/cuttlefish/host/commands/assemble_cvd/network_flags_test.cc @@ -0,0 +1,213 @@ +/* + * 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. + */ + +#include "cuttlefish/host/commands/assemble_cvd/network_flags.h" + +#include +#include +#include +#include +#include + +#include +#include + +namespace cuttlefish { +namespace { + +in6_addr Addr(const std::string& str) { + in6_addr addr{}; + EXPECT_EQ(inet_pton(AF_INET6, str.c_str(), &addr), 1) << str; + return addr; +} + +TEST(MobileIpv6ConfigFromHostAddress, HostIsFirstAddress) { + std::optional config = MobileIpv6ConfigFromHostAddress( + Addr("fd00:cf:21:1::1"), Addr("ffff:ffff:ffff:ffff::")); + ASSERT_TRUE(config.has_value()); + EXPECT_EQ(config->ipaddr, "fd00:cf:21:1::2"); + EXPECT_EQ(config->gateway, "fd00:cf:21:1::1"); + EXPECT_EQ(config->prefixlen, 64); +} + +TEST(MobileIpv6ConfigFromHostAddress, HexInstanceNumber) { + std::optional config = MobileIpv6ConfigFromHostAddress( + Addr("fd00:cf:21:1a::1"), Addr("ffff:ffff:ffff:ffff::")); + ASSERT_TRUE(config.has_value()); + EXPECT_EQ(config->ipaddr, "fd00:cf:21:1a::2"); + EXPECT_EQ(config->gateway, "fd00:cf:21:1a::1"); +} + +TEST(MobileIpv6ConfigFromHostAddress, HostIsNotFirstAddress) { + std::optional config = MobileIpv6ConfigFromHostAddress( + Addr("fd00:cf:21:2::5"), Addr("ffff:ffff:ffff:ffff::")); + ASSERT_TRUE(config.has_value()); + EXPECT_EQ(config->ipaddr, "fd00:cf:21:2::1"); + EXPECT_EQ(config->gateway, "fd00:cf:21:2::5"); +} + +TEST(MobileIpv6ConfigFromHostAddress, NoRoomInPrefix) { + // A /128 leaves no address for the guest. + EXPECT_FALSE(MobileIpv6ConfigFromHostAddress( + Addr("fd00:cf:21:1::1"), + Addr("ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff")) + .has_value()); + // In a /127 whose second address is the host, the only other address is + // the network address, which is not used. + EXPECT_FALSE(MobileIpv6ConfigFromHostAddress( + Addr("fd00:cf:21:1::1"), + Addr("ffff:ffff:ffff:ffff:ffff:ffff:ffff:fffe")) + .has_value()); +} + +TEST(MobileIpv6ConfigFromHostAddress, PrefixLength48) { + std::optional config = MobileIpv6ConfigFromHostAddress( + Addr("fd00:cf:21::1"), Addr("ffff:ffff:ffff::")); + ASSERT_TRUE(config.has_value()); + EXPECT_EQ(config->ipaddr, "fd00:cf:21::2"); + EXPECT_EQ(config->gateway, "fd00:cf:21::1"); + EXPECT_EQ(config->prefixlen, 48); +} + +TEST(MobileIpv6ConfigFromHostAddress, HostIsNetworkAddress) { + // The host owns the all-zeros address; the guest gets the next one. + std::optional config = MobileIpv6ConfigFromHostAddress( + Addr("fd00:cf:21:3::"), Addr("ffff:ffff:ffff:ffff::")); + ASSERT_TRUE(config.has_value()); + EXPECT_EQ(config->ipaddr, "fd00:cf:21:3::1"); + EXPECT_EQ(config->gateway, "fd00:cf:21:3::"); + EXPECT_EQ(config->prefixlen, 64); +} + +TEST(MobileIpv6ConfigFromHostAddress, SmallPrefix126) { + std::optional config = MobileIpv6ConfigFromHostAddress( + Addr("fd00:cf:21:4::1"), Addr("ffff:ffff:ffff:ffff:ffff:ffff:ffff:fffc")); + ASSERT_TRUE(config.has_value()); + EXPECT_EQ(config->ipaddr, "fd00:cf:21:4::2"); + EXPECT_EQ(config->prefixlen, 126); +} + +TEST(MobileIpv6ConfigFromHostAddress, CarryAcrossBytes) { + // Host address bits outside the prefix are ignored when picking the guest + // address, and the increment carries across byte boundaries. + std::optional config = MobileIpv6ConfigFromHostAddress( + Addr("fd00:cf:21:5::1:0"), Addr("ffff:ffff:ffff:ffff::")); + ASSERT_TRUE(config.has_value()); + EXPECT_EQ(config->ipaddr, "fd00:cf:21:5::1"); + EXPECT_EQ(config->gateway, "fd00:cf:21:5::1:0"); +} + +TEST(MobileIpv6ConfigFromHostAddress, DefaultPlanAllInstances) { + // cuttlefish-host-resources assigns fd00:cf:21:::1/64 to + // cvd-mtap- for i in [1, 128]. + for (int i = 1; i <= 128; ++i) { + char host[64]; + char guest[64]; + snprintf(host, sizeof(host), "fd00:cf:21:%x::1", i); + snprintf(guest, sizeof(guest), "fd00:cf:21:%x::2", i); + std::optional config = MobileIpv6ConfigFromHostAddress( + Addr(host), Addr("ffff:ffff:ffff:ffff::")); + ASSERT_TRUE(config.has_value()) << host; + EXPECT_EQ(config->ipaddr, guest); + EXPECT_EQ(config->gateway, host); + EXPECT_EQ(config->prefixlen, 64); + } +} + +TEST(MobileIpv6ConfigFromHostAddress, RoutedModeGlobalPrefix) { + // Routed mode (ipv6_routed_prefix=2001:db8:cf00::/48): cuttlefish-host- + // resources assigns P:21::1/64 to cvd-mtap-. The guest + // address is derived the same way as in private mode. + for (int i = 1; i <= 128; ++i) { + char host[64]; + char guest[64]; + snprintf(host, sizeof(host), "2001:db8:cf00:21%02x::1", i); + snprintf(guest, sizeof(guest), "2001:db8:cf00:21%02x::2", i); + std::optional config = MobileIpv6ConfigFromHostAddress( + Addr(host), Addr("ffff:ffff:ffff:ffff::")); + ASSERT_TRUE(config.has_value()) << host; + EXPECT_EQ(config->ipaddr, guest); + EXPECT_EQ(config->gateway, host); + EXPECT_EQ(config->prefixlen, 64); + } +} + +TEST(ParseDns6ServersFromDefaults, DefaultWhenUnsetOrCommented) { + EXPECT_EQ(ParseDns6ServersFromDefaults(""), + "2001:4860:4860::8888,2001:4860:4860::8844"); + EXPECT_EQ(ParseDns6ServersFromDefaults( + "# defaults for cuttlefish-host-resources\n" + "#dns6_servers=2001:4860:4860::8888,2001:4860:4860::8844\n"), + "2001:4860:4860::8888,2001:4860:4860::8844"); +} + +TEST(ParseDns6ServersFromDefaults, SingleCustomServer) { + EXPECT_EQ(ParseDns6ServersFromDefaults("dns6_servers=fd00:cf:2e::53\n"), + "fd00:cf:2e::53"); +} + +TEST(ParseDns6ServersFromDefaults, MultipleServersQuotesAndInlineComments) { + EXPECT_EQ( + ParseDns6ServersFromDefaults( + "dns_servers=8.8.8.8\n" + "dns6_servers=\"2001:db8:eeee::53, 2001:db8:eeee::54\" # lab\n"), + "2001:db8:eeee::53,2001:db8:eeee::54"); + EXPECT_EQ(ParseDns6ServersFromDefaults("dns6_servers=fd00:cf:2e::1\n" + "dns6_servers='fd00:cf:2e::2'\n"), + "fd00:cf:2e::2"); +} + +TEST(ParseDns6ServersFromDefaults, InvalidFallsBackToDefault) { + EXPECT_EQ(ParseDns6ServersFromDefaults("dns6_servers=not-an-ipv6,8.8.8.8\n"), + "2001:4860:4860::8888,2001:4860:4860::8844"); + EXPECT_EQ(ParseDns6ServersFromDefaults("dns6_servers=\n"), + "2001:4860:4860::8888,2001:4860:4860::8844"); +} + +TEST(ResolveMobileIpv6Dns, OmitsDnsWhenNoHostEgressAndUnsetInDefaults) { + EXPECT_EQ(ResolveMobileIpv6Dns("", /*has_ipv6_egress=*/false), ""); + EXPECT_EQ(ResolveMobileIpv6Dns( + "# defaults for cuttlefish-host-resources\n" + "#dns6_servers=2001:4860:4860::8888,2001:4860:4860::8844\n", + /*has_ipv6_egress=*/false), + ""); +} + +TEST(ResolveMobileIpv6Dns, EmitsDefaultDnsWhenHostHasEgress) { + EXPECT_EQ(ResolveMobileIpv6Dns("", /*has_ipv6_egress=*/true), + "2001:4860:4860::8888,2001:4860:4860::8844"); + EXPECT_EQ(ResolveMobileIpv6Dns("dns6_servers=2001:db8:eeee::53\n", + /*has_ipv6_egress=*/true), + "2001:db8:eeee::53"); +} + +TEST(ResolveMobileIpv6Dns, ExplicitOverrideOrRoutedPrefixWithoutEgressMarker) { + EXPECT_EQ(ResolveMobileIpv6Dns("dns6_servers=fd00:cf:2e::53\n", + /*has_ipv6_egress=*/false), + "fd00:cf:2e::53"); + EXPECT_EQ(ResolveMobileIpv6Dns("ipv6_egress=1\n", + /*has_ipv6_egress=*/false), + "2001:4860:4860::8888,2001:4860:4860::8844"); + EXPECT_EQ(ResolveMobileIpv6Dns("ipv6_routed_prefix=2001:db8:cf00::/48\n", + /*has_ipv6_egress=*/false), + "2001:4860:4860::8888,2001:4860:4860::8844"); + EXPECT_EQ(ResolveMobileIpv6Dns("ipv6_egress=0\n", + /*has_ipv6_egress=*/true), + ""); +} + +} // namespace +} // namespace cuttlefish diff --git a/base/cvd/cuttlefish/host/commands/modem_simulator/BUILD.bazel b/base/cvd/cuttlefish/host/commands/modem_simulator/BUILD.bazel index 4329ff098e4..103419e6e68 100644 --- a/base/cvd/cuttlefish/host/commands/modem_simulator/BUILD.bazel +++ b/base/cvd/cuttlefish/host/commands/modem_simulator/BUILD.bazel @@ -64,6 +64,16 @@ cf_cc_library( ], ) +cf_cc_test( + name = "data_service_test", + srcs = ["unittest/data_service_test.cpp"], + depend_on_what_you_use_enabled = False, + include_cleaner_enabled = False, + deps = [ + "//cuttlefish/host/commands/modem_simulator:data_service", + ], +) + cf_cc_library( name = "device_config", srcs = ["cf_device_config.cpp"], @@ -230,6 +240,27 @@ cf_cc_test( deps = ["//cuttlefish/host/commands/modem_simulator:pdu_parser"], ) +cf_cc_test( + name = "service_ipv6_test", + srcs = [ + "unittest/iccfile.h", + "unittest/service_ipv6_test.cpp", + ], + clang_format_enabled = False, + depend_on_what_you_use_enabled = False, + include_cleaner_enabled = False, + deps = [ + "//cuttlefish/common/libs/fs", + "//cuttlefish/host/commands/modem_simulator:channel_monitor", + "//cuttlefish/host/commands/modem_simulator:client", + "//cuttlefish/host/commands/modem_simulator:device_config", + "//cuttlefish/host/commands/modem_simulator:modem_simulator_class", + "//cuttlefish/host/commands/modem_simulator:modem_simulator_lib", + "//cuttlefish/host/commands/modem_simulator:nvram_config", + "//cuttlefish/host/libs/config:cuttlefish_config", + ], +) + cf_cc_test( name = "service_test", srcs = [ diff --git a/base/cvd/cuttlefish/host/commands/modem_simulator/cf_device_config.cpp b/base/cvd/cuttlefish/host/commands/modem_simulator/cf_device_config.cpp index e92196d8952..324694e2e55 100644 --- a/base/cvd/cuttlefish/host/commands/modem_simulator/cf_device_config.cpp +++ b/base/cvd/cuttlefish/host/commands/modem_simulator/cf_device_config.cpp @@ -61,6 +61,28 @@ std::string DeviceConfig::ril_dns() { return instance.ril_dns(); } +std::string DeviceConfig::ril_ipv6_address_and_prefix() { + auto config = cuttlefish::CuttlefishConfig::Get(); + auto instance = config->ForDefaultInstance(); + if (instance.ril_ipv6_ipaddr().empty()) { + return ""; + } + return instance.ril_ipv6_ipaddr() + "/" + + std::to_string(instance.ril_ipv6_prefixlen()); +} + +std::string DeviceConfig::ril_ipv6_gateway() { + auto config = cuttlefish::CuttlefishConfig::Get(); + auto instance = config->ForDefaultInstance(); + return instance.ril_ipv6_gateway(); +} + +std::string DeviceConfig::ril_ipv6_dns() { + auto config = cuttlefish::CuttlefishConfig::Get(); + auto instance = config->ForDefaultInstance(); + return instance.ril_ipv6_dns(); +} + std::ifstream DeviceConfig::open_ifstream_crossplat(const char* filename) { return std::ifstream(filename); } diff --git a/base/cvd/cuttlefish/host/commands/modem_simulator/data_service.cpp b/base/cvd/cuttlefish/host/commands/modem_simulator/data_service.cpp index 117aba18dcc..0e77321a281 100644 --- a/base/cvd/cuttlefish/host/commands/modem_simulator/data_service.cpp +++ b/base/cvd/cuttlefish/host/commands/modem_simulator/data_service.cpp @@ -153,14 +153,21 @@ void DataService::HandlePDPContext(const Client& client, std::string ip_type(cmd.GetNextStr(',')); std::string apn(cmd.GetNextStr(',')); - auto address = cuttlefish::modem::DeviceConfig::ril_address_and_prefix(); - auto dnses = cuttlefish::modem::DeviceConfig::ril_dns(); - auto gateways = cuttlefish::modem::DeviceConfig::ril_gateway(); + IpParams ipv4 = { + .address_and_prefix = + cuttlefish::modem::DeviceConfig::ril_address_and_prefix(), + .gateways = cuttlefish::modem::DeviceConfig::ril_gateway(), + .dnses = cuttlefish::modem::DeviceConfig::ril_dns(), + }; + IpParams ipv6 = { + .address_and_prefix = + cuttlefish::modem::DeviceConfig::ril_ipv6_address_and_prefix(), + .gateways = cuttlefish::modem::DeviceConfig::ril_ipv6_gateway(), + .dnses = cuttlefish::modem::DeviceConfig::ril_ipv6_dns(), + }; - PDPContext pdp_context = {cid, PDPContext::ACTIVE, - ip_type, // IPV4 or IPV6 or IPV4V6 - apn, address, - dnses, gateways}; + // IPV4 or IPV6 or IPV4V6 + PDPContext pdp_context = MakePDPContext(cid, ip_type, apn, ipv4, ipv6); // check cid auto iter = pdp_context_.begin(); @@ -184,17 +191,49 @@ void DataService::HandlePDPContext(const Client& client, void DataService::HandleQueryPDPContextList(const Client& client) { std::vector responses; - std::stringstream ss; for (auto it = pdp_context_.begin(); it != pdp_context_.end(); ++it) { - std::stringstream ss; - ss << "+CGDCONT: " << it->cid << "," << it->conn_types << "," << it->apn - << "," << it->addresses << ",0,0"; - responses.push_back(ss.str()); + responses.push_back(PDPContextLine(*it)); } responses.push_back("OK"); client.SendCommandResponse(responses); } +// Always keep IPv4 as the first +CGCONTRDP line and append IPv6 as the second +// line whenever IPv6 is configured, regardless of the requested pdp_type ("IP", +// "IPV6", or "IPV4V6"): +// (1) On unpatched stock guests whose Radio HAL reads only the first +// +CGCONTRDP line, sim_type=2 ("IPV6") retains IPv4 instead of losing IPv4. +// (2) On guests with the dual-stack Goldfish/Cuttlefish Radio HAL, both +// sim_type=1 ("IP" in apns-conf.xml/CarrierSettings) and sim_type=2 +// ("IPV6") receive dual-stack IPv4+IPv6 (IPV4V6). +DataService::PDPContext DataService::MakePDPContext(int cid, + const std::string& pdp_type, + const std::string& apn, + const IpParams& ipv4, + const IpParams& ipv6) { + PDPContext context = { + .cid = cid, + .state = PDPContext::ACTIVE, + .conn_types = pdp_type, + .apn = apn, + .addresses = ipv4.address_and_prefix, + .dnses = ipv4.dnses, + .gateways = ipv4.gateways, + .ipv6 = {}, + }; + if (!ipv6.address_and_prefix.empty()) { + context.ipv6 = ipv6; + } + return context; +} + +std::string DataService::PDPContextLine(const PDPContext& context) { + std::stringstream ss; + ss << "+CGDCONT: " << context.cid << "," << context.conn_types << "," + << context.apn << "," << context.addresses << ",0,0"; + return ss.str(); +} + /** * AT+CGDATA * The execution command causes the MT to perform whatever actions are @@ -283,16 +322,43 @@ void DataService::HandleReadDynamicParam(const Client& client, if (iter == pdp_context_.end()) { responses.push_back(kCmeErrorInvalidIndex); // number } else { - std::stringstream ss; - ss << "+CGCONTRDP: " << iter->cid << ",5," << iter->apn << "," - << iter->addresses << "," << iter->gateways << "," << iter->dnses; - responses.push_back(ss.str()); + for (auto& line : DynamicParamLines(*iter)) { + responses.push_back(std::move(line)); + } responses.push_back("OK"); } client.SendCommandResponse(responses); } +static std::string DynamicParamLine(int cid, const std::string& apn, + const std::string& addresses, + const std::string& gateways, + const std::string& dnses) { + std::stringstream ss; + ss << "+CGCONTRDP: " << cid << ",5," << apn << "," << addresses << "," + << gateways << "," << dnses; + return ss.str(); +} + +// 3GPP TS 27.007 ยง10.1.23: for a dual-stack (IPV4V6) context the MT returns +// two lines per , the IPv4 parameters first, then the IPv6 parameters. +// Addresses use the "address/prefix length" notation that this simulator +// already uses for IPv4 (for IPv6: colon notation with a CIDR prefix, as +// selected by AT+CGPIAF=1,1 in TS 27.007). +std::vector DataService::DynamicParamLines( + const PDPContext& context) { + std::vector lines; + lines.push_back(DynamicParamLine(context.cid, context.apn, context.addresses, + context.gateways, context.dnses)); + if (!context.ipv6.address_and_prefix.empty()) { + lines.push_back(DynamicParamLine( + context.cid, context.apn, context.ipv6.address_and_prefix, + context.ipv6.gateways, context.ipv6.dnses)); + } + return lines; +} + void DataService::sendOnePhysChanCfgUpdate(int status, int bandwidth, int rat, int freq, int id) { std::stringstream ss; diff --git a/base/cvd/cuttlefish/host/commands/modem_simulator/data_service.h b/base/cvd/cuttlefish/host/commands/modem_simulator/data_service.h index 923e001b351..d5455cefc96 100644 --- a/base/cvd/cuttlefish/host/commands/modem_simulator/data_service.h +++ b/base/cvd/cuttlefish/host/commands/modem_simulator/data_service.h @@ -39,13 +39,13 @@ class DataService : public ModemService, void onUpdatePhysicalChannelconfigs(int modem_tech, int freq, int cellBandwidthDownlink); - private: - std::vector InitializeCommandHandlers(); - void InitializeServiceState(); - void sendOnePhysChanCfgUpdate(int status, int bandwidth, int rat, int freq, - int id); - void updatePhysicalChannelconfigs(int modem_tech, int freq, - int cellBandwidthDownlink, int count); + // Address, gateway and DNS servers of one IP family, formatted as they + // appear in +CGCONTRDP. An empty address means the family is not available. + struct IpParams { + std::string address_and_prefix; + std::string gateways; + std::string dnses; + }; struct PDPContext { enum CidState { ACTIVE, NO_ACTIVE }; @@ -54,10 +54,34 @@ class DataService : public ModemService, CidState state; std::string conn_types; std::string apn; + // IPv4 parameters, or the IPv6 parameters for an IPV6 context. std::string addresses; std::string dnses; std::string gateways; + // IPv6 parameters of an IPV4V6 context. Empty for other PDP types and + // when the mobile network has no IPv6. + IpParams ipv6; }; + + // Builds the context set by AT+CGDCONT=,,. + // is kept as received (quoted). Without IPv6 parameters every PDP type gets + // the IPv4 parameters, as before IPv6 support. + static PDPContext MakePDPContext(int cid, const std::string& pdp_type, + const std::string& apn, const IpParams& ipv4, + const IpParams& ipv6); + // The +CGDCONT? line of a context. + static std::string PDPContextLine(const PDPContext& context); + // The +CGCONTRDP lines of an active context. + static std::vector DynamicParamLines(const PDPContext& context); + + private: + std::vector InitializeCommandHandlers(); + void InitializeServiceState(); + void sendOnePhysChanCfgUpdate(int status, int bandwidth, int rat, int freq, + int id); + void updatePhysicalChannelconfigs(int modem_tech, int freq, + int cellBandwidthDownlink, int count); + std::vector pdp_context_; }; diff --git a/base/cvd/cuttlefish/host/commands/modem_simulator/device_config.h b/base/cvd/cuttlefish/host/commands/modem_simulator/device_config.h index f4426b57193..d39db5a46ac 100644 --- a/base/cvd/cuttlefish/host/commands/modem_simulator/device_config.h +++ b/base/cvd/cuttlefish/host/commands/modem_simulator/device_config.h @@ -33,6 +33,11 @@ class DeviceConfig { static std::string ril_address_and_prefix(); static std::string ril_gateway(); static std::string ril_dns(); + // Mobile network IPv6 parameters. All empty when the instance has no IPv6 + // on the mobile network. + static std::string ril_ipv6_address_and_prefix(); + static std::string ril_ipv6_gateway(); + static std::string ril_ipv6_dns(); static std::ifstream open_ifstream_crossplat(const char* filename); static std::ofstream open_ofstream_crossplat( const char* filename, std::ios_base::openmode mode = std::ios_base::out); diff --git a/base/cvd/cuttlefish/host/commands/modem_simulator/unittest/data_service_test.cpp b/base/cvd/cuttlefish/host/commands/modem_simulator/unittest/data_service_test.cpp new file mode 100644 index 00000000000..d2b52eb7b35 --- /dev/null +++ b/base/cvd/cuttlefish/host/commands/modem_simulator/unittest/data_service_test.cpp @@ -0,0 +1,114 @@ +// +// 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. + +#include "cuttlefish/host/commands/modem_simulator/data_service.h" + +#include +#include + +#include +#include + +namespace cuttlefish { +namespace { + +using ::testing::ElementsAre; + +const DataService::IpParams kIpv4 = { + .address_and_prefix = "192.168.97.2/30", + .gateways = "192.168.97.1", + .dnses = "8.8.8.8", +}; +const DataService::IpParams kIpv6 = { + .address_and_prefix = "fd00:cf:21:1::2/64", + .gateways = "fd00:cf:21:1::1", + .dnses = "2001:4860:4860::8888", +}; +const DataService::IpParams kNoIpv6 = {}; + +// Output of +CGCONTRDP and +CGDCONT? before IPv6 support, for any PDP type. +constexpr char kIpv4DynamicParamLine[] = + "+CGCONTRDP: 1,5,\"ctlte\",192.168.97.2/30,192.168.97.1,8.8.8.8"; + +std::vector DynamicParams(const std::string& pdp_type, + const DataService::IpParams& ipv6) { + return DataService::DynamicParamLines( + DataService::MakePDPContext(1, pdp_type, "\"ctlte\"", kIpv4, ipv6)); +} + +std::string PDPContextLine(const std::string& pdp_type, + const DataService::IpParams& ipv6) { + return DataService::PDPContextLine( + DataService::MakePDPContext(1, pdp_type, "\"ctlte\"", kIpv4, ipv6)); +} + +TEST(DataServiceDynamicParams, IpWithoutIpv6) { + EXPECT_THAT(DynamicParams("\"IP\"", kNoIpv6), + ElementsAre(kIpv4DynamicParamLine)); + EXPECT_EQ(PDPContextLine("\"IP\"", kNoIpv6), + "+CGDCONT: 1,\"IP\",\"ctlte\",192.168.97.2/30,0,0"); +} + +TEST(DataServiceDynamicParams, IpWithIpv6) { + EXPECT_THAT(DynamicParams("\"IP\"", kIpv6), + ElementsAre(kIpv4DynamicParamLine, + "+CGCONTRDP: 1,5,\"ctlte\",fd00:cf:21:1::2/64," + "fd00:cf:21:1::1,2001:4860:4860::8888")); + EXPECT_EQ(PDPContextLine("\"IP\"", kIpv6), + "+CGDCONT: 1,\"IP\",\"ctlte\",192.168.97.2/30,0,0"); +} + +TEST(DataServiceDynamicParams, Ipv4v6WithoutIpv6) { + EXPECT_THAT(DynamicParams("\"IPV4V6\"", kNoIpv6), + ElementsAre(kIpv4DynamicParamLine)); + EXPECT_EQ(PDPContextLine("\"IPV4V6\"", kNoIpv6), + "+CGDCONT: 1,\"IPV4V6\",\"ctlte\",192.168.97.2/30,0,0"); +} + +TEST(DataServiceDynamicParams, Ipv4v6WithIpv6) { + EXPECT_THAT(DynamicParams("\"IPV4V6\"", kIpv6), + ElementsAre(kIpv4DynamicParamLine, + "+CGCONTRDP: 1,5,\"ctlte\",fd00:cf:21:1::2/64," + "fd00:cf:21:1::1,2001:4860:4860::8888")); + // +CGDCONT? keeps reporting the IPv4 address, as before. + EXPECT_EQ(PDPContextLine("\"IPV4V6\"", kIpv6), + "+CGDCONT: 1,\"IPV4V6\",\"ctlte\",192.168.97.2/30,0,0"); +} + +TEST(DataServiceDynamicParams, Ipv6WithoutIpv6) { + EXPECT_THAT(DynamicParams("\"IPV6\"", kNoIpv6), + ElementsAre(kIpv4DynamicParamLine)); + EXPECT_EQ(PDPContextLine("\"IPV6\"", kNoIpv6), + "+CGDCONT: 1,\"IPV6\",\"ctlte\",192.168.97.2/30,0,0"); +} + +TEST(DataServiceDynamicParams, Ipv6WithIpv6) { + EXPECT_THAT(DynamicParams("\"IPV6\"", kIpv6), + ElementsAre(kIpv4DynamicParamLine, + "+CGCONTRDP: 1,5,\"ctlte\",fd00:cf:21:1::2/64," + "fd00:cf:21:1::1,2001:4860:4860::8888")); + EXPECT_EQ(PDPContextLine("\"IPV6\"", kIpv6), + "+CGDCONT: 1,\"IPV6\",\"ctlte\",192.168.97.2/30,0,0"); +} + +TEST(DataServiceDynamicParams, UnquotedPdpType) { + EXPECT_THAT(DynamicParams("IPV4V6", kIpv6), + ElementsAre(kIpv4DynamicParamLine, + "+CGCONTRDP: 1,5,\"ctlte\",fd00:cf:21:1::2/64," + "fd00:cf:21:1::1,2001:4860:4860::8888")); +} + +} // namespace +} // namespace cuttlefish diff --git a/base/cvd/cuttlefish/host/commands/modem_simulator/unittest/service_ipv6_test.cpp b/base/cvd/cuttlefish/host/commands/modem_simulator/unittest/service_ipv6_test.cpp new file mode 100644 index 00000000000..a7eb25eb67b --- /dev/null +++ b/base/cvd/cuttlefish/host/commands/modem_simulator/unittest/service_ipv6_test.cpp @@ -0,0 +1,194 @@ +// +// 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. + +// End-to-end AT tests of the modem simulator with mobile IPv6 configured: +// CuttlefishConfig ril_ipv6_* -> DeviceConfig -> DataService -> +CGCONTRDP. +// service_test.cpp covers the same commands without IPv6. This is a separate +// binary because the config is loaded once per process. + +#include +#include + +#include +#include +#include +#include + +#include "iccfile.h" + +#include "cuttlefish/common/libs/fs/shared_fd.h" +#include "cuttlefish/host/commands/modem_simulator/channel_monitor.h" +#include "cuttlefish/host/commands/modem_simulator/device_config.h" +#include "cuttlefish/host/commands/modem_simulator/modem_simulator.h" +#include "cuttlefish/host/commands/modem_simulator/nvram_config.h" +#include "cuttlefish/host/libs/config/cuttlefish_config.h" + +namespace cuttlefish { +namespace { + +namespace fs = std::filesystem; + +const std::string kTestDir = + std::string(fs::temp_directory_path()) + "/cuttlefish_modem_ipv6_test"; + +class ModemIpv6ServiceTest : public ::testing::Test { + protected: + static void SetUpTestSuite() { + CuttlefishConfig config; + const std::string config_file = kTestDir + "/.cuttlefish_config.json"; + config.set_root_dir(kTestDir + "/cuttlefish"); + auto instance = config.ForInstance(GetInstance()); + instance.set_ril_ipaddr("192.168.97.2"); + instance.set_ril_gateway("192.168.97.1"); + instance.set_ril_prefixlen(30); + instance.set_ril_dns("8.8.8.8"); + instance.set_ril_ipv6_ipaddr("fd00:cf:21:1::2"); + instance.set_ril_ipv6_gateway("fd00:cf:21:1::1"); + instance.set_ril_ipv6_dns("2001:4860:4860::8888"); + instance.set_ril_ipv6_prefixlen(64); + for (const auto& inst : config.Instances()) { + fs::create_directories(inst.instance_dir()); + ASSERT_TRUE( + config.SaveToFile(inst.PerInstancePath("cuttlefish_config.json"))); + std::ofstream icc(inst.PerInstancePath("/iccprofile_for_sim0.xml")); + icc << std::string(myiccfile); + icc.close(); + fs::copy_file(inst.PerInstancePath("cuttlefish_config.json"), config_file, + fs::copy_options::overwrite_existing); + } + ::setenv("CUTTLEFISH_CONFIG_FILE", config_file.c_str(), 1); + + SharedFD ril_fd, modem_fd; + ASSERT_TRUE( + SharedFD::SocketPair(AF_LOCAL, SOCK_STREAM, 0, &ril_fd, &modem_fd)); + NvramConfig::InitNvramConfigService(1, 1); + ril_fd_ = new SharedFD(ril_fd); + modem_side_ = new Client(modem_fd); + modem_simulator_ = new ModemSimulator(0); + SharedFD server; + modem_simulator_->Initialize( + std::make_unique(*modem_simulator_, server)); + } + + static void TearDownTestSuite() { + delete ril_fd_; + delete modem_side_; + delete modem_simulator_; + fs::remove_all(kTestDir); + } + + // Sends command and returns the lines starting with prefix, followed by + // the final result line (OK or an error). + std::vector Send(const std::string& command, + const std::string& prefix) { + std::string mutable_command = command; + modem_simulator_->DispatchCommand(*modem_side_, mutable_command); + std::vector lines; + std::string pending; + while (true) { + std::vector buf(4096); + Result n = (*ril_fd_)->Read(buf.data(), buf.size() - 1); + if (n.value_or(0) == 0) { + ADD_FAILURE() << "modem closed while reading response to " << command; + return lines; + } + pending.append(buf.data(), *n); + size_t pos; + while ((pos = pending.find_first_of("\r\n")) != std::string::npos) { + std::string line = pending.substr(0, pos); + pending.erase(0, pos + 1); + if (line.empty()) { + continue; + } + if (line == "OK" || line.rfind("ERROR", 0) == 0 || + line.rfind("+CME ERROR", 0) == 0) { + lines.push_back(line); + return lines; + } + if (!prefix.empty() && line.rfind(prefix, 0) == 0) { + lines.push_back(line); + } + } + } + } + + // The RIL end of the socket pair; responses to modem_side_ arrive here. + static SharedFD* ril_fd_; + static Client* modem_side_; + static ModemSimulator* modem_simulator_; +}; + +SharedFD* ModemIpv6ServiceTest::ril_fd_ = nullptr; +Client* ModemIpv6ServiceTest::modem_side_ = nullptr; +ModemSimulator* ModemIpv6ServiceTest::modem_simulator_ = nullptr; + +TEST_F(ModemIpv6ServiceTest, DeviceConfigReadsIpv6FromCuttlefishConfig) { + EXPECT_EQ(modem::DeviceConfig::ril_ipv6_address_and_prefix(), + "fd00:cf:21:1::2/64"); + EXPECT_EQ(modem::DeviceConfig::ril_ipv6_gateway(), "fd00:cf:21:1::1"); + EXPECT_EQ(modem::DeviceConfig::ril_ipv6_dns(), "2001:4860:4860::8888"); + EXPECT_EQ(modem::DeviceConfig::ril_address_and_prefix(), "192.168.97.2/30"); +} + +// Every PDP type gets the IPv4 line first and the IPv6 line second, and +// +CGDCONT? keeps reporting the IPv4 address. +TEST_F(ModemIpv6ServiceTest, DualStackDynamicParamsPerPdpType) { + int cid = 21; + for (const char* pdp_type : {"IP", "IPV6", "IPV4V6"}) { + const std::string c = std::to_string(cid); + EXPECT_EQ( + Send("AT+CGDCONT=" + c + ",\"" + pdp_type + "\",\"ctlte\",,0,0", ""), + std::vector{"OK"}) + << pdp_type; + EXPECT_EQ(Send("AT+CGCONTRDP=" + c, "+CGCONTRDP:"), + (std::vector{ + "+CGCONTRDP: " + c + + ",5,\"ctlte\",192.168.97.2/30,192.168.97.1,8.8.8.8", + "+CGCONTRDP: " + c + + ",5,\"ctlte\",fd00:cf:21:1::2/64,fd00:cf:21:1::1," + "2001:4860:4860::8888", + "OK"})) + << pdp_type; + std::vector list = Send("AT+CGDCONT?", "+CGDCONT: " + c + ","); + EXPECT_EQ(list, + (std::vector{"+CGDCONT: " + c + ",\"" + pdp_type + + "\",\"ctlte\"," + "192.168.97.2/30,0,0", + "OK"})) + << pdp_type; + ++cid; + } +} + +// Redefining a cid replaces its parameters (no stale second line). +TEST_F(ModemIpv6ServiceTest, RedefineCidKeepsTwoLines) { + ASSERT_EQ(Send("AT+CGDCONT=30,\"IP\",\"ctlte\",,0,0", ""), + std::vector{"OK"}); + ASSERT_EQ(Send("AT+CGDCONT=30,\"IPV4V6\",\"ims\",,0,0", ""), + std::vector{"OK"}); + std::vector lines = Send("AT+CGCONTRDP=30", "+CGCONTRDP:"); + ASSERT_EQ(lines.size(), 3u); + EXPECT_EQ(lines[0].rfind("+CGCONTRDP: 30,5,\"ims\",192.168.97.2/30", 0), 0u); + EXPECT_EQ(lines[1].rfind("+CGCONTRDP: 30,5,\"ims\",fd00:cf:21:1::2/64", 0), + 0u); +} + +TEST_F(ModemIpv6ServiceTest, UnknownCidIsError) { + EXPECT_EQ(Send("AT+CGCONTRDP=99", "+CGCONTRDP:"), + std::vector{"+CME ERROR: 21"}); +} + +} // namespace +} // namespace cuttlefish diff --git a/base/cvd/cuttlefish/host/commands/modem_simulator/unittest/service_test.cpp b/base/cvd/cuttlefish/host/commands/modem_simulator/unittest/service_test.cpp index 348269ef6a2..9c7f7281925 100644 --- a/base/cvd/cuttlefish/host/commands/modem_simulator/unittest/service_test.cpp +++ b/base/cvd/cuttlefish/host/commands/modem_simulator/unittest/service_test.cpp @@ -15,12 +15,13 @@ #include #include -#include "absl/log/log.h" -#include "absl/strings/str_replace.h" #include #include +#include "absl/log/log.h" +#include "absl/strings/str_replace.h" + #include "cuttlefish/common/libs/fs/shared_select.h" #include "cuttlefish/common/libs/utils/files.h" #include "cuttlefish/host/commands/assemble_cvd/flags_defaults.h" @@ -47,8 +48,9 @@ class ModemServiceTest : public ::testing::Test { for (int i = 0; i < 1; i++) { instance_nums.push_back(cuttlefish::GetInstance() + i); } - for (const auto &num : instance_nums) { - auto instance = tmp_config_obj.ForInstance(num); // Trigger creation in map + for (const auto& num : instance_nums) { + auto instance = + tmp_config_obj.ForInstance(num); // Trigger creation in map instance.set_ril_dns(CF_DEFAULTS_RIL_DNS); } @@ -61,7 +63,8 @@ class ModemServiceTest : public ::testing::Test { } std::string icfilename = instance.PerInstancePath("/iccprofile_for_sim0.xml"); - std::ofstream offile = modem::DeviceConfig::open_ofstream_crossplat(icfilename.c_str(), std::ofstream::out); + std::ofstream offile = modem::DeviceConfig::open_ofstream_crossplat( + icfilename.c_str(), std::ofstream::out); offile << std::string(myiccfile); offile.close(); fs::copy_file(instance.PerInstancePath("/cuttlefish_config.json"), @@ -136,16 +139,17 @@ class ModemServiceTest : public ::testing::Test { auto command = commands.substr(pos, r_pos - pos); if (!command.empty()) { // "\r\r" ? VLOG(0) << "AT< " << command; - if (IsFinalResponseSuccess(command) || IsFinalResponseError(command)) { + if (IsFinalResponseSuccess(command) || + IsFinalResponseError(command)) { response.push_back(command); return; } else if (IsIntermediateResponse(command)) { response.push_back(command); } else { - ; // Ignore unsolicited command + ; // Ignore unsolicited command } } - pos = r_pos + 1; // skip '\r' + pos = r_pos + 1; // skip '\r' } else if (pos < commands.length()) { // incomplete command incomplete_command = commands.substr(pos); VLOG(1) << "incomplete command: " << incomplete_command; @@ -207,21 +211,19 @@ class ModemServiceTest : public ::testing::Test { return (response[0].compare(0, expect.size(), expect) == 0); } - const std::vector kFinalResponseSuccess = {"OK", "CONNECT", "> "}; + const std::vector kFinalResponseSuccess = {"OK", "CONNECT", + "> "}; const std::vector kFinalResponseError = { - "ERROR", - "+CMS ERROR:", - "+CME ERROR:", - "NO CARRIER", /* sometimes! */ - "NO ANSWER", - "NO DIALTONE", + "ERROR", "+CMS ERROR:", "+CME ERROR:", "NO CARRIER", /* sometimes! */ + "NO ANSWER", "NO DIALTONE", }; static Client* ril_side_; static Client* modem_side_; static ModemSimulator* modem_simulator_; - // For distinguishing the response from command response or unsolicited command + // For distinguishing the response from command response or unsolicited + // command std::string command_prefix_; }; @@ -231,8 +233,7 @@ Client* ModemServiceTest::modem_side_ = nullptr; /* Sim Service Test */ TEST_F(ModemServiceTest, GetIccCardStatus) { - const char *expects[] = {"+CPIN: READY", - "OK"}; + const char* expects[] = {"+CPIN: READY", "OK"}; std::string command = "AT+CPIN?"; std::vector response; @@ -244,13 +245,18 @@ TEST_F(ModemServiceTest, GetIccCardStatus) { } TEST_F(ModemServiceTest, ChangeOrEnterPIN) { - std::vector commands = {"AT+CPIN=1234,0000", - "AT+CPIN=1111,2222",}; - std::vector expects = {"OK", - "+CME ERROR: 16",}; + std::vector commands = { + "AT+CPIN=1234,0000", + "AT+CPIN=1111,2222", + }; + std::vector expects = { + "OK", + "+CME ERROR: 16", + }; std::vector response; auto expects_iter = expects.begin(); - for (auto iter = commands.begin(); iter != commands.end(); ++iter, ++expects_iter) { + for (auto iter = commands.begin(); iter != commands.end(); + ++iter, ++expects_iter) { SendCommand(*iter); ReadCommandResponse(response); ASSERT_STREQ(response[0].c_str(), (*expects_iter).c_str()); @@ -262,13 +268,14 @@ TEST_F(ModemServiceTest, SIM_IO) { std::vector commands = {"AT+CRSM=192,12258,0,0,15", "AT+CRSM=192,28436,0,0,15", "AT+CRSM=220,28618,1,4,5,0000000000"}; - std::vector expects = {"+CRSM: 144,0,62178202412183022FE28A01058B032F06038002000A880110", - "+CRSM: 106,130", - "+CRSM: 144,0"}; + std::vector expects = { + "+CRSM: 144,0,62178202412183022FE28A01058B032F06038002000A880110", + "+CRSM: 106,130", "+CRSM: 144,0"}; std::vector response; auto expects_iter = expects.begin(); - for (auto iter = commands.begin(); iter != commands.end(); ++iter, ++expects_iter) { + for (auto iter = commands.begin(); iter != commands.end(); + ++iter, ++expects_iter) { SendCommand(*iter); ReadCommandResponse(response); ASSERT_EQ(response.size(), 2); @@ -283,8 +290,8 @@ TEST_F(ModemServiceTest, GetIMSI) { SendCommand(command); ReadCommandResponse(response); ASSERT_EQ(response.size(), 2); - const char *expect = "460110031689666"; - ASSERT_STREQ(response[0].c_str(),expect); + const char* expect = "460110031689666"; + ASSERT_STREQ(response[0].c_str(), expect); } TEST_F(ModemServiceTest, GetIccId) { @@ -293,26 +300,23 @@ TEST_F(ModemServiceTest, GetIccId) { SendCommand(command); ReadCommandResponse(response); ASSERT_EQ(response.size(), 2); - const char *expect = "89860318640220133897"; - ASSERT_STREQ(response[0].c_str(),expect); + const char* expect = "89860318640220133897"; + ASSERT_STREQ(response[0].c_str(), expect); } TEST_F(ModemServiceTest, FacilityLock) { - std::vector commands = - { "AT+CLCK=\"FD\",2,"",7", - "AT+CLCK=\"SC\",2,"",7", - "AT+CLCK=\"SC\",1,\"1234\",7", - "AT+CLCK=\"SC\",1,\"023000\",7" - }; - std::vector expects = - { "+CLCK: 0", - "+CLCK: 0", - "+CME ERROR: 16", - "+CME ERROR: 16" - }; + std::vector commands = { + "AT+CLCK=\"FD\",2," + ",7", + "AT+CLCK=\"SC\",2," + ",7", + "AT+CLCK=\"SC\",1,\"1234\",7", "AT+CLCK=\"SC\",1,\"023000\",7"}; + std::vector expects = {"+CLCK: 0", "+CLCK: 0", "+CME ERROR: 16", + "+CME ERROR: 16"}; std::vector response; auto expects_iter = expects.begin(); - for (auto iter = commands.begin(); iter != commands.end(); ++iter, ++expects_iter) { + for (auto iter = commands.begin(); iter != commands.end(); + ++iter, ++expects_iter) { SendCommand(*iter); ReadCommandResponse(response); ASSERT_STREQ(response[0].c_str(), (*expects_iter).c_str()); @@ -321,11 +325,11 @@ TEST_F(ModemServiceTest, FacilityLock) { } TEST_F(ModemServiceTest, OpenLogicalChannel) { - std::string command= "A000000063504B43532D3135"; + std::string command = "A000000063504B43532D3135"; int firstChannel = openLogicalChannel(command); ASSERT_EQ(firstChannel, 1); - command= "A000000063504B43532D3135"; + command = "A000000063504B43532D3135"; int secondChannel = openLogicalChannel(command); ASSERT_GE(secondChannel, 1); @@ -334,7 +338,7 @@ TEST_F(ModemServiceTest, OpenLogicalChannel) { } TEST_F(ModemServiceTest, CloseLogicalChannel) { - std::string command= "A000000063504B43532D3135"; + std::string command = "A000000063504B43532D3135"; int channel = openLogicalChannel(command); ASSERT_EQ(channel, 1); @@ -343,7 +347,7 @@ TEST_F(ModemServiceTest, CloseLogicalChannel) { } TEST_F(ModemServiceTest, TransmitLogicalChannel) { - std::string command= "A000000063504B43532D3135"; + std::string command = "A000000063504B43532D3135"; int channel = openLogicalChannel(command); ASSERT_EQ(channel, 1); command = "AT+CGLA="; @@ -352,8 +356,8 @@ TEST_F(ModemServiceTest, TransmitLogicalChannel) { std::vector response; SendCommand(command); ReadCommandResponse(response); - const char *expect = "+CME ERROR: 21"; - ASSERT_STREQ(response[0].c_str(),expect); + const char* expect = "+CME ERROR: 21"; + ASSERT_STREQ(response[0].c_str(), expect); ASSERT_TRUE(closeLogicalChannel(channel)); } @@ -364,7 +368,8 @@ TEST_F(ModemServiceTest, testRadioPowerReq) { SendCommand(command, "+CFUN:"); ReadCommandResponse(response); ASSERT_EQ(response.size(), 2); - ASSERT_STREQ(response[response.size() - 1].c_str(), kFinalResponseSuccess[0].c_str()); + ASSERT_STREQ(response[response.size() - 1].c_str(), + kFinalResponseSuccess[0].c_str()); } TEST_F(ModemServiceTest, testSetRadioPower) { @@ -373,7 +378,8 @@ TEST_F(ModemServiceTest, testSetRadioPower) { SendCommand(command); ReadCommandResponse(response); ASSERT_EQ(response.size(), 1); - ASSERT_STREQ(response[response.size() - 1].c_str(), kFinalResponseSuccess[0].c_str()); + ASSERT_STREQ(response[response.size() - 1].c_str(), + kFinalResponseSuccess[0].c_str()); } TEST_F(ModemServiceTest, testSignalStrength) { @@ -382,7 +388,8 @@ TEST_F(ModemServiceTest, testSignalStrength) { SendCommand(command, "+CSQ:"); ReadCommandResponse(response); ASSERT_EQ(response.size(), 2); - ASSERT_STREQ(response[response.size() - 1].c_str(), kFinalResponseSuccess[0].c_str()); + ASSERT_STREQ(response[response.size() - 1].c_str(), + kFinalResponseSuccess[0].c_str()); } TEST_F(ModemServiceTest, testQueryNetworkSelectionMode) { @@ -391,7 +398,8 @@ TEST_F(ModemServiceTest, testQueryNetworkSelectionMode) { SendCommand(command, "+COPS:"); ReadCommandResponse(response); ASSERT_EQ(response.size(), 2); - ASSERT_STREQ(response[response.size() - 1].c_str(), kFinalResponseSuccess[0].c_str()); + ASSERT_STREQ(response[response.size() - 1].c_str(), + kFinalResponseSuccess[0].c_str()); } TEST_F(ModemServiceTest, testRequestOperator) { @@ -408,7 +416,8 @@ TEST_F(ModemServiceTest, testVoiceNetworkRegistration) { SendCommand(command, "+CREG:"); ReadCommandResponse(response); ASSERT_EQ(response.size(), 2); - ASSERT_STREQ(response[response.size() - 1].c_str(), kFinalResponseSuccess[0].c_str()); + ASSERT_STREQ(response[response.size() - 1].c_str(), + kFinalResponseSuccess[0].c_str()); } TEST_F(ModemServiceTest, testDataNetworkRegistration) { @@ -417,7 +426,8 @@ TEST_F(ModemServiceTest, testDataNetworkRegistration) { SendCommand(command, "+CGREG:"); ReadCommandResponse(response); ASSERT_EQ(response.size(), 2); - ASSERT_STREQ(response[response.size() - 1].c_str(), kFinalResponseSuccess[0].c_str()); + ASSERT_STREQ(response[response.size() - 1].c_str(), + kFinalResponseSuccess[0].c_str()); } TEST_F(ModemServiceTest, testDataNetworkRegistrationWithLte2) { @@ -426,7 +436,8 @@ TEST_F(ModemServiceTest, testDataNetworkRegistrationWithLte2) { SendCommand(command, "+CEREG:"); ReadCommandResponse(response); ASSERT_EQ(response.size(), 2); - ASSERT_STREQ(response[response.size() - 1].c_str(), kFinalResponseSuccess[0].c_str()); + ASSERT_STREQ(response[response.size() - 1].c_str(), + kFinalResponseSuccess[0].c_str()); } TEST_F(ModemServiceTest, testGetPreferredNetworkType) { @@ -435,7 +446,8 @@ TEST_F(ModemServiceTest, testGetPreferredNetworkType) { SendCommand(command, "+CTEC:"); ReadCommandResponse(response); ASSERT_EQ(response.size(), 2); - ASSERT_STREQ(response[response.size() - 1].c_str(), kFinalResponseSuccess[0].c_str()); + ASSERT_STREQ(response[response.size() - 1].c_str(), + kFinalResponseSuccess[0].c_str()); } TEST_F(ModemServiceTest, testQuerySupportedTechs) { @@ -444,7 +456,8 @@ TEST_F(ModemServiceTest, testQuerySupportedTechs) { SendCommand(command, "+CTEC:"); ReadCommandResponse(response); ASSERT_EQ(response.size(), 2); - ASSERT_STREQ(response[response.size() - 1].c_str(), kFinalResponseSuccess[0].c_str()); + ASSERT_STREQ(response[response.size() - 1].c_str(), + kFinalResponseSuccess[0].c_str()); } TEST_F(ModemServiceTest, testSetPreferredNetworkType) { @@ -453,7 +466,8 @@ TEST_F(ModemServiceTest, testSetPreferredNetworkType) { SendCommand(command, "+CTEC:"); ReadCommandResponse(response); ASSERT_EQ(response.size(), 2); - ASSERT_STREQ(response[response.size() - 1].c_str(), kFinalResponseSuccess[0].c_str()); + ASSERT_STREQ(response[response.size() - 1].c_str(), + kFinalResponseSuccess[0].c_str()); } /* Call Service Test */ @@ -466,7 +480,7 @@ TEST_F(ModemServiceTest, testCurrentCalls) { } TEST_F(ModemServiceTest, testHangup) { - for (int i = 0; i < 5; i ++) { + for (int i = 0; i < 5; i++) { std::stringstream ss; ss.clear(); ss << "AT+CHLD=" << i; @@ -475,7 +489,8 @@ TEST_F(ModemServiceTest, testHangup) { std::vector response; ReadCommandResponse(response); ASSERT_EQ(response.size(), 1); - ASSERT_STREQ(response[response.size() - 1].c_str(), kFinalResponseSuccess[0].c_str()); + ASSERT_STREQ(response[response.size() - 1].c_str(), + kFinalResponseSuccess[0].c_str()); } TEST_F(ModemServiceTest, testMute) { @@ -484,7 +499,8 @@ TEST_F(ModemServiceTest, testMute) { SendCommand(command); ReadCommandResponse(response); ASSERT_EQ(response.size(), 1); - ASSERT_STREQ(response[response.size() - 1].c_str(), kFinalResponseSuccess[0].c_str()); + ASSERT_STREQ(response[response.size() - 1].c_str(), + kFinalResponseSuccess[0].c_str()); } TEST_F(ModemServiceTest, testSendDtmf) { @@ -493,7 +509,8 @@ TEST_F(ModemServiceTest, testSendDtmf) { SendCommand(command); ReadCommandResponse(response); ASSERT_EQ(response.size(), 1); - ASSERT_STREQ(response[response.size() - 1].c_str(), kFinalResponseSuccess[0].c_str()); + ASSERT_STREQ(response[response.size() - 1].c_str(), + kFinalResponseSuccess[0].c_str()); } TEST_F(ModemServiceTest, testExitEmergencyMode) { @@ -502,7 +519,8 @@ TEST_F(ModemServiceTest, testExitEmergencyMode) { SendCommand(command); ReadCommandResponse(response); ASSERT_EQ(response.size(), 1); - ASSERT_STREQ(response[response.size() - 1].c_str(), kFinalResponseSuccess[0].c_str()); + ASSERT_STREQ(response[response.size() - 1].c_str(), + kFinalResponseSuccess[0].c_str()); } /* Data Service Test */ @@ -516,7 +534,7 @@ TEST_F(ModemServiceTest, SetPDPContext) { } TEST_F(ModemServiceTest, QueryPDPContextList) { - for (int i = 1; i < 5; i ++) { + for (int i = 1; i < 5; i++) { std::stringstream ss; ss.clear(); ss << "AT+CGDCONT=" << i << ",\"IPV4V6\",\"ctlte\",,0,0"; @@ -526,7 +544,7 @@ TEST_F(ModemServiceTest, QueryPDPContextList) { std::vector response; SendCommand(command, "+CGDCONT:"); ReadCommandResponse(response); - const char *result = response[response.size() - 1].c_str(); + const char* result = response[response.size() - 1].c_str(); ASSERT_EQ(response.size(), 1); ASSERT_STREQ(result, kFinalResponseSuccess[0].c_str()); } @@ -536,7 +554,7 @@ TEST_F(ModemServiceTest, ActivateDataCall) { std::vector response; SendCommand(command); ReadCommandResponse(response); - const char *result = response[0].c_str(); + const char* result = response[0].c_str(); ASSERT_STREQ(result, kFinalResponseSuccess[0].c_str()); } @@ -545,7 +563,7 @@ TEST_F(ModemServiceTest, QueryDataCallList) { std::vector response; SendCommand(command, "+CGACT:"); ReadCommandResponse(response); - const char *result = response[response.size() - 1].c_str(); + const char* result = response[response.size() - 1].c_str(); ASSERT_STREQ(result, kFinalResponseSuccess[0].c_str()); } @@ -554,7 +572,7 @@ TEST_F(ModemServiceTest, ReadDynamicParamTrue) { std::vector response; SendCommand(command); ReadCommandResponse(response); - const char *result = response[response.size() - 1].c_str(); + const char* result = response[response.size() - 1].c_str(); ASSERT_STREQ(result, kFinalResponseSuccess[0].c_str()); } @@ -563,17 +581,46 @@ TEST_F(ModemServiceTest, ReadDynamicParamFalse) { std::vector response; SendCommand(command); ReadCommandResponse(response); - const char *result = response[response.size() - 1].c_str(); - const char *expect = "+CME ERROR: 21"; + const char* result = response[response.size() - 1].c_str(); + const char* expect = "+CME ERROR: 21"; ASSERT_STREQ(result, expect); } +// The test config has no mobile IPv6, so every PDP type must produce the +// single IPv4 line that the simulator returned before IPv6 support. +TEST_F(ModemServiceTest, ReadDynamicParamWithoutIpv6PerPdpType) { + ASSERT_TRUE(modem::DeviceConfig::ril_ipv6_address_and_prefix().empty()); + const std::string expected_suffix = + ",5,\"ctlte\"," + modem::DeviceConfig::ril_address_and_prefix() + "," + + modem::DeviceConfig::ril_gateway() + "," + modem::DeviceConfig::ril_dns(); + int cid = 11; + for (const char* pdp_type : {"IP", "IPV6", "IPV4V6"}) { + std::stringstream set_cmd; + set_cmd << "AT+CGDCONT=" << cid << ",\"" << pdp_type << "\",\"ctlte\",,0,0"; + std::vector set_response; + SendCommand(set_cmd.str()); + ReadCommandResponse(set_response); + ASSERT_EQ(set_response.size(), 1); + ASSERT_EQ(set_response[0], "OK"); + + std::vector response; + SendCommand("AT+CGCONTRDP=" + std::to_string(cid), "+CGCONTRDP:"); + ReadCommandResponse(response); + ASSERT_EQ(response.size(), 2) << pdp_type; + EXPECT_EQ(response[0], + "+CGCONTRDP: " + std::to_string(cid) + expected_suffix) + << pdp_type; + EXPECT_EQ(response[1], "OK"); + ++cid; + } +} + TEST_F(ModemServiceTest, EnterDataState) { std::string command = "AT+CGDATA=1,1"; std::vector response; SendCommand(command); ReadCommandResponse(response); - const char *result = response[response.size() - 1].c_str(); + const char* result = response[response.size() - 1].c_str(); ASSERT_STREQ(result, kFinalResponseSuccess[1].c_str()); } @@ -583,11 +630,13 @@ TEST_F(ModemServiceTest, SendSMS) { std::vector response; SendCommand(command); ReadCommandResponse(response); - const char *result = response[response.size() - 1].c_str(); - const char *expect = "> "; + const char* result = response[response.size() - 1].c_str(); + const char* expect = "> "; ASSERT_STREQ(result, expect); - command = "0001000D91688118109844F0000017AFD7903AB55A9BBA69D639D4ADCBF99E3DCCAE9701^Z"; - //command += '\032'; + command = + "0001000D91688118109844F0000017AFD7903AB55A9BBA69D639D4ADCBF99E3DCCAE9701" + "^Z"; + // command += '\032'; SendCommand(command); ReadCommandResponse(response); // TODO (bohu) for some reason the following asserts fail, fix them @@ -601,14 +650,15 @@ TEST_F(ModemServiceTest, WriteSMSToSim) { std::vector response; SendCommand(command); ReadCommandResponse(response); - const char *result = response[response.size() - 1].c_str(); - const char *expect = "> "; + const char* result = response[response.size() - 1].c_str(); + const char* expect = "> "; ASSERT_STREQ(result, expect); command = "00240B815123106351F100000240516054410005C8329BFD06^Z"; SendCommand(command); ReadCommandResponse(response); ASSERT_EQ(response.size(), 3); - ASSERT_STREQ(response[response.size() - 1].c_str(), kFinalResponseSuccess[0].c_str()); + ASSERT_STREQ(response[response.size() - 1].c_str(), + kFinalResponseSuccess[0].c_str()); } TEST_F(ModemServiceTest, SMSAcknowledge) { @@ -616,7 +666,7 @@ TEST_F(ModemServiceTest, SMSAcknowledge) { std::vector response; SendCommand(command); ReadCommandResponse(response); - const char *result = response[response.size() - 1].c_str(); + const char* result = response[response.size() - 1].c_str(); ASSERT_STREQ(result, kFinalResponseSuccess[0].c_str()); } @@ -625,7 +675,7 @@ TEST_F(ModemServiceTest, DeleteSmsOnSimTure) { std::vector response; SendCommand(command); ReadCommandResponse(response); - const char *result = response[0].c_str(); + const char* result = response[0].c_str(); ASSERT_STREQ(result, kFinalResponseSuccess[0].c_str()); } @@ -634,8 +684,8 @@ TEST_F(ModemServiceTest, DeleteSmsOnSimFalse) { std::vector response; SendCommand(command); ReadCommandResponse(response); - const char *result = response[0].c_str(); - const char *expect = "+CME ERROR: 21"; + const char* result = response[0].c_str(); + const char* expect = "+CME ERROR: 21"; ASSERT_STREQ(result, expect); } @@ -644,7 +694,7 @@ TEST_F(ModemServiceTest, SetBroadcastConfig) { std::vector response; SendCommand(command); ReadCommandResponse(response); - const char *result = response[0].c_str(); + const char* result = response[0].c_str(); ASSERT_STREQ(result, kFinalResponseSuccess[0].c_str()); } @@ -654,7 +704,7 @@ TEST_F(ModemServiceTest, GetBroadcastConfig) { SendCommand(command); ReadCommandResponse(response); ASSERT_EQ(response.size(), 2); - const char *result = response[response.size() - 1].c_str(); + const char* result = response[response.size() - 1].c_str(); ASSERT_STREQ(result, kFinalResponseSuccess[0].c_str()); } @@ -664,7 +714,7 @@ TEST_F(ModemServiceTest, SetSmscAddress) { SendCommand(command); ReadCommandResponse(response); ASSERT_EQ(response.size(), 1); - const char *result = response[response.size() - 1].c_str(); + const char* result = response[response.size() - 1].c_str(); ASSERT_STREQ(result, kFinalResponseSuccess[0].c_str()); } @@ -674,7 +724,7 @@ TEST_F(ModemServiceTest, GetSmscAddress) { SendCommand(command); ReadCommandResponse(response); ASSERT_EQ(response.size(), 2); - const char *result = response[response.size() - 1].c_str(); + const char* result = response[response.size() - 1].c_str(); ASSERT_STREQ(result, kFinalResponseSuccess[0].c_str()); } @@ -685,7 +735,8 @@ TEST_F(ModemServiceTest, testUSSD) { SendCommand(command); ReadCommandResponse(response); ASSERT_EQ(response.size(), 1); - ASSERT_STREQ(response[response.size() - 1].c_str(), kFinalResponseSuccess[0].c_str()); + ASSERT_STREQ(response[response.size() - 1].c_str(), + kFinalResponseSuccess[0].c_str()); } TEST_F(ModemServiceTest, testCLIR) { @@ -694,7 +745,8 @@ TEST_F(ModemServiceTest, testCLIR) { SendCommand(command); ReadCommandResponse(response); ASSERT_EQ(response.size(), 1); - ASSERT_STREQ(response[response.size() - 1].c_str(), kFinalResponseSuccess[0].c_str()); + ASSERT_STREQ(response[response.size() - 1].c_str(), + kFinalResponseSuccess[0].c_str()); } TEST_F(ModemServiceTest, testQueryCLIR) { @@ -711,7 +763,8 @@ TEST_F(ModemServiceTest, testCallWaiting) { SendCommand(command, "+CCWA:"); ReadCommandResponse(response); ASSERT_EQ(response.size(), 1); - ASSERT_STREQ(response[response.size() - 1].c_str(), kFinalResponseSuccess[0].c_str()); + ASSERT_STREQ(response[response.size() - 1].c_str(), + kFinalResponseSuccess[0].c_str()); } TEST_F(ModemServiceTest, testCLIP) { @@ -737,8 +790,8 @@ TEST_F(ModemServiceTest, ReportStkServiceIsRunning) { SendCommand(command); ReadCommandResponse(response); ASSERT_EQ(response.size(), 2); - const char *result = response[0].c_str(); - const char *expect = "+CUSATD: 0,1"; + const char* result = response[0].c_str(); + const char* expect = "+CUSATD: 0,1"; ASSERT_STREQ(result, expect); } @@ -748,8 +801,8 @@ TEST_F(ModemServiceTest, SendEnvelope) { SendCommand(command); ReadCommandResponse(response); ASSERT_EQ(response.size(), 2); - const char *result = response[0].c_str(); - const char *expect = "+CUSATT: 0"; + const char* result = response[0].c_str(); + const char* expect = "+CUSATT: 0"; ASSERT_STREQ(result, expect); } @@ -759,8 +812,8 @@ TEST_F(ModemServiceTest, GetSendTerminalResponseToSim) { SendCommand(command); ReadCommandResponse(response); ASSERT_EQ(response.size(), 2); - const char *result = response[0].c_str(); - const char *expect = "+CUSATE: 0"; + const char* result = response[0].c_str(); + const char* expect = "+CUSATE: 0"; ASSERT_STREQ(result, expect); } @@ -771,7 +824,7 @@ TEST_F(ModemServiceTest, GetIMEI) { SendCommand(command); ReadCommandResponse(response); ASSERT_EQ(response.size(), 2); - const char *result = response[0].c_str(); - const char *expect = "867400022047199"; + const char* result = response[0].c_str(); + const char* expect = "867400022047199"; ASSERT_STREQ(result, expect); } diff --git a/base/cvd/cuttlefish/host/libs/config/BUILD.bazel b/base/cvd/cuttlefish/host/libs/config/BUILD.bazel index c9a28bd7c32..e0d50f19bab 100644 --- a/base/cvd/cuttlefish/host/libs/config/BUILD.bazel +++ b/base/cvd/cuttlefish/host/libs/config/BUILD.bazel @@ -425,6 +425,14 @@ cf_cc_library( ], ) +cf_cc_test( + name = "openwrt_args_test", + srcs = ["openwrt_args_test.cc"], + deps = [ + "//cuttlefish/host/libs/config:openwrt_args", + ], +) + cf_cc_library( name = "secure_hals", srcs = ["secure_hals.cpp"], diff --git a/base/cvd/cuttlefish/host/libs/config/cuttlefish_config.h b/base/cvd/cuttlefish/host/libs/config/cuttlefish_config.h index a06b42eae19..fb556a34d3c 100644 --- a/base/cvd/cuttlefish/host/libs/config/cuttlefish_config.h +++ b/base/cvd/cuttlefish/host/libs/config/cuttlefish_config.h @@ -490,6 +490,12 @@ class CuttlefishConfig { std::string ril_gateway() const; std::string ril_broadcast() const; uint8_t ril_prefixlen() const; + // Mobile network IPv6 info (RIL). Empty when IPv6 is not provided over + // the RIL for this instance. + std::string ril_ipv6_ipaddr() const; + std::string ril_ipv6_gateway() const; + std::string ril_ipv6_dns() const; + uint8_t ril_ipv6_prefixlen() const; std::string webrtc_assets_dir() const; @@ -749,6 +755,10 @@ class CuttlefishConfig { void set_ril_gateway(const std::string& ril_gateway); void set_ril_broadcast(const std::string& ril_broadcast); void set_ril_prefixlen(uint8_t ril_prefixlen); + void set_ril_ipv6_ipaddr(const std::string& ril_ipv6_ipaddr); + void set_ril_ipv6_gateway(const std::string& ril_ipv6_gateway); + void set_ril_ipv6_dns(const std::string& ril_ipv6_dns); + void set_ril_ipv6_prefixlen(uint8_t ril_ipv6_prefixlen); // Configuration flags for a minimal device void set_enable_minimal_mode(bool enable_minimal_mode); diff --git a/base/cvd/cuttlefish/host/libs/config/cuttlefish_config_instance.cpp b/base/cvd/cuttlefish/host/libs/config/cuttlefish_config_instance.cpp index 8e949efc918..d43894cc4eb 100644 --- a/base/cvd/cuttlefish/host/libs/config/cuttlefish_config_instance.cpp +++ b/base/cvd/cuttlefish/host/libs/config/cuttlefish_config_instance.cpp @@ -1271,6 +1271,43 @@ uint8_t CuttlefishConfig::InstanceSpecific::ril_prefixlen() const { return static_cast((*Dictionary())[kRilPrefixlen].asUInt()); } +static constexpr char kRilIpv6Ipaddr[] = "ril_ipv6_ipaddr"; +void CuttlefishConfig::MutableInstanceSpecific::set_ril_ipv6_ipaddr( + const std::string& ril_ipv6_ipaddr) { + (*Dictionary())[kRilIpv6Ipaddr] = ril_ipv6_ipaddr; +} +std::string CuttlefishConfig::InstanceSpecific::ril_ipv6_ipaddr() const { + return (*Dictionary())[kRilIpv6Ipaddr].asString(); +} + +static constexpr char kRilIpv6Gateway[] = "ril_ipv6_gateway"; +void CuttlefishConfig::MutableInstanceSpecific::set_ril_ipv6_gateway( + const std::string& ril_ipv6_gateway) { + (*Dictionary())[kRilIpv6Gateway] = ril_ipv6_gateway; +} +std::string CuttlefishConfig::InstanceSpecific::ril_ipv6_gateway() const { + return (*Dictionary())[kRilIpv6Gateway].asString(); +} + +static constexpr char kRilIpv6Dns[] = "ril_ipv6_dns"; +void CuttlefishConfig::MutableInstanceSpecific::set_ril_ipv6_dns( + const std::string& ril_ipv6_dns) { + (*Dictionary())[kRilIpv6Dns] = ril_ipv6_dns; +} +std::string CuttlefishConfig::InstanceSpecific::ril_ipv6_dns() const { + return (*Dictionary())[kRilIpv6Dns].asString(); +} + +static constexpr char kRilIpv6Prefixlen[] = "ril_ipv6_prefixlen"; +void CuttlefishConfig::MutableInstanceSpecific::set_ril_ipv6_prefixlen( + uint8_t ril_ipv6_prefixlen) { + (*Dictionary())[kRilIpv6Prefixlen] = + static_cast(ril_ipv6_prefixlen); +} +uint8_t CuttlefishConfig::InstanceSpecific::ril_ipv6_prefixlen() const { + return static_cast((*Dictionary())[kRilIpv6Prefixlen].asUInt()); +} + static constexpr char kDisplayConfigs[] = "display_configs"; static constexpr char kXRes[] = "x_res"; static constexpr char kYRes[] = "y_res"; diff --git a/base/cvd/cuttlefish/host/libs/config/openwrt_args.cpp b/base/cvd/cuttlefish/host/libs/config/openwrt_args.cpp index 6bd3484cc3c..48842b753ed 100644 --- a/base/cvd/cuttlefish/host/libs/config/openwrt_args.cpp +++ b/base/cvd/cuttlefish/host/libs/config/openwrt_args.cpp @@ -16,6 +16,14 @@ #include "cuttlefish/host/libs/config/openwrt_args.h" +#include +#include +#include +#include +#include + +#include +#include #include #include @@ -32,8 +40,104 @@ std::string getIpAddress(int c_class, int d_class) { return "192.168." + std::to_string(c_class) + "." + std::to_string(d_class); } +// Network numbers in the fourth hextet of the routed mode prefixes, see +// ipv6_routed_prefix in the cuttlefish-host-resources defaults file +// (base/debian/cuttlefish-base.cuttlefish-host-resources.default). +constexpr uint8_t kRoutedWifiApNetwork = 0x23; +constexpr uint8_t kRoutedWifiLanNetwork = 0x25; + +std::optional Ipv6ToString(const in6_addr& addr) { + char buf[INET6_ADDRSTRLEN]; + if (inet_ntop(AF_INET6, &addr, buf, sizeof(buf)) == nullptr) { + return std::nullopt; + } + return std::string(buf); +} + +int Ipv6PrefixLength(const in6_addr& netmask) { + int ret = 0; + for (uint8_t byte : netmask.s6_addr) { + for (; byte; byte <<= 1) { + ret += (byte & 0x80) ? 1 : 0; + } + } + return ret; +} + +// Looks for a routed mode address on the host side of the OpenWrt WAN tap. +std::optional ObtainRoutedIpv6Args( + const std::string& interface, int instance_num) { + struct ifaddrs* ifa_list = nullptr; + if (getifaddrs(&ifa_list) != 0) { + return std::nullopt; + } + std::optional ret; + for (struct ifaddrs* ifa = ifa_list; ifa; ifa = ifa->ifa_next) { + if (strcmp(ifa->ifa_name, interface.c_str()) != 0 || + ifa->ifa_addr == nullptr || ifa->ifa_netmask == nullptr || + ifa->ifa_addr->sa_family != AF_INET6) { + continue; + } + const in6_addr& addr = + reinterpret_cast(ifa->ifa_addr)->sin6_addr; + const in6_addr& netmask = + reinterpret_cast(ifa->ifa_netmask)->sin6_addr; + ret = OpenwrtRoutedIpv6ArgsFromHostAddress(addr, Ipv6PrefixLength(netmask), + instance_num); + if (ret) { + break; + } + } + freeifaddrs(ifa_list); + return ret; +} + } // namespace +std::optional OpenwrtRoutedIpv6ArgsFromHostAddress( + const in6_addr& host_addr, int prefix_length, int instance_num) { + if (prefix_length != 64 || instance_num < 1 || instance_num > 0xff) { + return std::nullopt; + } + const uint8_t* bytes = host_addr.s6_addr; + // Private mode uses Unique Local Addresses (fc00::/7); routed mode needs a + // global prefix. Link-local, loopback and multicast are never used. + if ((bytes[0] & 0xfe) == 0xfc || IN6_IS_ADDR_LINKLOCAL(&host_addr) || + IN6_IS_ADDR_LOOPBACK(&host_addr) || IN6_IS_ADDR_MULTICAST(&host_addr)) { + return std::nullopt; + } + // P:23NN::1, where NN is the instance number. + if (bytes[6] != kRoutedWifiApNetwork || bytes[7] != instance_num) { + return std::nullopt; + } + for (int i = 8; i < 15; ++i) { + if (bytes[i] != 0) { + return std::nullopt; + } + } + if (bytes[15] != 1) { + return std::nullopt; + } + + in6_addr wan_addr = host_addr; + wan_addr.s6_addr[15] = 2; + in6_addr lan_prefix = host_addr; + lan_prefix.s6_addr[6] = kRoutedWifiLanNetwork; + lan_prefix.s6_addr[15] = 0; + + std::optional wan_addr_str = Ipv6ToString(wan_addr); + std::optional gateway_str = Ipv6ToString(host_addr); + std::optional lan_prefix_str = Ipv6ToString(lan_prefix); + if (!wan_addr_str || !gateway_str || !lan_prefix_str) { + return std::nullopt; + } + return OpenwrtRoutedIpv6Args{ + .wan_ip6addr = *wan_addr_str + "/64", + .wan_ip6gw = *gateway_str, + .lan_ip6prefix = *lan_prefix_str + "/64", + }; +} + std::unordered_map OpenwrtArgsFromConfig( const CuttlefishConfig::InstanceSpecific& instance) { std::unordered_map openwrt_args; @@ -86,6 +190,18 @@ std::unordered_map OpenwrtArgsFromConfig( getIpAddress(94 + c_class_base, d_class_base + 2); openwrt_args["wan_broadcast"] = getIpAddress(94 + c_class_base, d_class_base + 3); + + // IPv6 routed mode: the host init script gives cvd-wifiap-NN an address + // from the routed /48, so the routed mode is detected from that address + // with no extra flag. In private mode no IPv6 keys are passed and + // OpenWrt keeps its default (ULA LAN prefix and masq6). + std::optional ipv6 = + ObtainRoutedIpv6Args(instance.wifi_tap_name(), instance_num); + if (ipv6) { + openwrt_args["wan_ip6addr"] = ipv6->wan_ip6addr; + openwrt_args["wan_ip6gw"] = ipv6->wan_ip6gw; + openwrt_args["lan_ip6prefix"] = ipv6->lan_ip6prefix; + } } } diff --git a/base/cvd/cuttlefish/host/libs/config/openwrt_args.h b/base/cvd/cuttlefish/host/libs/config/openwrt_args.h index f6bfee071ee..86059390358 100644 --- a/base/cvd/cuttlefish/host/libs/config/openwrt_args.h +++ b/base/cvd/cuttlefish/host/libs/config/openwrt_args.h @@ -16,6 +16,9 @@ #pragma once +#include + +#include #include #include @@ -26,4 +29,22 @@ namespace cuttlefish { std::unordered_map OpenwrtArgsFromConfig( const CuttlefishConfig::InstanceSpecific& instance); +// IPv6 settings passed to OpenWrt on the kernel command line when the host +// runs in IPv6 routed mode (ipv6_routed_prefix in +// /etc/default/cuttlefish-host-resources). +struct OpenwrtRoutedIpv6Args { + std::string wan_ip6addr; // P:23NN::2/64 + std::string wan_ip6gw; // P:23NN::1 + std::string lan_ip6prefix; // P:25NN::/64 +}; + +// Derives the routed mode OpenWrt IPv6 settings from the host's address on +// cvd-wifiap-NN. In routed mode the host init script assigns P:23NN::1/64, +// where P is the routed /48 and NN is the instance number as two hex digits. +// Returns nullopt for any other address, which includes the private mode +// addresses (ULA, fd00:cf:23:::1), so OpenWrt keeps its default +// IPv6 configuration. +std::optional OpenwrtRoutedIpv6ArgsFromHostAddress( + const in6_addr& host_addr, int prefix_length, int instance_num); + } // namespace cuttlefish diff --git a/base/cvd/cuttlefish/host/libs/config/openwrt_args_test.cc b/base/cvd/cuttlefish/host/libs/config/openwrt_args_test.cc new file mode 100644 index 00000000000..438360bbd40 --- /dev/null +++ b/base/cvd/cuttlefish/host/libs/config/openwrt_args_test.cc @@ -0,0 +1,141 @@ +/* + * 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. + */ + +#include "cuttlefish/host/libs/config/openwrt_args.h" + +#include +#include +#include +#include +#include + +#include +#include + +namespace cuttlefish { +namespace { + +in6_addr Addr(const std::string& str) { + in6_addr addr{}; + EXPECT_EQ(inet_pton(AF_INET6, str.c_str(), &addr), 1) << str; + return addr; +} + +TEST(OpenwrtRoutedIpv6ArgsFromHostAddress, FirstInstance) { + std::optional args = + OpenwrtRoutedIpv6ArgsFromHostAddress(Addr("2001:db8:cf00:2301::1"), 64, + 1); + ASSERT_TRUE(args.has_value()); + EXPECT_EQ(args->wan_ip6addr, "2001:db8:cf00:2301::2/64"); + EXPECT_EQ(args->wan_ip6gw, "2001:db8:cf00:2301::1"); + EXPECT_EQ(args->lan_ip6prefix, "2001:db8:cf00:2501::/64"); +} + +TEST(OpenwrtRoutedIpv6ArgsFromHostAddress, ShortRoutedPrefix) { + // ipv6_routed_prefix=2001:db8::/48 gives P = 2001:db8:0. + std::optional args = + OpenwrtRoutedIpv6ArgsFromHostAddress(Addr("2001:db8:0:230a::1"), 64, 10); + ASSERT_TRUE(args.has_value()); + EXPECT_EQ(args->wan_ip6addr, "2001:db8:0:230a::2/64"); + EXPECT_EQ(args->wan_ip6gw, "2001:db8:0:230a::1"); + EXPECT_EQ(args->lan_ip6prefix, "2001:db8:0:250a::/64"); +} + +TEST(OpenwrtRoutedIpv6ArgsFromHostAddress, AllInstances) { + // The host init script sets up instances 1 to 128. + for (int i = 1; i <= 128; ++i) { + char host[64]; + char wan[64]; + char lan[64]; + snprintf(host, sizeof(host), "2001:db8:cf00:23%02x::1", i); + snprintf(wan, sizeof(wan), "2001:db8:cf00:23%02x::2/64", i); + snprintf(lan, sizeof(lan), "2001:db8:cf00:25%02x::/64", i); + std::optional args = + OpenwrtRoutedIpv6ArgsFromHostAddress(Addr(host), 64, i); + ASSERT_TRUE(args.has_value()) << host; + EXPECT_EQ(args->wan_ip6addr, wan); + EXPECT_EQ(args->wan_ip6gw, host); + EXPECT_EQ(args->lan_ip6prefix, lan); + } +} + +TEST(OpenwrtRoutedIpv6ArgsFromHostAddress, PrivateModeAddressIsIgnored) { + // Private mode: fd00:cf:23:::1/64, a ULA. + EXPECT_FALSE( + OpenwrtRoutedIpv6ArgsFromHostAddress(Addr("fd00:cf:23:1::1"), 64, 1) + .has_value()); + // A ULA with the routed layout is still private mode. + EXPECT_FALSE( + OpenwrtRoutedIpv6ArgsFromHostAddress(Addr("fd00:cf:0:2301::1"), 64, 1) + .has_value()); + EXPECT_FALSE( + OpenwrtRoutedIpv6ArgsFromHostAddress(Addr("fc00:cf:0:2301::1"), 64, 1) + .has_value()); +} + +TEST(OpenwrtRoutedIpv6ArgsFromHostAddress, CustomGlobalBaseIsIgnored) { + // wifiap_ipv6_prefix_base=2001:db8:23 in private mode gives + // 2001:db8:23:::1, which is not the routed layout. + EXPECT_FALSE( + OpenwrtRoutedIpv6ArgsFromHostAddress(Addr("2001:db8:23:1::1"), 64, 1) + .has_value()); +} + +TEST(OpenwrtRoutedIpv6ArgsFromHostAddress, WrongInstance) { + EXPECT_FALSE( + OpenwrtRoutedIpv6ArgsFromHostAddress(Addr("2001:db8:cf00:2302::1"), 64, 1) + .has_value()); +} + +TEST(OpenwrtRoutedIpv6ArgsFromHostAddress, WrongNetwork) { + // P:21NN::1 is the mobile network, not the OpenWrt WAN. + EXPECT_FALSE( + OpenwrtRoutedIpv6ArgsFromHostAddress(Addr("2001:db8:cf00:2101::1"), 64, 1) + .has_value()); +} + +TEST(OpenwrtRoutedIpv6ArgsFromHostAddress, NotHostAddress) { + EXPECT_FALSE( + OpenwrtRoutedIpv6ArgsFromHostAddress(Addr("2001:db8:cf00:2301::2"), 64, 1) + .has_value()); + EXPECT_FALSE(OpenwrtRoutedIpv6ArgsFromHostAddress( + Addr("2001:db8:cf00:2301:1::1"), 64, 1) + .has_value()); +} + +TEST(OpenwrtRoutedIpv6ArgsFromHostAddress, WrongPrefixLength) { + EXPECT_FALSE( + OpenwrtRoutedIpv6ArgsFromHostAddress(Addr("2001:db8:cf00:2301::1"), 48, 1) + .has_value()); + EXPECT_FALSE( + OpenwrtRoutedIpv6ArgsFromHostAddress(Addr("2001:db8:cf00:2301::1"), 96, 1) + .has_value()); +} + +TEST(OpenwrtRoutedIpv6ArgsFromHostAddress, LinkLocalIsIgnored) { + EXPECT_FALSE( + OpenwrtRoutedIpv6ArgsFromHostAddress(Addr("fe80::2301:0:0:1"), 64, 1) + .has_value()); +} + +TEST(OpenwrtRoutedIpv6ArgsFromHostAddress, InvalidInstance) { + EXPECT_FALSE( + OpenwrtRoutedIpv6ArgsFromHostAddress(Addr("2001:db8:cf00:2300::1"), 64, 0) + .has_value()); +} + +} // namespace +} // namespace cuttlefish diff --git a/base/cvd/host_tests/common/dnsmasq_shim.sh b/base/cvd/host_tests/common/dnsmasq_shim.sh index 30037582547..d1d61424b93 100644 --- a/base/cvd/host_tests/common/dnsmasq_shim.sh +++ b/base/cvd/host_tests/common/dnsmasq_shim.sh @@ -34,6 +34,6 @@ setsid /bin/sh -c ' child=$! wait "$child" cleanup -' dnsmasq-shim "$pidfile" /dev/null 2>&1 & +' dnsmasq-shim "$pidfile" "$@" /dev/null 2>&1 & exit 0 diff --git a/base/cvd/host_tests/common/nft.go b/base/cvd/host_tests/common/nft.go index 9af1aa691c4..b6e0d8764ed 100644 --- a/base/cvd/host_tests/common/nft.go +++ b/base/cvd/host_tests/common/nft.go @@ -130,8 +130,8 @@ type nftExpr struct { } type nftMatch struct { - Left nftMatchLeft `json:"left"` - Right nftMatchRight `json:"right"` + Left nftMatchLeft `json:"left"` + Right json.RawMessage `json:"right"` } type nftMatchLeft struct { @@ -180,8 +180,11 @@ func convertNftRule(r nftRule) NftRule { if len(e.Masquerade) > 0 { nr.Masquerade = true } - if e.Match != nil && e.Match.Left.Payload != nil && e.Match.Left.Payload.Field == "saddr" && e.Match.Right.Prefix != nil { - nr.SaddrPrefix = fmt.Sprintf("%s/%d", e.Match.Right.Prefix.Addr, e.Match.Right.Prefix.Len) + if e.Match != nil && e.Match.Left.Payload != nil && e.Match.Left.Payload.Field == "saddr" { + var right nftMatchRight + if err := json.Unmarshal(e.Match.Right, &right); err == nil && right.Prefix != nil { + nr.SaddrPrefix = fmt.Sprintf("%s/%d", right.Prefix.Addr, right.Prefix.Len) + } } } return nr diff --git a/base/cvd/host_tests/static_resources_init_test/BUILD.bazel b/base/cvd/host_tests/static_resources_init_test/BUILD.bazel index 8af4ce5a77d..fa74ba9c4ad 100644 --- a/base/cvd/host_tests/static_resources_init_test/BUILD.bazel +++ b/base/cvd/host_tests/static_resources_init_test/BUILD.bazel @@ -17,12 +17,18 @@ load("@rules_go//go:def.bzl", "go_test") go_test( name = "static_resources_init_test", size = "large", - srcs = ["main_test.go"], + srcs = [ + "ipv6_test.go", + "main_test.go", + "routed_test.go", + ], data = [ "@cuttlefish_debian//:cuttlefish-base.cuttlefish-host-resources.init", + "@cuttlefish_debian//:cuttlefish-base.postrm", ], env = { "INIT_SCRIPT": "$(rlocationpath @cuttlefish_debian//:cuttlefish-base.cuttlefish-host-resources.init)", + "POSTRM_SCRIPT": "$(rlocationpath @cuttlefish_debian//:cuttlefish-base.postrm)", }, tags = [ "exclusive", @@ -34,5 +40,6 @@ go_test( "//host_tests/common", "@com_github_google_go_cmp//cmp", "@com_github_google_go_cmp//cmp/cmpopts", + "@rules_go//go/runfiles", ], ) diff --git a/base/cvd/host_tests/static_resources_init_test/ipv6_test.go b/base/cvd/host_tests/static_resources_init_test/ipv6_test.go new file mode 100644 index 00000000000..01f75bfe939 --- /dev/null +++ b/base/cvd/host_tests/static_resources_init_test/ipv6_test.go @@ -0,0 +1,1055 @@ +// 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 + +// Static-mode IPv6 behaviors of cuttlefish-host-resources, checked in the +// rootless user+net+mount+pid namespace sandbox of the host_resources tests. + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "slices" + "strconv" + "strings" + "testing" + "time" + + "github.com/bazelbuild/rules_go/go/runfiles" + "github.com/google/android-cuttlefish/base/cvd/host_tests/common" + "github.com/google/go-cmp/cmp" +) + +// ipv6Fixture is a sandbox with the init script, driven through +// /etc/default/cuttlefish-host-resources (a tmpfs file in the sandbox). +type ipv6Fixture struct { + t *testing.T + s *common.Sandbox + script string +} + +func newIPv6Fixture(t *testing.T) *ipv6Fixture { + t.Helper() + s := common.NewSandbox(t) + t.Cleanup(s.Close) + rel := os.Getenv("INIT_SCRIPT") + if rel == "" { + t.Fatal("INIT_SCRIPT env var is not set") + } + script, err := runfiles.Rlocation(rel) + if err != nil { + t.Fatalf("locating %q: %v", rel, err) + } + return &ipv6Fixture{t: t, s: s, script: script} +} + +// sh runs a shell command in the sandbox and fails the test on error. +func (f *ipv6Fixture) sh(cmd string) string { + f.t.Helper() + out, err := f.s.Run("sh", "-c", cmd) + if err != nil { + f.t.Fatalf("%v", err) + } + return out.Stdout +} + +// writeDefaults replaces /etc/default/cuttlefish-host-resources with lines. +// Lines must not contain single quotes. +func (f *ipv6Fixture) writeDefaults(lines ...string) { + f.t.Helper() + f.sh("printf '%s\\n' '" + strings.Join(lines, "' '") + "' > /etc/default/cuttlefish-host-resources") +} + +// initScript runs the init script with an action (start, stop, restart). +func (f *ipv6Fixture) initScript(action string) { + f.t.Helper() + if _, err := f.s.Run("sh", f.script, action); err != nil { + f.t.Fatalf("init script %s: %v", action, err) + } +} + +func (f *ipv6Fixture) setHostIPv6Disabled(disabled bool) { + f.t.Helper() + v := "0" + if disabled { + v = "1" + } + f.sh("echo " + v + " > /proc/sys/net/ipv6/conf/all/disable_ipv6") +} + +func (f *ipv6Fixture) snapshot() common.HostState { + f.t.Helper() + hs, err := common.Snapshot(f.s) + if err != nil { + f.t.Fatalf("snapshot: %v", err) + } + return hs +} + +// globalIPv6 returns the global IPv6 addresses ("addr/len") of ifname. +func globalIPv6(hs common.HostState, ifname string) []string { + var out []string + for _, a := range hs.Addrs { + if a.Ifname != ifname { + continue + } + for _, ai := range a.AddrInfo { + if ai.Family == "inet6" && ai.Scope == "global" { + out = append(out, fmt.Sprintf("%s/%d", ai.Local, ai.Prefixlen)) + } + } + } + slices.Sort(out) + return out +} + +// raDnsmasqIfaces returns the interfaces with an RA-only dnsmasq pidfile. +func raDnsmasqIfaces(s *common.Sandbox) []string { + var out []string + for _, i := range common.DnsmasqPidfileIfaces(s) { + if rest, ok := strings.CutPrefix(i, "ra-"); ok { + out = append(out, rest) + } + } + slices.Sort(out) + return out +} + +func (f *ipv6Fixture) nftTables() []string { + var out []string + for _, tb := range f.snapshot().Nft.Tables { + out = append(out, tb.Family+" "+tb.Name) + } + slices.Sort(out) + return out +} + +// dnsmasqProcesses returns the command lines of live dnsmasq (or dnsmasq-shim) +// processes, one per line. Zombies are skipped: the sandbox's pid 1 (sleep) +// does not reap. +func (f *ipv6Fixture) dnsmasqProcesses() string { + f.t.Helper() + return strings.TrimSpace(f.sh(`for p in $({ pgrep -x dnsmasq; pgrep -f '[d]nsmasq-shim'; } 2>/dev/null | sort -u); do + [ "$(awk '{print $3}' /proc/$p/stat 2>/dev/null)" = Z ] && continue + tr '\0' ' ' < /proc/$p/cmdline 2>/dev/null + echo +done`)) +} + +// waitDnsmasq polls dnsmasqProcesses for up to 2s until ok returns true, and +// returns the last sample. Killed processes need a moment to exit. +func (f *ipv6Fixture) waitDnsmasq(ok func(string) bool) string { + f.t.Helper() + var out string + for i := 0; i < 20; i++ { + if out = f.dnsmasqProcesses(); ok(out) { + break + } + time.Sleep(100 * time.Millisecond) + } + return out +} + +// requireNoLeak checks that stop removed everything start added: links, +// addresses, nftables, /run/cuttlefish files and dnsmasq processes. +func (f *ipv6Fixture) requireNoLeak(base common.HostState) { + f.t.Helper() + if diff := common.DiffState(common.Normalize(base), common.Normalize(f.snapshot())); diff != "" { + f.t.Errorf("state leaked after stop (-before +after):\n%s", diff) + } + if files := common.HandleFiles(f.s); len(files) != 0 { + f.t.Errorf("/run/cuttlefish not empty after stop: %v", files) + } + if p := f.waitDnsmasq(func(s string) bool { return s == "" }); p != "" { + f.t.Errorf("dnsmasq still running after stop:\n%s", p) + } + if ra := raDnsmasqIfaces(f.s); len(ra) != 0 { + f.t.Errorf("RA dnsmasq pidfiles left after stop: %v", ra) + } +} + +func tapName(kind string, i int) string { return fmt.Sprintf("cvd-%s-%02d", kind, i) } + +// TestStaticIPv6Addressing checks the default ULA plan, RA placement, +// accept_ra/autoconf, and the NAT66 and RA guard rules, with 10 accounts so +// instance 10 exercises the hex prefix (fd00:cf:21:a::/64). +func TestStaticIPv6Addressing(t *testing.T) { + const n = 10 + f := newIPv6Fixture(t) + f.writeDefaults(fmt.Sprintf("num_cvd_accounts=%d", n)) + base := f.snapshot() + f.initScript("start") + + hs := f.snapshot() + want := map[string][]string{ + "cvd-ebr": {"fd00:cf:24::1/64"}, + "cvd-wbr": {"fd00:cf:22::1/64"}, + } + for i := 1; i <= n; i++ { + want[tapName("mtap", i)] = []string{fmt.Sprintf("fd00:cf:21:%x::1/64", i)} + want[tapName("wifiap", i)] = []string{fmt.Sprintf("fd00:cf:23:%x::1/64", i)} + want[tapName("etap", i)] = nil + want[tapName("wtap", i)] = nil + } + for ifname, w := range want { + if diff := cmp.Diff(w, globalIPv6(hs, ifname)); diff != "" { + t.Errorf("global IPv6 of %s (-want +got):\n%s", ifname, diff) + } + } + + // RAs on the bridges and on every cvd-wifiap-XX, never on cvd-mtap-XX. + wantRA := []string{"cvd-ebr", "cvd-wbr"} + for i := 1; i <= n; i++ { + wantRA = append(wantRA, tapName("wifiap", i)) + } + slices.Sort(wantRA) + if diff := cmp.Diff(wantRA, raDnsmasqIfaces(f.s)); diff != "" { + t.Errorf("RA dnsmasq interfaces (-want +got):\n%s", diff) + } + + // The RA dnsmasq advertises the interface's prefix, ra-only (no DHCPv6). + for ifname, prefix := range map[string]string{ + "cvd-ebr": "fd00:cf:24::", + "cvd-wbr": "fd00:cf:22::", + tapName("wifiap", n): fmt.Sprintf("fd00:cf:23:%x::", n), + } { + cmdline := f.sh(fmt.Sprintf("tr '\\0' ' ' < /proc/$(cat /run/cuttlefish-dnsmasq-ra-%s.pid)/cmdline", ifname)) + for _, arg := range []string{"--enable-ra", "--interface=" + ifname, "--dhcp-range=" + prefix + ",ra-only,64"} { + if !strings.Contains(cmdline, arg) { + t.Errorf("RA dnsmasq on %s lacks %q: %s", ifname, arg, cmdline) + } + } + } + + // The host ignores RAs from guests on every cuttlefish interface. + ifaces := []string{"cvd-ebr", "cvd-wbr"} + for i := 1; i <= n; i++ { + ifaces = append(ifaces, tapName("etap", i), tapName("wtap", i), tapName("mtap", i), tapName("wifiap", i)) + } + for _, ifname := range ifaces { + for _, key := range []string{"accept_ra", "autoconf"} { + if v := strings.TrimSpace(f.sh(fmt.Sprintf("cat /proc/sys/net/ipv6/conf/%s/%s", ifname, key))); v != "0" { + t.Errorf("%s/%s = %s, want 0", ifname, key, v) + } + } + } + + nat6 := f.sh("nft list chain ip6 cuttlefish_nat6 postrouting") + natRule := regexp.MustCompile(`ip6 saddr fd00:cf:20::/44 oifname != "cvd-\*" counter packets \d+ bytes \d+ masquerade`) + if got := len(natRule.FindAllString(nat6, -1)); got != 1 { + t.Errorf("want exactly 1 NAT66 rule excluding cvd-* egress, got %d:\n%s", got, nat6) + } + + bridgeGuard := f.sh("nft list chain bridge cuttlefish_ra_guard prerouting") + inetGuard := f.sh("nft list chain inet cuttlefish_ra_guard input") + for _, c := range []struct{ out, pattern string }{ + {bridgeGuard, "cvd-etap-*"}, + {bridgeGuard, "cvd-wtap-*"}, + {inetGuard, "cvd-mtap-*"}, + {inetGuard, "cvd-wifiap-*"}, + } { + if !guardRuleFor(c.out, c.pattern) { + t.Errorf("no RA/redirect drop rule for %s:\n%s", c.pattern, c.out) + } + } + if strings.Contains(bridgeGuard+inetGuard, "nd-router-solicit") || strings.Contains(bridgeGuard+inetGuard, "nd-neighbor") { + t.Errorf("RA guard must not drop RS/NS/NA:\n%s\n%s", bridgeGuard, inetGuard) + } + + f.initScript("stop") + f.requireNoLeak(base) +} + +// guardRuleFor reports whether out has a rule dropping RAs and redirects +// arriving on ifname pattern. +func guardRuleFor(out, pattern string) bool { + for _, line := range strings.Split(out, "\n") { + if strings.Contains(line, `iifname "`+pattern+`"`) && + strings.Contains(line, "nd-router-advert") && + strings.Contains(line, "nd-redirect") && + strings.HasSuffix(strings.TrimSpace(line), "drop") { + return true + } + } + return false +} + +// sendICMPv6 sends 3 ICMPv6 messages of type icmpType (134 = RA, 133 = RS) +// out of ifname to the all-nodes / all-routers group, with hop limit 255. +const sendICMPv6 = ` +import socket, struct, sys +ifname, icmp_type = sys.argv[1], int(sys.argv[2]) +s = socket.socket(socket.AF_INET6, socket.SOCK_RAW, socket.IPPROTO_ICMPV6) +s.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_MULTICAST_HOPS, 255) +s.setsockopt(socket.SOL_SOCKET, 25, ifname.encode()) # SO_BINDTODEVICE +if icmp_type == 134: + msg, dst = struct.pack("!BBHBBHII", 134, 0, 0, 64, 0, 1800, 0, 0), "ff02::1" +else: + msg, dst = struct.pack("!BBHI", 133, 0, 0, 0), "ff02::2" +for _ in range(3): + s.sendto(msg, (dst, 0, 0, socket.if_nametoindex(ifname))) +` + +// guardCounter returns the packet counter of the RA guard rule for pattern. +func guardCounter(t *testing.T, out, pattern string) int { + t.Helper() + re := regexp.MustCompile(`counter packets (\d+)`) + for _, line := range strings.Split(out, "\n") { + if strings.Contains(line, `iifname "`+pattern+`"`) { + if m := re.FindStringSubmatch(line); m != nil { + v, _ := strconv.Atoi(m[1]) + return v + } + } + } + t.Fatalf("no counter for %s in:\n%s", pattern, out) + return 0 +} + +// TestStaticIPv6RAGuardDropsGuestRA sends RAs and RSs from a fake guest on a +// bridged tap and on a routed tap, and checks that the RA guard drops RAs +// only. +func TestStaticIPv6RAGuardDropsGuestRA(t *testing.T) { + f := newIPv6Fixture(t) + if _, err := f.s.Run("python3", "-c", "import socket"); err != nil { + t.Skipf("python3 is required to craft ICMPv6: %v", err) + } + f.writeDefaults("num_cvd_accounts=1") + f.initScript("start") + t.Cleanup(func() { f.s.Run("sh", f.script, "stop") }) + + // Fake guests: veth peers named like cuttlefish taps. The guest side has + // no DAD so its link-local source address is usable immediately. + f.sh(`set -e +ip link add cvd-etap-99 type veth peer name guest-e +ip link set cvd-etap-99 master cvd-ebr up +ip link add cvd-mtap-99 type veth peer name guest-m +ip link set cvd-mtap-99 up +for g in guest-e guest-m; do + echo 0 > /proc/sys/net/ipv6/conf/$g/accept_dad + ip link set $g up +done`) + + cases := []struct { + guest, family, chain, pattern string + }{ + {"guest-e", "bridge", "prerouting", "cvd-etap-*"}, + {"guest-m", "inet", "input", "cvd-mtap-*"}, + } + for _, c := range cases { + t.Run(c.pattern, func(t *testing.T) { + list := "nft list chain " + c.family + " cuttlefish_ra_guard " + c.chain + before := guardCounter(t, f.sh(list), c.pattern) + if _, err := f.s.Run("python3", "-c", sendICMPv6, c.guest, "133"); err != nil { + t.Fatalf("sending RS: %v", err) + } + if got := guardCounter(t, f.sh(list), c.pattern); got != before { + t.Errorf("RS was dropped: counter %d -> %d", before, got) + } + if _, err := f.s.Run("python3", "-c", sendICMPv6, c.guest, "134"); err != nil { + t.Fatalf("sending RA: %v", err) + } + if got := guardCounter(t, f.sh(list), c.pattern); got != before+3 { + t.Errorf("RA guard counter %d -> %d, want +3", before, got) + } + }) + } + f.sh("ip link del cvd-etap-99; ip link del cvd-mtap-99") +} + +// TestStaticIPv6HostDisabled checks the path where the host has IPv6 +// disabled: IPv4 is set up as usual, and nothing IPv6 is added. +func TestStaticIPv6HostDisabled(t *testing.T) { + f := newIPv6Fixture(t) + f.writeDefaults("num_cvd_accounts=2") + f.setHostIPv6Disabled(true) + base := f.snapshot() + f.initScript("start") + + hs := f.snapshot() + if got := hs.PrimaryIPv4("cvd-ebr"); got != "192.168.98.1/24" { + t.Errorf("cvd-ebr IPv4 = %q, want 192.168.98.1/24", got) + } + if got := hs.PrimaryIPv4("cvd-mtap-02"); got != "192.168.97.5/30" { + t.Errorf("cvd-mtap-02 IPv4 = %q, want 192.168.97.5/30", got) + } + wantTables := []string{"bridge cuttlefish_bridge", "ip cuttlefish_nat"} + if diff := cmp.Diff(wantTables, f.nftTables()); diff != "" { + t.Errorf("nft tables with IPv6 disabled (-want +got):\n%s", diff) + } + for _, l := range hs.Links { + if g := globalIPv6(hs, l.Ifname); len(g) != 0 { + t.Errorf("%s has IPv6 %v with host IPv6 disabled", l.Ifname, g) + } + } + if ra := raDnsmasqIfaces(f.s); len(ra) != 0 { + t.Errorf("RA dnsmasq started with host IPv6 disabled: %v", ra) + } + if slices.Contains(common.HandleFiles(f.s), "ipv6-enabled") { + t.Error("ipv6-enabled marker created with host IPv6 disabled") + } + + f.initScript("stop") + f.requireNoLeak(base) +} + +// TestStaticIPv6DisabledBeforeStop checks that stop removes the IPv6 state +// that start created even when the host disabled IPv6 in between. +func TestStaticIPv6DisabledBeforeStop(t *testing.T) { + f := newIPv6Fixture(t) + f.writeDefaults("num_cvd_accounts=1") + f.initScript("start") + f.setHostIPv6Disabled(true) + f.initScript("stop") + + if tables := f.nftTables(); len(tables) != 0 { + t.Errorf("nft tables left after stop: %v", tables) + } + if files := common.HandleFiles(f.s); len(files) != 0 { + t.Errorf("/run/cuttlefish not empty after stop: %v", files) + } + if p := f.waitDnsmasq(func(s string) bool { return s == "" }); p != "" { + t.Errorf("dnsmasq still running after stop:\n%s", p) + } +} + +// TestStaticIPv6DefaultOverrides checks that the IPv6 settings of +// /etc/default/cuttlefish-host-resources are applied. +func TestStaticIPv6DefaultOverrides(t *testing.T) { + f := newIPv6Fixture(t) + f.writeDefaults( + "num_cvd_accounts=1", + "ethernet_ipv6_prefix=fd00:cf:2a::", + "ethernet_ipv6_prefix_length=64", + "wifi_ipv6_prefix=fd00:cf:2b::", + "wifi_ipv6_prefix_length=64", + "mobile_ipv6_prefix_base=fd00:cf:2c", + "wifiap_ipv6_prefix_base=fd00:cf:2d", + "dns6_servers=fd00:cf:2e::53", + ) + base := f.snapshot() + f.initScript("start") + + hs := f.snapshot() + for ifname, w := range map[string]string{ + "cvd-ebr": "fd00:cf:2a::1/64", + "cvd-wbr": "fd00:cf:2b::1/64", + "cvd-mtap-01": "fd00:cf:2c:1::1/64", + "cvd-wifiap-01": "fd00:cf:2d:1::1/64", + } { + if diff := cmp.Diff([]string{w}, globalIPv6(hs, ifname)); diff != "" { + t.Errorf("global IPv6 of %s (-want +got):\n%s", ifname, diff) + } + } + cmdline := f.sh("tr '\\0' ' ' < /proc/$(cat /run/cuttlefish-dnsmasq-ra-cvd-ebr.pid)/cmdline") + for _, arg := range []string{"--dhcp-range=fd00:cf:2a::,ra-only,64", "option6:dns-server,fd00:cf:2e::53"} { + if !strings.Contains(cmdline, arg) { + t.Errorf("RA dnsmasq on cvd-ebr lacks %q: %s", arg, cmdline) + } + } + + f.initScript("stop") + f.requireNoLeak(base) +} + +// TestStaticIPv6Restart checks that start/stop cycles and the restart action +// leave exactly one copy of each IPv6 rule and clean up completely. +func TestStaticIPv6Restart(t *testing.T) { + f := newIPv6Fixture(t) + f.writeDefaults("num_cvd_accounts=2") + base := f.snapshot() + + f.initScript("start") + f.initScript("stop") + f.initScript("start") + f.initScript("restart") + + nat6 := f.sh("nft list chain ip6 cuttlefish_nat6 postrouting") + if got := strings.Count(nat6, "masquerade"); got != 1 { + t.Errorf("want 1 NAT66 rule after restart, got %d:\n%s", got, nat6) + } + inetGuard := f.sh("nft list chain inet cuttlefish_ra_guard input") + if got := strings.Count(inetGuard, "drop"); got != 2 { + t.Errorf("want 2 inet RA guard rules after restart, got %d:\n%s", got, inetGuard) + } + hs := f.snapshot() + if diff := cmp.Diff([]string{"fd00:cf:21:2::1/64"}, globalIPv6(hs, "cvd-mtap-02")); diff != "" { + t.Errorf("cvd-mtap-02 IPv6 after restart (-want +got):\n%s", diff) + } + // One RA dnsmasq per interface: the restarted ones replaced the old ones. + const wantRA = 2 + 2 // cvd-ebr, cvd-wbr, cvd-wifiap-01, cvd-wifiap-02 + countRA := func(s string) int { return strings.Count(s, "--enable-ra") } + if procs := f.waitDnsmasq(func(s string) bool { return countRA(s) == wantRA }); countRA(procs) != wantRA { + t.Errorf("%d RA dnsmasq processes after restart, want %d:\n%s", countRA(procs), wantRA, procs) + } + + f.initScript("stop") + f.requireNoLeak(base) +} + +// TestStaticIPv6StartTwice checks that start without a stop in between (the +// ipv6-enabled marker is left over) replaces the IPv6 setup instead of +// duplicating it: one RA dnsmasq per interface, one copy of each nft rule, +// and a following stop removes everything IPv6. IPv4 is not checked here: +// its setup is not idempotent (duplicate masquerade rules and dnsmasq). +func TestStaticIPv6StartTwice(t *testing.T) { + f := newIPv6Fixture(t) + f.writeDefaults("num_cvd_accounts=2") + + f.initScript("start") + f.s.Run("sh", f.script, "start") // IPv4 re-setup reports "File exists". + + for chain, want := range map[string]int{ + "ip6 cuttlefish_nat6 postrouting": 1, + "ip6 filter FORWARD": 2, + "bridge cuttlefish_ra_guard prerouting": 2, + "inet cuttlefish_ra_guard input": 2, + } { + out := f.sh("nft list chain " + chain) + if got := strings.Count(out, "counter packets"); got != want { + t.Errorf("%s: unexpected rules after second start, got %d want %d:\n%s", chain, got, want, out) + } + } + const wantRA = 2 + 2 // cvd-ebr, cvd-wbr, cvd-wifiap-01, cvd-wifiap-02 + countRA := func(s string) int { return strings.Count(s, "--enable-ra") } + if procs := f.waitDnsmasq(func(s string) bool { return countRA(s) == wantRA }); countRA(procs) != wantRA { + t.Errorf("%d RA dnsmasq processes after second start, want %d:\n%s", countRA(procs), wantRA, procs) + } + + f.initScript("stop") + if procs := f.waitDnsmasq(func(s string) bool { return countRA(s) == 0 }); countRA(procs) != 0 { + t.Errorf("RA dnsmasq still running after stop:\n%s", procs) + } + if ra := raDnsmasqIfaces(f.s); len(ra) != 0 { + t.Errorf("RA dnsmasq pidfiles left after stop: %v", ra) + } + for _, tb := range f.nftTables() { + if strings.Contains(tb, "nat6") || strings.Contains(tb, "ra_guard") || strings.Contains(tb, "ip6 filter") { + t.Errorf("IPv6 nft table left after stop: %s", tb) + } + } +} + +// TestStaticIPv6UnmanagedBridge checks bridge_interface: the host does not +// address or advertise on a bridge it does not manage, but still sets up the +// routed taps. +func TestStaticIPv6UnmanagedBridge(t *testing.T) { + f := newIPv6Fixture(t) + f.sh("ip link add br-test type bridge && ip link set br-test up") + f.writeDefaults("num_cvd_accounts=1", "bridge_interface=br-test") + base := f.snapshot() + f.initScript("start") + + hs := f.snapshot() + if g := globalIPv6(hs, "br-test"); len(g) != 0 { + t.Errorf("unmanaged bridge got IPv6 %v", g) + } + if diff := cmp.Diff([]string{"cvd-wifiap-01"}, raDnsmasqIfaces(f.s)); diff != "" { + t.Errorf("RA dnsmasq interfaces (-want +got):\n%s", diff) + } + if diff := cmp.Diff([]string{"fd00:cf:21:1::1/64"}, globalIPv6(hs, "cvd-mtap-01")); diff != "" { + t.Errorf("cvd-mtap-01 IPv6 (-want +got):\n%s", diff) + } + + f.initScript("stop") + f.requireNoLeak(base) +} + +// TestStaticIPv6PreservesUpstreamRA checks that enabling +// net.ipv6.conf.all.forwarding=1 promotes accept_ra from 1 to 2 on default and +// on existing upstream interfaces (or interfaces with RTF_ADDRCONF default +// routes) so kernel RA default routes are not purged by rt6_purge_dflt_routers +// and future RAs continue to be accepted, while interfaces with accept_ra=0 +// (e.g. NetworkManager userspace RA or cvd-*) stay at 0. +func TestStaticIPv6PreservesUpstreamRA(t *testing.T) { + f := newIPv6Fixture(t) + if _, err := f.s.Run("python3", "-c", "import socket"); err != nil { + t.Skipf("python3 is required to craft ICMPv6: %v", err) + } + f.writeDefaults("num_cvd_accounts=1") + + // Simulate an upstream interface eth0 (accept_ra=1) that received an RA + // from upstream-rtr, plus a dhclient0 interface that received an RA and + // then had accept_ra reset to 0 by dhclient-script, plus an nm0 interface + // with accept_ra=0 and no kernel RA route. + f.sh(`set -e +echo 0 > /proc/sys/net/ipv6/conf/all/forwarding +ip link add eth0 type veth peer name upstream-rtr +ip link add dhclient0 type veth peer name dhclient-rtr +ip link add nm0 type dummy +for dev in eth0 upstream-rtr dhclient0 dhclient-rtr nm0; do + echo 0 > /proc/sys/net/ipv6/conf/$dev/accept_dad +done +echo 1 > /proc/sys/net/ipv6/conf/eth0/accept_ra +echo 1 > /proc/sys/net/ipv6/conf/dhclient0/accept_ra +echo 0 > /proc/sys/net/ipv6/conf/nm0/accept_ra +ip link set eth0 up +ip link set upstream-rtr up +ip link set dhclient0 up +ip link set dhclient-rtr up +ip link set nm0 up`) + + if _, err := f.s.Run("python3", "-c", sendICMPv6, "upstream-rtr", "134"); err != nil { + t.Fatalf("sending RA on upstream-rtr: %v", err) + } + if _, err := f.s.Run("python3", "-c", sendICMPv6, "dhclient-rtr", "134"); err != nil { + t.Fatalf("sending RA on dhclient-rtr: %v", err) + } + // Simulate Debian 12 dhclient-script resetting accept_ra=0 after kernel RA route installation. + f.sh("echo 0 > /proc/sys/net/ipv6/conf/dhclient0/accept_ra") + + routesBefore := f.sh("ip -6 route show default") + if !strings.Contains(routesBefore, "dev eth0 proto ra") || !strings.Contains(routesBefore, "dev dhclient0 proto ra") { + t.Fatalf("expected proto ra default routes on eth0 and dhclient0 before start, got:\n%s", routesBefore) + } + + f.initScript("start") + t.Cleanup(func() { f.s.Run("sh", f.script, "stop") }) + + for _, dev := range []string{"default", "eth0", "dhclient0"} { + if got := strings.TrimSpace(f.sh("cat /proc/sys/net/ipv6/conf/" + dev + "/accept_ra")); got != "2" { + t.Errorf("%s/accept_ra = %s after start, want 2", dev, got) + } + } + if got := strings.TrimSpace(f.sh("cat /proc/sys/net/ipv6/conf/nm0/accept_ra")); got != "0" { + t.Errorf("nm0/accept_ra = %s after start, want 0", got) + } + + routesAfter := f.sh("ip -6 route show default") + if !strings.Contains(routesAfter, "dev eth0 proto ra") || !strings.Contains(routesAfter, "dev dhclient0 proto ra") { + t.Errorf("RA default routes were purged by start:\n%s", routesAfter) + } + + // Flush eth0's default route and send another RA while forwarding=1 to + // verify future RAs are still accepted on eth0. + f.sh("ip -6 route flush default dev eth0") + if _, err := f.s.Run("python3", "-c", sendICMPv6, "upstream-rtr", "134"); err != nil { + t.Fatalf("sending second RA on upstream-rtr: %v", err) + } + if routes := f.sh("ip -6 route show default dev eth0"); !strings.Contains(routes, "proto ra") { + t.Errorf("eth0 did not accept RA with forwarding=1: %q", routes) + } +} + +// TestStaticIPv6ForwardAcceptWithDockerDrop checks that explicit +// iifname/oifname "cvd-*" accept rules in ip6 filter FORWARD allow routed +// Cuttlefish IPv6 traffic in both directions (outbound from cvd-* and inbound +// to cvd-*) while dropping non-cvd-* traffic when Docker sets +// ip6tables -P FORWARD DROP (both when Docker starts before Cuttlefish and +// when Docker starts after Cuttlefish), remain compatible with ip6tables-nft, +// and are cleanly removed on stop without disturbing Docker's chains or policy. +func TestStaticIPv6ForwardAcceptWithDockerDrop(t *testing.T) { + for _, dockerFirst := range []bool{true, false} { + name := "cuttlefish_before_docker" + if dockerFirst { + name = "docker_before_cuttlefish" + } + t.Run(name, func(t *testing.T) { + f := newIPv6Fixture(t) + f.writeDefaults("num_cvd_accounts=1") + + setupDocker := func() { + f.sh(`set -e +ip6tables -P FORWARD DROP +ip6tables -N DOCKER-USER +ip6tables -A DOCKER-USER -j RETURN +ip6tables -I FORWARD 1 -j DOCKER-USER`) + } + + if dockerFirst { + setupDocker() + f.initScript("start") + } else { + f.initScript("start") + setupDocker() + } + + rules := f.sh("ip6tables -S FORWARD") + for _, want := range []string{ + "-P FORWARD DROP", + "-A FORWARD -j DOCKER-USER", + "-A FORWARD -i cvd-+ -j ACCEPT", + "-A FORWARD -o cvd-+ -j ACCEPT", + } { + if !strings.Contains(rules, want) { + t.Errorf("ip6tables -S FORWARD lacks %q:\n%s", want, rules) + } + } + + // Send: + // 1. Outbound packet from guest-m -> cvd-mtap-99 destined to 2001:db8:1::1 (via eth0), matching iifname "cvd-*". + // 2. Inbound packet from wan0 -> eth0 destined to fd00:cf:21:99::2 (via cvd-mtap-99), matching oifname "cvd-*". + // 3. Non-Cuttlefish packet from other-peer -> other0 destined to 2001:db8:1::1 (via eth0), which must be dropped by policy DROP. + f.sh(`set -e +ip link add cvd-mtap-99 address 02:00:00:00:00:01 type veth peer name guest-m address 02:00:00:00:00:02 +ip link add eth0 address 02:00:00:00:00:11 type veth peer name wan0 address 02:00:00:00:00:12 +ip link add other0 address 02:00:00:00:00:21 type veth peer name other-peer address 02:00:00:00:00:22 +for d in cvd-mtap-99 guest-m eth0 wan0 other0 other-peer; do + echo 0 > /proc/sys/net/ipv6/conf/$d/accept_dad + ip link set $d up +done +echo 0 > /proc/sys/net/ipv6/conf/guest-m/forwarding +echo 0 > /proc/sys/net/ipv6/conf/wan0/forwarding +ip -6 addr add fd00:cf:21:99::1/64 dev cvd-mtap-99 +ip -6 neigh add fd00:cf:21:99::2 lladdr 02:00:00:00:00:02 dev cvd-mtap-99 +ip -6 addr add 2001:db8:1::2/64 dev eth0 +ip -6 neigh add 2001:db8:1::1 lladdr 02:00:00:00:00:12 dev eth0 +ip -6 addr add fd00:99::1/64 dev other0 +python3 -c ' +import socket, struct +def send_pkt(tx_if, dst_mac, src_mac, src_ip_str, dst_ip_str): + eth = dst_mac + src_mac + b"\x86\xdd" + src_ip = socket.inet_pton(socket.AF_INET6, src_ip_str) + dst_ip = socket.inet_pton(socket.AF_INET6, dst_ip_str) + icmp6 = struct.pack("!BBHHH", 128, 0, 0, 1, 1) + ip6 = struct.pack("!IHBB", (6 << 28), len(icmp6), 58, 64) + src_ip + dst_ip + s = socket.socket(socket.AF_PACKET, socket.SOCK_RAW) + s.bind((tx_if, 0)) + s.send(eth + ip6 + icmp6) + s.close() + +send_pkt("guest-m", b"\x02\x00\x00\x00\x00\x01", b"\x02\x00\x00\x00\x00\x02", "fd00:cf:21:99::2", "2001:db8:1::1") +send_pkt("wan0", b"\x02\x00\x00\x00\x00\x11", b"\x02\x00\x00\x00\x00\x12", "2001:db8:1::1", "fd00:cf:21:99::2") +send_pkt("other-peer", b"\x02\x00\x00\x00\x00\x21", b"\x02\x00\x00\x00\x00\x22", "fd00:99::2", "2001:db8:1::1") +' +ip link del cvd-mtap-99 +ip link del eth0 +ip link del other0`) + + fwdChain := f.sh("nft list chain ip6 filter FORWARD") + if !regexp.MustCompile(`iifname "cvd-\*" counter packets [1-9]\d* bytes [1-9]\d* accept`).MatchString(fwdChain) { + t.Errorf("expected iifname cvd-* accept counter > 0 in ip6 filter FORWARD:\n%s", fwdChain) + } + if !regexp.MustCompile(`oifname "cvd-\*" counter packets [1-9]\d* bytes [1-9]\d* accept`).MatchString(fwdChain) { + t.Errorf("expected oifname cvd-* accept counter > 0 in ip6 filter FORWARD:\n%s", fwdChain) + } + iptVerbose := f.sh("ip6tables -v -L FORWARD") + if !regexp.MustCompile(`policy DROP [1-9]\d* packets`).MatchString(iptVerbose) { + t.Errorf("expected non-cvd-* packet to be dropped by FORWARD policy DROP:\n%s", iptVerbose) + } + + f.initScript("stop") + afterStop := f.sh("ip6tables -S FORWARD") + if !strings.Contains(afterStop, "-P FORWARD DROP") || !strings.Contains(afterStop, "-A FORWARD -j DOCKER-USER") { + t.Errorf("Docker FORWARD state lost after stop:\n%s", afterStop) + } + if strings.Contains(afterStop, "cvd-") { + t.Errorf("cvd-* FORWARD rule leaked after stop:\n%s", afterStop) + } + }) + } +} + +// TestStaticIPv6ForwardingReadOnly checks read-only sysctl handling for both +// IPv6 and IPv4 forwarding, as well as the packaged sysctl.d configuration: +// - when /proc/sys/net/ipv6/conf/all/forwarding is read-only and 0, start logs +// an explicit error to stderr and skips IPv6 setup while keeping IPv4 working; +// - when it is read-only and already 1 (as in podcvd with --sysctl), start +// succeeds and configures IPv6 normally; +// - when /proc/sys/net/ipv4/ip_forward is read-only and 0, start fails with an +// explicit error when allocate_static_resources=1, but succeeds without +// mutating sysctls (and still sets up /.dockerenv device permissions) when +// allocate_static_resources=0; +// - 90-cuttlefish-ip-forward.conf sets net.ipv4.ip_forward=1 and +// net.ipv6.conf.default.accept_ra=2 without enabling early-boot +// net.ipv6.conf.all.forwarding=1. +func TestStaticIPv6ForwardingReadOnly(t *testing.T) { + t.Run("read_only_zero_logs_and_skips_ipv6", func(t *testing.T) { + f := newIPv6Fixture(t) + f.writeDefaults("num_cvd_accounts=1") + f.sh(`set -e +echo 0 > /proc/sys/net/ipv6/conf/all/forwarding +echo 0 > /tmp/ro_zero +mount --bind /tmp/ro_zero /proc/sys/net/ipv6/conf/all/forwarding +mount -o remount,bind,ro /proc/sys/net/ipv6/conf/all/forwarding`) + t.Cleanup(func() { f.s.Run("umount", "/proc/sys/net/ipv6/conf/all/forwarding") }) + + out, err := f.s.Run("sh", f.script, "start") + if err != nil { + t.Fatalf("start failed: %v", err) + } + if want := "failed to enable net.ipv6.conf.all.forwarding; skipping IPv6 setup"; !strings.Contains(out.Stderr, want) { + t.Errorf("stderr lacks %q:\n%s", want, out.Stderr) + } + hs := f.snapshot() + if got := hs.PrimaryIPv4("cvd-ebr"); got != "192.168.98.1/24" { + t.Errorf("cvd-ebr IPv4 = %q, want 192.168.98.1/24", got) + } + if g := globalIPv6(hs, "cvd-ebr"); len(g) != 0 { + t.Errorf("cvd-ebr got IPv6 %v when forwarding could not be enabled", g) + } + f.initScript("stop") + }) + + t.Run("read_only_preset_one_succeeds", func(t *testing.T) { + f := newIPv6Fixture(t) + f.writeDefaults("num_cvd_accounts=1") + f.sh(`set -e +echo 1 > /proc/sys/net/ipv6/conf/all/forwarding +echo 1 > /tmp/ro_one +mount --bind /tmp/ro_one /proc/sys/net/ipv6/conf/all/forwarding +mount -o remount,bind,ro /proc/sys/net/ipv6/conf/all/forwarding`) + t.Cleanup(func() { f.s.Run("umount", "/proc/sys/net/ipv6/conf/all/forwarding") }) + + f.initScript("start") + hs := f.snapshot() + if diff := cmp.Diff([]string{"fd00:cf:21:1::1/64"}, globalIPv6(hs, "cvd-mtap-01")); diff != "" { + t.Errorf("cvd-mtap-01 IPv6 (-want +got):\n%s", diff) + } + f.initScript("stop") + }) + + t.Run("ipv4_read_only_zero_fails_when_static_allocated", func(t *testing.T) { + f := newIPv6Fixture(t) + f.writeDefaults("num_cvd_accounts=1", "allocate_static_resources=1") + f.sh(`set -e +echo 0 > /proc/sys/net/ipv4/ip_forward +echo 0 > /tmp/ro_v4_zero +mount --bind /tmp/ro_v4_zero /proc/sys/net/ipv4/ip_forward +mount -o remount,bind,ro /proc/sys/net/ipv4/ip_forward`) + t.Cleanup(func() { f.s.Run("umount", "/proc/sys/net/ipv4/ip_forward") }) + + out, err := f.s.Run("sh", f.script, "start") + if err == nil { + t.Fatalf("expected start to fail when ip_forward is read-only 0 and allocate_static_resources=1") + } + if want := "failed to enable net.ipv4.ip_forward"; !strings.Contains(out.Stderr, want) { + t.Errorf("stderr lacks %q:\n%s", want, out.Stderr) + } + }) + + t.Run("ipv4_read_only_zero_succeeds_when_cvdalloc", func(t *testing.T) { + f := newIPv6Fixture(t) + f.writeDefaults("allocate_static_resources=0") + f.sh(`set -e +echo 0 > /proc/sys/net/ipv4/ip_forward +echo 0 > /proc/sys/net/ipv6/conf/all/forwarding +echo 0 > /tmp/ro_v4_zero +mount --bind /tmp/ro_v4_zero /proc/sys/net/ipv4/ip_forward +mount -o remount,bind,ro /proc/sys/net/ipv4/ip_forward`) + t.Cleanup(func() { f.s.Run("umount", "/proc/sys/net/ipv4/ip_forward") }) + + f.initScript("start") + if got := strings.TrimSpace(f.sh("cat /proc/sys/net/ipv6/conf/all/forwarding")); got != "0" { + t.Errorf("all/forwarding mutated to %s when allocate_static_resources=0, want 0", got) + } + f.initScript("stop") + }) + + t.Run("sysctl_d_conf_sets_default_accept_ra_2", func(t *testing.T) { + f := newIPv6Fixture(t) + realInit, err := filepath.EvalSymlinks(f.script) + if err != nil { + t.Fatalf("EvalSymlinks(%q): %v", f.script, err) + } + sysctlConf := filepath.Join(filepath.Dir(realInit), "../host/packages/cuttlefish-base/usr/lib/sysctl.d/90-cuttlefish-ip-forward.conf") + f.sh(fmt.Sprintf(`set -e +echo 0 > /proc/sys/net/ipv4/ip_forward +echo 1 > /proc/sys/net/ipv6/conf/default/accept_ra +echo 0 > /proc/sys/net/ipv6/conf/all/forwarding +sysctl -p %q`, sysctlConf)) + if got := strings.TrimSpace(f.sh("cat /proc/sys/net/ipv4/ip_forward")); got != "1" { + t.Errorf("net.ipv4.ip_forward = %s, want 1", got) + } + if got := strings.TrimSpace(f.sh("cat /proc/sys/net/ipv6/conf/default/accept_ra")); got != "2" { + t.Errorf("net.ipv6.conf.default.accept_ra = %s, want 2", got) + } + if got := strings.TrimSpace(f.sh("cat /proc/sys/net/ipv6/conf/all/forwarding")); got != "0" { + t.Errorf("net.ipv6.conf.all.forwarding = %s after loading sysctl.d conf, want 0", got) + } + }) +} + +// TestStaticIPv6DefaultDisableIPv6 checks that when a host sets +// net.ipv6.conf.default.disable_ipv6=1 while net.ipv6.conf.all.disable_ipv6=0, +// start_ipv6 enables IPv6 on the cvd-* bridges and taps and configures IPv6 +// addresses without error. +func TestStaticIPv6DefaultDisableIPv6(t *testing.T) { + f := newIPv6Fixture(t) + f.writeDefaults("num_cvd_accounts=1") + f.sh("echo 1 > /proc/sys/net/ipv6/conf/default/disable_ipv6") + base := f.snapshot() + f.initScript("start") + + hs := f.snapshot() + for ifname, w := range map[string]string{ + "cvd-ebr": "fd00:cf:24::1/64", + "cvd-wbr": "fd00:cf:22::1/64", + "cvd-mtap-01": "fd00:cf:21:1::1/64", + "cvd-wifiap-01": "fd00:cf:23:1::1/64", + } { + if diff := cmp.Diff([]string{w}, globalIPv6(hs, ifname)); diff != "" { + t.Errorf("global IPv6 of %s (-want +got):\n%s", ifname, diff) + } + } + + f.initScript("stop") + f.requireNoLeak(base) +} + +// TestStaticIPv6ShrinkAccountsStopsOrphanRaDnsmasq checks that when +// num_cvd_accounts is reduced in /etc/default/cuttlefish-host-resources +// between start and stop, stop_ipv6 terminates all RA dnsmasq daemons via +// /var/run/cuttlefish-dnsmasq-ra-*.pid rather than leaving higher-numbered +// instances running. +func TestStaticIPv6ShrinkAccountsStopsOrphanRaDnsmasq(t *testing.T) { + f := newIPv6Fixture(t) + f.writeDefaults("num_cvd_accounts=2") + f.initScript("start") + + if diff := cmp.Diff([]string{"cvd-ebr", "cvd-wbr", "cvd-wifiap-01", "cvd-wifiap-02"}, raDnsmasqIfaces(f.s)); diff != "" { + t.Fatalf("RA dnsmasq interfaces after start (-want +got):\n%s", diff) + } + + // Shrink num_cvd_accounts from 2 to 1 before stopping. + f.writeDefaults("num_cvd_accounts=1") + f.initScript("stop") + + if ra := raDnsmasqIfaces(f.s); len(ra) != 0 { + t.Errorf("RA dnsmasq pidfiles left after stop with shrunk num_cvd_accounts: %v", ra) + } + if p := f.waitDnsmasq(func(s string) bool { return s == "" }); p != "" { + t.Errorf("dnsmasq still running after stop with shrunk num_cvd_accounts:\n%s", p) + } +} + +// TestStaticIPv6PostrmPurgeCleanup checks that running +// cuttlefish-base.postrm purge removes routed IPv6 prefixes, cuttlefish_* +// nftables tables, ip6 filter FORWARD rules, dnsmasq processes, and +// /run/cuttlefish state even if stop was not run prior to purge. +func TestStaticIPv6PostrmPurgeCleanup(t *testing.T) { + f := newIPv6Fixture(t) + postrmRel := os.Getenv("POSTRM_SCRIPT") + if postrmRel == "" { + t.Fatal("POSTRM_SCRIPT env var is not set") + } + postrm, err := runfiles.Rlocation(postrmRel) + if err != nil { + t.Fatalf("locating %q: %v", postrmRel, err) + } + f.writeDefaults("num_cvd_accounts=1", "ipv6_routed_prefix=2001:db8:cf00::/48") + f.initScript("start") + + if routes := f.sh("ip -6 route show 2001:db8:cf00:2501::/64"); !strings.Contains(routes, "via 2001:db8:cf00:2301::2") { + t.Fatalf("expected routed OpenWrt LAN route before purge, got: %q", routes) + } + + if _, err := f.s.Run("sh", postrm, "purge"); err != nil { + t.Fatalf("postrm purge: %v", err) + } + if routes := strings.TrimSpace(f.sh("ip -6 route show 2001:db8:cf00:2501::/64")); routes != "" { + t.Errorf("routed OpenWrt LAN route left after postrm purge: %q", routes) + } + if tables := f.nftTables(); len(tables) != 0 { + t.Errorf("nft tables left after postrm purge: %v", tables) + } + if files := common.HandleFiles(f.s); len(files) != 0 { + t.Errorf("/run/cuttlefish not empty after postrm purge: %v", files) + } + if p := f.waitDnsmasq(func(s string) bool { return s == "" }); p != "" { + t.Errorf("dnsmasq still running after postrm purge:\n%s", p) + } +} + +// TestStaticIPv6EgressDetectionControlsDnsServerAndMarker checks that when the +// host has kernel IPv6 enabled (disable_ipv6=0) but no IPv6 default route and +// no explicit dns6_servers override, start_ipv6 keeps ULA + RA prefix +// advertisements on cvd-* while omitting option6:dns-server and leaving +// /run/cuttlefish/ipv6-egress absent (so OpenWrt dnsmasq and RIL do not blackhole +// DNS queries to unreachable IPv6 servers), whereas when an IPv6 default route +// is present, start_ipv6 advertises option6:dns-server and writes +// /run/cuttlefish/ipv6-egress. +func TestStaticIPv6EgressDetectionControlsDnsServerAndMarker(t *testing.T) { + t.Run("no_default_route_omits_dns_server_and_marker", func(t *testing.T) { + f := newIPv6Fixture(t) + f.writeDefaults("num_cvd_accounts=1") + base := f.snapshot() + + out, err := f.s.Run("sh", f.script, "start") + if err != nil { + t.Fatalf("start: %v", err) + } + if want := "no IPv6 default route on host; omitting IPv6 DNS servers"; !strings.Contains(out.Stderr, want) { + t.Errorf("stderr lacks %q:\n%s", want, out.Stderr) + } + + hs := f.snapshot() + for ifname, w := range map[string]string{ + "cvd-ebr": "fd00:cf:24::1/64", + "cvd-wbr": "fd00:cf:22::1/64", + "cvd-mtap-01": "fd00:cf:21:1::1/64", + "cvd-wifiap-01": "fd00:cf:23:1::1/64", + } { + if diff := cmp.Diff([]string{w}, globalIPv6(hs, ifname)); diff != "" { + t.Errorf("global IPv6 of %s (-want +got):\n%s", ifname, diff) + } + } + + for _, ifname := range []string{"cvd-ebr", "cvd-wbr", "cvd-wifiap-01"} { + cmdline := f.sh(fmt.Sprintf("tr '\\0' ' ' < /proc/$(cat /run/cuttlefish-dnsmasq-ra-%s.pid)/cmdline", ifname)) + if !strings.Contains(cmdline, "--enable-ra") { + t.Errorf("RA dnsmasq on %s lacks --enable-ra: %s", ifname, cmdline) + } + if strings.Contains(cmdline, "option6:dns-server") { + t.Errorf("RA dnsmasq on %s unexpectedly advertised option6:dns-server without host IPv6 default route: %s", ifname, cmdline) + } + } + + files := common.HandleFiles(f.s) + if !slices.Contains(files, "ipv6-enabled") { + t.Errorf("expected ipv6-enabled marker in /run/cuttlefish, got: %v", files) + } + if slices.Contains(files, "ipv6-egress") { + t.Errorf("unexpected ipv6-egress marker in /run/cuttlefish without host IPv6 default route: %v", files) + } + + f.initScript("stop") + f.requireNoLeak(base) + }) + + t.Run("with_default_route_emits_dns_server_and_marker", func(t *testing.T) { + f := newIPv6Fixture(t) + f.writeDefaults("num_cvd_accounts=1") + f.sh(`set -e +ip link add eth0 type dummy +echo 0 > /proc/sys/net/ipv6/conf/eth0/accept_dad +ip link set eth0 up +ip -6 addr add 2001:db8:ffff::2/64 dev eth0 +ip -6 route add default via 2001:db8:ffff::1 dev eth0`) + base := f.snapshot() + + f.initScript("start") + + for _, ifname := range []string{"cvd-ebr", "cvd-wbr", "cvd-wifiap-01"} { + cmdline := f.sh(fmt.Sprintf("tr '\\0' ' ' < /proc/$(cat /run/cuttlefish-dnsmasq-ra-%s.pid)/cmdline", ifname)) + want := "--dhcp-option=option6:dns-server,2001:4860:4860::8888,2001:4860:4860::8844" + if !strings.Contains(cmdline, want) { + t.Errorf("RA dnsmasq on %s lacks %q when host has IPv6 default route: %s", ifname, want, cmdline) + } + } + + files := common.HandleFiles(f.s) + if !slices.Contains(files, "ipv6-egress") { + t.Errorf("expected ipv6-egress marker in /run/cuttlefish when host has IPv6 default route, got: %v", files) + } + if got := strings.TrimSpace(f.sh("cat /run/cuttlefish/ipv6-egress")); got != "2001:4860:4860::8888,2001:4860:4860::8844" { + t.Errorf("/run/cuttlefish/ipv6-egress = %q, want 2001:4860:4860::8888,2001:4860:4860::8844", got) + } + + f.initScript("stop") + f.requireNoLeak(base) + }) +} diff --git a/base/cvd/host_tests/static_resources_init_test/main_test.go b/base/cvd/host_tests/static_resources_init_test/main_test.go index 1be421572d7..1ac72a9f2b7 100644 --- a/base/cvd/host_tests/static_resources_init_test/main_test.go +++ b/base/cvd/host_tests/static_resources_init_test/main_test.go @@ -60,17 +60,25 @@ func TestStaticResourcesInit(t *testing.T) { }, NftTables: []common.NftTable{ {Family: "ip", Name: "cuttlefish_nat"}, + {Family: "ip6", Name: "cuttlefish_nat6"}, + {Family: "ip6", Name: "filter"}, {Family: "bridge", Name: "cuttlefish_bridge"}, + {Family: "bridge", Name: "cuttlefish_ra_guard"}, + {Family: "inet", Name: "cuttlefish_ra_guard"}, }, NftChains: []common.NftChain{ {Family: "ip", Table: "cuttlefish_nat", Name: "postrouting", Type: "nat", Hook: "postrouting"}, + {Family: "ip6", Table: "cuttlefish_nat6", Name: "postrouting", Type: "nat", Hook: "postrouting"}, + {Family: "ip6", Table: "filter", Name: "FORWARD", Type: "filter", Hook: "forward"}, {Family: "bridge", Table: "cuttlefish_bridge", Name: "prerouting", Type: "filter", Hook: "prerouting"}, {Family: "bridge", Table: "cuttlefish_bridge", Name: "forward", Type: "filter", Hook: "forward"}, + {Family: "bridge", Table: "cuttlefish_ra_guard", Name: "prerouting", Type: "filter", Hook: "prerouting"}, + {Family: "inet", Table: "cuttlefish_ra_guard", Name: "input", Type: "filter", Hook: "input"}, }, - Masquerades: []string{"192.168.94.0/30", "192.168.96.0/24", "192.168.97.0/30", "192.168.98.0/24"}, + Masquerades: []string{"192.168.94.0/30", "192.168.96.0/24", "192.168.97.0/30", "192.168.98.0/24", "fd00:cf:20::/44"}, Sysctls: map[string]string{"net.ipv4.ip_forward": "1", "net.ipv6.conf.all.forwarding": "1"}, - HandleFiles: []string{"masq-br-cvd-ebr.handle", "masq-br-cvd-wbr.handle", "masq-cvd-mtap-01.handle", "masq-cvd-wifiap-01.handle"}, - DnsmasqIfaces: []string{"cvd-ebr", "cvd-wbr"}, + HandleFiles: []string{"ip6fwd-in.handle", "ip6fwd-out.handle", "ipv6-enabled", "masq-br-cvd-ebr.handle", "masq-br-cvd-wbr.handle", "masq-cvd-mtap-01.handle", "masq-cvd-wifiap-01.handle"}, + DnsmasqIfaces: []string{"cvd-ebr", "cvd-wbr", "ra-cvd-ebr", "ra-cvd-wbr", "ra-cvd-wifiap-01"}, }, }, } diff --git a/base/cvd/host_tests/static_resources_init_test/routed_test.go b/base/cvd/host_tests/static_resources_init_test/routed_test.go new file mode 100644 index 00000000000..59d99c63e98 --- /dev/null +++ b/base/cvd/host_tests/static_resources_init_test/routed_test.go @@ -0,0 +1,311 @@ +// 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 + +// IPv6 routed mode of cuttlefish-host-resources (ipv6_routed_prefix in +// /etc/default/cuttlefish-host-resources): every guest network gets a /64 +// from a routed /48, with no NAT66, and the host routes each OpenWrt Wi-Fi +// LAN /64 to that instance's OpenWrt WAN address. + +import ( + "fmt" + "regexp" + "slices" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" +) + +// routedPrefix is the /48 used by the tests (documentation range, RFC 3849). +const ( + routedPrefix = "2001:db8:cf00::/48" + routedBase = "2001:db8:cf00" +) + +// routedNet returns the routed mode /64 prefix ("P:::") of network +// net (21 mobile, 23 OpenWrt WAN, 25 OpenWrt LAN) for instance i. +func routedNet(net, i int) string { return fmt.Sprintf("%s:%d%02x::", routedBase, net, i) } + +// lanRoutes returns the IPv6 routes to OpenWrt LAN /64s of the routed prefix, +// one " via dev " per entry, sorted. +func (f *ipv6Fixture) lanRoutes() []string { + f.t.Helper() + var out []string + re := regexp.MustCompile(`^(\S+) via (\S+) dev (\S+)`) + for _, line := range strings.Split(f.sh("ip -6 route show"), "\n") { + m := re.FindStringSubmatch(strings.TrimSpace(line)) + if m == nil || !strings.HasPrefix(m[1], routedBase+":25") { + continue + } + out = append(out, m[1]+" via "+m[2]+" dev "+m[3]) + } + slices.Sort(out) + return out +} + +func wantLanRoutes(n int) []string { + var want []string + for i := 1; i <= n; i++ { + want = append(want, fmt.Sprintf("%s/64 via %s2 dev %s", routedNet(25, i), routedNet(23, i), tapName("wifiap", i))) + } + slices.Sort(want) + return want +} + +// TestRoutedIPv6Addressing checks the routed mode address plan, the RA +// prefixes, the absence of NAT66 and the routes to the OpenWrt LAN /64s, +// with 10 accounts so instance 10 exercises the hex instance number. +func TestRoutedIPv6Addressing(t *testing.T) { + const n = 10 + f := newIPv6Fixture(t) + f.writeDefaults(fmt.Sprintf("num_cvd_accounts=%d", n), "ipv6_routed_prefix="+routedPrefix) + base := f.snapshot() + f.initScript("start") + + hs := f.snapshot() + want := map[string][]string{ + "cvd-ebr": {routedBase + ":24::1/64"}, + "cvd-wbr": {routedBase + ":22::1/64"}, + } + for i := 1; i <= n; i++ { + want[tapName("mtap", i)] = []string{routedNet(21, i) + "1/64"} + want[tapName("wifiap", i)] = []string{routedNet(23, i) + "1/64"} + want[tapName("etap", i)] = nil + want[tapName("wtap", i)] = nil + } + for ifname, w := range want { + if diff := cmp.Diff(w, globalIPv6(hs, ifname)); diff != "" { + t.Errorf("global IPv6 of %s (-want +got):\n%s", ifname, diff) + } + } + + // RAs as in private mode (bridges and cvd-wifiap-XX), with routed prefixes. + for ifname, prefix := range map[string]string{ + "cvd-ebr": routedBase + ":24::", + "cvd-wbr": routedBase + ":22::", + tapName("wifiap", 1): routedNet(23, 1), + tapName("wifiap", n): routedNet(23, n), + } { + cmdline := f.sh(fmt.Sprintf("tr '\\0' ' ' < /proc/$(cat /run/cuttlefish-dnsmasq-ra-%s.pid)/cmdline", ifname)) + if arg := "--dhcp-range=" + prefix + ",ra-only,64"; !strings.Contains(cmdline, arg) { + t.Errorf("RA dnsmasq on %s lacks %q: %s", ifname, arg, cmdline) + } + } + wantRA := []string{"cvd-ebr", "cvd-wbr"} + for i := 1; i <= n; i++ { + wantRA = append(wantRA, tapName("wifiap", i)) + } + slices.Sort(wantRA) + if diff := cmp.Diff(wantRA, raDnsmasqIfaces(f.s)); diff != "" { + t.Errorf("RA dnsmasq interfaces (-want +got):\n%s", diff) + } + + // No NAT66: the table exists (so stop is the same in both modes), but + // has no rule. + if nat6 := f.sh("nft list chain ip6 cuttlefish_nat6 postrouting"); strings.Contains(nat6, "masquerade") { + t.Errorf("NAT66 rule in routed mode:\n%s", nat6) + } + // The RA guard is the same as in private mode. + inetGuard := f.sh("nft list chain inet cuttlefish_ra_guard input") + for _, p := range []string{"cvd-mtap-*", "cvd-wifiap-*"} { + if !guardRuleFor(inetGuard, p) { + t.Errorf("no RA/redirect drop rule for %s:\n%s", p, inetGuard) + } + } + + if diff := cmp.Diff(wantLanRoutes(n), f.lanRoutes()); diff != "" { + t.Errorf("OpenWrt LAN routes (-want +got):\n%s", diff) + } + + f.initScript("stop") + if r := f.lanRoutes(); len(r) != 0 { + t.Errorf("OpenWrt LAN routes left after stop: %v", r) + } + f.requireNoLeak(base) +} + +// TestRoutedIPv6ShortPrefix checks a /48 written with fewer than three +// hextets: 2001:db8::/48 is 2001:db8:0::/48. +func TestRoutedIPv6ShortPrefix(t *testing.T) { + f := newIPv6Fixture(t) + f.writeDefaults("num_cvd_accounts=1", "ipv6_routed_prefix=2001:DB8::/48") + base := f.snapshot() + f.initScript("start") + + hs := f.snapshot() + for ifname, w := range map[string]string{ + "cvd-ebr": "2001:db8:0:24::1/64", + "cvd-mtap-01": "2001:db8:0:2101::1/64", + "cvd-wifiap-01": "2001:db8:0:2301::1/64", + } { + if diff := cmp.Diff([]string{w}, globalIPv6(hs, ifname)); diff != "" { + t.Errorf("global IPv6 of %s (-want +got):\n%s", ifname, diff) + } + } + if r := f.sh("ip -6 route show 2001:db8:0:2501::/64"); !strings.Contains(r, "via 2001:db8:0:2301::2 dev cvd-wifiap-01") { + t.Errorf("route to the OpenWrt LAN missing: %q", r) + } + + f.initScript("stop") + f.requireNoLeak(base) +} + +// TestRoutedIPv6InvalidPrefix checks that a value that is not a /48 is +// rejected with a message, and that private mode is used instead. +func TestRoutedIPv6InvalidPrefix(t *testing.T) { + for _, prefix := range []string{ + "2001:db8:cf00::/56", + "2001:db8:cf00::/64", + "2001:db8:cf00:1::/48", + "2001:db8:cf00::", + "not-a-prefix", + } { + t.Run(prefix, func(t *testing.T) { + f := newIPv6Fixture(t) + f.writeDefaults("num_cvd_accounts=1", "ipv6_routed_prefix="+prefix) + base := f.snapshot() + out, err := f.s.Run("sh", f.script, "start") + if err != nil { + t.Fatalf("init script start: %v", err) + } + if want := "invalid ipv6_routed_prefix '" + prefix + "'"; !strings.Contains(out.Stderr, want) { + t.Errorf("stderr lacks %q:\n%s", want, out.Stderr) + } + + hs := f.snapshot() + for ifname, w := range map[string]string{ + "cvd-ebr": "fd00:cf:24::1/64", + "cvd-wbr": "fd00:cf:22::1/64", + "cvd-mtap-01": "fd00:cf:21:1::1/64", + "cvd-wifiap-01": "fd00:cf:23:1::1/64", + } { + if diff := cmp.Diff([]string{w}, globalIPv6(hs, ifname)); diff != "" { + t.Errorf("global IPv6 of %s (-want +got):\n%s", ifname, diff) + } + } + nat6 := f.sh("nft list chain ip6 cuttlefish_nat6 postrouting") + if !strings.Contains(nat6, "ip6 saddr fd00:cf:20::/44") || strings.Count(nat6, "masquerade") != 1 { + t.Errorf("want the private mode NAT66 rule:\n%s", nat6) + } + if r := f.lanRoutes(); len(r) != 0 { + t.Errorf("OpenWrt LAN routes in private mode: %v", r) + } + + f.initScript("stop") + f.requireNoLeak(base) + }) + } +} + +// TestRoutedIPv6NatOverride checks ipv6_nat: 1 in routed mode NATs the +// routed /48, 0 in private mode turns NAT66 off. +func TestRoutedIPv6NatOverride(t *testing.T) { + for _, c := range []struct { + name string + defaults []string + wantNat string // expected masquerade source, "" for none + }{ + {"routed_nat_on", []string{"ipv6_routed_prefix=" + routedPrefix, "ipv6_nat=1"}, routedPrefix}, + {"routed_default", []string{"ipv6_routed_prefix=" + routedPrefix}, ""}, + {"private_nat_off", []string{"ipv6_nat=0"}, ""}, + {"private_default", nil, "fd00:cf:20::/44"}, + {"invalid_value", []string{"ipv6_routed_prefix=" + routedPrefix, "ipv6_nat=yes"}, ""}, + } { + t.Run(c.name, func(t *testing.T) { + f := newIPv6Fixture(t) + f.writeDefaults(append([]string{"num_cvd_accounts=1"}, c.defaults...)...) + base := f.snapshot() + f.initScript("start") + + nat6 := f.sh("nft list chain ip6 cuttlefish_nat6 postrouting") + if c.wantNat == "" { + if strings.Contains(nat6, "masquerade") { + t.Errorf("unexpected NAT66 rule:\n%s", nat6) + } + } else { + re := regexp.MustCompile(`ip6 saddr ` + regexp.QuoteMeta(c.wantNat) + ` oifname != "cvd-\*" counter packets \d+ bytes \d+ masquerade`) + if got := len(re.FindAllString(nat6, -1)); got != 1 || strings.Count(nat6, "masquerade") != 1 { + t.Errorf("want exactly 1 NAT66 rule for %s:\n%s", c.wantNat, nat6) + } + } + + f.initScript("stop") + f.requireNoLeak(base) + }) + } +} + +// TestRoutedIPv6StartTwice checks that a second start (the ipv6-enabled +// marker is left over, the interfaces still exist) removes the routes of the +// first start before adding them again, and that stop removes them. +func TestRoutedIPv6StartTwice(t *testing.T) { + const n = 2 + f := newIPv6Fixture(t) + f.writeDefaults(fmt.Sprintf("num_cvd_accounts=%d", n), "ipv6_routed_prefix="+routedPrefix) + + f.initScript("start") + f.s.Run("sh", f.script, "start") // IPv4 re-setup reports "File exists". + + if diff := cmp.Diff(wantLanRoutes(n), f.lanRoutes()); diff != "" { + t.Errorf("OpenWrt LAN routes after second start (-want +got):\n%s", diff) + } + if got := strings.Count(f.sh("cat /run/cuttlefish/ipv6-routes"), "\n"); got != n { + t.Errorf("/run/cuttlefish/ipv6-routes has %d lines, want %d", got, n) + } + + f.initScript("stop") + if r := f.lanRoutes(); len(r) != 0 { + t.Errorf("OpenWrt LAN routes left after stop: %v", r) + } + if strings.Contains(f.sh("ls /run/cuttlefish"), "ipv6-routes") { + t.Error("/run/cuttlefish/ipv6-routes left after stop") + } +} + +// TestRoutedIPv6ModeSwitch checks that the routes recorded by a routed mode +// start are removed by the next start even after the configuration was +// switched back to private mode (the routes file, not the configuration, +// decides what stop removes). +func TestRoutedIPv6ModeSwitch(t *testing.T) { + f := newIPv6Fixture(t) + f.writeDefaults("num_cvd_accounts=1", "ipv6_routed_prefix="+routedPrefix) + f.initScript("start") + if diff := cmp.Diff(wantLanRoutes(1), f.lanRoutes()); diff != "" { + t.Fatalf("OpenWrt LAN routes (-want +got):\n%s", diff) + } + + f.writeDefaults("num_cvd_accounts=1") + f.s.Run("sh", f.script, "start") // IPv4 re-setup reports "File exists". + if r := f.lanRoutes(); len(r) != 0 { + t.Errorf("routed mode routes left after switching to private mode: %v", r) + } + hs := f.snapshot() + for ifname, w := range map[string]string{ + "cvd-ebr": "fd00:cf:24::1/64", + "cvd-wbr": "fd00:cf:22::1/64", + "cvd-mtap-01": "fd00:cf:21:1::1/64", + "cvd-wifiap-01": "fd00:cf:23:1::1/64", + } { + if diff := cmp.Diff([]string{w}, globalIPv6(hs, ifname)); diff != "" { + t.Errorf("global IPv6 of %s after mode switch (-want +got):\n%s", ifname, diff) + } + } + nat6 := f.sh("nft list chain ip6 cuttlefish_nat6 postrouting") + if !strings.Contains(nat6, "ip6 saddr fd00:cf:20::/44") { + t.Errorf("want the private mode NAT66 rule after the switch:\n%s", nat6) + } + f.initScript("stop") +} diff --git a/base/debian/cuttlefish-base.cuttlefish-host-resources.default b/base/debian/cuttlefish-base.cuttlefish-host-resources.default index bf79e3f91dc..65341c69cf4 100644 --- a/base/debian/cuttlefish-base.cuttlefish-host-resources.default +++ b/base/debian/cuttlefish-base.cuttlefish-host-resources.default @@ -5,8 +5,8 @@ # Network bridge to use with the cuttlefish wifi and ethernet tap devices. # By default, we will create and destroy managed bridges called 'cvd-wbr' -# and 'cvd-ebr'. These bridges will be assigned IPv4 addresses and NAT'ed. -# IPv6 will not be configured unless '*_ipv6_prefix' is given. Setting a +# and 'cvd-ebr'. These bridges will be assigned IPv4 addresses and NAT'ed, +# and IPv6 prefixes (see '*_ipv6_prefix' below). Setting a # preconfigured bridge interface here (usually with just one physical # ethernet controller in it) suppresses bridge management, and instead # adds the cuttlefish wifi and ethernet tap devices to the specified @@ -28,19 +28,75 @@ # A comma separated list of IPv6 addresses used by cuttlefish guests # to resolve domain names. There must be no spaces after the commas. +# When left unset and the host has no IPv6 default route at service start, +# IPv6 DNS servers are omitted from RAs and RIL so guest DNS resolves over +# IPv4 without blackholing. Set dns6_servers explicitly (or ipv6_egress=1) +# to advertise IPv6 DNS servers even without a host IPv6 default route. #dns6_servers=2001:4860:4860::8888,2001:4860:4860::8844 +#ipv6_egress= + +# IPv6 is set up after IPv4, only if the host has IPv6 enabled +# (net.ipv6.conf.all.disable_ipv6=0). An IPv6 failure does not affect IPv4. +# The default prefixes are Unique Local Addresses (ULA, RFC 4193). Guest +# traffic from fd00:cf:20::/44, which covers all the defaults below, is NATed +# (NAT66) when it leaves the host. Prefixes outside that range are not NATed, +# so they must be routed to this host. +# The defaults use fixed Global IDs (the fd00:cf:2X::/48 prefixes) instead of +# the pseudo-random ones that RFC 4193 section 3.2 asks for, so that addresses +# are the same on every host and easy to recognize in tests and documentation. +# The guest networks are private to this host and NATed, so they are not +# routed to other sites. If they overlap a ULA prefix used on your network, +# set a random prefix below (and ipv6_nat_source to match). # 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= +# 'bridge_interface' not to be set. The host sends router advertisements on +# the bridges, and guests configure their addresses with SLAAC, which needs a +# prefix length of 64. dnsmasq does not send router advertisements for other +# lengths. +#wifi_ipv6_prefix=fd00:cf:22:: +#wifi_ipv6_prefix_length=64 +#ethernet_ipv6_prefix=fd00:cf:24:: +#ethernet_ipv6_prefix_length=64 + +# Bases of the per-instance IPv6 prefixes of the point-to-point taps. Instance +# i gets :::/64, for example cvd-mtap-10 gets fd00:cf:21:a::/64. +# The host sends router advertisements on cvd-wifiap-XX (OpenWrt WAN), but not +# on cvd-mtap-XX: the mobile network gets its IPv6 address from the modem. +#mobile_ipv6_prefix_base=fd00:cf:21 +#wifiap_ipv6_prefix_base=fd00:cf:23 + +# Guest IPv6 source prefix that is NATed (NAT66) when it leaves the host. +# Change it together with the prefixes above. In routed mode (below) the +# default is the routed /48. +#ipv6_nat_source=fd00:cf:20::/44 + +# Routed mode. Set this to a global IPv6 /48 that the upstream network routes +# to this host (for example from a lab router or an ISP prefix delegation) to +# give every guest network its own /64 from it, with no NAT66. Guests are then +# reachable from outside, and servers see each guest's own address. Empty +# (the default) means private mode: the ULA prefixes above plus NAT66. The +# value must be written as "a:b:c::/48" (or "a:b::/48", "a::/48"); any other +# value is rejected with a message and private mode is used. When set, the +# *_ipv6_prefix and *_ipv6_prefix_base settings above are ignored, and with +# P = the first three hextets and NN = the instance number as two hex digits: +# cvd-ebr P:24::/64 host P:24::1, guests use SLAAC +# cvd-wbr P:22::/64 host P:22::1, guests use SLAAC +# cvd-mtap-NN P:21NN::/64 host ::1, guest ::2 (from the modem simulator) +# cvd-wifiap-NN P:23NN::/64 host ::1, OpenWrt WAN ::2 +# OpenWrt LAN P:25NN::/64 routed by the host via P:23NN::2 +# A prefix smaller than a /48 (for example the /96 a cloud VM network +# interface may get) cannot be used: SLAAC needs a /64 per network. +#ipv6_routed_prefix= + +# NAT66 of guest IPv6 traffic, 1 (on) or 0 (off). Defaults to 1 in private +# mode and 0 in routed mode. +#ipv6_nat= # allocate_static_resources creates resources like tap devices # statically for instance networking at boot. # # Static resources are disjoint to those allocated by cvdalloc. # Thus, the legacy behaviour is maintained by the below default. +# The IPv6 setup above applies to the static resources only. # #allocate_static_resources=1 diff --git a/base/debian/cuttlefish-base.cuttlefish-host-resources.init b/base/debian/cuttlefish-base.cuttlefish-host-resources.init index 3c42b8dac0d..80f2dcd5d5b 100755 --- a/base/debian/cuttlefish-base.cuttlefish-host-resources.init +++ b/base/debian/cuttlefish-base.cuttlefish-host-resources.init @@ -44,10 +44,95 @@ ethernet_bridge_interface=${bridge_interface:-cvd-ebr} ipv4_bridge=${ipv4_bridge:-1} ipv6_bridge=${ipv6_bridge:-1} dns_servers=${dns_servers:-8.8.8.8,8.8.4.4} +dns6_servers_explicit="${dns6_servers:+1}" dns6_servers=${dns6_servers:-2001:4860:4860::8888,2001:4860:4860::8844} +effective_dns6_servers="${dns6_servers}" +wifi_ipv6_prefix=${wifi_ipv6_prefix:-fd00:cf:22::} +wifi_ipv6_prefix_length=${wifi_ipv6_prefix_length:-64} +ethernet_ipv6_prefix=${ethernet_ipv6_prefix:-fd00:cf:24::} +ethernet_ipv6_prefix_length=${ethernet_ipv6_prefix_length:-64} +mobile_ipv6_prefix_base=${mobile_ipv6_prefix_base:-fd00:cf:21} +wifiap_ipv6_prefix_base=${wifiap_ipv6_prefix_base:-fd00:cf:23} +ipv6_routed_prefix=${ipv6_routed_prefix:-} allocate_static_resources=${allocate_static_resources:-1} readonly CUTTLEFISH_RUN_DIR="/run/cuttlefish" +# Present while IPv6 state set up by start() exists, so stop() removes it +# even if IPv6 was disabled on the host in between. +readonly CUTTLEFISH_IPV6_MARKER="${CUTTLEFISH_RUN_DIR}/ipv6-enabled" +# Present while the host has IPv6 egress (an IPv6 default route at start_ipv6 +# time, routed mode, or an explicit dns6_servers / ipv6_egress=1 override in +# /etc/default/cuttlefish-host-resources). Read by assemble_cvd so ril_ipv6_dns +# is omitted when the host has no IPv6 egress. +readonly CUTTLEFISH_IPV6_EGRESS="${CUTTLEFISH_RUN_DIR}/ipv6-egress" +# Routes added by start_ipv6 in routed mode, one " " per line, +# so stop_ipv6 removes them even if the configuration changed in between. +readonly CUTTLEFISH_IPV6_ROUTES="${CUTTLEFISH_RUN_DIR}/ipv6-routes" + +# Prints the first three hextets ("a:b:c") of a /48 prefix written as +# "a:b:c::/48", "a:b::/48" or "a::/48", in lower case. Fails for any other +# value, including prefixes that are not /48 and /48s with bits set after the +# first three hextets. +# $1 = prefix +routed_prefix_base() { + case "${1}" in + *::/48) ;; + *) return 1 ;; + esac + routed_head="${1%::/48}" + if ! echo "${routed_head}" | grep -Eq '^[0-9a-fA-F]{1,4}(:[0-9a-fA-F]{1,4}){0,2}$'; then + return 1 + fi + case "${routed_head}" in + *:*:*) ;; + *:*) routed_head="${routed_head}:0" ;; + *) routed_head="${routed_head}:0:0" ;; + esac + echo "${routed_head}" | tr 'A-F' 'a-f' +} + +# Routed mode: the guest networks get /64s from a /48 routed to this host, +# with no NAT66 (see ipv6_routed_prefix in /etc/default/cuttlefish-host-resources). +# An invalid value falls back to private mode. +ipv6_routed_base="" +if [ -n "${ipv6_routed_prefix}" ]; then + if ipv6_routed_base="$(routed_prefix_base "${ipv6_routed_prefix}")"; then + echo "IPv6 routed mode: guest networks use ${ipv6_routed_base}::/48" + else + ipv6_routed_base="" + echo "invalid ipv6_routed_prefix '${ipv6_routed_prefix}' (want a /48 such as 2001:db8:cf00::/48), using private IPv6 addresses" >&2 + fi +fi + +if [ -n "${ipv6_routed_base}" ]; then + ipv6_nat_default=0 + # The *_ipv6_prefix settings are ignored in routed mode. + ethernet_ipv6_prefix="${ipv6_routed_base}:24::" + ethernet_ipv6_prefix_length=64 + wifi_ipv6_prefix="${ipv6_routed_base}:22::" + wifi_ipv6_prefix_length=64 +else + ipv6_nat_default=1 +fi +case "${ipv6_nat:-}" in + 0|1) ;; + "") ipv6_nat="${ipv6_nat_default}" ;; + *) + echo "invalid ipv6_nat '${ipv6_nat}' (want 0 or 1), using ${ipv6_nat_default}" >&2 + ipv6_nat="${ipv6_nat_default}" + ;; +esac + +# Guest IPv6 source addresses that are NATed (NAT66) when they leave the +# host, if ipv6_nat is 1. fd00:cf:20::/44 covers fd00:cf:20:: to +# fd00:cf:2f:ffff::, so every default prefix above. In routed mode the +# default is the routed /48. +if [ -n "${ipv6_routed_base}" ]; then + ipv6_nat_source=${ipv6_nat_source:-${ipv6_routed_base}::/48} +else + ipv6_nat_source=${ipv6_nat_source:-fd00:cf:20::/44} +fi +readonly CUTTLEFISH_IPV6_NAT_SOURCE="${ipv6_nat_source}" if [ -z ${bridge_interface} ]; then create_bridges=1 @@ -94,10 +179,63 @@ stop_dnsmasq() { fi } +# Start IPv6 router advertisements (RA) on an interface. +# This dnsmasq is separate from the IPv4 one, so an IPv6 failure (for example +# no ICMPv6 socket) cannot stop DHCPv4. It only sends RAs with the prefix and +# the DNS servers (RDNSS): no DNS (--port=0), no DHCPv4 range, and an ra-only +# range does not start a DHCPv6 server. +# $1 = interface to bind to +# $2 = IPv6 prefix ("a:b::") +# $3 = IPv6 prefix length +start_ra_dnsmasq() { + set -- \ + --port=0 \ + --except-interface=lo \ + --interface="${1}" \ + --bind-interfaces \ + --conf-file="" \ + --enable-ra \ + --dhcp-range="${2},ra-only,${3}" \ + --pid-file=/var/run/cuttlefish-dnsmasq-ra-"${1}".pid \ + --dhcp-leasefile=/var/run/cuttlefish-dnsmasq-ra-"${1}".leases + if [ -n "${effective_dns6_servers}" ]; then + set -- "$@" --dhcp-option="option6:dns-server,${effective_dns6_servers}" + fi + dnsmasq "$@" +} + +# Stop IPv6 router advertisements on an interface +# $1 = interface to stop on +stop_ra_dnsmasq() { + if [ -f /var/run/cuttlefish-dnsmasq-ra-"${1}".pid ]; then + kill "$(cat /var/run/cuttlefish-dnsmasq-ra-"${1}".pid)" + rm -f /var/run/cuttlefish-dnsmasq-ra-"${1}".pid + fi + if [ -f /var/run/cuttlefish-dnsmasq-ra-"${1}".leases ]; then + rm -f /var/run/cuttlefish-dnsmasq-ra-"${1}".leases + fi +} + +# Make the host ignore IPv6 router advertisements on an interface, so a guest +# cannot give the host addresses or routes. Link-local addresses are kept. +# Does nothing if the kernel has no IPv6 (booted with ipv6.disable=1). +# $1 = interface name +ignore_router_advertisements() { + if [ -d /proc/sys/net/ipv6/conf/"${1}" ]; then + if [ -w /proc/sys/net/ipv6/conf/"${1}"/accept_ra ]; then + echo 0 > /proc/sys/net/ipv6/conf/"${1}"/accept_ra + fi + if [ -w /proc/sys/net/ipv6/conf/"${1}"/autoconf ]; then + echo 0 > /proc/sys/net/ipv6/conf/"${1}"/autoconf + fi + fi +} + # Create a tap interface (with no address) # $1 = tap name create_tap() { ip tuntap add dev "${1}" mode tap group cvdnetwork vnet_hdr + ignore_router_advertisements "${1}" ip link set dev "${1}" up } @@ -128,8 +266,81 @@ delete_nftables() { if [ "${allocate_static_resources}" != "1" ]; then return fi - nft delete table ip cuttlefish_nat - nft delete table bridge cuttlefish_bridge + if nft list table ip cuttlefish_nat >/dev/null 2>&1; then + nft delete table ip cuttlefish_nat + fi + if nft list table bridge cuttlefish_bridge >/dev/null 2>&1; then + nft delete table bridge cuttlefish_bridge + fi +} + +# IPv6 tables are separate from the IPv4 ones, so the IPv4 ruleset does not +# change and IPv6 can be skipped or removed on its own. +setup_ipv6_nftables() { + # Explicit FORWARD accept rules in ip6 filter FORWARD so guest IPv6 traffic + # is forwarded even when Docker, libvirt, or UFW sets ip6 filter FORWARD + # policy to DROP. Using iifname/oifname + counter + accept produces an + # ip6tables-nft-compatible rule AST and is tracked by handle so stop removes + # only Cuttlefish's rules. + nft add table ip6 filter + nft add chain ip6 filter FORWARD '{ type filter hook forward priority 0 ; }' + manage_nft_rule add ip6fwd in 'iifname "cvd-*" counter accept' + manage_nft_rule add ip6fwd out 'oifname "cvd-*" counter accept' + + # NAT66 of guest traffic that leaves through a non-cuttlefish interface. + # Traffic between two cuttlefish interfaces keeps its addresses. The table + # exists also without NAT66 (ipv6_nat=0, the routed mode default), so + # stop removes the same tables in both modes. + nft add table ip6 cuttlefish_nat6 + nft add chain ip6 cuttlefish_nat6 postrouting '{ type nat hook postrouting priority 100 ; }' + if [ "${ipv6_nat}" = "1" ]; then + nft add rule ip6 cuttlefish_nat6 postrouting \ + "ip6 saddr ${CUTTLEFISH_IPV6_NAT_SOURCE} oifname != \"cvd-*\" counter masquerade" + fi + + # RA guard: drop router advertisements and redirects sent by guests, so a + # guest cannot change the routes of other guests on the same bridge, or of + # the host. Neighbor discovery (NS/NA) and router solicitations pass. + # Bridged taps: checked when the frame enters the bridge, before the + # cuttlefish_bridge prerouting chain (priority -250). + nft add table bridge cuttlefish_ra_guard + nft add chain bridge cuttlefish_ra_guard prerouting '{ type filter hook prerouting priority -300 ; }' + # "meta protocol ip6" matches the frame's IPv6 payload also when it carries + # an 802.1Q tag (for example VLAN 0). A bare "icmpv6 type" match in the + # bridge family checks the outer Ethernet type, so it misses tagged frames. + for ifname_pattern in "cvd-etap-*" "cvd-wtap-*"; do + nft add rule bridge cuttlefish_ra_guard prerouting \ + "iifname \"${ifname_pattern}\" meta protocol ip6 meta l4proto ipv6-icmp icmpv6 type { nd-router-advert, nd-redirect } counter drop" + done + # Routed taps: RAs and redirects are link-local, so they only reach the + # host's input path. accept_ra=0 already ignores RAs; this is a second layer. + nft add table inet cuttlefish_ra_guard + nft add chain inet cuttlefish_ra_guard input '{ type filter hook input priority -300 ; }' + for ifname_pattern in "cvd-mtap-*" "cvd-wifiap-*"; do + nft add rule inet cuttlefish_ra_guard input \ + "iifname \"${ifname_pattern}\" icmpv6 type { nd-router-advert, nd-redirect } counter drop" + done +} + +delete_ipv6_nftables() { + manage_nft_rule delete ip6fwd out + manage_nft_rule delete ip6fwd in + if nft list table ip6 filter >/dev/null 2>&1; then + if [ "$(nft -j list table ip6 filter | jq '[.nftables[] | select(has("rule"))] | length')" = "0" ] && + [ "$(nft -j list table ip6 filter | jq '[.nftables[] | select(has("chain"))] | length')" = "1" ] && + [ "$(nft -j list table ip6 filter | jq -r '.nftables[] | select(has("chain")) | .chain.policy')" = "accept" ]; then + nft delete table ip6 filter + fi + fi + if nft list table inet cuttlefish_ra_guard >/dev/null 2>&1; then + nft delete table inet cuttlefish_ra_guard + fi + if nft list table bridge cuttlefish_ra_guard >/dev/null 2>&1; then + nft delete table bridge cuttlefish_ra_guard + fi + if nft list table ip6 cuttlefish_nat6 >/dev/null 2>&1; then + nft delete table ip6 cuttlefish_nat6 + fi } @@ -159,6 +370,11 @@ manage_nft_rule() { table="cuttlefish_bridge" chain="forward" ;; + ip6fwd) + family="ip6" + table="filter" + chain="FORWARD" + ;; esac if [ -z "$family" ] || [ -z "$table" ] || [ -z "$chain" ]; then @@ -177,6 +393,9 @@ manage_nft_rule() { echo "Contents required for add operation" return 1 fi + if [ -f "$handle_file" ]; then + manage_nft_rule delete "$type" "$id" + fi # Add the rule and store the handle. handle=$(nft -j -e add rule "$family" "$table" "$chain" "$contents" | jq -r '.nftables[] | select(has("add")) | .add.rule.handle') echo "$handle" > "$handle_file" @@ -185,8 +404,10 @@ manage_nft_rule() { if [ -f "$handle_file" ]; then # Find the handle file and use it to clean things up. handle=$(cat "$handle_file") - nft delete rule "$family" "$table" "$chain" handle "$handle" - rm "$handle_file" + if nft -a list chain "$family" "$table" "$chain" 2>/dev/null | grep -q "# handle ${handle}$"; then + nft delete rule "$family" "$table" "$chain" handle "$handle" + fi + rm -f "$handle_file" else echo "Handle file not found: $handle_file" fi @@ -205,21 +426,14 @@ manage_nft_rule() { # $1 = tap interface name # $2 = ip address base ("a.b.c") # $3 = interface index within ip address base -# $4 = IPv6 prefix ("a:b::") -# $5 = IPv6 prefix length create_interface() { tap="${1}" gateway="${2}.$((4*$3 - 3))" netmask="/30" network="${2}.$((4*$3 - 4))${netmask}" - ipv6_prefix="${4}" - ipv6_prefix_length="${5}" create_tap "${tap}" ip addr add "${gateway}${netmask}" broadcast + dev "${tap}" - if [ -n "${ipv6_prefix}" -a -n "${ipv6_prefix_length}" ]; then - ip -6 addr add "${ipv6_prefix}1/${ipv6_prefix_length}" dev "${tap}" - fi manage_nft_rule add masq "${tap}" "ip saddr ${network} masquerade" } @@ -227,21 +441,14 @@ create_interface() { # $1 = tap interface name # $2 = ip address base ("a.b.c") # $3 = interface index within ip address base -# $4 = IPv6 prefix ("a:b::") -# $5 = IPv6 prefix length destroy_interface() { tap="${1}" gateway="${2}.$((4*$3 - 3))" netmask="/30" network="${2}.$((4*$3 - 4))${netmask}" - ipv6_prefix="${4}" - ipv6_prefix_length="${5}" manage_nft_rule delete masq "${tap}" ip addr del "${gateway}${netmask}" dev "${tap}" - if [ -n "${ipv6_prefix}" -a -n "${ipv6_prefix_length}" ]; then - ip -6 addr del "${ipv6_prefix}1/${ipv6_prefix_length}" dev "${tap}" - fi destroy_tap "${tap}" } @@ -249,16 +456,20 @@ destroy_interface() { # $1 = IPv4 address base ("a.b.c") # $2 = bridge interface name # $3 = tap base name -# $4 = IPv6 prefix ("a:b::") -# $5 = IPv6 prefix length create_bridged_interfaces() { if [ "${create_bridges}" = "1" ]; then ip link add name "${2}" type bridge forward_delay 0 stp_state 0 ip link set dev "${2}" up - echo 0 > /proc/sys/net/ipv6/conf/${2}/disable_ipv6 - echo 0 > /proc/sys/net/ipv6/conf/${2}/addr_gen_mode - echo 1 > /proc/sys/net/ipv6/conf/${2}/autoconf + if ipv6_enabled && [ -d /proc/sys/net/ipv6/conf/"${2}" ]; then + if [ -w /proc/sys/net/ipv6/conf/"${2}"/disable_ipv6 ]; then + echo 0 > /proc/sys/net/ipv6/conf/"${2}"/disable_ipv6 + fi + if [ -w /proc/sys/net/ipv6/conf/"${2}"/addr_gen_mode ]; then + echo 0 > /proc/sys/net/ipv6/conf/"${2}"/addr_gen_mode + fi + fi + ignore_router_advertisements "${2}" fi for i in $(seq ${num_cvd_accounts}); do tap="$(printf ${3}-%02d $i)" @@ -280,15 +491,8 @@ create_bridged_interfaces() { netmask="/24" network="${1}.0${netmask}" dhcp_range="${1}.2,${1}.255" - ipv6_prefix="${4}" - ipv6_prefix_length="${5}" ip addr add "${gateway}${netmask}" broadcast + dev "${2}" - if [ -n "${ipv6_prefix}" -a -n "${ipv6_prefix_length}" ]; then - ip -6 addr add "${ipv6_prefix}1/${ipv6_prefix_length}" dev "${2}" - fi - start_dnsmasq \ - "${2}" "${gateway}" "${dhcp_range}" \ - "${ipv6_prefix}" "${ipv6_prefix_length}" + start_dnsmasq "${2}" "${gateway}" "${dhcp_range}" manage_nft_rule add masq "br-${2}" "ip saddr ${network} masquerade" fi } @@ -297,20 +501,13 @@ create_bridged_interfaces() { # $1 = ip address base ("a.b.c") # $2 = bridge interface name # $3 = tap base name -# $4 = IPv6 prefix ("a:b::") -# $5 = IPv6 prefix length destroy_bridged_interfaces() { if [ "${create_bridges}" = "1" ]; then gateway="${1}.1" netmask="/24" network="${1}.0${netmask}" - ipv6_prefix="${4}" - ipv6_prefix_length="${5}" manage_nft_rule delete masq "br-${2}" stop_dnsmasq "${2}" - if [ -n "${ipv6_prefix}" -a -n "${ipv6_prefix_length}" ]; then - ip -6 addr del "${ipv6_prefix}1/${ipv6_prefix_length}" dev "${2}" - fi ip addr del "${gateway}${netmask}" dev "${2}" fi for i in $(seq ${num_cvd_accounts}); do @@ -333,6 +530,235 @@ destroy_bridged_interfaces() { fi } +# Returns success if the host has IPv6 enabled. +ipv6_enabled() { + [ -d /proc/sys/net/ipv6 ] && + [ "$(cat /proc/sys/net/ipv6/conf/all/disable_ipv6)" = "0" ] +} + +# Enable IPv6 forwarding while preserving kernel Router Advertisement +# acceptance on host interfaces. Setting net.ipv6.conf.all.forwarding=1 sets +# forwarding=1 on all interfaces and calls rt6_purge_dflt_routers(), which +# deletes kernel RA default routes (RTF_ADDRCONF) on any interface where +# accept_ra != 2. Promote accept_ra to 2 on default, on any non-Cuttlefish +# interface with accept_ra=1, and on any interface holding an RTF_ADDRCONF +# default route in /proc/net/ipv6_route before enabling all/forwarding. +enable_ipv6_forwarding() { + if [ ! -d /proc/sys/net/ipv6/conf ]; then + return 0 + fi + ra_ifaces="" + if [ -r /proc/net/ipv6_route ]; then + ra_ifaces="$(awk ' + $1 == "00000000000000000000000000000000" && $2 == "00" { + flags = substr($9, length($9) - 4, 1) + if (flags ~ /[4567cdefCDEF]/) print $10 + } + ' /proc/net/ipv6_route)" + fi + for conf_dir in /proc/sys/net/ipv6/conf/*; do + [ -d "${conf_dir}" ] || continue + ifname="${conf_dir##*/}" + case "${ifname}" in + all|lo|cvd-*) continue ;; + esac + accept_ra_file="${conf_dir}/accept_ra" + [ -r "${accept_ra_file}" ] || continue + need_hybrid=0 + if [ "$(cat "${accept_ra_file}")" = "1" ]; then + need_hybrid=1 + else + for ra_if in ${ra_ifaces}; do + if [ "${ifname}" = "${ra_if}" ]; then + need_hybrid=1 + break + fi + done + fi + if [ "${need_hybrid}" = "1" ] && [ "$(cat "${accept_ra_file}")" != "2" ]; then + if [ -w "${accept_ra_file}" ]; then + echo 2 > "${accept_ra_file}" + fi + fi + done + if [ "$(cat /proc/sys/net/ipv6/conf/all/forwarding 2>/dev/null)" != "1" ]; then + if ! echo 1 > /proc/sys/net/ipv6/conf/all/forwarding; then + echo "cuttlefish-host-resources: failed to enable net.ipv6.conf.all.forwarding; skipping IPv6 setup" >&2 + return 1 + fi + fi +} + +# Prints the per-instance /64 prefix of a point-to-point tap: +# ":::". Example: fd00:cf:21 and 10 give fd00:cf:21:a:: +# The base must be exactly three hextets ("a:b:c", no "::"), so the instance +# number is the fourth hextet and every instance gets its own /64. +# $1 = prefix base ("a:b:c") +# $2 = instance number +instance_ipv6_prefix() { + if ! echo "${1}" | grep -Eq '^[0-9a-fA-F]{1,4}(:[0-9a-fA-F]{1,4}){2}$'; then + echo "invalid IPv6 prefix base '${1}' (want three hextets, e.g. fd00:cf:21)" >&2 + return 1 + fi + printf '%s:%x::' "${1}" "${2}" +} + +# Prints the /64 prefix of a point-to-point tap or routed network of an +# instance. Private mode: instance_ipv6_prefix of the private base. Routed +# mode: ":::", for example +# 2001:db8:cf00:210a:: for network 21 and instance 10. +# $1 = private prefix base ("a:b:c") +# $2 = routed network number (21 mobile, 23 OpenWrt WAN, 25 OpenWrt LAN) +# $3 = instance number (1 to 128) +tap_ipv6_prefix() { + if [ -n "${ipv6_routed_base}" ]; then + printf '%s:%s%02x::' "${ipv6_routed_base}" "${2}" "${3}" + else + instance_ipv6_prefix "${1}" "${3}" + fi +} + +# Succeeds when the host has IPv6 egress for guest DNS, or when IPv6 DNS / +# egress is explicitly overridden in /etc/default/cuttlefish-host-resources. +# On a host with kernel IPv6 enabled (disable_ipv6=0) but no IPv6 default route, +# advertising default external IPv6 DNS servers in RAs or RIL causes OpenWrt +# dnsmasq and guest resolvers to send queries that blackhole at the host. +host_has_ipv6_egress() { + case "${ipv6_egress:-}" in + 1) return 0 ;; + 0) return 1 ;; + "") ;; + *) + echo "cuttlefish-host-resources: invalid ipv6_egress '${ipv6_egress}' (want 0 or 1), auto-detecting from default route" >&2 + ;; + esac + if [ -n "${dns6_servers_explicit}" ] || [ -n "${ipv6_routed_base}" ]; then + return 0 + fi + [ -n "$(ip -6 route show default 2>/dev/null)" ] +} + +# Set up IPv6 on the interfaces created for IPv4: gateway address ::1 on each +# segment, router advertisements where guests use SLAAC, NAT66 and RA guard. +# Runs after all IPv4 setup, so a failure here cannot affect IPv4. Applies to +# the static resources only; cvdalloc-managed interfaces are not touched. +start_ipv6() { + # The marker is still present when an earlier start was not followed by + # stop_ipv6, for example when the package was replaced by a version that + # does not set up IPv6 and then installed again. Remove the leftovers + # first, so router advertisement daemons and nftables rules are not + # duplicated. + stop_ipv6 + if ! ipv6_enabled; then + return + fi + if [ "${allocate_static_resources}" != "1" ]; then + return + fi + if ! enable_ipv6_forwarding; then + return 0 + fi + touch "${CUTTLEFISH_IPV6_MARKER}" + if host_has_ipv6_egress; then + effective_dns6_servers="${dns6_servers}" + printf '%s\n' "${effective_dns6_servers}" > "${CUTTLEFISH_IPV6_EGRESS}" + else + effective_dns6_servers="" + rm -f "${CUTTLEFISH_IPV6_EGRESS}" + echo "cuttlefish-host-resources: no IPv6 default route on host; omitting IPv6 DNS servers (${dns6_servers}) from RA and RIL while keeping ULA addressing" >&2 + fi + + if [ "${create_bridges}" = "1" ]; then + for br in "${ethernet_bridge_interface}" "${wifi_bridge_interface}"; do + if [ -w /proc/sys/net/ipv6/conf/"${br}"/disable_ipv6 ]; then + echo 0 > /proc/sys/net/ipv6/conf/"${br}"/disable_ipv6 + fi + done + # Ethernet (default fd00:cf:24::/64) on cvd-ebr, shared by the cvd-etap-XX guests + ip -6 addr add "${ethernet_ipv6_prefix}1/${ethernet_ipv6_prefix_length}" dev "${ethernet_bridge_interface}" + start_ra_dnsmasq "${ethernet_bridge_interface}" "${ethernet_ipv6_prefix}" "${ethernet_ipv6_prefix_length}" + # Legacy wireless (default fd00:cf:22::/64) on cvd-wbr, shared by the cvd-wtap-XX guests + ip -6 addr add "${wifi_ipv6_prefix}1/${wifi_ipv6_prefix_length}" dev "${wifi_bridge_interface}" + start_ra_dnsmasq "${wifi_bridge_interface}" "${wifi_ipv6_prefix}" "${wifi_ipv6_prefix_length}" + fi + + for i in $(seq "${num_cvd_accounts}"); do + if [ "$i" -lt 129 ]; then + # Mobile Network: fd00:cf:21:::/64 (routed: P:21::/64) on + # cvd-mtap-XX. No RA: the guest gets its IPv6 address from the + # modem simulator (RIL). + tap="$(printf cvd-mtap-%02d "$i")" + if [ -w /proc/sys/net/ipv6/conf/"${tap}"/disable_ipv6 ]; then + echo 0 > /proc/sys/net/ipv6/conf/"${tap}"/disable_ipv6 + fi + if prefix="$(tap_ipv6_prefix "${mobile_ipv6_prefix_base}" 21 "$i")"; then + ip -6 addr add "${prefix}1/64" dev "${tap}" + fi + + # Wireless Network (OpenWrt WAN): fd00:cf:23:::/64 (routed: + # P:23::/64) on cvd-wifiap-XX + tap="$(printf cvd-wifiap-%02d "$i")" + if [ -w /proc/sys/net/ipv6/conf/"${tap}"/disable_ipv6 ]; then + echo 0 > /proc/sys/net/ipv6/conf/"${tap}"/disable_ipv6 + fi + if prefix="$(tap_ipv6_prefix "${wifiap_ipv6_prefix_base}" 23 "$i")"; then + ip -6 addr add "${prefix}1/64" dev "${tap}" + start_ra_dnsmasq "${tap}" "${prefix}" 64 + if [ -n "${ipv6_routed_base}" ]; then + # OpenWrt Wi-Fi LAN P:25::/64, routed through the + # OpenWrt WAN address P:23::2. + lan_prefix="$(tap_ipv6_prefix "" 25 "$i")/64" + ip -6 route add "${lan_prefix}" via "${prefix}2" dev "${tap}" && + echo "${lan_prefix} ${tap}" >> "${CUTTLEFISH_IPV6_ROUTES}" + fi + fi + fi + done + + setup_ipv6_nftables +} + +# Remove what start_ipv6 set up, including global/ULA IPv6 addresses when +# stop_ipv6 runs before a second start while the interfaces still exist. +stop_ipv6() { + if [ -f "${CUTTLEFISH_IPV6_MARKER}" ]; then + delete_ipv6_nftables + if [ -f "${CUTTLEFISH_IPV6_ROUTES}" ]; then + while read -r route_prefix route_dev; do + if [ -n "$(ip -6 route show "${route_prefix}" dev "${route_dev}" 2>/dev/null)" ]; then + ip -6 route del "${route_prefix}" dev "${route_dev}" + fi + done < "${CUTTLEFISH_IPV6_ROUTES}" + rm -f "${CUTTLEFISH_IPV6_ROUTES}" + fi + for pidfile in /var/run/cuttlefish-dnsmasq-ra-*.pid; do + [ -f "${pidfile}" ] || continue + ra_if="${pidfile#/var/run/cuttlefish-dnsmasq-ra-}" + stop_ra_dnsmasq "${ra_if%.pid}" + done + for i in $(seq "${num_cvd_accounts}"); do + if [ "$i" -lt 129 ]; then + stop_ra_dnsmasq "$(printf cvd-wifiap-%02d "$i")" + fi + done + if [ "${create_bridges}" = "1" ]; then + stop_ra_dnsmasq "${wifi_bridge_interface}" + stop_ra_dnsmasq "${ethernet_bridge_interface}" + if ip link show "${wifi_bridge_interface}" >/dev/null 2>&1; then + ip -6 addr flush dev "${wifi_bridge_interface}" scope global + fi + if ip link show "${ethernet_bridge_interface}" >/dev/null 2>&1; then + ip -6 addr flush dev "${ethernet_bridge_interface}" scope global + fi + fi + for tap in $(ip -o link show | awk -F': ' '{print $2}' | sed 's/@.*//' | grep -E '^cvd-(mtap|wifiap)-'); do + ip -6 addr flush dev "${tap}" scope global + done + rm -f "${CUTTLEFISH_IPV6_EGRESS}" + rm "${CUTTLEFISH_IPV6_MARKER}" + fi +} + create_static_interfaces() { if [ "${allocate_static_resources}" != "1" ]; then return @@ -341,8 +767,7 @@ create_static_interfaces() { # Ethernet # 192.168.98.X for cvd-ebr and cvd-etap-XX create_bridged_interfaces \ - 192.168.98 "${ethernet_bridge_interface}" cvd-etap \ - "${ethernet_ipv6_prefix}" "${ethernet_ipv6_prefix_length}" + 192.168.98 "${ethernet_bridge_interface}" cvd-etap # Mobile Network # 192.168.97.X from cvd-mtap-01 to cvd-mtap-64 @@ -364,8 +789,7 @@ create_static_interfaces() { # 192.168.94.X from cvd-wifiap-01 to cvd-wifiap-64 # 192.168.95.X from cvd-wifiap-65 to cvd-wifiap-128 create_bridged_interfaces \ - 192.168.96 "${wifi_bridge_interface}" cvd-wtap \ - "${wifi_ipv6_prefix}" "${wifi_ipv6_prefix_length}" + 192.168.96 "${wifi_bridge_interface}" cvd-wtap for i in $(seq ${num_cvd_accounts}); do tap="$(printf cvd-wifiap-%02d $i)" if [ $i -lt 65 ]; then @@ -377,16 +801,8 @@ create_static_interfaces() { } start() { - # Enable ip forwarding - echo 1 > /proc/sys/net/ipv4/ip_forward - echo 1 > /proc/sys/net/ipv6/conf/all/forwarding - - setup_nftables - - create_static_interfaces - # When running inside a privileged container, set the ownership and access - # of these device nodes. + # of these device nodes before any conditional network return. if test -f /.dockerenv; then chown root:kvm /dev/kvm chown root:cvdnetwork /dev/vhost-net @@ -396,11 +812,34 @@ start() { chmod ug+rw /dev/vhost-vsock fi + if [ "${allocate_static_resources}" = "1" ]; then + # Enable IPv4 forwarding for static bridge/TAP routing + if [ "$(cat /proc/sys/net/ipv4/ip_forward 2>/dev/null)" != "1" ]; then + if ! echo 1 > /proc/sys/net/ipv4/ip_forward; then + echo "cuttlefish-host-resources: failed to enable net.ipv4.ip_forward" >&2 + return 1 + fi + fi + fi + + setup_nftables + + create_static_interfaces + + # IPv6, after IPv4 is fully set up + if ipv6_enabled; then + start_ipv6 + else + echo "IPv6 is disabled on this host, skipping IPv6 setup" + fi + # Try to preload the Nvidia modeset kernel module. /usr/bin/nvidia-modprobe --modeset || /bin/true } stop() { + stop_ipv6 + if [ "${allocate_static_resources}" != "1" ]; then delete_nftables return @@ -408,8 +847,7 @@ stop() { # Ethernet destroy_bridged_interfaces \ - 192.168.98 "${ethernet_bridge_interface}" cvd-etap \ - "${ethernet_ipv6_prefix}" "${ethernet_ipv6_prefix_length}" + 192.168.98 "${ethernet_bridge_interface}" cvd-etap # Mobile Network for i in $(seq ${num_cvd_accounts}); do @@ -423,8 +861,7 @@ stop() { # Wireless Network destroy_bridged_interfaces \ - 192.168.96 "${wifi_bridge_interface}" cvd-wtap \ - "${wifi_ipv6_prefix}" "${wifi_ipv6_prefix_length}" + 192.168.96 "${wifi_bridge_interface}" cvd-wtap for i in $(seq ${num_cvd_accounts}); do tap="$(printf cvd-wifiap-%02d $i)" if [ $i -lt 65 ]; then @@ -443,30 +880,37 @@ usage() { if test $# != 1; then usage fi +RETVAL=0 case "$1" in --help) usage 0 ;; start|stop) "$1" + RETVAL=$? ;; restart) stop && start + RETVAL=$? ;; condrestart|try-restart) stop && start + RETVAL=$? ;; reload|force-reload) # Nothing to do; we reread configuration on each invocation ;; status) rh_status + RETVAL=$? ;; shutdown) stop + RETVAL=$? ;; *) usage + RETVAL=$? ;; esac exit $RETVAL diff --git a/base/debian/cuttlefish-base.postrm b/base/debian/cuttlefish-base.postrm index 6060c4fb6b1..fc93cfe9d90 100755 --- a/base/debian/cuttlefish-base.postrm +++ b/base/debian/cuttlefish-base.postrm @@ -1,4 +1,62 @@ #!/bin/sh set -e +if [ "$1" = "purge" ]; then + for pidfile in /run/cuttlefish-dnsmasq-*.pid /var/run/cuttlefish-dnsmasq-*.pid; do + [ -f "$pidfile" ] || continue + pid="$(cat "$pidfile" 2>/dev/null)" + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + if ! kill "$pid" 2>/dev/null; then + if kill -0 "$pid" 2>/dev/null; then + exit 1 + fi + fi + fi + rm -f "$pidfile" + done + rm -f /run/cuttlefish-dnsmasq-*.leases /var/run/cuttlefish-dnsmasq-*.leases + if [ -f /run/cuttlefish/ipv6-routes ] && command -v ip >/dev/null 2>&1; then + while read -r route_prefix route_dev; do + if [ -n "$route_prefix" ] && [ -n "$route_dev" ] && + [ -n "$(ip -6 route show "$route_prefix" dev "$route_dev" 2>/dev/null)" ]; then + ip -6 route del "$route_prefix" dev "$route_dev" + fi + done < /run/cuttlefish/ipv6-routes + rm -f /run/cuttlefish/ipv6-routes + fi + if command -v nft >/dev/null 2>&1; then + for hfile in /run/cuttlefish/ip6fwd-*.handle; do + [ -f "$hfile" ] || continue + handle="$(cat "$hfile" 2>/dev/null)" + if [ -n "$handle" ] && nft -a list chain ip6 filter FORWARD 2>/dev/null | grep -q "# handle ${handle}$"; then + nft delete rule ip6 filter FORWARD handle "$handle" + fi + rm -f "$hfile" + done + if command -v jq >/dev/null 2>&1 && nft list table ip6 filter >/dev/null 2>&1; then + if [ "$(nft -j list table ip6 filter | jq '[.nftables[] | select(has("rule"))] | length')" = "0" ] && + [ "$(nft -j list table ip6 filter | jq '[.nftables[] | select(has("chain"))] | length')" = "1" ] && + [ "$(nft -j list table ip6 filter | jq -r '.nftables[] | select(has("chain")) | .chain.policy')" = "accept" ]; then + nft delete table ip6 filter + fi + fi + if nft list table inet cuttlefish_ra_guard >/dev/null 2>&1; then + nft delete table inet cuttlefish_ra_guard + fi + if nft list table bridge cuttlefish_ra_guard >/dev/null 2>&1; then + nft delete table bridge cuttlefish_ra_guard + fi + if nft list table ip6 cuttlefish_nat6 >/dev/null 2>&1; then + nft delete table ip6 cuttlefish_nat6 + fi + if nft list table bridge cuttlefish_bridge >/dev/null 2>&1; then + nft delete table bridge cuttlefish_bridge + fi + if nft list table ip cuttlefish_nat >/dev/null 2>&1; then + nft delete table ip cuttlefish_nat + fi + fi + rm -rf /run/cuttlefish +fi + #DEBHELPER# diff --git a/base/debian/rules b/base/debian/rules index b89add0a174..0b02e0f79d2 100755 --- a/base/debian/rules +++ b/base/debian/rules @@ -80,7 +80,7 @@ override_dh_installinit: .PHONY: override_dh_installsystemd override_dh_installsystemd: dh_installsystemd --name=cuttlefish-host-resources - dh_installsystemd + dh_installsystemd --remaining-packages .PHONY: cf_bazel_build cf_bazel_build: diff --git a/base/host/packages/cuttlefish-base/usr/lib/sysctl.d/90-cuttlefish-ip-forward.conf b/base/host/packages/cuttlefish-base/usr/lib/sysctl.d/90-cuttlefish-ip-forward.conf index ce1cac212e2..df99269b1a1 100644 --- a/base/host/packages/cuttlefish-base/usr/lib/sysctl.d/90-cuttlefish-ip-forward.conf +++ b/base/host/packages/cuttlefish-base/usr/lib/sysctl.d/90-cuttlefish-ip-forward.conf @@ -1,5 +1,7 @@ # Override GCE /etc/sysctl.d/60-gce-network-security.conf (net.ipv4.ip_forward = 0) # at early boot and during systemd-sysctl reloads so Cuttlefish guest traffic is # routed and docker.service does not set iptables -P FORWARD DROP. +# Set default.accept_ra = 2 so newly created host interfaces keep accepting +# Router Advertisements after cuttlefish-host-resources enables IPv6 forwarding. net.ipv4.ip_forward = 1 -net.ipv6.conf.all.forwarding = 1 +net.ipv6.conf.default.accept_ra = 2 diff --git a/container/src/podcvd/internal/container.go b/container/src/podcvd/internal/container.go index e8b4f5422cf..138a481bd7e 100644 --- a/container/src/podcvd/internal/container.go +++ b/container/src/podcvd/internal/container.go @@ -162,6 +162,8 @@ func (m *CuttlefishContainerManagerImpl) CreateAndStartContainer(ctx context.Con args := []string{"run", "-d", "-t", "--rm", "--cap-add", "NET_ADMIN"} // TODO(b/383428636): Remove this when vhost_user_vsock is enabled by default. args = append(args, "--security-opt", "seccomp=unconfined") + // Enable IPv6 forwarding before /proc/sys is mounted read-only inside the container. + args = append(args, "--sysctl", "net.ipv6.conf.all.forwarding=1") devices := []string{ "/dev/kvm", "/dev/net/tun", diff --git a/docs/networking/ipv6_routed_mode.md b/docs/networking/ipv6_routed_mode.md new file mode 100644 index 00000000000..b87a14bcde5 --- /dev/null +++ b/docs/networking/ipv6_routed_mode.md @@ -0,0 +1,139 @@ +# IPv6 routed mode (static networking mode) + +By default `cuttlefish-host-resources` gives the guests private IPv6 +addresses (Unique Local Addresses, `fd00:cf:2X::`) and NATs them (NAT66) when +they leave the host, as it does for IPv4. See +[openwrt_ipv6_static.md](openwrt_ipv6_static.md). + +In **routed mode** the host instead gives every guest network its own `/64` +from a global `/48` that the upstream network routes to the host. There is no +NAT66: servers see each guest's own address, and the address differs per guest +network. This is what tests such as CTS +`ConnectivityManagerTest#testOpenConnection` check: the address an echo server +sees over Wi-Fi and over mobile data must differ, and must be on that +network's link. A NATed setup cannot pass that check. + +Private mode stays the default. Routed mode only adds behavior. + +## Requirements + +- A global IPv6 **`/48`** routed to the Cuttlefish host by the upstream + router (static route or DHCPv6 prefix delegation). Every guest network + uses SLAAC or a fixed `/64`, so the host needs one `/64` per network and + per instance; the layout below uses the fourth hextet for that. +- A prefix smaller than a `/48` does not fit the layout. In particular, a + `/64` on the host's own uplink, or the `/96` that some cloud VMs get per + network interface (for example + [Compute Engine](https://cloud.google.com/compute/docs/ip-addresses/configure-ipv6-address)), + cannot be split into `/64`s for SLAAC. Such a host can only use private + mode. +- The init script promotes `accept_ra` to `2` on `default` and on existing + non-Cuttlefish interfaces that use kernel router advertisements before + enabling IPv6 forwarding (`net.ipv6.conf.all.forwarding=1`), so the host's + uplink keeps its RA default route with forwarding on. + +## Host configuration + +In `/etc/default/cuttlefish-host-resources`: + +``` +ipv6_routed_prefix=2001:db8:cf00::/48 +#ipv6_nat=0 +``` + +- `ipv6_routed_prefix` must be written as `a:b:c::/48` (or `a:b::/48`, + `a::/48`). Any other value (another length, bits set after the first 48) + is rejected with a message on standard error and the host uses private + mode. +- `ipv6_nat` defaults to `0` in routed mode and `1` in private mode. With + `ipv6_nat=1` in routed mode, the host NATs the routed `/48` (source set by + `ipv6_nat_source`, which defaults to the routed `/48`). +- In routed mode the `*_ipv6_prefix` and `*_ipv6_prefix_base` settings are + ignored. + +Restart the service after changing the file +(`sudo systemctl restart cuttlefish-host-resources`). + +## Address layout + +`P` is the first three hextets of the `/48`; `NN` is the instance number as +two hex digits (`01` to `80`). + +| Network | Host interface | Prefix | Host | Guest | +| --- | --- | --- | --- | --- | +| Ethernet | `cvd-ebr` | `P:24::/64` | `P:24::1` | SLAAC (RA from the host) | +| Legacy Wi-Fi bridge | `cvd-wbr` | `P:22::/64` | `P:22::1` | SLAAC (RA from the host) | +| Mobile | `cvd-mtap-NN` | `P:21NN::/64` | `::1` | `::2` from `modem_simulator` | +| OpenWrt WAN | `cvd-wifiap-NN` | `P:23NN::/64` | `::1` | OpenWrt WAN `::2` (static) | +| OpenWrt Wi-Fi LAN | behind OpenWrt | `P:25NN::/64` | route via `P:23NN::2` | SLAAC (RA from OpenWrt) | + +Example for `2001:db8:cf00::/48` and instance 1: the guest mobile address is +`2001:db8:cf00:2101::2`, and the guest `wlan0` gets a SLAAC address in +`2001:db8:cf00:2501::/64`. + +The host: + +- assigns the host addresses above and sends router advertisements on + `cvd-ebr`, `cvd-wbr` and every `cvd-wifiap-NN`, as in private mode; +- adds `ip -6 route add P:25NN::/64 via P:23NN::2 dev cvd-wifiap-NN` for every + instance and records the routes in `/run/cuttlefish/ipv6-routes`, so `stop` + (and a repeated `start`) removes them even if the configuration changed; +- keeps the router advertisement guard (guests cannot send RAs or redirects) + and explicit `iifname`/`oifname "cvd-*"` accept rules in `ip6 filter FORWARD`; +- adds no NAT66 rule (the `ip6 cuttlefish_nat6` table exists, but is empty). + +## How the device learns routed mode + +No new `cvd`/`launch_cvd` flag is needed. The host tools read the host +address of each instance's taps: + +- `assemble_cvd` (`network_flags.cpp`) reads the first global IPv6 address of + `cvd-mtap-NN` and gives the guest the next address (`::2`), gateway `::1`. + This is the same code as in private mode. +- `OpenwrtArgsFromConfig()` (`openwrt_args.cpp`) reads the address of + `cvd-wifiap-NN`. Only when it is a global (non-ULA) `P:23NN::1/64` does it + add these OpenWrt kernel command line arguments: + + | Key | Value | + | --- | --- | + | `wan_ip6addr` | `P:23NN::2/64` | + | `wan_ip6gw` | `P:23NN::1` | + | `lan_ip6prefix` | `P:25NN::/64` | + + With the private mode address (`fd00:cf:23:::1`), nothing is added and + OpenWrt keeps its default configuration. The bridged Wi-Fi tap + (`cvd-wbr`) and cvdalloc modes do not get these arguments. + +The OpenWrt image (`external/openwrt-prebuilts`, +`shared/uci-defaults/0_default_config`) reads these keys from +`/proc/cmdline`: when `lan_ip6prefix` is present it sets the static WAN IPv6 +address and gateway, sets that `/64` as the LAN prefix, and disables `masq6` +on the `wan` zone. Otherwise it keeps the ULA LAN prefix and `masq6`. An +OpenWrt image without this support keeps NAT66 on the Wi-Fi path, so the +Wi-Fi address that servers see is the OpenWrt WAN address, not the guest's. + +## Tests + +- `e2etests/host_resources/static_resources_init_test` (`routed_test.go`) + runs the init script in a rootless network namespace sandbox: address + layout, router advertisement prefixes, no NAT66 rule, routes to the OpenWrt + LAN `/64`s, cleanup on `stop` and on a repeated `start`, the `ipv6_nat` + override, and the fallback to private mode for invalid prefixes. +- `e2etests/cvd/networking_tests` (`routed_echo_test.go`, + `TestIPv6RoutedEcho`) fetches an echo URL from the guest over Wi-Fi and over + mobile data separately (`curl --interface`, as root on a debuggable image) + and checks that the address the server saw differs per network and is one + of the guest's own addresses on that interface. It is skipped unless + `--routed_echo_url` is given: + + ``` + bazel test //cvd/networking_tests:networking_tests \ + --test_arg=--routed_echo_url=http://[2001:db8:ec00::80]/ \ + --test_arg=-test.run=TestIPv6RoutedEcho + ``` + + The server must answer with the caller's address in the response body. + Use an IPv6 literal URL, so the request cannot use IPv4. +- The unit tests `//cuttlefish/host/libs/config:openwrt_args_test` and + `//cuttlefish/host/commands/assemble_cvd:network_flags_test` cover the + address derivation. diff --git a/docs/networking/openwrt_ipv6_static.md b/docs/networking/openwrt_ipv6_static.md new file mode 100644 index 00000000000..c086f1db6b7 --- /dev/null +++ b/docs/networking/openwrt_ipv6_static.md @@ -0,0 +1,84 @@ +# OpenWrt IPv6 in static networking mode + +The OpenWrt side of this configuration lives in +[`platform/external/openwrt-prebuilts`](https://android.googlesource.com/platform/external/openwrt-prebuilts/+/main/shared/config/) +(`shared/config/{network,dhcp,firewall}`). + +## Topology + +With `virtio_mac80211_hwsim` (the default), the guest `wlan0` is a client of +the OpenWrt access point, and OpenWrt routes it to the host: + +``` +guest wlan0 --(wifi0 LAN, br-wifi0)-- OpenWrt --(wan, eth0/br-lan)-- host + fd00:cf:25:X::/64 cvd-wbr fd00:cf:22::/64 (bridged) + 192.168.99.0/25 cvd-wifiap-i fd00:cf:23:i::/64 (non-bridged) +``` + +The host side (init script `cuttlefish-host-resources`) provides, on the WAN +segment, a router advertisement from a separate RA-only `dnsmasq` and NAT66 to +the upstream network. + +## OpenWrt configuration (declarative, ships in the image) + +| File | Change | Why | +| --- | --- | --- | +| `shared/config/network` | `config interface 'wan6'`: `device '@wan'`, `proto 'dhcpv6'`, `reqaddress 'try'`, `reqprefix 'no'`, `sourcefilter '0'` | `odhcp6c` takes a SLAAC address and the default route from the host RA. No DHCPv6 prefix delegation exists on the host. `sourcefilter 0`: without it the default route is limited to `from ` and forwarded LAN packets have no route. | +| `shared/config/network` | `globals.ula_prefix 'fd00:cf:25::/48'` (was a random `fd72:5afb:a7cf::/48`), `ip6assign '64'` on `wifi0` and `wifi1` | Fixed, documented LAN prefix; one /64 per Wi-Fi LAN. | +| `shared/config/network` | `br-wifi0` and `br-wifi1`: replace `list ports 'eth0.0'` / `'eth0.1'` and the `eth0.0` / `eth0.1` device sections with `option bridge_empty '1'` and `option macaddr '28:80:88:2A:6D:01'` / `'28:80:88:2A:6D:02'` | Keeps `br-wifi0` and `br-wifi1` up with fixed MAC addresses before `hostapd` attaches `wlan0`/`wlan1` without enslaving 802.1Q VLAN sub-interfaces of `eth0`, so Wi-Fi LAN RAs and broadcast frames do not leak onto `eth0`. | +| `shared/config/dhcp` | `wifi0`/`wifi1`: `ra 'server'`, `ra_slaac '1'`, `ra_default '1'`, `dhcpv6 'disabled'` | `odhcpd` sends RAs with the LAN prefix and RDNSS (the router's LAN address; OpenWrt `dnsmasq` answers, as for IPv4 DHCP DNS). `ra_default 1` is needed because the WAN has only a ULA address; without it `odhcpd` announces router lifetime 0. | +| `shared/config/firewall` | `masq6 '1'` on the `wan` zone | NAT66 on OpenWrt, then NAT66 on the host: same double NAT as IPv4 today. The `wan` zone already lists `wan6`. fw4 already accepts established/related traffic and ICMPv6; no accept-all rule is added. | + +IPv4 (`wan` static address from `/proc/cmdline`, `masq '1'`, DHCPv4 on +`wifi0`/`wifi1`) is unchanged. `shared/uci-defaults/0_default_config` is +unchanged. + +### Why static UCI and not new `/proc/cmdline` arguments + +IPv4 needs per-instance values (`wan_ipaddr`, `wan_gateway`, `wan_broadcast`) +because the host has no DHCPv4 server on the WAN segment, so +`OpenwrtArgsFromConfig()` passes them through `/proc/cmdline` to +`0_default_config`. IPv6 has no per-instance values on the OpenWrt side: the +WAN learns its address and gateway from the host RA, and the LAN prefix is the +same in every OpenWrt instance (it is behind NAT66). The existing static LAN +configuration (`wifi0` `192.168.99.1/25`) lives in `shared/config/network`; +the IPv6 LAN configuration sits next to it. This needs no new host arguments, +no new shell logic, and works for every instance number and both Wi-Fi modes. + +This applies to the default (private) mode. In IPv6 routed mode the LAN +prefix is a per-instance global `/64` with no NAT66, so the host passes +`wan_ip6addr`, `wan_ip6gw` and `lan_ip6prefix` on `/proc/cmdline`; see +[ipv6_routed_mode.md](ipv6_routed_mode.md). + +## Compatibility + +- Older OpenWrt images: no `wan6`; Wi-Fi stays IPv4-only. No IPv4 change. +- New image with a host without IPv6 RA (old host package): `wan6` stays + without address; the LAN still gets `fd00:cf:25:X::/64` RAs with + `ra_default 1`, so the guest installs an IPv6 default route that leads + nowhere (OpenWrt answers with ICMPv6 unreachable). Android does not count a + ULA-only address as IPv6 provisioning (`LinkAddress.isGlobalPreferred()` + excludes ULA), so IPv4 stays the provisioned family. IPv4 is unaffected. +- cvdalloc (dynamic) mode: not covered by this configuration. A routed + (non-NATed) guest prefix would require `firewall.@zone[wan].masq6=0`. + Static mode supports a routed prefix: see + [ipv6_routed_mode.md](ipv6_routed_mode.md). + +## Local test (no sudo) + +Rootfs layout: `openwrt_rootfs_x86_64` is a squashfs (first 74 ร— 64 KiB) plus +an f2fs overlay holding `upper/etc/config/*` and +`upper/etc/uci-defaults/0_default_config`. `sload.f2fs` does not overwrite +files, so the overlay is dumped and rebuilt with the new files, then appended +to the squashfs part. + +Guest checks (after `cmd wifi connect-network`, if Wi-Fi is not already +connected to the default AP `VirtWifi`): + +``` +adb shell ip -6 addr show wlan0 # inet6 fd00:cf:25:... scope global dynamic +adb shell ip -6 route show table all | grep '^default.*wlan0' # proto ra +adb shell ping6 -c3 -I wlan0 2001:4860:4860::8888 +adb shell dumpsys connectivity | grep -o 'InterfaceName: wlan0.*DnsAddresses: \[[^]]*\]' +adb shell ping -c3 -I wlan0 8.8.8.8 # IPv4 unchanged +``` diff --git a/e2etests/cvd/networking_tests/BUILD.bazel b/e2etests/cvd/networking_tests/BUILD.bazel index 07f6b17475c..68c18442214 100644 --- a/e2etests/cvd/networking_tests/BUILD.bazel +++ b/e2etests/cvd/networking_tests/BUILD.bazel @@ -1,22 +1,38 @@ load("@rules_go//go:def.bzl", "go_test") +_SRCS = [ + "ipv6_helpers_test.go", + "ipv6_test.go", + "main_test.go", + "routed_echo_helpers_test.go", + "routed_echo_test.go", +] + +_TAGS = [ + "exclusive", + "external", + "no-sandbox", + "requires_ab", + "supports-graceful-termination", +] + +# Hermetic by default: runs on IPv4-only hosts with the package's default +# settings, checking eth1 SLAAC IPv6, buried_eth0 IPv4, and absence of +# 2001:db8::/32 addresses. Pass --test_arg=--image_ril_ipv6, +# --test_arg=--image_openwrt_ipv6, --test_arg=--image_manages_eth1, and +# --test_arg=-test.skip= to also run RIL/OpenWrt IPv6 and NAT66 egress checks. +# TestIPv6RoutedEcho (IPv6 routed mode) is skipped unless +# --test_arg=--routed_echo_url= is given; see routed_echo_test.go. go_test( name = "networking_tests", size = "large", - srcs = ["main_test.go"], - data = [ - "main_test.go", - "//:debian_substitution_marker", - ], + srcs = _SRCS, + args = ["-test.skip=^TestIPv6Nat66Egress$$"], + data = _SRCS + ["//:debian_substitution_marker"], env = {"LOCAL_DEBIAN_SUBSTITUTION_MARKER_FILE": "$(rlocationpath //:debian_substitution_marker)"}, - tags = [ - "exclusive", - "external", - "no-sandbox", - "requires_ab", - "supports-graceful-termination", - ], + tags = _TAGS, deps = [ "//cvd/common", + "@com_github_google_go_cmp//cmp", ], ) diff --git a/e2etests/cvd/networking_tests/ipv6_helpers_test.go b/e2etests/cvd/networking_tests/ipv6_helpers_test.go new file mode 100644 index 00000000000..13ffd9fc597 --- /dev/null +++ b/e2etests/cvd/networking_tests/ipv6_helpers_test.go @@ -0,0 +1,112 @@ +// 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 + +// Device-free tests of the parsing and plan helpers used by ipv6_test.go. +// Run alone with: go test ./cvd/networking_tests -run 'TestIPv6Helper' + +import ( + "net/netip" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestIPv6HelperGlobalIPv6Addrs(t *testing.T) { + out := "3: eth1 inet6 fd00:cf:24::5054:ff:fe12:3456/64 scope global dynamic mngtmpaddr \\ valid_lft 86394sec\n" + + "3: eth1 inet6 fe80::1/64 scope link \\ valid_lft forever\n" + + "4: buried_eth0 inet6 fd00:cf:21:a::2/64 scope global \\ valid_lft forever\n" + + "garbage\n" + got := globalIPv6Addrs(out) + want := []netip.Addr{ + netip.MustParseAddr("fd00:cf:24::5054:ff:fe12:3456"), + netip.MustParseAddr("fe80::1"), + netip.MustParseAddr("fd00:cf:21:a::2"), + } + if diff := cmp.Diff(want, got, cmp.Comparer(func(a, b netip.Addr) bool { return a == b })); diff != "" { + t.Errorf("globalIPv6Addrs (-want +got):\n%s", diff) + } +} + +func TestIPv6HelperBracketList(t *testing.T) { + line := "NetworkAgentInfo{... InterfaceName: buried_eth0 LinkAddresses: [ 192.168.97.2/30,fd00:cf:21:1::2/64 ] DnsAddresses: [ /8.8.8.8,/2001:4860:4860::8888 ] ...}" + if diff := cmp.Diff([]string{"192.168.97.2/30", "fd00:cf:21:1::2/64"}, bracketList(line, "LinkAddresses")); diff != "" { + t.Errorf("LinkAddresses (-want +got):\n%s", diff) + } + if diff := cmp.Diff([]string{"/8.8.8.8", "/2001:4860:4860::8888"}, bracketList(line, "DnsAddresses")); diff != "" { + t.Errorf("DnsAddresses (-want +got):\n%s", diff) + } + if got := bracketList(line, "Routes"); got != nil { + t.Errorf("missing key: got %v, want nil", got) + } +} + +func TestIPv6HelperMobileIPv4(t *testing.T) { + for _, c := range []struct { + num int + addr, gateway string + }{ + {1, "192.168.97.2/30", "192.168.97.1"}, + {64, "192.168.97.254/30", "192.168.97.253"}, + {65, "192.168.93.2/30", "192.168.93.1"}, + } { + addr, gw := deviceInstance{num: c.num}.mobileIPv4() + if addr != c.addr || gw != c.gateway { + t.Errorf("instance %d: got (%s, %s), want (%s, %s)", c.num, addr, gw, c.addr, c.gateway) + } + } +} + +// The expected mobile address is the one assemble_cvd derives from the host's +// fd00:cf:21:::1/64 (MobileIpv6ConfigFromHostAddress): ::2. +func TestIPv6HelperGuestAdapters(t *testing.T) { + defer func(r, o, e bool) { *imageRilIPv6, *imageOpenwrtIPv6, *imageManagesEth1 = r, o, e }( + *imageRilIPv6, *imageOpenwrtIPv6, *imageManagesEth1) + + *imageRilIPv6, *imageOpenwrtIPv6, *imageManagesEth1 = false, false, false + a := guestAdapters(deviceInstance{num: 10}) + if len(a) != 2 || a[0].iface != "eth1" || a[1].iface != "buried_eth0" { + t.Fatalf("default adapters: %+v", a) + } + if a[1].ipv6 { + t.Error("buried_eth0 IPv6 checked without --image_ril_ipv6") + } + if a[0].androidNetwork { + t.Error("eth1 LinkProperties checked without --image_manages_eth1") + } + + *imageRilIPv6, *imageOpenwrtIPv6 = true, true + a = guestAdapters(deviceInstance{num: 10}) + if len(a) != 3 || a[2].iface != "wlan0" { + t.Fatalf("adapters with all flags: %+v", a) + } + if want := netip.MustParseAddr("fd00:cf:21:a::2"); a[1].exactAddr != want { + t.Errorf("buried_eth0 exact address %v, want %v", a[1].exactAddr, want) + } + if want := netip.MustParsePrefix("fd00:cf:23:a::/64"); a[2].hostPrefix != want { + t.Errorf("wlan0 host prefix %v, want %v", a[2].hostPrefix, want) + } + if want := netip.MustParsePrefix("fd00:cf:25::/48"); a[2].prefix != want { + t.Errorf("wlan0 prefix %v, want %v", a[2].prefix, want) + } + + a = guestAdapters(deviceInstance{num: 3, useBridgedWifiTap: true}) + // wlan0 stays an OpenWrt LAN client; only the WAN segment moves to cvd-wbr. + wantLAN := netip.MustParsePrefix("fd00:cf:25::/48") + wantHost := netip.MustParsePrefix("fd00:cf:22::/64") + if a[2].prefix != wantLAN || a[2].hostPrefix != wantHost { + t.Errorf("bridged wlan0 prefix %v host %v, want %v host %v", a[2].prefix, a[2].hostPrefix, wantLAN, wantHost) + } +} diff --git a/e2etests/cvd/networking_tests/ipv6_test.go b/e2etests/cvd/networking_tests/ipv6_test.go new file mode 100644 index 00000000000..9c5e0a41de8 --- /dev/null +++ b/e2etests/cvd/networking_tests/ipv6_test.go @@ -0,0 +1,594 @@ +// 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 ( + "encoding/json" + "flag" + "fmt" + "net/netip" + "os" + "os/exec" + "strconv" + "strings" + "testing" + "time" + + e2etests "github.com/google/android-cuttlefish/e2etests/cvd/common" +) + +// Image capabilities. The IPv6 host setup of cuttlefish-host-resources +// (static mode) works with any image, but some guest adapters only get IPv6 +// when the image carries the matching AOSP change. Pass these flags with +// --test_arg (see BUILD.bazel); the test never probes the image to decide +// what to check. +var ( + imageRilIPv6 = flag.Bool("image_ril_ipv6", false, + "The image's reference RIL requests IPV4V6 PDP contexts and configures "+ + "the IPv6 address, route and DNS it gets from modem_simulator on buried_eth0.") + imageOpenwrtIPv6 = flag.Bool("image_openwrt_ipv6", false, + "The image connects Wi-Fi at boot and its OpenWrt build advertises the "+ + "fd00:cf:25::/48 LAN prefix with NAT66 on the WAN side "+ + "(docs/networking/openwrt_ipv6_static.md).") + imageManagesEth1 = flag.Bool("image_manages_eth1", false, + "Android runs a network (IpClient) on eth1. Phone images do not; there the "+ + "test only checks the kernel SLAAC state of eth1.") +) + +// Static-mode IPv6 plan of cuttlefish-host-resources. is the instance +// number in hex. +const ( + mobilePrefixFmt = "fd00:cf:21:%x::/64" // cvd-mtap-, address from the RIL. + wifiBridgePrefix = "fd00:cf:22::/64" // cvd-wbr (bridged Wi-Fi tap). + wifiApPrefixFmt = "fd00:cf:23:%x::/64" // cvd-wifiap- (OpenWrt WAN). + ethernetPrefix = "fd00:cf:24::/64" // cvd-ebr. + openwrtLanPrefix = "fd00:cf:25::/48" // OpenWrt LAN, behind OpenWrt NAT66. + documentationRange = "2001:db8::/32" // Must never appear in the guest. + + ipv6EgressTarget = "2001:4860:4860::8888" + ipv6Timeout = 60 * time.Second + pollInterval = 2 * time.Second +) + +// deviceInstance holds the parts of cuttlefish_config.json the test needs. +type deviceInstance struct { + num int + useBridgedWifiTap bool +} + +// readDeviceInstance reads the instance created by CVDCreate. CVDCreate runs +// `cvd create` with HOME set to the test directory, which is also the current +// directory. +func readDeviceInstance() (deviceInstance, error) { + const path = "cuttlefish_runtime/cuttlefish_config.json" + raw, err := os.ReadFile(path) + if err != nil { + return deviceInstance{}, fmt.Errorf("reading %s: %w", path, err) + } + var config struct { + Instances map[string]struct { + UseBridgedWifiTap bool `json:"use_bridged_wifi_tap"` + } `json:"instances"` + } + if err := json.Unmarshal(raw, &config); err != nil { + return deviceInstance{}, fmt.Errorf("parsing %s: %w", path, err) + } + if len(config.Instances) != 1 { + return deviceInstance{}, fmt.Errorf("%s has %d instances, want 1", path, len(config.Instances)) + } + d := deviceInstance{} + for key, instance := range config.Instances { + if d.num, err = strconv.Atoi(key); err != nil { + return deviceInstance{}, fmt.Errorf("instance key %q in %s: %w", key, path, err) + } + d.useBridgedWifiTap = instance.UseBridgedWifiTap + } + return d, nil +} + +// mobileIPv4 returns the guest address and gateway of buried_eth0, following +// create_interface() in cuttlefish-base.cuttlefish-host-resources.init. +func (d deviceInstance) mobileIPv4() (addr, gateway string) { + base, n := "192.168.97", d.num + if n > 64 { + base, n = "192.168.93", n-64 + } + return fmt.Sprintf("%s.%d/30", base, 4*n-2), fmt.Sprintf("%s.%d", base, 4*n-3) +} + +// guestAdapter describes the IPv6 and IPv4 state expected on one guest +// interface. +type guestAdapter struct { + iface string + // ipv6 enables the IPv6 checks. + ipv6 bool + // prefix is the prefix that the guest address must be in. + prefix netip.Prefix + // exactAddr, if valid, is the only acceptable address (RIL-assigned). + exactAddr netip.Addr + // ra is true when the default route comes from a Router Advertisement, + // false when the RIL installs it. + ra bool + // androidNetwork is true when Android runs a network on the interface, so + // LinkProperties must carry the IPv6 address and an IPv6 DNS server. + androidNetwork bool + // hostPrefix is the source prefix that the host masquerades (NAT66). + hostPrefix netip.Prefix + // ipv4Addr and ipv4Gateway, if set, are the IPv4 address and default + // gateway that must stay on the interface. ipv4Any accepts any address + // and gateway. + ipv4Addr string + ipv4Gateway string + ipv4Any bool +} + +// guestAdapters returns the adapters of the device selected by the image +// capability flags. +func guestAdapters(d deviceInstance) []guestAdapter { + mobile := netip.MustParsePrefix(fmt.Sprintf(mobilePrefixFmt, d.num)) + mobileAddr, mobileGateway := d.mobileIPv4() + wifiHost := netip.MustParsePrefix(fmt.Sprintf(wifiApPrefixFmt, d.num)) + if d.useBridgedWifiTap { + wifiHost = netip.MustParsePrefix(wifiBridgePrefix) + } + eth1 := guestAdapter{ + iface: "eth1", + ipv6: true, + prefix: netip.MustParsePrefix(ethernetPrefix), + ra: true, + androidNetwork: *imageManagesEth1, + hostPrefix: netip.MustParsePrefix(ethernetPrefix), + ipv4Any: *imageManagesEth1, + } + adapters := []guestAdapter{ + eth1, + { + iface: "buried_eth0", + ipv6: *imageRilIPv6, + prefix: mobile, + exactAddr: mobile.Addr().Next().Next(), + ra: false, + androidNetwork: true, + hostPrefix: mobile, + ipv4Addr: mobileAddr, + ipv4Gateway: mobileGateway, + }, + } + if *imageOpenwrtIPv6 { + // wlan0 is an OpenWrt LAN client in both Wi-Fi tap modes; the tap mode + // only selects the OpenWrt WAN segment (wifiHost). + lan := netip.MustParsePrefix(openwrtLanPrefix) + adapters = append(adapters, guestAdapter{ + iface: "wlan0", + ipv6: true, + prefix: lan, + ra: true, + androidNetwork: true, + hostPrefix: wifiHost, + ipv4Any: true, + }) + } + return adapters +} + +// guestShell runs a read-only command in the guest. +func guestShell(c *e2etests.TestContext, cmd string) (string, error) { + out, err := c.RunCmd("adb", "shell", cmd) + return out.Stdout, err +} + +// waitForGuestOutput polls a read-only guest command until match returns a +// non-empty string or the timeout expires. RA, DAD and RIL data call setup +// complete asynchronously after boot. +func waitForGuestOutput(c *e2etests.TestContext, cmd string, match func(string) string) (string, error) { + deadline := time.Now().Add(ipv6Timeout) + last := "" + for { + out, err := guestShell(c, cmd) + if err == nil { + if m := match(out); m != "" { + return m, nil + } + last = out + } + if time.Now().After(deadline) { + return "", fmt.Errorf("timed out after %v running %q, last output:\n%s", ipv6Timeout, cmd, last) + } + time.Sleep(pollInterval) + } +} + +// globalIPv6Addrs parses `ip -6 -o addr show scope global`. +func globalIPv6Addrs(out string) []netip.Addr { + var addrs []netip.Addr + for _, line := range strings.Split(out, "\n") { + fields := strings.Fields(line) + if len(fields) < 4 || fields[2] != "inet6" { + continue + } + if p, err := netip.ParsePrefix(fields[3]); err == nil { + addrs = append(addrs, p.Addr()) + } + } + return addrs +} + +// waitForIPv6Addr waits for the expected global address on the adapter. +func waitForIPv6Addr(c *e2etests.TestContext, a guestAdapter) (netip.Addr, error) { + out, err := waitForGuestOutput(c, "ip -6 -o addr show dev "+a.iface+" scope global", func(out string) string { + for _, addr := range globalIPv6Addrs(out) { + if a.exactAddr.IsValid() && addr == a.exactAddr { + return addr.String() + } + if !a.exactAddr.IsValid() && a.prefix.Contains(addr) { + return addr.String() + } + } + return "" + }) + if err != nil { + return netip.Addr{}, err + } + return netip.MustParseAddr(out), nil +} + +// waitForIPv6DefaultRoute waits for the IPv6 default route of the adapter. +// The route is in the per-network table that Android (or the kernel, for +// interfaces Android does not manage) uses, so all tables are searched. +func waitForIPv6DefaultRoute(c *e2etests.TestContext, a guestAdapter) (string, error) { + return waitForGuestOutput(c, "ip -6 route show table all", func(out string) string { + for _, line := range strings.Split(out, "\n") { + if !strings.HasPrefix(line, "default via ") || !strings.Contains(line, " dev "+a.iface+" ") { + continue + } + isRA := strings.Contains(line, " proto ra ") + if a.ra && isRA && strings.HasPrefix(line, "default via fe80:") { + return line + } + if !a.ra && !isRA { + return line + } + } + return "" + }) +} + +// raRoutes returns the routes on iface that a Router Advertisement installed. +func raRoutes(c *e2etests.TestContext, iface string) ([]string, error) { + out, err := guestShell(c, "ip -6 route show table all") + if err != nil { + return nil, err + } + var routes []string + for _, line := range strings.Split(out, "\n") { + if strings.Contains(line, " dev "+iface+" ") && strings.Contains(line, " proto ra ") { + routes = append(routes, line) + } + } + return routes, nil +} + +// bracketList returns the items of the first ": [ a b ]" in s. +func bracketList(s, key string) []string { + start := strings.Index(s, key+": [") + if start < 0 { + return nil + } + rest := s[start+len(key)+3:] + end := strings.Index(rest, "]") + if end < 0 { + return nil + } + return strings.Fields(strings.ReplaceAll(rest[:end], ",", " ")) +} + +// waitForLinkProperties waits until the LinkProperties of the Android network +// on iface carry addr and an IPv6 DNS server. ConnectivityService pushes these +// DNS servers to the DNS resolver. +func waitForLinkProperties(c *e2etests.TestContext, iface string, addr netip.Addr) (string, error) { + return waitForGuestOutput(c, "dumpsys connectivity", func(out string) string { + for _, line := range strings.Split(out, "\n") { + if !strings.Contains(line, "NetworkAgentInfo{") || !strings.Contains(line, "InterfaceName: "+iface+" ") { + continue + } + hasAddr := false + for _, la := range bracketList(line, "LinkAddresses") { + if p, err := netip.ParsePrefix(la); err == nil && p.Addr() == addr { + hasAddr = true + } + } + var dns6 []string + for _, d := range bracketList(line, "DnsAddresses") { + if a, err := netip.ParseAddr(strings.TrimPrefix(d, "/")); err == nil && a.Is6() && !a.Is4In6() { + dns6 = append(dns6, a.String()) + } + } + if hasAddr && len(dns6) > 0 { + return strings.Join(dns6, " ") + } + } + return "" + }) +} + +// waitForIPv4 waits for the IPv4 address and default route of the adapter. +func waitForIPv4(c *e2etests.TestContext, a guestAdapter) (string, error) { + addr, err := waitForGuestOutput(c, "ip -4 -o addr show dev "+a.iface, func(out string) string { + for _, line := range strings.Split(out, "\n") { + fields := strings.Fields(line) + if len(fields) < 4 || fields[2] != "inet" { + continue + } + if a.ipv4Any || fields[3] == a.ipv4Addr { + return fields[3] + } + } + return "" + }) + if err != nil { + return "", err + } + route, err := waitForGuestOutput(c, "ip -4 route show table all", func(out string) string { + for _, line := range strings.Split(out, "\n") { + if !strings.HasPrefix(line, "default via ") || !strings.Contains(line, " dev "+a.iface+" ") { + continue + } + if a.ipv4Any || strings.HasPrefix(line, "default via "+a.ipv4Gateway+" ") { + return line + } + } + return "" + }) + if err != nil { + return "", err + } + return addr + "; " + route, nil +} + +// skipUnderPodcvd skips t under podcvd: the static-mode bridges and taps and +// cuttlefish_runtime/cuttlefish_config.json live inside the podcvd container, +// not on the host where the test runs. +func skipUnderPodcvd(t *testing.T) { + t.Helper() + if os.Getenv("USE_PODCVD") == "true" { + t.Skip("skipping: IPv6 host checks need the static-mode host setup outside a podcvd container") + } +} + +// launchDevice fetches and launches the phone image used by the IPv6 tests. +func launchDevice(t *testing.T, c *e2etests.TestContext) deviceInstance { + t.Log("Fetching Cuttlefish 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.CVDCreate(e2etests.CreateArgs{}); err != nil { + t.Fatal(err) + } + if err := c.RunAdbWaitForDevice(); err != nil { + t.Fatal(err) + } + d, err := readDeviceInstance() + if err != nil { + t.Fatal(err) + } + t.Logf("instance %d, use_bridged_wifi_tap=%v", d.num, d.useBridgedWifiTap) + return d +} + +// requireHostStaticIPv6 skips t when the host kernel has IPv6 disabled or +// cuttlefish-host-resources has not provisioned the static-mode ULA prefix on +// cvd-ebr (for example, on a host running an older cuttlefish-base package). +func requireHostStaticIPv6(t *testing.T) { + t.Helper() + if raw, err := os.ReadFile("/proc/sys/net/ipv6/conf/all/disable_ipv6"); err == nil && strings.TrimSpace(string(raw)) == "1" { + t.Skip("skipping IPv6 check: host /proc/sys/net/ipv6/conf/all/disable_ipv6 is 1") + } + out, err := exec.Command("ip", "-6", "-o", "addr", "show", "dev", "cvd-ebr", "scope", "global").CombinedOutput() + if err != nil { + t.Skipf("skipping IPv6 check: cannot query cvd-ebr IPv6 addresses (%v: %s)", err, strings.TrimSpace(string(out))) + } + ethPrefix := netip.MustParsePrefix(ethernetPrefix) + for _, addr := range globalIPv6Addrs(string(out)) { + if ethPrefix.Contains(addr) { + return + } + } + t.Skipf("skipping IPv6 check: host cvd-ebr lacks an address in %s (output: %s)", ethPrefix, strings.TrimSpace(string(out))) +} + +// TestIPv6Provisioning checks the IPv6 configuration that the static-mode +// host setup (cuttlefish-host-resources with its default settings) gives each +// guest adapter, and that IPv4 is unchanged. It needs no IPv6 upstream on the +// host, so it also runs on IPv4-only hosts. +// +// The test only reads guest and host state. It never adds addresses, routes +// or rules and never changes settings, so it sees what Android set up. +// +// - eth1: SLAAC address in fd00:cf:24::/64 and an RA default route. Phone +// images do not run an Android network on eth1, so only the kernel state +// is checked unless --image_manages_eth1 is set. +// - buried_eth0: IPv4 address and gateway of the instance. With +// --image_ril_ipv6, also the RIL address fd00:cf:21:::2, a RIL (not RA) +// default route and IPv6 DNS in LinkProperties. +// - wlan0 (--image_openwrt_ipv6 only): SLAAC address in the OpenWrt LAN +// prefix fd00:cf:25::/48 (in both Wi-Fi tap modes), an +// RA default route, IPv6 DNS in LinkProperties and an IPv4 address. +// - No guest address is in the 2001:db8::/32 documentation range. +func TestIPv6Provisioning(t *testing.T) { + skipUnderPodcvd(t) + c := e2etests.TestContext{} + c.SetUp(t) + defer c.TearDown() + + d := launchDevice(t, &c) + + for _, a := range guestAdapters(d) { + t.Run(a.iface, func(t *testing.T) { + if a.ipv4Addr != "" || a.ipv4Any { + ipv4, err := waitForIPv4(&c, a) + if err != nil { + logDiagnostics(&c, t) + t.Fatalf("IPv4 changed on %s (want address %q, gateway %q): %v", a.iface, a.ipv4Addr, a.ipv4Gateway, err) + } + t.Logf("IPv4: %s", ipv4) + } + + if a.ipv6 { + requireHostStaticIPv6(t) + addr, err := waitForIPv6Addr(&c, a) + if err != nil { + logDiagnostics(&c, t) + if a.exactAddr.IsValid() { + t.Fatalf("no IPv6 address %s: %v", a.exactAddr, err) + } + t.Fatalf("no IPv6 address in %s: %v", a.prefix, err) + } + t.Logf("IPv6 address %s", addr) + + route, err := waitForIPv6DefaultRoute(&c, a) + if err != nil { + logDiagnostics(&c, t) + t.Fatalf("no IPv6 default route (from RA: %v): %v", a.ra, err) + } + t.Logf("IPv6 default route: %s", route) + + if !a.ra { + // The host sends no RAs on this segment; the RIL owns the + // configuration. + routes, err := raRoutes(&c, a.iface) + if err != nil { + t.Fatal(err) + } + if len(routes) > 0 { + t.Errorf("unexpected RA routes on %s:\n%s", a.iface, strings.Join(routes, "\n")) + } + } + + if a.androidNetwork { + dns, err := waitForLinkProperties(&c, a.iface, addr) + if err != nil { + logDiagnostics(&c, t) + t.Fatalf("LinkProperties of %s lack %s or an IPv6 DNS server: %v", a.iface, addr, err) + } + t.Logf("IPv6 DNS servers in LinkProperties: %s", dns) + } + } else { + t.Logf("IPv6 checks of %s need an image capability flag; checking IPv4 only", a.iface) + } + }) + } + + t.Run("NoDocumentationPrefix", func(t *testing.T) { + out, err := guestShell(&c, "ip -6 -o addr show scope global") + if err != nil { + t.Fatal(err) + } + doc := netip.MustParsePrefix(documentationRange) + for _, addr := range globalIPv6Addrs(out) { + if doc.Contains(addr) { + t.Errorf("guest has address %s in %s", addr, doc) + } + } + }) +} + +// checkHostNat66 checks that the host masquerades the adapter's source +// prefix. Reading nftables needs CAP_NET_ADMIN; without it the ping result is +// the evidence. +func checkHostNat66(t *testing.T, c *e2etests.TestContext, prefix netip.Prefix) { + out, err := c.RunCmd("nft", "list", "table", "ip6", "cuttlefish_nat6") + if err != nil { + t.Logf("cannot read host nftables without privileges (%v); relying on the guest ping", err) + return + } + for _, line := range strings.Split(out.Stdout, "\n") { + _, after, ok := strings.Cut(line, "ip6 saddr ") + if !ok || !strings.Contains(line, "masquerade") { + continue + } + fields := strings.Fields(after) + if len(fields) == 0 { + continue + } + if natPrefix, err := netip.ParsePrefix(fields[0]); err == nil && + natPrefix.Contains(prefix.Addr()) && natPrefix.Bits() <= prefix.Bits() { + t.Logf("host NAT66 rule covering %s: %s", prefix, strings.TrimSpace(line)) + return + } + } + t.Errorf("host table ip6 cuttlefish_nat6 has no masquerade rule covering %s:\n%s", prefix, out.Stdout) +} + +// TestIPv6Nat66Egress checks off-link IPv6 from each Android network through +// the host NAT66. It needs an IPv6 upstream on the host, so the Bazel target +// skips it by default (-test.skip in BUILD.bazel). It checks the adapters that +// the image capability flags enable and fails when none is enabled. +func TestIPv6Nat66Egress(t *testing.T) { + skipUnderPodcvd(t) + requireHostStaticIPv6(t) + c := e2etests.TestContext{} + c.SetUp(t) + defer c.TearDown() + + d := launchDevice(t, &c) + + var adapters []guestAdapter + for _, a := range guestAdapters(d) { + if a.ipv6 && a.androidNetwork { + adapters = append(adapters, a) + } + } + if len(adapters) == 0 { + t.Fatal("no Android network with IPv6 selected; set --image_ril_ipv6, --image_openwrt_ipv6 or --image_manages_eth1") + } + + for _, a := range adapters { + t.Run(a.iface, func(t *testing.T) { + addr, err := waitForIPv6Addr(&c, a) + if err != nil { + logDiagnostics(&c, t) + t.Fatalf("no IPv6 address in %s: %v", a.prefix, err) + } + // ping6 binds to the interface, so netd's per-network rules + // route it through that network. + cmd := fmt.Sprintf("ping6 -c 3 -I %s %s", a.iface, ipv6EgressTarget) + out, err := waitForGuestOutput(&c, cmd, func(out string) string { + if strings.Contains(out, " 0% packet loss") { + return out + } + return "" + }) + if err != nil { + logDiagnostics(&c, t) + t.Fatalf("IPv6 egress from %s (%s) failed: %v", a.iface, addr, err) + } + // ping6 prints "PING () from : ...". + src := netip.Addr{} + if _, after, ok := strings.Cut(out, ") from "); ok { + if fields := strings.Fields(after); len(fields) > 0 { + src, _ = netip.ParseAddr(fields[0]) + } + } + if !a.prefix.Contains(src) { + t.Errorf("ping6 source %v is not in %s:\n%s", src, a.prefix, out) + } + checkHostNat66(t, &c, a.hostPrefix) + }) + } +} diff --git a/e2etests/cvd/networking_tests/routed_echo_helpers_test.go b/e2etests/cvd/networking_tests/routed_echo_helpers_test.go new file mode 100644 index 00000000000..cda88089612 --- /dev/null +++ b/e2etests/cvd/networking_tests/routed_echo_helpers_test.go @@ -0,0 +1,93 @@ +// 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 + +// Unit tests of the TestIPv6RoutedEcho helpers. They need no device. + +import ( + "net/netip" + "testing" +) + +func TestRoutedEchoHelperPrefixFromHostAddr(t *testing.T) { + for _, c := range []struct { + addr string + num int + want string // "" for an error + }{ + {"2001:db8:cf00:2101::1", 1, "2001:db8:cf00::/48"}, + {"2001:db8:0:210a::1", 10, "2001:db8::/48"}, + {"2001:db8:cf00:2180::1", 128, "2001:db8:cf00::/48"}, + {"2001:db8:cf00:2102::1", 1, ""}, // other instance + {"2001:db8:cf00:2301::1", 1, ""}, // OpenWrt WAN, not mobile + {"fd00:cf:21:1::1", 1, ""}, // private mode (ULA) + {"fd00:cf:0:2101::1", 1, ""}, // ULA with the routed layout + {"fe80::2101:0:0:1", 1, ""}, + } { + got, err := routedPrefixFromHostAddr(netip.MustParseAddr(c.addr), c.num) + if c.want == "" { + if err == nil { + t.Errorf("routedPrefixFromHostAddr(%s, %d) = %s, want error", c.addr, c.num, got) + } + continue + } + if err != nil || got != netip.MustParsePrefix(c.want) { + t.Errorf("routedPrefixFromHostAddr(%s, %d) = %s, %v, want %s", c.addr, c.num, got, err, c.want) + } + } +} + +func TestRoutedEchoHelperInstancePrefix(t *testing.T) { + p := netip.MustParsePrefix("2001:db8:cf00::/48") + for _, c := range []struct { + net byte + num int + want string + }{ + {routedMobileNet, 1, "2001:db8:cf00:2101::/64"}, + {routedWifiLanNet, 10, "2001:db8:cf00:250a::/64"}, + {routedWifiLanNet, 128, "2001:db8:cf00:2580::/64"}, + } { + if got := routedInstancePrefix(p, c.net, c.num); got != netip.MustParsePrefix(c.want) { + t.Errorf("routedInstancePrefix(%s, %x, %d) = %s, want %s", p, c.net, c.num, got, c.want) + } + } +} + +func TestRoutedEchoHelperEchoAddr(t *testing.T) { + for _, c := range []struct { + body, want string // want "" for an error + }{ + {"2001:db8:cf00:2101::2\n", "2001:db8:cf00:2101::2"}, + {"2001:db8:cf00:2501:a:b:c:d", "2001:db8:cf00:2501:a:b:c:d"}, + {`{"ip":"2001:db8:cf00:2101::2"}`, "2001:db8:cf00:2101::2"}, + {"Your IP: 2001:db8::1, port 443", "2001:db8::1"}, + {"192.0.2.7", "192.0.2.7"}, + {"fe80::1%eth0", "fe80::1"}, + {"no address here", ""}, + {"", ""}, + } { + got, err := echoAddr(c.body) + if c.want == "" { + if err == nil { + t.Errorf("echoAddr(%q) = %s, want error", c.body, got) + } + continue + } + if err != nil || got != netip.MustParseAddr(c.want) { + t.Errorf("echoAddr(%q) = %s, %v, want %s", c.body, got, err, c.want) + } + } +} diff --git a/e2etests/cvd/networking_tests/routed_echo_test.go b/e2etests/cvd/networking_tests/routed_echo_test.go new file mode 100644 index 00000000000..1e97dc9c3c2 --- /dev/null +++ b/e2etests/cvd/networking_tests/routed_echo_test.go @@ -0,0 +1,225 @@ +// 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 ( + "flag" + "fmt" + "net/netip" + "os/exec" + "slices" + "strings" + "testing" + "time" + + e2etests "github.com/google/android-cuttlefish/e2etests/cvd/common" +) + +// IPv6 routed mode echo test. See TestIPv6RoutedEcho. +var ( + routedEchoURL = flag.String("routed_echo_url", "", + "HTTP(S) URL of a server that answers with the caller's IP address in "+ + "the response body (for example \"2001:db8:cf00:2101::2\"). Setting it "+ + "enables TestIPv6RoutedEcho. Prefer an IPv6 literal host so the "+ + "request cannot fall back to IPv4.") + routedEchoWifiIface = flag.String("routed_echo_wifi_iface", "wlan0", + "Guest Wi-Fi interface used by TestIPv6RoutedEcho.") + routedEchoMobileIface = flag.String("routed_echo_mobile_iface", "buried_eth0", + "Guest mobile data interface used by TestIPv6RoutedEcho.") +) + +// Routed mode networks: the fourth hextet of an instance prefix is +// (ipv6_routed_prefix in +// /etc/default/cuttlefish-host-resources). +const ( + routedMobileNet = 0x21 + routedWifiLanNet = 0x25 +) + +// routedPrefixFromHostAddr returns the routed /48 of a host address on +// cvd-mtap- in routed mode (P:21::1), or an error when addr does not +// follow the routed mode layout. +func routedPrefixFromHostAddr(addr netip.Addr, num int) (netip.Prefix, error) { + if !addr.Is6() || addr.Is4In6() || addr.IsPrivate() || !addr.IsGlobalUnicast() { + return netip.Prefix{}, fmt.Errorf("%s is not a global IPv6 address", addr) + } + b := addr.As16() + if b[6] != routedMobileNet || int(b[7]) != num { + return netip.Prefix{}, fmt.Errorf("%s is not in P:%02x%02x::/64", addr, routedMobileNet, num) + } + return netip.PrefixFrom(addr, 48).Masked(), nil +} + +// routedInstancePrefix returns P:::/64 of the routed /48 p. +func routedInstancePrefix(p netip.Prefix, net byte, num int) netip.Prefix { + b := p.Addr().As16() + b[6], b[7] = net, byte(num) + return netip.PrefixFrom(netip.AddrFrom16(b), 64) +} + +// echoAddr returns the first IP address found in an echo server response. +// It accepts a bare address, or an address among other text or JSON. +func echoAddr(body string) (netip.Addr, error) { + fields := strings.FieldsFunc(body, func(r rune) bool { + return !(r == ':' || r == '.' || r == '%' || + (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) + }) + for _, f := range fields { + if a, err := netip.ParseAddr(f); err == nil { + return a.WithZone(""), nil + } + } + return netip.Addr{}, fmt.Errorf("no IP address in echo response %q", body) +} + +// hostGlobalIPv6 returns the global IPv6 addresses of a host interface. +func hostGlobalIPv6(ifname string) ([]netip.Addr, error) { + out, err := exec.Command("ip", "-6", "-o", "addr", "show", "dev", ifname, "scope", "global").CombinedOutput() + if err != nil { + return nil, fmt.Errorf("ip addr show dev %s: %v: %s", ifname, err, strings.TrimSpace(string(out))) + } + return globalIPv6Addrs(string(out)), nil +} + +// guestGlobalIPv6InPrefix waits until iface has at least one global address +// in prefix, and returns all its global addresses in prefix (the SLAAC +// address and any temporary privacy addresses). +func guestGlobalIPv6InPrefix(c *e2etests.TestContext, iface string, prefix netip.Prefix) ([]netip.Addr, error) { + var addrs []netip.Addr + _, err := waitForGuestOutput(c, "ip -6 -o addr show dev "+iface+" scope global", func(out string) string { + addrs = nil + for _, a := range globalIPv6Addrs(out) { + if prefix.Contains(a) { + addrs = append(addrs, a) + } + } + if len(addrs) == 0 { + return "" + } + return "ok" + }) + return addrs, err +} + +// fetchEcho fetches url from the guest over iface only and returns the +// address the echo server saw. curl --interface binds the socket to the +// interface (SO_BINDTODEVICE, which needs root), so Android routes it through +// that network's table, as a per-network socket of an app would be. +func fetchEcho(c *e2etests.TestContext, iface, url string) (netip.Addr, error) { + var lastErr error + for i := 0; i < 5; i++ { + out, err := c.RunCmd("adb", "shell", "curl", "-6", "-sS", "--max-time", "15", + "--interface", iface, "'"+url+"'") + if err == nil { + return echoAddr(out.Stdout) + } + lastErr = fmt.Errorf("curl over %s: %v (stdout %q, stderr %q)", iface, err, out.Stdout, out.Stderr) + time.Sleep(pollInterval) + } + return netip.Addr{}, lastErr +} + +// TestIPv6RoutedEcho checks the property that CTS +// ConnectivityManagerTest#testOpenConnection needs on each network: when the +// guest fetches an echo URL over Wi-Fi and over mobile data separately, the +// server sees a different address per network, and each seen address is one +// of the guest's own addresses on that interface (no NAT66 on the path). +// +// Requirements, none of which the test sets up: +// - The host runs cuttlefish-host-resources in IPv6 routed mode +// (ipv6_routed_prefix=P::/48) and P::/48 is routed to the host. +// - The server at --routed_echo_url is reachable from P::/48 over IPv6. +// - A debuggable image (adb root, for curl --interface) that connects Wi-Fi +// at boot, whose RIL configures IPv6 on the mobile interface and whose +// OpenWrt reads lan_ip6prefix from the kernel command line. +// - The instance uses the non-bridged Wi-Fi tap (cvd-wifiap-). +// +// The test is skipped unless --routed_echo_url is set, for example: +// +// bazel test //cvd/networking_tests:networking_tests \ +// --test_arg=--routed_echo_url=http://[2001:db8:ec00::80]/ \ +// --test_arg=-test.run=TestIPv6RoutedEcho +func TestIPv6RoutedEcho(t *testing.T) { + if *routedEchoURL == "" { + t.Skip("skipping: set --routed_echo_url to run the routed mode echo test") + } + skipUnderPodcvd(t) + c := e2etests.TestContext{} + c.SetUp(t) + defer c.TearDown() + + d := launchDevice(t, &c) + if d.useBridgedWifiTap { + t.Skip("skipping: routed mode Wi-Fi needs the non-bridged Wi-Fi tap (cvd-wifiap)") + } + + mtap := fmt.Sprintf("cvd-mtap-%02d", d.num) + hostAddrs, err := hostGlobalIPv6(mtap) + if err != nil { + t.Fatal(err) + } + var routed netip.Prefix + for _, a := range hostAddrs { + if p, err := routedPrefixFromHostAddr(a, d.num); err == nil { + routed = p + break + } + } + if !routed.IsValid() { + t.Fatalf("host %s has no routed mode address (P:%02x%02x::1/64), got %v; is ipv6_routed_prefix set?", + mtap, routedMobileNet, d.num, hostAddrs) + } + t.Logf("routed prefix %s", routed) + + if out, err := guestShell(&c, "getprop ro.debuggable"); err != nil || strings.TrimSpace(out) != "1" { + t.Skipf("skipping: curl --interface needs adb root on a debuggable image (ro.debuggable=%q, %v)", strings.TrimSpace(out), err) + } + if _, err := c.RunCmd("adb", "root"); err != nil { + t.Fatal(err) + } + if err := c.RunAdbWaitForDevice(); err != nil { + t.Fatal(err) + } + + networks := []struct { + name, iface string + prefix netip.Prefix + }{ + {"mobile", *routedEchoMobileIface, routedInstancePrefix(routed, routedMobileNet, d.num)}, + {"wifi", *routedEchoWifiIface, routedInstancePrefix(routed, routedWifiLanNet, d.num)}, + } + seen := map[string]netip.Addr{} + for _, n := range networks { + own, err := guestGlobalIPv6InPrefix(&c, n.iface, n.prefix) + if err != nil { + t.Errorf("%s (%s): no address in %s: %v", n.name, n.iface, n.prefix, err) + continue + } + got, err := fetchEcho(&c, n.iface, *routedEchoURL) + if err != nil { + t.Errorf("%s (%s): %v", n.name, n.iface, err) + continue + } + t.Logf("%s (%s): server saw %s, guest addresses %v", n.name, n.iface, got, own) + if !slices.Contains(own, got) { + t.Errorf("%s (%s): server saw %s, want one of the guest's own addresses %v (NAT on the path?)", + n.name, n.iface, got, own) + } + seen[n.name] = got + } + if m, w := seen["mobile"], seen["wifi"]; m.IsValid() && w.IsValid() && m == w { + t.Errorf("server saw the same address %s over mobile and Wi-Fi", m) + } +} diff --git a/e2etests/ipv6_upstream_sim/Dockerfile b/e2etests/ipv6_upstream_sim/Dockerfile new file mode 100644 index 00000000000..6b593031f30 --- /dev/null +++ b/e2etests/ipv6_upstream_sim/Dockerfile @@ -0,0 +1,28 @@ +# 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. +# +# IPv6 upstream simulator for Cuttlefish routed-mode testing. +# See README.md. Build: docker build -t cf-ipv6-upstream-sim . + +FROM alpine:3.23 + +RUN apk add --no-cache dnsmasq python3 iproute2 openssl util-linux-misc \ + curl bind-tools + +COPY entrypoint.sh gen_certs.sh echo_server.py /sim/ +RUN chmod 755 /sim/entrypoint.sh /sim/gen_certs.sh /sim/echo_server.py + +VOLUME /certs +EXPOSE 53/udp 53/tcp 80/tcp 443/tcp +ENTRYPOINT ["/sim/entrypoint.sh"] diff --git a/e2etests/ipv6_upstream_sim/README.md b/e2etests/ipv6_upstream_sim/README.md new file mode 100644 index 00000000000..bbda3fa951f --- /dev/null +++ b/e2etests/ipv6_upstream_sim/README.md @@ -0,0 +1,201 @@ +# IPv6 upstream simulator for Cuttlefish routed mode + +A small Docker image that plays the part of an ISP or lab router for a +Cuttlefish host running in IPv6 **routed mode** +(`ipv6_routed_prefix=P::/48` in `/etc/default/cuttlefish-host-resources`). +It lets you test routed mode, and the CTS test +`ConnectivityManagerTest#testOpenConnection`, on a machine that has no routed +IPv6 prefix. + +The container provides: + +| Role | Detail | +|---|---| +| IPv6 router | Routes `P::/48` to the Cuttlefish host over a transit link. No NAT. | +| Echo server | HTTP (80) and HTTPS (443). Any `GET` returns the client source address as plain text with no trailing newline, the same format as `https://google-ipv6test.appspot.com/ip.js?fmt=text`. | +| DNS server | `dnsmasq`. Answers AAAA for configured names with the echo server address. Other names are refused (no upstream resolver). | +| Lab CA | `gen_certs.sh` creates a CA and a server certificate for the configured names. The CA is also written in Android trust-store format (`.0`). | + +Files: [`Dockerfile`](Dockerfile), [`entrypoint.sh`](entrypoint.sh), +[`echo_server.py`](echo_server.py), [`gen_certs.sh`](gen_certs.sh), +[`sim_up.sh`](sim_up.sh), [`sim_down.sh`](sim_down.sh), +[`selftest.sh`](selftest.sh). + +## Topology + +Defaults (all addresses are from the documentation range `2001:db8::/32`, +[RFC 3849](https://www.rfc-editor.org/rfc/rfc3849)): + +``` + simulator container (--network none) Cuttlefish host netns (TARGET) + +----------------------------------+ +-------------------------------------+ + | inet0 (dummy) | | | + | 2001:db8:eeee::80 echo | veth | cf-upstream0 2001:db8:ffff::2/64 | + | 2001:db8:eeee::53 DNS | pair | route ::/0 via 2001:db8:ffff::1 | + | transit0 2001:db8:ffff::1/64 <-+----------+-> | + | route P::/48 via ffff::2 | | cvd-mtap-NN P:21NN::1/64 -> guest P:21NN::2 + +----------------------------------+ | cvd-wbr P:22::1/64 -> Wi-Fi guests + | cvd-wifiap-NN P:23NN::1/64 -> OpenWrt WAN P:23NN::2 + | cvd-ebr P:24::1/64 | + | route P:25NN::/64 via P:23NN::2 (OpenWrt LAN) + +-------------------------------------+ + P = 2001:db8:cf00 by default (--prefix) +``` + +Traffic from a guest address such as `P:2101::2` leaves the Cuttlefish host +unchanged (routed mode has no NAT66), reaches the echo server, and the echo +server returns `P:2101::2`. That is what the IPv6 on-link check in +`testOpenConnection` requires. + +## Usage + +```bash +# 1. Start and attach to a Cuttlefish host network namespace. +./sim_up.sh +# TARGET = PID of a process in the namespace, a netns file +# (/run/netns/NAME, /proc/PID/ns/net), or "host" (+ --allow-host). +# Options: --prefix P::/48 --transit T::/64 --route default|inet +# --names "a b c" --state DIR --no-build (see --help) + +# 2. Stop. Deleting the container removes the veth pair and the route. +./sim_down.sh [--purge-certs] + +# Self-test (no Cuttlefish; uses a throwaway rootless netns). +./selftest.sh +``` + +`sim_up.sh`: + +1. Builds the image (`cf-ipv6-upstream-sim`). +2. Generates the lab CA and server certificate into `--state` + (default `~/.cache/cf-ipv6-upstream-sim`), as the calling user. +3. Starts the container with `--network none --cap-add NET_ADMIN` and + `--sysctl net.ipv6.conf.all.forwarding=1`. +4. Runs a short-lived privileged helper container + (`--privileged --pid=host --network none`) that creates the veth pair + between the container namespace and TARGET, assigns `T::1` / `T::2`, adds + `P::/48 via T::2` in the container and `::/0 via T::1` (or only the + simulator prefix with `--route inet`) in TARGET, and pings `T::1`. +5. Prints the status. + +The helper is needed because a veth pair that spans two namespaces must be +created by a process with `CAP_NET_ADMIN` over both. It changes nothing +outside the two namespaces, except with `TARGET=host`. + +### Connecting to a Cuttlefish host + +- **Cuttlefish host in a network namespace (recommended).** Run the host + init script and `cvd` inside a namespace (for example `unshare --user --net + --mount ...`), then `./sim_up.sh `. The real host + network is untouched. +- **Cuttlefish on the real host.** `./sim_up.sh --allow-host host`. This adds + `cf-upstream0` and an IPv6 default route to the host. Use `--route inet` to + add only a route to `2001:db8:eeee::/64` and keep the existing default + route. With `--route inet`, guest traffic reaches only the simulator. +- **Other wiring.** Any link works if the two routes exist: `P::/48` via the + Cuttlefish host in the simulator, and the simulator addresses via the + simulator in the Cuttlefish host. For example, a Docker network with + `--ipv6` plus `ip -6 route add P::/48 via ` inside the + container, or macvlan on a lab interface. + +The Cuttlefish host must forward IPv6 (`net.ipv6.conf.all.forwarding=1`); +the Cuttlefish host init script already sets this. + +### DNS for guests + +The simulator does not change guest DNS. To make guests use it, point the +resolver the guests use at `2001:db8:eeee::53`, for example with a `server=` +line in the host's dnsmasq for the Cuttlefish bridges, or with the guest +network settings. + +## Intercepted CTS runs (lab only) + +Official CTS accepts only two echo URLs +([ConnectivityManagerTest.java](https://cs.android.com/android/platform/superproject/main/+/main:packages/modules/Connectivity/tests/cts/net/src/android/net/cts/ConnectivityManagerTest.java), +`ALLOWED_IP_ADDRESS_ECHO_URLS`): +`https://google-ipv6test.appspot.com/ip.js?fmt=text` and +`https://ipv6test.googleapis-cn.com/ip.js?fmt=text`. Both need the public +internet and a routed global prefix. + +To run `testOpenConnection` against this simulator (verified on an Android 17 +userdebug image, `aosp_cf_x86_64_only_phone`): + +1. **Split DNS.** The CTS preparer still needs real names + (`connectivitycheck.gstatic.com`, `www.google.com`), so forward only the + echo names to the simulator. For example, a host `dnsmasq` that the guest + networks use as resolver: + `--server=/google-ipv6test.appspot.com/2001:db8:eeee::53 + --server=/ipv6test.googleapis-cn.com/2001:db8:eeee::53 --server=8.8.8.8`. + Do not bind it to UDP port 5353 on the host: `adb` uses that port for mDNS. +2. **OpenWrt rebind protection.** OpenWrt's `dnsmasq` treats answers in + `2001:db8::/32` as a DNS rebind attack and drops them (`possible DNS-rebind + attack detected` in the OpenWrt console log), so Wi-Fi clients never see + the AAAA record. With the default documentation prefix, turn it off in + OpenWrt for the run: + `uci set dhcp.@dnsmasq[0].rebind_protection=0; uci commit dhcp; + /etc/init.d/dnsmasq restart`. A real global prefix (`--prefix`) is not + affected. +3. **Lab CA.** On Android 14 and later the system CA store is + `/apex/com.android.conscrypt/cacerts`, which is read-only. Copy the store + to a writable directory, add `.0`, and bind-mount the copy over the + APEX and system paths, in the mount namespace of every process that + validates certificates (init, zygote, system_server, the network stack). + The following works on a userdebug build after `adb root`: + ```bash + adb push ~/.cache/cf-ipv6-upstream-sim/.0 /data/local/tmp/ + adb shell 'mkdir -p /data/local/tmp/cacerts && + cp -a /apex/com.android.conscrypt/cacerts/* /data/local/tmp/cacerts/ && + cp /data/local/tmp/.0 /data/local/tmp/cacerts/ && + chmod 644 /data/local/tmp/cacerts/.0 && + chcon u:object_r:system_security_cacerts_file:s0 /data/local/tmp/cacerts /data/local/tmp/cacerts/* && + for pid in 1 $(pidof zygote zygote64 system_server com.android.networkstack); do + nsenter --mount=/proc/$pid/ns/mnt -- mount --bind /data/local/tmp/cacerts /apex/com.android.conscrypt/cacerts + nsenter --mount=/proc/$pid/ns/mnt -- mount --bind /data/local/tmp/cacerts /system/etc/security/cacerts + done' + ``` + The mounts do not survive a reboot. +4. **Certificate transparency (Android 17 and later).** Conscrypt enforces + certificate transparency by default (compat change `407952621`, + `DEFAULT_ENABLE_CERTIFICATE_TRANSPARENCY`). The lab certificate has no + signed certificate timestamps, so the handshake fails with a misleading + `Trust anchor for certification path not found` even though the CA is + trusted. Disable the check for the test package only: + `adb shell am compat disable --no-kill 407952621 android.net.cts`. The + setting survives the APK reinstall done by the CTS runner. + +**Label every result from such a run "lab, intercepted". It is not an +official CTS result** and cannot be used as CTS evidence for a device. It +only shows that the network setup satisfies the test logic. + +### Certificate-transparency risk + +Android can enforce certificate transparency (CT) for publicly trusted +certificates ([Android CT +docs](https://developer.android.com/privacy-and-security/security-config#CertificateTransparency)). +The lab CA is a private root and its certificates are not in any CT log. On +Android 17 the check applies to the lab CA as well (see step 4 above); on +older releases it did not get in the way. If it fails, the failure is a +limitation of the intercepted setup, not of routed mode. + +Never use the lab CA key outside the lab. Anyone with `ca.key` can +impersonate any site to a device that trusts the CA. `sim_down.sh +--purge-certs` deletes it. + +## Self-test + +`selftest.sh` checks the simulator without Cuttlefish. It creates a rootless +network namespace (`unshare --user --map-root-user --net`) as the "Cuttlefish +host" (IPv6 forwarding on, `P:22::2/64` on a dummy interface) and a second +namespace as a guest behind veth `cvd-mtap-01` (host `P:2101::1/64`, guest +`P:2101::2/64`). It attaches the simulator and checks: + +- AAAA answers for `google-ipv6test.appspot.com` and `echo.sim.test`, and an + empty `NOERROR` answer for A. +- HTTP and HTTPS (with the lab CA) return the exact source address, for the + forwarded guest (`P:2101::2`) and for the host-local address (`P:22::2`). + No NAT, and different per network. +- HTTPS without the lab CA fails. + +Container images do not have a working resolver (`--network none`), so the +echo server skips the `getfqdn()` lookup that Python's `HTTPServer` does at +bind time. diff --git a/e2etests/ipv6_upstream_sim/echo_server.py b/e2etests/ipv6_upstream_sim/echo_server.py new file mode 100755 index 00000000000..dcb30ef1108 --- /dev/null +++ b/e2etests/ipv6_upstream_sim/echo_server.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +# +# 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. +"""IP echo server: replies to any GET with the client source address. + +The body is the bare address with no trailing newline, which matches the +`/ip.js?fmt=text` format that CTS `ConnectivityManagerTest#testOpenConnection` +parses with `InetAddresses.parseNumericAddress`. Listens on HTTP and HTTPS +(dual-stack `::`). IPv4-mapped addresses (`::ffff:a.b.c.d`) are reported as +plain IPv4. +""" + +import argparse +import http.server +import socket +import socketserver +import ssl +import sys +import threading + + +class Handler(http.server.BaseHTTPRequestHandler): + server_version = "ipv6-upstream-sim-echo/1" + + def _client_ip(self): + ip = self.client_address[0] + if ip.startswith("::ffff:") and "." in ip: + ip = ip[len("::ffff:"):] + return ip.split("%", 1)[0] + + def _reply(self, send_body): + body = self._client_ip().encode() + self.send_response(200) + self.send_header("Content-Type", "text/plain") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-cache") + self.send_header("Connection", "close") + self.end_headers() + if send_body: + self.wfile.write(body) + + def do_GET(self): + self._reply(True) + + def do_HEAD(self): + self._reply(False) + + def log_message(self, fmt, *args): + sys.stderr.write("echo %s %s\n" % (self._client_ip(), fmt % args)) + sys.stderr.flush() + + +class Server(socketserver.ThreadingMixIn, http.server.HTTPServer): + address_family = socket.AF_INET6 + daemon_threads = True + allow_reuse_address = True + + def server_bind(self): + # Accept IPv4 too (IPv4-mapped), in case the sim is ever dual-stack. + self.socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0) + # HTTPServer.server_bind() calls socket.getfqdn(), which blocks when no + # resolver is reachable (the container has --network none). Skip it. + socketserver.TCPServer.server_bind(self) + self.server_name = "ipv6-upstream-sim" + self.server_port = self.server_address[1] + + +class TlsServer(Server): + """HTTPS server; the TLS handshake runs in the per-connection thread.""" + + ssl_context = None + + def finish_request(self, request, client_address): + try: + request.settimeout(10) + request = self.ssl_context.wrap_socket(request, server_side=True) + except (ssl.SSLError, OSError) as e: + sys.stderr.write("echo %s TLS handshake failed: %s\n" % + (client_address[0], e)) + return + super().finish_request(request, client_address) + + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--bind", default="::") + p.add_argument("--http-port", type=int, default=80) + p.add_argument("--https-port", type=int, default=443) + p.add_argument("--cert", help="server certificate chain (PEM)") + p.add_argument("--key", help="server private key (PEM)") + a = p.parse_args() + + servers = [Server((a.bind, a.http_port), Handler)] + if a.cert and a.key: + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ctx.load_cert_chain(a.cert, a.key) + TlsServer.ssl_context = ctx + servers.append(TlsServer((a.bind, a.https_port), Handler)) + + for s in servers[1:]: + threading.Thread(target=s.serve_forever, daemon=True).start() + print("echo: listening http=%d https=%s" % + (a.http_port, a.https_port if len(servers) > 1 else "off"), + file=sys.stderr, flush=True) + servers[0].serve_forever() + + +if __name__ == "__main__": + main() diff --git a/e2etests/ipv6_upstream_sim/entrypoint.sh b/e2etests/ipv6_upstream_sim/entrypoint.sh new file mode 100755 index 00000000000..139fc22152b --- /dev/null +++ b/e2etests/ipv6_upstream_sim/entrypoint.sh @@ -0,0 +1,98 @@ +#!/bin/sh +# +# 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. +# +# Container entrypoint for the IPv6 upstream simulator. +# +# Brings up the "internet side" (dummy interface inet0 holding the echo and +# DNS service addresses), generates certificates if missing, writes the +# dnsmasq configuration and runs dnsmasq plus the echo server. The transit +# link (transit0) and the route to the routed /48 are added from outside by +# sim_up.sh, because the peer end lives in another network namespace. + +set -eu + +: "${SIM_ECHO_ADDR:=2001:db8:eeee::80}" +: "${SIM_DNS_ADDR:=2001:db8:eeee::53}" +: "${SIM_INET_PREFIX:=2001:db8:eeee::/64}" +: "${SIM_NAMES:=google-ipv6test.appspot.com ipv6test.googleapis-cn.com echo.sim.test}" +# Extra records, space separated NAME=IPV6 pairs. +: "${SIM_DNS_RECORDS:=}" +: "${SIM_CERT_DIR:=/certs}" + +log() { echo "entrypoint: $*" >&2; } + +if [ "$(cat /proc/sys/net/ipv6/conf/all/forwarding)" != 1 ]; then + log "WARNING: net.ipv6.conf.all.forwarding=0; start with --sysctl net.ipv6.conf.all.forwarding=1" +fi + +ip link set lo up +if ! ip link show inet0 >/dev/null 2>&1; then + ip link add inet0 type dummy +fi +ip link set inet0 up +ip -6 addr replace "$SIM_ECHO_ADDR/128" dev inet0 nodad +ip -6 addr replace "$SIM_DNS_ADDR/128" dev inet0 nodad +ip -6 route replace "$SIM_INET_PREFIX" dev inet0 + +if [ ! -s "$SIM_CERT_DIR/server.crt" ]; then + /sim/gen_certs.sh "$SIM_CERT_DIR" "$SIM_NAMES" "$SIM_ECHO_ADDR" +fi + +conf=/run/dnsmasq-sim.conf +{ + echo "no-resolv" + echo "no-hosts" + echo "bind-dynamic" + echo "listen-address=$SIM_DNS_ADDR" + echo "log-queries" + echo "log-facility=-" + # local=/NAME/ makes dnsmasq authoritative for NAME, so other record types + # (A) get an empty NOERROR answer instead of REFUSED. + for n in $SIM_NAMES; do + echo "local=/$n/" + echo "address=/$n/$SIM_ECHO_ADDR" + done + for r in $SIM_DNS_RECORDS; do + echo "local=/${r%%=*}/" + echo "address=/${r%%=*}/${r#*=}" + done +} > "$conf" +log "dnsmasq config:"; sed 's/^/ /' "$conf" >&2 + +dnsmasq --keep-in-foreground --conf-file="$conf" & +dns_pid=$! +python3 /sim/echo_server.py --cert "$SIM_CERT_DIR/server_chain.crt" \ + --key "$SIM_CERT_DIR/server.key" & +echo_pid=$! + +trap 'kill $dns_pid $echo_pid 2>/dev/null; exit 0' TERM INT +for _ in $(seq 50); do + l=$(netstat -ltn 2>/dev/null) + echo "$l" | grep -q ':53 ' && echo "$l" | grep -q ':80 ' && echo "$l" | grep -q ':443 ' && break + sleep 0.2 +done +log "ready echo=$SIM_ECHO_ADDR dns=$SIM_DNS_ADDR names=[$SIM_NAMES]" +# Exit (and let Docker report it) if either service dies. +while kill -0 $dns_pid 2>/dev/null && kill -0 $echo_pid 2>/dev/null; do + sleep 2 +done +log "a service exited; stopping" +for p in $dns_pid $echo_pid; do + if kill -0 "$p" 2>/dev/null; then + kill "$p" 2>/dev/null + fi +done +exit 1 diff --git a/e2etests/ipv6_upstream_sim/gen_certs.sh b/e2etests/ipv6_upstream_sim/gen_certs.sh new file mode 100755 index 00000000000..dbd59740242 --- /dev/null +++ b/e2etests/ipv6_upstream_sim/gen_certs.sh @@ -0,0 +1,72 @@ +#!/bin/sh +# +# 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. +# +# Generates a lab CA and a server certificate for the echo server. +# +# Usage: gen_certs.sh OUT_DIR "name1 name2 ..." "ip1 ip2 ..." +# +# Output (OUT_DIR): +# ca.key ca.crt lab CA (keep ca.key private; never reuse outside the lab) +# server.key server.crt echo server key and certificate (SAN = names + IPs) +# server_chain.crt server.crt + ca.crt +# .0 ca.crt named for Android's system trust store +# (/system/etc/security/cacerts/.0), where +# = `openssl x509 -subject_hash_old`. +# Existing files are kept, so re-running is idempotent. + +set -eu + +OUT=${1:?usage: gen_certs.sh OUT_DIR NAMES [IPS]} +NAMES=${2:-google-ipv6test.appspot.com} +IPS=${3:-} +DAYS=${SIM_CERT_DAYS:-397} + +mkdir -p "$OUT" +cd "$OUT" + +if [ ! -s ca.crt ] || [ ! -s ca.key ]; then + openssl req -x509 -newkey rsa:2048 -nodes -sha256 -days 3650 \ + -keyout ca.key -out ca.crt \ + -subj "/O=Cuttlefish IPv6 upstream simulator (LAB ONLY)/CN=cf-ipv6-sim lab CA" \ + -addext "basicConstraints=critical,CA:TRUE" \ + -addext "keyUsage=critical,keyCertSign,cRLSign" 2>/dev/null + chmod 600 ca.key +fi + +if [ ! -s server.crt ] || [ ! -s server.key ]; then + san="" + for n in $NAMES; do san="${san:+$san,}DNS:$n"; done + for i in $IPS; do san="${san:+$san,}IP:$i"; done + first=$(echo "$NAMES" | awk '{print $1}') + cat > server.ext </dev/null + openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial \ + -days "$DAYS" -sha256 -extfile server.ext -out server.crt 2>/dev/null + rm -f server.csr server.ext + chmod 600 server.key +fi + +cat server.crt ca.crt > server_chain.crt +h=$(openssl x509 -in ca.crt -noout -subject_hash_old) +# Android trust-store format: PEM followed by the text dump. +{ cat ca.crt; openssl x509 -in ca.crt -noout -text -fingerprint; } > "$h.0" +echo "gen_certs: CA=$OUT/ca.crt android=$OUT/$h.0 SAN=$(openssl x509 -in server.crt -noout -ext subjectAltName | tail -1 | sed 's/^ *//')" diff --git a/e2etests/ipv6_upstream_sim/selftest.sh b/e2etests/ipv6_upstream_sim/selftest.sh new file mode 100755 index 00000000000..f2703f63a6b --- /dev/null +++ b/e2etests/ipv6_upstream_sim/selftest.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# +# 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. +# +# Self-test without Cuttlefish. A throwaway rootless network namespace plays +# the Cuttlefish host (IPv6 forwarding on, P:22::2/64 on a dummy interface). +# A second namespace plays a guest behind veth cvd-mtap-01 +# (host P:2101::1/64, guest P:2101::2/64, default route via the host), so its +# traffic is forwarded through the fake host. The simulator is attached with +# sim_up.sh, then the test checks that +# - DNS answers AAAA for the configured names (and an empty A answer), +# - HTTP and HTTPS (with the lab CA) echo the exact source address (no NAT), +# for the forwarded guest and for a host-local address, +# - HTTPS without the lab CA is rejected. +# Requires: docker access, unshare/nsenter (util-linux), curl, dig. + +set -uo pipefail + +DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +P=2001:db8:cf00 +ECHO=2001:db8:eeee::80 +DNS=2001:db8:eeee::53 +NAME=cf-ipv6-upstream-sim-selftest +STATE=$(mktemp -d) +FAIL=0 + +pass() { echo "PASS: $*"; } +fail() { echo "FAIL: $*"; FAIL=1; } + +unshare --user --map-root-user --net sleep infinity & +H=$! +sleep 0.5 +NS=(nsenter -t "$H" -U -n --preserve-credentials) +# Guest netns, owned by the same user namespace, behind the fake host. +nsenter -t "$H" -U --preserve-credentials unshare --net sleep infinity & +G=$! +sleep 0.5 +GNS=(nsenter -t "$G" -U -n --preserve-credentials) +cleanup() { + "$DIR/sim_down.sh" --name "$NAME" >/dev/null 2>&1 + kill "$G" "$H" 2>/dev/null + rm -rf "$STATE" +} +trap cleanup EXIT + +echo "== fake Cuttlefish host netns: holder pid $H ($(readlink /proc/$H/ns/net))" +echo "== fake guest netns: holder pid $G ($(readlink /proc/$G/ns/net))" +"${NS[@]}" sh -euc " + ip link set lo up + echo 1 > /proc/sys/net/ipv6/conf/all/forwarding + ip link add cvd-fake0 type dummy + ip link set cvd-fake0 up + ip -6 addr add $P:22::2/64 dev cvd-fake0 nodad + ip link add cvd-mtap-01 type veth peer name eth0 netns $G + ip -6 addr add $P:2101::1/64 dev cvd-mtap-01 nodad + ip link set cvd-mtap-01 up + ip -br -6 addr show dev cvd-fake0 + ip -br -6 addr show dev cvd-mtap-01 +" || { echo "cannot configure rootless host netns"; exit 1; } +"${GNS[@]}" sh -euc " + ip link set lo up + ip -6 addr add $P:2101::2/64 dev eth0 nodad + ip link set eth0 up + ip -6 route add default via $P:2101::1 dev eth0 + ip -br -6 addr show dev eth0 +" || { echo "cannot configure rootless guest netns"; exit 1; } + +echo "== sim_up" +"$DIR/sim_up.sh" --name "$NAME" --state "$STATE" --prefix "$P::/48" "$H" \ + || { echo "sim_up failed"; exit 1; } + +echo "== DNS (queried from the forwarded guest netns)" +for n in google-ipv6test.appspot.com echo.sim.test; do + a=$("${GNS[@]}" dig +short +time=2 +tries=1 "@$DNS" AAAA "$n") + [ "$a" = "$ECHO" ] && pass "AAAA $n = $a" || fail "AAAA $n = '$a' (want $ECHO)" +done +a4=$("${GNS[@]}" dig +time=2 +tries=1 "@$DNS" A google-ipv6test.appspot.com) +a4s=$(echo "$a4" | sed -n 's/.*status: \([A-Z]*\).*/\1/p') +a4n=$(echo "$a4" | sed -n 's/.*ANSWER: \([0-9]*\).*/\1/p') +[ "$a4s" = NOERROR ] && [ "$a4n" = 0 ] \ + && pass "A google-ipv6test.appspot.com: NOERROR, 0 answers (IPv6-only)" \ + || fail "A google-ipv6test.appspot.com: status=$a4s answers=$a4n" +st=$("${NS[@]}" dig +time=2 +tries=1 "@$DNS" AAAA www.example.com | sed -n 's/.*status: \([A-Z]*\).*/\1/p') +echo "INFO: unconfigured name www.example.com -> status $st" + +echo "== HTTP/HTTPS echo" +URL_PATH='/ip.js?fmt=text' +# Case 1: forwarded guest (P:2101::2 behind cvd-mtap-01), default source +# address selection, name resolved through the simulator DNS. +src=$P:2101::2 +got=$("${GNS[@]}" curl -gs --max-time 5 "http://[$ECHO]$URL_PATH") +[ "$got" = "$src" ] && pass "HTTP guest(forwarded) echoed $got" || fail "HTTP guest echoed '$got' (want $src)" +got=$("${GNS[@]}" curl -gs --max-time 5 --cacert "$STATE/ca.crt" \ + --resolve "google-ipv6test.appspot.com:443:[$("${GNS[@]}" dig +short "@$DNS" AAAA google-ipv6test.appspot.com)]" \ + "https://google-ipv6test.appspot.com$URL_PATH") +[ "$got" = "$src" ] && pass "HTTPS guest(forwarded) echoed $got (lab CA verified)" \ + || fail "HTTPS guest echoed '$got' (want $src)" +# Case 2: address local to the fake host (P:22::2), explicit source. +src=$P:22::2 +got=$("${NS[@]}" curl -gs --max-time 5 --interface "$src" "http://[$ECHO]$URL_PATH") +[ "$got" = "$src" ] && pass "HTTP host-local src $src echoed $got" || fail "HTTP src $src echoed '$got'" +got=$("${NS[@]}" curl -gs --max-time 5 --interface "$src" --cacert "$STATE/ca.crt" \ + --resolve "google-ipv6test.appspot.com:443:[$ECHO]" "https://google-ipv6test.appspot.com$URL_PATH") +[ "$got" = "$src" ] && pass "HTTPS host-local src $src echoed $got (lab CA verified)" \ + || fail "HTTPS src $src echoed '$got'" +body=$("${GNS[@]}" curl -gs --max-time 5 "http://[$ECHO]$URL_PATH" | od -c | head -3) +echo "INFO: raw body bytes: $(echo "$body" | tr -s ' ' | tr '\n' ' ')" +if "${GNS[@]}" curl -gs --max-time 5 --resolve "google-ipv6test.appspot.com:443:[$ECHO]" \ + "https://google-ipv6test.appspot.com$URL_PATH" >/dev/null 2>&1; then + fail "HTTPS without lab CA succeeded" +else + pass "HTTPS without lab CA rejected" +fi + +echo "== simulator log (tail)" +docker logs "$NAME" 2>&1 | tail -12 + +[ "$FAIL" = 0 ] && echo "SELFTEST: ALL PASS" || echo "SELFTEST: FAILURES" +exit "$FAIL" diff --git a/e2etests/ipv6_upstream_sim/sim_down.sh b/e2etests/ipv6_upstream_sim/sim_down.sh new file mode 100755 index 00000000000..5e0bf7fb6f2 --- /dev/null +++ b/e2etests/ipv6_upstream_sim/sim_down.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# +# 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. +# +# Stops the IPv6 upstream simulator. Removing the container destroys its +# network namespace, which deletes both ends of the veth pair and the route +# via the transit link in the target namespace. +# +# Usage: sim_down.sh [--name NAME] [--purge-certs [--state DIR]] + +set -euo pipefail + +NAME=cf-ipv6-upstream-sim +STATE=${XDG_CACHE_HOME:-$HOME/.cache}/cf-ipv6-upstream-sim +PURGE=0 +while [ $# -gt 0 ]; do + case "$1" in + --name) NAME=$2; shift 2 ;; + --state) STATE=$2; shift 2 ;; + --purge-certs) PURGE=1; shift ;; + *) echo "usage: sim_down.sh [--name NAME] [--purge-certs [--state DIR]]" >&2; exit 1 ;; + esac +done + +if docker inspect "$NAME" >/dev/null 2>&1; then + docker rm -f "$NAME" >/dev/null + echo "sim_down: removed container $NAME" +else + echo "sim_down: container $NAME not running" +fi +if [ "$PURGE" = 1 ] && [ -d "$STATE" ]; then + rm -rf "$STATE" + echo "sim_down: removed $STATE" +fi diff --git a/e2etests/ipv6_upstream_sim/sim_up.sh b/e2etests/ipv6_upstream_sim/sim_up.sh new file mode 100755 index 00000000000..1b86bd2ad4d --- /dev/null +++ b/e2etests/ipv6_upstream_sim/sim_up.sh @@ -0,0 +1,179 @@ +#!/usr/bin/env bash +# +# 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. +# +# Starts the IPv6 upstream simulator container and wires it to the network +# namespace of a Cuttlefish host with a veth pair ("transit link"). +# +# Usage: sim_up.sh [options] TARGET +# TARGET PID of any process in the Cuttlefish host network namespace, +# a netns file (e.g. /run/netns/cf or /proc/PID/ns/net), or +# "host" (the initial namespace; requires --allow-host because it +# changes the real host's IPv6 routes). +# Options: +# --prefix P::/48 routed prefix of the Cuttlefish host +# (default 2001:db8:cf00::/48) +# --transit T::/64 transit link (default 2001:db8:ffff::/64; +# sim = T::1, Cuttlefish host = T::2) +# --route default|inet route installed in TARGET: "default" = ::/0 via T::1 +# (default), "inet" = only the sim service prefix +# --names "N1 N2 ..." DNS names answered with the echo address +# --host-if NAME TARGET-side veth name (default cf-upstream0) +# --name NAME container name (default cf-ipv6-upstream-sim) +# --image NAME image tag (default cf-ipv6-upstream-sim) +# --state DIR certificate dir (default ~/.cache/cf-ipv6-upstream-sim) +# --no-build do not (re)build the image +# --allow-host permit TARGET=host +# +# Privileges: needs access to the Docker daemon. The veth pair spans two +# network namespaces, so it is created by a short-lived privileged helper +# container (--privileged --pid=host --network=none). The helper only touches +# the simulator namespace and TARGET; nothing else on the host is changed +# unless TARGET=host. + +set -euo pipefail + +DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +PREFIX=2001:db8:cf00::/48 +TRANSIT=2001:db8:ffff::/64 +ROUTE=default +NAMES="google-ipv6test.appspot.com ipv6test.googleapis-cn.com echo.sim.test" +HOST_IF=cf-upstream0 +NAME=cf-ipv6-upstream-sim +IMAGE=cf-ipv6-upstream-sim +STATE=${XDG_CACHE_HOME:-$HOME/.cache}/cf-ipv6-upstream-sim +BUILD=1 +ALLOW_HOST=0 +ECHO_ADDR=2001:db8:eeee::80 +DNS_ADDR=2001:db8:eeee::53 +INET_PREFIX=2001:db8:eeee::/64 + +die() { echo "sim_up: $*" >&2; exit 1; } + +while [ $# -gt 0 ]; do + case "$1" in + --prefix) PREFIX=$2; shift 2 ;; + --transit) TRANSIT=$2; shift 2 ;; + --route) ROUTE=$2; shift 2 ;; + --names) NAMES=$2; shift 2 ;; + --host-if) HOST_IF=$2; shift 2 ;; + --name) NAME=$2; shift 2 ;; + --image) IMAGE=$2; shift 2 ;; + --state) STATE=$2; shift 2 ;; + --no-build) BUILD=0; shift ;; + --allow-host) ALLOW_HOST=1; shift ;; + -h|--help) sed -n '17,45p' "$0"; exit 0 ;; + -*) die "unknown option $1" ;; + *) break ;; + esac +done +[ $# -eq 1 ] || die "usage: sim_up.sh [options] TARGET (see --help)" +TARGET=$1 + +case "$PREFIX" in */48) ;; *) die "--prefix must be a /48 (got $PREFIX)" ;; esac +case "$TRANSIT" in *::/64) ;; *) die "--transit must be written as X:Y:Z::/64" ;; esac +case "$ROUTE" in default) TGT_ROUTE=default ;; inet) TGT_ROUTE=$INET_PREFIX ;; + *) die "--route must be default or inet" ;; esac +T=${TRANSIT%::/64} +SIM_T="$T::1"; HOST_T="$T::2" + +# Resolve TARGET into a helper mount argument and an in-helper netns file. +HELPER_MOUNT=() +case "$TARGET" in + host) + [ "$ALLOW_HOST" = 1 ] || die "TARGET=host changes host IPv6 routes; add --allow-host" + TGT_NS=/proc/1/ns/net ;; + ''|*[!0-9]*) + [ -e "$TARGET" ] || die "netns file $TARGET not found" + HELPER_MOUNT=(-v "$TARGET:/target_netns:ro") + TGT_NS=/target_netns ;; + *) + [ -d "/proc/$TARGET" ] || die "no process $TARGET" + TGT_NS=/proc/$TARGET/ns/net ;; +esac + +if [ "$BUILD" = 1 ] || ! docker image inspect "$IMAGE" >/dev/null 2>&1; then + echo "sim_up: building image $IMAGE" + docker build -q -t "$IMAGE" "$DIR" >/dev/null +fi + +# Certificates are generated as the calling user so they stay readable. +mkdir -p "$STATE" +docker run --rm --network none --user "$(id -u):$(id -g)" -v "$STATE:/certs" \ + --entrypoint /sim/gen_certs.sh "$IMAGE" /certs "$NAMES" "$ECHO_ADDR" + +if docker inspect "$NAME" >/dev/null 2>&1; then + docker rm -f "$NAME" >/dev/null +fi +docker run -d --name "$NAME" --network none --cap-add NET_ADMIN \ + --sysctl net.ipv6.conf.all.disable_ipv6=0 \ + --sysctl net.ipv6.conf.default.disable_ipv6=0 \ + --sysctl net.ipv6.conf.all.forwarding=1 \ + -v "$STATE:/certs:ro" \ + -e SIM_NAMES="$NAMES" -e SIM_ECHO_ADDR="$ECHO_ADDR" -e SIM_DNS_ADDR="$DNS_ADDR" \ + -e SIM_INET_PREFIX="$INET_PREFIX" \ + "$IMAGE" >/dev/null + +for _ in $(seq 50); do + docker logs "$NAME" 2>&1 | grep -q "entrypoint: ready" && break + [ "$(docker inspect -f '{{.State.Running}}' "$NAME")" = true ] || break + sleep 0.2 +done +docker logs "$NAME" 2>&1 | grep -q "entrypoint: ready" || { + docker logs "$NAME" >&2; die "container did not become ready"; } +CPID=$(docker inspect -f '{{.State.Pid}}' "$NAME") + +docker run --rm --privileged --pid=host --network none "${HELPER_MOUNT[@]}" \ + -e CPID="$CPID" -e TGT_NS="$TGT_NS" -e HOST_IF="$HOST_IF" \ + -e SIM_T="$SIM_T" -e HOST_T="$HOST_T" -e PREFIX="$PREFIX" -e TGT_ROUTE="$TGT_ROUTE" \ + -e ECHO_ADDR="$ECHO_ADDR" \ + --entrypoint /bin/sh "$IMAGE" -euc ' + mkdir -p /run/netns + touch /run/netns/sim /run/netns/tgt + mount --bind /proc/$CPID/ns/net /run/netns/sim + mount --bind "$TGT_NS" /run/netns/tgt + if ip -n sim link show transit0 >/dev/null 2>&1; then + ip -n sim link del transit0 + fi + if ip -n tgt link show "$HOST_IF" >/dev/null 2>&1; then + ip -n tgt link del "$HOST_IF" + fi + ip -n sim link add transit0 type veth peer name "$HOST_IF" netns tgt + ip -n sim -6 addr add "$SIM_T/64" dev transit0 nodad + ip -n tgt -6 addr add "$HOST_T/64" dev "$HOST_IF" nodad + ip -n sim link set transit0 up + ip -n tgt link set "$HOST_IF" up + ip -n sim -6 route replace "$PREFIX" via "$HOST_T" dev transit0 + ip -n tgt -6 route replace $TGT_ROUTE via "$SIM_T" dev "$HOST_IF" + echo "--- simulator netns" + ip -n sim -br -6 addr + ip -n sim -6 route + echo "--- target netns ($TGT_NS)" + ip -n tgt -br -6 addr show dev "$HOST_IF" + ip -n tgt -6 route show dev "$HOST_IF" + echo "target forwarding=$(ip netns exec tgt cat /proc/sys/net/ipv6/conf/all/forwarding)" + ip netns exec tgt ping -6 -c1 -W2 "$SIM_T" >/dev/null && echo "ping $SIM_T from target: ok" \ + || echo "ping $SIM_T from target: FAILED" + ' + +cat </ip.js?fmt=text + DNS server $DNS_ADDR names: $NAMES + lab CA $STATE/ca.crt (Android: $(ls "$STATE"/*.0 2>/dev/null | head -1)) +EOF