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
+7 -1
View File
@@ -177,7 +177,10 @@ class LifecycleService:
raise ConflictError(f"resize is pending for {vm.id}; retry resize or stop it before starting")
if vm.status != "running" or vm.operation not in {None, "policy"}:
raise ConflictError(f"{vm.id} has an interrupted boot/stop; stop it before starting")
return self._sync_ports_locked(vm)
self._validate_retained_network(vm)
vm = self._sync_ports_locked(vm)
self._network.ensure_bridge()
return vm
self._validate_resume(vm)
if kernel is None:
self._check_boot()
@@ -276,6 +279,9 @@ class LifecycleService:
disk = Path(vm.disk)
if not vm.disk or not disk.is_file() or disk.is_symlink() or disk.stat().st_nlink != 1:
raise UvmError(f"private VM disk is missing or unsafe: {disk}")
self._validate_retained_network(vm)
def _validate_retained_network(self, vm: VmRecord) -> None:
if (vm.gateway != str(self._settings.gateway)
or vm.network not in {None, str(self._settings.network)}
or vm.bridge not in {None, self._settings.bridge}):
+29 -102
View File
@@ -9,7 +9,7 @@ from ipaddress import IPv4Address
from .config import Settings
from .domain import VmRecord
from .errors import ConflictError, UvmError, ValidationError
from .errors import CommandError, ConflictError, UvmError, ValidationError
from .firewall import FirewallManager
from .system import CommandRunner
@@ -178,111 +178,38 @@ class NetworkManager:
raise UvmError(f"could not delete TAP device {name}{suffix}")
def _ensure_masquerade(self) -> None:
route = self._runner.run(("ip", "route", "show", "default"), capture=True)
match = re.search(r"\bdev\s+(\S+)", route.stdout)
if match is None:
return
uplink = match.group(1)
rule = (
"iptables",
"-t",
"nat",
"-C",
"POSTROUTING",
"-s",
str(self._settings.network),
"-o",
uplink,
"-j",
"MASQUERADE",
)
present = self._runner.run(rule, check=False)
if present.returncode != 0:
self._runner.run(
(
"iptables",
"-t",
"nat",
"-A",
"POSTROUTING",
"-s",
str(self._settings.network),
"-o",
uplink,
"-j",
"MASQUERADE",
)
)
# Match the guest network, not a snapshot of the host's default uplink.
# Routing selects the current interface for each destination, even after
# an offline boot, a reconnect, or a switch between Wi-Fi/Ethernet/VPN.
bridge = self._settings.bridge
network = str(self._settings.network)
owner = ("-m", "comment", "--comment", "uvm-egress")
self._ensure_iptables_rule(
(
"iptables",
"-C",
"FORWARD",
"-i",
self._settings.bridge,
"-o",
uplink,
"-s",
str(self._settings.network),
"-j",
"ACCEPT",
),
(
"iptables",
"-A",
"FORWARD",
"-i",
self._settings.bridge,
"-o",
uplink,
"-s",
str(self._settings.network),
"-j",
"ACCEPT",
),
"nat", "POSTROUTING",
("-s", network, "!", "-o", bridge, *owner, "-j", "MASQUERADE"),
)
# Native bridge policy still checks guest identity and private ports.
# This also permits peer traffic when br_netfilter is already enabled.
self._ensure_iptables_rule(
"filter", "FORWARD",
("-i", bridge, "-s", network, *owner, "-j", "ACCEPT"),
)
self._ensure_iptables_rule(
(
"iptables",
"-C",
"FORWARD",
"-i",
uplink,
"-o",
self._settings.bridge,
"-d",
str(self._settings.network),
"-m",
"conntrack",
"--ctstate",
"ESTABLISHED,RELATED",
"-j",
"ACCEPT",
),
(
"iptables",
"-A",
"FORWARD",
"-i",
uplink,
"-o",
self._settings.bridge,
"-d",
str(self._settings.network),
"-m",
"conntrack",
"--ctstate",
"ESTABLISHED,RELATED",
"-j",
"ACCEPT",
),
"filter", "FORWARD",
("-o", bridge, "-d", network, "-m", "conntrack",
"--ctstate", "ESTABLISHED,RELATED", *owner, "-j", "ACCEPT"),
)
def _ensure_iptables_rule(
self,
check_rule: tuple[str, ...],
add_rule: tuple[str, ...],
self, table: str, chain: str, rule: tuple[str, ...],
) -> None:
present = self._runner.run(check_rule, check=False)
if present.returncode != 0:
self._runner.run(add_rule)
command = ("iptables", "--wait", "5", "-t", table)
present = self._runner.run((*command, "-C", chain, *rule), check=False, capture=True)
if present.returncode == 0:
return
if present.returncode != 1:
detail = present.stderr.strip() or present.stdout.strip()
raise CommandError(f"could not inspect UVM {table}/{chain} rule ({present.returncode}): {detail}")
# Scoped exceptions must precede terminal drops in the host's chains.
# Keep unrelated rules and the global forwarding policy intact.
self._runner.run((*command, "-I", chain, "1", *rule), capture=True)