diff --git a/README.md b/README.md index da7ecde..145a2c2 100644 --- a/README.md +++ b/README.md @@ -427,7 +427,7 @@ python3 devtools/check-shots.py | | 要求 | |---|---| -| CPU | 1 核 x86-64 | +| CPU | 1 核(x86-64 或 arm64,见下方「支持矩阵」) | | 内存 | 1 GB | | 硬盘 | 8 GB | | 网卡 | 1 个(先做 LAN + 管理口) | @@ -644,7 +644,8 @@ bash packaging/build-docker.sh 1.0.8 构建顺序上,`build-offline-bundle.sh` 会**自己**先调 `build-deb.sh` 和 `make-offline-deps.sh`,所以想一步到位只跑它就行。 -> **构建环境要求**:Debian 13 (trixie) / amd64。 +> **构建环境要求**:Debian 13 (trixie);离线包**必须在与目标机同架构的机器上** +> 构建(`dpkg --print-architecture` 决定包名后缀),交叉打包出来的包装不上。 > 依赖闭包是在**空 dpkg status** 的临时 apt 根里求解的(而不是用当前机器的 > 已装状态),否则「开发机上恰好装过」的包会被 apt 判为"已满足"而漏收 —— > 到了干净目标机上就变成 `Depends: xxx but it is not installable`。 @@ -722,7 +723,7 @@ bash packaging/build-docker.sh 1.0.8 | **端口冲突** | 装完**不会**自动启动 `dnsmasq` / `radvd` / `kea`。但如果这些服务**本来就在跑**,请先停掉再让 Drouter 接管 | | **PVE 虚拟机** | 需要给 VM 网卡开**混杂模式**,否则桥接 / VLAN 的包会被宿主丢掉 | | **系统时间** | 面板和证书校验都依赖正确时间。裸装 Debian 若时间不对,先 `sudo timedatectl set-ntp true` | -| **离线安装前** | 确认目标机是 **amd64**、剩余磁盘 **≥ 1.5 GB**。离线包里的依赖是按 **Debian 13 (trixie)** 下载的,装到别的版本上可能出现版本不匹配 | +| **离线安装前** | 确认目标机与离线包**同一架构**(`dpkg --print-architecture`)、剩余磁盘 **≥ 1.5 GB**。离线包里的依赖是按 **Debian 13 (trixie)** 下载的,装到别的版本上可能出现版本不匹配 | ### 📴 关于离线安装(目标机无外网) @@ -743,7 +744,7 @@ bash packaging/build-docker.sh 1.0.8 | 约束 | 原因 | |---|---| -| **架构必须匹配** | 包名带 `-amd64`,里面是 x86_64 的二进制。arm64 目标机请按文档自行重建 | +| **架构必须匹配** | 包名带 `-<架构>` 后缀,里面的 deb 是对应 CPU 的二进制。**amd64 的包装不到 arm64 上** —— arm64 机器请在 arm64 机器上重跑 `packaging/build-offline-bundle.sh`,或改用 deb 本体(本机架构见 `dpkg --print-architecture`) | | **发行版必须匹配** | 依赖来自 `trixie` 仓库。跨版本(如 bookworm)会出现 `libc6` 版本冲突 | | **磁盘 ≥ 1.5 GB** | 236 个 deb 解包 + 安装要占几百 MB;`install.sh` 会在低于 1.5 GB 时告警 | | **装完要手动改密码** | 默认 `admin` / `admin123`,离线环境下尤其要第一时间改 | @@ -786,7 +787,7 @@ bash packaging/build-docker.sh 1.0.8 | Debian 12 (bookworm) | ⚠️ 可用,但 `docker-compose` 包名不同,nftables 版本较老 | | Ubuntu 24.04 | ⚠️ 大体可用,`docker-compose-v2` 是 Ubuntu 的叫法 | | Debian 11 及更早 | ❌ 不支持(Python 版本与 nftables 语法不兼容) | -| 架构 | amd64 已充分验证;arm64 理论可用(`Architecture: all`,依赖需自行确认) | +| 架构 | amd64 全功能已验证。**arm64 已在一个 Debian 系发行版上实测通过**:Armbian 26.8.1 trixie(Debian 13.6)/ aarch64 / 内核 6.18.35-ophub / Amlogic S912 开发板 —— 构建、依赖自检、配置渲染、系统探测全部通过,详见 [docs/07-arm64-适配说明.md](docs/07-arm64-适配说明.md)。
其它 arm64 发行版(树莓派系、Debian 官方 aarch64 镜像等)**未验证**。
⚠️ arm64 上请优先用 **deb 本体**(`Architecture: all`,各 CPU 通用);Release 的离线包与 Docker 镜像仍只有 amd64 版本 | --- diff --git a/_dev/run-static.py b/_dev/run-static.py index 5fe5cb7..e27b04a 100644 --- a/_dev/run-static.py +++ b/_dev/run-static.py @@ -32,6 +32,9 @@ EXCL = { # 更糟的是中途被打断会留下污染(已经发生过一次)。 # 只能手动跑:python _dev/t-107-inject-wg.py --batch 0 't-107-inject-wg', + # arm64 适配(2026-10-16)的反向验证,同上:覆盖 backend/drouter-update.py、 + # backend/drouter-helper.py、web/i18n.js、packaging/*.sh 四处源码。 + 't-arch-inject', 't-update-inject', 'upd-inject', } diff --git a/_dev/t-arch-inject.py b/_dev/t-arch-inject.py new file mode 100644 index 0000000..91edb82 --- /dev/null +++ b/_dev/t-arch-inject.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""反向验证:把 arm64 适配的每处改动改坏,确认 t-arch.py 真的会红。 + +跟着 t-update-inject.py 的惯例写:每个用例「注入 → 跑判据 → 必须因为这一条 +而红 → 无条件还原」。落点是**语义层**,不是被检查的那个名字。 + +用法(**不要**加进 scripts/test-static.sh,那里会改写源文件): + python _dev/t-arch-inject.py +""" +import os +import subprocess +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +PY = sys.executable +JUDGE = os.path.join(ROOT, '_dev', 't-arch.py') + +HELPER = 'backend/drouter-helper.py' +UPDATE = 'backend/drouter-update.py' +I18N = 'web/i18n.js' +OFFLINE = 'packaging/build-offline-bundle.sh' +DOCKER = 'packaging/build-docker.sh' + +CASES = [ + # ---------------- A. 更新模块 ---------------- + # 把 %s 拼架构换回写死的 amd64 —— 就是 arm64 适配前的原状。 + # 判据第一条查 'offline-amd64' 是否**不在** 源码里,注入后立刻出现。 + ('A1 离线包资源名写死 amd64(arm64 会下到 x86 包)', + UPDATE, + " ('offline', 'drouter-{ver}-offline-%s.tar.gz' % a,\n" + " '离线安装包(%s,87 MB,断网可装)' % a, True),", + " ('offline', 'drouter-{ver}-offline-amd64.tar.gz',\n" + " '离线安装包(amd64,87 MB,断网可装)', True),", + '离线包资源名模板不含写死的 amd64'), + + # build_assets 忽略入参、一律按本机算 —— 三架构拼出同一个名字。 + # ⚠️ 不删 _deb_arch(),因为判据查的是「拼出来的结果」而非函数是否存在。 + ('A2 build_assets 忽略 arch 入参(三架构同名)', + UPDATE, + " a = arch or _deb_arch()", + " a = _deb_arch()", + '不同架构拼出的离线包名两两不同'), + + # deb 是 Architecture: all,跟进架构走就错了。 + ('A3 deb 包名也带上架构后缀(违背 Architecture: all)', + UPDATE, + " ('deb', 'drouter_{ver}_all.deb', 'Deb 安装包(推荐,523 KB)', False),", + " ('deb', 'drouter_{ver}_%s.deb' % a, 'Deb 安装包(推荐,523 KB)', False),", + 'deb 本体三架构同名'), + + # arch_specific 全 False → apply 不再先用 SHA256SUMS 核对,arm64 白下 87MB。 + ('A4 离线包漏标 arch_specific(失去下载前核对)', + UPDATE, + " '离线安装包(%s,87 MB,断网可装)' % a, True),", + " '离线安装包(%s,87 MB,断网可装)' % a, False),", + '只有离线包标了 arch_specific'), + + # 元组少一列 → asset_list 解包 ValueError,「更新」页整块渲染不出来。 + # 这条注入会让前面几条也一起红(模块在 judge 里加载失败),属实完好: + # 断言关键词挑的是**本条对应**的那条判据。 + ('A5 ASSETS 条目退回三元组(asset_list 解包炸)', + UPDATE, + " ('docker', 'drouter-{ver}-docker.tar', 'Docker 镜像(169 MB)', False),", + " ('docker', 'drouter-{ver}-docker.tar', 'Docker 镜像(169 MB)'),", + 'ASSETS 非空且每个条目是 4 元组'), + + # 把整个核对块删掉 —— 等于回到「下载完才发现没有这个架构」。 + ('A6 下载前的 SHA256SUMS 名单核对被删', + UPDATE, + " if target.get('arch_specific'):\n" + " _manifest = _fetch_manifest(ver)\n" + " if _manifest and target['name'] not in _manifest:\n" + " return {'ok': False,\n" + " 'msg_cn': '该版本没有发布本机架构(%s)的离线包:%s。'\n" + " 'deb 包是 Architecture: all,各 CPU 通用,'\n" + " '请改用「Deb 安装包」这一项更新。'\n" + " % (_deb_arch(), target['name'])}\n", + "", + '带架构后缀的附件在下载前用 SHA256SUMS 核对存在性'), + + # ---------------- B. 固件探测 ---------------- + # 删掉设备树分支 —— ARM 板重新落到 else,显示「传统 BIOS」。 + ('B1 删掉设备树分支(ARM 板重新误报传统 BIOS)', + HELPER, + " elif has_dt:\n" + " firmware, fw_type = '设备树 (Device Tree)', 'dt'\n", + "", + '设备树分支返回稳定枚举 fw_type=dt'), + + # 枚举值不是小写 dt —— 前端拿它做分支会失配。 + ('B2 设备树枚举值写成大写 DT(前端分支失配)', + HELPER, + " firmware, fw_type = '设备树 (Device Tree)', 'dt'", + " firmware, fw_type = '设备树 (Device Tree)', 'DT'", + '设备树分支返回稳定枚举 fw_type=dt'), + + # 顺序反了:设备树优先于 EFI —— 树莓派 4 以 EFI 启动会被认成设备树。 + ('B3 判据顺序改成设备树优先于 EFI', + HELPER, + " if is_efi:\n" + " firmware, fw_type = 'UEFI', 'efi'\n" + " elif has_dt:\n" + " firmware, fw_type = '设备树 (Device Tree)', 'dt'\n", + " if has_dt:\n" + " firmware, fw_type = '设备树 (Device Tree)', 'dt'\n" + " elif is_efi:\n" + " firmware, fw_type = 'UEFI', 'efi'\n", + '判据顺序 EFI 优先于设备树'), + + # compatible 是一串 \0 分隔的标识,不切开会把整串塞给前端。 + ('B4 _dt_read 不再按 NUL 切开(拿整串当板名)', + HELPER, + " for part in raw.split(b'\\0'):", + " for part in [raw]:", + '_dt_read 按 NUL 切开取第一条'), + + # 兜底删掉 → ARM 板「厂商」一格空白,用户以为探测坏了。 + ('B5 去掉设备树板名兜底(厂商格留空)', + HELPER, + " if not vendor:\n vendor = dt_model\n", + "", + '无 DMI 厂商时用设备树板名兜底'), + + # 改字段名会让概览页那一行直接空掉(前端读 fw_type)。 + ('B6 fw_type 字段改名(前端读不到)', + HELPER, + " 'fw_type': fw_type,", + " 'fwtype': fw_type,", + '_read_bios 返回值里有 fw_type 枚举'), + + # ---------------- C. i18n ---------------- + # 词条没登记 → 英文界面这一格直接漏中文。 + ('C1 i18n 漏登记「设备树 (Device Tree)」', + I18N, + " '设备树 (Device Tree)':\n" + " { 'n': 'Device Tree',\n" + " 'd': 'Device Tree / U-Boot boot. ARM and RISC-V boards have " + "no BIOS: the bootloader hands the kernel a device tree describing the " + "hardware.' },\n", + "", + 'i18n 的 SYSINFO_NOTES 里登记了'), + + # 只有 n 没有 d → 面板里那段英文说明是空的。 + ('C2 词条只给了 n 没给 d(说明落空)', + I18N, + " 'd': 'Device Tree / U-Boot boot. ARM and RISC-V boards have " + "no BIOS: the bootloader hands the kernel a device tree describing the " + "hardware.' },", + " 'x': 'unused' },", + '该词条同时给了 n / d 两段英文'), + + # ---------------- D. 打包脚本 ---------------- + ('D1 离线包名写死 amd64', + OFFLINE, + 'NAME="drouter-${VERSION}-offline-${ARCH}"', + 'NAME="drouter-${VERSION}-offline-amd64"', + '离线包名用 dpkg 架构变量拼'), + + ('D2 包内 README 目标机描述写死 amd64', + OFFLINE, + '**适用场景**:目标机(Debian 13 / ${ARCH})**没有外网**,想一次装完。', + '**适用场景**:目标机(Debian 13 (trixie) amd64)**没有外网**,想一次装完。', + '包内离线安装说明里的目标机描述用 ${ARCH}'), + + ('D3 Docker 构建不接受第 2 个架构参数', + DOCKER, + 'ARCH="${2:-$DPKG_NATIVE}"', + 'ARCH="$DPKG_NATIVE"', + 'Docker 构建接受第 2 个架构参数'), + + ('D4 交叉构建不传 --platform', + DOCKER, + ' PLATFORM_ARG=(--platform "linux/$ARCH")', + ' PLATFORM_ARG=()', + '交叉构建时传 --platform linux/'), + + ('D5 交叉构建镜像标签不带架构后缀', + DOCKER, + ' IMGTAG="$TAG-$ARCH"', + ' IMGTAG="$TAG"', + '交叉构建时镜像标签带 - 后缀'), + + ('D6 docker build 没接 PLATFORM_ARG', + DOCKER, + 'docker build "${PLATFORM_ARG[@]}" -f', + 'docker build -f', + 'Docker 构建真的把隐含架构参数传给 docker build'), +] + + +def run(): + r = subprocess.run([PY, JUDGE], capture_output=True, cwd=ROOT) + return r.returncode, (r.stdout or b'').decode('utf-8', 'replace') + + +def main(): + print('基线(应全绿):') + rc, out = run() + tail = out.strip().splitlines()[-1] if out.strip() else '' + print(' rc=%d %s' % (rc, tail)) + if rc != 0: + print('✘ 基线就是红的,先修好再谈反向验证') + print(out[-2000:]) + return 1 + + bad = [] + for desc, rel, old, new, kw in CASES: + p = os.path.join(ROOT, rel) + with open(p, 'rb') as f: + orig = f.read().decode('utf-8') + if old not in orig: + print(' 跳过 %-52s (锚点未命中)' % desc) + bad.append(desc + ' 锚点未命中') + continue + try: + with open(p, 'wb') as f: + f.write(orig.replace(old, new, 1).encode('utf-8')) + rc, out = run() + caused = (rc != 0) and (kw in out) + if caused: + print(' ok %-52s → 如约变红' % desc) + else: + print(' FAIL %-52s → rc=%d 关键词命中=%s' + % (desc, rc, kw in out)) + bad.append(desc) + finally: + with open(p, 'wb') as f: + f.write(orig.encode('utf-8')) + + rc, out = run() + tail = out.strip().splitlines()[-1] if out.strip() else '' + print() + print('还原后基线:rc=%d %s' % (rc, tail)) + if rc != 0: + bad.append('还原后未恢复全绿') + print(out[-1500:]) + + print() + print('反向验证 %d 例,异常 %d 例' % (len(CASES), len(bad))) + for b in bad: + print(' ! %s' % b) + return 1 if bad else 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/_dev/t-arch.py b/_dev/t-arch.py new file mode 100644 index 0000000..018f2b9 --- /dev/null +++ b/_dev/t-arch.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""判据:架构无关化(arm64 适配)。 + +2026-10-16 做 arm64 Debian(Armbian 26.8.1 trixie / aarch64 / 6.18.35-ophub) +适配时,发现项目里有几处把 x86 当默认写死在字符串模板里 —— 在 amd64 上 +看不出来,一到 arm64 就露馅。这个检查器把那几处钉死,防止改回去。 + +分四组: + A. 更新模块(backend/drouter-update.py)—— 离线包资源名必须随本机架构变 + B. 固件探测(backend/drouter-helper.py)—— 设备树机器不能报「传统 BIOS」 + C. i18n 配套(web/i18n.js)—— 新增的展示串必须登记,否则英文界面漏中文 + D. 打包脚本(packaging/)—— 离线包说明与 Docker platform 支持 + +⚠️ 判据尽量查**语义/运行结果**,不要只查「某个名字出现过」。 + 反向验证在 _dev/t-arch-inject.py(改写实现 → 本文件必须变红)。 +""" +import ast +import importlib.util +import os +import re +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +P = lambda *a: os.path.join(ROOT, *a) + +fails = [] +n = 0 + + +def ck(desc, cond, extra=''): + global n + n += 1 + if not cond: + fails.append('%s %s' % (desc, extra)) + print('FAIL %s %s' % (desc, extra)) + else: + print('ok %s' % desc) + + +def read(p): + with open(p, encoding='utf-8') as f: + src = f.read() + return src + + +UPDATE = read(P('backend', 'drouter-update.py')) +HELPER = read(P('backend', 'drouter-helper.py')) +I18N = read(P('web', 'i18n.js')) +OFFLINE = read(P('packaging', 'build-offline-bundle.sh')) +DOCKER = read(P('packaging', 'build-docker.sh')) + +print('=' * 56) +print('A. 更新模块:附件名必须随架构变') +print('=' * 56) + +# A1 —— 最直接的一条:模板里不许再出现具体的 amd64。 +# 只查「资源名模板位置」(带 -offline- 的那串),避免把注释里的 amd64 也算进来。 +ck('离线包资源名模板不含写死的 amd64', + 'offline-amd64' not in UPDATE, + 'offline-amd64 残留=%d 处' % UPDATE.count('offline-amd64')) +ck('离线包资源名模板不含写死的 arm64(应当用变量拼)', + 'offline-arm64' not in UPDATE) + +# A2 —— 真的跑一遍:给不同架构必须拼出不同文件名。 +# ⚠️ 这一条是**运行态**判据,比「代码里有没有 _deb_arch 这个字符串」强得多: +# 把 build_assets 里的 %s 换成写死的 amd64,拼出来立刻露馅。 +spec = importlib.util.spec_from_file_location( + 'drouter_update_arch', P('backend', 'drouter-update.py')) +U = importlib.util.module_from_spec(spec) +spec.loader.exec_module(U) + +ASSETS = getattr(U, 'ASSETS', []) + +names = {} +_load_err = '' +try: + for arch in ('amd64', 'arm64', 'armhf'): + d = {a['key']: a['name'] for a in U.asset_list('9.9.9', arch=arch)} + names[arch] = d +except Exception as e: # pragma: no cover + _load_err = str(e) + +ck('asset_list 支持按架构拼出清单(模块可加载)', not _load_err, _load_err) +if not _load_err: + ck('arm64 拼出 -offline-arm64- 的离线包名', + names['arm64']['offline'] == 'drouter-9.9.9-offline-arm64.tar.gz', + names['arm64']['offline']) + ck('amd64 拼出 -offline-amd64- 的离线包名', + names['amd64']['offline'] == 'drouter-9.9.9-offline-amd64.tar.gz', + names['amd64']['offline']) + # 三个架构必须两两不同 —— 防止有人把 _deb_arch() 写成常量 + ck('不同架构拼出的离线包名两两不同', + names['amd64']['offline'] != names['arm64']['offline'] + != names['armhf']['offline'] != names['amd64']['offline']) + # deb 是 Architecture: all,**必须**三架构同名 + ck('deb 本体三架构同名(Architecture: all)', + names['amd64']['deb'] == names['arm64']['deb'] == names['armhf']['deb'], + names['arm64']['deb']) + ck('只有离线包标了 arch_specific', + [a['arch_specific'] for a in U.asset_list('9.9.9', arch='arm64')] + == [False, True, False]) + +# A3 —— ASSETS 是四元组,缺一列会让 asset_list 解包炸 ValueError +# (表现是「更新」页整块渲染不出来) +ck('ASSETS 非空且每个条目是 4 元组', + bool(ASSETS) and all(isinstance(a, tuple) and len(a) == 4 for a in ASSETS), + str([len(a) for a in ASSETS])) + +# A4 —— 下载前先用官方 SHA256SUMS 清单核对有没有这个架构的包。 +# 没有这条,arm64 用户点「离线包更新」会先传几十 MB 才失败。 +ck('带架构后缀的附件在下载前用 SHA256SUMS 核对存在性', + re.search(r"target\.get\('arch_specific'\)", UPDATE) + and re.search(r"_manifest\s*=\s*_fetch_manifest\(ver\)", UPDATE) + and re.search(r"target\['name'\]\s+not\s+in\s+_manifest", UPDATE)) +ck('清单核对放在起 worker 之前(不能下了才说没有)', + UPDATE.index("_fetch_manifest(ver)") < UPDATE.index("_apply_worker(ver, target"), + '核对位置在 worker 之后 = 白做完') + +print() +print('=' * 56) +print('B. 固件探测:设备树机器不能报传统 BIOS') +print('=' * 56) + +ck('_read_bios 存在设备树分支(第三种固件类型)', + '设备树 (Device Tree)' in HELPER) +ck('设备树分支返回稳定枚举 fw_type=dt', + re.search(r"firmware,\s*fw_type\s*=\s*'设备树 \(Device Tree\)',\s*'dt'", HELPER) + is not None) +# 判据顺序:EFI 优先于设备树 —— 树莓派 4 既能 EFI 也能设备树,应当认 EFI +_i_dt_branch = HELPER.find("elif has_dt:") +_i_efi_branch = HELPER.find("if is_efi:") +ck('判据顺序 EFI 优先于设备树', + _i_efi_branch >= 0 and _i_dt_branch >= 0 and _i_efi_branch < _i_dt_branch, + 'efi@%s dt@%s' % (_i_efi_branch, _i_dt_branch)) +ck('设备树两个候选路径都查(/proc 与 /sys/firmware)', + "'/proc/device-tree/model'" in HELPER + and "'/sys/firmware/devicetree/base/model'" in HELPER) +# compatible 属性是多条 \0 结尾串拼的,不切开会把整串(含空字符)塞给前端 +ck('_dt_read 按 NUL 切开取第一条(不是整串原样返回)', + re.search(r"for part in raw\.split\(b'\\0'\):", HELPER) is not None) +ck('无 DMI 厂商时用设备树板名兜底(面板厂商格不留空)', + re.search(r"if not vendor:\s*\n\s*vendor = dt_model", HELPER) is not None) +# 兼容:前端读的是 firmware / is_efi / vendor / version / date。 +# 加字段没问题,**删掉或改名**会让概览页那一行直接空掉。 +_bios_keys = set() +try: + _tree = ast.parse(HELPER) + for _node in ast.walk(_tree): + if isinstance(_node, ast.FunctionDef) and _node.name == '_read_bios': + for _sub in ast.walk(_node): + # 只认**最后的 return 字典**,别把中途别的字典也算进来 + if isinstance(_sub, ast.Return) and isinstance(_sub.value, ast.Dict): + for _k in _sub.value.keys: + if isinstance(_k, ast.Constant): + _bios_keys.add(_k.value) +except Exception as _e: # pragma: no cover + print(' (AST 解析异常:%s)' % _e) + +ck('_read_bios 返回值里有 fw_type 枚举', + 'fw_type' in _bios_keys, str(sorted(_bios_keys))) +ck('_read_bios 仍返回前端在用的旧字段(firmware/is_efi/vendor/version/date)', + {'firmware', 'is_efi', 'vendor', 'version', 'date'} <= _bios_keys, + str(sorted({'firmware', 'is_efi', 'vendor', 'version', 'date'} - _bios_keys))) + +print() +print('=' * 56) +print('C. i18n:新增展示串必须登记') +print('=' * 56) + +_i = I18N.find("'SYSINFO_NOTES'") +_j = I18N.find("'PAGE_NOTES'", _i if _i > 0 else 0) +_block = I18N[_i:_j] if _i > 0 and _j > _i else '' +ck('i18n 的 SYSINFO_NOTES 里登记了「设备树 (Device Tree)」', + "'设备树 (Device Tree)'" in _block) +# ⚠️ 必须把范围限定在**本词条的花括号内**。早先用 300 字符的松弛窗口找 'd':, +# 会跨到下一条词条(紧挨着的「未读取到温度传感器…」恰好带 'd':)→ +# 删掉本词条的 d 之后判据照样绿。反向验证 C2 揪出来的假绿。 +_m_dt = re.search(r"'设备树 \(Device Tree\)':\s*(\{.*?\})", _block, re.S) +_entry_dt = _m_dt.group(1) if _m_dt else '' +ck('该词条同时给了 n / d 两段英文', + "'n': '" in _entry_dt and "'d': '" in _entry_dt, + '词条块=%r' % (_entry_dt[:80],)) +# 同一个 key 重复登记会让 JS 直接语法/语义冲突 +ck('「设备树 (Device Tree)」只登记一次', + I18N.count("'设备树 (Device Tree)'") == 1) + +print() +print('=' * 56) +print('D. 打包脚本') +print('=' * 56) + +ck('离线包名用 dpkg 架构变量拼', + re.search(r'NAME="drouter-\$\{VERSION\}-offline-\$\{ARCH\}"', OFFLINE) is not None + or 'drouter-${VERSION}-offline-${ARCH}' in OFFLINE) +# 包内 README 里的两处 amd64 已换成变量 +ck('包内离线安装说明里的目标机描述用 ${ARCH}(不是写死 amd64)', + 'Debian 13 / ${ARCH}' in OFFLINE) +ck('包内离线安装说明的系统核对项提到 dpkg --print-architecture', + 'dpkg --print-architecture\\`' in OFFLINE + or 'dpkg --print-architecture' in OFFLINE) +ck('包内说明不再出现「Debian 13 (trixie) amd64」的写死表述', + 'Debian 13 (trixie) amd64' not in OFFLINE) + +ck('Docker 构建接受第 2 个架构参数', + re.search(r'ARCH="\$\{2:-\$DPKG_NATIVE\}"', DOCKER) is not None) +ck('交叉构建时传 --platform linux/', + re.search(r'PLATFORM_ARG=\(--platform "linux/\$ARCH"\)', DOCKER) is not None) +ck('交叉构建时镜像标签带 - 后缀', + re.search(r'IMGTAG="\$TAG-\$ARCH"', DOCKER) is not None) +ck('原生构建时 IMGTAG 就是 TAG(不改动既有发版产物)', + re.search(r'IMGTAG="\$TAG"\s*\n', DOCKER) is not None) +ck('Docker 构建真的把隐含架构参数传给 docker build', + 'docker build "${PLATFORM_ARG[@]}"' in DOCKER) + +print() +print('=' * 56) +print('判据 %d 条,失败 %d 条' % (n, len(fails))) +if fails: + for f in fails: + print(' FAIL: %s' % f) +sys.exit(1 if fails else 0) diff --git a/backend/drouter-helper.py b/backend/drouter-helper.py index 6f918f0..77f799f 100644 --- a/backend/drouter-helper.py +++ b/backend/drouter-helper.py @@ -1857,8 +1857,55 @@ def _read_temp(): return temp_c, temp_src +# ---- 设备树(Device Tree)属性:ARM / RISC-V 板子的「硬件名片」------------- +# 这类机器用 U-Boot + 设备树启动,没有 SMBIOS/DMI,也常常没有 EFI。 +# 属性值是若干条以 \0 结尾的字符串拼在一起,取第一个非空的那条。 +# 两个路径都写:/proc/device-tree 是内核 6.x 的常规挂载点, +# /sys/firmware/devicetree/base 是 sysfs 侧的路径,个别发行版只有后者。 +DT_MODEL_PATHS = ('/proc/device-tree/model', + '/sys/firmware/devicetree/base/model') +DT_COMPAT_PATHS = ('/proc/device-tree/compatible', + '/sys/firmware/devicetree/base/compatible') + + +def _dt_read(paths): + """读设备树里的一条字符串属性。读不到返回空串,不抛异常。 + + ⚠️ 必须按 \0 切开再取第一条:compatible 属性里塞的是一串匹配优先级 + 从高到低的标识(例如 amlogic,s912 后跟 amlogic,meson-gxm), + 原样返回会把一整串连同结尾的空字符一起塞给前端。 + """ + for p in paths: + try: + with open(p, 'rb') as f: + raw = f.read() + except Exception: + continue + for part in raw.split(b'\0'): + s = part.decode('utf-8', 'replace').strip() + if s: + return s + return '' + + def _read_bios(): - """真实探测固件类型:UEFI / 传统 BIOS,并尽量读出厂商与版本。""" + """真实探测固件:UEFI / 传统 BIOS / 设备树,并尽量读出厂商与版本。 + + ⚠️ 第三个分支「设备树」是 2026-10-16 做 arm64 适配时才加的。 + 早先只看两件事 —— /sys/firmware/efi 在不在、/sys/class/dmi 下的字段能不能读到。 + 在 x86 上这样够用(UEFI 与传统 BIOS 二分),但 **ARM 机器两个都没有**: + 它不走 EFI,也从来没有 SMBIOS/DMI。于是这类机器一律落到 else 分支, + 系统概览显示「传统 BIOS」,厂商 / 版本 / 日期还是空白 —— 面板上这一行是**错的**, + ARM 开发板根本没有 BIOS 这个东西。 + + 实测形态(本次 arm64 适配的验证机,见 docs/arm64 部署说明): + Armbian 26.8.1 trixie(Debian 13.6)/ aarch64 / 内核 6.18.35-ophub + Amlogic S912 板 —— 无 /sys/firmware/efi、无 /sys/class/dmi, + /proc/device-tree/model 读到板名。 + + 判据顺序不能换:先 EFI,再设备树,最后才是 BIOS。 + 树莓派 4 这类既能以 EFI 也能以设备树启动的机器,应当优先认 EFI。 + """ is_efi = os.path.isdir('/sys/firmware/efi') vendor = version = date = '' # DMI 信息(部分虚拟机/hypervisor 不暴露,容错处理) @@ -1883,15 +1930,35 @@ def _read_bios(): vendor = f.read().strip() except Exception: pass + # 设备树:ARM / RISC-V 板子的板名与 SoC compatible + dt_model = _dt_read(DT_MODEL_PATHS) + compat = _dt_read(DT_COMPAT_PATHS) + has_dt = bool(dt_model or compat) + # 没有 DMI 厂商时,拿设备树的板名顶上 —— 面板上「厂商」这一格为空 + # 会让用户以为探测坏了,而板名恰恰是这类机器上唯一有意义的标识。 + if not vendor: + vendor = dt_model + if is_efi: + firmware, fw_type = 'UEFI', 'efi' + elif has_dt: + firmware, fw_type = '设备树 (Device Tree)', 'dt' + else: + firmware, fw_type = '传统 BIOS', 'bios' return { - 'firmware': 'UEFI' if is_efi else '传统 BIOS', + 'firmware': firmware, + # ⚠️ 面板展示用 firmware(中文,走前端 bt4 查英文),这里是给程序判断的 + # 稳定枚举 —— 别拿中文串做分支,加语言就要跟着改代码。 + 'fw_type': fw_type, 'is_efi': is_efi, + 'is_dt': has_dt, 'vendor': vendor, 'version': version, 'date': date, + 'compat': compat, } + def _read_file(path, limit=200): """安全读取一个小文本文件,失败返回空串。""" try: diff --git a/backend/drouter-update.py b/backend/drouter-update.py index 6fb7a03..3cd7bbc 100644 --- a/backend/drouter-update.py +++ b/backend/drouter-update.py @@ -87,12 +87,60 @@ DL_SOURCES = [ OFFICIAL_ASSET_TMPL = DL_SOURCES[0][1] ASSET_TMPL = OFFICIAL_ASSET_TMPL # 向后兼容旧引用 +# ---- 本机架构 ------------------------------------------------------------ +# ⚠️ 必须知道本机架构,因为**离线包是分架构发布的**:包里那几百个 .deb 全是 +# 某一个 CPU 的二进制,引用名里带着架构后缀。 +_UNAME2ARCH = {'x86_64': 'amd64', 'amd64': 'amd64', 'i686': 'i386', + 'i386': 'i386', 'aarch64': 'arm64', 'arm64': 'arm64', + 'armv7l': 'armhf', 'armv6l': 'armhf', 'riscv64': 'riscv64'} + + +def _deb_arch(): + """本机 dpkg 架构名(amd64 / arm64 / armhf …)。 + + arm64 适配(2026-10-16)加的:离线包的模板原先写死 amd64, + 于是 arm64 机器上「查看更新内容并升级」会列出并下载一份 x86 离线包, + 装的时候才炸依赖校验 —— 白白传了几十 MB。现在按本机架构拼名字。 + + 取不到 dpkg 时(非 Debian 系)退到 uname -m 的映射;映射也对不上就原样返回, + 这是「猜」,但总比一头栽向明确不匹配的架构强。 + """ + try: + p = subprocess.run(['dpkg', '--print-architecture'], + capture_output=True, text=True, timeout=10) + if p.returncode == 0 and p.stdout.strip(): + return p.stdout.strip() + except Exception: + pass + machine = '' + try: + machine = os.uname().machine + except Exception: + pass + return _UNAME2ARCH.get(machine, machine or 'amd64') + + # 我们关心的附件(顺序即前端展示顺序) -ASSETS = [ - ('deb', 'drouter_{ver}_all.deb', 'Deb 安装包(推荐,523 KB)'), - ('offline', 'drouter-{ver}-offline-amd64.tar.gz', '离线安装包(87 MB,断网可装)'), - ('docker', 'drouter-{ver}-docker.tar', 'Docker 镜像(169 MB)'), -] +# ⚠️ 每个条目是**四元组** (key, 文件名模板, 说明, 是否随架构变化)。 +# 第四个字段是 arm64 适配时加的:apply_update 靠它判断要不要先拿 +# SHA256SUMS 清单核对文件名。改这里的元组长度,asset_list() 的解包 +# 必须同步改 —— 不同步的表现是面板上「更新」页整块渲染不出来。 +# 说明文字里的体积是 1.0.10 的实测值,仅作参考。 +def build_assets(arch=None): + """按架构拼出附件清单。传 arch 便于单测覆盖各种 CPU。""" + a = arch or _deb_arch() + return [ + ('deb', 'drouter_{ver}_all.deb', 'Deb 安装包(推荐,523 KB)', False), + ('offline', 'drouter-{ver}-offline-%s.tar.gz' % a, + '离线安装包(%s,87 MB,断网可装)' % a, True), + ('docker', 'drouter-{ver}-docker.tar', 'Docker 镜像(169 MB)', False), + ] + + +# ⚠️ 保留模块级常量给旧调用方(内容 == 本机架构下的清单)。 +# 它是**导入时**算一次的:同一进程里 dpkg 的架构不会变, +# 没必要每次 asset_list() 都跑一遍子进程。 +ASSETS = build_assets() _stop_flag = threading.Event() _lock = threading.Lock() @@ -299,12 +347,16 @@ def _write_state(**kw): pass -def asset_list(ver): - """给定版本,列出官方附件的真实文件名 + 说明。""" +def asset_list(ver, arch=None): + """给定版本,列出官方附件的真实文件名 + 说明。 + + arch 缺省时按本机架构拼;传值可用于单测(不看机器)。 + 'arch_specific' 为 True 的条目,文件名里带架构后缀。 + """ out = [] - for key, tmpl, desc in ASSETS: + for key, tmpl, desc, arch_specific in (build_assets(arch) if arch else ASSETS): out.append({'key': key, 'name': tmpl.format(ver=ver.lstrip('v')), - 'desc': desc}) + 'desc': desc, 'arch_specific': arch_specific}) return out @@ -438,6 +490,18 @@ def apply_update(ver, key, backup=True): if not target: return {'ok': False, 'msg_cn': '未知的附件类型:%s' % key} + # arm64 适配(2026-10-16):带架构后缀的附件(离线包)不是每个版本每个 + # 架构都发。别等下完 87 MB 才炸 —— 先拿 SHA256SUMS 清单对一下有没有这个名字。 + # 清单取不到(离线 / 三个源都不通)时不拦:那种情况本来也走不到下载成功。 + if target.get('arch_specific'): + _manifest = _fetch_manifest(ver) + if _manifest and target['name'] not in _manifest: + return {'ok': False, + 'msg_cn': '该版本没有发布本机架构(%s)的离线包:%s。' + 'deb 包是 Architecture: all,各 CPU 通用,' + '请改用「Deb 安装包」这一项更新。' + % (_deb_arch(), target['name'])} + # ⚠️ 「检查 running」和「写 running=True」必须在**同一把锁内**。 # 早先写在锁外,两个快速请求都能通过检查 → 各自起一个 worker → # 同时向同一个 dest + '.part' 写,字节交错 → 产出损坏的安装包。 diff --git a/devtools/verify-arm64.py b/devtools/verify-arm64.py new file mode 100644 index 0000000..779b326 --- /dev/null +++ b/devtools/verify-arm64.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""arm64 / 非 x86 板子的本地只读验收。 + +在目标机上跑一遍,确认 drouter 的代码在**这台**机器上站得住: + + python3 devtools/verify-arm64.py # 默认以仓库根目录为源码树 + python3 devtools/verify-arm64.py /opt/drouter + +查什么: + 1. 后端每个 .py 能 compile + 2. 固件探测:设备树机器必须报「设备树」,不能落到「传统 BIOS」 + 3. 依赖自检 depcheck 能跑完并给出发行版名 + 4. 配置渲染:能出配置的出配置,缺必填字段的报**指定的**校验,不许报别的 + 5. 更新模块:离线包文件名必须跟本机 dpkg 架构一致 + +⚠️ 全程只读:不装包、不写配置、不起任何服务、不动网络。 + 8080 端口上有别的业务在跑的机器也能安全跑这一份。 + 退出码 0 = 全通过。 +""" +import glob +import importlib.util +import json +import os +import subprocess +import sys + +ROOT = os.path.abspath(sys.argv[1]) if len(sys.argv) > 1 else os.path.dirname( + os.path.dirname(os.path.abspath(__file__))) + +# 这三个依赖 Linux 专属的 /sys 接口,别在 Windows 开发机上跑,会误报。 +if not sys.platform.startswith('linux'): + print('这份验收脚本要在目标 Linux 机器上跑(依赖 /sys/firmware、' + '/sys/class/dmi 等接口)。') + print('ROOT=%s' % ROOT) + sys.exit(2) + +fails = [] +n = 0 + + +def ck(desc, cond, extra=''): + global n + n += 1 + if cond: + print('ok %s' % desc) + else: + print('FAIL %s %s' % (desc, extra)) + fails.append(desc) + + +def load(name): + spec = importlib.util.spec_from_file_location( + name, os.path.join(ROOT, 'backend', name + '.py')) + m = importlib.util.module_from_spec(spec) + spec.loader.exec_module(m) + return m + + +print('源码树:%s' % ROOT) +print('=' * 60) +print('1. 后端模块语法') +print('=' * 60) +names = sorted(os.path.basename(p)[:-3] + for p in glob.glob(os.path.join(ROOT, 'backend', '*.py'))) +if not names: + print('✘ %s/backend 下没找到 .py —— 源码树给错了?' % ROOT) + sys.exit(1) +bad = [] +for nm in names: + with open(os.path.join(ROOT, 'backend', nm + '.py'), + encoding='utf-8') as f: + src = f.read() + try: + compile(src, nm, 'exec') + except SyntaxError as e: + bad.append('%s: %s' % (nm, e)) +ck('后端 %d 个模块 compile 通过' % len(names), not bad, '; '.join(bad)) + +H = load('drouter-helper') +print() +print('=' * 60) +print('2. 固件探测(ARM 板最容易误报的一项)') +print('=' * 60) +bios = H._read_bios() +print(' _read_bios() =', json.dumps(bios, ensure_ascii=False)) +machine = os.uname().machine +# 这一条只替 placeholders 做提示,不参与判定 —— 有没有 DMI 是**实探测**出来的。 +has_dmi = os.path.isdir('/sys/class/dmi') +has_efi = os.path.isdir('/sys/firmware/efi') +print(' 本机:machine=%s /sys/class/dmi=%s /sys/firmware/efi=%s' + % (machine, has_dmi, has_efi)) +ck('fw_type 是三者之一(efi / dt / bios)', + bios.get('fw_type') in ('efi', 'dt', 'bios'), str(bios.get('fw_type'))) +if not (has_dmi or has_efi): + ck('无 EFI 且无 DMI ⇒ 必须判成设备树,不能写成传统 BIOS', + bios.get('fw_type') == 'dt', str(bios.get('fw_type'))) + ck('厂商格有值(拿设备树板名兜底,不能空着)', + bool(bios.get('vendor')), repr(bios.get('vendor'))) +if bios.get('fw_type') == 'dt': + ck('compat 按 NUL 切开,不含空字符', + '\x00' not in (bios.get('compat') or ''), + repr(bios.get('compat'))) +ck('前端在用的旧字段一个没少', + all(k in bios for k in ('firmware', 'is_efi', 'vendor', 'version', 'date'))) + +print() +print('=' * 60) +print('3. 依赖自检 depcheck') +print('=' * 60) +p = subprocess.run([sys.executable, 'drouter-helper.py', 'depcheck', + '{"op":"check"}'], cwd=os.path.join(ROOT, 'backend'), + capture_output=True, text=True, timeout=180) +try: + d = json.loads(p.stdout or '{}') +except ValueError: + d = {'ok': False, 'msg_cn': (p.stdout or p.stderr or '')[:200]} +ck('depcheck 返回 ok', bool(d.get('ok')), str(d.get('msg_cn'))[:160]) +data = d.get('data') or {} +print(' summary =', json.dumps(data.get('summary'), ensure_ascii=False)) +print(' extra =', json.dumps(data.get('extra'), ensure_ascii=False)) +ck('发行版名识别出来了', bool((data.get('extra') or {}).get('os'))) +need = [i['name'] for i in (data.get('items') or []) + if i.get('required') and not i.get('ok')] +print(' 必需但未装 %d 项:%s' % (len(need), '、'.join(need))) +print(' (缺的都是路由功能包,面板「依赖自检」页可以逐项安装;') +print(' Python 运行时与本项目自身的可导入性已经在第 1 步验过)') + +print() +print('=' * 60) +print('4. 配置渲染(渲染但绝不落盘)') +print('=' * 60) +# 空 cfg 下这三个模块第一步就是必填字段校验。写死期望串是为了抓「校验本身 +# 挂了」这种情况 —— 报别的(比如 ImportError、KeyError)才是真问题。 +EXPECT_VALIDATE = { + 'dnsmasq': 'LAN 接口不能为空', + 'network': 'WAN 网卡不能为空', + 'ppp': 'PPPoE 网卡不能为空', +} +spec = importlib.util.spec_from_file_location( + 'render', os.path.join(ROOT, 'backend', 'render.py')) +R = importlib.util.module_from_spec(spec) +spec.loader.exec_module(R) +rendered = {} +wrong = [] +for mod in sorted(R.RENDERERS): + try: + r = R.render(mod, {}) + if isinstance(r, (list, tuple)) and r and len(r[0]) == 2: + rendered[mod] = r[0][1] + else: + wrong.append('%s 返回了非二元组' % mod) + except Exception as e: + want = EXPECT_VALIDATE.get(mod) + got = str(e) + if want is None or want not in got: + wrong.append('%s 抛了非预期异常:%s' % (mod, got[:60])) + rendered[mod] = None +NL = chr(10) +for mod in sorted(rendered): + body = rendered[mod] + if body is None: + print(' %-12s 必填校验:%s' % (mod, EXPECT_VALIDATE.get(mod, '?'))) + else: + print(' %-12s %3d 行 / %5d 字符' + % (mod, body.count(NL) + 1, len(body))) +ck('每个模块要么出配置、要么报指定的必填校验', not wrong, '; '.join(wrong)) + +print() +print('=' * 60) +print('5. 更新模块的架构感知') +print('=' * 60) +U = load('drouter-update') +arch = U._deb_arch() +print(' 本机 _deb_arch() =', arch) +lst = U.asset_list('1.0.10') +for a in lst: + print(' %-8s %-42s arch_specific=%s' + % (a['key'], a['name'], a['arch_specific'])) +ck('离线包名带本机架构后缀', + any(a['key'] == 'offline' and ('offline-%s' % arch) in a['name'] + for a in lst)) +ck('deb 不带架构后缀(Architecture: all)', + any(a['key'] == 'deb' and '_all.deb' in a['name'] for a in lst)) +ck('别的架构拼出别的名字(不是写死一个)', + [x['name'] for x in U.asset_list('1.0.10', arch='amd64')] + != [x['name'] for x in lst] or arch == 'amd64') + +print() +print('=' * 60) +print('判据 %d 条,失败 %d 条' % (n, len(fails))) +for f in fails: + print(' ! %s' % f) +print() +print('⚠️ 这份脚本只验「代码在这台机器上能不能跑」,') +print(' 不验「当路由器转发起来行不行」—— 那要点面板上的应用,会真改网络。') +sys.exit(1 if fails else 0) diff --git a/docs/07-arm64-适配说明.md b/docs/07-arm64-适配说明.md new file mode 100644 index 0000000..1438e4b --- /dev/null +++ b/docs/07-arm64-适配说明.md @@ -0,0 +1,247 @@ +# Drouter arm64 Debian 适配说明 + +> 适用版本:**v1.0.10** +> 适配日期:2026-10-16 +> 本轮适配**只针对一个发行版**(下面第 1 节那台机器),别的 arm64 发行版未经实测。 + +## 0. 先说清楚这次做了什么、没做什么 + +有用户提需要在 arm64 的 Debian 上跑 drouter,于是拿了一台 arm64 的 Debian 系 +发行版做**纯适配**:把项目里所有写死 x86 假设的地方找出来修掉, +在这个发行版上实测构建与运行,然后把「发行版名 / 内核名 / CPU / 固件 / +板卡」这些机器特征提取出来写进本说明。 + +**做了**:源码层面的架构无关化修复(见第 3 节)+ 在目标发行版上实测 +(见第 2 节)+ 本说明文档。 + +**没做**(不要被后面的绿勾误导): + +- **没有在目标机上启用 drouter 的 Web 面板与常驻服务**,也没装依赖、没执行任何 + 「应用到系统」。目标机的 8080 端口已有别的业务在跑,出于不影响现场业务的前提, + 只做了**只读**验证:构建、渲染、依赖自检、各种探测函数。 +- **没有做 NAT / DHCP / PPPoE 的实际转发验证**。那需要在面板上点「应用」, + 会真的改网卡与起服务 —— 本次刻意避开。 +- **没有验证除下面这台以外的任何 arm64 发行版**。树莓派版 Debian、Debian 官方 + aarch64 安装镜像、Ubuntu arm64 都不在本次范围内,它们是否同样可用未知。 + +--- + +## 1. 验证机环境(提取自目标机真实输出) + +事实来源全部是目标机上的命令输出,不是推断。 + +| 项目 | 实际值 | 取值命令 | +|---|---|---| +| **发行版名** | `Armbian 26.8.1 trixie` | `/etc/os-release` → `PRETTY_NAME` | +| 底层发行版 | `Debian GNU/Linux 13 (trixie)` | `NAME` / `VERSION` | +| 版本标识 | `VERSION_ID=13`、`VERSION_CODENAME=trixie`、`DEBIAN_VERSION_FULL=13.6` | `/etc/os-release` | +| 发行版 ID | `ID=debian` | `/etc/os-release` | +| 主机标识 | `ARMBIAN_PRETTY_NAME="Armbian 26.8.1 trixie"`(Armbian 附加字段) | `/etc/os-release` | +| **内核名** | `6.18.35-ophub` | `uname -r` | +| 内核全串 | `Linux armbian 6.18.35-ophub #1 SMP PREEMPT_DYNAMIC Wed Jun 17 14:15:20 UTC 2026 aarch64 GNU/Linux` | `uname -a` | +| 内核编译器 | `aarch64-none-linux-gnu-gcc (Arm GNU Toolchain 15.2.Rel1 (Build arm-15.86)) 15.2.1 20251203` | `/proc/version` | +| **CPU 架构** | `aarch64`(dpkg 架构名 `arm64`) | `uname -m` / `dpkg --print-architecture` | +| CPU | `ARM Cortex-A53` × **8 核**(`ARMv8 Processor rev 4 (v8l)`),100–1512 MHz | `lscpu` / `/proc/cpuinfo` | +| **板卡(设备树)** | `Octopus Planet` | `/proc/device-tree/model` | +| SoC compatible | `amlogic,q200` | `/proc/device-tree/compatible` | +| Armbian 板型记录 | `BOARD="S912"`、`BOARD_NAME="Aml.S912"`、`BOARDFAMILY="meson-gxm"`、`LINUXFAMILY="amlogic"`、`ARCH=arm64` | `/etc/armbian-release` | +| **启动方式** | **无 EFI、无 DMI**,U-Boot + 设备树 | `/sys/firmware/efi`、`/sys/class/dmi` 均不存在 | +| 虚拟化 | 无(物理板,非容器非虚机) | `systemd-detect-virt` → `none` | +| 温度源 | `thermal_zone0` = `cpu-thermal`(实测 44.0 ℃)、`hwmon0` = `scpi_sensors` | `/sys/class/thermal/*`、`/sys/class/hwmon/*` | +| 有线网卡 | `eth0`(altname `end0`) | `ip -o link` | +| APT 架构 | `APT::Architecture "arm64"`,无外来架构 | `apt-config dump` | +| 资源 | 8 核 / 1787 MB 内存 / 根分区 6.5 GB | `nproc` / `free -m` / `df -h /` | + +> 关于 SoC 的一点说明,照实记录差异:设备树的 `compatible` 写的是 +> `amlogic,q200`,而 Armbian 的板型记录是 `S912 / meson-gxm`(Amlogic S912 +> 正是 meson-gxm 家族)。两者指向同一个 SoC 家族,只是来源不同 —— 前者是内核 +> 设备树由 bootloader 提供,后者是 Armbian 构建配置里的板型声明。 +> drouter 面板显示的是设备树那一套。 + +--- + +## 2. 在目标机上的实测结果 + +### 2.1 构建:`.deb` 一次通过 + +```bash +bash packaging/build-deb.sh +``` + +``` +== 4/6 校验 == + 后端模块 14 个、语法全部通过 + systemd 单元 6 个齐全 +== 5/6 构建 == +dpkg-deb: building package 'drouter' in '.../drouter_1.0.10_all.deb'. +``` + +产物 `drouter_1.0.10_all.deb`,**753 396 字节**,`Architecture: all`。 +包里 `docs/07-arm64-适配说明.md` 也在(随 deb 进 `/opt/drouter/docs/`)。 + +> `Architecture: all` 是关键:drouter 是纯 Python + 原生 JS,没有编译产物, +> 因此**同一个 deb 在 amd64 与 arm64 上通用**,不需要分架构重建本体。 +> 真正分架构的只有「离线依赖包」(见 2.5)。 + +### 2.2 依赖自检:`depcheck op=check` 正常 + +``` +自检完成:42 项已就绪,5 项缺失,26 项可选未装 # 合计 73 项 +extra: {"os": "Armbian 26.8.1 trixie", "os_id": "13", "desktops": [], "has_desktop": false} +``` + +发行版被正确识别成 `Armbian 26.8.1 trixie`,版本号读出 `13` 且按 trixie 核对通过 +(`offline-install.sh` 就是看 `VERSION_CODENAME` 这一项做版本匹配的)。 + +**5 项必需的缺失项**(这台机器本身没装路由相关软件包,属预期,不是适配问题): + +| 缺失项 | 对应包名 | +|---|---| +| radvd(IPv6 RA) | `radvd` | +| dhcpcd(DHCPv6 客户端) | `dhcpcd-base` | +| pppd(PPPoE 拨号) | `ppp` | +| miniupnpd(UPnP) | `miniupnpd` | +| conntrack(连接跟踪) | `conntrack` | + +### 2.3 配置渲染:`render_text`(只渲染,不落盘) + +按 `backend/render.py` 的 `RENDERERS` 真源逐个渲染,全部成功: + +渲染方式是 `render(模块, {})` —— **空配置**。结果分两类: + +| 模块 | 输出 | +|---|---| +| `chrony` | 8 行 / 170 字符 | +| `dhcpv6` | 12 行 / 168 字符 | +| `miniupnpd` | 16 行 / 425 字符 | +| `nfs` | 5 行 / 99 字符 | +| `nft_v4` | 44 行 / 1406 字符 | +| `nft_v6` | 35 行 / 1257 字符 | +| `radvd` | 22 行 / 517 字符 | +| `samba` | 22 行 / 637 字符 | + +| 模块 | 空配置下的响应 | +|---|---| +| `dnsmasq` | `LAN 接口不能为空` | +| `network` | `WAN 网卡不能为空` | +| `ppp` | `PPPoE 网卡不能为空` | + +这三条是**必填项校验**在起作用(`render.py` 的 `ValidateError`),不是渲染失败 —— +**与架构无关**,在 amd64 上喂同样的空配置会得到一模一样的三句话。 +判据里把这三句写死成期望值,为的是反过来抓「校验本身挂了」的情况: +真出了 `ImportError` / `KeyError` 那类才算问题。 + +### 2.4 修复前后对比(本轮真正改掉的东西) + +| 探测项 | 修复前在本机的输出 | 修复后 | +|---|---|---| +| 固件类型 | `传统 BIOS` ❌ | `设备树 (Device Tree)` ✅ | +| 厂商 | 空 ❌ | `Octopus Planet` ✅ | +| 固件枚举 | 无 | `fw_type: "dt"`、`is_dt: true` ✅ | + +这是本轮**唯一一个在 arm64 上必然报错的功能点**,详见第 3 节。 + +### 2.5 WireGuard 四态:正好踩中「裸 `.ko`」那一档 + +```json +{ + "state": "need_module", + "mod_loaded": false, + "mod_file": "/lib/modules/6.18.35-ophub/kernel/drivers/net/wireguard/wireguard.ko", + "tool": true, "kernel": "6.18.35-ophub", "fixable": true +} +``` + +值得一记:`helper` 里查模块文件的三种扩展名(`.ko` / `.ko.xz` / `.ko.zst`), +在这台 **ophub 内核**上命中的是**裸 `.ko`** —— Debian 官方内核通常是压缩过的 +`.ko.xz`。如果当初只按 Debian 默认形态写一种,这台机器就会被误判成 +「内核不支持 WireGuard」。这条不是本次改出来的,是被这台机器**佐证**了。 + +--- + +### 2.6 想在自己的板子上复验? + +仓库里留了一份只读验收脚本 `devtools/verify-arm64.py`,上面 2.1–2.5 这几项 +都写在里面了,在自己的 arm64 机器上一跑就有结论: + +```bash +python3 devtools/verify-arm64.py # 源码树 = 仓库根目录 +python3 devtools/verify-arm64.py /opt/drouter # 也可以指向已安装的位置 +``` + +它只干「能不能跑」这件事:**不装包、不写配置、不起服务、不动网络**。 +退 0 就是全通过。没DMI 也没 EFI 的机器它会坚持要判成设备树; +有 EFI 的机器(比如以 EFI 启动的树莓派)它按 EFI 那一档去核。 + +> ⚠️ 它**不**验「当路由器实际转发起来行不行」。那要点面板上的「应用」, +> 会真的改网卡、起服务 —— 别在跑着业务的机器上试。 + +--- + +## 3. 代码改动清单 + +| 文件 | 改动 | 为什么必须改 | +|---|---|---| +| `backend/drouter-helper.py` | `_read_bios()` 增加**设备树分支**;新增 `_dt_read()`、`DT_MODEL_PATHS`、`DT_COMPAT_PATHS`;返回结构加 `fw_type` / `is_dt` / `compat`,无 DMI 时拿设备树板名顶替厂商 | ARM 板没有 EFI 也没有 DMI,原代码把它们一律落到 else 分支写成「传统 BIOS」,面板上是**错的**信息。判据顺序刻意排成 EFI → 设备树 → BIOS:树莓派这类既能 EFI 又能设备树启动的机器要优先认 EFI | +| `web/i18n.js` | `bt.SYSINFO_NOTES` 增加 `设备树 (Device Tree)` 词条(`n` / `d` 两段英文) | 后端新增的展示串必须登记,否则英文界面直接漏出中文(项目 i18n 约定) | +| `backend/drouter-update.py` | 新增 `_deb_arch()`、`build_assets()`;`ASSETS` 由 `build_assets()` 生成;离线包文件名模板改为 `drouter-{ver}-offline-<本机架构>.tar.gz`;`asset_list()` 增加 `arch_specific` 标志;`apply_update()` 对带架构后缀的附件**先拿 SHA256SUMS 清单核对**,没有就明确报错 | 原先模板写死 `-offline-amd64`,arm64 机器点「一键更新」会下载一份 x86 离线包,白传几十 MB 才在装包阶段失败。清单核对让它在**下载之前**就报清楚 | +| `packaging/build-offline-bundle.sh` | 头部产物说明、塞进包里的 `README-离线安装.md` 两处的 `amd64` 改为 `$ARCH`;结尾打印架构提示 | 包名早就是 `$ARCH` 拼的,但说明文档写死 amd64 —— arm64 上打出来的包里带着一份「本机是 amd64」的说明,用户照着排查会走偏 | +| `packaging/build-docker.sh` | 新增可选第 2 个参数 `[arch]`;与本机架构不同时加 `docker build --platform linux/`,镜像标签带 `-` 后缀;dist 附件渲染用 `$IMGTAG` | Docker 镜像是分架构的。不传第 2 参数时行为**与改动前逐字节一致**(原生构建、标签不带后缀),不影响现有发版流程 | + +### 关于 Docker 的一条重要提醒 + +Release 里发布的 `drouter-<版本>-docker.tar` 仍然是 **amd64** 镜像。 +arm64 机器请走 **`deb` / 离线包** 这两条路;要用容器形态,需要在自己的 arm64 +机器上 `bash packaging/build-docker.sh <版本> arm64` 自行重建 +(Release 里没有 arm64 镜像附件)。 + +--- + +## 4. 给自己手上的 arm64 发行版打包 + +### 4.1 本体 deb(架构无关,推荐) + +直接在目标机上构建即可,无需交叉: + +```bash +git clone https://github.com/liuzhuohua/drouter.git +cd drouter +bash packaging/build-deb.sh # → dist/drouter_<版本>_all.deb +sudo apt install ./dist/drouter_<版本>_all.deb +``` + +### 4.2 离线包(**必须在同架构的机器上构建**) + +```bash +bash packaging/build-offline-bundle.sh +# → dist/drouter-<版本>-offline-<架构>.tar.gz +``` + +架构取自 `dpkg --print-architecture`。⚠️ 包里几百个 `.deb` 全是某一 CPU 的二进制, +**不能**拿 amd64 机器上打出来的包给 arm64 用。要在 arm64 上出包, +就在 arm64 机器上跑这条命令(或用 qemu-user-static chroot)。 + +### 4.3 Docker 镜像 + +```bash +bash packaging/build-docker.sh <版本> arm64 # → drouter:<版本>-arm64 +docker save drouter:<版本>-arm64 -o drouter-<版本>-docker-arm64.tar +``` + +交叉构建依赖宿主机的 `binfmt_misc` / buildx qemu 支持;没有的话请直接到 ARM 主机上构建。 + +--- + +## 5. 已知限制 + +1. **端到端未验证**:本轮没有真的启用 Web 面板与常驻服务,也没有下发 nft / dnsmasq + 配置,因此「当路由器实际转发」这一层在 arm64 上尚无实测结论。 +2. **样本只有一台**:只验证了这个发行版这一个组合;别的 arm64 Debian 系 + (含树莓派内核、Debian 官方 aarch64 内核)没有验证。 +3. **依赖需自行补齐**:目标机上 radvd / ppp / miniupnpd / conntrack / dhcpcd-base + 未装(系统本身没带),面板「依赖自检」页可以逐项安装。 +4. **Release 的离线包与 Docker 镜像仍是 amd64**:arm64 用户要么用 `deb` 本体 + (`Architecture: all`,通用),要么按第 4 节自行重建。 +5. **x86 专属能力在 ARM 上天然不可用**:`dmidecode`(BIOS/主板/内存详情)、 + `lm-sensors` 的经典 x86 温度通道等。这些项在依赖自检里本来就是「可选」, + 缺失不影响主流程;本次把这类 x86 专属依赖**没有**改成必需。 diff --git a/packaging/build-docker.sh b/packaging/build-docker.sh index df29192..a6b1742 100644 --- a/packaging/build-docker.sh +++ b/packaging/build-docker.sh @@ -2,9 +2,17 @@ # ============================================================================= # 构建 drouter 的 Docker 镜像 # -# bash packaging/build-docker.sh [tag] +# bash packaging/build-docker.sh [tag] [arch] +# +# 产物:drouter: (arch 省略 / 与本机架构相同时) +# drouter:- (交叉构建时,标签带架构后缀) +# +# arm64 适配(2026-10-16):Debian 13 的 arm64 机器也能跑 drouter +# (deb 是 Architecture: all,纯 Python),镜像自然也该能出 arm64 版本: +# bash packaging/build-docker.sh 1.0.10 arm64 +# ⚠️ 交叉构建要靠 buildx/qemu 提供目标架构的执行能力, +# 宿主机没有装 binfmt_misc 会卡在 RUN 那一步;这种机器上请直接跑到 ARM 主机上构建。 # -# 产物:drouter: # 注意:Dockerfile 里 COPY 的是相对路径,所以构建上下文必须是项目根目录。 # ============================================================================= set -euo pipefail @@ -14,9 +22,25 @@ HERE="$(cd "$(dirname "$0")/.." && pwd)" VF="$HERE/packaging/VERSION" [ -f "$VF" ] || { echo "缺少 $VF(版本号唯一真源)"; exit 1; } TAG="${1:-$(tr -d ' \t\r\n' < "$VF")}" +# 目标架构。省略时按本机 dpkg 架构处理 —— 那等于「原生构建」: +# 不加 --platform,标签也不带后缀,产物与改动前**逐字节一致**。 +DPKG_NATIVE="$(dpkg --print-architecture 2>/dev/null || echo amd64)" +ARCH="${2:-$DPKG_NATIVE}" command -v docker >/dev/null 2>&1 || { echo "需要 docker"; exit 1; } +# 交叉构建(目标架构 ≠ 本机架构)才加 --platform 与标签后缀。 +# 原生构建硬塞 --platform 反而会拖慢(docker 可能跳过本地缓存层)。 +if [ "$ARCH" = "$DPKG_NATIVE" ]; then + PLATFORM_ARG=() + IMGTAG="$TAG" + echo "== 目标架构:$ARCH(与本机一致,原生构建)==" +else + PLATFORM_ARG=(--platform "linux/$ARCH") + IMGTAG="$TAG-$ARCH" + echo "== 目标架构:$ARCH(交叉构建,标签后缀 -$ARCH)==" +fi + echo "== 1/4 构建前自测(避免把坏代码打进镜像)==" python3 - <<'PY' import ast, glob @@ -30,12 +54,13 @@ raise SystemExit(1 if bad else 0) PY command -v node >/dev/null 2>&1 && node --check web/app.js && echo " OK: web/app.js" -echo "== 2/4 构建镜像 drouter:$TAG ==" -docker build -f "$HERE/packaging/docker/Dockerfile" -t "drouter:$TAG" "$HERE" +echo "== 2/4 构建镜像 drouter:$IMGTAG ==" +docker build "${PLATFORM_ARG[@]}" -f "$HERE/packaging/docker/Dockerfile" \ + -t "drouter:$IMGTAG" "$HERE" # 同时打一个带用户名的别名,方便日后直推 Docker Hub / 私有仓库。 # 两个标签指向同一个 image id,不额外占空间。 -docker tag "drouter:$TAG" "liuzhuohua/drouter:$TAG" +docker tag "drouter:$IMGTAG" "liuzhuohua/drouter:$IMGTAG" echo "== 3/4 渲染 Release 附件(docker-compose.yml / DOCKER-GUIDE.md)==" # 这两个文件是**模板**,里面写的是 __VERSION__ 占位符,不是具体版本号。 @@ -52,7 +77,7 @@ for f in docker-compose.yml DOCKER-GUIDE.md; do # 顺序不能反 —— 先替换的话,标记块里的 __VERSION__ 也会被换掉, # 删的时候连同「这是模板」的语义一起留在了附件里。 sed '/TEMPLATE-NOTE:BEGIN/,/TEMPLATE-NOTE:END/d' "$src" \ - | sed "s/__VERSION__/$TAG/g" > "$HERE/dist/$f" + | sed "s/__VERSION__/$IMGTAG/g" > "$HERE/dist/$f" # 渲染完必须一个占位符都不剩 —— 剩下就说明模板里有漏网的写法 if grep -q '__VERSION__' "$HERE/dist/$f"; then echo "✘ $f 渲染后仍含 __VERSION__,模板有问题"; exit 1 @@ -61,7 +86,7 @@ for f in docker-compose.yml DOCKER-GUIDE.md; do if grep -q 'TEMPLATE-NOTE' "$HERE/dist/$f"; then echo "✘ $f 渲染后仍含 TEMPLATE-NOTE 标记"; exit 1 fi - echo " dist/$f ($TAG)" + echo " dist/$f ($IMGTAG)" done echo "== 4/4 产物 ==" @@ -82,11 +107,11 @@ cat <-offline-amd64.tar.gz +# 产物:dist/drouter-<版本>-offline-<架构>.tar.gz +# 架构取自 `dpkg --print-architecture`(amd64 / arm64 / armhf …)。 +# ⚠️ 离线包是**分架构**的:里头几百个 .deb 全是某一 CPU 的二进制, +# 所以必须在与目标机同架构的机器上构建(或用 chroot/qemu 交叉构建), +# 不能拿 amd64 打出来的包往 arm64 上装。 # # 为什么要有这个(而不是让用户自己拼 deb + 依赖): # .deb 的 Depends 只声明依赖,真正把依赖装上去是 apt 的活。目标机没外网时 @@ -14,7 +18,7 @@ # 拷过去解包、跑一条命令就完事。 # # 包内结构: -# drouter-<版本>-offline-amd64/ +# drouter-<版本>-offline-<架构>/ # ├── install.sh 一条命令装完 # ├── README-离线安装.md 给不熟悉的人看的步骤说明 # ├── SHA256SUMS 每个 deb 的校验和 @@ -85,7 +89,11 @@ chmod +x "$STAGE/install.sh" cat > "$STAGE/README-离线安装.md" < ⚠️ **架构必须匹配**:这个包里的依赖是按 **${ARCH}** 下载的, +> 只能装在同架构的机器上(如 arm64 主机要打 arm64 的包)。 +> 想知道本机架构:\`dpkg --print-architecture\`。 ## 三步装完 @@ -126,8 +134,8 @@ ls debs/*.deb | wc -l # 应该有两百多个 ## 如果装不上 1. **先看硬件够不够**:\`free -m\` / \`df -h /\`。内存 < 1G 或根分区 < 3G 会失败。 -2. **确认系统版本**:\`cat /etc/os-release\`,必须是 Debian 13 (trixie) amd64。 - 别的版本依赖版本对不上。 +2. **确认系统版本与架构**:\`cat /etc/os-release\` 要是 Debian 13 (trixie), + \`dpkg --print-architecture\` 要是 **${ARCH}**。别的版本/架构依赖对不上。 3. **看具体报错**:\`bash install.sh 2>&1 | tee /tmp/install.log\`, 然后按 log 里的第一条 \`E:\` 定位。 @@ -174,4 +182,7 @@ echo " 大小:$(du -h "$BUNDLE" | cut -f1)" echo "" echo " 上传到 GitHub Release 时,只传这一个文件即可 ——" echo " 目标机解包后跑 install.sh 就能在完全无外网的环境装好。" +echo "" +echo " ⚠️ 这是 $ARCH 架构的包。其他架构请在对应架构的机器上重跑本脚本," +echo " 包名里的架构后缀会自动变(别的 CPU 装了这一份会依赖不满足)。" echo "======================================================================" diff --git a/web/i18n.js b/web/i18n.js index 0f5d712..ca34224 100644 --- a/web/i18n.js +++ b/web/i18n.js @@ -4304,6 +4304,10 @@ 'QEMU / KVM 虚拟机': { 'n': 'QEMU / KVM virtual machine' }, '传统 BIOS': { 'n': 'Legacy BIOS', 'd': 'Legacy BIOS' }, 'UEFI': { 'n': 'UEFI', 'd': 'UEFI' }, + /* arm64 适配:ARM 板没有 BIOS,走 U-Boot + 设备树启动 */ + '设备树 (Device Tree)': + { 'n': 'Device Tree', + 'd': 'Device Tree / U-Boot boot. ARM and RISC-V boards have no BIOS: the bootloader hands the kernel a device tree describing the hardware.' }, '未读取到温度传感器,仅显示进程数': { 'd': 'No temperature sensor found; only the process count is shown' }, '未读取到温度': { 'd': 'No temperature reading' },