Add network editing with VLAN, firewall, IPv4/IPv6 and undo

Press n in the info screen to edit one interface: bridge, VLAN tag,
firewall, IPv4 (DHCP, static or none) and IPv6 (SLAAC, DHCP, static or
none). The netN string is parsed, the chosen fields are changed and
the string is written back, so hwaddr, model=MAC, mtu, rate, trunks
and type are kept. For VMs, IP settings go to cloud-init ipconfigN and
only when the VM has a cloud-init drive.

A confirm shows the old and new raw values before anything is sent.
Press u to restore the values from before the last change to that
guest in this session.

Review fixes:
- Undo entries are cleared on a context switch, because VMIDs repeat
  across clusters.
- has_cloudinit only looks at disks.
- CIDR needs a decimal prefix: no netmask form and no /0.
- Fields you did not touch are not rewritten.

Live test on CT 104: firewall, VLAN 99, IPv6 DHCP and static IPv4 were
each applied and undone, and net0 ended identical to the baseline.
Going from static back to DHCP left the CT without IPv4 until a
reboot, so the confirm now warns that IP changes on a running
container may need one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit was merged in pull request #4.
This commit is contained in:
DuckbootandClaude Opus 5.5 committed 2026-11-02 21:45:06 +01:00
1 parent b874996b1d
commit 5038148686
9 files changed
+1422 -11

No files matched your search

