在《VPN的概念以及要点》中,我指出了OpenVPN构建的网络是单向的,也就是说在不手工配置系统路由的情况下,只能由OpenVPN客户端一侧发起连接。这是因为OpenVPN服务器会把自己一侧的路由推送至OpenVPN客户端,反之,OpenVPN客户端却不能把自己一侧的路由推送给OpenVPN服务器。于是我自己修改代码手工实现了一个PUSH消息,将客户端的信息放在PUSH_REQUEST的后面进行发送,然后OpenVPN服务器端进行解析。此举改动了大量的代码,浪费了我一天无聊的时间…
有什么更直接的方式么?事实上,当你认为没有的时候,蓦然回首,那人正在灯火阑珊处等你呢。如果你执行openvpn –help的时候,你会发现一个很有趣的选项,那就是push-peer-info选项,正如help解释所说,它只能用于client。该选项的含义是将OpenVPN客户端的信息推送至OpenVPN服务器。那么赶紧试试看。
有时候,大多数时候,事情总是不像你想象的那样顺利。在客户端配置文件中天剑push-peer-info后,日志级别开到最高,服务器端并没有任何信息输出,那么请问,客户端都推送给服务器哪些信息,这些信息又推到哪里去了?带着这两个问题,我看了OpenVPN的源代码。
我找到了push_peer_info函数,在该函数中,有很多的判断,比如下面这句:
if (!strncmp(e->string, "UV_", 3)&& buf_safe(&out, strlen(e->string)+1))
buf_printf(&out, "%s\n", e->string);
必须以UV_开头的信息才可被推送,当我去掉这个限制的时候,在客户端打印出了推送的信息,可是服务器端还是没有收到。为了尽快得到服务器端哪个位置可能会接收这个信息,首先要找到客户端哪里推送了这个信息,也就是说push_peer_info函数是被谁调用的,因为对于数据通信程序而言,处理一般都是对称的。
经查阅,发现是key_method_2_write调用了这个push_peer_info函数,那么对应的服务端,就是key_method_2_read中读取这个信息了。待会再看key_method_2_read,先把key_method_2_write搞明白再说。在key_method_2_write中:
static bool
key_method_2_write (struct buffer *buf, structtls_session *session)
{
ASSERT(session->opt->key_method == 2);
ASSERT(buf_init (buf, 0));
/* writea uint32 0 */
buf_write_u32 (buf, 0)
/* writekey_method + flags */
buf_write_u8(buf, (session->opt->key_method & KEY_METHOD_MASK))
/* writekey source material */
key_source2_randomize_write(ks->key_src, buf, session->opt->server)
/* writeoptions string */
write_string(buf, local_options_string (session), TLS_OPTIONS_LEN)
/* writeusername/password if specified */
if(auth_user_pass_enabled){
auth_user_pass_setup (NULL);
write_string(buf, auth_user_pass.username, -1)
write_string(buf, auth_user_pass.password, -1)
purge_user_pass (&auth_user_pass, false);
}else{
write_empty_string (buf)/* no username */
write_empty_string (buf) /* no password */
}
push_peer_info (buf, session)
…
}
通过以上的逻辑,发现无论如何,这个顺序执行都是要进行的,也就是说,一个信息没有写对,那么握手就不会完成,如果不理解OpenVPN密钥协商的过程,那么请参考前面的关于OpenVPN协议的文章。因此可以知道,这个peer-info信息是绝对写进去了的,那么既然服务器端没有收到,问题肯定在服务器端了,也就是说,服务器端根本就没有读取,或者说读取了丢掉了。接下来就该看服务器端的key_method_2_read代码逻辑了。
在key_method_2_read中,逻辑要复杂一些,但是基本框架还是简单的,无非就是key_method_2_write的逆过程:
static bool
key_method_2_read (struct buffer *buf,struct tls_multi *multi, struct tls_session *session)
{
…
//前面密钥协商相关的我不关心,因此从读取option开始
/*get options */
read_string(buf, options, TLS_OPTIONS_LEN)
//按照write的逆过程,接下来该读取username和password了,如果没有这两项,比如不使用username的话,那么也要读取两个0
/* should wecheck username/password? */
ks->authenticated= false;
if(session->opt->auth_user_pass_verify_script
|| plugin_defined(session->opt->plugins, OPENVPN_PLUGIN_AUTH_USER_PASS_VERIFY)
|| man_def_auth == KMDA_DEF) {
…
//在这个if分支中确实有读取peer-info的
#ifdef MANAGEMENT_DEF_AUTH
/* get peer info from control channel */
free (multi->peer_info);
multi->peer_info = read_string_alloc(buf);
…
#endif
//可是,根本就没有进入这个if分支,只有在相关plugin定义时才会进入
//因此对于peer-info的读取,并不是和peer-info的设置对称的,读取是有条件的。
} else {
//else分支中也没有读取peer-info
}
…
buf_clear(buf);//直接clear了,peer-info胎死腹中,烂在了buf里面
...
}
问题找到了,那么在else分支中加上读取peer-info的语句会怎样?那就是试试看,在else分支的最开始加上:
buf_read_u16 (buf); //略去一个0,代表username
buf_read_u16 (buf);//略去一个0,代表password
//至于为何这么写,还请看看前面介绍的OpenVPN的协议封装格式。
printf(“#####-peer-info:%s\n”, read_string_alloc (buf);
//最后将以上的字符串赋值给multi->peer_info即可。
//注意,对multi->peer_info的赋值和前面的打印对read_string_alloc是互斥的,因为该read函数会推进buf的当前指针。
multi->peer_info=read_string_alloc (buf);
再次测试,屏幕上打印出了客户端的信息。可是这些信息看样子没有什么用处啊,我该怎样把路由信息加入其中呢?这才是最关键的啊。大的框架问题已经解决,于是回过头来看push_peer_info函数,掠过无关紧要的信息,我看到了一个for:
for (e=es->list; e != NULL; e=e->next) {
if (e->string) {
//去掉前导字符串匹配限制,为了尽可能多的发送信息
if (/*!strncmp(e->string, "UV_", 3) &&*/ buf_safe(&out, strlen(e->string)+1))
buf_printf (&out, "%s\n", e->string);
}
}
可以看出,是从一个envset中取出的值加入了peer-info中。现在问题就转化成了如何将路由信息加入其中。在动手修改之前还是要深入理解OpenVPN本身是否支持某些现成的机制。再次help,发现了有一个—setenv-safe name value选项,真是太好了,看看能否从这里做点文章,于是在客户端配置文件中加入了setenv-safe route 172.17.0.0/16,再次测试,发现服务器端的multi->peer_info真的就包含了这个OPENVPN_route=172.17.0.0/16这条信息,只可惜所有的peer-info信息是分行的,不便于env解析操作,于是下面的修改就是将分行的改成单行的,这个太简单了,直接将push_peer_info中的\n换成;即可啦,而且正是因为setenv-safe传入的只是一个字符串,那么我就可以自己定义这个串的格式,完全可以支持由:分割的多条路由啦:
setenv-safe route 172.17.0.0/16:1.2.3.0/24:2.2.3.0/24:123.234.123.0/24
…
现在离最后的成功就差一步了,那就是既然OpenVPN服务器端得到了OpenVPN客户端的路由信息:
OPENVPN_route=172.17.0.0/16:1.2.3.0/24:2.2.3.0/24:123.234.123.0/24
那么怎么将其加入到OpenVPN服务器主机的系统路由表里面呢?可以想象,到达这些路由的网关一定指向分配给该客户端的虚拟网卡的IP地址,或者在TUN模式下直接指向本地的虚拟网卡。在什么情况下能得到分配给客户端的虚拟地址呢?最简单的方式就是使用plugin/script机制添加这些路由,于是添加了一个client-connect的script,因为在这个脚本中,已经为客户端分配了IP地址了,虽然该script可以覆盖这个地址,但不管怎么说都是在script内部完成的,于是在script中实现添加路由即可,对应的plugin是OPENVPN_PLUGIN_CLIENT_CONNECT 这个HOOK点,这里再次体现了OpenVPN事件机制的强大。
还有最后一步就是在调用script或者plugin之前将对应客户端的tls_multi->peer_info信息存入env中,而这个再简单不过了:
if (mi->context.c2.tls_multi->peer_info)
setenv_str(mi->context.c2.es,"peerinfo", mi->context.c2.tls_multi->peer_info);
两行即搞定了。接下来的代码就是:
if (plugin_defined (mi->context.plugins,OPENVPN_PLUGIN_CLIENT_CONNECT))
上述的代码在函数multi_connection_established中。
最后,看看client-connect的定义:
#!/bin/bash
gateway=$(printenv ifconfig_pool_remote_ip)
peer=$(printenv peerinfo)
for rt in `echo$peer | awk -F';' '{ for (i = 1; i<NF;i++) print $i }' |grep route|awk -F'=' '{print $2}'`;
do
forrrt in `echo $rt|awk -F ':' '{for (j = 1;j<=NF;j++) print $j}'`;
do
routeadd -net $rrt gw $gateway
done
done
最后再次测试,非常完美,客户端的路由完美的推送到了服务器端!
总结一下就是一共修改了以下几个地方,总共添加修改的代码不超过10行:
1. ssl.c中修改了key_method_2_read和push_peer_info函数
2. multi.c中修改了multi_connection_established函数
OpenVPN至此已经非常完美,非常对称。至此我可以说,OpenVPN已经可以完美兼容IPSec VPN的网络拓扑了。