仰望星辰工作室

clearlove2.1

clearlove2.1/ internal/handlers/selfupdate.go 8.3 KB · 275 行 原始文件
1// 一键自更新(仅 Linux x86_64):
2// 管理员在后台「检查更新」页点击"立即更新",程序从 update_url 指向的云端 JSON
3// 获取版本信息,下载 linux-amd64 二进制,校验 SHA256 与 ELF 头后原子替换自身,
4// 最后通过 syscall.Exec 原地重启(同 PID,外部守护进程无感知)。
5//
6// 云端 JSON 契约(兼容旧版检查更新的 version/notes/download 字段):
7//
8// {
9// "version": "2.0.1",
10// "notes": "更新说明",
11// "packages": {
12// "linux-amd64": { "url": "二进制直链", "sha256": "…", "size": 21823488 }
13// }
14// }
15//
16// 未提供 packages 时回退使用顶层 download + sha256;缺少 sha256 时拒绝自动更新。
17package handlers
18
19import (
20 "crypto/sha256"
21 "encoding/binary"
22 "encoding/hex"
23 "encoding/json"
24 "errors"
25 "fmt"
26 "io"
27 "net/http"
28 "os"
29 "path/filepath"
30 "runtime"
31 "strings"
32 "sync"
33 "time"
34
35 "clearlove/internal/config"
36 "clearlove/internal/models"
37 "clearlove/internal/util"
38)
39
40// selfUpdateMu 防止并发触发两次更新流程
41var selfUpdateMu sync.Mutex
42
43// maxSelfUpdateBytes 二进制下载上限
44const maxSelfUpdateBytes = 256 << 20
45
46// updatePackage 单平台下载条目
47type updatePackage struct {
48 URL string `json:"url"`
49 SHA256 string `json:"sha256"`
50 Size int64 `json:"size"`
51}
52
53// updateInfo 云端更新接口(update_url)返回的 JSON
54type updateInfo struct {
55 Version string `json:"version"`
56 Notes string `json:"notes"`
57 Download string `json:"download"`
58 SHA256 string `json:"sha256"`
59 Packages map[string]updatePackage `json:"packages"`
60}
61
62// AdminUpdateApply 执行一键更新(POST /admin/update/apply)
63func AdminUpdateApply(w http.ResponseWriter, r *http.Request) {
64 a := requireAdmin(w, r, models.PermSetting)
65 if a == nil {
66 return
67 }
68 if !selfUpdateMu.TryLock() {
69 fail(w, http.StatusTooManyRequests, "已有一次更新正在进行,请稍后再试")
70 return
71 }
72 defer selfUpdateMu.Unlock()
73 applySelfUpdate(w, r, a)
74}
75
76func applySelfUpdate(w http.ResponseWriter, r *http.Request, a *models.Admin) {
77 // 1) 平台门禁:本特性仅针对 Linux x86_64
78 if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" {
79 fail(w, http.StatusBadRequest, fmt.Sprintf("一键更新仅支持 Linux x86_64(当前 %s/%s),请手动替换二进制", runtime.GOOS, runtime.GOARCH))
80 return
81 }
82
83 // 2) 拉取并校验更新信息
84 info, err := fetchUpdateInfo()
85 if err != nil {
86 fail(w, http.StatusBadGateway, err.Error())
87 return
88 }
89 if info.Version == "" {
90 fail(w, http.StatusBadGateway, "更新接口未提供版本号")
91 return
92 }
93 if info.Version <= config.Version {
94 fail(w, http.StatusBadRequest, "当前已是最新版本(云端 "+info.Version+")")
95 return
96 }
97 dlURL, wantSum := linuxAMD64Package(info)
98 if dlURL == "" {
99 fail(w, http.StatusBadGateway, "更新接口未提供 linux-amd64 下载地址")
100 return
101 }
102 wantSum = strings.ToLower(strings.TrimSpace(wantSum))
103 if wantSum == "" {
104 fail(w, http.StatusBadGateway, "更新接口未提供 SHA256 校验值,为安全起见拒绝自动更新")
105 return
106 }
107
108 // 3) 下载到可执行文件同目录的临时文件(保证同分区,rename 原子生效)
109 exe, err := os.Executable()
110 if err != nil {
111 fail(w, http.StatusInternalServerError, "无法定位当前程序:"+err.Error())
112 return
113 }
114 tmp, err := os.CreateTemp(filepath.Dir(exe), ".clearlove-update-*.tmp")
115 if err != nil {
116 fail(w, http.StatusInternalServerError, "无法创建临时文件:"+err.Error())
117 return
118 }
119 tmpName := tmp.Name()
120 cleanup := func() { _ = tmp.Close(); _ = os.Remove(tmpName) }
121
122 client := &http.Client{Timeout: 10 * time.Minute}
123 resp, err := client.Get(dlURL)
124 if err != nil {
125 cleanup()
126 fail(w, http.StatusBadGateway, "下载失败:"+err.Error())
127 return
128 }
129 defer resp.Body.Close()
130 if resp.StatusCode != http.StatusOK {
131 cleanup()
132 fail(w, http.StatusBadGateway, "下载失败:云端返回 "+resp.Status)
133 return
134 }
135 written, err := io.Copy(tmp, io.LimitReader(resp.Body, maxSelfUpdateBytes))
136 if cerr := tmp.Close(); err == nil {
137 err = cerr
138 }
139 if err != nil || written <= 0 {
140 cleanup()
141 fail(w, http.StatusBadGateway, "下载失败:网络中断或内容为空")
142 return
143 }
144 if written >= maxSelfUpdateBytes {
145 cleanup()
146 fail(w, http.StatusBadGateway, "下载失败:文件超过大小上限")
147 return
148 }
149
150 // 4) SHA256 校验
151 sum, err := fileSHA256(tmpName)
152 if err != nil {
153 cleanup()
154 fail(w, http.StatusInternalServerError, "读取下载文件失败:"+err.Error())
155 return
156 }
157 if sum != wantSum {
158 cleanup()
159 fail(w, http.StatusBadRequest, "SHA256 校验失败,云端文件与校验值不一致(已放弃更新)")
160 return
161 }
162
163 // 5) ELF 平台校验(防止云端误传其它平台的文件)
164 if err := validLinuxAMD64ELF(tmpName); err != nil {
165 cleanup()
166 fail(w, http.StatusBadRequest, err.Error())
167 return
168 }
169
170 // 6) 补执行权限
171 if err := os.Chmod(tmpName, 0o755); err != nil {
172 cleanup()
173 fail(w, http.StatusInternalServerError, "设置执行权限失败:"+err.Error())
174 return
175 }
176
177 // 7) 原子替换:旧程序留作 .bak 便于回滚
178 bak := exe + ".bak"
179 _ = os.Remove(bak)
180 if err := os.Rename(exe, bak); err != nil {
181 cleanup()
182 fail(w, http.StatusInternalServerError, "备份当前程序失败:"+err.Error())
183 return
184 }
185 if err := os.Rename(tmpName, exe); err != nil {
186 _ = os.Rename(bak, exe)
187 fail(w, http.StatusInternalServerError, "替换程序失败(已回滚):"+err.Error())
188 return
189 }
190 util.Log("info", "管理员 %s(#%d)执行一键更新:%s -> %s", a.Username, a.ID, config.Version, info.Version)
191
192 // 8) 先应答,再原地换载新二进制(syscall.Exec,同 PID)
193 okJSON(w, map[string]any{
194 "msg": "更新完成(" + config.Version + " → " + info.Version + "),程序正在重启,页面稍后自动刷新",
195 "version": info.Version,
196 })
197 if f, ok := w.(http.Flusher); ok {
198 f.Flush()
199 }
200 time.Sleep(500 * time.Millisecond)
201 if err := restartSelf(exe); err != nil {
202 // 新二进制已就位但重启失败:保留新文件,等服务下次重启时生效
203 util.Log("error", "自动重启失败,新版本已就位,请手动重启服务:%v", err)
204 }
205}
206
207// fetchUpdateInfo 请求 update_url 获取更新信息
208func fetchUpdateInfo() (*updateInfo, error) {
209 url := strings.TrimSpace(models.GetSetting("update_url"))
210 if url == "" {
211 return nil, errors.New("未配置更新接口地址(网站设置 → 更新接口地址)")
212 }
213 client := &http.Client{Timeout: 15 * time.Second}
214 resp, err := client.Get(url)
215 if err != nil {
216 return nil, errors.New("无法连接更新服务器")
217 }
218 defer resp.Body.Close()
219 if resp.StatusCode != http.StatusOK {
220 return nil, fmt.Errorf("更新服务器返回 %s", resp.Status)
221 }
222 var info updateInfo
223 if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&info); err != nil {
224 return nil, errors.New("更新接口返回内容不是有效的 JSON")
225 }
226 return &info, nil
227}
228
229// linuxAMD64Package 取 linux-amd64 的下载地址与校验值;无 packages 时回退顶层字段
230func linuxAMD64Package(info *updateInfo) (url, sha string) {
231 if info.Packages != nil {
232 if p, ok := info.Packages["linux-amd64"]; ok {
233 return p.URL, p.SHA256
234 }
235 }
236 return info.Download, info.SHA256
237}
238
239// fileSHA256 计算文件 SHA256(十六进制小写)
240func fileSHA256(path string) (string, error) {
241 f, err := os.Open(path)
242 if err != nil {
243 return "", err
244 }
245 defer f.Close()
246 h := sha256.New()
247 if _, err := io.Copy(h, f); err != nil {
248 return "", err
249 }
250 return hex.EncodeToString(h.Sum(nil)), nil
251}
252
253// validLinuxAMD64ELF 校验文件是 64 位 x86-64 ELF:
254// 魔数 \x7fELF、EI_CLASS=2(64 位)、e_machine=62(EM_X86_64)。
255func validLinuxAMD64ELF(path string) error {
256 f, err := os.Open(path)
257 if err != nil {
258 return err
259 }
260 defer f.Close()
261 head := make([]byte, 20)
262 if _, err := io.ReadFull(f, head); err != nil {
263 return errors.New("下载文件过小,不是有效的程序文件")
264 }
265 if head[0] != 0x7f || head[1] != 'E' || head[2] != 'L' || head[3] != 'F' {
266 return errors.New("下载文件不是 ELF 程序(云端是否误传了压缩包?)")
267 }
268 if head[4] != 2 {
269 return errors.New("下载文件不是 64 位 ELF")
270 }
271 if machine := binary.LittleEndian.Uint16(head[18:20]); machine != 62 {
272 return errors.New("下载文件不是 x86_64 架构的 ELF")
273 }
274 return nil
275}