diff --git a/.gitignore b/.gitignore index 3efb2c0..4738393 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,12 @@ Thumbs.db # 内嵌前端的单二进制产物 /artex +# 一键更新在可执行文件旁留下的中间产物(暂存件 / 备份 / 失败版本 / 升级标记) +/artex.new +/artex.new.sha256 +/artex.old +/artex.failed +/artex.upgrade.json /server/webui/dist/ /dist/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fba79f..c40f9ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,22 @@ - 修复 work 因**空转回合**提前中断的问题:模型有时会用一整轮只输出思考,既不给正文也不调工具,此时 harness 看到的是一次自然结束(`end_turn` 且无 `tool_use`),直接以 `completed` + 空总结收场——一条还没做完的意图就断在半路,表现为「任务正常结束但没有任何文字总结」。五层 LLM 重试一层都够不着它:它不是错误,同 provider 安全窗口重试的入口是"流失败",熔断在 `err == nil` 时直接判成功,意图重跑只认 `model_error`;SDK 的空响应重试也够不着,因为那层以"有没有 yield 过事件"判空,而思考增量本身就是事件。现在 work 的 Stop 钩子会识别这种回合并注入一条续跑指令,让模型带着已经产出的思考继续执行下一步——刻意不选"原样重发",因为这类空转通常由提示词与上下文形状决定,是稳定行为而非随机抽风,重发只会让模型再想一遍。 - 次数复用 LLM 页「重试与退避」里的**空响应重试次数**(两者都在回答"模型完成了却没产出实质内容",只是判据与手段不同),默认 2 次,填 `-1` 即关掉、退回原先"空转即收场"的行为。它是一条意图的**总量**上限而非连续次数:harness 自身已限死连续空转只推一次,只有真正发生过工具回合配额才刷新,所以这个数挡的是「工具 → 空转 → 推 → 工具 → 空转」这类循环把意图预算耗光。触发时日志记 `[work · #<意图>] 空转回合…注入续跑指令 (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 ### LLM diff --git a/Dockerfile b/Dockerfile index bf8a412..fa7da7f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,10 +32,14 @@ RUN npm install -g @playwright/mcp@latest @playwright/cli@latest playwright@late WORKDIR /app # 预编译好的对应架构二进制(dist/amd64/artex 或 dist/arm64/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/ # data/(SQLite + jwt.key)持久化点 VOLUME ["/app/data"] EXPOSE 8787 8788 -ENTRYPOINT ["/app/artex"] +ENTRYPOINT ["/app/start.sh"] CMD ["-addr", ":8787", "-proxy", ":8788"] diff --git a/README.md b/README.md index 075d390..c83c86c 100644 --- a/README.md +++ b/README.md @@ -103,13 +103,16 @@ docker compose up -d # 拉取 autumn27/artex 镜像 + postgres ### 方式三:下载预编译二进制(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 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 @@ -119,7 +122,7 @@ cd web && npm ci && npm run build:static && cd .. cp -r web/out server/webui/dist # 3) 编译(-tags embedui 才内嵌前端) CGO_ENABLED=0 go build -tags embedui -o artex ./cmd/artex -./artex +./start.sh ``` ### 方式五:构建跨平台 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` 与数据库。 -### 方式一:一键更新脚本(推荐) +### 方式一:页面一键更新(推荐) + +在 **系统配置** 页(侧边栏「系统配置」→ `/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 cd ARTEX @@ -156,7 +172,7 @@ cd ARTEX - **① Docker**:可指定目标镜像 tag(回车沿用 `.env` 的 `ARTEX_TAG`,缺省 `latest`)→ `docker compose pull` → `docker compose up -d`(换新镜像重启即自动迁移)。 - **② 本地**:重建前端静态产物 → 重新编译 `./artex`(完成后重启进程生效)。 -### 方式二:Docker Compose(手动) +### 方式三:Docker Compose(手动) ```bash cd ARTEX @@ -167,23 +183,23 @@ docker compose up -d artex # 换新镜像重启 → 自动迁移 schema docker image prune -f # 清理旧镜像(可选) ``` -### 方式三:预编译二进制(Releases) +### 方式四:预编译二进制(Releases) 到 [Releases](https://github.com/Autumn-27/ARTEX/releases) 下载新版本 zip,停掉旧进程后覆盖 `artex` 与 `skills/`(保留你的 `config.json` 与 `data/`),重启即可: ```bash cp -r <解压目录>/skills ./ && cp <解压目录>/artex ./ -./artex +./start.sh ``` -### 方式四:从源码编译 +### 方式五:从源码编译 ```bash git pull cd web && npm ci && npm run build:static && cd .. cp -r web/out server/webui/dist 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)。 -**常用参数**:`./artex -addr :8787 -proxy :8788`(`-addr` 前端+API,`-proxy` 流量录制代理)。 +**常用参数**:`./start.sh -addr :8787 -proxy :8788`(`-addr` 前端+API,`-proxy` 流量录制代理)。启动脚本会把参数原样透传给 `artex`。 --- diff --git a/build.sh b/build.sh index e8efac8..062bf79 100755 --- a/build.sh +++ b/build.sh @@ -173,6 +173,14 @@ package_binary() { rm -rf "$package_root" "$archive" mkdir -p "$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 config.example.json "$package_root/" if [ -f README.md ]; then cp README.md "$package_root/"; fi diff --git a/cmd/artex/main.go b/cmd/artex/main.go index cbc5b42..15d08e5 100644 --- a/cmd/artex/main.go +++ b/cmd/artex/main.go @@ -18,6 +18,7 @@ import ( "github.com/Autumn-27/artex/agent" "github.com/Autumn-27/artex/config" + "github.com/Autumn-27/artex/selfupdate" "github.com/Autumn-27/artex/server" ) @@ -41,7 +42,15 @@ func printBanner(addr string) { 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() { + os.Exit(run()) +} + +func run() int { var ( 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)") @@ -59,6 +68,16 @@ func main() { // page can show a live log stream. Do this first, to catch startup logs too. 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 // "config.json" — resolved against the CWD — is unambiguous). cfgPath := config.Path() @@ -82,6 +101,12 @@ func main() { } 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() if abs, err := filepath.Abs(skillDir); err == nil { 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...") shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _ = httpSrv.Shutdown(shutdownCtx) + return code } // shutdownContext deliberately does not derive from signalCtx. If it did, the diff --git a/selfupdate/bootstrap.go b/selfupdate/bootstrap.go new file mode 100644 index 0000000..6734513 --- /dev/null +++ b/selfupdate/bootstrap.go @@ -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 +} diff --git a/selfupdate/github.go b/selfupdate/github.go new file mode 100644 index 0000000..e3ea60e --- /dev/null +++ b/selfupdate/github.go @@ -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-<版本>--.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 +} diff --git a/selfupdate/selfupdate.go b/selfupdate/selfupdate.go new file mode 100644 index 0000000..9fa6182 --- /dev/null +++ b/selfupdate/selfupdate.go @@ -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(ab)。 +// 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") +} diff --git a/selfupdate/selfupdate_test.go b/selfupdate/selfupdate_test.go new file mode 100644 index 0000000..9e06d75 --- /dev/null +++ b/selfupdate/selfupdate_test.go @@ -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-<版本>--/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-<版本>--.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 不应影响任何文件") + } +} diff --git a/selfupdate/stage.go b/selfupdate/stage.go new file mode 100644 index 0000000..fe41aaf --- /dev/null +++ b/selfupdate/stage.go @@ -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) { + // 格式为 " "(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-<版本>--/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 +} diff --git a/server/server.go b/server/server.go index 9a87298..7db9486 100644 --- a/server/server.go +++ b/server/server.go @@ -750,6 +750,13 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/logs/history", s.getLogsHistory) 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("POST /api/tasks", s.createTask) mux.HandleFunc("GET /api/task-categories", s.pgListTaskCategories) diff --git a/server/update.go b/server/update.go new file mode 100644 index 0000000..4e42dc1 --- /dev/null +++ b/server/update.go @@ -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() + } + } +} diff --git a/server/update_test.go b/server/update_test.go new file mode 100644 index 0000000..8a2e088 --- /dev/null +++ b/server/update_test.go @@ -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) + } +} diff --git a/start.bat b/start.bat new file mode 100644 index 0000000..b891aa0 --- /dev/null +++ b/start.bat @@ -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 diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..08d34ef --- /dev/null +++ b/start.sh @@ -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 diff --git a/web/src/app/(main)/_components/main-content.tsx b/web/src/app/(main)/_components/main-content.tsx index 9d822f6..3d2afa8 100644 --- a/web/src/app/(main)/_components/main-content.tsx +++ b/web/src/app/(main)/_components/main-content.tsx @@ -14,6 +14,7 @@ import { AccountSwitcher } from "./sidebar/account-switcher"; import { LayoutControls } from "./sidebar/layout-controls"; import { SearchDialog } from "./sidebar/search-dialog"; import { ThemeSwitcher } from "./sidebar/theme-switcher"; +import { UpdateBadge } from "./update-badge"; // 任务详情页保持原样:它自带头部/Tabs 与内边距,这里不再叠加全局头部和 padding。 function isFullBleed(pathname: string) { @@ -64,6 +65,7 @@ export function MainContent({ children }: { children: ReactNode }) { {version && ( 版本 · {version} )} + diff --git a/web/src/app/(main)/_components/update-badge.tsx b/web/src/app/(main)/_components/update-badge.tsx new file mode 100644 index 0000000..c914abb --- /dev/null +++ b/web/src/app/(main)/_components/update-badge.tsx @@ -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 ( + + {/* 呼吸点:顶栏元素很多,纯文字容易被忽略,动效让它一眼可见。 */} + + + + + + 新版本 {latest} + 新版本 + + ); +} diff --git a/web/src/app/(main)/system/settings/_components/update-card.tsx b/web/src/app/(main)/system/settings/_components/update-card.tsx new file mode 100644 index 0000000..ff83c65 --- /dev/null +++ b/web/src/app/(main)/system/settings/_components/update-card.tsx @@ -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(null); + const [checking, setChecking] = React.useState(true); + const [progress, setProgress] = React.useState(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 的注释)。 + + + + + 版本与更新 + + 从 GitHub 检查并安装新版本。更新会重启程序,正在运行的任务会被中断。 + + +
+ 当前版本 + + {info?.current ?? "…"} + + {info && ( + <> + + {info.os}/{info.arch} + + {info.mode === "docker" ? "Docker" : "独立程序"} + + )} + {info?.latest && ( + <> + 最新版本 + + {info.latest} + + + )} + {info?.html_url && ( + + 更新日志 + + )} +
+ + {info?.boot_notice && ( +

+ + {info.boot_notice} +

+ )} + + {info?.error && ( +

+ + 无法连接 GitHub:{info.error} + {" "}可在上方配置全局代理后重试。 +

+ )} + + {info && !info.comparable && info.reason &&

{info.reason}

} + + {info?.has_update && info.asset_available === false && ( +

+ + {info.latest} 没有提供 {info.os}/{info.arch} 的发布包(缺少 {info.asset}),无法自动更新。 +

+ )} + + {info?.has_update && info.asset_available !== false && ( +

+ 将下载 {info.asset} + {info.size ? `(${humanSize(info.size)})` : ""},校验 SHA256 并冒烟测试后才会替换,失败自动保留当前版本。 +

+ )} + + {info && !info.has_update && info.comparable && !info.error && ( +

+ + 当前已是最新版本。 +

+ )} + + {info?.mode === "docker" && info.has_update && ( +

+ Docker 下的更新只替换程序本身,不更新镜像里的 playwright / nmap 等工具链,且 + docker compose up -d + 重建容器后会退回镜像自带的版本。需要连镜像一起升级请执行 + docker compose pull artex && docker compose up -d artex。 +

+ )} + + {showProgress && ( +
+ +

+ {restarting ? "正在重启并应用新版本,请稍候(页面会自动刷新)…" : progress?.message} +

+
+ )} + +
+ + + {info?.has_backup && ( + + )} +
+ +

