Files
archy/tests/regression/c01-startup.py
T

223 lines
11 KiB
Python

#!/usr/bin/env python3
"""Actual non-test binary, real distinct UIDs and launch sockets, disposable mounts.
Invoked only by the ignored Rust fixture through test-backend-isolated.sh. No
time assertion, wallet, live supervisor, or installation authority is created.
"""
import array
import fcntl
import json
import os
from pathlib import Path
import signal
import shutil
import socket
import subprocess
import sys
import tempfile
import time
assert os.geteuid() == 0 and os.getppid() == 1
assert os.environ.get('ARCHY_TEST_ISOLATED') == '1'
assert os.environ.get('ARCHY_TEST_C01_STARTUP') == '1'
# The fixture must not populate a real installed payload/config directory.
for directory in ('/opt/archipelago', '/etc/archipelago'):
fields = [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 fields), directory
binary = Path(sys.argv[1]).resolve(strict=True)
APP, PEER = 65534, 65533
def drop(uid):
os.setgroups([])
os.setresgid(uid, uid, uid)
os.setresuid(uid, uid, uid)
def independent_endpoint():
parent, child = socket.socketpair(socket.AF_UNIX, socket.SOCK_SEQPACKET)
pid = os.fork()
if pid == 0:
try:
parent.close()
drop(PEER)
retained, passed = socket.socketpair(socket.AF_UNIX, socket.SOCK_SEQPACKET)
child.sendmsg([b'endpoint'], [(socket.SOL_SOCKET, socket.SCM_RIGHTS,
array.array('i', [passed.fileno()]))])
passed.close()
child.recv(1) # Keep the actual peer alive until the case is finished.
retained.close()
finally:
os._exit(0)
child.close()
parent.settimeout(5)
_, ancillary, flags, _ = parent.recvmsg(32, socket.CMSG_SPACE(array.array('i').itemsize))
assert not flags and len(ancillary) == 1
level, kind, raw = ancillary[0]
assert (level, kind) == (socket.SOL_SOCKET, socket.SCM_RIGHTS)
received = array.array('i'); received.frombytes(raw)
assert len(received) == 1
return pid, parent, received[0]
def wait(pid, read_fd):
os.set_blocking(read_fd, False)
output = bytearray()
deadline = time.monotonic() + 10
try:
while True:
try:
output.extend(os.read(read_fd, 65536))
except BlockingIOError:
pass
assert len(output) <= 1024 * 1024, 'startup output exceeded fixture bound'
done, status = os.waitpid(pid, os.WNOHANG)
if done:
# The terminated program has no authorized child work. Retire
# any stray descendants before collecting the final pipe bytes.
try:
os.killpg(pid, signal.SIGKILL)
except ProcessLookupError:
pass
while True:
try:
part = os.read(read_fd, 65536)
except BlockingIOError:
break
if not part:
break
output.extend(part)
return os.waitstatus_to_exitcode(status), output.decode(errors='replace')
assert time.monotonic() < deadline, 'startup did not refuse within the fixture deadline'
time.sleep(0.01)
except BaseException:
try:
os.killpg(pid, signal.SIGKILL)
except ProcessLookupError:
pass
os.waitpid(pid, 0)
raise
finally:
os.close(read_fd)
with tempfile.TemporaryDirectory(prefix='c01-startup-') as temporary:
root = Path(temporary)
root.chmod(0o755)
executable = root / 'archipelago'
shutil.copyfile(binary, executable)
executable.chmod(0o755)
proxy = root / 'archipelago-runtime-proxy'
with proxy.open('xb') as output:
subprocess.run([str(executable), 'export-runtime-proxy'], stdout=output, check=True, timeout=5)
proxy.chmod(0o555)
tools = root / 'bin'; tools.mkdir()
# Detect forbidden scheduling as well as actual privileged writes. This
# stub never invokes sudo/systemd or changes installed files.
sudo = tools / 'sudo'
sudo.write_text('#!/bin/sh\n: > "$C01_FIXTURE_MARKER"\nexit 0\n')
sudo.chmod(0o755)
web = Path('/opt/archipelago/web-ui'); web.mkdir(mode=0o700)
cases = [
# Older ABI refusal is not evidence for later storage/path/proxy checks.
# Current FD10/11/12 probes live in c01-normal-systemd.py.
('current-missing-fd12', 'c01-managed-v6', False, 'required Tor lifecycle FD12 missing'),
('radio-v7-missing-fd14', 'c01-managed-v7', False, 'required v7 radio FD14 missing'),
('obsolete-v3', 'c01-managed-v3', False, 'obsolete protected application ABI'),
('obsolete-v4', 'c01-managed-v4', False, 'obsolete protected application ABI'),
('obsolete-v5', 'c01-managed-v5', False, 'obsolete protected application ABI'),
('invalid-admin', 'c01-authority-v3', True, 'C01 authority peer identity mismatch'),
('legacy-application', 'c01-managed-v2', False, 'unknown command or option'),
('legacy-authority', 'c01-authority', False, 'unknown command or option'),
('unknown-command', 'c01-managed-typo', False, 'unknown command or option'),
('version', '--version', False, 'archipelago '),
]
for name, selector, authenticated, message in cases:
case = root / name; case.mkdir(mode=0o755)
data = case / 'data'; data.mkdir(mode=0o700)
runtime_data = case / 'runtime'; runtime_data.mkdir(mode=0o700); os.chown(runtime_data, APP, APP)
store = case / 'store'; store.mkdir(mode=0o700)
scratch = case / 'scratch'; scratch.mkdir(mode=0o700)
os.chown(data, PEER if selector == 'c01-authority-v3' else APP, PEER if selector == 'c01-authority-v3' else APP)
for path in (store, scratch):
os.chown(path, APP, APP)
marker = scratch / 'privileged-startup-ran'
configured = scratch / 'must-not-be-created'
peer_pid, peer_control, peer_fd = independent_endpoint()
grant_parent, grant_child = socket.socketpair(socket.AF_UNIX, socket.SOCK_SEQPACKET)
admin_parent, admin_child = socket.socketpair(socket.AF_UNIX, socket.SOCK_SEQPACKET)
reader, writer = os.pipe()
key_path = case / 'storage.key'
if name == 'invalid-key-capability':
os.mkfifo(key_path, 0o600)
else:
key_path.write_bytes(bytes([7] * 32)); key_path.chmod(0o600)
key_fd = os.open(key_path, os.O_RDONLY | os.O_NONBLOCK | os.O_CLOEXEC)
key_metadata = os.fstat(key_fd)
if name == 'invalid-proxy':
fifo = case / 'proxy-fifo'; os.mkfifo(fifo, 0o600)
proxy_fd = os.open(fifo, os.O_PATH | os.O_CLOEXEC)
else:
proxy_fd = os.open(proxy, os.O_PATH | os.O_CLOEXEC)
pid = os.fork()
if pid == 0:
try:
os.setsid()
os.dup2(writer, 1); os.dup2(writer, 2)
copies = [fcntl.fcntl(fd, fcntl.F_DUPFD_CLOEXEC, 20)
for fd in (grant_child.fileno(), peer_fd, admin_child.fileno(), admin_child.fileno(), proxy_fd, key_fd)]
# Preserve slots before closing unrelated inherited descriptors.
for slot, fd in zip((3, 4, 5, 6, 8, 9), copies):
if authenticated and (slot != 5 or selector == 'c01-authority-v3') and (slot not in (6, 8) or selector == 'c01-managed-v3'):
os.dup2(fd, slot, inheritable=True)
else:
os.close(slot)
os.close(7)
os.closerange(10, 1048576)
drop(APP)
env = {'PATH': str(tools) + ':/usr/bin:/bin', 'HOME': str(scratch),
'ARCHIPELAGO_DATA_DIR': str(data if name == 'runtime-bound-path' else configured),
'ARCHIPELAGO_RUNTIME_DATA_DIR': str(configured if name == 'runtime-bound-path' else runtime_data),
'ARCHIPELAGO_HOST_IP': '127.0.0.1',
'ARCHIPELAGO_BIND': '127.0.0.1:0', 'C01_FIXTURE_MARKER': str(marker),
'RUST_MIN_STACK': '16777216'}
os.execve(executable, [str(executable), selector], env)
except BaseException:
os._exit(111)
os.close(writer)
if name != 'invalid-proxy':
grant_child.close()
admin_child.close(); os.close(peer_fd); os.close(proxy_fd)
if authenticated:
grant_parent.send(json.dumps({'version': 3, 'role': 'authority' if selector == 'c01-authority-v3' else 'application',
'process_id': pid, 'data_dir': str(data), 'store_dir': str(store),
'runtime_data_dir': None if name == 'missing-runtime-binding' else str(runtime_data),
'binding': {'local_identity': [1] * 32, 'incarnation': [2] * 32, 'reader_schema': 2, 'writer_schema': 2},
'key_file': {'device': key_metadata.st_dev, 'inode': key_metadata.st_ino},
'peer': {'pid': peer_pid, 'uid': PEER, 'gid': PEER},
'admin': {'pid': os.getpid(), 'uid': APP, 'gid': APP} if selector == 'c01-authority-v3' else None,
'provision': selector == 'c01-authority-v3', 'old_writers_excluded': True}).encode())
try:
code, output = wait(pid, reader)
assert (code == 0) == (selector == '--version'), (name, code, output)
assert message in output, (name, output)
assert not configured.exists(), (name, 'created an unadmitted data directory')
assert not marker.exists(), (name, 'scheduled privileged startup')
assert list(store.iterdir()) == [], (name, 'mutated authority state before channel admission')
if name == 'invalid-proxy':
# Retained duplicate of the receiving endpoint proves the
# private grant remained queued, and its inherited key capability
# has not been read before executable admission.
grant_child.setblocking(False)
assert json.loads(grant_child.recv(8192))['key_file'] == {'device': key_metadata.st_dev, 'inode': key_metadata.st_ino}
assert os.lseek(key_fd, 0, os.SEEK_CUR) == 0
print('PASS C01 startup:', name)
finally:
grant_parent.close(); grant_child.close(); admin_parent.close(); peer_control.close()
os.close(key_fd)
os.waitpid(peer_pid, 0)
# The same explicitly isolated namespace also checks the positive recovery
# handoff against the non-test authority executable and distinct real UIDs.
import runpy
runpy.run_path(str(Path(__file__).with_name('c01-recovery.py')), run_name='__main__')
runpy.run_path(str(Path(__file__).with_name('host-access.py')), run_name='__main__')