delphij's Chaos

选择chaos这个词是因为~~实在很难找到一个更合适的词来形容这儿了……

22 Jan 2022

重建了 IPv6 隧道

之前 提到我换了一家ISP。这家ISP目前尚未提供IPv6服务,因此想要用IPv6的话就需要自己动手。 此前也有一些其他朋友问过我关于为什么一定要有 IPv6,毕竟 狼来了 的故事已经讲了这么多年, 而且 十几年前 IPv4 的中央地址池其实就已经用完了,只是在工业界抱残守缺^H^H^H^H食古不化^H^H^H^H我也不知道该管这种行为叫什么, 总之苟延残喘了十几年依然没有把服务迁移到 IPv6 上去,所以目前用 IPv6 往往也只是满足一下一些技术宅的恶趣味, 例如看 kame.net 那只能动的海龟之类。

当然,我自己用 IPv6 有一些更加现实的动力,比如我的一些服务是只在 IPv6 上提供的,这样做可以把那些做的不好的客户端直接排除在外, 有点类似于在当年我把网站的 TLSv1.2 和更早版本的 TLS/SSL 支持全都砍掉一样。

话说回来,由于新的 ISP 出于一些我不理解的原因死活不愿意支持 ping (唯一可能的解释是,这样做会导致扫描起来容易不少,毕竟扫描 IPv4 地址段比扫描 IPv6 地址段要容易太多了, 但安全不能建立在别人不知道的基础上,anyway,打电话、开票之后未能解决该问题),而大河的隧道服务又要求必须可以 ping 到终点,于是陷入了死循环。

既然两边都不愿意妥协,那么就只能自己动手了。

OpenVPN(失败)

OpenVPN 的文档 中提到的一种做法是把 /64 的网段拆成两个 /65, 乍一看感觉可行。这个做法大致的思路是把一个属于自己的 IPv6 /64 地址段拆成两半,然后把后一半 /65 用于家里的网络。

这个做法最大的问题是那个我无法控制的上游路由器必须配置为能够正确路由该 /65 网段到我的服务器,这个原因不难理解: 直接与该路由器连接的我自己的服务器通过 NDP 协议(大致对应于 IPv4 的 ARP 协议)告知了路由器它的以太网地址, 而在家里的网络显然是无法在该广播域广播 NDP 的。

除此之外,还有一个明显的问题便是 SLAAC。FreeBSD 的 rtadvd(8) 并不会告诉你 prefix 比 /64 不行, 甚至于相关的 RFC 4861RFC 4862 也通篇没有明示这件事,但事实上就是不行。这到底是为什么呢?我们来看下源代码:

首先是看 Router Advertisement 是如何处理的。稍微捋一下不难找到 sys/netinet6/nd6_rtr.c 里的这段:

