feat: enhance network management and lifecycle handling with improved egress setup and validation

This commit is contained in:
2026-09-23 06:23:36 +05:30
parent bd92573158
commit 534a78f2a9
5 changed files with 206 additions and 152 deletions
+69 -9
View File
@@ -2,16 +2,18 @@
Run: UVM_FIREWALL_INTEGRATION=1 python3 -B -m unittest discover -s tests/integration -v
Needs Linux, nft, ip, bridge, conntrack, sysctl and permission to create network
Needs Linux, nft, iptables, ip, bridge, conntrack, sysctl and permission to create network
namespaces (root, or unprivileged user namespaces). No sudo, installation,
/run/netns mounts, host firewall changes or br_netfilter sysctls are used.
Bridge-connected veths stand in for guest TAPs. A third, routed namespace tests
guest egress without NAT. Ordinary test runs skip without executing commands.
Bridge-connected veths stand in for guest TAPs. A third namespace with two
uplinks tests NAT, offline startup, reconnects and host route changes without
using the real internet. Ordinary test runs skip without executing commands.
"""
from __future__ import annotations
import contextlib
import errno
import json
import os
import re
@@ -62,6 +64,13 @@ class SocketEndpoint:
except TimeoutError:
sock.close()
return False
except OSError as error:
sock.close()
if operation == "connect" and error.errno in {
errno.ENETUNREACH, errno.EHOSTUNREACH, errno.ENETDOWN, errno.ECONNREFUSED,
}:
return False
raise
except BaseException:
sock.close()
raise
@@ -206,6 +215,7 @@ def _sandbox(parent_fd):
from uvm.domain import PortRule, VmRecord
from uvm.errors import UvmError
from uvm.firewall import FirewallManager
from uvm.network import NetworkManager
from uvm.system import CommandRunner
runner = CommandRunner(emit=None)
@@ -220,11 +230,12 @@ def _sandbox(parent_fd):
manager = FirewallManager(settings, runner)
manager.check_available()
run("ip", "link", "set", "lo", "up")
run("ip", "link", "add", settings.bridge, "type", "bridge", "stp_state", "0")
run("ip", "addr", "add", f"{settings.gateway}/24", "dev", settings.bridge)
run("ip", "link", "set", settings.bridge, "up")
# This IPv4 sysctl belongs only to the freshly verified network namespace.
run("sysctl", "-w", "net.ipv4.ip_forward=1")
# All changes are inside the freshly verified namespace. A pre-existing
# terminal drop catches regressions that append ineffective forward rules.
run("iptables", "-P", "FORWARD", "DROP")
run("iptables", "-A", "FORWARD", "-j", "DROP")
assert not run("ip", "route", "show", "default").strip()
NetworkManager(settings, runner).ensure_bridge()
metadata = dict(cpu=1, ram_mib=128, gateway=str(settings.gateway),
socket="", config="", log="", network=str(settings.network), bridge=settings.bridge)
@@ -263,6 +274,16 @@ def _sandbox(parent_fd):
run("ip", "link", "set", tap, "type", "bridge_slave", "group_fwd_mask", "0x4000")
endpoint.request("run", command=["ip", "route", "add", "default", "via", str(settings.gateway)])
run("ip", "link", "add", "fw-up2", "type", "veth", "peer", "name", "fw-end2")
run("ip", "link", "set", "fw-end2", "netns", str(outside.process.pid))
run("ip", "addr", "add", "172.30.74.1/24", "dev", "fw-up2")
run("ip", "link", "set", "fw-up2", "up")
outside.request("run", command=["ip", "link", "set", "fw-end2", "name", "eth1"])
outside.request("run", command=["ip", "addr", "add", "172.30.74.2/24", "dev", "eth1"])
outside.request("run", command=["ip", "link", "set", "eth1", "up"])
internet_ip = "203.0.113.73"
outside.request("run", command=["ip", "addr", "add", f"{internet_ip}/32", "dev", "lo"])
manager.apply(vm)
manager.apply(peer_vm)
for tap in (vm.tap, peer_vm.tap):
@@ -328,6 +349,7 @@ def _sandbox(parent_fd):
guest.request("udp_send", name="dns", ip="172.30.73.2", port=53, data="dns-query")
query = outside.request("udp_recv", name="dns")
assert query[0] == "dns-query"
assert query[1][0] == "172.30.73.1", "guest egress must use the host uplink's address"
outside.request("udp_send", name="dns", ip=query[1][0], port=query[1][1], data="dns-reply")
assert guest.request("udp_recv", name="dns")[0] == "dns-reply"
guest.request("udp_bind", name="unsolicited", ip=vm.guest_ip, port=40054)
@@ -338,6 +360,44 @@ def _sandbox(parent_fd):
assert endpoint.request("ping", ip=target), (endpoint, target)
print("PASS: independent peer policy, outbound UDP/DNS and ICMP; unsolicited UDP blocked", flush=True)
# No UVM reconfiguration after this point: the kernel must follow the
# host routes for both TCP and UDP/53, including after offline creation.
outside.request("listen", name="internet", ip=internet_ip, port=9443)
outside.request("udp_bind", name="internet-dns", ip=internet_ip, port=53)
def offline(label):
assert not host.request("connect", name=f"host-{label}", ip=internet_ip, port=9443)
assert not guest.request("connect", name=f"guest-{label}", ip=internet_ip, port=9443)
assert guest.request("ping", ip=str(settings.gateway)), "local networking must still work offline"
def online(label, source):
for endpoint, role in ((host, "host"), (guest, "guest")):
name = f"{role}-{label}"
assert endpoint.request("connect", name=name, ip=internet_ip, port=9443)
assert outside.request("accept", listener="internet", name=name)
transfer(endpoint, outside, name, "request")
transfer(outside, endpoint, name, "response")
guest.request("udp_bind", name=label, ip=vm.guest_ip, port=0)
guest.request("udp_send", name=label, ip=internet_ip, port=53, data=label)
query = outside.request("udp_recv", name="internet-dns")
assert query == [label, [source, query[1][1]]], query
outside.request("udp_send", name="internet-dns", ip=source, port=query[1][1], data="dns-reply")
assert guest.request("udp_recv", name=label)[0] == "dns-reply"
offline("initially-offline")
run("ip", "route", "add", "default", "via", "172.30.73.2", "dev", "fw-up")
online("first-uplink", "172.30.73.1")
run("ip", "route", "replace", "default", "via", "172.30.74.2", "dev", "fw-up2")
online("changed-uplink", "172.30.74.1")
run("ip", "route", "del", "default")
run("ip", "link", "set", "fw-up", "down")
run("ip", "link", "set", "fw-up2", "down")
offline("disconnected")
run("ip", "link", "set", "fw-up", "up")
run("ip", "route", "add", "default", "via", "172.30.73.2", "dev", "fw-up")
online("reconnected", "172.30.73.1")
print("PASS: host/guest NAT egress follows offline/online, uplink changes and reconnects", flush=True)
# Spoofed source MACs must not reach FDB learning, including the local
# link-local path which can skip bridge prerouting entirely.
spoof = bytes.fromhex("02aabbccddee")
@@ -393,7 +453,7 @@ class PrivatePortNamespaceTests(unittest.TestCase):
probe = subprocess.run((*prefix, "true"), text=True, capture_output=True, timeout=5)
if probe.returncode:
self.skipTest(f"isolated namespace permission unavailable: {probe.stderr.strip()}")
missing = [name for name in ("ip", "bridge", "nft", "conntrack", "sysctl") if shutil.which(name) is None]
missing = [name for name in ("ip", "bridge", "nft", "iptables", "conntrack", "sysctl") if shutil.which(name) is None]
if missing:
self.skipTest(f"integration tools unavailable: {', '.join(missing)}")
with open("/proc/self/ns/net", "rb") as parent: