Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d83316326e | |||
| e06ed97fa8 | |||
| e4b5cf9693 | |||
| faa3cad92e | |||
| fdd87178d2 | |||
| 0342c7bb57 |
@@ -1,3 +1,86 @@
|
||||
## unreleased — comments and the retention record only, no binary change (burn-down 2026-10-05: R-291, R-348)
|
||||
|
||||
- **R-291:** `scripts/retention-policy.json` names where its 10 comes from — the R-267 newest-10 prune of generic
|
||||
packages, established 2026-08-10 (R-287) — instead of „observed, no located ruling"; the non-existent
|
||||
`registry-retention.md` reader is dropped. `check-published-versions.py` still reads 10 (checked).
|
||||
- **R-348:** `internal/backup/store.go` no longer says backups are „unaffected" by a restart: the reported backup list
|
||||
reads 0 until the next backup runs; only the hub's verdict (7-day look-back) is unaffected.
|
||||
|
||||
## v0.146.1 — R-861 review fixes: the signed update flips a root-owned copy; no Wants=/continuations in mount units; the escrow read follows no symlink anywhere (2026-10-05)
|
||||
|
||||
Released by `scripts/release-agent.sh`: binary sha256 `badd6c9a2e40c8bfe856d2d1a203443b21b7eb92ecc35d6090ab44518d4d082a`,
|
||||
config bundle sha256 `42333e969028867ad8142335e6c1bc4040eec231de0d8d330c2d4b2cf7bc3442`. **Supersedes v0.146.0, which
|
||||
was released but never vouched or delivered to any box.** The same order applies: signed `agent_update` first, then the
|
||||
signed `agent_config_update`.
|
||||
|
||||
**Delivery needs a STEP bundle (R-880, found while delivering).** An installed `felhom-os-apply` checks an incoming
|
||||
bundle's paths against its OWN table (R16), so every box on the v0.145.0 bundle REFUSES the v0.146.1 bundle (it adds 4
|
||||
paths). `scripts/build-step-bundle.py` builds the transition: the box's current bundle with ONLY `felhom-os-apply`
|
||||
replaced (same paths — the old wrapper accepts it), published as bundle version `0.146.1-step1`; then the release's own
|
||||
bundle. Order on a box: `agent_update` 0.146.1 → `agent_config_update` 0.146.1-step1 → `agent_config_update` 0.146.1.
|
||||
Tests `StepBundle` (the R16 refusal reproduced; the step accepted; exactly one file changed). Tooling only — not in the
|
||||
binary or the bundle.
|
||||
|
||||
A background security review of the v0.146.0 commit found three holes in the new code; each is fixed and red-proved
|
||||
(`felhom.eu/documentation/audits/hub-safety-2026-10-05/partF/red-proof.txt`, S1–S3):
|
||||
|
||||
- **S1 — a race in the signed update.** `felhom-os-apply` hashed the agent's staged file and then let the A/B wrapper copy
|
||||
it BY PATH; the agent owns that directory and could swap the file in between. Now the root step reads the file ONCE
|
||||
(`read_staged_once`: O_NOFOLLOW, fstat, owner, size), hashes those bytes, writes them to a root-owned directory
|
||||
(`/var/lib/felhom-os-apply/agent-update/`) and hands ONLY that copy to `felhom-selfupdate-guarded apply`, which now
|
||||
refuses any other directory, a symlink, or a file not owned by root. Tests: `AgentUpdate` (+1),
|
||||
`SelfupdateWrapperConfinement`.
|
||||
- **S2 — an allowlist escape in `felhom-priv-apply`.** `[Unit]` accepted `Wants=`/`Requires=`/`Before=` naming any unit, so
|
||||
a mount unit could start e.g. `reboot.target`. `[Unit]` now holds only `Description` and `After=local-fs-pre.target`
|
||||
(what the renderers write), and any line ending in a backslash (a systemd continuation this parser would read
|
||||
differently) is refused. Tests `test_U2_wants_starts_another_unit`, `test_U2_continuation_line`.
|
||||
- **S3 — a path traversal in the escrow read.** `O_NOFOLLOW` guards only the last component; a symlinked DIRECTORY in the
|
||||
agent's own state dir still redirected the root read. `readStagedNoFollow` now walks the path from `/` with
|
||||
`openat(O_NOFOLLOW)` per component. Test `TestAttach_RefusesASymlinkedDirectory`.
|
||||
|
||||
## v0.146.0 — the agent's root grants narrowed: exact sudo patterns, a root content checker, fixed files from the bundle, the signed update checked as root (R-861) (2026-10-05)
|
||||
|
||||
Released by `scripts/release-agent.sh`: binary sha256 `b860af465076041e07f35fed1b12d64ae2b2985d8995f0ce167418d39c2b00d5`,
|
||||
config bundle sha256 `161c737e523aa7910cf32ce41b83f989569bee55b8c5938e7211c92aef68548e`. **Order on a box: the signed
|
||||
`agent_update` FIRST (the old bundle still grants the old flip), then the signed `agent_config_update`.** Between the
|
||||
two (minutes) the new agent's checker calls are refused and retried; nothing is lost. After the bundle, an agent BELOW
|
||||
0.146.0 cannot update itself on that box any more (the unsigned flip grant is gone) — deliver both together.
|
||||
|
||||
Design: `felhom.eu/documentation/architecture/03-host-agent.md` §3.1 (new). Measured before the change (real sudo
|
||||
1.9.16, a throwaway container): the v0.145.0 sudoers let **23 of 29** attack command lines through; v0.146.0 lets
|
||||
**0** through and still allows all **64** commands the agent's capability check uses.
|
||||
|
||||
- **Exact patterns.** A sudoers `*` in the arguments also matches spaces: `pct set [0-9]* -onboot 1` matched
|
||||
`pct set 100 --dev0 /dev/sda -onboot 1` (a raw host disk for a guest), `mount --bind /mnt/*/felhom-data
|
||||
/mnt/felhom-drives/*` matched a `..` path onto `/etc/sudoers.d`, `nft add element … *` took a chained `; flush
|
||||
ruleset`. Every varying argument list is now a sudo regex (`^…$`): one value per slot, a fixed character set, no
|
||||
`..`, no extra argument. `TestSudoersRefusesTheR861Injections` (29 attacks) + `TestManifestCoveredBySudoers`
|
||||
(regex-aware now).
|
||||
- **`felhom-priv-apply`** (new root wrapper, in the bundle). A systemd mount/automount unit, a dnsmasq drop-in, the
|
||||
WireGuard config and the OOB sshd config + felhom-op key reach their root-read places only through it: fixed source,
|
||||
fixed destination, CONTENT checked against what the agent's renderers write (no `[Service]`, `Where=` only
|
||||
`/mnt/<name>` or `/mnt/felhom-drives/<name>` and equal to the unit name, no `bind`/`suid`; a network share must carry
|
||||
`nosuid,nodev`; no `dhcp-script=`; no `PostUp=`; the sshd config only the one template with its Port). Its 30 tests
|
||||
(`configs/test_felhom_priv_apply.py`) + Go contract tests feeding each renderer's real output
|
||||
(`internal/privapplytest`). Pre-flight: every live file on both demo boxes reads OK.
|
||||
- **NFS/SMB options gain `nosuid,nodev`** (a set-uid file on a server outside the box never acts on the host).
|
||||
- **Fixed files from the bundle.** The guest pre-start hook (`/var/lib/vz/snippets/felhom-guest-hook.sh`, run as root at
|
||||
every guest start) and the shared drive parent script + unit are bundle files now (byte-identical to the agent's
|
||||
constants, pinned). The agent no longer installs them from `/tmp`; it checks them (`guesthook.SnippetReady`,
|
||||
`ensureSharedParentBoot`) and only registers / enables.
|
||||
- **The signed update is checked as root.** `felhom-os-apply` mode `agent_update` verifies the operator signature
|
||||
(root-owned signers, this host, the window, the nonce), re-hashes the staged binary against the SIGNED sha, then runs
|
||||
the A/B flip; `felhom-selfupdate-guarded apply` is no longer in the agent's sudoers. 7 tests (`AgentUpdate`).
|
||||
- **The root escrow run reads no path from the agent's config.** As root it pins the PVE secret dir and the WireGuard
|
||||
state dir to their defaults, refuses a storage id that is a path, and reads its two staged files without following a
|
||||
symlink (`readStagedNoFollow`) — before, a symlink in the agent's own directory sealed any root file into the blob.
|
||||
- **Not narrowed here (named in `03` §3.1):** `FELHOM_CONTROLLERSWAP` stays guest-scoped (a compromised agent can run a
|
||||
chosen controller image in the guest — the household's data, not host root); `FELHOM_ESCROW` still hands the agent R
|
||||
by design (the agent relays the ceremony); the mkfs / pbs-apply / backup-target wrappers keep a coarse argument and
|
||||
their own checks.
|
||||
- Red-proofs F1–F9: `felhom.eu/documentation/audits/hub-safety-2026-10-05/partF/red-proof.txt` (F1's first run did NOT
|
||||
convict — the name rule masked it — and the test now uses the pair only the Where rule stops).
|
||||
|
||||
## v0.145.0 — the OS update repairs itself after a power cut; a short-session box gets restore-tested; "sent late" (R-876, R-874, R-875) (2026-10-05)
|
||||
|
||||
Released by `scripts/release-agent.sh`: binary sha256 `894da35c7b9e1ac78885690b78352b634e6831e7d99b321573c75d878db8886e`,
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
# REPORT — agent v0.145.0 (2026-10-05, afternoon): the OS update repairs itself after a power cut
|
||||
# REPORT — 2026-10-05 burn-down: two record corrections (no release)
|
||||
|
||||
Brief: the 2026-10-05 catch-up brief (operator), Parts C (R-874, R-875) and D (R-876). Full session report:
|
||||
`felhom.eu/REPORT-catchup-2026-10-05.md`. Architecture: `11-os-updates.md` §5.4.1, §8.4–8.5; `09` decisions 117–118.
|
||||
Full session report: `felhom.eu/REPORT-burndown-2026-10-05.md`. Baseline `e06ed97` (v0.146.1). No binary, bundle or
|
||||
version change; `go build`, `go vet ./internal/backup/`, `agent_gates.py --fast` green.
|
||||
|
||||
| Row | Fix | Proof |
|
||||
|---|---|---|
|
||||
| R-876 | the wrapper reads `dpkg --audit` AND the update journal in ONE call, repairs on either; belt: repair + retry once when apt says "interrupted" | 3 tests + 3 red-proofs; **live (operator's go): crash mid-unpack on demo-hp → the next pass `REPAIR … journal=1` → `DONE rc=0 upgraded=12`, nobody touched the box** |
|
||||
| R-874 | the restore-test's first due-check 30 min after start | 2 tests + red-proof; live on demo-felhom: passed restore-test at start + 30 min |
|
||||
| R-875 | a kept report's reason is neutral ("sent late …") | test + red-proof |
|
||||
|
||||
Released by `scripts/release-agent.sh`: v0.145.0 (`894da35c…`, bundle `78c00adc…`), verified by download; signed
|
||||
`agent_update` + `agent_config_update` to demo-hp, demo-felhom, tester-1 (71/71 after each bundle); vouched with
|
||||
golden 0.295.0, min_agent 0.131.0. `go test ./...` rc 0, Python suites OK, `agent_gates.py` OK.
|
||||
- R-291 — `scripts/retention-policy.json`: the source of the number (R-267 newest-10 prune, R-287) and readers corrected.
|
||||
- R-348 — `internal/backup/store.go`: the restart comment says what a restart really blanks.
|
||||
|
||||
+43
-7
@@ -113,6 +113,9 @@ CRASH_GUARD_STATE = "/var/lib/felhom-crash-guard/state.json"
|
||||
SELFUPDATE_OP = "agent_update"
|
||||
SELFUPDATE_DIR = "/var/lib/felhom-agent/selfupdate"
|
||||
SELFUPDATE_WRAPPER = "/usr/local/sbin/felhom-selfupdate-guarded"
|
||||
# The verified bytes are written HERE (a root-owned directory the agent cannot write) and only this copy reaches the A/B wrapper — never the agent's file.
|
||||
SELFUPDATE_ROOT_DIR = "/var/lib/felhom-os-apply/agent-update"
|
||||
SELFUPDATE_MAX_BYTES = 256 * 1024 * 1024
|
||||
BUNDLE_FORMAT = 1
|
||||
BUNDLE_OP = "agent_config_update"
|
||||
BUNDLE_RECORD = "/etc/felhom/config-bundle.json" # what the box runs (0644 root; the non-root agent reports it)
|
||||
@@ -289,6 +292,30 @@ class Runner:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def read_staged_once(self, path, owner_uid, limit):
|
||||
"""R-861: read a file the AGENT staged ONCE, as root, safely: O_NOFOLLOW (the last component may not be a
|
||||
symlink), fstat on the opened fd (a regular file owned by owner_uid), at most `limit` bytes. The caller hashes
|
||||
and uses exactly these bytes — never the path again (the agent owns the directory and could swap the file)."""
|
||||
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC)
|
||||
try:
|
||||
st = os.fstat(fd)
|
||||
if not stat.S_ISREG(st.st_mode) or st.st_uid != owner_uid:
|
||||
raise Refused("R19", f"{path} is not a regular file owned by {AGENT_USER}")
|
||||
if st.st_size > limit:
|
||||
raise Refused("R19", f"{path} is larger than {limit} bytes")
|
||||
chunks, n = [], 0
|
||||
while True:
|
||||
b = os.read(fd, 1 << 20)
|
||||
if not b:
|
||||
break
|
||||
chunks.append(b)
|
||||
n += len(b)
|
||||
if n > limit:
|
||||
raise Refused("R19", f"{path} grew past {limit} bytes while it was read")
|
||||
return b"".join(chunks)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
# ---------- host files, for the config bundle (R-840). Tests replace these with an in-memory tree. ----------
|
||||
def read_bytes(self, path):
|
||||
with open(path, "rb") as f:
|
||||
@@ -547,17 +574,26 @@ class Apply:
|
||||
staged = os.path.join(SELFUPDATE_DIR, "felhom-agent-" + ver)
|
||||
if plan.get("staged") != staged:
|
||||
raise Refused("R19", f"the staged binary must be {staged}, got {plan.get('staged')!r}")
|
||||
# ONE read, then never the agent's path again: hash exactly these bytes and hand the A/B wrapper a ROOT-OWNED
|
||||
# copy of them. Hashing the agent's file and then letting the wrapper copy it by path was a race — the agent owns
|
||||
# that directory and could swap the file between the check and the copy (found by review 2026-10-05).
|
||||
try:
|
||||
st = self.r.stat(staged)
|
||||
data = self.r.read_staged_once(staged, self.r.agent_uid(), SELFUPDATE_MAX_BYTES)
|
||||
except OSError as e:
|
||||
raise Refused("R19", f"cannot stat the staged binary: {e}")
|
||||
if not stat.S_ISREG(st.st_mode) or st.st_uid != self.r.agent_uid():
|
||||
raise Refused("R19", "the staged binary is not a regular file owned by the agent")
|
||||
got = sha256_hex(self.r.read_bytes(staged))
|
||||
raise Refused("R19", f"cannot read the staged binary: {e}")
|
||||
got = sha256_hex(data)
|
||||
if got != sha:
|
||||
raise Refused("R19", f"the staged binary's sha256 {got[:16]}… is not the signed {sha[:16]}…")
|
||||
self.r.log(f"os-apply: AGENT-UPDATE signed by the operator: version={ver} sha={sha[:16]} — handing to the A/B wrapper")
|
||||
rc, out, err = self.r.host([SELFUPDATE_WRAPPER, "apply", staged, sha], 120)
|
||||
root_copy = os.path.join(SELFUPDATE_ROOT_DIR, "felhom-agent-" + ver)
|
||||
self.r.put_file(root_copy, data, 0o755)
|
||||
self.r.log(f"os-apply: AGENT-UPDATE signed by the operator: version={ver} sha={sha[:16]} — handing the root copy to the A/B wrapper")
|
||||
try:
|
||||
rc, out, err = self.r.host([SELFUPDATE_WRAPPER, "apply", root_copy, sha], 120)
|
||||
finally:
|
||||
try:
|
||||
self.r.remove(root_copy)
|
||||
except OSError:
|
||||
pass
|
||||
self.report["agent_update"] = {"version": ver, "sha256": sha, "wrapper_rc": rc,
|
||||
"wrapper": (out + err).strip()[-300:]}
|
||||
if rc != 0:
|
||||
|
||||
@@ -55,7 +55,6 @@ NET_TYPES = {"nfs", "nfs4", "cifs"}
|
||||
# Options that turn a device mount into something else, or let set-uid/device files act on the host.
|
||||
FORBIDDEN_OPTS = {"bind", "rbind", "move", "rmove", "remount", "suid", "dev", "user", "users", "owner", "group",
|
||||
"x-mount.mkdir", "helper"}
|
||||
UNIT_TOKEN_RE = re.compile(r"^[A-Za-z0-9@_.\\:-]+$")
|
||||
DESC_RE = re.compile(r"^[^\x00-\x1f\x7f]{0,200}$")
|
||||
WG_KEY_RE = re.compile(r"^[A-Za-z0-9+/]{42}[AEIMQUYcgkosw480]=$")
|
||||
KEY_LINE_RE = re.compile(r"^(ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp(256|384|521)|sk-ssh-ed25519@openssh\.com) "
|
||||
@@ -164,6 +163,10 @@ def parse_ini(text, what):
|
||||
sections, cur = {}, None
|
||||
for n, raw in enumerate(text.split("\n"), 1):
|
||||
line = raw.strip()
|
||||
if line.endswith("\\"):
|
||||
# systemd joins a line ending in a backslash with the next one; this parser does not. Refused, so the two
|
||||
# can never read the same bytes differently (review 2026-10-05).
|
||||
raise Refused("U2", f"{what}: line {n} ends with a backslash (a continuation)")
|
||||
if not line or line.startswith("#") or line.startswith(";"):
|
||||
continue
|
||||
m = re.match(r"^\[([A-Za-z]+)\]$", line)
|
||||
@@ -190,7 +193,10 @@ def check_unit(name, text):
|
||||
kind = "automount" if name.endswith(".automount") else "mount"
|
||||
s = parse_ini(text, name)
|
||||
body = "Automount" if kind == "automount" else "Mount"
|
||||
allowed = {"Unit": {"Description", "After", "Before", "Wants", "Requires"},
|
||||
# [Unit] holds ONLY what the renderers write: Description, and After=local-fs-pre.target on a local mount. A
|
||||
# Wants=/Requires=/Before= naming any unit would start it with the mount (Wants=reboot.target — found by review
|
||||
# 2026-10-05), so none of them is accepted.
|
||||
allowed = {"Unit": {"Description", "After"},
|
||||
body: {"Where", "TimeoutIdleSec"} if kind == "automount" else {"What", "Where", "Type", "Options"},
|
||||
"Install": {"WantedBy"}}
|
||||
for sec, keys in s.items():
|
||||
@@ -202,9 +208,8 @@ def check_unit(name, text):
|
||||
u = s.get("Unit", {})
|
||||
if not DESC_RE.match(u.get("Description", "")):
|
||||
raise Refused("U2", f"{name}: Description has control characters")
|
||||
for k in ("After", "Before", "Wants", "Requires"):
|
||||
if k in u and not all(UNIT_TOKEN_RE.match(t) for t in u[k].split()):
|
||||
raise Refused("U2", f"{name}: {k}= names something that is not a unit")
|
||||
if "After" in u and u["After"] != "local-fs-pre.target":
|
||||
raise Refused("U2", f"{name}: After= may only be local-fs-pre.target")
|
||||
inst = s.get("Install", {})
|
||||
if inst and inst.get("WantedBy") != "multi-user.target":
|
||||
raise Refused("U2", f"{name}: WantedBy must be multi-user.target")
|
||||
|
||||
@@ -24,6 +24,11 @@ set -u
|
||||
BIN=/usr/local/bin/felhom-agent
|
||||
PREV=$BIN.prev
|
||||
STAGING=/var/lib/felhom-agent/selfupdate
|
||||
# R-861 (agent v0.146.1): `apply` takes ONLY the root-owned copy felhom-os-apply writes after it has verified the
|
||||
# operator's signature and hashed exactly those bytes (mode agent_update). The agent cannot call `apply` any more (it
|
||||
# left the sudoers), and the agent's own staging dir is no longer accepted: a file in a directory the agent owns can be
|
||||
# swapped between this script's sha check and its copy.
|
||||
ROOT_STAGING=/var/lib/felhom-os-apply/agent-update
|
||||
PENDING=$STAGING/pending.json
|
||||
UNIT=felhom-agent.service
|
||||
|
||||
@@ -42,11 +47,14 @@ apply)
|
||||
log "refusing apply: usage: apply <staged> <sha256>"
|
||||
exit 2
|
||||
fi
|
||||
# Root-side path confinement: the staged binary MUST live in the agent's staging dir.
|
||||
# Root-side path confinement: the staged binary MUST be felhom-os-apply's root-owned copy (R-861).
|
||||
case "$staged" in
|
||||
"$STAGING"/*) ;;
|
||||
*) log "refusing apply: staged path outside $STAGING: $staged"; exit 1 ;;
|
||||
"$ROOT_STAGING"/*) ;;
|
||||
*) log "refusing apply: staged path outside $ROOT_STAGING: $staged"; exit 1 ;;
|
||||
esac
|
||||
if [ -L "$staged" ] || [ "$(stat -c %u "$staged" 2>/dev/null)" != "0" ]; then
|
||||
log "refusing apply: $staged is a symlink or not root-owned"; exit 1
|
||||
fi
|
||||
case "$staged" in
|
||||
*..*) log "refusing apply: staged path contains '..'"; exit 1 ;;
|
||||
esac
|
||||
|
||||
@@ -94,6 +94,14 @@ class Box:
|
||||
raise OSError("no such file")
|
||||
return self.files[p]
|
||||
|
||||
def read_staged_once(self, p, owner_uid, limit):
|
||||
if p not in self.files:
|
||||
raise OSError("no such file")
|
||||
if self.uids[p] != owner_uid:
|
||||
raise osapply.Refused("R19", f"{p} is not a regular file owned by felhom-agent")
|
||||
self.staged_reads = getattr(self, "staged_reads", 0) + 1
|
||||
return self.files[p]
|
||||
|
||||
def stat(self, p):
|
||||
if p not in self.files:
|
||||
raise OSError("no such file")
|
||||
@@ -577,10 +585,23 @@ class AgentUpdate(unittest.TestCase):
|
||||
box = update_box(update_job(sha))
|
||||
rc, rep = run(box)
|
||||
self.assertEqual(rc, 0, rep)
|
||||
self.assertEqual(wrapper_calls(box), [[osapply.SELFUPDATE_WRAPPER, "apply", STAGED, sha]])
|
||||
root_copy = osapply.SELFUPDATE_ROOT_DIR + "/felhom-agent-0.146.0"
|
||||
# the wrapper gets the ROOT-OWNED copy of the bytes that were hashed — never the agent's path (review 2026-10-05)
|
||||
self.assertEqual(wrapper_calls(box), [[osapply.SELFUPDATE_WRAPPER, "apply", root_copy, sha]])
|
||||
self.assertIn(root_copy, box.writes)
|
||||
self.assertNotIn(root_copy, box.files, "the root copy is removed after the flip")
|
||||
self.assertEqual(box.staged_reads, 1, "the agent's file is read exactly once")
|
||||
self.assertIn("u1", box.nonces)
|
||||
self.assertEqual(rep["agent_update"]["version"], "0.146.0")
|
||||
|
||||
def test_a_staged_file_the_agent_does_not_own_is_refused(self):
|
||||
sha = hashlib.sha256(NEW_BIN).hexdigest()
|
||||
box = update_box(update_job(sha))
|
||||
box.uids[STAGED] = 0
|
||||
rc, rep = run(box)
|
||||
self.assertEqual((rc, rep["refused"]["code"]), (2, "R19"), rep)
|
||||
self.assertEqual(wrapper_calls(box), [])
|
||||
|
||||
def test_a_bad_signature_never_reaches_the_wrapper(self):
|
||||
sha = hashlib.sha256(NEW_BIN).hexdigest()
|
||||
box = update_box(update_job(sha))
|
||||
@@ -630,5 +651,83 @@ class AgentUpdate(unittest.TestCase):
|
||||
self.assertNotIn("u1", box.nonces)
|
||||
|
||||
|
||||
|
||||
class SelfupdateWrapperConfinement(unittest.TestCase):
|
||||
"""R-861 (v0.146.1): the A/B wrapper takes only felhom-os-apply's root-owned copy, never the agent's staging dir
|
||||
(a file there can be swapped between the wrapper's sha check and its copy). The path check runs before anything
|
||||
is touched, so the real script can be run here unprivileged.
|
||||
RED-PROOF: point ROOT_STAGING back at /var/lib/felhom-agent/selfupdate → this fails (the path is accepted and the
|
||||
script goes on to `staged file missing`)."""
|
||||
|
||||
def test_the_agents_staging_dir_is_refused(self):
|
||||
import subprocess
|
||||
sha = "0" * 64
|
||||
p = subprocess.run(["sh", str(HERE / "felhom-selfupdate-guarded"), "apply",
|
||||
"/var/lib/felhom-agent/selfupdate/felhom-agent-0.146.1", sha], capture_output=True, text=True)
|
||||
self.assertEqual(p.returncode, 1, p.stderr)
|
||||
self.assertIn("outside /var/lib/felhom-os-apply/agent-update", p.stderr)
|
||||
|
||||
|
||||
_sb = importlib.machinery.SourceFileLoader("stepbuild", str(REPO / "scripts" / "build-step-bundle.py"))
|
||||
_ss = importlib.util.spec_from_loader("stepbuild", _sb)
|
||||
stepbuild = importlib.util.module_from_spec(_ss)
|
||||
_sb.exec_module(stepbuild)
|
||||
NEW_IN_0146 = {"/usr/local/sbin/felhom-priv-apply", "/var/lib/vz/snippets/felhom-guest-hook.sh",
|
||||
"/usr/local/sbin/felhom-shared-parent.sh", "/etc/systemd/system/felhom-shared-parent.service"}
|
||||
|
||||
|
||||
class StepBundle(unittest.TestCase):
|
||||
"""R-880 (agent v0.146.1): an INSTALLED wrapper checks an incoming bundle's paths against its OWN table (R16), so a
|
||||
release that adds paths needs a step bundle: the boxes' current bundle with only felhom-os-apply replaced.
|
||||
RED-PROOF: deliver the full bundle to the old table → R16 (test_the_full_bundle_is_refused_by_an_old_table)."""
|
||||
|
||||
def old_world(self):
|
||||
"""The base bundle an older wrapper (no R-861 paths) installed, and that wrapper's table."""
|
||||
full = json.loads(builder.build("0.145.0"))
|
||||
full["files"] = [e for e in full["files"] if e["path"] not in NEW_IN_0146]
|
||||
old_wrapper = b'# the v0.145.0 wrapper stands in here\nBUNDLE_OP = "agent_config_update"\n'
|
||||
for e in full["files"]:
|
||||
if e["path"] == "/usr/local/sbin/felhom-os-apply":
|
||||
e["content_b64"], e["sha256"] = base64.b64encode(old_wrapper).decode(), hashlib.sha256(old_wrapper).hexdigest()
|
||||
base = (json.dumps(full, indent=1, sort_keys=True) + "\n").encode()
|
||||
old_dests = {k: v for k, v in osapply.BUNDLE_DESTS.items() if k not in NEW_IN_0146}
|
||||
return base, old_dests
|
||||
|
||||
def parse_with_table(self, data, dests, version):
|
||||
saved = osapply.BUNDLE_DESTS
|
||||
osapply.BUNDLE_DESTS = dests
|
||||
try:
|
||||
return osapply.Bundle(osapply.Apply(Box(b"{}", None), "")).parse(data, hashlib.sha256(data).hexdigest(), version)
|
||||
finally:
|
||||
osapply.BUNDLE_DESTS = saved
|
||||
|
||||
def test_the_full_bundle_is_refused_by_an_old_table(self):
|
||||
_, old_dests = self.old_world()
|
||||
full = builder.build("0.146.1")
|
||||
with self.assertRaises(osapply.Refused) as cm:
|
||||
self.parse_with_table(full, old_dests, "0.146.1")
|
||||
self.assertEqual(cm.exception.code, "R16")
|
||||
|
||||
def test_the_step_bundle_is_accepted_by_the_old_table_and_changes_only_the_wrapper(self):
|
||||
base, old_dests = self.old_world()
|
||||
new_wrapper = (HERE / "felhom-os-apply").read_bytes()
|
||||
step = stepbuild.build_step(base, "0.146.1-step1", new_wrapper)
|
||||
ver, files = self.parse_with_table(step, old_dests, "0.146.1-step1")
|
||||
self.assertEqual(ver, "0.146.1-step1")
|
||||
b, s_ = json.loads(base), json.loads(step)
|
||||
self.assertEqual(sorted(e["path"] for e in b["files"]), sorted(e["path"] for e in s_["files"]), "the paths must not change")
|
||||
changed = [e["path"] for e, f in zip(sorted(b["files"], key=lambda x: x["path"]), sorted(s_["files"], key=lambda x: x["path"]))
|
||||
if e != f]
|
||||
self.assertEqual(changed, ["/usr/local/sbin/felhom-os-apply"], "exactly the wrapper changes")
|
||||
installed = dict((d, c) for d, c, *_ in files)
|
||||
self.assertEqual(installed["/usr/local/sbin/felhom-os-apply"], new_wrapper)
|
||||
# and the NEW wrapper (now installed) knows every path the release's full bundle names
|
||||
self.assertTrue({e["path"] for e in json.loads(builder.build("0.146.1"))["files"]} <= set(osapply.BUNDLE_DESTS))
|
||||
|
||||
def test_a_step_version_must_carry_a_suffix(self):
|
||||
base, _ = self.old_world()
|
||||
with self.assertRaises(SystemExit):
|
||||
stepbuild.build_step(base, "0.146.1", b"x")
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -204,6 +204,17 @@ class Refuses(unittest.TestCase):
|
||||
def test_U2_service_section(self):
|
||||
self.refused(*self.unit(LOCAL_UNIT + "\n[Service]\nExecStart=/bin/sh -c id\n"), "U2")
|
||||
|
||||
def test_U2_wants_starts_another_unit(self): # review 2026-10-05: Wants=reboot.target would reboot the host
|
||||
for extra in ("Wants=reboot.target", "Requires=felhom-agent-rollback.service", "Before=pve-guests.service"):
|
||||
self.refused(*self.unit(LOCAL_UNIT.replace("After=local-fs-pre.target", "After=local-fs-pre.target\n" + extra)), "U2")
|
||||
self.refused(*self.unit(LOCAL_UNIT.replace("After=local-fs-pre.target", "After=poweroff.target")), "U2")
|
||||
|
||||
def test_U2_continuation_line(self):
|
||||
t = LOCAL_UNIT.replace("Description=Felhom storage mount 91d2dc2d-2d28-4929-9bdd-3e11fa2f41ae",
|
||||
"Description=Felhom storage mount \\")
|
||||
self.refused(*self.unit(t), "U2")
|
||||
self.refused(*self.unit(LOCAL_UNIT.replace("# Managed by felhom-agent", "# comment \\\n# Managed by felhom-agent")), "U2")
|
||||
|
||||
def test_U2_unknown_key(self):
|
||||
self.refused(*self.unit(LOCAL_UNIT.replace("Type=ext4", "Type=ext4\nDirectoryMode=0777")), "U2")
|
||||
|
||||
|
||||
@@ -25,7 +25,11 @@ import (
|
||||
// merges the two — see hub.ProvenRestoreTestReporter. This store remains the ONLY place a FAILURE is
|
||||
// recorded, and that asymmetry is deliberate: a failing tier stays due and is retried, so a lost
|
||||
// failure heals itself, while a lost success leaves the system quietly less tested than it believes.
|
||||
// Backups are unaffected — their freshness has a ground truth on the storage (R-84).
|
||||
// Backups are NOT unaffected (corrected 2026-10-05, R-348): byTarget is in memory too, so after a restart the
|
||||
// reported backup LIST reads 0 until the next backup of each tier runs (daily local, weekly offsite) — measured
|
||||
// 2026-08-20, two consecutive host-reports with `0 backups` while `pvesm list` showed archives on both tiers. What
|
||||
// is unaffected is the hub's VERDICT: it looks back 7 days over stored reports (felhom.eu hub/internal/monitor/
|
||||
// deadline.go backupEvidenceLookback) and the storage stays the ground truth (R-84).
|
||||
type Store struct {
|
||||
mu sync.Mutex
|
||||
byTarget map[string]hub.Backup // latest backup per target id
|
||||
|
||||
@@ -184,15 +184,35 @@ func UnwrapIdentityBundle(ctx context.Context, blob []byte, recoveryCode string)
|
||||
}
|
||||
|
||||
// readStagedNoFollow reads a file the AGENT staged, for the escrow ceremony that runs as ROOT (FELHOM_ESCROW). R-861
|
||||
// (agent v0.146.0): both files live in the agent's own directory, so a compromised agent could put a SYMLINK there
|
||||
// (to any root-only file) and the root ceremony would seal that file into the blob and hand the agent R — a root file
|
||||
// read. So: no symlink (O_NOFOLLOW), a regular file, at most 4 KiB. A missing file keeps its os.IsNotExist meaning.
|
||||
// Pinned by TestAttach_RefusesASymlink.
|
||||
// (agent v0.146.0/0.146.1): both files live in the agent's own directory, so a compromised agent could put a SYMLINK
|
||||
// there — at the file OR at any directory on the way (review 2026-10-05) — to a root-only file, and the root ceremony
|
||||
// would seal that file into the blob and hand the agent R. So the path is walked from "/" one component at a time with
|
||||
// openat(O_NOFOLLOW): no symlink anywhere, the last a regular file of at most 4 KiB. Once a directory is open, renaming
|
||||
// it does not redirect the walk. A missing file keeps its os.IsNotExist meaning. Pinned by TestAttach_RefusesASymlink*.
|
||||
func readStagedNoFollow(path string) ([]byte, error) {
|
||||
f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW, 0)
|
||||
if !filepath.IsAbs(path) {
|
||||
return nil, fmt.Errorf("%s is not an absolute path", path)
|
||||
}
|
||||
clean := filepath.Clean(path)
|
||||
parts := strings.Split(strings.TrimPrefix(clean, "/"), "/")
|
||||
dirfd, err := syscall.Open("/", syscall.O_RDONLY|syscall.O_DIRECTORY|syscall.O_CLOEXEC, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i, part := range parts {
|
||||
last := i == len(parts)-1
|
||||
flags := syscall.O_RDONLY | syscall.O_NOFOLLOW | syscall.O_CLOEXEC
|
||||
if !last {
|
||||
flags |= syscall.O_DIRECTORY
|
||||
}
|
||||
fd, err := syscall.Openat(dirfd, part, flags, 0)
|
||||
syscall.Close(dirfd)
|
||||
if err != nil {
|
||||
return nil, &os.PathError{Op: "open", Path: clean, Err: err}
|
||||
}
|
||||
dirfd = fd
|
||||
}
|
||||
f := os.NewFile(uintptr(dirfd), clean)
|
||||
defer f.Close()
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
|
||||
@@ -36,3 +36,28 @@ func TestAttach_RefusesASymlink(t *testing.T) {
|
||||
t.Fatalf("control: a missing file must stay a clean no-attach: %v %v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Review 2026-10-05: a symlinked DIRECTORY on the way must stop the read too (O_NOFOLLOW alone guards only the last
|
||||
// component). RED-PROOF: open the full path with O_NOFOLLOW only → this fails.
|
||||
func TestAttach_RefusesASymlinkedDirectory(t *testing.T) {
|
||||
d := t.TempDir()
|
||||
secretDir := filepath.Join(d, "root-only-dir")
|
||||
_ = os.Mkdir(secretDir, 0o700)
|
||||
_ = os.WriteFile(filepath.Join(secretDir, "private.key"), []byte("AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=\n"), 0o600)
|
||||
agentDir := filepath.Join(d, "agent")
|
||||
_ = os.Mkdir(agentDir, 0o700)
|
||||
if err := os.Symlink(secretDir, filepath.Join(agentDir, "wg")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var b IdentityBundle
|
||||
if ok, err := AttachWGKey(&b, filepath.Join(agentDir, "wg", "private.key")); err == nil || ok || b.WGPrivateKey != "" {
|
||||
t.Fatalf("a key behind a symlinked directory was read: ok=%v err=%v", ok, err)
|
||||
}
|
||||
// control: the same key under a REAL directory is read
|
||||
_ = os.Remove(filepath.Join(agentDir, "wg"))
|
||||
_ = os.Mkdir(filepath.Join(agentDir, "wg"), 0o700)
|
||||
_ = os.WriteFile(filepath.Join(agentDir, "wg", "private.key"), []byte("AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=\n"), 0o600)
|
||||
if ok, err := AttachWGKey(&b, filepath.Join(agentDir, "wg", "private.key")); err != nil || !ok {
|
||||
t.Fatalf("control: a key under a real directory was not read: %v %v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env python3
|
||||
"""build-step-bundle.py — the TRANSITION bundle for a release whose bundle ADDS a path (R-880, `11` §5.4.2).
|
||||
|
||||
Usage: python3 scripts/build-step-bundle.py <base-bundle.json> <step-version> <out.json> (prints the sha256)
|
||||
|
||||
WHY. A box's INSTALLED felhom-os-apply checks every path of an incoming bundle against ITS OWN table (rule R16) — so a
|
||||
bundle that adds a path (v0.146.1 adds felhom-priv-apply, the guest hook and the shared-parent files, R-861) is refused
|
||||
by every box still running an older wrapper. The fix is a step: first a bundle the old wrapper accepts that brings ONLY
|
||||
the new felhom-os-apply (the new table), then the release's own bundle, which the new wrapper accepts.
|
||||
|
||||
WHAT IT BUILDS. <base-bundle.json> is the bundle the boxes run now (download it from the package registry, e.g.
|
||||
felhom-agent/0.145.0/felhom-config-bundle.json, and check its sha against the hub's record). The step bundle is that
|
||||
bundle with EXACTLY ONE change: the /usr/local/sbin/felhom-os-apply entry's content is replaced by configs/felhom-os-apply
|
||||
(this tree). Same paths, same modes, same checks, every other byte identical; agent_version is <step-version> (e.g.
|
||||
0.146.1-step1). The old wrapper verifies it like any bundle (signature, sha, R16, content checks, self-check of the new
|
||||
wrapper) — nothing about the trust route changes.
|
||||
|
||||
Pinned by configs/test_felhom_config_bundle.py (StepBundle): same paths as the base, only the wrapper differs, the
|
||||
new wrapper's table is a superset of the base's paths.
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
REPO = pathlib.Path(__file__).resolve().parent.parent
|
||||
OSAPPLY_DEST = "/usr/local/sbin/felhom-os-apply"
|
||||
|
||||
|
||||
def build_step(base_bytes, version, new_osapply_bytes):
|
||||
if not re.match(r"^[0-9]+\.[0-9]+\.[0-9]+-[0-9A-Za-z.]+$", version):
|
||||
raise SystemExit(f"build-step-bundle: {version!r} must be a semver with a step suffix, e.g. 0.146.1-step1")
|
||||
base = json.loads(base_bytes)
|
||||
files = base.get("files")
|
||||
if base.get("format") != 1 or not isinstance(files, list):
|
||||
raise SystemExit("build-step-bundle: the base is not a format-1 bundle")
|
||||
hit = [e for e in files if e.get("path") == OSAPPLY_DEST]
|
||||
if len(hit) != 1:
|
||||
raise SystemExit(f"build-step-bundle: the base has {len(hit)} {OSAPPLY_DEST} entries, want exactly 1")
|
||||
hit[0]["content_b64"] = base64.b64encode(new_osapply_bytes).decode()
|
||||
hit[0]["sha256"] = hashlib.sha256(new_osapply_bytes).hexdigest()
|
||||
base["agent_version"] = version
|
||||
return (json.dumps(base, indent=1, sort_keys=True) + "\n").encode()
|
||||
|
||||
|
||||
def main(argv):
|
||||
if len(argv) != 4:
|
||||
print(__doc__, file=sys.stderr)
|
||||
return 2
|
||||
data = build_step(pathlib.Path(argv[1]).read_bytes(), argv[2], (REPO / "configs" / "felhom-os-apply").read_bytes())
|
||||
pathlib.Path(argv[3]).write_bytes(data)
|
||||
print(hashlib.sha256(data).hexdigest())
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv))
|
||||
@@ -10,13 +10,11 @@
|
||||
"CI went red at a commit whose own run had been green the day before, on a true finding that",
|
||||
"no one could act on. The red will return at the next publish unless the two read one number.",
|
||||
"",
|
||||
"HOW THE NUMBER WAS ARRIVED AT — stated honestly, because it is weaker than it looks.",
|
||||
"generic_versions_kept is 10 because that is what the registry demonstrably holds today",
|
||||
"(felhom-agent 0.121.0..0.128.0 = 10 versions, queried 2026-08-09). It is an OBSERVED state,",
|
||||
"NOT a ruling anyone has been able to locate: no register row records a package prune, R-210",
|
||||
"is WAITING-ON-OPERATOR and says 'Nothing was deleted; this is a list, not an action', and it",
|
||||
"concerns local Docker images rather than this registry. Container packages currently hold 19",
|
||||
"each, so there is no uniform ten-per-package cap visible either. See R-287.",
|
||||
"HOW THE NUMBER WAS ARRIVED AT. generic_versions_kept is 10 because that is what the registry",
|
||||
"keeps: the deleter was ESTABLISHED on 2026-08-10 (R-287) — the newest-10 prune of generic packages",
|
||||
"run under R-267 (felhom-agent and felhom-golden generic held exactly 10 afterwards). Until then this",
|
||||
"file called the 10 an observed state with no located ruling; that is superseded (corrected",
|
||||
"2026-10-05, R-291). Container packages are not pruned by that rule.",
|
||||
"",
|
||||
"SO THIS FILE IS A FLOOR, NOT A LICENCE. It says: CI may assume nothing older than the newest",
|
||||
"N generic versions is still downloadable. It does NOT authorise deleting anything, and the",
|
||||
@@ -36,9 +34,8 @@
|
||||
],
|
||||
"generic_versions_kept": 10,
|
||||
"readers": [
|
||||
"scripts/check-published-versions.py — bounds its assertion to the newest N versions",
|
||||
"documentation/runbooks/registry-retention.md (felhom.eu) — the prune procedure"
|
||||
"scripts/check-published-versions.py — bounds its assertion to the newest N versions"
|
||||
],
|
||||
"recorded": "2026-08-09",
|
||||
"recorded_by": "CC, from the registry's observed state; NOT from a located operator ruling"
|
||||
"recorded_by": "CC 2026-08-09; the number's source (the R-267 newest-10 prune, established 2026-08-10 by R-287) recorded 2026-10-05 (R-291)"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user