648 lines
37 KiB
Python
648 lines
37 KiB
Python
#!/usr/bin/python3
|
|
"""Disposable root/kernel fixture; requires its own private run/home/data/net.
|
|
|
|
No native compilation. The protocol client implements the frozen C proxy wire.
|
|
The access lease fixture is synthetic; it is NOT production access qualification.
|
|
"""
|
|
import array
|
|
import ctypes
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import select
|
|
import shutil
|
|
import signal
|
|
import socket
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
import c01_runtime as runtime
|
|
from c01_runtime_view import RuntimeLease
|
|
from c01_runtime_groups import current_group
|
|
|
|
LIBC = ctypes.CDLL(None, use_errno=True)
|
|
UID, MANAGER, AUTHORITY, OPERATOR = 61000, 61001, 61002, 61003
|
|
HOME = Path('/home/runtime-fixture')
|
|
BACKING = Path('/var/lib/runtime-home')
|
|
RUN = Path(f'/run/user/{UID}')
|
|
DATA = Path('/var/lib/runtime-data')
|
|
CODE = Path('/run/runtime-code')
|
|
UNITS = Path('/run/runtime-units')
|
|
|
|
|
|
def require(value, message='fixture assertion failed'):
|
|
if not value:
|
|
raise AssertionError(message)
|
|
|
|
|
|
def wait_for(check, timeout=20):
|
|
end = time.monotonic() + timeout
|
|
while time.monotonic() < end:
|
|
result = check()
|
|
if result:
|
|
return result
|
|
time.sleep(.05)
|
|
raise AssertionError('fixture deadline expired')
|
|
|
|
|
|
def drop(uid):
|
|
os.setgroups([]); os.setresgid(uid, uid, uid); os.setresuid(uid, uid, uid)
|
|
|
|
|
|
def mount(source, target, kind=None, flags=4096, data=None):
|
|
require(LIBC.mount(os.fsencode(source) if source else None, os.fsencode(target),
|
|
os.fsencode(kind) if kind else None, flags, os.fsencode(data) if data else None) == 0,
|
|
f'mount failed: {source} -> {target}: errno {ctypes.get_errno()}')
|
|
|
|
|
|
def owned(path, uid=UID):
|
|
path.mkdir(parents=True, exist_ok=True)
|
|
path.chmod(0o700); os.chown(path, uid, uid)
|
|
|
|
|
|
def packet(argv, cwd=str(HOME), env=()):
|
|
def string(text):
|
|
raw = text.encode(); return struct.pack('!I', len(raw)) + raw
|
|
raw = struct.pack('!IIII', runtime.runtime_owner.MAGIC, 1, len(argv), len(env))
|
|
raw += b''.join(string(text) for text in [cwd, *argv, *env])
|
|
return struct.pack('!I', len(raw)) + raw
|
|
|
|
|
|
def client(argv, uid=MANAGER, gid=None, disconnect=False):
|
|
# Fork gives real SO_PEERCRED. Regular fixture files avoid pipe-EOF ambiguity
|
|
# from an intentionally persistent container/tmux process retaining stdout.
|
|
out = os.memfd_create('runtime-output', os.MFD_CLOEXEC)
|
|
err = os.memfd_create('runtime-error', os.MFD_CLOEXEC)
|
|
report_read, report_write = os.pipe2(os.O_CLOEXEC)
|
|
cancel_read, cancel_write = os.pipe2(os.O_CLOEXEC)
|
|
pid = os.fork()
|
|
if pid == 0:
|
|
try:
|
|
os.close(report_read)
|
|
os.close(cancel_write)
|
|
os.setgroups([MANAGER] if gid is not None else [])
|
|
os.setresgid(gid or uid, gid or uid, gid or uid); os.setresuid(uid, uid, uid)
|
|
channel = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
channel.settimeout(45)
|
|
channel.connect('/run/archipelago-managed/runtime.sock')
|
|
# Exactly what runtime-proxy.c requires; root creator must remain >1.
|
|
peer_pid, peer_uid, peer_gid = runtime.peer(channel)
|
|
require(peer_pid > 1 and (peer_uid, peer_gid) == (0, 0), 'root listener credential mismatch')
|
|
with open('/dev/null', 'rb') as source:
|
|
raw = packet(argv)
|
|
count = channel.sendmsg([raw], [(socket.SOL_SOCKET, socket.SCM_RIGHTS,
|
|
array.array('i', [source.fileno(), out, err]))])
|
|
channel.sendall(raw[count:])
|
|
if disconnect:
|
|
require(os.read(cancel_read, 1) == b'x', 'fixture cancellation barrier')
|
|
channel.close(); os.write(report_write, b'cancelled')
|
|
else:
|
|
raw = b''
|
|
while len(raw) < 12:
|
|
chunk = channel.recv(12 - len(raw))
|
|
if not chunk:
|
|
break
|
|
raw += chunk
|
|
os.write(report_write, raw)
|
|
os._exit(0)
|
|
except (PermissionError, ConnectionResetError, BrokenPipeError):
|
|
if (uid, gid or uid) != (MANAGER, MANAGER):
|
|
os.write(report_write, b'REFUSED'); os._exit(0)
|
|
import traceback; traceback.print_exc(); os._exit(111)
|
|
except BaseException:
|
|
import traceback; traceback.print_exc(); os._exit(111)
|
|
os.close(report_write)
|
|
os.close(cancel_read)
|
|
if disconnect:
|
|
wait_for(lambda: (DATA / 'descendant.json').exists(), 5)
|
|
os.write(cancel_write, b'x')
|
|
os.close(cancel_write)
|
|
poll = select.poll(); handle = os.pidfd_open(pid); poll.register(handle, select.POLLIN)
|
|
require(poll.poll(50000), 'fixture client timeout')
|
|
_, status = os.waitpid(pid, 0); os.close(handle)
|
|
result = os.read(report_read, 128); os.close(report_read)
|
|
os.lseek(out, 0, 0); os.lseek(err, 0, 0)
|
|
output, error = os.read(out, 1024 * 1024), os.read(err, 1024 * 1024)
|
|
os.close(out); os.close(err)
|
|
require(os.waitstatus_to_exitcode(status) == 0, f'client failed: {error!r}')
|
|
if disconnect:
|
|
return result
|
|
return struct.unpack('!III', result)[1:] if len(result) == 12 else None, output, error
|
|
|
|
|
|
def command(argv):
|
|
out = os.memfd_create('static-proxy-output', os.MFD_CLOEXEC)
|
|
err = os.memfd_create('static-proxy-error', os.MFD_CLOEXEC)
|
|
try:
|
|
result = subprocess.run(['/opt/archipelago-runtime-proxy', '--', *argv], cwd='/', preexec_fn=lambda: drop(MANAGER),
|
|
stdin=subprocess.DEVNULL, stdout=out, stderr=err,
|
|
env={'PATH': '/usr/bin:/bin'}, timeout=45)
|
|
os.lseek(out, 0, 0); os.lseek(err, 0, 0)
|
|
output, error = os.read(out, 1024 * 1024), os.read(err, 1024 * 1024)
|
|
require(result.returncode == 0, f'{argv}: {result.returncode}: {error.decode(errors="replace")}')
|
|
return output.decode().strip()
|
|
finally:
|
|
os.close(out); os.close(err)
|
|
|
|
|
|
def asynchronous_command(source):
|
|
return subprocess.Popen(['/opt/archipelago-runtime-proxy', '--', '/usr/bin/python3', '-c', source],
|
|
cwd='/', preexec_fn=lambda: drop(MANAGER),
|
|
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
env={'PATH': '/usr/bin:/bin'})
|
|
|
|
|
|
def rendered_home_view(repo):
|
|
"""Exercise the rendered template's home/runtime binds over covered roots.
|
|
|
|
This checks kernel visibility/inodes with the actual bus/services below.
|
|
It is NOT a systemd SYSTEM-manager/drop-in dependency-order qualification;
|
|
that still requires a disposable boot of the rendered user@ composition.
|
|
"""
|
|
text = (repo / 'image-recipe/configs/80-archipelago-runtime-user.conf.in').read_text()
|
|
for key, value in {'ORIGINAL_RUNTIME_UID': str(UID), 'ORIGINAL_HOME': str(HOME),
|
|
'SANITIZED_RUNTIME_HOME': str(BACKING),
|
|
'MANAGER_BACKING_PARENT': '/var/lib/private-manager',
|
|
'SNAPSHOT_BACKING_PARENT': '/var/lib/private-snapshot',
|
|
'AUTHORITY_BACKING_PARENT': '/var/lib/private-authority'}.items():
|
|
text = text.replace('@' + key + '@', value)
|
|
require('ProtectHome=tmpfs' in text)
|
|
binds = []
|
|
for line in text.splitlines():
|
|
if line.startswith('BindPaths='):
|
|
source, _, target = line.split('=', 1)[1].partition(':')
|
|
binds.append((os.open(source, os.O_PATH | os.O_DIRECTORY | os.O_CLOEXEC), Path(target or source)))
|
|
try:
|
|
for target in ('/home', '/root', '/run/user'):
|
|
mount('tmpfs', target, 'tmpfs', 0, 'mode=755,size=4M')
|
|
for fd, target in binds:
|
|
target.mkdir(parents=True, exist_ok=True)
|
|
mount('/proc/self/fd/' + str(fd), target)
|
|
for target in ('/home', '/root', '/run/user'):
|
|
mount(None, target, flags=32 | 1) # readonly cover, writable sub-binds
|
|
require(RUN.stat().st_ino == next(os.fstat(fd).st_ino for fd, path in binds if path == RUN))
|
|
require(not Path('/run/user/61003').exists(), 'foreign user runtime exposed')
|
|
finally:
|
|
for fd, _ in binds:
|
|
os.close(fd)
|
|
|
|
|
|
def native_terminal_contract(identity):
|
|
binary = Path('/opt/runtime-terminal-tests')
|
|
if not binary.exists():
|
|
print('NOT RUN native handler/runtime workspace: supply --terminal-tests rebuilt test binary', flush=True)
|
|
return
|
|
require(not (DATA / 'native-terminal-pass').exists(), 'stale native terminal success marker')
|
|
work_identity = (BACKING / 'Work').stat()
|
|
original = [(path.stat().st_dev, path.stat().st_ino, path.read_bytes())
|
|
for path in (BACKING / 'Work/preserved', BACKING / 'Work/explicit ; literal/preserved')]
|
|
view = RuntimeLease(MANAGER, MANAGER, UID, UID, identity['configuration_sha256'])
|
|
child = os.fork()
|
|
if child == 0:
|
|
try:
|
|
require(LIBC.unshare(0x00020000) == 0, 'private terminal manager namespace unavailable')
|
|
mount(None, '/', flags=16384 | (1 << 18))
|
|
mount('tmpfs', '/home', 'tmpfs', 0, 'mode=755,size=4M')
|
|
mount('tmpfs', '/run', 'tmpfs', 0, 'mode=755,size=16M')
|
|
view.install(); view.close()
|
|
home = Path('/run/archipelago-c01/manager')
|
|
owned(home, MANAGER)
|
|
aliases = Path('/run/archipelago-c01/bin'); aliases.mkdir()
|
|
for name in ('sh', 'tmux'):
|
|
(aliases / name).symlink_to('/opt/archipelago-runtime-proxy')
|
|
drop(MANAGER)
|
|
os.chdir(home) # Handler commands must explicitly use runtime-visible /.
|
|
os.execve(binary, [str(binary), 'api::handler::terminal::runtime_workspace_tests::actual_runtime_workspace_contract',
|
|
'--exact', '--ignored', '--nocapture', '--test-threads=1'],
|
|
{'PATH': str(aliases), 'HOME': str(home), 'ARCHY_SESSION_STATE_DIR': str(home / 'sessions'),
|
|
'ARCHIPELAGO_RUNTIME_WORKSPACE': str(HOME / 'Work'), 'ARCHY_RUNTIME_TERMINAL_FIXTURE': '1'})
|
|
except BaseException:
|
|
import traceback; traceback.print_exc(); os._exit(111)
|
|
try:
|
|
poll = select.poll(); handle = os.pidfd_open(child); poll.register(handle, select.POLLIN)
|
|
reaped = False
|
|
try:
|
|
require(poll.poll(60000), 'native terminal contract deadline')
|
|
_, status = os.waitpid(child, 0)
|
|
reaped = True
|
|
require(os.waitstatus_to_exitcode(status) == 0, 'native terminal contract failed')
|
|
finally:
|
|
try:
|
|
if not reaped:
|
|
os.kill(child, signal.SIGKILL) # Still pinned by our unreaped child ownership.
|
|
require(poll.poll(5000), 'native terminal fixture retirement deadline')
|
|
os.waitpid(child, 0)
|
|
finally:
|
|
os.close(handle)
|
|
finally:
|
|
view.close()
|
|
after = [(path.stat().st_dev, path.stat().st_ino, path.read_bytes())
|
|
for path in (BACKING / 'Work/preserved', BACKING / 'Work/explicit ; literal/preserved')]
|
|
require(after == original, 'preserved workspace inode/content changed')
|
|
require((work_identity.st_dev, work_identity.st_ino) ==
|
|
((BACKING / 'Work').stat().st_dev, (BACKING / 'Work').stat().st_ino), 'preserved Work inode replaced')
|
|
require((DATA / 'native-terminal-pass').read_text() == 'passed', 'native test did not run to completion')
|
|
print('PASS native handler -> proxy -> runtime workspace -> persistent tmux; default/explicit pwd, preserved files, reconnect metadata', flush=True)
|
|
|
|
|
|
def main():
|
|
require(os.geteuid() == 0 and os.environ.get('ARCHY_RUNTIME_FIXTURE') == '1', 'isolated root fixture required')
|
|
rows = Path('/proc/self/mountinfo').read_text().splitlines()
|
|
for target in ('/run', '/home', '/var/lib'):
|
|
require(any(row.split()[4] == target and ' - tmpfs ' in row for row in rows), 'private fixture mount missing')
|
|
Path('/tmp').chmod(0o1777)
|
|
Path('/var/tmp').chmod(0o1777)
|
|
require('INTERP' not in subprocess.check_output(['/usr/bin/readelf', '-l', '/opt/archipelago-runtime-proxy'], text=True),
|
|
'existing proxy is not static')
|
|
root = current_group()
|
|
require('c01-runtime-fixture-' in str(root), 'owned transient unit required')
|
|
control = root / 'control'; control.mkdir(); (control / 'cgroup.procs').write_text(str(os.getpid()))
|
|
(root / 'cgroup.subtree_control').write_text('+cpu +memory +pids')
|
|
user_group = root / 'runtime-user.service'; user_group.mkdir()
|
|
for name in ('', 'cgroup.procs', 'cgroup.threads', 'cgroup.subtree_control'):
|
|
os.chown(user_group / name, UID, UID)
|
|
CODE.mkdir(); CODE.chmod(0o755)
|
|
repo = Path(__file__).resolve().parents[2]
|
|
subprocess.run(['/usr/bin/python3', '-B', '-m', 'unittest', 'discover', '-s', str(repo / 'scripts/tests'),
|
|
'-p', 'test*runtime*.py', '-v'], check=True, timeout=10)
|
|
for name in ('c01_runtime.py', 'c01_runtime_groups.py', 'runtime_owner.py'):
|
|
shutil.copyfile(repo / 'scripts' / name, CODE / name)
|
|
(CODE / name).chmod(0o644)
|
|
for path in (BACKING, RUN, DATA):
|
|
owned(path)
|
|
owned(Path('/run/user/61003'), OPERATOR)
|
|
for path in (BACKING / 'Work', BACKING / 'Work/explicit ; literal',
|
|
BACKING / 'Work/literal##dir', BACKING / 'Work/literal#{pane_id}###'):
|
|
owned(path)
|
|
(path / 'preserved').write_text('preserved-work-inode\n')
|
|
os.chown(path / 'preserved', UID, UID)
|
|
(BACKING / 'root-alias').symlink_to('/', target_is_directory=True)
|
|
HOME.mkdir(parents=True)
|
|
for path in (Path('/run/archipelago-runtime'), Path('/run/archipelago-managed'),
|
|
Path('/run/archipelago-c01'), Path('/run/systemd/system')):
|
|
path.mkdir(parents=True, exist_ok=True); path.chmod(0o755)
|
|
# Root-only backing parents retain synthetic old-UID private file ownership.
|
|
for name in ('manager', 'authority'):
|
|
parent = Path('/var/lib/private-' + name); parent.mkdir(mode=0o700)
|
|
(parent / 'secret').write_text('synthetic-private-state')
|
|
os.chown(parent / 'secret', UID, UID)
|
|
config_dir = Path('/run/runtime-etc'); config_dir.mkdir()
|
|
for name, content in {
|
|
'passwd': f'root:x:0:0:root:/root:/bin/sh\nruntime-fixture:x:{UID}:{UID}::/home/runtime-fixture:/usr/sbin/nologin\n',
|
|
'group': f'root:x:0:\nruntime-fixture:x:{UID}:\n',
|
|
'subuid': 'runtime-fixture:2000000:65536\n',
|
|
'subgid': 'runtime-fixture:2000000:65536\n',
|
|
}.items():
|
|
source = config_dir / name; source.write_text(content); source.chmod(0o644)
|
|
mount(source, '/etc/' + name)
|
|
# Minimal private /etc, synthesized rather than copying host configuration.
|
|
etc = Path('/run/etc-view'); etc.mkdir()
|
|
for name in ('passwd', 'group', 'subuid', 'subgid'):
|
|
shutil.copyfile(config_dir / name, etc / name)
|
|
(etc / 'machine-id').write_text('684f9c0509564804bc9ad0816083f71d\n')
|
|
(etc / 'nsswitch.conf').write_text('passwd: files\ngroup: files\nhosts: files\n')
|
|
(etc / 'hosts').write_text('127.0.0.1 localhost\n')
|
|
(etc / 'resolv.conf').touch()
|
|
(etc / 'os-release').symlink_to('/usr/lib/os-release')
|
|
(etc / 'dbus-1').mkdir()
|
|
# --session loads /usr/share/dbus-1/session.conf itself; that distro file
|
|
# optionally includes /etc/dbus-1/session.conf. Do not create a recursive alias.
|
|
(etc / 'dbus-1/session.d').mkdir()
|
|
(etc / 'containers').mkdir()
|
|
(etc / 'containers/policy.json').write_text('{"default":[{"type":"insecureAcceptAnything"}]}')
|
|
(etc / 'archipelago-c01').mkdir()
|
|
mount(etc, '/etc')
|
|
value = {'version': 1, 'runtime_user': 'runtime-fixture', 'runtime_uid': UID, 'runtime_gid': UID,
|
|
'manager_uid': MANAGER, 'manager_gid': MANAGER, 'authority_uid': AUTHORITY, 'authority_gid': AUTHORITY,
|
|
'operator_uid': OPERATOR, 'operator_gid': OPERATOR, 'home': str(HOME), 'home_backing': str(BACKING),
|
|
'runtime_dir': str(RUN), 'runtime_data_dir': str(DATA), 'graphroot': str(DATA / 'storage'),
|
|
'subuids': [[2000000, 65536]], 'subgids': [[2000000, 65536]],
|
|
'user_cgroup': str(user_group).removeprefix('/sys/fs/cgroup'),
|
|
'private_parents': ['/var/lib/private-manager', '/var/lib/private-authority']}
|
|
runtime.CONFIG = Path('/etc/archipelago-c01/runtime.json')
|
|
runtime.CONFIG.write_text(json.dumps(value)); runtime.CONFIG.chmod(0o600)
|
|
shutil.copyfile(repo / 'image-recipe/configs/archipelago-runtime-tmux.conf',
|
|
'/etc/archipelago-c01/runtime-tmux.conf')
|
|
containers = BACKING / '.config/containers'; containers.mkdir(parents=True)
|
|
(containers / 'storage.conf').write_text('[storage]\ndriver="vfs"\ngraphroot="' + str(DATA / 'storage') +
|
|
'"\nrunroot="' + str(RUN / 'containers') + '"\n')
|
|
for path in (BACKING / '.config', containers, containers / 'storage.conf'):
|
|
os.chown(path, UID, UID)
|
|
UNITS.mkdir(); UNITS.chmod(0o755)
|
|
for name in ('archipelago-runtime-worker.service', 'archipelago-runtime-tmux.service', 'archipelago-runtime-podman.service'):
|
|
text = (repo / 'image-recipe/configs' / name).read_text().replace('/usr/lib/archipelago/', str(CODE) + '/')
|
|
text = text.replace('[Service]', '[Service]\nStandardOutput=append:/var/lib/runtime-data/' + name + '.log\nStandardError=inherit')
|
|
(UNITS / name).write_text(text)
|
|
for name in ('basic', 'sockets', 'paths', 'timers', 'shutdown', 'exit'):
|
|
(UNITS / (name + '.target')).write_text('[Unit]\nDescription=Private fixture ' + name + '\nDefaultDependencies=no\n')
|
|
(UNITS / 'default.target').write_text('[Unit]\nRequires=dbus.socket archipelago-runtime-tmux.service\n')
|
|
# User bus: distro dbus-daemon with the distro session policy, private socket.
|
|
(UNITS / 'dbus.socket').write_text('[Socket]\nListenStream=%t/bus\nSocketMode=0600\n')
|
|
(UNITS / 'dbus.service').write_text('[Unit]\nRequires=dbus.socket\n[Service]\nType=notify\n'
|
|
'StandardOutput=append:/var/lib/runtime-data/dbus.log\nStandardError=inherit\n'
|
|
'ExecStart=/usr/bin/dbus-daemon --session --address=systemd: --nofork --nopidfile --systemd-activation\n')
|
|
log = open('/run/fixture-service.log', 'wb', buffering=0)
|
|
# Actual dependency refusal, not a saved qualification flag or mocked answer.
|
|
refused = subprocess.run(['/usr/bin/python3', '-I', '-S', str(CODE / 'c01_runtime.py'), 'broker'],
|
|
stdout=log, stderr=log, env={'PATH': '/usr/bin:/bin'}, timeout=5)
|
|
require(refused.returncode != 0 and not (runtime.PUBLIC / 'runtime.sock').exists(),
|
|
'missing access owner published a command listener')
|
|
def rejects():
|
|
try:
|
|
runtime.load_config()
|
|
except (ValueError, KeyError):
|
|
return
|
|
raise AssertionError('invalid runtime prerequisite accepted')
|
|
original = runtime.CONFIG.read_text()
|
|
for key, wrong in (('runtime_uid', MANAGER), ('subuids', [[2000001, 65536]]), ('graphroot', '/var/lib/private-manager/storage')):
|
|
runtime.CONFIG.write_text(json.dumps({**value, key: wrong})); rejects()
|
|
runtime.CONFIG.write_text(original)
|
|
group_file = Path('/etc/group'); group_original = group_file.read_text()
|
|
group_file.write_text(group_original + 'sudo:x:27:runtime-fixture\n'); rejects(); group_file.write_text(group_original)
|
|
(BACKING / '.ssh').mkdir(); rejects(); (BACKING / '.ssh').rmdir()
|
|
runtime.load_config()
|
|
print('PASS fail-closed missing live access owner, overlapping UID/private root, changed subuids, supplementary privilege, credential home', flush=True)
|
|
access = runtime.listen(runtime.ACCESS, 0, socket.SOCK_SEQPACKET)
|
|
broker = subprocess.Popen(['/usr/bin/python3', '-I', '-S', str(CODE / 'c01_runtime.py'), 'broker'],
|
|
stdout=log, stderr=log, env={'PATH': '/usr/bin:/bin'})
|
|
require(select.select([access], [], [], 5)[0], 'broker never requested live lease')
|
|
lease, _ = access.accept(); lease.settimeout(2)
|
|
request, _ = runtime.receive(lease); runtime.send(lease, request)
|
|
wait_for(lambda: (runtime.BASE / 'grant.sock').exists())
|
|
user = os.fork()
|
|
if user == 0:
|
|
try:
|
|
(user_group / 'cgroup.procs').write_text(str(os.getpid()))
|
|
require(LIBC.unshare(0x00020000) == 0, 'user private view unavailable')
|
|
mount(None, '/', flags=16384 | (1 << 18))
|
|
rendered_home_view(repo)
|
|
for fd in (1, 2):
|
|
os.dup2(log.fileno(), fd)
|
|
drop(UID)
|
|
os.chdir(HOME)
|
|
os.execve('/usr/lib/systemd/systemd', ['systemd', '--user'],
|
|
{'HOME': str(HOME), 'USER': 'runtime-fixture', 'LOGNAME': 'runtime-fixture',
|
|
'PATH': '/usr/bin:/bin', 'XDG_RUNTIME_DIR': str(RUN), 'DBUS_SESSION_BUS_ADDRESS': 'unix:path=' + str(RUN / 'bus'),
|
|
'SYSTEMD_UNIT_PATH': str(UNITS), 'SYSTEMD_LOG_TARGET': 'console'})
|
|
except BaseException:
|
|
import traceback; traceback.print_exc(); os._exit(111)
|
|
watch = None
|
|
try:
|
|
user_environment = {'HOME': str(HOME), 'XDG_RUNTIME_DIR': str(RUN),
|
|
'DBUS_SESSION_BUS_ADDRESS': 'unix:path=' + str(RUN / 'bus'), 'PATH': '/usr/bin:/bin'}
|
|
def user_control(*args):
|
|
return subprocess.run(['/usr/bin/systemctl', '--user', *args], env=user_environment,
|
|
preexec_fn=lambda: drop(UID), capture_output=True, timeout=10)
|
|
wait_for(lambda: (RUN / 'systemd/private').exists())
|
|
bus = user_control('start', 'dbus.service')
|
|
require(bus.returncode == 0, 'bus preflight: ' + bus.stderr.decode())
|
|
bus = subprocess.run(['/usr/bin/busctl', '--user', '--no-pager', 'list'], env=user_environment,
|
|
preexec_fn=lambda: drop(UID), capture_output=True, timeout=10)
|
|
require(bus.returncode == 0 and b'org.freedesktop.systemd1' in bus.stdout,
|
|
'user bus systemd name unavailable: ' + bus.stderr.decode())
|
|
check = user_control('is-active', 'archipelago-runtime-tmux.service')
|
|
require(check.returncode == 0, 'tmux preflight: ' + check.stdout.decode() + check.stderr.decode())
|
|
wait_for(lambda: (Path('/tmp') / f'tmux-{UID}' / 'default').is_socket(), 5)
|
|
print('PASS staged private user-manager/tmux startup preflight', flush=True)
|
|
podman = user_control('--no-block', 'start', 'archipelago-runtime-podman.service')
|
|
require(podman.returncode == 0, podman.stderr.decode())
|
|
wait_for(lambda: user_control('is-active', 'archipelago-runtime-podman.service').returncode == 0, 35)
|
|
print('PASS independent USER-unit Podman bootstrap preflight', flush=True)
|
|
started = user_control('--no-block', 'start', 'archipelago-runtime-worker.service')
|
|
require(started.returncode == 0, started.stderr.decode())
|
|
def ready():
|
|
nonlocal watch
|
|
require(broker.poll() is None, 'broker exited: ' + Path('/run/fixture-service.log').read_text())
|
|
channel = runtime.root_connect(runtime.PUBLIC / 'readiness.sock')
|
|
try:
|
|
result, rights = runtime.receive(channel, 3)
|
|
require(result['version'] == 2, 'runtime detached-mount ABI mismatch')
|
|
for fd in rights:
|
|
os.close(fd)
|
|
watch = channel
|
|
return result
|
|
except (ValueError, ConnectionResetError):
|
|
channel.close(); return None
|
|
identity = wait_for(ready, 40)
|
|
print('PASS live root broker/user-worker readiness:', json.dumps(identity), flush=True)
|
|
info = json.loads(command(['/usr/bin/python3', '-c',
|
|
'import os,json;print(json.dumps([os.getuid(),os.getgid(),os.getgroups(),os.environ["HOME"],os.environ["XDG_RUNTIME_DIR"]]))']))
|
|
require(info == [UID, UID, [], str(HOME), str(RUN)], str(info))
|
|
runtime_meta = json.loads(command(['/usr/bin/python3', '-c',
|
|
'import os,json;from pathlib import Path;p=Path(os.environ["XDG_RUNTIME_DIR"]);s=p.stat();'
|
|
'assert not Path("/run/user/61003").exists();print(json.dumps([s.st_dev,s.st_ino,s.st_uid,s.st_mode & 511]))']))
|
|
meta = RUN.stat()
|
|
require(runtime_meta == [meta.st_dev, meta.st_ino, UID, 0o700], 'runtime directory identity changed under ProtectHome cover')
|
|
print('PASS rendered home/runtime bindings over /home /root /run/user covers preserve exact runtime inode, owner, mode and real bus', flush=True)
|
|
require(client(['/usr/bin/true'], uid=MANAGER, gid=AUTHORITY)[0] is None, 'foreign GID accepted')
|
|
require(client(['/usr/bin/true'], uid=AUTHORITY, gid=MANAGER)[0] is None, 'foreign UID accepted')
|
|
command(['/usr/bin/python3', '-c', 'from pathlib import Path\n'
|
|
'for p in ["/var/lib/private-manager/secret","/var/lib/private-authority/secret"]:\n'
|
|
' try:Path(p).read_text()\n except PermissionError:continue\n raise SystemExit(1)'])
|
|
print('PASS actual permanent identity, exact peer UID/GID, private-state denial', flush=True)
|
|
fds = len(list(Path('/proc/self/fd').iterdir()))
|
|
try:
|
|
RuntimeLease(MANAGER, MANAGER, UID, UID, '0' * 64)
|
|
except ValueError:
|
|
pass
|
|
else:
|
|
raise AssertionError('stale configuration accepted')
|
|
require(len(list(Path('/proc/self/fd').iterdir())) == fds, 'refused readiness leaked capabilities')
|
|
view = RuntimeLease(MANAGER, MANAGER, UID, UID, identity['configuration_sha256'])
|
|
child = os.fork()
|
|
if child == 0:
|
|
try:
|
|
require(LIBC.unshare(0x00020000) == 0, 'private manager view unavailable')
|
|
mount(None, '/', flags=16384 | (1 << 18))
|
|
mount('tmpfs', '/run', 'tmpfs', 0, 'mode=755,size=16M')
|
|
view.install()
|
|
require(command(['/usr/bin/id', '-u']) == str(UID), 'mounted static proxy delegation failed')
|
|
os._exit(0)
|
|
except BaseException:
|
|
import traceback; traceback.print_exc(); os._exit(111)
|
|
_, status = os.waitpid(child, 0)
|
|
require(os.waitstatus_to_exitcode(status) == 0, 'detached socket view failed')
|
|
view.close()
|
|
print('PASS exact socket capability survives covered /run via detached mount; actual static proxy executes', flush=True)
|
|
escaped = ('import os,time,json\nfrom pathlib import Path\n'
|
|
'p=os.fork()\n'
|
|
'if p==0:\n os.setsid();Path("/var/lib/runtime-data/descendant.json").write_text(json.dumps([os.getpid()]));time.sleep(120)\n'
|
|
'else:time.sleep(120)')
|
|
require(client(['/usr/bin/python3', '-c', escaped], disconnect=True) == b'cancelled')
|
|
descendant = json.loads((DATA / 'descendant.json').read_text())[0]
|
|
wait_for(lambda: not Path(f'/proc/{descendant}').exists(), 5)
|
|
wait_for(lambda: not list(Path(identity['cgroup']).parent.glob('job-*')), 5)
|
|
print('PASS disconnected job retires escaped setsid descendant and cgroup', flush=True)
|
|
(DATA / 'descendant.json').unlink()
|
|
command(['/usr/bin/python3', '-c',
|
|
'import os,time,json\nfrom pathlib import Path\nr,w=os.pipe();p=os.fork()\n'
|
|
'if p==0:\n os.close(r);os.setsid();Path("/var/lib/runtime-data/descendant.json").write_text(json.dumps([os.getpid()]));os.write(w,b"x");time.sleep(120)\n'
|
|
'else:\n os.close(w);os.read(r,1);os._exit(0)'])
|
|
descendant = json.loads((DATA / 'descendant.json').read_text())[0]
|
|
wait_for(lambda: not Path(f'/proc/{descendant}').exists(), 5)
|
|
wait_for(lambda: not list(Path(identity['cgroup']).parent.glob('job-*')), 5)
|
|
print('PASS normal leader exit also retires escaped descendant before completion', flush=True)
|
|
# Keep one leader alive while it repeatedly double-forks exited helpers.
|
|
# Each wave must disappear BEFORE releasing the leader; unrelated work
|
|
# must still be admitted. A killed leader cannot make this case pass.
|
|
producer = asynchronous_command(
|
|
'import os,time,json\nfrom pathlib import Path\n'
|
|
'root=Path("/var/lib/runtime-data")\n'
|
|
'root.joinpath("producer-pid").write_text(str(os.getpid()))\n'
|
|
'for wave in range(4):\n'
|
|
' pids=[]\n'
|
|
' for i in range(32):\n'
|
|
' r,w=os.pipe(); helper=os.fork()\n'
|
|
' if helper==0:\n'
|
|
' os.close(r); orphan=os.fork()\n'
|
|
' if orphan==0: os.setsid();os._exit(0)\n'
|
|
' os.write(w,str(orphan).encode());os._exit(0)\n'
|
|
' os.close(w);pids.append(int(os.read(r,32)));os.close(r);os.waitpid(helper,0)\n'
|
|
' tmp=root/"wave-pending";tmp.write_text(json.dumps(pids));tmp.rename(root/("wave-"+str(wave)))\n'
|
|
' while not root.joinpath("release-"+str(wave)).exists(): time.sleep(.02)\n'
|
|
'while not root.joinpath("producer-done").exists(): time.sleep(.02)')
|
|
try:
|
|
for wave in range(4):
|
|
path = DATA / ('wave-' + str(wave))
|
|
wait_for(path.exists, 5)
|
|
pids = json.loads(path.read_text())
|
|
wait_for(lambda: all(not Path(f'/proc/{pid}').exists() for pid in pids), 5)
|
|
require(producer.poll() is None, 'producer exited before live-leader reaping assertion')
|
|
require(Path('/proc/' + (DATA / 'producer-pid').read_text()).exists())
|
|
command(['/usr/bin/true'])
|
|
(DATA / ('release-' + str(wave))).touch()
|
|
# Nested empty cgroups must retire without cancelling that leader.
|
|
command(['/usr/bin/python3', '-c',
|
|
'from pathlib import Path\n'
|
|
'root=Path("/sys/fs/cgroup")/Path("/proc/self/cgroup").read_text().strip()[4:]\n'
|
|
'for i in range(80): (root/str(i)/"nested").mkdir(parents=True)'])
|
|
require(producer.poll() is None, 'nested retirement cancelled unrelated producer')
|
|
# A genuinely unreadable descendant retains only its own slot until
|
|
# the fixture repairs permissions; successful response is withheld.
|
|
blocked = asynchronous_command(
|
|
'from pathlib import Path\n'
|
|
'root=Path("/sys/fs/cgroup")/Path("/proc/self/cgroup").read_text().strip()[4:]\n'
|
|
'child=root/"unreadable";child.mkdir();child.chmod(0)\n'
|
|
'tmp=Path("/var/lib/runtime-data/blocked-pending");tmp.write_text(str(child));tmp.rename(tmp.with_name("blocked-group"))')
|
|
locked = None
|
|
try:
|
|
wait_for(lambda: (DATA / 'blocked-group').exists(), 5)
|
|
locked = Path((DATA / 'blocked-group').read_text())
|
|
time.sleep(.2)
|
|
require(blocked.poll() is None and locked.exists(), 'failed cleanup released ownership')
|
|
command(['/usr/bin/true'])
|
|
require(producer.poll() is None, 'cleanup failure cancelled unrelated producer')
|
|
locked.chmod(0o755)
|
|
require(blocked.wait(timeout=10) == 0, 'retained cleanup did not recover')
|
|
require(not locked.parent.exists(), 'nested invocation root remained')
|
|
finally:
|
|
if locked is not None and locked.exists():
|
|
locked.chmod(0o755)
|
|
if blocked.poll() is None:
|
|
blocked.terminate(); blocked.wait(timeout=5)
|
|
(DATA / 'producer-done').touch()
|
|
require(producer.wait(timeout=10) == 0)
|
|
wait_for(lambda: not list(Path(identity['cgroup']).parent.glob('job-*')), 5)
|
|
finally:
|
|
if producer.poll() is None:
|
|
producer.terminate(); producer.wait(timeout=5)
|
|
print('PASS live-leader orphan waves, nested empty cgroups, retained unreadable cleanup and concurrent admission', flush=True)
|
|
native_terminal_contract(identity)
|
|
command(['/usr/bin/tmux', 'new-session', '-d', '-s', 'fixture', '/bin/sh'])
|
|
command(['/usr/bin/tmux', 'send-keys', '-t', 'fixture', 'printf "runtime-terminal-ok\\n"', 'Enter'])
|
|
command(['/usr/bin/tmux', 'resize-window', '-t', 'fixture', '-x', '90', '-y', '30'])
|
|
wait_for(lambda: 'runtime-terminal-ok' in command(['/usr/bin/tmux', 'capture-pane', '-p', '-t', 'fixture']))
|
|
print('PASS actual persistent tmux new-session/send-keys/resize/capture', flush=True)
|
|
command(['/usr/bin/python3', '-c', 'import tempfile\n'
|
|
'for p in ["/tmp","/var/tmp"]:\n'
|
|
' with tempfile.TemporaryFile(dir=p) as f:f.write(b"private-temp-probe")'])
|
|
command(['/usr/bin/podman', 'load', '-i', '/run/public.oci.tar'])
|
|
image = command(['/usr/bin/podman', 'images', '--format', '{{.ID}}']).splitlines()[0]
|
|
cid = command(['/usr/bin/podman', 'run', '-d', '--name', 'runtime-fixture', '--network=none',
|
|
'--entrypoint=/bin/sh', image, '-c', 'while :; do sleep 1; done'])
|
|
require(command(['/usr/bin/podman', 'exec', cid, '/bin/sh', '-c', 'printf actual-podman-exec']) == 'actual-podman-exec')
|
|
before = command(['/usr/bin/podman', 'inspect', '--format', '{{.Id}} {{.State.Pid}} {{.State.StartedAt}}', cid])
|
|
# Real manager surrogate service, separate from all runtime user groups.
|
|
manager_group = root / 'manager-surrogate'; manager_group.mkdir()
|
|
for _ in range(2):
|
|
manager = os.fork()
|
|
if manager == 0:
|
|
(manager_group / 'cgroup.procs').write_text(str(os.getpid()))
|
|
drop(MANAGER); time.sleep(60); os._exit(0)
|
|
wait_for(lambda: (manager_group / 'cgroup.procs').read_text().strip())
|
|
(manager_group / 'cgroup.kill').write_text('1'); os.waitpid(manager, 0)
|
|
manager_group.rmdir()
|
|
require(command(['/usr/bin/podman', 'inspect', '--format', '{{.Id}} {{.State.Pid}} {{.State.StartedAt}}', cid]) == before)
|
|
require(command(['/usr/bin/tmux', 'has-session', '-t', 'fixture']) == '')
|
|
require(command(['/usr/bin/podman', 'exec', cid, '/bin/true']) == '')
|
|
print('PASS actual Podman USER-unit load/run/exec; container and tmux survive manager cgroup stop', flush=True)
|
|
live = RuntimeLease(MANAGER, MANAGER, UID, UID, identity['configuration_sha256'])
|
|
stop_case = os.environ.get('ARCHY_RUNTIME_STOP', 'revoke')
|
|
if stop_case == 'stall':
|
|
os.kill(broker.pid, signal.SIGSTOP)
|
|
time.sleep(3.3)
|
|
try:
|
|
live.check()
|
|
except ValueError:
|
|
pass
|
|
else:
|
|
raise AssertionError('stalled root observer retained readiness')
|
|
finally:
|
|
os.kill(broker.pid, signal.SIGCONT)
|
|
else:
|
|
lease.close()
|
|
wait_for(lambda: broker.poll() is not None, 5)
|
|
def revoked():
|
|
for _ in range(128):
|
|
if not select.select([watch], [], [], 0)[0]:
|
|
return False
|
|
if watch.recv(16384) == b'':
|
|
return True
|
|
return False
|
|
wait_for(revoked, 5)
|
|
try:
|
|
live.check()
|
|
except ValueError:
|
|
pass
|
|
else:
|
|
raise AssertionError('revoked root lease remained valid')
|
|
live.close()
|
|
wait_for(lambda: not Path(f'/proc/{identity["pid"]}').exists(), 5)
|
|
def persistent_command(argv):
|
|
# Actual transient USER unit; never system-unit --uid Podman exec.
|
|
result = subprocess.run(['/usr/bin/systemd-run', '--user', '--quiet', '--wait', '--pipe', '--collect', *argv],
|
|
env=user_environment, preexec_fn=lambda: drop(UID), capture_output=True, timeout=15)
|
|
require(result.returncode == 0, result.stderr.decode())
|
|
return result.stdout.decode().strip()
|
|
require(persistent_command(['/usr/bin/podman', 'inspect', '--format', '{{.Id}} {{.State.Pid}} {{.State.StartedAt}}', cid]) == before)
|
|
persistent_command(['/usr/bin/podman', 'exec', cid, '/bin/true'])
|
|
persistent_command(['/usr/bin/tmux', 'has-session', '-t', 'fixture'])
|
|
persistent_command(['/usr/bin/podman', 'rm', '-f', cid])
|
|
persistent_command(['/usr/bin/tmux', 'kill-session', '-t', 'fixture'])
|
|
print('PASS ' + stop_case + ' revokes broker/readiness/worker; persistent container and tmux survive worker exit too', flush=True)
|
|
finally:
|
|
for path in DATA.glob('*.log'):
|
|
print(path.name + ':', path.read_text(), flush=True)
|
|
for path in user_group.rglob('cgroup.procs'):
|
|
for pid in path.read_text().split():
|
|
try:
|
|
print('OWNED TASK', pid, path, Path('/proc/' + pid + '/cmdline').read_bytes(),
|
|
Path('/proc/' + pid + '/wchan').read_text(), flush=True)
|
|
except FileNotFoundError:
|
|
pass
|
|
if watch is not None:
|
|
watch.close()
|
|
if broker.poll() is None:
|
|
broker.terminate(); broker.wait(timeout=5)
|
|
# Fixture-owned hierarchy only, AFTER workload lifecycle assertions.
|
|
(user_group / 'cgroup.kill').write_text('1')
|
|
os.waitpid(user, 0)
|
|
print('SERVICE LOG:', Path('/run/fixture-service.log').read_text(), flush=True)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|