PC set-up: one line per PC (/setup/<name>.ps1), the admin laptop always let in, and the admin check no longer breaks the script
- the inserted administrator check split the header comment, leaving '} for ... run ONCE' as code: WS and SC
join.ps1 failed to parse (Windows' parser: Missing opening '(' after keyword 'for'); now after the whole line
- site.admin_ips / admin_keys (laptop 10.6.7.38, fixed in UniFi) go into every PC script's firewall rule and key
files, and into the Mac join line, so running a set-up again never shuts the laptop out
- scripts verified by Windows' own PowerShell parser on the NA Lighting PC
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+7
-1
@@ -1382,7 +1382,13 @@
|
||||
],
|
||||
"ingest_name": "NA Ingest",
|
||||
"live_services": [],
|
||||
"name": "North Attleboro"
|
||||
"name": "North Attleboro",
|
||||
"admin_ips": [
|
||||
"10.6.7.38"
|
||||
],
|
||||
"admin_keys": [
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPKCGDLncJQfNPpoZUy/5BQWGNtyaU1/xKXwHQWx3ETa christian-laptop@waters"
|
||||
]
|
||||
},
|
||||
"spotify": {
|
||||
"name": "Christian EDM 2026",
|
||||
|
||||
@@ -60,6 +60,12 @@ $userKeys = Join-Path $userSsh 'authorized_keys'
|
||||
if (-not (Test-Path $userKeys) -or -not (Select-String -Path $userKeys -SimpleMatch $KeyMark -Quiet)) {
|
||||
Add-Content -Path $userKeys -Value $Key -Encoding ascii
|
||||
}
|
||||
# The admin laptop's key too (it is let through the firewall above as well).
|
||||
foreach ($k in @({{ADMIN_KEYS}})) {
|
||||
foreach ($f in @($adminKeys, $userKeys)) {
|
||||
if (-not (Select-String -Path $f -SimpleMatch $k -Quiet)) { Add-Content -Path $f -Value $k -Encoding ascii }
|
||||
}
|
||||
}
|
||||
|
||||
# 3. Start tasks for the apps found on this PC (they run on this user's desktop)
|
||||
function Find-First($candidates) {
|
||||
|
||||
@@ -32,6 +32,10 @@ def field(path, type, default=None, help="", screen="", advanced=False, secret=F
|
||||
# -- site --
|
||||
field("site", "dict", {}, "This location: its name, this Mac, and when changes must wait.", "Site")
|
||||
field("site.id", "id", "na", "Short name used for folders and files.", "Site", advanced=True, regex=r"[a-z0-9_-]+")
|
||||
field("site.admin_ips", "list", [], "The admin laptop's fixed addresses: let through the Windows PCs' SSH firewall with this Mac.",
|
||||
"Site", advanced=True, apply="none")
|
||||
field("site.admin_keys", "list", [], "The admin laptop's public SSH keys: every PC and Mac set up from here takes them too.",
|
||||
"Site", advanced=True, apply="none")
|
||||
field("site.name", "str", "", "The location, as people say it.", "Site", example="North Attleboro", apply="none")
|
||||
field("site.ingest_name", "str", "NA Ingest", "What this Mac is called in logs, notes and alerts.", "Site", apply="pages")
|
||||
field("site.ingest_hostname", "str", "", "This Mac's network name, used in the page address and by the page tools.",
|
||||
|
||||
+4
-5
@@ -555,6 +555,7 @@ if [ "$(id -u)" = 0 ]; then
|
||||
fi
|
||||
mkdir -p "$H/.ssh" && touch "$H/.ssh/authorized_keys" && chmod 700 "$H/.ssh" && chmod 600 "$H/.ssh/authorized_keys"
|
||||
grep -qF "$KEY" "$H/.ssh/authorized_keys" || echo "$KEY" >> "$H/.ssh/authorized_keys"
|
||||
for A in %(admin)s; do grep -qF "$A" "$H/.ssh/authorized_keys" || echo "$A" >> "$H/.ssh/authorized_keys"; done
|
||||
[ "$(id -u)" = 0 ] && chown -R "$U:staff" "$H/.ssh"
|
||||
[ "$(id -u)" = 0 ] && { /usr/sbin/systemsetup -setremotelogin on >/dev/null 2>&1 || /bin/launchctl load -w /System/Library/LaunchDaemons/ssh.plist >/dev/null 2>&1; }
|
||||
if ! /usr/bin/nc -z -G 2 127.0.0.1 22 >/dev/null 2>&1; then
|
||||
@@ -574,7 +575,8 @@ else
|
||||
echo "Could not reach %(ingest)s at %(base)s - is this Mac on the wired network?"
|
||||
exit 1
|
||||
fi
|
||||
""" % {"ingest": ingest, "key": shq(key), "hello": shq(base + "/api/hello"), "base": base}
|
||||
""" % {"ingest": ingest, "key": shq(key), "hello": shq(base + "/api/hello"), "base": base,
|
||||
"admin": " ".join(shq(k) for k in scripts.admin_keys(cfg))}
|
||||
|
||||
|
||||
def join_windows(cfg, base):
|
||||
@@ -591,10 +593,7 @@ def join_windows(cfg, base):
|
||||
raise RuntimeError("the Windows template changed: cannot put the program finder in")
|
||||
text = text.replace("# Status panel remote access", "# Run in PowerShell as administrator: irm %s/join.ps1 | iex\n# Status panel remote access" % base, 1)
|
||||
# Not elevated: say how, before anything runs (SC's Lighting PC got a DISM error instead, 2026-10-21).
|
||||
text = text.replace("# Status panel remote access", """# Status panel remote access
|
||||
if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||
throw 'Run this as administrator: close this window, right-click Windows PowerShell > Run as administrator, then paste the line again. Nothing was changed.'
|
||||
}""", 1)
|
||||
text = scripts.with_admin_check(text)
|
||||
text += """
|
||||
# 5. Tell %(ingest)s this PC is here: it sets it up within a minute.
|
||||
$hello = @{ user = $env:USERNAME; name = $env:COMPUTERNAME; os = 'windows'
|
||||
|
||||
+29
-1
@@ -44,15 +44,43 @@ def ps_quote(value, what):
|
||||
return "'" + value + "'"
|
||||
|
||||
|
||||
def admin_keys(cfg):
|
||||
"""The admin laptop's SSH keys (site.admin_keys), checked: one line each, no quotes."""
|
||||
out = []
|
||||
for k in (cfg.get("site") or {}).get("admin_keys") or []:
|
||||
k = str(k).strip()
|
||||
if not re.fullmatch(r"ssh-(ed25519|rsa) [A-Za-z0-9+/=]+( [A-Za-z0-9@._-]+)?", k):
|
||||
raise ValueError("site.admin_keys: %r is not one public SSH key line" % k[:40])
|
||||
out.append(k)
|
||||
return out
|
||||
|
||||
|
||||
def site_values(cfg):
|
||||
site = cfg.get("site") or {}
|
||||
key, mark = public_key(cfg)
|
||||
# The admin laptop (site.admin_ips / admin_keys) gets in everywhere this Mac does, so running a set-up script
|
||||
# again never shuts it out (2026-10-21, the user: "keep SSH on at all times for windows pc, just like the macs").
|
||||
ips = list(dict.fromkeys(list(site.get("ingest_ips") or []) + list(site.get("admin_ips") or [])))
|
||||
return {"INGEST_NAME": site.get("ingest_name") or "the Ingest Mac",
|
||||
"INGEST_IPS": ", ".join("'%s'" % ip for ip in site.get("ingest_ips") or []) or "'127.0.0.1'",
|
||||
"INGEST_IPS": ", ".join("'%s'" % ip for ip in ips if re.fullmatch(r"[0-9a-fA-F.:]+", str(ip))) or "'127.0.0.1'",
|
||||
"ADMIN_KEYS": ", ".join("'%s'" % k for k in admin_keys(cfg)),
|
||||
"KEY": key or "<< this Mac has no login key yet: run install.sh, then come back >>",
|
||||
"KEY_COMMENT": mark or "status-panel", "DVS_HELPER": site.get("dvs_helper") or "/usr/local/sbin/status-panel-dvs-start"}
|
||||
|
||||
|
||||
ADMIN_CHECK = """if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||
throw 'Run this as administrator: close this window, right-click Windows PowerShell > Run as administrator, then paste the line again. Nothing was changed.'
|
||||
}"""
|
||||
|
||||
|
||||
def with_admin_check(text):
|
||||
"""The script stops with plain words unless PowerShell runs as administrator: the check goes after the
|
||||
whole header comment line (inserting it mid-line left "} for ... - run ONCE" as code, 2026-10-21)."""
|
||||
lines = text.split("\n")
|
||||
at = next((i for i, ln in enumerate(lines) if ln.startswith("# Status panel remote access")), 0)
|
||||
return "\n".join(lines[:at + 1] + [ADMIN_CHECK] + lines[at + 1:])
|
||||
|
||||
|
||||
def windows_setup(cfg, alias):
|
||||
host = cfg["hosts"][alias]
|
||||
tasks = [r for r in host.get("runs") or [] if r.get("kind") == "task"]
|
||||
|
||||
@@ -716,6 +716,17 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
return
|
||||
if path == "/join":
|
||||
return self.send(200, campus.join_mac(site.load(), self.base_url()), "text/plain; charset=utf-8")
|
||||
m = re.fullmatch(r"/setup/([A-Za-z0-9_]+)\.ps1", path)
|
||||
if m:
|
||||
# One computer's set-up script, for "irm http://<this Mac>:8020/setup/<name>.ps1 | iex" in an
|
||||
# administrator PowerShell on that PC (2026-10-21: the Bible TV PC's SSH had stopped).
|
||||
cfg = site.load()
|
||||
h = (cfg.get("hosts") or {}).get(m.group(1))
|
||||
if not h or h.get("os") != "windows":
|
||||
return self.send(404, {"error": "no Windows computer called %s here" % m.group(1)})
|
||||
text = "# Run in PowerShell as administrator: irm %s/setup/%s.ps1 | iex\n%s" % (
|
||||
self.base_url(), m.group(1), scripts.with_admin_check(scripts.windows_setup(cfg, m.group(1))))
|
||||
return self.send(200, text, "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":
|
||||
|
||||
@@ -12,7 +12,13 @@
|
||||
],
|
||||
"contacts": "",
|
||||
"live_services": [],
|
||||
"editing_code": "5690"
|
||||
"editing_code": "5690",
|
||||
"admin_ips": [
|
||||
"10.6.7.38"
|
||||
],
|
||||
"admin_keys": [
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPKCGDLncJQfNPpoZUy/5BQWGNtyaU1/xKXwHQWx3ETa christian-laptop@waters"
|
||||
]
|
||||
},
|
||||
"companion": {
|
||||
"host": "127.0.0.1",
|
||||
|
||||
@@ -340,7 +340,13 @@
|
||||
],
|
||||
"ingest_name": "WS ProPresenter",
|
||||
"live_services": [],
|
||||
"name": "Woonsocket"
|
||||
"name": "Woonsocket",
|
||||
"admin_ips": [
|
||||
"10.6.7.38"
|
||||
],
|
||||
"admin_keys": [
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPKCGDLncJQfNPpoZUy/5BQWGNtyaU1/xKXwHQWx3ETa christian-laptop@waters"
|
||||
]
|
||||
},
|
||||
"tiles": [
|
||||
{
|
||||
|
||||
@@ -627,3 +627,43 @@ class TestLongerHolds(unittest.TestCase):
|
||||
self.assertEqual(pages, {"down": [], "up": [1], "2000": [19], "3000": [98]})
|
||||
self.assertEqual(st["options"]["runWhileHeld"], [2000, 3000])
|
||||
self.assertEqual(schema.validate(cfg)[0], [])
|
||||
|
||||
|
||||
class TestPcScripts(unittest.TestCase):
|
||||
"""The PC scripts: the admin check after the whole header line, the admin laptop let in, one line per PC."""
|
||||
|
||||
def cfg(self):
|
||||
cfg = json.load(open(os.path.join(ROOT, "config.json")))
|
||||
cfg["site"]["admin_ips"] = ["10.6.7.38"]
|
||||
cfg["site"]["admin_keys"] = ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITESTKEY christian-laptop@waters"]
|
||||
return cfg
|
||||
|
||||
def test_the_admin_check_never_splits_a_line(self):
|
||||
from settings import scripts
|
||||
text = scripts.with_admin_check(scripts.windows_setup(self.cfg(), "bibletv"))
|
||||
lines = text.split("\n")
|
||||
i = next(i for i, ln in enumerate(lines) if ln.startswith("if (-not ([Security.Principal"))
|
||||
self.assertTrue(lines[i - 1].startswith("# Status panel remote access for "))
|
||||
self.assertEqual(lines[i + 2], "}") # nothing left after the brace
|
||||
self.assertIn("$IngestIPs = @('10.1.1.1', '10.6.3.222', '10.6.7.38')", text) # the laptop through the firewall
|
||||
self.assertIn("christian-laptop@waters", text)
|
||||
join = campus.join_windows(self.cfg(), "http://10.5.2.60:8020").split("\n")
|
||||
self.assertTrue(join[1].startswith("# Status panel remote access for "))
|
||||
self.assertEqual(join[4], "}")
|
||||
|
||||
def test_a_bad_admin_key_is_refused(self):
|
||||
from settings import scripts
|
||||
cfg = self.cfg()
|
||||
cfg["site"]["admin_keys"] = ["ssh-ed25519 AAAA'; rm -rf / #"]
|
||||
with self.assertRaises(ValueError):
|
||||
scripts.admin_keys(cfg)
|
||||
|
||||
def test_the_mac_join_line_takes_the_admin_key_too(self):
|
||||
from settings import scripts
|
||||
real = scripts.public_key
|
||||
scripts.public_key = lambda cfg: ("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPANELKEY status-panel@sc", "status-panel@sc")
|
||||
try:
|
||||
text = campus.join_mac(self.cfg(), "http://10.5.2.60:8020")
|
||||
finally:
|
||||
scripts.public_key = real
|
||||
self.assertIn("for A in 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITESTKEY christian-laptop@waters'; do", text)
|
||||
|
||||
@@ -177,7 +177,7 @@ class TestSchemaMatchesCode(unittest.TestCase):
|
||||
|
||||
def test_every_declared_key_is_read_somewhere(self):
|
||||
"""Dead settings are caught; keys only the settings page uses are listed here."""
|
||||
ui_only = {"site.id", "site.name", "site.ingest_hostname", "site.ingest_ips", "site.contacts", "site.editing_code",
|
||||
ui_only = {"site.id", "site.name", "site.ingest_hostname", "site.ingest_ips", "site.admin_ips", "site.admin_keys", "site.contacts", "site.editing_code",
|
||||
"site.settings_port", "site.dvs_helper", "site.solo_dir", "hosts.*.name", "hosts.*.role",
|
||||
"hosts.*.notes", "hosts.*.pingable", "hosts.*.runs", "hosts.*.networks", "hosts.*.manual_done",
|
||||
"gear", "gear.*.location", "gear.*.notes", "gear.*.kind", "gear.*.address", "ready.ltc_campuses[].name", "ready.ltc_campuses[].short", "ready.ltc_campuses[].service_type", "atem.source_folder", "fixes.*.template", "fixes.*.params",
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ class TestScripts(unittest.TestCase):
|
||||
text = scripts.windows_setup(CFG, "lighting")
|
||||
self.assertIn("$Key = '%s'" % KEY[0], text)
|
||||
self.assertIn("$KeyMark = 'status-panel@NA-Ingest'", text)
|
||||
self.assertIn("$IngestIPs = @('10.1.1.1', '10.6.3.222')", text)
|
||||
self.assertIn("$IngestIPs = @('10.1.1.1', '10.6.3.222', '10.6.7.38')", text) # NA Ingest, then the admin laptop
|
||||
for exe in ("Vista.exe", "Arena.exe", "rtpMIDI.exe"):
|
||||
self.assertIn("(Find-Exe '%s')" % exe, text)
|
||||
self.assertIn("foreach ($svc in @('rtpMIDIService'))", text)
|
||||
|
||||
Reference in New Issue
Block a user