mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-11-14 08:17:36 +01:00
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:
27 files changed
+821
-370
No files matched your search
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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 包解压安装、
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
}()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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": "نتيجة التحديث",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Результат обновления",
|
||||
|
||||
@@ -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": "更新结果",
|
||||
|
||||
@@ -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'] : [],
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user