Armbian,更适合 R2S 软路由的系统

OpenWrt的困境

人们安装OpenWrt,是因为他们觉得它比路由器或者嵌入式设备的原厂固件更好用。

——《使用 OpenWrt 的理由》

不错,作为一个路由器系统来说OpenWrt确实实现了相对官方固件更好的可玩性。实际用久了自然会发现OpenWrt作为一个Linux来说是非常难用的,这点突出体现在其包管理上。官方软件源各种残缺,第三方软件源也充斥着野包,让我想起了用CentOS的时候。开发者倾向于为传统大发行版打包,相比较而言极少看到过opkg。OpenWrt官方甚至不建议升级软件包 1,因为包管理器并没有能力升级OpenWrt本身……这对Tailscale和Xray-core等需要经常更新的软件来说很致命。

整个OpenWrt给人的印象也是能省则省,因为它需要照顾到大量低性能的设备,甚至低至8MB Flash、32M RAM的设备;前文提到的包管理器区别是因为opkg不支持ABI兼容性监测;对于普通的Linux发行版也有诸多其他不同。而对于R2S这种1GB RAM的设备来说,倒没必要这么节省。更何况即使在Armbian上,日常占用RAM也仅有300MB,这里面还有100MB以上是Xray-core的缓存。

我并没有说OpenWrt不适合路由器使用,它的大部分配置都能点几下就完成设置,也有更多的中文资料,还有很多开箱即用的网络优化,在社区活跃开发者的帮助下,它十分适合路由器使用。但若是想要跳出插件开发者设定的笼子,尝试稍高级一点的玩法,OpenWrt就会显得各种别扭。而Armbian基于Debian或者Ubuntu,都是许多Linux用户再熟悉不过的发行版,因此使用Armbian不是找罪受,反而能够利用已有的其他Linux使用习惯和资料,是一个偷懒的选择。

包含 Armbian 信息的 neofetch

因此,本文希望为读者提供另一种软路由的思路,让R2S兼有传统主路由的功能,又有更熟悉的Linux发行版体验

我的网络拓扑大概如下(无线路由器连接的各个设备仅为示意)。R2S作为主路由使用,Redmi AC2100 (已升级为小米AX3000)作为AP,不存在旁路由。

网络拓扑
网络拓扑

安装Armbian

安装Armbian的第一步就像OpenWrt一样,从 官方网站 下载对应的img(本文使用基于Ubuntu的Jammy),然后用你的工具烧录进SD卡。将R2S接入电源,指示灯应该闪烁。

初始设置需要将其WAN或LAN口接到另一台 已经启用DHCP 的路由器上,想办法找到其IP地址(比如进管理员后台),然后使用SSH登录,初始用户名和密码分别为 root1234。注意其此时不能作为一个开箱即用的路由器,也就是说不能直接接入电脑。

登入后有一个初始引导,跟着做就行了,没什么好说的。然后这就是一个标准的Linux系统了,基本可以直接遵循其他Linux的操作方法。但国内许多用户会到手换源,由于Armbian是基于其他发行版做的,所以换源不完全一样。其软件源有两个位置,均需要更改:

  • /etc/apt/sources.list 与你安装的版本所基于的发行版有关,如Armbian Jammy基于Ubuntu Jammy,就换对应的源(如 清华源,注意Ubuntu使用Ports源);
  • /etc/apt/sources.list.d/armbian.list 是Armbian专属软件源,也要换(仍然如 清华源)。

配置路由器功能

OpenWRT说白了也是个Linux,作为一个路由器所需要的功能也有许多方案可以通过后期手动安装实现。

顺着走到这一步,执行 ip l,一台R2S的显示应该类似于下面这样:

plaintext
1
2
3
4
5
6
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: eth0: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc mq state DOWN mode DEFAULT group default qlen 1000
    link/ether aa:aa:aa:aa:aa:aa brd ff:ff:ff:ff:ff:ff
4: lan0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP mode DEFAULT group default qlen 1000
    link/ether aa:aa:aa:aa:aa:aa brd ff:ff:ff:ff:ff:ff

此处我把外部网络(相对,如上层路由器)插到了 lan0 上,局域网设备则均连接至 eth0,相当于许多OpenWRT包所做的反转WAN和LAN口。如无特别说明,后面的操作均按照这个来。

R2S的LAN是USB 3.0转接的网卡,而WAN是PCIe网卡。进行高速传输时,USB造成的中断会大量消耗性能,所以对于内网流量,将其扔给性能更高的WAN口。R2S在小包较多时速度明显下降,也是基于同样的原因。

DNS

DNS用于将主机名解析为IP地址。由于传统DNS查询是明文的,可以被中间人任意截留修改,部分运营商会进行DNS污染,以达到加广告或封锁境外网站的目的。因此,使用DoH(DHS-over-HTTPS)和DoT(DNS-over-TLS),并对国内和国外域名进行分流,就有其意义。

下文介绍了几种DNS转发器的的配置方案。其中,EasyMosdns自带了较完善的方案,而TelescopeDNS的配置文件较为简明,都能有效对抗DNS污染。

TelescopeDNS

此处使用 Telescope DNS 负责监听53端口,为局域网内设备进行解析,并根据条件将其发送至不同的DNS服务器。

虽然我有透明代理的需求,但是本着解耦的原则(说白了就是怕Xray-core挂掉全都上不了网),还是由单独的DNS转发器处理局域网查询吧。

Armbian默认使用systemd-resolved解析DNS并占用25端口,由于其仅能监听本机请求而不能接受局域网内其他机器的请求[^7],需要将其禁用。此外需要将 /etc/resolv.conf 指向127.0.0.1以将本机的DNS查询转发至Telescope DNS。

以下是我的配置文件,读者可以参考。

toml
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
listen = "192.168.0.1:53"
disable_qtypes = ["AAAA"] # 禁用 IPv6 解析

[cache]
size = 4096

[hosts] # 为 DoH 域名指定 IP 地址,防止回环解析(指解析所必需的 DoH 域名本身也需要解析)
"doh.pub" = "1.12.12.12"
"cloudflare-dns.com" = "1.0.0.1"

[groups]
  [groups.clean] # 境内域名
  doh = ["https://doh.pub/dns-query"] # 腾讯 DNSPod DoH
  dns = ["114.114.114.114"]
  concurrent = true
  no_cookie = true # DNSPod 可能需要
  redirector = "oversea_ip2dirty" # 按照下方同名 redirector 设置重定向

  [groups.dirty] # 境外域名
  dns = ["208.67.222.222:5353", "176.103.130.130:5353"]
  doh = ["https://cloudflare-dns.com/dns-query"] # Cloudflare DoH
  gfwlist_file = "/etc/ts-dns/gfwlist.txt"

[redirectors]
  [redirectors.oversea_ip2dirty]
  # 解析后如发现ip地址不匹配cnip,则重定向到dirty组解析
  type = "mismatch_cidr"
  rules_file = "/etc/ts-dns/cnip.txt"
  dst_group = "dirty"

为了让本机的DNS解析通过本机的服务器,需要更改默认的DNS解析器。

EasyMosdns

Mosdns以强大的功能而闻名,但对于不想花时间配置的人来说,使用别人配好的方案也能十分舒适,比如 EasyMosdns

因为它和Mosdns都没有被大部分发行版打包,所以在Armbian上,需要先手动安装对应版本的Mosdns,然后将上述repo内的文件丢进对应目录。至于Systemd单元等文件,可以参见 AUR

默认监听53端口,可以在 /etc/mosdns/config.yaml 中设置。

dnsmasq 整合

确实dnsmasq可以当DNS转发器啊,但因为它对DNS污染抗性比较差,所以我肯定不会单独用它的。不过可以把它套娃,在本机上做两层转发。

如果你希望做两层转发,那么需要将嵌套的转发器监听端口改一下,比如5353,将这个地址改为dnsmasq的上游DNS,并且把dnsmasq的cache size设为0(否则第一次查询大概率失败);如果你希望把dnsmasq的DNS禁掉,那么这三个设置都不用动,然后给dnsmasq加个 -p0 启动参数就可以了。

DHCP

DHCP用于为局域网内的设备分配各自的IP地址,同时也会传递网关、默认DNS等信息。在我的网络拓扑中,由软路由充当DHCP服务器,其他设备则仅需自带的DHCP客户端。

一般来说Linux的网络连接由systemd-networkd或NetworkManager之类的东西管理(包含DHCP客户端),而在基于Ubuntu的系统上有个好东西叫做Netplan,用于在前述两者等一系列工具之上再构建一层抽象,以求简化其配置。

该机型Armbian的Netplan配置文件位于 /etc/netplan/armbian-default.yaml。未经修改的文件类似于:

