如果你看过我的前两篇文章,大概已经知道我和小米这台路由器的爱恨情仇了,在这里说一声抱歉,前面两文的方法或多或少都显得很臃肿,接下来的文章,我将讲述我最后的解决方案。

前言

给没看过前两篇文章的读者介绍下我的网络拓扑:

运营商
  │
  │ PPPoE + DHCPv6-PD /60
  ▼
小米 AX6000 原厂固件
  │
  │ DHCPv6-PD
  ▼
二级 OpenWrt
  │
  ▼
下级 LAN

运营商实际上会给 AX6000 下发一个 /60 IPv6 Prefix,例如:

240e:391:1aa2:1630::/60

理论上,这个 /60 完全可以继续切出多个 /64,分配给下一级 OpenWrt。

但小米原厂固件存在一个问题:

即使手工将 network.lan.ip6assign 改为 60,在 PPPoE 重拨或 IPv6 Native 模式重新初始化后,它仍会自动变回 64。

结果就是一级路由自己占用一个 /64 后,odhcpd 无法正常向二级 OpenWrt 委派新的 /64,二级 OpenWrt 的:

ifstatus wan6

会出现:

"ipv6-prefix": []

最初可以通过改 UCI、重启 network、重启 odhcpd 等方式临时恢复,但这些都是事后补救,而且容易和小米自己的网络状态机打架。

最后分析原厂脚本后发现,这个问题其实可以从源头修掉。


一、根因:小米自己把 Native IPv6 的 ip6assign 默认写成了 64

AX6000 的 IPv6 管理主要涉及:

/etc/init.d/ipv6
/usr/sbin/enable_ipv6

这两个文件并不是二进制,而是普通 POSIX Shell 脚本。实机检查可以确认这一点。

问题首先出在 /etc/init.d/ipv6。

Native IPv6 模式启动时,它执行的是:

$CMD wan $1

也就是类似:

/usr/sbin/enable_ipv6 wan native

这里没有传入 ip6assign 参数;相比之下,Static 模式会明确取得 ipv6.settings.ip6assign 并传给 enable_ipv6。

再看 /usr/sbin/enable_ipv6:

ip6assign=$6

Native6 分支中存在:

"native")
    [ -z "$ip6assign" ] && {
        logerr "WARN: [Native6-mode] ip6assign is NULL, set 64 as default."
        ip6assign="64"
    }

随后 PPPoE 分支直接执行:

set network.${iface}.ipv6='auto'
set network.lan.ip6assign=${ip6assign}
commit network

也就是说调用链实际上是:

PPPoE / IPv6 Native 重新初始化
        ↓
/etc/init.d/ipv6
        ↓
enable_ipv6 wan native
        ↓
第 6 个参数为空
        ↓
ip6assign="64"
        ↓
uci 写入 network.lan.ip6assign=64

这就是为什么手工设置的 60 会在重拨以后会恢复成 64。

有意思的是,OpenWrt 底层的 /bin/config_generate 本身却是:

uci set network.$1.ip6assign='60'

所以真正把值覆盖回 /64 的,是小米后来增加的 Native IPv6 管理逻辑。


二、为什么不用直接改 /usr/sbin/enable_ipv6

这台 AX6000 的根文件系统是只读 SquashFS:

mtd:ubi_rootfs on / type squashfs (ro,noatime)

并且:

df -h /

得到:

Filesystem        Size    Used    Available    Use%
mtd:ubi_rootfs    21.8M   21.8M   0            100%

因此在不做刷机之类的操作下,应该不能像标准带 overlay 的 OpenWrt 一样直接修改 /usr/sbin/enable_ipv6。

但 /data 是持久、可写的 UBIFS。

所以最终方案是:

只读 SquashFS
/usr/sbin/enable_ipv6
        ▲
        │ bind mount
        │
/data/enable_ipv6.patched
可写、持久

Linux 的 bind mount 不会修改原厂 SquashFS 文件,只是运行时让:

/usr/sbin/enable_ipv6

指向 /data 中的修改版。


三、制作修改版 enable_ipv6

以下操作均需要 AX6000 已取得 SSH/root 权限。

