feat(update): 页面一键更新 + 守护启动脚本

系统配置页新增「版本与更新」卡片,顶栏版本号旁在有新版时亮出提示胶囊。
更新链路:下载发布包 → 比对 SHA256SUMS → -h 冒烟测试 → 暂存 artex.new
→ 以退出码 75 退出 → 守护脚本重新拉起并完成换装,页面轮询 /api/health
等版本号变化后自动刷新。

新增 start.sh / start.bat 作为正式启动入口,只按退出码决定是否重新拉起
(0 停止 / 75 应用新版 / 其余崩溃退避)。校验、冒烟、换装、回滚全部收在
selfupdate 包里而不写进脚本:这些逻辑在 sh 和 bat 上要写两套,而它们恰恰
最不能出错——换上跑不起来的二进制后守护进程会反复拉起它,只能上机器手工救。
Docker 的 ENTRYPOINT 一并改为该脚本,并由它转发 SIGTERM,否则 docker stop
只发给 PID 1,artex 做不了优雅关闭会被 SIGKILL 硬杀。

失败保护两道闸门互补:无法 exec 由换装前的冒烟挡住,能执行但初始化崩溃
由启动计数兜住(连续 3 次失败自动回滚 artex.old,坏的留作 artex.failed)。

GitHub 查询结果后端缓存 30 分钟(失败 2 分钟)。未认证 API 是每 IP 每小时
60 次,且这 60 次是服务器 IP 上所有用户共用的,顶栏每次整页加载都查一次,
不缓存会很快耗光配额导致真想更新时反而查不动。手动「检查更新」带 force=1
绕过缓存。

安全:只认 GitHub 域名且强制 HTTPS(逐跳校验重定向),发布源写死不可配置;
升级文件挂在可执行文件所在目录而非 CWD(服务化运行时 CWD 可能是 /);
开发构建版本号不可比较,禁用一键更新。

走完整 zip 而非裸二进制,因为现有 Release 的 SHA256SUMS 只覆盖 zip,
这样无需改 CI,也兼容已发布的历史版本。

已知限制:Docker 下只换程序不换镜像(工具链不同步、重建容器会退回镜像
版本);不同步发布包里的 skills/;更新即重启会中断运行中的任务。
This commit is contained in:
Autumn-27 committed 2026-09-11 12:39:59 -04:00
1 parent 557317491b
commit 44156a0a4c
22 files changed
+2619 -16

No files matched your search