yaml
1
2
3
network:
  version: 2
  renderer: NetworkManager

要将软路由作为DHCP服务器,需要先将对应接口的DHCP客户端关掉(路由器一般当然不能从局域网其他主机取得IP地址了),然后再为其单独安排一个IP地址。再次提醒,我计划把局域网设备插在 eth0

yaml
1
2
3
4
5
6
7
network:
  version: 2
  renderer: NetworkManager
  ethernets:
    eth0:
      dhcp4: false # 这个 false 是关掉 DHCP 客户端
      addresses: [192.168.0.1/24] # 网段自定,后面对应修改

保存后不要忘了使用 sudo netplan try 测试配置文件是不是写对了,没问题的话就确认。

至于DHCP服务器,我起初选择了 coredhcp。其并不支持SLAAC宣告,故对IPv6环境并不友好;而dnsmasq虽然看起来麻烦,但实际上只需要动几个配置项就行了,而且对IPv6比较友好。

照例是配置文件:

yaml
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
server4:
    listen:
        - "%eth0" # 只监听 eth0 接口的请求
    plugins:
        - lease_time: 3600s
        - server_id: 192.168.0.1 # 填路由器 IP 就行
        - dns: 192.168.0.1 # 重要,设置为路由器 IP 以指向路由器上的 DNS
        - router: 192.168.0.1 # 网关,主路由兼具代理功能,所以这里也是路由器 IP
        - netmask: 255.255.255.0 # 子网掩码
        - range: /etc/coredhcp/leases.txt 192.168.0.2 192.168.0.254 12h # 分配地址文件,起始地址,结束地址,租约时间

NAT

NAT对局域网内IP和端口号二元组与外网IP和端口号二元组之间建立联系,并进行转换。此处使用的工具是 nftables。依托其强大的规则体系,我们可以自由选择转发暴露的端口,以及内外能访问的服务等。

nftables 官方文档 2 给了我们一个很好的基础配置,我也知道其他高级用途也需要依托 nftables,此处按下不表。但要做到NAT,只需要用一张 nat 表的 masquerade 规则。

perl
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
#!/usr/sbin/nft -f

flush ruleset

define WANLINK = lan0

table ip nat {
        chain  prerouting {
                type nat hook prerouting priority -100;
        }
        chain postrouting {
                type nat hook postrouting priority 100; policy accept;
                oif $WANLINK masquerade
        }
}

要暂时应用Nftables规则,可以使用 sudo nft -f <filename>,永久应用则需要将其保存到 /etc/nftables.conf,并设置权限755。

启用IP转发

只有启用代理转发,流量才能进入Netfilter的 forward 链,从而不经本地用户程序而向外转发。

/etc/sysctl.d/ 下新建一个文件,名字随意(如 98-ip-forwarding.conf),内容为:

plaintext
1
2
net.ipv4.ip_forward=1
net.ipv6.conf.all.forwarding=1

然后运行 sudo sysctl -p 使其生效。这样,软路由就有了将不属于它的包转发出去的能力。

PPPoE

安装 pppoeconf,然后运行它就可以了。它的底层是 pppd,所以修改配置也需要到 /etc/ppp/ 里面修改。

使用PPPoE上网后,会生成一个 ppp0 虚拟网卡,它充当了WAN口的作用;使用原来的WAN接口是无法直接联网的。使用透明代理等需要指定接口的应用时请务必注意。

进阶应用

按照上面的步骤配置完毕后,如无意外,软路由本身和接入其的设备应该已经可以上网了。然后是一些相比OpenWrt,Armbian没有out-of-box支持的。

透明代理

请阅读 Armbian + R2S 番外篇:透明代理 一文。

另外,如果使用Tailscale,实测透明代理可能会与Tailscale规则冲突(实测23/8/26有此现象),从而导致上不了网的严重问题,所幸1.48.0开始Tailscale增加了Nftables支持,将其启用似乎可以解决这个问题。在 /etc/default/tailscaled中加入 TS_DEBUG_FIREWALL_MODE=nftables 即可3

基本防火墙

将这些规则加入之前的 nftables 配置,可以对内外网进行基本的隔离。修改自 4

