2b (unreleased): the root step that keeps a copy of Proxmox's root CA (wrapper mode pve-ca-copy, R-1380)
gates / gates (push) Successful in 44s
gates / gates (push) Successful in 44s
This commit is contained in:
1 parent
2706e35198
commit
a9ab47ec71
3 files changed
+221
-1
No files matched your search
@@ -1,3 +1,15 @@
|
|||||||
|
## Unreleased (2026-11-14 night) — release 2b, built and NOT released: trust in Proxmox by a copy of the box's own root CA (R-1380, D80, felhom.eu `09` §3 decision 303; design `03-host-agent.md` §12a)
|
||||||
|
|
||||||
|
Commit only. No box ran any of this; the proof on a box (demo-hp, a forced new certificate, twice) is the next attended
|
||||||
|
session's. `configs/` changes, so the release needs BOTH signed jobs.
|
||||||
|
|
||||||
|
- **The root step that keeps the copy** (`configs/felhom-os-apply`, host-layer mode `pve-ca-copy`). It copies exactly
|
||||||
|
`/etc/pve/pve-root-ca.pem` to exactly `/etc/felhom-agent/pve-root-ca.pem` (0644 root), only if the source is ONE
|
||||||
|
certificate with `CA:TRUE` that has not ended; written whole or not at all; „same" when nothing changed. The plan may
|
||||||
|
name nothing (T1); no source (T2); not one CA certificate (T3). No appliance check: the copy is the agent's own trust
|
||||||
|
file and changes nothing of a BYO owner's host. Tests `PVECACopy` (9, one with the real openssl through the real
|
||||||
|
runner); 7 mutations seen red (felhom.eu `audits/night-build-2026-11-14/A/redproof-wrapper.txt`).
|
||||||
|
|
||||||
## v0.174.0 (2026-11-14) — the box removes its old kernels (R-1368); security profiles are refreshed after a kernel change (R-1388, R-1301); Go 1.27.4 (R-1391) — RELEASED 2026-11-14 (release-agent.sh from the clean working clone at `cc63dd5`, CI run id 2641; agent sha256 6efbb97809135483bc446d71d1321f9e2f7fc3268fe9b20e7fe742a2eb13db92, bundle sha256 7d87d0584dee79eee766f01c27b9e89273d39c89a14b0725a6e5564a55b3ec5f) and VOUCHED 2026-11-14 15:21:45 CET
|
## v0.174.0 (2026-11-14) — the box removes its old kernels (R-1368); security profiles are refreshed after a kernel change (R-1388, R-1301); Go 1.27.4 (R-1391) — RELEASED 2026-11-14 (release-agent.sh from the clean working clone at `cc63dd5`, CI run id 2641; agent sha256 6efbb97809135483bc446d71d1321f9e2f7fc3268fe9b20e7fe742a2eb13db92, bundle sha256 7d87d0584dee79eee766f01c27b9e89273d39c89a14b0725a6e5564a55b3ec5f) and VOUCHED 2026-11-14 15:21:45 CET
|
||||||
|
|
||||||
Release 2a of the build order (felhom.eu `09` §3 decision 306). Design: felhom.eu `11-os-updates.md` §5.11.1 and §5.12.
|
Release 2a of the build order (felhom.eu `09` §3 decision 306). Design: felhom.eu `11-os-updates.md` §5.11.1 and §5.12.
|
||||||
|
|||||||
+71
-1
@@ -161,6 +161,13 @@ KERNEL_MODES = ("kernel-status", "kernel-reboot", "kernel-boot", "kernel-good",
|
|||||||
KERNEL_ACTIVE = ("staged", "oneshot", "judging", "reverting")
|
KERNEL_ACTIVE = ("staged", "oneshot", "judging", "reverting")
|
||||||
# The clean-up of old kernels (R-1368, `09` §3 decision 286, `11` §5.11.1). A step that ENDED in one of these phases
|
# The clean-up of old kernels (R-1368, `09` §3 decision 286, `11` §5.11.1). A step that ENDED in one of these phases
|
||||||
# and is not "good" keeps every kernel (the box fell back or reverted); any other phase is "not ended" (P1).
|
# and is not "good" keeps every kernel (the box fell back or reverted); any other phase is "not ended" (P1).
|
||||||
|
# Trust in Proxmox by a copy of the box's own root CA (R-1380, `09` §3 decision 303, felhom.eu `03` §12a). The agent's
|
||||||
|
# user cannot read Proxmox's CA file (0640 root:www-data); this root step keeps a readable copy of that ONE public
|
||||||
|
# certificate where the agent's `proxmox.tls.ca_file` points.
|
||||||
|
PVE_CA_SOURCE = "/etc/pve/pve-root-ca.pem"
|
||||||
|
PVE_CA_COPY = "/etc/felhom-agent/pve-root-ca.pem" # 0644 root: a CA certificate is public; the agent only reads it
|
||||||
|
PVE_CA_MAX_BYTES = 64 * 1024
|
||||||
|
PEM_CERT_RE = re.compile(rb"-----BEGIN CERTIFICATE-----\s[A-Za-z0-9+/=\s]+-----END CERTIFICATE-----")
|
||||||
# The security-profile refresh (R-1388, R-1301, `11` §5.12; measured on demo-hp 2026-11-14,
|
# The security-profile refresh (R-1388, R-1301, `11` §5.12; measured on demo-hp 2026-11-14,
|
||||||
# felhom.eu audits/profile-refresh-spike-2026-11-14/). AppArmor keeps one cache folder per kernel feature set; a
|
# felhom.eu audits/profile-refresh-spike-2026-11-14/). AppArmor keeps one cache folder per kernel feature set; a
|
||||||
# profile that changed while ANOTHER kernel ran leaves an old compiled copy in this kernel's folder, with a time that
|
# profile that changed while ANOTHER kernel ran leaves an old compiled copy in this kernel's folder, with a time that
|
||||||
@@ -514,7 +521,7 @@ class Apply:
|
|||||||
def check_plan(self, plan):
|
def check_plan(self, plan):
|
||||||
mode = plan.get("mode", "apply")
|
mode = plan.get("mode", "apply")
|
||||||
if mode not in ("apply", "inventory", "health", "facts", "live-restore-on", "bundle", "bundle-policy", "agent_update",
|
if mode not in ("apply", "inventory", "health", "facts", "live-restore-on", "bundle", "bundle-policy", "agent_update",
|
||||||
"oom-check", "profile-refresh") \
|
"oom-check", "profile-refresh", "pve-ca-copy") \
|
||||||
+ KERNEL_MODES:
|
+ KERNEL_MODES:
|
||||||
raise Refused("R11", f"unknown mode {mode!r}")
|
raise Refused("R11", f"unknown mode {mode!r}")
|
||||||
if mode == "agent_update":
|
if mode == "agent_update":
|
||||||
@@ -529,6 +536,15 @@ class Apply:
|
|||||||
if plan.get("layer") != "host":
|
if plan.get("layer") != "host":
|
||||||
raise Refused("R11", "bundle-policy is a host-layer mode")
|
raise Refused("R11", "bundle-policy is a host-layer mode")
|
||||||
return mode, "host", 0, "bundle-policy"
|
return mode, "host", 0, "bundle-policy"
|
||||||
|
if mode == "pve-ca-copy":
|
||||||
|
if plan.get("layer") != "host":
|
||||||
|
raise Refused("R11", "pve-ca-copy is a host-layer mode")
|
||||||
|
# R-1380 (D80): ONE fixed source, ONE fixed place — the plan may name NOTHING (no path, no file, no content)
|
||||||
|
extra = sorted(k for k in plan if k not in ("mode", "layer", "release_id", "run_id", "trigger", "ring"))
|
||||||
|
if extra:
|
||||||
|
raise Refused("T1", f"a pve-ca-copy plan carries {', '.join(extra)} — it takes nothing from a plan: "
|
||||||
|
f"the source is {PVE_CA_SOURCE} and the copy is {PVE_CA_COPY}, both fixed")
|
||||||
|
return mode, "host", 0, "pve-ca-copy"
|
||||||
if mode == "profile-refresh":
|
if mode == "profile-refresh":
|
||||||
if plan.get("layer") != "host":
|
if plan.get("layer") != "host":
|
||||||
raise Refused("R11", "profile-refresh is a host-layer mode")
|
raise Refused("R11", "profile-refresh is a host-layer mode")
|
||||||
@@ -1124,6 +1140,10 @@ class Apply:
|
|||||||
return Bundle(self).from_plan(plan)
|
return Bundle(self).from_plan(plan)
|
||||||
if self.mode == "bundle-policy":
|
if self.mode == "bundle-policy":
|
||||||
return Bundle(self).policy_pass()
|
return Bundle(self).policy_pass()
|
||||||
|
if self.mode == "pve-ca-copy":
|
||||||
|
# NO appliance check, on purpose (like the bundle): the copy is the agent's OWN trust file in the agent's own
|
||||||
|
# config folder, and it changes nothing of the host's — a BYO host's agent talks to Proxmox the same way.
|
||||||
|
return self.pve_ca_copy()
|
||||||
if self.mode == "profile-refresh":
|
if self.mode == "profile-refresh":
|
||||||
self.check_appliance()
|
self.check_appliance()
|
||||||
return self.profile_refresh(plan)
|
return self.profile_refresh(plan)
|
||||||
@@ -1192,6 +1212,56 @@ class Apply:
|
|||||||
self.report["oom_check"] = self.oom_check()
|
self.report["oom_check"] = self.oom_check()
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
# ---------- trust in Proxmox by a copy of its root CA (R-1380, D80) ----------
|
||||||
|
def pve_ca_copy(self):
|
||||||
|
"""Copy exactly PVE_CA_SOURCE to exactly PVE_CA_COPY — only if the source is ONE CA certificate that has not
|
||||||
|
ended — written whole or not at all (a root-owned temp file beside the target, fsync, rename). "same" when the
|
||||||
|
copy already holds the source's bytes. The bytes that are checked are the bytes that are written."""
|
||||||
|
r = self.r
|
||||||
|
res = {"outcome": None, "path": PVE_CA_COPY}
|
||||||
|
self.report["pve_ca_copy"] = res
|
||||||
|
try:
|
||||||
|
data = r.read_bytes(PVE_CA_SOURCE)
|
||||||
|
except OSError as e:
|
||||||
|
raise Refused("T2", f"{PVE_CA_SOURCE} cannot be read ({e.__class__.__name__}) — is this a Proxmox host?")
|
||||||
|
if not data or len(data) > PVE_CA_MAX_BYTES:
|
||||||
|
raise Refused("T3", f"{PVE_CA_SOURCE} is {len(data)} bytes — not one certificate")
|
||||||
|
certs = PEM_CERT_RE.findall(data)
|
||||||
|
if len(certs) != 1 or b"PRIVATE KEY" in data:
|
||||||
|
raise Refused("T3", f"{PVE_CA_SOURCE} holds {len(certs)} certificate(s)"
|
||||||
|
+ (" and a private key" if b"PRIVATE KEY" in data else "") + " — exactly one CA certificate is expected")
|
||||||
|
try:
|
||||||
|
text = data.decode("ascii") # PEM is ASCII; the runner hands text to openssl's stdin
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
raise Refused("T3", f"{PVE_CA_SOURCE} is not a PEM text file")
|
||||||
|
rc, out, err = r.host(["openssl", "x509", "-noout", "-ext", "basicConstraints", "-enddate"], 30, stdin=text)
|
||||||
|
if rc != 0:
|
||||||
|
raise Refused("T3", f"{PVE_CA_SOURCE} is not a readable certificate: {(out + err).strip()[-160:]}")
|
||||||
|
if not re.search(r"\bCA:TRUE\b", out):
|
||||||
|
raise Refused("T3", f"{PVE_CA_SOURCE} is not a CA certificate (no CA:TRUE) — a leaf or any other file is never copied")
|
||||||
|
m = re.search(r"notAfter=(.+)", out)
|
||||||
|
try:
|
||||||
|
import calendar
|
||||||
|
not_after = calendar.timegm(time.strptime(m.group(1).strip(), "%b %d %H:%M:%S %Y GMT")) # UTC, no DST
|
||||||
|
except (AttributeError, ValueError):
|
||||||
|
raise Refused("T3", f"the end date of {PVE_CA_SOURCE} cannot be read")
|
||||||
|
days = int((not_after - r.now()) // 86400)
|
||||||
|
if days < 0:
|
||||||
|
raise Refused("T3", f"{PVE_CA_SOURCE} ended {-days} day(s) ago — an ended CA is never copied")
|
||||||
|
res.update(sha256=sha256_hex(data), not_after=_iso(not_after), days_left=days)
|
||||||
|
try:
|
||||||
|
have = r.read_bytes(PVE_CA_COPY)
|
||||||
|
except OSError:
|
||||||
|
have = None
|
||||||
|
if have == data:
|
||||||
|
res["outcome"] = "same"
|
||||||
|
return 0
|
||||||
|
r.put_file(PVE_CA_COPY, data, 0o644)
|
||||||
|
res["outcome"], res["replaced"] = "written", have is not None
|
||||||
|
r.log(f"os-apply: PVE CA COPY written {PVE_CA_COPY} sha256={res['sha256'][:16]} ends={res['not_after']} "
|
||||||
|
f"({'replaced an older copy' if have is not None else 'first copy'})")
|
||||||
|
return 0
|
||||||
|
|
||||||
# ---------- the security-profile refresh (R-1388, R-1301, `11` §5.12) ----------
|
# ---------- the security-profile refresh (R-1388, R-1301, `11` §5.12) ----------
|
||||||
def profile_cache(self):
|
def profile_cache(self):
|
||||||
"""{"<folder>/<file>": sha256} of every cached profile, every kernel's folder."""
|
"""{"<folder>/<file>": sha256} of every cached profile, every kernel's folder."""
|
||||||
|
|||||||
@@ -2633,5 +2633,143 @@ class ProfileRefresh(unittest.TestCase):
|
|||||||
run(f)
|
run(f)
|
||||||
self.assertFalse([c for c in f.calls if c[1][0] in ("apt-get", "dpkg", "apt")], "no package command at all")
|
self.assertFalse([c for c in f.calls if c[1][0] in ("apt-get", "dpkg", "apt")], "no package command at all")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- trust in Proxmox by a copy of its root CA (R-1380, D80, felhom.eu `03` §12a) ----------
|
||||||
|
CA_PEM = b"-----BEGIN CERTIFICATE-----\nQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo=\n-----END CERTIFICATE-----\n"
|
||||||
|
CA_PEM_2 = b"-----BEGIN CERTIFICATE-----\nWllYV1ZVVFNSUVBPTk1MS0pJSEdGRURDQkE=\n-----END CERTIFICATE-----\n"
|
||||||
|
|
||||||
|
|
||||||
|
class TFake(Fake):
|
||||||
|
"""A Proxmox host: the root CA file, the copy (or none), and openssl's answer about the bytes it is given."""
|
||||||
|
|
||||||
|
def __init__(self, source=CA_PEM, copy=None, ca=True, ends="Aug 17 15:44:42 2036 GMT"):
|
||||||
|
super().__init__()
|
||||||
|
self.source, self.tree, self.ca, self.ends, self.openssl_rc = source, {}, ca, ends, 0
|
||||||
|
if copy is not None:
|
||||||
|
self.tree[osapply.PVE_CA_COPY] = copy
|
||||||
|
self.openssl_stdin, self.puts = [], []
|
||||||
|
self.plan = {"release_id": "pve-ca", "layer": "host", "mode": "pve-ca-copy"}
|
||||||
|
|
||||||
|
def read_bytes(self, p):
|
||||||
|
if p == osapply.PVE_CA_SOURCE:
|
||||||
|
if self.source is None:
|
||||||
|
raise FileNotFoundError(p)
|
||||||
|
return self.source
|
||||||
|
if p in self.tree:
|
||||||
|
return self.tree[p]
|
||||||
|
raise FileNotFoundError(p)
|
||||||
|
|
||||||
|
def put_file(self, path, data, mode):
|
||||||
|
self.puts.append((path, data, mode))
|
||||||
|
self.tree[path] = data
|
||||||
|
|
||||||
|
def host(self, argv, timeout=600, stdin=None):
|
||||||
|
if argv[0] == "openssl":
|
||||||
|
self.calls.append(("host", argv))
|
||||||
|
self.openssl_stdin.append(stdin)
|
||||||
|
if self.openssl_rc:
|
||||||
|
return self.openssl_rc, "", "Could not read certificate from <stdin>"
|
||||||
|
return 0, ("X509v3 Basic Constraints: critical\n CA:%s\nnotAfter=%s\n" % ("TRUE" if self.ca else "FALSE", self.ends)), ""
|
||||||
|
return super().host(argv, timeout, stdin)
|
||||||
|
|
||||||
|
|
||||||
|
class PVECACopy(unittest.TestCase):
|
||||||
|
"""Red-proofs: felhom.eu audits/night-build-2026-11-14/A/redproof-wrapper.txt."""
|
||||||
|
|
||||||
|
def refused(self, f, code):
|
||||||
|
rc, rep = run(f)
|
||||||
|
self.assertEqual((rc, rep["refused"]["code"]), (2, code), rep)
|
||||||
|
self.assertEqual(f.puts, [], "nothing is written on a refusal")
|
||||||
|
return rep
|
||||||
|
|
||||||
|
def test_a_new_box_gets_the_copy(self):
|
||||||
|
f = TFake()
|
||||||
|
rc, rep = run(f)
|
||||||
|
self.assertEqual(rc, 0, rep)
|
||||||
|
self.assertEqual(f.puts, [(osapply.PVE_CA_COPY, CA_PEM, 0o644)], "ONE file, the fixed place, the source's bytes, 0644")
|
||||||
|
self.assertEqual(f.openssl_stdin, [CA_PEM.decode()], "the bytes that are checked are the bytes that are written")
|
||||||
|
c = rep["pve_ca_copy"]
|
||||||
|
self.assertEqual((c["outcome"], c["path"], c["replaced"]), ("written", "/etc/felhom-agent/pve-root-ca.pem", False))
|
||||||
|
self.assertEqual(c["not_after"], "2036-08-17T15:44:42Z")
|
||||||
|
self.assertGreater(c["days_left"], 3000)
|
||||||
|
|
||||||
|
def test_the_same_copy_is_not_written_again(self):
|
||||||
|
f = TFake(copy=CA_PEM)
|
||||||
|
rc, rep = run(f)
|
||||||
|
self.assertEqual((rc, rep["pve_ca_copy"]["outcome"], f.puts), (0, "same", []), rep)
|
||||||
|
|
||||||
|
def test_a_renewed_ca_refreshes_the_copy(self):
|
||||||
|
f = TFake(source=CA_PEM_2, copy=CA_PEM)
|
||||||
|
rc, rep = run(f)
|
||||||
|
self.assertEqual((rc, rep["pve_ca_copy"]["outcome"], rep["pve_ca_copy"]["replaced"]), (0, "written", True), rep)
|
||||||
|
self.assertEqual(f.tree[osapply.PVE_CA_COPY], CA_PEM_2)
|
||||||
|
|
||||||
|
def test_a_source_that_is_not_a_ca_certificate_is_refused(self):
|
||||||
|
self.refused(TFake(ca=False), "T3") # a leaf certificate
|
||||||
|
self.refused(TFake(source=b"hello\n"), "T3") # not a certificate at all
|
||||||
|
self.refused(TFake(source=CA_PEM + CA_PEM_2), "T3") # two certificates (a chain, a bundle)
|
||||||
|
self.refused(TFake(source=CA_PEM + b"-----BEGIN PRIVATE KEY-----\nAAAA\n-----END PRIVATE KEY-----\n"), "T3")
|
||||||
|
self.refused(TFake(source=b""), "T3")
|
||||||
|
self.refused(TFake(source=CA_PEM + b"x" * (osapply.PVE_CA_MAX_BYTES + 1)), "T3")
|
||||||
|
f = TFake()
|
||||||
|
f.openssl_rc = 1 # openssl cannot parse it
|
||||||
|
self.refused(f, "T3")
|
||||||
|
self.refused(TFake(ends="Aug 17 15:44:42 2020 GMT"), "T3") # an ended CA
|
||||||
|
self.refused(TFake(ends="garbage"), "T3")
|
||||||
|
|
||||||
|
def test_no_source_is_refused(self):
|
||||||
|
self.refused(TFake(source=None), "T2")
|
||||||
|
|
||||||
|
def test_with_the_real_openssl_and_the_real_runner(self):
|
||||||
|
"""NOT a fake: a CA and a leaf made by openssl in a temp folder, judged through Runner.host as on a box (the
|
||||||
|
fake cannot show a wrong argument or a bytes/text mix-up). Skipped where openssl is missing."""
|
||||||
|
import shutil, tempfile
|
||||||
|
if not shutil.which("openssl"):
|
||||||
|
self.skipTest("no openssl here")
|
||||||
|
d = tempfile.mkdtemp()
|
||||||
|
try:
|
||||||
|
def sh(*a):
|
||||||
|
subprocess.run(a, check=True, capture_output=True, cwd=d)
|
||||||
|
sh("openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", "ca.key", "-out", "ca.pem", "-days", "30",
|
||||||
|
"-subj", "/CN=test CA", "-addext", "basicConstraints=critical,CA:TRUE")
|
||||||
|
sh("openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", "leaf.key", "-out", "leaf.pem", "-days", "30",
|
||||||
|
"-subj", "/CN=leaf", "-addext", "basicConstraints=CA:FALSE")
|
||||||
|
real = osapply.Runner()
|
||||||
|
for name, want in (("ca.pem", "written"), ("leaf.pem", "T3")):
|
||||||
|
data = open(os.path.join(d, name), "rb").read()
|
||||||
|
f = TFake(source=data)
|
||||||
|
f.host = lambda argv, timeout=600, stdin=None: real.host(argv, timeout, stdin) if argv[0] == "openssl" else Fake.host(f, argv, timeout, stdin)
|
||||||
|
f.clock = __import__("time").time()
|
||||||
|
rc, rep = run(f)
|
||||||
|
got = rep["pve_ca_copy"]["outcome"] if rc == 0 else rep["refused"]["code"]
|
||||||
|
self.assertEqual(got, want, f"{name}: {rep}")
|
||||||
|
if want == "written":
|
||||||
|
self.assertIn(rep["pve_ca_copy"]["days_left"], (29, 30))
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(d, ignore_errors=True)
|
||||||
|
|
||||||
|
def test_a_plan_that_names_a_path_or_a_file_is_refused(self):
|
||||||
|
for extra in ({"source": "/etc/shadow"}, {"path": "/etc/sudoers.d/x"}, {"dest": "/root/.ssh/authorized_keys"},
|
||||||
|
{"content_b64": "QQ=="}, {"packages": []}, {"file": "x"}, {"vmid": 9201}, {"dry_run": True}):
|
||||||
|
f = TFake()
|
||||||
|
f.plan.update(extra)
|
||||||
|
rep = self.refused(f, "T1")
|
||||||
|
self.assertEqual(f.openssl_stdin, [], "refused before the source is even read")
|
||||||
|
g = TFake()
|
||||||
|
g.plan["layer"] = "kernel"
|
||||||
|
self.refused(g, "R11")
|
||||||
|
|
||||||
|
def test_a_refused_source_keeps_an_older_copy(self):
|
||||||
|
f = TFake(source=b"not a certificate", copy=CA_PEM)
|
||||||
|
self.refused(f, "T3")
|
||||||
|
self.assertEqual(f.tree[osapply.PVE_CA_COPY], CA_PEM, "written whole or not at all")
|
||||||
|
|
||||||
|
def test_it_runs_on_a_byo_host_too_and_changes_nothing_else(self):
|
||||||
|
f = TFake()
|
||||||
|
f.files[osapply.INSTALL_STATE] = json.dumps({"mode": "byo"})
|
||||||
|
rc, rep = run(f)
|
||||||
|
self.assertEqual((rc, rep["pve_ca_copy"]["outcome"]), (0, "written"), rep)
|
||||||
|
self.assertEqual([c[1][0] for c in f.calls], ["openssl"], "one openssl call; no package, no service, no other file")
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
Reference in new issue
Block a user