使用 ip xfrm 手工配置 IPsec VPN

2022-09-29 ⏳5.8分钟(2.3千字)

WireGuard 是在 Linux 内核实现的 VPN 模块。得益于内核实现,它跟基于 tun 设备的 OpenVPN 相比,可以省掉在内核态和用户态之间复制数据,性能自然要好很多。但我从 WireGuard 技术白皮书得知,WireGuard 的性能只比 IPsec 高一点。这让我不禁怀疑 IPsec 的核心功能也是在内核态实现的。经过一番研究Google,果不其然。Linux 内核通过 xfrm 模块实现了最基础的 IP 数据加解密功能,而用户认证和密钥协商等功能则交由用户态的 IKE 服务完成。如果没有 IKE 服务,我们也可以通过手工执行 ip xfrm 命令配置 IPsec VPN。

本文假设 VPN 两边的节点都有公网 IP 地址。如果一方使用了 NAT,则需要涉及到 IPsec 的 NAT 穿透协议。限于文章篇幅,NAT相关问题我会单写一篇文章。

在实际操作之前,先给大家介绍一点理论知识。

IPsec 由一系列的 RFC 文档组成,最核心的是RFC4303,它定义了 ESP 协议,全称IP Encapsulating Security Payload。ESP 是跟 TCP/UDP 同级别的协议,它的协议号是 50。ESP 在 IP 头信息之后添加加密和认证相关字段,结构如下:

+------------------------------+
|           IP Header          |
+==============================+
|Security Parameters Index(SPI)|
+------------------------------+
|       Sequence Number        |
+------------------------------+
|      Encrypted Payload       |
+------------------------------+
| Padding | pad len | next hdr |
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|      Authentication Data     |
+------------------------------+

SPI 是一个三十二位整数,这个数字可以随便指定。相同 SPI 的数据使用相同的加密算法和密钥。Sequence Number 表示同一 SPI 内的加密数据自增编号,用于抵抗重放攻击。再后面就是加密后的数据。IPsec 分为传输模式和隧道模式。传输模式只加密原明文 IP 包中的 TCP/UDP 等数据部分,隧道模式会加密整个明文 IP 报文。

以 IPv4 报文为例,传输模式与隧道模式的区别如下:

BEFORE APPLYING ESP
----------------------------
|orig IP hdr  |     |      |
|(any options)| TCP | Data |
----------------------------

AFTER APPLYING ESP of TRANSPORT MODE
-------------------------------------------------
|orig IP hdr  | ESP |     |      |   ESP   | ESP|
|(any options)| Hdr | TCP | Data | Trailer | ICV|
-------------------------------------------------
                    |<---- encryption ---->|
              |<-------- integrity ------->|

AFTER APPLYING ESP of TUNNEL MODE
---------------------===============-----------------------
| new IP hdr* |     | orig IP hdr*  |   |    | ESP   | ESP|
|(any options)| ESP | (any options) |TCP|Data|Trailer| ICV|
---------------------===============-----------------------
                    |<--------- encryption --------->|
              |<------------- integrity ------------>|

在传输模式下,接收端收到数据解密后只能拿到 TCP/UDP 等数据,无法转发给其他主机,所以该模式只能用于点对点加密通信。而隧道模式则完整加密了整个 IP 报文,接收端解密后可以继续传发。所以隧道模式可以用于连接不同的网络。一般的 VPN 网络多使用隧道模式。但也可以先使用 GRE 等技术建立隧道,然后再用 IPsec 对 GRE 通信做加密,这个时候就可以用传输模式。本文只讲隧道模式。

一套 IPsec 配置要解决两个问题:

  1. 怎样加解密数据
  2. 要给哪些数据加密或者解密

这两个问题都由 Linux 内核的 xfrm 模块实现,我们可以使用 ip xfrm 管理相关配置。这里的 xfrm 读作 transform。

ip xfrm 有 state 和 policy 两个子命令,分别对应前面的两个问题。

假设网络拓朴如下:

     10.0.1.1      1.1.1.1    2.2.2.2      10.0.2.1
>-------------|R1|--------------------|R2|------------<
  10.0.1.0/24                              10.0.2.0/24

我们希望 10.0.1.0/24 网段可以访问 10.0.2.0/24 网段。我们首先需要设置加密算法。在 R1 和 R2 上执行如下指令:

ip xfrm state add src 1.1.1.1 dst 2.2.2.2 \
                  proto esp spi $ID \
		  reqid $ID \
		  mode tunnel \
		  aead 'rfc4106(gcm(aes))' $KEY 128
ip xfrm state add src 2.2.2.2 dst 1.1.1.1 \
                  proto esp spi $ID \
		  reqid $ID \
		  mode tunnel \
		  aead 'rfc4106(gcm(aes))' $KEY 128