perl
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
table inet filter {
        chain inbound {
                type filter hook input priority 0; policy drop;
                ct state vmap { established : accept, related : accept, invalid : accept } counter
                ip protocol icmp icmp type { destination-unreachable, echo-reply, echo-request, source-quench, time-exceeded } limit rate 5/second accept
                tcp dport 22 accept
                iifname vmap { lo: accept, $WANLINK : jump inbound_world, $LANLINK : jump inbound_private }
        }
        chain inbound_world {
                ip saddr { $RESERVED_IP } drop
        }
        chain inbound_private {
                ip protocol . th dport vmap { tcp . 22 : accept, udp . 53 : accept, tcp . 53 : accept, udp . 67 : accept }
        }
        chain forward {
                type filter hook forward priority 0; policy accept;
        }
}

UPnP / NAT-PMP

UPnP和NAT-PMP本质上是类似的东西,类似于一个自动的路由器端口映射。可以使用 MiniUPnPd 来实现。

Ubuntu和Debian已经打包了 miniupnpdminiupnpd-nftables 两个包,安装时会自动进入向导,分别填入内网接口(eth0)和外网接口(lan0),并选择启用即可。

Full Cone NAT

Nftables的NAT行为 masquerade 是Symmetric NAT。打游戏的应该会需要Full Cone NAT,可以参考一下 这个实现。需要编译对应的 libnftnlnftablesnft,以及内核模块。

如果你使用Xray-core透明代理,那么它已经帮你用神奇的方式实现了Full Cone NAT5。但在实现透明代理时,需要将所有流量转发至Xray-core进行分流,以防不同分流规则下NAT行为出现不同。

另外还可以使用 einat-ebpf,使用时需禁用上述NAT Nftables规则中的 MASQUERADE 链,实测Armbian内核支持该程序eBPF所需的内核选项,性能良好,较为易用,无需重新编译内核模块或使用第三方代理工具。

MSS Clamping

我不只一次见网友说自己搭的软路由访问某些网站非常慢,而换回硬路由就正常。这是因为多数家用路由器默认对IPv4下的TCP开启了MSS (maximum segment size) Clamping。

《开启 IPv6 后网速变得很慢?可能是 PMTU 黑洞的问题》

简而言之就是家用硬路由会自动帮你设置MSS,而软路由不会,造成被路径上丢包只能重传。进行MSS Clamping后,终于能跑满300M宽带了。

在Nftables中添加以下内容:

perl
1
2
3
4
5
6
table inet filter {
    chain forward {
        type filter hook forward priority 0; policy accept;
        tcp flags syn tcp option maxseg size set rt mtu
    }
}

IPv6

以网上资料的细碎程度,IPv6其实也是一个进阶应用;但是感觉对自己来说,即使没有全局梯也不能没有IPv6,所以就挺拧巴的,这也是我把它放在最后的原因。如果你对IPv6没有那么熟悉,我强烈建议你展开下面的内容看点更拧巴的现象和我的理解,防止配完黑人问号。

常用的路由器 IPv6 部署方案解析

IPv6的情况与IPv4不同,路由器较少使用DHCPv6来为局域网设备分配IP地址(而且支持也像屎一样 [^13]),更常用的叫做无状态地址自动配置(SLAAC,StateLess Address Auto Configuration),一般来说路由器会选择LAN上前缀的一个 /64地址块,向局域网内进行宣告,然后各个主机通过某种方式,自己在这个 /64里选择一个地址(并非一个设备直接分走一个 /64,所以运营商只给 /60而不是 /56并没有什么问题;感谢 V2EX @dalaoshu 的纠正)。因为一个 /64实在是太巨大了,所以一般也不会冲突。

诶,看起来拧巴的事情来了:本人使用的长沙联通宽带,光猫已改为桥接路由器拨号;而对于PPPoE,协议本身会给路由器 ppp0 指定一个 /64的地址 [^14]。不知细心的你是否发现了, 我们没说过 ppp0 在委托的那个子网里面——事实上也确实不在。当执行 ip -6 addr 时,就会得到以下看起来十分抽象实际上却又合理的输出(节选):

bash
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
$ ip -6 addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 state UNKNOWN qlen 1000
    inet6 ::1/128 scope host
       valid_lft forever preferred_lft forever
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 state UP qlen 1000
    inet6 2408:aaaa:450:3650:4c43:81ff:fe4d:9cac/60 scope global
       valid_lft forever preferred_lft forever
    inet6 fe80::4c43:81ff:fe4d:9cac/64 scope link
       valid_lft forever preferred_lft forever
