From 6bb5fe2362058fae38f6bb43d2d29733ad2cdc3a Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 16 Oct 2026 14:02:00 -0300 Subject: [PATCH] openvmm: answer guest UDP to host loopback from the gateway Fixes #560. Part of #393. Pin nanvix/openvmm#137 at 5ab8a84621524f1777b2cb5e65a70dab42c8b453, two commits on c6d97c52. OpenVMM answered the guest's UDP to host-loopback services, which the guest reaches at its gateway, from the addresses at which it presents other host peers: 10.0.0.254 instead of 10.0.0.1, and fd00::a00:ff instead of fd00::a00:1, for 10.0.0.2/24. Unconnected guest sockets accepted those replies, but connected ones, which accept datagrams only from the peer to which they are connected, dropped them, so connected UDP to host loopback timed out over IPv4 and IPv6 on every backend. Replies on the guest's UDP flows now come from the address and port that the guest addressed, while host peers that the guest did not address keep their virtual addresses. OpenVMM also tore down the partition and guest memory without stopping the VM's devices, so a netvsp queue worker that ran during teardown could read its vmbus ring from guest memory that was already unmapped, and OpenVMM aborted. ttrpc::test_ttrpc_interface failed this way on Linux/MSHV, including in this change's first CI run. The second commit stops the devices first. Extend the allow phase of the host-loopback-policy microVM scenario: under its egress default of deny, with rules that admit only these datagrams, the guest's connected nc exchanges a datagram with host UDP services on IPv4 and IPv6 loopback through its gateway. The phase fails with the previous pin. Describe the source of these replies in the run guide and the network design, and index both OpenVMM commits in the roadmap. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- doc/design/3.5-network.md | 11 +- doc/openvmm-upstream-roadmap.md | 8 +- doc/run.md | 10 +- openvmm | 2 +- .../host-loopback-policy.sh.in | 9 + scripts/nvx_tools/microvm_tests.py | 107 ++++++++- scripts/test_microvm_tests.py | 222 ++++++++++++++++++ 7 files changed, 355 insertions(+), 14 deletions(-) diff --git a/doc/design/3.5-network.md b/doc/design/3.5-network.md index 28815f3dd..efa4793c6 100644 --- a/doc/design/3.5-network.md +++ b/doc/design/3.5-network.md @@ -140,7 +140,9 @@ admits the guest's replies on the flows that the endpoint carries, as By default, and subject to the egress policy, the gateway maps guest TCP and UDP flows onto host loopback: the IPv4 gateway maps to `127.0.0.1`, and the -IPv6 gateway maps to `::1`. The guest's own loopback remains guest loopback. +IPv6 gateway maps to `::1`. Replies on these flows come from the gateway +address and port that the guest addressed, as connected UDP sockets require. +The guest's own loopback remains guest loopback. Other host-local destinations, such as link-local addresses, also reach host sockets unless host loopback is denied. @@ -202,9 +204,10 @@ endpoint carries: a TCP segment other than an initial SYN, or a UDP datagram from a published port to a peer that recently sent one to it, which leaves through the port's host listener. -The endpoint presents host peers at synthetic addresses inside the guest's -subnet of each family, allocated downward from its last usable address, such -as `10.0.0.254` and `fd00::a00:ff` for the identity in Table 3.12. Explicit +The endpoint presents other host peers, such as the host clients of published +listeners, at synthetic addresses inside the guest's subnet of each family, +allocated downward from its last usable address, such as `10.0.0.254` and +`fd00::a00:ff` for the identity in Table 3.12. Explicit host-loopback access therefore requires an IPv4 network of /29 or larger: a /30 holds only the gateway and the guest and leaves no address for host peers. The guest routes its replies to a synthetic address through its NIC rather diff --git a/doc/openvmm-upstream-roadmap.md b/doc/openvmm-upstream-roadmap.md index 8e97801d6..47de63bd9 100644 --- a/doc/openvmm-upstream-roadmap.md +++ b/doc/openvmm-upstream-roadmap.md @@ -5,7 +5,7 @@ - OpenVMM fork base: `e1cdbd916d3aacb216309c8e7779b832546759a1`. - Mapped fork tip: `2728f33ea9a44d2d0fb956d9f01768ae2100fb62`, position 166. The graph, PR summary, and commit mapping below cover positions 001-166. -- NVX's pinned OpenVMM revision: `c6d97c529986c0b3b1bf1fd7f00406654cdeee67`, 152 commits past the +- NVX's pinned OpenVMM revision: `5ab8a84621524f1777b2cb5e65a70dab42c8b453`, 154 commits past the mapped tip on the same first-parent chain. These commits are not yet assigned to PRs; see [Commits after the mapped tip](#commits-after-the-mapped-tip). - Upstream main: `1fd455b19e7c69c72a2d375d0ed1cb35d901c688` (2026-10-01), 18 commits past @@ -199,7 +199,7 @@ e1cdbd916 -> PR18/PR23 split [162] -> PR18 fixup [163] -> PR25 fixup [164] -> PR27 [165-166] -> 2728f33ea (mapped tip) - -> unassigned [167-318] -> c6d97c529 (NVX pin) + -> unassigned [167-320] -> 5ab8a8462 (NVX pin) ``` For upstream review, PR01-PR08 and PR14 can be rebased independently onto current upstream main. @@ -215,7 +215,7 @@ can be submitted independently if preferred. ## Commits after the mapped tip -The NVX pin extends the chain past position 166 without merges. The 152 later commits are not +The NVX pin extends the chain past position 166 without merges. The 154 later commits are not yet classified or assigned to PRs. This table groups them by topic in chain order; ranges are inclusive, as in the [PR summary](#pr-summary). @@ -243,6 +243,8 @@ inclusive, as in the [PR summary](#pr-summary). | 316 | `a44fcc2aa` | 1 | Bidirectional host-loopback access: `--host-loopback allow` publishes the guest's announced listeners on host loopback for the host alone | | 317 | `7f225e2cd` | 1 | IPv6 host peers appear inside the static IPv6 subnet, so the guest answers them from its address there | | 318 | `c6d97c529` | 1 | Standalone time ABI documentation: comments describe the contract without citing NVX's removed specification | +| 319 | `eae7da6f3` | 1 | Replies of the host-loopback services that the guest reaches at its gateway come from the gateway, so connected guest UDP sockets receive them | +| 320 | `5ab8a8462` | 1 | The VM worker stops its devices before it tears down the partition and guest memory, so no device worker reads guest memory that teardown has unmapped | Some of these commits change mapped work rather than add to it. Positions 255-259 remove the legacy microVM clock paths and change files that PR10 and PR17 also change. Position 261 makes diff --git a/doc/run.md b/doc/run.md index 5690cad05..a984dd70c 100644 --- a/doc/run.md +++ b/doc/run.md @@ -450,10 +450,12 @@ to host loopback, without a port list: The guest reaches host loopback services through its gateway, subject to the egress policy: a host service on `127.0.0.1:8080` at `10.0.0.1:8080`, and one -on `[::1]:8080` at `[fd00::a00:1]:8080`. The guest sees host peers at -addresses that OpenVMM assigns inside its own network, and the egress policy -applies to its traffic to them as it applies at the gateway, so a rule that -denies a gateway port denies it at those addresses too. In the other +on `[::1]:8080` at `[fd00::a00:1]:8080`. Their replies come from the gateway +address and port that the guest addressed, so connected UDP sockets receive +them too. The guest sees other host peers, such as clients of its published +ports, at addresses that OpenVMM assigns inside its own network, and the egress +policy applies to its traffic to them as it applies at the gateway, so a rule +that denies a gateway port denies it at those addresses too. In the other direction, OpenVMM appends `virtnet_ingress=host-loopback` to the kernel command line unless ingress is `allow`, and the guest's `nvx-ingress` reports its listeners as it does for diff --git a/openvmm b/openvmm index c6d97c529..5ab8a8462 160000 --- a/openvmm +++ b/openvmm @@ -1 +1 @@ -Subproject commit c6d97c529986c0b3b1bf1fd7f00406654cdeee67 +Subproject commit 5ab8a84621524f1777b2cb5e65a70dab42c8b453 diff --git a/scripts/nvx_tools/microvm_test_scripts/host-loopback-policy.sh.in b/scripts/nvx_tools/microvm_test_scripts/host-loopback-policy.sh.in index b309cdbae..4cae3becf 100644 --- a/scripts/nvx_tools/microvm_test_scripts/host-loopback-policy.sh.in +++ b/scripts/nvx_tools/microvm_test_scripts/host-loopback-policy.sh.in @@ -68,6 +68,15 @@ allow) general=$(wget -T 5 -qO- http://@GATEWAY_IPV4@:@GENERAL_PORT@/general) || fail 93 [ "$general" = NVX-HOST-LOOPBACK-GENERAL ] || fail 94 + # A connected UDP socket, as nc's, receives the answers of host services + # on IPv4 and IPv6 loopback only from the gateway address and port that it + # sent its datagram to (#560). + udp_echo=$(printf 'NVX-HOST-LOOPBACK-UDP-ECHO\n' | + nc -u -w 2 @GATEWAY_IPV4@ @UDP_ECHO_PORT@ 2>/dev/null) || true + [ "$udp_echo" = NVX-HOST-LOOPBACK-UDP-ECHO-REPLY ] || fail 84 + udp_echo=$(printf 'NVX-HOST-LOOPBACK-UDP-ECHO\n' | + nc -u -w 2 @GATEWAY_IPV6@ @UDP_ECHO_IPV6_PORT@ 2>/dev/null) || true + [ "$udp_echo" = NVX-HOST-LOOPBACK-UDP-ECHO-REPLY ] || fail 85 rm -f /tmp/nvx-host-loopback-tcp-ipv4 /tmp/nvx-host-loopback-tcp-ipv6 \ /tmp/nvx-host-loopback-tcp-forward /tmp/nvx-host-loopback-udp \ /tmp/nvx-host-loopback-udp-ipv6 \ diff --git a/scripts/nvx_tools/microvm_tests.py b/scripts/nvx_tools/microvm_tests.py index 86a21e057..0138450b8 100644 --- a/scripts/nvx_tools/microvm_tests.py +++ b/scripts/nvx_tools/microvm_tests.py @@ -210,6 +210,8 @@ HOST_LOOPBACK_TCP_REQUEST = b"NVX-HOST-LOOPBACK-INBOUND\n" HOST_LOOPBACK_UDP_REQUEST = b"NVX-HOST-LOOPBACK-UDP\n" HOST_LOOPBACK_UDP_REPLY = b"NVX-HOST-LOOPBACK-UDP-REPLY\n" +HOST_LOOPBACK_UDP_ECHO_REQUEST = b"NVX-HOST-LOOPBACK-UDP-ECHO\n" +HOST_LOOPBACK_UDP_ECHO_REPLY = b"NVX-HOST-LOOPBACK-UDP-ECHO-REPLY\n" HOST_LOOPBACK_PORT_BIND_ATTEMPTS = 16 # IANA's dynamic port range, which is also Windows' default. The host-loopback # scenario draws the port of each TCP and UDP listener pair from it. @@ -3171,6 +3173,57 @@ def _http_server( errors.append(error) +def _udp_echo_server( + server: socket.socket, stop: threading.Event, errors: list[Exception] +) -> None: + """Answer each echo request that ``server`` receives until ``stop`` is set.""" + try: + server.settimeout(0.05) + while not stop.is_set(): + try: + request, peer = server.recvfrom(4096) + except (TimeoutError, ConnectionResetError): + # Windows reports a datagram that a closed port refused as a + # reset on the next receive. + continue + if request != HOST_LOOPBACK_UDP_ECHO_REQUEST: + raise RuntimeError( + f"unexpected host-loopback UDP echo request: {request!r}" + ) + server.sendto(HOST_LOOPBACK_UDP_ECHO_REPLY, peer) + except Exception as error: + errors.append(error) + + +def _bind_host_loopback_udp_echo( + family: int, address: str, published: Sequence[int] +) -> socket.socket: + """Bind a UDP socket to an ephemeral port of ``address`` outside ``published``. + + The probes that drew the guest ports that OpenVMM publishes released them, + so an ephemeral bind can take one, keep OpenVMM from publishing it, and + receive the host's datagrams for the guest. Each rejected socket holds its + port until the search ends, so no later attempt draws that port again. + """ + attempts: list[socket.socket] = [] + try: + for _ in range(HOST_LOOPBACK_PORT_BIND_ATTEMPTS): + udp_echo = socket.socket(family, socket.SOCK_DGRAM) + attempts.append(udp_echo) + if family == socket.AF_INET6: + udp_echo.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1) + udp_echo.bind((address, 0)) + if int(udp_echo.getsockname()[1]) not in published: + return attempts.pop() + finally: + for rejected in attempts: + rejected.close() + raise RuntimeError( + f"failed to bind a UDP port on {address} outside guest ports " + f"{sorted(published)} after {HOST_LOOPBACK_PORT_BIND_ATTEMPTS} attempts" + ) + + def _host_loopback_port_candidates() -> list[int]: """Draw distinct ports uniformly from ``HOST_LOOPBACK_PORT_RANGE``. @@ -3278,11 +3331,14 @@ def run_host_loopback_policy( "host-loopback-policy.sh.in", MODE="udp-control", GATEWAY_IPV4=DIRECTIONAL_NETWORK_GATEWAY_IPV4, + GATEWAY_IPV6=DIRECTIONAL_NETWORK_GATEWAY_IPV6, GENERAL_PORT=str(denied_general_port), PROXY_PORT=str(proxy_port), GUEST_PORT="0", PRIVATE_GUEST_PORT="0", UDP_IPV6_GUEST_PORT="0", + UDP_ECHO_PORT="0", + UDP_ECHO_IPV6_PORT="0", ), HOST_LOOPBACK_UDP_CONTROL_MARKER, timeout=timeout, @@ -3313,11 +3369,14 @@ def run_host_loopback_policy( "host-loopback-policy.sh.in", MODE="deny", GATEWAY_IPV4=DIRECTIONAL_NETWORK_GATEWAY_IPV4, + GATEWAY_IPV6=DIRECTIONAL_NETWORK_GATEWAY_IPV6, GENERAL_PORT=str(denied_general_port), PROXY_PORT=str(proxy_port), GUEST_PORT="0", PRIVATE_GUEST_PORT="0", UDP_IPV6_GUEST_PORT="0", + UDP_ECHO_PORT="0", + UDP_ECHO_IPV6_PORT="0", ), HOST_LOOPBACK_DENY_MARKER, timeout=timeout, @@ -3382,7 +3441,34 @@ def run_host_loopback_policy( daemon=True, ) allow_server.start() + udp_echoes: list[socket.socket] = [] + udp_echo_servers: list[threading.Thread] = [] + udp_echo_errors: list[Exception] = [] + udp_echo_stop = threading.Event() try: + # Host UDP services on IPv4 and IPv6 loopback, which the guest reaches + # at its gateway. The guest's UDP servers are dual-stack, so OpenVMM + # publishes both of their ports on both loopback addresses, and + # neither service may hold either port. + for family, address in ( + (socket.AF_INET, "127.0.0.1"), + (socket.AF_INET6, "::1"), + ): + udp_echo = _bind_host_loopback_udp_echo( + family, address, (guest_port, udp_ipv6_guest_port) + ) + udp_echoes.append(udp_echo) + udp_echo_server = threading.Thread( + target=_udp_echo_server, + args=(udp_echo, udp_echo_stop, udp_echo_errors), + name="nvx-host-loopback-udp-echo", + daemon=True, + ) + udp_echo_server.start() + udp_echo_servers.append(udp_echo_server) + udp_echo_port, udp_echo_ipv6_port = ( + int(udp_echo.getsockname()[1]) for udp_echo in udp_echoes + ) allow_command = workload_boot_command( executable, backend, @@ -3393,8 +3479,9 @@ def run_host_loopback_policy( network=DIRECTIONAL_NETWORK_CIDR, ) # Egress admits only the guest's own connection to the general host - # service, so the guest's replies to the host pass because host - # loopback admits them, not because egress does. + # service and its datagrams to the UDP services, so the guest's + # replies to the host pass because host loopback admits them, not + # because egress does. allow_command.extend( ( "--host-loopback", @@ -3405,6 +3492,10 @@ def run_host_loopback_policy( "deny", "--network-egress-allow", f"{DIRECTIONAL_NETWORK_GATEWAY_IPV4}/32:tcp:{allowed_general_port}", + "--network-egress-allow", + f"{DIRECTIONAL_NETWORK_GATEWAY_IPV4}/32:udp:{udp_echo_port}", + "--network-egress-allow", + f"{DIRECTIONAL_NETWORK_GATEWAY_IPV6}/128:udp:{udp_echo_ipv6_port}", "--host-loopback-forward", f"tcp:{host_forward_port}:{guest_port}", ) @@ -3422,11 +3513,14 @@ def run_host_loopback_policy( "host-loopback-policy.sh.in", MODE="allow", GATEWAY_IPV4=DIRECTIONAL_NETWORK_GATEWAY_IPV4, + GATEWAY_IPV6=DIRECTIONAL_NETWORK_GATEWAY_IPV6, GENERAL_PORT=str(allowed_general_port), PROXY_PORT="0", GUEST_PORT=str(guest_port), PRIVATE_GUEST_PORT=str(private_guest_port), UDP_IPV6_GUEST_PORT=str(udp_ipv6_guest_port), + UDP_ECHO_PORT=str(udp_echo_port), + UDP_ECHO_IPV6_PORT=str(udp_echo_ipv6_port), ), ) process.wait_for_line(HOST_LOOPBACK_INGRESS_READY_MARKER, timeout) @@ -3451,9 +3545,18 @@ def run_host_loopback_policy( raise RuntimeError("general host-loopback server failed") from allow_errors[ 0 ] + if udp_echo_errors: + raise RuntimeError( + "host-loopback UDP echo service failed" + ) from udp_echo_errors[0] finally: allowed_general.close() allow_server.join(timeout=NETWORK_SERVER_JOIN_TIMEOUT_SECONDS) + udp_echo_stop.set() + for udp_echo_server in udp_echo_servers: + udp_echo_server.join(timeout=NETWORK_SERVER_JOIN_TIMEOUT_SECONDS) + for udp_echo in udp_echoes: + udp_echo.close() run_host_loopback_rejections( executable, diff --git a/scripts/test_microvm_tests.py b/scripts/test_microvm_tests.py index ae121262f..fc2976302 100644 --- a/scripts/test_microvm_tests.py +++ b/scripts/test_microvm_tests.py @@ -2641,6 +2641,32 @@ def bind(address: tuple[str, int]) -> None: return endpoint +class _EphemeralPortSockets: + """Create sockets to which the host assigns ``ports`` in turn on bind.""" + + def __init__(self, ports: Collection[int]) -> None: + self._socket_type = socket.socket + self._ports = iter(ports) + self.created: list[MagicMock] = [] + self.events: list[tuple[str, int]] = [] + + def __call__(self, _family: int, _kind: int) -> MagicMock: + endpoint = MagicMock(spec=self._socket_type) + port = next(self._ports) + + def bind(_address: tuple[str, int]) -> None: + self.events.append(("bind", port)) + + def close() -> None: + self.events.append(("close", port)) + + endpoint.bind.side_effect = bind + endpoint.close.side_effect = close + endpoint.getsockname.return_value = ("127.0.0.1", port) + self.created.append(endpoint) + return endpoint + + class MicrovmTests(unittest.TestCase): def test_managed_lifecycle_spawns_openvmm_with_a_prepared_capability_pipe(self): # OpenVMM reads its capability as soon as it starts, so the scenario hands @@ -3235,11 +3261,14 @@ def test_host_loopback_udp_probes_reject_failed_sender(self): "host-loopback-policy.sh.in", MODE=mode, GATEWAY_IPV4="10.0.0.1", + GATEWAY_IPV6="fd00::a00:1", GENERAL_PORT="8444", PROXY_PORT="8443", GUEST_PORT="0", PRIVATE_GUEST_PORT="0", UDP_IPV6_GUEST_PORT="0", + UDP_ECHO_PORT="0", + UDP_ECHO_IPV6_PORT="0", ) .replace("nvx-exit", "nvx_exit") .replace("/proc/cmdline", cmdline.as_posix()) @@ -3283,11 +3312,14 @@ def test_host_loopback_deny_requires_no_publication(self): "host-loopback-policy.sh.in", MODE="deny", GATEWAY_IPV4="10.0.0.1", + GATEWAY_IPV6="fd00::a00:1", GENERAL_PORT="8444", PROXY_PORT="8443", GUEST_PORT="0", PRIVATE_GUEST_PORT="0", UDP_IPV6_GUEST_PORT="0", + UDP_ECHO_PORT="0", + UDP_ECHO_IPV6_PORT="0", ) .replace("nvx-exit", "nvx_exit") .replace("/proc/cmdline", path.as_posix()) @@ -3316,12 +3348,16 @@ def test_host_loopback_script_probes_udp_on_proxy_and_general_ports(self): "host-loopback-policy.sh.in", MODE="deny", GATEWAY_IPV4="10.0.0.1", + GATEWAY_IPV6="fd00::a00:1", GENERAL_PORT="8444", PROXY_PORT="8443", GUEST_PORT="0", PRIVATE_GUEST_PORT="0", UDP_IPV6_GUEST_PORT="0", + UDP_ECHO_PORT="0", + UDP_ECHO_IPV6_PORT="0", ) + self.assertNotRegex(script, "@[A-Z0-9_]+@") self.assertIn("nc -u -w 1 10.0.0.1 8443", script) self.assertIn("nc -u -w 1 10.0.0.1 8444", script) self.assertIn("http://10.0.0.1:8443/proxy", script) @@ -3338,6 +3374,8 @@ def _run_host_loopback_allow_script( listeners: bool = True, udp_ipv6_listener: bool = True, private_listeners: bool = True, + udp_echo: bool = True, + udp_echo_ipv6: bool = True, ) -> subprocess.CompletedProcess[str]: shell = _posix_shell() if shell is None: @@ -3371,11 +3409,14 @@ def _run_host_loopback_allow_script( "host-loopback-policy.sh.in", MODE="allow", GATEWAY_IPV4="10.0.0.1", + GATEWAY_IPV6="fd00::a00:1", GENERAL_PORT="8444", PROXY_PORT="0", GUEST_PORT="8080", PRIVATE_GUEST_PORT="8081", UDP_IPV6_GUEST_PORT="8082", + UDP_ECHO_PORT="8083", + UDP_ECHO_IPV6_PORT="8084", ) .replace("nvx-exit", "nvx_exit") .replace("/proc/", f"{root.as_posix()}/proc/") @@ -3392,6 +3433,9 @@ def _run_host_loopback_allow_script( private_server = ( "echo NVX-HOST-LOOPBACK-INBOUND" if private_request else ":" ) + echo_reply = "echo NVX-HOST-LOOPBACK-UDP-ECHO-REPLY" + udp_echo_server = echo_reply if udp_echo else ":" + udp_echo_ipv6_server = echo_reply if udp_echo_ipv6 else ":" path = root / "host-loopback-policy.sh" path.write_text( "sleep() { :; }\n" @@ -3402,6 +3446,8 @@ def _run_host_loopback_allow_script( f"'-u -l -p 8082 '*) {udp_ipv6_server} ;;\n" f"'-u -l -p '*) {udp_server} ;;\n" f"'-l -s '*) {private_server} ;;\n" + f"'-u -w 2 10.0.0.1 8083') {udp_echo_server} ;;\n" + f"'-u -w 2 fd00::a00:1 8084') {udp_echo_ipv6_server} ;;\n" "*) return 1 ;;\n" "esac; }\n" 'nvx_exit() { exit "$1"; }\n' + script, @@ -3552,6 +3598,176 @@ def test_host_loopback_udp_exchange_times_out_without_an_answer(self): with self.assertRaisesRegex(TimeoutError, "did not answer"): microvm_tests._host_loopback_udp_exchange("127.0.0.1", port, 0) + def test_udp_echo_server_answers_each_request(self): + for address, family in ( + ("127.0.0.1", socket.AF_INET), + ("::1", socket.AF_INET6), + ): + with self.subTest(address): + server = socket.socket(family, socket.SOCK_DGRAM) + try: + server.bind((address, 0)) + except OSError: + server.close() + continue + stop = threading.Event() + errors: list[Exception] = [] + thread = threading.Thread( + target=microvm_tests._udp_echo_server, + args=(server, stop, errors), + daemon=True, + ) + thread.start() + try: + with socket.socket(family, socket.SOCK_DGRAM) as client: + client.settimeout(5) + client.connect(server.getsockname()[:2]) + for _ in range(2): + client.send(microvm_tests.HOST_LOOPBACK_UDP_ECHO_REQUEST) + self.assertEqual( + client.recv(64), + microvm_tests.HOST_LOOPBACK_UDP_ECHO_REPLY, + ) + finally: + stop.set() + thread.join(10) + server.close() + self.assertFalse(thread.is_alive()) + self.assertEqual(errors, []) + + def test_udp_echo_server_rejects_an_unexpected_request(self): + with ( + socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as server, + socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client, + ): + server.bind(("127.0.0.1", 0)) + errors: list[Exception] = [] + thread = threading.Thread( + target=microvm_tests._udp_echo_server, + args=(server, threading.Event(), errors), + daemon=True, + ) + thread.start() + client.sendto(b"UNEXPECTED\n", server.getsockname()) + thread.join(10) + self.assertFalse(thread.is_alive()) + self.assertEqual(len(errors), 1) + self.assertRegex(str(errors[0]), "unexpected host-loopback UDP echo request") + + def test_host_loopback_udp_echo_avoids_the_published_guest_ports(self): + # The probes that drew the guest ports released them, so ephemeral binds + # can draw them. Each rejected socket holds its port until one is free. + sockets = _EphemeralPortSockets((8080, 8082, 50000)) + + with patch.object(microvm_tests.socket, "socket", side_effect=sockets): + udp_echo = microvm_tests._bind_host_loopback_udp_echo( + socket.AF_INET6, "::1", (8080, 8082) + ) + + self.assertIs(udp_echo, sockets.created[-1]) + self.assertEqual( + sockets.events, + [ + ("bind", 8080), + ("bind", 8082), + ("bind", 50000), + ("close", 8080), + ("close", 8082), + ], + ) + for endpoint in sockets.created: + endpoint.setsockopt.assert_called_once_with( + socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1 + ) + endpoint.bind.assert_called_once_with(("::1", 0)) + + def test_host_loopback_udp_echo_gives_up_after_the_attempt_budget(self): + attempts = microvm_tests.HOST_LOOPBACK_PORT_BIND_ATTEMPTS + published = range(50000, 50000 + attempts) + sockets = _EphemeralPortSockets(published) + + with ( + patch.object(microvm_tests.socket, "socket", side_effect=sockets), + self.assertRaises(RuntimeError) as raised, + ): + microvm_tests._bind_host_loopback_udp_echo( + socket.AF_INET, "127.0.0.1", published + ) + + self.assertEqual( + str(raised.exception), + "failed to bind a UDP port on 127.0.0.1 outside guest ports " + f"{list(published)} after {attempts} attempts", + ) + self.assertEqual(len(sockets.created), attempts) + for endpoint in sockets.created: + endpoint.setsockopt.assert_not_called() + endpoint.close.assert_called_once_with() + + def test_host_loopback_udp_echoes_avoid_both_published_guest_ports(self): + # The guest's UDP servers are dual-stack, so OpenVMM publishes both of + # their ports on both loopback addresses. + def answer_udp_control( + command: list[str], script: str, *_args: object, **_kwargs: object + ) -> None: + if "--host-loopback" in command: + return + ports = { + int(line.split()[5]) + for line in script.splitlines() + if line.strip().startswith("nc -u -w 1") + } + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sender: + for port in ports: + sender.sendto(b"NVX-HOST-LOOPBACK-UDP-CONTROL", ("127.0.0.1", port)) + + with tempfile.TemporaryDirectory() as temporary: + with ( + patch.object(microvm_tests, "_http_server"), + patch.object(microvm_tests, "_udp_echo_server"), + patch.object( + microvm_tests, "run_guest_script", side_effect=answer_udp_control + ), + patch.object( + microvm_tests, + "_host_loopback_publication_port", + side_effect=range(50001, 50101), + ), + patch.object( + microvm_tests, "_bind_host_loopback_udp_echo" + ) as bind_udp_echo, + patch.object(microvm_tests, "OpenvmmProcess") as process, + patch.object( + microvm_tests, "_verify_host_loopback_publication" + ) as verify, + patch.object(microvm_tests, "run_host_loopback_rejections"), + ): + bind_udp_echo.return_value.getsockname.return_value = ( + "127.0.0.1", + 40000, + ) + process.return_value.__enter__.return_value.wait.return_value = ( + openvmm_process.OpenvmmProcessResult(0, b"") + ) + microvm_tests.run_host_loopback_policy( + Path("openvmm"), + Path("kernel"), + Path("initrd"), + "whp", + memory_mib=128, + timeout=5, + output_dir=Path(temporary), + ) + + published = tuple(verify.call_args.args[:2]) + self.assertEqual( + [bound.args for bound in bind_udp_echo.call_args_list], + [ + (socket.AF_INET, "127.0.0.1", published), + (socket.AF_INET6, "::1", published), + ], + ) + def test_host_loopback_allow_script_checks_publication_and_traffic(self): run = self._run_host_loopback_allow_script for label, check, expected in ( @@ -3588,6 +3804,12 @@ def test_host_loopback_allow_script_checks_publication_and_traffic(self): 97, ), ("private-reached", functools.partial(run, private_request=True), 96), + ("udp-echo-unanswered", functools.partial(run, udp_echo=False), 84), + ( + "udp-echo-ipv6-unanswered", + functools.partial(run, udp_echo_ipv6=False), + 85, + ), ): with self.subTest(label): result = check()