Files
Athena-Deck/network/test_integration.py
T

143 lines
7.7 KiB
Python

"""Run explicitly on a Linux Docker host; creates two disposable containers.
No published ports, no production endpoints, fresh throwaway keys kept in memory.
"""
import hashlib
import json
import os
from pathlib import Path
import secrets
import subprocess
import tempfile
import time
IMAGE='athena-deck-network-test:20260928'
DECK='athena-deck-test-runtime'
PEER='athena-deck-test-peer'
def run(*args, data=None, check=True):
result=subprocess.run(args,input=data,text=True,capture_output=True,timeout=45)
if check and result.returncode:
raise RuntimeError('Test command failed: '+args[0]+' (output redacted)')
return result.stdout.strip()
def execute(container, script, value=None):
payload=json.dumps(value) if value is not None else None
result=run('docker','exec','-i',container,'python3','-c',script,data=payload)
return json.loads(result) if result else None
def rpc(action, **values):
result=execute(DECK,"import json,sys;from network.rpc import request;print(json.dumps(request(json.load(sys.stdin))))",dict(action=action,**values))
return result
HTTP = '''import http.client,json,sys
v=json.load(sys.stdin)
c=http.client.HTTPConnection(v['host'],8110,timeout=5)
h={'Content-Type':'application/json','X-Athena-Deck':'1'}
if v.get('cookie'):h['Cookie']=v['cookie']
if v.get('origin'):h['Origin']=v['origin']
c.request(v.get('method','GET'),v.get('path','/'),json.dumps(v['body']) if 'body' in v else None,h)
r=c.getresponse();b=r.read().decode()
print(json.dumps({'status':r.status,'body':b,'cookie':r.getheader('Set-Cookie')}))
'''
def http(host,path='/',method='GET',body=None,cookie=None):
value=dict(host=host,path=path,method=method,cookie=cookie)
if body is not None:value['body']=body
return execute(PEER,HTTP,value)
def main():
for name in (DECK,PEER):
if run('docker','ps','-aq','--filter','name=^/'+name+'$'):
raise RuntimeError('Test container name already exists; refusing takeover')
ids=run('docker','ps','-aq').splitlines()
baseline=run('docker','inspect','--format','{{.Id}} {{.State.StartedAt}}',*ids) if ids else ''
state=tempfile.TemporaryDirectory(prefix='athena-deck-test-')
password=secrets.token_urlsafe(32)
salt=secrets.token_bytes(16)
auth=dict(salt=salt.hex(),hash=hashlib.pbkdf2_hmac('sha256',password.encode(),salt,600000).hex())
p=Path(state.name)/'auth.json';p.write_text(json.dumps(auth));p.chmod(0o600)
try:
run('docker','run','-d','--name',PEER,'--cap-drop','ALL','--cap-add','NET_ADMIN',IMAGE,'sleep','600')
run('docker','run','-d','--name',DECK,'--read-only','--cap-drop','ALL','--cap-add','NET_ADMIN','--cap-add','NET_RAW','--cap-add','SETUID','--cap-add','SETGID','--cap-add','CHOWN','--security-opt','no-new-privileges:true','--tmpfs','/run:rw,nosuid,nodev,size=8m','--tmpfs','/tmp:rw,nosuid,nodev,size=8m','-v',state.name+':/data',IMAGE)
for _ in range(20):
try:
assert rpc('status')['state']=='disabled'
break
except Exception:time.sleep(.5)
else:raise RuntimeError('Helper did not become ready')
print('PASS helper startup and unprivileged web child',flush=True)
uid=execute(DECK,"import json;from pathlib import Path;print(json.dumps([p.read_text().split('Uid:')[1].splitlines()[0].split()[0] for p in Path('/proc').glob('[0-9]*/status') if 'Name:\\tpython' in p.read_text()]))")
assert '65534' in uid
deckip=run('docker','inspect','--format','{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}',DECK)
peerip=run('docker','inspect','--format','{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}',PEER)
assert http(deckip,'/api/v1/status')['status']==401
login=http(deckip,'/api/v1/login','POST',{'password':password})
assert login['status']==200
cookie=login['cookie'].split(';')[0]
assert http(deckip,'/api/v1/status',cookie=cookie)['status']==200
print('PASS real LAN login and authenticated API',flush=True)
# Keys stay in this Python process and stdin pipes; never printed.
private_a=run('docker','exec',PEER,'wg','genkey')
private_b=run('docker','exec',PEER,'wg','genkey')
public_a=run('docker','exec','-i',PEER,'wg','pubkey',data=private_a+'\n')
public_b=run('docker','exec','-i',PEER,'wg','pubkey',data=private_b+'\n')
conf=f'[Interface]\nPrivateKey = {private_a}\nAddress = 10.240.77.1/32\nListenPort = 51822\n[Peer]\nPublicKey = {public_b}\nEndpoint = {peerip}:51823\nAllowedIPs = 10.240.77.2/32\nPersistentKeepalive = 1\n'
status=rpc('import',config=conf)
assert private_a not in json.dumps(status) and public_b not in json.dumps(status)
peerconf=f'[Interface]\nPrivateKey = {private_b}\nListenPort = 51823\n[Peer]\nPublicKey = {public_a}\nEndpoint = {deckip}:51822\nAllowedIPs = 10.240.77.1/32\nPersistentKeepalive = 1\n'
execute(PEER,'''import json,sys,subprocess,os
v=json.load(sys.stdin)
def cmd(*a):subprocess.run(a,check=True,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
cmd('ip','link','add','peerwg0','type','wireguard')
fd=os.open('/tmp/peer.conf',os.O_WRONLY|os.O_CREAT,0o600)
with os.fdopen(fd,'w') as f:f.write(v)
cmd('wg','setconf','peerwg0','/tmp/peer.conf');os.unlink('/tmp/peer.conf')
cmd('ip','addr','add','10.240.77.2/32','dev','peerwg0')
cmd('ip','link','set','peerwg0','up')
cmd('ip','route','add','10.240.77.1/32','dev','peerwg0')
''',peerconf)
rpc('connect')
for _ in range(20):
if rpc('status')['connected']:break
time.sleep(.5)
else:raise RuntimeError('No real WireGuard handshake')
assert http('10.240.77.1','/api/v1/status',cookie=cookie)['status']==403
print('PASS actual WireGuard handshake; LAN-only blocks tunnel ingress',flush=True)
status=rpc('mode',mode='both');trial=status['pending']['id']
assert http('10.240.77.1','/api/v1/status',cookie=cookie)['status']==200
assert http(deckip,'/api/v1/network/confirm','POST',{'trial_id':trial},cookie)['status']==400
assert http('10.240.77.1','/api/v1/network/confirm','POST',{'trial_id':trial},cookie)['status']==200
print('PASS both paths; only actual tunnel ingress can confirm',flush=True)
status=rpc('mode',mode='tunnel');trial=status['pending']['id']
assert http(deckip,'/api/v1/status',cookie=cookie)['status']==403
assert http('10.240.77.1','/api/v1/network/confirm','POST',{'trial_id':trial},cookie)['status']==200
rpc('disconnect')
assert http(deckip,'/api/v1/status',cookie=cookie)['status']==403
assert rpc('status')['mode']=='tunnel'
print('PASS tunnel-only closes LAN and stays closed after disconnect',flush=True)
status=rpc('mode',mode='lan');trial=status['pending']['id']
assert http(deckip,'/api/v1/network/confirm','POST',{'trial_id':trial},cookie)['status']==200
rpc('connect')
for _ in range(20):
if rpc('status')['connected']:break
time.sleep(.5)
rpc('mode',mode='tunnel')
run('docker','restart',DECK)
for _ in range(20):
try:
status=rpc('status')
break
except Exception:time.sleep(.5)
assert status['mode']=='lan' and not status['pending']
print('PASS restart discards unconfirmed trial and restores confirmed mode',flush=True)
after=run('docker','inspect','--format','{{.Id}} {{.State.StartedAt}}',*ids) if ids else ''
assert baseline==after
print('PASS production container start times unchanged',flush=True)
finally:
for name in (DECK,PEER):run('docker','rm','-f',name,check=False)
state.cleanup()
if __name__=='__main__':main()