6: ppp0: <POINTOPOINT,MULTICAST,NOARP,UP,LOWER_UP> mtu 1492 state UNKNOWN qlen 3
    inet6 2408:bbbb:451:50b6:7a88:efdb:db94:4b77/64 scope global temporary dynamic
       valid_lft 259001sec preferred_lft 85804sec
    inet6 2408:bbbb:451:50b6:4e43:81ff:fe4d:9cac/64 scope global dynamic mngtmpaddr
       valid_lft 259001sec preferred_lft 172601sec
    inet6 fe80::4e43:81ff:fe4d:9cac peer fe80::16eb:8ff:feb2:b27d/128 scope link
       valid_lft forever preferred_lft forever

说它抽象吧,是因为在我的直觉里,似乎IPv6不会分配私有IP,所以大家都是平等的设备,也理所应当属于一个子网;说它合理吧,是既然IP转发启用了,那么路由器就可以将其送达该有的目的地,路径也并不需要跟着网段走。事实上,WAN口的 /64是通过PPPoE的SLAAC获得的,这与LAN口的DHCPv6-PD获得的前缀来源不同,所以才会出现两个地址不在同一网段的情况。

其实类比IPv4也是很好理解的:运营商给一个普通路由器的WAN口分配了一个公网IP;同时路由器通过DHCP将内网的私有地址分配给各个主机,LAN口也获得了一个该网段下的私有地址。IPv6情况其实差不多,只是上面的私有地址段变成了通过DHCPv6-PD获得的公网地址段,通过某个公网地址能够直接路由到特定的主机,无需经过NAT而已;子网内的DHCPv4也变成了SLAAC,不过结果上没区别,都是主机获得了自己的IP。

评论区有人提到了IPv6的一些奇怪情况;此外关于PD和SLAAC,也可以多阅读 这篇文章 作为参考。

好的,逼叨完了,结果很明了了:我们需要配置的有

  1. 一个能进行DHCPv6-PD的客户端,
  2. 一个能进行SLAAC宣告的程序,和
  3. 让路由器本身获得IPv6。

第一个事好说,用得很广泛的是 wide-dhcpv6-client$^包$。安装后编辑 /etc/wide-dhcpv6/dhcp6c.conf。以下给出一个示例,它从运营商请求一个非临时的前缀,然后将这个前缀委托给 eth0,让 eth0 获得一个 /64地址。

perl
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
interface ppp0 { # 通过 ppp0 进行 DHCPv6
    send ia-pd 0; # 发送 Prefix Delegation 消息
    send ia-na 0; # 请求非临时地址(non-temporary address)
    script "/etc/wide-dhcpv6/dhcp6c-script";
};

id-assoc pd 0 { # PD 0 的配置
    prefix-interface eth0 { # 将前缀委托给 eth0
        sla-id 0; # SLA ID,即网段编号
        sla-len 0; # SLA 长度,即用掉整个 PD
    };
};

id-assoc na 0 {}; # NA 0 的配置,留空就行

这是一个很基础的配置,如果想整更多花活,建议读 manual

重启一下服务,如果看到 eth0(或自己定义的LAN)上有了一个大于 /64的非私有IP,那么就是成功了,不出意外的话route也会为你配好;否则可以自行使用 dhcp6c -Df -c /path/to/conf <interface> 进行debug。

现在连上设备并不能用SLAAC,因为我们还没有配置路由宣告。如果你已经使用 dnsmasq 作为DHCP服务器,那么宣告非常简单:仅需要在配置文件中加类似下面这一行。

plaintext
1
dhcp-range=::1000,::1fff,constructor:eth0,slaac,ra-names

我来详细解释一下:

  • 即使向运营商申请了非临时前缀,也可能变,所以前缀不能写死,要自动从接口获取
  • constructor:eth0 指的是分配给 eth0 的IP段
  • ::1000,::1fff 指的是所有IPv6地址,当然不能给IPv4做SLAAC不是嘛
  • slaac 指的是选择外部分配的前缀,而非 fe80 开头的内网IP段
  • ra-names 代表进行SLAAC宣告,不进行DHCPv6,并将主机添加到DNS

第三个事情看起来很玄学。在远古的Linux内核中,当开启IPv6 forwarding的时候,即使 net.ipv6.conf.all.accept_ra 不为0,其也会自作主张地停止进行SLAAC,导致路由器WAN(eth0)接口无法获取IPv6地址;而在不那么老的内核上,将其设为2,就可以同时开启IPv6 forwarding和进行SLAAC6

这样,局域网下的设备就可以通过IPv6上网了,没有邪道DHCPv6。