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