From 9838ce9292d2e524407f64d0c5f3b38e510e47e7 Mon Sep 17 00:00:00 2001 From: christian Date: Thu, 8 Oct 2026 13:09:13 -0400 Subject: [PATCH] Add netgear.py: read the Dante switches' MAC table and PoE status. The Dante network runs on two Netgear M4250 AV switches (10.66.1.100 M4250-8G2XF-PoE+, 10.66.1.102 M4250-26G4XF-PoE+), managed by Engage from NA Ingest. netgear.py logs in over their CLI with the login stored in NA Ingest's login keychain (service netgear-status-panel) and, read-only, finds which port a MAC is on and what PoE a port is delivering. The Studio B AVIOs and talkback are on 10.66.1.102 ports 0/14-0/17. Co-Authored-By: Claude Opus 5.5 --- netgear.py | 111 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 netgear.py diff --git a/netgear.py b/netgear.py new file mode 100644 index 0000000..fdd28f6 --- /dev/null +++ b/netgear.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Netgear AV (M4250-class) switches on the Dante network, over their CLI (telnet). + +The switch login is read from NA Ingest's login keychain (service +"netgear-status-panel", the account is the username) and is never printed. Store +it once in Terminal on NA Ingest (it asks for the password twice): + security add-generic-password -s netgear-status-panel -a admin -w +A login saved in the Passwords app cannot be read by scripts. The keychain is only +unlocked inside the logged-in session, so run this from a LaunchAgent or a +Terminal there, not over SSH. + + python3 netgear.py lookup 00:1d:c1:51:18:84 ... which switch / port each MAC is on (read-only) +""" +import re +import subprocess +import sys +import telnetlib # stdlib in the macOS python3 (3.9) + +SWITCHES = ["10.66.1.100", "10.66.1.102"] +KEYCHAIN_SERVICE = "netgear-status-panel" + + +def credentials(service=KEYCHAIN_SERVICE): + attrs = subprocess.run(["/usr/bin/security", "find-generic-password", "-s", service], + capture_output=True, text=True).stdout + m = re.search(r'"acct"="([^"]*)"', attrs) + password = subprocess.run(["/usr/bin/security", "find-generic-password", "-s", service, "-w"], + capture_output=True, text=True).stdout.rstrip("\n") + if not m or not password: + raise RuntimeError('keychain item "%s" not readable here (not stored yet, locked keychain, ' + 'or no access)' % service) + return m.group(1), password + + +class Switch: + PROMPT = re.compile(rb"[>#]\s*$") + + def __init__(self, host, user, password, timeout=8): + self.t = telnetlib.Telnet(host, 23, timeout) + self.timeout = timeout + self.t.read_until(b"User:", timeout) + self.t.write(user.encode() + b"\n") + self.t.read_until(b"Password:", timeout) + self.t.write(password.encode() + b"\n") + _, m, text = self.t.expect([self.PROMPT, rb"(?i)fail|denied|incorrect"], timeout) + if m is None or not self.PROMPT.search(m.group(0)): + raise RuntimeError("%s: login failed" % host) + if text.rstrip().endswith(b">"): + self.t.write(b"enable\n") + idx, _, _ = self.t.expect([rb"Password:", self.PROMPT], timeout) + if idx == 0: + self.t.write(b"\n") # blank enable password + self.t.expect([self.PROMPT], timeout) + self.run("terminal length 0") + + def run(self, command): + self.t.write(command.encode() + b"\n") + _, _, text = self.t.expect([self.PROMPT], self.timeout) + lines = text.decode("ascii", "replace").splitlines() + return "\n".join(lines[1:-1]) # drop the echoed command and the prompt + + def close(self): + try: + self.t.write(b"logout\n") + finally: + self.t.close() + + +def lookup(macs): + user, password = credentials() + wanted = {m.lower(): m for m in macs} + for host in SWITCHES: + sw = Switch(host, user, password) + try: + model = [l.strip() for l in sw.run("show version").splitlines() + if re.search(r"(?i)machine model|system description", l)] + table = sw.run("show mac-addr-table") + print("== %s %s" % (host, " | ".join(model))) + hits = 0 + for line in table.splitlines(): + mm = re.search(r"([0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5})\s+(\S+)", line) + if mm and mm.group(1).lower() in wanted: + print(" %s on port %s (%s)" % (mm.group(1).lower(), mm.group(2), line.strip())) + hits += 1 + print(" %d of the MACs here; %d entries in the table" % + (hits, sum(1 for l in table.splitlines() if re.search(r"(?:[0-9A-Fa-f]{2}:){5}", l)))) + finally: + sw.close() + + +def poe_status(host, ports): + """Read-only: what the switch reports for PoE on these ports.""" + user, password = credentials() + sw = Switch(host, user, password) + try: + for port in ports: + print("== %s port %s" % (host, port)) + for line in sw.run("show poe port info %s" % port).splitlines(): + if line.strip(): + print(" " + line.rstrip()) + finally: + sw.close() + + +if __name__ == "__main__": + if len(sys.argv) >= 3 and sys.argv[1] == "lookup": + lookup(sys.argv[2:]) + elif len(sys.argv) >= 4 and sys.argv[1] == "poe-status": + poe_status(sys.argv[2], sys.argv[3:]) + else: + sys.exit(__doc__)