如何解决动态IP导致的VPN失效

2019-01-17

文章主要分三个部分,前面的偏基础,重点在最后第三条,是写的如何解决动态ip导致的一系列问题。有需要的话,可以直接到最后查看。

想要实现的目标:

1.通过公司内网的网段可以直接访问阿里云上的服务器主机,VPN 可以将一个机构的多个数据中心通过隧道的方式连接起来,让机构感觉像是在一个数据中心里面。
2.在公司之外,无论在祖国各地,五湖四海,只要是能联网的地方,就能访问到公司内网。

具体的实现

1.打通公司内网到阿里云的VPN通道

首先要说的就是通过公司内网访问阿里云上的服务器。
IPsec连接

这样说比较抽象,举个例子,如果我阿里云上的某台服务器的ip是188.100.100.1,那我要实现的效果是:

1
ping 188.100.100.1

能够ping通,当然首先要保证的是我这台服务器的ip和公司内网的网段不冲突,也就是说阿里云的vpc(Virtual Private Cloud可以理解为分配给服务器的私有网段)和公司内网正常办公的网段不冲突。

在这里要注意的一点,是先设置路由器,然后在去阿里云上做设置。

在阿里云中做这些设置:

阿里云vpc中的vpn设置

vpn网关:
这里的vpn网关,一般指的是公司服务器集群总的网关,是一个固定IP作为网络的输入口,这里我们是不用新建设置的。注意这里的ip,是我们要用到的。

vpn网关

用户网关:
用户网关这里的ip就是我们公司的外网ip,获得外网ip的方法有很多种,比如访问http://www.ip138.com/,这里需要我们新建创建用户网关来填入我们的ip。

用户网关

IPsec连接:
这一步就是集大成的关键性一步了。
IPsec连接
这里的VPN网关和用户网关都选择我们上一步设置好的选择就好。这里的本端网段填写的是阿里云的VPC网段。而对端网段填写的是我们公司内网分配给设备上网的网段。
新建A
还有就是约定好喝公司的网关路由器上的共享秘钥。和加密算法之类的,保证云上和本地配置的一致。
新建B

重要的一点:如果你保证你配置的没问题,但还是提示‘第二阶段协商未成功’这样的错误。很可能是因为比较玄学,要先去路由器做好配置,然后在阿里云上做好配置,要保证一个顺序的先后性。

2.打通外部到公司内部的VPN通道

也就是下图中的移动办公的这种场景:
移动办公VPN

要达到的效果说的实例化一点就是

1
ping 188.100.100.1

能够ping通,值得注意的一点是这里的188.100.100.1是阿里云上服务器的内网ip,也就是说,并不只是从公司外访问公司内的某台设备那样简单,而是要穿过两层vpn隧道。

这里要在网关上分配一个网段,来给进来的设备。相当于从外部访问的设备进来后,就相当于一个内网的设备。

采用的是PPTP协议连接。在路由器上做些配置就OK了。
在路由器的networks中设置

Windows电脑自带PPTP,Mac电脑可以用Shimo软件连接。填写公司的ip地址,和分配给你的username和password就好了。

这样后,可以ping通内网设备,但是并不能ping的通188.100.100.1。这个问题很奇葩。查了很多资料,终于找到了这个办法试了试。
v2ex上的v友的回答

但这里值得注意的一点是:在一些路由器上,如unifi路由器,在输入框中是不允许填写和内网网段重复的网段的。所以,我的做法是到路由器中,改写etc/pptpd.conf这个文件,设置remoteip,把它的网段改为和内网相同的网段。这里还有一点是值得注意的是,分配的ip不要和已有的分配给内网的设备的ip重复。所以我的做法是选择了ip网段的后面几位。localip是pptp的服务器的地址。如果用traceroute命令查看,访问非局域网,会有显示这个地址。
更改后,保存,并使其生效。重启生效的命令为:

1
sudo service pptpd restart

3.精华:解决动态IP导致的问题

