文章

MikroTik ROS 开荒: 公网访问与 ZeroTier 组网

我的环境:

  • 硬件: Mikrotik RB5009UG+S+IN
  • 系统: ROS 7.16
  • 宽带: 江苏电信千兆家庭宽带,光猫桥接

阅读本文需要一定的网络知识,了解 DNS, DDNS, 路由等网络概念。

前言

OpenWrt 是个好东西,但终究不是一个专业的路由器。从个人角度,主要问题是 ① 设置杂乱,加上翻译问题,经常不知所云。② 命令行不友好,原生 Linux 远不如 Cisco 等专业系统配置起来方便。另外就是太灵活了,灵活到总是给人不安全感。终于,决定把家里网络换成主路由 + 旁路网关的模式,让路由器回归本质。

诚然,与 OpenWrt 相比,RouterOS 没有那么直观,界面也非常复古,但从专业角度,无论是 WebUI 还是命令行,配置起来其实更加顺手。Quick Set 的加入使得新手(需要具备基础网络知识)也能快速完成 PPPoE 拨号配置,缺省的防火墙规则也足够安全。本文不会关注 ROS 的基本使用,假定你已经或手动或通过 Quick Set 配置,能够正常访网络。此外,本文的目的是尽可能地解释清楚每个配置背后的原理,而不是直接给出命令。所以如果你在寻找一个无脑配置清单,或许其他文章更适合你。

访问光猫

光猫改完桥接模式后便无法直接访问到了。解决方法非常简单,在给光猫的那个物理接口手动分配一个与光猫同网段的 ip 即可。例如我的光猫 ip 为 192.168.1.1,那在「IP - Address - Add New」:

  • Address: 自定义一个同网段且不冲突的 IP,例如 192.168.1.2
  • Network: 光猫所在的网段,例如 192.168.1.0
  • Interface: 连接光猫的物理接口,通常应该是 etherX,注意不要选 pppoe-out1

这样应该就已经可以了。部分教程说还要配置防火墙的 NAT 或 Mangle,但我认为不需要,实测也不需要。

端口映射

基本配置

从外部访问的端口映射本质上是 dnat,点击「IP - Firewall - Add New」添加规则:

General

  • Chain: dstnat
  • Dst. Address: 路由器的公网 IP。(我知道这个会变,先填当前的再说)
  • Protocal: 根据需求填写,多为 tcp
  • Dst. Port: 从外网访问的端口号。

Action

  • Action: dst-nat
  • To Addresses: 要转发到的内网地址。
  • To Ports: 要转发到的内网端口。

此时从外部应该就可以访问了,但是还有两个问题:

  • 无法从内网通过公网 ip 访问其他内网服务。
  • 公网 ip 变更后无法及时更新。

回流

目前内部主机无法通过公网 IP 访问映射的其他内网服务。在解决之前先来分析下原因,假设我们内网主机 A 192.168.50.2 试图通过公网地址 20.1.1.1(已配置好端口转发)访问内网服务器 S 192.168.50.10,数据流如下:

  1. A 的数据发送到公网地址 20.1.1.1,最终到达路由器。
  2. 因为已经配置了端口转发(DNAT),路由器会重写目的地址,然后把数据包发给 S。
  3. S 向源地址发送响应。注意,DNAT 不会修改源地址,因此这里是 192.168.50.2,即主机 A 的内网地址。
  4. 因为源地址与 S 在同一网段,因此响应数据通过二层转发送达,那么主机 A 收到的响应的源地址是 192.168.50.10,即服务器 S 的内网地址,而不是一开始的公网地址。此时问题出现了,A 不会认可这个响应,无法真正建立连接。

所以我们要让服务器 S 响应给路由器,而不是 A 的内网地址,因此路由器不仅要重写目的地址,也需要重写源地址。具体来说需要添加一个 SNAT。

点击「IP - Firewall - NAT - Add New」:

General

  • Chain: srcnat
  • Src. Address: 路由器网段,例如 192.168.50.0/24
  • Out. Interface List: LAN

Action

Action: masquerade

IP 更新

家庭宽带得到的公网 IP 都是随时变动的,需要实时将新的地址更新到 DNAT 中。为此我们得编写一些脚本。点击 「System - Scripts - Add New」:

  • Name: 随意填写,用于引用脚本,例如 dynamic_nat

