diff --git a/forgetest/forgetest/bench.py b/forgetest/forgetest/bench.py index 8d58b8e..e4a113d 100644 --- a/forgetest/forgetest/bench.py +++ b/forgetest/forgetest/bench.py @@ -265,6 +265,14 @@ TOOLS = [ "(a line queued right behind the port's included), the refusals, the single client, a CR LF " "sender, a soft reset, and the client as the dead-man. A CI harness (the grblHAL repo): needs " "the host-built null-sink controller, not the machine, so it is not a bench-page tool."}, + {"id": "cool-report-test", "title": "Cooling report secret harness", "script": "cool_report_test.py", + "safety": "dry", "where": "host", "ported": False, "args": [], + "desc": "The controller's cooling reports on the null-sink controller, read by a stand-in for " + "forgectrl's listener: every report carries the secret the supervisor hands the controller at " + "its spawn (GF_REPORT_SECRET), none does when there is none, a value that is not 32 hex digits " + "(a CR LF with a header behind it included) never reaches the wire, and the homing runner the " + "controller starts does not inherit it. A CI harness (the grblHAL repo): needs the host-built " + "null-sink controller, not the machine, so it is not a bench-page tool."}, {"id": "manual-home-test", "title": "Manual home and motor release harness", "script": "manual_home_test.py", "safety": "dry", "where": "host", "ported": False, "args": [], "desc": "The manual homing provider and the motor release on the null-sink controller: $H under manual " diff --git a/forgetest/forgetest/suite/cooling.py b/forgetest/forgetest/suite/cooling.py index 63bebe9..564b36a 100644 --- a/forgetest/forgetest/suite/cooling.py +++ b/forgetest/forgetest/suite/cooling.py @@ -1532,3 +1532,60 @@ def fail_tier_stop(ctx): "thresholds not restored: %s", {k: after.get(k) for k in CRASH_KEYS}) ctx.log("PASS: the crash tier stopped the job, locked the latch, and the supervisor restarted " "the controller (pid %s -> %s)", pid0, m1 and m1.get("pid")) + + +@test("cooling.report-channel", title="A forged cooling report inside a run session is refused and changes nothing", + subsystem="cooling", kind="auto", mode="grbl", est_min=2, + covers=_COOL_COVERS + [("forgectrl", "src/super.*"), ("forgectrl", "src/main.c"), + ("forgectrl", "src/auth.*")], + requires=["forgectrl.auth"], + description="The report channel (POST /cool/state) is what tells the engine a job is running: a " + "forged idle report would stand the fans down under a cut. M8 opens a run session " + "from the suite's Grbl client, and once the engine is in phase run with its run fan " + "profile commanded, a process on this host that is not the controller reports " + "mode=idle, armed=0: with no secret, with a made-up secret, and with a made-up " + "secret and a forged Host header. Each is refused (403). Over the next five seconds " + "the engine stays in phase run and no commanded fan duty drops. M9 " + "ends the session, and the engine leaves phase run on the controller's own report.") +def report_channel(ctx): + fc = ctx.forgectrl + ev = ctx.evidence + forged = {"mode": "idle", "armed": "0"} + + with ctx.grbl() as g: + def in_run(c): + return c.get("phase") == "run" + ctx.check(_cool(fc).get("phase") != "run", "a run session is already open") + g.command("M8") + try: + ok = ctx.wait_for(lambda: in_run(_cool(fc)), VERDICT_WAIT_S, poll=0.5) + ctx.check(ok is not None, "M8 did not open a run session: %s", _cool(fc).get("phase")) + ctx.sleep(2) # the run fan profile is commanded + before = _cool(fc) + duties0 = _duties() + ev["before"] = {"phase": before.get("phase"), "duties": duties0} + refused = {} + for name, headers in (("no secret", {}), + ("a made-up secret", {"X-ForgeFIRM-Report": "0123456789abcdef" * 2}), + ("a made-up secret and a forged Host", + {"X-ForgeFIRM-Report": "f" * 32, "Host": "localhost"})): + st, body = fc.post("/cool/state", params=forged, headers=headers, auth=False) + refused[name] = [st, str(body)[:100]] + ctx.check(st == 403, "a forged report (%s) -> %s %s", name, st, str(body)[:100]) + ev["refused"] = refused + held = [] + for _ in range(5): + ctx.sleep(1) + c = _cool(fc) + held.append([c.get("phase"), _duties()]) + ev["after"] = held + ctx.log("phase and duties over 5 s after three forged idle reports: %s", held) + ctx.check(all(p == "run" for p, _d in held), "the engine left phase run: %s", [p for p, _d in held]) + ctx.check(all(d[k] >= duties0[k] for _p, d in held for k in duties0), + "a commanded fan duty dropped under the forged reports: %s -> %s", duties0, held) + finally: + g.command("M9") + _session_ended(ctx, fc, "report channel") + ctx.check(_cool(fc).get("phase") != "run", "the session did not end on the controller's own report") + ctx.log("PASS: three forged idle reports from this host were refused inside a run session, and the " + "engine held its phase and its fan duties") diff --git a/forgetest/forgetest/suite/forgectrl.py b/forgetest/forgetest/suite/forgectrl.py index 71a1801..d6fa2ee 100644 --- a/forgetest/forgetest/suite/forgectrl.py +++ b/forgetest/forgetest/suite/forgectrl.py @@ -143,14 +143,39 @@ def auth(ctx): "GET /fuse-identity with the token but no button -> %s %r (expected the two-factor refusal)", st, body) - # the cooling report channel: the loopback peer is accepted. An idle - # report is what the controller sends every period; the engine is idle - # here, so it changes nothing. A dual-stack listener reports this peer - # as ::ffff:127.0.0.1, which the check must recognize in full. - st, body = fc.post("/cool/state", params={"mode": "idle", "armed": "0"}) + # the cooling report channel is the running controller's alone. A + # loopback peer is not enough: anything on this host is one. The + # supervisor hands the controller a secret at its spawn, and the route + # asks for it. An idle report is what the controller sends every + # period; the engine is idle here, so the accepted one changes nothing. + # A dual-stack listener reports this peer as ::ffff:127.0.0.1, which + # the loopback check must recognize in full. + report = {"mode": "idle", "armed": "0"} + st, body = fc.post("/cool/state", params=report) + ev["cool_state_from_loopback_no_secret"] = st + ctx.log("POST /cool/state from loopback, no secret -> %s %s", st, body if isinstance(body, str) else "") + ctx.check(st == 403 and "controller's alone" in str(body), + "/cool/state from loopback without the secret -> %s %r", st, body) + st, body = fc.post("/cool/state", params=report, headers={"X-ForgeFIRM-Report": "0" * 32}) + ctx.check(st == 403, "/cool/state with a secret that is not the controller's -> %s %r", st, body) + # The secret itself, as only root on this host can read it: out of + # the running controller's environment. It is never logged. + secret = None + pid = (fc.get("/mode")[1] or {}).get("pid") + if pid: + try: + with open("/proc/%d/environ" % int(pid), "rb") as f: + for item in f.read().split(b"\0"): + if item.startswith(b"GF_REPORT_SECRET="): + secret = item.split(b"=", 1)[1].decode("ascii", "replace") + except OSError: + pass + ev["controller_has_a_secret"] = bool(secret) + ctx.check(secret and len(secret) == 32, "the running controller (pid %s) was handed no report secret", pid) + st, body = fc.post("/cool/state", params=report, headers={"X-ForgeFIRM-Report": secret}) ev["cool_state_from_loopback"] = st - ctx.log("POST /cool/state from loopback -> %s %s", st, body if isinstance(body, dict) else "") - ctx.check(st == 200, "/cool/state refused the loopback peer (%s %r): the controller's " + ctx.log("POST /cool/state from loopback with the controller's secret -> %s", st) + ctx.check(st == 200, "/cool/state refused the controller's own secret (%s %r): the controller's " "reports never reach the engine", st, body) # ...and a non-loopback peer is refused, even with a token. Over HTTP @@ -162,8 +187,7 @@ def auth(ctx): ip = lan_ip() ev["lan_ip"] = ip ctx.check(ip, "cannot determine the board's LAN address") - token = {"X-ForgeFIRM-Token": fc.token} - report = {"mode": "idle", "armed": "0"} + token = {"X-ForgeFIRM-Token": fc.token, "X-ForgeFIRM-Report": secret} st, body, hdrs = request("http://%s" % ip, "POST", "/cool/state", data=report, headers=token) loc = hdrs.get("location", "") ev["cool_state_from_lan_http"] = {"status": st, "location": loc} diff --git a/scripts/bench/README.md b/scripts/bench/README.md index f77ed8c..d06ba78 100644 --- a/scripts/bench/README.md +++ b/scripts/bench/README.md @@ -34,6 +34,7 @@ page's takeover does that; from a host, stop them first. | `laser_lifecycle_test.py` | Host-side operator-armed-window lifecycle harness (null-sink controller): arm once per job with M5/M3 persistence, the M2 close, sender-change re-consent, the disarm grace counting down in Hold, and arm refusal under a blocking cooling verdict. Runs in the grblHAL repo's CI. | | `z_envelope_test.py` | Host-side Z envelope harness (null-sink controller): the Z soft limit belongs to the driver, not to `$20`, so the driver re-applies `sys.work_envelope`, `sys.homed` and `sys.soft_limits` for Z from the settings-changed chain. Checks that an unreferenced Z is collapsed to where the lens stands and blocks a move each way, that X and Y stay free, and that neither a `$20` write (the core clears the soft-limit mask in the setter) nor a `$132` write (which un-homes the axis as well) frees Z. Runs in the grblHAL repo's CI. | | `ctlport_test.py` | Host-side controller port and line-multiplexer harness (null-sink controller): a scripted sender that counts every `ok` and `error` runs beside the port's one client on `grbl.ctl`. A port jog's status goes to the port and never to the sender, a port error does not reach the sender's parser, a sender line cancels a port jog and gets its own `ok` (a line queued right behind the port's included), plus the refusals, the single client, a CR LF sender, a soft reset, and the client as the dead-man. `python3 ctlport_test.py `. | +| `cool_report_test.py` | Host-side cooling report harness (null-sink controller, a stand-in for forgectrl's listener on `FORGECTRL_PORT`): every `POST /cool/state` carries the secret the supervisor hands the controller at its spawn (`GF_REPORT_SECRET`) as `X-ForgeFIRM-Report`; with no secret the header is absent; a value that is not 32 hex digits, a CR LF with a header behind it included, never reaches the wire; and the homing runner the controller starts does not inherit the secret. `python3 cool_report_test.py `. | | `manual_home_test.py` | Host-side manual homing and motor release harness (null-sink controller, `GFSINK_DUMP` and `GFSINK_ATTR_LOG`): `$H` under `homing_mode = manual` ships nothing and declares X0 Y0; while the motors are released (`$MD`) every motion source is refused and ships nothing, `$X` and a soft reset do not unlock it, and only `$ME` and a manual `$H` write the energize. Imports its sender and port client from `ctlport_test.py`. `python3 manual_home_test.py `. | | `live_fire_drills.py` | **LIVE LASER** drills, on the board (the bench page) or from a LAN host (`GF_HOST`): `live_fire_drills.py [S] [F]` - `witness` (emission witness, lid-IR peaks vs the ambient baseline, HV current, job-based disarm on M2), `hold` (disarm grace in Hold), `faultpos` (armed job refuses a stale origin after an underrun), `ircut` (lid-IR characterization cut at S/F), `pthresh` (laser power-threshold ladder: 13 constant-power rungs from 2 % to 30 % of full on scrap; the lowest rung that marks is the tube's striking threshold and reads directly as the `$35` value - requires `$35` = 0 for the run), `dladder` (density ladder at a chosen base period), `pcurve` (laser performance-curve ladder: one 100 mm line per level at 10 mm/s under M3, the laser off between rungs and a mid-ladder rung repeated at the end; reads `pic/hv_current` and the head thermopile `head/beam_detect_analog` (a scatter detector in the beam path upstream of the final mirror, so it sees the beam, not the material) from sysfs at ~25 Hz on the board, brackets each rung on the controller's Run/Idle states, and reports per rung the current with a clipped-at-1023 flag, the thermopile delta over its laser-off baseline and in-line drift, then the normalized curve, monotonicity, a line fit with its threshold intercept and the repeat-rung drift; JSON record with the raw trace in the bench data directory; rungs follow `laser_power_model`, a comma list overrides; a curve measurement wants `$35` = 0), `dpatch [F] [pitch] [length]` (depth witness for the density dose curve: two rows of small serpentine-filled patches, row A CW at feeds giving relative doses 1.0 to 0.25 of the reference feed, row B at the reference feed at 100/80/60/45/30 % density; the operator matches each row-B patch to the row-A patch of equal depth, which reads the density's light fraction off the material beside the thermopile's prediction; JSON record), `m4feeds [S] [F1] [F2]` (the density time base across feeds: one out-and-back line pair per feed at the same S under M4 density, one armed run; the operator reads within-line evenness and reversal darkness at both feeds - M4's velocity scaling is what holds dose per mm through the accel), `m4corner [S] [F]` (M4 velocity-scaled power into corners: a corner-heavy vector pattern at 30 % under M4 density, one armed run; the operator confirms every commanded segment marks - the floor makes a dropout unreachable - and the drill asserts the arm report, one discharge window and dark after), `m5dark` (the rapids after an M5 ship dark: one 20 mm line at M3 S400, M5, dwell, rapid back, dwell, rapid forward; PASS when the 25 Hz current trace shows one discharge segment and reads dark after the M5 and `laser_on_sampled` never re-lights; the catalog's `laser.m5-rapid-dark` is its port), `flowload` (cooling under laser load, one armed run per invocation, the conf keys it writes put back at the end, the pump never commanded off: `t1` reproduces the flow-check trip with the check on at its defaults and two 30 x 4 mm CW fills at F1500 starting on the press with no dark dwell, and reports the engine's rise/dT verdict beside the 25 Hz trace of both coolant sensors, the current, the digital witness and the heater output in 5 s bins across the window, with the shape at fire start; `t2 [pct]` runs with the check off and one fill of about `secs` lit seconds at CW or at `pct` density, and reports the lag to each sensor, the rise per raw-second of `hv_current` and what a full 50 s window would add against the 1.6 C margin; `fit` fits rise against dose over every t2 record; JSON records), `expstop` (armed kill on the expected-stop path; needs the panel token - `GF_TOKEN`, or the board's token file) and `ctrlstart` (the separate controller restart after it). Every drill waits for the operator's physical arm press; eye protection, fire watch, extinguisher, and exhaust are mandatory. | | `pacing_test.py` | Protocol-loop pacing check (runs on the board, dry motion): idle and parked-in-Hold states are coarse-paced, active motion is tight-paced, and a feed-hold/resume mid-move preserves position with no feeder starve. | diff --git a/scripts/bench/cool_report_test.py b/scripts/bench/cool_report_test.py new file mode 100644 index 0000000..9850a0b --- /dev/null +++ b/scripts/bench/cool_report_test.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +# Copyright 2026 514 LLC d/b/a OpenGlow +# Written by Scott Wiederhold +# https://community.openglow.org +# SPDX-License-Identifier: MIT +"""Host harness: the controller's cooling reports carry the supervisor's secret. + +POST /cool/state is the running controller's channel alone: forgectrl hands +each controller it spawns a secret in its environment (GF_REPORT_SECRET), and +the route asks for it. This harness stands in for forgectrl's listener, runs +the null-sink controller against it, and reads every report as it arrives. + + secret every report carries X-ForgeFIRM-Report with the secret it was + started with, and the request is otherwise as it always was + none started with no secret, the reports carry no such header + malformed a value that is not 32 hex digits (short, a letter out of range, + a CR LF and a header of its own behind it) is never sent, and + nothing of it reaches the wire + runner the homing runner the controller starts does not inherit the + secret, and the reports still carry it afterwards + +Usage: cool_report_test.py +""" +import http.server +import os +import shutil +import socket +import subprocess +import sys +import tempfile +import threading +import time + +BIN = os.path.abspath(sys.argv[1]) if len(sys.argv) > 1 else "build/grblHAL_glowforge" +SECRET = "0123456789abcdef0123456789abcdef" +GRBL_PORT = 23960 + + +def fail(msg): + print("FAIL:", msg) + sys.exit(1) + + +class Listener: + """forgectrl's HTTP listener, as far as the reports need it: every + request is kept whole (the raw header block included) and answered 200.""" + + def __init__(self): + self.seen = [] + outer = self + + class H(http.server.BaseHTTPRequestHandler): + def do_POST(self): + outer.seen.append({"path": self.path, "headers": {k.lower(): v for k, v in self.headers.items()}, + "raw": bytes(self.headers)}) + self.send_response(200) + self.send_header("Content-Length", "2") + self.end_headers() + self.wfile.write(b"{}") + + def log_message(self, *a): + pass + self.srv = http.server.ThreadingHTTPServer(("127.0.0.1", 0), H) + self.port = self.srv.server_address[1] + threading.Thread(target=self.srv.serve_forever, daemon=True).start() + + def wait(self, n, timeout=8.0): + end = time.time() + timeout + while time.time() < end and len(self.seen) < n: + time.sleep(0.05) + return list(self.seen) + + def close(self): + self.srv.shutdown() + + +def publish_verdicts(path, stop): + while not stop.is_set(): + tmp = path + ".tmp" + with open(tmp, "w") as f: + f.write('{"ts_mono":%.3f,"fire_ok":true,"verdict":"OK","hold":false,' + '"resume_ok":true,"armed":false,"reason":""}' % time.clock_gettime(time.CLOCK_MONOTONIC)) + os.replace(tmp, path) + stop.wait(0.5) + + +class Controller: + def __init__(self, listener, secret, runner_cmd=None): + self.workdir = tempfile.mkdtemp(prefix="cool-report-") + conf = os.path.join(self.workdir, "forgefirm.conf") + with open(conf, "w") as f: + f.write("cool_fan_grace_s = 0\nhoming_mode = %s\n" % ("gfcloud" if runner_cmd else "none")) + if runner_cmd: + f.write("gfcloud_home_cmd = %s\n" % runner_cmd) + verdict = os.path.join(self.workdir, "cooling.state") + self.stop = threading.Event() + threading.Thread(target=publish_verdicts, args=(verdict, self.stop), daemon=True).start() + env = dict(os.environ, GFHOME_CONF=conf, GF_STATE_DIR=self.workdir, GF_VERDICT_FILE=verdict, + GFSINK_DUMP=os.path.join(self.workdir, "stream.bin"), FFLOG_STDERR="1", + FORGECTRL_PORT=str(listener.port)) + for k in ("GFSINK", "GF_SWITCH_FILE", "GF_REPORT_SECRET"): + env.pop(k, None) + if secret is not None: + env["GF_REPORT_SECRET"] = secret + self.proc = subprocess.Popen([BIN, "-p", str(GRBL_PORT)], cwd=self.workdir, env=env, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + def sender(self): + for _ in range(50): + try: + s = socket.create_connection(("127.0.0.1", GRBL_PORT), timeout=2) + s.settimeout(6) + return s + except OSError: + time.sleep(0.1) + fail("the controller's Grbl socket never opened") + + def close(self): + self.stop.set() + self.proc.terminate() + try: + self.proc.wait(5) + except subprocess.TimeoutExpired: + self.proc.kill() + shutil.rmtree(self.workdir, ignore_errors=True) + + +def run(name, secret, check, runner=False): + lis = Listener() + ctl = Controller(lis, secret, runner_cmd=("env > %s" % "RUNNER_ENV") if runner else None) + try: + if runner: + env_file = os.path.join(ctl.workdir, "RUNNER_ENV") + s = ctl.sender() + time.sleep(0.5) + s.sendall(b"$H\n") + end = time.time() + 10 + while time.time() < end and not os.path.exists(env_file): + time.sleep(0.1) + if not os.path.exists(env_file): + fail("[%s] the stand-in homing runner never ran" % name) + time.sleep(0.3) + with open(env_file) as f: + runner_env = f.read() + if "GF_REPORT_SECRET" in runner_env or SECRET in runner_env: + fail("[%s] the homing runner inherited the report secret" % name) + if "GF_STATE_DIR" not in runner_env: + fail("[%s] the runner's environment was not read: %r" % (name, runner_env[:80])) + s.close() + lis.seen.clear() # what matters is what is reported after it + reports = lis.wait(3) + if len(reports) < 3: + fail("[%s] %d reports in 8 s: the level-triggered report did not arrive" % (name, len(reports))) + for r in reports: + if not r["path"].startswith("/cool/state?mode="): + fail("[%s] a request that is no report: %s" % (name, r["path"])) + check(name, r) + print("ok: %s (%d reports)" % (name, len(reports))) + finally: + ctl.close() + lis.close() + + +def with_secret(name, r): + if r["headers"].get("x-forgefirm-report") != SECRET: + fail("[%s] a report without the secret: %s" % (name, r["headers"])) + if set(r["headers"]) != {"host", "connection", "content-length", "x-forgefirm-report"}: + fail("[%s] the report's headers changed: %s" % (name, sorted(r["headers"]))) + + +def without(name, r): + if "x-forgefirm-report" in r["headers"] or "x-evil" in r["headers"] or b"Evil" in r["raw"]: + fail("[%s] a header that must not be sent: %s" % (name, r["headers"])) + if set(r["headers"]) != {"host", "connection", "content-length"}: + fail("[%s] the report's headers changed: %s" % (name, sorted(r["headers"]))) + + +def main(): + if not os.path.exists(BIN): + fail("no controller binary at %s" % BIN) + run("secret", SECRET, with_secret) + run("none", None, without) + for i, bad in enumerate(("short", SECRET[:-1] + "g", SECRET + "0", SECRET.upper(), + SECRET[:16] + "\r\nX-Evil: 1\r\nX-Pad: 12", + SECRET[:16] + "\r\nX-Evil: 1\r\nX:1"), 1): # the last is 32 long + run("malformed-%d" % i, bad, without) + run("runner", SECRET, with_secret, runner=True) + print("cool_report_test: all passed") + + +if __name__ == "__main__": + main()