如今,无论是对于家庭网络还是公司网络(家庭网络与公司网络一个重要的区别是:公司网络是上传和下载的速度相同),网络运营商默认提供的网络,都不是固定IP,而是动态IP。如果我们去找网络运营商去要求固定IP,运营商会很客服的告诉你,当然可以提供这项套餐的,然后转而告诉你,这个得加钱。而固定套餐的价格相比于动态IP的套餐的价格要高出数倍。

因为遇到了动态IP,使得我原有的设置,复杂了至少两倍。我所遇到的困难:

  • IP地址经常更换,每次重启路由器后,都会导致IP变化。这使得如果IP每次变化后,我都需要到阿里云上重新新建用户网关和IPsec连接,同时配置本地网络。还有pptpd.conf文件也会重新恢复原样。

  • Unifi网关的路由器系统的Linux版本,对linux包源进行了限制,比如wget都不能下载安装,所幸提供了Python,但是也仅仅支持Python2,Python的第三方包也不能下载,比如常用的requests包。

我们把这些问题拆分化,一一解决:

通过阿里云API接口来创建IPsec连接

对于创建用户网关和IPsec连接,阿里云是提供一个公共的API接口用户创建和删除的,可以参考这个链接阿里云VPN接口文档

当然并不只是看文档这么简单,文档里并没有提供Python的示例代码。搜索后发现一些都是Java代码,零星的几个Python代码还跑不起来。所有这里直接黏贴上我组合后的Python2代码,Python3只要稍稍改动部分,就可以跑的起来,这个我稍后会写。
Python2调用阿里云API示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
# -*- coding: utf-8 -*-
import base64
import urllib
import hmac
from hashlib import sha1
import requests
import uuid
import time
import hmac,ssl
ALIYUN_ACCESS_KEY_ID=""#在阿里云上生成
ALIYUN_ACCESS_KEY_SECRET=""#在阿里云上生成

try:
_create_unverified_https_context = ssl._create_unverified_context
except AttributeError:
# Legacy Python that doesn't verify HTTPS certificates by default
pass
else:
# Handle target environment that doesn't support HTTPS verification
ssl._create_default_https_context = _create_unverified_https_context

D = {
'Format':'JSON',
'Version':'2016-04-28',
'SignatureMethod':'HMAC-SHA1'
}
timestamp = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
D['SignatureNonce']=str(uuid.uuid1())
D['SignatureVersion']=1.0
D['AccessKeyId']=ALIYUN_ACCESS_KEY_ID
D['Timestamp']=timestamp


def percent_encode(encodeStr):
encodeStr = str(encodeStr)
#res = urllib.parse.quote(encodeStr)
res = urllib.quote_plus(encodeStr)
res = res.replace('+', '%20')
res = res.replace('*', '%2A')
res = res.replace('%7E', '~')
res = res.replace('/', '%2F') #That is for the LocalSubnet "/"
return res

def sign(parameters):
sortedParameters = sorted(parameters.items(), key=lambda parameters: parameters[0])
canonicalizedQueryString = ''
for (k, v) in sortedParameters:
canonicalizedQueryString += '&' + percent_encode(k) + '=' + percent_encode(v)
print(canonicalizedQueryString)
stringToSign = 'GET&%2F&' + percent_encode(canonicalizedQueryString[1:])
bs = ALIYUN_ACCESS_KEY_SECRET + '&'
bs = bytes(bs).encode('utf8')
stringToSign = bytes(stringToSign).encode('utf8')
h = hmac.new(bs, stringToSign, sha1)
# 进行编码
signature = base64.b64encode(h.digest()).strip()
return signature

def genipsec(ip,customId):
D['Action']="CreateVpnConnection"
D['CustomerGatewayId']=customId
D['VpnGatewayId']=""
D['RegionId']="cn-shanghai"
D['Name']="newIPsec"+ip
D['LocalSubnet']=""
D['RemoteSubnet']=""
D['EffectImmediately']=True
D['IkeConfig']={"Psk":"",'IkeVersion':'','IkeMode':'','IkeEncAlg':'',
'IkeAuthAlg':'','IkePfs':'','IkeLifetime':'','LocalIdIPsec':'','RemoteId':ip}
D['IpsecConfig']={'IpsecEncAlg':'','IpsecAuthAlg':'','IpsecPfs':'','IpsecLifetime':''}
D['Signature'] = sign(D)