首先保存原厂版本,并创建修改版:

cp /usr/sbin/enable_ipv6 /data/enable_ipv6.original
cp /usr/sbin/enable_ipv6 /data/enable_ipv6.patched

chmod 755 /data/enable_ipv6.original
chmod 755 /data/enable_ipv6.patched

只修改 Native6 分支:

awk '
BEGIN { in_native=0 }

$0 ~ /^"native"\)/ {
    in_native=1
}

in_native {
    if ($0 ~ /WARN: \[Native6-mode\] ip6assign is NULL, set 64 as default\./)
        sub(/set 64 as default\./, "set 60 as default.")

    if ($0 ~ /ip6assign="64"/)
        sub(/ip6assign="64"/, "ip6assign=\"60\"")
}

{
    print
}

in_native && $0 ~ /^[[:space:]]*;;[[:space:]]*$/ {
    in_native=0
}
' /usr/sbin/enable_ipv6 > /data/enable_ipv6.patched.tmp

mv /data/enable_ipv6.patched.tmp /data/enable_ipv6.patched
chmod 755 /data/enable_ipv6.patched

检查:

grep -n 'ip6assign="6[04]"' /data/enable_ipv6.patched

我的 AX6000 上结果为:

82:        ip6assign="64"
119:       ip6assign="60"
158:       ip6assign="64"

再做一次 Shell 语法检查:

sh -n /data/enable_ipv6.patched

没有输出即正常。

查看 Native6:

grep -n -A 5 -B 1 '^"native")' /data/enable_ipv6.patched

应当看到:

"native")
    [ -z "$ip6assign" ] && {
        logerr "WARN: [Native6-mode] ip6assign is NULL, set 60 as default."
        ip6assign="60"
    }

四、临时 bind 测试

执行:

mount -o bind /data/enable_ipv6.patched /usr/sbin/enable_ipv6

确认:

grep -n -A 5 -B 1 '^"native")' /usr/sbin/enable_ipv6

此时应该已经看到:

ip6assign="60"

然后正常重拨 PPPoE / 重新初始化 Native IPv6,验证小米自己的流程能否自动产生正确配置。

重拨完成后:

uci get network.lan.ip6assign

应为:

60

日志也能形成很直观的前后对照:

原厂:
WARN: [Native6-mode] ip6assign is NULL, set 64 as default.

修改后:
WARN: [Native6-mode] ip6assign is NULL, set 60 as default.

五、验证上游 /60 和下游 /64

一级 AX6000:

ubus call network.interface.wan_6 status

我的实测结果包含:

"ipv6-prefix": [
    {
        "address": "240e:391:1aa2:1630::",
        "mask": 60,
        "class": "wan_6",
        "assigned": {
            "lan": {
                "address": "240e:391:1aa2:1630::",
                "mask": 60
            }
        }
    }
]

这说明:

ISP → AX6000:/60
AX6000 LAN assignment:/60

再看二级 OpenWrt:

ifstatus wan6

最终实测成功取得:

"ipv6-prefix": [
    {
        "address": "240e:391:1aa2:1631::",
        "mask": 64,
        "class": "wan6",
        "assigned": {
            "lan": {
                "address": "240e:391:1aa2:1631::",
                "mask": 64
            }
        }
    }
]

最终拓扑变成:

ISP
 │
 └─ 240e:391:1aa2:1630::/60
              │
              ▼
        Xiaomi AX6000
              │
              ├─ 上级 LAN:1630::/64
              │
              └─ DHCPv6-PD
                     │
                     ▼
              二级 OpenWrt
                     │
                     └─ 1631::/64

至此无需手工重启 odhcpd,PD 本身即可正常建立。


六、持久化 bind mount

临时测试成功以后,只需要开机执行一次 bind mount。

创建:

cat > /data/apply-enable-ipv6-bind.sh <<'EOF'
#!/bin/sh

SRC="/data/enable_ipv6.patched"
DST="/usr/sbin/enable_ipv6"
TAG="enable-ipv6-bind"

fail() {
    logger -t "$TAG" "ERROR: $*"
    exit 1
}

[ -f "$SRC" ] || fail "$SRC not found"

