213 lines
7.4 KiB
Python
Executable File
213 lines
7.4 KiB
Python
Executable File
#!/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 <con> ipv4/ipv6 method/addresses/gateway/dns
|
|
set-ip <con> ipv4|ipv6 <method> [addr_cidr] [gateway] [dns_space_sep]
|
|
routes kernel routing table (read-only)
|
|
vlans configured VLAN connections
|
|
vlan-add <parent_dev> <id> [name] create + bring up a VLAN
|
|
vlan-del <name> delete a VLAN connection
|
|
vlan-up <name> activate a VLAN connection
|
|
dns effective DNS servers per device
|
|
set-dns <con> ipv4|ipv6 <servers_space_sep> <yes|no ignore-auto>
|
|
"""
|
|
|
|
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:]))
|