Source 如下,注意修改前两个变量 pppoenatComment,分别是 pppoe 的逻辑接口名与 DNAT 规则的注释。因为防火墙规则没有 name 属性,所以只能用注释来识别。

# name of pppoe interface
:local pppoe "pppoe-out1"
# comment of dnat rule
:local natComment "port_mapping"

:if ([/interface get [/interface find name=$pppoe] running]=true) do={
   :local ipaddr [/ip address get [/ip address find dynamic=yes interface=$pppoe] address]
   :set ipaddr [:pick $ipaddr 0 ([len $ipaddr] -3)]
   :local oldip [/ip firewall nat get [/ip firewall nat find comment=$natComment] dst-address]
   :if ($ipaddr != $oldip) do={
       /ip firewall nat set [/ip firewall nat find comment=$natComment] dst-address=$ipaddr
       :log info ("changed dst-address of " . $natComment . ": " . $oldip . " -> " . $ipaddr)
   } else={
       :log info ($pppoe . "'s ip not changed: " . $oldip)
   }
} else={
   :log info ($pppoe . "not running, stop updating ip address")
}

有了脚本,接下来要设置触发方式,理想情况下我们 IP 变动后即时修改,通常唯一可能发生 IP 改变的时机就是 pppoe 重新连接。所以这里使用 profile 特性自动执行我们的更新脚本。

进入「PPP - Profiles - default」,如果没有就新创建一个。在 Scripts 节可以看到 On Up 输入框,这里面的脚本就是在连接成功时自动执行的,新增下面的指令:

delay 3s
:execute "dynamic_nat"

注意 :execute 后面的名字要和刚才创建的脚本名匹配。然后进入「Interfaces - pppoe - Dial Out」确认 Profile 是刚才设置的那个。

配置好后可以尝试禁用再重新启用 pppoe 接口,应该可以看到类似下面的日志(/log print):

18:17:34 script,info changed dst-address of port_mapping: 49.68.119.48 -> 49.68.118.71

但为了以防万一,最好再设置一个定时任务来兜底。

点击「System - Scheduler - Add New」:

  • Name: 任意填写,比如 update_dnat_ip

  • Start Time: startup

  • Interval: 任意设置,比如 5 分钟 00:05:00。

  • On Event: 注意更改为刚才创建的脚本名称

    :execute "dynamic_nat"
    

多规则更新

现在还有一个问题,如果设置多个端口映射,每个规则需要分别更新 IP 非常麻烦。我们创建一个新的 NAT 规则:

General

  • Comment: 随便填,但这个需要在上文更新 IP 脚本中的引用,也就是 natComment 变量的值。
  • Chain: dstnat。
  • Dst. Address: 路由器公网 IP,我们的脚本应该会更新它。

Action

  • Action: Jump
  • Jump Target: 新建一个,比如 port-mapping

然后编辑所有端口映射 DNAT 规则:

  • Chain: 改为刚才的 Jump Target,也就是 port-mapping
  • Dst. Address: 清空,不再设置

其他保持不变。这样一来只需要用一个脚本更新一个 Action 为 Jump 的规则就行了。

DDNS

设置脚本

ROS 自带 DDNS 服务称为 Cloud,但在大陆水土不服,似乎是更新解析的 API 被墙了。要使用第三方 DDNS 服务需要自己编写脚本,其核心是通过 /tool fetch 命令发送 http 请求。所以理论上可以接入任何提供 HTTP API 的 DNS 厂商。很多小伙伴喜欢使用阿里云或腾讯云的 DNS 服务,但免费的套餐 TTL 较长,不适合 IP 经常变动的场景。所以这里以 dynv6.com 为例,这是一个免费的同时支持 IPv4 与 IPv6 的 DDNS 服务,并且针对 IPv6 提供前后缀组合。

点击「System - Scripts - Add New」创建新脚本,名称随意,例如 ddns_update,输入下面的内容,记得设置自己的 token 和域名:

#!rsc by RouterOS
#
# Used to update dynv6 DNS records.
#
# Args:
#   ddnsnic: the name of pppoe interface
#   token: dynv6 account token
#   zone: zone registered on dynv6

