diff --git a/forgetest/forgetest/suite/exthost.py b/forgetest/forgetest/suite/exthost.py index 0a33c12..5e99e2e 100644 --- a/forgetest/forgetest/suite/exthost.py +++ b/forgetest/forgetest/suite/exthost.py @@ -15,6 +15,7 @@ import time from ..catalog import test from .forgectrl import lan_ip from .image import kernel_config +from .setup import SAFETY_PHRASE, read_file, record_path, request, write_file CG = "/sys/fs/cgroup" POOL_GROUP = CG + "/ffx" @@ -464,3 +465,380 @@ def platform(ctx): ctx.check(not os.path.isdir(PROBE_GROUP), "the probe group stayed behind") ctx.log("PASS: the kernel, the cgroup tree, the account pool, and the deny rules are in place, and each " "held a probe process the way it will hold a package") + + +# ------------------------------------------------------------ the host + +FORGEEXT = "/usr/bin/forgeext" +FWUP = "/usr/bin/fwup" +EXT_ROOT = "/data/forgefirm/ext" +HOST_STATUS = "/run/forgefirm/ext/status.json" +SAFE_FILE = "/run/forgefirm/ext-safe" +HOST_LOG = "/data/log/forgefirm/forgeext/forgeext.log" +REF_ID = "org.forgetest.reference" +REF_KEY = "forgetest-reference" +REF_DEST = "192.0.2.1" # TEST-NET-1: declared so that port 443 is, and never dialed + +# The reference package's service. It looks at its own confinement from the +# inside, leaves what it found in its data directory, and stays up. +REF_SERVICE = r''' +import errno, json, os, socket, sys, time +lan = sys.argv[1] +data, pkg = os.environ["FFX_DATA"], os.environ["FFX_PKG"] + + +def word(e): + return errno.errorcode.get(e.errno, str(e.errno)) + + +def opened(path, mode="r"): + try: + with open(path, mode) as f: + if "r" in mode: + f.read(1) + else: + f.write("x") + return "ok" + except OSError as e: + return word(e) + + +def dial(addr, port): + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(4.0) + try: + s.connect((addr, port)) + return "connected" + except socket.timeout: + return "timeout" + except OSError as e: + return word(e) + finally: + s.close() + + +def family(af): + try: + socket.socket(af, socket.SOCK_RAW if af == 16 else socket.SOCK_DGRAM).close() + return "ok" + except OSError as e: + return word(e) + + +report = { + "uid": os.getuid(), "gid": os.getgid(), "groups": os.getgroups(), "cwd": os.getcwd(), + "env": sorted(os.environ), + "read_settings": opened("/data/forgefirm/forgefirm.conf"), + "read_setup": opened("/data/forgefirm/setup.json"), + "read_etc": opened("/etc/hostname"), + "read_pkg": opened(os.path.join(pkg, "manifest.json")), + "write_pkg": opened(os.path.join(pkg, "x"), "w"), + "write_data": opened(os.path.join(data, "scratch"), "w"), + "write_tmp": opened("/tmp/forgetest-reference", "w"), + "pulse_device": opened("/dev/glowforge"), + "loopback_declared_port": dial("127.0.0.1", 443), + "lan_declared_port": dial(lan, 443) if lan != "-" else "skipped", + "loopback_undeclared_port": dial("127.0.0.1", 80), + "netlink_socket": family(16), + "unix_socket": family(1), +} +with open(os.path.join(data, "report.json.new"), "w") as f: + json.dump(report, f) +os.rename(os.path.join(data, "report.json.new"), os.path.join(data, "report.json")) +print("reference service up", flush=True) +n = 0 +while True: + n += 1 + with open(os.path.join(data, "beat"), "w") as f: + f.write(str(n)) + time.sleep(0.5) +''' + + +def _host_pids(): + """The extension host: /usr/bin/forgeext run, and not an install or a + check someone has under way.""" + out = [] + for d in os.listdir("/proc"): + if not d.isdigit(): + continue + try: + if os.readlink("/proc/%s/exe" % d) != FORGEEXT: + continue + with open("/proc/%s/cmdline" % d, "rb") as f: + argv = f.read().split(b"\0") + except OSError: + continue + if argv[:2] == [FORGEEXT.encode(), b"run"]: + out.append(int(d)) + return out + + +def _host_status(): + try: + with open(HOST_STATUS) as f: + return json.load(f) + except (OSError, ValueError): + return {} + + +def _svc(id_): + return next((x for x in _host_status().get("services", []) if x.get("id") == id_), {}) + + +def _forgeext(*args, wait=120): + p = subprocess.run([FORGEEXT] + list(args), capture_output=True, text=True, timeout=wait) + try: + return json.loads(p.stdout) + except ValueError: + return {"ok": False, "error": "no JSON answer (exit %s): %s %s" % (p.returncode, p.stdout[-200:], p.stderr[-200:])} + + +def _until(ctx, cond, seconds, poll=0.25): + end = time.time() + seconds + while time.time() < end: + v = cond() + if v: + return v + ctx.checkpoint() + time.sleep(poll) + return cond() + + +def _tree(root): + """Every path under the extension root but the host's own bookkeeping.""" + out = [] + for base, dirs, files in os.walk(root): + for n in dirs + files: + rel = os.path.relpath(os.path.join(base, n), root) + if rel not in ("state.json", "lock"): + out.append(rel) + return sorted(out) + + +def _pack_reference(work, lan): + """The reference package, signed with a key made here: (archive, public key).""" + import io + import tarfile + manifest = {"manifest": 1, "id": REF_ID, "name": "forgetest reference", "version": "1.0.0", + "author": "forgetest", "license": "MIT", "api": "0.1", "runtime": "python", + "service": {"exec": "bin/reference.py", "args": [lan or "-"]}, + "capabilities": ["net.outbound:%s:443" % REF_DEST, "storage:1"]} + payload = os.path.join(work, "payload.tar.gz") + with tarfile.open(payload, "w:gz") as t: + for name, text, mode in (("manifest.json", json.dumps(manifest), 0o644), + ("bin/reference.py", REF_SERVICE, 0o755)): + info = tarfile.TarInfo(name) + data = text.encode() + info.size, info.mode = len(data), mode + t.addfile(info, io.BytesIO(data)) + conf = os.path.join(work, "fwup.conf") + _write(conf, 'meta-product = "ForgeFIRM extension"\nmeta-description = "%s"\nmeta-version = "1.0.0"\n' + 'meta-platform = "forgefirm-ext"\nfile-resource payload.tar.gz {\n host-path = "%s"\n}\n' + % (REF_ID, payload)) + key = os.path.join(work, REF_KEY) + raw, signed = os.path.join(work, "raw.ffx"), os.path.join(work, "reference.ffx") + for cmd in ([FWUP, "-g", "-o", key], [FWUP, "-c", "-f", conf, "-o", raw], + [FWUP, "-S", "-s", key + ".priv", "-i", raw, "-o", signed]): + subprocess.run(cmd, check=True, capture_output=True, timeout=60, cwd=work) + return signed, key + ".pub" + + +@test("exthost.service", title="A package's service runs confined under the extension host", + subsystem="exthost", kind="auto", hardware="takeover", est_min=5, + covers=[("forgeext", "**"), ("forgectrl", "src/main.c"), ("forgectrl", "src/logs.*")], + requires=["exthost.platform", "setup.extensions-consent"], + description="The extension host is one running process (/usr/bin/forgeext run) and the status " + "file is its word, and with extensions off it runs nothing and says why. The test builds a reference " + "package on the board, signs it with a key it makes there, and adds that key as an owner " + "key: an install without the community consent is refused, with it the package is " + "installed at the community tier. It turns extensions on over the advisory. The service " + "then runs as its pool account with no_new_privs and a seccomp filter, in its own group " + "under /sys/fs/cgroup/ffx with the limits of a service (25 percent of the core, 48 MiB, 32 " + "processes), with its chain in the rule table. From the inside it reads /etc and its " + "package, writes its data directory and nothing else, cannot read the settings file, the " + "setup record, or the pulse device, is refused by the machine on loopback and on its LAN " + "address even on the port it declared, cannot connect on a port it did not declare, and " + "cannot open a netlink socket; its output is in the forgeext log under its id. Safe mode " + "stops it and its end starts it again. A host killed outright takes its services with it " + "at once (the init wrapper), comes back, and starts the service again. ext_enabled=0 " + "stops it and leaves no group and no chain. The package, the key, the setting, and the " + "setup record are put back as found, the record under a forgectrl restart. The layer " + "content (the recipe, the init script's install, the image list) is in the platform " + "identity of every fingerprint.") +def service(ctx): + import shutil + import tempfile + fc = ctx.forgectrl + ev = ctx.evidence + ctx.check(os.path.isfile(FORGEEXT) and os.access("/etc/init.d/forgeext", os.X_OK), + "the image has no forgeext, or no init script for it") + hosts = _host_pids() + ev["host_pids"] = hosts + ctx.check(len(hosts) == 1, "the extension host is not one running process: %s", hosts) + ctx.check(_until(ctx, lambda: _host_status().get("pid") == hosts[0], 10), + "the status file %s is not the running host's: %s", HOST_STATUS, _host_status().get("pid")) + ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle: settings are locked") + prior = fc.settings().get("ext_enabled") or "" + raw = read_file(record_path()) + ctx.check(raw, "no setup record at %s", record_path()) + ctx.check(not os.path.exists(SAFE_FILE), "%s exists: the machine is in safe mode", SAFE_FILE) + ctx.check(REF_ID not in [x.get("id") for x in _forgeext("list").get("packages", [])], + "%s is already installed", REF_ID) + found_tree = _tree(EXT_ROOT) + data_dir = os.path.dirname(EXT_ROOT) + dir_mode = os.stat(data_dir).st_mode & 0o7777 + ev["data_dir_mode_found"] = "%04o" % dir_mode + if prior != "1": + st = _host_status() + ev["off"] = {k: st.get(k) for k in ("enabled", "off_reason")} + ctx.check(st.get("enabled") is False and "ext_enabled" in (st.get("off_reason") or "") + and not [x for x in st.get("services", []) if x.get("state") == "running"], + "with extensions off the host does not say so: %s", ev["off"]) + + st, body, hdrs = request(fc.base, "GET", "/advisories/extensions", headers={"Host": fc.host_header()}) + etag = hdrs.get("etag") + ctx.check(st == 200 and etag, "GET /advisories/extensions -> %s", st) + work = tempfile.mkdtemp(prefix="forgetest-ffx.") + owner_key = os.path.join(EXT_ROOT, "keys", REF_KEY + ".pub") + uid = None + + def cg(name): + return _read("%s/%s/%s" % (POOL_GROUP, REF_ID, name)).strip() + + def chains(): + return subprocess.run([NFT, "list", "table", "inet", "ffx"], capture_output=True, text=True, timeout=30).stdout + + def running(other_than=0): + # The status file outlives a killed host: a pid that is the old one is no news. + x = _svc(REF_ID) + return x if x.get("state") == "running" and x.get("pid") and x["pid"] != other_than and os.path.exists("/proc/%d" % x["pid"]) else None + + try: + lan = lan_ip() + archive, pub = _pack_reference(work, lan) + r = _forgeext("inspect", archive) + ev["inspect_without_the_key"] = r.get("tier") + ctx.check(r.get("ok") and r.get("tier") == "unverified", "before its key is the owner's the package reads %s", r) + shutil.copy(pub, owner_key) + os.chmod(owner_key, 0o644) + r = _forgeext("inspect", archive) + ctx.check(r.get("ok") and r.get("tier") == "community", "with the owner's key the package reads %s", r) + r = _forgeext("install", archive) + ev["install_without_consent"] = r.get("error") + ctx.log("install without the consent -> %s", r.get("error")) + ctx.check(r.get("ok") is False, "a community package was installed without the consent") + r = _forgeext("install", archive, "--consent-community") + ctx.check(r.get("ok") is True, "the install -> %s", r.get("error")) + ctx.check(_forgeext("check", REF_ID).get("ok") is True, "the installed tree fails its integrity check") + + st, reply = fc.post("/settings", data={"ext_enabled": "1", "advisory": etag, "phrase": SAFETY_PHRASE}) + ctx.check(st == 200, "ext_enabled=1 over the advisory -> %s %r", st, reply) + x = _until(ctx, running, 90, poll=0.5) + ev["status_at_start"] = _host_status() + ctx.check(x, "the service is not running: %s", {k: ev["status_at_start"].get(k) for k in ("enabled", "off_reason", "not_ready")} + if not _svc(REF_ID) else _svc(REF_ID)) + pid = x["pid"] + uid = POOL_FIRST + int(x["account"][3:]) + proc = _read("/proc/%d/status" % pid) + ev["service"] = {"pid": pid, "account": x["account"], "cgroup": _read("/proc/%d/cgroup" % pid).strip(), + "cpu.max": cg("cpu.max"), "memory.max": cg("memory.max"), "pids.max": cg("pids.max"), + "memory.current": cg("memory.current")} + ctx.log("the service: %s", ev["service"]) + ctx.check(("Uid:\t%d\t%d\t%d\t%d" % ((uid,) * 4)) in proc and ("Gid:\t%d\t%d\t%d\t%d" % ((uid,) * 4)) in proc, + "the service does not run as %s alone", x["account"]) + ctx.check("NoNewPrivs:\t1" in proc and "Seccomp:\t2" in proc, "no no_new_privs or no seccomp filter on the service") + ctx.check(ev["service"]["cgroup"] == "0::/ffx/" + REF_ID, "the service's group is %s", ev["service"]["cgroup"]) + ctx.check(cg("cpu.max").split() == ["25000", "100000"] and cg("memory.max") == str(48 << 20) and cg("pids.max") == "32", + "the group's limits are not a service's: %s", ev["service"]) + table = chains() + ctx.check(("chain u%d " % uid) in table and REF_DEST in table, "the service's chain is not in the rule table") + + report_path = os.path.join(EXT_ROOT, "data", REF_ID, "report.json") + ctx.check(_until(ctx, lambda: os.path.isfile(report_path), 60, poll=0.5), "the service left no report") + rep = json.loads(_read(report_path)) + ev["from_the_inside"] = rep + ctx.log("from the inside: %s", rep) + want = {"uid": uid, "gid": uid, "groups": [], "read_etc": "ok", "read_pkg": "ok", "write_data": "ok", + "read_settings": "EACCES", "read_setup": "EACCES", "write_pkg": "EACCES", "write_tmp": "EACCES", + "pulse_device": "EACCES", "loopback_declared_port": "ECONNREFUSED", + "loopback_undeclared_port": "EACCES", "unix_socket": "ok", "netlink_socket": "EPERM"} + if lan: + want["lan_declared_port"] = "ECONNREFUSED" + for k, v in want.items(): + ctx.check(rep.get(k) == v, "from the inside, %s is %r, expected %r", k, rep.get(k), v) + # LC_CTYPE is the interpreter's own doing (it coerces the C locale at its start) + fixed = {"PATH", "LANG", "HOME", "TMPDIR", "FFX_ID", "FFX_PKG", "FFX_DATA", "PYTHONDONTWRITEBYTECODE", + "PYTHONUNBUFFERED"} + ctx.check(rep.get("cwd") == os.path.join(EXT_ROOT, "data", REF_ID) and set(rep.get("env") or []) - {"LC_CTYPE"} == fixed, + "its working directory or its environment is not the fixed one: %s %s", rep.get("cwd"), rep.get("env")) + ctx.check(_until(ctx, lambda: ("ext %s: reference service up" % REF_ID) in _read(HOST_LOG), 20, poll=1), + "the service's output is not in %s under its id", HOST_LOG) + + _write(SAFE_FILE, "") + ctx.check(_until(ctx, lambda: not running() and "safe mode" in (_host_status().get("off_reason") or ""), 15), + "safe mode did not stop the service: %s", _host_status()) + ctx.check(not os.path.exists("/proc/%d" % pid) and not os.path.isdir("%s/%s" % (POOL_GROUP, REF_ID)), + "safe mode left the process or its group") + os.remove(SAFE_FILE) + x = _until(ctx, lambda: running(pid), 60, poll=0.5) + ctx.check(x, "out of safe mode the service did not start again: %s", _svc(REF_ID)) + pid = x["pid"] + ctx.log("safe mode stopped it and its end started it again (pid %d)", pid) + + host = _host_pids() + ctx.check(len(host) == 1, "the extension host is not one process: %s", host) + # Only once the service is in its quiet loop: a service still on its way up ends by + # itself when its first line meets the dead host's pipe, and that would prove nothing. + beat_path = os.path.join(EXT_ROOT, "data", REF_ID, "beat") + seen = set() + ctx.check(_until(ctx, lambda: seen.add(_read(beat_path)) or len(seen) >= 3, 30, poll=0.2), + "the service's heartbeat does not advance") + t0 = time.time() + os.kill(host[0], signal.SIGKILL) + ctx.check(_until(ctx, lambda: not os.path.exists("/proc/%d" % pid), 2, poll=0.05), + "a killed host left its service running for 2 s: nobody would freeze it in an armed window") + ev["service_outlived_a_killed_host_s"] = round(time.time() - t0, 2) + x = _until(ctx, lambda: running(pid) if _host_status().get("pid") in _host_pids() else None, 60, poll=0.5) + ev["host_back_s"] = round(time.time() - t0, 1) + ctx.check(x, "the host did not come back and start the service again: %s", _host_status()) + pid = x["pid"] + ctx.log("a killed host: its service gone in %.2f s, the host back and the service running after %.1f s", + ev["service_outlived_a_killed_host_s"], ev["host_back_s"]) + + st, reply = fc.post("/settings", data={"ext_enabled": "0"}) + ctx.check(st == 200, "ext_enabled=0 -> %s %r", st, reply) + ctx.check(_until(ctx, lambda: not running() and not os.path.exists("/proc/%d" % pid), 15), + "ext_enabled=0 did not stop the service: %s", _svc(REF_ID)) + ctx.check(not os.path.isdir("%s/%s" % (POOL_GROUP, REF_ID)) and ("chain u%d " % uid) not in chains(), + "ext_enabled=0 left the service's group or its chain") + ctx.check("ext_enabled" in (_host_status().get("off_reason") or ""), "the host does not say why nothing runs: %s", + _host_status().get("off_reason")) + finally: + if os.path.exists(SAFE_FILE): + os.remove(SAFE_FILE) + st, reply = fc.post("/settings", data={"ext_enabled": "0"}) + r = _forgeext("remove", REF_ID) + ctx.log("remove %s -> %s", REF_ID, "ok" if r.get("ok") else r.get("error")) + if os.path.exists(owner_key): + os.remove(owner_key) + shutil.rmtree(work, ignore_errors=True) + if prior == "1": + st, reply = fc.post("/settings", data={"ext_enabled": "1", "advisory": etag, "phrase": SAFETY_PHRASE}) + elif prior == "": + st, reply = fc.post("/settings", params={"ext_enabled": ""}) + ctx.log("restore ext_enabled=%r -> %s", prior, st) + if prior != "1": + os.chmod(data_dir, dir_mode) + with ctx.takeover(): + write_file(record_path(), raw) + ctx.log("the previous record is back under a restart") + + ctx.check((fc.settings().get("ext_enabled") or "") == prior, "ext_enabled not restored: %r, was %r", + fc.settings().get("ext_enabled"), prior) + ctx.check(read_file(record_path()) == raw, "the setup record on disk is not the one found") + ctx.check(prior == "1" or os.stat(data_dir).st_mode & 0o7777 == dir_mode, "the data directory's mode is not the one found") + left = _tree(EXT_ROOT) + ctx.check(left == found_tree, "the extension root is not as found: %s", sorted(set(left) ^ set(found_tree))) + ctx.check(len(_host_pids()) == 1, "the extension host is not running at the end") + diff --git a/forgetest/forgetest/suite/setup.py b/forgetest/forgetest/suite/setup.py index d4055b9..475252c 100644 --- a/forgetest/forgetest/suite/setup.py +++ b/forgetest/forgetest/suite/setup.py @@ -1011,6 +1011,146 @@ def cloud_disabled_surface(ctx): "the list did not come back as found: %s, was %s", back, e) +# ----------------------------------------------------------- extensions + +@test("setup.extensions-consent", title="Extensions are turned on over their own advisory", + subsystem="setup", kind="auto", hardware="takeover", est_min=3, + covers=[("forgectrl", "src/main.c"), ("forgectrl", "src/advisories.*"), ("forgectrl", "src/setup.*"), + ("forgectrl", "src/wiz.c"), ("forgectrl", "src/settings.*"), + ("forgectrl", "docs/advisories/extensions.md"), ("forgectrl", "src/ui/embed_docs.cmake")], + requires=["forgectrl.settings-bounds", "setup.advisories-rehash"], + description="The Extensions advisory is on demand: GET /wiz does not list it among the " + "first-run documents, GET /advisories/extensions serves it as markdown with an " + "ETag equal to the SHA-256 of the body, and the first-run accept refuses it (400) " + "and leaves the press. The test turns ext_enabled off itself and puts it back as " + "found. From off, POST /settings ext_enabled=1 is refused without the advisory's " + "hash and with a stale one (409), and without the typed phrase and with the phrase " + "in another case (400); each refusal leaves the setting at 0 and the record on " + "disk untouched. A request that carries the hash and the phrase beside a write the " + "daemon refuses (controller_mode=cloud with cloud_enabled=0, 409) is refused whole " + "and records nothing. With the " + "hash and the phrase it is accepted (200): the setting reads 1, the record on disk " + "holds on_demand.extensions with that hash and the typed method, the first-run " + "documents and their press are as they were, and the data directory has gained " + "the search bit for group and others and nothing else (a package's account walks " + "through it to its own files). Re-sending 1 while it stands and " + "sending 0 ask for nothing. The previous record is put back under a forgectrl " + "restart.") +def extensions_consent(ctx): + fc = ctx.forgectrl + ev = ctx.evidence + ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle: settings are locked") + raw = read_file(record_path()) + ctx.check(raw, "no setup record at %s", record_path()) + before = wiz(fc) + ev["before"] = wiz_summary(before) + prior = fc.settings().get("ext_enabled") or "" + dir_mode = os.stat(data_dir()).st_mode & 0o7777 + ev["found"] = {"ext_enabled": prior, "data_dir_mode": "%04o" % dir_mode} + + def on_disk(): + try: + return json.loads((read_file(record_path()) or b"{}").decode("utf-8")) + except ValueError: + return {} + + ctx.check("extensions" not in [d.get("id") for d in before.get("documents") or []], + "GET /wiz lists the Extensions advisory among the first-run documents") + st, body, hdrs = request(fc.base, "GET", "/advisories/extensions", headers={"Host": fc.host_header()}) + etag = hdrs.get("etag") + ev["etag"] = etag + ctx.log("GET /advisories/extensions -> %s, %d bytes, ETag %s", st, len(body), etag) + ctx.check(st == 200 and body, "GET /advisories/extensions -> %s", st) + ctx.check((hdrs.get("content-type") or "").startswith("text/markdown"), + "the document is not served as markdown: %r", hdrs.get("content-type")) + ctx.check(etag == hashlib.sha256(body).hexdigest(), "the ETag is not the SHA-256 of the body") + + try: + st, reply = fc.post("/wiz/advisories/accept", + data={"doc": "extensions", "hash": etag, "phrase": SAFETY_PHRASE}) + ev["first_run_accept"] = st + ctx.log("the first-run accept of the on-demand document -> %s %s", st, reply) + ctx.check(st == 400, "the first-run accept of the on-demand document -> %s, expected 400", st) + ctx.check(wiz(fc).get("acceptance_done") == before.get("acceptance_done"), + "the refused accept changed the press record") + + if prior == "1": + st, reply = fc.post("/settings", data={"ext_enabled": "0"}) + ctx.check(st == 200, "ext_enabled=0 -> %s %s", st, reply) + ctx.log("extensions turned off for the test (found %r)", prior) + ctx.check((fc.settings().get("ext_enabled") or "0") == "0", "ext_enabled does not read off") + start = on_disk() + + for name, form, want, words in ( + ("no hash", {"ext_enabled": "1"}, 409, "read the Extensions advisory first"), + ("stale hash", {"ext_enabled": "1", "advisory": "0" * 64, "phrase": SAFETY_PHRASE}, 409, + "read the Extensions advisory first"), + ("no phrase", {"ext_enabled": "1", "advisory": etag}, 400, "type I UNDERSTAND"), + ("phrase in another case", {"ext_enabled": "1", "advisory": etag, + "phrase": SAFETY_PHRASE.lower()}, 400, "type I UNDERSTAND"), + ("good consent beside a refused write", {"ext_enabled": "1", "advisory": etag, + "phrase": SAFETY_PHRASE, "cloud_enabled": "0", + "controller_mode": "cloud"}, + 409, "cloud mode is not enabled on this machine")): + st, reply = fc.post("/settings", data=form) + ev[name] = st + ctx.log("POST /settings ext_enabled=1, %s -> %s %s", name, st, reply if isinstance(reply, str) else "") + ctx.check(st == want, "%s -> %s, expected %s", name, st, want) + ctx.check(isinstance(reply, str) and words in reply, "%s was refused in other words: %r", name, reply) + ctx.check((fc.settings().get("ext_enabled") or "0") == "0", "%s: a refused write turned extensions on", name) + ctx.check(on_disk() == start, "%s: a refused write changed the record on disk", name) + + st, reply = fc.post("/settings", data={"ext_enabled": "1", "advisory": etag, "phrase": SAFETY_PHRASE}) + ev["accept"] = st + ctx.log("POST /settings ext_enabled=1 with the hash and the phrase -> %s", st) + ctx.check(st == 200, "the consent -> %s %r", st, reply) + ctx.check(fc.settings().get("ext_enabled") == "1", "ext_enabled does not read 1") + rec = on_disk() + entry = (rec.get("on_demand") or {}).get("extensions") or {} + ev["recorded"] = entry + ctx.check(entry.get("hash") == etag and entry.get("method") == "typed" and entry.get("accepted"), + "the record on disk does not carry the acceptance: %s", entry) + ctx.check("extensions" not in (rec.get("advisories") or {}), + "the acceptance landed among the first-run documents") + # a package's account walks through the data directory to its own files: the + # consent adds the search bit, and nothing else + now_mode = os.stat(data_dir()).st_mode & 0o7777 + ev["data_dir_mode_after"] = "%04o" % now_mode + ctx.check(now_mode == dir_mode | 0o011, "the data directory went from %04o to %04o, expected %04o", + dir_mode, now_mode, dir_mode | 0o011) + ctx.check(rec.get("advisories") == start.get("advisories") and rec.get("acceptance") == start.get("acceptance"), + "the first-run documents or their press moved") + after = wiz(fc) + ctx.check(after.get("acceptance_done") == before.get("acceptance_done") and after.get("gate") == before.get("gate"), + "the machine's gate moved with the consent: %s", wiz_summary(after)) + for form in ({"ext_enabled": "1"}, {"ext_enabled": "0"}): + st, reply = fc.post("/settings", data=form) + ctx.check(st == 200, "%s while on -> %s, expected 200", form, st) + ctx.check(fc.settings().get("ext_enabled") == "0", "ext_enabled=0 did not turn extensions off") + finally: + if prior == "": + st, reply = fc.post("/settings", params={"ext_enabled": ""}) + elif prior == "1": + st, reply = fc.post("/settings", data={"ext_enabled": "1", "advisory": etag, "phrase": SAFETY_PHRASE}) + else: + st, reply = fc.post("/settings", data={"ext_enabled": prior}) + ctx.log("restore ext_enabled=%r -> %s", prior, st) + if prior != "1": + os.chmod(data_dir(), dir_mode) + with ctx.takeover(): + write_file(record_path(), raw) + ctx.log("the previous record is back under a restart") + + ctx.check((fc.settings().get("ext_enabled") or "") == prior, "ext_enabled not restored: %r, was %r", + fc.settings().get("ext_enabled"), prior) + ctx.check(read_file(record_path()) == raw, "the record on disk is not the one found") + ctx.check(prior == "1" or os.stat(data_dir()).st_mode & 0o7777 == dir_mode, "the data directory's mode is not the one found") + final = wiz(fc) + ev["after_restore"] = wiz_summary(final) + ctx.check(final.get("acceptance_done") == before.get("acceptance_done") and final.get("gate") == before.get("gate"), + "the machine does not read as before: %s", wiz_summary(final)) + + # ---------------------------------------------------------- the operator @test("setup.factory-return", title="The return to the factory firmware is guarded", diff --git a/meta-forgefirm/recipes-forgefirm/forgeext/forgeext.bb b/meta-forgefirm/recipes-forgefirm/forgeext/forgeext.bb index 4d98914..0069d1c 100644 --- a/meta-forgefirm/recipes-forgefirm/forgeext/forgeext.bb +++ b/meta-forgefirm/recipes-forgefirm/forgeext/forgeext.bb @@ -12,7 +12,7 @@ require forgeext-pin.inc S = "${WORKDIR}/git" -inherit cmake pkgconfig forgefirm-manifest +inherit cmake pkgconfig update-rc.d forgefirm-manifest # jansson (the manifest, state.json, every answer), libarchive (the .ffx and # its payload, read streaming), libsodium (the archive's signature and the @@ -23,3 +23,13 @@ DEPENDS += "jansson libarchive libsodium" # extension is never signed with; forgefirm-sandbox is the account pool, # the cgroup tree, and the deny rules a package's service runs inside. RDEPENDS:${PN} = "fwup forgefirm-keys forgefirm-sandbox" + +# After forgectrl (90), whose read-only routes the host takes the machine's +# state from, and down before it. +INITSCRIPT_NAME = "forgeext" +INITSCRIPT_PARAMS = "start 91 2 3 4 5 . stop 9 0 1 6 ." + +do_install:append() { + install -d ${D}${sysconfdir}/init.d + install -m 0755 ${S}/init/forgeext.init ${D}${sysconfdir}/init.d/forgeext +} diff --git a/meta-forgefirm/recipes-forgefirm/images/forgefirm-image.bb b/meta-forgefirm/recipes-forgefirm/images/forgefirm-image.bb index ee6fa89..18c76cb 100644 --- a/meta-forgefirm/recipes-forgefirm/images/forgefirm-image.bb +++ b/meta-forgefirm/recipes-forgefirm/images/forgefirm-image.bb @@ -68,8 +68,10 @@ IMAGE_INSTALL:append = " forgefirm-users forgefirm-hostname forgefirm-banner for # any package exists: the ffx account pool, the cgroup v2 tree with the cpu, # memory, and pids controllers, and the nftables rules (nft comes with it) # that refuse everything a pool uid sends, loaded from rcS before the -# network starts. -IMAGE_INSTALL:append = " forgefirm-sandbox" +# network starts. forgeext: the extension host that verifies, installs, +# and runs packages inside it. It starts after forgectrl and runs nothing +# while ext_enabled is 0, which is the default. +IMAGE_INSTALL:append = " forgefirm-sandbox forgeext" # The rootfs mounts read-only on both images; /data (p3) is the writable # partition. read-only-rootfs is poky's feature for it: the root line of