+ 一键更新依赖守护脚本重启程序。请通过 start.sh(Windows 为 + start.bat)启动 ARTEX;直接运行 artex 本体时,程序退出后不会被自动拉起。 +

+
+
+ ); +} diff --git a/web/src/app/(main)/system/settings/page.tsx b/web/src/app/(main)/system/settings/page.tsx index b555343..71e945f 100644 --- a/web/src/app/(main)/system/settings/page.tsx +++ b/web/src/app/(main)/system/settings/page.tsx @@ -15,6 +15,8 @@ import { api } from "@/lib/api"; import { CHAT_SEND_MODE_OPTIONS, type ChatSendMode, setChatSendMode, useChatSendMode } from "@/lib/chat-send-mode"; import type { Settings } from "@/lib/types"; +import { UpdateCard } from "./_components/update-card"; + export default function SystemSettingsPage() { const [trafficCapture, setTrafficCapture] = React.useState(false); const [agentTrafficBinding, setAgentTrafficBinding] = React.useState(false); @@ -247,6 +249,8 @@ export default function SystemSettingsPage() { 多列则自动按内容高度平衡填充。卡片间距靠 mb 而非 gap——多列布局下 column-gap 只管列间距,行间距要由子元素自己给。 */}
+ + diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index f8ae0ac..a5842ba 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -89,6 +89,7 @@ import type { TrafficEvidenceRole, TrafficHost, TrafficResp, + UpdateCheck, UsageStats, WorkspaceFile, WorkspaceListing, @@ -619,11 +620,15 @@ export const api = { // 删除漏洞:移除 findings 记录 + 来源探索节点(从发现列表/任务发现 Tab/探索图一并消失)。 deleteFinding: (id: string) => del<{ deleted: boolean; id: number }>(`/exploration/findings/${id}`), 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: () => get<{ retests: ActiveFindingRetest[] }>("/exploration/findings/retests/active").then((r) => arr(r.retests)), 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) => post(`/exploration/findings/${id}/deepen`, { description }), intents: (task?: string) => get(`/exploration/intents${tq(task)}`).then(arr), @@ -1123,4 +1128,15 @@ export const api = { // per-agent 绑定 / 轮询 / 中断消耗都覆盖)。 tokensByModel: (task: string) => get<{ models: ModelTokenStat[] }>(`/llm/records/by-model?task=${encodeURIComponent(task)}`), + + // ---- 一键更新 ---- + // 检查以后端为准:下载是后端做的,浏览器能连 GitHub 而服务器连不上的情况很常见 + // (服务器在内网、代理只配在浏览器上),那时点更新必然失败。 + // 后端对 GitHub 的查询结果有 30 分钟缓存(未认证的 GitHub API 是 60 次/小时/IP, + // 顶栏每次整页加载都会查一次,不缓存会很快耗光配额)。force=true 强制回源, + // 留给用户显式点「检查更新」时用。 + checkUpdate: (force = false) => get(`/update/check${force ? "?force=1" : ""}`), + // 202 即返回,实际下载在后台跑,进度走 /api/update/stream。 + applyUpdate: () => post<{ ok: boolean; target: string }>(`/update/apply`), + rollbackUpdate: () => post<{ ok: boolean }>(`/update/rollback`), }; diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index d5cf7d1..626431f 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -1288,7 +1288,9 @@ export interface InterceptAudit { input_digest: string; user_message: string; 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; captured_at: string; model_fallback?: boolean; @@ -1358,3 +1360,44 @@ export interface FindingTrafficDetail { request: 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; +}