266 lines
12 KiB
Python
266 lines
12 KiB
Python
#!/usr/bin/python3
|
|
"""Real finite TCP/UDP route qualification in an owned rootless PID1 fixture."""
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
assert os.geteuid() == 0 and socket.gethostname() == 'd09-qualification'
|
|
assert Path('/run/.containerenv').exists() and os.environ.get('C01_NETWORK_FIXTURE') == '1'
|
|
ROOT = Path('/opt/d09-fixture')
|
|
sys.path.insert(0, str(ROOT / 'scripts'))
|
|
import c01_network as namespaces
|
|
import c01_network_owner as network
|
|
|
|
|
|
def run(*args, check=True):
|
|
result = subprocess.run(args, capture_output=True, text=True, timeout=30)
|
|
if check and result.returncode:
|
|
raise AssertionError(f'{args[0]} failed ({result.returncode}): {result.stderr}')
|
|
return result
|
|
|
|
|
|
def wait(predicate, description):
|
|
deadline = time.monotonic() + 20
|
|
while time.monotonic() < deadline:
|
|
value = predicate()
|
|
if value:
|
|
return value
|
|
time.sleep(.05)
|
|
raise AssertionError(description)
|
|
|
|
|
|
def write(path, value, mode=0o644):
|
|
path = Path(path)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(value)
|
|
path.chmod(mode)
|
|
|
|
|
|
for directory, _, files in os.walk(ROOT):
|
|
for path in [Path(directory), *(Path(directory) / name for name in files)]:
|
|
path.chmod(path.stat().st_mode & 0o755)
|
|
for role, path in namespaces.NAMES.items():
|
|
run('ip', 'netns', 'add', path.name)
|
|
run('nsenter', '--net=' + str(path), '--', 'ip', 'link', 'set', 'lo', 'up')
|
|
|
|
modules = ('c01_network.py', 'c01_endpoints.py', 'c01_network_owner.py', 'c01_network_client.py')
|
|
destination = Path('/usr/lib/archipelago')
|
|
destination.mkdir(exist_ok=True)
|
|
for name in modules:
|
|
shutil.copyfile(ROOT / 'scripts' / name, destination / name)
|
|
(destination / name).chmod(0o644)
|
|
|
|
# Exercise the production root-worker transition in a disposable child. Neither
|
|
# the supervisor/application namespace nor this fixture's namespace is changed.
|
|
write(ROOT / 'host-worker-namespace.py', '''import ctypes,os,sys
|
|
sys.path.insert(0,'/usr/lib/archipelago')
|
|
import c01_network as namespaces
|
|
libc=ctypes.CDLL(None,use_errno=True)
|
|
assert libc.prctl(38,1,0,0,0)==0
|
|
namespaces.require_private(os.getpid(),'managed')
|
|
namespaces.enter_host_worker(libc)
|
|
assert namespaces.process_namespace(os.getpid())==namespaces.process_namespace(1)
|
|
try:
|
|
namespaces.enter_host_worker(libc)
|
|
except ValueError:
|
|
pass
|
|
else:
|
|
raise AssertionError('host worker accepted a non-managed source namespace')
|
|
print('PASS root host worker enters PID1 network with NNP')
|
|
''')
|
|
print(run('nsenter', '--net=' + str(namespaces.NAMES['managed']), '--',
|
|
'/usr/bin/python3', str(ROOT / 'host-worker-namespace.py')).stdout, flush=True)
|
|
shutil.copyfile(ROOT / 'image-recipe/configs/archipelago-c01-network.service',
|
|
'/etc/systemd/system/archipelago-c01-network.service')
|
|
|
|
producer = '''import json,os,socket,sys,pathlib
|
|
kind,port,record=sys.argv[1:];port=int(port)
|
|
s=socket.socket(socket.AF_INET,socket.SOCK_STREAM if kind=='tcp' else socket.SOCK_DGRAM)
|
|
s.bind(('127.0.0.1',port))
|
|
if kind=='tcp':s.listen(8)
|
|
pathlib.Path(record).write_text(json.dumps({'pid':os.getpid(),'fd':s.fileno()}))
|
|
while True:
|
|
if kind=='udp':
|
|
data,peer=s.recvfrom(4096);s.sendto(b'udp:'+data,peer)
|
|
else:
|
|
c,_=s.accept()
|
|
with c:
|
|
data=b''
|
|
while True:
|
|
part=c.recv(4096)
|
|
if not part:break
|
|
data+=part
|
|
if len(data)>65536:raise ValueError('fixture bound')
|
|
c.sendall(b'tcp:'+data)
|
|
'''
|
|
write(ROOT / 'producer.py', producer)
|
|
for name, kind, port in (('tor@default', 'tcp', 9050), ('archipelago-fips', 'udp', 5354)):
|
|
write('/etc/systemd/system/' + name + '.service',
|
|
'[Service]\nType=exec\nExecStart=/usr/bin/python3 /opt/d09-fixture/producer.py '
|
|
+ f'{kind} {port} /run/{name}.json\nRestart=no\n')
|
|
|
|
run('systemctl', 'daemon-reload')
|
|
run('systemctl', 'start', 'tor@default.service', 'archipelago-fips.service')
|
|
policy = {'version': 1, 'namespaces': {
|
|
'host': list(namespaces.process_namespace(1)),
|
|
'managed': list(namespaces.appointed('managed')),
|
|
'runtime': list(namespaces.appointed('runtime'))}, 'manifests': [], 'routes': []}
|
|
for name, protocol, port, unit in (('tor-socks', 'tcp', 9050, 'tor@default.service'),
|
|
('fips-dns', 'udp', 5354, 'archipelago-fips.service')):
|
|
policy['routes'].append({'name': name, 'source': 'managed', 'target': 'host',
|
|
'protocol': protocol, 'port': port, 'app_id': None, 'projection_sha256': None,
|
|
'target_cgroup': run('systemctl', 'show', unit, '-p', 'ControlGroup', '--value').stdout.strip(),
|
|
'target_uid': 0})
|
|
policy['routes'].append({'name':'management','source':'host','target':'managed','protocol':'tcp',
|
|
'port':5678,'app_id':None,'projection_sha256':None,
|
|
'target_cgroup':'/system.slice/archipelago.service/c01-application','target_uid':21001})
|
|
write(network.CONFIG, network.canonical(policy).decode(), 0o600)
|
|
policy_sha = network.digest(network.canonical(policy))
|
|
control = '''import socket,sys
|
|
s=socket.socket(socket.AF_UNIX,socket.SOCK_SEQPACKET);s.settimeout(3)
|
|
s.connect('/run/archipelago-c01-network/control.sock');s.sendall(sys.argv[1].encode())
|
|
data=s.recv(65536)
|
|
if not data:raise SystemExit(3)
|
|
sys.stdout.buffer.write(data)
|
|
'''
|
|
write(ROOT / 'control.py', control)
|
|
run('systemctl', 'daemon-reload')
|
|
run('systemctl', 'start', 'archipelago-c01-network.service')
|
|
wait(lambda: network.CONTROL.exists(), 'network owner did not bind')
|
|
|
|
|
|
def call(body, trusted=True, check=True):
|
|
command = ['python3', str(ROOT / 'control.py'), json.dumps({'policy_sha256': policy_sha, **body})]
|
|
if trusted:
|
|
command = ['systemd-run', '--quiet', '--wait', '--pipe', '--collect',
|
|
'--unit=archipelago-c01-network-provision', *command]
|
|
result = run(*command, check=check)
|
|
return json.loads(result.stdout) if result.returncode == 0 else result
|
|
|
|
|
|
def register(name, unit, previous=None, override=None, check=True):
|
|
record = json.loads(wait(lambda: Path('/run/' + unit + '.json').read_text()
|
|
if Path('/run/' + unit + '.json').exists() else None, 'producer missing'))
|
|
return call({'method': 'register', 'name': name, 'pid': record['pid'], 'fd': record['fd'],
|
|
'previous': previous, **(override or {})}, check=check)
|
|
|
|
|
|
first = register('tor-socks', 'tor@default')
|
|
register('fips-dns', 'archipelago-fips')
|
|
client = '''import socket,sys
|
|
kind,port=sys.argv[1:];s=socket.socket(socket.AF_INET,socket.SOCK_STREAM if kind=='tcp' else socket.SOCK_DGRAM)
|
|
s.settimeout(3);s.connect(('127.0.0.1',int(port)));s.sendall(b'qualified')
|
|
if kind=='tcp':s.shutdown(socket.SHUT_WR)
|
|
assert s.recv(4096)==kind.encode()+b':qualified'
|
|
'''
|
|
for kind, port in (('tcp', '9050'), ('udp', '5354')):
|
|
run('nsenter', '--net=/run/netns/archipelago-managed', '--', 'python3', '-c', client, kind, port)
|
|
print('PASS actual named TCP and UDP routes across distinct network namespaces', flush=True)
|
|
|
|
assert call({'method': 'inspect', 'name': 'tor-socks'}, trusted=False, check=False).returncode != 0
|
|
assert register('tor-socks', 'tor@default', first['generation'], override={'pid': os.getpid()}, check=False).returncode != 0
|
|
assert call({'method': 'register', 'name': 'arbitrary-host', 'pid': os.getpid(), 'fd': 0, 'previous': None}, check=False).returncode != 0
|
|
print('PASS wrong caller cgroup, wrong producer and arbitrary route are refused', flush=True)
|
|
|
|
run('systemctl', 'stop', 'tor@default.service')
|
|
wait(lambda: call({'method': 'inspect', 'name': 'tor-socks'})['producer'] is None, 'dead producer stayed admitted')
|
|
Path('/run/tor@default.json').unlink()
|
|
run('systemctl', 'start', 'tor@default.service')
|
|
assert register('tor-socks', 'tor@default', check=False).returncode != 0
|
|
second = register('tor-socks', 'tor@default', first['generation'])
|
|
assert second['generation'] != first['generation']
|
|
run('nsenter', '--net=/run/netns/archipelago-managed', '--', 'python3', '-c', client, 'tcp', '9050')
|
|
print('PASS producer death retires route; replacement requires exact prior generation', flush=True)
|
|
|
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as host_ipc:
|
|
host_ipc.bind('\0d09-host-abstract-qualification');host_ipc.listen(1)
|
|
negative = "import socket;s=socket.socket(socket.AF_UNIX);s.connect('\\0d09-host-abstract-qualification')"
|
|
assert run('nsenter', '--net=/run/netns/archipelago-managed', '--', 'python3', '-c', negative, check=False).returncode != 0
|
|
print('PASS host abstract Unix IPC remains unreachable from managed namespace', flush=True)
|
|
|
|
lease = '''import sys,time,pathlib,os,socket,signal,json
|
|
sys.path.insert(0,'/usr/lib/archipelago')
|
|
from c01_network_client import NetworkLease
|
|
from c01_network_owner import LIBC
|
|
p=NetworkLease(21001)
|
|
root=pathlib.Path('/sys/fs/cgroup/system.slice/archipelago.service')
|
|
group=root/'c01-application';group.mkdir()
|
|
read,write=os.pipe()
|
|
pid=os.fork()
|
|
if pid==0:
|
|
os.close(read)
|
|
(group/'cgroup.procs').write_text(str(os.getpid()))
|
|
ns=os.open('/run/netns/archipelago-managed',os.O_RDONLY)
|
|
assert LIBC.setns(ns,0x40000000)==0
|
|
os.close(ns)
|
|
# Do not inherit the supervisor's lifetime capabilities into a role.
|
|
p.close()
|
|
os.setgroups([]);os.setgid(21001);os.setuid(21001)
|
|
s=socket.socket();s.bind(('127.0.0.1',5678));s.listen(8)
|
|
os.write(write,str(s.fileno()).encode());os.close(write)
|
|
while True:
|
|
c,_=s.accept()
|
|
with c:
|
|
data=b''
|
|
while True:
|
|
part=c.recv(4096)
|
|
if not part:break
|
|
data+=part
|
|
c.sendall(b'tcp:'+data)
|
|
os.close(write)
|
|
number=int(os.read(read,32));os.close(read)
|
|
try:
|
|
p.register_management(pid,number)
|
|
pathlib.Path('/run/network-lease-ready').write_text('ready')
|
|
while True:
|
|
try:p.check()
|
|
except (OSError,ValueError):
|
|
pathlib.Path('/run/network-owner-lost').write_text('lost')
|
|
# Simulate retirement delay: pinned ingress must remain unavailable to theft.
|
|
while not pathlib.Path('/run/network-release').exists():time.sleep(.05)
|
|
break
|
|
time.sleep(.05)
|
|
finally:
|
|
os.kill(pid,signal.SIGKILL);os.waitpid(pid,0)
|
|
group.rmdir()
|
|
p.close()
|
|
'''
|
|
write(ROOT / 'lease.py', lease)
|
|
write('/etc/systemd/system/archipelago.service', '[Service]\nType=exec\nDelegate=yes\nDelegateSubgroup=supervisor\nExecStart=/usr/bin/python3 /opt/d09-fixture/lease.py\nRestart=no\n')
|
|
run('systemctl', 'daemon-reload');run('systemctl', 'start', 'archipelago.service')
|
|
wait(lambda: Path('/run/network-lease-ready').exists(), 'supervisor lease not established')
|
|
run('python3','-c',client,'tcp','5678')
|
|
print('PASS management listener registered by exact root MainPID for its non-root child',flush=True)
|
|
wrong_main = '''import os,socket,json
|
|
pid=os.fork()
|
|
if pid==0:
|
|
s=socket.socket(socket.AF_UNIX,socket.SOCK_SEQPACKET);s.connect('/run/archipelago-c01-network/control.sock')
|
|
s.settimeout(5)
|
|
try:
|
|
s.sendall(PAYLOAD);assert not s.recv(4096)
|
|
except (ConnectionResetError,BrokenPipeError):
|
|
pass
|
|
os._exit(0)
|
|
_,status=os.waitpid(pid,0);assert status==0
|
|
'''.replace('PAYLOAD',repr(network.canonical({'method':'inspect','name':'management','policy_sha256':policy_sha})))
|
|
run('systemd-run','--quiet','--wait','--pipe','--collect','--unit=archipelago-c01-network-provision',
|
|
'python3','-c',wrong_main)
|
|
print('PASS root descendant in trusted service cgroup cannot impersonate MainPID',flush=True)
|
|
run('systemctl', 'stop', 'archipelago-c01-network.service')
|
|
wait(lambda:Path('/run/network-owner-lost').exists(),'network death was not observed')
|
|
with socket.socket() as stolen:
|
|
try:stolen.bind(('127.0.0.1',5678))
|
|
except OSError:pass
|
|
else:raise AssertionError('ingress port released before role retirement')
|
|
write('/run/network-release','release')
|
|
wait(lambda: run('systemctl', 'show', 'archipelago.service', '-p', 'ActiveState', '--value').stdout.strip()
|
|
in ('failed', 'inactive'), 'dead network owner did not invalidate supervisor lease')
|
|
print('PASS actual network-owner death invalidates retained supervisor lease', flush=True)
|
|
print('PASS retained ingress capability reserves port until child reaped',flush=True)
|