Files
archy/tests/regression/runtime-delegation.py

259 lines
14 KiB
Python

#!/usr/bin/env python3
"""Compile static proxy and run real different-UID command/descriptor fixtures.
Only a fresh PID/run/data namespace is allowed. No installed account, app,
wallet, service manager or persistent runtime is touched.
"""
import ctypes
import fcntl
import json
import os
from pathlib import Path
import select
import signal
import shutil
import socket
import subprocess
import sys
import tempfile
import time
require = lambda condition: condition or (_ for _ in ()).throw(AssertionError('fixture isolation required'))
require(os.geteuid() == 0 and os.environ.get('ARCHY_TEST_ISOLATED') == '1')
require(os.getpid() == 1 or os.getppid() == 1)
require(any(row.split()[4] == '/run' and row.split()[row.split().index('-') + 1] == 'tmpfs'
for row in Path('/proc/self/mountinfo').read_text().splitlines()))
repo = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(repo / 'scripts'))
import runtime_owner
APP, RUNTIME = 65533, 65532
libc = ctypes.CDLL(None, use_errno=True)
def drop(uid, gid=None):
gid = uid if gid is None else gid
os.setgroups([]); os.setresgid(gid, gid, gid); os.setresuid(uid, uid, uid)
def run_fixture():
with tempfile.TemporaryDirectory(prefix='runtime-delegation-') as temporary:
root = Path(temporary); root.chmod(0o755)
proxy = root / 'archipelago-runtime-proxy'
assert len(sys.argv) == 2, 'the isolated Rust harness supplies its actual embedded proxy'
shutil.copyfile(sys.argv[1], proxy)
headers = subprocess.check_output(['/usr/bin/readelf', '-l', str(proxy)], text=True)
assert 'INTERP' not in headers, 'proxy must not have an injectable dynamic loader'
proxy.chmod(0o755)
short_dir = root / 'short'; short_dir.mkdir(mode=0o755)
short_proxy = short_dir / proxy.name
subprocess.run(['/usr/bin/cc', '-std=c11', '-O2', '-static', '-Wall', '-Wextra', '-Werror',
'-DRUNTIME_RESULT_MS=1000', str(repo / 'core/runtime-proxy.c'), '-o', str(short_proxy)], check=True, timeout=60)
short_proxy.chmod(0o755)
home = root / 'runtime-home'; home.mkdir(mode=0o700); os.chown(home, RUNTIME, RUNTIME)
runtime_dir = root / 'runtime-run'; runtime_dir.mkdir(mode=0o700); os.chown(runtime_dir, RUNTIME, RUNTIME)
private = root / 'manager-private'; private.mkdir(mode=0o700); os.chown(private, APP, APP)
secret = private / 'identity'; secret.write_bytes(b'synthetic-manager-identity'); secret.chmod(0o600); os.chown(secret, APP, APP)
server_dir = Path('/run/archipelago-managed'); server_dir.mkdir(mode=0o755)
path = server_dir / 'runtime.sock'
listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
listener.bind(str(path)); listener.listen(16)
os.chown(path, 0, APP); path.chmod(0o660)
owner_pid = os.fork()
if owner_pid == 0:
try:
drop(RUNTIME)
assert libc.prctl(1, signal.SIGKILL, 0, 0, 0) == 0
import builtins
real_import = builtins.__import__
owner_identity = os.getpid()
def import_guard(name, *args, **kwargs):
# Force any regressed child-side fcntl import to stall even
# if this fixture has already cached the module. Parent-side
# loading stays ordinary; exec replaces this test hook.
if name == 'fcntl' and os.getpid() != owner_identity:
time.sleep(60)
return real_import(name, *args, **kwargs)
builtins.__import__ = import_guard
real_exec = os.execvpe
def held_exec(program, argv, environment):
# Only this disposable fixture overrides exec. It blocks in
# a real FIFO open immediately before executable resolution.
if program == 'fixture-held-exec':
marker = Path(argv[2]); temporary = marker.with_suffix('.tmp')
temporary.write_text(str(os.getpid())); temporary.replace(marker)
fd = os.open(argv[1], os.O_RDONLY)
os.read(fd, 1); os.close(fd)
real_exec('/usr/bin/true', ['true'], environment)
real_exec(program, argv, environment)
os.execvpe = held_exec
owner = runtime_owner.RuntimeOwner(listener, APP, APP, home, runtime_dir, 'runtime-fixture')
owner.serve()
os._exit(0)
except BaseException:
os._exit(111)
listener.close()
results = []
def command(args, *, stdin=b'', env=None, uid=APP, gid=None, block_term=False):
def credentials():
drop(uid, gid)
if block_term:
signal.pthread_sigmask(signal.SIG_BLOCK, [signal.SIGTERM])
return subprocess.run([str(proxy), '--', *args], input=stdin, capture_output=True, timeout=10,
cwd=root, env=env or {'PATH': '/usr/bin:/bin'}, preexec_fn=credentials)
def record(path, count):
end = time.monotonic() + 5
while not path.exists() and time.monotonic() < end:
time.sleep(0.01)
assert path.exists(), 'delegated readiness missing'
values = list(map(int, path.read_text().split()))
assert len(values) == count and len(set(values)) == count and all(pid > 1 for pid in values), 'incomplete readiness record'
return [os.pidfd_open(pid) for pid in values]
def dead(handles):
for handle in handles:
poll = select.poll(); poll.register(handle, select.POLLIN)
assert poll.poll(5000), 'delegated process did not terminate'
os.close(handle)
try:
value = command(['/usr/bin/python3', '-c', 'import os,sys;sys.stdout.buffer.write(sys.stdin.buffer.read());sys.stderr.write(str(os.getuid()))'],
stdin=b'actual transferred stdin\n')
assert (value.returncode, value.stdout, value.stderr) == (0, b'actual transferred stdin\n', str(RUNTIME).encode())
results.append('actual different UID and stdin/stdout/stderr descriptors')
script = 'import os,json,sys;print(json.dumps({"argv":sys.argv[1:],"home":os.environ["HOME"],"bus":os.environ["DBUS_SESSION_BUS_ADDRESS"],"cc":os.environ["CC_aarch64-linux-android"]}))'
value = command(['/usr/bin/python3', '-c', script, 'space ; $ literal ü'],
env={'PATH': '/bad/operator/path', 'HOME': '/operator/home', 'DBUS_SESSION_BUS_ADDRESS': 'operator-bus',
'CC_aarch64-linux-android': '/synthetic/ndk/compiler'})
assert value.returncode == 0, value.stderr
decoded = json.loads(value.stdout)
assert decoded == {'argv': ['space ; $ literal ü'], 'home': str(home),
'bus': 'unix:path=' + str(runtime_dir / 'bus'), 'cc': '/synthetic/ndk/compiler'}
results.append('literal argv and runtime home/bus isolation')
value = command(['/usr/bin/python3', '-c', 'import pathlib,sys\ntry:pathlib.Path(sys.argv[1]).read_bytes()\nexcept PermissionError:sys.exit(0)\nsys.exit(1)', str(secret)])
assert value.returncode == 0
assert secret.read_bytes() == b'synthetic-manager-identity'
results.append('runtime cannot read manager private identity')
assert command(['/bin/sh', '-c', 'exit 23']).returncode == 23
assert command(['/bin/sh', '-c', 'kill -TERM $$']).returncode == -signal.SIGTERM
assert command(['/usr/bin/python3', '-c',
'import os,signal;signal.pthread_sigmask(signal.SIG_UNBLOCK,[signal.SIGTERM]);os.kill(os.getpid(),signal.SIGTERM)'],
block_term=True).returncode == -signal.SIGTERM
results.append('exit code and termination signal propagation')
assert command(['/usr/bin/true'], uid=65531, gid=APP).returncode == 125
results.append('foreign UID refused')
owned = root / 'app-output'; owned.mkdir(mode=0o700); os.chown(owned, RUNTIME, RUNTIME)
started = owned / 'running'
child = subprocess.Popen([str(proxy), '--', '/usr/bin/python3', '-c',
'import os,pathlib,sys,time\nchild=os.fork()\n'
'if child==0:time.sleep(60);os._exit(0)\n'
'p=pathlib.Path(sys.argv[1]);t=p.with_suffix(".tmp");t.write_text(str(os.getpid())+" "+str(child));t.replace(p);time.sleep(60)', str(started)],
cwd=root, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
env={'PATH': '/usr/bin:/bin'}, preexec_fn=lambda: drop(APP))
delegated = record(started, 2)
child.kill(); child.communicate(timeout=5)
dead(delegated)
results.append('client disconnect cancels delegated process group')
# Normal leader exit must settle a child that still holds stdout.
for disconnect in (False, True):
ready = owned / ('leader-ready-' + str(disconnect))
gate = owned / ('leader-gate-' + str(disconnect))
program = ('import os,pathlib,sys,time\nchild=os.fork()\n'
'if child==0:time.sleep(60);os._exit(0)\n'
'p=pathlib.Path(sys.argv[1]);t=p.with_suffix(".tmp");t.write_text(str(os.getpid())+" "+str(child));t.replace(p)\n'
'while not pathlib.Path(sys.argv[2]).exists():time.sleep(.01)\n'
'os._exit(0)')
client = subprocess.Popen([str(proxy), '--', '/usr/bin/python3', '-c', program, str(ready), str(gate)],
cwd=root, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
env={'PATH': '/usr/bin:/bin'}, preexec_fn=lambda: drop(APP))
handles = record(ready, 2)
if disconnect:
os.kill(owner_pid, signal.SIGSTOP)
gate.write_text('exit')
poll = select.poll(); poll.register(handles[0], select.POLLIN)
assert poll.poll(5000), 'leader did not exit'
if disconnect:
client.kill(); client.wait(timeout=5)
os.kill(owner_pid, signal.SIGCONT)
client.communicate(timeout=5)
assert client.returncode == (-signal.SIGKILL if disconnect else 0)
dead(handles)
results.append('leader exit before normal completion or disconnect settles children')
# A full BLOCKING stderr must not trap the proxy in its fail path.
read_error, write_error = os.pipe()
flags = fcntl.fcntl(write_error, fcntl.F_GETFL)
fcntl.fcntl(write_error, fcntl.F_SETFL, flags | os.O_NONBLOCK)
try:
while True:
os.write(write_error, b'x' * 4096)
except BlockingIOError:
pass
fcntl.fcntl(write_error, fcntl.F_SETFL, flags)
ready = owned / 'stderr-ready'
client = subprocess.Popen([str(short_proxy), '--', '/usr/bin/python3', '-c',
'import os,pathlib,sys;p=pathlib.Path(sys.argv[1]);t=p.with_suffix(".tmp");t.write_text(str(os.getpid()));t.replace(p);os.write(2,b"x"*1000000)', str(ready)],
cwd=root, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=write_error,
env={'PATH': '/usr/bin:/bin'}, preexec_fn=lambda: drop(APP))
try:
handles = record(ready, 1)
assert client.wait(timeout=3) == 125
dead(handles)
finally:
if client.poll() is None:
client.kill(); client.wait(timeout=5)
os.close(read_error); os.close(write_error)
results.append('full blocking stderr cannot prevent timeout cancellation')
# Hold one child in actual pre-exec FIFO I/O. The owner's admission
# and cancellation loop must stay responsive for other connections.
fifo = owned / 'exec-fifo'; os.mkfifo(fifo, 0o600); os.chown(fifo, RUNTIME, RUNTIME)
ready = owned / 'exec-ready'
held = subprocess.Popen([str(proxy), '--', 'fixture-held-exec', str(fifo), str(ready)],
cwd=root, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
env={'PATH': '/usr/bin:/bin'}, preexec_fn=lambda: drop(APP))
handles = record(ready, 1)
assert command(['/usr/bin/true']).returncode == 0
incomplete = ('import socket,array,struct,time;'
's=socket.socket(socket.AF_UNIX,socket.SOCK_STREAM);s.settimeout(5);s.connect("/run/archipelago-managed/runtime.sock");'
's.sendmsg([struct.pack("!I",100)],[(socket.SOL_SOCKET,socket.SCM_RIGHTS,array.array("i",[0,1,2]))]);'
'assert s.recv(1)==b""')
assert subprocess.run(['/usr/bin/python3', '-c', incomplete], preexec_fn=lambda: drop(APP),
stdin=subprocess.DEVNULL, capture_output=True, timeout=6).returncode == 0
os.kill(owner_pid, signal.SIGTERM)
dead(handles)
held.communicate(timeout=5)
assert held.returncode == 125
results.append('pre-exec filesystem stall does not block admission expiry or shutdown')
for label in results:
print('PASS runtime delegation:', label, flush=True)
finally:
try:
os.kill(owner_pid, signal.SIGCONT)
os.kill(owner_pid, signal.SIGTERM)
except ProcessLookupError:
pass
handle = os.pidfd_open(owner_pid)
poll = select.poll(); poll.register(handle, select.POLLIN)
if not poll.poll(5000):
os.kill(owner_pid, signal.SIGKILL)
_, status = os.waitpid(owner_pid, 0); os.close(handle)
assert os.waitstatus_to_exitcode(status) == 0, 'runtime owner failed cleanup'
# Keep a real root parent >PID1 for the socket credentials checked by the proxy.
pid = os.fork()
if pid == 0:
try:
run_fixture()
except BaseException:
import traceback
traceback.print_exc()
os._exit(1)
os._exit(0)
_, status = os.waitpid(pid, 0)
raise SystemExit(os.waitstatus_to_exitcode(status))