292 lines
14 KiB
Python
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()
|