#!/usr/bin/env python3 """NetworkManager backend for the Network quad's expanded settings. Every subcommand prints JSON to stdout ({"ok": bool, ...} for mutations) and diagnostics to stderr. Reads use `nmcli -g` (raw, unescaped single fields) and `ip -j`; writes go through `nmcli connection modify/up`, so persistence and polkit auth are handled by NetworkManager. Kept as one script so the UI has a single, stable contract (see modules/network.py). adapters list manageable devices + their connection ipconfig ipv4/ipv6 method/addresses/gateway/dns set-ip ipv4|ipv6 [addr_cidr] [gateway] [dns_space_sep] routes kernel routing table (read-only) vlans configured VLAN connections vlan-add [name] create + bring up a VLAN vlan-del delete a VLAN connection vlan-up activate a VLAN connection dns effective DNS servers per device set-dns ipv4|ipv6 """ from __future__ import annotations import json import subprocess import sys def _run(argv: list[str], timeout: int = 20) -> tuple[bool, str, str]: try: p = subprocess.run(argv, capture_output=True, text=True, timeout=timeout) except (FileNotFoundError, subprocess.SubprocessError) as exc: return False, "", str(exc) return p.returncode == 0, p.stdout, p.stderr def _get(field: str, con: str) -> str: ok, out, _ = _run(["nmcli", "-g", field, "connection", "show", con]) return out.strip() if ok else "" def _list(field: str, con: str) -> list[str]: raw = _get(field, con) return [v.strip() for v in raw.replace(",", " ").split() if v.strip()] def _emit(obj) -> int: print(json.dumps(obj)) return 0 def _ok(ok: bool, err: str = "") -> int: print(json.dumps({"ok": bool(ok), "error": err.strip()})) return 0 if ok else 1 # -- reads ----------------------------------------------------------------- def adapters() -> int: ok, out, _ = _run(["nmcli", "-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device"]) rows = [] if ok: for line in out.splitlines(): # DEVICE may contain escaped colons (bt MACs); split from the right on # the three trailing fields we know are colon-free-ish. parts = line.split(":") if len(parts) < 4: continue connection = parts[-1] state = parts[-2] typ = parts[-3] device = ":".join(parts[:-3]).replace("\\:", ":") if typ not in ("ethernet", "wifi"): continue rows.append({"device": device, "type": typ, "state": state, "connection": connection.replace("\\:", ":")}) return _emit(rows) def ipconfig(con: str) -> int: def family(fam: str) -> dict: return { "method": _get(f"{fam}.method", con) or "auto", "addresses": _list(f"{fam}.addresses", con), "gateway": _get(f"{fam}.gateway", con), "dns": _list(f"{fam}.dns", con), "ignore_auto_dns": _get(f"{fam}.ignore-auto-dns", con) == "yes", } return _emit({"connection": con, "ipv4": family("ipv4"), "ipv6": family("ipv6")}) def routes() -> int: ok, out, err = _run(["ip", "-j", "route", "show"]) if not ok: return _emit([]) try: return _emit(json.loads(out)) except json.JSONDecodeError: return _emit([]) def vlans() -> int: ok, out, _ = _run(["nmcli", "-t", "-f", "NAME,TYPE,DEVICE,ACTIVE", "connection", "show"]) rows = [] if ok: for line in out.splitlines(): parts = line.rsplit(":", 3) if len(parts) < 4 or parts[1] != "vlan": continue name, _typ, device, active = parts rows.append({"name": name, "device": device, "active": active == "yes"}) return _emit(rows) def dns() -> int: """Effective DNS servers, per device, from `nmcli device show`.""" ok, out, _ = _run(["nmcli", "-t", "-f", "GENERAL.DEVICE,IP4.DNS,IP6.DNS", "device", "show"]) devices: dict[str, dict] = {} cur = None if ok: for line in out.splitlines(): if ":" not in line: continue key, _, val = line.partition(":") val = val.strip() if key == "GENERAL.DEVICE": cur = val devices.setdefault(cur, {"device": cur, "servers": []}) elif cur and key.startswith(("IP4.DNS", "IP6.DNS")) and val: devices[cur]["servers"].append(val) return _emit([d for d in devices.values() if d["servers"]]) # -- writes ---------------------------------------------------------------- def set_ip(con: str, fam: str, method: str, addr: str = "", gateway: str = "", dns: str = "") -> int: if fam not in ("ipv4", "ipv6"): return _ok(False, "family must be ipv4 or ipv6") args = ["nmcli", "connection", "modify", con, f"{fam}.method", method] if method == "manual": args += [f"{fam}.addresses", addr or ""] args += [f"{fam}.gateway", gateway or ""] if dns: args += [f"{fam}.dns", dns] else: # auto: clear any manual leftovers so DHCP is clean args += [f"{fam}.addresses", "", f"{fam}.gateway", ""] ok, _, err = _run(args) if ok: ok, _, err = _run(["nmcli", "connection", "up", con]) return _ok(ok, err) def set_dns(con: str, fam: str, servers: str, ignore_auto: str) -> int: if fam not in ("ipv4", "ipv6"): return _ok(False, "family must be ipv4 or ipv6") ok, _, err = _run(["nmcli", "connection", "modify", con, f"{fam}.dns", servers, f"{fam}.ignore-auto-dns", "yes" if ignore_auto == "yes" else "no"]) if ok: ok, _, err = _run(["nmcli", "connection", "up", con]) return _ok(ok, err) def vlan_add(parent: str, vid: str, name: str = "") -> int: name = name or f"vlan{vid}" ok, _, err = _run(["nmcli", "connection", "add", "type", "vlan", "con-name", name, "dev", parent, "id", str(vid)]) if ok: ok, _, err = _run(["nmcli", "connection", "up", name]) return _ok(ok, err) def vlan_del(name: str) -> int: ok, _, err = _run(["nmcli", "connection", "delete", name]) return _ok(ok, err) def vlan_up(name: str) -> int: ok, _, err = _run(["nmcli", "connection", "up", name]) return _ok(ok, err) def main(argv: list[str]) -> int: if not argv: print("no subcommand", file=sys.stderr) return 2 cmd, args = argv[0], argv[1:] table = { "adapters": lambda: adapters(), "ipconfig": lambda: ipconfig(args[0]), "set-ip": lambda: set_ip(*args), "routes": lambda: routes(), "vlans": lambda: vlans(), "vlan-add": lambda: vlan_add(*args), "vlan-del": lambda: vlan_del(args[0]), "vlan-up": lambda: vlan_up(args[0]), "dns": lambda: dns(), "set-dns": lambda: set_dns(*args), } fn = table.get(cmd) if fn is None: print(f"unknown subcommand: {cmd}", file=sys.stderr) return 2 try: return fn() except TypeError as exc: print(f"bad arguments for {cmd}: {exc}", file=sys.stderr) return 2 if __name__ == "__main__": raise SystemExit(main(sys.argv[1:]))