mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-27 16:51:12 -07:00
forgectrl's firmware paths now read an archive's product and take firmware only (fwproduct.c). The upload route was not exercised by any catalog test; this one is its door. Archives made on the spot with the machine's own fwup go to POST /update/upload as a multipart form: an extension package unsigned, one signed with a throwaway key, and one that carries a task are each refused with 400 "this archive is an extension package, not firmware" and nothing is staged; an archive of another product is refused as not firmware; one whose product is ForgeFIRM firmware is taken as the unsigned upload it is, and the staged file is removed. Nothing is applied: the apply calls the same gate before fwup -a, and its proof is the daemon's host test, because a catalog test that could fail there would write a slot. Proven. On the bench reference, image 20260920211625 with the gated daemon and this file mounted over the image's: update.product-gate PASS, and update.slots-and-signature PASS beside it (the signature refusal still comes first). Against the image's own daemon, which has no gate, the test fails on its first upload (200), and its cleanup leaves nothing staged. The unit suite passes (422) with no undefined name. Acceptance. update.product-gate covers forgectrl's src/fwproduct.* with the update sources.
282 lines
16 KiB
Python
282 lines
16 KiB
Python
# Copyright 2026 514 LLC d/b/a OpenGlow
|
|
# Written by Scott Wiederhold
|
|
# https://community.openglow.org
|
|
# SPDX-License-Identifier: MIT
|
|
|
|
"""update.* - the A/B slot inventory and the firmware verification path."""
|
|
import os
|
|
import re
|
|
import tempfile
|
|
import time
|
|
import shutil
|
|
|
|
from ..catalog import test
|
|
from .. import hw
|
|
|
|
_UPDATE_COVERS = [("forgectrl", "src/update.c"), ("forgectrl", "src/update.h"),
|
|
("forgectrl", "src/relcheck.c"), ("forgectrl", "src/relcheck.h"),
|
|
("ffboot", "**")]
|
|
|
|
|
|
@test("update.slots-and-signature", title="Boot slots readable, unsigned and foreign-signed archives refused",
|
|
subsystem="update", kind="auto", est_min=2,
|
|
covers=_UPDATE_COVERS, requires=["forgectrl.auth"],
|
|
description="/slots reports the A/B inventory consistent with `ffboot -l`; /update/status "
|
|
"answers; `fwup` refuses a garbage archive. A tiny archive signed with a "
|
|
"throwaway key made on the spot verifies with its own key and fails against the "
|
|
"shipped release key, and an apply of it without confirm_unsigned is refused by "
|
|
"the update job before anything is written. Nothing is written to any slot.")
|
|
def slots_and_signature(ctx):
|
|
fc = ctx.forgectrl
|
|
ev = ctx.evidence
|
|
st, slots = fc.get("/slots")
|
|
ctx.log("GET /slots -> %s %s", st, slots)
|
|
ctx.check(st == 200 and isinstance(slots, dict), "GET /slots -> %s", st)
|
|
ev["slots"] = slots
|
|
rc, out = hw.run(["ffboot", "-l"])
|
|
ev["ffboot_l_rc"] = rc
|
|
ctx.log("ffboot -l -> rc %s\n%s", rc, out.strip())
|
|
ctx.check(rc == 0, "ffboot -l failed (%s)", rc)
|
|
text = str(slots).lower()
|
|
ctx.check("forgefirm" in text or "slot" in text, "/slots does not look like a slot inventory")
|
|
|
|
st, us = fc.get("/update/status")
|
|
ctx.log("GET /update/status -> %s %s", st, us)
|
|
ctx.check(st == 200 and isinstance(us, dict) and "running" in us, "GET /update/status -> %s", st)
|
|
ctx.check(not us.get("running"), "an update is running")
|
|
|
|
key = "/etc/forgefirm/keys/forgefirm-release.pub"
|
|
ctx.check(os.path.exists(key), "release key %s missing", key)
|
|
with tempfile.NamedTemporaryFile(prefix="forgetest-", suffix=".fw", delete=False) as f:
|
|
f.write(b"this is not a firmware archive" * 64)
|
|
garbage = f.name
|
|
try:
|
|
rc, out = hw.run(["fwup", "-V", "-i", garbage, "-p", key], timeout=30)
|
|
ev["fwup_garbage_rc"] = rc
|
|
ctx.log("fwup -V garbage -> rc %s: %s", rc, out.strip()[:200])
|
|
ctx.check(rc != 0, "fwup accepted a garbage archive")
|
|
finally:
|
|
os.unlink(garbage)
|
|
|
|
# A foreign signature: a throwaway key pair signs a tiny archive. The
|
|
# shipped key must refuse it, its own key must accept it, and the
|
|
# apply job must refuse it without confirm_unsigned, before it
|
|
# touches the slot.
|
|
work = tempfile.mkdtemp(prefix="forgetest-fw-")
|
|
staged = "/data/forgefirm/upload.fw"
|
|
try:
|
|
with open(os.path.join(work, "note.txt"), "w") as f:
|
|
f.write("forgetest foreign-signature drill\n")
|
|
with open(os.path.join(work, "fwup.conf"), "w") as f:
|
|
f.write('meta-product = "forgetest"\nmeta-version = "0.0.0-test"\n'
|
|
'file-resource note.txt { host-path = "note.txt" }\n'
|
|
'task complete { on-resource note.txt { raw_write(0) } }\n')
|
|
rc, out = hw.run(["sh", "-c", "cd %s && fwup -g" % work], timeout=60)
|
|
ev["fwup_gen_rc"] = rc
|
|
ctx.check(rc == 0 and os.path.exists(os.path.join(work, "fwup-key.pub")),
|
|
"fwup -g did not make a key pair (rc %s): %s", rc, out.strip()[:200])
|
|
plain, signed = os.path.join(work, "plain.fw"), os.path.join(work, "signed.fw")
|
|
rc, out = hw.run(["sh", "-c", "cd %s && fwup -c -f fwup.conf -o plain.fw && "
|
|
"fwup -S -s fwup-key.priv -i plain.fw -o signed.fw" % work], timeout=60)
|
|
ctx.check(rc == 0 and os.path.exists(signed), "could not make the signed archive (rc %s): %s",
|
|
rc, out.strip()[:200])
|
|
rc_own, _ = hw.run(["fwup", "-V", "-i", signed, "-p", os.path.join(work, "fwup-key.pub")], timeout=30)
|
|
rc_ship, out = hw.run(["fwup", "-V", "-i", signed, "-p", key], timeout=30)
|
|
ev["fwup_foreign"] = {"own_key_rc": rc_own, "shipped_key_rc": rc_ship}
|
|
ctx.log("fwup -V signed: own key rc %s, shipped key rc %s", rc_own, rc_ship)
|
|
ctx.check(rc_own == 0, "the archive does not verify with the key that signed it")
|
|
ctx.check(rc_ship != 0, "the shipped release key accepted a foreign signature")
|
|
|
|
# The apply path. The target is the slot not booted; a slot already
|
|
# selected for the next boot is refused for its own reason, which
|
|
# this drill records and steps over.
|
|
target = None
|
|
for name, si in (slots.get("slots") or {}).items():
|
|
if name in ("a", "b") and isinstance(si, dict) and not si.get("booted"):
|
|
target = name
|
|
ctx.check(target is not None, "no inactive firmware slot in /slots: %s", slots)
|
|
shutil.copyfile(signed, staged)
|
|
st, body = fc.post("/update/apply", params={"slot": target, "file": "upload"})
|
|
ev["apply"] = {"status": st, "body": body}
|
|
ctx.log("POST /update/apply slot=%s file=upload (no confirm_unsigned) -> %s %s", target, st, body)
|
|
if st == 409 and isinstance(body, dict) and "next boot" in str(body.get("error", "")):
|
|
ctx.log("slot %s is selected for the next boot: the apply is refused before the "
|
|
"signature check, which is its own guard", target)
|
|
else:
|
|
ctx.check(st == 202 and isinstance(body, dict) and body.get("started") is True,
|
|
"the apply job did not start: %s %s", st, body)
|
|
result = None
|
|
t0 = time.time()
|
|
while time.time() - t0 < 60:
|
|
ctx.sleep(1)
|
|
st, us = fc.get("/update/status")
|
|
if isinstance(us, dict) and not us.get("running"):
|
|
result = us.get("result")
|
|
break
|
|
ev["apply_result"] = result
|
|
ctx.log("apply result: %s", result)
|
|
ctx.check(isinstance(result, dict) and result.get("ok") is False
|
|
and "not signed" in str(result.get("error", "")),
|
|
"the apply of a foreign-signed archive was not refused for its signature: %s", result)
|
|
finally:
|
|
try:
|
|
os.unlink(staged)
|
|
except OSError:
|
|
pass
|
|
shutil.rmtree(work, ignore_errors=True)
|
|
|
|
|
|
@test("update.product-gate", title="The firmware upload takes firmware only: an extension package is refused by name",
|
|
subsystem="update", kind="auto", est_min=1,
|
|
covers=_UPDATE_COVERS + [("forgectrl", "src/fwproduct.c"), ("forgectrl", "src/fwproduct.h")],
|
|
requires=["forgectrl.auth"],
|
|
description="Firmware and extension packages are the same container, and a signature says who "
|
|
"made an archive, never what it is. Archives made on the spot with the machine's own "
|
|
"fwup go to POST /update/upload: an extension package unsigned, one signed with a "
|
|
"throwaway key, and one that carries a task are each refused with 400 in words that "
|
|
"say what they are, and nothing is staged; an archive of some other product is "
|
|
"refused as not firmware; and one whose product is ForgeFIRM firmware is taken as an "
|
|
"unsigned upload, which is what it is. Nothing is applied: the apply calls the same "
|
|
"gate before fwup -a, and its proof is the daemon's host test, because a catalog test "
|
|
"that could fail there would write a slot.")
|
|
def product_gate(ctx):
|
|
fc = ctx.forgectrl
|
|
ev = ctx.evidence
|
|
staged = "/data/forgefirm/upload.fw"
|
|
ctx.check(not os.path.exists(staged), "an upload is already staged: %s", staged)
|
|
work = tempfile.mkdtemp(prefix="forgetest-gate-")
|
|
|
|
def archive(name, product, task=False, sign=False):
|
|
conf = os.path.join(work, name + ".conf")
|
|
with open(conf, "w") as f:
|
|
f.write('meta-product = "%s"\nmeta-version = "0.0.0-test"\n'
|
|
'file-resource payload.tar.gz { host-path = "payload.tar.gz" }\n' % product)
|
|
if task:
|
|
f.write('task upgrade.a { on-resource payload.tar.gz { raw_write(0) } }\n')
|
|
out = name + ".fw"
|
|
cmd = "cd %s && fwup -c -f %s.conf -o %s" % (work, name, out)
|
|
if sign:
|
|
cmd += " && fwup -S -s fwup-key.priv -i %s -o %s.signed && mv %s.signed %s" % (out, out, out, out)
|
|
rc, text = hw.run(["sh", "-c", cmd], timeout=60)
|
|
ctx.check(rc == 0, "could not make %s (rc %s): %s", name, rc, text.strip()[:200])
|
|
return os.path.join(work, out)
|
|
|
|
def upload(path):
|
|
mark = "forgetestGateBoundary9c2"
|
|
body = ('--%s\r\nContent-Disposition: form-data; name="file"; filename="%s"\r\n'
|
|
'Content-Type: application/octet-stream\r\n\r\n' % (mark, os.path.basename(path))).encode()
|
|
with open(path, "rb") as f:
|
|
body += f.read() + ("\r\n--%s--\r\n" % mark).encode()
|
|
return fc.post("/update/upload", data=body,
|
|
headers={"Content-Type": "multipart/form-data; boundary=%s" % mark})
|
|
|
|
try:
|
|
with open(os.path.join(work, "payload.tar.gz"), "wb") as f:
|
|
f.write(b"forgetest product-gate drill\n" * 8)
|
|
rc, text = hw.run(["sh", "-c", "cd %s && fwup -g" % work], timeout=60)
|
|
ctx.check(rc == 0, "fwup -g did not make a key pair (rc %s): %s", rc, text.strip()[:200])
|
|
seen = {}
|
|
for name, product, task, sign in (("extension-unsigned", "ForgeFIRM extension", False, False),
|
|
("extension-signed", "ForgeFIRM extension", False, True),
|
|
("extension-with-task", "ForgeFIRM extension", True, False)):
|
|
st, body = upload(archive(name, product, task, sign))
|
|
words = body.get("error", "") if isinstance(body, dict) else str(body)
|
|
seen[name] = [st, words]
|
|
ctx.log("upload %s -> %s %s", name, st, words)
|
|
ctx.check(st == 400 and "an extension package, not firmware" in words,
|
|
"%s at the firmware upload -> %s %s", name, st, words)
|
|
ctx.check(not os.path.exists(staged), "%s was refused and is staged all the same", name)
|
|
st, body = upload(archive("another-product", "forgetest"))
|
|
words = body.get("error", "") if isinstance(body, dict) else str(body)
|
|
seen["another-product"] = [st, words]
|
|
ctx.log("upload another-product -> %s %s", st, words)
|
|
ctx.check(st == 400 and "not firmware" in words and not os.path.exists(staged),
|
|
"an archive of another product -> %s %s", st, words)
|
|
st, body = upload(archive("firmware-unsigned", "ForgeFIRM firmware"))
|
|
seen["firmware-unsigned"] = [st, body]
|
|
ctx.log("upload firmware-unsigned -> %s %s", st, body)
|
|
ctx.check(st == 200 and isinstance(body, dict) and body.get("signature") == "unsigned"
|
|
and os.path.exists(staged), "an unsigned archive that is firmware -> %s %s", st, body)
|
|
ev["uploads"] = seen
|
|
finally:
|
|
try:
|
|
os.unlink(staged)
|
|
except OSError:
|
|
pass
|
|
shutil.rmtree(work, ignore_errors=True)
|
|
ctx.log("PASS: three extension packages and one foreign product refused at the firmware upload with "
|
|
"nothing staged; firmware taken as the unsigned upload it is")
|
|
|
|
|
|
_VERSION_RX = re.compile(r"^v?\d+\.\d+\.\d+")
|
|
|
|
|
|
@test("update.release-check", title="Release check answers from the releases API; the alert dismissal is per release",
|
|
subsystem="update", kind="auto", est_min=1,
|
|
covers=_UPDATE_COVERS + [("forgectrl", "src/ui/panel.js"), ("forgectrl", "src/ui/index.html")],
|
|
requires=["forgectrl.auth"],
|
|
description="GET /update/release answers the kept answer of the daily check (available, version, "
|
|
"current, new, checked, dismissed). POST /update/check asks the releases API now and "
|
|
"answers the same shape; a machine with no route to the API answers 502, which the drill "
|
|
"records and steps over. A published release is a v<semver> tag with the firmware "
|
|
"file's size and its notes, and `new` follows the version order: a development build "
|
|
"sees every release as newer. POST /update/dismiss marks that version dismissed, an "
|
|
"empty version undoes it, and a version that is not a tag is refused. The dismissal "
|
|
"the machine had before the drill is put back.")
|
|
def release_check(ctx):
|
|
fc = ctx.forgectrl
|
|
ev = ctx.evidence
|
|
keys = ("available", "version", "current", "new", "checked", "dismissed", "detail", "bytes")
|
|
|
|
st, before = fc.get("/update/release")
|
|
ctx.log("GET /update/release -> %s %s", st, {k: before.get(k) for k in keys} if isinstance(before, dict) else before)
|
|
ctx.check(st == 200 and isinstance(before, dict) and "available" in before and "checked" in before,
|
|
"GET /update/release -> %s", st)
|
|
ev["before"] = {k: before.get(k) for k in keys}
|
|
prior = before.get("version", "") if before.get("dismissed") else ""
|
|
|
|
st, now = fc.post("/update/check")
|
|
ctx.log("POST /update/check -> %s %s", st, {k: now.get(k) for k in keys} if isinstance(now, dict) else now)
|
|
if st == 502:
|
|
ctx.log("the machine has no route to the releases API; the kept answer stands")
|
|
now = before
|
|
else:
|
|
ctx.check(st == 200 and isinstance(now, dict) and "available" in now, "POST /update/check -> %s", st)
|
|
ctx.check(isinstance(now.get("checked"), int) and now["checked"] >= before.get("checked", 0),
|
|
"the check did not move `checked`: %s", now.get("checked"))
|
|
ev["after_check"] = {k: now.get(k) for k in keys}
|
|
|
|
if now.get("available"):
|
|
ctx.check(_VERSION_RX.match(str(now.get("version", ""))) is not None,
|
|
"the release version is not a v<semver> tag: %r", now.get("version"))
|
|
ctx.check(isinstance(now.get("bytes"), int) and now["bytes"] > 0,
|
|
"the firmware file's size is missing: %r", now.get("bytes"))
|
|
ctx.check(isinstance(now.get("notes"), str), "the notes are not a string")
|
|
ctx.check(isinstance(now.get("new"), bool), "`new` is not a bool")
|
|
if not _VERSION_RX.match(str(now.get("current", ""))):
|
|
ctx.check(now.get("new") is True,
|
|
"a development build (%r) must see release %s as newer", now.get("current"), now["version"])
|
|
else:
|
|
ctx.log("no release available: %s", now.get("detail"))
|
|
ctx.check(isinstance(now.get("detail"), str) and now["detail"], "an unavailable release names no reason")
|
|
|
|
try:
|
|
st, body = fc.post("/update/dismiss", params={"version": "v0.0.1; rm -rf /"})
|
|
ctx.log("POST /update/dismiss version=<not a tag> -> %s %s", st, body)
|
|
ctx.check(st == 400, "a version that is not a tag was not refused: %s", st)
|
|
|
|
tag = now["version"] if now.get("available") else "v0.0.0"
|
|
st, body = fc.post("/update/dismiss", params={"version": tag})
|
|
ctx.log("POST /update/dismiss version=%s -> %s %s", tag, st,
|
|
{k: body.get(k) for k in keys} if isinstance(body, dict) else body)
|
|
ctx.check(st == 200 and isinstance(body, dict), "POST /update/dismiss -> %s", st)
|
|
if now.get("available"):
|
|
ctx.check(body.get("dismissed") is True, "the dismissal of %s was not recorded: %r", tag, body.get("dismissed"))
|
|
st, body = fc.post("/update/dismiss", params={"version": ""})
|
|
ctx.log("POST /update/dismiss version= -> %s", st)
|
|
ctx.check(st == 200 and isinstance(body, dict) and body.get("dismissed") is False,
|
|
"the empty version did not undo the dismissal: %s %r", st, body.get("dismissed") if isinstance(body, dict) else body)
|
|
finally:
|
|
st, body = fc.post("/update/dismiss", params={"version": prior})
|
|
ctx.log("dismissal put back to %r -> %s", prior, st)
|