856 lines
39 KiB
Python
856 lines
39 KiB
Python
#!/usr/bin/env python3
|
|
"""Admission expérimentale cgroup v2 ; aucun effet à l'importation.
|
|
|
|
Le seul bootstrap natif est fixe et bloqué sur un socket privé. systemd-run
|
|
n'a pas besoin de transmettre un descripteur : le bootstrap se connecte par
|
|
nom, puis les deux extrémités vérifient SO_PEERCRED et la durée de vie PID.
|
|
"""
|
|
import argparse
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import resource
|
|
import select
|
|
import selectors
|
|
import socket
|
|
import stat
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
|
|
CLEAN_ENV = {"PATH": "/usr/bin", "LC_ALL": "C"}
|
|
TASKS = 256
|
|
# systemctl show renders these D-Bus microsecond properties as durations.
|
|
# Compare its exact CLI representation, matching the fixed Sec= launch options.
|
|
POLICY = {"Delegate": "yes", "TasksMax": "256", "RuntimeMaxUSec": "12s",
|
|
"TimeoutStopUSec": "2s", "KillMode": "control-group"}
|
|
LIMIT = 1048576
|
|
|
|
|
|
class Refusal(RuntimeError):
|
|
"""Une preuve absente ou contradictoire interdit la libération."""
|
|
|
|
|
|
def require(condition, message):
|
|
if not condition:
|
|
raise Refusal(message)
|
|
|
|
|
|
def policy(nonce):
|
|
"""Identité fraîche, scellée dans le manifeste ; jamais un nom réutilisable."""
|
|
require(re.fullmatch(r"[0-9a-f]{32}", nonce) is not None, "nonce invalide")
|
|
return {"nonce": nonce, "unit": "mosaic-egl-" + nonce + ".scope",
|
|
"description": "Mosaic experimental admission " + nonce,
|
|
"properties": dict(POLICY), "pids_max": TASKS}
|
|
|
|
|
|
def scope_command(spec, bootstrap_argv):
|
|
"""Construction fermée du mécanisme manager ; aucune option libre."""
|
|
argv = ["/usr/bin/systemd-run", "--user", "--scope", "--slice=app.slice",
|
|
"--collect", "--no-ask-password", "--expand-environment=no",
|
|
"--unit=" + spec["unit"], "--description=" + spec["description"]]
|
|
properties = ["Delegate=yes", "TasksMax=256", "RuntimeMaxSec=12s",
|
|
"TimeoutStopSec=2s", "KillMode=control-group"]
|
|
return argv + ["--property=" + value for value in properties] + bootstrap_argv
|
|
|
|
|
|
def bootstrap_command(lab, spec, identity, argv):
|
|
return ["/usr/bin/python3", "-I", str(lab / "fixture/cgroup_gate.py"),
|
|
"--bootstrap", str(lab / "gate.sock"), spec["nonce"],
|
|
str(identity["pid"]), str(identity["startticks"]), *argv]
|
|
|
|
|
|
def sealed_scope_argv(lab, manifest):
|
|
"""Seuls PID/startticks seront remplis par le préflight de cette invocation."""
|
|
identity = {"pid": "<SUPERVISOR_PID>", "startticks": "<SUPERVISOR_STARTTICKS>"}
|
|
return scope_command(manifest["scope"], bootstrap_command(
|
|
lab, manifest["scope"], identity, manifest["argv"]))
|
|
|
|
|
|
class ProcFS:
|
|
"""Observations procfs strictes, sans migration ni changement de rlimit."""
|
|
|
|
def __init__(self, root=Path("/proc")):
|
|
self.root = root
|
|
|
|
def identity(self, pid):
|
|
directory = self.root / str(pid)
|
|
before = directory.stat()
|
|
fields = (directory / "stat").read_text().rsplit(")", 1)[1].split()
|
|
lines = (directory / "cgroup").read_text().splitlines()
|
|
require(len(lines) == 1 and lines[0].startswith("0::/"), "cgroup v1/hybride")
|
|
status = dict(line.split(":", 1) for line in
|
|
(directory / "status").read_text().splitlines() if ":" in line)
|
|
require(set(map(int, status["Uid"].split())) == {before.st_uid},
|
|
"UID process divergent")
|
|
require(directory.stat().st_ino == before.st_ino, "proc identity drift")
|
|
return {"pid": pid, "uid": before.st_uid, "startticks": int(fields[19]),
|
|
"cgroup": lines[0][3:]}
|
|
|
|
def _thread_snapshot(self, directory, uid):
|
|
"""Observe une durée de vie sans authentifier ses credentials mixtes."""
|
|
before = directory.stat()
|
|
status = dict(line.split(":", 1) for line in
|
|
(directory / "status").read_text().splitlines()
|
|
if ":" in line)
|
|
uids = tuple(map(int, status["Uid"].split()))
|
|
require(len(uids) == 4, "UID diagnostic incomplet")
|
|
# Foreign real UIDs contribute nothing; compare their credentials and
|
|
# directory lifetime across passes to detect a transition into this UID.
|
|
if uids[0] != uid:
|
|
return (int(directory.name), None, before.st_dev, before.st_ino,
|
|
uids, None, None, ())
|
|
raw_stat = (directory / "stat").read_text()
|
|
pid = int(raw_stat.split("(", 1)[0].strip())
|
|
fields = raw_stat.rsplit(")", 1)[1].split()
|
|
require(pid == int(directory.name), "PID divergent pendant diagnostic")
|
|
lines = (directory / "cgroup").read_text().splitlines()
|
|
require(len(lines) == 1 and lines[0].startswith("0::/"),
|
|
"cgroup v1/hybride pendant diagnostic")
|
|
threads = int(status["Threads"])
|
|
tasks = tuple(sorted(entry.name for entry in
|
|
(directory / "task").iterdir()))
|
|
require(all(name.isdecimal() for name in tasks)
|
|
and threads > 0 and threads == len(tasks),
|
|
f"PID {pid}: compte threads incohérent")
|
|
after = directory.stat()
|
|
require((before.st_dev, before.st_ino) == (after.st_dev, after.st_ino),
|
|
"proc identity drift pendant diagnostic")
|
|
return (pid, int(fields[19]), before.st_dev, before.st_ino,
|
|
uids, lines[0][3:], threads, tasks)
|
|
|
|
def uid_threads(self, uid):
|
|
"""Compte le real UID ; toute incertitude observée refuse le diagnostic."""
|
|
try:
|
|
directories = {entry.name: entry for entry in self.root.iterdir()
|
|
if entry.name.isdecimal()}
|
|
# RLIMIT_NPROC counts real-UID threads, including privileged helpers.
|
|
# Authentication remains exclusively in the unchanged identity().
|
|
before = {name: self._thread_snapshot(directory, uid)
|
|
for name, directory in directories.items()}
|
|
after = {name: self._thread_snapshot(directory, uid)
|
|
for name, directory in directories.items()}
|
|
final_names = {entry.name for entry in self.root.iterdir()
|
|
if entry.name.isdecimal()}
|
|
require(final_names == set(directories),
|
|
"process creation/end pendant diagnostic")
|
|
fields = ("pid", "startticks", "device", "inode", "UID",
|
|
"cgroup", "Threads", "task")
|
|
for name, sample in before.items():
|
|
changed = [field for field, old, new in
|
|
zip(fields, sample, after[name]) if old != new]
|
|
require(not changed,
|
|
f"PID {name}: {','.join(changed)} drift pendant diagnostic")
|
|
total = sum(sample[6] for sample in after.values()
|
|
if sample[4][0] == uid)
|
|
require(total > 0, "charge UID inconnue")
|
|
return total
|
|
except (OSError, ValueError, KeyError, IndexError) as error:
|
|
raise Refusal(f"proc diagnostic illisible/disparu: {error}") from error
|
|
|
|
def bounded_text(self, relative, limit, root_owned=False):
|
|
"""Lire sans suivre le fichier final ; refuser troncature et remplacement."""
|
|
path = self.root / relative
|
|
descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC)
|
|
try:
|
|
before = os.fstat(descriptor)
|
|
require(stat.S_ISREG(before.st_mode) and not before.st_mode & 0o022,
|
|
"source proc modifiable/non régulière")
|
|
require(not root_owned or before.st_uid == 0,
|
|
"source proc non root")
|
|
data = os.read(descriptor, limit + 1)
|
|
require(len(data) <= limit and os.read(descriptor, 1) == b"",
|
|
"source proc trop longue")
|
|
after = path.stat(follow_symlinks=False)
|
|
require((before.st_dev, before.st_ino) == (after.st_dev, after.st_ino),
|
|
"source proc remplacée")
|
|
return data.decode("ascii")
|
|
finally:
|
|
os.close(descriptor)
|
|
|
|
def host_context(self):
|
|
"""Qualifier seulement le procfs hôte Linux reconnu, sinon refuser."""
|
|
require(self.root == Path("/proc") and self.root.resolve() == self.root,
|
|
"source proc hors chemin hôte")
|
|
root = self.root.stat()
|
|
require(root.st_uid == 0 and not root.st_mode & 0o022,
|
|
"racine proc non fiable")
|
|
mounts = self.bounded_text("self/mountinfo", LIMIT)
|
|
matches = []
|
|
for line in mounts.splitlines():
|
|
left, right = line.split(" - ", 1)
|
|
fields, source = left.split(), right.split()
|
|
require(len(fields) >= 6 and len(source) == 3,
|
|
"mountinfo proc invalide")
|
|
target = fields[4]
|
|
if target == "/proc":
|
|
require(fields[3] == "/" and source[0] == "proc"
|
|
and source[1] == "proc", "montage proc hôte requis")
|
|
matches.append((fields[0], fields[2]))
|
|
else:
|
|
require(not any(target == name or target.startswith(name + "/")
|
|
for name in ("/proc/loadavg", "/proc/self", "/proc/1")),
|
|
"surmonture source proc refusée")
|
|
require(len(matches) == 1, "montage proc ambigu/absent")
|
|
require(matches[0][1] == f"{os.major(root.st_dev)}:{os.minor(root.st_dev)}",
|
|
"device proc divergent")
|
|
# WHY CONTRACT INVARIANT: recognizing Linux initial self namespace
|
|
# inodes avoids confusing two equally nested namespaces with the host.
|
|
# PID1 namespace links may be ptrace-protected for ordinary users; the
|
|
# singleton self NSpid additionally binds this procfs PID view to us.
|
|
namespaces = []
|
|
for kind, initial in (("pid", 4026531836), ("user", 4026531837)):
|
|
link = os.readlink(self.root / "self" / "ns" / kind)
|
|
require(link == f"{kind}:[{initial}]", "namespace proc non initial")
|
|
namespaces.append(link)
|
|
uid_map = self.bounded_text("self/uid_map", 256).split()
|
|
require(uid_map == ["0", "0", "4294967295"], "UID map non initiale")
|
|
status = self.bounded_text("self/status", 65536).splitlines()
|
|
nspids = [line.split()[1:] for line in status if line.startswith("NSpid:")]
|
|
require(nspids == [[str(os.getpid())]], "vue PID proc non hôte")
|
|
loadavg = (self.root / "loadavg").stat(follow_symlinks=False)
|
|
require(loadavg.st_dev == root.st_dev, "device loadavg divergent")
|
|
return (root.st_dev, root.st_ino, tuple(matches), tuple(namespaces),
|
|
loadavg.st_ino)
|
|
|
|
def global_tasks(self):
|
|
"""Borne système ponctuelle ; aucun parcours PID ni réservation."""
|
|
try:
|
|
before = self.host_context()
|
|
text = self.bounded_text("loadavg", 256, root_owned=True)
|
|
# WHY CONTRACT INVARIANT: bounded ASCII full-match rejects partial,
|
|
# extra and malformed fields rather than accepting an undercount.
|
|
match = re.fullmatch(
|
|
r"[0-9]{1,10}\.[0-9]{2} [0-9]{1,10}\.[0-9]{2} "
|
|
r"[0-9]{1,10}\.[0-9]{2} ([0-9]{1,10})/([0-9]{1,10}) "
|
|
r"[0-9]{1,10}\n", text)
|
|
require(match is not None, "loadavg format invalide")
|
|
runnable, total = map(int, match.groups())
|
|
require(0 <= runnable <= total and total > 0,
|
|
"loadavg compte incohérent")
|
|
require(self.host_context() == before, "contexte proc divergent")
|
|
return total
|
|
except (OSError, UnicodeError, ValueError, IndexError) as error:
|
|
raise Refusal(f"source loadavg non fiable/illisible: {error}") from error
|
|
|
|
def nproc(self):
|
|
return resource.getrlimit(resource.RLIMIT_NPROC)
|
|
|
|
def pidfd(self, pid):
|
|
return os.pidfd_open(pid)
|
|
|
|
|
|
class CgroupFS:
|
|
"""Accès exclusivement en lecture au montage unifié et à ses compteurs."""
|
|
|
|
def __init__(self, root=Path("/sys/fs/cgroup")):
|
|
self.root = root
|
|
|
|
def mount(self):
|
|
matches = []
|
|
for line in Path("/proc/self/mountinfo").read_text().splitlines():
|
|
left, right = line.split(" - ", 1)
|
|
fields = left.split()
|
|
if fields[4] == str(self.root):
|
|
require(right.split()[0] == "cgroup2" and fields[3] == "/",
|
|
"montage cgroup2 unifié racine requis")
|
|
matches.append((int(fields[0]), fields[2]))
|
|
require(len(matches) == 1, "identité montage ambiguë")
|
|
return matches[0]
|
|
|
|
def path(self, group):
|
|
require(group.startswith("/") and ".." not in group.split("/"),
|
|
"chemin cgroup invalide")
|
|
path = self.root / group.lstrip("/")
|
|
require(path.resolve(strict=True) == path, "symlink cgroup refusé")
|
|
require(path.stat().st_dev == self.root.stat().st_dev,
|
|
"montage cgroup divergent")
|
|
return path
|
|
|
|
def snapshot(self, group):
|
|
path = self.path(group)
|
|
for name in ("cgroup.type", "pids.max", "pids.current", "cgroup.procs"):
|
|
require(not (path / name).is_symlink(), "symlink fichier cgroup refusé")
|
|
require((path / "cgroup.type").read_text().strip() == "domain",
|
|
"cgroup non domain/threaded")
|
|
info = path.stat()
|
|
return {"inode": info.st_ino, "device": info.st_dev, "uid": info.st_uid,
|
|
"pids_max": (path / "pids.max").read_text().strip(),
|
|
"pids_current": int((path / "pids.current").read_text()),
|
|
"procs": [int(pid) for pid in
|
|
(path / "cgroup.procs").read_text().split()],
|
|
"children": [entry.name for entry in path.iterdir() if entry.is_dir()]}
|
|
|
|
def available(self, group):
|
|
path = self.path(group)
|
|
return ((path / "cgroup.controllers").read_text().split(),
|
|
(path / "cgroup.subtree_control").read_text().split(),
|
|
os.access(path / "cgroup.procs", os.W_OK, effective_ids=True))
|
|
|
|
def exists(self, group):
|
|
path = self.root / group.lstrip("/")
|
|
return path.exists() or path.is_symlink()
|
|
|
|
def empty(self, group):
|
|
path = self.path(group)
|
|
def fail_walk(error):
|
|
raise error
|
|
|
|
walk = os.walk(path, followlinks=False, onerror=fail_walk)
|
|
for directory, names, files in walk:
|
|
current = Path(directory)
|
|
require(not any((current / name).is_symlink() for name in names + files),
|
|
"symlink dans sous-arbre")
|
|
events = dict(line.split() for line in
|
|
(current / "cgroup.events").read_text().splitlines())
|
|
if events.get("populated") != "0":
|
|
return False
|
|
if (current / "cgroup.procs").read_text().strip():
|
|
return False
|
|
if (current / "cgroup.threads").read_text().strip():
|
|
return False
|
|
if int((current / "pids.current").read_text()) != 0:
|
|
return False
|
|
return True
|
|
|
|
|
|
class DrainFailure(Refusal):
|
|
"""Une collecte échouée conserve les préfixes bruts déjà observés."""
|
|
|
|
def __init__(self, message, channels):
|
|
super().__init__(message)
|
|
self.channels = [bytes(channel) for channel in channels]
|
|
|
|
|
|
def drain(process, deadline, limit=LIMIT):
|
|
"""Collecte bornée ; le propriétaire effectue le stop et le wait final."""
|
|
channels = [bytearray(), bytearray()]
|
|
try:
|
|
with selectors.DefaultSelector() as selector:
|
|
for index, stream in enumerate((process.stdout, process.stderr)):
|
|
os.set_blocking(stream.fileno(), False)
|
|
selector.register(stream, selectors.EVENT_READ, index)
|
|
while selector.get_map():
|
|
remaining = deadline - time.monotonic()
|
|
require(remaining > 0, "drain timeout")
|
|
for key, _ in selector.select(min(.05, remaining)):
|
|
chunk = os.read(key.fd, 65536)
|
|
if not chunk:
|
|
selector.unregister(key.fileobj)
|
|
continue
|
|
target = channels[key.data]
|
|
room = limit - len(target)
|
|
target.extend(chunk[:room])
|
|
require(len(chunk) <= room, "stdout/stderr overflow")
|
|
process.wait(timeout=max(.001, deadline - time.monotonic()))
|
|
return channels
|
|
except BaseException as error:
|
|
raise DrainFailure(f"{type(error).__name__}: {error}", channels) from error
|
|
|
|
|
|
class SystemManager:
|
|
"""Les seules mutations sont la création scellée et le stop possédé."""
|
|
|
|
def __init__(self, uid, popen=None):
|
|
self.popen = popen or subprocess.Popen
|
|
self.environment = {
|
|
**CLEAN_ENV, "XDG_RUNTIME_DIR": f"/run/user/{uid}",
|
|
"DBUS_SESSION_BUS_ADDRESS": f"unix:path=/run/user/{uid}/bus",
|
|
"SYSTEMD_PAGER": "cat", "SYSTEMD_COLORS": "0",
|
|
}
|
|
|
|
def spawn(self, argv):
|
|
return self.popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE, close_fds=True,
|
|
env=self.environment, start_new_session=True)
|
|
|
|
def call(self, argv, deadline):
|
|
"""Auxiliaire borné, récolté même si la lecture échoue."""
|
|
require(deadline - time.monotonic() > .1, "budget manager épuisé")
|
|
process = self.spawn(argv)
|
|
try:
|
|
channels = drain(process, deadline - .05, limit=65536)
|
|
require(process.returncode == 0, "manager commande refusée")
|
|
return bytes(channels[0]).decode("utf-8", errors="strict")
|
|
finally:
|
|
try:
|
|
if process.poll() is None:
|
|
process.kill() # Our unreaped Popen only; never a discovered PID.
|
|
process.wait(timeout=max(.001, deadline - time.monotonic()))
|
|
finally:
|
|
process.stdout.close()
|
|
process.stderr.close()
|
|
|
|
def show(self, unit, deadline, system=False):
|
|
argv = ["/usr/bin/systemctl"] + ([] if system else ["--user"])
|
|
text = self.call(argv + ["show", unit, "--no-pager"], deadline)
|
|
return dict(line.split("=", 1) for line in text.splitlines() if "=" in line)
|
|
|
|
def stop(self, unit, deadline):
|
|
argv = ["/usr/bin/systemctl", "--user", "--no-ask-password", "stop", unit]
|
|
self.call(argv, deadline)
|
|
|
|
def create(self, spec, bootstrap):
|
|
return self.spawn(scope_command(spec, bootstrap))
|
|
|
|
|
|
def ancestors(group):
|
|
current = Path(group)
|
|
while str(current) != "/":
|
|
yield str(current)
|
|
current = current.parent
|
|
|
|
|
|
def capacity(cgroups, app):
|
|
"""Le headroom observé est un critère de refus, pas une réservation."""
|
|
records = {}
|
|
for group in ancestors(app):
|
|
record = cgroups.snapshot(group)
|
|
count = record["pids_current"]
|
|
require(count >= 0, "pids.current négatif")
|
|
maximum = record["pids_max"]
|
|
if maximum != "max":
|
|
require(maximum.isdecimal(), "pids.max ancêtre invalide")
|
|
require(int(maximum) - count >= TASKS, "capacité ancêtre insuffisante")
|
|
records[group] = record
|
|
return records
|
|
|
|
|
|
def nproc_headroom(proc):
|
|
"""Vérifier la réserve entière contre la borne système, sans muter NPROC."""
|
|
limits = proc.nproc()
|
|
require(len(limits) == 2 and all(
|
|
isinstance(value, int) and (value == resource.RLIM_INFINITY or value >= 0)
|
|
for value in limits), "RLIMIT_NPROC invalide")
|
|
total = proc.global_tasks()
|
|
require(isinstance(total, int) and total > 0, "charge système invalide")
|
|
finite = [value for value in limits if value != resource.RLIM_INFINITY]
|
|
# WHY CONTRACT INVARIANT: the system total bounds any real UID, including
|
|
# mixed credentials. Strict > reserves TASKS at each sample, never atomically.
|
|
require(not finite or min(finite) > total + TASKS,
|
|
f"inherited RLIMIT_NPROC insuffisant : {limits}, total={total}, "
|
|
f"réserve={TASKS}")
|
|
return {"nproc": limits, "global_tasks": total, "required_headroom": TASKS}
|
|
|
|
|
|
def preflight(proc, cgroups, manager, uid, pid, deadline):
|
|
"""Toutes les observations précèdent ATTEMPT et la première mutation."""
|
|
require(uid != 0 and uid == os.geteuid(), "UID réel/effectif invalide")
|
|
mount = cgroups.mount()
|
|
base = f"/user.slice/user-{uid}.slice/user@{uid}.service"
|
|
app = base + "/app.slice"
|
|
launcher = proc.identity(pid)
|
|
require(launcher["uid"] == uid and launcher["cgroup"].startswith(app + "/"),
|
|
"launcher hors app.slice ; session scope refusé")
|
|
owner = manager.show(f"user@{uid}.service", deadline, system=True)
|
|
require(owner.get("Id") == f"user@{uid}.service"
|
|
and owner.get("Delegate") == "yes" and owner.get("ActiveState") == "active"
|
|
and owner.get("ControlGroup") == base, "manager non délégué/invalide")
|
|
require(re.fullmatch(r"[0-9a-f]{32}", owner.get("InvocationID", "")),
|
|
"identité invocation manager absente")
|
|
manager_process = proc.identity(int(owner["MainPID"]))
|
|
require(manager_process["uid"] == uid
|
|
and manager_process["cgroup"].startswith(base + "/"),
|
|
"identité process manager divergente")
|
|
application = manager.show("app.slice", deadline)
|
|
require(application.get("Id") == "app.slice"
|
|
and application.get("ActiveState") == "active"
|
|
and application.get("ControlGroup") == app, "app.slice inactif/invalide")
|
|
controllers, enabled, writable = cgroups.available(app)
|
|
require("pids" in controllers and "pids" in enabled, "contrôleur pids absent")
|
|
require(writable, "ancêtre commun non writable")
|
|
headroom = nproc_headroom(proc)
|
|
return {"launcher": launcher, "manager": owner, "manager_process": manager_process,
|
|
"mount": mount, "app": app, "uid": uid, **headroom,
|
|
"ancestors": capacity(cgroups, app)}
|
|
|
|
|
|
def same_lifetime(first, second):
|
|
return all(first[key] == second[key] for key in ("pid", "uid", "startticks"))
|
|
|
|
|
|
def scope_identity(spec, observed, group):
|
|
"""Nonce Description + invocation + chemin ; aucun stop sur nom seul."""
|
|
for key, expected in (("Id", spec["unit"]), ("Description", spec["description"]),
|
|
("ControlGroup", group)):
|
|
require(observed.get(key) == expected, "scope identité divergente : " + key)
|
|
require(re.fullmatch(r"[0-9a-f]{32}", observed.get("InvocationID", "")),
|
|
"scope InvocationID absent")
|
|
|
|
|
|
class Barrier:
|
|
"""Socket privé, authentifié et à libération unique, sans FD relayé."""
|
|
|
|
def __init__(self, path, nonce, uid, pid, deadline):
|
|
self.path = path
|
|
self.nonce = nonce
|
|
self.uid = uid
|
|
self.pid = pid
|
|
self.deadline = deadline
|
|
self.connection = None
|
|
self.released = False
|
|
require(len(os.fsencode(path)) < 104, "nom socket trop long")
|
|
self.listener = socket.socket(socket.AF_UNIX, socket.SOCK_SEQPACKET)
|
|
try:
|
|
self.listener.bind(str(path))
|
|
os.chmod(path, 0o600)
|
|
self.listener.listen(1)
|
|
except BaseException:
|
|
self.listener.close()
|
|
raise
|
|
|
|
def accept(self):
|
|
require(self.connection is None and not self.released,
|
|
"bootstrap replay refusé")
|
|
self.listener.settimeout(max(.001, self.deadline - time.monotonic()))
|
|
self.connection, _ = self.listener.accept()
|
|
self.connection.settimeout(max(.001, self.deadline - time.monotonic()))
|
|
pid, uid, _ = struct.unpack("3i", self.connection.getsockopt(
|
|
socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize("3i")))
|
|
require(uid == self.uid, "bootstrap UID divergent")
|
|
message = json.loads(self.connection.recv(4096))
|
|
require(message == {"nonce": self.nonce, "pid": pid},
|
|
"bootstrap nonce/PID invalide")
|
|
return pid
|
|
|
|
def release(self):
|
|
require(not self.released, "seconde release refusée")
|
|
require(time.monotonic() < self.deadline, "release timeout")
|
|
self.released = True
|
|
self.connection.sendall(("RELEASE " + self.nonce).encode())
|
|
message = self.connection.recv(4096)
|
|
require(message == ("EXEC_REQUESTED " + self.nonce).encode(),
|
|
"bootstrap EOF/exec intent absent")
|
|
|
|
def close(self):
|
|
if self.connection is not None:
|
|
self.connection.close()
|
|
self.listener.close()
|
|
self.path.unlink(missing_ok=True)
|
|
|
|
|
|
def bootstrap(path, nonce, supervisor_pid, startticks, argv, proc=None,
|
|
execute=os.execve):
|
|
"""Attendre avant prlimit/bwrap ; EOF, parent mort et nonce faux refusent."""
|
|
proc = proc or ProcFS()
|
|
identity = proc.identity(supervisor_pid)
|
|
require(identity["uid"] == os.getuid() and identity["startticks"] == startticks,
|
|
"superviseur identité divergente")
|
|
descriptor = proc.pidfd(supervisor_pid)
|
|
try:
|
|
with socket.socket(socket.AF_UNIX, socket.SOCK_SEQPACKET) as channel:
|
|
channel.settimeout(4)
|
|
channel.connect(str(path))
|
|
peer_pid, peer_uid, _ = struct.unpack("3i", channel.getsockopt(
|
|
socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize("3i")))
|
|
require(peer_pid == supervisor_pid and peer_uid == os.getuid(),
|
|
"serveur étranger")
|
|
require(same_lifetime(identity, proc.identity(supervisor_pid)),
|
|
"PID superviseur recyclé")
|
|
channel.sendall(json.dumps({"nonce": nonce, "pid": os.getpid()}).encode())
|
|
ready, _, _ = select.select([descriptor, channel], [], [], 4)
|
|
require(descriptor not in ready and channel in ready, "parent mort/timeout")
|
|
require(channel.recv(4096) == ("RELEASE " + nonce).encode(),
|
|
"EOF/nonce release invalide")
|
|
require(not select.select([descriptor], [], [], 0)[0],
|
|
"parent mort avant exec")
|
|
channel.sendall(("EXEC_REQUESTED " + nonce).encode())
|
|
os.close(descriptor)
|
|
descriptor = None
|
|
# Do not rely on systemd-run's internal bus descriptor inheritance.
|
|
# close_fds at Popen protects the initial launch; close again at payload.
|
|
ceiling = resource.getrlimit(resource.RLIMIT_NOFILE)[1]
|
|
require(ceiling != resource.RLIM_INFINITY, "NOFILE hérité non fini")
|
|
os.closerange(3, ceiling)
|
|
os.chdir("/tmp")
|
|
execute(argv[0], argv, CLEAN_ENV)
|
|
raise Refusal("exec retourné sans remplacement")
|
|
finally:
|
|
if descriptor is not None:
|
|
os.close(descriptor)
|
|
|
|
|
|
def admit(spec, evidence, pid, proc, cgroups, manager, deadline):
|
|
"""Figer l'identité complète avant RELEASE, puis revalider immédiatement."""
|
|
group = evidence["app"] + "/" + spec["unit"]
|
|
observed = manager.show(spec["unit"], deadline)
|
|
scope_identity(spec, observed, group)
|
|
require(all(observed.get(key) == value for key, value in POLICY.items()),
|
|
"scope propriétés/budget divergents")
|
|
identity = proc.identity(pid)
|
|
require(identity["uid"] == evidence["uid"] and identity["cgroup"] == group,
|
|
"bootstrap PID/UID/membership divergent")
|
|
pidfd = proc.pidfd(pid)
|
|
try:
|
|
record = cgroups.snapshot(group)
|
|
require(record["uid"] == evidence["uid"] and record["pids_max"] == "256",
|
|
"pids.max non fini/incorrect ou propriétaire divergent")
|
|
require(record["procs"] == [pid] and record["pids_current"] == 1
|
|
and not record["children"],
|
|
"scope occupé/pids.current incohérent")
|
|
require(same_lifetime(identity, proc.identity(pid)), "PID/startticks divergent")
|
|
require(proc.identity(evidence["launcher"]["pid"]) == evidence["launcher"],
|
|
"superviseur déplacé/identité divergente")
|
|
require(cgroups.mount() == evidence["mount"], "montage remplacé")
|
|
capacity(cgroups, evidence["app"])
|
|
require(not select.select([pidfd], [], [], 0)[0],
|
|
"bootstrap mort avant admission")
|
|
current_manager = manager.show(
|
|
f"user@{evidence['uid']}.service", deadline, system=True)
|
|
require(all(current_manager.get(key) == evidence["manager"].get(key)
|
|
for key in ("MainPID", "InvocationID", "ControlGroup", "Delegate")),
|
|
"manager identité remplacée")
|
|
observed_manager = proc.identity(evidence["manager_process"]["pid"])
|
|
require(observed_manager == evidence["manager_process"],
|
|
"manager PID/startticks divergent")
|
|
headroom = nproc_headroom(proc)
|
|
require(headroom["nproc"] == evidence["nproc"],
|
|
"NPROC hérité divergent avant release")
|
|
return {"properties": observed, "process": identity, "cgroup": group,
|
|
"kernel": record, "pidfd": pidfd, "headroom": headroom}
|
|
except BaseException:
|
|
os.close(pidfd)
|
|
raise
|
|
|
|
|
|
def validate_owned(spec, receipt, cgroups, manager, deadline):
|
|
"""Même invocation et inode ; un PID mort n'autorise jamais un nouveau PID."""
|
|
observed = manager.show(spec["unit"], deadline)
|
|
scope_identity(spec, observed, receipt["cgroup"])
|
|
require(observed["InvocationID"] == receipt["properties"]["InvocationID"],
|
|
"scope invocation remplacée")
|
|
kernel = cgroups.snapshot(receipt["cgroup"])
|
|
require(all(kernel[key] == receipt["kernel"][key]
|
|
for key in ("inode", "device", "uid")),
|
|
"scope inode/propriétaire remplacé")
|
|
|
|
|
|
class EmptyWitness:
|
|
"""Observer avant release et pendant stop, sans autorité de mutation.
|
|
|
|
Un ENOENT/ENODEV n'est jamais converti en populated=0. Les lectures du
|
|
même inode doivent réellement constater events=0 et l'absence de tâches.
|
|
"""
|
|
|
|
def __init__(self, cgroups, receipt):
|
|
self.cgroups = cgroups
|
|
self.receipt = receipt
|
|
self.empty_seen = False
|
|
self.errors = []
|
|
self.finished = threading.Event()
|
|
self.thread = threading.Thread(target=self._observe, daemon=True)
|
|
self.thread.start()
|
|
|
|
def sample(self):
|
|
try:
|
|
record = self.cgroups.snapshot(self.receipt["cgroup"])
|
|
require(all(record[key] == self.receipt["kernel"][key]
|
|
for key in ("inode", "device", "uid")),
|
|
"witness inode divergent")
|
|
if self.cgroups.empty(self.receipt["cgroup"]):
|
|
# Recheck inode after recursive observation, before accepting proof.
|
|
after = self.cgroups.snapshot(self.receipt["cgroup"])
|
|
require(all(after[key] == record[key]
|
|
for key in ("inode", "device", "uid")),
|
|
"witness inode remplacé pendant lecture")
|
|
self.empty_seen = True
|
|
except (FileNotFoundError, ProcessLookupError):
|
|
# Disappearance alone never establishes emptiness.
|
|
return
|
|
except BaseException as error:
|
|
self.errors.append(f"{type(error).__name__}: {error}"[:512])
|
|
self.finished.set()
|
|
|
|
def _observe(self):
|
|
while not self.finished.is_set():
|
|
self.sample()
|
|
self.finished.wait(.002)
|
|
|
|
def close(self):
|
|
self.finished.set()
|
|
self.thread.join(timeout=.1)
|
|
require(not self.thread.is_alive(), "witness arrêt non prouvé")
|
|
|
|
|
|
def cleanup(spec, receipt, process, proc, cgroups, manager, deadline, witness=None):
|
|
"""Ne jamais laisser une exception de cleanup masquer son verdict fermé."""
|
|
errors = []
|
|
empty_seen = False
|
|
try:
|
|
if receipt is not None:
|
|
if witness is not None:
|
|
witness.sample()
|
|
empty_seen = witness.empty_seen
|
|
require(not witness.errors, "witness observation échouée")
|
|
observed = manager.show(spec["unit"], deadline)
|
|
exists = cgroups.exists(receipt["cgroup"])
|
|
if observed.get("LoadState") == "not-found" and not exists:
|
|
require(empty_seen, "vide récursif non observé avant disparition")
|
|
else:
|
|
validate_owned(spec, receipt, cgroups, manager, deadline)
|
|
try:
|
|
current = proc.identity(receipt["process"]["pid"])
|
|
except FileNotFoundError:
|
|
current = None
|
|
require(current is None or current == receipt["process"],
|
|
"PID/membership divergent avant stop")
|
|
empty_seen = empty_seen or cgroups.empty(receipt["cgroup"])
|
|
manager.stop(spec["unit"], deadline)
|
|
while time.monotonic() < deadline:
|
|
if witness is not None:
|
|
witness.sample()
|
|
require(not witness.errors, "witness observation échouée")
|
|
empty_seen = empty_seen or witness.empty_seen
|
|
observed = manager.show(spec["unit"], deadline)
|
|
exists = cgroups.exists(receipt["cgroup"])
|
|
if exists:
|
|
validate_owned(spec, receipt, cgroups, manager, deadline)
|
|
empty_seen = empty_seen or cgroups.empty(receipt["cgroup"])
|
|
if observed.get("LoadState") == "not-found" and not exists:
|
|
require(empty_seen,
|
|
"vide récursif non observé avant disparition")
|
|
break
|
|
time.sleep(.02)
|
|
else:
|
|
raise Refusal("unit/cgroup disparition non prouvée")
|
|
else:
|
|
require(not cgroups.exists(evidence_group(spec)),
|
|
"scope créé sans ownership établi")
|
|
except BaseException as error:
|
|
errors.append(f"{type(error).__name__}: {error}"[:512])
|
|
if witness is not None:
|
|
try:
|
|
witness.close()
|
|
except BaseException as error:
|
|
errors.append("witness close: " + str(error)[:128])
|
|
if process is not None:
|
|
try:
|
|
if process.poll() is None:
|
|
process.kill() # Exact unreaped launcher child only.
|
|
process.wait(timeout=max(.001, deadline - time.monotonic()))
|
|
except BaseException as error:
|
|
errors.append(f"collection: {type(error).__name__}: {error}"[:512])
|
|
for stream in (process.stdout, process.stderr):
|
|
try:
|
|
stream.close()
|
|
except BaseException as error:
|
|
errors.append("pipe close: " + str(error)[:128])
|
|
if receipt is not None:
|
|
try:
|
|
os.close(receipt["pidfd"])
|
|
except OSError as error:
|
|
errors.append("pidfd close: " + str(error))
|
|
return {"proved": not errors, "empty_observed": empty_seen, "errors": errors}
|
|
|
|
|
|
def evidence_group(spec):
|
|
uid = os.getuid()
|
|
return f"/user.slice/user-{uid}.slice/user@{uid}.service/app.slice/" + spec["unit"]
|
|
|
|
|
|
def run_scope(lab, manifest, reserve, journal, proc=None, cgroups=None,
|
|
manager=None, barrier_factory=Barrier, collector=drain):
|
|
"""Orchestration injectée : préflight, réserve, admission, release et récolte."""
|
|
proc = proc or ProcFS()
|
|
cgroups = cgroups or CgroupFS()
|
|
manager = manager or SystemManager(os.getuid())
|
|
spec = manifest["scope"]
|
|
started = time.monotonic()
|
|
process = barrier = receipt = witness = None
|
|
channels = [b"", b""]
|
|
result = {"cause": "failure", "phases": [], "runtime": "NOT_RUN",
|
|
"returncode": None, "collected": False}
|
|
|
|
def phase(name, **detail):
|
|
result["phases"].append({"phase": name, **detail})
|
|
journal(result)
|
|
|
|
try:
|
|
evidence = preflight(proc, cgroups, manager, os.getuid(), os.getpid(),
|
|
started + 3)
|
|
group = evidence["app"] + "/" + spec["unit"]
|
|
require(manager.show(spec["unit"], started + 3).get("LoadState") == "not-found"
|
|
and not cgroups.exists(group), "scope déjà occupé ; aucun spawn")
|
|
# PRECHECK is not journaled until ATTEMPT: failed preflight stays read-only.
|
|
reserve(evidence)
|
|
phase("PRECHECK", nproc=evidence["nproc"],
|
|
global_tasks=evidence["global_tasks"],
|
|
required_headroom=evidence["required_headroom"])
|
|
barrier = barrier_factory(lab / "gate.sock", spec["nonce"], os.getuid(),
|
|
os.getpid(), started + 5)
|
|
identity = evidence["launcher"]
|
|
bootstrap_argv = bootstrap_command(lab, spec, identity, manifest["argv"])
|
|
process = manager.create(spec, bootstrap_argv)
|
|
phase("SCOPE_CREATED", status="creation_requested")
|
|
pid = barrier.accept()
|
|
receipt = admit(spec, evidence, pid, proc, cgroups, manager, started + 5)
|
|
public_receipt = {key: value for key, value in receipt.items()
|
|
if key != "pidfd"}
|
|
phase("ADMITTED", ownership=public_receipt)
|
|
witness = EmptyWitness(cgroups, receipt)
|
|
validate_owned(spec, receipt, cgroups, manager, started + 5)
|
|
require(proc.identity(pid) == receipt["process"], "PID drift avant release")
|
|
require(not select.select([receipt["pidfd"]], [], [], 0)[0],
|
|
"bootstrap mort avant release")
|
|
barrier.release()
|
|
phase("RELEASED", exec_intent=True)
|
|
if manifest.get("qualification_mode") == "parent-death":
|
|
# Only a separately sealed, separately approved benign LAB can reach this.
|
|
require(manifest["kind"] == "benign", "parent-death réservé au bénin")
|
|
phase("PARENT_DEATH_TEST", external_cleanup_required=True)
|
|
os._exit(71)
|
|
channels = collector(process, started + 8)
|
|
result["returncode"] = process.returncode
|
|
result["collected"] = process.returncode is not None
|
|
phase("COLLECTED", returncode=process.returncode)
|
|
if manifest["kind"] == "benign":
|
|
require(b"BENIGN_PAYLOAD_STARTED\n" in channels[0],
|
|
"marqueur payload bénin absent")
|
|
phase("PAYLOAD_STARTED", proof="benign stdout marker")
|
|
result["runtime"] = ("BENIGN" if manifest["kind"] == "benign"
|
|
else "EGL_RESULT_UNINTERPRETED")
|
|
result["cause"] = "exit" if process.returncode == 0 else "payload_failure"
|
|
except BaseException as error:
|
|
if isinstance(error, DrainFailure):
|
|
channels = error.channels
|
|
result["cause"] = ("interrupted" if isinstance(error, KeyboardInterrupt)
|
|
else "failure")
|
|
result["error"] = f"{type(error).__name__}: {error}"[:512]
|
|
finally:
|
|
if barrier is not None:
|
|
try:
|
|
barrier.close()
|
|
except BaseException as error:
|
|
result["barrier_cleanup_error"] = str(error)[:512]
|
|
if process is not None:
|
|
cleaned = cleanup(spec, receipt, process, proc, cgroups, manager,
|
|
started + 10, witness=witness)
|
|
result["cleanup"] = cleaned
|
|
if not cleaned["proved"] or "barrier_cleanup_error" in result:
|
|
result["cause"] = "CLEANUP_UNPROVEN"
|
|
try:
|
|
phase("CLEANED", proved=cleaned["proved"])
|
|
except BaseException as error:
|
|
result["cause"] = "journal_failure"
|
|
result["error"] = str(error)[:512]
|
|
result["elapsed"] = time.monotonic() - started
|
|
return result, channels
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--bootstrap", nargs=argparse.REMAINDER, required=True)
|
|
args = parser.parse_args().bootstrap
|
|
require(len(args) >= 5, "bootstrap argv absent")
|
|
bootstrap(Path(args[0]), args[1], int(args[2]), int(args[3]), args[4:])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
main()
|
|
except (Refusal, OSError, ValueError) as error:
|
|
print("Bootstrap refusé: " + str(error), file=sys.stderr)
|
|
sys.exit(70)
|