:local ddnsnic "pppoe-out1"
:local token "xxx"
:local zone "xxx.dns.navy"

:global ddnsCurIPv4
:global ddnsCurIPv6

:local ipv4 [/ip address get [/ip address find dynamic=yes interface=$ddnsnic] address]
# "49.68.118.208/32" -> "49.68.118.208"
:set ipv4 [:pick $ipv4 0 [:find $ipv4 "/"]]
:local ipv6 [/ipv6 dhcp-client get [/ipv6/dhcp-client find interface=$ddnsnic] prefix]
# "240e:aaaa:bbbb:cccc::/60, 2d23h5m11s" -> "240e:aaaa:bbbb:cccc::/60"
:set ipv6 [:pick $ipv6 0 [:find $ipv6 ","]];

:if ( [:typeof $ipv4]="nothing" && [:typeof $ipv6]="nothing" ) do={
    :log info "ddns: no IP address (4&6) on $ddnsnic, clear records..."
    :local url "https://dynv6.com/api/update?zone=$zone&token=$token&ipv4=-&ipv6prefix=-"
    /tool fetch http-method=get url=$url keep-result=no
} else={
    :local ipv4str $ipv4
    :local ipv6str $ipv6
    :if ([:typeof $ipv4]="nothing") do={ :set ipv4str "-" }
    :if ([:typeof $ipv6]="nothing") do={ :set ipv6str "-" }

    :if ($ipv4=$ddnsCurIPv4 && $ipv6=$ddnsCurIPv6) do={
        :log debug "ddns: IP address (4&6) on $ddnsnic has not changed, [$ipv4str] [$ipv6str]"
    } else={
        :local url "https://dynv6.com/api/update?zone=$zone&token=$token&ipv4=$ipv4str&ipv6prefix=$ipv6str"
        /tool fetch http-method=get url=$url keep-result=no
        :log info "ddns: IP address updated: [$ipv4str] [$ipv6str]"
    }
}

:set ddnsCurIPv4 $ipv4
:set ddnsCurIPv6 $ipv6

如果使用其他提供商,可以参考着修改脚本,应该不难理解。

脚本详解

  1. 从指定的接口读取 IPv4 与 IPv6 并预处理为需要的格式(针对 dynv6 API)。
  2. 若 IPv4 与 IPv6 均不存在则清除已有的 DNS 记录。
  3. 若地址发生改变则更新 DNS 为新地址或清除记录。
  4. 更新本地缓存,用于识别下一次地址变更。

同样,我们利用 PPP 的 profile 来在重新拨号时触发 DDNS 更新,并且设置一个定时任务来兜底,具体参见「端口映射 - IP 更新」章节,这里不赘述了。

多设备 IPv6

理论上每个设备都有不同的 IPv6 地址,所以需要分别部署 DDNS,显然这非常麻烦甚至不现实。一个优化方案是编写复杂的路由器脚本来分别更新(因为路由器理应知道每个子设备的地址),这依然很麻烦而且不够稳定(会大量调用更新 API)。

更好的方案是,利用 IPv6 可以固定后缀的特性,只更新运营商分配给我们(会改变)的前缀,手动输入后缀,然后让 DDNS 服务商帮我们组合成真实地址。dynv6.com 就支持这种功能(而阿里云 DNSPOD 等传统服务不行)。

提示:部分地区屏蔽了 dynv6 的免费域名访问,所以建议使用自己的域名 CNAME 过去,或者干脆把一个子域(比如 dns.example.com)接入 dynv6,如果需要的解析的主机名很多这样会更简单,而且也不会影响根域名的解析。

上述脚本就是基于前缀更新功能,具体来说,假设更新的 zone 是 chenhe.dns.navy,脚本实际更新的是 chenhe.dns.navy 的 IPv4 地址以及 IPv6 前缀。现在有一个主机 nas.chenhe.dns.navy 希望通过域名从公网访问,那么需要前往 dynv6 后台「Zones - Records」添加 2 条记录:

  • AAAA, nas.chenhe.dns.navy, v6DATA
  • CNAME, *.chenhe.dns.navy, chenhe.dns.navy

