跑在本机的「类 openwrt.ai」在线定制站:双发行版 · 全部设备 · 任意版本 · 插件随版本自动变换。 零第三方依赖(只用 Node 内置模块),所有元数据实时抓取自上游官方下载服务器并落盘缓存。
MIT 许可,见 LICENSE。
git clone https://github.com/sunjg789/sjg-openwrt-builder.git
cd sjg-openwrt-builder
node server.js
# 浏览器打开 http://127.0.0.1:8730Windows 双击 start.bat。改端口:PORT=9000 node server.js。
首次访问需要联网向 downloads.openwrt.org / downloads.immortalwrt.org 抓取元数据,
抓到后会缓存在 cache/,之后离线也能用缓存过的版本。
若
git push报CRYPT_E_REVOCATION_OFFLINE,是 Windows Git 的 schannel 在你所在网络下 查不到证书吊销列表,换 OpenSSL 后端即可:git config http.sslBackend openssl。
后端(零依赖,服务需先启动):
node selftest2.js # 双发行版 × 双引擎 端到端
node selftest3.js # 多设备树 / 版本差异 / 首启脚本哈希完整性前端(需要 jsdom,服务需先启动):
npm i jsdom # 或 NODE_PATH 指向已装 jsdom 的目录
node selftest-ui.jsselftest-ui.js 用 jsdom 加载真实的 index.html + app.js,覆盖七组用例:静态首屏、首屏即时性、
版本→设备→插件加载链、运行时错误、切换发行版联动、步骤导航与预览生成、上游不可用时的降级。
最后一组是回归用例——曾经因为服务端不可达时返回 profiles:{}(对象,无法被 || [] 兜住)
导致 for...of 抛 TypeError,整条加载链崩掉。
注:早期版本的
selftest.js依赖已被重构掉的./lib/catalog,已删除;其职责由selftest2/3-ui接管。
| 原来的问题 | 现在的做法 | 实测结果 |
|---|---|---|
| 不能选其它设备 | 逐层实时拉取 targets/ → subtargets/ → profiles.json |
ImmortalWrt 25.12.2 → 44 个 CPU 架构;OpenWrt 23.05.5 ath79/generic 单目标就有 349 个机型 |
| 不能在 ImmortalWrt / OpenWrt 之间自由选版本 | 双发行版独立数据源 + 各自的版本目录解析 | ImmortalWrt 31 个版本(18.06 ~ 25.12-SNAPSHOT);OpenWrt 89 个版本(17.01 ~ 25.12.5) |
| 插件不能随版本变换 | 每个版本的软件索引(index.json / Packages.gz)现场解析出真实存在的包 |
25.12.5 自带 1020 个 luci 应用 vs 23.05.5 自带 647 个;仅新版有 498 个、仅旧版有 125 个 |
| 引擎 | 耗时 | 磁盘 | 适用场景 |
|---|---|---|---|
| ImageBuilder(默认) | 3~10 分钟 | < 2GB | 选设备 + 挑插件 + 预配置。99% 的需求走这条 |
| 源码全编译 | 1~3 小时 | ≈ 50GB | 官方与第三方都没有预编译包 / 要改内核选项 / 要产出自己的 SDK |
ImageBuilder 之所以能支持「任意设备 × 任意版本」,是因为上游为每个版本的每个子目标都发布了对应的打包工具(内含该版本的全部包与内核模块),不需要重编译任何东西。
生成器会自动探测真实可用的压缩包,并处理不同时代的后缀差异:
25.12.x → *.tar.zst
24.10.x → *.tar.zst
23.05.x → *.tar.xz ← 多格式回退已在 23.05.5 上实测通过
- 发行版 — ImmortalWrt / OpenWrt 官方
- 版本 — 按系列分组;可选「锁定 tag(可复现)」或「跟踪维护分支(含后续补丁)」
- 设备 — target → subtarget → profile,带机型关键字搜索
- 插件 — 随所选版本动态变化,三类来源都标注清楚:
第三方源码:只有源码、必须编译(来自插件清单,每项都带真实上游 git 地址)官方源有/部分缺失/该版本源没有:按当前版本的软件索引实时判定版本自带:该版本软件源里未被精选清单覆盖的其它 luci 应用
- 系统定制 — 主机名 / 用户 / 密码 / LAN / WAN(DHCP / PPPoE / 静态)/ 旁路由 / 网口指定 / IPv6 / 分区尺寸 / 附加脚本
- 生成 — 实时预览全部产物,直接下载 ZIP
openwrt-custom-builder/
├── server.js 零依赖 HTTP 服务 + 全部 API
├── selftest2.js 端到端自检(双发行版 × 双引擎)
├── selftest3.js 广度自检(多设备树 / 版本差异 / 哈希完整性)
├── selftest-ui.js 前端冒烟(jsdom 真实 DOM:首屏 / 加载链 / 交互 / 降级)
├── start.bat Windows 双击启动
├── data/
│ └── imm-plugins.json 内置的版本专属插件清单(imm 24.10 / 25.12)
├── cache/ 上游元数据缓存(自动生成,可随时删除)
├── out/ 自检产出的样例工程
├── lib/
│ ├── distros.js 双发行版定义 + 版本→git 引用映射 + CONFIG 符号规则
│ ├── upstream.js 实时抓取层 + 磁盘缓存(版本/设备树/软件索引/feeds)
│ ├── imagebuilder.js ImageBuilder 压缩包探测(Range 请求 + 多后缀回退)
│ ├── plugins.js 插件注册表(第三方源码 + 官方精选 + 版本动态发现)
│ ├── genib.js ImageBuilder 引擎生成器
│ ├── gensrc.js 源码全编译引擎生成器
│ ├── hash.js md5crypt(已与 openssl passwd -1 对拍一致)
│ └── zip.js store 模式 ZIP 打包(无第三方依赖)
├── public/ 前端(原生 HTML/CSS/JS)
└── presets/ 保存的预设
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /api/distros |
发行版列表 |
| GET | /api/versions?distro= |
全部可用版本(按系列分组) |
| GET | /api/targets?distro=&version= |
CPU 架构列表 |
| GET | /api/subtargets?...&target= |
子架构列表 |
| GET | /api/profiles?...&target=&sub= |
设备 profile 列表(含 package 架构 / 内核版本 / 设备专属包) |
| GET | /api/packages?...&arch= |
该版本软件索引 + 可用插件清单 |
| GET | /api/images?distro=&version=&target=&sub=&profile= |
官方预编译固件清单(基于上游 sha256sums,附 SHA256 + 用途标签) |
| GET | /api/ib?...&target=&sub= |
ImageBuilder 真实下载地址探测 |
| GET | /api/imm-plugins?series= |
内置的版本专属插件清单 |
| POST | /api/repo/test |
校验第三方软件源是否可用 |
| POST | /api/validate |
包名可用性 / PPPoE 必填 / 分区容量 等校验 |
| POST | /api/preview |
生成全部产物预览 |
| POST | /api/zip |
打包下载 |
| GET/POST | /api/preset/* |
预设存取 |
| GET/POST | /api/build/config |
在线构建凭据:owner/repo + Token(读接口不回显 Token) |
| POST | /api/build/start |
生成构建包 → 推到专属分支 → 触发 Actions 运行,返回任务 id |
| GET | /api/build/status?id= |
查询进度(job/step 级);运行结束后自动把产物(Release 优先 / Artifact 兜底)与失败日志拉回本地 |
| GET | /api/build/list |
历史在线构建 |
| GET | /api/build/file?id=&name= |
下载已拉回本地的产物 zip |
make download必须在make -j N之前(并行下载会撞坏校验)tmp/.packageinfo竞态:自检「行数 >1000 且重复定义 ≤20」,不合格rm -rf tmp重来,最多 3 轮- 内核包重复定义必须为 0,否则会引发成片的
recursive dependency detected - GNU make <4.3 会把
$(shell ...)里的#当注释 → 预检直接拦 - Ubuntu 26.04+ 的 uutils
install让 prereq 失败 → 要求gnu-coreutils+gnuinstall软链;不用FORCE=1掩盖 flock单实例锁,且日志重定向必须在拿到锁之后- ccache 必须配
CONFIG_DEVEL=y;缓存目录在源码树根.ccache而非~/.ccache - 第三方 feed 一律指名 install,杜绝
feeds install -a - 换 branch/tag 前必须
make distclean - ImageBuilder 对未知包是硬失败(
package not found,不是警告):生成时若某包不在该版本官方索引里、 且你没配第三方源,它会从PACKAGES=里剔除,并在PACKAGES.txt里留痕(× 包名)——不静默丢包。 配了第三方源则一律保留(那些包可能正由第三方源提供) - 源码全编译按实测 ≈50GB 估算磁盘,与生成的脚本里「可用磁盘 <50GB 直接退出」的硬校验一致
(免费 runner 只有 ~14GB,所以源码档的 Actions 工作流跑
self-hosted) - root 密码用 md5crypt,且哈希必须用单引号赋值(
$1$进双引号会被 shell 当成位置参数,导致密码损坏)
上述最后一条已交叉验证:生成的哈希与 openssl passwd -1 -salt <salt> 逐字符一致。
- 参考实现:一个未公开的云端 ImageBuilder 工作流仓库(本项目的 ImageBuilder 引擎即按它的机制实现)。 本仓库是公开的,所以这里不点名那个仓库 —— 公开仓库里的链接、仓库名对全网可见
- 你的公开插件清单
sunjg789/sjg-openwrt-packages(plugins.conf,46 条第三方插件带真实上游地址) - 上游实时元数据:
downloads.openwrt.org、downloads.immortalwrt.org
第 6 步的「在线构建」把上面第三条路做成了闭环,不用手动 fork / 手动推工作流:
填一次 owner/repo + Token → 点「开始在线构建」
① 生成构建包(含 .github/workflows/*.yml)
② Git Data API 推到孤儿分支 build/<distro>-<ver>-<target>-<sub>-<stamp>
③ 触发运行(dispatch 优先,push 兜底)
④ 每 5 秒轮询 job / step 进度,前端按步骤着色
⑤ 运行结束 → 云端自动发 Release(永久存档、免登录可下);站点**优先从该 Release 拉回产物**,取不到才退回 Artifacts,落在 out/builds/<id>/,页面上直接「下载到本地」
⑥ 若失败 → 拉回云端日志,面板里摊开报错摘要 + 可折叠的日志尾部
落地时有几个坑,都已固化在代码里(lib/gh.js / lib/build.js):
| 现象 | 真实原因 | 处理 |
|---|---|---|
workflow_dispatch 返回 404 |
只认默认分支上注册的工作流,推到临时分支的 yml 一律不认 | 先探测 isRegisteredOnDefault(),未注册就走 push 触发器(此时不能加 [skip ci]) |
加了 [skip ci] 后一个 run 都没有 |
注册状态未必准确,dispatch 说成功但没跑 | 轮询不到 run 时自动去掉 [skip ci] 再推一次兜底 |
| 明明跑完了却查不到 run | 用 created>= 过滤时,本机 Date.now() 与 GitHub created_at 有偏差 |
完全不用时间过滤,取该独享分支最新的 run |
| 两个 run 同时跑,分钟数翻倍 | push 触发器与 dispatch 各起一个 | 走 dispatch 时才带 [skip ci] |
| Artifact 下载 302 拿到 HTML | REST 接口返回跳转地址 | 手动跟 Location,并校验魔数(zip 认 PK,.img.gz 认 1f 8b,按扩展名分派) |
失败只见 Process completed with exit code 2 |
真正的报错埋在几百行 make 输出里 | 拉 /actions/runs/<id>/logs(零依赖手写 ZIP 解析),提炼报错行后在前端显示 |
大产物下载到一半 terminated |
Node 的 fetch 不读 HTTP_PROXY,且整包流式下载中途断掉就前功尽弃 |
改成 Range 分片 + 断点续传 + 逐片重试,先 Range: bytes=0-0 探总长,再按 4MB 一片写入 |
| 续传到一半全部报 HTTP 403 | 下载直链是 Azure 的短期 SAS 令牌,十几分钟就失效;旧链接重试多少次都没用 | 每次重试都重新向 REST 取一张新票(artifactZipUrl())再续下 |
| 点了状态半天不返回 | 回传大产物把请求线程占住了 | 回传改为后台任务,状态接口立刻返回,进度写进 meta.json,前端每 5 秒刷新「已回传 24.0 / 168.0 MB」 |
| 本地下载按钮对上百 MB 的文件不友好 | 原来整包读进内存后再吐,且忽略 Range | 改成流式 + Accept-Ranges,支持 206 / 416,浏览器断线可续 |
产物体积与回传速度是这闭环里唯一不适合"等一下就好"的部分,目前是这样收敛的:
- 云端默认关闭虚拟磁盘格式(
CONFIG_QCOW2/VDI/VMDK/VHDX_IMAGES=n):单实例 x86/64 产物从 386MB 降到 168MB,省掉的一半几乎全是 qcow2/vdi/vmdk/vhdx。 - 回传是可中断可续传的:关掉页面、甚至重启服务,下次轮询都会从已落盘的位置继续。
- 面板显示回传百分比;回传中不会给出"下载"按钮,避免下到半截的 zip。
Artifact 只适合"把产物取回本地"——它 90 天过期、必须登录才能下载,而且占用账户级 500MB 存储额度(与 Packages 共享),用尽后不是变慢而是直接阻止上传。所以工作流在产物之外还会自动发一个 Release:附件不占那份额度,永久保存,公开仓库免登录即可下载。
tag 形如 <distro>-<version>-<target>-<subtarget>-<profile>-r<run_id>,每跑一次构建就是一个独立条目,不会互相覆盖,也不用手工清 tag。附件是解压后的各个固件文件(不是一整个 zip),外加 SHA256SUMS.txt,可以只下自己要的那一个。
用 run_id 而不是时间戳当后缀,是因为站点在 dispatch 之后立刻就拿到了 run_id,却不可能预知工作流运行时才生成的日期。tag 必须完全由站点已知的信息构成,这样它才能用 GET /releases/tags/<tag> 精确定位,而不必退化成「拉列表按前缀模糊匹配」——后者在并发构建时会返回别人的 Release。
这条规则只写在 lib/reltag.js 一个地方,生成器(genib.js / gensrc.js)与站点(build.js)都从它取。两边一旦各自拼一遍,症状是「构建全绿、Release 也在 GitHub 上、站点却说没有产物」,而且双方都不报错 —— 所以 selftest3.js 的 M 组会把「生成器输出的字符串」与「站点会算出的字符串」直接逐字符对拍。
Release 默认带 --latest=false:这是按需构建的定制固件,不该去抢仓库的 Latest 徽章。
权限是按 job 分层的,这是有意的设计:
| job | 权限 | 原因 |
|---|---|---|
构建(ib / src) |
contents: read |
它要执行 feeds 里拉进来的成百上千个第三方 Makefile,是整个流程里最不可信的一环。只要它没有写权限,第三方代码就算被投毒也推不动这个仓库 |
发布(release) |
contents: write |
只做三件事:取回 Artifact、算校验和、建 Release 上传。不 checkout、不执行任何来自构建产物的代码,攻击面小得多 |
对应断言在 selftest3.js 的 H 组:构建 job 不得持有写权限、写权限全文件只出现一次、且必须落在 release job 上、release job 不得 checkout。
产物在云端有两份,站点的取用顺序是 Release 优先:
| 来源 | 判断方式 | 为什么 |
|---|---|---|
| Release 附件(首选) | GET /releases/tags/<tag>,tag 由 lib/reltag.js 算出 |
不占账户级 500MB 额度、永久保存、公开仓库免登录;而且是解压后的单文件,不用再拆 zip |
| Actions Artifacts(兜底) | Release 查不到时退回 GET /actions/runs/<id>/artifacts |
release job 万一失败,固件依然能被取回,不会因为一个附属步骤把整次构建判死 |
这样即使发布环节挂了,站点仍能拿到产物(面板会明确写出「已回退到 Actions Artifacts,注意 90 天过期」以及回退原因),而不是安静地少一个文件。
云端也因此把 Artifacts 的 retention-days 压到 1 天:它只是兜底通道,留着长期占配额没有意义。
两条来源共用同一套下载主体(downloadTo():探长度 → Range 分片 → 断点续传 → 逐片换新票 → 收尾校验),差别只有两处,都容易踩:
- 取票接口不同:Artifact 走
/actions/artifacts/<id>/zip,Release 附件走/releases/assets/<id>。 Accept必须改成application/octet-stream:默认的application/vnd.github+json拿到的是附件元数据 JSON,而且是 200 而不是 302 —— 会静悄悄地把一段 JSON 当固件存到磁盘上。- 魔数不能只认
PK:Artifact 是 zip,Release 附件是.img.gz/.iso/.bin/.txt,所以魔数按扩展名分派(magicOf()),认不出的类型就不校验(.iso/.bin没有统一魔数)。
对应断言在 selftest3.js 的 N 组(含 Release 查询必须排在 Artifact 之前、空产物集不得算「回传完成」)与 selftest-ui.js 的 I 组(Release 态、兜底态、两来源皆空的渲染)。
依赖也是我们踩出来的补齐项(lib/genib.js 的 apt 那一步):
qemu-utils→ 提供qemu-img。x86 目标会顺带产出 qcow2/vdi/vmdk/vhdx,缺了它 make 在最后一步才Error 1,整轮白跑。genisoimage+mkisofs软链 → 产出 ISO 那一步写死了mkisofs,而 Ubuntu 22.04 根本没有叫mkisofs的包(写了会让整段apt-get install失败),所以只能装genisoimage再补链接。
因为编译这件事在 Windows 上跑不了:
- OpenWrt 官方明确只支持 GNU/Linux(原生或虚拟化),macOS 与 WSL 都属于「有人成功但非官方支持」; 官方文档更直接点名——不要在 Windows 原生文件系统上构建(大小写不敏感会导致诡异失败)。
- 源码全编译需要 50GB 磁盘与 1~3 小时;ImageBuilder 虽然只要分钟级,但它的分发包
…-Linux-x86_64.tar.zst只能跑在 x86_64 Linux 上。 - 所以本站定位是「生成器」:把你的选择固化成一整套可在 Linux 上执行的构建脚本, 而不是在本机调用交叉工具链。
要拿到固件,有三条路:
| 方式 | 得到什么 | 怎么用 |
|---|---|---|
| 第 3 步「官方预编译固件」直下 | 上游现成镜像,不含你勾选的插件 | 选完设备后点击下载,附 SHA256 可校验 |
| 把构建包丢给 Linux 主机 | 带自定义插件的完整固件 | 下载 ZIP → 在 Linux/云主机上 bash build.sh |
| GitHub Actions 在线编译 | 带自定义插件的完整固件,不用自己备机器 | 第 6 步「在线构建」填一次仓库 + Token,站点自动推分支、触发、轮询并把产物(Release 优先)拉回本地(详见下一章) |
第一条已在本站实现,数据源是上游 sha256sums(不是 profiles.json 的 images[]——
后者缺 .gz 后缀,实测会 404)。
某些受限环境(沙箱 / 受限终端)里,git push 会直接被杀掉、ls-remote 却正常,Node 里
spawnSync('git', …) 还会报 EBUSY。这时还能用 REST 通道把本地 HEAD 推上去:
git rev-parse HEAD > tmp-head-sha.txt
git ls-tree -r HEAD > tmp-ls.txt
git cat-file commit HEAD > tmp-commit.txt
git rev-parse "HEAD^{tree}" > tmp-root-tree.txt
node tools/push-via-api.js脚本自己重建 blob → tree → commit 再更新 ref。因为 Git 对象的 sha 由内容决定,只要 tree / parent / author / committer / message 全部对齐,远端生成的 commit 会和本地 HEAD 是同一个 sha。
两个踩过的坑,脚本里已经处理了:
- 默认保留远端独有的文件(如
.github/workflows/*.yml)。本地仓库里没有.github/: 那份工作流是站点在在线构建时自动补推的(见上表坑1)。按本地 HEAD 原样重建树会把它删掉, 下一次在线构建立刻 422。真要完全对齐本地 HEAD 时加--prune。 因此远端 commit 会比本地多这一个文件,sha 自然不同 —— 属于预期,不是错位。 - 树条目必须排序,目录按「名字 +
/」参与比较;顺序错了根树 sha 就对不上(子树却是对的,很难查)。 git archive会按.gitattributes反向出差换行,拿它导出再上传会得到另一个 blob; 脚本直接读工作区文件,并在 sha 对不上时自动试 LF / CRLF 两种还原。
- 站点只做生成,不实际执行编译;编译由产出的
build.sh/.github/workflows/*.yml在 Linux 侧完成 - 第 3 步下载的官方固件是未经定制的原版,插件/网络配置要刷完后手动安装
- 在线构建的 Token 明文存在
config/gh.json(该目录已在.gitignore),用完建议在 GitHub Settings 里吊销 - 在线构建会把每个任务推成一个
build/*分支,跑完要手动删,否则仓库里分支会越攒越多 - ImageBuilder 只能打包软件源里真实存在的包。第三方插件需要先在「第三方软件源」里配上对应仓库根目录
- 资源估算是经验量级,用于可行性判断,不是承诺
- 服务只监听
127.0.0.1,无鉴权;要放局域网请自行加反代