feat: enhance network management and lifecycle handling with improved egress setup and validation
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user