diff --git a/.github/workflows/self-update.yml b/.github/workflows/self-update.yml index 582cb0eb..2c693472 100644 --- a/.github/workflows/self-update.yml +++ b/.github/workflows/self-update.yml @@ -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 diff --git a/cmd/server/main.go b/cmd/server/main.go index b2581e5e..1c4d2e4a 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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() { diff --git a/docs/en-US/deployment.md b/docs/en-US/deployment.md index 0e9d82cc..f91bc7df 100644 --- a/docs/en-US/deployment.md +++ b/docs/en-US/deployment.md @@ -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 diff --git a/docs/en-US/developer-guide.md b/docs/en-US/developer-guide.md index 96081f18..45c24bed 100644 --- a/docs/en-US/developer-guide.md +++ b/docs/en-US/developer-guide.md @@ -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 diff --git a/docs/en-US/plugin-development.md b/docs/en-US/plugin-development.md index 66ef2490..dececffe 100644 --- a/docs/en-US/plugin-development.md +++ b/docs/en-US/plugin-development.md @@ -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 | diff --git a/docs/zh-CN/deployment.md b/docs/zh-CN/deployment.md index 1cd0c344..db93e838 100644 --- a/docs/zh-CN/deployment.md +++ b/docs/zh-CN/deployment.md @@ -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 包解压安装、 diff --git a/docs/zh-CN/developer-guide.md b/docs/zh-CN/developer-guide.md index c9d752bf..4a42d4ae 100644 --- a/docs/zh-CN/developer-guide.md +++ b/docs/zh-CN/developer-guide.md @@ -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 存储重新加载。首次从旧版升级时可能需要重新登录。离开控制台则停止探测。 ## 数据库 diff --git a/internal/app/app.go b/internal/app/app.go index 891c22c1..36de93a6 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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) diff --git a/internal/app/update_restart_other.go b/internal/app/update_restart_other.go new file mode 100644 index 00000000..dc621f5a --- /dev/null +++ b/internal/app/update_restart_other.go @@ -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 } diff --git a/internal/app/update_restart_unix.go b/internal/app/update_restart_unix.go new file mode 100644 index 00000000..94b31add --- /dev/null +++ b/internal/app/update_restart_unix.go @@ -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 + } +} diff --git a/internal/app/update_restart_unix_test.go b/internal/app/update_restart_unix_test.go new file mode 100644 index 00000000..1dbcdf55 --- /dev/null +++ b/internal/app/update_restart_unix_test.go @@ -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") +} diff --git a/internal/handler/openapi.go b/internal/handler/openapi.go index 26b22115..e8cae135 100644 --- a/internal/handler/openapi.go +++ b/internal/handler/openapi.go @@ -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", }, }, }, diff --git a/internal/handler/update.go b/internal/handler/update.go index 3cdcfce6..657be816 100644 --- a/internal/handler/update.go +++ b/internal/handler/update.go @@ -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() }() diff --git a/internal/handler/update_http_test.go b/internal/handler/update_http_test.go index 3f26cd76..4a164be6 100644 --- a/internal/handler/update_http_test.go +++ b/internal/handler/update_http_test.go @@ -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") } } diff --git a/internal/security/auth_manager.go b/internal/security/auth_manager.go index 48f25f77..6f15c04f 100644 --- a/internal/security/auth_manager.go +++ b/internal/security/auth_manager.go @@ -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() } diff --git a/internal/security/auth_restart.go b/internal/security/auth_restart.go new file mode 100644 index 00000000..2248a40d --- /dev/null +++ b/internal/security/auth_restart.go @@ -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 +} diff --git a/internal/security/auth_restart_test.go b/internal/security/auth_restart_test.go new file mode 100644 index 00000000..6d3937b7 --- /dev/null +++ b/internal/security/auth_restart_test.go @@ -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") + } +} diff --git a/internal/security/auth_role_sessions_test.go b/internal/security/auth_role_sessions_test.go index 3e20fb33..f09ff644 100644 --- a/internal/security/auth_role_sessions_test.go +++ b/internal/security/auth_role_sessions_test.go @@ -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 { diff --git a/web/static/css/style.css b/web/static/css/style.css index b423436a..a0f3cc8d 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -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; diff --git a/web/static/i18n/ar-SA.json b/web/static/i18n/ar-SA.json index 735612a6..6f87e584 100644 --- a/web/static/i18n/ar-SA.json +++ b/web/static/i18n/ar-SA.json @@ -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": "نتيجة التحديث", diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index 01b85ae0..d225707f 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -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", diff --git a/web/static/i18n/ru-RU.json b/web/static/i18n/ru-RU.json index 5d2e6fc1..eb2fe9a1 100644 --- a/web/static/i18n/ru-RU.json +++ b/web/static/i18n/ru-RU.json @@ -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": "Результат обновления", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index 039fcb6d..7f55a325 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -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": "更新结果", diff --git a/web/static/js/permission-gate-state.test.cjs b/web/static/js/permission-gate-state.test.cjs index 8e72d998..f68b35f8 100644 --- a/web/static/js/permission-gate-state.test.cjs +++ b/web/static/js/permission-gate-state.test.cjs @@ -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: () => '
', isUpdateJobRunning: () => true, updateApplyBlockers: (status) => (status.blockingChanges || []).length ? ['blocked'] : [], diff --git a/web/static/js/update-ui.test.cjs b/web/static/js/update-ui.test.cjs index 1e42d24b..4c885bd3 100644 --- a/web/static/js/update-ui.test.cjs +++ b/web/static/js/update-ui.test.cjs @@ -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, / - +