Files

292 lines
14 KiB
Python

#!/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()