mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-29 01:21:16 -07:00
exthost: the reference package uses its API socket, and raises its own hold
The reference service talks to the host over the socket named in FFX_API and takes machine.read. exthost.service adds, from the inside: GET /v0/self names the package and what it may use; GET /v0/machine/mode is forgectrl's answer; a hold it was not granted is 403, a path the API does not have 404, a path with .. 400; FFX_API is in the fixed environment. From the outside: the socket is root's and the account's at 0660, and another pool account that connects to it gets EACCES (a new probe mode). exthost.hold-pause-tier adds the package's own word. The test leaves what to say in the service's data directory and the service passes it on as POST /v0/hold: raised, the verdict is EXT with the package's words and fire withheld; words the form does not take are refused and the hold reads as before; cleared, the verdict is OK. Then the rest as before: the host's own raise for a package that ends at every start, the operator's exits, the stale file under a suspended host. Proven. The unit suite: 451 tests, 0 undefined names. On the bench reference, image 20260921130558 with the cross-built host mounted over the image's: exthost.service PASS, exthost.hold-pause-tier PASS (the package's hold stood 1.5 s after it raised it; held 2.0 s after the service was killed; safe mode released it in 0.8 s, extensions off in 1.1 s), exthost.armed-freeze PASS with the changed reference service. Against the image's own host, which gives a service no socket, exthost.service FAILS, as it should. Acceptance. Both tests already cover forgeext whole (exthost.service) and the hold's files by name (exthost.hold-pause-tier); neither covers map changes.
This commit is contained in:
@@ -8,6 +8,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import signal
|
import signal
|
||||||
|
import stat
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
@@ -99,6 +100,15 @@ elif mode == "eat":
|
|||||||
say(survived=True)
|
say(survived=True)
|
||||||
elif mode == "net":
|
elif mode == "net":
|
||||||
say(results=[[t, reach(*t)] for t in json.loads(arg)])
|
say(results=[[t, reach(*t)] for t in json.loads(arg)])
|
||||||
|
elif mode == "unix":
|
||||||
|
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
|
s.settimeout(4.0)
|
||||||
|
try:
|
||||||
|
s.connect(arg)
|
||||||
|
say(result="connected")
|
||||||
|
except OSError as e:
|
||||||
|
say(result=word(e))
|
||||||
|
s.close()
|
||||||
elif mode == "landlock":
|
elif mode == "landlock":
|
||||||
target = json.loads(arg)
|
target = json.loads(arg)
|
||||||
before = {"etc": None, "usr": None, "tcp": reach(*target)[0]}
|
before = {"etc": None, "usr": None, "tcp": reach(*target)[0]}
|
||||||
@@ -476,6 +486,7 @@ EXT_ROOT = "/data/forgefirm/ext"
|
|||||||
HOST_STATUS = "/run/forgefirm/ext/status.json"
|
HOST_STATUS = "/run/forgefirm/ext/status.json"
|
||||||
SAFE_FILE = "/run/forgefirm/ext-safe"
|
SAFE_FILE = "/run/forgefirm/ext-safe"
|
||||||
HOST_LOG = "/data/log/forgefirm/forgeext/forgeext.log"
|
HOST_LOG = "/data/log/forgefirm/forgeext/forgeext.log"
|
||||||
|
API_DIR = "/run/forgefirm/ext/api"
|
||||||
REF_ID = "org.forgetest.reference"
|
REF_ID = "org.forgetest.reference"
|
||||||
REF_KEY = "forgetest-reference"
|
REF_KEY = "forgetest-reference"
|
||||||
REF_DEST = "192.0.2.1" # TEST-NET-1: declared so that port 443 is, and never dialed
|
REF_DEST = "192.0.2.1" # TEST-NET-1: declared so that port 443 is, and never dialed
|
||||||
@@ -494,6 +505,31 @@ def word(e):
|
|||||||
return errno.errorcode.get(e.errno, str(e.errno))
|
return errno.errorcode.get(e.errno, str(e.errno))
|
||||||
|
|
||||||
|
|
||||||
|
def api(method, path, body=None):
|
||||||
|
"""One request to the host over the socket named in FFX_API: (status, JSON)."""
|
||||||
|
payload = json.dumps(body).encode() if body is not None else b""
|
||||||
|
head = "%s %s HTTP/1.1\r\nHost: forgeext\r\n" % (method, path)
|
||||||
|
if body is not None:
|
||||||
|
head += "Content-Type: application/json\r\nContent-Length: %d\r\n" % len(payload)
|
||||||
|
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
|
s.settimeout(5)
|
||||||
|
try:
|
||||||
|
s.connect(os.environ["FFX_API"])
|
||||||
|
s.sendall(head.encode() + b"\r\n" + payload)
|
||||||
|
buf = b""
|
||||||
|
while True:
|
||||||
|
c = s.recv(65536)
|
||||||
|
if not c:
|
||||||
|
break
|
||||||
|
buf += c
|
||||||
|
top, _, text = buf.partition(b"\r\n\r\n")
|
||||||
|
return int(top.split()[1]), json.loads(text or b"null")
|
||||||
|
except (OSError, ValueError, IndexError) as e:
|
||||||
|
return 599, {"error": str(e)}
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
|
||||||
def opened(path, mode="r"):
|
def opened(path, mode="r"):
|
||||||
try:
|
try:
|
||||||
with open(path, mode) as f:
|
with open(path, mode) as f:
|
||||||
@@ -544,6 +580,11 @@ report = {
|
|||||||
"loopback_undeclared_port": dial("127.0.0.1", 80),
|
"loopback_undeclared_port": dial("127.0.0.1", 80),
|
||||||
"netlink_socket": family(16),
|
"netlink_socket": family(16),
|
||||||
"unix_socket": family(1),
|
"unix_socket": family(1),
|
||||||
|
"api_self": api("GET", "/v0/self"),
|
||||||
|
"api_mode": api("GET", "/v0/machine/mode"),
|
||||||
|
"api_hold": api("GET", "/v0/hold"),
|
||||||
|
"api_nowhere": api("GET", "/v0/nowhere"),
|
||||||
|
"api_traversal": api("GET", "/v0/machine/../../settings"),
|
||||||
}
|
}
|
||||||
with open(os.path.join(data, "report.json.new"), "w") as f:
|
with open(os.path.join(data, "report.json.new"), "w") as f:
|
||||||
json.dump(report, f)
|
json.dump(report, f)
|
||||||
@@ -554,6 +595,15 @@ while True:
|
|||||||
n += 1
|
n += 1
|
||||||
with open(os.path.join(data, "beat"), "w") as f:
|
with open(os.path.join(data, "beat"), "w") as f:
|
||||||
f.write(str(n))
|
f.write(str(n))
|
||||||
|
# the test's word to the service: what to say of its hold
|
||||||
|
say = os.path.join(data, "say")
|
||||||
|
if os.path.exists(say):
|
||||||
|
with open(say) as f:
|
||||||
|
words = json.load(f)
|
||||||
|
os.remove(say)
|
||||||
|
with open(os.path.join(data, "said.new"), "w") as f:
|
||||||
|
json.dump(api("POST", "/v0/hold", words), f)
|
||||||
|
os.rename(os.path.join(data, "said.new"), os.path.join(data, "said"))
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
'''
|
'''
|
||||||
|
|
||||||
@@ -626,7 +676,7 @@ def _pack_reference(work, lan, more_caps=()):
|
|||||||
manifest = {"manifest": 1, "id": REF_ID, "name": "forgetest reference", "version": "1.0.0",
|
manifest = {"manifest": 1, "id": REF_ID, "name": "forgetest reference", "version": "1.0.0",
|
||||||
"author": "forgetest", "license": "MIT", "api": "0.1", "runtime": "python",
|
"author": "forgetest", "license": "MIT", "api": "0.1", "runtime": "python",
|
||||||
"service": {"exec": "bin/reference.py", "args": [lan or "-"]},
|
"service": {"exec": "bin/reference.py", "args": [lan or "-"]},
|
||||||
"capabilities": ["net.outbound:%s:443" % REF_DEST, "storage:1"] + list(more_caps)}
|
"capabilities": ["net.outbound:%s:443" % REF_DEST, "storage:1", "machine.read"] + list(more_caps)}
|
||||||
payload = os.path.join(work, "payload.tar.gz")
|
payload = os.path.join(work, "payload.tar.gz")
|
||||||
with tarfile.open(payload, "w:gz") as t:
|
with tarfile.open(payload, "w:gz") as t:
|
||||||
for name, text, mode in (("manifest.json", json.dumps(manifest), 0o644),
|
for name, text, mode in (("manifest.json", json.dumps(manifest), 0o644),
|
||||||
@@ -700,7 +750,10 @@ def _as_found(ctx, fc, prior, raw, dir_mode, found_tree):
|
|||||||
"package, writes its data directory and nothing else, cannot read the settings file, the "
|
"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 "
|
"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 "
|
"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 "
|
"cannot open a netlink socket. Its one way to the machine is its API socket (root's and its "
|
||||||
|
"account's, 0660, refused to another pool account): GET /v0/self names it and what it may use, "
|
||||||
|
"GET /v0/machine/mode is forgectrl's answer relayed, a hold it was not granted is 403, a path "
|
||||||
|
"the API does not have 404, a path with .. 400; 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 "
|
"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 "
|
"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 "
|
"stops it and leaves no group and no chain. The package, the key, the setting, and the "
|
||||||
@@ -809,10 +862,31 @@ def service(ctx):
|
|||||||
for k, v in want.items():
|
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)
|
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)
|
# 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",
|
fixed = {"PATH", "LANG", "HOME", "TMPDIR", "FFX_ID", "FFX_PKG", "FFX_DATA", "FFX_API", "PYTHONDONTWRITEBYTECODE",
|
||||||
"PYTHONUNBUFFERED"}
|
"PYTHONUNBUFFERED"}
|
||||||
ctx.check(rep.get("cwd") == os.path.join(EXT_ROOT, "data", REF_ID) and set(rep.get("env") or []) - {"LC_CTYPE"} == fixed,
|
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"))
|
"its working directory or its environment is not the fixed one: %s %s", rep.get("cwd"), rep.get("env"))
|
||||||
|
# its one way to the machine: the API socket, and the broker behind it
|
||||||
|
me = rep.get("api_self") or [0, {}]
|
||||||
|
ev["api"] = {k: rep.get(k) for k in ("api_self", "api_mode", "api_hold", "api_nowhere", "api_traversal")}
|
||||||
|
ctx.check(me[0] == 200 and me[1].get("id") == REF_ID and me[1].get("version") == "1.0.0"
|
||||||
|
and sorted(me[1].get("capabilities") or []) == sorted(["net.outbound:%s:443" % REF_DEST, "storage:1", "machine.read"]),
|
||||||
|
"GET /v0/self from the inside: %s", me)
|
||||||
|
mode_ = rep.get("api_mode") or [0, {}]
|
||||||
|
ctx.check(mode_[0] == 200 and mode_[1].get("mode") in ("grbl", "cloud") and "controller" in mode_[1],
|
||||||
|
"GET /v0/machine/mode from the inside is not forgectrl's answer: %s", mode_)
|
||||||
|
ctx.check((rep.get("api_hold") or [0])[0] == 403 and (rep.get("api_nowhere") or [0])[0] == 404
|
||||||
|
and (rep.get("api_traversal") or [0])[0] == 400,
|
||||||
|
"a hold it was not granted, a path the API does not have, a path with ..: %s %s %s",
|
||||||
|
rep.get("api_hold"), rep.get("api_nowhere"), rep.get("api_traversal"))
|
||||||
|
sock = "%s/%s.sock" % (API_DIR, REF_ID)
|
||||||
|
st_ = os.stat(sock)
|
||||||
|
ctx.check(stat.S_ISSOCK(st_.st_mode) and stat.S_IMODE(st_.st_mode) == 0o660 and (st_.st_uid, st_.st_gid) == (0, uid),
|
||||||
|
"its API socket is not root's and its account's at 0660: %o %d:%d", stat.S_IMODE(st_.st_mode), st_.st_uid, st_.st_gid)
|
||||||
|
other = POOL_FIRST + POOL_SIZE - 1 if uid != POOL_FIRST + POOL_SIZE - 1 else POOL_FIRST
|
||||||
|
rc, lines, errtext = _probe("-", other, "unix", sock)
|
||||||
|
ev["another_account_at_its_socket"] = lines
|
||||||
|
ctx.check(lines and lines[0].get("result") == "eacces", "another pool account at its socket: %s %s", lines, errtext)
|
||||||
ctx.check(_until(ctx, lambda: ("ext %s: reference service up" % REF_ID) in _read(HOST_LOG), 20, poll=1),
|
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)
|
"the service's output is not in %s under its id", HOST_LOG)
|
||||||
|
|
||||||
@@ -995,7 +1069,9 @@ REQUIRED_HOLDS = EXT_ROOT + "/required-holds"
|
|||||||
description="The reference package is installed with the hold grant and its hold is marked required "
|
description="The reference package is installed with the hold grant and its hold is marked required "
|
||||||
"(the package is then named under required-holds for forgectrl). Until its service has "
|
"(the package is then named under required-holds for forgectrl). Until its service has "
|
||||||
"run healthy (60 s) the required hold stands in the host's words; from then on the host "
|
"run healthy (60 s) the required hold stands in the host's words; from then on the host "
|
||||||
"keeps its hold file fresh and clear and the engine's verdict is OK. The test then "
|
"keeps its hold file fresh and clear and the engine's verdict is OK. The package then raises "
|
||||||
|
"its hold itself over its API socket (POST /v0/hold): EXT with the package's own words, "
|
||||||
|
"words the form does not take refused with the hold unchanged, and cleared again. The test then "
|
||||||
"makes the service end at every start: the host raises the hold in its own words and GET "
|
"makes the service end at every start: the host raises the hold in its own words and GET "
|
||||||
"/cool/status reads verdict EXT, fire_ok false, hold true, with the reason naming the "
|
"/cool/status reads verdict EXT, fire_ok false, hold true, with the reason naming the "
|
||||||
"package. Safe mode ends the hold within two ticks and leaving it brings the hold back; "
|
"package. Safe mode ends the hold within two ticks and leaving it brings the hold back; "
|
||||||
@@ -1080,6 +1156,29 @@ def hold_pause_tier(ctx):
|
|||||||
ctx.check(h and h["raised"] is False and h["age"] < 1.5, "healthy: the hold file is not fresh and clear: %s", h)
|
ctx.check(h and h["raised"] is False and h["age"] < 1.5, "healthy: the hold file is not fresh and clear: %s", h)
|
||||||
ctx.check(_svc(REF_ID).get("hold") == "required", "the status file does not say the hold is required: %s", _svc(REF_ID))
|
ctx.check(_svc(REF_ID).get("hold") == "required", "the status file does not say the hold is required: %s", _svc(REF_ID))
|
||||||
|
|
||||||
|
# the package's own word, over its API socket: raised in its words, and cleared
|
||||||
|
def tell(words):
|
||||||
|
said = os.path.join(EXT_ROOT, "data", REF_ID, "said")
|
||||||
|
if os.path.exists(said):
|
||||||
|
os.remove(said)
|
||||||
|
_write(os.path.join(EXT_ROOT, "data", REF_ID, "say"), json.dumps(words))
|
||||||
|
ctx.check(_until(ctx, lambda: os.path.exists(said), 10), "the service did not pass %s on", words)
|
||||||
|
return json.loads(_read(said))
|
||||||
|
|
||||||
|
r = tell({"raised": True, "reason": "no badge presented"})
|
||||||
|
ctx.check(r[0] == 200 and r[1] == {"raised": True, "reason": "no badge presented"}, "POST /v0/hold raise -> %s", r)
|
||||||
|
t0 = time.time()
|
||||||
|
c = verdict_is("EXT", 6, REF_ID + ": no badge presented")
|
||||||
|
ev["raised_by_the_package"] = {"after_s": round(time.time() - t0, 1), "cool": {k: (c or cool()).get(k) for k in ("verdict", "fire_ok", "hold", "reason")}}
|
||||||
|
ctx.log("the package raised its hold: %s", ev["raised_by_the_package"])
|
||||||
|
ctx.check(c and c.get("fire_ok") is False and c.get("hold") is True, "the package's own hold does not withhold fire: %s", c or cool())
|
||||||
|
r = tell({"raised": True, "reason": "a \"quote\""})
|
||||||
|
ctx.check(r[0] == 400 and verdict_is("EXT", 3, REF_ID + ": no badge presented"), "words the form does not take -> %s, and the "
|
||||||
|
"hold now reads %s", r, cool().get("reason"))
|
||||||
|
r = tell({"raised": False})
|
||||||
|
ctx.check(r[0] == 200 and r[1].get("raised") is False and verdict_is("OK", 6), "POST /v0/hold clear -> %s, verdict %s", r,
|
||||||
|
cool().get("verdict"))
|
||||||
|
|
||||||
# the package can no longer speak for itself
|
# the package can no longer speak for itself
|
||||||
_write(stop_path, "")
|
_write(stop_path, "")
|
||||||
os.kill(_svc(REF_ID)["pid"], signal.SIGKILL)
|
os.kill(_svc(REF_ID)["pid"], signal.SIGKILL)
|
||||||
|
|||||||
Reference in New Issue
Block a user