其中 v6DATA 根据路由器配置的不同可以是 NAS IPv6 的后缀,也可以是它的 mac 地址(SLAAC 默认通过 mac 地址计算 IPv6 后缀)。dynv6 会自动把配置的后缀与 zone 的前缀拼接起来。

至于 CNAME 的泛解析,是为了让 IPv4 栈也可以顺利访问,当然,它的前提是 ①有公网地址;②路由器配置好了端口映射。如果不需要 IPv4 公网访问,就不要配置这条记录。

IPv6

获取公网地址

警告:获取公网地址意味着你的路由器(甚至所有子网设备)均已暴露在互联网,我的 ROS Quick Set 默认配置了一些防火墙规则以保证安全。如果你的防火墙规则是空白,请参阅其他帖子补全之。

首先要启用 IPv6,进入「PPP - Profiles」点击实际使用的那个(通常是 default),在 Protocols 节中确认 Use IPv6 为 yes。

enable IPv6 in PPP profile

其次进入「IPv6 - Settings」,确认没有选中 Disable IPv6 并开启 IPv6 Forward。

IPv6 Settings

接下来设置 IPv6 DHCP 客户端以便从运营商那里拿到一个前缀。点击「IPv6 - DHCP Client - Add New」:

  • Interface: 拨号的逻辑接口,通常是 pppoe-out1。
  • Request: prefix 通常运营商都是分配前缀。
  • Pool Name: 自定义,例如 ipv6
  • Pool Prefix Length: 无子网划分需求填写 64 即可,即往下分配的都是具体的 IPv6 地址。
  • Prefix Hint: 用于指示希望得到的前缀长度,但实营运商有决定权,所以用处不大。
  • Use Peer DNS: 关闭,如果你之前已经自己配置了 DNS 服务器的话。(IPv4 的 DNS 也可能解析出 IPv6 的地址,不一定要配置地址为 IPv6 的上游 DNS 服务器)。

添加后应该就可以取得公网 IPv6 地址了,如图:

IPv6 Address

分配给内网设备

背景知识

有两种方式分配 IPv6:SLAAC (Stateless Address Autoconfiguration) 与 DHCPv6。SLAAC 没有中心服务器来「分配」,各个主机通过协议自行生成、协商、通告地址。SLAAC 是唯一全平台支持的方式Android 明确不会支持有状态 DHCPv6(尽管近期略微松口),谷歌认为有状态协议对于终端用户没有明显优点,还会造成隐私问题,属于 IPv4 时代的陋习。

其实 SLAAC 可以与 DHCPv6 搭配使用,称为无状态 DHCPv6。此时 IP 地址本身还是通过 SLAAC 生成,但通过 DHCPv6 服务器获取其他参数,例如网关地址、DNS 等。

SLAAC 的关键数据是路由器定期发送的 RA(路由通告),其包括了前缀信息,以及是否使用无状态 DHCPv6 的指示。

这里我们配置纯无状态 SLAAC。ROS 中默认启用 RA,通告的具体数据由多项 IPv6 子菜单的设置共同决定。

为了让 RA 广播可用的前缀,需要点击「IPv6 - Addresses - Add New」中为 LAN 接口分配地址:

  • Address: 保持默认让系统自动生成。
  • From Pool: 就是之前 DHCPv6 Client 里配置的地址池。
  • Interface: 选择 LAN 接口,通常是 bridge。
  • EUI64: 若勾选则系统根据 LAN 接口的 mac 生成地址。
  • Advertise: 勾选,从而通告这一地址。

添加后「IPv6 - ND - Prefixes」中应该自动出现了一个带有 D 标记的前缀,这就是刚才添加的地址自动生成的:

Auto generated ND prefix

当从运营商获取的前缀变化时,这里以及 Addresses 处分配的地址都会自动更新,不用担心。

根据 RFC 9096 Improving the Reaction of Customer Edge Routers to IPv6 Renumbering Events 的建议,在这里点击「Default」按钮 修改通告的前缀有效期:

  • Valid Lifetime: 01:30:00 90 分钟。
  • Preferred Lifetime: 00:45:00 45 分钟。

Edit RA prefix default lifetime

这样可以缓解网络信息突然变化(路由器因故没有发送相应信号,例如突然重启)时对客户端的影响。

