Files
status-indicators/settings/discover.py
T

320 lines
14 KiB
Python
Raw Normal View History

"""Auto-setup's eyes: what this campus has, read from Companion, Dante, the network MIDI sessions,
this Mac's own network, and the computers that joined (each one ran the join line once).
look(cfg, data_dir) the inventory autoconf.py turns into keys
register(data_dir, hello) a computer's join report, kept in computers.json
Read-only everywhere: nothing here changes Companion, a session or a computer. Every remote
command is a fixed string; names from the network only ever come back as data.
"""
import ipaddress
import json
import os
import re
import subprocess
import time
import urllib.parse
import urllib.request
from settings import PARENT # noqa: F401 (puts the project folder on sys.path)
import status_panel as sp
TIMEOUT = 8
# find, not a glob: in zsh (the Macs' login shell) a pattern that matches nothing aborts the whole line.
MAC_LOOK = "echo SP_PROCS; ps -axo comm= 2>/dev/null; echo SP_APPS; find /Applications -maxdepth 3 -name '*.app' -prune 2>/dev/null; echo SP_END"
WIN_LOOK = "echo SP_PROCS & tasklist /FO CSV /NH 2>nul & echo SP_TASKS & schtasks /Query /FO CSV /NH 2>nul & echo SP_END"
HOST_FIELDS = ("host", "bonjourHost", "serverHost", "ip", "address")
# --- the computers that joined ---------------------------------------------------------
def registry_path(data_dir):
return os.path.join(data_dir, "computers.json")
def registered(data_dir):
try:
with open(registry_path(data_dir)) as f:
return json.load(f)
except (OSError, ValueError):
return {}
def clean_hello(hello, peer_ip):
"""A join report, kept to the fields auto-setup uses and checked: it comes from the network."""
def s(v, n=80):
return re.sub(r"[^\w .@:\-()'+]", "", str(v or ""))[:n]
ips = [str(ipaddress.ip_address(i)) for i in (hello.get("ips") or []) if _is_ip(i)][:12]
if peer_ip and _is_ip(peer_ip) and peer_ip not in ips:
ips.insert(0, peer_ip)
user = str(hello.get("user") or "")
if not re.fullmatch(r"[A-Za-z0-9._\-][A-Za-z0-9._\- ]{0,39}", user): # as given: a name to log in with is never "cleaned up"
raise ValueError("no usable user name")
return {"name": s(hello.get("name")) or user, "hostname": s(hello.get("hostname"), 60), "user": user,
"os": "windows" if str(hello.get("os", "")).lower().startswith("win") else "mac", "ips": ips,
"macs": [m for m in (str(x).lower().replace("-", ":") for x in hello.get("macs") or []) if re.fullmatch(r"([0-9a-f]{2}:){5}[0-9a-f]{2}", m)][:4],
"tasks": [s(t, 60) for t in hello.get("tasks") or []][:20], "at": int(time.time())}
def _is_ip(v):
try:
ipaddress.ip_address(str(v))
return True
except ValueError:
return False
def register(data_dir, hello, peer_ip=None):
entry = clean_hello(hello, peer_ip)
reg = registered(data_dir)
key = entry["name"]
reg[key] = entry
sp.write_json(registry_path(data_dir), reg)
return entry
# --- this Mac --------------------------------------------------------------------------
def run(argv, timeout=TIMEOUT):
try:
return subprocess.run(argv, capture_output=True, text=True, timeout=timeout).stdout
except (OSError, subprocess.TimeoutExpired):
return ""
def wifi_ports():
out = run(["/usr/sbin/networksetup", "-listallhardwareports"])
ports, last = set(), ""
for line in out.splitlines():
if line.startswith("Hardware Port:"):
last = line.split(":", 1)[1].strip()
elif line.startswith("Device:") and last in ("Wi-Fi", "AirPort"):
ports.add(line.split(":", 1)[1].strip())
return ports
def interfaces():
"""{interface: (ip, network)} for every IPv4 address on this Mac, from ifconfig."""
out, cur = {}, None
for line in run(["/sbin/ifconfig"]).splitlines():
m = re.match(r"^(\S+?):", line)
if m:
cur = m.group(1)
continue
m = re.search(r"inet (\d+\.\d+\.\d+\.\d+) netmask 0x([0-9a-f]{8})", line)
if m and cur:
mask = ipaddress.ip_address(int(m.group(2), 16))
net = ipaddress.ip_network("%s/%s" % (m.group(1), mask), strict=False)
out.setdefault(cur, (m.group(1), str(net)))
return out
def gateway_of(interface):
out = run(["/usr/sbin/netstat", "-rn", "-f", "inet"])
for line in out.splitlines():
parts = line.split()
if len(parts) >= 4 and parts[0] == "default" and parts[-1] == interface:
return parts[1]
return ""
def this_mac():
"""Its names and addresses, and which network is the wired one: the interface with a gateway that
is neither Wi-Fi nor a link-local (Dante) address, preferring the one the default route uses."""
ifs = interfaces()
wifi = wifi_ports()
route = run(["/sbin/route", "-n", "get", "default"])
m = re.search(r"interface: (\S+)", route)
default_if = m.group(1) if m else ""
wired = None
for name in [default_if] + sorted(ifs):
if name in ifs and name not in wifi and not ifs[name][0].startswith(("127.", "169.254.")) and gateway_of(name):
wired = name
break
if wired is None:
wired = next((n for n in sorted(ifs) if n not in wifi and not ifs[n][0].startswith(("127.", "169.254."))), None)
info = {"name": run(["/usr/sbin/scutil", "--get", "ComputerName"]).strip(),
"hostname": (run(["/usr/sbin/scutil", "--get", "LocalHostName"]).strip() + ".local").lstrip("."),
"ips": [ip for n, (ip, _) in sorted(ifs.items()) if not ip.startswith("127.")]}
if wired:
ip, net = ifs[wired]
# A /24 or smaller on a campus usually sits inside a /16 the rest of the gear shares: use what is configured.
info["wired"] = {"interface": wired, "ip": ip, "net": net, "gateway": gateway_of(wired)}
return info
# --- Companion -------------------------------------------------------------------------
def companion_get(cfg, path, timeout=5):
c = cfg.get("companion") or {}
url = "http://%s:%s%s" % (c.get("host", "127.0.0.1"), c.get("port", 8000), path)
with urllib.request.urlopen(url, timeout=timeout) as r:
return r.read().decode("utf-8", "replace")
def companion_var(cfg, label, name):
try:
return companion_get(cfg, "/api/variable/%s/%s/value" % (urllib.parse.quote(label, safe=""), urllib.parse.quote(name, safe="")), 3)
except OSError:
return ""
def connection_hosts(cfg):
"""{label: address} from Companion's own database, opened read-only (the host field differs by module)."""
import sqlite3
path = sp.companion_db_path(cfg)
if not path:
return {}
db = sqlite3.connect("file:%s?mode=ro" % urllib.parse.quote(path), uri=True, timeout=2)
try:
rows = db.execute("SELECT value FROM instances").fetchall()
finally:
db.close()
out = {}
for (value,) in rows:
try:
inst = json.loads(value)
except (TypeError, ValueError):
continue
if not isinstance(inst, dict) or not inst.get("label"):
continue
conf = inst.get("config") or {}
host = next((str(conf[k]).strip() for k in HOST_FIELDS if isinstance(conf.get(k), str) and conf[k].strip()), "")
out[inst["label"]] = host.split("|")[-1].rsplit(":", 1)[0] if host.count(":") == 1 else host
return out
def connections(cfg):
raw = json.loads(companion_get(cfg, "/api/connections"))
items = raw if isinstance(raw, list) else list(raw.values()) if isinstance(raw, dict) else []
hosts = connection_hosts(cfg)
out = []
for c in items:
if not isinstance(c, dict) or not c.get("label"):
continue
entry = {"label": c["label"], "module": c.get("moduleId") or c.get("instance_type") or "", "enabled": c.get("enabled", True) is not False,
"host": hosts.get(c["label"], ""), "status": ((c.get("status") or {}).get("category") if isinstance(c.get("status"), dict) else "")}
if entry["module"] in ("shure-wireless", "shure-psm1000"):
probe = "ch_%d_battery_bars" if entry["module"] == "shure-wireless" else "ch_%d_rf_mute"
n = 0
for ch in range(1, 9):
if companion_var(cfg, c["label"], probe % ch).strip() in ("", "undefined"):
break
n = ch
entry["channels"] = n or None
out.append(entry)
return out
def dante(cfg, conns):
label = next((c["label"] for c in conns if c["module"] == "audinate-dantecontroller" and c["enabled"]), None)
if not label:
return {}
try:
devices = json.loads(companion_var(cfg, label, "devices") or "[]")
except ValueError:
devices = []
return {"conn": label, "devices": sorted(d for d in devices if isinstance(d, str))}
# --- the computers ---------------------------------------------------------------------
def sections(out):
parts, cur = {}, None
for line in out.splitlines():
line = line.strip()
if line.startswith("SP_") and line[3:].isupper():
cur = line[3:]
parts[cur] = []
elif cur:
parts[cur].append(line)
return parts
def look_at(cfg, alias, host):
"""What runs on one computer: its programs (and, on a Mac, its network MIDI sessions)."""
os_name = host.get("os", "mac")
try:
r = subprocess.run(sp.ssh_cmd(cfg, alias) + [MAC_LOOK if os_name == "mac" else WIN_LOOK], capture_output=True, text=True, timeout=TIMEOUT)
except subprocess.TimeoutExpired:
return {"reachable": False}
if r.returncode != 0 or "SP_END" not in r.stdout:
return {"reachable": False, "error": (r.stderr or r.stdout).strip()[-160:]}
got = sections(r.stdout)
out = {"reachable": True}
if os_name == "mac":
out["procs"] = sorted({os.path.basename(p.strip()) for p in got.get("PROCS", []) if p.strip()})
# Where each running program's app is, from its own path (".../SuperRack SoundGrid.app/Contents/MacOS/...").
out["paths"] = {os.path.basename(p.strip()): p.strip().split(".app/")[0] + ".app"
for p in got.get("PROCS", []) if ".app/Contents/MacOS/" in p}
out["apps"] = sorted(a.strip() for a in got.get("APPS", []) if a.strip().endswith(".app"))
try:
m = subprocess.run(sp.ssh_cmd(cfg, alias) + sp.remote_python("-"), input=sp.remote_midi_script(), capture_output=True, text=True, timeout=15)
out["midi"] = sp.parse_remote_midi(m.stdout) or {}
out["python"] = m.returncode != 97
if not out["python"]:
out["midi"] = sp.jxa_midi(cfg, alias) or {}
except subprocess.TimeoutExpired:
out["midi"] = {}
else:
out["procs"] = sorted({row.split('","')[0].strip('"') for row in got.get("PROCS", []) if row.startswith('"')})
out["tasks"] = sorted({row.split('","')[0].strip('"').split("\\")[-1] for row in got.get("TASKS", [])
if row.startswith('"\\StatusPanel\\')})
return out
def computers(cfg, data_dir):
"""The joined computers and the ones already in the settings, each looked at over SSH."""
found = {}
hosts = dict(cfg.get("hosts") or {})
for alias, h in hosts.items():
if h.get("self") or h.get("disabled") or not h.get("ssh"):
continue
ip = str(h["ssh"]).split("@")[-1]
found[alias] = {"name": h.get("name", alias), "os": h.get("os", "mac"), "user": str(h["ssh"]).split("@")[0], "ip": ip,
"ips": [ip], "macs": [h["mac"]] if h.get("mac") else []}
known_ips = {c["ip"] for c in found.values()}
taken = set(found) | set(hosts)
nets = [(this_mac().get("wired") or {}).get("net")]
for name, j in registered(data_dir).items():
ip = next((i for i in j["ips"] if any(n and ipaddress.ip_address(i) in ipaddress.ip_network(n) for n in nets)), j["ips"][0] if j["ips"] else None)
if not ip or ip in known_ips:
continue
from settings.autoconf import slug
alias = slug(j["name"].replace(" ", "_"), taken)
taken.add(alias)
found[alias] = {"name": j["name"], "hostname": j.get("hostname", ""), "os": j["os"], "user": j["user"], "ip": ip, "ips": j["ips"],
"macs": j.get("macs") or [], "tasks": j.get("tasks") or []}
probe_cfg = dict(cfg, hosts=dict(hosts, **{a: {"ssh": "%s@%s" % (c["user"], c["ip"]), "os": c["os"]} for a, c in found.items()}))
for alias, c in found.items():
seen = look_at(probe_cfg, alias, probe_cfg["hosts"][alias])
if c.get("tasks") and "tasks" in seen:
seen["tasks"] = sorted(set(seen["tasks"]) | set(c["tasks"]))
c.update(seen)
return found
# --- everything ------------------------------------------------------------------------
def look(cfg, data_dir):
inv = {"ingest": this_mac(), "at": int(time.time())}
# A campus whose MIDI rule names its wired network keeps it (it may be wider than this Mac's subnet).
rule = [n for t in sp.all_tiles(cfg) for c in t.get("checks", []) if c.get("type") == "midi_wired" for n in c.get("nets", [])]
if rule and inv["ingest"].get("wired"):
inv["ingest"]["wired"]["net"] = rule[0]
try:
inv["connections"] = connections(cfg)
except (OSError, ValueError) as e:
inv["connections"], inv["companion_error"] = [], str(e)
inv["dante"] = dante(cfg, inv["connections"])
try:
import coremidi
inv["midi"] = {"": coremidi.sessions()}
except Exception as e: # noqa: BLE001 - no CoreMIDI (tests, Linux): no sessions here
inv["midi"], inv["midi_error"] = {}, str(e)
inv["computers"] = computers(cfg, data_dir)
for alias, c in inv["computers"].items():
if c.get("midi"):
inv["midi"][alias] = c.pop("midi")
return inv