sortedParameters = sorted(D.items(), key=lambda D: D[0])

url = 'https://vpc.aliyuncs.com/?' + urllib.urlencode(sortedParameters)
r = requests.get(url)
print(r.text)
  • 关于这块代码的原理可以参见这篇文章阿里云 API 签名机制的 Python 实现

  • 将此段Python2的代码变为python3的代码,需要改动两个地方,一个是import urllib变为import urllib.parse,还有就是就是转码python2为bytes(bs).encode('utf8'),python3为bytes(bs,encoding='utf8')

  • 阿里云提供的文档中,有一个容易引起人们误解的地方就是:
    阿里云vpn文档
    我在代码中要构建一个字典,需要构建key和value,这里的key值,不要填写阿里云文档中的IkeConfig.Psk这样的格式,而是添加PSK,如下:

    1
    2
    D['IkeConfig']={"Psk":"",'IkeVersion':'','IkeMode':'','IkeEncAlg':'',
    'IkeAuthAlg':'','IkePfs':'','IkeLifetime':'','LocalIdIPsec':'','RemoteId':ip}
  • 其他示例的Python代码中还有一种情况没有涉及,就是因为我需要在LocalSubnet那填写网段,这个网段里包含“/”字符,所以需要处理“/”这个字符的转换,所以我添加了如下的代码。

    1
    res = res.replace('/', '%2F')  #That is for the LocalSubnet "/"
如何监测IP变化

方案是这样:在路由器的linux系统中通过crontab跑一个定时检测ip变化的脚本,如果有变化则触发相应的后续的一系列创建和修改的操作。所以包含四部分:
1.crontab怎么写?
2.如何获取实时公网ip?
3.在哪找到源ip来实时的与当前ip做对比来判断ip是否发生了变化?
4.在一个处处受限的linux系统下,如何进行一系列的后续复杂操作?

  • crontab 定时执行python脚本

关于crontab的用法可以参考这篇文章crontab 定时执行python脚本。通过crontab命令的一个重大好处是:机器重启之后,依然生效。
用到的主要是这两条命令:

1
2
crontab -e   #编写定时任务
crontab -l #列出已经在运行的定时任务

每两分钟检查一次

在当前目录下,生成了log

  • 获取实时的公网IP的方法

可以用如下代码:

1
2
3
4
5
6
7
8
9
10
11
#!/usr/bin/python
# -*- coding:utf8 -*-

import urllib2
import re

url = urllib2.urlopen("http://txt.go.sohu.com/ip/soip")
text = url.read()
ip = re.findall(r'\d+.\d+.\d+.\d+',text)

print(ip[0])

转换为python3的代码需要改动urllib.parse

  • 判断公网ip是否变化

这个解决方法,并不能把第一次的ip给写死,因为路由器会经常重启,不能每次重启都要重新更改代码,把ip重新写死。这里的做法是在路由器中读取etc/ipsec.conf的文件,这个文件中有当前路由的公网ip,而且当ip变化时,我们也需要更改这个文件。

1
2
3
4
5
6
7
def getOriginIp():
with open('/etc/ipsec.conf', 'r+') as f:
for line in f.readlines():
if re.search('left=',line):
text=line.decode('GBK')
ip = re.findall(r'\d+.\d+.\d+.\d+',text)
return ip[0]

通过这个OriginIp与当前的IP进行对比,如果有变化,需要去执行后面的一系列操作。

  • 在一个处处受限的linux系统下,如何进行一系列的后续复杂操作?

Unifi路由器的linux系统处处受限,那后续的操作我就不放在路由器上了。于是,我就把后续的一些复杂点的操作移到了另外一台服务器上,路由器上的任务就变成了只负责检测ip变化,如果有变化,通过curl命令去触发另一台服务器。

