275 lines
13 KiB
Python
275 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""Disposable process integration; no installed state or trusted-time claim."""
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import signal
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import traceback
|
|
from types import SimpleNamespace
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / 'scripts'))
|
|
import c01_supervisor as supervisor
|
|
import c01_host_watch as watch
|
|
import c01_views as views
|
|
from c01_fixture_process import run_bounded
|
|
|
|
assert os.geteuid() == 0 and os.environ.get('ARCHY_C01_SUPERVISOR_FIXTURE') == '1'
|
|
assert sys.argv[1:] in ([], ['--python-only'])
|
|
assert Path(supervisor.lifecycle.process_group_path(os.getpid())).parent.name.startswith('opencode-c01-supervisor-')
|
|
for directory in ('/run', '/var/lib/archipelago', '/var/lib/archipelago-c01', '/etc/archipelago-c01'):
|
|
rows = [line.split() for line in Path('/proc/self/mountinfo').read_text().splitlines()]
|
|
assert any(row[4] == directory and row[row.index('-') + 1] == 'tmpfs' for row in rows), directory
|
|
|
|
root = Path('/var/lib/archipelago-c01/fixture')
|
|
root.mkdir(mode=0o700)
|
|
|
|
|
|
def refused(body, text=None):
|
|
try:
|
|
body()
|
|
except (OSError, ValueError) as error:
|
|
if text: assert text in str(error), str(error)
|
|
else:
|
|
raise AssertionError('unexpected admission')
|
|
|
|
|
|
def owner(name):
|
|
return supervisor.Supervisor(supervisor.LaunchLock(root / (name + '.lock')))
|
|
|
|
|
|
def await_status(record, wanted):
|
|
record.channel.settimeout(5)
|
|
assert record.channel.recv(128) == wanted
|
|
|
|
|
|
def done(record):
|
|
print('PASS ' + record, flush=True)
|
|
|
|
|
|
refused(lambda: supervisor.admit({'old_writers_excluded': True, 'migration_complete': True}, None, None),
|
|
'C01_ADMISSION_UNAVAILABLE')
|
|
done('persisted-exclusion-booleans-cannot-admit')
|
|
|
|
assert supervisor.runtime_workspace_environment({'home': '/home/preserved-runtime'}) == {
|
|
'ARCHIPELAGO_RUNTIME_WORKSPACE': '/home/preserved-runtime/Work'}
|
|
assert supervisor.BASE / 'manager' != Path('/home/preserved-runtime/Work')
|
|
done('runtime-workspace-contract-is-independent-of-private-manager-home')
|
|
|
|
key = root / 'key'
|
|
key.write_bytes(bytes(range(1, 33)))
|
|
key.chmod(0o600)
|
|
fd = supervisor.root_capability(key, 32)
|
|
identity = supervisor.key_binding(fd)
|
|
assert os.lseek(fd, 0, os.SEEK_CUR) == 0
|
|
os.link(key, root / 'key-alias')
|
|
refused(lambda: supervisor.root_capability(key, 32), 'invalid root capability')
|
|
(root / 'key-alias').unlink()
|
|
done('key-capability-no-parent-read-and-hardlink-denial')
|
|
|
|
managed = root / 'manager'; managed.mkdir(mode=0o700)
|
|
directory = views.pin_directory(managed)
|
|
identity_dir = managed / 'identity'; identity_dir.mkdir(mode=0o700)
|
|
secret = identity_dir / 'nostr_secret'; secret.write_bytes(b'synthetic-identity'); secret.chmod(0o600)
|
|
assert supervisor.private_identity(directory, 0) == (secret.stat().st_dev, secret.stat().st_ino)
|
|
os.link(secret, root / 'secret-alias')
|
|
refused(lambda: supervisor.private_identity(directory, 0), 'secret/alias')
|
|
(root / 'secret-alias').unlink()
|
|
identity_dir.rename(managed / 'original')
|
|
identity_dir.symlink_to(managed / 'original')
|
|
refused(lambda: supervisor.private_identity(directory, 0))
|
|
os.close(directory)
|
|
done('identity-symlink-and-external-hardlink-denied')
|
|
|
|
owned = owner('keyless')
|
|
try:
|
|
owned.children.reserve('administrator')
|
|
reservation = owned.children.reserved['administrator']
|
|
reserved_inode = reservation[0].path.stat().st_ino
|
|
refused(lambda: owned.children.reserve('administrator'), 'role already')
|
|
def child(control):
|
|
for entry in Path('/proc/self/fd').iterdir():
|
|
try: meta = os.fstat(int(entry.name))
|
|
except OSError: continue
|
|
assert (meta.st_dev, meta.st_ino) != (identity['device'], identity['inode'])
|
|
supervisor.lifecycle.drop_credentials(60002, 60002, os.getppid())
|
|
control.send(b'KEYLESS')
|
|
supervisor.child_wait(control, b'RELEASE', supervisor.now() + 5_000_000_000)
|
|
record = owned.children.spawn('administrator', child)
|
|
assert not owned.children.reserved and record.group.path.stat().st_ino == reserved_inode
|
|
time.sleep(0.2) # Force queued status to survive delayed parent scheduling.
|
|
owned.wait(record, b'KEYLESS', 60002, 60002, supervisor.now() + 5_000_000_000)
|
|
assert os.lseek(fd, 0, os.SEEK_CUR) == 0
|
|
supervisor.packet(record.channel, b'RELEASE')
|
|
end = time.monotonic() + 3
|
|
while 'administrator' not in owned.children.results and time.monotonic() < end:
|
|
owned.tick(); time.sleep(0.005)
|
|
assert owned.children.results.get('administrator') == 0
|
|
finally:
|
|
owned.retire()
|
|
os.close(fd)
|
|
done('late-administrator-inherits-no-storage-key-fds')
|
|
|
|
owned = owner('unused-reservation')
|
|
owned.children.reserve('administrator')
|
|
reserved_path = owned.children.reserved['administrator'][0].path
|
|
owned.retire()
|
|
assert not reserved_path.exists()
|
|
done('unused-precollection-admin-reservation-retired')
|
|
|
|
owned = owner('retained')
|
|
try:
|
|
record = owned.children.spawn('host-io', lambda control: time.sleep(60))
|
|
original_empty = record.group.empty
|
|
record.group.empty = lambda: False # injected kernel-unretired observation
|
|
owned.stop('injected stall')
|
|
for _ in range(10):
|
|
owned.tick(); time.sleep(0.01)
|
|
assert 'host-io' in owned.children.children
|
|
refused(lambda: supervisor.LaunchLock(root / 'retained.lock'))
|
|
for _ in range(5): os.kill(os.getpid(), signal.SIGTERM)
|
|
assert owned.children.stopping
|
|
record.group.empty = original_empty
|
|
finally:
|
|
owned.retire()
|
|
reacquired = supervisor.LaunchLock(root / 'retained.lock'); reacquired.close()
|
|
done('stall-retains-lock-and-repeated-stop-remains-responsive')
|
|
|
|
# A new flock owner must not mistake the previous root process's crash for
|
|
# completed role retirement, including a different delegated-unit path.
|
|
owned = owner('crash-locator')
|
|
try:
|
|
record = owned.children.spawn('host-io', lambda control: time.sleep(60))
|
|
other = supervisor.LaunchLock(root / 'second.lock')
|
|
locator = json.dumps({'boot': supervisor.recovery.boot_id(), 'group': str(owned.children.root)}).encode()
|
|
os.write(other.fd, locator)
|
|
refused(lambda: other.bind_group(owned.children.root), 'retirement remains unresolved')
|
|
other.close()
|
|
finally:
|
|
owned.retire()
|
|
done('crash-locator-rejects-prior-unretired-role')
|
|
|
|
owned = owner('host-watch')
|
|
client, accepted = socket.socketpair(socket.AF_UNIX, socket.SOCK_SEQPACKET)
|
|
try:
|
|
def worker(control):
|
|
control.send(watch.PACKET.pack(1, watch.READY, 0, 0))
|
|
control.send(watch.PACKET.pack(1, watch.STARTED, 1, watch.now() + 300_000_000))
|
|
time.sleep(60)
|
|
record = owned.children.spawn('host-io', worker, keep=(accepted.fileno(),))
|
|
owned.watch = watch.HostWatch(record.channel, accepted, record.pid, watch.now() + 1_000_000_000)
|
|
end = time.monotonic() + 3
|
|
while owned.tick() and time.monotonic() < end: time.sleep(0.01)
|
|
assert owned.children.stopping and owned.watch.failure == 'host worker deadline expired'
|
|
client.settimeout(1); assert client.recv(1) == b''
|
|
finally:
|
|
owned.retire(); client.close()
|
|
done('host-progress-stall-revokes-actual-application-endpoint')
|
|
|
|
owned = owner('forged-status')
|
|
try:
|
|
record = owned.children.spawn('authority', lambda control: (control.send(b'STORE_READY'), time.sleep(60)))
|
|
refused(lambda: owned.wait(record, b'STORE_READY', 60002, 60002,
|
|
supervisor.now() + 5_000_000_000), 'status refused')
|
|
finally:
|
|
owned.retire()
|
|
done('setup-status-requires-actual-role-credentials')
|
|
|
|
if sys.argv[1:] == ['--python-only']:
|
|
assert not Path(f'/proc/self/task/{os.getpid()}/children').read_text().strip()
|
|
print('Python-only fixture boundary: native embedding/proxy cases NOT executed', flush=True)
|
|
raise SystemExit(0)
|
|
|
|
# Compose the launcher's actual mount preparation with its lifecycle owner,
|
|
# using preserved tmpfs backing inodes, not a mocked view implementation.
|
|
prepared = supervisor.Prepared.__new__(supervisor.Prepared)
|
|
prepared.fds = []
|
|
prepared.value = {'runtime_uid': 1000, 'runtime_gid': 1000, 'runtime_data_dir': '/var/lib/archipelago'}
|
|
prepared.backings = {}
|
|
prepared.backing_sources = {}
|
|
for name in ('manager', 'snapshot', 'authority'):
|
|
path = root / ('backing-' + name); path.mkdir(mode=0o700); os.chown(path, 1000, 1000)
|
|
(path / 'record').write_bytes(name.encode()); (path / 'record').chmod(0o600); os.chown(path / 'record', 1000, 1000)
|
|
prepared.backings[name] = prepared.own(views.require_private_backing(path))
|
|
prepared.backing_sources[name] = views.PinnedSource.capture(prepared.backings[name], path)
|
|
runtime = Path('/var/lib/archipelago'); runtime.chmod(0o700); os.chown(runtime, 1000, 1000)
|
|
(runtime / 'wallet-fixture').write_bytes(b'preserved'); (runtime / 'wallet-fixture').chmod(0o600)
|
|
os.chown(runtime / 'wallet-fixture', 1000, 1000)
|
|
prepared.runtime = prepared.own(os.open(runtime, os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC))
|
|
prepared.runtime_source = views.PinnedSource.capture(prepared.runtime, runtime)
|
|
proxy = root / 'proxy'
|
|
with proxy.open('xb') as output:
|
|
subprocess.run(['/opt/fixture/native', 'export-runtime-proxy'], stdout=output, check=True, timeout=5)
|
|
proxy.chmod(0o555)
|
|
prepared.proxy = prepared.own(os.open(proxy, os.O_PATH | os.O_CLOEXEC))
|
|
prepared.proxy_source = views.PinnedSource.capture(prepared.proxy, proxy)
|
|
original = {name: views.identity(fd) for name, fd in prepared.backings.items()}
|
|
old_namespace = os.stat('/proc/self/ns/mnt').st_ino
|
|
host_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
host_socket.bind('/run/forbidden-host-bus')
|
|
owned = owner('views')
|
|
try:
|
|
# Capture proxy metadata before drop; the protected source path is denied.
|
|
proxy_inode = proxy.stat().st_ino
|
|
def mapped(control):
|
|
try:
|
|
prepared.role_views(SimpleNamespace(pw_uid=60001, pw_gid=60001), 'application')
|
|
supervisor.close_except({control.fileno()})
|
|
supervisor.lifecycle.drop_credentials(60001, 60001, os.getppid())
|
|
assert (supervisor.BASE / 'manager/record').read_bytes() == b'manager'
|
|
assert (runtime / 'wallet-fixture').read_bytes() == b'preserved'
|
|
assert not Path('/run/forbidden-host-bus').exists()
|
|
assert (supervisor.BASE / 'bin/podman').stat().st_ino == proxy_inode
|
|
refused(lambda: (root / 'backing-authority/record').read_bytes())
|
|
refused(lambda: (supervisor.BASE / 'bin/podman').open('wb'))
|
|
control.send(b'VIEWS_READY')
|
|
time.sleep(60)
|
|
except BaseException:
|
|
traceback.print_exc(); raise
|
|
record = owned.children.spawn('application', mapped, keep=prepared.fds)
|
|
owned.wait(record, b'VIEWS_READY', 60001, 60001, supervisor.now() + 15_000_000_000)
|
|
finally:
|
|
owned.retire(); host_socket.close()
|
|
assert os.stat('/proc/self/ns/mnt').st_ino == old_namespace
|
|
assert all(views.identity(fd) == original[name] for name, fd in prepared.backings.items())
|
|
assert (runtime / 'wallet-fixture').stat().st_uid == 1000
|
|
prepared.close()
|
|
done('composed-private-views-proxy-inode-aliases-and-runtime-preservation')
|
|
|
|
# Use the real embedded payload for both identity and hostile import checks.
|
|
binary = root / 'native'; shutil.copyfile('/opt/fixture/native', binary); binary.chmod(0o755)
|
|
poison = root / 'poison'; poison.mkdir()
|
|
marker = root / 'imported-untrusted-code'
|
|
for name in ('sitecustomize', 'c01_supervisor', 'ctypes', 'json'):
|
|
(poison / (name + '.py')).write_text(f'open({str(marker)!r},"w").write("bad")\n')
|
|
env = {**os.environ, 'PYTHONPATH': str(poison), 'PYTHONHOME': str(poison), 'PYTHONSTARTUP': str(poison / 'c01_supervisor.py')}
|
|
seen_native = False
|
|
def sample_native(pid):
|
|
global seen_native
|
|
try:
|
|
actual = Path(f'/proc/{pid}/exe').stat()
|
|
expected = binary.stat()
|
|
seen_native |= (actual.st_dev, actual.st_ino) == (expected.st_dev, expected.st_ino)
|
|
except FileNotFoundError:
|
|
pass
|
|
result = run_bounded([str(binary), 'c01-supervisor-v3'], cwd=poison, env=env,
|
|
seconds=5, observe=sample_native)
|
|
output, errors = result.stdout, result.stderr
|
|
assert result.returncode != 0 and seen_native and not marker.exists(), errors.decode()
|
|
assert b'/etc/archipelago-c01/launch.json' in errors, errors.decode()
|
|
assert not output
|
|
done('native-mainpid-isolated-embedding-ignores-env-site-and-cwd')
|
|
|
|
extra = run_bounded([str(binary), 'c01-supervisor-v3', str(poison / 'c01_supervisor.py')], seconds=5)
|
|
assert extra.returncode != 0 and b'accepts no arguments' in extra.stderr and not marker.exists()
|
|
done('native-supervisor-rejects-arbitrary-root-code-arguments')
|
|
|
|
assert not Path(f'/proc/self/task/{os.getpid()}/children').read_text().strip()
|
|
assert not any((Path('/sys/fs/cgroup') / supervisor.lifecycle.process_group_path(os.getpid()).lstrip('/')).parent.glob('c01-*'))
|
|
done('all-owned-children-and-role-cgroups-retired')
|