171 lines
9.7 KiB
Python
171 lines
9.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('token'):h['Authorization']='Bearer '+v['token']
|
|
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,token=None):
|
|
value=dict(host=host,path=path,method=method,cookie=cookie,token=token)
|
|
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)
|
|
api_token=secrets.token_urlsafe(32)
|
|
auth=dict(version=1,password=dict(salt=salt.hex(),hash=hashlib.pbkdf2_hmac('sha256',password.encode(),salt,600000).hex()),revision=secrets.token_hex(16),api_token_hash=hashlib.sha256(api_token.encode()).hexdigest(),password_changed_at=time.time(),token_changed_at=time.time())
|
|
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)
|
|
assert http(deckip,'/api/v1/status',token=api_token)['status']==200
|
|
assert http(deckip,'/api/v1/network',token=api_token)['status']==401
|
|
next_token=secrets.token_urlsafe(32)
|
|
assert http(deckip,'/api/v1/auth/token','POST',{'current_password':password,'new_token':next_token},cookie)['status']==200
|
|
assert http(deckip,'/api/v1/status',token=api_token)['status']==401
|
|
assert http(deckip,'/api/v1/status',token=next_token)['status']==200
|
|
assert http(deckip,'/api/v1/status',cookie=cookie)['status']==200
|
|
next_password=secrets.token_urlsafe(32)
|
|
assert http(deckip,'/api/v1/auth/password','POST',{'current_password':password,'new_password':next_password},cookie)['status']==200
|
|
assert http(deckip,'/api/v1/status',cookie=cookie)['status']==401
|
|
assert http(deckip,'/api/v1/login','POST',{'password':password})['status']==401
|
|
password=next_password
|
|
login=http(deckip,'/api/v1/login','POST',{'password':password})
|
|
assert login['status']==200
|
|
cookie=login['cookie'].split(';')[0]
|
|
print('PASS proxy forwards API token; rotations persist through privileged helper; old credentials rejected',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)
|
|
for _ in range(20):
|
|
try:
|
|
restored=http(deckip,'/api/v1/login','POST',{'password':password})
|
|
if restored['status']==200:break
|
|
except Exception:pass
|
|
time.sleep(.5)
|
|
else:raise RuntimeError('Changed password did not survive container restart')
|
|
assert http(deckip,'/api/v1/status',token=next_token)['status']==200
|
|
assert http(deckip,'/api/v1/status',token=api_token)['status']==401
|
|
print('PASS changed password and token survive container restart',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()
|