managedNetwork.live.test.ts 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566
  1. // @vitest-environment node
  2. import {expect,it} from 'vitest'
  3. import {execFile,spawn} from 'node:child_process'
  4. import {promisify} from 'node:util'
  5. import {createServer} from 'node:net'
  6. import {randomUUID} from 'node:crypto'
  7. import {ManagedProxy} from '../../src/runner/network/managedProxy'
  8. import {ContainerComputerProvider} from '../../src/runner/computers/container'
  9. import {ComposeComputerProvider} from '../../src/runner/computers/compose'
  10. import {mkdtemp,rm} from 'node:fs/promises'
  11. import {tmpdir} from 'node:os'
  12. import {join} from 'node:path'
  13. const exec=promisify(execFile)
  14. const enabled=process.env.HERMES_YAOYAO_LIVE_TEST==='1'
  15. it.skipIf(!enabled).each(['pipe','compose'] as const)('routes all programs through %s transport and cannot connect directly after proxy failure',async transport=>{
  16. const desktopId=randomUUID(),name='yaoyao-network-test-'+desktopId,connections:string[]=[]
  17. let reject=false,proxy:ManagedProxy|undefined
  18. const upstream=createServer(socket=>{
  19. let buffer='',connected=false
  20. socket.on('data',chunk=>{buffer+=chunk.toString();if(!buffer.includes('\r\n\r\n'))return
  21. if(!connected){connections.push(buffer.split('\r\n')[0]!);buffer='';if(reject){socket.end('HTTP/1.1 503 Unavailable\r\n\r\n');return}connected=true;socket.write('HTTP/1.1 200 Connection Established\r\n\r\n')}
  22. else{socket.end('HTTP/1.1 200 OK\r\nContent-Length: 13\r\nConnection: close\r\n\r\nthrough-proxy');buffer=''}
  23. })
  24. })
  25. await new Promise<void>(r=>upstream.listen(0,'127.0.0.1',r))
  26. const port=(upstream.address() as {port:number}).port
  27. const guest=(code:string)=>exec('docker',['run','--rm','--network','container:'+name,'--cap-drop','ALL','--entrypoint','python3','localhost/yaoyao/network:1','-c',code],{timeout:15000})
  28. const request="import socket; s=socket.create_connection(('1.1.1.1',80),5); s.settimeout(5); s.sendall(b'GET / HTTP/1.1\\r\\nHost: fixture\\r\\n\\r\\n'); print(s.recv(4096).decode())"
  29. try{
  30. await exec('docker',['run','-d','--name',name,'--network','none','--dns','198.18.0.1','--cap-drop','ALL','--cap-add','NET_ADMIN','--device','/dev/net/tun','--env','YAOYAO_COMPOSE_DESKTOP_ID='+desktopId,'localhost/yaoyao/network:1','--init',...(transport==='compose'?['--socket']:[])])
  31. await exec('docker',['exec',name,'sh','-c','i=0; until ip link show tun0 >/dev/null 2>&1; do i=$((i+1)); test "$i" -lt 50 || exit 1; sleep .1; done'])
  32. const relay=async(_id:string,operation:string,body:Record<string,unknown>)=>{
  33. const code="import socket,http.client,json,sys\np=json.load(sys.stdin)\nc=http.client.HTTPConnection('localhost',timeout=5)\ns=socket.socket(socket.AF_UNIX);s.connect('/run/yaoyao-network/gateway.sock');c.sock=s\nc.request('POST','/'+p['operation'],body=json.dumps(p['body']),headers={'Content-Type':'application/json'})\nr=c.getresponse();data=r.read();assert r.status==200,data;print(data.decode())"
  34. return await new Promise<any>((resolve,reject)=>{const child=spawn('docker',['exec','-i',name,'python3','-c',code],{stdio:'pipe'});let out='',err='';child.stdout.on('data',b=>out+=b);child.stderr.on('data',b=>err+=b);child.once('error',reject);child.once('close',status=>{try{if(status)throw new Error(err);resolve(JSON.parse(out))}catch(error){reject(error)}});child.stdin.end(JSON.stringify({operation,body:{...body,desktopId}}))})
  35. }
  36. if(transport==='compose')await exec('docker',['exec',name,'sh','-c','i=0; until test -S /run/yaoyao-network/gateway.sock; do i=$((i+1)); test "$i" -lt 50 || exit 1; sleep .1; done'])
  37. const provider=transport==='compose'?new ComposeComputerProvider(randomUUID(),'/tmp',relay):{openNetworkPipe:async()=>spawn('docker',['exec','-i',name,'python3','/usr/local/libexec/yaoyao/network_gateway.py'],{stdio:'pipe'})} as any
  38. proxy=await ManagedProxy.start(provider,{id:randomUUID(),ownerKey:'fixture',imageId:'sha256:'+'a'.repeat(64)}, {enabled:true,protocol:'http',host:'127.0.0.1',port,username:'',revision:1},()=>{})
  39. expect((await guest(request)).stdout).toContain('through-proxy')
  40. expect(connections).toEqual(['CONNECT 1.1.1.1:80 HTTP/1.1'])
  41. expect((await guest("import socket; print(socket.gethostbyname('example.com'))")).stdout.trim()).toMatch(/^198\.19\./)
  42. await expect(guest("import socket; socket.create_connection(('169.254.169.254',80),2).sendall(b'GET / HTTP/1.0\\r\\n\\r\\n'); raise SystemExit(1)")).rejects.toThrow()
  43. reject=true
  44. const failed=await guest(request).catch(()=>({stdout:''}))
  45. expect(failed.stdout).not.toContain('through-proxy')
  46. expect(connections.length).toBe(2)
  47. const interfaces=await exec('docker',['exec',name,'ip','-j','link'])
  48. expect(JSON.parse(interfaces.stdout).map((i:any)=>i.ifname).sort()).toEqual(['lo','tun0'])
  49. }finally{await proxy?.close();await exec('docker',['rm','-f',name]).catch(()=>{});await new Promise<void>(r=>upstream.close(()=>r()))}
  50. },60000)
  51. it.skipIf(!enabled||!process.env.YAOYAO_NETWORK_TEST_DESKTOP_IMAGE)('prepares a real managed desktop with a separate gateway and removes only its own resources',async()=>{
  52. const home=await mkdtemp(join(tmpdir(),'yaoyao-network-provider-')),provider=new ContainerComputerProvider('docker',randomUUID(),home)
  53. const spec={id:randomUUID(),ownerKey:'network-provider-fixture',imageId:process.env.YAOYAO_NETWORK_TEST_DESKTOP_IMAGE!,network:'managed-proxy' as const}
  54. try{
  55. const state=await provider.ensure(spec,()=>{})
  56. expect(state.running).toBe(true)
  57. const result=await provider.execute(spec,['python3','-c','import os,socket; print(os.getuid()); print(open("/etc/resolv.conf").read())'],{authorize:()=>{}})
  58. expect(result.stdout).toContain('1000');expect(result.stdout).toContain('198.18.0.1')
  59. await expect(provider.execute(spec,['sh','-c','ip link add bypass type dummy'],{authorize:()=>{},user:'root'})).rejects.toThrow()
  60. const namespace=JSON.parse((await exec('docker',['inspect',state.containerId])).stdout)[0].HostConfig.NetworkMode
  61. expect(namespace).toMatch(/^container:/)
  62. await provider.remove(spec)
  63. await expect(exec('docker',['inspect',namespace.slice('container:'.length)])).rejects.toThrow()
  64. }finally{await provider.remove(spec).catch(()=>{});await rm(home,{recursive:true,force:true})}
  65. },60000)