正确地配置 IPv6 防火墙和 DDNS 以在公网访问设备

IPv6 已经相当普及,无论是家宽、校园网,抑或是蜂窝移动网络,获得 /64 的 IPv6 子网已经不是难事。本文意在解决:在获得 IPv6 公网地址(全球单播地址)且 ISP 未阻止入站连接的前提下,如何在路由器上配置防火墙及如何配置 DDNS,以便于在公网访问自己的设备。

环境

  • 路由器:OpenWrt 21.02.0-rc3
    • 绝大部分厂商的原厂固件的 IPv6 防火墙都是残缺的,请不要使用原厂固件
    • 光猫应当配置为桥接模式并使用路由器拨号,请不要让光猫承担它不该承受之重
  • 需要暴露的主机:Debian GNU/Linux 11
    • 当然也通用于其他 Linux 发行版

准备工作

确认已经取得 /64 的 IPv6 子网

一般可在路由器管理页面确认,也可通过其他方式确认。
如果获得的子网大小不是 /64 ,本文仍可供参考,但需要有一些小改动。
如果只能取得 /128 的子网(整个子网只有一个地址),这种梦回 IPv4 时代的情况通常出现在校园网,考虑到没法换 ISP,要不……行而上学,不行退学?
下文中,我们假定取得的子网是 2001:2002:2003:2004::/64

确认 ISP 没有阻止入站连接

如果路由器使用的是 OpenWrt,默认的防火墙规则不会阻止 ICMP 入站连接;如果是其他路由器固件,请先完全关闭防火墙。
本文假设系统防火墙已经被恰当配置,不会阻止需要暴露的端口的入站连接。

  1. 访问 ip.sb,取得当前设备的 IPv6 临时公网地址,我们假设它是 2001:2002:2003:2004::1234
  2. 尝试在外部网络 ping 上述 IPv6 公网地址。
    • 最简单的方式是,在 Android 手机上通过 Termux 使用蜂窝移动网络 ping。(应当先确定 Termux 能连接到 IPv6)
  3. 如果能 ping 通,则进行下一步。如果不能,通常没有必要再进行下一步。
  4. 向 OpenWrt 添加一条通信规则(访问 cgi-bin/luci/admin/network/firewall/rules)以临时允许入站连接:
    • 允许 TCP 和 UDP,源区域为 wan (包含 wan, wan6),目标区域为 lan,操作为接受,其余留空,保存并应用。
  5. 在本机挂载一个 web 服务,如 python3 -m http.server 83218321 可以替换为自己选定的端口(如为 80, 443 等端口,可能需要 root 权限)。
  6. 尝试在外部网络访问这个端口,如 curl http://[2001:2002:2003:2004::1234]:8321
  7. 更换端口重试几遍,重点留意 80, 443, 8080 等常见端口有无被封锁;如被封锁,尝试不常见的端口。
  8. 关闭 web 服务,删除先前创建的通信规则,保存并应用。如果是其他路由器固件,请重新打开防火墙,并想办法刷入 OpenWrt。

如果 ISP 阻止了入站连接,请考虑换一家 ISP。
有些路由器的原厂固件可能无法关闭 IPv6 防火墙并造成假阳性结果,请考虑使用 OpenWrt

取得设备的不变后缀

选项一:EUI-64(推荐)

EUI-64 地址有着我们需要的优点:后缀固定不变、前缀实时更新。一般情况下,我们应该使用它。如果获得的子网大小不是 /64,可能不按预期工作。

Linux:

1
sudo ifconfig

Windows(需要提权):

1
2
set-netipv6protocol -RandomizeIdentifiers Disabled  # Windows 默认不使用 EUI-64,启用它
ipconfig /all
  1. 找到目前用于连接互联网的网络适配器。
  2. 找到一个 IP 地址,它应该类似于 2001:2002:2003:2004:****:**ff:fe**:****,它就是该设备的 EUI-64 地址。
  3. 记下 ****:**ff:fe**:**** 的部分,下文假设它是 1:20ff:fe77:2077
  4. 验证 EUI-64 地址(可选):参见 通过EUI-64自动生成IPv6地址和IPv6链路本地地址(Link-Local Address) | CCIE 工程师社区

选项二:不使用不变后缀,使用临时 IPv6 地址

并不推荐使用临时地址,这将导致防火墙规则难以编写。

请直接跳到下一节。

选项三:DHCPv6

并不推荐使用 DHCPv6,因为它总是落后于网络变化。如果家宽重拨导致分配到的 v6 子网变化,它根本不会自动更新,只有等到租约到期(一般是 12h)续租时才会更新。

