167 lines
7.3 KiB
Python
167 lines
7.3 KiB
Python
"""Read-only network diagnostics, independent of a desktop applet's manager view."""
|
|
from __future__ import annotations
|
|
import json
|
|
import os
|
|
import re
|
|
import stat
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
|
|
_IFACE = re.compile(r"[A-Za-z0-9_.:@-]{1,100}\Z")
|
|
|
|
def _command(argv, *, timeout=4):
|
|
try:
|
|
result = subprocess.run(argv, capture_output=True, text=True, timeout=timeout,
|
|
env={**os.environ, "LC_ALL": "C", "LANG": "C"})
|
|
return result.returncode, result.stdout
|
|
except (OSError, subprocess.SubprocessError):
|
|
return None, ""
|
|
|
|
def _json(argv):
|
|
code, text = _command(argv)
|
|
if code != 0:
|
|
return None
|
|
try:
|
|
value = json.loads(text)
|
|
except (ValueError, TypeError):
|
|
return None
|
|
return value if type(value) is list else None
|
|
|
|
def _active(service):
|
|
code, text = _command(["systemctl", "is-active", service])
|
|
return code == 0 and text.strip() == "active"
|
|
|
|
def _available(name):
|
|
return bool(shutil.which(name) or any(
|
|
(Path(directory) / name).is_file() and os.access(Path(directory) / name, os.X_OK)
|
|
for directory in ["/usr/sbin", "/sbin", "/usr/bin", "/bin"]))
|
|
|
|
def _ifupdown(state_file=Path("/run/network/ifstate")):
|
|
"""Read active interface names from bounded state, never credential/config files."""
|
|
try:
|
|
fd = os.open(state_file, os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW | os.O_NONBLOCK)
|
|
try:
|
|
info = os.fstat(fd)
|
|
if (not stat.S_ISREG(info.st_mode) or info.st_uid not in {0, os.getuid()}
|
|
or info.st_mode & 0o022 or info.st_size > 65536):
|
|
return set()
|
|
raw = os.read(fd, 65537)
|
|
finally:
|
|
os.close(fd)
|
|
if len(raw) > 65536:
|
|
return set()
|
|
text = raw.decode("ascii")
|
|
except (OSError, UnicodeError):
|
|
return set()
|
|
names = set()
|
|
for line in text.splitlines():
|
|
if "=" not in line:
|
|
continue
|
|
name, mapped = line.split("=", 1)
|
|
if _IFACE.fullmatch(name) and _IFACE.fullmatch(mapped):
|
|
names.add(name)
|
|
return names
|
|
|
|
def _nm_devices():
|
|
"""Only device metadata, never connection profiles, SSIDs or saved secrets."""
|
|
code, text = _command([
|
|
"nmcli", "-t", "-f", "GENERAL.DEVICE,GENERAL.STATE,GENERAL.NM-MANAGED", "device", "show",
|
|
])
|
|
if code != 0:
|
|
return None
|
|
devices = {}
|
|
current = None
|
|
for line in text.splitlines():
|
|
if ":" not in line:
|
|
continue
|
|
key, value = line.split(":", 1)
|
|
# nmcli escapes colons/backslashes in terse output. Linux interface
|
|
# names containing either are excluded rather than guessed.
|
|
if key == "GENERAL.DEVICE":
|
|
current = value if _IFACE.fullmatch(value) and "\\" not in value else None
|
|
if current:
|
|
devices[current] = {"managed": None, "state": ""}
|
|
elif current and key == "GENERAL.STATE":
|
|
devices[current]["state"] = value[:120]
|
|
elif current and key == "GENERAL.NM-MANAGED":
|
|
devices[current]["managed"] = {"yes": True, "no": False}.get(value.strip().lower())
|
|
return devices
|
|
|
|
def summarize(addresses, routes, nm_devices=None, ifupdown=None, managers=None):
|
|
"""Separate observed kernel connectivity from network-manager ownership."""
|
|
nm_devices = {} if nm_devices is None else nm_devices
|
|
ifupdown = set() if ifupdown is None else set(ifupdown)
|
|
managers = {} if managers is None else managers
|
|
defaults = []
|
|
for family, rows in routes.items():
|
|
for row in rows or []:
|
|
name = row.get("dev")
|
|
if row.get("dst") != "default" or not isinstance(name, str) or _IFACE.fullmatch(name) is None:
|
|
continue
|
|
defaults.append({"family": family, "interface": name,
|
|
"gateway": row.get("gateway"), "metric": row.get("metric")})
|
|
interfaces = []
|
|
for row in addresses or []:
|
|
name = row.get("ifname")
|
|
if not isinstance(name, str) or _IFACE.fullmatch(name) is None:
|
|
continue
|
|
flags = row.get("flags") if isinstance(row.get("flags"), list) else []
|
|
addresses_public = []
|
|
for address in row.get("addr_info", []):
|
|
if (isinstance(address, dict) and address.get("family") in {"inet", "inet6"}
|
|
and isinstance(address.get("local"), str)):
|
|
addresses_public.append({"family": address["family"], "address": address["local"],
|
|
"prefixlen": address.get("prefixlen"),
|
|
"scope": address.get("scope")})
|
|
nm = nm_devices.get(name, {})
|
|
managed = nm.get("managed")
|
|
if managed is True:
|
|
owner = "NetworkManager"
|
|
elif name in ifupdown:
|
|
owner = "ifupdown"
|
|
else:
|
|
owner = "unverified"
|
|
default = any(route["interface"] == name for route in defaults)
|
|
link = name != "lo" and "UP" in flags and row.get("operstate") in {"UP", "UNKNOWN"}
|
|
usable_address = any(a["scope"] == "global" for a in addresses_public)
|
|
interfaces.append({
|
|
"name": name, "link_up": link, "operstate": row.get("operstate"),
|
|
"addresses": addresses_public, "has_default_route": default,
|
|
"manager": owner, "networkmanager_managed": managed,
|
|
"networkmanager_state": nm.get("state", ""),
|
|
"local_route_ready": bool(link and usable_address and default),
|
|
})
|
|
ready = any(item["local_route_ready"] for item in interfaces)
|
|
warnings = []
|
|
for item in interfaces:
|
|
if item["local_route_ready"] and item["networkmanager_managed"] is False:
|
|
warnings.append({
|
|
"code": "CONNECTED_INTERFACE_NOT_MANAGED_BY_NETWORKMANAGER",
|
|
"interface": item["name"],
|
|
"message": "A NetworkManager desktop applet may not represent this working interface.",
|
|
})
|
|
return {"ok": True, "interfaces": interfaces, "default_routes": defaults,
|
|
"managers": managers, "connectivity": "LINK_ADDRESS_DEFAULT_ROUTE" if ready else "NOT_ESTABLISHED",
|
|
"internet_verified": False,
|
|
"internet_note": "Link, address and route checks do not verify DNS or Internet access.",
|
|
"warnings": warnings}
|
|
|
|
def inspect():
|
|
addresses = _json(["ip", "-j", "address", "show"])
|
|
routes4 = _json(["ip", "-j", "-4", "route", "show", "default"])
|
|
routes6 = _json(["ip", "-j", "-6", "route", "show", "default"])
|
|
nm = _nm_devices() if shutil.which("nmcli") else None
|
|
managers = {
|
|
"NetworkManager": {"available": bool(shutil.which("nmcli")), "active": _active("NetworkManager.service")},
|
|
"ifupdown": {"available": _available("ifquery"), "active": _active("networking.service")},
|
|
"systemd-networkd": {"active": _active("systemd-networkd.service")},
|
|
}
|
|
active = _ifupdown() if managers["ifupdown"]["active"] else set()
|
|
value = summarize(addresses, {"ipv4": routes4, "ipv6": routes6}, nm, active, managers)
|
|
if addresses is None or (routes4 is None and routes6 is None):
|
|
value.update(ok=False, error="NETWORK_KERNEL_STATUS_UNAVAILABLE")
|
|
if nm is None and managers["NetworkManager"]["available"]:
|
|
value["warnings"].append({"code": "NETWORKMANAGER_STATUS_UNAVAILABLE"})
|
|
return value
|