Pages

Thursday, 25 February 2016

用 OpenVPN 实现双方 NAT 内 VPN 连接的尝试

学校位于墙外,宿舍宽带上下 100Mbps,如此优势不拿来架代理简直浪费.. 问题在于宿舍网络位于NAT 后面没公网 IP,也没有 UPnP 这类方便的东西。不仅架代理麻烦,开 MC / TR 服务器什么的也很麻烦.. 尤其是在双方都是内网的情况下。

困难

于是做了不少尝试,比如宿舍内通过 VPN 连接自家路由器,然后将数据包通过自家路由器转发到宿舍路由器上,但这样做怎加了额外的延时,且受家里带宽限制。再比如通过 UDP 打洞 来实现双方直连..
但是如此一来,要用 UDP 来传送 TCP 内容,就需要自己实现 TCP 的各种功能,还要追踪连接,太过复杂大大超出自己能力水平(太弱了。所以就想着用各种办法偷懒。

绕开难点

上学期做的一个尝试是,用 tun 虚拟网卡,直接转发IP包。如此一来,TCP 全由系统实现,避开了这些复杂的事… (参见《OpenWrt 上使用 Python 操作 TAP/TUN》)最后止步于放假回家,和 Windows 下修改路由表的一些问题(很想具体喷一下但是这就扯远太了)。
前段时间忽然想到,OpenVPN 可使用 UDP 建立连接。如此一来,只要自己先(用少量代码)完成 UDP 打洞,而后的操作就可以全权交由(相当完善的) OpenVPN 处理,工作量、复杂度急剧下降。

具体实现

先简单重复一下 UDP 打洞的具体过程。
  1. 在 NAT 后的双方,向一台外网设备发送 UDP 包,让该设备得到双方的 外网 IP 和 UDP 端口号;
  2. 该外网设备通知双方对方的 外网 IP 和 端口号;
  3. 双方互相向对方的 外网端口 发送 UDP 包,连接建立;
  4. 完成,通过该连接通讯。
实际使用时可能会受到一些限制,宿舍网络不存在此问题不再累述。

 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))
view rawsendudp.py hosted with ❤ by GitHub

这段代码(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()
view rawsnatd.py hosted with ❤ by GitHub

(由于宿舍外网端口号实际上不改变,所以偷懒直接写代码里了,下同)

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)
view rawgetportd.py hosted with ❤ by GitHub

↑ 让用户得到自己的 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
# (...)
view rawviews.py hosted with ❤ by GitHub

↑ 这是 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')
view rawvps-snatd.py hosted with ❤ by GitHub

↑ 将 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()
view rawopenvpn-client.py hosted with ❤ by GitHub

用户使用起来还是很方便的。给 Windows 用的话用可用 py2exe 打包,然后将 OpenVPN 客户端放在bin\ 。连接前只要安装 Tun/tap 驱动就行了。Linux 下把bin\openvpn.exe改成openvpn即可。在 Windows 7 和 Ubuntu 下测试过没问题。

问题

  • 受 NAT 的具体实现方式限制,部分网络无法使用( 在拜托 @ipchihin同学用宿舍网络测试时,就遇到了这种情况);
  • 某墙对 OpenVPN 的政策有时十分严格,甚至有报告因此被封 IP 的(123)。很难想像整个宿舍网络从此无法与国内联系的情形;
  • 连接不便。Windows 需安装 Tun/tap 驱动,Android 和 iOS 需要 root / 越狱。在连接层实现,提供 Socks 5 / HTTP 代理才是王道啊!
另外,因为只是一次尝试并未正式投入实用,所以通信时并未做认证和加密。为避免安全隐患,代码发布前经过少量修改,但修改后未经实际测试。
from https://blog.sorz.org/p/openvpn-traversal/