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