static int
prelist_update(struct nd_prefixctl *new, struct nd_defrouter *dr,
    struct mbuf *m, int mcast)
{
[...]
	if (ia6_match == NULL && new->ndpr_vltime) {
		int ifidlen;

		/*
		 * 5.5.3 (d) (continued)
		 * No address matched and the valid lifetime is non-zero.
		 * Create a new address.
		 */

		/*
		 * Prefix Length check:
		 * If the sum of the prefix length and interface identifier
		 * length does not equal 128 bits, the Prefix Information
		 * option MUST be ignored.  The length of the interface
		 * identifier is defined in a separate link-type specific
		 * document.
		 */
		ifidlen = in6_if2idlen(ifp);
		if (ifidlen < 0) {
			/* this should not happen, so we always log it. */
			log(LOG_ERR, "prelist_update: IFID undefined (%s)\n",
			    if_name(ifp));
			goto end;
		}
		if (ifidlen + pr->ndpr_plen != 128) {
			nd6log((LOG_INFO,
			    "%s: invalid prefixlen %d for %s, ignored\n",
			    __func__, pr->ndpr_plen, if_name(ifp)));
			goto end;
		}

这里的 5.5.3(d) 指的是 RFC 4862 的 5.5.3 (d)。 该上下文中的 pr->ndpr_plen 是我们收到的 RA 广播中的前缀长度。

那么这个 in6_if2idlen() 会返回什么呢?我们接下来找到 sys/netinet6/in.c

/*
 * Provide the length of interface identifiers to be used for the link attached
 * to the given interface.  The length should be defined in "IPv6 over
 * xxx-link" document.  Note that address architecture might also define
 * the length for a particular set of address prefixes, regardless of the
 * link type.  As clarified in rfc2462bis, those two definitions should be
 * consistent, and those really are as of August 2004.
 */
int
in6_if2idlen(struct ifnet *ifp)
{
	switch (ifp->if_type) {
	case IFT_ETHER:		/* RFC2464 */
	case IFT_PROPVIRTUAL:	/* XXX: no RFC. treat it as ether */
	case IFT_L2VLAN:	/* ditto */
	case IFT_BRIDGE:	/* bridge(4) only does Ethernet-like links */
	case IFT_INFINIBAND:
		return (64);
	case IFT_PPP:		/* RFC2472 */
		return (64);
	case IFT_FRELAY:	/* RFC2590 */
		return (64);
	case IFT_IEEE1394:	/* RFC3146 */
		return (64);
	case IFT_GIF:
		return (64);	/* draft-ietf-v6ops-mech-v2-07 */
	case IFT_LOOP:
		return (64);	/* XXX: is this really correct? */
	default:
		/*
		 * Unknown link type:
		 * It might be controversial to use the today's common constant
		 * of 64 for these cases unconditionally.  For full compliance,
		 * we should return an error in this case.  On the other hand,
		 * if we simply miss the standard for the link type or a new
		 * standard is defined for a new link type, the IFID length
		 * is very likely to be the common constant.  As a compromise,
		 * we always use the constant, but make an explicit notice
		 * indicating the "unknown" case.
		 */
		printf("in6_if2idlen: unknown link type (%d)\n", ifp->if_type);
		return (64);
	}
}

对,你没看错,该函数无论参数如何都是返回 64。也就是说实践上 interface identifier 永远是 64 位。 因此绝大多数情况下 SLAAC 的地址前缀长度如果不是 64 的话会被客户端直接丑拒。

(╯°Д°)╯︵ ┻━┻

大意了。

这里记一笔,尽管 rtadvd.conf(5) 里允许指定任意长短的前缀, 实际上只有前缀长度是 64 的时候才能正常工作。原因是 RFC 4291, 其 2.5.1 小节如此规定:

   For all unicast addresses, except those that start with the binary
   value 000, Interface IDs are required to be 64 bits long and to be
   constructed in Modified EUI-64 format.

接下来便是想办法获得一个超过 /64 尺寸的地址块(出于显而易见的原因,我拒绝NAT66)。虽然大河不能直接给我搞一条隧道,但是从大河直接去大河机房拉个隧道是毫无问题的, 于是我搞了一个新的隧道,并申请了一个 /48 的地址块。

接着用 OpenVPN 把这个地址块塞回家。这里有个小插曲: OpenVPN 要求 IPv6 的 netbits 在 64 到 124 之间, 我找了很久也没找到相关的 RFC 依据,思考之下直接删去了这三处代码。不过,真正的问题并不在此:本质上,OpenVPN 在这里只是实现隧道两端,因此其实 /128 也是完全可以的。

通过 OpenVPN 给家里推了一条 2000::/3 (RFC 3587 section 2) 的路由,于是网关上看到了光, 可以连通外面的 IPv6 服务了。但是,通过 SLAAC 推给家里其他机器的 IPv6 地址去 ping 外面的 IPv6 地址却没有反应。

听包发现,在网关的 tun 设备上可以看到这些 ICMPv6 包,但是到另一端却没有了。此时 张师傅 提醒说你为什么不直接用 IPsec 呢? 考虑到已经在 OpenVPN 上浪费了不少时间,我认为是时候及时止损了。回退了之前在 OpenVPN 上的所有 hack,开始搞 IPsec VPN。

IPsec VPN

相对来说,搞 IPsec VPN 要简单不少。首先我们需要一个隧道,因为大河已经有了一个隧道设备 (gif0),我们再建一个新的 (gif1):

此处,198.51.100.1 是服务器的 IPv4 地址,203.0.113.1 是家里路由器的 IPv4 地址。 (并非实际地址;这两个是 RFC 5737 TEST-NET-2 和 TEST-NET-3 地址)。

2001:db8:55aa::1 是隧道的服务器端 IPv6 地址,2001:db8:55aa::2 是隧道家里路由器端的 IPv6 地址,这两个地址可以是链路地址,不过反正有个 /48 的地址空间,没必要那么省,直接用公网地址即可。

在服务器端的 /etc/rc.conf 中加入:

gifconfig_gif1="198.51.100.1 203.0.113.1"
ifconfig_gif1_ipv6="inet6 2001:db8:55aa::1 2001:db8:55aa::2 prefixlen 128"

对应地,在家里的路由器上配置:

gifconfig_gif0="203.0.113.1 198.51.100.1"
ifconfig_gif0_ipv6="inet6 2001:db8:55aa::2 2001:db8:55aa::1 prefixlen 128"

注意此处需要对应地调整 gif_interfaces 令其包含 gif1gif0,此处不赘述。

接下来分别在服务器和家里的路由器上执行 service netif start gif1service netif start gif0

如此一个不加密的隧道就建立起来了。分别在两边 ping 一下对端确认这条路是通的。

假设我们分给家中的 IPv6 地址段是 2001:db8:aa55::/48,我们需要告诉服务器这边把对应的 IPv6 数据包通过隧道转发过去,这是通过配置一条静态路由来实现的,在 /etc/rc.conf 中加入:

ipv6_static_routes="home"
ipv6_route_home="2001:db8:aa55::/48 2001:db8:55aa::2"

来实现。

与此对应,在家中的路由器上也需要配置对应的路由。由于家里没有其他的 IPv6 服务提供商,我们直接将默认路由设置为对端 IPv6:

ipv6_defaultrouter="2001:db8:55aa::1"

两边运行 service routing start inet6。在家中的路由器应该就能立即访问 IPv6 了。

不过搞到这里还不算完:虽然我访问的 IPv6 服务大部分是加密的,但是 DNS 流量并不是。隧道里面在传什么, 只要在网卡上听一下包就可以看到了(以 IPv4 为传输媒介传输 IPv6 流量)。这些数据路由到服务器之后, 只要发出去和收回来的时候服务器那边的 ISP 是能看到全部内容的,这个是没有办法的事,但是我并不是很乐意家里的 ISP 也能看到这些流量,因此非常有必要对隧道进行加密。

IPsec 我们都很熟悉了。FreeBSD 内核包括了 IPsec 的支持基础设施 (IPSEC_SUPPORT),但是并不包括 IPsec 本身。 加入 IPsec 只要 kldload ipsec 即可。

我们配置一条简单的 IPsec 规则,在家中的 /usr/local/etc/racoon/setkey.conf,此处我们使用 2000::/3 来指代整个 Internet 的 IPv6:

flush;
spdflush;
spdadd 2001:db8:aa55::/48 2000::/3 any -P out ipsec esp/tunnel/203.0.113.1-198.51.100.1/require;
spdadd 2000::/3 2001:db8:aa55::/48 any -P in ipsec esp/tunnel/198.51.100.1-203.0.113.1/require;

与之对应,服务器端基本上就是把两者对调:

flush;
spdflush;
spdadd 2000::/3 2001:db8:aa55::/48 any -P out ipsec esp/tunnel/198.51.100.1-203.0.113.1/require;
spdadd 2001:db8:aa55::/48 2000::/3 any -P in ipsec esp/tunnel/203.0.113.1-198.51.100.1/require;

racoon 的配置比较容易,我们采用 pre_shared_key 即可。首次运行 racoon 时建议使用 racoon -F -f /usr/local/etc/racoon/racoon.conf 以便观察其协商进展,注意在家中的路由器上把 nat_traversal 关掉。

继续在发出的网卡上听包,可以发现全部 IPv6 流量均封装到了 ESP 中,并且 setkey -D 应该给出正确的配置。

至此,IPv6 隧道重建完成。最近这段时间测试了一下,基本上符合预期,唯一的问题是分配到的这个隧道之前被其他人用过, 因此被一些公司认定位于中国,不过隧道服务提供商已经提供了新的数据,该问题目前已经得到修正。