diff --git a/README.md b/README.md index 89d8e7c..78adfa7 100644 --- a/README.md +++ b/README.md @@ -260,6 +260,23 @@ device or MIDI link into its key). Never during the auto-fix hours or a recordin was there before and was taken off by someone is not added back (`discover-seen.json`). "Look now" adds what is new at once. Everything it makes is ordinary settings, changed on the page like North Attleboro's. +## Every campus on North Attleboro's deck (NA only) + +Each campus set up from NA's install line tells NA's settings page where it is (`/api/campus/hello`) and learns +nothing back: no campus ever shows another, and only NA (the hub: no "home" in `campuses.json`) does +(`settings/campuses.py`). NA's settings page reads each campus's key states every two seconds (`/api/state`) and +keeps two pages in NA's Companion, added at the end when no page is empty (today 98 and 99): + +- **CAMPUSES (98):** one key per campus, its ALL key with its name ("WS / ALL OK"); purple "WS ?" when it stops answering. Tap NA: NA's status page. Tap another: the CAMPUS page showing it. +- **CAMPUS (99):** the tapped campus's status page, same places, colours and words, live; view only (holds stay on that campus's own deck). Bottom-left goes back to CAMPUSES. + +## SSH from the admin laptop to every machine + +`python3 tools/fleet.py ard` prints a command for Apple Remote Desktop (Send UNIX Command, as root): on each Mac it +turns Remote Login on, lets the logged-in user take the laptop's key (`~/.ssh/id_ed25519_waters`) and reports to +NA's settings page (`/api/fleet/hello`). `python3 tools/fleet.py config` then writes `~/.ssh/config.d/waters`: +`ssh na-translation`, `ssh ws-tracks`... A Mac where nobody is logged in is skipped (run it again later). + ## One-time Companion setup 1. **Variables → Custom Variables:** add three variables. Leave the start values empty and "persist" off: diff --git a/settings/campus.py b/settings/campus.py index 74ff1b5..16527ab 100644 --- a/settings/campus.py +++ b/settings/campus.py @@ -153,9 +153,13 @@ def wait_applied(applier, limit=240): return applier.status or {} -def setup(data_dir, cid, name=None, say=print): +def setup(data_dir, cid, name=None, say=print, home=None): """The first setup of a campus (or, when it is set up already, a look for what is new).""" - from settings import server + from settings import server, campuses + if home: # where it was installed from: the campus list lives there + data = campuses.load(data_dir) + data["home"] = home + campuses.save(data_dir, data) site = server.Site(data_dir) cfg = site.load() if not is_fresh(cfg): @@ -335,7 +339,7 @@ if [[ -n "$SUPPORT" ]]; then fi zsh install.sh --site %(cid)s cd "$HOME/Library/Application Support/status-panel" -/usr/bin/python3 -m settings.campus setup --id %(cid)s --name %(qname)s +/usr/bin/python3 -m settings.campus setup --id %(cid)s --name %(qname)s --home "$SRC" if ! /usr/bin/nc -z -G 2 127.0.0.1 22 >/dev/null 2>&1; then echo echo "One more thing: turn on Remote Login (System Settings > General > Sharing), so North Attleboro can help remotely." @@ -411,9 +415,10 @@ def main(argv=None): ap.add_argument("--id", default="new") ap.add_argument("--name") ap.add_argument("--data-dir", default=PARENT) + ap.add_argument("--home", help="the settings page this campus was installed from (the campus list lives there)") a = ap.parse_args(argv) if a.what == "setup": - setup(a.data_dir, a.id, a.name) + setup(a.data_dir, a.id, a.name, home=a.home) elif a.what == "look": cfg = sp.load_config(os.path.join(a.data_dir, "config.json")) inv = discover.look(cfg, a.data_dir) diff --git a/settings/campuses.py b/settings/campuses.py new file mode 100644 index 0000000..136bcdb --- /dev/null +++ b/settings/campuses.py @@ -0,0 +1,354 @@ +"""Every campus's status, on North Attleboro's Companion only. + +Each other campus's settings page tells North Attleboro's (the hub: a campus with no "home") where it +is, and learns nothing back: no other campus ever sees another campus (2026-10-20, the user: "NA +should be the only one that sees every single location's status"). Only the hub, every two seconds, +copies each campus's key states (what that campus's watchdog draws, /api/state) into its own +Companion as custom variables sp_state_ / sp_hb_, and keeps two kinds of page there: + + CAMPUSES page one key per campus: that campus's ALL key, with its name; tap = its status page + CAMPUS page whichever campus was tapped, as its own status page looks (same places, same + colours, live), view only: holds and presses are for that campus's own deck. + The ALARMS corner goes back to the CAMPUSES page. + +Two pages, whatever the number of campuses (NA's Companion has 97 pages of its own; Companion stops +at 99): the CAMPUS page's keys are cells ("c_1_3"), and the hub fills them from the tapped campus's +layout (sp_view says which). A campus that stops answering goes purple. Pages are only written +where nothing a person made sits (push_page.safe_target): an empty page, else one added at the +end. State: campuses.json in the data folder. +""" +import gzip +import hashlib +import json +import os +import tempfile +import threading +import time +import urllib.request + +from settings import PARENT # noqa: F401 (puts the project folder on sys.path) +from settings import discover +import make_page +import push_page +import status_panel as sp + +FILE = "campuses.json" +POLL, REFRESH = 2.0, 60.0 +SHORT = {"na": "NA", "ab": "AB", "sc": "SC", "ws": "WS"} +MIRROR_MARK = "STATUS tile" # copied keys keep the tile marker, so a later rebuild may overwrite them + + +def load(data_dir): + try: + with open(os.path.join(data_dir, FILE)) as f: + return json.load(f) + except (OSError, ValueError): + return {} + + +def save(data_dir, data): + sp.write_json(os.path.join(data_dir, FILE), data) + + +def short(cid, name=""): + return SHORT.get(cid) or (name or cid)[:4].upper() + + +def my_url(cfg): + w = (discover.this_mac().get("wired") or {}).get("ip") or "127.0.0.1" + return "http://%s:%d" % (w, int((cfg.get("site") or {}).get("settings_port", 8020))) + + +def me(cfg, url=None): + site = cfg.get("site") or {} + return {"id": site.get("id") or "new", "name": site.get("name") or "This campus", "url": url or my_url(cfg)} + + +def layout_sha(cfg): + keys = [(t.get("id"), t.get("label"), t.get("pos"), t.get("kind"), t.get("members")) for t in cfg.get("tiles", [])] + return hashlib.sha256(json.dumps([keys, cfg.get("page_grid"), cfg.get("variables")], sort_keys=True, default=str).encode()).hexdigest()[:16] + + +def state_now(site): + """What /api/state answers: this campus's key states as its watchdog draws them, right now.""" + cfg = site.load() + names = cfg.get("variables") or {} + comp = site.comp + return {"id": (cfg.get("site") or {}).get("id"), "name": (cfg.get("site") or {}).get("name"), + "state": comp.get_custom(names.get("state", "sp_state")) or "", "hb": comp.get_custom(names.get("heartbeat", "sp_hb")) or "", + "layout": layout_sha(cfg), "time": int(time.time())} + + +def http_json(url, body=None, timeout=3): + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"} if data else {}, method="POST" if data else "GET") + with urllib.request.urlopen(req, timeout=timeout) as r: + return json.loads(r.read().decode("utf-8", "replace")) + + +# --- the pages (the hub only) ----------------------------------------------------------- + +VIEW = {"state": "sp_state_view", "heartbeat": "sp_hb_view"} +SELECT = "sp_view" +CELLS = [(r, c) for r in range(4) for c in range(1, 8)] + + +def cell_id(r, c): + return "c_%d_%d" % (r, c) + + +def view_state(state, keys): + """A campus's key states, moved onto the cells its keys sit in (empty cells blank).""" + out = {cell_id(r, c): {"x": "", "b": 0, "b2": 0, "f": 0xFFFFFF, "f2": 0xFFFFFF, "s": 14} for r, c in CELLS} + for k in keys: + r, c = k["pos"] + if (r, c) in CELLS and k["id"] in state: + out[cell_id(r, c)] = state[k["id"]] + return out + + +def build_view(local_cfg, page, overview_page): + """The CAMPUS page: a key per cell, drawn from sp_state_view; the ALARMS corner goes back.""" + opts = make_page.settings(local_cfg) + controls = make_page.nav_controls(local_cfg) + for r, c in CELLS: + layers = make_page.layers(cell_id(r, c), {}, VIEW, "''", opts) + make_page.place(controls, (r, c), { + "type": "button-layered", "style": {"layers": layers}, + "options": {"stepProgression": "auto", "stepExpression": "", "rotaryActions": False, "canModifyStyleInApis": True, + "notes": "STATUS tile 'campus view %d/%d' - the tapped campus's key here, view only (hold it on that campus's own deck)." % (r, c)}, + "feedbacks": [], "steps": {"0": {"action_sets": {"down": [], "up": []}, "options": {"runWhileHeld": []}}}, "localVariables": []}) + make_page.place(controls, (3, 0), make_page.static_button("campus_back", "ALL\\nCAMPUSES", 0x37474F, 0xFFFFFF, overview_page, + "Tap: back to the CAMPUSES page (page %d)." % overview_page, "STATUS jump key")) + return make_page.page_export("%d\\nCampus" % page, page, controls, local_cfg) + + +def select_action(cid): + return {"type": "action", "id": make_page.stable_id("campus_" + cid, "select"), "definitionId": "custom_variable_set_value", + "connectionId": "internal", "options": {"name": make_page.prop(SELECT), "value": make_page.prop(cid)}, "children": {}} + + +def campus_key(cid, name, names, actions, note, local_cfg): + """One CAMPUSES key: that campus's ALL key with its name on top.""" + opts = make_page.settings(local_cfg) + tag = short(cid, name) + stale_text = "`%s\\n?`" % tag + layers = make_page.layers("all", {}, names, stale_text, opts) + text = next(layer for layer in layers if layer["type"] == "text") + stale = "$(internal:time_unix) - $(custom:%s) > %d" % (names["heartbeat"], opts["stale"]) + text["text"] = make_page.prop("%s ? %s : `%s\\n${jsonpath($(custom:%s), '$.all.x')}`" % (stale, stale_text, tag, names["state"]), True) + text["fontsize"] = make_page.prop("auto") + return {"type": "button-layered", "style": {"layers": layers}, + "options": {"stepProgression": "auto", "stepExpression": "", "rotaryActions": False, "canModifyStyleInApis": True, + "notes": "STATUS tile 'campus %s' - %s's ALL key. %s" % (cid, name, note)}, + "feedbacks": [], "steps": {"0": {"action_sets": {"down": [], "up": actions}, "options": {"runWhileHeld": []}}}, + "localVariables": []} + + +def build_overview(local_cfg, campuses, page, view_page): + controls = make_page.nav_controls(local_cfg) + mine = (local_cfg.get("site") or {}).get("id") + for cid, cell in zip(sorted(campuses, key=lambda c: (c != mine, c)), CELLS): + name = campuses[cid].get("name", cid) + if cid == mine: + key = campus_key(cid, name, local_cfg["variables"], [make_page.set_page_action("campus_" + cid, "go", local_cfg["page"])], + "Tap: its status page (page %d)." % local_cfg["page"], local_cfg) + else: + key = campus_key(cid, name, {"state": "sp_state_" + cid, "heartbeat": "sp_hb_" + cid}, + [select_action(cid), make_page.set_page_action("campus_" + cid, "go", view_page)], + "Tap: its status page, live (page %d)." % view_page, local_cfg) + make_page.place(controls, cell, key) + return make_page.page_export("%d\\nCampuses" % page, page, controls, local_cfg) + + +def ensure_variables(cfg, ids): + from settings import campus + have, _ = campus.companion_state(cfg) + want = [n for cid in ids for n in ("sp_state_" + cid, "sp_hb_" + cid) if n not in have] + want += [n for n in (VIEW["state"], VIEW["heartbeat"], SELECT) if n not in have] + if not want: + return + comp = cfg.get("companion") or {} + ws = push_page.WebSocket(comp.get("host", "127.0.0.1"), int(comp.get("port", 8000)), "/trpc") + try: + api = push_page.Trpc(ws) + for n in want: + api.mutation("customVariables.create", {"name": n, "defaultVal": "0" if n.startswith("sp_hb_") else ""}) + finally: + ws.close() + + +def free_page(cfg, taken, start=21): + """The first page from `start` that holds nothing but Companion's own page keys.""" + comp = cfg.get("companion") or {} + host, port = comp.get("host", "127.0.0.1"), int(comp.get("port", 8000)) + for page in range(start, 100): + if page in taken: + continue + if not exists(host, port, page): + break + try: + export = push_page.fetch_page(host, port, page) + except (OSError, ValueError): + continue + ctrls = (export.get("page") or {}).get("controls") or {} + if not any(ctl and ctl.get("type") not in push_page.NAV_TYPES for row in ctrls.values() for ctl in (row or {}).values()): + return page + # None empty: a new page at the end (Companion's own page keys on it), never in between. + last = max(p for p in range(1, 100) if exists(host, port, p)) + if last >= 99: + raise RuntimeError("Companion has 99 pages and none is empty: free one for the campuses") + ws = push_page.WebSocket(host, port, "/trpc") + try: + push_page.Trpc(ws).mutation("pages.insert", {"asPageNumber": last + 1, "pageNames": ["CAMPUSES"]}) + finally: + ws.close() + return last + 1 + + +def exists(host, port, page): + try: + push_page.fetch_page(host, port, page) + return True + except (OSError, ValueError): + return False + + +def push(cfg, page, export): + comp = cfg.get("companion") or {} + host, port = comp.get("host", "127.0.0.1"), int(comp.get("port", 8000)) + ok, why = push_page.safe_target(push_page.fetch_page(host, port, page)) + if not ok: + raise RuntimeError("page %d: %s" % (page, why)) + with tempfile.NamedTemporaryFile(suffix=".companionconfig", delete=False) as f: + f.write(gzip.compress(json.dumps(export).encode())) + path = f.name + try: + push_page.push(host, port, page, path, export["page"]["name"]) + finally: + os.remove(path) + + +# --- the loops -------------------------------------------------------------------------- + +class Mirror: + def __init__(self, site): + self.site = site + self.remote = {} # id -> last /api/state answer + self.keys = {} # id -> [{"id", "pos"}] of its status page (from its /api/config) + + @staticmethod + def is_hub(data): + return not data.get("home") + + def sync_registry(self): + """A campus: tell the hub where this settings page is (it learns nothing back). The hub: know itself.""" + cfg = self.site.load() + data = load(self.site.data_dir) + mine = me(cfg) + home = data.get("home") + if home and home.rstrip("/") != mine["url"]: + try: + http_json(home.rstrip("/") + "/api/campus/hello", mine) + data.pop("error", None) + except (OSError, ValueError) as e: + data["error"] = "hub %s: %s" % (home, e) + data["list"] = {mine["id"]: mine} + else: + data.setdefault("list", {})[mine["id"]] = mine + save(self.site.data_dir, data) + return data + + def taken_pages(self, cfg): + t = {cfg.get("page"), (cfg.get("restart_page") or {}).get("page"), (cfg.get("ready_page") or {}).get("page")} + return {p for p in t if p} + + def refresh_pages(self): + """The hub: each campus's layout (when it changed), the CAMPUS page once, the CAMPUSES page when the roster changes.""" + cfg = self.site.load() + data = self.sync_registry() + if not self.is_hub(data): + return data + mine = (cfg.get("site") or {}).get("id") + others = {cid: e for cid, e in data.get("list", {}).items() if cid != mine} + if not others and not (data.get("pages") or {}).get("overview"): + return data # no other campus yet: no pages needed + pages = data.setdefault("pages", {}) + layouts = data.setdefault("layouts", {}) + for cid, e in sorted(others.items()): + st = self.remote.get(cid) + if st and (layouts.get(cid) != st.get("layout") or cid not in self.keys): + remote_cfg = http_json(e["url"].rstrip("/") + "/api/config", timeout=8)["config"] + self.keys[cid] = [{"id": t["id"], "pos": t["pos"]} for t in remote_cfg.get("tiles", []) if t.get("pos")] + layouts[cid] = st.get("layout") + ensure_variables(cfg, list(others)) + taken = self.taken_pages(cfg) | {p for p in pages.values() if p} + for which in ("overview", "view"): + if not pages.get(which): + pages[which] = free_page(cfg, taken) + taken.add(pages[which]) + built = layout_sha(cfg) + if data.get("view_built") != built: + push(cfg, pages["view"], build_view(cfg, pages["view"], pages["overview"])) + data["view_built"] = built + roster = sorted(data["list"]) + if data.get("overview_roster") != roster or data.get("overview_built") != built: + push(cfg, pages["overview"], build_overview(cfg, data["list"], pages["overview"], pages["view"])) + data["overview_roster"], data["overview_built"] = roster, built + data.pop("error", None) + save(self.site.data_dir, data) + return data + + def poll(self): + """The hub: copy each other campus's key states into this Companion.""" + cfg = self.site.load() + data = load(self.site.data_dir) + if not self.is_hub(data): + return + mine = (cfg.get("site") or {}).get("id") + others = {cid: e for cid, e in (data.get("list") or {}).items() if cid != mine and e.get("url")} + threads = [] + + def one(cid, url): + try: + st = http_json(url.rstrip("/") + "/api/state", timeout=1.5) + except (OSError, ValueError): + return # not answering: its heartbeat goes stale, its keys purple + self.remote[cid] = st + if (data.get("pages") or {}).get("overview"): + self.site.comp.set_custom("sp_state_" + cid, st.get("state") or "{}") + self.site.comp.set_custom("sp_hb_" + cid, str(st.get("hb") or "0")) + for cid, e in others.items(): + t = threading.Thread(target=one, args=(cid, e["url"]), daemon=True) + t.start() + threads.append(t) + for t in threads: + t.join(2) + # The CAMPUS page shows the tapped campus: its keys moved onto their cells. + pick = (self.site.comp.get_custom(SELECT) or "").strip() + st = self.remote.get(pick) + if st and pick in self.keys and (data.get("pages") or {}).get("view"): + try: + state = json.loads(st.get("state") or "{}") + except ValueError: + state = {} + self.site.comp.set_custom(VIEW["state"], json.dumps(view_state(state, self.keys[pick]), separators=(",", ":"))) + self.site.comp.set_custom(VIEW["heartbeat"], str(st.get("hb") or "0")) + + +def loop(site, stop): + m = Mirror(site) + site.mirror = m + last = 0.0 + stop.wait(15) + while not stop.is_set(): + try: + m.poll() + if time.time() - last > REFRESH: + last = time.time() + m.refresh_pages() + except Exception as e: # noqa: BLE001 - the page must keep running + data = load(site.data_dir) + data["error"] = str(e)[:300] + save(site.data_dir, data) + stop.wait(POLL) diff --git a/settings/server.py b/settings/server.py index 3eb96c5..5ccacdc 100644 --- a/settings/server.py +++ b/settings/server.py @@ -40,7 +40,7 @@ from settings import builders from settings import scripts from settings import outlet as outlets from settings import keyguide, probes -from settings import campus, discover +from settings import campus, campuses, discover log = logging.getLogger("status-settings") UI = os.path.join(HERE, "ui") @@ -696,6 +696,15 @@ class Handler(http.server.BaseHTTPRequestHandler): return self.send(200, campus.join_mac(site.load(), self.base_url()), "text/plain; charset=utf-8") if path == "/join.ps1": return self.send(200, campus.join_windows(site.load(), self.base_url()), "text/plain; charset=utf-8") + if path == "/api/state": + return self.send(200, campuses.state_now(site)) + if path == "/api/campuses": + # The hub's own view (its settings page). A campus only ever lists itself. + data = campuses.load(site.data_dir) + return self.send(200, {"list": data.get("list") or {}, "pages": data.get("pages") or {} if not data.get("home") else {}, + "error": data.get("error"), "hub": not data.get("home")}) + if path == "/api/fleet": + return self.send(200, {"machines": list((site.read_json("fleet.json", {}) or {}).values())}) if path == "/api/discover": cfg = site.load() base = self.base_url() @@ -873,6 +882,29 @@ class Handler(http.server.BaseHTTPRequestHandler): log.info("joined: %s (%s, %s) from %s", entry["name"], entry["os"], entry["user"], self.client_address[0]) site.discover_poke.set() return self.send(200, {"ok": True}) + if path == "/api/campus/hello": + # Another campus says where its settings page is (the hub keeps the list for everyone). + cid = str(body.get("id") or "") + url = str(body.get("url") or "") + if not re.fullmatch(r"[a-z0-9]{1,8}", cid) or not re.fullmatch(r"http://\d{1,3}(\.\d{1,3}){3}:\d{2,5}", url) \ + or not allowed_client(url[7:].split(":")[0]): + return self.send(400, {"error": "an id like ws and an address like http://10.5.1.1:8020"}) + data = campuses.load(site.data_dir) + data.setdefault("list", {})[cid] = {"id": cid, "name": str(body.get("name") or cid)[:40], "url": url} + campuses.save(site.data_dir, data) + return self.send(200, {"ok": True}) + if path == "/api/fleet/hello": + # A machine anywhere (any campus) says it now takes the admin laptop's key: kept for the + # laptop's SSH shortcuts (tools/fleet.py). Nothing here acts on it. + try: + entry = discover.clean_hello(body, self.client_address[0]) + except (ValueError, TypeError) as e: + return self.send(400, {"error": str(e)}) + fleet = site.read_json("fleet.json", {}) or {} + fleet["%s|%s" % (entry["name"], (entry["ips"] or ["?"])[0])] = entry + sp.write_json(os.path.join(site.data_dir, "fleet.json"), fleet) + log.info("fleet: %s (%s, %s) from %s", entry["name"], entry["os"], entry["user"], self.client_address[0]) + return self.send(200, {"ok": True}) if path == "/api/editing/check": return self.send(200, site.check_code(self.client_address[0], str(body.get("code", "")))) if path == "/api/machines/probe": @@ -1444,6 +1476,7 @@ def main(argv=None): stop = threading.Event() threading.Thread(target=rebind_loop, args=(site, servers, port, stop), daemon=True).start() threading.Thread(target=campus.loop, args=(site, stop, site.discover_poke), name="auto-setup", daemon=True).start() + threading.Thread(target=campuses.loop, args=(site, stop), name="campuses", daemon=True).start() try: while True: time.sleep(3600) diff --git a/tests/test_campus.py b/tests/test_campus.py index 82b10ba..385002f 100644 --- a/tests/test_campus.py +++ b/tests/test_campus.py @@ -245,3 +245,81 @@ class TestRoutes(unittest.TestCase): if __name__ == "__main__": unittest.main() + + +class TestCampusesView(unittest.TestCase): + """North Attleboro alone shows every campus: a copy of each status page (view only) and a CAMPUSES page.""" + + def test_the_campus_page_is_view_only_cells_with_a_way_back(self): + from settings import campuses as cv + import push_page + na = json.load(open(os.path.join(ROOT, "config.json"))) + export = cv.build_view(na, 99, 98) + ctrls = export["page"]["controls"] + self.assertEqual(export["page"]["name"], "99\\nCampus") + self.assertIn("CAMPUSES", json.dumps(ctrls["3"]["0"])) # where ALARMS sits on every status page + cells = [ctl for row in ctrls.values() for ctl in row.values() if "campus view" in (ctl.get("options") or {}).get("notes", "")] + self.assertEqual(len(cells), 28) + self.assertTrue(all(c["steps"]["0"]["action_sets"] == {"down": [], "up": []} for c in cells)) # no press, no hold + self.assertIn("sp_state_view", json.dumps(cells[0]["style"])) + self.assertTrue(push_page.safe_target(export)[0]) # a rebuild may overwrite it + + def test_a_campus_state_lands_on_its_cells(self): + from settings import campuses as cv + ws, _ = ac.build(BASE, small(), {"id": "ws", "name": "Woonsocket"}, "1234") + keys = [{"id": t["id"], "pos": t["pos"]} for t in ws["tiles"] if t.get("pos")] + out = cv.view_state({"hd1": {"x": "HD1\nREC", "b": 1}, "all": {"x": "ALL OK"}}, keys) + hd1 = next(k for k in keys if k["id"] == "hd1") + self.assertEqual(out[cv.cell_id(*hd1["pos"])]["x"], "HD1\nREC") + self.assertEqual(out["c_0_1"]["x"], "ALL OK") + self.assertEqual(out["c_2_7"]["x"], "") # nothing there: blank + + def test_the_campuses_page_puts_this_campus_first_and_taps_to_each(self): + from settings import campuses as cv + na = json.load(open(os.path.join(ROOT, "config.json"))) + roster = {"na": {"name": "North Attleboro"}, "ws": {"name": "Woonsocket"}, "ab": {"name": "Apollo Beach"}} + export = cv.build_overview(na, roster, 98, 99) + keys = [(r, c, ctl) for r, row in export["page"]["controls"].items() for c, ctl in row.items() if "'campus " in (ctl.get("options") or {}).get("notes", "")] + self.assertEqual([(r, c) for r, c, _ in keys], [("0", "1"), ("0", "2"), ("0", "3")]) + self.assertIn("'campus na'", keys[0][2]["options"]["notes"]) + self.assertIn("page 19", keys[0][2]["options"]["notes"]) + ws_key = next(k for _, _, k in keys if "'campus ws'" in k["options"]["notes"]) + acts = ws_key["steps"]["0"]["action_sets"]["up"] + self.assertEqual([a["definitionId"] for a in acts], ["custom_variable_set_value", "set_page"]) + self.assertEqual((acts[0]["options"]["value"]["value"], acts[1]["options"]["page"]["value"]), ("ws", 99)) + + def test_a_campus_tells_the_hub_and_learns_nothing_back(self): + from settings import campuses as cv + data_dir = tempfile.mkdtemp(prefix="sp-cv-") + cv.save(data_dir, {"home": "http://10.1.1.1:8020", "list": {"na": {"url": "x"}, "ab": {"url": "y"}}}) + + class FakeSite: + def __init__(self): + self.data_dir = data_dir + + def load(self): + return {"site": {"id": "ws", "name": "Woonsocket"}} + sent = [] + real = cv.http_json + cv.http_json = lambda url, body=None, timeout=3: sent.append((url, body)) or {"ok": True} + real_url = cv.my_url + cv.my_url = lambda cfg: "http://10.5.1.1:8020" + try: + m = cv.Mirror(FakeSite()) + data = m.sync_registry() + m.poll() + self.assertEqual(m.refresh_pages()["list"], {"ws": {"id": "ws", "name": "Woonsocket", "url": "http://10.5.1.1:8020"}}) + finally: + cv.http_json, cv.my_url = real, real_url + self.assertEqual(list(data["list"]), ["ws"]) # the others are forgotten + self.assertTrue(all(u.endswith("/api/campus/hello") for u, _ in sent)) # it only ever says hello + + +class TestCampusHello(TestRoutes): + def test_campus_hello_is_checked(self): + self.assertEqual(get(self.port, "/api/campus/hello", {"id": "ws", "name": "Woonsocket", "url": "http://10.5.1.1:8020"})[0], 200) + for bad in ({"id": "WS; rm", "url": "http://10.5.1.1:8020"}, {"id": "ws", "url": "http://evil.example.com:80"}, + {"id": "ws", "url": "http://8.8.8.8:8020"}): + self.assertEqual(get(self.port, "/api/campus/hello", bad)[0], 400, bad) + from settings import campuses as cv + self.assertEqual(cv.load(self.data)["list"]["ws"]["url"], "http://10.5.1.1:8020") diff --git a/tools/fleet.py b/tools/fleet.py new file mode 100644 index 0000000..ad32bdc --- /dev/null +++ b/tools/fleet.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""SSH from the admin laptop to every machine on every campus. + + python3 tools/fleet.py ard the command to paste into Apple Remote Desktop (Send UNIX Command, as root) + python3 tools/fleet.py config write ~/.ssh/config.d/waters from the machines that reported back + +The ARD command, on each Mac: turns on Remote Login, lets the logged-in user's account take this +laptop's key (~/.ssh/id_ed25519_waters), and reports name, user and addresses to North Attleboro's +settings page (/api/fleet/hello). `config` turns that list into shortcuts: ssh na-tracks-mac. +""" +import json +import os +import re +import sys +import urllib.request + +HUB = os.environ.get("FLEET_HUB", "http://10.1.1.1:8020") +KEY = os.path.expanduser("~/.ssh/id_ed25519_waters") +OUT = os.path.expanduser("~/.ssh/config.d/waters") + + +def pubkey(): + return open(KEY + ".pub").read().strip() + + +def ard(): + key = pubkey() + return r"""U=$(stat -f%Su /dev/console); if [ "$U" = root ] || [ -z "$U" ]; then echo "SKIPPED $(scutil --get ComputerName): nobody is logged in"; exit 0; fi +H=$(dscl . -read "/Users/$U" NFSHomeDirectory | awk '{print $2}'); KEY='""" + key + r"""' +mkdir -p "$H/.ssh" && touch "$H/.ssh/authorized_keys" && (grep -qF "$KEY" "$H/.ssh/authorized_keys" || echo "$KEY" >> "$H/.ssh/authorized_keys") && chown -R "$U:staff" "$H/.ssh" && chmod 700 "$H/.ssh" && chmod 600 "$H/.ssh/authorized_keys" +nc -z -G 2 127.0.0.1 22 >/dev/null 2>&1 || systemsetup -setremotelogin on >/dev/null 2>&1 || launchctl load -w /System/Library/LaunchDaemons/ssh.plist >/dev/null 2>&1 +dseditgroup -o read com.apple.access_ssh >/dev/null 2>&1 && dseditgroup -o edit -a "$U" -t user com.apple.access_ssh >/dev/null 2>&1 +IPS=$(ifconfig | awk '/inet /{print $2}' | grep -v '^127\.' | sed 's/.*/"&"/' | paste -sd, -); N=$(scutil --get ComputerName | tr -d '"\\') +curl -s -m 5 -X POST -H 'Content-Type: application/json' -d "{\"user\":\"$U\",\"name\":\"$N\",\"hostname\":\"$(scutil --get LocalHostName).local\",\"os\":\"mac\",\"ips\":[$IPS]}" """ + HUB + r"""/api/fleet/hello >/dev/null +echo "OK $N user=$U ssh=$(nc -z -G 2 127.0.0.1 22 >/dev/null 2>&1 && echo on || echo OFF)" +""" + + +def slug(s): + s = re.sub(r"\s*\(\d+\)$", "", s) # macOS's "Name (2)" after a rename clash + return re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-") or "mac" + + +def config(): + with urllib.request.urlopen(HUB + "/api/fleet", timeout=10) as r: + machines = json.load(r)["machines"] + wired = lambda ip: not ip.startswith(("10.6.", "169.254.", "127.")) + lines, used = ["# Written by tools/fleet.py from %s/api/fleet - every machine that took this laptop's key." % HUB], set() + for m in sorted(machines, key=lambda m: m["name"].lower()): + ip = next((i for i in m["ips"] if wired(i)), m["ips"][0] if m["ips"] else None) + if not ip: + continue + alias = slug(m["name"]) + while alias in used: + alias += "-2" + used.add(alias) + lines += ["", "Host %s" % alias, " HostName %s" % ip, " User %s" % m["user"], + " IdentityFile %s" % KEY, " IdentitiesOnly yes", " ConnectTimeout 8"] + os.makedirs(os.path.dirname(OUT), exist_ok=True) + open(OUT, "w").write("\n".join(lines) + "\n") + cfg = os.path.expanduser("~/.ssh/config") + text = open(cfg).read() if os.path.exists(cfg) else "" + if "Include ~/.ssh/config.d/*" not in text: + open(cfg, "w").write("Include ~/.ssh/config.d/*\n\n" + text) + print("%d machines -> %s: %s" % (len(used), OUT, ", ".join(sorted(used)))) + + +if __name__ == "__main__": + what = (sys.argv[1:] or ["ard"])[0] + print(ard()) if what == "ard" else config()