sh -n "$SRC" || fail "syntax check failed"

grep -q 'WARN: \[Native6-mode\] ip6assign is NULL, set 60 as default\.' "$SRC" \
    || fail "Native6 /60 patch marker not found"

COUNT="$(grep -c 'ip6assign="60"' "$SRC")"
[ "$COUNT" = "1" ] || fail "unexpected ip6assign=60 count: $COUNT"

if grep -qs " $DST " /proc/mounts; then
    logger -t "$TAG" "already mounted"
    exit 0
fi

mount -o bind "$SRC" "$DST" || fail "bind mount failed"

if ! grep -q 'WARN: \[Native6-mode\] ip6assign is NULL, set 60 as default\.' "$DST"; then
    umount "$DST" 2>/dev/null
    fail "post-mount verification failed; rolled back"
fi

logger -t "$TAG" "patch active: $SRC -> $DST"
exit 0
EOF

chmod 700 /data/apply-enable-ipv6-bind.sh

语法检查:

sh -n /data/apply-enable-ipv6-bind.sh

然后利用firewall 的 script include 机制启动:

uci -q delete firewall.enable_ipv6_bind

uci set firewall.enable_ipv6_bind='include'
uci set firewall.enable_ipv6_bind.type='script'
uci set firewall.enable_ipv6_bind.path='/data/apply-enable-ipv6-bind.sh'
uci set firewall.enable_ipv6_bind.reload='1'
uci set firewall.enable_ipv6_bind.enabled='1'

uci commit firewall

检查:

uci show firewall.enable_ipv6_bind

应该类似:

firewall.enable_ipv6_bind=include
firewall.enable_ipv6_bind.type='script'
firewall.enable_ipv6_bind.path='/data/apply-enable-ipv6-bind.sh'
firewall.enable_ipv6_bind.reload='1'
firewall.enable_ipv6_bind.enabled='1'

七、验证持久化机制

不必立即重启路由器,可以先卸载 bind:

umount /usr/sbin/enable_ipv6

检查:

grep -n -A 4 '^"native")' /usr/sbin/enable_ipv6

应该恢复原厂:

ip6assign="64"

然后:

/etc/init.d/firewall reload

输出中应该出现:

* Running script '/data/apply-enable-ipv6-bind.sh'

日志:

grep 'enable-ipv6-bind' /tmp/messages | tail

应出现:

enable-ipv6-bind: patch active: /data/enable_ipv6.patched -> /usr/sbin/enable_ipv6

重新检查:

grep -n -A 4 '^"native")' /usr/sbin/enable_ipv6

应再次变成:

ip6assign="60"

这证明持久化调用链本身已经成立。


八、启动顺序

还需要确认一件事:bind 必须在 Xiaomi 初始化 IPv6 之前完成。

AX6000 1.0.122 实测:

grep -H '^START=' /etc/init.d/firewall /etc/init.d/ipv6

结果:

/etc/init.d/firewall:START=19
/etc/init.d/ipv6:START=58

对应:

/etc/rc.d/S19firewall
/etc/rc.d/S58ipv6

因此启动流程正好是:

系统启动
   ↓
S19 firewall
   ↓
运行 /data/apply-enable-ipv6-bind.sh
   ↓
bind patched enable_ipv6
   ↓
S58 ipv6
   ↓
Xiaomi Native IPv6 初始化
   ↓
ip6assign=60

时序正确。


九、碎碎念。。

折腾到最后,真正长期工作的东西实际上只有两个文件:

/data/enable_ipv6.patched
/data/apply-enable-ipv6-bind.sh

另外建议保留一份以便回滚吧:

/data/enable_ipv6.original

没有后台 daemon,没有 cron,没有 hotplug,没有不断检测 WAN6 状态,也没有自动执行 network restart 或 odhcpd restart,鬼知道我之前到底在干什么。。。不过也确实学到了很多新东西吧。(汗


写在最后

有时候,少即是多。其实什么都不用运行——只是在系统启动时,把一个修正过的 8 KB Shell 脚本盖到原厂脚本上,然后让小米自己继续正常工作,仅此而已。