316 lines
18 KiB
Python
316 lines
18 KiB
Python
#!/usr/bin/python3
|
|
"""Real namespace/systemd prerequisite lanes, never a migration/ready receipt.
|
|
|
|
No access transition is fabricated. Native admission still refuses unsupported
|
|
host operations. This fixture tests the implementable network/lifetime boundary
|
|
and a real PAM-less rootless user manager, Podman and persistent tmux separately.
|
|
"""
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import pwd
|
|
import shutil
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
import time
|
|
import types
|
|
|
|
assert os.geteuid() == 0 and socket.gethostname() == 'c01-prerequisite-qualification'
|
|
assert Path('/run/.containerenv').exists() and os.environ.get('C01_DISPOSABLE_SYSTEMD') == '1'
|
|
PACKAGE = Path('/opt/c01-fixture')
|
|
for directory, _, files in os.walk(PACKAGE):
|
|
for p in [Path(directory), *(Path(directory) / f for f in files)]:
|
|
mode=p.stat().st_mode & 0o7777
|
|
if mode & 0o755 != mode:p.chmod(mode & 0o755)
|
|
sys.path.insert(0, str(PACKAGE / 'scripts'))
|
|
import c01_network as network
|
|
import c01_access_kernel as kernel
|
|
import c01_supervisor as supervisor
|
|
|
|
|
|
def run(*args, check=True):
|
|
result = subprocess.run(args, capture_output=True, text=True, timeout=60)
|
|
if check and result.returncode:
|
|
raise AssertionError((args, result.returncode, result.stdout, result.stderr))
|
|
return result
|
|
|
|
|
|
def write(path, value, mode=0o644):
|
|
path = Path(path)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_bytes(value.encode() if isinstance(value, str) else value)
|
|
path.chmod(mode)
|
|
|
|
|
|
def wait(predicate, label):
|
|
for _ in range(600):
|
|
result = predicate()
|
|
if result:
|
|
return result
|
|
time.sleep(.1)
|
|
raise AssertionError('timeout: ' + label)
|
|
|
|
|
|
def refused(body):
|
|
try:
|
|
body()
|
|
except (ValueError, OSError):
|
|
return
|
|
raise AssertionError('unexpected boundary acceptance')
|
|
|
|
|
|
def own(path):
|
|
Path(path).mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
os.chown(path, 1000, 1000)
|
|
|
|
|
|
ROOT = Path('/var/lib/c01-fixture')
|
|
assert not ROOT.exists(), 'fresh disposable overlay required'
|
|
(ROOT / 'receipts').mkdir(parents=True, mode=0o755)
|
|
run('tar', '-xf', str(PACKAGE/'tmux-runtime.tar'), '-C', str(PACKAGE))
|
|
write('/usr/bin/tmux', '#!/bin/sh\nexec /opt/c01-fixture/tmux-runtime/ld-linux-x86-64.so.2 --library-path /opt/c01-fixture/tmux-runtime /opt/c01-fixture/tmux-runtime/tmux "$@"\n', 0o755)
|
|
write('/etc/archipelago-c01/runtime-tmux.conf', (PACKAGE/'image-recipe/configs/archipelago-runtime-tmux.conf').read_bytes())
|
|
run('systemctl', 'stop', 'archipelago.service', check=False)
|
|
run('systemctl', 'stop', 'user@1000.service', check=False)
|
|
for role in ('managed', 'runtime'):
|
|
run('ip', 'netns', 'add', 'archipelago-' + role)
|
|
run('nsenter', '--net=/run/netns/archipelago-' + role, '--', 'ip', 'link', 'set', 'lo', 'up')
|
|
refused(lambda: network.require_private(os.getpid(), 'managed'))
|
|
print('namespace metadata:', [(role, network.NAMES[role].stat()) for role in network.NAMES], flush=True)
|
|
assert network.appointed('managed') != network.appointed('runtime')
|
|
print('PASS real nsfs binding: host and wrong/private namespaces refuse', flush=True)
|
|
|
|
# Appointed supervisor cgroup/netns, real systemd lifetime and abstract IPC.
|
|
abstract = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
abstract.bind('\0c01-prerequisite-host-only')
|
|
abstract.listen(2)
|
|
write(PACKAGE / 'managed-probe.py', '''import json,os,pathlib,socket,sys,time
|
|
sys.path.insert(0,'/opt/c01-fixture/scripts')
|
|
import c01_network,c01_access_kernel
|
|
actual=c01_access_kernel.process(os.getpid())
|
|
policy=type('Policy',(),{'value':{'supervisor_cgroup':'/sys/fs/cgroup/system.slice/c01-fixture-managed.service'}})()
|
|
c01_access_kernel.verify_supervisor(actual,policy)
|
|
s=socket.socket(socket.AF_UNIX,socket.SOCK_STREAM)
|
|
try:s.connect('\\0c01-prerequisite-host-only')
|
|
except OSError:pass
|
|
else:raise AssertionError('host abstract IPC exposed')
|
|
pathlib.Path('/var/lib/c01-fixture/receipts/managed.json').write_text(json.dumps(actual))
|
|
while True:time.sleep(1)
|
|
''')
|
|
run('systemd-run', '--unit=c01-fixture-managed', '--property=Type=exec', '--property=Delegate=yes',
|
|
'--property=DelegateSubgroup=supervisor', '--property=NoNewPrivileges=yes',
|
|
'--property=NetworkNamespacePath=/run/netns/archipelago-managed',
|
|
'/usr/bin/python3', '-B', str(PACKAGE / 'managed-probe.py'))
|
|
wait(lambda: (ROOT / 'receipts/managed.json').exists(), 'managed namespace proof')
|
|
managed = json.loads((ROOT / 'receipts/managed.json').read_text())
|
|
print('PASS actual systemd supervisor cgroup/NNP binding and host abstract-IPC denial', flush=True)
|
|
|
|
# A genuine private TCP route between the two disposable namespaces. This is
|
|
# not an invented production ingress/localhost-forwarding policy.
|
|
run('ip', 'link', 'add', 'c01-m', 'type', 'veth', 'peer', 'name', 'c01-r')
|
|
run('ip', 'link', 'set', 'c01-m', 'netns', 'archipelago-managed')
|
|
run('ip', 'link', 'set', 'c01-r', 'netns', 'archipelago-runtime')
|
|
for name, interface, address in [('managed', 'c01-m', '198.18.0.1/30'), ('runtime', 'c01-r', '198.18.0.2/30')]:
|
|
run('nsenter', '--net=/run/netns/archipelago-' + name, '--', 'ip', 'address', 'add', address, 'dev', interface)
|
|
run('nsenter', '--net=/run/netns/archipelago-' + name, '--', 'ip', 'link', 'set', interface, 'up')
|
|
server = subprocess.Popen(['nsenter', '--net=/run/netns/archipelago-runtime', '--', 'python3', '-c',
|
|
"import socket,pathlib;s=socket.socket();s.bind(('198.18.0.2',5678));s.listen();pathlib.Path('/var/lib/c01-fixture/receipts/route-ready').touch();c,_=s.accept();c.sendall(b'private-route');c.close()"], stdin=subprocess.DEVNULL)
|
|
wait(lambda: (ROOT/'receipts/route-ready').exists(), 'private TCP server')
|
|
run('nsenter', '--net=/run/netns/archipelago-managed', '--', 'python3', '-c',
|
|
"import socket;s=socket.create_connection(('198.18.0.2',5678),timeout=3);assert s.recv(32)==b'private-route'")
|
|
assert server.wait(timeout=5) == 0
|
|
print('PASS isolated private route; no host networking or host abstract IPC granted', flush=True)
|
|
|
|
# Appoint the production egress owner before any user-manager/Podman invocation.
|
|
# This uses the outer disposable realm's already bounded usermode WAN, not the
|
|
# physical host network namespace or a synthetic default route.
|
|
import runpy
|
|
for module in ('c01_network','c01_endpoints','c01_uplink','runtime_uplink','c01_network_owner'):
|
|
write(Path('/usr/lib/archipelago')/(module+'.py'),(PACKAGE/'scripts'/(module+'.py')).read_bytes(),0o644)
|
|
compose=runpy.run_path(str(PACKAGE/'tests/regression/c01-compose.py'))
|
|
compose['network_owner']()
|
|
print('PASS production-owned runtime uplink appointed before user manager',flush=True)
|
|
network_pid=int(run('systemctl','show','archipelago-c01-network.service','-p','MainPID','--value').stdout)
|
|
owner_namespace=network.process_namespace(1)
|
|
for _ in range(100):
|
|
assert network.process_namespace(network_pid)==owner_namespace,'owner changed namespace during a probe'
|
|
time.sleep(.01)
|
|
print('PASS retained network owner namespace is stable during owned probes',flush=True)
|
|
import hashlib
|
|
policy_digest=hashlib.sha256(Path('/etc/archipelago-c01/endpoints.json').read_bytes()).hexdigest()
|
|
for request in ({'method':'lease','policy_sha256':policy_digest,'nonce':'a'*64},
|
|
{'method':'register','policy_sha256':policy_digest,'name':'management',
|
|
'pid':os.getpid(),'fd':1,'previous':None}):
|
|
with socket.socket(socket.AF_UNIX,socket.SOCK_SEQPACKET) as caller:
|
|
caller.settimeout(3);caller.connect('/run/archipelago-c01-network/control.sock')
|
|
caller.send(json.dumps(request).encode())
|
|
try: assert caller.recv(65536)==b'', 'unappointed root caller/producer admitted'
|
|
except ConnectionResetError:pass
|
|
print('PASS real owner refuses unappointed root caller and producer',flush=True)
|
|
|
|
# Build a tiny local rootfs from container-owned distro objects, no image pulls.
|
|
binary = Path('/usr/bin/true')
|
|
files = {binary, Path('/bin/true')}
|
|
for word in run('ldd', str(binary)).stdout.split():
|
|
if word.startswith('/'):
|
|
files.add(Path(word))
|
|
with tarfile.open(PACKAGE / 'probe-rootfs.tar', 'w') as archive:
|
|
for path in sorted(files):
|
|
source = path.resolve()
|
|
info = tarfile.TarInfo(str(path).lstrip('/'))
|
|
info.mode, info.uid, info.gid, info.size = 0o755, 0, 0, source.stat().st_size
|
|
with source.open('rb') as data:
|
|
archive.addfile(info, data)
|
|
(PACKAGE / 'probe-rootfs.tar').chmod(0o644)
|
|
|
|
account = pwd.getpwnam('archipelago')
|
|
assert account.pw_uid == account.pw_gid == 1000
|
|
# This disposable account inventory is local. Do not let inherited image NSS
|
|
# discovery block account retirement or introduce unenumerated remote principals.
|
|
write('/etc/nsswitch.conf', 'passwd: files\ngroup: files\nshadow: files\nsudoers: files\nhosts: files dns\nnetworks: files\n')
|
|
run('usermod', '-s', '/usr/sbin/nologin', '-G', '', 'archipelago')
|
|
run('usermod', '-L', 'archipelago')
|
|
history_migration=os.environ.get('C01_HISTORY_MIGRATION')=='1'
|
|
subordinate='archipelago:100000:65536\n' if history_migration else 'archipelago:2000:10000\n'
|
|
write('/etc/subuid', subordinate)
|
|
write('/etc/subgid', subordinate)
|
|
node_fixture=PACKAGE/'node-cutover-fixture.json'
|
|
if node_fixture.exists():
|
|
node=json.loads(node_fixture.read_text())
|
|
for filename in ('subuid','subgid'):
|
|
assert node['subordinate_maps'][filename][0]=='archipelago:100000:65536'
|
|
write('/etc/'+filename,'\n'.join(node['subordinate_maps'][filename])+'\n')
|
|
write('/etc/sudoers', 'root ALL=(ALL:ALL) ALL\n', 0o440)
|
|
run('visudo', '-c')
|
|
assert run('runuser', '-u', 'archipelago', '--', 'sudo', '-n', 'true', check=False).returncode != 0
|
|
# Deliberately writable DAC preimages: namespace denial must do real work.
|
|
for path in ('/opt/archipelago/c01-writable-fixture', '/usr/local/bin/c01-writable-fixture'):
|
|
write(path, 'retained old-principal-owned fixture\n', 0o600)
|
|
os.chown(path, 1000, 1000)
|
|
home = ROOT / 'home'
|
|
for path in (home, home / 'Work', home / '.config', home / '.config/containers',
|
|
home / '.config/systemd', home / '.config/systemd/user', ROOT / 'data', ROOT / 'graphroot',
|
|
home / '.local', home / '.local/state', home / '.local/state/archipelago',
|
|
home / '.local/state/archipelago/sessions'):
|
|
own(path)
|
|
for name in ('manager', 'snapshot', 'authority'):
|
|
(ROOT / ('private-' + name)).mkdir(mode=0o700)
|
|
if history_migration:(ROOT/'data').chmod(0o755)
|
|
write(home / 'Work/operator.txt', 'synthetic retained work\n', 0o600)
|
|
os.chown(home / 'Work/operator.txt', 1000, 1000)
|
|
preserved = (home / 'Work/operator.txt').stat()
|
|
graphroot=ROOT/'data/containers/storage' if history_migration else ROOT/'graphroot'
|
|
if history_migration:
|
|
for path in (ROOT/'data/containers',graphroot,home/'.local/share',home/'.local/share/containers'):own(path)
|
|
(home/'.local/share/containers/storage').symlink_to(graphroot)
|
|
os.lchown(home/'.local/share/containers/storage',1000,1000)
|
|
driver='overlay' if history_migration else 'vfs'
|
|
write(home / '.config/containers/storage.conf',
|
|
'[storage]\ndriver="'+driver+'"\ngraphroot="'+str(graphroot)+'"\nrunroot="/run/user/1000/containers"\n')
|
|
write(home / '.config/containers/containers.conf', '[containers]\ndefault_sysctls=[]\nkeyring=false\n[engine]\ncgroup_manager="cgroupfs"\nevents_logger="file"\n')
|
|
write(PACKAGE / 'runtime-probe.py', '''import json,os,pathlib,subprocess,time
|
|
def run(*args):
|
|
r=subprocess.run(args,capture_output=True,text=True,timeout=45)
|
|
if r.returncode:raise AssertionError((args,r.returncode,r.stdout,r.stderr))
|
|
return r.stdout
|
|
run('podman','import','/opt/c01-fixture/probe-rootfs.tar','localhost/c01-prerequisite:fixture')
|
|
run('podman','run','--rm','--network=none','--security-opt=seccomp=unconfined','localhost/c01-prerequisite:fixture','/bin/true')
|
|
run('tmux','new-session','-d','-s','retained','-c','/home/archipelago/Work','sleep infinity')
|
|
pathlib.Path('/home/archipelago/Work/runtime-ready.json').write_text(json.dumps({'pid':os.getpid(),'podman':True,'tmux':True}))
|
|
while True:time.sleep(1)
|
|
''')
|
|
write(home / '.config/systemd/user/c01-fixture-runtime.service', '''[Unit]
|
|
Description=Disposable C01 runtime qualification
|
|
Requires=archipelago-runtime-tmux.service archipelago-runtime-podman.service
|
|
After=archipelago-runtime-tmux.service archipelago-runtime-podman.service
|
|
[Service]
|
|
Type=exec
|
|
ExecStart=/usr/bin/python3 -B /opt/c01-fixture/runtime-probe.py
|
|
Delegate=yes
|
|
NoNewPrivileges=no
|
|
[Install]
|
|
WantedBy=default.target
|
|
''')
|
|
write(home / '.config/systemd/user/archipelago-runtime-tmux.service',
|
|
(PACKAGE/'image-recipe/configs/archipelago-runtime-tmux.service').read_bytes())
|
|
write(home / '.config/systemd/user/archipelago-runtime-podman.service',
|
|
(PACKAGE/'image-recipe/configs/archipelago-runtime-podman.service').read_bytes())
|
|
wants = home / '.config/systemd/user/default.target.wants'
|
|
own(wants)
|
|
(wants / 'c01-fixture-runtime.service').symlink_to('../c01-fixture-runtime.service')
|
|
for file, mapping in [('80-archipelago-runtime-user.conf.in', {
|
|
'@ORIGINAL_RUNTIME_UID@':'1000', '@ORIGINAL_HOME@':'/home/archipelago',
|
|
'@SANITIZED_RUNTIME_HOME@':str(home), '@MANAGER_BACKING_PARENT@':str(ROOT/'private-manager'),
|
|
'@SNAPSHOT_BACKING_PARENT@':str(ROOT/'private-snapshot'), '@AUTHORITY_BACKING_PARENT@':str(ROOT/'private-authority')}),
|
|
('81-c01-runtime-access.conf.in', {'@MANAGER_PARENT@':str(ROOT/'private-manager'),
|
|
'@SNAPSHOT_PARENT@':str(ROOT/'private-snapshot'), '@AUTHORITY_PARENT@':str(ROOT/'private-authority')})]:
|
|
text = (PACKAGE / 'image-recipe/configs' / file).read_text()
|
|
for key, value in mapping.items():text=text.replace(key,value)
|
|
write(Path('/etc/systemd/system/user@1000.service.d') / file.replace('.in',''), text)
|
|
Path('/home/archipelago-admin').mkdir(exist_ok=True, mode=0o700)
|
|
run('systemctl', 'daemon-reload')
|
|
start = run('systemctl', 'start', 'user@1000.service', check=False)
|
|
if start.returncode:
|
|
print(run('systemctl', 'status', 'user@1000.service', '--no-pager', check=False).stdout, flush=True)
|
|
print(run('systemctl', 'show', 'user@1000.service', '-p', 'ExecMainStatus', '-p', 'Result', '-p', 'StatusErrno').stdout, flush=True)
|
|
raise AssertionError(start.stderr)
|
|
wait(lambda: (home/'Work/runtime-ready.json').exists(), 'real rootless Podman/tmux user service')
|
|
runtime = json.loads((home/'Work/runtime-ready.json').read_text())
|
|
with kernel.PinnedTask(runtime['pid']) as task:
|
|
evidence = kernel.view_evidence(task, types.SimpleNamespace(runtime={
|
|
'home':'/home/archipelago','home_backing':str(home),
|
|
'private_parents':[str(ROOT/('private-'+r)) for r in ('manager','snapshot','authority')]}))
|
|
write(ROOT/'receipts/runtime.json', json.dumps(evidence))
|
|
for path in ('/opt/archipelago/c01-writable-fixture', '/usr/local/bin/c01-writable-fixture'):
|
|
before = Path(path).stat()
|
|
result = run('nsenter','-t',str(runtime['pid']),'-m','-n','--','setpriv','--reuid=1000','--regid=1000','--clear-groups','--',
|
|
'python3','-c',"import sys;open(sys.argv[1],'wb').write(b'forbidden')",path,check=False)
|
|
assert result.returncode != 0
|
|
assert Path(path).read_text() == 'retained old-principal-owned fixture\n'
|
|
assert Path(path).stat().st_ino == before.st_ino and Path(path).stat().st_uid == 1000
|
|
print('PASS old runtime UID denied package writes despite owning exact original inodes; no data chown', flush=True)
|
|
run('systemctl', 'stop', 'c01-fixture-managed.service')
|
|
assert Path('/proc/'+str(runtime['pid'])).exists()
|
|
run('nsenter','-t',str(runtime['pid']),'-m','-n','--','runuser','-u','archipelago','--',
|
|
'tmux','has-session','-t','retained')
|
|
after = (home/'Work/operator.txt').stat()
|
|
assert (preserved.st_ino,preserved.st_uid,preserved.st_gid,preserved.st_mode) == (after.st_ino,after.st_uid,after.st_gid,after.st_mode)
|
|
assert (home/'Work/operator.txt').read_text() == 'synthetic retained work\n'
|
|
print('PASS real PAM-less user manager/rootless Podman/persistent tmux and original synthetic Work inode survive management stop', flush=True)
|
|
import select
|
|
import signal
|
|
owner_pid=int(run('systemctl','show','archipelago-c01-network.service','-p','MainPID','--value').stdout)
|
|
children=[int(p) for p in Path(f'/proc/{owner_pid}/task/{owner_pid}/children').read_text().split()]
|
|
appointed_exe=Path(json.loads(Path('/etc/archipelago-c01/endpoints.json').read_text())['uplink']['executable']).stat()
|
|
egress=[]
|
|
for child in children:
|
|
try:actual=Path(f'/proc/{child}/exe').stat()
|
|
except FileNotFoundError:continue
|
|
if (actual.st_dev,actual.st_ino)==(appointed_exe.st_dev,appointed_exe.st_ino):egress.append(child)
|
|
assert len(egress)==1, 'exact owned pasta child required'
|
|
pin=os.pidfd_open(egress[0])
|
|
try:
|
|
signal.pidfd_send_signal(pin,signal.SIGTERM)
|
|
wait(lambda: bool(select.select([pin],[],[],0)[0]), 'egress child exit')
|
|
wait(lambda: run('systemctl','is-active','archipelago-c01-network.service',check=False).stdout.strip()=='failed',
|
|
'network owner fail closed on egress loss')
|
|
wait(lambda: run('systemctl','show','user@1000.service','-p','MainPID','--value').stdout.strip()=='0',
|
|
'runtime hierarchy retired on network owner loss')
|
|
wait(lambda: not Path('/run/user/1000').exists(),'PID1 retired ephemeral runtime metadata')
|
|
finally:os.close(pin)
|
|
assert not Path('/proc/'+str(runtime['pid'])).exists()
|
|
assert (home/'Work/operator.txt').read_text()=='synthetic retained work\n'
|
|
print('PASS real pasta loss reaped child, failed network owner and retired runtime hierarchy',flush=True)
|
|
# Explicit fixture-owner recovery, not an automatic daemon restart or new grant.
|
|
run('systemctl','reset-failed','archipelago-c01-network.service')
|
|
run('systemctl','start','archipelago-c01-network.service')
|
|
wait(lambda: Path('/run/archipelago-c01-network/control.sock').exists(),'new owned network lifetime')
|
|
(home/'Work/runtime-ready.json').unlink()
|
|
run('systemctl','start','user@1000.service')
|
|
wait(lambda: (home/'Work/runtime-ready.json').exists(),'real Podman runtime bootstrap after explicit recovery')
|
|
refused(lambda: supervisor.admit({'migration_complete':True}, None, None))
|
|
print('PASS missing live admission/candidate contracts refuse; NOT application readiness', flush=True)
|