+6
View File
@@ -36,6 +36,12 @@ Thumbs.db
# 内嵌前端的单二进制产物 # 内嵌前端的单二进制产物
/artex /artex
# 一键更新在可执行文件旁留下的中间产物(暂存件 / 备份 / 失败版本 / 升级标记)
/artex.new
/artex.new.sha256
/artex.old
/artex.failed
/artex.upgrade.json
/server/webui/dist/ /server/webui/dist/
/dist/ /dist/
+16
View File
@@ -11,6 +11,22 @@
- 修复 work 因**空转回合**提前中断的问题:模型有时会用一整轮只输出思考,既不给正文也不调工具,此时 harness 看到的是一次自然结束(`end_turn` 且无 `tool_use`),直接以 `completed` + 空总结收场——一条还没做完的意图就断在半路,表现为「任务正常结束但没有任何文字总结」。五层 LLM 重试一层都够不着它:它不是错误,同 provider 安全窗口重试的入口是"流失败",熔断在 `err == nil` 时直接判成功,意图重跑只认 `model_error`;SDK 的空响应重试也够不着,因为那层以"有没有 yield 过事件"判空,而思考增量本身就是事件。现在 work 的 Stop 钩子会识别这种回合并注入一条续跑指令,让模型带着已经产出的思考继续执行下一步——刻意不选"原样重发",因为这类空转通常由提示词与上下文形状决定,是稳定行为而非随机抽风,重发只会让模型再想一遍。 - 修复 work 因**空转回合**提前中断的问题:模型有时会用一整轮只输出思考,既不给正文也不调工具,此时 harness 看到的是一次自然结束(`end_turn` 且无 `tool_use`),直接以 `completed` + 空总结收场——一条还没做完的意图就断在半路,表现为「任务正常结束但没有任何文字总结」。五层 LLM 重试一层都够不着它:它不是错误,同 provider 安全窗口重试的入口是"流失败",熔断在 `err == nil` 时直接判成功,意图重跑只认 `model_error`;SDK 的空响应重试也够不着,因为那层以"有没有 yield 过事件"判空,而思考增量本身就是事件。现在 work 的 Stop 钩子会识别这种回合并注入一条续跑指令,让模型带着已经产出的思考继续执行下一步——刻意不选"原样重发",因为这类空转通常由提示词与上下文形状决定,是稳定行为而非随机抽风,重发只会让模型再想一遍。
- 次数复用 LLM 页「重试与退避」里的**空响应重试次数**(两者都在回答"模型完成了却没产出实质内容",只是判据与手段不同),默认 2 次,填 `-1` 即关掉、退回原先"空转即收场"的行为。它是一条意图的**总量**上限而非连续次数:harness 自身已限死连续空转只推一次,只有真正发生过工具回合配额才刷新,所以这个数挡的是「工具 → 空转 → 推 → 工具 → 空转」这类循环把意图预算耗光。触发时日志记 `[work <worker> · #<意图>] 空转回合…注入续跑指令 (n/N)`。设计见 `docs/LLM重试设计.md` §1.1。 - 次数复用 LLM 页「重试与退避」里的**空响应重试次数**(两者都在回答"模型完成了却没产出实质内容",只是判据与手段不同),默认 2 次,填 `-1` 即关掉、退回原先"空转即收场"的行为。它是一条意图的**总量**上限而非连续次数:harness 自身已限死连续空转只推一次,只有真正发生过工具回合配额才刷新,所以这个数挡的是「工具 → 空转 → 推 → 工具 → 空转」这类循环把意图预算耗光。触发时日志记 `[work <worker> · #<意图>] 空转回合…注入续跑指令 (n/N)`。设计见 `docs/LLM重试设计.md` §1.1。
### 部署与更新
#### 新增的功能
- 支持**页面一键更新**:系统配置页新增「版本与更新」卡片,可直接从 GitHub Release 检查并安装新版本;顶栏版本号旁在有新版时亮出提示胶囊,点击直达。链路为「下载当前平台发布包 → 比对 Release 的 `SHA256SUMS` → 用 `-h` 冒烟测试新二进制 → 暂存为 `artex.new` → 进程以退出码 75 退出 → 守护脚本重新拉起并完成换装」,页面轮询 `/api/health` 等版本号变化后自动刷新。判据刻意是"版本变了"而非"能连上了"——换装过程中旧版本会短暂重新起来一次(那一次只负责把 `artex.new` 换上去然后立刻退出),只看连通性会误判成功。走完整 zip 而非裸二进制,是因为现有 Release 的 `SHA256SUMS` 本就只覆盖 zip,这样不必改 CI,也兼容已发布的历史版本。
- 新增守护启动脚本 `start.sh` / `start.bat` 作为正式启动入口(发布包与 Docker 镜像均已带上,`install.sh` 不变)。它只按退出码决定是否重新拉起:`0` = 正常停止、退出循环,`75` = 应用新版本、立刻重跑,其余 = 崩溃并按 1→2→4…最多 60 秒退避重启。刻意不在脚本里做校验与换装——那些逻辑在 sh 和 bat 上要写两套,而它们恰恰最不能出错:一旦换上跑不起来的二进制,守护进程会忠实地反复拉起它,用户只能上机器手工救。因此校验 / 冒烟 / 换装 / 回滚全部收在 Go 里(`selfupdate` 包),由 artex 自己在启动时完成,脚本保持傻瓜化。Docker 的 ENTRYPOINT 一并改为该脚本,它同时负责把 SIGTERM 转发给 artex——`docker stop` 只把信号发给 PID 1,不转发的话 artex 收不到信号、做不了优雅关闭,10 秒后被 SIGKILL 硬杀,正在跑的任务直接断在半路。
- 更新失败不会留下坏程序:校验或冒烟不通过就丢弃暂存件、继续跑当前版本;换装后的新版若连续 3 次启动失败则自动回滚到 `artex.old`(起不来的那个留作 `artex.failed` 供排查)。两道闸门互补——「根本无法 exec」(下载截断、架构选错、缺依赖)由换装前的冒烟挡住,「能执行但初始化时崩溃」(配置不兼容、端口被占)由启动计数兜住,少任何一道都有漏网。设置页另有「回滚到上一版本」可主动退回,但数据库结构不会回退,旧版本可能无法识别新版写入的数据。
- 后端对 GitHub 的查询结果缓存 30 分钟(失败结果 2 分钟,网络恢复后能很快自愈):未认证的 GitHub API 是每 IP 每小时 60 次,而顶栏提示每次整页加载都会查一次,且这 60 次是**服务器 IP** 上所有用户共用的——不缓存的话多开几个标签页或刷几次页面就把配额耗光,之后顶栏提示不再出现、点「检查更新」也报错,真想更新时反而点不动。用户显式点「检查更新」时带 `force=1` 绕过缓存拿实时结果(人工触发,频率天然有限)。取数期间全程持锁,并发请求排队等同一次查询而不是各自去打 GitHub。
- 安全约束:更新只从 GitHub 域名下载并强制 HTTPS,每一跳重定向都校验域名;发布源写死、不做成配置项——更新源可配等于给任何能改配置的人一条远程代码执行通道,对渗透测试平台这个口子开不得。所有升级文件都挂在**可执行文件所在目录**而非 CWD:服务化运行时工作目录可能是 `/`,用 CWD 会让暂存件落到别处、换装彻底失效。开发构建(版本号为 `dev` 或 `git describe` 带后缀的不可比较版本)禁用一键更新,避免正式版覆盖掉本地正在调试的二进制。
#### 已知限制
- Docker 下只换程序、不换镜像:镜像里的 playwright / nmap / node 等工具链不会跟着升级,新版若依赖新工具会坏;且 `docker compose up -d` 重建容器后会退回镜像自带的版本(换装写的是容器可写层)。要连镜像一起升级仍请用 `docker compose pull artex && docker compose up -d artex`。界面与确认弹窗均已就此提示。
- 更新只替换二进制,不同步发布包里的 `skills/`,新版新增的内置 skill 不会自动生效(原先靠 `update.sh` 的 `git pull` 顺带解决;Docker 下 `./skills:/app/skills` 的绑定挂载也会盖掉镜像内置的那份)。
- 更新即重启,会中断正在运行的任务,目前不做拦截,仅在确认弹窗中提示。
## [0.3.8] - 2026-09-09 ## [0.3.8] - 2026-09-09
### LLM ### LLM
+6 -2
View File
@@ -32,10 +32,14 @@ RUN npm install -g @playwright/mcp@latest @playwright/cli@latest playwright@late
WORKDIR /app WORKDIR /app
# 预编译好的对应架构二进制(dist/amd64/artex 或 dist/arm64/artex) # 预编译好的对应架构二进制(dist/amd64/artex 或 dist/arm64/artex)
COPY dist/${TARGETARCH}/artex /app/artex COPY dist/${TARGETARCH}/artex /app/artex
RUN chmod +x /app/artex # 守护启动脚本:进程退出后按退出码决定是否重新拉起,页面一键更新靠它完成换装。
# 它同时负责把 SIGTERM 转发给 artex —— docker stop 只把信号发给 PID 1,
# 不转发的话 artex 收不到、做不了优雅关闭,10 秒后被 SIGKILL 硬杀。
COPY start.sh /app/start.sh
RUN chmod +x /app/artex /app/start.sh
COPY skills/ /app/skills/ COPY skills/ /app/skills/
# data/(SQLite + jwt.key)持久化点 # data/(SQLite + jwt.key)持久化点
VOLUME ["/app/data"] VOLUME ["/app/data"]
EXPOSE 8787 8788 EXPOSE 8787 8788
ENTRYPOINT ["/app/artex"] ENTRYPOINT ["/app/start.sh"]
CMD ["-addr", ":8787", "-proxy", ":8788"] CMD ["-addr", ":8787", "-proxy", ":8788"]
+26 -10
View File
@@ -103,13 +103,16 @@ docker compose up -d # 拉取 autumn27/artex 镜像 + postgres
### 方式三:下载预编译二进制(Releases) ### 方式三:下载预编译二进制(Releases)
到 [Releases](https://github.com/Autumn-27/ARTEX/releases) 下载对应平台的 zip,解压后得到 `artex` + `skills/` + `config.example.json`: 到 [Releases](https://github.com/Autumn-27/ARTEX/releases) 下载对应平台的 zip,解压后得到 `artex` + `start.sh`(Windows 为 `start.bat`)+ `skills/` + `config.example.json`:
```bash ```bash
cp config.example.json config.json # 填好 database 连接 cp config.example.json config.json # 填好 database 连接
./artex # → http://localhost:8787 ./start.sh # → http://localhost:8787
``` ```
> 请用 `start.sh` / `start.bat` 启动,而不是直接跑 `./artex`。它是个守护脚本:程序退出后按退出码决定是否重新拉起,**页面上的[一键更新](#方式一页面一键更新推荐)靠它完成换装**。直接运行 `./artex` 时更新完就不会被拉起了。
> 后台常驻:`nohup ./start.sh >artex.log 2>&1 &`。
### 方式四:从源码编译单二进制 ### 方式四:从源码编译单二进制
```bash ```bash
@@ -119,7 +122,7 @@ cd web && npm ci && npm run build:static && cd ..
cp -r web/out server/webui/dist cp -r web/out server/webui/dist
# 3) 编译(-tags embedui 才内嵌前端) # 3) 编译(-tags embedui 才内嵌前端)
CGO_ENABLED=0 go build -tags embedui -o artex ./cmd/artex CGO_ENABLED=0 go build -tags embedui -o artex ./cmd/artex
./artex ./start.sh
``` ```
### 方式五:构建跨平台 Release 压缩包 ### 方式五:构建跨平台 Release 压缩包
@@ -144,7 +147,20 @@ ARTEX_TARGETS=linux/amd64,windows/amd64 ./build.sh --release
> 升级只换程序、不动数据:Postgres 数据卷 `pgdata`、`./data`(jwt.key / SQLite 等)、`./skills` 都会保留。**数据库迁移无需手动执行**——`artex` 每次启动会幂等重跑 `schema.sql`(含 `ADD COLUMN` / `CREATE INDEX IF NOT EXISTS`),即“重启即迁移”。升级前仍建议先备份 `./data` 与数据库。 > 升级只换程序、不动数据:Postgres 数据卷 `pgdata`、`./data`(jwt.key / SQLite 等)、`./skills` 都会保留。**数据库迁移无需手动执行**——`artex` 每次启动会幂等重跑 `schema.sql`(含 `ADD COLUMN` / `CREATE INDEX IF NOT EXISTS`),即“重启即迁移”。升级前仍建议先备份 `./data` 与数据库。
### 方式一:一键更新脚本(推荐) ### 方式一:页面一键更新(推荐)
在 **系统配置** 页(侧边栏「系统配置」→ `/system/settings`)的**版本与更新**卡片里,可以直接检查并安装新版本,无需登录服务器。
点「更新」后:下载当前平台的发布包 → 比对 Release 的 `SHA256SUMS` → 用 `-h` 冒烟测试新二进制 → 暂存为 `artex.new` → 程序退出,由 `start.sh` / `start.bat` 重新拉起并完成换装。页面会自动等到新版本上线后刷新。
- **失败不会留下坏程序**:校验或冒烟不通过就丢弃暂存件、继续跑当前版本;换装后的新版若连续 3 次启动失败,会自动回滚到 `artex.old`(失败的那个留作 `artex.failed` 供排查)。
- **随时可回退**:上一版本保留为 `artex.old`,卡片上有「回滚到上一版本」。注意数据库结构不会回退。
- **更新会中断正在运行的任务**——更新即重启,请在空闲时进行。
- **开发构建不给更新**:版本号是 `dev` 或 `git describe` 带后缀时禁用,避免正式版覆盖掉本地调试的二进制。
- **Docker 下只换程序、不换镜像**:镜像里的 playwright / nmap 等工具链不会跟着升级,且 `docker compose up -d` 重建容器后会退回镜像自带的版本。要连镜像一起升级仍请用 `docker compose pull artex && docker compose up -d artex`。
- 访问 GitHub 需要代理时,在同一页面配置**全局代理**即可,更新链路会走它。更新只从 GitHub 域名下载并强制 HTTPS。
### 方式二:一键更新脚本
```bash ```bash
cd ARTEX cd ARTEX
@@ -156,7 +172,7 @@ cd ARTEX
- **① Docker**:可指定目标镜像 tag(回车沿用 `.env` 的 `ARTEX_TAG`,缺省 `latest`)→ `docker compose pull` → `docker compose up -d`(换新镜像重启即自动迁移)。 - **① Docker**:可指定目标镜像 tag(回车沿用 `.env` 的 `ARTEX_TAG`,缺省 `latest`)→ `docker compose pull` → `docker compose up -d`(换新镜像重启即自动迁移)。
- **② 本地**:重建前端静态产物 → 重新编译 `./artex`(完成后重启进程生效)。 - **② 本地**:重建前端静态产物 → 重新编译 `./artex`(完成后重启进程生效)。
### 方式二:Docker Compose(手动) ### 方式三:Docker Compose(手动)
```bash ```bash
cd ARTEX cd ARTEX
@@ -167,23 +183,23 @@ docker compose up -d artex # 换新镜像重启 → 自动迁移 schema
docker image prune -f # 清理旧镜像(可选) docker image prune -f # 清理旧镜像(可选)
``` ```
### 方式三:预编译二进制(Releases) ### 方式四:预编译二进制(Releases)
到 [Releases](https://github.com/Autumn-27/ARTEX/releases) 下载新版本 zip,停掉旧进程后覆盖 `artex` 与 `skills/`(保留你的 `config.json` 与 `data/`),重启即可: 到 [Releases](https://github.com/Autumn-27/ARTEX/releases) 下载新版本 zip,停掉旧进程后覆盖 `artex` 与 `skills/`(保留你的 `config.json` 与 `data/`),重启即可:
```bash ```bash
cp -r <解压目录>/skills ./ && cp <解压目录>/artex ./ cp -r <解压目录>/skills ./ && cp <解压目录>/artex ./
./artex ./start.sh
``` ```
### 方式四:从源码编译 ### 方式五:从源码编译
```bash ```bash
git pull git pull
cd web && npm ci && npm run build:static && cd .. cd web && npm ci && npm run build:static && cd ..
cp -r web/out server/webui/dist cp -r web/out server/webui/dist
CGO_ENABLED=0 go build -tags embedui -o artex ./cmd/artex CGO_ENABLED=0 go build -tags embedui -o artex ./cmd/artex
# 重启 ./artex # 重启 ./start.sh
``` ```
--- ---
@@ -207,7 +223,7 @@ CGO_ENABLED=0 go build -tags embedui -o artex ./cmd/artex
**并发**:每个任务的 work agent 数在「系统设置」里配置(默认 3)。 **并发**:每个任务的 work agent 数在「系统设置」里配置(默认 3)。
**常用参数**:`./artex -addr :8787 -proxy :8788`(`-addr` 前端+API,`-proxy` 流量录制代理)。 **常用参数**:`./start.sh -addr :8787 -proxy :8788`(`-addr` 前端+API,`-proxy` 流量录制代理)。启动脚本会把参数原样透传给 `artex`。
--- ---
+8
View File
@@ -173,6 +173,14 @@ package_binary() {
rm -rf "$package_root" "$archive" rm -rf "$package_root" "$archive"
mkdir -p "$package_root" mkdir -p "$package_root"
cp "$binary" "$package_root/" cp "$binary" "$package_root/"
# 守护启动脚本是正式入口:页面上的一键更新要靠它在进程退出后重新拉起,
# 直接跑 artex 本体的话更新完就再也起不来了。按目标系统只带对应的那一份。
if [ "$goos" = "windows" ]; then
cp start.bat "$package_root/"
else
cp start.sh "$package_root/"
chmod +x "$package_root/start.sh"
fi
cp -R skills "$package_root/" cp -R skills "$package_root/"
cp config.example.json "$package_root/" cp config.example.json "$package_root/"
if [ -f README.md ]; then cp README.md "$package_root/"; fi if [ -f README.md ]; then cp README.md "$package_root/"; fi
+37 -1
View File
@@ -18,6 +18,7 @@ import (
"github.com/Autumn-27/artex/agent" "github.com/Autumn-27/artex/agent"
"github.com/Autumn-27/artex/config" "github.com/Autumn-27/artex/config"
"github.com/Autumn-27/artex/selfupdate"
"github.com/Autumn-27/artex/server" "github.com/Autumn-27/artex/server"
) )
@@ -41,7 +42,15 @@ func printBanner(addr string) {
version, runtime.GOOS, runtime.GOARCH, runtime.Version(), addr) version, runtime.GOOS, runtime.GOARCH, runtime.Version(), addr)
} }
// main only maps run's result onto the process exit code. The exit code is part
// of the update protocol — the supervising start script reads it to decide
// whether to relaunch us (see selfupdate.ExitRestart) — so the body has to live
// in a function that can *return* rather than os.Exit past its own defers.
func main() { func main() {
os.Exit(run())
}
func run() int {
var ( var (
addr = flag.String("addr", ":8787", "HTTP listen address") addr = flag.String("addr", ":8787", "HTTP listen address")
dataDir = flag.String("data", filepath.Join(config.BaseDir(), "data"), "data directory for SQLite stores (default: data/ next to the executable)") dataDir = flag.String("data", filepath.Join(config.BaseDir(), "data"), "data directory for SQLite stores (default: data/ next to the executable)")
@@ -59,6 +68,16 @@ func main() {
// page can show a live log stream. Do this first, to catch startup logs too. // page can show a live log stream. Do this first, to catch startup logs too.
server.StartLogCapture() server.StartLogCapture()
// Self-update bootstrap: swap in a staged binary, or count a post-swap boot
// attempt and roll back if the new build keeps dying. Must run before we open
// the stores or bind a port — this may end with "exit and let the start script
// relaunch me", and there is no point paying for either first.
action, upState := selfupdate.Bootstrap()
server.SetBootUpdateState(upState)
if action == selfupdate.Restart {
return selfupdate.ExitRestart
}
// surface which config file the binary reads (absolute, so `go run`'s relative // surface which config file the binary reads (absolute, so `go run`'s relative
// "config.json" — resolved against the CWD — is unambiguous). // "config.json" — resolved against the CWD — is unambiguous).
cfgPath := config.Path() cfgPath := config.Path()
@@ -82,6 +101,12 @@ func main() {
} }
defer mgr.Close() defer mgr.Close()
// Surviving this long means a freshly swapped-in build actually works, so drop
// the upgrade marker and stop counting attempts. Until it fires, every boot
// increments the count and a build that keeps dying gets rolled back.
settle := time.AfterFunc(selfupdate.SettleDelay, selfupdate.Settle)
defer settle.Stop()
skillDir := config.SkillDir() skillDir := config.SkillDir()
if abs, err := filepath.Abs(skillDir); err == nil { if abs, err := filepath.Abs(skillDir); err == nil {
skillDir = abs skillDir = abs
@@ -101,11 +126,22 @@ func main() {
} }
}() }()
<-ctx.Done() // Two ways out: a signal (normal stop → exit 0, the start script stops looping)
// or a staged update / rollback (→ exit 75, the script relaunches us and the
// bootstrap above installs the new build).
code := 0
select {
case <-ctx.Done():
case <-server.RestartRequested():
code = selfupdate.ExitRestart
shutdown(agent.AbortShutdown)
}
log.Println("shutting down...") log.Println("shutting down...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
_ = httpSrv.Shutdown(shutdownCtx) _ = httpSrv.Shutdown(shutdownCtx)
return code
} }
// shutdownContext deliberately does not derive from signalCtx. If it did, the // shutdownContext deliberately does not derive from signalCtx. If it did, the
+299
View File
@@ -0,0 +1,299 @@
package selfupdate
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"log"
"os"
"os/exec"
"strings"
"time"
)
// smokeEnv 让被冒烟测试拉起的子进程直接跳过 Bootstrap。
//
// 严格来说不加也不会出事:子进程的 os.Executable() 是 artex.new,推导出来的
// 全部路径都带 .new 前缀,碰不到真正的升级文件。但依赖这种巧合太脆弱,
// 显式短路一目了然,也省掉子进程一次无谓的磁盘探测。
const smokeEnv = "ARTEX_SELFUPDATE_SMOKE"
// Action 是 Bootstrap 给 main 的指令。
type Action int
const (
// Continue:照常启动 server。
Continue Action = iota
// Restart:立刻以 ExitRestart 退出,让守护脚本重新拉起。
Restart
)
// State 描述本次启动时的升级状态,供 /api/update/check 如实告诉前端
// "上一次升级是成功了还是被回滚了"。
type State struct {
Pending bool // 换装后尚未确认稳定
RolledBack bool // 本次启动刚刚执行过自动回滚
FailedStage bool // 暂存件校验/冒烟未通过,已丢弃
Detail string // 面向用户的一句话说明
}
// Bootstrap 在 main 的最开头运行,必须在任何监听端口、打开数据库之前调用。
//
// 三种局面:
//
// ① 存在暂存件 artex.new → 校验 + 冒烟,通过则换装并要求重启;不通过则丢弃继续跑旧版
// ② 只剩标记文件 → 说明刚换装完,累计一次尝试;连续失败够多次则回滚
// ③ 什么都没有 → 正常启动
func Bootstrap() (Action, State) {
if os.Getenv(smokeEnv) != "" {
return Continue, State{}
}
p, err := ResolvePaths()
if err != nil {
log.Printf("[update] 跳过自举:%v", err)
return Continue, State{}
}
if _, err := os.Stat(p.New); err == nil {
return applyStaged(p)
}
m, ok := readMarker(p.Marker)
if !ok {
return Continue, State{}
}
return confirmOrRollback(p, m)
}
// applyStaged 处理"存在暂存件"的局面:校验通过就换装,失败就丢弃。
//
// 这里是整个升级链路唯一会覆盖可执行文件的地方,也是最后一道闸门——冒烟测试挡掉
// 下载损坏、架构选错、动态链接缺失这类问题。一旦放行一个跑不起来的二进制,
// 守护脚本会不知疲倦地反复拉起它,而 Go 代码根本没机会运行,自动回滚也就无从谈起。
func applyStaged(p Paths) (Action, State) {
m, _ := readMarker(p.Marker)
if err := verifyStaged(p); err != nil {
log.Printf("[update] 暂存的新版本未通过校验,已丢弃,继续运行当前版本:%v", err)
cleanStaged(p)
_ = os.Remove(p.Marker)
return Continue, State{FailedStage: true, Detail: "新版本校验失败,已丢弃:" + err.Error()}
}
if err := swap(p); err != nil {
log.Printf("[update] 换装失败,继续运行当前版本:%v", err)
cleanStaged(p)
_ = os.Remove(p.Marker)
return Continue, State{FailedStage: true, Detail: "换装失败:" + err.Error()}
}
// 换装成功。保留标记,交给下一次启动(跑的就是新版)确认是否稳定。
m.Attempts = 0
if m.StagedAt == 0 {
m.StagedAt = time.Now().Unix()
}
if err := writeMarker(p.Marker, m); err != nil {
log.Printf("[update] 写升级标记失败(失去自动回滚能力):%v", err)
}
log.Printf("[update] 已换装到 %s,退出以重启(exit %d)", orUnknown(m.To), ExitRestart)
return Restart, State{Pending: true}
}
// confirmOrRollback 处理"换装后的启动":累计尝试次数,超限则把旧版换回来。
//
// 计数只在 Go 代码跑起来后才递增,所以它覆盖的是"能执行但初始化时崩溃"
// (配置不兼容、端口被占、DB 迁移炸了)这类故障;"根本无法 exec" 由换装前的
// 冒烟测试挡住,两者合起来才是完整的。
func confirmOrRollback(p Paths, m marker) (Action, State) {
m.Attempts++
if m.Attempts > maxAttempts {
if err := rollback(p); err != nil {
// 回滚都失败了就别再重启了,否则会陷入无限重启。清掉标记,
// 让进程按当前状态起——起不来的话用户至少能在日志里看到原因。
log.Printf("[update] 新版本连续 %d 次启动失败,且回滚失败:%v", maxAttempts, err)
_ = os.Remove(p.Marker)
return Continue, State{Detail: "新版本启动失败且回滚失败:" + err.Error()}
}
log.Printf("[update] 新版本连续 %d 次启动失败,已回滚到 %s,退出以重启(exit %d)",
maxAttempts, orUnknown(m.From), ExitRestart)
_ = os.Remove(p.Marker)
return Restart, State{RolledBack: true, Detail: fmt.Sprintf("新版本启动失败,已回滚到 %s", orUnknown(m.From))}
}
if err := writeMarker(p.Marker, m); err != nil {
log.Printf("[update] 更新升级标记失败:%v", err)
}
log.Printf("[update] 新版本启动中(第 %d/%d 次尝试),稳定运行后将确认升级",
m.Attempts, maxAttempts)
return Continue, State{Pending: true}
}
// Settle 确认新版本已稳定运行,清除升级标记。
//
// 由 main 在 HTTP 监听起来之后延迟调用:活过这段时间才算数,否则标记留在原地,
// 下次启动继续累计尝试次数,直到触发回滚。
func Settle() {
p, err := ResolvePaths()
if err != nil {
return
}
settle(p)
}
func settle(p Paths) {
if _, ok := readMarker(p.Marker); !ok {
return // 不是升级后的启动,无事可做
}
if err := os.Remove(p.Marker); err != nil && !errors.Is(err, os.ErrNotExist) {
log.Printf("[update] 清除升级标记失败:%v", err)
return
}
log.Printf("[update] 新版本运行稳定,升级完成(上一版本保留为 %s)", p.Old)
}
// SettleDelay 是判定"新版本活下来了"所需的运行时长。
const SettleDelay = 30 * time.Second
// verifyStaged 校验暂存件:先比对 SHA256,再真正把它拉起来跑一次。
func verifyStaged(p Paths) error {
want, err := os.ReadFile(p.Sum)
if err != nil {
return fmt.Errorf("读取校验和: %w", err)
}
got, err := fileSHA256(p.New)
if err != nil {
return fmt.Errorf("计算校验和: %w", err)
}
if !strings.EqualFold(strings.TrimSpace(string(want)), got) {
return errors.New("SHA256 不匹配(下载损坏或被篡改)")
}
return smokeTest(p.New)
}
// smokeTest 用 -h 拉起新二进制,确认它在当前系统上真的能执行。
// 这能挡掉下载截断、架构选错(exec format error)、缺依赖等一大类问题。
func smokeTest(bin string) error {
if err := os.Chmod(bin, 0o755); err != nil {
return fmt.Errorf("赋予执行权限: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, bin, "-h")
cmd.Env = append(os.Environ(), smokeEnv+"=1")
out, err := cmd.CombinedOutput()
if ctx.Err() != nil {
return errors.New("冒烟测试超时(新二进制无响应)")
}
if err != nil {
snippet := strings.TrimSpace(string(out))
if len(snippet) > 300 {
snippet = snippet[:300] + "…"
}
return fmt.Errorf("冒烟测试失败: %v: %s", err, snippet)
}
return nil
}
// swap 把当前二进制换成暂存的新版本。
//
// Unix 和 Windows 都允许 rename 一个正在运行的可执行文件(Windows 禁止的是删除和
// 覆盖,rename 不在其列),所以这里不需要分平台,也不需要先停掉自己。
func swap(p Paths) error {
// Windows 的 rename 不会覆盖已存在的目标,上一轮升级留下的 .old 必须先清掉。
if err := os.Remove(p.Old); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("清理旧备份 %s: %w", p.Old, err)
}
if err := os.Rename(p.Current, p.Old); err != nil {
return fmt.Errorf("备份当前版本: %w", err)
}
if err := os.Rename(p.New, p.Current); err != nil {
// 换装失败但当前版本已经被挪走了,必须原样放回去,否则下次启动没有可执行文件。
if rerr := os.Rename(p.Old, p.Current); rerr != nil {
return fmt.Errorf("装入新版本失败(%v),且恢复当前版本失败: %w", err, rerr)
}
return fmt.Errorf("装入新版本: %w", err)
}
_ = os.Remove(p.Sum)
return nil
}
// rollback 把 swap 备份的旧版本换回来。
func rollback(p Paths) error {
if _, err := os.Stat(p.Old); err != nil {
return fmt.Errorf("没有可回滚的备份 %s: %w", p.Old, err)
}
// 把起不来的新版挪到 .failed 留作排查,而不是直接删掉。
failed := p.Current + ".failed"
_ = os.Remove(failed)
if err := os.Rename(p.Current, failed); err != nil {
return fmt.Errorf("移走失败的版本: %w", err)
}
if err := os.Rename(p.Old, p.Current); err != nil {
return fmt.Errorf("恢复旧版本: %w", err)
}
return nil
}
// Rollback 是 /api/update/rollback 的实现:主动退回上一版本。
// 只做换装,重启同样交给守护脚本(调用方随后以 ExitRestart 退出)。
func Rollback() error {
p, err := ResolvePaths()
if err != nil {
return err
}
if _, err := os.Stat(p.Old); err != nil {
return errors.New("没有可回滚的上一版本(" + p.Old + " 不存在)")
}
cleanStaged(p)
if err := smokeTest(p.Old); err != nil {
return fmt.Errorf("上一版本无法执行,拒绝回滚: %w", err)
}
// 交换当前与备份:回滚之后还能再滚回来。
tmp := p.Current + ".swap"
_ = os.Remove(tmp)
if err := os.Rename(p.Current, tmp); err != nil {
return fmt.Errorf("移走当前版本: %w", err)
}
if err := os.Rename(p.Old, p.Current); err != nil {
_ = os.Rename(tmp, p.Current)
return fmt.Errorf("装入上一版本: %w", err)
}
if err := os.Rename(tmp, p.Old); err != nil {
log.Printf("[update] 回滚后整理备份失败(不影响运行):%v", err)
}
_ = os.Remove(p.Marker)
return nil
}
// HasBackup 报告是否存在可回滚的上一版本,供前端决定要不要显示回滚按钮。
func HasBackup() bool {
p, err := ResolvePaths()
if err != nil {
return false
}
_, err = os.Stat(p.Old)
return err == nil
}
func fileSHA256(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
func orUnknown(s string) string {
if strings.TrimSpace(s) == "" {
return "未知版本"
}
return s
}
+139
View File
@@ -0,0 +1,139 @@
package selfupdate
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
)
// Repo 是发布源。写死而不是做成配置项:更新源可配等于给任何能改配置的人一条
// 远程代码执行通道,对一个渗透测试平台来说这个口子开不得。
const Repo = "Autumn-27/artex"
// latestURL 是 GitHub 的"最新正式版"接口。它会自动跳过 prerelease 和 draft。
const latestURL = "https://api.github.com/repos/" + Repo + "/releases/latest"
// allowedHosts 限定升级链路能访问的域名。配合下面的 checkRedirect,
// 任何一跳被重定向到名单外的主机都会直接失败——这是防止 DNS 污染 / 中间人
// 把二进制换掉的第一道闸门,第二道是 SHA256SUMS 比对。
var allowedHosts = map[string]bool{
"api.github.com": true,
"github.com": true,
"objects.githubusercontent.com": true, // release 资产实际落地的对象存储
"release-assets.githubusercontent.com": true,
"raw.githubusercontent.com": true,
}
// Release 是 GitHub Release 里我们关心的字段。
type Release struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
Body string `json:"body"`
Draft bool `json:"draft"`
Prerelease bool `json:"prerelease"`
PublishedAt time.Time `json:"published_at"`
HTMLURL string `json:"html_url"`
Assets []Asset `json:"assets"`
}
// Asset 是 Release 上挂的一个文件。
type Asset struct {
Name string `json:"name"`
URL string `json:"browser_download_url"`
Size int64 `json:"size"`
}
// NewClient 构造一个只认 GitHub 域名的 HTTP 客户端。proxy 为空则直连。
//
// 刻意不复用默认 Transport:升级链路必须强制走 TLS 且校验证书,不能被别处
// 设置的 InsecureSkipVerify 之类影响到。
func NewClient(proxy string) *http.Client {
tr := &http.Transport{
ForceAttemptHTTP2: true,
TLSHandshakeTimeout: 15 * time.Second,
}
if p := strings.TrimSpace(proxy); p != "" {
if pu, err := url.Parse(p); err == nil {
tr.Proxy = http.ProxyURL(pu)
}
}
return &http.Client{
Transport: tr,
Timeout: 30 * time.Minute, // 下载整包,不能按请求级超时卡死
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return fmt.Errorf("重定向次数过多")
}
return checkURL(req.URL)
},
}
}
// checkURL 强制 https + 域名白名单。
func checkURL(u *url.URL) error {
if u.Scheme != "https" {
return fmt.Errorf("拒绝非 HTTPS 地址: %s", u.Scheme+"://"+u.Host)
}
if !allowedHosts[strings.ToLower(u.Hostname())] {
return fmt.Errorf("拒绝非 GitHub 域名: %s", u.Hostname())
}
return nil
}
// FetchLatest 查询最新正式版。
func FetchLatest(ctx context.Context, c *http.Client) (*Release, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, latestURL, nil)
if err != nil {
return nil, err
}
if err := checkURL(req.URL); err != nil {
return nil, err
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "artex-selfupdate")
resp, err := c.Do(req)
if err != nil {
return nil, fmt.Errorf("访问 GitHub 失败(可在系统设置里配置全局代理): %w", err)
}
defer resp.Body.Close()
switch {
case resp.StatusCode == http.StatusForbidden, resp.StatusCode == http.StatusTooManyRequests:
// 未认证的 GitHub API 是每 IP 每小时 60 次,共用出口 IP 时很容易撞上。
return nil, fmt.Errorf("GitHub 接口限流(每小时 60 次),请稍后再试")
case resp.StatusCode == http.StatusNotFound:
return nil, fmt.Errorf("仓库 %s 尚未发布任何正式版本", Repo)
case resp.StatusCode != http.StatusOK:
return nil, fmt.Errorf("GitHub 返回 %d", resp.StatusCode)
}
var rel Release
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
return nil, fmt.Errorf("解析 Release 失败: %w", err)
}
if strings.TrimSpace(rel.TagName) == "" {
return nil, fmt.Errorf("Release 缺少 tag")
}
return &rel, nil
}
// AssetName 返回当前平台对应的发布包名,与 build.sh 的 package_binary 保持一致:
// artex-<版本>-<os>-<arch>.zip(版本号不带 v 前缀)。
func AssetName(tag, goos, goarch string) string {
return fmt.Sprintf("artex-%s-%s-%s.zip", strings.TrimPrefix(tag, "v"), goos, goarch)
}
// FindAsset 在 Release 里按名字找资产。
func (r *Release) FindAsset(name string) (Asset, bool) {
for _, a := range r.Assets {
if strings.EqualFold(a.Name, name) {
return a, true
}
}
return Asset{}, false
}
+175
View File
@@ -0,0 +1,175 @@
// Package selfupdate implements ARTEX 的页面一键更新:从 GitHub Release 拉取新版
// 二进制、校验、暂存,并在下次启动时原子换装。
//
// 整体分工(见 start.sh / start.bat):
//
// 启动脚本 = 傻瓜守护循环,只负责"进程退出后按退出码决定是否再拉起"
// 本包 = 全部易错逻辑(下载 / SHA256 校验 / 冒烟 / 换装 / 失败回滚)
//
// 之所以把换装放在 Go 而不是脚本里,是因为 sha256 校验和冒烟测试在 sh 和 bat 上
// 要写两套(sha256sum / shasum / certutil),而这恰恰是最不能出错的一环——换上一个
// 跑不起来的二进制,守护进程会忠实地反复拉起它,用户只能上机器手工救。
//
// 一次完整升级经过三次进程启动:
//
// ① 旧版 server 收到 /api/update/apply → 下载校验 → 暂存 artex.new → exit 75
// ② 脚本重新拉起旧版 → Bootstrap 发现 artex.new → 校验+冒烟 → 换装 → exit 75
// ③ 脚本重新拉起,此时已是新版 → Bootstrap 记一次尝试 → 启动成功后清除标记
//
// 任何一步失败都退回旧版:② 校验不过就删掉暂存件继续跑旧版;③ 连续 3 次没活到
// 清除标记(起不来就崩)则自动把 artex.old 换回去。
package selfupdate
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)
// ExitRestart 是"请守护进程重新拉起我"的退出码(EX_TEMPFAIL)。启动脚本看到它
// 就立刻重跑,不计入崩溃退避。0 表示用户正常停止(脚本退出循环),其余均视为崩溃。
const ExitRestart = 75
// maxAttempts 是换装后允许的启动尝试次数。新版每次启动都会把计数 +1,活过
// settleDelay 则清除标记;连崩 maxAttempts 次说明新版根本起不来,自动回滚。
const maxAttempts = 3
// Paths 是一次升级涉及的全部文件,统一挂在**可执行文件所在目录**下。
// 刻意不用 CWD:服务化运行时工作目录可能是 / 或任意路径,用 CWD 会让暂存件落到
// 别处,换装逻辑直接失效。
type Paths struct {
Dir string // 可执行文件所在目录
Current string // 当前运行的二进制 artex / artex.exe
New string // 暂存的新版本 artex.new / artex.new.exe
Sum string // 新版本的 sha256(hex) artex.new.sha256 / artex.new.exe.sha256
Old string // 换装前备份的旧版本 artex.old / artex.old.exe
Marker string // 升级状态标记 artex.upgrade.json
}
// ResolvePaths 按当前可执行文件推导全部升级路径。
//
// Windows 上 .new/.old 也必须带 .exe 后缀,否则冒烟测试和换装后的执行都会失败,
// 所以先把后缀摘掉再拼,两个平台的命名才对称。
func ResolvePaths() (Paths, error) {
exe, err := os.Executable()
if err != nil {
return Paths{}, fmt.Errorf("定位可执行文件: %w", err)
}
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
exe = resolved
}
dir := filepath.Dir(exe)
name := filepath.Base(exe)
ext := filepath.Ext(name) // Windows 上是 ".exe",Unix 上通常为空
stem := strings.TrimSuffix(name, ext)
join := func(suffix string) string { return filepath.Join(dir, stem+suffix+ext) }
return Paths{
Dir: dir,
Current: exe,
New: join(".new"),
Sum: join(".new") + ".sha256",
Old: join(".old"),
Marker: filepath.Join(dir, stem+".upgrade.json"),
}, nil
}
// marker 记录一次换装的进度,用来在新版起不来时触发自动回滚。
type marker struct {
From string `json:"from"` // 升级前的版本
To string `json:"to"` // 目标版本
Attempts int `json:"attempts"` // 换装后已尝试启动的次数
StagedAt int64 `json:"staged_at"`
}
func readMarker(path string) (marker, bool) {
b, err := os.ReadFile(path)
if err != nil {
return marker{}, false
}
var m marker
if json.Unmarshal(b, &m) != nil {
return marker{}, false
}
return m, true
}
func writeMarker(path string, m marker) error {
b, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, b, 0o644)
}
// cleanStaged 清掉暂存件。换装成功、校验失败、用户取消都走它,避免残留的
// artex.new 在下次启动时被重新尝试。
func cleanStaged(p Paths) {
_ = os.Remove(p.New)
_ = os.Remove(p.Sum)
}
// CompareVersions 比较两个版本号,返回 -1/0/1(a<b / a==b / a>b)。
// ok=false 表示至少一边不是可比较的版本号(例如本地开发构建的 "dev" 或
// git describe 产出的 "0.3.7-2-gabc1234-dirty"),此时调用方应禁用一键更新,
// 否则会把开发中的构建"升级"成正式版、覆盖掉未提交的改动。
func CompareVersions(a, b string) (int, bool) {
av, aok := parseVersion(a)
bv, bok := parseVersion(b)
if !aok || !bok {
return 0, false
}
for i := range 3 {
if av[i] != bv[i] {
if av[i] < bv[i] {
return -1, true
}
return 1, true
}
}
return 0, true
}
// parseVersion 解析 "v0.3.7" / "0.3.7" 形式的版本号为 [3]int。
//
// 只接受纯净的三段式:build.sh 在非 tag 构建时用 git describe 产出
// "0.3.7-2-gabc1234" 这类带后缀的版本,它们必须被判为不可比较,而不是被当成
// 0.3.7 —— 否则开发构建会被误判为"已是最新"或被正式版覆盖。
func parseVersion(s string) ([3]int, bool) {
s = strings.TrimSpace(s)
s = strings.TrimPrefix(s, "v")
if s == "" {
return [3]int{}, false
}
parts := strings.Split(s, ".")
if len(parts) != 3 {
return [3]int{}, false
}
var out [3]int
for i, p := range parts {
n, err := strconv.Atoi(p)
if err != nil || n < 0 {
return [3]int{}, false
}
out[i] = n
}
return out, true
}
// InDocker 报告进程是否跑在容器里。Docker 下换装写的是容器可写层,
// `docker compose up -d` 重建容器会退回镜像自带的版本——这是预期行为
// (那时用户本来就在拉新镜像),但前端要能据此把话说清楚。
func InDocker() bool {
if _, err := os.Stat("/.dockerenv"); err == nil {
return true
}
b, err := os.ReadFile("/proc/1/cgroup")
if err != nil {
return false
}
s := string(b)
return strings.Contains(s, "docker") || strings.Contains(s, "containerd")
}
+459
View File
@@ -0,0 +1,459 @@
package selfupdate
import (
"archive/zip"
"net/url"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
// testPaths 造一个隔离的升级目录。不能直接用 ResolvePaths()——那会指向测试
// 二进制本身,一跑就把 go test 的可执行文件改名了。
func testPaths(t *testing.T) Paths {
t.Helper()
dir := t.TempDir()
return Paths{
Dir: dir,
Current: filepath.Join(dir, "artex"),
New: filepath.Join(dir, "artex.new"),
Sum: filepath.Join(dir, "artex.new.sha256"),
Old: filepath.Join(dir, "artex.old"),
Marker: filepath.Join(dir, "artex.upgrade.json"),
}
}
// fakeBin 写一个可执行的壳脚本冒充 artex。smokeTest 只是用 -h 拉起它看退出码,
// 脚本完全够用,而且比编译一个真二进制快得多。
func fakeBin(t *testing.T, path, marker string, exitCode int) {
t.Helper()
script := "#!/bin/sh\necho " + marker + "\nexit " + itoa(exitCode) + "\n"
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
t.Fatalf("写入假二进制 %s: %v", path, err)
}
}
func itoa(n int) string {
if n == 0 {
return "0"
}
return string(rune('0' + n))
}
// stage 把 bin 布置成"已暂存待换装"的样子:写好 artex.new 和它的校验和。
func stage(t *testing.T, p Paths, marker string, exitCode int) {
t.Helper()
fakeBin(t, p.New, marker, exitCode)
sum, err := fileSHA256(p.New)
if err != nil {
t.Fatalf("计算校验和: %v", err)
}
if err := os.WriteFile(p.Sum, []byte(sum), 0o644); err != nil {
t.Fatalf("写入校验和: %v", err)
}
}
func readAll(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("读取 %s: %v", path, err)
}
return string(b)
}
func requireUnix(t *testing.T) {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("假二进制用的是 sh 脚本,Windows 上跑不了")
}
}
func TestCompareVersions(t *testing.T) {
cases := []struct {
a, b string
want int
comparable bool
}{
{"0.3.7", "0.3.8", -1, true},
{"0.3.8", "0.3.7", 1, true},
{"0.3.7", "0.3.7", 0, true},
{"v0.3.7", "0.3.8", -1, true}, // build.sh 去掉 v,tag 带 v,两边都要认
{"0.3.7", "v0.3.7", 0, true},
{"0.9.0", "0.10.0", -1, true}, // 按数字比而不是字典序
{"1.0.0", "0.99.99", 1, true},
// 开发构建必须判为不可比较,否则会被正式版覆盖掉未提交的改动。
{"dev", "0.3.8", 0, false},
{"0.3.7-2-gabc1234", "0.3.8", 0, false},
{"0.3.7-dirty", "0.3.8", 0, false},
{"0.3", "0.3.8", 0, false},
{"", "0.3.8", 0, false},
}
for _, c := range cases {
got, ok := CompareVersions(c.a, c.b)
if ok != c.comparable {
t.Errorf("CompareVersions(%q,%q) comparable=%v, 期望 %v", c.a, c.b, ok, c.comparable)
continue
}
if ok && got != c.want {
t.Errorf("CompareVersions(%q,%q)=%d, 期望 %d", c.a, c.b, got, c.want)
}
}
}
func TestResolvePathsNaming(t *testing.T) {
p, err := ResolvePaths()
if err != nil {
t.Fatalf("ResolvePaths: %v", err)
}
// 关键不变量:所有升级文件都和可执行文件同目录。落到 CWD 会让服务化运行
// (工作目录可能是 /)时的换装彻底失效。
for name, path := range map[string]string{"New": p.New, "Sum": p.Sum, "Old": p.Old, "Marker": p.Marker} {
if filepath.Dir(path) != p.Dir {
t.Errorf("%s 不在可执行文件目录下: %s (期望 %s)", name, path, p.Dir)
}
}
// Windows 上 .new/.old 必须保留 .exe,否则冒烟测试和换装后的执行都会失败。
if runtime.GOOS == "windows" {
if !strings.HasSuffix(p.New, ".exe") || !strings.HasSuffix(p.Old, ".exe") {
t.Errorf("Windows 上 .new/.old 必须以 .exe 结尾: new=%s old=%s", p.New, p.Old)
}
}
}
func TestVerifyStagedRejectsTamperedBinary(t *testing.T) {
requireUnix(t)
p := testPaths(t)
stage(t, p, "new", 0)
// 校验和写好之后再改动文件,模拟下载损坏 / 被掉包。
fakeBin(t, p.New, "tampered", 0)
if err := verifyStaged(p); err == nil {
t.Fatal("期望 SHA256 不匹配被拒绝,却通过了")
}
}
func TestVerifyStagedRejectsUnrunnableBinary(t *testing.T) {
requireUnix(t)
p := testPaths(t)
stage(t, p, "broken", 1) // 能执行但退出码非 0
if err := verifyStaged(p); err == nil {
t.Fatal("期望冒烟测试失败被拒绝,却通过了")
}
}
func TestApplyStagedHappyPath(t *testing.T) {
requireUnix(t)
p := testPaths(t)
fakeBin(t, p.Current, "old", 0)
stage(t, p, "new", 0)
if err := writeMarker(p.Marker, marker{From: "0.3.7", To: "0.3.8"}); err != nil {
t.Fatalf("写标记: %v", err)
}
action, st := applyStaged(p)
if action != Restart {
t.Fatalf("期望 Restart,得到 %v", action)
}
if !st.Pending {
t.Error("换装后状态应为 Pending")
}
if !strings.Contains(readAll(t, p.Current), "new") {
t.Error("artex 应已被替换为新版本")
}
if !strings.Contains(readAll(t, p.Old), "old") {
t.Error("旧版本应备份到 artex.old")
}
if _, err := os.Stat(p.New); !os.IsNotExist(err) {
t.Error("换装后 artex.new 应已消失")
}
if _, err := os.Stat(p.Sum); !os.IsNotExist(err) {
t.Error("换装后校验和文件应已清理")
}
// 标记必须留着,下一次启动(跑的是新版)靠它计数、必要时回滚。
if _, ok := readMarker(p.Marker); !ok {
t.Error("换装后升级标记应保留")
}
}
func TestApplyStagedKeepsCurrentWhenVerifyFails(t *testing.T) {
requireUnix(t)
p := testPaths(t)
fakeBin(t, p.Current, "old", 0)
stage(t, p, "new", 0)
fakeBin(t, p.New, "tampered", 0) // 破坏校验和
action, st := applyStaged(p)
if action != Continue {
t.Fatalf("校验失败时期望 Continue,得到 %v", action)
}
if !st.FailedStage {
t.Error("状态应标记为 FailedStage")
}
if !strings.Contains(readAll(t, p.Current), "old") {
t.Fatal("校验失败时绝不能动当前版本")
}
if _, err := os.Stat(p.New); !os.IsNotExist(err) {
t.Error("校验失败的暂存件应被清理,否则下次启动会再试一遍")
}
}
func TestSwapOverwritesPreviousBackup(t *testing.T) {
requireUnix(t)
p := testPaths(t)
fakeBin(t, p.Current, "v2", 0)
fakeBin(t, p.Old, "v1", 0) // 上一轮升级留下的备份
stage(t, p, "v3", 0)
if err := swap(p); err != nil {
t.Fatalf("swap: %v", err)
}
if !strings.Contains(readAll(t, p.Current), "v3") {
t.Error("应换装到 v3")
}
if !strings.Contains(readAll(t, p.Old), "v2") {
t.Error("备份应更新为刚被换下的 v2")
}
}
func TestConfirmCountsAttemptsThenRollsBack(t *testing.T) {
requireUnix(t)
p := testPaths(t)
fakeBin(t, p.Current, "broken-new", 0)
fakeBin(t, p.Old, "good-old", 0)
m := marker{From: "0.3.7", To: "0.3.8"}
// 前 maxAttempts 次启动只累计计数,让新版有机会自己站稳。
for i := 1; i <= maxAttempts; i++ {
action, st := confirmOrRollback(p, m)
if action != Continue {
t.Fatalf("第 %d 次尝试期望 Continue,得到 %v", i, action)
}
if !st.Pending {
t.Errorf("第 %d 次尝试状态应为 Pending", i)
}
got, ok := readMarker(p.Marker)
if !ok || got.Attempts != i {
t.Fatalf("第 %d 次尝试后 attempts=%d(ok=%v),期望 %d", i, got.Attempts, ok, i)
}
m = got
}
// 再崩一次就超限,自动把旧版换回来。
action, st := confirmOrRollback(p, m)
if action != Restart {
t.Fatalf("超过尝试上限时期望 Restart,得到 %v", action)
}
if !st.RolledBack {
t.Error("状态应标记为 RolledBack")
}
if !strings.Contains(readAll(t, p.Current), "good-old") {
t.Fatal("应已回滚到旧版本")
}
if _, err := os.Stat(p.Marker); !os.IsNotExist(err) {
t.Error("回滚后标记应清除,否则会无限回滚")
}
// 起不来的那个版本留作排查,不直接删。
if _, err := os.Stat(p.Current + ".failed"); err != nil {
t.Error("失败的版本应保留为 .failed 供排查")
}
}
func TestManualRollbackIsReversible(t *testing.T) {
requireUnix(t)
p := testPaths(t)
fakeBin(t, p.Current, "v2", 0)
fakeBin(t, p.Old, "v1", 0)
// Rollback() 走 ResolvePaths(),这里直接测底层的交换语义。
tmp := p.Current + ".swap"
if err := os.Rename(p.Current, tmp); err != nil {
t.Fatal(err)
}
if err := os.Rename(p.Old, p.Current); err != nil {
t.Fatal(err)
}
if err := os.Rename(tmp, p.Old); err != nil {
t.Fatal(err)
}
if !strings.Contains(readAll(t, p.Current), "v1") {
t.Error("回滚后当前版本应是 v1")
}
if !strings.Contains(readAll(t, p.Old), "v2") {
t.Error("回滚后备份应变成 v2,这样还能再滚回去")
}
}
func TestParseSums(t *testing.T) {
const (
linuxSum = "1111111111111111111111111111111111111111111111111111111111111111"
winSum = "ABCDEF0000000000000000000000000000000000000000000000000000000000"
)
// sha256sum 输出是双空格分隔;shasum -a 256 在二进制模式下会给文件名加 *。
raw := linuxSum + " artex-0.3.8-linux-amd64.zip\n" +
winSum + " *artex-0.3.8-windows-amd64.zip\n" +
"\n" +
"garbage line\n" + // 恰好两个字段,但第一个不是摘要
"deadbeef artex-0.3.8-darwin-arm64.zip\n" // 摘要长度不对
out := parseSums(raw)
if out["artex-0.3.8-linux-amd64.zip"] != linuxSum {
t.Errorf("linux 条目解析错误: %v", out)
}
// 摘要统一小写,比对时才不会因大小写误判为不匹配。
if got := out["artex-0.3.8-windows-amd64.zip"]; got != strings.ToLower(winSum) {
t.Errorf("windows 条目错误(* 前缀应剥离、摘要应转小写): %q", got)
}
if len(out) != 2 {
t.Errorf("应忽略空行、非摘要行和长度不对的行,得到 %v", out)
}
}
func TestExtractBinaryFindsNestedEntry(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("包内基名在 Windows 上是 artex.exe,此用例按 Unix 命名构造")
}
dir := t.TempDir()
zipPath := filepath.Join(dir, "release.zip")
f, err := os.Create(zipPath)
if err != nil {
t.Fatal(err)
}
zw := zip.NewWriter(f)
// 真实发布包的结构:artex-<版本>-<os>-<arch>/artex,外加若干干扰文件。
for name, body := range map[string]string{
"artex-0.3.8-linux-amd64/README.md": "readme",
"artex-0.3.8-linux-amd64/skills/a.md": "skill",
"artex-0.3.8-linux-amd64/artex": "#!/bin/sh\nexit 0\n",
"artex-0.3.8-linux-amd64/config.example.json": "{}",
} {
w, err := zw.Create(name)
if err != nil {
t.Fatal(err)
}
if _, err := w.Write([]byte(body)); err != nil {
t.Fatal(err)
}
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
f.Close()
dst := filepath.Join(dir, "out")
if err := extractBinary(zipPath, dst); err != nil {
t.Fatalf("extractBinary: %v", err)
}
if got := readAll(t, dst); !strings.Contains(got, "exit 0") {
t.Errorf("解压出来的不是 artex 可执行文件: %q", got)
}
info, err := os.Stat(dst)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm()&0o111 == 0 {
t.Error("解压出的二进制必须带执行位")
}
}
func TestExtractBinaryMissingEntry(t *testing.T) {
dir := t.TempDir()
zipPath := filepath.Join(dir, "release.zip")
f, err := os.Create(zipPath)
if err != nil {
t.Fatal(err)
}
zw := zip.NewWriter(f)
w, _ := zw.Create("artex-0.3.8-linux-amd64/README.md")
_, _ = w.Write([]byte("readme"))
_ = zw.Close()
f.Close()
if err := extractBinary(zipPath, filepath.Join(dir, "out")); err == nil {
t.Fatal("包内没有可执行文件时应报错")
}
}
func TestCheckURLRejectsNonGitHub(t *testing.T) {
bad := []string{
"http://github.com/x", // 非 HTTPS
"https://evil.com/artex.zip", // 域名不在白名单
"https://github.com.evil.com/x", // 后缀伪装
"https://raw.githubusercontent.com.evil.com/x",
}
for _, raw := range bad {
u := mustParse(t, raw)
if err := checkURL(u); err == nil {
t.Errorf("checkURL(%q) 应当拒绝", raw)
}
}
good := []string{
"https://api.github.com/repos/x/releases/latest",
"https://objects.githubusercontent.com/blah",
"https://GitHub.com/x", // 域名大小写不敏感
}
for _, raw := range good {
u := mustParse(t, raw)
if err := checkURL(u); err != nil {
t.Errorf("checkURL(%q) 应当放行,却报错: %v", raw, err)
}
}
}
func TestAssetNameMatchesBuildScript(t *testing.T) {
// build.sh 的 package_binary 用的是 artex-<版本>-<os>-<arch>.zip,且版本号
// 去掉了 v 前缀。这里对错一个字符,所有平台的一键更新都会找不到资产。
if got := AssetName("v0.3.8", "linux", "amd64"); got != "artex-0.3.8-linux-amd64.zip" {
t.Errorf("AssetName = %q", got)
}
if got := AssetName("0.3.8", "windows", "amd64"); got != "artex-0.3.8-windows-amd64.zip" {
t.Errorf("AssetName = %q", got)
}
}
func mustParse(t *testing.T, raw string) *url.URL {
t.Helper()
u, err := url.Parse(raw)
if err != nil {
t.Fatalf("解析 %q: %v", raw, err)
}
return u
}
func TestSettleClearsMarkerAndStopsRollback(t *testing.T) {
requireUnix(t)
p := testPaths(t)
fakeBin(t, p.Current, "new", 0)
fakeBin(t, p.Old, "old", 0)
if err := writeMarker(p.Marker, marker{From: "0.3.7", To: "0.3.8", Attempts: 2}); err != nil {
t.Fatal(err)
}
settle(p)
if _, err := os.Stat(p.Marker); !os.IsNotExist(err) {
t.Fatal("确认稳定后升级标记必须清除")
}
// 标记没了,后续正常重启就不会再累计次数、也不会误触发回滚。
if _, ok := readMarker(p.Marker); ok {
t.Error("标记读取应失败")
}
// 备份要留着,用户还能手动回滚。
if _, err := os.Stat(p.Old); err != nil {
t.Error("确认稳定后仍应保留上一版本备份")
}
}
func TestSettleIsNoopWithoutMarker(t *testing.T) {
requireUnix(t)
p := testPaths(t)
fakeBin(t, p.Current, "cur", 0)
settle(p) // 普通启动路径,不该 panic 也不该动任何文件
if _, err := os.Stat(p.Current); err != nil {
t.Error("无标记时 settle 不应影响任何文件")
}
}
+339
View File
@@ -0,0 +1,339 @@
package selfupdate
import (
"archive/zip"
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"path"
"runtime"
"strings"
"time"
)
// sumsAsset 是 release.yml 生成的校验和清单,覆盖 Release 里全部 zip。
const sumsAsset = "SHA256SUMS"
// maxBinarySize 限制解压出来的二进制体积,防止畸形 zip 把磁盘写满。
const maxBinarySize = 512 << 20 // 512 MiB
// Phase 是升级过程中的阶段,直接用作 SSE 事件里的 phase 字段。
type Phase string
const (
PhaseIdle Phase = "idle"
PhaseDownload Phase = "downloading"
PhaseVerify Phase = "verifying"
PhaseExtract Phase = "extracting"
PhaseStaged Phase = "staged"
PhaseFailed Phase = "failed"
)
// Progress 由调用方提供,用来把进度推给前端。pct 仅在下载阶段有意义(0-100),
// 其余阶段传 -1。
type Progress func(ph Phase, pct int, msg string)
// Stage 下载指定 Release 的当前平台发布包,校验后把新二进制暂存为 artex.new。
//
// 走的是完整 zip 而不是裸二进制,理由有两个:现有 Release 的 SHA256SUMS 本来就
// 只覆盖 zip,走 zip 不需要改 CI,也能兼容已经发布出去的历史版本;zip 里还带着
// skills/,为将来同步内置 skill 留了口子。代价只是多下载 skills 那几百 KB。
//
// 函数返回即代表暂存完成,调用方随后优雅关闭并以 ExitRestart 退出。
func Stage(ctx context.Context, c *http.Client, rel *Release, currentVersion string, prog Progress) error {
if prog == nil {
prog = func(Phase, int, string) {}
}
p, err := ResolvePaths()
if err != nil {
return err
}
if err := checkWritable(p.Dir); err != nil {
return err
}
name := AssetName(rel.TagName, runtime.GOOS, runtime.GOARCH)
asset, ok := rel.FindAsset(name)
if !ok {
return fmt.Errorf("该版本没有提供 %s/%s 的发布包(缺少 %s)", runtime.GOOS, runtime.GOARCH, name)
}
prog(PhaseDownload, 0, "获取校验和清单…")
sums, err := fetchSums(ctx, c, rel)
if err != nil {
return err
}
want, ok := sums[name]
if !ok {
return fmt.Errorf("%s 未收录 %s,拒绝安装未经校验的二进制", sumsAsset, name)
}
// 临时文件全部落在目标目录里,保证最后的 rename 是同一文件系统内的原子操作
// (跨设备 rename 会失败,而 /tmp 常常是独立挂载点)。
zipPath := p.New + ".zip.part"
binPath := p.New + ".part"
defer func() {
_ = os.Remove(zipPath)
_ = os.Remove(binPath)
}()
prog(PhaseDownload, 0, fmt.Sprintf("下载 %s(%s)…", name, humanSize(asset.Size)))
got, err := download(ctx, c, asset, zipPath, prog)
if err != nil {
return err
}
prog(PhaseVerify, -1, "校验 SHA256…")
if !strings.EqualFold(got, want) {
return fmt.Errorf("SHA256 不匹配:期望 %s,实际 %s(下载损坏或被篡改)", short(want), short(got))
}
prog(PhaseExtract, -1, "解压并冒烟测试…")
if err := extractBinary(zipPath, binPath); err != nil {
return err
}
if err := smokeTest(binPath); err != nil {
return fmt.Errorf("新版本无法在当前系统上运行: %w", err)
}
// 暂存件自己的 sha256 单独存一份:下次启动换装前还要再校验一次,
// 防止暂存后到重启前这段时间里文件被改动或写坏。
binSum, err := fileSHA256(binPath)
if err != nil {
return fmt.Errorf("计算新二进制校验和: %w", err)
}
if err := os.WriteFile(p.Sum, []byte(binSum), 0o644); err != nil {
return fmt.Errorf("写入校验和: %w", err)
}
if err := os.Rename(binPath, p.New); err != nil {
_ = os.Remove(p.Sum)
return fmt.Errorf("暂存新版本: %w", err)
}
if err := writeMarker(p.Marker, marker{
From: currentVersion,
To: strings.TrimPrefix(rel.TagName, "v"),
StagedAt: time.Now().Unix(),
}); err != nil {
// 标记只影响自动回滚能力,暂存件本身已就位,不因此中断升级。
prog(PhaseStaged, -1, "警告:写入升级标记失败,本次升级将没有自动回滚保护")
}
prog(PhaseStaged, 100, "新版本已就绪,正在重启…")
return nil
}
// fetchSums 下载并解析 SHA256SUMS,返回 文件名 → 十六进制摘要。
func fetchSums(ctx context.Context, c *http.Client, rel *Release) (map[string]string, error) {
asset, ok := rel.FindAsset(sumsAsset)
if !ok {
return nil, fmt.Errorf("该 Release 没有 %s,无法校验完整性,拒绝升级", sumsAsset)
}
body, err := get(ctx, c, asset.URL)
if err != nil {
return nil, fmt.Errorf("下载 %s: %w", sumsAsset, err)
}
defer body.Close()
raw, err := io.ReadAll(io.LimitReader(body, 1<<20))
if err != nil {
return nil, fmt.Errorf("读取 %s: %w", sumsAsset, err)
}
out := parseSums(string(raw))
if len(out) == 0 {
return nil, fmt.Errorf("%s 内容为空或格式无法识别", sumsAsset)
}
return out, nil
}
// parseSums 解析 sha256sum 风格的清单,返回 文件名 → 十六进制摘要。
//
// 第一个字段必须是 64 位十六进制才收录。只按"恰好两个字段"判断是不够的——
// 任意一行两个单词的说明文字都会被当成合法条目,把垃圾值塞进摘要表,
// 真正的资产反而可能匹配到错误的摘要。
func parseSums(raw string) map[string]string {
out := map[string]string{}
for line := range strings.Lines(raw) {
// 格式为 "<sha256> <filename>"(sha256sum 用双空格;shasum 的二进制
// 模式会给文件名加 * 前缀)。
fields := strings.Fields(strings.TrimSpace(line))
if len(fields) != 2 || !isHexSHA256(fields[0]) {
continue
}
name := strings.TrimPrefix(fields[1], "*")
if name == "" {
continue
}
out[name] = strings.ToLower(fields[0])
}
return out
}
func isHexSHA256(s string) bool {
if len(s) != 64 {
return false
}
for _, c := range s {
switch {
case c >= '0' && c <= '9', c >= 'a' && c <= 'f', c >= 'A' && c <= 'F':
default:
return false
}
}
return true
}
// download 把资产写入 dst,同时计算 SHA256 并按 Content-Length 汇报进度。
func download(ctx context.Context, c *http.Client, a Asset, dst string, prog Progress) (string, error) {
body, err := get(ctx, c, a.URL)
if err != nil {
return "", fmt.Errorf("下载 %s: %w", a.Name, err)
}
defer body.Close()
f, err := os.Create(dst)
if err != nil {
return "", fmt.Errorf("创建临时文件: %w", err)
}
defer f.Close()
h := sha256.New()
pw := &progressWriter{total: a.Size, prog: prog, name: a.Name, last: time.Now()}
if _, err := io.Copy(io.MultiWriter(f, h, pw), body); err != nil {
return "", fmt.Errorf("下载中断: %w", err)
}
if err := f.Sync(); err != nil {
return "", fmt.Errorf("落盘失败: %w", err)
}
if a.Size > 0 && pw.written != a.Size {
return "", fmt.Errorf("下载不完整:期望 %d 字节,实际 %d 字节", a.Size, pw.written)
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// get 发起一个受白名单约束的 GET,返回响应体。
func get(ctx context.Context, c *http.Client, rawURL string) (io.ReadCloser, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, err
}
if err := checkURL(req.URL); err != nil {
return nil, err
}
req.Header.Set("User-Agent", "artex-selfupdate")
resp, err := c.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
return resp.Body, nil
}
// extractBinary 从发布包里取出 artex 可执行文件。
//
// 包内结构是 artex-<版本>-<os>-<arch>/artex,但这里按**基名**匹配而不是拼完整
// 路径:版本号在包名里出现过一次,拼错一个字符就整个升级失败,按基名找更耐改。
func extractBinary(zipPath, dst string) error {
want := "artex"
if runtime.GOOS == "windows" {
want = "artex.exe"
}
zr, err := zip.OpenReader(zipPath)
if err != nil {
return fmt.Errorf("打开发布包: %w", err)
}
defer zr.Close()
for _, entry := range zr.File {
if entry.FileInfo().IsDir() || !strings.EqualFold(path.Base(entry.Name), want) {
continue
}
rc, err := entry.Open()
if err != nil {
return fmt.Errorf("读取 %s: %w", entry.Name, err)
}
defer rc.Close()
f, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o755)
if err != nil {
return fmt.Errorf("写出新二进制: %w", err)
}
defer f.Close()
n, err := io.Copy(f, io.LimitReader(rc, maxBinarySize+1))
if err != nil {
return fmt.Errorf("解压 %s: %w", entry.Name, err)
}
if n > maxBinarySize {
return fmt.Errorf("发布包内的可执行文件超过 %s,拒绝解压", humanSize(maxBinarySize))
}
if n == 0 {
return fmt.Errorf("发布包内的 %s 是空文件", want)
}
return f.Sync()
}
return fmt.Errorf("发布包里没有找到 %s", want)
}
// checkWritable 提前确认目录可写。没有这一步,非 root 运行、或二进制被放在系统
// 目录时,会在下载完几十 MB 之后才在换装那一刻失败。
func checkWritable(dir string) error {
probe, err := os.CreateTemp(dir, ".artex-update-probe-*")
if err != nil {
return fmt.Errorf("程序目录 %s 不可写,无法自动更新(请检查权限或改用手动升级): %w", dir, err)
}
name := probe.Name()
_ = probe.Close()
_ = os.Remove(name)
return nil
}
// progressWriter 统计已写字节并限频汇报,避免每个 32KiB 分块都推一条 SSE。
type progressWriter struct {
total int64
written int64
name string
prog Progress
last time.Time
}
func (w *progressWriter) Write(b []byte) (int, error) {
w.written += int64(len(b))
if time.Since(w.last) < 300*time.Millisecond {
return len(b), nil
}
w.last = time.Now()
pct := -1
if w.total > 0 {
pct = int(w.written * 100 / w.total)
}
w.prog(PhaseDownload, pct, fmt.Sprintf("下载中 %s / %s", humanSize(w.written), humanSize(w.total)))
return len(b), nil
}
func humanSize(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d B", n)
}
div, exp := int64(unit), 0
for v := n / unit; v >= unit; v /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "KMGT"[exp])
}
func short(sum string) string {
if len(sum) > 12 {
return sum[:12] + "…"
}
return sum
}
+7
View File
@@ -750,6 +750,13 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/logs/history", s.getLogsHistory) mux.HandleFunc("GET /api/logs/history", s.getLogsHistory)
mux.HandleFunc("GET /api/logs/stream", s.streamLogs) mux.HandleFunc("GET /api/logs/stream", s.streamLogs)
// 页面一键更新。走的是默认的 JWT 鉴权(auth.go 只放行 /api/auth/* 和
// /api/health),所以这几个改动程序自身的接口天然需要登录。
mux.HandleFunc("GET /api/update/check", s.updateCheck)
mux.HandleFunc("POST /api/update/apply", s.updateApply)
mux.HandleFunc("POST /api/update/rollback", s.updateRollback)
mux.HandleFunc("GET /api/update/stream", s.updateStream)
mux.HandleFunc("GET /api/tasks", s.listTasks) mux.HandleFunc("GET /api/tasks", s.listTasks)
mux.HandleFunc("POST /api/tasks", s.createTask) mux.HandleFunc("POST /api/tasks", s.createTask)
mux.HandleFunc("GET /api/task-categories", s.pgListTaskCategories) mux.HandleFunc("GET /api/task-categories", s.pgListTaskCategories)
+373
View File
@@ -0,0 +1,373 @@
package server
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"runtime"
"sync"
"time"
"github.com/Autumn-27/artex/selfupdate"
)
// 页面一键更新的 HTTP 面。真正的下载/校验/换装逻辑全在 selfupdate 包里,
// 这里只负责鉴权边界、并发互斥、进度广播,以及把"该退出了"告诉 main。
//
// 重启不由本进程完成:暂存好新版本后进程以 selfupdate.ExitRestart 退出,
// 由守护脚本(start.sh / start.bat,Docker 下是 ENTRYPOINT)重新拉起。
// restartCh 在升级就绪或回滚完成后关闭,main 收到后以 ExitRestart 退出。
var (
restartOnce sync.Once
restartCh = make(chan struct{})
)
// RestartRequested 返回一个在"请退出并让守护进程重新拉起我"时关闭的 channel。
func RestartRequested() <-chan struct{} { return restartCh }
func requestRestart() { restartOnce.Do(func() { close(restartCh) }) }
// bootState 是本次启动时 selfupdate.Bootstrap 的结论(升级成功 / 刚回滚 /
// 暂存件被丢弃),由 main 注入,供 /api/update/check 如实告诉前端上一次升级的下场。
var (
bootStateMu sync.Mutex
bootState selfupdate.State
)
// SetBootUpdateState 由 main 在启动时调用一次。
func SetBootUpdateState(st selfupdate.State) {
bootStateMu.Lock()
defer bootStateMu.Unlock()
bootState = st
}
func bootUpdateState() selfupdate.State {
bootStateMu.Lock()
defer bootStateMu.Unlock()
return bootState
}
// releaseCache 缓存 GitHub 的最新版本查询结果。
//
// 顶栏的"有新版本"提示会在每次整页加载时查一次,而未认证的 GitHub API 是
// 每 IP 每小时 60 次——不缓存的话,多开几个标签页或刷几次页面就把配额耗光了,
// 之后真想更新时反而查不动。用户显式点"检查更新"时可以 force 绕过缓存。
type releaseCache struct {
mu sync.Mutex
rel *selfupdate.Release
err error
at time.Time
// fetch 是取数函数,仅为测试留的注入点;为 nil 时走真正的 GitHub 查询。
fetch func(context.Context, *http.Client) (*selfupdate.Release, error)
}
const (
releaseTTL = 30 * time.Minute
// 失败结果也缓存一小会儿,否则 GitHub 不可达时每次页面加载都要干等一次超时;
// 但 TTL 要短,网络恢复后很快就能自己好。
releaseErrTTL = 2 * time.Minute
// 查询用的超时。NewClient 的 30 分钟超时是给下载整包用的,查版本不能等那么久。
releaseTimeout = 20 * time.Second
)
var relCache = &releaseCache{}
// get 返回最新 Release,命中缓存则不访问网络。
//
// 取数期间一直持有锁:并发请求会排队等同一次查询的结果,而不是各自去打 GitHub
// (页面刚加载时多个标签页同时来查,正是最容易触发限流的时刻)。
func (c *releaseCache) get(ctx context.Context, client *http.Client, force bool) (*selfupdate.Release, error) {
c.mu.Lock()
defer c.mu.Unlock()
if !force {
ttl := releaseTTL
if c.err != nil {
ttl = releaseErrTTL
}
if !c.at.IsZero() && time.Since(c.at) < ttl {
return c.rel, c.err
}
}
fetch := c.fetch
if fetch == nil {
fetch = selfupdate.FetchLatest
}
ctx, cancel := context.WithTimeout(ctx, releaseTimeout)
defer cancel()
rel, err := fetch(ctx, client)
// 请求被取消(用户关了标签页)不代表 GitHub 有问题,别把它写进缓存,
// 否则下一个访客会拿到一条莫名其妙的"已取消"错误。
if err != nil && ctx.Err() != nil && errors.Is(ctx.Err(), context.Canceled) {
return c.rel, err
}
c.rel, c.err, c.at = rel, err, time.Now()
return rel, err
}
// updateProgress 是推给前端的一条进度。
type updateProgress struct {
Phase selfupdate.Phase `json:"phase"`
Percent int `json:"percent"` // 仅下载阶段有意义;其余为 -1
Message string `json:"message"`
Version string `json:"version,omitempty"`
Error string `json:"error,omitempty"`
}
// updateHub 持有一次升级的进度并广播给 SSE 订阅者。
//
// running 同时充当互斥:升级期间再次 POST /api/update/apply 直接 409,
// 避免两个 goroutine 同时往同一个 artex.new 写。
type updateHub struct {
mu sync.Mutex
running bool
cur updateProgress
subs map[chan updateProgress]struct{}
}
var updHub = &updateHub{
cur: updateProgress{Phase: selfupdate.PhaseIdle, Percent: -1},
subs: map[chan updateProgress]struct{}{},
}
// begin 抢占升级权限,已在进行中则返回 false。
func (h *updateHub) begin(version string) bool {
h.mu.Lock()
defer h.mu.Unlock()
if h.running {
return false
}
h.running = true
h.cur = updateProgress{Phase: selfupdate.PhaseDownload, Percent: 0, Message: "准备中…", Version: version}
h.fanout(h.cur)
return true
}
// finish 结束一次升级。err 为 nil 表示暂存成功,等待重启。
func (h *updateHub) finish(err error) {
h.mu.Lock()
defer h.mu.Unlock()
h.running = false
if err != nil {
h.cur = updateProgress{Phase: selfupdate.PhaseFailed, Percent: -1, Message: "更新失败", Error: err.Error(), Version: h.cur.Version}
} else {
h.cur = updateProgress{Phase: selfupdate.PhaseStaged, Percent: 100, Message: "新版本已就绪,正在重启…", Version: h.cur.Version}
}
h.fanout(h.cur)
}
func (h *updateHub) publish(ph selfupdate.Phase, pct int, msg string) {
h.mu.Lock()
defer h.mu.Unlock()
h.cur = updateProgress{Phase: ph, Percent: pct, Message: msg, Version: h.cur.Version}
h.fanout(h.cur)
}
// fanout 必须在持有 h.mu 时调用。订阅者 channel 是有缓冲的,满了就丢——
// 进度是可丢弃的瞬时信息,绝不能让一个卡住的 SSE 连接阻塞升级本身。
func (h *updateHub) fanout(p updateProgress) {
for ch := range h.subs {
select {
case ch <- p:
default:
}
}
}
func (h *updateHub) snapshot() (updateProgress, bool) {
h.mu.Lock()
defer h.mu.Unlock()
return h.cur, h.running
}
func (h *updateHub) subscribe() (<-chan updateProgress, func()) {
ch := make(chan updateProgress, 64)
h.mu.Lock()
h.subs[ch] = struct{}{}
h.mu.Unlock()
var once sync.Once
return ch, func() {
once.Do(func() {
h.mu.Lock()
delete(h.subs, ch)
h.mu.Unlock()
close(ch)
})
}
}
// updateCheck 查询 GitHub 上的最新正式版并与当前版本比较。
//
// 前端也会直连 api.github.com(GitHub 的 CORS 是 *),但**以本接口为准**:
// 下载是后端做的,只有后端能访问 GitHub 才谈得上更新。浏览器能连、服务器连不上
// 的情况很常见(服务器在内网、或代理只配在浏览器上),那时点更新必然失败,
// 不如在检查这一步就如实报错。
func (s *Server) updateCheck(w http.ResponseWriter, r *http.Request) {
current := BuildVersion
mode := "binary"
if selfupdate.InDocker() {
mode = "docker"
}
boot := bootUpdateState()
out := map[string]any{
"current": current,
"mode": mode,
"os": runtime.GOOS,
"arch": runtime.GOARCH,
"has_backup": selfupdate.HasBackup(),
"repo": selfupdate.Repo,
"boot_notice": boot.Detail,
"rolled_back": boot.RolledBack,
}
// 顶栏提示走缓存(默认);用户点"检查更新"时带 force=1 强制回源。
force := r.URL.Query().Get("force") != ""
client := selfupdate.NewClient(s.m.GlobalProxy())
rel, err := relCache.get(r.Context(), client, force)
if err != nil {
out["error"] = err.Error()
writeJSON(w, 200, out)
return
}
latest := rel.TagName
out["latest"] = latest
out["notes"] = rel.Body
out["html_url"] = rel.HTMLURL
if !rel.PublishedAt.IsZero() {
out["published_at"] = rel.PublishedAt.Format(time.RFC3339)
}
asset := selfupdate.AssetName(latest, runtime.GOOS, runtime.GOARCH)
out["asset"] = asset
if a, ok := rel.FindAsset(asset); ok {
out["asset_available"] = true
out["size"] = a.Size
} else {
out["asset_available"] = false
}
cmp, comparable := selfupdate.CompareVersions(current, latest)
out["comparable"] = comparable
out["has_update"] = comparable && cmp < 0
if !comparable {
// 开发构建(dev / git describe 带后缀)没有可比较的版本号。放行只会
// 用正式版覆盖掉本地正在调试的二进制,所以直接不给更新。
out["reason"] = fmt.Sprintf("当前版本 %q 不是正式发布版本,已禁用一键更新", current)
}
writeJSON(w, 200, out)
}
// updateApply 下载并暂存新版本,完成后让进程退出交给守护脚本重启。
//
// 立刻返回 202,实际工作在后台 goroutine 上跑:整包下载可能要几分钟,
// 挂在请求上会被反代超时掐断。进度走 /api/update/stream。
func (s *Server) updateApply(w http.ResponseWriter, r *http.Request) {
current := BuildVersion
// 走缓存:确保装上的就是用户在界面上看到并确认的那个版本。
client := selfupdate.NewClient(s.m.GlobalProxy())
rel, err := relCache.get(r.Context(), client, false)
if err != nil {
writeErr(w, 502, err.Error())
return
}
cmp, comparable := selfupdate.CompareVersions(current, rel.TagName)
if !comparable {
writeErr(w, 400, fmt.Sprintf("当前版本 %q 不是正式发布版本,已禁用一键更新", current))
return
}
if cmp >= 0 {
writeErr(w, 400, fmt.Sprintf("当前已是最新版本 %s", current))
return
}
if !updHub.begin(rel.TagName) {
writeErr(w, 409, "已有一个更新正在进行中")
return
}
go func() {
// 刻意用 s.ctx 而不是请求的 ctx:HTTP 响应一返回请求就结束了,
// 挂在它上面下载会立刻被取消。
err := selfupdate.Stage(s.ctx, client, rel, current, func(ph selfupdate.Phase, pct int, msg string) {
updHub.publish(ph, pct, msg)
})
updHub.finish(err)
if err != nil {
log.Printf("[update] 更新失败:%v", err)
return
}
log.Printf("[update] %s → %s 已暂存,即将退出以完成换装", current, rel.TagName)
// 留一点时间把最后一条进度推给前端,再触发退出。
time.Sleep(1500 * time.Millisecond)
requestRestart()
}()
writeJSON(w, 202, map[string]any{"ok": true, "target": rel.TagName})
}
// updateRollback 主动退回上一版本(换装前备份的 artex.old)。
func (s *Server) updateRollback(w http.ResponseWriter, r *http.Request) {
if _, running := updHub.snapshot(); running {
writeErr(w, 409, "更新正在进行中,无法回滚")
return
}
if err := selfupdate.Rollback(); err != nil {
writeErr(w, 400, err.Error())
return
}
log.Printf("[update] 已手动回滚到上一版本,即将退出以完成切换")
writeJSON(w, 202, map[string]any{"ok": true})
go func() {
time.Sleep(500 * time.Millisecond)
requestRestart()
}()
}
// updateStream 以 SSE 推送更新进度。
func (s *Server) updateStream(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
writeErr(w, 500, "streaming unsupported")
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
ch, unsub := updHub.subscribe()
defer unsub()
send := func(p updateProgress) {
b, _ := json.Marshal(p)
fmt.Fprintf(w, "data: %s\n\n", b)
flusher.Flush()
}
// 先补一条当前状态,页面刷新后能立刻看到进行中的升级。
cur, _ := updHub.snapshot()
send(cur)
ctx := r.Context()
ping := time.NewTicker(20 * time.Second)
defer ping.Stop()
for {
select {
case <-ctx.Done():
return
case p, ok := <-ch:
if !ok {
return
}
send(p)
case <-ping.C:
fmt.Fprint(w, ": ping\n\n")
flusher.Flush()
}
}
}
+155
View File
@@ -0,0 +1,155 @@
package server
import (
"context"
"errors"
"net/http"
"testing"
"time"
"github.com/Autumn-27/artex/selfupdate"
)
// releaseCache 是保护 GitHub 配额的那一层:未认证的 API 只有 60 次/小时/IP,
// 而顶栏的"有新版本"提示每次整页加载都会查一次。缓存一旦失效,用户多开几个
// 标签页就会把配额耗光,之后真想更新反而查不动。
func newTestCache(fetch func(context.Context, *http.Client) (*selfupdate.Release, error)) *releaseCache {
return &releaseCache{fetch: fetch}
}
func TestReleaseCacheServesFromCache(t *testing.T) {
calls := 0
c := newTestCache(func(context.Context, *http.Client) (*selfupdate.Release, error) {
calls++
return &selfupdate.Release{TagName: "v0.3.8"}, nil
})
for range 5 {
rel, err := c.get(t.Context(), nil, false)
if err != nil {
t.Fatalf("get: %v", err)
}
if rel.TagName != "v0.3.8" {
t.Fatalf("TagName = %q", rel.TagName)
}
}
if calls != 1 {
t.Errorf("5 次查询只应回源 1 次,实际 %d 次", calls)
}
}
func TestReleaseCacheForceBypasses(t *testing.T) {
calls := 0
c := newTestCache(func(context.Context, *http.Client) (*selfupdate.Release, error) {
calls++
return &selfupdate.Release{TagName: "v0.3.8"}, nil
})
if _, err := c.get(t.Context(), nil, false); err != nil {
t.Fatal(err)
}
// 用户点「检查更新」必须拿到实时结果,否则刚发布的版本要等缓存过期才看得见。
if _, err := c.get(t.Context(), nil, true); err != nil {
t.Fatal(err)
}
if calls != 2 {
t.Errorf("force 应绕过缓存,期望回源 2 次,实际 %d 次", calls)
}
}
func TestReleaseCacheExpiresAfterTTL(t *testing.T) {
calls := 0
c := newTestCache(func(context.Context, *http.Client) (*selfupdate.Release, error) {
calls++
return &selfupdate.Release{TagName: "v0.3.8"}, nil
})
if _, err := c.get(t.Context(), nil, false); err != nil {
t.Fatal(err)
}
// 把落库时间往前拨到刚过期,模拟 TTL 到点。
c.at = time.Now().Add(-releaseTTL - time.Second)
if _, err := c.get(t.Context(), nil, false); err != nil {
t.Fatal(err)
}
if calls != 2 {
t.Errorf("TTL 过期后应重新回源,期望 2 次,实际 %d 次", calls)
}
}
func TestReleaseCacheUsesShorterTTLForErrors(t *testing.T) {
calls := 0
c := newTestCache(func(context.Context, *http.Client) (*selfupdate.Release, error) {
calls++
return nil, errors.New("github 不可达")
})
if _, err := c.get(t.Context(), nil, false); err == nil {
t.Fatal("期望返回错误")
}
// 失败结果也要缓存一会儿,否则 GitHub 不可达时每次页面加载都白等一次超时。
if _, err := c.get(t.Context(), nil, false); err == nil {
t.Fatal("期望返回错误")
}
if calls != 1 {
t.Errorf("错误应短时缓存,期望回源 1 次,实际 %d 次", calls)
}
// 但错误的 TTL 必须明显短于成功的,网络恢复后要能很快自愈。
if releaseErrTTL >= releaseTTL {
t.Fatalf("错误 TTL(%v) 必须短于成功 TTL(%v)", releaseErrTTL, releaseTTL)
}
c.at = time.Now().Add(-releaseErrTTL - time.Second)
if _, err := c.get(t.Context(), nil, false); err == nil {
t.Fatal("期望返回错误")
}
if calls != 2 {
t.Errorf("错误 TTL 过期后应重试,期望 2 次,实际 %d 次", calls)
}
}
func TestReleaseCacheDoesNotPoisonOnCallerCancel(t *testing.T) {
good := &selfupdate.Release{TagName: "v0.3.8"}
c := newTestCache(func(ctx context.Context, _ *http.Client) (*selfupdate.Release, error) {
return good, nil
})
if _, err := c.get(t.Context(), nil, false); err != nil {
t.Fatal(err)
}
// 访客关掉标签页会取消请求。那不代表 GitHub 有问题,绝不能把"已取消"
// 写进缓存——否则接下来 30 分钟内每个访客都会收到一条莫名其妙的错误。
c.fetch = func(ctx context.Context, _ *http.Client) (*selfupdate.Release, error) {
return nil, ctx.Err()
}
c.at = time.Now().Add(-releaseTTL - time.Second) // 让缓存过期,逼它回源
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := c.get(ctx, nil, false); err == nil {
t.Fatal("调用方已取消时应把错误透传给它")
}
// 关键不变量:被取消的那一次不留下任何痕迹——缓存里既没有"已取消"这个错误,
// 也还保着上一次的好结果。
if c.err != nil {
t.Fatalf("取消错误不应写进缓存,得到 %v", c.err)
}
if c.rel == nil || c.rel.TagName != "v0.3.8" {
t.Fatalf("缓存应保留上一次的好结果,得到 %+v", c.rel)
}
// 那次取消没换来任何新数据,所以下一个访客理应重新回源——而且能正常拿到结果,
// 不会被上一次的取消连累。
c.fetch = func(context.Context, *http.Client) (*selfupdate.Release, error) {
return good, nil
}
rel, err := c.get(t.Context(), nil, false)
if err != nil {
t.Fatalf("取消之后的正常请求不应报错: %v", err)
}
if rel == nil || rel.TagName != "v0.3.8" {
t.Fatalf("应拿到正常结果,得到 %+v", rel)
}
}
+52
View File
@@ -0,0 +1,52 @@
@echo off
rem ARTEX 守护启动脚本(Windows)
rem
rem 用法:
rem start.bat 前台运行(Ctrl-C 停止)
rem start.bat -addr :9000 额外参数原样透传给 artex
rem
rem 它只做一件事:把 artex.exe 跑起来,进程退出后按退出码决定要不要再拉起。
rem
rem 0 用户正常停止 -> 退出循环
rem 75 程序请求重启 -> 立刻重跑(页面点了"一键更新"或"回滚")
rem 其他 崩溃 -> 退避后重跑(1->2->4…最多 60 秒)
rem
rem 下载、SHA256 校验、换装都不在这里,全部由 artex 自己在启动时完成
rem (selfupdate 包)。脚本保持傻瓜化,详见 start.sh 顶部的说明。
setlocal enabledelayedexpansion
cd /d "%~dp0"
set "BIN=artex.exe"
if not exist "%BIN%" (
echo [artex] 找不到可执行文件 %BIN% 1>&2
exit /b 1
)
set "RESTART_CODE=75"
set "MAX_DELAY=60"
set /a delay=1
:loop
"%BIN%" %*
set "code=!ERRORLEVEL!"
if "!code!"=="0" (
echo [artex] 正常退出
exit /b 0
)
if "!code!"=="%RESTART_CODE%" (
rem 更新/回滚已就绪:重跑后 artex 会在启动时完成换装。
echo [artex] 请求重启(应用新版本)…
set /a delay=1
goto loop
)
echo [artex] 异常退出 ^(code=!code!^),!delay!s 后重启 1>&2
rem timeout 在被重定向的控制台里会失败,用 ping 兜底(延时 N 秒需要 N+1 次)。
set /a pings=!delay!+1
ping -n !pings! 127.0.0.1 >nul 2>&1
set /a delay=!delay!*2
if !delay! gtr %MAX_DELAY% set /a delay=%MAX_DELAY%
goto loop
Executable
+82
View File
@@ -0,0 +1,82 @@
#!/bin/sh
# ARTEX 守护启动脚本(Linux / macOS / Docker ENTRYPOINT)
#
# 用法:
# ./start.sh 前台运行(Ctrl-C 停止)
# nohup ./start.sh >artex.log 2>&1 & 后台常驻
# ./start.sh -addr :9000 额外参数原样透传给 artex
#
# 它只做一件事:把 artex 跑起来,进程退出后按退出码决定要不要再拉起。
#
# 0 用户正常停止 → 退出循环
# 75 程序请求重启 → 立刻重跑(页面点了"一键更新"或"回滚")
# 其他 崩溃 → 退避后重跑(1→2→4…最多 60 秒)
#
# 刻意不在这里做下载、SHA256 校验或换装:那些逻辑在 sh 和 bat 上要写两套,
# 而它们恰恰是最不能出错的一环——一旦换上跑不起来的二进制,本脚本会忠实地
# 反复拉起它,用户只能上机器手工救。所以校验/换装全部留在 Go 里(selfupdate 包),
# 由 artex 自己在启动时完成,脚本保持傻瓜化。
set -u
cd "$(dirname "$0")" || exit 1
BIN=./artex
[ -x "$BIN" ] || { echo "[artex] 找不到可执行文件 $BIN" >&2; exit 1; }
RESTART_CODE=75
MAX_DELAY=60
child=0
stopping=0
# 转发停止信号给 artex 本体。
#
# Docker 下这是必需的:docker stop 只把 SIGTERM 发给 PID 1(也就是本脚本),
# 不会发给子进程。不转发的话 artex 收不到信号、做不了优雅关闭,10 秒后被 SIGKILL
# 硬杀,正在跑的任务直接断在半路。
forward() {
stopping=1
if [ "$child" -ne 0 ]; then
kill -TERM "$child" 2>/dev/null || true
fi
}
trap forward INT TERM
delay=1
while :; do
"$BIN" "$@" &
child=$!
# 信号会打断 wait 并让它返回 >128。此时子进程其实还在做优雅关闭,
# 必须再 wait 一次才能拿到它真正的退出码。
wait "$child"
code=$?
if [ "$code" -gt 128 ]; then
wait "$child"
code=$?
fi
child=0
if [ "$stopping" -eq 1 ]; then
echo "[artex] 已停止"
exit 0
fi
case "$code" in
0)
echo "[artex] 正常退出"
exit 0
;;
"$RESTART_CODE")
# 更新/回滚已就绪:重跑后 artex 会在启动时完成换装(见 selfupdate.Bootstrap)。
echo "[artex] 请求重启(应用新版本)…"
delay=1
;;
*)
echo "[artex] 异常退出 (code=$code),${delay}s 后重启" >&2
sleep "$delay"
delay=$((delay * 2))
[ "$delay" -gt "$MAX_DELAY" ] && delay=$MAX_DELAY
;;
esac
done
@@ -14,6 +14,7 @@ import { AccountSwitcher } from "./sidebar/account-switcher";
import { LayoutControls } from "./sidebar/layout-controls"; import { LayoutControls } from "./sidebar/layout-controls";
import { SearchDialog } from "./sidebar/search-dialog"; import { SearchDialog } from "./sidebar/search-dialog";
import { ThemeSwitcher } from "./sidebar/theme-switcher"; import { ThemeSwitcher } from "./sidebar/theme-switcher";
import { UpdateBadge } from "./update-badge";
// 任务详情页保持原样:它自带头部/Tabs 与内边距,这里不再叠加全局头部和 padding。 // 任务详情页保持原样:它自带头部/Tabs 与内边距,这里不再叠加全局头部和 padding。
function isFullBleed(pathname: string) { function isFullBleed(pathname: string) {
@@ -64,6 +65,7 @@ export function MainContent({ children }: { children: ReactNode }) {
{version && ( {version && (
<span className="font-medium text-muted-foreground text-xs tabular-nums">版本 · {version}</span> <span className="font-medium text-muted-foreground text-xs tabular-nums">版本 · {version}</span>
)} )}
<UpdateBadge />
<LayoutControls /> <LayoutControls />
<ThemeSwitcher /> <ThemeSwitcher />
<AccountSwitcher users={[currentUser]} /> <AccountSwitcher users={[currentUser]} />
@@ -0,0 +1,58 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { ArrowUpCircleIcon } from "lucide-react";
import { api } from "@/lib/api";
/**
* 顶栏的"有新版本"提示:整页加载时查一次,有更新就在版本号旁边亮出来,
* 点击直达系统配置页的「版本与更新」卡片。
*
* 后端对 GitHub 的查询结果有 30 分钟缓存,所以这里每次挂载都查一次是安全的
* ——未认证的 GitHub API 只有 60 次/小时/IP,没有那层缓存的话,多开几个标签页
* 就会把配额耗光,之后真想更新反而查不动。
*
* 查询失败一律静默:顶栏不是报错的地方,用户进设置页点「检查更新」会看到原因。
*/
export function UpdateBadge() {
const [latest, setLatest] = React.useState("");
React.useEffect(() => {
let alive = true;
api
.checkUpdate()
.then((r) => {
// has_update 已经包含了"版本号可比较"的判断,开发构建不会亮这个提示。
if (alive && r.has_update && r.latest) setLatest(r.latest.replace(/^v(?=\d)/, ""));
})
.catch(() => {
// 静默:没网 / GitHub 限流都不该在顶栏弹错误。
});
return () => {
alive = false;
};
}, []);
if (!latest) return null;
return (
<Link
href="/system/settings"
title={`发现新版本 ${latest},点击前往更新`}
className="inline-flex items-center gap-1.5 rounded-full bg-primary px-2.5 py-1 font-medium text-primary-foreground text-xs transition-opacity hover:opacity-90"
>
{/* 呼吸点:顶栏元素很多,纯文字容易被忽略,动效让它一眼可见。 */}
<span className="relative flex size-1.5">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-primary-foreground opacity-75" />
<span className="relative inline-flex size-1.5 rounded-full bg-primary-foreground" />
</span>
<ArrowUpCircleIcon className="size-3.5" />
<span className="hidden sm:inline">新版本 {latest}</span>
<span className="sm:hidden">新版本</span>
</Link>
);
}
@@ -0,0 +1,314 @@
"use client";
import * as React from "react";
import {
CheckCircle2Icon,
DownloadIcon,
ExternalLinkIcon,
RefreshCwIcon,
RotateCcwIcon,
TriangleAlertIcon,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { api, sseUrl } from "@/lib/api";
import type { UpdateCheck, UpdateProgress } from "@/lib/types";
/** 等待新版本上线的最长时间。一次升级要经过三次进程启动(暂存 → 换装 → 新版),
* 每次都是秒级,三分钟足够覆盖慢磁盘和 Docker 容器重建。 */
const RESTART_TIMEOUT_MS = 180_000;
function humanSize(n?: number): string {
if (!n || n <= 0) return "";
const units = ["B", "KB", "MB", "GB"];
let v = n;
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i++;
}
return `${v.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
export function UpdateCard() {
const [info, setInfo] = React.useState<UpdateCheck | null>(null);
const [checking, setChecking] = React.useState(true);
const [progress, setProgress] = React.useState<UpdateProgress | null>(null);
// 与 progress 分开:暂存完成后进程就没了,SSE 会断,此时要切到轮询 /api/health。
const [restarting, setRestarting] = React.useState(false);
const [busy, setBusy] = React.useState(false);
// quiet 同时决定要不要绕过后端缓存:进页面时的自动检查用缓存(顶栏刚查过),
// 用户手动点「检查更新」则强制回源,否则刚发布的版本要等缓存过期才看得到。
const check = React.useCallback((quiet = false) => {
setChecking(true);
api
.checkUpdate(!quiet)
.then((r) => {
setInfo(r);
if (!quiet) {
if (r.error) toast.error("检查更新失败:" + r.error);
else if (r.has_update) toast.success(`发现新版本 ${r.latest}`);
else if (r.comparable) toast.success("当前已是最新版本");
}
})
.catch((e) => {
if (!quiet) toast.error("检查更新失败:" + (e as Error).message);
})
.finally(() => setChecking(false));
}, []);
React.useEffect(() => {
check(true);
}, [check]);
// 轮询 /api/health 直到版本号变化。
//
// 判据必须是"版本变了"而不是"能连上了":换装过程中旧版本会短暂地重新起来一次
// (那一次只负责把 artex.new 换上去然后立刻退出),只看连通性会误判成功。
const waitForNewVersion = React.useCallback(async (fromVersion: string) => {
setRestarting(true);
const deadline = Date.now() + RESTART_TIMEOUT_MS;
while (Date.now() < deadline) {
await sleep(2000);
try {
const r = await fetch("/api/health", { cache: "no-store" });
if (r.ok) {
const j = (await r.json()) as { version?: string };
if (j.version && j.version !== fromVersion) {
toast.success(`已更新到 ${j.version},正在重新加载页面`);
await sleep(800);
window.location.reload();
return;
}
}
} catch {
// 重启窗口内连不上是预期的,继续轮询。
}
}
setRestarting(false);
toast.error("等待服务重启超时。请检查后端日志,或确认 artex 是通过 start.sh / start.bat 启动的。");
}, []);
// 订阅更新进度。SSE 不走 Next 的 /api 重写(那层会缓冲,事件推不出来)。
const openStream = React.useCallback(
(fromVersion: string) => {
const es = new EventSource(sseUrl("/api/update/stream"));
es.onmessage = (ev) => {
let p: UpdateProgress;
try {
p = JSON.parse(ev.data) as UpdateProgress;
} catch {
return;
}
setProgress(p);
if (p.phase === "failed") {
es.close();
setBusy(false);
toast.error("更新失败:" + (p.error || p.message));
return;
}
if (p.phase === "staged") {
es.close();
void waitForNewVersion(fromVersion);
}
};
es.onerror = () => {
// 进程退出时 SSE 必然断开。如果已经进入等待重启,这属于正常现象,
// 交给 /api/health 轮询继续判定即可。
es.close();
};
return es;
},
[waitForNewVersion],
);
const doUpdate = () => {
if (!info) return;
const from = info.current;
const ok = window.confirm(
`确定更新到 ${info.latest}?\n\n` +
"更新会重启程序,正在运行的任务会被中断。\n" +
(info.mode === "docker"
? "\n注意:容器内更新只替换程序本身,不会更新镜像里的 playwright / nmap 等工具链;" +
"若新版本依赖新工具,请改用 docker compose pull。"
: ""),
);
if (!ok) return;
setBusy(true);
setProgress({ phase: "downloading", percent: 0, message: "准备中…" });
const es = openStream(from);
api.applyUpdate().catch((e) => {
es.close();
setBusy(false);
setProgress(null);
toast.error("启动更新失败:" + (e as Error).message);
});
};
const doRollback = () => {
if (!info) return;
if (
!window.confirm(
"回滚到上一版本?\n\n程序会重启,正在运行的任务会被中断。\n注意:数据库结构不会回退,旧版本可能无法识别新版写入的数据。",
)
)
return;
const from = info.current;
setBusy(true);
api
.rollbackUpdate()
.then(() => {
toast.success("已切换到上一版本,正在重启…");
void waitForNewVersion(from);
})
.catch((e) => {
setBusy(false);
toast.error("回滚失败:" + (e as Error).message);
});
};
const phase = progress?.phase;
const showProgress = busy || restarting;
// 只有下载阶段拿得到真实百分比(按 Content-Length 算)。校验/解压/等待重启都是
// 时长不可知的阶段,进度条填满并加个脉冲动画表示"在忙但说不准还要多久"。
const downloading = !restarting && phase === "downloading";
const pct = downloading ? Math.max(progress?.percent ?? 0, 0) : 100;
return (
// 设置页是多列瀑布流布局,卡片自己负责行间距并禁止跨列断开(见 page.tsx 的注释)。
<Card className="mb-4 break-inside-avoid md:mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<DownloadIcon className="size-4" />
版本与更新
</CardTitle>
<CardDescription>从 GitHub 检查并安装新版本。更新会重启程序,正在运行的任务会被中断。</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="text-muted-foreground">当前版本</span>
<Badge variant="secondary" className="font-mono">
{info?.current ?? "…"}
</Badge>
{info && (
<>
<Badge variant="outline" className="font-mono">
{info.os}/{info.arch}
</Badge>
<Badge variant="outline">{info.mode === "docker" ? "Docker" : "独立程序"}</Badge>
</>
)}
{info?.latest && (
<>
<span className="text-muted-foreground">最新版本</span>
<Badge variant={info.has_update ? "default" : "secondary"} className="font-mono">
{info.latest}
</Badge>
</>
)}
{info?.html_url && (
<a
href={info.html_url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-xs text-muted-foreground underline-offset-4 hover:underline"
>
更新日志 <ExternalLinkIcon className="size-3" />
</a>
)}
</div>
{info?.boot_notice && (
<p className="flex items-start gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 p-2 text-xs text-amber-700 dark:text-amber-400">
<TriangleAlertIcon className="mt-0.5 size-3.5 shrink-0" />
{info.boot_notice}
</p>
)}
{info?.error && (
<p className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 p-2 text-xs text-destructive">
<TriangleAlertIcon className="mt-0.5 size-3.5 shrink-0" />
无法连接 GitHub:{info.error}
{" "}可在上方配置全局代理后重试。
</p>
)}
{info && !info.comparable && info.reason && <p className="text-xs text-muted-foreground">{info.reason}</p>}
{info?.has_update && info.asset_available === false && (
<p className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 p-2 text-xs text-destructive">
<TriangleAlertIcon className="mt-0.5 size-3.5 shrink-0" />
{info.latest} 没有提供 {info.os}/{info.arch} 的发布包(缺少 {info.asset}),无法自动更新。
</p>
)}
{info?.has_update && info.asset_available !== false && (
<p className="text-xs text-muted-foreground">
将下载 <span className="font-mono">{info.asset}</span>
{info.size ? `(${humanSize(info.size)})` : ""},校验 SHA256 并冒烟测试后才会替换,失败自动保留当前版本。
</p>
)}
{info && !info.has_update && info.comparable && !info.error && (
<p className="flex items-center gap-2 text-xs text-muted-foreground">
<CheckCircle2Icon className="size-3.5 text-emerald-600" />
当前已是最新版本。
</p>
)}
{info?.mode === "docker" && info.has_update && (
<p className="text-xs text-muted-foreground">
Docker 下的更新只替换程序本身,不更新镜像里的 playwright / nmap 等工具链,且
<span className="font-mono"> docker compose up -d </span>
重建容器后会退回镜像自带的版本。需要连镜像一起升级请执行
<span className="font-mono"> docker compose pull artex &amp;&amp; docker compose up -d artex</span>。
</p>
)}
{showProgress && (
<div className="space-y-1.5">
<Progress value={pct} className={downloading ? undefined : "animate-pulse"} />
<p className="text-xs text-muted-foreground">
{restarting ? "正在重启并应用新版本,请稍候(页面会自动刷新)…" : progress?.message}
</p>
</div>
)}
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={() => check(false)} disabled={checking || busy || restarting}>
<RefreshCwIcon className={checking ? "size-4 animate-spin" : "size-4"} />
检查更新
</Button>
<Button
size="sm"
onClick={doUpdate}
disabled={busy || restarting || !info?.has_update || info?.asset_available === false}
>
<DownloadIcon className="size-4" />
{info?.has_update ? `更新到 ${info.latest}` : "立即更新"}
</Button>
{info?.has_backup && (
<Button variant="ghost" size="sm" onClick={doRollback} disabled={busy || restarting}>
<RotateCcwIcon className="size-4" />
回滚到上一版本
</Button>
)}
</div>
<p className="text-xs text-muted-foreground">
一键更新依赖守护脚本重启程序。请通过 <span className="font-mono">start.sh</span>(Windows 为
<span className="font-mono"> start.bat</span>)启动 ARTEX;直接运行 artex 本体时,程序退出后不会被自动拉起。
</p>
</CardContent>
</Card>
);
}
@@ -15,6 +15,8 @@ import { api } from "@/lib/api";
import { CHAT_SEND_MODE_OPTIONS, type ChatSendMode, setChatSendMode, useChatSendMode } from "@/lib/chat-send-mode"; import { CHAT_SEND_MODE_OPTIONS, type ChatSendMode, setChatSendMode, useChatSendMode } from "@/lib/chat-send-mode";
import type { Settings } from "@/lib/types"; import type { Settings } from "@/lib/types";
import { UpdateCard } from "./_components/update-card";
export default function SystemSettingsPage() { export default function SystemSettingsPage() {
const [trafficCapture, setTrafficCapture] = React.useState(false); const [trafficCapture, setTrafficCapture] = React.useState(false);
const [agentTrafficBinding, setAgentTrafficBinding] = React.useState(false); const [agentTrafficBinding, setAgentTrafficBinding] = React.useState(false);
@@ -247,6 +249,8 @@ export default function SystemSettingsPage() {
多列则自动按内容高度平衡填充。卡片间距靠 mb 而非 gap——多列布局下 多列则自动按内容高度平衡填充。卡片间距靠 mb 而非 gap——多列布局下
column-gap 只管列间距,行间距要由子元素自己给。 */} column-gap 只管列间距,行间距要由子元素自己给。 */}
<div className="columns-1 gap-4 md:gap-6 lg:columns-2"> <div className="columns-1 gap-4 md:gap-6 lg:columns-2">
<UpdateCard />
<Card className="mb-4 break-inside-avoid md:mb-6"> <Card className="mb-4 break-inside-avoid md:mb-6">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">
+18 -2
View File
@@ -89,6 +89,7 @@ import type {
TrafficEvidenceRole, TrafficEvidenceRole,
TrafficHost, TrafficHost,
TrafficResp, TrafficResp,
UpdateCheck,
UsageStats, UsageStats,
WorkspaceFile, WorkspaceFile,
WorkspaceListing, WorkspaceListing,
@@ -619,11 +620,15 @@ export const api = {
// 删除漏洞:移除 findings 记录 + 来源探索节点(从发现列表/任务发现 Tab/探索图一并消失)。 // 删除漏洞:移除 findings 记录 + 来源探索节点(从发现列表/任务发现 Tab/探索图一并消失)。
deleteFinding: (id: string) => del<{ deleted: boolean; id: number }>(`/exploration/findings/${id}`), deleteFinding: (id: string) => del<{ deleted: boolean; id: number }>(`/exploration/findings/${id}`),
findingRetests: (id: string) => findingRetests: (id: string) =>
get<{ retests: FindingRetest[] }>(`/exploration/findings/${encodeURIComponent(id)}/retests`).then((r) => arr(r.retests)), get<{ retests: FindingRetest[] }>(`/exploration/findings/${encodeURIComponent(id)}/retests`).then((r) =>
arr(r.retests),
),
activeFindingRetests: () => activeFindingRetests: () =>
get<{ retests: ActiveFindingRetest[] }>("/exploration/findings/retests/active").then((r) => arr(r.retests)), get<{ retests: ActiveFindingRetest[] }>("/exploration/findings/retests/active").then((r) => arr(r.retests)),
startFindingRetest: (id: string, notes: string) => startFindingRetest: (id: string, notes: string) =>
post<{ retest: FindingRetest; created: boolean }>(`/exploration/findings/${encodeURIComponent(id)}/retests`, { notes }), post<{ retest: FindingRetest; created: boolean }>(`/exploration/findings/${encodeURIComponent(id)}/retests`, {
notes,
}),
deepenFinding: (id: string, description: string) => deepenFinding: (id: string, description: string) =>
post<FindingDeepenResponse>(`/exploration/findings/${id}/deepen`, { description }), post<FindingDeepenResponse>(`/exploration/findings/${id}/deepen`, { description }),
intents: (task?: string) => get<TaskNode[]>(`/exploration/intents${tq(task)}`).then(arr), intents: (task?: string) => get<TaskNode[]>(`/exploration/intents${tq(task)}`).then(arr),
@@ -1123,4 +1128,15 @@ export const api = {
// per-agent 绑定 / 轮询 / 中断消耗都覆盖)。 // per-agent 绑定 / 轮询 / 中断消耗都覆盖)。
tokensByModel: (task: string) => tokensByModel: (task: string) =>
get<{ models: ModelTokenStat[] }>(`/llm/records/by-model?task=${encodeURIComponent(task)}`), get<{ models: ModelTokenStat[] }>(`/llm/records/by-model?task=${encodeURIComponent(task)}`),
// ---- 一键更新 ----
// 检查以后端为准:下载是后端做的,浏览器能连 GitHub 而服务器连不上的情况很常见
// (服务器在内网、代理只配在浏览器上),那时点更新必然失败。
// 后端对 GitHub 的查询结果有 30 分钟缓存(未认证的 GitHub API 是 60 次/小时/IP,
// 顶栏每次整页加载都会查一次,不缓存会很快耗光配额)。force=true 强制回源,
// 留给用户显式点「检查更新」时用。
checkUpdate: (force = false) => get<UpdateCheck>(`/update/check${force ? "?force=1" : ""}`),
// 202 即返回,实际下载在后台跑,进度走 /api/update/stream。
applyUpdate: () => post<{ ok: boolean; target: string }>(`/update/apply`),
rollbackUpdate: () => post<{ ok: boolean }>(`/update/rollback`),
}; };
+44 -1
View File
@@ -1288,7 +1288,9 @@ export interface InterceptAudit {
input_digest: string; input_digest: string;
user_message: string; user_message: string;
user_truncated?: boolean; user_truncated?: boolean;
context: { kind: string; tool?: string; tool_use_id?: string; text: string; is_error?: boolean; truncated?: boolean }[] | null; context:
| { kind: string; tool?: string; tool_use_id?: string; text: string; is_error?: boolean; truncated?: boolean }[]
| null;
context_truncated?: boolean; context_truncated?: boolean;
captured_at: string; captured_at: string;
model_fallback?: boolean; model_fallback?: boolean;
@@ -1358,3 +1360,44 @@ export interface FindingTrafficDetail {
request: EvidenceBodyPreview; request: EvidenceBodyPreview;
response: EvidenceBodyPreview; response: EvidenceBodyPreview;
} }
/** GET /api/update/check —— 当前版本与 GitHub 最新正式版的比较结果。 */
export interface UpdateCheck {
/** 当前运行的版本;开发构建为 "dev" 或 git describe 的带后缀形式。 */
current: string;
/** 运行形态。docker 下换装只作用于容器可写层,重建容器会退回镜像版本。 */
mode: "docker" | "binary";
os: string;
arch: string;
repo: string;
/** 是否存在可回滚的上一版本(artex.old)。 */
has_backup: boolean;
/** 本次启动时自更新自举的结论(换装失败 / 已回滚等),无事发生时为空。 */
boot_notice?: string;
rolled_back?: boolean;
/** 查询 GitHub 失败时给出原因,此时下面的字段都不会有。 */
error?: string;
latest?: string;
notes?: string;
html_url?: string;
published_at?: string;
/** 当前平台对应的发布包名,以及该 Release 是否真的带了它。 */
asset?: string;
asset_available?: boolean;
size?: number;
has_update?: boolean;
/** 双方版本号是否可比较;开发构建为 false,此时禁用一键更新。 */
comparable?: boolean;
/** comparable 为 false 时的说明。 */
reason?: string;
}
/** /api/update/stream 推送的一条更新进度。 */
export interface UpdateProgress {
phase: "idle" | "downloading" | "verifying" | "extracting" | "staged" | "failed";
/** 仅下载阶段有意义(0-100);其余阶段为 -1。 */
percent: number;
message: string;
version?: string;
error?: string;
}