exthost.operator-destinations: the operator names where a package may connect

A new catalog test in a module of its own (suite/extdest.py). It installs
the reference package's id and key asking for net.outbound.operator, with
a service that reports at every start what GET /v0/self says it may
reach and what one dial to the machine's DNS resolver on TCP port 53 does,
and one to the port above it. The resolver is an address every network
gives the machine, that is not the machine, and that answers; named by
address, it gives the service no resolver of its own. It holds: before
anything is named, no destination and the dial refused inside the
sandbox (EACCES), and no rule for it in the package's chain; the machine's
LAN address and loopback refused in the host's words, a destination out
of form 400, an unnamed removal refused; named through POST /ext/dest,
the service started again with the destination, connected, still refused
one port up, and the chain carrying the rule, with no crash; taken away,
started again and refused again, the rule gone. A resolver that takes no
TCP connection even from root fails the test in words rather than passing
it on nothing. covers: forgeext src/install.*, state.*, run.*, super.*,
api.*, caps.*, manifest.*, netrules.*, main.c; forgectrl src/extpkg.*,
main.c.

Proof: on the bench reference (image 20260922225653, with forgeext
28f829e and forgectrl e2d0d05 cross-built and bind-mounted, and this
suite bind-mounted over the installed one), exthost.operator-destinations
PASS in 22 s, with exthost.service, exthost.page-call,
exthost.ui-delivery, exthost.package-routes, exthost.events, and
exthost.panel-install PASS in the same campaign. The first run of it
failed in its own reading of /etc/resolv.conf (bytes against text); the
fix is in the test's module alone, and the PASS is on the fixed module.
This commit is contained in:
ScottW514
2026-09-22 23:15:37 -04:00
parent 861f5597e6
commit fab8920cd9
2 changed files with 238 additions and 0 deletions
+1
View File
@@ -23,3 +23,4 @@ from . import cloud # noqa: F401,E402
from . import exthost # noqa: F401,E402
from . import extcore # noqa: F401,E402
from . import extcall # noqa: F401,E402
from . import extdest # noqa: F401,E402
+237
View File
@@ -0,0 +1,237 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""The destinations the operator names for a package that asks for them.
Its own module rather than a test in exthost.py, for the reason extcore.py
gives. The package here is the reference package's id and key with a
service of its own, so exthost's put-back takes it away like the others.
What it dials is the machine's own DNS resolver on TCP port 53: an address
every machine on a network has, that is not the machine, and that answers.
Named by address, it gives the service no resolver of its own, so the
only way through to it is the one the operator named.
"""
import json
import os
import socket
import subprocess
from ..catalog import test
from .exthost import (EXT_ROOT, FWUP, NFT, REF_ID, REF_KEY, _as_found, _forgeext, _put_back, _read, _svc, _tree,
_until, _write)
from .forgectrl import lan_ip
from .setup import SAFETY_PHRASE, read_file, record_path, request
# The service: at every start, where it may connect and what one dial does.
DEST_SERVICE = r'''
import errno, json, os, socket, sys, time
data, target, port = os.environ["FFX_DATA"], sys.argv[1], int(sys.argv[2])
def api(method, path):
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.settimeout(5)
try:
s.connect(os.environ["FFX_API"])
s.sendall(("%s %s HTTP/1.1\r\nHost: forgeext\r\n\r\n" % (method, path)).encode())
buf = b""
while True:
c = s.recv(65536)
if not c:
break
buf += c
return json.loads(buf.partition(b"\r\n\r\n")[2] or b"null")
finally:
s.close()
def dial(p):
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(4)
try:
s.connect((target, p))
return "connected"
except socket.timeout:
return "timeout"
except OSError as e:
return errno.errorcode.get(e.errno, str(e.errno))
finally:
s.close()
report = {"pid": os.getpid(), "destinations": (api("GET", "/v0/self") or {}).get("destinations"),
"dial": dial(port), "dial_other_port": dial(port + 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("destinations service up: %s" % report, flush=True)
while True:
time.sleep(60)
'''
def _resolver():
"""The first IPv4 nameserver of /etc/resolv.conf, or None."""
for line in _read("/etc/resolv.conf").splitlines():
parts = line.split()
if len(parts) >= 2 and parts[0] == "nameserver" and parts[1].count(".") == 3:
return parts[1]
return None
def _pack_dest(work, target, port):
"""The reference package's id and key, asking for the operator's destinations: (archive, public key)."""
import io
import tarfile
manifest = {"manifest": 1, "id": REF_ID, "name": "forgetest destinations", "version": "1.0.0",
"author": "forgetest", "license": "MIT", "api": "0.1", "runtime": "python",
"service": {"exec": "bin/dest.py", "args": [target, str(port)]},
"capabilities": ["net.outbound.operator"]}
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/dest.py", DEST_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, "dest.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.operator-destinations", title="The operator names where a package may connect, and takes it away",
subsystem="exthost", kind="auto", hardware="api", est_min=4,
covers=[("forgeext", "src/install.*"), ("forgeext", "src/state.*"), ("forgeext", "src/run.*"),
("forgeext", "src/super.*"), ("forgeext", "src/api.*"), ("forgeext", "src/caps.*"),
("forgeext", "src/manifest.*"), ("forgeext", "src/netrules.*"), ("forgeext", "src/main.c"),
("forgectrl", "src/extpkg.*"), ("forgectrl", "src/main.c")],
requires=["exthost.service"],
description="A package that asks for net.outbound.operator may reach the places the operator names for it "
"and nothing else. Installed, it has none: its own GET /v0/self names no destination and its "
"dial to the machine's DNS resolver on TCP port 53 is refused inside the sandbox (EACCES). "
"The operator names that resolver through POST /ext/dest; the host starts the service again "
"(said in its log, and no crash), its answer names the destination, its dial connects, the "
"next port up is still refused, and the account's chain in the rule table carries the "
"address. The machine's own LAN address and loopback are refused in words, a destination "
"out of form is 400, and taking away one that was never named is refused. Taken away, the "
"service starts again with no destination, the dial is refused again, and the chain no longer "
"carries the address. The package, the key, the setting, and the setup record are put back as "
"found.")
def operator_destinations(ctx):
import shutil
import tempfile
fc = ctx.forgectrl
ev = ctx.evidence
target = _resolver()
ctx.check(target, "/etc/resolv.conf names no IPv4 resolver: there is nothing to dial")
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(4)
try:
s.connect((target, 53))
reachable = True
except OSError as e:
reachable = str(e)
finally:
s.close()
ev["resolver"] = {"address": target, "tcp_53_from_root": reachable}
ctx.check(reachable is True, "the resolver %s does not take a TCP connection on port 53 even from outside the "
"sandbox (%s): this network cannot show a way out opening", target, reachable)
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())
found_tree = _tree(EXT_ROOT)
dir_mode = os.stat(os.path.dirname(EXT_ROOT)).st_mode & 0o7777
st, body, hdrs = request(fc.base, "GET", "/advisories/extensions", headers={"Host": fc.host_header()})
etag = hdrs.get("etag")
work = tempfile.mkdtemp(prefix="forgetest-dest.")
owner_key = os.path.join(EXT_ROOT, "keys", REF_KEY + ".pub")
report = os.path.join(EXT_ROOT, "data", REF_ID, "report.json")
where = "%s:53" % target
def seen():
try:
with open(report) as f:
return json.load(f)
except (OSError, ValueError):
return None
def fresh(than):
r = seen()
return r if r and r.get("pid") != than and _svc(REF_ID).get("state") == "running" else None
def chain_has(addr):
"""Does the package's own chain let it through to addr on TCP 53? No chain is no."""
acct = _svc(REF_ID).get("account") or ""
if not acct.startswith("ffx"):
return False
out = subprocess.run([NFT, "list", "chain", "inet", "ffx", "u%d" % (800 + int(acct[3:]))], capture_output=True,
text=True, timeout=30).stdout
return ("daddr %s tcp dport 53 accept" % addr) in out
try:
archive, pub = _pack_dest(work, target, 53)
shutil.copy(pub, owner_key)
os.chmod(owner_key, 0o644)
r = _forgeext("install", archive, "--consent-community")
ctx.check(r.get("ok") is True, "the install of a package asking for the operator's destinations -> %s",
r.get("error"))
st, reply = fc.post("/settings", data={"ext_enabled": "1", "advisory": etag, "phrase": SAFETY_PHRASE})
ctx.check(st == 200, "ext_enabled=1 -> %s %r", st, reply)
first = _until(ctx, lambda: fresh(None), 60)
ctx.check(first, "the service did not come up: %s", _svc(REF_ID))
ev["none_named"] = first
ctx.log("none named: %s", first)
ctx.check(first.get("destinations") == [] and first.get("dial") == "EACCES",
"with nothing named it may reach nothing, and the sandbox refuses the dial: %s", first)
ctx.check(not chain_has(target), "the rule table carries %s before the operator named it", target)
# Refusals, in the host's words or the relay's.
refusals = []
for dest, want, words in (("%s:80" % lan_ip(), 409, "is this machine"), ("127.0.0.1:80", 409, "is this machine"),
("--root", 400, "host:port"), ("%s" % target, 409, "host:port")):
st_, why = fc.post("/ext/dest", data={"id": REF_ID, "action": "add", "dest": dest})
refusals.append([dest, st_, why])
ctx.check(st_ == want and words in json.dumps(why), "add %s -> %s %s, wanted %s %s", dest, st_, why, want, words)
st_, why = fc.post("/ext/dest", data={"id": REF_ID, "action": "remove", "dest": where})
refusals.append(["remove " + where, st_, why])
ctx.check(st_ == 409 and "is not one of" in json.dumps(why), "taking away one never named -> %s %s", st_, why)
ev["refusals"] = refusals
# Named: started again with its way out.
st_, doc = fc.post("/ext/dest", data={"id": REF_ID, "action": "add", "dest": where})
mine = [p for p in (doc or {}).get("packages", []) if p.get("id") == REF_ID] if isinstance(doc, dict) else []
ctx.check(st_ == 200 and mine and mine[0].get("destinations") == [where], "POST /ext/dest add %s -> %s %s",
where, st_, doc if st_ != 200 else mine)
named = _until(ctx, lambda: fresh(first.get("pid")), 60)
ev["named"] = named
ctx.log("named: %s", named)
ctx.check(named and named.get("destinations") == [where] and named.get("dial") == "connected"
and named.get("dial_other_port") == "EACCES",
"named, it is started again, names it, reaches it, and still not the next port: %s", named)
ctx.check(chain_has(target), "the rule table does not carry %s once named", target)
svc = _svc(REF_ID)
ctx.check(svc.get("state") == "running" and "quarantined" not in (svc.get("reason") or ""),
"the restart was no crash: %s", svc)
# Taken away: started again with none.
st_, doc = fc.post("/ext/dest", data={"id": REF_ID, "action": "remove", "dest": where})
ctx.check(st_ == 200, "POST /ext/dest remove %s -> %s %s", where, st_, doc)
gone = _until(ctx, lambda: fresh(named.get("pid") if named else None), 60)
ev["taken_away"] = gone
ctx.check(gone and gone.get("destinations") == [] and gone.get("dial") == "EACCES",
"taken away, it starts again and reaches nothing: %s", gone)
ctx.check(not chain_has(target), "the rule table still carries %s after it was taken away", target)
finally:
_put_back(ctx, fc, work, prior, etag, raw, dir_mode)
_as_found(ctx, fc, prior, raw, dir_mode, found_tree)