+265 -9
View File
@@ -10,6 +10,7 @@ from textual.containers import Horizontal, Vertical
from textual.screen import ModalScreen
from textual.widgets import (
Button,
Checkbox,
DataTable,
Footer,
Header,
@@ -160,6 +161,168 @@ class BackupScreen(ModalScreen[str | None]):
self.dismiss(None)
_IPV4_MODES = [("DHCP", "dhcp"), ("Static", "static"), ("None", "none")]
_IPV6_MODES = [("SLAAC", "auto"), *_IPV4_MODES]
class NetScreen(ModalScreen):
"""Edit bridge, VLAN, firewall and IP of one interface; confirms, then applies."""
BINDINGS = [("escape", "dismiss", "Cancel")] # noqa: RUF012 - Textual idiom
DEFAULT_CSS = "NetScreen Horizontal { height: auto; }"
def __init__(
self, guest: dict, config: dict, bridges: list[str], running: bool
) -> None:
super().__init__()
self.guest, self.config, self.running = guest, config, running
self.nets = cli.nets(config)
self.is_vm = cli.kind(guest) == "vm"
self.ip_enabled = not self.is_vm or cli.has_cloudinit(config)
# a bridge the node no longer lists must stay selectable, or Select rejects the prefill
used = {n.get("bridge") for n in self.nets.values()} - {None}
self.bridges = sorted(set(bridges) | used)
def compose(self) -> ComposeResult:
with Vertical():
yield Static(
f"Network of {self.guest['name']} ({self.guest['vmid']})", markup=False
)
yield Select(
[(k, k) for k in self.nets],
value=next(iter(self.nets)),
allow_blank=False,
id="iface",
)
with Horizontal():
yield Select(
[(b, b) for b in self.bridges], allow_blank=False, id="bridge"
)
yield Input(placeholder="VLAN tag (empty = none)", id="tag")
yield Checkbox("Firewall", id="firewall")
if not self.ip_enabled:
yield Static(
"IP is configured inside the VM (no cloud-init drive)", id="ip_note"
)
for fam, modes in (("ipv4", _IPV4_MODES), ("ipv6", _IPV6_MODES)):
with Horizontal():
yield Select(modes, allow_blank=False, id=fam)
yield Input(placeholder="address/prefix", id=f"{fam}_cidr")
yield Input(placeholder="gateway", id=f"{fam}_gw")
with Horizontal():
yield Button("Apply", id="apply", variant="primary")
yield Button("Cancel", id="cancel")
def on_mount(self) -> None:
for wid in ("ipv4", "ipv4_cidr", "ipv4_gw", "ipv6", "ipv6_cidr", "ipv6_gw"):
self.query_one(f"#{wid}").disabled = not self.ip_enabled
self._prefill()
def on_select_changed(self, event: Select.Changed) -> None:
if event.select.id == "iface":
self._prefill()
def _ip_cfg(self, iface: str) -> dict:
"""The dict holding the IP keys: the VM's ipconfigN, or the LXC net itself."""
if self.is_vm:
return cli.parse_net(str(self.config.get(f"ipconfig{iface[3:]}", "")))
return self.nets[iface]
def _prefill(self) -> None:
iface = self.query_one("#iface", Select).value
net = self.nets[iface]
self.query_one("#bridge", Select).value = net.get("bridge", self.bridges[0])
self.query_one("#tag", Input).value = net.get("tag", "")
self.query_one("#firewall", Checkbox).value = net.get("firewall") == "1"
for fam, family in (("ipv4", 4), ("ipv6", 6)):
mode, cidr, gw = cli.ip_mode(self._ip_cfg(iface), family)
self.query_one(f"#{fam}", Select).value = mode
self.query_one(f"#{fam}_cidr", Input).value = cidr
self.query_one(f"#{fam}_gw", Input).value = gw
def _changes(self) -> tuple[dict, dict]:
"""(changes, old) as raw netN/ipconfigN strings, None = absent; ValueError on bad input."""
iface = self.query_one("#iface", Select).value
text = self.query_one("#tag", Input).value.strip()
if text and not text.isdecimal():
raise ValueError("VLAN tag must be 1-4094")
current = self.nets[iface]
link = (
self.query_one("#bridge", Select).value,
int(text) if text else None,
self.query_one("#firewall", Checkbox).value,
)
was = (current.get("bridge"), int(current["tag"]) if current.get("tag") else None,
current.get("firewall") == "1") # fmt: skip
# untouched fields are not rewritten (e.g. firewall=0 stays as it is)
net = cli.apply_link(current, *link) if link != was else dict(current)
ip_key = f"ipconfig{iface[3:]}"
ip_old = self._ip_cfg(iface) if self.is_vm else {}
ip = ip_old if self.is_vm else net # the dict that holds the IP keys
if self.ip_enabled:
for fam, family in (("ipv4", 4), ("ipv6", 6)):
mode, cidr, gw = (
self.query_one(f"#{fam}{s}").value for s in ("", "_cidr", "_gw")
)
wanted = (mode, cidr.strip(), gw.strip())
if wanted != cli.ip_mode(
ip, family
): # untouched: e.g. ip6=manual stays
ip = cli.apply_ip(ip, family, *wanted)
if not self.is_vm:
net = ip
changes, old = {}, {}
if net != self.nets[iface]:
changes[iface] = cli.format_net(net)
old[iface] = str(self.config[iface])
if self.is_vm and ip != ip_old:
changes[ip_key] = cli.format_net(ip) or None
old[ip_key] = self.config.get(ip_key)
return changes, old
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "cancel":
self.dismiss()
return
try:
changes, old = self._changes()
except ValueError as exc:
self.app.notify(str(exc), severity="error")
return
if not changes:
self.app.notify("No change")
return
lines = [
f"{k}\n from {old[k] or '(not set)'}\n to {v or '(removed)'}"
for k, v in changes.items()
]
notes = []
if self.running and any(k.startswith("net") for k in changes):
notes.append(
"Link changes apply immediately and can cut the guest's network."
)
if (
self.running
and not self.is_vm
and any(_ip_changed(old[k], v) for k, v in changes.items())
): # seen live: static -> dhcp left the CT without IPv4 until it was rebooted
notes.append(
"IP changes on a running container may need a reboot of the container "
"(e.g. DHCP is not restarted)."
)
if any(k.startswith("ipconfig") for k in changes):
notes.append("IP settings (cloud-init) apply at next boot.")
who = f"{self.guest['name']} ({self.guest['vmid']})"
self.app.push_screen(
ConfirmScreen("\n".join([f"Change network of {who}?", *lines, *notes])),
lambda ok: (
self.app.start_net_change(self.guest, changes, old, self)
if ok
else None
),
)
class InfoScreen(ModalScreen):
"""Backups, backup job and network of one guest (read-only), plus backup now."""
@@ -168,6 +331,8 @@ class InfoScreen(ModalScreen):
("g", "reload", "Refresh"),
("b", "backup", "Backup now"),
("a", "toggle_all", "All backups"),
("n", "net", "Edit network"),
("u", "undo", "Undo network"),
]
DEFAULT_CSS = "InfoScreen DataTable { height: auto; max-height: 12; }"
@@ -178,6 +343,8 @@ class InfoScreen(ModalScreen):
self.jobs: list[dict] = []
self.backups: list[dict] = []
self.show_all = False
self.config: dict = {}
self.running = False
def compose(self) -> ComposeResult:
with Vertical():
@@ -231,6 +398,7 @@ class InfoScreen(ModalScreen):
addresses = cli.interfaces(guest)
except Exception as exc: # noqa: BLE001 - shown inline, the rest of the screen still works
unavailable = f"unavailable: {_message(exc)}"
self.running = running
self.app.call_from_thread(
self._show, config, nets, storages, jobs, backups, addresses, unavailable
)
@@ -250,7 +418,7 @@ class InfoScreen(ModalScreen):
def _fill_screen(
self, config, nets, storages, jobs, backups, addresses, unavailable
) -> None:
self.storages, self.jobs = storages, jobs
self.storages, self.jobs, self.config = storages, jobs, config
covering = cli.covering_jobs(jobs, self.guest)
self.query_one("#jobs", Static).update(
"; ".join(_job_text(j) for j in covering)
@@ -309,6 +477,46 @@ class InfoScreen(ModalScreen):
self.show_all = not self.show_all
self._show_backups()
def _refuse_if_busy(self) -> bool:
busy = self.guest["vmid"] in self.app.busy
if busy:
self.app.notify(
f"{self.guest['name']} ({self.guest['vmid']}) is busy",
severity="warning",
)
return busy
def action_net(self) -> None:
if cli.nets(self.config) and not self._refuse_if_busy():
self._open_net()
@work(thread=True)
def _open_net(self) -> None:
try:
bridges = cli.bridges(self.guest)
except Exception as exc: # noqa: BLE001 - surface every failure, never kill the app
self.app.call_from_thread(self.app.notify, _message(exc), severity="error")
return
self.app.call_from_thread(
self.app.push_screen,
NetScreen(self.guest, self.config, bridges, self.running),
)
def action_undo(self) -> None:
old = self.app.undo.get(self.guest["vmid"])
if old is None:
self.app.notify("No network change to undo")
return
if self._refuse_if_busy():
return
lines = [f"{k} = {v or '(remove)'}" for k, v in old.items()]
self.app.push_screen(
ConfirmScreen(
"\n".join(["Restore the previous network settings?", *lines])
),
lambda ok: self.app.start_net_change(self.guest, old, None) if ok else None,
)
def action_backup(self) -> None:
if not self.storages:
self.app.notify("No backup storage available", severity="warning")
@@ -324,6 +532,11 @@ class InfoScreen(ModalScreen):
)
def _ip_changed(old: str | None, new: str | None) -> bool:
before, after = cli.parse_net(old or ""), cli.parse_net(new or "")
return any(before.get(k) != after.get(k) for k in ("ip", "gw", "ip6", "gw6"))
def _earlier_guest(backup: dict, name: str) -> bool:
"""VMIDs are reused: notes (the job's {{guestname}}) naming another guest mean a deleted one."""
notes = backup.get("notes") or ""
@@ -355,6 +568,7 @@ class GuestApp(App):
super().__init__()
self.guests: dict[str, dict] = {}
self.busy: set[int] = set()
self.undo: dict[int, dict] = {} # vmid -> values before the last network change
# bumped on every context switch; replies started earlier come from the old cluster
self.generation = 0
self.switching = False
@@ -476,6 +690,7 @@ class GuestApp(App):
"#guests", DataTable
).clear() # old cluster's guests: not actionable
self.guests = {}
self.undo.clear() # VMIDs repeat across clusters: never undo onto another cluster
self.generation += 1
self.switching = True
self._switch_context(name)
@@ -576,16 +791,57 @@ class GuestApp(App):
else:
self.call_from_thread(self.notify, f"Backup done: {who}")
finally:
for screen in list(
self.screen_stack
): # reload while still busy, like _run_job
if (
isinstance(screen, InfoScreen)
and screen.guest["vmid"] == guest["vmid"]
):
screen.load()
self._reload_infos(guest)
self.busy.discard(guest["vmid"])
def _reload_infos(self, guest: dict) -> None:
"""Runs in a worker thread: reload the guest's open InfoScreens (call while still busy)."""
for screen in list(self.screen_stack):
if isinstance(screen, InfoScreen) and screen.guest["vmid"] == guest["vmid"]:
screen.load()
def start_net_change(
self, guest: dict, changes: dict, entry: dict | None, screen=None
) -> None:
"""`entry` becomes the guest's undo entry on success (None clears it).
`screen` (the NetScreen) is closed on success and stays open on failure.
"""
who = f"{guest['name']} ({guest['vmid']})"
if guest["vmid"] in self.busy:
self.notify(f"{who} is busy", severity="warning")
return
self.busy.add(guest["vmid"])
self.notify(f"Network change started: {who}")
self._run_net(guest, changes, entry, screen)
@work(thread=True)
def _run_net(self, guest: dict, changes: dict, entry: dict | None, screen) -> None:
who = f"{guest['name']} ({guest['vmid']})"
done = False
try:
cli.set_net(guest, changes)
except Exception as exc: # noqa: BLE001 - surface every failure, never kill the app
self.call_from_thread(
self.notify, f"Network change failed: {_message(exc)}", severity="error"
)
else:
done = True
if entry is None:
self.undo.pop(guest["vmid"], None)
else:
self.undo[guest["vmid"]] = entry
self.call_from_thread(self.notify, f"Network change done: {who}")
finally:
self._reload_infos(guest)
if done and screen is not None:
self.call_from_thread(self._close_net, screen)
self.busy.discard(guest["vmid"])
def _close_net(self, screen) -> None:
if self.screen is screen: # the user may have left it already
screen.dismiss()
def action_shell(self) -> None:
guest = self._selected()
if guest is None:
+105
View File
@@ -1,5 +1,6 @@
"""Thin subprocess wrapper around cv4pve-cli."""
import ipaddress
import json
import re
import subprocess
@@ -274,3 +275,107 @@ def interfaces(guest: dict) -> list[dict]:
)
found.append({"name": i["name"], "mac": i.get("hardware-address"), **addrs})
return [i for i in found if i["name"] != "lo"]
def bridges(guest: dict) -> list[str]:
items = json.loads(run("get", "node", "network", guest["node"], "-o", "json"))
return sorted(i["iface"] for i in items if i.get("type") in ("bridge", "OVSBridge"))
def format_net(d: dict) -> str:
return ",".join(
f"{k}={v}" if v else k for k, v in d.items()
) # bare flags stay bare
def has_cloudinit(config: dict) -> bool:
"""A cloud-init drive: a disk (not a name or description) whose volume says so."""
return any(
_VM_DISK.fullmatch(k) and "cloudinit" in str(v) for k, v in config.items()
)
def apply_link(net: dict, bridge: str, tag: int | None, firewall: bool) -> dict:
"""Copy of `net` with bridge, VLAN tag and firewall set; other keys stay in place."""
if tag is not None and not 1 <= tag <= 4094:
raise ValueError("VLAN tag must be 1-4094")
out = dict(net)
out["bridge"] = bridge
if tag is None:
out.pop("tag", None)
else:
out["tag"] = str(tag)
if firewall:
out["firewall"] = "1"
else:
out.pop("firewall", None)
return out
def ip_mode(cfg: dict, family: int) -> tuple[str, str, str]:
"""`(mode, cidr, gw)` of one family from an LXC net or VM ipconfig dict."""
ip, gw = cfg.get(_IP_KEYS[family][0], ""), cfg.get(_IP_KEYS[family][1], "")
if ip in ("dhcp", "auto"):
return ip, "", ""
if ip in ("", "manual"):
return "none", "", ""
return "static", ip, gw
_IP_KEYS = {4: ("ip", "gw"), 6: ("ip6", "gw6")}
def apply_ip(cfg: dict, family: int, mode: str, cidr: str = "", gw: str = "") -> dict:
"""Copy of `cfg` with one family's address keys set; ValueError on bad input."""
ip_key, gw_key = _IP_KEYS[family]
out = dict(cfg)
if mode == "static":
try:
prefix = cidr.partition("/")[2]
if not (
prefix.isdecimal() and 1 <= int(prefix) <= (32 if family == 4 else 128)
):
raise ValueError # CIDR only: no netmask form, no /0
if ipaddress.ip_interface(cidr).version != family:
raise ValueError
except ValueError:
raise ValueError(f"IPv{family} needs an address like 10.0.0.5/24") from None
try:
if gw and ipaddress.ip_address(gw).version != family:
raise ValueError
except ValueError:
raise ValueError(
f"IPv{family} gateway must be an IPv{family} address"
) from None
out[ip_key] = cidr
if gw:
out[gw_key] = gw
else:
out.pop(gw_key, None)
elif mode in ("dhcp", "auto"):
if mode == "auto" and family != 6:
raise ValueError("SLAAC (auto) exists only for IPv6")
out[ip_key] = mode
out.pop(gw_key, None)
elif mode == "none":
out.pop(ip_key, None)
out.pop(gw_key, None)
else:
raise ValueError(f"unknown mode: {mode}")
return out
def set_net(guest: dict, changes: dict[str, str | None]) -> None:
"""Set the given keys; `None` values are deleted. One call."""
if not changes:
raise ValueError("nothing to set")
opts = []
for key, value in changes.items():
if value is not None:
opts += [f"--{key}", value]
gone = [k for k, v in changes.items() if v is None]
if gone:
opts += ["--delete", ",".join(gone)]
run(
"set", kind(guest), "config", guest["node"], str(guest["vmid"]), *opts, "--wait"
)