#!/usr/bin/env python3 """Actual isolated root I/O executable, nonroot app and inherited capabilities.""" import fcntl import json import os from pathlib import Path import shutil import signal import socket 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' APP = 65533 binary = Path(sys.argv[1]).resolve(strict=True) sys.path.insert(0, str(Path(__file__).resolve().parents[2] / 'scripts')) from c01_host_watch import HostWatch, now, PACKET, VERSION, READY, STARTED def close_except(keep): for text in os.listdir('/proc/self/fd'): fd = int(text) if fd > 2 and fd not in keep: try: os.close(fd) except OSError: pass class FixtureResources: """Own every parent resource before any later setup/constructor can fail.""" def __init__(self): self.pids, self.sockets, self.fds = set(), set(), set() def __enter__(self): return self def __exit__(self, kind, error, traceback): try: self.retire() except BaseException as cleanup: if error is None: raise error.add_note('fixture cleanup failed: ' + str(cleanup)) def own_socket(self, value): self.sockets.add(value) return value def pair(self): return tuple(self.own_socket(value) for value in socket.socketpair(socket.AF_UNIX, socket.SOCK_SEQPACKET)) def retire_pid(self, pid): if pid not in self.pids: return try: os.kill(pid, signal.SIGKILL) except ProcessLookupError: pass deadline = time.monotonic() + 5 while True: done, _ = os.waitpid(pid, os.WNOHANG) if done: self.pids.remove(pid) return assert time.monotonic() < deadline, 'fixture retained an unreaped child' time.sleep(0.005) def retire(self): failures = [] for value in self.sockets: try: value.close() except OSError as error: failures.append(error) for fd in list(self.fds): try: os.close(fd) self.fds.remove(fd) except OSError as error: failures.append(error) for pid in list(self.pids): try: self.retire_pid(pid) except (OSError, AssertionError) as error: failures.append(error) if failures: raise RuntimeError('fixture resource retirement incomplete: ' + ', '.join(type(error).__name__ for error in failures)) from failures[0] with tempfile.TemporaryDirectory(prefix='host-access-') as temporary, FixtureResources() as resources: root = Path(temporary); root.chmod(0o755) executable = root / 'archipelago' shutil.copyfile(binary, executable); executable.chmod(0o755) for case in ('read-and-replay', 'wrong-application', 'wrong-data-capability', 'missing-grant', 'legacy-owner-grant', 'setup-stall-revoked', 'busy-stall-revoked', 'foreign-progress-sender', 'watch-construction-rejected', 'watch-setup-early-exit'): data = root / case; data.mkdir(mode=0o700) credential = data / 'lnd/data/chain/bitcoin/mainnet/admin.macaroon' credential.parent.mkdir(parents=True, mode=0o700) secret = b'only-a-synthetic-host-capability-fixture' credential.write_bytes(secret); credential.chmod(0o600) before = credential.stat() address = '\0host-access-fixture-' + os.urandom(16).hex() listener = resources.own_socket(socket.socket(socket.AF_UNIX, socket.SOCK_SEQPACKET)) listener.bind(address); listener.listen(1); listener.settimeout(5) parent, app_control = resources.pair() app_pid = os.fork() if app_pid == 0: try: close_except({app_control.fileno()}) os.setgroups([]); os.setresgid(APP, APP, APP); os.setresuid(APP, APP, APP) try: credential.read_bytes() except PermissionError: pass else: raise AssertionError('application unexpectedly read root credential directly') channel = socket.socket(socket.AF_UNIX, socket.SOCK_SEQPACKET) channel.settimeout(5); channel.connect(address) assert app_control.recv(32) == b'exercise' request = {'version': 1, 'sequence': 1, 'operation': 'ReadLndAdminMacaroon'} try: channel.send(json.dumps(request).encode()) raw = channel.recv(65536) except (BrokenPipeError, ConnectionResetError): raw = b'' if case == 'read-and-replay': assert json.loads(raw) == {'version': 1, 'sequence': 1, 'result': {'Bytes': list(secret)}} # Do not make worker exit race a queued valid transcript. # Parent must observe Completed before authorizing replay. assert app_control.recv(32) == b'replay' channel.send(json.dumps(request).encode()) assert channel.recv(65536) == b'', 'replayed sequence was answered' else: assert not raw, 'unadmitted owner returned a credential' channel.close() os._exit(0) except BaseException: os._exit(111) resources.pids.add(app_pid) app_control.close() channel, _ = listener.accept(); resources.own_socket(channel); listener.close() grant_parent, grant_child = resources.pair() # Credential delivery is enabled before fork, so every progress packet # carries the kernel identity of its actual sender, not socket creator. grant_parent.setsockopt(socket.SOL_SOCKET, socket.SO_PASSCRED, 1) directory = os.open(data, os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC) resources.fds.add(directory) metadata = os.fstat(directory) owner_pid = os.fork() if owner_pid == 0: try: copies = [fcntl.fcntl(fd, fcntl.F_DUPFD_CLOEXEC, 20) for fd in (grant_child.fileno(), channel.fileno(), directory)] for slot, fd in zip((3, 4, 5), copies): os.dup2(fd, slot, inheritable=True) if case == 'missing-grant': os.close(3) os.closerange(6, 1048576) if case == 'watch-setup-early-exit': os._exit(113) if case in ('setup-stall-revoked', 'watch-construction-rejected'): os.kill(os.getpid(), signal.SIGSTOP) if case in ('busy-stall-revoked', 'foreign-progress-sender'): # A deliberately stopped test worker models the observer's # stalled-I/O path; it is not a claim to force kernel D state. control = socket.socket(fileno=3) control.recv(8192) if case == 'foreign-progress-sender': child = os.fork() if child == 0: control.send(PACKET.pack(VERSION, READY, 0, 0)); os._exit(0) os.waitpid(child, 0) else: control.send(PACKET.pack(VERSION, READY, 0, 0)) app_channel = socket.socket(fileno=4) request = app_channel.recv(65536) assert json.loads(request)['sequence'] == 1 control.send(PACKET.pack(VERSION, STARTED, 1, now() + 150_000_000)) os.kill(os.getpid(), signal.SIGSTOP) os.execve(executable, [str(executable), 'host-access-owner'], {'PATH': '/usr/bin:/bin'}) except BaseException: os._exit(111) resources.pids.add(owner_pid) grant_child.close(); os.close(directory); resources.fds.remove(directory) if case in ('watch-construction-rejected', 'watch-setup-early-exit'): end = time.monotonic() + 5 while True: # Observe without consuming an unexpected exit: the resource # owner remains the sole reaper, including failure branches. status = os.waitid(os.P_PID, owner_pid, os.WSTOPPED | os.WEXITED | os.WNOHANG | os.WNOWAIT) if status is not None: break assert time.monotonic() < end time.sleep(0.005) try: if case == 'watch-setup-early-exit': assert status.si_code == os.CLD_EXITED and status.si_status == 113 raise ValueError('injected early worker exit') assert status.si_code == os.CLD_STOPPED HostWatch(grant_parent, channel, owner_pid, now() - 1) except ValueError: owned = tuple(resources.pids) resources.retire() assert not resources.pids and not resources.fds assert all(value.fileno() == -1 for value in resources.sockets) for pid in owned: try: os.waitpid(pid, os.WNOHANG) except ChildProcessError: pass else: raise AssertionError('constructor rejection left a waitable child') print('PASS host capability:', case) continue raise AssertionError('expired constructor deadline accepted') setup_deadline = now() + (150_000_000 if case == 'setup-stall-revoked' else 5_000_000_000) watch = HostWatch(grant_parent, channel, owner_pid, setup_deadline) observed = [] busy_deadline = None replay_sent = False try: if case != 'missing-grant': grant_parent.send(json.dumps({'version': 1 if case == 'legacy-owner-grant' else 2, 'process_id': owner_pid, 'application': {'pid': app_pid + (1 if case == 'wrong-application' else 0), 'uid': APP, 'gid': APP}, 'data_device': metadata.st_dev, 'data_inode': metadata.st_ino + (1 if case == 'wrong-data-capability' else 0)}).encode()) parent.send(b'exercise') end = time.monotonic() + 10 owner_status = None while app_pid is not None or owner_pid is not None: assert time.monotonic() < end, 'observer failed to retire fixture processes' for _ in range(4): watch.receive() observed.append((watch.state, watch.sequence)) if watch.state == 'busy': if busy_deadline is None: busy_deadline = watch.deadline assert watch.deadline == busy_deadline if case == 'read-and-replay' and watch.state == 'idle' and watch.sequence == 1 and not replay_sent: parent.send(b'replay'); replay_sent = True if owner_pid is not None: done, status = os.waitpid(owner_pid, os.WNOHANG) if done: resources.pids.remove(owner_pid) owner_status = os.waitstatus_to_exitcode(status) owner_pid = None; watch.worker_exited() elif watch.state == 'failed': os.kill(owner_pid, signal.SIGKILL) if app_pid is not None: done, status = os.waitpid(app_pid, os.WNOHANG) if done: resources.pids.remove(app_pid) assert os.waitstatus_to_exitcode(status) == 0, (case, status) app_pid = None time.sleep(0.005) assert owner_status in (1, -signal.SIGKILL), (case, owner_status) if case == 'read-and-replay': assert ('busy', 0) in observed and ('idle', 1) in observed assert watch.sequence == 1, 'replay advanced observer sequence' elif case in ('setup-stall-revoked', 'busy-stall-revoked'): assert watch.failure == 'host worker deadline expired' assert owner_status == -signal.SIGKILL busy_timeout = (('idle', 0) in observed and ('busy', 0) in observed and ('idle', 1) not in observed and busy_deadline is not None and busy_deadline != setup_deadline and now() >= busy_deadline) if case == 'busy-stall-revoked': assert busy_timeout, 'busy coverage requires accepted Started and its deadline' else: # Withholding Ready must never qualify as a busy-I/O timeout. assert not busy_timeout and busy_deadline is None assert all(state in ('starting', 'failed') for state, _ in observed) elif case == 'foreign-progress-sender': assert watch.failure == 'host worker progress refused' after = credential.stat() assert credential.read_bytes() == secret assert (after.st_uid, after.st_gid, after.st_mode, after.st_ino, after.st_size) == ( before.st_uid, before.st_gid, before.st_mode, before.st_ino, before.st_size) print('PASS host capability:', case) finally: parent.close(); watch.revoke('fixture cleanup') for pid in (app_pid, owner_pid): if pid is not None: resources.retire_pid(pid) watch.release_reaped()