用 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))

这段代码(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 代理才是王道啊!

另外,因为只是一次尝试并未正式投入实用,所以通信时并未做认证和加密。为避免安全隐患,代码发布前经过少量修改,但修改后未经实际测试。如有疏漏请留言,谢谢。

当时做得就挺乱的,现在讲得更乱,还请求轻喷。

用 OpenVPN 实现双方 NAT 内 VPN 连接的尝试》上有5条评论

所有评论已归档,无法添加新的评论。请直接邮件与我联系,谢谢。