下面以以 R1 为例,分析各参数作用。

网上很多资料都会使用 auth sha256 $KEY1 enc aes $KEY2 参数。这里加密使用 aes cbc,密钥是 $KEY2,数据完整性认证使用 hmac-sha256 算法,密钥为 $KEY1。这应该算是一种过时的用法。如果设备允许,推荐大家使用 rfc4106(gcm(aes)),这是一种同时支持加密和完整性校验的算法。此外 gcm 模式下的 aes 算法还支持并行加密,性能比 aes cbc 模式要好,配置起来也更简单,只需要生成一个 key。但是 RFC4106 规定 key 的长度只能是 20/28/36 三种情况,最后四个字节表示加密盐值,前面的部分表示 AES 密钥。

最后的 128 表示 aes-gcm 的 Integrity Check Value (ICV) 长度。RFC4106 要求至少支持 128 位长度,可选支持 62 位和 96 位。

因为要同时处理发送和接收两个方向的报文,所以需要添加两条 ip xfrm state,区别是 src 跟 dst 不一样。又因为用的是加密算法,所以 R2 设备上也要执行相同的命令。

我给的例子中发送和接收使用了相同的 SPI 和加密密钥,这纯属图方便。我们也可以为不同方向的数据包设置独立的加密密钥。但如果要分开设置,那么 R1 的发送密钥得跟 R2 的接收密钥对应,反之亦然。

上面命令中还有一个 reqid $ID 参数,这是用来跟 ip xfrm policy 关联的,解决要为哪些包加解密的问题。现在我们分析 ip xfrm policy 命令。

跟 ip xfrm state 不同,R1 和 R2 需要分别执行不同的 ip xfrm policy 命令。我们以 R1 为例讲解。

ip xfrm policy add src 10.0.1.0/24 dst 10.0.2.0/24 dir out \
                   tmpl src 1.1.1.1 dst 2.2.2.2 \
		   proto esp reqid $ID mode tunnel
ip xfrm policy add src 10.0.2.0/24 dst 10.0.1.0/24 dir fwd \
                   tmpl src 2.2.2.2 dst 1.1.1.1 \
		   proto esp reqid $ID mode tunnel
ip xfrm policy add src 10.0.2.0/24 dst 10.0.1.0/24 dir in \
                   tmpl src 2.2.2.2 dst 1.1.1.1 \
		   proto esp reqid $ID mode tunnel

每一条 add 之后都会指定 src 和 dst,用来匹配未加密的 IP 报文。dir 表示 direction,有 out/fwd/in 三种方向。tmpl 后面表示处理规则或者 ip xfrm stat 匹配规则。

第一条命令的意思是:对于从 10.0.1.0/24 网段发往 10.0.2.0/24 网段的数据包,对其执行 ESP 加密,使用隧道模式,且加密参数通过 reqid $ID 从 ip xfrm state 中查找。

第二条和第三条命令虽然形式跟第一条类似,但功能却大不相同,主要用于过滤解密后的 IP 报文。我们前面已经添加了 ip xfrm state,所以所有从 R2 发给 R1 的 ESP 报文都会被 R1 尝试解密。如果解密成功,R1 会根据 stat 的 reqid 找对应的 policy,如果解密后的 IP 报文跟 policy 的 src/dst/dir 匹配,则接收或者转发给对应的设备,否则丢弃报文。

细心的读者会发现,这里好像缺一条从 10.0.1.0/24 到 10.0.2.0/24 的 fwd 规则。我当时也纳闷,但查了网上的资料发现,这个方向的数据包会走 out 规则。具体可以参考这里

我总结下来就是所有出站数据走 out 规则,入站数据走 fwd 和 in 规则。如果解密后的 IP 报文目标地址为 R1,则会命中 in 规则。因此,最多只需要设置 out/fwd/in 三条规则。

最后,我们需要在 R2 上执行类似的命令,但要调换 src 和 dst 的顺序:

ip xfrm policy add src 10.0.2.0/24 dst 10.0.1.0/24 dir out \
                   tmpl src 2.2.2.2 dst 1.1.1.1 \
		   proto esp reqid $ID mode tunnel
ip xfrm policy add src 10.0.1.0/24 dst 10.0.2.0/24 dir fwd \
                   tmpl src 1.1.1.1 dst 2.2.2.2 \
		   proto esp reqid $ID mode tunnel
ip xfrm policy add src 10.0.1.0/24 dst 10.0.2.0/24 dir in \
                   tmpl src 1.1.1.1 dst 2.2.2.2 \
		   proto esp reqid $ID mode tunnel