在另一台服务器上,搭建一台web服务器,我的愿景是尽量少下载第三方包,所以我并没有flask框架,而是用了python原生的一个web服务。大家可能之前听说python中有个内建的http服务器,只要通过这个命令就能开启(详情参见非常简单的PYTHON HTTP服务):

1
python -m SimpleHTTPServer 8080

于是,我就找到了Python2自带的包BaseHTTPRequestHandler,用法可以参考这篇文章Python BaseHTTPServer 介绍。我这里展示一个最简单的用法,通过crul http://localhost:8888就可以执行HelloWorld函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
from BaseHTTPServer import BaseHTTPRequestHandler
import cgi
import json


def HelloWorld():
print("hello world")


class TodoHandler(BaseHTTPRequestHandler):
"""A simple TODO server

which can display and manage todos for you.
"""

# Global instance to store todos. You should use a database in reality.
TODOS = []

def do_GET(self):
# return all todos

if self.path != '/':
self.send_error(404, "File not found.")
return

# Just dump data to json, and return it
message = json.dumps(self.TODOS)
HelloWorld()

self.send_response(200)
self.send_header('Content-type', 'application/json')
self.end_headers()
self.wfile.write(message)

def do_POST(self):
"""Add a new todo

Only json data is supported, otherwise send a 415 response back.
Append new todo to class variable, and it will be displayed
in following get request
"""
ctype, pdict = cgi.parse_header(self.headers['content-type'])
if ctype == 'application/json':
length = int(self.headers['content-length'])
post_values = json.loads(self.rfile.read(length))
self.TODOS.append(post_values)
else:
self.send_error(415, "Only json data is supported.")
return

self.send_response(200)
self.send_header('Content-type', 'application/json')
self.end_headers()

self.wfile.write(post_values)

if __name__ == '__main__':
# Start a simple server, and loop forever
from BaseHTTPServer import HTTPServer
server = HTTPServer(('localhost', 8888), TodoHandler)
print("Starting server, use <Ctrl-C> to stop")
server.serve_forever()

所以可以把一些后续操作,移动到另一台服务器上来。

动态DNS(DDNS)来解决动态IP

搭建外部访问公司的vpn通道是使用的pptp协议,因为我们的IP是动态IP,经常更换,所以一个比较好的解决办法是,把ip都解析到同一个域名下。这就引出了DDNS的概念。Unifi路由器配置的方法参见这个教程UniFi - 动态 DNS 配置
还有这个教程在usg里怎么填写动态DNS?。其中freedns是一个提供免费DDNS的网站,可以提供给你一个免费的域名来做DDNS的解析。在freedns注册登陆后,并选择好域名后,进入Dynamic DNS页面移动到底,会看到你的域名和ip地址。如果我想更改ip,可以点击quick
freedns
如果我想更改ip,可以点击quick,进入到另一页面,其中提供的方法就是用wget把ip推送给freedns网站,来达到更改ip的效果。
命令行更改ip

所以每当检测到更换ip后,我们通过代码:

1
os.system('wget -O http://freedns的地址')

来更换域名和ip的绑定关系。

#####结尾
“有钱能使鬼推磨”,这是一句无比正确却毫无价值的一句话。直接多花些钱,当然可以升级成固定IP。但如果切换到上帝视角,就会发现,动态IP是很多企业和家庭所共同面临的一个问题。只是可能我平时上网需求,动态IP对我的上网没有任何影响。但如果当我们准备对网络搞些事情的时候,动态IP的问题就会铺面而来。所以我想如果不回避动态IP,而是想出一个解决办法,实际上是找出了一个对全行业都有价值的通用性解决方案。

参考链接:
极客时间| VPN:朝中有人好做官
v2ex|用 vpn 连上公司内网后无法访问内网其他服务器
阿里云VPN接口文档
阿里云 API 签名机制的 Python 实现
crontab 定时执行python脚本
非常简单的PYTHON HTTP服务
Python BaseHTTPServer 介绍
UniFi - 动态 DNS 配置
在usg里怎么填写动态DNS?