Files

338 lines
21 KiB
Python

#!/usr/bin/python3
"""Disposable PID1 kiosk/audio exercise. Hardware seams are explicit.
ALSA device IO is replaced by regular files with REAL Linux ACLs. Xorg uses its
dummy video driver. PipeWire/Pulse/WirePlumber and Chromium are real processes.
No host device, profile, service bus, wallet or runtime graphroot is mounted.
"""
import json
import hashlib
import os
from pathlib import Path
import shutil
import socket
import stat
import subprocess
import sys
import time
ROOT = Path('/opt/kiosk-fixture')
sys.path.insert(0, str(ROOT/'scripts'))
def command(*args, check=True):
result = subprocess.run(args,capture_output=True,text=True,timeout=90)
if check and result.returncode:
raise RuntimeError((args,result.returncode,result.stdout,result.stderr))
return result.stdout
def wait(predicate, message, seconds=40):
end=time.monotonic()+seconds
while time.monotonic()<end:
if predicate():return
time.sleep(.25)
raise AssertionError(message)
def fixture_guard():
assert os.geteuid()==0 and Path('/run/.containerenv').exists()
assert socket.gethostname()=='c01-kiosk-fixture'
assert Path('/proc/1/comm').read_text().strip()=='systemd'
def audio_daemon():
import c01_audio_devices as audio
class FakeALSA(audio.Device):
def __init__(self,item):
self.item=item;self.path=Path('/run/fixture-sound')/item['name']
self.fd=os.open(self.path,os.O_PATH|os.O_NOFOLLOW|os.O_CLOEXEC)
self.handle=Path('/proc/self/fd')/str(self.fd)
self.original_identity=audio.identity(os.fstat(self.fd))
self.check_kernel()
def check_kernel(self):
meta=self.path.lstat()
audio.require(stat.S_ISREG(meta.st_mode) and audio.identity(meta)==self.original_identity and
meta.st_uid==0 and meta.st_gid==29,'fixture ALSA incarnation replaced')
audio.Device=FakeALSA
audio.device_path=lambda item:Path('/run/fixture-sound')/item['name']
sys.argv=['c01_audio_devices.py',sys.argv[2]]
audio.main()
def put(path,text,mode=0o644):
path=Path(path);path.parent.mkdir(parents=True,exist_ok=True)
path.write_text(text);path.chmod(mode)
def user(*args,check=True):
return command('runuser','-u','archipelago','--','env','XDG_RUNTIME_DIR=/run/user/1000',
'DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus',*args,check=check)
def main():
fixture_guard()
if len(sys.argv)>1 and sys.argv[1]=='audio-owner':
audio_daemon();return
import c01_audio_devices as audio
command('systemctl','stop','user@1000.service',check=False)
command('usermod','-G','','-s','/usr/sbin/nologin','archipelago')
# Original operator-home test secrets must be hidden by the runtime view.
put('/home/archipelago/.ssh/fixture-secret','DO-NOT-EXPOSE\n',0o600)
for name in ('pulse','wireplumber'):
path=Path('/home/archipelago/.config/pulse' if name=='pulse' else '/home/archipelago/.local/state/wireplumber')
path.mkdir(parents=True,exist_ok=True);os.chown(path,1000,1000)
cookie=Path('/home/archipelago/.config/pulse/cookie')
cookie.write_bytes(b'F'*256);cookie.chmod(0o600);os.chown(cookie,1000,1000)
cookie_identity=(cookie.stat().st_dev,cookie.stat().st_ino,hashlib.sha256(cookie.read_bytes()).hexdigest())
command('install','-d','-o','root','-g','root','-m','0700','/var/lib/fixture-runtime')
command('install','-d','-o','archipelago','-g','archipelago','-m','0700','/var/lib/fixture-runtime/home')
for name in ('.config','.cache','.local','.local/state','.config/pulse','.local/state/wireplumber'):
command('install','-d','-o','archipelago','-g','archipelago','-m','0700','/var/lib/fixture-runtime/home/'+name)
for name in ('manager','snapshot','authority'):
command('install','-d','-m','0700','/var/lib/fixture-'+name)
command('install','-d','-m','0700',str(audio.STATE))
command('install','-d','-m','0755','/run/fixture-sound','/tmp/.X11-unix','/dev/snd')
os.chmod('/tmp/.X11-unix',0o1777)
device=Path('/run/fixture-sound/controlC0');device.touch();os.chown(device,0,29);device.chmod(0o660)
policy=dict(version=1,uid=1000,prior_groups=[20,27,29,1000],devices=[dict(name='controlC0',
sysfs='/sys/devices/fixture/sound/card0/controlC0',rdev=os.makedev(116,0),uid=0,gid=29,mode=0o660,acl_sha256=None)])
put(audio.CONFIG,json.dumps(policy),0o600)
configs=ROOT/'image-recipe/configs'
for src,dest in (('archipelago-kiosk-launcher.sh','/usr/local/bin/archipelago-kiosk-launcher'),
('archipelago-audio-router.sh','/usr/local/bin/archipelago-audio-router')):
put(dest,(configs/src).read_text(),0o755)
put('/usr/lib/archipelago/c01_audio_devices.py',(ROOT/'scripts/c01_audio_devices.py').read_text(),0o644)
put('/etc/systemd/user/fixture-playback.service','''[Service]
ExecStart=/usr/bin/paplay --device=fixture_audio --raw --format=s16le --rate=48000 --channels=2 /dev/zero
Restart=no
''')
put('/etc/systemd/user/fixture-audio-reader.service','''[Service]
ExecStart=/usr/bin/python3 -c "import time; f=open('/run/fixture-sound/controlC0'); time.sleep(300)"
Restart=no
''')
for name in ('archipelago-kiosk-browser.service','archipelago-runtime-audio-router.service','archipelago-runtime-nostr-relay.service'):
put('/etc/systemd/user/'+name,(configs/name).read_text())
for name in ('c01_network','c01_endpoints','runtime_uplink','c01_uplink','c01_network_mdns','c01_retained_producer','c01_network_plan','c01_network_owner','c01_kiosk_endpoints'):
put('/usr/lib/archipelago/'+name+'.py',(ROOT/'scripts'/f'{name}.py').read_text())
for name in ('archipelago-c01-network.service','archipelago-c01-kiosk-endpoints.service'):
put('/etc/systemd/system/'+name,(configs/name).read_text())
command('install','-d','-o','archipelago','-g','archipelago','-m','0700','/var/lib/archipelago/nostr-relay')
put('/var/lib/archipelago/nostr-relay/config.toml',(configs/'nostr-relay-config.toml').read_text())
put('/var/lib/archipelago/nostr-relay/preservation-marker','retained-relay\n')
# Real systemd user manager; exact existing private home/X11/state templates.
drop=(configs/'80-archipelago-runtime-user.conf.in').read_text()
for key,value in {'ORIGINAL_RUNTIME_UID':'1000','ORIGINAL_HOME':'/home/archipelago',
'SANITIZED_RUNTIME_HOME':'/var/lib/fixture-runtime/home','MANAGER_BACKING_PARENT':'/var/lib/fixture-manager',
'SNAPSHOT_BACKING_PARENT':'/var/lib/fixture-snapshot','AUTHORITY_BACKING_PARENT':'/var/lib/fixture-authority'}.items():
drop=drop.replace('@'+key+'@',value)
put('/etc/systemd/system/user@1000.service.d/80-runtime.conf',drop)
for name in ('84-c01-kiosk-x11.conf','86-c01-runtime-audio-state.conf','87-c01-runtime-audio-owner.conf'):
put('/etc/systemd/system/user@1000.service.d/'+name,(configs/name).read_text())
command('ip','netns','add','archipelago-runtime')
command('ip','-n','archipelago-runtime','link','set','lo','up')
command('ip','netns','add','archipelago-managed')
command('ip','-n','archipelago-managed','link','set','lo','up')
put('/run/fixture-web/index.html','<html><body>C01 Kiosk fixture</body></html>')
put('/etc/nginx/nginx.conf','''worker_processes 1;
events { worker_connections 128; }
http { server { listen 80; location / { root /run/fixture-web; try_files $uri /index.html; } } }
''')
put('/etc/systemd/system/nginx.service','[Service]\nType=simple\nExecStart=/usr/sbin/nginx -g "daemon off;"\n')
import c01_network_owner as network
runtime=dict(runtime_uid=1000,runtime_gid=1000,runtime_user='archipelago',user_cgroup='/user.slice/user-1000.slice/user@1000.service')
put('/etc/archipelago-c01/runtime.json',json.dumps(runtime),0o600)
manifests=[];routes=[dict(name='kiosk-http',source='runtime',target='host',protocol='tcp',port=80,
app_id=None,projection_sha256=None,target_uid=0,target_cgroup='/system.slice/nginx.service')]
for app,port,unit,source in (('archipelago-kiosk',9222,'archipelago-kiosk-browser.service','managed'),
('nostr-relay',7777,'archipelago-runtime-nostr-relay.service','host')):
group=runtime['user_cgroup']+'/app.slice/'+unit
doc=dict(app_id=app,manifest_sha256=hashlib.sha256((configs/unit).read_bytes()).hexdigest(),
loopback_ports=[['tcp',port]],target_cgroup=group)
manifests.append(doc)
routes.append(dict(name=f'app/{app}/tcp/{port}/{source}',source=source,target='runtime',protocol='tcp',port=port,
app_id=app,projection_sha256=network.digest(network.canonical(doc)),target_uid=1000,target_cgroup=group))
namespaces={}
for role,path in (('host','/proc/1/ns/net'),('runtime','/run/netns/archipelago-runtime'),('managed','/run/netns/archipelago-managed')):
m=os.stat(path);namespaces[role]=[m.st_dev,m.st_ino]
endpoint_raw=network.canonical(dict(version=1,namespaces=namespaces,manifests=manifests,routes=routes))
put('/etc/archipelago-c01/endpoints.json',endpoint_raw.decode(),0o600)
executables={p:hashlib.sha256(Path(p).read_bytes()).hexdigest() for p in ('/usr/lib/chromium/chromium','/usr/local/bin/nostr-rs-relay')}
put('/etc/archipelago-c01/kiosk-endpoints.json',json.dumps(dict(version=1,policy_sha256=network.digest(endpoint_raw),
executables=executables,routes=[r['name'] for r in routes if r['target']=='runtime'])),0o600)
command('install','-d','/opt/archipelago')
access=(configs/'81-c01-runtime-access.conf.in').read_text()
for key,value in {'MANAGER_PARENT':'/var/lib/fixture-manager','SNAPSHOT_PARENT':'/var/lib/fixture-snapshot',
'AUTHORITY_PARENT':'/var/lib/fixture-authority'}.items():access=access.replace('@'+key+'@',value)
# Egress is outside this targeted loopback/hardware fixture. Retain EVERY
# production view/exec/IPC restriction, overriding only the external uplink
# readiness probe already qualified in the separate runtime composition.
put('/etc/systemd/system/user@1000.service.d/81-private.conf',access)
put('/etc/systemd/system/user@1000.service.d/99-fixture-uplink.conf','[Service]\nExecCondition=\nExecCondition=/usr/bin/true\n')
owner=(configs/'archipelago-audio-devices.service').read_text().replace(
'/usr/bin/python3 -I -S /usr/lib/archipelago/c01_audio_devices.py',
'/usr/bin/python3 -B /opt/kiosk-fixture/scripts/tests/c01_kiosk_pid1.py audio-owner')
owner=owner.replace('ReadWritePaths=/dev/snd ','ReadWritePaths=/run/fixture-sound ')
put('/etc/systemd/system/archipelago-audio-devices.service',owner)
recovery=(configs/'archipelago-audio-reconcile.service').read_text().replace(
'/usr/bin/python3 -I -S /usr/lib/archipelago/c01_audio_devices.py',
'/usr/bin/python3 -B /opt/kiosk-fixture/scripts/tests/c01_kiosk_pid1.py audio-owner').replace(
'ReadWritePaths=/dev/snd ','ReadWritePaths=/run/fixture-sound ')
put('/etc/systemd/system/archipelago-audio-reconcile.service',recovery)
put('/etc/X11/fixture-dummy.conf','''Section "ServerFlags"
Option "AutoAddDevices" "false"
Option "AutoAddGPU" "false"
EndSection
Section "Device"
Identifier "dummy"
Driver "dummy"
VideoRam 256000
EndSection
Section "Monitor"
Identifier "monitor"
HorizSync 5.0-1000.0
VertRefresh 5.0-200.0
Modeline "1280x720" 74.50 1280 1344 1472 1664 720 723 728 748
EndSection
Section "Screen"
Identifier "screen"
Device "dummy"
Monitor "monitor"
DefaultDepth 24
SubSection "Display"
Depth 24
Modes "1280x720"
EndSubSection
EndSection
''')
# Run the complete production root launcher. Its Xorg executable appointment
# is the explicit hardware seam: real dummy-driver Xorg, without physical VT.
Path('/usr/bin/Xorg').unlink()
put('/usr/bin/Xorg','#!/bin/sh\nexec /usr/lib/xorg/Xorg :0 -config /etc/X11/fixture-dummy.conf -noreset -nolisten tcp\n',0o755)
put('/etc/systemd/system/archipelago-kiosk.service','''[Service]
Environment=ARCHIPELAGO_C01_KIOSK=1
ExecStart=/usr/local/bin/archipelago-kiosk-launcher
Restart=no
''')
command('systemctl','daemon-reload')
command('systemctl','start','nginx.service','archipelago-c01-network.service')
command('systemctl','start','archipelago-kiosk.service')
wait(lambda:Path('/tmp/.X11-unix/X0').exists(),'dummy Xorg socket')
wait(lambda:Path('/run/archipelago-kiosk/browser.env').exists(),'production display environment handoff')
command('install','-d','-o','archipelago','-g','archipelago','-m','0700','/var/lib/archipelago/chromium-kiosk')
put('/var/lib/archipelago/chromium-kiosk/preservation-marker','retained-profile\n')
original=Path('/var/lib/archipelago/chromium-kiosk').stat().st_ino
command('systemctl','start','user@1000.service')
assert audio.permissions(audio.decode(audio.acl(device),device.stat().st_mode),0,29,1000,[1000])==6
user('systemctl','--user','start','pipewire.service','pipewire-pulse.service','wireplumber.service')
user('pactl','load-module','module-null-sink','sink_name=fixture_audio','sink_properties=device.description=FixtureAudio')
def sink_ready():
try:user('pactl','set-default-sink','fixture_audio');return True
except RuntimeError:return False
wait(sink_ready,'WirePlumber default-sink metadata')
user('systemctl','--user','start','fixture-playback.service')
wait(lambda:'RUNNING' in user('pactl','list','short','sinks'),'real null-sink playback running')
# Let the fresh null node's WirePlumber restore job finish before setting
# the synthetic *prior* operator state tested across the kiosk startup.
time.sleep(3)
user('pactl','set-sink-volume','fixture_audio','37%')
user('pactl','set-sink-mute','fixture_audio','1')
wait(lambda:user('pactl','get-sink-mute','fixture_audio').strip()=='Mute: yes' and
'37%' in user('pactl','get-sink-volume','fixture_audio'),'synthetic prior sink state applied')
print(json.dumps({'before_browser':{'default':user('pactl','get-default-sink').strip(),
'mute':user('pactl','get-sink-mute','fixture_audio').strip(),
'volume':user('pactl','get-sink-volume','fixture_audio').strip()}}),flush=True)
assert user('pactl','get-sink-mute','fixture_audio').strip()=='Mute: yes','synthetic prior mute not established'
user('systemctl','--user','start','archipelago-runtime-audio-router.service')
user('systemctl','--user','start','archipelago-kiosk-browser.service')
user('systemctl','--user','start','archipelago-runtime-nostr-relay.service')
command('systemctl','start','archipelago-c01-kiosk-endpoints.service')
wait(lambda:bool(user('systemctl','--user','show','archipelago-kiosk-browser.service','-p','MainPID','--value').strip() not in ('','0')),'browser service')
# CDP must be a real listening Chromium socket in the private runtime realm.
wait(lambda:'9222' in command('ip','netns','exec','archipelago-runtime','ss','-ltn',check=False),'real Chromium CDP',60)
state=json.loads(command('ip','netns','exec','archipelago-runtime','curl','-fsS','http://127.0.0.1:9222/json/version'))
assert 'Chrome' in state['Browser']
def bridged_cdp(previous=None):
try:
response=json.loads(command('ip','netns','exec','archipelago-managed','curl','-fsS','--max-time','2','http://127.0.0.1:9222/json/version'))
return 'Chrome' in response['Browser'] and response['webSocketDebuggerUrl']!=previous
except (RuntimeError,ValueError,KeyError):return False
wait(bridged_cdp,'real CDP through pinned managed-to-runtime bridge')
assert 'C01 Kiosk fixture' in command('ip','netns','exec','archipelago-runtime','curl','-fsS','http://127.0.0.1/kiosk')
def relay_ready():
try:return bool(command('curl','-fsS','--max-time','2','-H','Accept: application/nostr+json','http://127.0.0.1:7777/'))
except RuntimeError:return False
wait(relay_ready,'real relay through host-to-runtime bridge')
first_browser=state['webSocketDebuggerUrl']
user('systemctl','--user','restart','archipelago-kiosk-browser.service')
wait(bridged_cdp,'CDP producer re-registration after browser restart')
state=json.loads(command('ip','netns','exec','archipelago-managed','curl','-fsS','http://127.0.0.1:9222/json/version'))
assert state['webSocketDebuggerUrl']!=first_browser
before_display_restart=state['webSocketDebuggerUrl']
command('systemctl','restart','archipelago-kiosk.service')
wait(lambda:bridged_cdp(before_display_restart),'CDP re-admission after root display restart')
state=json.loads(command('ip','netns','exec','archipelago-managed','curl','-fsS','http://127.0.0.1:9222/json/version'))
assert state['webSocketDebuggerUrl']!=before_display_restart
pid=int(command('systemctl','show','user@1000.service','-p','MainPID','--value').strip())
assert not Path(f'/proc/{pid}/root/home/archipelago/.ssh').exists()
assert not Path(f'/proc/{pid}/root/home/archipelago/.pki').exists(),'browser created forbidden top-level operator credential directory'
assert not Path(f'/proc/{pid}/root/home/archipelago/.gnupg').exists()
for name in ('/run/dbus/system_bus_socket','/run/systemd/private','/run/sudo','/run/polkit-1',
'/usr/bin/sudo','/usr/bin/pkexec','/usr/bin/su','/usr/bin/doas','/usr/bin/chsh',
'/usr/bin/chfn','/usr/bin/passwd','/usr/bin/crontab','/usr/bin/at','/root',
'/home/archipelago-admin','/var/lib/fixture-manager','/var/lib/fixture-snapshot','/var/lib/fixture-authority'):
try:meta=Path(f'/proc/{pid}/root'+name).stat()
except FileNotFoundError:continue
assert meta.st_uid==0 and stat.S_IMODE(meta.st_mode)==0,('privileged view exposed',name)
for name in ('/opt/archipelago','/usr/local/bin'):
assert os.statvfs(f'/proc/{pid}/root'+name).f_flag & os.ST_RDONLY,('mutable package view',name)
assert Path(f'/proc/{pid}/root/home/archipelago').stat().st_ino==Path('/var/lib/fixture-runtime/home').stat().st_ino
assert Path('/var/lib/archipelago/chromium-kiosk').stat().st_ino==original
assert (cookie.stat().st_dev,cookie.stat().st_ino,hashlib.sha256(cookie.read_bytes()).hexdigest())==cookie_identity
assert user('pactl','get-default-sink').strip()=='fixture_audio'
mute=user('pactl','get-sink-mute','fixture_audio').strip()
assert mute=='Mute: yes',mute
assert '37%' in user('pactl','get-sink-volume','fixture_audio')
user('systemctl','--user','restart','archipelago-runtime-audio-router.service')
time.sleep(6)
assert user('pactl','get-default-sink').strip()=='fixture_audio'
assert user('pactl','get-sink-mute','fixture_audio').strip()=='Mute: yes'
assert '37%' in user('pactl','get-sink-volume','fixture_audio')
# Owner stop must retire the complete runtime BEFORE preimage restoration.
command('systemctl','stop','archipelago-audio-devices.service')
wait(lambda:command('systemctl','is-active','user@1000.service',check=False).strip()=='inactive','runtime dependent retirement')
audio.runtime_retired()
assert audio.acl(device) is None and stat.S_IMODE(device.stat().st_mode)==0o660
assert all(json.loads(p.read_text())['phase']=='revoked' for p in audio.STATE.glob('*.json'))
command('systemctl','start','user@1000.service')
user('systemctl','--user','start','fixture-audio-reader.service')
reader=int(user('systemctl','--user','show','fixture-audio-reader.service','-p','MainPID','--value').strip())
assert reader>1
command('systemctl','kill','--kill-whom=main','--signal=KILL','archipelago-audio-devices.service')
wait(lambda:command('systemctl','is-active','user@1000.service',check=False).strip()=='inactive','runtime retirement after owner SIGKILL')
assert not Path(f'/proc/{reader}').exists(),'open-device consumer survived revocation'
wait(lambda:audio.acl(device) is None,'ACL restoration after owner SIGKILL')
command('systemctl','reset-failed','archipelago-audio-devices.service')
command('systemctl','start','user@1000.service')
user('systemctl','--user','start','fixture-audio-reader.service')
device.unlink();device.touch();os.chown(device,0,29);device.chmod(0o660)
wait(lambda:command('systemctl','is-active','user@1000.service',check=False).strip()=='inactive','runtime retirement on device incarnation replacement')
assert audio.acl(device) is None,'old grant followed a replacement node'
wait(lambda:all(json.loads(p.read_text())['phase'] in ('revoked','detached') for p in audio.STATE.glob('*.json')),
'detached incarnation journal reconciliation')
command('systemctl','reset-failed','archipelago-audio-devices.service')
command('systemctl','start','user@1000.service')
assert audio.permissions(audio.decode(audio.acl(device),device.stat().st_mode),0,29,1000,[1000])==6
command('systemctl','stop','archipelago-audio-devices.service')
assert audio.acl(device) is None,'fresh incarnation preimage not restored'
print(json.dumps({'real_pid1':True,'real_xorg_dummy':True,'real_chromium_cdp':state['Browser'],
'real_pipewire_null_sink':True,'real_acl_roundtrip':True,'runtime_retired_before_revoke':True,
'real_relay':True,'real_pinned_http_cdp_relay_routes':True,'cdp_restart_reregistered':True,
'owner_sigkill_retired_open_device_consumer':True,'device_replacement_blocked_without_regrant':True,
'fresh_device_readmission_after_retirement':True,
'profile_inode_preserved':True,'original_pulse_cookie_inode_bytes_preserved':True,
'physical_audio_accepted':False,'full_native_cutover_accepted':False}),flush=True)
if __name__=='__main__':main()