From 7d2465ec898c3096012f2c10739a836ea08d09aa Mon Sep 17 00:00:00 2001
From: Sycun <165354365+Sycun@users.noreply.github.com>
Date: Fri, 13 Nov 2026 10:05:42 +0800
Subject: [PATCH] fix(update): reload after confirming the accepted update is
running
(cherry picked from commit aa7a533497a6b524fbd4d7d330ca06b04414e2fb)
---
cmd/server/main.go | 3 +-
internal/app/app.go | 4 +-
internal/app/update_restart_other.go | 4 +-
internal/app/update_restart_unix.go | 19 ++-
internal/app/update_restart_unix_test.go | 7 +-
internal/handler/update.go | 61 +++++---
internal/handler/update_http_test.go | 6 +-
web/static/i18n/ar-SA.json | 2 +
web/static/i18n/en-US.json | 2 +
web/static/i18n/ru-RU.json | 2 +
web/static/i18n/zh-CN.json | 2 +
web/static/js/update-ui.test.cjs | 72 +++++++--
web/static/js/update.js | 177 +++++++++++++++--------
web/templates/index.html | 2 +-
14 files changed, 257 insertions(+), 106 deletions(-)
diff --git a/cmd/server/main.go b/cmd/server/main.go
index 1c4d2e4a..3c78bd75 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -22,6 +22,7 @@ import (
)
func main() {
+ updateActivation := app.TakeUpdateActivation()
updateSessions := app.TakeUpdateSessionHandoff()
var configPath = flag.String("config", "config.yaml", "Path to the configuration file")
var httpsBootstrap = flag.Bool("https", false, "Enable HTTPS for the main site; uses an in-memory self-signed certificate when no cert/key is configured")
@@ -157,7 +158,7 @@ func main() {
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
// 创建应用
- application, err := app.New(cfg, log, cp)
+ application, err := app.New(cfg, log, cp, updateActivation)
if err != nil {
log.Fatal("应用初始化失败", "error", err)
}
diff --git a/internal/app/app.go b/internal/app/app.go
index 36de93a6..83aeae35 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -79,7 +79,7 @@ type App struct {
}
// New 创建新应用
-func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error) {
+func New(cfg *config.Config, log *logger.Logger, configPath, updateActivation string) (*App, error) {
toolGuard, err := toolguard.NewManager(cfg.EffectiveToolGuard())
if err != nil {
return nil, fmt.Errorf("初始化调用拦截规则: %w", err)
@@ -613,7 +613,7 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
})
updateHandler := handler.NewUpdateHandler(configDir, log.Logger, auditSvc,
- app.updateRestart(configDir), configHandler.UpdateSource, configHandler.SetUpdateSource)
+ app.updateRestart(configDir), updateActivation, configHandler.UpdateSource, configHandler.SetUpdateSource)
// 设置路由(使用 App 实例以便动态获取 handler)
setupRoutes(
diff --git a/internal/app/update_restart_other.go b/internal/app/update_restart_other.go
index dc621f5a..600285ed 100644
--- a/internal/app/update_restart_other.go
+++ b/internal/app/update_restart_other.go
@@ -6,4 +6,6 @@ import "os"
func TakeUpdateSessionHandoff() *os.File { return nil }
-func (a *App) updateRestart(string) func() (func(), error) { return nil }
+func TakeUpdateActivation() string { return "" }
+
+func (a *App) updateRestart(string) func(string) (func(), error) { return nil }
diff --git a/internal/app/update_restart_unix.go b/internal/app/update_restart_unix.go
index 94b31add..21a39f03 100644
--- a/internal/app/update_restart_unix.go
+++ b/internal/app/update_restart_unix.go
@@ -18,6 +18,19 @@ import (
)
const updateSessionsEnv = "CYBERSTRIKE_UPDATE_SESSIONS_FD"
+const updateActivationEnv = "CYBERSTRIKE_UPDATE_ACTIVATION"
+
+// TakeUpdateActivation identifies the update that launched this process.
+// A fresh start by a supervisor has no activation ID.
+func TakeUpdateActivation() string {
+ value := os.Getenv(updateActivationEnv)
+ _ = os.Unsetenv(updateActivationEnv)
+ pid, id, ok := strings.Cut(value, ":")
+ if !ok || pid != strconv.Itoa(os.Getpid()) {
+ return ""
+ }
+ return id
+}
// TakeUpdateSessionHandoff claims the descriptor left by an in-place exec.
// Do this before startup runs any subprocesses.
@@ -61,7 +74,7 @@ func writeUpdateSessionHandoff(payload []byte) (*os.File, error) {
// updateRestart only replaces the executable this instance was started from.
// For example, go run and installs with a separate config directory must not
// start a different executable just because the updater wrote one there.
-func (a *App) updateRestart(root string) func() (func(), error) {
+func (a *App) updateRestart(root string) func(string) (func(), error) {
dir, err := filepath.Abs(root)
if err != nil {
return nil
@@ -79,7 +92,7 @@ func (a *App) updateRestart(root string) func() (func(), error) {
if err != nil || self != path {
return nil
}
- return func() (func(), error) {
+ return func(activationID string) (func(), error) {
info, err := os.Lstat(path)
if err != nil {
return nil, err
@@ -95,7 +108,7 @@ func (a *App) updateRestart(root string) func() (func(), error) {
// Keep the same PID, arguments, environment and working directory.
// This works both in a terminal and under an existing supervisor.
exec := func(payload []byte) error {
- env := os.Environ()
+ env := append(os.Environ(), fmt.Sprintf("%s=%d:%s", updateActivationEnv, os.Getpid(), activationID))
if len(payload) != 0 {
f, err := writeUpdateSessionHandoff(payload)
if err == nil {
diff --git a/internal/app/update_restart_unix_test.go b/internal/app/update_restart_unix_test.go
index 1dbcdf55..62a307d7 100644
--- a/internal/app/update_restart_unix_test.go
+++ b/internal/app/update_restart_unix_test.go
@@ -105,6 +105,9 @@ func TestUpdateSessionHandoffChild(t *testing.T) {
t.Fatal(err)
}
if token := os.Getenv("CSAI_RESTART_TEST_TOKEN"); token != "" {
+ if got := TakeUpdateActivation(); got != "upd-session-test" || os.Getenv(updateActivationEnv) != "" {
+ t.Fatalf("replacement did not consume the update activation: %q", got)
+ }
if handoff == nil || os.Getenv(updateSessionsEnv) != "" {
t.Fatal("replacement did not claim the private handoff")
}
@@ -142,7 +145,7 @@ func TestUpdateSessionHandoffChild(t *testing.T) {
if err := os.Rename("next", update.Binary); err != nil {
t.Fatal(err)
}
- restart, err := prepare()
+ restart, err := prepare("upd-session-test")
if err != nil {
t.Fatal(err)
}
@@ -162,7 +165,7 @@ func TestUpdateRestartChild(t *testing.T) {
if err := os.Rename("next", update.Binary); err != nil {
t.Fatal(err)
}
- restart, err := prepare()
+ restart, err := prepare("upd-standalone-test")
if err != nil {
t.Fatal(err)
}
diff --git a/internal/handler/update.go b/internal/handler/update.go
index 657be816..4e15f8e5 100644
--- a/internal/handler/update.go
+++ b/internal/handler/update.go
@@ -7,6 +7,7 @@ import (
"fmt"
"net/http"
"os"
+ "runtime/debug"
"strings"
"sync"
"time"
@@ -34,8 +35,10 @@ type UpdateHandler struct {
logger *zap.Logger
audit *audit.Service
// prepareRestart validates the replacement before the running service shuts down.
- prepareRestart func() (func(), error)
+ prepareRestart func(activationID string) (func(), error)
bootID string
+ activationID string
+ buildRevision string
// source reports the configured update source (config.yaml update.repo) as the
// operator last saved it; an empty string means "not configured" and the official
// repository is used. A function rather than a value because the config can change
@@ -82,14 +85,25 @@ func (j *updateJob) view() *updateJob {
// NewUpdateHandler takes the audit service as a constructor argument rather than through
// a SetAudit the assembly has to remember: forgetting here is a compile error instead of
// a privileged endpoint that writes no audit records at all.
-func NewUpdateHandler(root string, logger *zap.Logger, auditSvc *audit.Service, prepareRestart func() (func(), error),
+func NewUpdateHandler(root string, logger *zap.Logger, auditSvc *audit.Service, prepareRestart func(string) (func(), error), activationID string,
source func() string, saveSource func(repo string) error) *UpdateHandler {
+ var revision string
+ if info, ok := debug.ReadBuildInfo(); ok {
+ for _, setting := range info.Settings {
+ if setting.Key == "vcs.revision" {
+ revision = setting.Value
+ break
+ }
+ }
+ }
return &UpdateHandler{
root: root,
logger: logger,
audit: auditSvc,
prepareRestart: prepareRestart,
bootID: uuid.NewString(),
+ activationID: activationID,
+ buildRevision: revision,
source: source,
saveSource: saveSource,
baseline: update.StampBinary(root, update.Options{Root: root}),
@@ -145,6 +159,7 @@ func (h *UpdateHandler) sourceView() gin.H {
// GetStatus answers GET /api/system/update: the install tree as it is on disk, without a
// network round trip, so opening the page is never gated on GitHub being reachable.
func (h *UpdateHandler) GetStatus(c *gin.Context) {
+ c.Header("Cache-Control", "no-store")
snap, err := update.Status(c.Request.Context(), h.options())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -152,15 +167,17 @@ func (h *UpdateHandler) GetStatus(c *gin.Context) {
}
needsRestart, binaryBuiltAt := h.restartPending()
c.JSON(http.StatusOK, gin.H{
- "status": snap,
- "job": h.latest(),
- "canRestart": h.prepareRestart != nil,
- "autoRestart": h.prepareRestart != nil,
- "bootID": h.bootID,
- "supervised": restartSupervised(),
- "needsRestart": needsRestart,
- "binaryBuiltAt": binaryBuiltAt,
- "source": h.sourceView(),
+ "status": snap,
+ "job": h.latest(),
+ "canRestart": h.prepareRestart != nil,
+ "autoRestart": h.prepareRestart != nil,
+ "bootID": h.bootID,
+ "buildRevision": h.buildRevision,
+ "activationTracking": true,
+ "supervised": restartSupervised(),
+ "needsRestart": needsRestart,
+ "binaryBuiltAt": binaryBuiltAt,
+ "source": h.sourceView(),
})
}
@@ -168,6 +185,12 @@ func (h *UpdateHandler) GetStatus(c *gin.Context) {
// a new boot from a revoked or expired session on the current boot.
func (h *UpdateHandler) SetBootHeader(c *gin.Context) {
c.Header("X-CyberStrike-Boot-ID", h.bootID)
+ c.Header("X-CyberStrike-Build-Revision", h.buildRevision)
+ activationID := h.activationID
+ if activationID == "" {
+ activationID = "-"
+ }
+ c.Header("X-CyberStrike-Activation-ID", activationID)
c.Header("Cache-Control", "no-store")
}
@@ -260,11 +283,11 @@ func (h *UpdateHandler) Adopt(c *gin.Context) {
}
job, started := h.startJob("adopt", body.Restart)
if !started {
- c.JSON(http.StatusConflict, gin.H{"error": "已有一次更新在进行中", "job": job})
+ c.JSON(http.StatusConflict, gin.H{"error": "已有一次更新在进行中", "job": job, "bootID": h.bootID})
return
}
h.record(c, "update.adopt", "started", "开始把目录接入更新源", map[string]interface{}{"job": job.ID, "restart": body.Restart})
- c.JSON(http.StatusAccepted, gin.H{"job_id": job.ID, "state": job.State})
+ c.JSON(http.StatusAccepted, gin.H{"job_id": job.ID, "state": job.State, "bootID": h.bootID})
}
// Apply answers POST /api/system/update/apply. It starts one job and returns immediately;
@@ -287,12 +310,12 @@ func (h *UpdateHandler) Apply(c *gin.Context) {
job, started := h.startJob("apply", body.Restart)
if !started {
- c.JSON(http.StatusConflict, gin.H{"error": "已有一次更新在进行中", "job": job})
+ c.JSON(http.StatusConflict, gin.H{"error": "已有一次更新在进行中", "job": job, "bootID": h.bootID})
return
}
h.record(c, "update.apply", "started", "开始一键更新源码并重编译", map[string]interface{}{"job": job.ID, "restart": body.Restart})
- c.JSON(http.StatusAccepted, gin.H{"job_id": job.ID, "state": job.State})
+ c.JSON(http.StatusAccepted, gin.H{"job_id": job.ID, "state": job.State, "bootID": h.bootID})
}
// Restart answers POST /api/system/update/restart: replace this process so the binary
@@ -327,14 +350,15 @@ func (h *UpdateHandler) Restart(c *gin.Context) {
c.JSON(http.StatusConflict, gin.H{"error": "磁盘上的二进制不存在,无法重启", "reason": "no_binary"})
return
}
- restart, err := h.prepareRestart()
+ activationID := uuid.NewString()
+ restart, err := h.prepareRestart(activationID)
if err != nil {
failure := &update.Error{Reason: "restart_unavailable", Message: "无法启动磁盘上的二进制:" + err.Error()}
c.JSON(statusForError(failure), errorBody(failure))
return
}
h.record(c, "update.restart", "started", "重启安装以运行磁盘上的新二进制", nil)
- c.JSON(http.StatusAccepted, gin.H{"restarting": true})
+ c.JSON(http.StatusAccepted, gin.H{"restarting": true, "activation_id": activationID, "bootID": h.bootID})
scheduled = true
time.AfterFunc(updateRestartDelay, func() { defer release(); restart() })
}
@@ -342,6 +366,7 @@ func (h *UpdateHandler) Restart(c *gin.Context) {
// Job answers GET /api/system/update/job: the running job, or the most recent one, with
// every progress line so far.
func (h *UpdateHandler) Job(c *gin.Context) {
+ c.Header("Cache-Control", "no-store")
c.JSON(http.StatusOK, gin.H{"job": h.latest()})
}
@@ -429,7 +454,7 @@ func (h *UpdateHandler) startJob(kind string, restart bool) (*updateJob, bool) {
if job.Restart && finish == "succeeded" && res != nil && res.BinaryBuilt && h.prepareRestart != nil {
release, lockErr := update.LockInstall(opts)
if lockErr == nil {
- restart, prepareErr := h.prepareRestart()
+ restart, prepareErr := h.prepareRestart(job.ID)
if prepareErr == nil {
h.mu.Unlock()
// Keep both CLI and HTTP writers out until the old executable is replaced.
diff --git a/internal/handler/update_http_test.go b/internal/handler/update_http_test.go
index 4a164be6..478ff765 100644
--- a/internal/handler/update_http_test.go
+++ b/internal/handler/update_http_test.go
@@ -36,11 +36,11 @@ func newUpdateRouter(root string, restart func()) (*gin.Engine, *UpdateHandler)
func newUpdateRouterWithSource(root string, restart func(),
source func() string, save func(string) error) (*gin.Engine, *UpdateHandler) {
gin.SetMode(gin.TestMode)
- var prepare func() (func(), error)
+ var prepare func(string) (func(), error)
if restart != nil {
- prepare = func() (func(), error) { return restart, nil }
+ prepare = func(string) (func(), error) { return restart, nil }
}
- h := NewUpdateHandler(root, nil, nil, prepare, source, save)
+ h := NewUpdateHandler(root, nil, nil, prepare, "", source, save)
router := gin.New()
router.GET("/api/system/update", h.GetStatus)
router.GET("/api/system/update/job", h.Job)
diff --git a/web/static/i18n/ar-SA.json b/web/static/i18n/ar-SA.json
index 6f87e584..53d2ee59 100644
--- a/web/static/i18n/ar-SA.json
+++ b/web/static/i18n/ar-SA.json
@@ -112,6 +112,7 @@
"rollbackTitle": "التراجع",
"root": "دليل التثبيت",
"branch": "الفرع المحلّي",
+ "runningBuild": "البنية قيد التشغيل",
"commit": "الإيداع الحالي",
"committedAt": "أُودِع في",
"subject": "موضوع الإيداع",
@@ -180,6 +181,7 @@
"restartOverlayBody": "ستُعاد الصفحة تلقائيًا عندما يصبح الإصدار الجديد جاهزًا.",
"restartOverlaySlowRunning": "لا تزال الخدمة تستجيب بوصفها العملية القديمة - يبدو أنّ إعادة التشغيل لم تحدث. استخدم الزرّ أدناه لمعاينة الحالة الحالية.",
"restartOverlaySlowDown": "لم تعد الخدمة بعد. ستواصل الصفحة المحاولة. راجع سجلّات الخدمة إذا استمر الانقطاع.",
+ "restartOverlayUnexpected": "عادت الخدمة، لكن لم يُؤكّد تثبيت الإصدار المستهدف. ستستمر الصفحة في التحقق، ويمكنك تحديثها لمعرفة الإصدار الحالي.",
"restartOverlayRefresh": "تحديث هذه الصفحة",
"failureNoMessage": "لم يقدّم الخادم أيّ رسالة فشل",
"resultTitle": "نتيجة التحديث",
diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json
index d225707f..651c1ad4 100644
--- a/web/static/i18n/en-US.json
+++ b/web/static/i18n/en-US.json
@@ -112,6 +112,7 @@
"rollbackTitle": "Rollback",
"root": "Install directory",
"branch": "Local branch",
+ "runningBuild": "Running build",
"commit": "Current commit",
"committedAt": "Committed at",
"subject": "Commit subject",
@@ -180,6 +181,7 @@
"restartOverlayBody": "This page will reload automatically when the new version is ready.",
"restartOverlaySlowRunning": "The service is still answering as the old process - the restart does not seem to have happened. Use the button below to look at the current state.",
"restartOverlaySlowDown": "The service is taking longer to come back. This page will keep trying. Check the service logs if it remains unavailable.",
+ "restartOverlayUnexpected": "The service is back, but this update has not been confirmed. This page will keep checking. You can also refresh to see the current version.",
"restartOverlayRefresh": "Refresh this page",
"failureNoMessage": "The server gave no failure message",
"resultTitle": "Update result",
diff --git a/web/static/i18n/ru-RU.json b/web/static/i18n/ru-RU.json
index eb2fe9a1..cdfb2523 100644
--- a/web/static/i18n/ru-RU.json
+++ b/web/static/i18n/ru-RU.json
@@ -112,6 +112,7 @@
"rollbackTitle": "Откат",
"root": "Каталог установки",
"branch": "Локальная ветка",
+ "runningBuild": "Запущенная сборка",
"commit": "Текущий коммит",
"committedAt": "Дата коммита",
"subject": "Заголовок коммита",
@@ -180,6 +181,7 @@
"restartOverlayBody": "Когда новая версия будет готова, страница обновится автоматически.",
"restartOverlaySlowRunning": "Служба по-прежнему отвечает старым процессом — перезапуск, похоже, не произошёл. Нажмите кнопку ниже, чтобы увидеть текущее состояние.",
"restartOverlaySlowDown": "Служба пока недоступна. Страница продолжит попытки. Если служба долго не отвечает, проверьте её журналы.",
+ "restartOverlayUnexpected": "Служба снова доступна, но установка нужной сборки не подтверждена. Проверка продолжится. Текущую версию можно увидеть, обновив страницу.",
"restartOverlayRefresh": "Обновить страницу",
"failureNoMessage": "Сервер не передал сообщение об ошибке",
"resultTitle": "Результат обновления",
diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json
index 7f55a325..05374ddf 100644
--- a/web/static/i18n/zh-CN.json
+++ b/web/static/i18n/zh-CN.json
@@ -112,6 +112,7 @@
"rollbackTitle": "回滚",
"root": "安装目录",
"branch": "本机分支",
+ "runningBuild": "运行中的构建",
"commit": "当前提交",
"committedAt": "提交时间",
"subject": "提交说明",
@@ -180,6 +181,7 @@
"restartOverlayBody": "切换完成后,页面会自动恢复。",
"restartOverlaySlowRunning": "服务仍在以旧进程应答——重启似乎没有发生。可点下方按钮刷新页面查看当前状态。",
"restartOverlaySlowDown": "服务仍未恢复,页面会继续重试。如果长时间无法连接,请检查服务日志。",
+ "restartOverlayUnexpected": "服务已恢复,但未能确认本次更新的目标构建已生效。页面会继续检查,也可以刷新查看当前版本。",
"restartOverlayRefresh": "立即刷新页面",
"failureNoMessage": "服务端没有给出失败说明",
"resultTitle": "更新结果",
diff --git a/web/static/js/update-ui.test.cjs b/web/static/js/update-ui.test.cjs
index 4c885bd3..2d90d74a 100644
--- a/web/static/js/update-ui.test.cjs
+++ b/web/static/js/update-ui.test.cjs
@@ -96,6 +96,7 @@ function envelope(status, job, canRestart, source, flags) {
supervised: !!(flags && flags.supervised),
autoRestart: flags?.autoRestart,
bootID: flags?.bootID,
+ activationTracking: flags?.activationTracking,
needsRestart: !!(flags && flags.needsRestart),
binaryBuiltAt: (flags && flags.binaryBuiltAt) || '',
source: source || emptySource,
@@ -316,7 +317,11 @@ function harness(options) {
if (typeof answer === 'object') {
return Promise.resolve({
status: answer.status,
- headers: { get: name => name === 'X-CyberStrike-Boot-ID' ? answer.bootID : null },
+ headers: { get: name => ({
+ 'X-CyberStrike-Boot-ID': answer.bootID,
+ 'X-CyberStrike-Activation-ID': answer.activationID,
+ 'X-CyberStrike-Build-Revision': answer.revision,
+ })[name] },
json: () => Promise.resolve(answer.body || {}),
});
}
@@ -407,8 +412,8 @@ function harness(options) {
},
setConfirm(v) { confirmAnswer = v; },
liveTimers() { return timers.filter(t => !t.cleared && !t.deferred); },
- tick() {
- const live = h.liveTimers();
+ tick(ms) {
+ const live = h.liveTimers().filter(t => ms === undefined || t.ms === ms);
if (!live.length) throw new Error('no poll timer is armed');
return live[live.length - 1].fn();
},
@@ -439,7 +444,7 @@ test('the console lives inside the settings page as its own section', () => {
'settings-section-update must live inside page-settings');
assert.match(template, /id="settings-section-update"[\s\S]{0,700}?id="update-console"/);
assert.match(template, /id="update-console"/);
- assert.match(template, /
-
+