Merge pull request #391 from Sycun/codex/seamless-one-click-update

fix(update): activate new versions without interrupting sign-in
This commit is contained in:
公明 authored and GitHub committed 2026-11-12 18:29:49 +08:00
commit 13abffb1a2
27 files changed
+821 -370

No files matched your search

+5 -3
View File
@@ -1,9 +1,9 @@
name: Self-update
on:
push:
paths: ['internal/update/**', 'internal/workflow/**', 'internal/config/**', 'internal/handler/**', 'internal/mcp/**', 'cmd/server/**', 'web/static/js/**', 'web/static/css/style.css', 'web/templates/index.html', 'upgrade.sh', 'run.sh', 'go.mod', 'go.sum', '.gitignore', '.github/workflows/self-update.yml']
paths: ['internal/update/**', 'internal/app/update_restart*', 'internal/security/auth*', 'internal/workflow/**', 'internal/config/**', 'internal/handler/**', 'internal/mcp/**', 'cmd/server/**', 'web/static/js/**', 'web/static/i18n/**', 'web/static/css/style.css', 'web/templates/index.html', 'upgrade.sh', 'run.sh', 'go.mod', 'go.sum', '.gitignore', '.github/workflows/self-update.yml']
pull_request:
paths: ['internal/update/**', 'internal/workflow/**', 'internal/config/**', 'internal/handler/**', 'internal/mcp/**', 'cmd/server/**', 'web/static/js/**', 'web/static/css/style.css', 'web/templates/index.html', 'upgrade.sh', 'run.sh', 'go.mod', 'go.sum', '.gitignore', '.github/workflows/self-update.yml']
paths: ['internal/update/**', 'internal/app/update_restart*', 'internal/security/auth*', 'internal/workflow/**', 'internal/config/**', 'internal/handler/**', 'internal/mcp/**', 'cmd/server/**', 'web/static/js/**', 'web/static/i18n/**', 'web/static/css/style.css', 'web/templates/index.html', 'upgrade.sh', 'run.sh', 'go.mod', 'go.sum', '.gitignore', '.github/workflows/self-update.yml']
permissions:
contents: read
jobs:
@@ -23,7 +23,9 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '22'
- run: go test -race ./internal/update ./internal/config ./internal/handler ./cmd/server ./internal/workflow
- run: go test -race ./internal/update ./internal/app ./internal/config ./internal/handler ./cmd/server ./internal/workflow
- name: Authentication and update session handoff
run: go test -race ./internal/security -run '^(TestAuthManager|TestAttachRBACStore|TestRevokeRoleSessions|TestPublishSession|TestRestartHandoff)'
- run: go build ./...
- name: Frontend regression tests
shell: bash
+2
View File
@@ -22,6 +22,7 @@ import (
)
func main() {
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")
var httpBootstrap = flag.Bool("http", false, "Force plain HTTP for the main site, overriding TLS settings in the configuration file")
@@ -160,6 +161,7 @@ func main() {
if err != nil {
log.Fatal("应用初始化失败", "error", err)
}
application.RestoreUpdateSessions(updateSessions)
// 在后台监听信号
go func() {
+6 -5
View File
@@ -87,11 +87,12 @@ data/ config.yaml` - is put aside and restored, and the result names every file
is modified locally or the local history has diverged from the source it refuses and says why
instead of forcing. See [the developer guide](developer-guide.md) for the full semantics.
When a supervisor is detected (systemd/launchd startup markers in the environment), the "exit after
updating" tick is pre-ticked: the process stands down after a successful update, the supervisor brings
the new binary back, and the page reloads itself once the service answers (sessions live in memory, so
one fresh login follows). Forgot the tick, or swapped the binary from the CLI? The console carries a
persistent banner with **Restart now** - no shell login needed.
On macOS and Linux, when started from the `cyberstrike-ai` binary alongside its config, the console
defaults to switching to the new binary after a successful build. The old service remains available
during the build. Connections briefly drop at the switch and the page reloads when the new service
answers. Sign in again afterward because sessions live in memory. A supervisor is still useful for
starting at boot and recovering from crashes. If you defer the switch or replace the binary from the
CLI, the console offers **Restart now** on installations that support the switch.
The update source is a single repository address in the `update` section of `config.yaml` (`repo`),
or right on the console page: the official repository, your own fork, somebody else's repository or a
+12 -15
View File
@@ -48,7 +48,7 @@ disagree about why an update was refused.
| entry | how it is used |
|---|---|
| console | System settings -> One-click update (`#system-update`, the old deep link still works). Opening this section reads the local tree only and does not go online; **Check** is what performs the fetch; **Update** starts a job the page polls; the "exit after updating" tick is pre-ticked when a supervisor is detected (launchd/systemd startup markers), and while the process is down the page watches for its return and reloads itself (sessions live in memory, so a fresh login follows); a rollback button sits below |
| console | System settings -> One-click update (`#system-update`, the old deep link still works). **Update** starts a job the page polls. Supported installations switch to the newly built binary by default, and the page reloads once the service comes back. A rollback button sits below. |
| REST | `GET /api/system/update` (state on disk, no network), `POST /api/system/update/check` (fetch, then report the gap), `POST /api/system/update/apply` (`202` with `job_id`, polled through `GET /api/system/update/job`), `POST /api/system/update/rollback` |
| CLI | `./cyberstrike-ai -check-update`, `./cyberstrike-ai -update`, `./cyberstrike-ai -update-rollback`. The install root is the directory holding `--config`, or the current directory when `--config` was not given |
@@ -150,28 +150,25 @@ again after an interruption to finish recovery; the console reports `rollbackPen
updates. Keep the recovery record, backup directory and `.prev` file. If recorded binary hashes do not
match the files on disk, recovery refuses to overwrite them.
### The two restart cases
### Switching to the new binary
The process only stands down (graceful `Shutdown`, then exit 0) when the request explicitly says
`restart: true` **and** a restart hook was wired at startup. Whether it actually comes back is up to a
supervisor (systemd, `run.sh`), and the page says exactly that rather than promising a boot that may
not happen. Asking for a restart with no hook wired is a `400`, instead of stopping the service and
claiming it restarted.
On macOS and Linux, when started from the `cyberstrike-ai` binary alongside its config, the console
sends `restart: true` as soon as you click **One-Click Update**. After a successful build and binary swap, the service closes its
application resources and executes the new binary with the same PID, arguments, environment and
working directory. Installations that cannot switch in place return `400` on an explicit restart
request. CLI updates and REST updates without `restart: true` only replace files on disk.
**A pending binary is a durable state, and it can be activated later.** The status endpoint compares
the binary identity captured at startup (size + nanosecond mtime) with what is on disk now: after an
update, a rollback or a CLI run swapped the file without a restart, `needsRestart` is true,
`binaryBuiltAt` says when it was built, and the console grows a persistent banner with **Restart now**
(`POST /api/system/update/restart`; `409 nothing_pending` when there is nothing to activate, `409`
while a job runs, `400` without a hook). The `supervised` field comes from environment markers
(launchd's `XPC_SERVICE_NAME`, systemd's `INVOCATION_ID`/`JOURNAL_STREAM`) and only decides the tick's
default.
while a job runs, `400` when unavailable). The console checks `canRestart` before requesting automatic activation.
**And the page recovers by itself.** While the process is down the console becomes a recovery view
that probes the status endpoint every 2 seconds: a 200 means the old process is still answering (keep
waiting), a dead connection means it is on its way out, and a 401 from a process that no longer knows
the session is the proof the restart happened - the page then `location.replace`s itself back to
`#system-update`. Leaving the console stops the watching instead of yanking the user back.
**The page reloads automatically.** The console polls the session validation endpoint every two
seconds for the new boot marker. When both versions support session handoff, existing sessions keep
their original expiration and permissions are loaded from the current RBAC store. The first upgrade
from an older version may still require sign-in. Leaving the console stops the polling.
## Adding a Business Module
+1 -1
View File
@@ -46,7 +46,7 @@ Server `POST /api/auth/login` returns `{ token, expires_at }`. There is **no ref
| Local check | Re-check `expires_at` + `GET /api/auth/validate` every 30s |
| Server probe | Immediate probe when DevTools panel becomes visible |
| Unreachable | Show warning; keep token during transient outage |
| 401/403 | Clear token (server restart clears in-memory sessions) |
| 401/403 | Clear token and require re-authentication |
| Before Send | `ensureAuthReady()` before SSE |
| Permissions | `optional_host_permissions` — request origin on Validate |
+4 -3
View File
@@ -189,9 +189,10 @@ SQLite 热备份时最好先停止服务,或至少复制 `*.db`、`*.db-wal`
并被页面标出,装好 go 后再点一次「一键更新」即补编译。
细节见 [开发者指南](developer-guide.md) 的「一键更新」一节。
检测到 systemd/launchd(环境里有对应启动标记)时,"更新完成后退出进程"默认勾选:更新成功后进程退出、
由守护拉起新二进制,页面会在服务回来时自动刷新(会话在内存里,需要重新登录一次)。忘勾、或在命令行
换过二进制时,控制台顶部有常驻横幅和「立即重启服务」补上这一步——不必再登录服务器手动重启。
在 macOS/Linux 上,从配置文件同目录的 `cyberstrike-ai` 启动时,点击「一键更新」后,控制台在构建成功时自动切换到新二进制。
构建期间旧服务继续提供服务;切换时连接会短暂中断,页面在新服务应答后自动刷新并沿用仍然有效的登录会话。
这一步不依赖 systemd 或 launchd;长期部署仍建议用守护进程做开机自启和异常恢复。通过 API 或命令行换过
二进制后,可以在支持自动切换的安装上用控制台的「立即重启服务」启用磁盘上的版本。
更新源是 `config.yaml` `update` 段里的**一个仓库地址**(`repo`),也可以直接在控制台「一键更新」页填写保存:
想跟随官方仓库、自己的二开、别人的二开或镜像,改的都是这一处;不写就是官方仓库。**用 Release 包解压安装、
+8 -10
View File
@@ -54,7 +54,7 @@ go run ./cmd/server --config config.yaml
| 入口 | 怎么用 |
|---|---|
| 控制台 | 「系统设置 → 一键更新」(`#system-update` 老深链仍可用)。打开这一区只读本机状态、不联网;点「检查更新」才去 fetch;「一键更新」发起任务并轮询进度;"更新完成后退出进程"在检测到守护进程(launchd/systemd 的启动标记)时默认勾选,重启期间页面守着重连、新进程一应答就自动刷新(会话在内存里,刷新后需重新登录);另有回滚按钮 |
| 控制台 | 「系统设置 → 一键更新」(`#system-update` 老深链仍可用)。「一键更新」发起任务并轮询进度;支持自动切换的安装默认在构建成功后切到新二进制,页面在服务恢复后自动刷新;另有回滚按钮 |
| REST | `GET /api/system/update`(磁盘现状,不联网)、`POST /api/system/update/check`(fetch 后报告差集)、`POST /api/system/update/apply`(`202` 返回 `job_id`,用 `GET /api/system/update/job` 轮询)、`POST /api/system/update/rollback` |
| CLI | `./cyberstrike-ai -check-update`、`./cyberstrike-ai -update`、`./cyberstrike-ai -update-rollback`。安装目录 = `--config` 所在目录,未给 `--config` 时是当前目录 |
@@ -141,21 +141,19 @@ update:
`rollbackPending` 提示会暂停新更新。保留恢复记录、备份目录和 `.prev`,不要手动删除它们。
如果记录中的二进制哈希无法与现场对应,会拒绝覆盖并保留现场。
### 重启的两种情形
### 切换到新二进制
进程只有在请求显式带 `restart: true`、且启动时装配了重启钩子时才会优雅退出(`Shutdown` 后退出码 0)。
能不能被拉起来取决于外部守护(systemd、`run.sh`),页面如实这么写,而不是承诺一次可能不发生的启动;
没装配钩子时要求重启是 `400`,而不是先停掉服务再自称重启了。
macOS/Linux 上,从配置文件同目录的 `cyberstrike-ai` 启动时,点击「一键更新」直接发送 `restart: true`,无需二次确认。
只有成功构建并换入二进制才会切换:服务先关闭应用资源,再用相同 PID、启动参数、环境和工作目录执行新二进制。
不支持的启动方式不提供自动切换;REST 显式请求重启时返回 `400`。REST 不传该字段和 CLI 更新都只换磁盘文件。
**待生效状态是持久的、可补重启。** 状态接口对比"启动时记下的二进制身份(大小 + 纳秒 mtime)"与磁盘
现值:更新、回滚或 CLI 换过二进制而没重启时,`needsRestart` 为真、`binaryBuiltAt` 给出构建时间,
控制台顶部出现常驻横幅与「立即重启服务」(`POST /api/system/update/restart`;没有待生效版本时
`409 nothing_pending`、有任务在跑 `409`、没钩子 `400`)。`supervised` 字段来自环境标记(launchd 的
`XPC_SERVICE_NAME`、systemd 的 `INVOCATION_ID`/`JOURNAL_STREAM`),只用来决定勾选框的默认值。
`409 nothing_pending`、有任务在跑 `409`、不支持时 `400`)。控制台根据 `canRestart` 决定能否自动切换。
**重启后页面自己回来。** 重启期间控制台换成自恢复视图,每 2 秒探一次状态接口:旧进程还在应答(200)
就继续等;连不上说明正在退出;新进程接客但对旧会话只回 401——这就是"重启已完成"的判据,页面随即
`location.replace` 回 `#system-update` 整页刷新。离开控制台则静默停表,不把已经走开的用户拽回来。
**重启后页面自动刷新。** 控制台每 2 秒通过会话验证接口探测服务启动标记,收到新标记才刷新。
支持会话交接的两个版本之间切换时,有效登录保持原来的到期时间,权限从新进程的 RBAC 存储重新加载。首次从旧版升级时可能需要重新登录。离开控制台则停止探测。
## 数据库
+19 -9
View File
@@ -44,6 +44,7 @@ import (
// App 应用
type App struct {
shutdownOnce sync.Once
config *config.Config
logger *logger.Logger
router *gin.Engine
@@ -611,14 +612,8 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
return nil
})
// The one-click update acts on this installation's own tree, rooted where the live config
// file is. Its restart hook exits after a graceful shutdown, which is only "a restart" when
// something supervises the process - so the endpoint reports whether it is standing down or
// whether somebody has to start it again, instead of promising a boot that may not happen.
updateHandler := handler.NewUpdateHandler(configDir, log.Logger, auditSvc, func() {
app.Shutdown()
os.Exit(0)
}, configHandler.UpdateSource, configHandler.SetUpdateSource)
updateHandler := handler.NewUpdateHandler(configDir, log.Logger, auditSvc,
app.updateRestart(configDir), configHandler.UpdateSource, configHandler.SetUpdateSource)
// 设置路由(使用 App 实例以便动态获取 handler)
setupRoutes(
@@ -694,6 +689,17 @@ func (a *App) Run() error {
return a.RunWithContext(context.Background())
}
// RestoreUpdateSessions consumes the handoff before the new HTTP listener starts.
func (a *App) RestoreUpdateSessions(handoff *os.File) {
if handoff == nil {
return
}
defer handoff.Close()
if err := a.auth.RestoreRestartSessions(handoff); err != nil {
a.logger.Warn("无法恢复更新前的登录会话", zap.Error(err))
}
}
// RunWithContext 启动应用,支持通过 context 取消来优雅关闭
func (a *App) RunWithContext(ctx context.Context) error {
// 启动MCP服务器(如果启用)
@@ -802,6 +808,10 @@ func (a *App) RunWithContext(ctx context.Context) error {
// Shutdown 关闭应用
func (a *App) Shutdown() {
a.shutdownOnce.Do(a.shutdown)
}
func (a *App) shutdown() {
if a.agentHandler != nil {
a.agentHandler.ShutdownTasks()
}
@@ -970,7 +980,7 @@ func setupRoutes(
authRoutes.POST("/login", security.RateLimitMiddleware(loginRL), authHandler.Login)
authRoutes.POST("/logout", security.AuthMiddleware(authManager), authHandler.Logout)
authRoutes.POST("/change-password", security.AuthMiddleware(authManager), security.RequirePermission("auth:self"), authHandler.ChangePassword)
authRoutes.GET("/validate", security.AuthMiddleware(authManager), authHandler.Validate)
authRoutes.GET("/validate", updateHandler.SetBootHeader, security.AuthMiddleware(authManager), authHandler.Validate)
authRoutes.POST("/robot-binding-code", security.AuthMiddleware(authManager), security.RequirePermission("auth:self"), robotHandler.CreateRobotBindingCode)
authRoutes.GET("/robot-bindings", security.AuthMiddleware(authManager), security.RequirePermission("auth:self"), robotHandler.ListMyRobotBindings)
authRoutes.DELETE("/robot-bindings/:id", security.AuthMiddleware(authManager), security.RequirePermission("auth:self"), robotHandler.DeleteMyRobotBinding)
+9
View File
@@ -0,0 +1,9 @@
//go:build !darwin && !linux
package app
import "os"
func TakeUpdateSessionHandoff() *os.File { return nil }
func (a *App) updateRestart(string) func() (func(), error) { return nil }
+130
View File
@@ -0,0 +1,130 @@
//go:build darwin || linux
package app
import (
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"syscall"
"cyberstrike-ai/internal/update"
"go.uber.org/zap"
"golang.org/x/sys/unix"
)
const updateSessionsEnv = "CYBERSTRIKE_UPDATE_SESSIONS_FD"
// TakeUpdateSessionHandoff claims the descriptor left by an in-place exec.
// Do this before startup runs any subprocesses.
func TakeUpdateSessionHandoff() *os.File {
value := os.Getenv(updateSessionsEnv)
if value == "" {
return nil
}
_ = os.Unsetenv(updateSessionsEnv)
pid, rawFD, ok := strings.Cut(value, ":")
fd, err := strconv.Atoi(rawFD)
if !ok || pid != strconv.Itoa(os.Getpid()) || err != nil || fd < 3 {
return nil
}
var stat unix.Stat_t
if err := unix.Fstat(fd, &stat); err != nil || stat.Mode&unix.S_IFMT != unix.S_IFREG || stat.Nlink != 0 {
return nil
}
unix.CloseOnExec(fd)
return os.NewFile(uintptr(fd), "update-sessions")
}
func writeUpdateSessionHandoff(payload []byte) (*os.File, error) {
f, err := os.CreateTemp("", "cyberstrike-update-sessions-*")
if err != nil {
return nil, err
}
// The private file has no path by the time it contains session data.
if err = os.Remove(f.Name()); err == nil {
if _, err = f.Write(payload); err == nil {
_, err = f.Seek(0, io.SeekStart)
}
}
if err != nil {
_ = f.Close()
return nil, err
}
return f, nil
}
// 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) {
dir, err := filepath.Abs(root)
if err != nil {
return nil
}
dir, err = filepath.EvalSymlinks(dir)
if err != nil {
return nil
}
path := filepath.Join(dir, update.Binary)
self, err := os.Executable()
if err != nil {
return nil
}
self, err = filepath.EvalSymlinks(self)
if err != nil || self != path {
return nil
}
return func() (func(), error) {
info, err := os.Lstat(path)
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() {
return nil, fmt.Errorf("不是普通可执行文件:%s", path)
}
if err := unix.Access(path, unix.X_OK); err != nil {
return nil, err
}
return func() {
a.Shutdown()
// 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()
if len(payload) != 0 {
f, err := writeUpdateSessionHandoff(payload)
if err == nil {
fd := f.Fd()
// Prevent a concurrent subprocess from inheriting the descriptor.
syscall.ForkLock.Lock()
defer syscall.ForkLock.Unlock()
defer f.Close()
_, err = unix.FcntlInt(fd, unix.F_SETFD, 0)
if err == nil {
env = append(env, fmt.Sprintf("%s=%d:%d", updateSessionsEnv, os.Getpid(), fd))
return unix.Exec(path, os.Args, env)
}
}
a.logger.Warn("无法交接登录会话,继续切换版本", zap.Error(err))
}
return unix.Exec(path, os.Args, env)
}
var err error
if a.auth != nil {
err = a.auth.HandoffSessions(exec)
} else {
err = exec(nil)
}
if err != nil {
a.logger.Error("无法启动更新后的二进制", zap.Error(err))
_ = a.logger.Sync()
os.Exit(1)
}
}, nil
}
}
+171
View File
@@ -0,0 +1,171 @@
//go:build darwin || linux
package app
import (
"context"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/security"
"cyberstrike-ai/internal/update"
"go.uber.org/zap"
)
func copyRestartTestBinary(t *testing.T, binary string) {
t.Helper()
self, err := os.Executable()
if err != nil {
t.Fatal(err)
}
src, err := os.Open(self)
if err != nil {
t.Fatal(err)
}
defer src.Close()
dst, err := os.OpenFile(binary, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o755)
if err != nil {
t.Fatal(err)
}
_, copyErr := io.Copy(dst, src)
if closeErr := dst.Close(); closeErr != nil {
t.Fatal(closeErr)
}
if copyErr != nil {
t.Fatal(copyErr)
}
}
func TestStandaloneUpdateRestartExecsReplacement(t *testing.T) {
root := t.TempDir()
binary := filepath.Join(root, update.Binary)
copyRestartTestBinary(t, binary)
replacement := filepath.Join(root, "next")
if err := os.WriteFile(replacement, []byte("#!/bin/sh\nprintf 'replacement:%s:%s:%s:%s\\n' \"$$\" \"$PWD\" \"$CSAI_RESTART_TEST\" \"$1\"\n"), 0o755); err != nil {
t.Fatal(err)
}
canonicalRoot, err := filepath.EvalSymlinks(root)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, binary, "-test.run=^TestUpdateRestartChild$")
cmd.Dir = canonicalRoot
// No launchd/systemd markers or supervisor in this child.
cmd.Env = []string{"CSAI_RESTART_TEST=standalone"}
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("restart helper: %v\n%s", err, out)
}
want := fmt.Sprintf("replacement:%d:%s:standalone:-test.run=^TestUpdateRestartChild$", cmd.Process.Pid, canonicalRoot)
if !strings.Contains(string(out), want) {
t.Fatalf("new executable did not inherit PID, cwd, environment and arguments: %q", out)
}
}
func TestUpdateRestartExecsWithLiveSessions(t *testing.T) {
root := t.TempDir()
binary := filepath.Join(root, update.Binary)
copyRestartTestBinary(t, binary)
copyRestartTestBinary(t, filepath.Join(root, "next"))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, binary, "-test.run=^TestUpdateSessionHandoffChild$")
cmd.Dir = root
cmd.Env = []string{"CSAI_RESTART_TEST=handoff"}
out, err := cmd.CombinedOutput()
if err != nil || !strings.Contains(string(out), "session handoff verified") {
t.Fatalf("session handoff helper: %v\n%s", err, out)
}
}
func TestUpdateSessionHandoffChild(t *testing.T) {
if os.Getenv("CSAI_RESTART_TEST") != "handoff" {
return
}
handoff := TakeUpdateSessionHandoff()
db, err := database.NewDB("test.db", zap.NewNop())
if err != nil {
t.Fatal(err)
}
a := App{auth: security.NewAuthManager(12), db: db}
defer a.Shutdown()
password, err := a.auth.AttachRBACStore(db)
if err != nil {
t.Fatal(err)
}
if token := os.Getenv("CSAI_RESTART_TEST_TOKEN"); token != "" {
if handoff == nil || os.Getenv(updateSessionsEnv) != "" {
t.Fatal("replacement did not claim the private handoff")
}
a.RestoreUpdateSessions(handoff)
session, ok := a.auth.ValidateToken(token)
if !ok || session.Token != token || session.UserID != "admin" || !session.Permissions["update:apply"] ||
session.ExpiresAt.Format(time.RFC3339Nano) != os.Getenv("CSAI_RESTART_TEST_EXPIRY") {
t.Fatal("replacement lost login, permissions or original expiration")
}
if _, ok := a.auth.ValidateToken(os.Getenv("CSAI_RESTART_TEST_REVOKED")); ok {
t.Fatal("replacement accepted a logged-out session")
}
fmt.Println("session handoff verified")
return
}
token, expires, err := a.auth.Authenticate("admin", password)
if err != nil {
t.Fatal(err)
}
revoked, _, err := a.auth.Authenticate("admin", password)
if err != nil {
t.Fatal(err)
}
a.auth.RevokeToken(revoked)
// The test replacement needs the original client credentials to exercise validation.
t.Setenv("CSAI_RESTART_TEST_TOKEN", token)
t.Setenv("CSAI_RESTART_TEST_EXPIRY", expires.Format(time.RFC3339Nano))
t.Setenv("CSAI_RESTART_TEST_REVOKED", revoked)
prepare := a.updateRestart(".")
if prepare == nil {
t.Fatal("standalone install did not offer restart")
}
if err := os.Rename("next", update.Binary); err != nil {
t.Fatal(err)
}
restart, err := prepare()
if err != nil {
t.Fatal(err)
}
restart()
t.Fatal("exec returned instead of switching executables")
}
func TestUpdateRestartChild(t *testing.T) {
if os.Getenv("CSAI_RESTART_TEST") != "standalone" {
return
}
var app App
prepare := app.updateRestart(".")
if prepare == nil {
t.Fatal("standalone install did not offer restart")
}
if err := os.Rename("next", update.Binary); err != nil {
t.Fatal(err)
}
restart, err := prepare()
if err != nil {
t.Fatal(err)
}
restart()
t.Fatal("exec returned instead of switching executables")
}
+2 -2
View File
@@ -3457,7 +3457,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"type": "object",
"properties": map[string]interface{}{
"confirm": map[string]interface{}{"type": "boolean", "description": "false/缺省=只预览;true=确认执行"},
"restart": map[string]interface{}{"type": "boolean", "description": "接入成功后退出进程交给外部守护拉起,默认 false"},
"restart": map[string]interface{}{"type": "boolean", "description": "接入并构建成功后自动切换到新二进制,需要 canRestart,默认 false"},
},
},
},
@@ -6549,7 +6549,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"properties": map[string]interface{}{
"restart": map[string]interface{}{
"type": "boolean",
"description": "更新成功后优雅关闭并退出进程;只在有外部守护时才会被拉起,默认 false",
"description": "更新并构建成功后自动切换到新二进制,需要 canRestart,默认 false",
},
},
},
+66 -44
View File
@@ -12,6 +12,7 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
)
@@ -32,10 +33,9 @@ type UpdateHandler struct {
root string // the installation to update: the directory the config file lives in
logger *zap.Logger
audit *audit.Service
// restart is the operator's own shutdown hook. It is only ever called when the
// request asked for a restart, because an unsupervised process that exits stays
// exited - "I restarted you" would be a lie on most deployments.
restart func()
// prepareRestart validates the replacement before the running service shuts down.
prepareRestart func() (func(), error)
bootID 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
@@ -59,15 +59,16 @@ type UpdateHandler struct {
// returns a handle and the page polls it rather than sitting in one HTTP call until the
// compiler finishes.
type updateJob struct {
ID string `json:"id"`
Kind string `json:"kind"` // apply | adopt
State string `json:"state"` // running | succeeded | failed
Started string `json:"started"`
Finished string `json:"finished,omitempty"`
Steps []update.Step `json:"steps"`
Result *update.Result `json:"result,omitempty"`
Failure *update.Error `json:"failure,omitempty"`
Restart bool `json:"restartRequested"`
ID string `json:"id"`
Kind string `json:"kind"` // apply | adopt
State string `json:"state"` // running | succeeded | failed
Started string `json:"started"`
Finished string `json:"finished,omitempty"`
Steps []update.Step `json:"steps"`
Result *update.Result `json:"result,omitempty"`
Failure *update.Error `json:"failure,omitempty"`
Restart bool `json:"restartRequested"`
RestartError string `json:"restartError,omitempty"`
}
// view copies a job for transport, because its fields are written by the job's own
@@ -81,17 +82,18 @@ 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, restart func(),
func NewUpdateHandler(root string, logger *zap.Logger, auditSvc *audit.Service, prepareRestart func() (func(), error),
source func() string, saveSource func(repo string) error) *UpdateHandler {
return &UpdateHandler{
root: root,
logger: logger,
audit: auditSvc,
restart: restart,
source: source,
saveSource: saveSource,
baseline: update.StampBinary(root, update.Options{Root: root}),
jobs: map[string]*updateJob{},
root: root,
logger: logger,
audit: auditSvc,
prepareRestart: prepareRestart,
bootID: uuid.NewString(),
source: source,
saveSource: saveSource,
baseline: update.StampBinary(root, update.Options{Root: root}),
jobs: map[string]*updateJob{},
}
}
@@ -109,10 +111,8 @@ func (h *UpdateHandler) restartPending() (bool, string) {
return true, builtAt
}
// restartSupervised reports whether something outside this process is set up to bring it
// back: launchd (XPC_SERVICE_NAME) and systemd (INVOCATION_ID / JOURNAL_STREAM) both leave
// a marker in the environment. It is only the page's default for the restart tick - the
// operator's own checkbox remains the decision.
// restartSupervised is a legacy status hint. These environment markers do not
// prove that the supervisor is configured to restart the service.
func restartSupervised() bool {
return strings.TrimSpace(os.Getenv("XPC_SERVICE_NAME")) != "" ||
strings.TrimSpace(os.Getenv("INVOCATION_ID")) != "" ||
@@ -154,7 +154,9 @@ func (h *UpdateHandler) GetStatus(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": snap,
"job": h.latest(),
"canRestart": h.restart != nil,
"canRestart": h.prepareRestart != nil,
"autoRestart": h.prepareRestart != nil,
"bootID": h.bootID,
"supervised": restartSupervised(),
"needsRestart": needsRestart,
"binaryBuiltAt": binaryBuiltAt,
@@ -162,6 +164,13 @@ func (h *UpdateHandler) GetStatus(c *gin.Context) {
})
}
// SetBootHeader runs before auth on the validation route so a client can tell
// 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("Cache-Control", "no-store")
}
// Check answers POST /api/system/update/check: fetch the tracked branch and report the
// gap. A failed fetch is a 200 with checkError, not a 500 - "offline" and "bad token"
// are answers the operator needs to read, not server faults to debug.
@@ -235,7 +244,7 @@ func (h *UpdateHandler) Adopt(c *gin.Context) {
return
}
}
if body.Confirm && body.Restart && h.restart == nil {
if body.Confirm && body.Restart && h.prepareRestart == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "本次启动没有提供重启钩子,接入完成后请由外部进程管理器重启服务"})
return
}
@@ -271,7 +280,7 @@ func (h *UpdateHandler) Apply(c *gin.Context) {
return
}
}
if body.Restart && h.restart == nil {
if body.Restart && h.prepareRestart == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "本次启动没有提供重启钩子,更新完成后请由外部进程管理器重启服务"})
return
}
@@ -286,7 +295,7 @@ func (h *UpdateHandler) Apply(c *gin.Context) {
c.JSON(http.StatusAccepted, gin.H{"job_id": job.ID, "state": job.State})
}
// Restart answers POST /api/system/update/restart: stand this process down so the binary
// Restart answers POST /api/system/update/restart: replace this process so the binary
// already on disk - an update or a rollback that ran without one - becomes the thing
// answering requests. It refuses when there is nothing to activate, because a bounce that
// cannot change anything is a dropped service for no reason.
@@ -306,7 +315,7 @@ func (h *UpdateHandler) Restart(c *gin.Context) {
c.JSON(http.StatusConflict, gin.H{"error": "更新进行中,等它结束再重启", "job": busy})
return
}
if h.restart == nil {
if h.prepareRestart == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "本次启动没有提供重启钩子,请由外部进程管理器重启服务"})
return
}
@@ -318,10 +327,16 @@ func (h *UpdateHandler) Restart(c *gin.Context) {
c.JSON(http.StatusConflict, gin.H{"error": "磁盘上的二进制不存在,无法重启", "reason": "no_binary"})
return
}
restart, err := h.prepareRestart()
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})
scheduled = true
time.AfterFunc(updateRestartDelay, func() { defer release(); h.restart() })
time.AfterFunc(updateRestartDelay, func() { defer release(); restart() })
}
// Job answers GET /api/system/update/job: the running job, or the most recent one, with
@@ -411,22 +426,29 @@ func (h *UpdateHandler) startJob(kind string, restart bool) (*updateJob, bool) {
}
job.State = finish
job.Finished = time.Now().Format(time.RFC3339)
if job.Restart && finish == "succeeded" && res != nil && res.BinaryBuilt && h.restart != nil {
if job.Restart && finish == "succeeded" && res != nil && res.BinaryBuilt && h.prepareRestart != nil {
release, lockErr := update.LockInstall(opts)
if lockErr == nil {
h.mu.Unlock()
// Keep both CLI and HTTP writers out until the old process exits.
time.AfterFunc(updateRestartDelay, func() {
defer release()
defer func() { h.mu.Lock(); h.active = ""; h.mu.Unlock() }()
h.restart()
})
return
restart, prepareErr := h.prepareRestart()
if prepareErr == nil {
h.mu.Unlock()
// Keep both CLI and HTTP writers out until the old executable is replaced.
time.AfterFunc(updateRestartDelay, func() {
defer release()
defer func() { h.mu.Lock(); h.active = ""; h.mu.Unlock() }()
restart()
})
return
}
release()
job.RestartError = prepareErr.Error()
} else {
// Another writer acquired the lock after the build.
job.RestartError = lockErr.Error()
}
// Another process began an operation after the build. The binary is
// ready, but shutting down now would interrupt that operation.
job.Restart = false
}
// A completed job only reports restartRequested if it actually scheduled one.
job.Restart = false
h.active = ""
h.mu.Unlock()
}()
+7 -3
View File
@@ -36,7 +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)
h := NewUpdateHandler(root, nil, nil, restart, source, save)
var prepare func() (func(), error)
if restart != nil {
prepare = func() (func(), error) { return restart, nil }
}
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)
@@ -313,7 +317,7 @@ func TestUpdateStatusReportsAPendingBinaryAndSupervision(t *testing.T) {
t.Fatalf("replacing the binary must be reported as pending, with when it was built: %+v", after)
}
// Supervision is read from the markers launchd/systemd leave in the environment.
// Retain the supervision hint for older clients; it does not control autoRestart.
t.Setenv("XPC_SERVICE_NAME", "")
t.Setenv("INVOCATION_ID", "")
t.Setenv("JOURNAL_STREAM", "")
@@ -322,7 +326,7 @@ func TestUpdateStatusReportsAPendingBinaryAndSupervision(t *testing.T) {
}
t.Setenv("XPC_SERVICE_NAME", "com.example.thing")
if !read().Supervised {
t.Error("XPC_SERVICE_NAME is launchd's own marker; the page's default must follow it")
t.Error("XPC_SERVICE_NAME is launchd's own marker")
}
}
+31 -12
View File
@@ -1,6 +1,7 @@
package security
import (
"crypto/sha256"
"database/sql"
"errors"
"strings"
@@ -28,15 +29,20 @@ type Session struct {
Permissions map[string]bool
PermissionScopes map[string]string
Scope string
userUpdatedAt time.Time
}
type sessionDigest = [sha256.Size]byte
func tokenDigest(token string) sessionDigest { return sha256.Sum256([]byte(token)) }
// AuthManager manages password-based authentication and session lifecycle.
type AuthManager struct {
sessionDuration time.Duration
db *database.DB
mu sync.RWMutex
sessions map[string]Session
sessions map[sessionDigest]Session
// Changes when cached authorization is revoked, including while a login
// is resolving access outside mu.
accessVersion uint64
@@ -50,7 +56,7 @@ func NewAuthManager(sessionDurationHours int) *AuthManager {
return &AuthManager{
sessionDuration: time.Duration(sessionDurationHours) * time.Hour,
sessions: make(map[string]Session),
sessions: make(map[sessionDigest]Session),
}
}
@@ -111,7 +117,9 @@ func (a *AuthManager) publishSession(session Session, version uint64) bool {
if a.accessVersion != version {
return false
}
a.sessions[session.Token] = session
digest := tokenDigest(session.Token)
session.Token = ""
a.sessions[digest] = session
return true
}
@@ -144,21 +152,30 @@ func (a *AuthManager) authenticateSession(username, password string) (Session, e
if err != nil {
return Session{}, err
}
if !access.User.Enabled || access.User.PasswordHash != user.PasswordHash {
return Session{}, ErrInvalidPassword
}
session := sessionForAccess(access, expiresAt)
session.Token = token
return session, nil
}
func sessionForAccess(access *database.RBACAccess, expiresAt time.Time) Session {
roleIDs := make([]string, 0, len(access.Roles))
for _, role := range access.Roles {
roleIDs = append(roleIDs, role.ID)
}
return Session{
Token: token,
ExpiresAt: expiresAt,
UserID: user.ID,
Username: user.Username,
DisplayName: user.DisplayName,
UserID: access.User.ID,
Username: access.User.Username,
DisplayName: access.User.DisplayName,
Roles: roleIDs,
Permissions: access.Permissions,
PermissionScopes: access.PermissionScopes,
Scope: access.Scope,
}, nil
userUpdatedAt: access.User.UpdatedAt,
}
}
func (s Session) ScopeFor(permission string) string {
@@ -174,8 +191,9 @@ func (a *AuthManager) ValidateToken(token string) (Session, bool) {
return Session{}, false
}
digest := tokenDigest(token)
a.mu.RLock()
session, ok := a.sessions[token]
session, ok := a.sessions[digest]
a.mu.RUnlock()
if !ok {
return Session{}, false
@@ -183,11 +201,12 @@ func (a *AuthManager) ValidateToken(token string) (Session, bool) {
if time.Now().After(session.ExpiresAt) {
a.mu.Lock()
delete(a.sessions, token)
delete(a.sessions, digest)
a.mu.Unlock()
return Session{}, false
}
session.Token = token
return session, true
}
@@ -247,7 +266,7 @@ func (a *AuthManager) RevokeToken(token string) {
}
a.mu.Lock()
delete(a.sessions, token)
delete(a.sessions, tokenDigest(token))
a.mu.Unlock()
}
@@ -289,7 +308,7 @@ func (a *AuthManager) RevokeRoleSessions(roleID string) {
func (a *AuthManager) RevokeAllSessions() {
a.mu.Lock()
a.accessVersion++
a.sessions = make(map[string]Session)
a.sessions = make(map[sessionDigest]Session)
a.mu.Unlock()
}
+78
View File
@@ -0,0 +1,78 @@
package security
import (
"database/sql"
"encoding/json"
"errors"
"io"
"time"
"cyberstrike-ai/internal/database"
)
type restartSession struct {
Digest sessionDigest
UserID string
ExpiresAt time.Time
UserUpdatedAt time.Time
}
// HandoffSessions runs the update exec with the session map locked. A logout or
// revocation cannot complete in the old process after its token is handed off.
// The payload contains token digests only, and is passed through a private descriptor.
func (a *AuthManager) HandoffSessions(exec func([]byte) error) error {
a.mu.Lock()
defer a.mu.Unlock()
now := time.Now()
var active []restartSession
for digest, s := range a.sessions {
if s.ExpiresAt.After(now) {
active = append(active, restartSession{digest, s.UserID, s.ExpiresAt, s.userUpdatedAt})
}
}
if len(active) == 0 {
return exec(nil)
}
payload, err := json.Marshal(active)
if err != nil {
return err
}
return exec(payload)
}
// RestoreRestartSessions is called before the replacement starts serving.
// Rebuild access from the current RBAC store and keep the original expiration.
func (a *AuthManager) RestoreRestartSessions(r io.Reader) error {
var saved []restartSession
if err := json.NewDecoder(r).Decode(&saved); err != nil {
return err
}
a.mu.Lock()
defer a.mu.Unlock()
if a.db == nil {
return errors.New("authentication store is not configured")
}
accessByUser := make(map[string]*database.RBACAccess)
restored := make(map[sessionDigest]Session, len(saved))
now := time.Now()
for _, entry := range saved {
if !entry.ExpiresAt.After(now) {
continue
}
access, loaded := accessByUser[entry.UserID]
if !loaded {
var err error
access, err = a.db.ResolveRBACAccess(entry.UserID)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
accessByUser[entry.UserID] = access
}
if access == nil || !access.User.Enabled || !access.User.UpdatedAt.Equal(entry.UserUpdatedAt) {
continue
}
restored[entry.Digest] = sessionForAccess(access, entry.ExpiresAt)
}
a.sessions = restored
return nil
}
+65
View File
@@ -0,0 +1,65 @@
package security
import (
"bytes"
"path/filepath"
"testing"
"time"
"cyberstrike-ai/internal/database"
"go.uber.org/zap"
)
func TestRestartHandoffRejectsExpiredAndChangedAccounts(t *testing.T) {
db, err := database.NewDB(filepath.Join(t.TempDir(), "auth.db"), zap.NewNop())
if err != nil {
t.Fatal(err)
}
defer db.Close()
old := NewAuthManager(12)
password, err := old.AttachRBACStore(db)
if err != nil {
t.Fatal(err)
}
token, _, err := old.Authenticate("admin", password)
if err != nil {
t.Fatal(err)
}
digest := tokenDigest("expired-token")
expired := old.sessions[tokenDigest(token)]
expired.ExpiresAt = time.Now().Add(-time.Second)
old.sessions[digest] = expired
var handoff []byte
if err := old.HandoffSessions(func(payload []byte) error {
handoff = payload
return nil
}); err != nil {
t.Fatal(err)
}
if len(handoff) == 0 || bytes.Contains(handoff, []byte(token)) {
t.Fatal("handoff must contain only the digest of the active token")
}
next := NewAuthManager(12)
if _, err := next.AttachRBACStore(db); err != nil {
t.Fatal(err)
}
if err := next.RestoreRestartSessions(bytes.NewReader(handoff)); err != nil {
t.Fatal(err)
}
if len(next.sessions) != 1 {
t.Fatalf("restored %d sessions, want exactly the unexpired session", len(next.sessions))
}
// Simulate a password change committed to the store before revocation reached the old map.
if err := old.UpdateUserPassword("admin", "changed-test-password"); err != nil {
t.Fatal(err)
}
if err := next.RestoreRestartSessions(bytes.NewReader(handoff)); err != nil {
t.Fatal(err)
}
if _, ok := next.ValidateToken(token); ok {
t.Fatal("restored a session issued before the account changed")
}
}
+1 -1
View File
@@ -14,7 +14,7 @@ func TestRevokeRoleSessionsMatchesSnapshotAndPreservesOthers(t *testing.T) {
{Token: "admin", UserID: "admin", Roles: []string{"admin"}},
} {
session.ExpiresAt = time.Now().Add(time.Hour)
a.sessions[session.Token] = session
a.sessions[tokenDigest(session.Token)] = session
}
a.RevokeRoleSessions(" ")
if len(a.sessions) != 4 {
-19
View File
@@ -47205,25 +47205,6 @@ html[data-theme="dark"] { --tool-blocked-color: #f4bc62; }
margin: 10px 0;
}
.update-restart-choice {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 13px;
color: var(--text-primary);
cursor: pointer;
margin: 4px 0;
}
.update-restart-choice input {
margin-top: 2px;
}
.update-restart-preview {
border-left: 3px solid var(--border-color);
padding-left: 10px;
}
.update-blocker-list {
margin: 6px 0 0;
padding-left: 18px;
+7 -12
View File
@@ -147,13 +147,8 @@
"blockedLocalEdits": "{{n}} من ملفّات مصدر المنتج معدّلة محلّيًا ولن يستبدلها التحديث: أودِعها أو تراجع عنها أوّلًا.",
"blockedDiverged": "السجلّ المحلّي متقدّم بمقدار {{ahead}} إيداع ومتأخّر بمقدار {{behind}}: هذا دمج وليس تنزيلًا، لذا عالجه يدويًا.",
"blockedNoUpdate": "مصدر التحديث لا يحتوي على أيّ شيء أحدث من هذا الجهاز (اضغط على التحقّق من التحديثات إن لم تكن قد راجعت بعد).",
"restartChoiceLabel": "إنهاء العملية بعد التحديث (لن يعيد تشغيلها إلّا مشرف خارجي مثل systemd أو run.sh؛ أعد تشغيلها بنفسك إن لم يشرف عليها شيء)",
"restartNotWired": "لم يوفّر هذا التشغيل خطّاف إعادة تشغيل: أعد تشغيل الخدمة بنفسك بعد التحديث.",
"confirmRestartYes": "محدّد: تنتهي العملية بمجرّد نجاح التحديث، ولن يعيد تشغيلها إلّا مشرف خارجي (systemd، run.sh، ...).",
"confirmRestartNo": "غير محدّد: تستمرّ العملية في تشغيل الملف الثنائي القديم حتّى تعيد تشغيلها بنفسك.",
"confirmApply": "على وشك التنفيذ: التحديث من {{repo}} إلى {{target}} ({{n}} إيداع جديد، هذا الجهاز عند {{commit}}) -> إعادة بناء الملف الثنائي -> نسخ القديم احتياطيًا واستبداله.",
"confirmBuildPending": "على وشك التنفيذ: بناء الملف الثنائي المفقود للمصدر الموجود بالفعل (هذا الجهاز عند {{commit}}) -> إعادة البناء -> نسخ القديم احتياطيًا واستبدال الملف الثنائي القديم.",
"applyAccepted": "بدأ التحديث (المهمّة {{id}})؛ يتحدّث التقدّم كلّ 1.5 ثانية.",
"restartNotWired": "لا تدعم طريقة التشغيل الحالية التبديل التلقائي. بعد استبدال الملف التنفيذي، أعد تشغيل الخدمة بالطريقة المعتادة في بيئتك.",
"confirmRestartYes": "تظلّ الخدمة متاحة أثناء البناء، ثم تعيد التشغيل لفترة وجيزة للتبديل إلى الإصدار الجديد. تُعاد الصفحة تلقائيًا.",
"localChangesTitle": "التغييرات المحلّية",
"localChangesHint": "تُحفظ الملفّات المعلّمة كمحتوى محلّي كما هي تمامًا؛ أمّا الملفّات المعلّمة كمصدر منتج فتمنع التحديث التلقائي.",
"noLocalChanges": "شجرة العمل نظيفة: لا يوجد ملفّ متتبَّع معدّل أو محذوف.",
@@ -169,7 +164,7 @@
"stateSucceeded": "نجح",
"stateFailed": "فشل",
"stateUnknown": "حالة غير معروفة",
"restartRequested": "طُلب الإنهاء بعد الاكتمال",
"restartRequested": "جرت جدولة التبديل التلقائي",
"jobPollFailed": "تعذّرت قراءة التقدّم: {{reason}}",
"jobDoneToast": "انتهى التحديث",
"buildDoneToast": "انتهى البناء",
@@ -178,13 +173,13 @@
"restartBanner": "تمّ التحديث إلى {{commit}}، لكنّ هذه العملية لا تزال تشغّل البناء القديم (بُني الملف الثنائي على القرص في {{builtAt}}). يسري المفعول بعد إعادة التشغيل.",
"restartBannerNoCommit": "تمّ استبدال الملف الثنائي على القرص (بُني في {{builtAt}})، لكنّ هذه العملية لا تزال تشغّل البناء القديم؛ يسري المفعول بعد إعادة التشغيل.",
"restartNowBtn": "إعادة التشغيل الآن",
"restartNowConfirm": "ستنتهي عملية الخدمة؛ مع وجود مشرف (systemd/launchd) تعود النسخة الجديدة خلال ثوانٍ وتعيد هذه الصفحة تحميل نفسها. ومن دونه تبقى الخدمة متوقّفة حتّى تشغّلها. إعادة التشغيل الآن؟",
"restartRequestedToast": "طُلبت إعادة التشغيل: يعيدها المشرف وتتعافى هذه الصفحة من تلقاء نفسها",
"restartNowConfirm": "ستعيد الخدمة تشغيلها لفترة وجيزة لتفعيل الإصدار الموجود على القرص، ثم تُعاد الصفحة تلقائيًا. هل تريد التبديل الآن؟",
"restartRequestedToast": "جارٍ تبديل الإصدار. تُعاد الصفحة عند عودة الخدمة.",
"restartFailed": "فشل طلب إعادة التشغيل: {{reason}}",
"restartOverlayTitle": "جارٍ إعادة تشغيل الخدمة…",
"restartOverlayBody": "تعيد هذه الصفحة تحميل نفسها بمجرّد أن تستجيب العملية الجديدة (ستنتهي جلستك، لذا سيتبع ذلك تسجيل دخول جديد).",
"restartOverlayBody": "ستُعاد الصفحة تلقائيًا عندما يصبح الإصدار الجديد جاهزًا.",
"restartOverlaySlowRunning": "لا تزال الخدمة تستجيب بوصفها العملية القديمة - يبدو أنّ إعادة التشغيل لم تحدث. استخدم الزرّ أدناه لمعاينة الحالة الحالية.",
"restartOverlaySlowDown": "لم تعد الخدمة. إن لم يشرف عليها شيء (systemd/launchd) فلن تبدأ من تلقاء نفسها؛ شغّلها ثمّ حدّث هذه الصفحة.",
"restartOverlaySlowDown": "لم تعد الخدمة بعد. ستواصل الصفحة المحاولة. راجع سجلّات الخدمة إذا استمر الانقطاع.",
"restartOverlayRefresh": "تحديث هذه الصفحة",
"failureNoMessage": "لم يقدّم الخادم أيّ رسالة فشل",
"resultTitle": "نتيجة التحديث",
+7 -12
View File
@@ -147,13 +147,8 @@
"blockedLocalEdits": "{{n}} product source files are edited locally and an update will not overwrite them: commit or revert them first.",
"blockedDiverged": "Local history is {{ahead}} commits ahead and {{behind}} behind: that is a merge, not a download, so handle it by hand.",
"blockedNoUpdate": "The update source has nothing newer than this machine (press Check for updates if you have not looked yet).",
"restartChoiceLabel": "Exit the process after the update (only an external supervisor such as systemd or run.sh will bring it back; restart it yourself if nothing supervises it)",
"restartNotWired": "This start did not provide a restart hook: restart the service yourself after the update.",
"confirmRestartYes": "Selected: the process exits once the update succeeds, and only an external supervisor (systemd, run.sh, ...) will bring it back.",
"confirmRestartNo": "Not selected: the process keeps running the old binary until you restart it yourself.",
"confirmApply": "About to run: update from {{repo}} to {{target}} ({{n}} new commits, this machine is at {{commit}}) -> rebuild the binary -> back up and replace the old one.",
"confirmBuildPending": "About to run: build the missing binary for the source already in place (this machine is at {{commit}}) -> rebuild -> back up and replace the old binary.",
"applyAccepted": "Update started (job {{id}}); progress refreshes every 1.5 seconds.",
"restartNotWired": "This installation cannot switch versions automatically. Restart the service through your deployment setup after the binary is replaced.",
"confirmRestartYes": "The service stays available during the build, then briefly restarts to switch versions. This page reloads automatically.",
"localChangesTitle": "Local changes",
"localChangesHint": "Files marked local content are kept exactly as they are; files marked product source block the automatic update.",
"noLocalChanges": "Working tree clean: no tracked file is modified or deleted.",
@@ -169,7 +164,7 @@
"stateSucceeded": "Succeeded",
"stateFailed": "Failed",
"stateUnknown": "Unknown state",
"restartRequested": "Exit after completion requested",
"restartRequested": "Automatic switch scheduled",
"jobPollFailed": "Failed to read the progress: {{reason}}",
"jobDoneToast": "Update finished",
"buildDoneToast": "Build finished",
@@ -178,13 +173,13 @@
"restartBanner": "Updated to {{commit}}, but this process is still running the old build (the binary on disk was built at {{builtAt}}). It takes effect after a restart.",
"restartBannerNoCommit": "The binary on disk was replaced (built at {{builtAt}}), but this process is still running the old build; it takes effect after a restart.",
"restartNowBtn": "Restart now",
"restartNowConfirm": "The service process will exit; with a supervisor (systemd/launchd) the new version is back in seconds and this page reloads itself. Without one the service stays stopped until you start it. Restart now?",
"restartRequestedToast": "Restart requested: the supervisor brings it back and this page recovers by itself",
"restartNowConfirm": "The service will briefly restart to activate the version on disk. This page reloads automatically. Switch now?",
"restartRequestedToast": "Switching versions. This page reloads when the service is back.",
"restartFailed": "Restart request failed: {{reason}}",
"restartOverlayTitle": "Restarting the service…",
"restartOverlayBody": "This page reloads by itself once the new process answers (your session will be gone, so a fresh login follows).",
"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 has not come back. If nothing supervises it (systemd/launchd) it will not start by itself; start it, then refresh this page.",
"restartOverlaySlowDown": "The service is taking longer to come back. This page will keep trying. Check the service logs if it remains unavailable.",
"restartOverlayRefresh": "Refresh this page",
"failureNoMessage": "The server gave no failure message",
"resultTitle": "Update result",
+7 -12
View File
@@ -147,13 +147,8 @@
"blockedLocalEdits": "{{n}} файлов исходного кода изменены локально, и обновление не будет их перезаписывать: сначала закоммитьте или отмените изменения.",
"blockedDiverged": "Локальная история впереди на {{ahead}} коммитов и отстаёт на {{behind}}: это слияние, а не загрузка, разберитесь вручную.",
"blockedNoUpdate": "В источнике обновлений нет ничего новее этой машины (нажмите «Проверить обновления», если ещё не смотрели).",
"restartChoiceLabel": "Завершить процесс после обновления (вернуть его сможет только внешний супервизор, например systemd или run.sh; если ничего не супервизит — запустите сами)",
"restartNotWired": "При этом запуске хук перезапуска не предоставлен: перезапустите службу сами после обновления.",
"confirmRestartYes": "Выбрано: процесс завершится после успешного обновления, и вернуть его сможет только внешний супервизор (systemd, run.sh, ...).",
"confirmRestartNo": "Не выбрано: процесс продолжит работать со старым бинарным файлом, пока вы сами его не перезапустите.",
"confirmApply": "Сейчас будет выполнено: обновление из {{repo}} до {{target}} ({{n}} новых коммитов, эта машина на {{commit}}) -> пересборка бинарного файла -> резервная копия и замена старого.",
"confirmBuildPending": "Сейчас будет выполнено: сборка недостающего бинарного файла для уже установленных исходников (эта машина на {{commit}}) -> пересборка -> резервная копия и замена старого бинарного файла.",
"applyAccepted": "Обновление запущено (задача {{id}}); ход обновляется каждые 1,5 секунды.",
"restartNotWired": "Этот способ запуска не поддерживает автоматический переход. После замены файла перезапустите службу штатным способом.",
"confirmRestartYes": "Во время сборки служба продолжит работу. Для перехода она кратковременно перезапустится, и страница обновится сама.",
"localChangesTitle": "Локальные изменения",
"localChangesHint": "Файлы, помеченные как локальное содержимое, сохраняются как есть; файлы, помеченные как исходный код продукта, блокируют автоматическое обновление.",
"noLocalChanges": "Рабочее дерево чистое: ни один отслеживаемый файл не изменён и не удалён.",
@@ -169,7 +164,7 @@
"stateSucceeded": "Успешно",
"stateFailed": "Не удалось",
"stateUnknown": "Неизвестное состояние",
"restartRequested": "Запрошено завершение после выполнения",
"restartRequested": "Автоматический переход запланирован",
"jobPollFailed": "Не удалось прочитать ход выполнения: {{reason}}",
"jobDoneToast": "Обновление завершено",
"buildDoneToast": "Сборка завершена",
@@ -178,13 +173,13 @@
"restartBanner": "Обновлено до {{commit}}, но процесс всё ещё работает на старой сборке (бинарник на диске собран {{builtAt}}). Изменения вступят в силу после перезапуска.",
"restartBannerNoCommit": "Бинарник на диске заменён (собран {{builtAt}}), но процесс всё ещё работает на старой сборке; изменения вступят в силу после перезапуска.",
"restartNowBtn": "Перезапустить сейчас",
"restartNowConfirm": "Процесс службы завершится; при наличии супервизора (systemd/launchd) новая версия поднимется через несколько секунд, и страница обновится сама. Без супервизора служба останется остановленной. Перезапустить сейчас?",
"restartRequestedToast": "Перезапуск запрошен: супервизор поднимет службу, страница восстановится сама",
"restartNowConfirm": "Служба кратковременно перезапустится для запуска версии на диске. Страница обновится сама. Перейти сейчас?",
"restartRequestedToast": "Переключаем версию. Страница обновится, когда служба снова будет доступна.",
"restartFailed": "Не удалось запросить перезапуск: {{reason}}",
"restartOverlayTitle": "Перезапуск службы…",
"restartOverlayBody": "Страница обновится сама, как только новый процесс начнёт отвечать (сессия будет сброшена — потребуется войти заново).",
"restartOverlayBody": "Когда новая версия будет готова, страница обновится автоматически.",
"restartOverlaySlowRunning": "Служба по-прежнему отвечает старым процессом — перезапуск, похоже, не произошёл. Нажмите кнопку ниже, чтобы увидеть текущее состояние.",
"restartOverlaySlowDown": "Служба не вернулась. Если её ничто не супервизит (systemd/launchd), она не запустится сама — запустите её и обновите страницу.",
"restartOverlaySlowDown": "Служба пока недоступна. Страница продолжит попытки. Если служба долго не отвечает, проверьте её журналы.",
"restartOverlayRefresh": "Обновить страницу",
"failureNoMessage": "Сервер не передал сообщение об ошибке",
"resultTitle": "Результат обновления",
+7 -12
View File
@@ -147,13 +147,8 @@
"blockedLocalEdits": "有 {{n}} 个产品源码文件被本地改过,自动更新不会覆盖它们:先提交或还原。",
"blockedDiverged": "本地领先 {{ahead}} 个提交、落后 {{behind}} 个提交:这是合并而不是下载,请先手工处理。",
"blockedNoUpdate": "更新源没有比本机更新的提交(还没查过就先点「检查更新」)。",
"restartChoiceLabel": "更新完成后退出进程(需 systemd/run.sh 等外部守护才会自动拉起;无人守护请手动重启)",
"restartNotWired": "本次启动没有提供重启钩子:更新完成后请自己重启服务。",
"confirmRestartYes": "已勾选:更新成功后本进程会退出,只有外部守护(systemd/run.sh 等)才会把它再拉起来。",
"confirmRestartNo": "未勾选:进程不退出,运行的仍是旧二进制,需要你自行重启。",
"confirmApply": "将要执行:从更新源 {{repo}} 更新到 {{target}}({{n}} 个新提交,本机当前 {{commit}})→ 重新编译二进制 → 备份并替换旧二进制。",
"confirmBuildPending": "将要执行:为已经就位的源码补上二进制(本机当前 {{commit}})→ 重新编译 → 备份并替换旧二进制。",
"applyAccepted": "更新已开始(任务 {{id}}),进度每 1.5 秒刷新一次。",
"restartNotWired": "当前启动方式不支持自动切换。换入二进制后,请通过部署方式重启服务。",
"confirmRestartYes": "构建期间服务照常运行;新版本就绪后会短暂重启,页面随后自动刷新。",
"localChangesTitle": "本机改动",
"localChangesHint": "标为「本机内容」的文件更新后原样保留;标为「产品源码」的文件会阻止自动更新。",
"noLocalChanges": "工作树干净:没有被修改或被删除的受跟踪文件。",
@@ -169,7 +164,7 @@
"stateSucceeded": "已完成",
"stateFailed": "失败",
"stateUnknown": "未知状态",
"restartRequested": "已要求在完成后退出",
"restartRequested": "已安排自动切换",
"jobPollFailed": "进度读取失败:{{reason}}",
"jobDoneToast": "更新完成",
"buildDoneToast": "补编译完成",
@@ -178,13 +173,13 @@
"restartBanner": "已更新到 {{commit}},但当前进程仍在运行旧版本(磁盘上的二进制构建于 {{builtAt}})。重启后新版本才会生效。",
"restartBannerNoCommit": "磁盘上的二进制已换新(构建于 {{builtAt}}),但当前进程仍在运行旧版本;重启后新版本才会生效。",
"restartNowBtn": "立即重启服务",
"restartNowConfirm": "服务进程会退出;有守护进程(systemd/launchd)时几秒内会自动拉起新版本,本页随后自动刷新。没有守护时服务会保持停止,需要手动启动。确定现在重启?",
"restartRequestedToast": "已请求重启:进程退出后由守护进程拉起,页面会自动恢复",
"restartNowConfirm": "服务将短暂重启以启用磁盘上的版本,页面会自动刷新。现在切换吗?",
"restartRequestedToast": "正在切换版本,服务恢复后页面会自动刷新。",
"restartFailed": "重启请求失败:{{reason}}",
"restartOverlayTitle": "正在重启服务…",
"restartOverlayBody": "新进程接客后本页会自动刷新(会话会失效,需要重新登录)。无需手动操作。",
"restartOverlayBody": "切换完成后,页面会自动恢复。",
"restartOverlaySlowRunning": "服务仍在以旧进程应答——重启似乎没有发生。可点下方按钮刷新页面查看当前状态。",
"restartOverlaySlowDown": "服务还没有回来。若本机没有守护进程(systemd/launchd),它不会自己启动;手动启动服务后再刷新本页。",
"restartOverlaySlowDown": "服务仍未恢复,页面会继续重试。如果长时间无法连接,请检查服务日志。",
"restartOverlayRefresh": "立即刷新页面",
"failureNoMessage": "服务端没有给出失败说明",
"resultTitle": "更新结果",
+1 -1
View File
@@ -91,7 +91,7 @@ test('every control the update page disables by state carries the marker the gat
console,
escapeHtml: (v) => String(v),
updateT: (key) => key,
renderUpdateRestartChoice: () => '',
renderUpdateSwitchHint: () => '',
renderUpdateChangesTable: () => '<div class="update-changes"></div>',
isUpdateJobRunning: () => true,
updateApplyBlockers: (status) => (status.blockingChanges || []).length ? ['blocked'] : [],
+77 -99
View File
@@ -14,7 +14,7 @@ const ru = JSON.parse(fs.readFileSync('web/static/i18n/ru-RU.json', 'utf8'));
// The only handlers this console is allowed to wire into markup.
const HANDLES = ['startUpdateApply', 'checkForUpdates', 'rollbackUpdate',
'updateRestartChoiceChanged', 'loadUpdateConsole',
'loadUpdateConsole',
'saveUpdateSource', 'previewAdoptSource', 'confirmAdoptSource',
'startRestartNow', 'refreshUpdatePage'];
@@ -94,6 +94,8 @@ function envelope(status, job, canRestart, source, flags) {
job: job === undefined ? null : job,
canRestart: canRestart !== false,
supervised: !!(flags && flags.supervised),
autoRestart: flags?.autoRestart,
bootID: flags?.bootID,
needsRestart: !!(flags && flags.needsRestart),
binaryBuiltAt: (flags && flags.binaryBuiltAt) || '',
source: source || emptySource,
@@ -166,7 +168,6 @@ function harness(options) {
const el = {
id,
innerHTML: '',
checked: false,
insertAdjacentHTML(_pos, html) { this.innerHTML = html + this.innerHTML; },
};
// 控制台住在系统设置页的「一键更新」分区里:页面和分区都得是 active,它才算在前台。
@@ -192,10 +193,7 @@ function harness(options) {
'POST /api/system/update/adopt': [{ status: 200, body: { plan: adoptPlan } }],
}, opts.responses || {});
// The watchdog probes with plain fetch (the app's apiFetch turns a 401 into "logged out",
// and the whole point here is to read that 401 as "the new process is up"). Tests script
// the answers: 'ok' (old process still answering), 'unauthorized' (a new process without
// our session), 'down' (nothing is listening), 'bad gateway'.
// Plain fetch observes boot headers without apiFetch's logout side effect.
const probeQueue = (opts.probes || ['ok']).slice();
const fetchProbes = [];
@@ -228,6 +226,8 @@ function harness(options) {
String,
Date,
Math,
URLSearchParams,
authToken: 'test-session',
escapeHtml(text) {
const div = sandbox.document.createElement('div');
div.textContent = text;
@@ -302,13 +302,23 @@ function harness(options) {
});
},
fetch(url, reqOpts = {}) {
if (url === '/api/system/update/job') {
return sandbox.apiFetch(url, reqOpts);
}
fetchProbes.push({ url, opts: reqOpts });
const answer = probeQueue.length > 1 ? probeQueue.shift() : (probeQueue[0] || 'ok');
if (answer === 'down') {
return Promise.reject(new TypeError('Failed to fetch'));
}
if (answer === 'unauthorized') {
return Promise.resolve({ ok: false, status: 401, json: () => Promise.resolve({ error: 'unauthorized' }) });
return Promise.resolve({ ok: false, status: 401, json: () => Promise.resolve({ error: '未授权访问,请先登录' }) });
}
if (typeof answer === 'object') {
return Promise.resolve({
status: answer.status,
headers: { get: name => name === 'X-CyberStrike-Boot-ID' ? answer.bootID : null },
json: () => Promise.resolve(answer.body || {}),
});
}
if (answer === 'bad gateway') {
return Promise.resolve({ ok: false, status: 502, json: () => Promise.resolve({}) });
@@ -321,6 +331,7 @@ function harness(options) {
location: {
pathname: '/',
hash: '',
search: opts.search || '',
replaced: [],
reloads: 0,
replace(target) { this.replaced.push(target); },
@@ -333,12 +344,11 @@ function harness(options) {
vm.runInContext(`
this.api = {
loadUpdateConsole, checkForUpdates, startUpdateApply, rollbackUpdate,
updateRestartChoiceChanged, pollUpdateJob, renderUpdateConsole, stopUpdatePolling,
pollUpdateJob, renderUpdateConsole, stopUpdatePolling,
startRestartNow
};
this.vars = {
setBusy(v) { updateBusy = v; },
getRestartChoice() { return updateRestartChoice; },
getPollTimer() { return updatePollTimer; },
getCheckDone() { return updateCheck.done; },
autoCheckDone() { return updateAutoCheckDone; },
@@ -429,7 +439,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, /<script src="\/static\/js\/update\.js\?v=\{\{\.Version\}\}"><\/script>/);
assert.match(template, /<script src="\/static\/js\/update\.js\?v=\{\{\.Version\}\}-auto-switch"><\/script>/);
assert.match(template, /<div class="settings-section-header update-section-head">/);
assert.match(template, /<h3 data-i18n="update\.title">/);
@@ -466,7 +476,7 @@ test('the update console has its own stylesheet section', () => {
assert.ok(block.includes('.update-console'), 'the .update-* section must follow its own section header');
['.update-console', '.update-section-head', '.update-section-title', '.update-facts', '.update-chip-ok', '.update-chip-warn',
'.update-chip-danger', '.update-error', '.update-check-failed', '.update-step-list', '.update-table',
'.update-restart-choice', '.update-blocker-list', '.update-apply-btn:disabled'].forEach(sel => {
'.update-blocker-list', '.update-apply-btn:disabled'].forEach(sel => {
assert.ok(sheet.includes(sel), sel + ' must be defined');
});
// Every class the console emits has to be styled or be an existing shared one: a hook name
@@ -742,8 +752,6 @@ test('a tree that only owes a build still offers the button, and says it is a bu
await h.flush();
const apply = h.calls.find(c => c.method === 'POST' && c.url === '/api/system/update/apply');
assert.ok(apply, 'the retry must reach the server: ' + h.calls.map(c => c.method + ' ' + c.url));
assert.match(h.confirms[h.confirms.length - 1], /补上二进制/,
'the confirm dialog describes a build, not "0 new commits"');
const success = h.toasts.filter(t => t.type === 'success' && /补编译完成/.test(t.msg)).pop();
assert.ok(success, 'a finished build retry is announced as a build: ' + JSON.stringify(h.toasts));
assert.deepEqual(h.toasts.filter(t => t.type === 'success' && /a1b2c3d → a1b2c3d/.test(t.msg)), [],
@@ -782,7 +790,7 @@ test('a pending build with no toolchain fails with the server sentence, never a
'the server sentence must be shown: ' + JSON.stringify(h.toasts));
});
test('applying posts {"restart":false} and then reads the job every 1.5 seconds', async () => {
test('applying reads the job every 1.5 seconds', async () => {
// This test is about the poll cadence and the final re-read, so the job finishes without
// having asked for a restart; the restart hand-off has its own test below.
const plainSucceededJob = Object.assign({}, succeededJob, { restartRequested: false });
@@ -815,7 +823,7 @@ test('applying posts {"restart":false} and then reads the job every 1.5 seconds'
const apply = h.calls.find(c => c.method === 'POST' && c.url === '/api/system/update/apply');
assert.ok(apply, 'no apply was posted: ' + h.calls.map(c => c.method + ' ' + c.url));
assert.deepEqual(JSON.parse(apply.body), { restart: false },
assert.deepEqual(JSON.parse(apply.body), { restart: true },
'the body is one object carrying a restart boolean, not a positional bag');
assert.equal(h.liveTimers().length, 1, 'exactly one poller: ' + JSON.stringify(h.timers.map(t => t.ms)));
assert.equal(h.liveTimers()[0].ms, 1500, 'progress must be read every 1.5 seconds');
@@ -880,72 +888,25 @@ test('a failing job shows the server message with its item list, and never claim
assert.equal(h.toasts[h.toasts.length - 1].type, 'error');
});
test('the restart tick only appears when the process can actually be restarted', async () => {
const off = await harness({ canRestart: false });
assert.doesNotMatch(off.html(), /id="update-restart-choice"/,
'offering a promise the platform cannot keep is a lie');
assert.match(off.html(), /本次启动没有提供重启钩子/);
const on = await harness({
canRestart: true,
// No job to adopt: a running job would (correctly) block the second click, and this test
// is about what the checkbox does to the body, not about the block.
responses: { 'GET /api/system/update/job': [{ status: 200, body: { job: null } }] },
});
assert.match(on.html(), /<input type="checkbox" id="update-restart-choice"[^>]*onchange="updateRestartChoiceChanged\(this\.checked\)">/);
assert.ok(!/id="update-restart-choice" checked/.test(on.html()),
'with nothing supervising the process, the tick must default to off: ticking it would just stop the platform');
// The default follows the environment: launchd/systemd markers mean a restart is a
// restart, so one click should be the whole update.
const supervised = await harness({
canRestart: true,
flags: { supervised: true },
responses: { 'GET /api/system/update/job': [{ status: 200, body: { job: null } }] },
});
assert.match(supervised.html(), /id="update-restart-choice" checked/,
'detected supervision must pre-tick the box');
// Once the operator decides for themselves, the default stops second-guessing them.
supervised.sandbox.document.getElementById('update-restart-choice').checked = false;
supervised.fire('updateRestartChoiceChanged', { checked: false });
assert.ok(!/id="update-restart-choice" checked/.test(supervised.html()));
on.calls.length = 0;
on.fire('startUpdateApply');
await on.flush();
assert.deepEqual(JSON.parse(on.calls.find(c => c.url === '/api/system/update/apply').body), { restart: false });
// Tick it through the rendered onchange, exactly as a browser does (the element is already
// checked when change fires), then apply again.
on.vars.setBusy(false);
on.nodes.get('update-restart-choice').checked = true;
on.fire('updateRestartChoiceChanged', { checked: true });
assert.equal(on.vars.getRestartChoice(), true);
assert.match(on.html(), /id="update-restart-choice" checked/, 'the tick must survive the repaint');
on.calls.length = 0;
on.vars.setBusy(false);
on.fire('startUpdateApply');
await on.flush();
assert.deepEqual(JSON.parse(on.calls.find(c => c.url === '/api/system/update/apply').body), { restart: true },
'the restart flag that goes out is the checkbox, not a guess');
});
test('the confirm dialog says what the update will do, and cancelling posts nothing', async () => {
const h = await harness();
h.setConfirm(false);
h.calls.length = 0;
h.toasts.length = 0;
h.fire('startUpdateApply');
await h.flush();
assert.equal(h.confirms.length, 1, 'the operator must be asked before the tree moves');
assert.match(h.confirms[0], /重新编译/, 'the confirm must say the binary gets rebuilt: ' + h.confirms[0]);
assert.match(h.confirms[0], /二进制/);
assert.match(h.confirms[0], /github\.com\/AIPentest\/CyberStrikeAI/, 'the confirm must name the repository being fetched');
assert.match(h.confirms[0], /a1b2c3d/, 'the confirm must name where this machine is now');
assert.doesNotMatch(h.confirms[0], /\{\{/, 'the confirm went out un-interpolated: ' + h.confirms[0]);
assert.deepEqual(h.calls.map(c => c.method + ' ' + c.url), [], 'a cancelled update must not POST');
assert.deepEqual(h.toasts, [], 'a cancelled update must not be reported as anything');
test('one click requests automatic activation when supported, with no prompt or start toast', async () => {
for (const canRestart of [true, false]) {
const h = await harness({
canRestart,
confirm: false,
responses: { 'GET /api/system/update/job': [{
status: 200, body: { job: { ...runningJob, restartRequested: canRestart } },
}] },
});
assert.match(h.html(), canRestart ? /页面随后自动刷新/ : /当前启动方式不支持自动切换/);
assert.doesNotMatch(h.html(), /id="update-restart-choice"/);
h.fire('startUpdateApply');
await h.flush();
const apply = h.calls.filter(c => c.url === '/api/system/update/apply');
assert.equal(apply.length, 1);
assert.deepEqual(JSON.parse(apply[0].body), { restart: canRestart });
assert.deepEqual(h.confirms, []);
assert.deepEqual(h.toasts, []);
}
});
test('a second apply is refused with 409 and the running job is adopted', async () => {
@@ -964,7 +925,7 @@ test('a second apply is refused with 409 and the running job is adopted', async
await h.flush();
const apply = h.calls.find(c => c.url === '/api/system/update/apply');
assert.equal(apply.method, 'POST');
assert.deepEqual(JSON.parse(apply.body), { restart: false });
assert.deepEqual(JSON.parse(apply.body), { restart: true });
assert.match(h.html(), /正在编译二进制/, 'the job the server named must be shown, not swallowed');
assert.match(h.html(), /upd-7/);
assert.match(h.toasts[h.toasts.length - 1].msg, /已有一次更新在进行中/);
@@ -1125,13 +1086,13 @@ test('server strings cannot become markup, in text or in an attribute', async ()
// Every emitted handler must still be executable as written: an injection into an attribute
// would break the statement instead.
const found = h.extractHandlers(html);
assert.ok(found.onclick.length && found.onchange.length, 'handlers must still render under hostile data');
assert.ok(found.onclick.length, 'handlers must still render under hostile data');
found.onclick.concat(found.onchange).forEach(text => {
assert.doesNotMatch(text, /"/, 'handler text carries a raw quote: ' + text);
assert.equal(typeof vm.runInContext('(function(){' + text + '})', h.sandbox), 'function',
'the emitted handler is not valid JS: ' + text);
});
['startUpdateApply', 'checkForUpdates', 'rollbackUpdate', 'updateRestartChoiceChanged']
['startUpdateApply', 'checkForUpdates', 'rollbackUpdate']
.forEach(name => assert.ok(found.onclick.concat(found.onchange).some(t => t.startsWith(name + '(')),
name + ' must stay wired'));
});
@@ -1176,6 +1137,7 @@ test('opening the page schedules exactly one deferred remote check', async () =>
const checks = h.calls.filter(c => c.method === 'POST' && c.url === '/api/system/update/check');
assert.strictEqual(checks.length, 1, 'the deferred timer must ask the remote exactly once');
assert.match(h.html(), /3/, 'the answer must be on screen without another click');
assert.deepEqual(h.toasts, [], 'a background check should not pop up a success message');
// Re-entering the page in the same session does not go back to the remote again.
h.api.loadUpdateConsole();
@@ -1310,7 +1272,7 @@ test('adopt previews first, and confirming carries the counts into the dialog',
assert.match(h.confirms[0], /42/, 'the dialog counts what will happen');
const adopt = h.calls.find(c => c.url === '/api/system/update/adopt' && c.method === 'POST' && c.body && c.body.includes('"confirm":true'));
assert.ok(adopt, 'confirming must post to adopt');
assert.deepEqual(JSON.parse(adopt.body), { confirm: true, restart: false });
assert.deepEqual(JSON.parse(adopt.body), { confirm: true, restart: true });
assert.ok(h.vars.getPollTimer(), 'the job it started must be polled');
});
@@ -1356,7 +1318,7 @@ test('restart now asks first, posts once, and hands the page to the watchdog', a
h.fire('startRestartNow');
await h.flush();
assert.equal(h.confirms.length, 1, 'standing the service down is confirmed, never implied');
assert.match(h.confirms[0], /守护/, 'the confirm has to say what brings it back: ' + h.confirms[0]);
assert.match(h.confirms[0], /页面会自动刷新/);
assert.deepEqual(h.calls, [], 'a cancelled restart must not post');
assert.equal(h.vars.getWatchdogTimer(), null, 'nothing may be watched for a restart nobody asked for');
@@ -1389,8 +1351,8 @@ test('a refused restart is reported with the server sentence and arms nothing',
test('the watchdog reloads only when the NEW process answers, never while the old one still does', async () => {
const h = await harness({
flags: { needsRestart: true },
probes: ['ok', 'down', 'down', 'unauthorized'],
flags: { needsRestart: true, bootID: 'old' },
probes: [{ status: 401, bootID: 'old' }, 'down', { status: 401 }, { status: 200, bootID: 'new' }],
responses: { 'POST /api/system/update/restart': [{ status: 202, body: { restarting: true } }] },
});
await h.api.startRestartNow();
@@ -1399,18 +1361,18 @@ test('the watchdog reloads only when the NEW process answers, never while the ol
await h.tick();
await h.flush();
assert.equal(h.location.replaced.length, 0, 'a 200 from the old process is not "it is back"');
assert.equal(h.location.replaced.length, 0, 'an expired session on the old boot is not a restart');
await h.tick();
await h.flush();
await h.tick();
await h.flush();
assert.equal(h.location.replaced.length, 0, 'a dead service is not a recovered one');
assert.equal(h.location.replaced.length, 0, 'a dead service or proxy 401 does not prove recovery');
await h.tick();
await h.flush();
assert.equal(h.location.replaced.length, 1,
'a 401 is the proof: only a new process can fail to know our session');
'the new boot can be recognized independently of authentication status');
assert.match(h.location.replaced[0], /\?restarted=\d+/);
assert.match(h.location.replaced[0], /#system-update$/);
assert.equal(h.vars.getWatchdogTimer(), null, 'the page is leaving: the timer must be down');
@@ -1427,7 +1389,7 @@ test('a restart that does not come back says so instead of spinning forever', as
downCase.vars.expireWatchdog();
await downCase.tick();
await downCase.flush();
assert.match(downCase.html(), /没有守护进程|不会自己启动/, 'the down advice must name the supervision question');
assert.match(downCase.html(), /服务仍未恢复,页面会继续重试/);
const aliveCase = await harness({
flags: { needsRestart: true },
@@ -1445,17 +1407,15 @@ test('a restart that does not come back says so instead of spinning forever', as
test('an update that asked for a restart hands the page to the watchdog instead of reporting a failed poll', async () => {
const h = await harness({
flags: { supervised: true },
search: '?view=ops',
responses: {
'GET /api/system/update/job': [
{ status: 200, body: { job: runningJob } },
{ status: 200, body: { job: { ...runningJob, restartRequested: true } } },
{ status: 200, body: { job: succeededJob } },
],
},
probes: ['unauthorized'],
probes: ['bad gateway', { status: 200, bootID: 'new' }],
});
// In a real browser the pre-ticked box makes this true; the harness reads the element.
h.sandbox.document.getElementById('update-restart-choice').checked = true;
h.fire('startUpdateApply');
await h.flush();
const apply = h.calls.find(c => c.url === '/api/system/update/apply');
@@ -1470,10 +1430,14 @@ test('an update that asked for a restart hands the page to the watchdog instead
assert.deepEqual(h.toasts.filter(t => t.type === 'error'), [],
'the process going away by request is not a polling failure to complain about');
assert.ok(h.vars.restartPending());
assert.doesNotMatch(h.html(), /立即刷新页面|重新登录/);
await h.tick();
await h.flush();
assert.deepEqual(h.location.replaced, [], 'a proxy error must not reload');
await h.tick();
assert.equal(h.probes.at(-1).opts.headers.Authorization, 'Bearer test-session');
assert.equal(h.location.replaced.length, 1);
assert.match(h.location.replaced[0], /view=ops&restarted=\d+#system-update$/);
});
test('a job poll that dies while the restart is in flight is not a failure', async () => {
@@ -1484,7 +1448,6 @@ test('a job poll that dies while the restart is in flight is not a failure', asy
rejects: ['GET /api/system/update/job'],
probes: ['unauthorized'],
});
h.sandbox.document.getElementById('update-restart-choice').checked = true;
h.fire('startUpdateApply');
await h.flush();
assert.match(h.html(), /正在重启服务/, 'a dead poll while a restart is requested means the restart is in flight');
@@ -1495,6 +1458,21 @@ test('a job poll that dies while the restart is in flight is not a failure', asy
assert.equal(h.location.replaced.length, 1);
});
test('an update that did not build a binary stays on the progress page', async () => {
const h = await harness({
flags: { autoRestart: true },
responses: {
'GET /api/system/update/job': [{
status: 200,
body: { job: { ...succeededJob, result: { ...succeededJob.result, binaryBuilt: false } } },
}],
},
});
await h.api.startUpdateApply();
assert.deepEqual(JSON.parse(h.calls.find(c => c.url === '/api/system/update/apply').body), { restart: true });
assert.equal(h.vars.getWatchdogTimer(), null, 'a source-only update does not cause an outage');
});
test('an interrupted rollback offers recovery and blocks a new update', async () => {
const h = await harness({ status: statusOf({ rollbackPending: true, hasRollback: true }) });
assert.match(h.html(), /上一次回滚尚未完成/);
+97 -94
View File
@@ -20,14 +20,14 @@ const UPDATE_FAILURE_KEYS = {
restart: 'restartFailed',
};
let updateConsoleState = null; // GET /api/system/update 的整包:{status, job, canRestart, supervised, needsRestart}
let updateConsoleState = null;
let updateCheck = { done: false, error: '' };
let updateBusy = false; // 页面自己发着的动作请求(检查 / 启动更新 / 回滚 / 重启)
let updateChecking = false; // 只影响「检查更新」按钮的措辞
let updatePollTimer = null;
let updateRestartChoice = true; // 勾选框要在轮询重绘之后仍然是勾着的;默认值跟随 supervised
let updateRestartChoiceSet = false; // 操作员本次会话手动改过之后,默认值不再顶掉他的选择
let updateRestartPending = false; // 已请求重启:进程退出/新进程接客就在眼前
let updatePollInFlight = null;
let updateRestartExpected = false; // 更新结束后预计重启,构建阶段仍照常读进度
let updateRestartPending = false; // 正在等待服务恢复
let updateWatchdogTimer = null; // 重启守侧自己的定时器,跟任务进度轮询互不干扰
let updateWatchdogDeadline = 0;
let updateWatchdogSlow = null; // null=还没超时;'running'=仍由旧进程应答;'down'=服务没回来
@@ -75,8 +75,8 @@ function scheduleUpdateAutoCheck() {
if (!status || !status.installed || isUpdateJobRunning()) return false;
updateAutoCheckDone = true;
setTimeout(() => {
if (!isUpdateConsoleActive()) return;
checkForUpdates();
if (!isUpdateConsoleActive() || isUpdateJobRunning() || updateRestartPending) return;
checkForUpdates({ quiet: true });
}, 1200);
return true;
}
@@ -122,8 +122,8 @@ function resetUpdateCheck() {
// ok 不等于状态码:rollback 在"源码回了、二进制没回"这种半途情况下是按 202 回答并带着
// error 字段的(handler 里的 statusForError 把 no_toolchain 归到 202),只信 resp.ok
// 就会对着一次没做完的回滚播报成功。
async function runUpdateRequest(method, url, body) {
const resp = await apiFetch(url, {
async function runUpdateRequest(method, url, body, request = apiFetch) {
const resp = await request(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
@@ -138,6 +138,20 @@ async function runUpdateRequest(method, url, body) {
};
}
// A proxy 401 or a dropped connection during the switch must not clear the session.
function fetchDuringUpdate(url, options = {}) {
const headers = { Accept: 'application/json', ...options.headers };
if (typeof authToken === 'string' && authToken) headers.Authorization = 'Bearer ' + authToken;
return fetch(url, {
...options,
credentials: 'same-origin',
cache: 'no-store',
redirect: 'error',
headers,
signal: typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(5000) : undefined,
});
}
async function fetchUpdateStatus() {
const r = await runUpdateRequest('GET', '/api/system/update');
if (!r.ok) throw new Error(r.error);
@@ -145,22 +159,14 @@ async function fetchUpdateStatus() {
status: r.data.status || {},
job: r.data.job || null,
canRestart: !!r.data.canRestart,
supervised: !!r.data.supervised,
bootID: r.data.bootID || '',
needsRestart: !!r.data.needsRestart,
binaryBuiltAt: r.data.binaryBuiltAt || '',
source: r.data.source || { repo: '', configured: false, defaultRepo: '' },
};
noteRestartDefault();
return updateConsoleState;
}
// 勾选框的默认值:有守护(launchd/systemd 的启动标记)才默认勾上——没人守护时默认勾上
// 等于"一键把服务点停"。操作员自己动过勾选框之后,服务端的值不再覆盖他的选择。
function noteRestartDefault() {
if (updateRestartChoiceSet) return;
updateRestartChoice = !!(updateConsoleState && updateConsoleState.supervised);
}
// notify 只在平台真的载了 showNotification 时才动。它绝不能成为"操作看起来失败"的原因:
// 请求已经成功了,为了播报一句去调一个页面上不存在的函数,会把成功炸成失败。
function notify(message, type) {
@@ -212,8 +218,6 @@ async function loadUpdateConsole() {
return;
}
resetUpdateCheck();
updateRestartChoiceSet = false;
updateRestartChoice = true; // 之后由 noteRestartDefault 按 supervised 修正
updateActionError = ''; // 重新进页 = 干净画面,上一次动作的红字不跟过来
const el = document.getElementById('update-console');
if (!el) return;
@@ -226,7 +230,12 @@ async function loadUpdateConsole() {
return;
}
renderUpdateConsole();
// 开页就有一次在跑的更新(比如另开一个标签页点的),接着看它,别让人以为没在动。
const job = updateJobOf();
updateRestartExpected = !!(job && job.restartRequested && job.state === 'running');
if (job && job.state === 'succeeded' && job.restartRequested && job.result?.binaryBuilt) {
startUpdateWatchdog();
return;
}
if (isUpdateJobRunning()) startUpdatePolling();
scheduleUpdateAutoCheck();
}
@@ -246,8 +255,7 @@ function stopUpdatePolling() {
// 重启守侧:请求重启之后,盯着"新进程是否已经接客",接上了就整页刷新
// ---------------------------------------------------------------------------
// 只认一个新进程已经应答的事实:会话只活在旧进程内存里,新进程对旧令牌只会回 401。
// 200 说明还在跟旧进程说话(还没退出),连不上/502 说明正在退出或还没起来——都继续等。
// 服务启动标记变了才刷新。会话过期和代理的错误页都不代表换版。
// 离开控制台就静默停表:不把已经走开的用户从别的页面拽回更新页。
let updateWatchdogProbing = false;
@@ -259,28 +267,41 @@ async function watchdogProbe() {
if (updateWatchdogProbing) return;
updateWatchdogProbing = true;
let status = 0;
let bootID = '';
let legacyUnauthorized = false;
const previousBoot = updateConsoleState && updateConsoleState.bootID;
try {
const resp = await fetch('/api/system/update', {
method: 'GET',
credentials: 'same-origin',
cache: 'no-store',
headers: { Accept: 'application/json' },
});
const resp = await fetchDuringUpdate('/api/auth/validate');
status = resp.status;
bootID = resp.headers?.get('X-CyberStrike-Boot-ID') || '';
if (status === 401 && !bootID) {
// A downgrade may return to a build that predates the boot header.
const data = await resp.json().catch(() => null);
legacyUnauthorized = data?.error === '未授权访问,请先登录';
}
} catch (err) {
status = 0;
} finally {
updateWatchdogProbing = false;
}
if (status === 401) {
// 新进程已经接客、且不认我们的会话:整页刷新落到新版本,带 hash 回到本页。
// 先停表:页面马上要整体换掉,定时器不该再有第二次动作。
if (!isUpdateConsoleActive() || !updateWatchdogTimer) return;
if ((bootID && bootID !== previousBoot && (status === 200 || status === 401)) || legacyUnauthorized) {
stopUpdateWatchdog();
location.replace(location.pathname + '?restarted=' + Date.now() + '#system-update');
const params = new URLSearchParams(location.search);
params.set('restarted', Date.now());
location.replace(location.pathname + '?' + params + '#system-update');
return;
}
if (updateRestartExpected && previousBoot && bootID === previousBoot && status === 200) {
// The progress request may have hit a transient network error during the build.
stopUpdateWatchdog();
updateRestartPending = false;
renderUpdateConsole();
startUpdatePolling();
return;
}
if (updateWatchdogSlow === null && Date.now() > updateWatchdogDeadline) {
updateWatchdogSlow = status === 200 ? 'running' : 'down';
updateWatchdogSlow = bootID || status === 200 ? 'running' : 'down';
renderUpdateWatchdog();
}
}
@@ -306,18 +327,25 @@ async function pollUpdateJob() {
stopUpdatePolling();
return;
}
const timer = updatePollTimer;
if (!timer || updatePollInFlight === timer) return;
updatePollInFlight = timer;
// 传输层死亡(连接被拒、进程已经退出)是 reject 而不是带状态码的回应:两者在这里
// 归一——重启进行中它就是"进程退了",否则才是要播报的读取失败。
let r;
try {
r = await runUpdateRequest('GET', '/api/system/update/job');
r = await runUpdateRequest('GET', '/api/system/update/job', undefined,
updateRestartExpected ? fetchDuringUpdate : apiFetch);
} catch (err) {
r = { ok: false, error: err && err.message ? err.message : String(err) };
} finally {
if (updatePollInFlight === timer) updatePollInFlight = null;
}
if (timer !== updatePollTimer || !isUpdateConsoleActive()) return;
if (!r.ok) {
stopUpdatePolling();
if (updateRestartPending) {
// 进程按请求退了:这不是"读进度失败",是重启进入了下一幕。
if (updateRestartExpected) {
// The page may have missed the last progress response before handoff.
startUpdateWatchdog();
return;
}
@@ -327,12 +355,16 @@ async function pollUpdateJob() {
const job = r.data && r.data.job ? r.data.job : null;
if (updateConsoleState) updateConsoleState.job = job;
renderUpdateConsole();
if (job && job.state === 'running') updateRestartExpected = !!job.restartRequested;
if (!job && updateRestartExpected) {
stopUpdatePolling();
startUpdateWatchdog();
return;
}
if (job && job.state !== 'running') {
stopUpdatePolling();
if (job.state === 'succeeded' && job.restartRequested) {
// 进程马上要让位给新二进制:先把结论播报掉,再进自恢复视图;不再回头
// 去读一个即将消失的服务(reportFinishedJob 会去读,这里跳过它)。
notify(updateDoneToast(job.result || null), 'success');
updateRestartExpected = false;
if (job.state === 'succeeded' && job.restartRequested && job.result?.binaryBuilt) {
startUpdateWatchdog();
return;
}
@@ -357,7 +389,10 @@ function reportFinishedJob(job) {
const result = job.result || null;
if (job.state === 'succeeded') {
notify(updateDoneToast(result), 'success');
if (result && result.needsRestart) notify(updateT('needsRestartToast'), 'info');
if (job.restartError) {
updateActionError = updateT('restartFailed', { reason: job.restartError });
notify(updateActionError, 'error');
} else if (result && result.needsRestart) notify(updateT('needsRestartToast'), 'info');
} else {
const reason = (job.failure && job.failure.message) ? job.failure.message : updateT('failureNoMessage');
notify(updateT('jobFailedToast') + ': ' + reason, 'error');
@@ -373,7 +408,7 @@ function reportFinishedJob(job) {
// 动作
// ---------------------------------------------------------------------------
async function checkForUpdates() {
async function checkForUpdates({ quiet = false } = {}) {
await withUpdateBusy(async () => {
updateChecking = true;
renderUpdateConsole();
@@ -397,6 +432,8 @@ async function checkForUpdates() {
renderUpdateConsole();
if (updateCheck.error) {
notify(updateT(UPDATE_FAILURE_KEYS.check, { reason: updateCheck.error }), 'error');
} else if (quiet) {
return;
} else if (status.updateAvailable) {
notify(updateT('behindCount', { n: updateBehindCount(status) }), 'info');
} else {
@@ -430,32 +467,6 @@ function updateApplyBlockers(status, job) {
return blockers;
}
function updateRestartChoiceChanged(checked) {
updateRestartChoice = !!checked;
updateRestartChoiceSet = true; // 操作员自己决定过之后,服务端的默认值不再覆盖他
renderUpdateConsole();
}
// 勾选框是唯一的真相来源;模块里那份只是给轮询重绘用的记忆。
function updateRestartRequested() {
const box = document.getElementById('update-restart-choice');
if (box && typeof box.checked === 'boolean') return box.checked;
return updateRestartChoice;
}
function updateConfirmText(status, restart) {
// 补编译和"更新 N 个提交"是两件事:一句说"0 个新提交"的确认框会让人以为白点。
const base = (!status.updateAvailable && status.buildPending)
? updateT('confirmBuildPending', { commit: status.commit || '-' })
: updateT('confirmApply', {
repo: status.repo || '-',
target: status.remoteCommit || '-',
commit: status.commit || '-',
n: updateBehindCount(status),
});
return base + ' ' + (restart ? updateT('confirmRestartYes') : updateT('confirmRestartNo'));
}
async function startUpdateApply() {
if (updateBusy) return;
const status = updateStatusOf();
@@ -466,22 +477,21 @@ async function startUpdateApply() {
notify(blockers[0], 'error');
return;
}
const restart = updateRestartRequested();
if (!window.confirm(updateConfirmText(status, restart))) return;
const restart = updateCanRestart();
await withUpdateBusy(async () => {
const r = await runUpdateRequest('POST', '/api/system/update/apply', { restart: restart });
if (r.status === 409 && r.data.job) {
// 另一次更新真的在跑:把它的进度接下来并接着看,这才是"当前状态"。
if (updateConsoleState) updateConsoleState.job = r.data.job;
updateRestartExpected = !!r.data.job.restartRequested;
renderUpdateConsole();
startUpdatePolling();
}
if (!r.ok) throw new Error(r.error);
resetUpdateCheck();
if (restart) updateRestartPending = true;
updateRestartExpected = restart;
startUpdatePolling();
notify(updateT('applyAccepted', { id: r.data.job_id || '-' }), 'success');
await pollUpdateJob();
}, 'apply');
}
@@ -496,6 +506,7 @@ async function startRestartNow() {
const r = await runUpdateRequest('POST', '/api/system/update/restart');
if (!r.ok) throw new Error(r.error);
stopUpdatePolling();
updateRestartExpected = false;
notify(updateT('restartRequestedToast'), 'info');
startUpdateWatchdog();
}, 'restart');
@@ -574,7 +585,7 @@ async function previewAdoptSource() {
async function confirmAdoptSource() {
if (updateBusy || !updateAdoptPlan) return;
const restart = updateRestartRequested();
const restart = updateCanRestart();
const text = updateT('adoptConfirmText', {
incoming: updateAdoptPlan.incoming,
overwritten: updateAdoptPlan.overwrittenTotal,
@@ -587,7 +598,7 @@ async function confirmAdoptSource() {
if (!r.ok) throw new Error(r.error);
updateAdoptPlan = null;
resetUpdateCheck();
if (restart) updateRestartPending = true;
updateRestartExpected = restart;
startUpdatePolling();
notify(updateT('adoptAccepted', { id: r.data.job_id || '-' }), 'success');
await pollUpdateJob();
@@ -670,7 +681,7 @@ function renderAdoptBody() {
parts.push(list(plan.overwritten, updateT('adoptOverwrittenTitle')));
parts.push(list(plan.protected, updateT('adoptProtectedTitle')));
parts.push('<div class="update-hint">' + escapeHtml(updateT('adoptBackupNote')) + '</div>');
parts.push(renderUpdateRestartChoice());
parts.push(renderUpdateSwitchHint());
parts.push('<div class="update-actions"><button class="btn-primary update-adopt-confirm-btn"' + disabled +
' data-require-permission="update:apply" onclick="confirmAdoptSource()">' +
escapeHtml(updateT('adoptConfirmBtn')) + '</button></div>');
@@ -755,18 +766,9 @@ function renderUpdateChangesTable(list) {
'</tr></thead><tbody>' + rows + '</tbody></table>';
}
function renderUpdateRestartChoice() {
if (!updateCanRestart()) {
// 后端没给重启钩子,勾选框就不该出现:勾一个不会被兑现的选项是骗人。
return '<div class="update-hint">' + escapeHtml(updateT('restartNotWired')) + '</div>';
}
const preview = updateRestartChoice ? updateT('confirmRestartYes') : updateT('confirmRestartNo');
return '<label class="update-restart-choice">' +
'<input type="checkbox" id="update-restart-choice"' +
(updateRestartChoice ? ' checked' : '') +
' onchange="updateRestartChoiceChanged(this.checked)">' +
'<span>' + escapeHtml(updateT('restartChoiceLabel')) + '</span></label>' +
'<div class="update-hint update-restart-preview">' + escapeHtml(preview) + '</div>';
function renderUpdateSwitchHint() {
return '<div class="update-hint">' +
escapeHtml(updateT(updateCanRestart() ? 'confirmRestartYes' : 'restartNotWired')) + '</div>';
}
function renderUpdateApplyBody(status, job) {
@@ -804,7 +806,7 @@ function renderUpdateApplyBody(status, job) {
escapeHtml(updateChecking ? updateT('checking') : updateT('checkBtn')) + '</button>' +
'</div>');
parts.push(renderUpdateRestartChoice());
parts.push(renderUpdateSwitchHint());
if (blocked) {
parts.push('<ul class="update-blocker-list">' + blockers.map(b =>
@@ -949,8 +951,10 @@ function renderRestartBanner() {
});
return '<div class="update-restart-banner">' +
'<div class="update-restart-banner-text">' + escapeHtml(text) + '</div>' +
'<button class="btn-primary update-restart-btn" data-require-permission="update:apply" onclick="startRestartNow()">' +
escapeHtml(updateT('restartNowBtn')) + '</button></div>';
(updateCanRestart() ?
'<button class="btn-primary update-restart-btn" data-require-permission="update:apply" onclick="startRestartNow()">' +
escapeHtml(updateT('restartNowBtn')) + '</button>' : '') +
'</div>';
}
// 自恢复视图:重启请求发出后控制台画的就是它,直到新进程应答触发整页刷新。
@@ -964,10 +968,10 @@ function renderUpdateWatchdog() {
if (updateWatchdogSlow) {
parts.push('<div class="update-overlay-slow">' + escapeHtml(updateT(
updateWatchdogSlow === 'running' ? 'restartOverlaySlowRunning' : 'restartOverlaySlowDown')) + '</div>');
parts.push('<div class="update-overlay-actions">' +
'<button class="btn-secondary" type="button" onclick="refreshUpdatePage()">' +
escapeHtml(updateT('restartOverlayRefresh')) + '</button></div>');
}
parts.push('<div class="update-overlay-actions">' +
'<button class="btn-secondary" type="button" onclick="refreshUpdatePage()">' +
escapeHtml(updateT('restartOverlayRefresh')) + '</button></div>');
parts.push('</div>');
el.innerHTML = parts.join('');
}
@@ -1007,7 +1011,6 @@ window.startUpdateApply = startUpdateApply;
window.startRestartNow = startRestartNow;
window.refreshUpdatePage = refreshUpdatePage;
window.rollbackUpdate = rollbackUpdate;
window.updateRestartChoiceChanged = updateRestartChoiceChanged;
window.stopUpdatePolling = stopUpdatePolling;
window.renderUpdateConsole = renderUpdateConsole;
window.saveUpdateSource = saveUpdateSource;
+1 -1
View File
@@ -7078,7 +7078,7 @@
<script src="/static/js/tasks.js?v=20261027-pagination-custom-select"></script>
<script src="/static/js/workflow-package-client.js"></script>
<script src="/static/js/workflows.js"></script>
<script src="/static/js/update.js?v={{.Version}}"></script>
<script src="/static/js/update.js?v={{.Version}}-auto-switch"></script>
<script src="/static/js/roles.js?v=20261027-pagination-custom-select"></script>
<script src="/static/js/rbac.js"></script>
<script src="/static/js/c2.js?v=20261027-pagination-custom-select"></script>