首先选定一个 IPv6 后缀如 a1cd(也可不选定。默认分配的后缀一般是设备特定且不变的,不选定则自行记录默认分配的后缀)。
注意:有时候,在系统的默认配置下并没有完整的 stateful DHCPv6 支持,此时请使用其他方法。本文亦不含有打开 stateful DHCPv6 的教学。(DHCPv6 坏坏)

  1. 在 OpenWrt 为需要的设备创建一个静态地址分配(访问 cgi-bin/luci/admin/network/dhcp),指定租期如 5m(因租约到期才会自动刷新,必须设定为较短的值以确保 DHCPv6 分配维持最新),填写选定的 IPv6 后缀(也可留空),保存并应用。
  2. 物理上重新连接目标设备。
  3. 刷新页面,在页面底部的 已分配的 DHCPv6 租约 里确认确实为该设备正确分配了需要的后缀和租期
  4. 如果租期没有被正确分配,仍为默认的 12h,则该设备不适合该方法。(除非能获得不变的 IPv6 子网)

配置防火墙

向 OpenWrt 添加一条通信规则(访问 cgi-bin/luci/admin/network/firewall/rules):

  • 协议:TCP 和 UDP
  • 源区域:wan (包含 wan, wan6)
  • 目标区域:lan
  • 目标地址:
    • 使用 EUI-64 或 DHCPv6:::<需要暴露的主机的后缀>/::ffff:ffff:ffff:ffff,如 ::1:20ff:fe77:2077/::ffff:ffff:ffff:ffff::a1cd/::ffff:ffff:ffff:ffff(如果子网大小不是 /64,请自行改变掩码)
    • 使用临时地址:留空(注意:这会导致其他主机一并暴露,请避免同时留空目标端口)
  • 目标端口:
    • 暴露特定端口:<需要暴露的端口或端口范围>
    • 整台主机暴露:留空
  • 操作:接受
  • 高级设置
    • 限制地址类型:仅 IPv6
  • 其余维持默认
    • 特别提醒:源端口留空,只填目标端口

配置 DDNS

编写更新脚本

以 Cloudflare API 为例。
替换如下脚本中的环境变量,存放至合适的位置。

DNS ID 需要通过 Cloudflare API 查询获得,也可通过审查元素抓包获得。

使用 EUI-64 或 DHCPv6

在需要暴露的主机上运行,也可在同一子网下别的主机(含路由器)上运行,因此适合需要暴露的主机为 Windows 等比较坏坏的操作系统的情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
CURRENT_IP=$(curl -s ipv6.ip.sb)
CURRENT_PREFIX=$(echo $CURRENT_IP | cut -d : -f 1-4) # 如果子网大小不是 /64,此处需要修改
SUFFIX=<固定后缀>
DNS_RECORD=<域名>
ZONE_ID=<Zone ID>
DNS_ID=<DNS 记录 ID>
TOKEN=<API token>
if [ "$(cat /run/current_prefix 2>/dev/null)" != "$CURRENT_PREFIX" ]; then
echo $CURRENT_PREFIX > /run/current_prefix
curl -s \
-X PUT "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records/$DNS_ID" \
-H "Content-Type:application/json" \
-H "Authorization: Bearer $TOKEN" \
--data '{"type":"AAAA","name":"'$DNS_RECORD'","content":"'$CURRENT_PREFIX:$SUFFIX'","ttl":1,"proxied":false}'
fi

使用临时地址

只能在需要暴露的主机上运行。

1
2
3
4
5
6
7
8
9
10
11
12
13
CURRENT_IP=$(curl -s ipv6.ip.sb)
DNS_RECORD=<域名>
ZONE_ID=<Zone ID>
DNS_ID=<DNS 记录 ID>
TOKEN=<API token>
if [ "$(cat /run/current_ip 2>/dev/null)" != "$CURRENT_IP" ]; then
echo $CURRENT_IP > /run/current_ip
curl -s \
-X PUT "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records/$DNS_ID" \
-H "Content-Type:application/json" \
-H "Authorization: Bearer $TOKEN" \
--data '{"type":"AAAA","name":"'$DNS_RECORD'","content":"'$CURRENT_IP'","ttl":1,"proxied":false}'
fi

定时执行

使用 crontab

1
crontab -e

增加一行如下,注意替换 /path/to/script/。示例为每分钟执行一次,如需修改,可参考 crontab.guru

1
* * * * * /path/to/script/setDNS.sh

使用 systemd

setDNS.service

放置到 /etc/systemd/system/ 下,注意替换 /path/to/script/

1
2
3
4
5
[Unit]
Description=check IPv6 address and set DNS record

[Service]
ExecStart=/bin/sh /path/to/script/setDNS.sh

setDNS.timer

放置到 /etc/systemd/system/ 下。

1
2
3
4
5
6
7
8
9
[Unit]
Description=monitor IPv6 address and set DNS record minutely

[Timer]
OnBootSec=1min
OnUnitActiveSec=1min

[Install]
WantedBy=timers.target

即时启动并添加到开机启动。

1
2
3
sudo systemctl daemon-reload
sudo systemctl start setDNS.timer
sudo systemctl enable setDNS.timer