mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-27 08:41:13 -07:00
The cooling report secret: a driver harness, and two catalog tests
forgectrl's POST /cool/state now asks for the secret the supervisor hands the running controller at its spawn; both controllers' cooling clients send it. scripts/bench/cool_report_test.py is the driver's host harness for it (null-sink controller, a stand-in for forgectrl's listener on FORGECTRL_PORT): every report carries the secret and no other new header; 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; the homing runner the controller starts does not inherit the secret. It is in the bench registry and the bench README, and the driver's CI runs it. forgectrl.auth: the loopback case used to assert that any local peer is accepted. It now asserts the three answers: no secret 403, a made-up secret 403, and the running controller's own secret 200, read as only root on the machine can read it, out of the controller's environment, and never logged. The LAN cases carry the secret too and are still refused. cooling.report-channel is new, the drill the change exists for: M8 opens a run session, three forged idle reports from this host (no secret, a made-up one, a made-up one with a forged Host) are each refused, and over the next five seconds the engine stays in phase run and no commanded fan duty drops; M9 ends the session on the controller's own report. Proven. The harness passes on the host-built controller, with three negative controls that each fail as they should. The unit suite passes (421) with no undefined name. On the bench reference, forgectrl and both clients hot-deployed over image 20260920152153: forgectrl.auth PASS, cooling.report-channel PASS (phase run throughout, the exhaust at 65535 and the intake at 43278), and cooling.fans-quiet-after-motion and motion.job PASS on the same binaries. Acceptance. forgectrl.auth and cooling.report-channel are the gate for the report channel's secret; cloud.dark-print gates the cloud client's side.
This commit is contained in:
@@ -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 <path-to-grblHAL_glowforge>
|
||||
"""
|
||||
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()
|
||||
Reference in New Issue
Block a user