upstreamProxy.test.ts 5.7 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465
  1. // @vitest-environment node
  2. import {expect,it} from 'vitest'
  3. import {connect,createServer,type Server,type Socket} from 'node:net'
  4. import {createServer as createHttpsServer} from 'node:https'
  5. import {execFile,execFileSync} from 'node:child_process'
  6. import {mkdtempSync,readFileSync,writeFileSync,rmSync} from 'node:fs'
  7. import {tmpdir} from 'node:os'
  8. import {join} from 'node:path'
  9. import {promisify} from 'node:util'
  10. import {proxySocket} from '../../src/runner/network/upstreamProxy'
  11. import type {VmProxySettings} from '../../src/shared/executionEnvironment'
  12. const listen=async(server:Server)=>{await new Promise<void>(r=>server.listen(0,'127.0.0.1',r));return (server.address() as {port:number}).port}
  13. it('authenticates HTTP CONNECT, pins the destination and preserves bytes received with the header',async()=>{
  14. let header='';const server=createServer(socket=>socket.once('data',data=>{header=data.toString();socket.write('HTTP/1.1 200 Connection Established\r\n\r\nfirst-byte')}))
  15. const port=await listen(server)
  16. try{const socket=await proxySocket({enabled:true,protocol:'http',host:'127.0.0.1',port,username:'user',password:'password',revision:1},'1.1.1.1',443)
  17. const data=await new Promise<string>(r=>{socket.once('data',d=>r(d.toString()));socket.resume()})
  18. expect(data).toBe('first-byte');expect(header).toContain('CONNECT 1.1.1.1:443');expect(header).toContain(Buffer.from('user:password').toString('base64'));socket.destroy()
  19. }finally{await new Promise<void>(r=>server.close(()=>r()))}
  20. })
  21. it('fails closed when the proxy refuses authentication',async()=>{
  22. const server=createServer(socket=>socket.once('data',()=>socket.end('HTTP/1.1 407 Proxy Authentication Required\r\n\r\n'))),port=await listen(server)
  23. try{await expect(proxySocket({enabled:true,protocol:'http',host:'127.0.0.1',port,username:'',revision:1},'1.1.1.1',443)).rejects.toThrow('代理连接或认证失败')}finally{await new Promise<void>(r=>server.close(()=>r()))}
  24. })
  25. it('handles authenticated SOCKS5 and never offers anonymous downgrade',async()=>{
  26. const frames:Buffer[]=[]
  27. const server=createServer(socket=>{let phase=0;socket.on('data',data=>{frames.push(data);if(phase++===0)socket.write(Buffer.from([5,2]));else if(phase===2)socket.write(Buffer.from([1,0]));else socket.write(Buffer.from([5,0,0,1,127,0,0,1,0,80]))})}),port=await listen(server)
  28. try{const socket=await proxySocket({enabled:true,protocol:'socks5',host:'127.0.0.1',port,username:'u',password:'p',revision:1} satisfies VmProxySettings,'1.1.1.1',443);expect(frames[0]).toEqual(Buffer.from([5,1,2]));expect(frames[2]).toEqual(Buffer.from([5,1,0,1,1,1,1,1,1,187]));socket.destroy()}finally{await new Promise<void>(r=>server.close(()=>r()))}
  29. })
  30. it('resolves IPv4 and IPv6 through authenticated CONNECT with verified DNS-over-HTTPS',async()=>{
  31. const home=mkdtempSync(join(tmpdir(),'yaoyao-doh-')),key=join(home,'key.pem'),cert=join(home,'cert.pem'),config=join(home,'openssl.cnf')
  32. writeFileSync(config,'[req]\ndistinguished_name=dn\nx509_extensions=ext\nprompt=no\n[dn]\nCN=dns.google\n[ext]\nsubjectAltName=DNS:dns.google\nbasicConstraints=critical,CA:TRUE\n')
  33. execFileSync('openssl',['req','-x509','-newkey','rsa:2048','-nodes','-days','1','-keyout',key,'-out',cert,'-config',config],{stdio:'ignore'})
  34. const requests:string[]=[],headers:string[]=[],sockets=new Set<Socket>()
  35. const https=createHttpsServer({key:readFileSync(key),cert:readFileSync(cert)},(req,res)=>{
  36. requests.push(req.url!)
  37. const type=Number(new URL(req.url!,'https://dns.google').searchParams.get('type'))
  38. res.setHeader('content-type','application/json');res.end(JSON.stringify({Status:0,Answer:[{type,data:type===1?'93.184.216.34':'2606:4700:4700::1111'}]}))
  39. })
  40. const tlsPort=await listen(https)
  41. const proxy=createServer(socket=>{
  42. sockets.add(socket);socket.once('close',()=>sockets.delete(socket))
  43. let header=''
  44. const read=(chunk:Buffer)=>{
  45. header+=chunk.toString();if(!header.endsWith('\r\n\r\n'))return
  46. socket.off('data',read);headers.push(header)
  47. const target=connect(tlsPort,'127.0.0.1',()=>{socket.write('HTTP/1.1 200 Connection Established\r\n\r\n');socket.pipe(target);target.pipe(socket)})
  48. sockets.add(target);target.once('close',()=>sockets.delete(target));target.on('error',()=>socket.destroy());socket.once('close',()=>target.destroy())
  49. }
  50. socket.on('data',read)
  51. })
  52. try{
  53. const port=await listen(proxy)
  54. const settings={enabled:true,protocol:'http',host:'127.0.0.1',port,username:'fixture',password:'proxy-fixture',revision:1}
  55. const source=`import {proxyLookup} from ${JSON.stringify(new URL('../../src/runner/network/upstreamProxy.ts',import.meta.url).href)}; console.log(JSON.stringify(await proxyLookup(${JSON.stringify(settings)})('service.example.test')))`
  56. // Trust only this fixture certificate in a separate process; production TLS
  57. // verification and the parent test process's trust store remain unchanged.
  58. const result=await promisify(execFile)(process.execPath,['--import','tsx','--input-type=module','-e',source],{env:{...process.env,NODE_EXTRA_CA_CERTS:cert},timeout:15000})
  59. expect(JSON.parse(result.stdout)).toEqual([{address:'93.184.216.34',family:4},{address:'2606:4700:4700::1111',family:6}])
  60. expect(requests.sort()).toEqual(['/resolve?name=service.example.test&type=1','/resolve?name=service.example.test&type=28'])
  61. expect(headers).toHaveLength(2)
  62. for(const header of headers){expect(header).toContain('CONNECT 8.8.8.8:443');expect(header).toContain('Proxy-Authorization: Basic '+Buffer.from('fixture:proxy-fixture').toString('base64'))}
  63. }finally{for(const socket of sockets)socket.destroy();await Promise.all([new Promise<void>(r=>proxy.close(()=>r())),new Promise<void>(r=>https.close(()=>r()))]);rmSync(home,{recursive:true,force:true})}
  64. },20000)