完成上述配置后,10.0.1.0/24 网段就可以跟 10.0.2.0/24 通信了。但 IPsec 跟 OpenVPN 或者 WireGuard 很不一样,整个 VPN 系统基于内核的规则来运行,并没有产生新的虚拟网卡设备。所以默认情况下在 R1 和 R2 上无法访问对方的私有网段。为此,我们可以在台设备上各加一条路由:

# R1
ip route add 10.0.2.0/24 dev eth0 src 10.0.1.1
# R2
ip route add 10.0.1.0/24 dev eth0 src 10.0.2.1

这里假设 R1 跟 R2 的公网网卡都是 eth0。最关键的就是后面的 src 参数,指定了从 R1/R2 访问对端网络所使用的来源 IP 地址。只有 10.0.1.0/24 和 10.0.2.0/24 之间的流量才会被 xfrm 处理。如果没有这条路由,R1/R2 会尝试使用自己的的公网 IP 地址给对方私网发报文,自然不会命中 xfrm 中的规则。

最后提一下路由器的 NAT 转换问题。因为实验需要公网 IP 地址,所以我在自己的家用宽带路由器和一台公网 VPS 之间做实验,但总是无法连接。经过抓包发现,家宽路由器会改写 IP 报文的来源地址,从而无法命中 xfrm 规则。最简单的处理办法就是给防火墙的 MASQUERADE 规则加上来源网段限制。

如果 R1 和 R2 可以通过 SSH 相互登录,我们可以把以上命令整理成一个 shell 脚本:

#!/bin/sh
# manual-ipsec.sh

# 检查参数
if [ "$6" == "" ]; then
    echo "usage: $0 <local_ip> <remote_ip> <new_local_net> <new_local_ip> <new_remote_net> <new_remote_ip>"
    echo "creates an ipsec tunnel between two machines"
    exit 1
fi

SRC="$1"
DST="$2"
LOCAL="$3"
LOCAL_IP="$4"
REMOTE="$5"
REMOTE_IP="$6"

# 生成 reqid 和 AES 密钥
ID=0x`dd if=/dev/urandom count=4 bs=1 2> /dev/null| xxd -p -c 8
KEY=0x`dd if=/dev/urandom count=20 bs=1 2> /dev/null| xxd -p -c 40`

sudo ip xfrm state flush && sudo ip xfrm policy flush
sudo ip xfrm state add src $SRC dst $DST proto esp spi $ID reqid $ID mode tunnel aead 'rfc4106(gcm(aes))' $KEY 128
sudo ip xfrm state add src $DST dst $SRC proto esp spi $ID reqid $ID mode tunnel aead 'rfc4106(gcm(aes))' $KEY 128
sudo ip xfrm policy add src $LOCAL dst $REMOTE dir out tmpl src $SRC dst $DST proto esp reqid $ID mode tunnel
sudo ip xfrm policy add src $REMOTE dst $LOCAL dir in tmpl src $DST dst $SRC proto esp reqid $ID mode tunnel
sudo ip xfrm policy add src $REMOTE dst $LOCAL dir fwd tmpl src $DST dst $SRC proto esp reqid $ID mode tunnel
sudo ip route add $REMOTE dev eth0 src $LOCAL_IP

# 登录到对端机器并执行相关命令
ssh $DST /bin/bash << EOF
    sudo ip xfrm state flush && sudo ip xfrm policy flush
    sudo ip xfrm state add src $SRC dst $DST proto esp spi $ID reqid $ID mode tunnel aead 'rfc4106(gcm(aes))' $KEY 128
    sudo ip xfrm state add src $DST dst $SRC proto esp spi $ID reqid $ID mode tunnel aead 'rfc4106(gcm(aes))' $KEY 128
    sudo ip xfrm policy add src $REMOTE dst $LOCAL dir out tmpl src $DST dst $SRC proto esp reqid $ID mode tunnel
    sudo ip xfrm policy add src $LOCAL dst $REMOTE dir in tmpl src $SRC dst $DST proto esp reqid $ID mode tunnel
    #sudo ip route add $LOCAL dev eth0 src $REMOTE_IP
EOF

然后可以在 R1 上执行下面的脚本就可以配置好 IPsec VPN。

./manual-ipsec.sh 1.1.1.1 2.2.2.2 10.0.1.0/24 10.0.1.1 10.0.2.0/24 10.0.2.1

以上是本文的全部内容。跟网上的同类内容相比,本文详细分析了各命令和参数的作用,给出了双公网设备搭建 IPsec 隧道的方法。限于篇幅,诸如 NAT 穿透和密钥 rekey 等关键问题没能详细讨论,我后续会撰写专门文章,敬请期待。

参考文献: