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:
1 parent
557317491b
commit
44156a0a4c
22 files changed
+2619
-16
No files matched your search
@@ -36,6 +36,12 @@ Thumbs.db
|
||||
|
||||
# 内嵌前端的单二进制产物
|
||||
/artex
|
||||
# 一键更新在可执行文件旁留下的中间产物(暂存件 / 备份 / 失败版本 / 升级标记)
|
||||
/artex.new
|
||||
/artex.new.sha256
|
||||
/artex.old
|
||||
/artex.failed
|
||||
/artex.upgrade.json
|
||||
/server/webui/dist/
|
||||
/dist/
|
||||
|
||||
|
||||
@@ -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 <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
|
||||
|
||||
### LLM
|
||||
|
||||
+6
-2
@@ -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"]
|
||||
@@ -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`。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+37
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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 不应影响任何文件")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 { 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 && (
|
||||
<span className="font-medium text-muted-foreground text-xs tabular-nums">版本 · {version}</span>
|
||||
)}
|
||||
<UpdateBadge />
|
||||
<LayoutControls />
|
||||
<ThemeSwitcher />
|
||||
<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 && 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 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 只管列间距,行间距要由子元素自己给。 */}
|
||||
<div className="columns-1 gap-4 md:gap-6 lg:columns-2">
|
||||
<UpdateCard />
|
||||
|
||||
<Card className="mb-4 break-inside-avoid md:mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
|
||||
+18
-2
@@ -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<FindingDeepenResponse>(`/exploration/findings/${id}/deepen`, { description }),
|
||||
intents: (task?: string) => get<TaskNode[]>(`/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<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
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user