"""Read-only looks at the computers, for the Computers screen. Every probe is a fixed command. Whole lists come back (every agent, every process, every task, every service) and are matched here, so no name from the settings is ever put into a command line. The only values that reach a command are two folder paths from the site block, shell-quoted. Nothing is ever changed, nothing is woken, and a probe never runs while the watchdog runs a fix. """ import glob import json import os import re import shlex import socket import subprocess import threading import time from settings import PARENT # noqa: F401 (puts the project folder on sys.path) import status_panel as sp TIMEOUT = 8 MAC_PROBE = ( 'echo "SP_NAME=$(uname -n)"; echo "SP_OSV=$(sw_vers -productVersion 2>/dev/null)"; ' 'echo "SP_PY=$(/usr/bin/python3 --version 2>&1)"; ' 'echo "SP_KEY=$(grep -c status-panel ~/.ssh/authorized_keys 2>/dev/null)"; ' 'echo SP_AGENTS; launchctl list 2>/dev/null; echo SP_PROCS; ps -axo comm= 2>/dev/null; ' 'echo SP_APPS; ls /Applications 2>/dev/null; echo SP_HELPER; ls -l {helper} 2>&1; sudo -n -l {helper} 2>&1 | tail -1; ' 'echo SP_SOLO; cat {solo}/solo-mode.json 2>/dev/null; echo; stat -f %m {solo}/solo-mode.json 2>/dev/null; ' 'echo SP_NOW; date +%s; echo SP_END' ) WIN_PROBE = ( 'echo SP_NAME=%COMPUTERNAME% & echo SP_TASKS & schtasks /Query /FO CSV /NH 2>nul & echo SP_PROCS & tasklist /FO CSV /NH 2>nul & ' 'echo SP_SVC & sc query type= service state= all 2>nul & echo SP_PS & powershell -NoProfile -Command ' '"(Get-NetConnectionProfile | Select -First 1 -ExpandProperty NetworkCategory); ' '(Get-NetAdapterPowerManagement | Select -First 1 -ExpandProperty WakeOnMagicPacket); ' '((Get-NetFirewallRule -Name OpenSSH-Server-In-TCP -ErrorAction SilentlyContinue | Get-NetFirewallAddressFilter | ' 'Select -ExpandProperty RemoteAddress) -join \',\')" 2>nul & echo SP_END' ) def shell_path(path): """A path for a shell line: quoted, except that a leading ~ becomes $HOME outside the quotes.""" if path.startswith("~/"): return '"$HOME"' + shlex.quote(path[1:]) return shlex.quote(path) def quoted_paths(cfg): site = cfg.get("site", {}) return {"helper": shell_path(site.get("dvs_helper", "/usr/local/sbin/status-panel-dvs-start")), "solo": shell_path(site.get("solo_dir", "~/Library/Application Support/status-panel"))} def run_ssh(cfg, alias, command, timeout=TIMEOUT): try: r = subprocess.run(sp.ssh_cmd(cfg, alias) + [command], capture_output=True, text=True, timeout=timeout) return r.returncode, r.stdout, r.stderr except subprocess.TimeoutExpired: return None, "", "no answer within %d s" % timeout except OSError as e: return None, "", str(e) def sections(out, markers): """Split probe output at the SP_... marker lines: {marker: [lines]}.""" parts, current = {}, None for line in out.replace("\r", "").split("\n"): key = line.strip() if key in markers: current = key parts[current] = [] elif current: parts[current].append(line) return parts def values(lines_or_out): """SP_KEY=value lines -> {"KEY": "value"}.""" text = "\n".join(lines_or_out) if isinstance(lines_or_out, list) else lines_or_out return dict(re.findall(r"^SP_([A-Z]+)=(.*)$", text.replace("\r", ""), re.M)) def parse_launchctl(lines): """{label: pid or None} from `launchctl list`.""" agents = {} for line in lines: parts = line.split("\t") if len(parts) == 3 and parts[2] and parts[2] != "Label": agents[parts[2]] = int(parts[0]) if parts[0].isdigit() else None return agents def parse_csv_rows(lines): out = [] for line in lines: if not line.startswith('"'): continue out.append([c.strip('"') for c in line.strip().split('","')]) return out def parse_sc(lines): """{service name: state word} from `sc query`.""" services, name = {}, None for line in lines: m = re.match(r"^\s*SERVICE_NAME:\s*(.+?)\s*$", line) if m: name = m.group(1) m = re.match(r"^\s*STATE\s*:\s*\d+\s+(\w+)", line) if m and name: services[name] = m.group(1) return services def light(ok, reason=""): return {"light": "ok" if ok else "problem", "reason": reason if not ok else ""} def mac_items(host, parsed, cfg): """One line per thing that runs on a Mac, judged from the probe's lists.""" agents = parse_launchctl(parsed.get("SP_AGENTS", [])) procs = set(l.strip() for l in parsed.get("SP_PROCS", [])) proc_names = {os.path.basename(p) for p in procs} | {p.split("/")[-1] for p in procs} apps = set(l.strip() for l in parsed.get("SP_APPS", [])) helper = "\n".join(parsed.get("SP_HELPER", [])) solo_lines = [l for l in parsed.get("SP_SOLO", []) if l.strip()] now = int(("\n".join(parsed.get("SP_NOW", [])).strip() or "0") or 0) items = [] for run in host.get("runs", []): kind, name = run.get("kind"), run.get("name") or run.get("label") or run.get("process") or run.get("kind") item = {"name": name, "kind": kind, "watched": run.get("watched", True)} if kind == "sshd": item.update(light(True)) elif kind == "app": proc = run.get("process", "") running = any(p == proc or p.endswith("/" + proc) for p in procs) or proc in proc_names installed = (not run.get("path")) or True item.update(light(running, "not running" if installed else "not installed")) if not running and run.get("watched", True) is False: item["light"] = "not_needed" elif kind == "launchagent": label = run.get("label", "") if label not in agents: item.update(light(False, "not loaded")) elif agents[label] is None: item.update(light(False, "loaded but not running")) else: item.update(light(True)) if run.get("watched", True) is False and item["light"] == "problem": item["light"] = "not_needed" elif kind == "disabled": label = run.get("label", "") item.update(light(label not in agents, "loaded - it must not run here")) elif kind == "daemon": want = run.get("process", "").lower().replace(" ", "") running = any(want in p.lower().replace(" ", "") for p in procs) or any(want in a.lower() for a in agents) \ or (want.startswith("dantevirtual") and any("dvsd" in p for p in procs)) item.update(light(running, "not running")) elif kind == "helper_dvs": # `sudo -n -l ` answers with the command itself when it is allowed without a password. lines = [l for l in helper.splitlines() if l.strip()] installed = "No such file" not in helper and "-rwx" in helper last = lines[-1] if lines else "" allowed = installed and "status-panel-dvs-start" in last and "not allowed" not in last.lower() and "may not" not in last.lower() \ and "password" not in last.lower() item.update(light(installed and allowed, "not installed" if not installed else "sudo rule missing")) elif kind == "helper_solo": label = run.get("label", "org.waterschurch.status-panel.solo-mode") try: data = json.loads(solo_lines[0]) if solo_lines else {} age = now - int(solo_lines[1]) if len(solo_lines) > 1 and now else None except (ValueError, IndexError): data, age = {}, None running = agents.get(label) is not None fresh = age is not None and age < 60 trusted = bool(data.get("trusted")) if not running: item.update(light(False, "helper not running")) elif not trusted: item.update(light(False, "Accessibility not allowed on that Mac")) elif not fresh: item.update(light(False, "helper not reporting")) else: item.update(light(True)) item["detail"] = {"afl": data.get("afl"), "latch": data.get("latch"), "age": age} elif kind == "midi_session": item.update({"light": "unknown", "reason": "see the MIDI key"}) elif kind in ("folder",): item.update({"light": "unknown", "reason": "not checked here"}) else: item.update({"light": "unknown", "reason": ""}) items.append(item) return items, {"agents": sorted(a for a in agents if not a.startswith(("com.apple.", "application."))), "processes": sorted(proc_names)[:200]} def windows_items(host, parsed): tasks = {row[0]: row for row in parse_csv_rows(parsed.get("SP_TASKS", [])) if row} procs = {row[0].lower() for row in parse_csv_rows(parsed.get("SP_PROCS", [])) if row} services = parse_sc(parsed.get("SP_SVC", [])) ps = [l.strip() for l in parsed.get("SP_PS", []) if l.strip()] profile, wol, allow = (ps + ["", "", ""])[:3] items = [] for run in host.get("runs", []): kind, name = run.get("kind"), run.get("name") or run.get("kind") item = {"name": name, "kind": kind, "watched": run.get("watched", True)} if kind == "sshd": item.update(light(services.get("sshd") == "RUNNING", "the SSH service is %s" % services.get("sshd", "missing"))) elif kind == "task": full = "\\StatusPanel\\" + run["name"] if full not in tasks: item.update(light(False, "no scheduled task %s" % full)) else: exe = (run.get("exe") or "").lower() running = exe in procs if exe else None if running is False: item.update(light(False, "%s is not running" % run.get("exe"))) else: item.update(light(True)) item["task_state"] = tasks.get(full, ["", "", ""])[2] if full in tasks else None elif kind == "service": state = services.get(run["name"]) item.update(light(state == "RUNNING", "service %s is %s" % (run["name"], state or "missing"))) elif kind in ("web", "artnet"): item.update({"light": "unknown", "reason": "see the key that watches it"}) else: item.update({"light": "unknown", "reason": ""}) items.append(item) setup = {"profile": profile, "wake_on_lan": wol, "firewall_allow": allow} return items, {"tasks": sorted(t for t in tasks if "StatusPanel" in t), "services": sorted(k for k, v in services.items() if v == "RUNNING" and not k.lower().startswith(("w", "s", "d", "e", "n", "p", "t", "u", "c")))[:60]}, setup def probe_host(cfg, alias, host): """One computer, over ssh. Returns the record the Computers screen shows.""" started = time.time() rec = {"alias": alias, "name": host.get("name", alias), "os": host.get("os", "mac"), "time": started, "reachable": False, "items": [], "found": {}, "setup": {}} if host.get("self"): return probe_self(cfg, alias, host) if host.get("os") == "windows": rc, out, err = run_ssh(cfg, alias, WIN_PROBE, TIMEOUT + 6) if rc is None or "SP_END" not in out: rec["error"] = err.strip() or "no answer" return rec rec["reachable"] = True parsed = sections(out, ("SP_TASKS", "SP_PROCS", "SP_SVC", "SP_PS", "SP_END")) rec["computer_name"] = values(out).get("NAME", "").strip() rec["items"], rec["found"], rec["setup"] = windows_items(host, parsed) rec["setup"]["key_ok"] = True rec["setup"]["shell"] = "cmd" if rec["computer_name"] and "%" not in rec["computer_name"] else "not cmd" else: rc, out, err = run_ssh(cfg, alias, MAC_PROBE.format(**quoted_paths(cfg))) if rc is None or "SP_END" not in out: rec["error"] = err.strip() or "no answer" return rec rec["reachable"] = True v = values(out) parsed = sections(out, ("SP_AGENTS", "SP_PROCS", "SP_APPS", "SP_HELPER", "SP_SOLO", "SP_NOW", "SP_END")) rec["computer_name"], rec["os_version"], rec["python"] = v.get("NAME", ""), v.get("OSV", ""), v.get("PY", "") rec["items"], rec["found"] = mac_items(host, parsed, cfg) rec["setup"] = {"key_ok": (v.get("KEY") or "0").strip() not in ("", "0"), "python_ok": "3." in v.get("PY", "")} rec["took"] = round(time.time() - started, 1) return rec def listening_ports(): try: out = subprocess.run(["/usr/sbin/lsof", "-nP", "-iTCP", "-sTCP:LISTEN"], capture_output=True, text=True, timeout=6).stdout except (OSError, subprocess.TimeoutExpired): return [] ports = set() for line in out.splitlines()[1:]: parts = line.split() if len(parts) > 8: ports.add("%s %s" % (parts[0][:12], parts[8])) return sorted(ports) def interfaces(): """[(name, ipv4, active?)] of this Mac's network ports.""" try: out = subprocess.run(["/sbin/ifconfig"], capture_output=True, text=True, timeout=5).stdout except (OSError, subprocess.TimeoutExpired): return [] found = [] for block in re.split(r"\n(?=\w)", out): name = block.split(":")[0] ip = re.search(r"inet (\d+\.\d+\.\d+\.\d+)", block) if ip and name != "lo0": found.append((name, ip.group(1), "status: active" in block)) return found def probe_self(cfg, alias, host): """This Mac: no ssh; the same questions asked locally, plus the things only it has.""" started = time.time() rec = {"alias": alias, "name": host.get("name", "this Mac"), "os": "mac", "time": started, "reachable": True, "items": [], "found": {}, "setup": {}} try: agents_out = subprocess.run(["/bin/launchctl", "list"], capture_output=True, text=True, timeout=5).stdout procs_out = subprocess.run(["/bin/ps", "-axo", "comm="], capture_output=True, text=True, timeout=5).stdout except (OSError, subprocess.TimeoutExpired) as e: rec["error"] = str(e) return rec paths = quoted_paths(cfg) helper = subprocess.run(["/bin/zsh", "-c", "ls -l %s 2>&1; sudo -n -l %s 2>&1 | tail -1" % (paths["helper"], paths["helper"])], capture_output=True, text=True, timeout=6).stdout parsed = {"SP_AGENTS": agents_out.splitlines(), "SP_PROCS": procs_out.splitlines(), "SP_APPS": [], "SP_HELPER": helper.splitlines(), "SP_SOLO": [], "SP_NOW": [str(int(time.time()))]} rec["items"], rec["found"] = mac_items(host, parsed, cfg) for item, run in zip(rec["items"], host.get("runs", [])): if run.get("kind") == "folder": path = os.path.expanduser(run["path"]) item.update(light(os.path.isdir(path), "folder not found (is Google Drive signed in?)")) rec["found"]["ports"] = listening_ports() ifaces = interfaces() rec["found"]["interfaces"] = [{"iface": n, "ip": ip, "active": a} for n, ip, a in ifaces] key = os.path.expanduser(cfg.get("ssh_key", "~/.ssh/id_status_panel")) atem = cfg.get("atem") or {} rec["setup"] = { "login_key": os.path.exists(key) and os.path.exists(key + ".pub"), "python_ok": (3, 9) <= tuple(int(x) for x in subprocess.run(["/usr/bin/python3", "-c", "import sys; print('%d.%d' % sys.version_info[:2])"], capture_output=True, text=True, timeout=5).stdout.strip().split(".")) <= (3, 12), "companion_db": bool(sp.companion_db_path(cfg)), "netgear_keychain": "acct" in subprocess.run(["/usr/bin/security", "find-generic-password", "-s", "netgear-status-panel"], capture_output=True, text=True, timeout=5).stdout if cfg.get("dante_switches") else None, "atem_node": bool(sp.newest(atem["node"])) if atem.get("node") else None, "atem_library": bool(sp.newest(atem["library"])) if atem.get("library") else None, "switches": {s: port_open(s, 23) for s in cfg.get("dante_switches", [])}, "lan_ports": [n for n in host.get("networks", []) if n.get("use") == "LAN"], } rec["took"] = round(time.time() - started, 1) return rec def port_open(host, port, timeout=2): try: with socket.create_connection((host, port), timeout=timeout): return True except OSError: return False def probe_all(cfg, data_dir=None, only=None): """Every computer at once (threads), results cached in machines-live.json.""" results, threads = {}, [] for alias, host in cfg.get("hosts", {}).items(): if only and alias not in only: continue def work(alias=alias, host=host): try: results[alias] = probe_host(cfg, alias, host) except Exception as e: # one broken probe must not hide the others results[alias] = {"alias": alias, "name": host.get("name", alias), "reachable": False, "error": str(e), "items": [], "found": {}, "setup": {}, "time": time.time()} t = threading.Thread(target=work, daemon=True) t.start() threads.append(t) for t in threads: t.join(TIMEOUT + 10) if data_dir: path = os.path.join(data_dir, "machines-live.json") try: old = json.load(open(path)) if os.path.exists(path) else {} except (OSError, ValueError): old = {} old.update(results) sp.write_json(path, old) try: os.chmod(path, 0o600) except OSError: pass return results