"""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