现在,所有子网设备应该都可以得到公网 IPv6 地址了。

公网访问

IPv6 DDNS 说明请参考 DDNS 章节。

分配了公网 IP,有了 DDNS,理论上可以从公网访问任何一个设备了——如果防火墙允许的话。ROS 缺省的防火墙会阻止所有不是 LAN 口的未知流量,主要是这两个规则:

 9    ;;; defconf: drop everything else not coming from LAN
      chain=input action=drop in-interface-list=!LAN

 21    ;;; defconf: drop everything else not coming from LAN
      chain=forward action=drop in-interface-list=!LAN

inputforwa 链分别阻止了访问路由器本身和其他子网设备的流量。添加新的放行策略注意顺序,建议在最前面插入,流量按顺序匹配到一个规则后即停止匹配。

本来事情很简单,防火墙放通一下就行了,但偏偏前缀是动态的,于是机智的开发者们搞出了反掩码,比如 240e:3a3::aaaa:bbbb:cccc:eeee/-64 表示只匹配后 64 位,个别老旧的系统不支持缩写,那么就得这么表示 ``240e:3a3::aaaa:bbbb:cccc:eeee/::ffff:ffff:ffff:ffff`。而 ROS 就厉害了,人家全!都!不支持,是的,2021 年就有人提出,直到现在 (2024.10) 依然不支持。

变通方法是创建 address-list 并在防火墙规则里引用。然后编写脚本来定期更新 address-list 里的地址:

#!rsc by RouterOS
#
# Used to update prefixes in IPv6 firewall's address-list to 
# keep them in sync with the prefix assigned by the ISP via DHCP-v6.
# This is an workaround since stubborn ROS still doesn't provide 
# reverse masks feature in 2024.
#
# Args:
#   nic: the name of pppoe interface
#   keyword: RegExp used to match ipv6 firewall address-list item comment

:local nic "pppoe-out1"
:local keyword "\\[dp\\]"

:local ipv6 [/ipv6 dhcp-client get pppoe-out1 prefix]
:set ipv6 [:pick $ipv6 0 [:find $ipv6 "/"]]

:if ([:typeof $ipv6]="nothing") do={
    :log info "v6firewall: no IPv6 prefix on $nic, ignore..."
} else={
    :foreach i in=[/ipv6/firewall/address-list find where comment~$keyword] do={
        :local addr [/ipv6/firewall/address-list get $i address]
        :set addr [:pick $addr 0 [:find $addr "/"]]
        
        :local newAddr ([:toip6 $ipv6]|([:toip6 $addr]&::ffff:ffff:ffff:ffff))
        :if ($newAddr!=$addr) do={
            /ipv6/firewall/address-list set $i address=$newAddr
            :log info "v6firewall: update $addr -> $newAddr"
        }
    }
}

可以自定义 keyword 参数。至于脚本的定时执行以及在 PPPoE 重新拨号后触发请参阅「端口映射 - IP 更新」章节。

现在可以点击「IPv6 - Firewall - Address Lists - Add New」添加新条目:

  • Comment: [dp] xxx ([dp] 是脚本识别用的关键字)
  • List: 起个名字,比如 nas
  • Address: 设备的 IPv6 完整地址。

返回 Filter Rules,可以创建防火墙规则并引用这个地址列表了:

/ipv6/firewall/filter add chain=forward in-interface=pppoe-out1 dst-address-list=nas protocol=tcp dst-port=443 action=accept place-before=0

使用 WebUI 原理一样的,对照着输入参数就行。place-before 表示位置,可以添加后再移动。

另一个方案是配置 ULA 给设备分配固定的内网前缀,然后通过 NAT6 做端口映射,和 IPv4 一样。个人感觉这实在是太丑陋了,从心理反感这种历史倒车。

ZeroTier 组网

启动 ZeroTier

ROS 已支持 ZeroTier 扩展,从官网下载 Extra Package 解压后,从后台上传 ZeroTier 安装包,直接重启就会安装上去了。如果你想我一样买了原厂 arm 硬件,注意下是 ARM 版还是 ARM64 版,搞错了装不上的。而且扩展包版本必须与主系统版本一致。

现在 (ROS 7.16) web 管理已支持 ZeroTier。可以看到有一个默认的已禁用实例 (instance) 叫 zt1,直接启用它。然后进入主选项卡 (ZeroTier) 点击 Add New 新建一个 Interface,典型情况下只需要改两个地方:

  • Network: 输入 ZeroTier 的网络 ID。
  • Instance: 绑定一个 ZeroTier 实例,可以选择默认的 zt1

![Add ZeroTier](/Users/chenhe/Library/Application Support/typora-user-images/image-20241006191837108.png)

对应的命令为:

/zerotier enable zt1
/zerotier/interface add network=xxx instance=zt1

这里的「实例」Instance 其实是 ZeroTier 的 Control Panel,默认创建的是使用 ZeroTier 官方托管的服务器。如有需要也可以自建。

其他的部分选项这里简单解释一下:

  • Allow Managed: ZeroTier 会给接入网络的设备分配内网 IP,这个选项表示接受这一分配。不接受的话其实可以手动指定的。
  • Allow Default: 是否允许缺省路由。我们可以在 ZeroTier 控制面板指定路由,如果配置了 0.0.0.0/0 的路由条目,这就是缺省路由。一旦生效,几乎所有的流量都会从这里出去。假设我们在 ZT 配置了路由 0.0.0.0/0 via 192.168.192.254,并且 ROS 中勾选了这一项,那么应该可以在 ROS 的路由表中看到一个新条目 0.0.0.0/1 via 192.168.192.254。比较有趣的是这里的掩码长度可能为 1,其目的是确保覆盖原先的缺省路由(越长的掩码条目优先级越高)。

现在应该已经加入网络了,记得去 ZeroTier 后台允许这个新设备。但是 ZeroTier 作为一个独立的接口,目前不被允许访问路由器本身,或其他设备,从而几乎失去了组网的意义。所以下一步要配置防火墙。

配置防火墙

/ip firewall filter
# 允许 ZT 访问内网设备
add action=accept chain=forward in-interface=zerotier1 place-before=1 comment="zt: allow all from zerotier access others"
# 允许 ZT 访问路由器
add action=accept chain=input in-interface=zerotier1 place-before=1 comment="zt: allow all from zerotier access router"

# IPv6 防火墙
/ipv6/firewall/filter
add action=accept chain=forward in-interface=zerotier1 place-before=0 comment="zt: allow all"
add action=accept chain=input in-interface=zerotier1 place-before=0 comment="zt: allow all"

ROS 防火墙缺省策略是放行,而自动生成的规则对于 forward 仅仅是屏蔽了所有来自 WAN 的流量,所以即使不显式放行也可以从 ZeroTier 访问内网其他设备。不过从性能已经标准性来看,建议手动放行,这样流量无需匹配整个防火墙规则。

如果希望通过 ZeroTier 访问 ROS 的 web 管理页面,记得点击「IP - Services - www」,设置「Available From」,加入 ZeroTier 的网段,例如 192.168.192.0/24,否则只是可以 ping 通,访问 http 会提示 "Empty reply from server"。

UPnP 问题

根据官方指南,建议开启 UPnP 获得更好的性能。但很多中国的流氓喜欢白嫖用户宽带当成自己的 CDN,这里建议利用防火墙仅对指定的设备(需要安装 ZeroTier 客户端组网的设备)开放 UPnP(:1900/udp),虽然难以做到进程级拦截,多少也可以把电视盒子之类的东西拒之门外。

配置开启 UPnP 比较简单,进入「IP - UPnP」页面,勾选 Enable 保存。然后点击「Interfaces - Add New」按钮,添加两个接口分别定义内网和外网。一般来说内网绑定 bridge,外网绑定 pppoe-out1。如下:

UPnP Interfaces

回到「IP - Firewall」,创建两个 Filter Rules:

1. 放行需要的设备

  • Chain: input
  • Src. Address: 内网地址
  • Protocol: udp
  • Dst. Port: 1900
  • Action: accept

2. 禁止所有设备 UPnP

  • Src. Address: 留空
  • Action: drop
  • 其他和上一个一样。

千万注意规则顺序,防火墙从上往下匹配,只要命中一个就终止匹配,所以必须把更精确的匹配(这里是放行的条目)放在上面。

UPnP Firewall

参考