NA sees every campus; SSH from the admin laptop to every machine

Campuses set up from NA's install line tell NA where they are and learn nothing back (no campus
ever shows another). NA alone reads each campus's key states every two seconds and keeps two pages
in its Companion (added at the end, 98 and 99, since every other page is in use): CAMPUSES, a key
per campus (its ALL key, purple when it stops answering), and CAMPUS, the tapped campus's status
page drawn live in the same places, view only. Tried on NA with a test campus pointing at NA
itself, then removed.

tools/fleet.py: an Apple Remote Desktop command that turns on Remote Login, adds the laptop's key
and reports each Mac to NA, and the laptop SSH shortcuts made from those reports (tried on NA
Translation).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 14:57:18 -04:00
co-authored by Claude Opus 5.5
parent 3976667bba
commit d7dc931bd4
6 changed files with 562 additions and 5 deletions
+17
View File
@@ -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:
+9 -4
View File
@@ -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)
+354
View File
@@ -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_<id> / sp_hb_<id>, 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)
+34 -1
View File
@@ -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)
+78
View File
@@ -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")
+70
View File
@@ -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()