学校位于墙外,宿舍宽带上下 100Mbps,如此优势不拿来架代理简直浪费.. 问题在于宿舍网络位于
NAT 后面没公网 IP,也没有 UPnP 这类方便的东西。不仅架代理麻烦,开
MC /
TR 服务器什么的也很麻烦.. 尤其是在双方都是内网的情况下。
困难
于是做了不少尝试,比如宿舍内通过 VPN 连接自家路由器,然后将数据包通过自家路由器转发到宿舍路由器上,但这样做怎加了额外的延时,且受家里带宽限制。再比如通过 UDP 打洞 来实现双方直连..
但是如此一来,要用 UDP 来传送 TCP 内容,就需要自己实现 TCP 的各种功能,还要追踪连接,太过复杂大大超出自己能力水平(太弱了。所以就想着用各种办法偷懒。
绕开难点
前段时间忽然想到,OpenVPN 可使用 UDP 建立连接。如此一来,只要自己先(用少量代码)完成 UDP 打洞,而后的操作就可以全权交由(相当完善的) OpenVPN 处理,工作量、复杂度急剧下降。
具体实现
- 在 NAT 后的双方,向一台外网设备发送 UDP 包,让该设备得到双方的 外网 IP 和 UDP 端口号;
- 该外网设备通知双方对方的 外网 IP 和 端口号;
- 双方互相向对方的 外网端口 发送 UDP 包,连接建立;
- 完成,通过该连接通讯。
实际使用时可能会受到一些限制,宿舍网络不存在此问题不再累述。
OpenVPN 服务器
首先,在宿舍内架 OpenVPN。我在 Cubieboard 上的 Ubuntu 架的,就按通常设置就行。
UDP 打洞要求在客户端连接前,服务器发送一个 UDP 包到客户端,且必须从 OpenVPN 监听端口上发出。为了在发送时不中断 openvpnd,我用了 pylibnet 跳过系统 socket API 直接发送该包。代码如下:
| #!/usr/bin/env python |
| #encoding: utf-8 |
| import libnet |
| from libnet.constants import RAW4, RESOLVE, IPV4_H, UDP_H, IPPROTO_UDP |
|
|
|
|
| IFACE = 'wlan2' # Sending via the interface. |
|
|
| def sendto(sport, address): |
| l = libnet.context(RAW4, IFACE) |
| dest_ip = l.name2addr4(address[0], RESOLVE) |
| l.build_udp(sp=sport, dp=address[1], |
| payload='\x00hehe' |
|
|
| l.autobuild_ipv4(len=(IPV4_H + UDP_H), |
| prot=IPPROTO_UDP, dst=dest_ip) |
|
|
| l.write() |
|
|
|
|
| if __name__ == '__main__': |
| sendto(6000, ('vpn.sorz.org', 6001)) |
这段代码(ovpn-snatd.py)用于告知 VPS,OpenVPN 服务器的地址端口。同时接收由 VPS 转发的客户端地址端口信息:
| #!/usr/bin/env python |
| import socket |
|
|
| import sendudp |
|
|
|
|
| SNAT_BIND_PORT = 6001 |
| SNAT_SERVER = ('sorz.org', 6002) |
| OPENVPN_BIND_PORT = 1194 |
|
|
| def main(): |
| sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) |
| sock.bind(('', SNAT_BIND_PORT)) |
| sock.settimeout(20) |
|
|
| while True: |
| sock.sendto('\x00', SNAT_SERVER) |
| try: |
| sock.recv(1024) # Ignore ping response |
| data = sock.recv(1024) # Receiving users' connection request. |
| except socket.timeout: |
| continue |
| if data[0] != '\x03': |
| continue |
|
|
| print('a new connection from ' + data[1:]) |
| client = data[1:].split(':') |
| sendudp.sendto(OPENVPN_BIND_PORT, (client[0], int(client[1]))) |
|
|
|
|
| if __name__ == '__main__': |
| main() |
(由于宿舍外网端口号实际上不改变,所以偷懒直接写代码里了,下同)
VPS 转发数据协助建立连接
然后,让 VPS 帮助传递端口双方外网端口地址,总共四段代码:
| #!/usr/bin/python |
| import socket |
|
|
| s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) |
| s.bind(('0.0.0.0', 6000)) |
|
|
|
|
| while True: |
| data, addr = s.recvfrom(1024) |
| s.sendto(str(addr[1]), addr) |
↑ 让用户得到自己的 OpenVPN 客户端对应的外网端口。
| # (...) |
|
|
| SNAT_SERVER_PORT = 6002 |
|
|
| @app.route('/ovpn/connect') |
| def movpn_openvpn(): |
| server = get_memcache().get('movpn.openvpn.server') |
| if not server: |
| return 'Server is not running.', 404 |
| addr = request.remote_addr |
| if addr.startswith('::ffff:'): |
| addr = addr[7:] |
| port = request.args.get('port', 1194) |
|
|
| s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) |
| s.sendto('\x02%s:%s' % (addr, port), ('localhost', SNAT_SERVER_PORT)) |
|
|
| return server |
|
|
| # (...) |
↑ 这是
flask 的代码片段。通过 HTTP,将用户的外网地址端口发给下面的
ovpn-snatd.py
,同时返回给用户 OpenVPN 服务器的外网地址端口(为了方便,通过 memcache 读取)。
| #!/usr/bin/env python |
| import memcache |
|
|
| SNAT_SERVER_PORT = 6002 # local listening |
| OPENVPN_BIND_PORT = 1194 |
|
|
| s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) |
| s.bind(('0.0.0.0', SNAT_SERVER_PORT)) |
|
|
| mc = memcache.Client(['127.0.0.1:11211']) |
| server = mc.get('movpn.openvpn.server') |
| if server: |
| server = (server.split(':')[0], OPENVPN_BIND_PORT) |
|
|
| while True: |
| data, addr = s.recvfrom(1024) |
| if data[0] == '\x00': # From openvpn server |
| if addr != server: |
| server = addr |
| mc.set('movpn.openvpn.server', '%s:%s' % (server[0], OPENVPN_BIND_PORT)) |
| s.sendto('\x01', addr) |
|
|
| elif data[0] == '\x02': # From local web server (user's conn request) |
| if addr[0] != '127.0.0.1': |
| print('\x02 != localhost') |
| continue |
| print('new connect from ' + data[1:]) |
| s.sendto('\x03%s' % data[1:], server) |
|
|
| else: |
| print('unknown') |
↑ 将 flask 发来的用户地址端口信息,继续转发给 OpenVPN 服务器。也将后者的地址端口信息,储存至 memcache,方便 flask 读取。
OpenVPN 客户端请求连接
用户启动 OpenVPN 前,先随机生成一个 UDP 端口,通过getported.py
取得该端口对应的外网端口。然后通过 HTTP 将端口号传递给view.py
,同时取得服务器地址。最后释放该 UDP 端口,让给 OpenVPN ,由它建立连接。代码如下:
| #!/usr/bin/env python |
| import random |
| import logging |
| import subprocess |
| import time |
| import socket |
|
|
| import requests |
|
|
|
|
| NAT_REQUEST_URL = 'https://vpn.sorz.org/ovpn/connect?port=%s' |
| SNAT_SERVER = ('vpn.sorz.org', 6000) |
|
|
|
|
| def get_default_param(): |
| return ['bin\openvpn.exe', |
| '--client', |
| '--bind', |
| '--local', '0.0.0.0', |
| '--proto', 'udp', |
| '--dev', 'tun', |
| '--resolv-retry', 'infinite', |
| '--persist-key', |
| '--persist-tun', |
| '--ca', 'ca.crt', |
| '--cert', 'testclient.crt', |
| '--key', 'testclient.key', |
| '--ns-cert-type', 'server', |
| '--keepalive', '20', '60', |
| '--comp-lzo', |
| '--verb', '3', |
| '--mute', '20', |
| '--script-security', '2', 'system' |
| ] |
|
|
|
|
| def main(): |
| logging.basicConfig(level=logging.DEBUG, |
| format='%(asctime)s %(levelname)-8s %(message)s', |
| datefmt='%Y-%m-%d %H:%M:%S', filemode='a+') |
|
|
| logging.info('Version 0.2a1') |
| port = random.randint(8192, 65535) |
| logging.info('Use random port %s.' % port) |
| sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) |
| sock.bind(('0.0.0.0', port)) |
| sock.settimeout(3) |
| nat_port = None |
| for i in range(5): |
| try: |
| sock.sendto('orz', SNAT_SERVER) |
| nat_port = int(str(sock.recv(256))) |
| except socket.timeout: |
| logging.warn('Timeout, retry getting NAT port.') |
| continue |
| except ValueError: |
| logging.warn('Illegal value, retry getting NAT port.') |
| continue |
| sock.shutdown(socket.SHUT_RDWR) |
| sock.close() |
| if nat_port is None: |
| logging.error("Can't get NAT port. Using local bind port.") |
| nat_port = port |
| logging.info('NAT Port is %s.' % nat_port) |
|
|
| r = requests.get(NAT_REQUEST_URL % nat_port) |
| if r.status_code == 404: |
| logging.error('Server is offline.') |
| return |
| server = r.text.strip() |
| logging.info('Server address is %s.' % server) |
|
|
| logging.info('Waiting 2 seconds...') |
| time.sleep(2) |
| openvpn = get_default_param() |
| openvpn.extend(['--remote'] + server.split(':')) |
| openvpn.extend(('--lport', str(port))) |
| logging.info('Calling openvpn') |
| subprocess.call(openvpn) |
|
|
|
|
| if __name__ == '__main__': |
| main() |
用户使用起来还是很方便的。给 Windows 用的话用可用 py2exe 打包,然后将 OpenVPN 客户端放在
bin\
。连接前只要安装
Tun/tap 驱动就行了。Linux 下把
bin\openvpn.exe
改成
openvpn
即可。在 Windows 7 和 Ubuntu 下测试过没问题。
问题
- 受 NAT 的具体实现方式限制,部分网络无法使用( 在拜托 @ipchihin同学用宿舍网络测试时,就遇到了这种情况);
- 某墙对 OpenVPN 的政策有时十分严格,甚至有报告因此被封 IP 的(1, 2, 3)。很难想像整个宿舍网络从此无法与国内联系的情形;
- 连接不便。Windows 需安装 Tun/tap 驱动,Android 和 iOS 需要 root / 越狱。在连接层实现,提供 Socks 5 / HTTP 代理才是王道啊!
另外,因为只是一次尝试并未正式投入实用,所以通信时并未做认证和加密。为避免安全隐患,代码发布前经过少量修改,但修改后未经实际测试。
from https://blog.sorz.org/p/openvpn-traversal/