| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197 |
- """A network namespace with only loopback + TUN. No physical/default internet NIC.
- All TCP sockets belong to the authenticated parent broker. Guest processes never
- receive proxy credentials. Fake DNS maps back to hostnames at the SOCKS boundary;
- the parent resolves those through the upstream proxy and checks public addresses.
- """
- import asyncio
- import base64
- import contextlib
- import ipaddress
- import json
- import os
- from pathlib import Path
- import secrets
- import socket
- import struct
- import subprocess
- import sys
- import time
- import uuid
- SOCKS = 3129
- DNS = '198.18.0.1'
- class Tunnel:
- def __init__(self, send):
- self.send = send
- self.streams = {}
- self.names = {}
- self.addresses = {}
- def dns(self, packet):
- if len(packet) < 12 or packet[2] & 0x80 or struct.unpack('!H', packet[4:6])[0] != 1:
- raise ValueError('invalid DNS')
- end, labels = 12, []
- while end < len(packet) and packet[end]:
- length = packet[end]
- if length > 63 or end + length + 1 > len(packet): raise ValueError('invalid label')
- labels.append(packet[end+1:end+1+length].decode('ascii'))
- end += length + 1
- end += 1
- kind, cls = struct.unpack('!HH', packet[end:end+4])
- question = packet[12:end+4]
- name = '.'.join(labels).lower()
- answer = b''
- if cls == 1 and kind == 1 and name and len(name) <= 253:
- if name not in self.names:
- if len(self.names) >= 65000: raise ValueError('DNS limit')
- address = str(ipaddress.IPv4Address(int(ipaddress.IPv4Address('198.19.0.1')) + len(self.names)))
- self.names[name] = address
- self.addresses[address] = name
- answer = b'\xc0\x0c' + struct.pack('!HHIH', 1, 1, 60, 4) + socket.inet_aton(self.names[name])
- return packet[:2] + struct.pack('!HHHHH', 0x8180, 1, bool(answer), 0, 0) + question + answer
- async def socks(self, reader, writer):
- identity = str(uuid.uuid4())
- opened = asyncio.get_running_loop().create_future()
- if len(self.streams) >= 32:
- writer.close(); return
- self.streams[identity] = (writer, opened)
- try:
- version, count = await asyncio.wait_for(reader.readexactly(2), 10)
- methods = await reader.readexactly(count)
- if version != 5 or 0 not in methods: raise ValueError('SOCKS version')
- writer.write(b'\x05\x00'); await writer.drain()
- version, command, reserved, kind = await reader.readexactly(4)
- if version != 5 or command != 1 or reserved: raise ValueError('TCP CONNECT only')
- if kind == 1: host = socket.inet_ntop(socket.AF_INET, await reader.readexactly(4))
- elif kind == 4: host = socket.inet_ntop(socket.AF_INET6, await reader.readexactly(16))
- elif kind == 3: host = (await reader.readexactly((await reader.readexactly(1))[0])).decode('ascii')
- else: raise ValueError('SOCKS address')
- port = struct.unpack('!H', await reader.readexactly(2))[0]
- await self.send({'op':'open','id':identity,'host':self.addresses.get(host,host),'port':port})
- await asyncio.wait_for(opened, 25)
- writer.write(b'\x05\x00\x00\x01\x00\x00\x00\x00\x00\x00'); await writer.drain()
- while True:
- data = await reader.read(32768)
- if not data:
- await self.send({'op':'end','id':identity}); break
- await self.send({'op':'data','id':identity,'data':base64.b64encode(data).decode()})
- except Exception:
- with contextlib.suppress(Exception): writer.write(b'\x05\x01\x00\x01'+b'\x00'*6); await writer.drain()
- finally:
- self.streams.pop(identity,None); writer.close()
- with contextlib.suppress(Exception): await self.send({'op':'close','id':identity})
- async def receive(self, frame):
- writer, opened = self.streams.get(frame.get('id'), (None,None))
- if writer is None: return
- if frame['op'] == 'opened':
- if not opened.done(): opened.set_result(True)
- elif frame['op'] == 'data':
- data = base64.b64decode(frame['data'],validate=True)
- if len(data) > 65536: raise ValueError('frame limit')
- writer.write(data); await asyncio.wait_for(writer.drain(),10)
- elif frame['op'] in ('error','end','close'):
- if not opened.done(): opened.set_exception(ConnectionError('network unavailable'))
- writer.close()
- def close(self):
- for writer, opened in list(self.streams.values()):
- if not opened.done(): opened.cancel()
- writer.close()
- self.streams.clear()
- class DNSProtocol(asyncio.DatagramProtocol):
- def __init__(self, tunnel): self.tunnel=tunnel
- def connection_made(self, transport): self.transport=transport
- def datagram_received(self, data, addr):
- with contextlib.suppress(Exception): self.transport.sendto(self.tunnel.dns(data),addr)
- async def main():
- tun_process = None
- # This container has no workspace mounts or business processes.
- if '--init' in sys.argv:
- subprocess.run(['ip','tuntap','add','mode','tun','dev','tun0'],check=True)
- subprocess.run(['ip','addr','add',DNS+'/15','dev','tun0'],check=True)
- subprocess.run(['ip','link','set','tun0','up'],check=True)
- subprocess.run(['ip','route','add','default','dev','tun0'],check=True)
- argv=['tun2socks','--device','tun0','--proxy','socks5://127.0.0.1:'+str(SOCKS),'--loglevel','error']
- if '--socket' not in sys.argv: os.execvp('tun2socks',argv)
- tun_process=subprocess.Popen(argv,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
- loop=asyncio.get_running_loop()
- queue=asyncio.Queue(maxsize=128)
- async def send(frame): await asyncio.wait_for(queue.put(frame),10)
- tunnel=Tunnel(send)
- tcp=await asyncio.start_server(tunnel.socks,'127.0.0.1',SOCKS)
- dns,_=await loop.create_datagram_endpoint(lambda:DNSProtocol(tunnel),local_addr=(DNS,53))
- async def dns_tcp(reader,writer):
- try:
- while True:
- length=struct.unpack('!H',await reader.readexactly(2))[0]
- answer=tunnel.dns(await reader.readexactly(length))
- writer.write(struct.pack('!H',len(answer))+answer);await writer.drain()
- except Exception: pass
- finally: writer.close()
- dns_server=await asyncio.start_server(dns_tcp,DNS,53)
- try:
- if '--socket' not in sys.argv:
- transport,protocol=await loop.connect_write_pipe(asyncio.streams.FlowControlMixin,sys.stdout.buffer)
- wire=asyncio.StreamWriter(transport,protocol,None,loop)
- incoming=asyncio.StreamReader(limit=200000);await loop.connect_read_pipe(lambda:asyncio.StreamReaderProtocol(incoming),sys.stdin.buffer)
- async def output():
- while True: wire.write((json.dumps(await queue.get())+'\n').encode());await wire.drain()
- task=asyncio.create_task(output());await send({'op':'ready','port':SOCKS})
- try:
- while True:
- line=await incoming.readline()
- if not line: break
- await tunnel.receive(json.loads(line))
- finally: task.cancel();wire.close()
- else:
- path=Path('/run/yaoyao-network/gateway.sock');path.parent.mkdir(parents=True,exist_ok=True)
- path.unlink(missing_ok=True)
- token=None;last_seen=0
- async def http(reader,writer):
- nonlocal token,last_seen
- try:
- header=await asyncio.wait_for(reader.readuntil(b'\r\n\r\n'),10)
- lines=header.decode('ascii').split('\r\n');op=lines[0].split(' ')[1].removeprefix('/network-')
- length=int(next(line.split(':',1)[1] for line in lines[1:] if line.lower().startswith('content-length:')))
- if not 0<=length<=4*1024*1024: raise ValueError('limit')
- body=json.loads(await reader.readexactly(length))
- if body.get('desktopId')!=os.environ['YAOYAO_COMPOSE_DESKTOP_ID']: raise ValueError('identity')
- if op=='health': result={'ready':Path('/sys/class/net/tun0').exists() and (tun_process is None or tun_process.poll() is None),'version':1}
- elif op=='open':
- if token and time.monotonic()-last_seen<30: raise ValueError('busy')
- tunnel.close()
- while not queue.empty(): queue.get_nowait()
- token=secrets.token_hex(32);result={'token':token,'ready':True}
- else:
- if not token or not secrets.compare_digest(str(body.get('token','')),token): raise ValueError('token')
- if op=='close': tunnel.close();token=None;result={'ok':True}
- elif op=='exchange':
- for frame in body.get('frames',[]): await tunnel.receive(frame)
- frames=[]
- if queue.empty():
- with contextlib.suppress(asyncio.TimeoutError): frames.append(await asyncio.wait_for(queue.get(),1))
- while not queue.empty() and len(frames)<32: frames.append(queue.get_nowait())
- result={'frames':frames}
- else: raise ValueError('operation')
- last_seen=time.monotonic()
- data=json.dumps(result).encode();status='200 OK'
- except Exception: data=b'{"error":"network gateway unavailable"}';status='409 Conflict'
- writer.write(('HTTP/1.1 '+status+'\r\nContent-Type: application/json\r\nx-yaoyao-desktop-id: '+os.environ['YAOYAO_COMPOSE_DESKTOP_ID']+'\r\nContent-Length: '+str(len(data))+'\r\nConnection: close\r\n\r\n').encode()+data)
- with contextlib.suppress(Exception): await writer.drain()
- writer.close()
- async def watchdog():
- nonlocal token
- while True:
- await asyncio.sleep(5)
- if tun_process is not None and tun_process.poll() is not None: raise RuntimeError('TUN process stopped')
- if token and time.monotonic()-last_seen>30: tunnel.close();token=None
- server=await asyncio.start_unix_server(http,str(path));os.chmod(path,0o666)
- async with server: await asyncio.gather(server.serve_forever(),watchdog())
- finally: tcp.close();dns.close();dns_server.close();tunnel.close()
- if __name__=='__main__': asyncio.run(main())
|