mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-11-14 00:07:10 +01:00
Merge pull request #397 from Sycun/codex/fix-update-auto-refresh
fix(update): restore the browser after switching builds
This commit is contained in:
22 files changed
+385
-132
No files matched your search
+3
-2
@@ -22,9 +22,10 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
updateActivation := app.TakeUpdateActivation()
|
||||
updateSessions := app.TakeUpdateSessionHandoff()
|
||||
var configPath = flag.String("config", "config.yaml", "Path to the configuration file")
|
||||
var httpsBootstrap = flag.Bool("https", false, "Enable HTTPS for the main site; uses an in-memory self-signed certificate when no cert/key is configured")
|
||||
var httpsBootstrap = flag.Bool("https", false, "Enable HTTPS for the main site; creates and reuses a local 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")
|
||||
var resetAdminPassword = flag.Bool("reset-admin-password", false, "Interactively reset the built-in admin password and exit")
|
||||
checkIsolation := flag.Bool("check-process-isolation", false, "Probe task containment and cleanup, then exit without starting services")
|
||||
@@ -157,7 +158,7 @@ func main() {
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
// 创建应用
|
||||
application, err := app.New(cfg, log, cp)
|
||||
application, err := app.New(cfg, log, cp, updateActivation)
|
||||
if err != nil {
|
||||
log.Fatal("应用初始化失败", "error", err)
|
||||
}
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ server:
|
||||
# 方式 A(推荐生产):PEM 证书与私钥路径
|
||||
# tls_cert_path: /path/to/fullchain.pem
|
||||
# tls_key_path: /path/to/privkey.pem
|
||||
# 方式 B(仅本地/测试):无证书文件时内存自签(浏览器会提示不受信任;SAN 含 localhost / 127.0.0.1)
|
||||
# 方式 B(仅本地/测试):在配置目录的 data/tls/self-signed.pem 保存并复用自签证书和私钥(SAN 含 localhost / 127.0.0.1)。
|
||||
tls_auto_self_sign: true
|
||||
# 认证配置
|
||||
auth:
|
||||
|
||||
@@ -64,6 +64,8 @@ server:
|
||||
tls_auto_self_sign: true
|
||||
```
|
||||
|
||||
The generated certificate and private key are saved in `data/tls/self-signed.pem` under the configuration directory and reused across restarts and updates. The first upgrade from an in-memory certificate generates a new certificate. To rotate it, stop the service and move the file aside before starting again.
|
||||
|
||||
For production, use real certificates or terminate TLS at a reverse proxy. If the proxy terminates TLS and forwards HTTP to the app, avoid enabling app-side TLS on the same upstream unless `proxy_pass` uses HTTPS.
|
||||
|
||||
Nginx must not buffer SSE:
|
||||
|
||||
@@ -43,6 +43,8 @@ https://127.0.0.1:8080/
|
||||
|
||||
自签证书会触发浏览器安全提示,这是本地测试的正常现象。生产环境建议配置真实证书。
|
||||
|
||||
自动生成的证书和私钥保存在配置目录下的 `data/tls/self-signed.pem`,后续启动和一键更新都会复用。首次从内存自签版本升级时会生成新的证书。需要轮换时,先停止服务并移走此文件,下次启动会生成新的证书。
|
||||
|
||||
`run.sh` 是最常用的启动入口,适合:
|
||||
|
||||
- 本机体验。
|
||||
|
||||
+8
-6
@@ -46,6 +46,7 @@ import (
|
||||
type App struct {
|
||||
shutdownOnce sync.Once
|
||||
config *config.Config
|
||||
configDir string
|
||||
logger *logger.Logger
|
||||
router *gin.Engine
|
||||
mcpServer *mcp.Server
|
||||
@@ -79,7 +80,7 @@ type App struct {
|
||||
}
|
||||
|
||||
// New 创建新应用
|
||||
func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error) {
|
||||
func New(cfg *config.Config, log *logger.Logger, configPath, updateActivation string) (*App, error) {
|
||||
if err := cfg.AgentRuntime.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -516,6 +517,7 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
|
||||
// 创建 App 实例(部分字段稍后填充)
|
||||
app := &App{
|
||||
config: cfg,
|
||||
configDir: configDir,
|
||||
logger: log,
|
||||
router: router,
|
||||
mcpServer: mcpServer,
|
||||
@@ -622,7 +624,7 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
|
||||
})
|
||||
|
||||
updateHandler := handler.NewUpdateHandler(configDir, log.Logger, auditSvc,
|
||||
app.updateRestart(configDir), configHandler.UpdateSource, configHandler.SetUpdateSource)
|
||||
app.updateRestart(configDir), updateActivation, configHandler.UpdateSource, configHandler.SetUpdateSource)
|
||||
|
||||
// 设置路由(使用 App 实例以便动态获取 handler)
|
||||
setupRoutes(
|
||||
@@ -730,7 +732,7 @@ func (a *App) RunWithContext(ctx context.Context) error {
|
||||
|
||||
// 启动主服务器(可选 HTTPS + HTTP/2,见 config server.tls_*)
|
||||
addr := fmt.Sprintf("%s:%d", a.config.Server.Host, a.config.Server.Port)
|
||||
tlsMode, tlsConf, certFile, keyFile, tlsErr := prepareMainServerTLS(&a.config.Server)
|
||||
tlsMode, tlsConf, certFile, keyFile, tlsErr := prepareMainServerTLS(&a.config.Server, a.configDir)
|
||||
if tlsErr != nil {
|
||||
return tlsErr
|
||||
}
|
||||
@@ -749,8 +751,8 @@ func (a *App) RunWithContext(ctx context.Context) error {
|
||||
zap.String("address", addr),
|
||||
zap.String("cert", certFile),
|
||||
)
|
||||
case mainTLSInMemorySelfSigned:
|
||||
a.logger.Debug("启动 HTTPS 主服务(内存自签证书,仅测试;已启用 HTTP/2 协商)",
|
||||
case mainTLSAutoSelfSigned:
|
||||
a.logger.Debug("启动 HTTPS 主服务(自动自签证书,仅测试;已启用 HTTP/2 协商)",
|
||||
zap.String("address", addr),
|
||||
)
|
||||
}
|
||||
@@ -800,7 +802,7 @@ func (a *App) RunWithContext(ctx context.Context) error {
|
||||
err = srv.ListenAndServe()
|
||||
case tlsMode == mainTLSFromFiles:
|
||||
err = srv.ListenAndServeTLS(certFile, keyFile)
|
||||
case tlsMode == mainTLSInMemorySelfSigned:
|
||||
case tlsMode == mainTLSAutoSelfSigned:
|
||||
var ln net.Listener
|
||||
ln, err = tls.Listen("tcp", addr, srv.TLSConfig)
|
||||
if err == nil {
|
||||
|
||||
@@ -89,17 +89,14 @@ func TestServerHTTPRedirectEnabled(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMainServerMuxHTTPRedirectAndHTTPS(t *testing.T) {
|
||||
cert, err := generateMainServerSelfSignedCert()
|
||||
_, tlsConfig, _, _, err := prepareMainServerTLS(&config.ServerConfig{TLSAutoSelfSign: true}, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("generate cert: %v", err)
|
||||
}
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.WriteString(w, "ok")
|
||||
})
|
||||
srv := &http.Server{Handler: handler, TLSConfig: &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
Certificates: []tls.Certificate{cert},
|
||||
}}
|
||||
srv := &http.Server{Handler: handler, TLSConfig: tlsConfig}
|
||||
if err := http2.ConfigureServer(srv, &http2.Server{}); err != nil {
|
||||
t.Fatalf("configure http2: %v", err)
|
||||
}
|
||||
|
||||
@@ -8,9 +8,13 @@ import (
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -23,13 +27,13 @@ type mainTLSMode int
|
||||
const (
|
||||
mainTLSOff mainTLSMode = iota
|
||||
mainTLSFromFiles
|
||||
mainTLSInMemorySelfSigned
|
||||
mainTLSAutoSelfSigned
|
||||
)
|
||||
|
||||
// prepareMainServerTLS 根据 server 配置决定主站是否启用 HTTPS(及 HTTP/2 协商)。
|
||||
// fromFiles:使用 tls_cert_path + tls_key_path,由 http.Server.ListenAndServeTLS 加载 PEM。
|
||||
// inMemory:tls_auto_self_sign 生成的自签证书,仅用于本地/测试。
|
||||
func prepareMainServerTLS(cfg *config.ServerConfig) (mode mainTLSMode, tlsConf *tls.Config, certFile, keyFile string, err error) {
|
||||
// autoSelfSigned:tls_auto_self_sign 生成并复用的自签证书,仅用于本地/测试。
|
||||
func prepareMainServerTLS(cfg *config.ServerConfig, configDir string) (mode mainTLSMode, tlsConf *tls.Config, certFile, keyFile string, err error) {
|
||||
if cfg == nil || !config.MainWebUIUsesHTTPS(cfg) {
|
||||
return mainTLSOff, nil, "", "", nil
|
||||
}
|
||||
@@ -40,28 +44,71 @@ func prepareMainServerTLS(cfg *config.ServerConfig) (mode mainTLSMode, tlsConf *
|
||||
return mainTLSFromFiles, &tls.Config{MinVersion: tls.VersionTLS12}, certFile, keyFile, nil
|
||||
}
|
||||
if cfg.TLSAutoSelfSign {
|
||||
cert, genErr := generateMainServerSelfSignedCert()
|
||||
if genErr != nil {
|
||||
return mainTLSOff, nil, "", "", fmt.Errorf("生成自签 TLS 证书: %w", genErr)
|
||||
path := filepath.Join(configDir, "data", "tls", "self-signed.pem")
|
||||
cert, certErr := loadOrCreateMainServerSelfSignedCert(path)
|
||||
if certErr != nil {
|
||||
return mainTLSOff, nil, "", "", fmt.Errorf("读取或保存自签 TLS 证书 %s: %w", path, certErr)
|
||||
}
|
||||
tlsConf = &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
Certificates: []tls.Certificate{cert},
|
||||
}
|
||||
return mainTLSInMemorySelfSigned, tlsConf, "", "", nil
|
||||
return mainTLSAutoSelfSigned, tlsConf, "", "", nil
|
||||
}
|
||||
return mainTLSOff, nil, "", "", fmt.Errorf("server: 已启用 TLS(tls_enabled / tls_auto_self_sign / 证书路径),请设置 tls_cert_path 与 tls_key_path,或将 tls_auto_self_sign 设为 true(仅测试环境)")
|
||||
}
|
||||
|
||||
func generateMainServerSelfSignedCert() (tls.Certificate, error) {
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
func loadOrCreateMainServerSelfSignedCert(path string) (tls.Certificate, error) {
|
||||
bundle, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
return tls.X509KeyPair(bundle, bundle)
|
||||
}
|
||||
if !errors.Is(err, fs.ErrNotExist) {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
bundle, err = generateMainServerSelfSignedPEM()
|
||||
if err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
serial, err := rand.Int(rand.Reader, big.NewInt(1<<62))
|
||||
f, err := os.CreateTemp(dir, ".self-signed-*")
|
||||
if err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
defer os.Remove(f.Name())
|
||||
_, writeErr := f.Write(bundle)
|
||||
if writeErr == nil {
|
||||
writeErr = f.Sync()
|
||||
}
|
||||
if err := errors.Join(writeErr, f.Close()); err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
// Publish the complete pair once, even if two instances start concurrently.
|
||||
if err := os.Link(f.Name(), path); err != nil {
|
||||
if !errors.Is(err, fs.ErrExist) {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
bundle, err = os.ReadFile(path)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
}
|
||||
return tls.X509KeyPair(bundle, bundle)
|
||||
}
|
||||
|
||||
func generateMainServerSelfSignedPEM() ([]byte, error) {
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
serial, err := rand.Int(rand.Reader, big.NewInt(1<<62))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{CommonName: "CyberStrikeAI"},
|
||||
@@ -74,13 +121,13 @@ func generateMainServerSelfSignedCert() (tls.Certificate, error) {
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, err
|
||||
return nil, err
|
||||
}
|
||||
keyDER, err := x509.MarshalECPrivateKey(priv)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, err
|
||||
return nil, err
|
||||
}
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
|
||||
return tls.X509KeyPair(certPEM, keyPEM)
|
||||
return append(certPEM, keyPEM...), nil
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
)
|
||||
|
||||
func TestMainServerTLSReusesGeneratedIdentity(t *testing.T) {
|
||||
t.Parallel()
|
||||
configDir := t.TempDir()
|
||||
settings := config.ServerConfig{TLSAutoSelfSign: true}
|
||||
_, first, _, _, err := prepareMainServerTLS(&settings, configDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, next, _, _, err := prepareMainServerTLS(&settings, configDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(first.Certificates[0].Certificate[0], next.Certificates[0].Certificate[0]) {
|
||||
t.Fatal("server restart changed its self-signed certificate")
|
||||
}
|
||||
|
||||
bundlePath := filepath.Join(configDir, "data", "tls", "self-signed.pem")
|
||||
info, err := os.Stat(bundlePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if runtime.GOOS != "windows" && info.Mode().Perm()&0077 != 0 {
|
||||
t.Fatalf("TLS private key is accessible by other users: %v", info.Mode().Perm())
|
||||
}
|
||||
|
||||
brokenBundle := []byte("incomplete certificate bundle")
|
||||
if err := os.WriteFile(bundlePath, brokenBundle, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, _, _, err := prepareMainServerTLS(&settings, configDir); err == nil {
|
||||
t.Fatal("invalid certificate bundle was accepted")
|
||||
}
|
||||
bundle, err := os.ReadFile(bundlePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(bundle, brokenBundle) {
|
||||
t.Fatal("startup silently replaced the certificate bundle")
|
||||
}
|
||||
}
|
||||
@@ -6,4 +6,6 @@ import "os"
|
||||
|
||||
func TakeUpdateSessionHandoff() *os.File { return nil }
|
||||
|
||||
func (a *App) updateRestart(string) func() (func(), error) { return nil }
|
||||
func TakeUpdateActivation() string { return "" }
|
||||
|
||||
func (a *App) updateRestart(string) func(string) (func(), error) { return nil }
|
||||
@@ -18,6 +18,19 @@ import (
|
||||
)
|
||||
|
||||
const updateSessionsEnv = "CYBERSTRIKE_UPDATE_SESSIONS_FD"
|
||||
const updateActivationEnv = "CYBERSTRIKE_UPDATE_ACTIVATION"
|
||||
|
||||
// TakeUpdateActivation identifies the update that launched this process.
|
||||
// A fresh start by a supervisor has no activation ID.
|
||||
func TakeUpdateActivation() string {
|
||||
value := os.Getenv(updateActivationEnv)
|
||||
_ = os.Unsetenv(updateActivationEnv)
|
||||
pid, id, ok := strings.Cut(value, ":")
|
||||
if !ok || pid != strconv.Itoa(os.Getpid()) {
|
||||
return ""
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// TakeUpdateSessionHandoff claims the descriptor left by an in-place exec.
|
||||
// Do this before startup runs any subprocesses.
|
||||
@@ -61,7 +74,7 @@ func writeUpdateSessionHandoff(payload []byte) (*os.File, error) {
|
||||
// updateRestart only replaces the executable this instance was started from.
|
||||
// For example, go run and installs with a separate config directory must not
|
||||
// start a different executable just because the updater wrote one there.
|
||||
func (a *App) updateRestart(root string) func() (func(), error) {
|
||||
func (a *App) updateRestart(root string) func(string) (func(), error) {
|
||||
dir, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return nil
|
||||
@@ -79,7 +92,7 @@ func (a *App) updateRestart(root string) func() (func(), error) {
|
||||
if err != nil || self != path {
|
||||
return nil
|
||||
}
|
||||
return func() (func(), error) {
|
||||
return func(activationID string) (func(), error) {
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -95,7 +108,7 @@ func (a *App) updateRestart(root string) func() (func(), error) {
|
||||
// Keep the same PID, arguments, environment and working directory.
|
||||
// This works both in a terminal and under an existing supervisor.
|
||||
exec := func(payload []byte) error {
|
||||
env := os.Environ()
|
||||
env := append(os.Environ(), fmt.Sprintf("%s=%d:%s", updateActivationEnv, os.Getpid(), activationID))
|
||||
if len(payload) != 0 {
|
||||
f, err := writeUpdateSessionHandoff(payload)
|
||||
if err == nil {
|
||||
|
||||
@@ -105,6 +105,9 @@ func TestUpdateSessionHandoffChild(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if token := os.Getenv("CSAI_RESTART_TEST_TOKEN"); token != "" {
|
||||
if got := TakeUpdateActivation(); got != "upd-session-test" || os.Getenv(updateActivationEnv) != "" {
|
||||
t.Fatalf("replacement did not consume the update activation: %q", got)
|
||||
}
|
||||
if handoff == nil || os.Getenv(updateSessionsEnv) != "" {
|
||||
t.Fatal("replacement did not claim the private handoff")
|
||||
}
|
||||
@@ -142,7 +145,7 @@ func TestUpdateSessionHandoffChild(t *testing.T) {
|
||||
if err := os.Rename("next", update.Binary); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restart, err := prepare()
|
||||
restart, err := prepare("upd-session-test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -162,7 +165,7 @@ func TestUpdateRestartChild(t *testing.T) {
|
||||
if err := os.Rename("next", update.Binary); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restart, err := prepare()
|
||||
restart, err := prepare("upd-standalone-test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -695,7 +695,7 @@ type ServerConfig struct {
|
||||
// TLSCertPath / TLSKeyPath 非空时从 PEM 文件加载证书(生产环境推荐)。
|
||||
TLSCertPath string `yaml:"tls_cert_path,omitempty" json:"tls_cert_path,omitempty"`
|
||||
TLSKeyPath string `yaml:"tls_key_path,omitempty" json:"tls_key_path,omitempty"`
|
||||
// TLSAutoSelfSign 为 true 且未配置有效证书路径时,启动时生成内存自签证书(仅本地/测试;浏览器会提示不受信任)。
|
||||
// TLSAutoSelfSign 为 true 且未配置证书路径时,生成并复用安装目录中的自签证书(仅本地/测试)。
|
||||
TLSAutoSelfSign bool `yaml:"tls_auto_self_sign,omitempty" json:"tls_auto_self_sign,omitempty"`
|
||||
// TLSHTTPRedirect 为 false 时禁用 HTTP→HTTPS 跳转;省略或为 true 且已启用 HTTPS 时,明文 HTTP 访问将 308 跳转到 HTTPS(同端口嗅探分流)。
|
||||
TLSHTTPRedirect *bool `yaml:"tls_http_redirect,omitempty" json:"tls_http_redirect,omitempty"`
|
||||
|
||||
@@ -30,7 +30,7 @@ func ServerHTTPRedirectEnabled(s *ServerConfig) bool {
|
||||
}
|
||||
|
||||
// ApplyDevHTTPSBootstrap 供 --https / 一键脚本使用:强制开启主站 TLS。
|
||||
// 若已配置 tls_cert_path 与 tls_key_path 则仅用 PEM,不开启自签;否则启用 tls_auto_self_sign(内存证书,仅本地测试)。
|
||||
// 若已配置 tls_cert_path 与 tls_key_path 则使用 PEM,否则启用 tls_auto_self_sign(仅本地测试)。
|
||||
func ApplyDevHTTPSBootstrap(cfg *Config) {
|
||||
if cfg == nil {
|
||||
return
|
||||
|
||||
+43
-18
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -34,8 +35,10 @@ type UpdateHandler struct {
|
||||
logger *zap.Logger
|
||||
audit *audit.Service
|
||||
// prepareRestart validates the replacement before the running service shuts down.
|
||||
prepareRestart func() (func(), error)
|
||||
prepareRestart func(activationID string) (func(), error)
|
||||
bootID string
|
||||
activationID string
|
||||
buildRevision string
|
||||
// source reports the configured update source (config.yaml update.repo) as the
|
||||
// operator last saved it; an empty string means "not configured" and the official
|
||||
// repository is used. A function rather than a value because the config can change
|
||||
@@ -82,14 +85,25 @@ func (j *updateJob) view() *updateJob {
|
||||
// NewUpdateHandler takes the audit service as a constructor argument rather than through
|
||||
// a SetAudit the assembly has to remember: forgetting here is a compile error instead of
|
||||
// a privileged endpoint that writes no audit records at all.
|
||||
func NewUpdateHandler(root string, logger *zap.Logger, auditSvc *audit.Service, prepareRestart func() (func(), error),
|
||||
func NewUpdateHandler(root string, logger *zap.Logger, auditSvc *audit.Service, prepareRestart func(string) (func(), error), activationID string,
|
||||
source func() string, saveSource func(repo string) error) *UpdateHandler {
|
||||
var revision string
|
||||
if info, ok := debug.ReadBuildInfo(); ok {
|
||||
for _, setting := range info.Settings {
|
||||
if setting.Key == "vcs.revision" {
|
||||
revision = setting.Value
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return &UpdateHandler{
|
||||
root: root,
|
||||
logger: logger,
|
||||
audit: auditSvc,
|
||||
prepareRestart: prepareRestart,
|
||||
bootID: uuid.NewString(),
|
||||
activationID: activationID,
|
||||
buildRevision: revision,
|
||||
source: source,
|
||||
saveSource: saveSource,
|
||||
baseline: update.StampBinary(root, update.Options{Root: root}),
|
||||
@@ -145,6 +159,7 @@ func (h *UpdateHandler) sourceView() gin.H {
|
||||
// GetStatus answers GET /api/system/update: the install tree as it is on disk, without a
|
||||
// network round trip, so opening the page is never gated on GitHub being reachable.
|
||||
func (h *UpdateHandler) GetStatus(c *gin.Context) {
|
||||
c.Header("Cache-Control", "no-store")
|
||||
snap, err := update.Status(c.Request.Context(), h.options())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
@@ -152,15 +167,17 @@ func (h *UpdateHandler) GetStatus(c *gin.Context) {
|
||||
}
|
||||
needsRestart, binaryBuiltAt := h.restartPending()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": snap,
|
||||
"job": h.latest(),
|
||||
"canRestart": h.prepareRestart != nil,
|
||||
"autoRestart": h.prepareRestart != nil,
|
||||
"bootID": h.bootID,
|
||||
"supervised": restartSupervised(),
|
||||
"needsRestart": needsRestart,
|
||||
"binaryBuiltAt": binaryBuiltAt,
|
||||
"source": h.sourceView(),
|
||||
"status": snap,
|
||||
"job": h.latest(),
|
||||
"canRestart": h.prepareRestart != nil,
|
||||
"autoRestart": h.prepareRestart != nil,
|
||||
"bootID": h.bootID,
|
||||
"buildRevision": h.buildRevision,
|
||||
"activationTracking": true,
|
||||
"supervised": restartSupervised(),
|
||||
"needsRestart": needsRestart,
|
||||
"binaryBuiltAt": binaryBuiltAt,
|
||||
"source": h.sourceView(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -168,6 +185,12 @@ func (h *UpdateHandler) GetStatus(c *gin.Context) {
|
||||
// a new boot from a revoked or expired session on the current boot.
|
||||
func (h *UpdateHandler) SetBootHeader(c *gin.Context) {
|
||||
c.Header("X-CyberStrike-Boot-ID", h.bootID)
|
||||
c.Header("X-CyberStrike-Build-Revision", h.buildRevision)
|
||||
activationID := h.activationID
|
||||
if activationID == "" {
|
||||
activationID = "-"
|
||||
}
|
||||
c.Header("X-CyberStrike-Activation-ID", activationID)
|
||||
c.Header("Cache-Control", "no-store")
|
||||
}
|
||||
|
||||
@@ -260,11 +283,11 @@ func (h *UpdateHandler) Adopt(c *gin.Context) {
|
||||
}
|
||||
job, started := h.startJob("adopt", body.Restart)
|
||||
if !started {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "已有一次更新在进行中", "job": job})
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "已有一次更新在进行中", "job": job, "bootID": h.bootID})
|
||||
return
|
||||
}
|
||||
h.record(c, "update.adopt", "started", "开始把目录接入更新源", map[string]interface{}{"job": job.ID, "restart": body.Restart})
|
||||
c.JSON(http.StatusAccepted, gin.H{"job_id": job.ID, "state": job.State})
|
||||
c.JSON(http.StatusAccepted, gin.H{"job_id": job.ID, "state": job.State, "bootID": h.bootID})
|
||||
}
|
||||
|
||||
// Apply answers POST /api/system/update/apply. It starts one job and returns immediately;
|
||||
@@ -287,12 +310,12 @@ func (h *UpdateHandler) Apply(c *gin.Context) {
|
||||
|
||||
job, started := h.startJob("apply", body.Restart)
|
||||
if !started {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "已有一次更新在进行中", "job": job})
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "已有一次更新在进行中", "job": job, "bootID": h.bootID})
|
||||
return
|
||||
}
|
||||
h.record(c, "update.apply", "started", "开始一键更新源码并重编译", map[string]interface{}{"job": job.ID, "restart": body.Restart})
|
||||
|
||||
c.JSON(http.StatusAccepted, gin.H{"job_id": job.ID, "state": job.State})
|
||||
c.JSON(http.StatusAccepted, gin.H{"job_id": job.ID, "state": job.State, "bootID": h.bootID})
|
||||
}
|
||||
|
||||
// Restart answers POST /api/system/update/restart: replace this process so the binary
|
||||
@@ -327,14 +350,15 @@ func (h *UpdateHandler) Restart(c *gin.Context) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "磁盘上的二进制不存在,无法重启", "reason": "no_binary"})
|
||||
return
|
||||
}
|
||||
restart, err := h.prepareRestart()
|
||||
activationID := uuid.NewString()
|
||||
restart, err := h.prepareRestart(activationID)
|
||||
if err != nil {
|
||||
failure := &update.Error{Reason: "restart_unavailable", Message: "无法启动磁盘上的二进制:" + err.Error()}
|
||||
c.JSON(statusForError(failure), errorBody(failure))
|
||||
return
|
||||
}
|
||||
h.record(c, "update.restart", "started", "重启安装以运行磁盘上的新二进制", nil)
|
||||
c.JSON(http.StatusAccepted, gin.H{"restarting": true})
|
||||
c.JSON(http.StatusAccepted, gin.H{"restarting": true, "activation_id": activationID, "bootID": h.bootID})
|
||||
scheduled = true
|
||||
time.AfterFunc(updateRestartDelay, func() { defer release(); restart() })
|
||||
}
|
||||
@@ -342,6 +366,7 @@ func (h *UpdateHandler) Restart(c *gin.Context) {
|
||||
// Job answers GET /api/system/update/job: the running job, or the most recent one, with
|
||||
// every progress line so far.
|
||||
func (h *UpdateHandler) Job(c *gin.Context) {
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.JSON(http.StatusOK, gin.H{"job": h.latest()})
|
||||
}
|
||||
|
||||
@@ -429,7 +454,7 @@ func (h *UpdateHandler) startJob(kind string, restart bool) (*updateJob, bool) {
|
||||
if job.Restart && finish == "succeeded" && res != nil && res.BinaryBuilt && h.prepareRestart != nil {
|
||||
release, lockErr := update.LockInstall(opts)
|
||||
if lockErr == nil {
|
||||
restart, prepareErr := h.prepareRestart()
|
||||
restart, prepareErr := h.prepareRestart(job.ID)
|
||||
if prepareErr == nil {
|
||||
h.mu.Unlock()
|
||||
// Keep both CLI and HTTP writers out until the old executable is replaced.
|
||||
|
||||
@@ -36,11 +36,11 @@ func newUpdateRouter(root string, restart func()) (*gin.Engine, *UpdateHandler)
|
||||
func newUpdateRouterWithSource(root string, restart func(),
|
||||
source func() string, save func(string) error) (*gin.Engine, *UpdateHandler) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
var prepare func() (func(), error)
|
||||
var prepare func(string) (func(), error)
|
||||
if restart != nil {
|
||||
prepare = func() (func(), error) { return restart, nil }
|
||||
prepare = func(string) (func(), error) { return restart, nil }
|
||||
}
|
||||
h := NewUpdateHandler(root, nil, nil, prepare, source, save)
|
||||
h := NewUpdateHandler(root, nil, nil, prepare, "", source, save)
|
||||
router := gin.New()
|
||||
router.GET("/api/system/update", h.GetStatus)
|
||||
router.GET("/api/system/update/job", h.Job)
|
||||
|
||||
@@ -112,6 +112,7 @@
|
||||
"rollbackTitle": "التراجع",
|
||||
"root": "دليل التثبيت",
|
||||
"branch": "الفرع المحلّي",
|
||||
"runningBuild": "البنية قيد التشغيل",
|
||||
"commit": "الإيداع الحالي",
|
||||
"committedAt": "أُودِع في",
|
||||
"subject": "موضوع الإيداع",
|
||||
@@ -180,6 +181,7 @@
|
||||
"restartOverlayBody": "ستُعاد الصفحة تلقائيًا عندما يصبح الإصدار الجديد جاهزًا.",
|
||||
"restartOverlaySlowRunning": "لا تزال الخدمة تستجيب بوصفها العملية القديمة - يبدو أنّ إعادة التشغيل لم تحدث. استخدم الزرّ أدناه لمعاينة الحالة الحالية.",
|
||||
"restartOverlaySlowDown": "لم تعد الخدمة بعد. ستواصل الصفحة المحاولة. راجع سجلّات الخدمة إذا استمر الانقطاع.",
|
||||
"restartOverlayUnexpected": "عادت الخدمة، لكن لم يُؤكّد تثبيت الإصدار المستهدف. ستستمر الصفحة في التحقق، ويمكنك تحديثها لمعرفة الإصدار الحالي.",
|
||||
"restartOverlayRefresh": "تحديث هذه الصفحة",
|
||||
"failureNoMessage": "لم يقدّم الخادم أيّ رسالة فشل",
|
||||
"resultTitle": "نتيجة التحديث",
|
||||
|
||||
@@ -112,6 +112,7 @@
|
||||
"rollbackTitle": "Rollback",
|
||||
"root": "Install directory",
|
||||
"branch": "Local branch",
|
||||
"runningBuild": "Running build",
|
||||
"commit": "Current commit",
|
||||
"committedAt": "Committed at",
|
||||
"subject": "Commit subject",
|
||||
@@ -180,6 +181,7 @@
|
||||
"restartOverlayBody": "This page will reload automatically when the new version is ready.",
|
||||
"restartOverlaySlowRunning": "The service is still answering as the old process - the restart does not seem to have happened. Use the button below to look at the current state.",
|
||||
"restartOverlaySlowDown": "The service is taking longer to come back. This page will keep trying. Check the service logs if it remains unavailable.",
|
||||
"restartOverlayUnexpected": "The service is back, but this update has not been confirmed. This page will keep checking. You can also refresh to see the current version.",
|
||||
"restartOverlayRefresh": "Refresh this page",
|
||||
"failureNoMessage": "The server gave no failure message",
|
||||
"resultTitle": "Update result",
|
||||
|
||||
@@ -112,6 +112,7 @@
|
||||
"rollbackTitle": "Откат",
|
||||
"root": "Каталог установки",
|
||||
"branch": "Локальная ветка",
|
||||
"runningBuild": "Запущенная сборка",
|
||||
"commit": "Текущий коммит",
|
||||
"committedAt": "Дата коммита",
|
||||
"subject": "Заголовок коммита",
|
||||
@@ -180,6 +181,7 @@
|
||||
"restartOverlayBody": "Когда новая версия будет готова, страница обновится автоматически.",
|
||||
"restartOverlaySlowRunning": "Служба по-прежнему отвечает старым процессом — перезапуск, похоже, не произошёл. Нажмите кнопку ниже, чтобы увидеть текущее состояние.",
|
||||
"restartOverlaySlowDown": "Служба пока недоступна. Страница продолжит попытки. Если служба долго не отвечает, проверьте её журналы.",
|
||||
"restartOverlayUnexpected": "Служба снова доступна, но установка нужной сборки не подтверждена. Проверка продолжится. Текущую версию можно увидеть, обновив страницу.",
|
||||
"restartOverlayRefresh": "Обновить страницу",
|
||||
"failureNoMessage": "Сервер не передал сообщение об ошибке",
|
||||
"resultTitle": "Результат обновления",
|
||||
|
||||
@@ -112,6 +112,7 @@
|
||||
"rollbackTitle": "回滚",
|
||||
"root": "安装目录",
|
||||
"branch": "本机分支",
|
||||
"runningBuild": "运行中的构建",
|
||||
"commit": "当前提交",
|
||||
"committedAt": "提交时间",
|
||||
"subject": "提交说明",
|
||||
@@ -180,6 +181,7 @@
|
||||
"restartOverlayBody": "切换完成后,页面会自动恢复。",
|
||||
"restartOverlaySlowRunning": "服务仍在以旧进程应答——重启似乎没有发生。可点下方按钮刷新页面查看当前状态。",
|
||||
"restartOverlaySlowDown": "服务仍未恢复,页面会继续重试。如果长时间无法连接,请检查服务日志。",
|
||||
"restartOverlayUnexpected": "服务已恢复,但未能确认本次更新的目标构建已生效。页面会继续检查,也可以刷新查看当前版本。",
|
||||
"restartOverlayRefresh": "立即刷新页面",
|
||||
"failureNoMessage": "服务端没有给出失败说明",
|
||||
"resultTitle": "更新结果",
|
||||
|
||||
@@ -96,6 +96,7 @@ function envelope(status, job, canRestart, source, flags) {
|
||||
supervised: !!(flags && flags.supervised),
|
||||
autoRestart: flags?.autoRestart,
|
||||
bootID: flags?.bootID,
|
||||
activationTracking: flags?.activationTracking,
|
||||
needsRestart: !!(flags && flags.needsRestart),
|
||||
binaryBuiltAt: (flags && flags.binaryBuiltAt) || '',
|
||||
source: source || emptySource,
|
||||
@@ -316,7 +317,11 @@ function harness(options) {
|
||||
if (typeof answer === 'object') {
|
||||
return Promise.resolve({
|
||||
status: answer.status,
|
||||
headers: { get: name => name === 'X-CyberStrike-Boot-ID' ? answer.bootID : null },
|
||||
headers: { get: name => ({
|
||||
'X-CyberStrike-Boot-ID': answer.bootID,
|
||||
'X-CyberStrike-Activation-ID': answer.activationID,
|
||||
'X-CyberStrike-Build-Revision': answer.revision,
|
||||
})[name] },
|
||||
json: () => Promise.resolve(answer.body || {}),
|
||||
});
|
||||
}
|
||||
@@ -407,8 +412,8 @@ function harness(options) {
|
||||
},
|
||||
setConfirm(v) { confirmAnswer = v; },
|
||||
liveTimers() { return timers.filter(t => !t.cleared && !t.deferred); },
|
||||
tick() {
|
||||
const live = h.liveTimers();
|
||||
tick(ms) {
|
||||
const live = h.liveTimers().filter(t => ms === undefined || t.ms === ms);
|
||||
if (!live.length) throw new Error('no poll timer is armed');
|
||||
return live[live.length - 1].fn();
|
||||
},
|
||||
@@ -439,7 +444,7 @@ test('the console lives inside the settings page as its own section', () => {
|
||||
'settings-section-update must live inside page-settings');
|
||||
assert.match(template, /id="settings-section-update"[\s\S]{0,700}?id="update-console"/);
|
||||
assert.match(template, /id="update-console"/);
|
||||
assert.match(template, /<script src="\/static\/js\/update\.js\?v=\{\{\.Version\}\}-auto-switch"><\/script>/);
|
||||
assert.match(template, /<script src="\/static\/js\/update\.js\?v=\{\{\.Version\}\}-activation"><\/script>/);
|
||||
assert.match(template, /<div class="settings-section-header update-section-head">/);
|
||||
assert.match(template, /<h3 data-i18n="update\.title">/);
|
||||
|
||||
@@ -825,8 +830,7 @@ test('applying reads the job every 1.5 seconds', async () => {
|
||||
assert.ok(apply, 'no apply was posted: ' + h.calls.map(c => c.method + ' ' + c.url));
|
||||
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');
|
||||
assert.deepEqual(h.liveTimers().map(t => t.ms).sort(), [1500, 2000]);
|
||||
|
||||
// In the browser the poller runs off the timer; here the test drives the armed callback so the
|
||||
// whole progress path is exercised without waiting on wall-clock time.
|
||||
@@ -836,7 +840,7 @@ test('applying reads the job every 1.5 seconds', async () => {
|
||||
assert.deepEqual(h.calls.map(c => c.method + ' ' + c.url), ['GET /api/system/update/job']);
|
||||
assert.match(h.html(), /开始拉取更新源/, 'each step line must be rendered as it arrives');
|
||||
assert.match(h.html(), /进行中/);
|
||||
assert.equal(h.liveTimers().length, 1);
|
||||
assert.equal(h.liveTimers().filter(t => t.ms === 1500).length, 1);
|
||||
|
||||
h.calls.length = 0;
|
||||
await h.tick();
|
||||
@@ -1011,11 +1015,11 @@ test('a 202 that carries an error is not mistaken for success', async () => {
|
||||
'a half-finished rollback must not be followed by a victory lap through the status endpoint');
|
||||
});
|
||||
|
||||
test('leaving the page kills the poller, and reopening arms exactly one', async () => {
|
||||
test('leaving the page pauses both timers and returning resumes the accepted update', async () => {
|
||||
const h = await harness();
|
||||
h.fire('startUpdateApply');
|
||||
await h.flush();
|
||||
assert.equal(h.liveTimers().length, 1);
|
||||
assert.equal(h.liveTimers().length, 2);
|
||||
|
||||
h.calls.length = 0;
|
||||
h.setActive(false);
|
||||
@@ -1027,7 +1031,7 @@ test('leaving the page kills the poller, and reopening arms exactly one', async
|
||||
|
||||
h.setActive(true);
|
||||
await h.api.loadUpdateConsole();
|
||||
assert.equal(h.liveTimers().length, 0, 'a clean open arms nothing');
|
||||
assert.deepEqual(h.liveTimers().map(t => t.ms).sort(), [1500, 2000]);
|
||||
|
||||
const busy = await harness({ job: { id: 'upd-3', state: 'running', started: 'x', steps: [] } });
|
||||
assert.equal(busy.liveTimers().length, 1, 'opening onto a running job arms the poller');
|
||||
@@ -1453,11 +1457,57 @@ test('a job poll that dies while the restart is in flight is not a failure', asy
|
||||
assert.match(h.html(), /正在重启服务/, 'a dead poll while a restart is requested means the restart is in flight');
|
||||
assert.deepEqual(h.toasts.filter(t => t.type === 'error'), [], 'and it must not be toasted as a failure');
|
||||
|
||||
await h.tick();
|
||||
await h.tick(2000);
|
||||
await h.flush();
|
||||
assert.equal(h.location.replaced.length, 1);
|
||||
});
|
||||
|
||||
test('the accepted update reloads even when its final progress response is missed', async () => {
|
||||
const h = await harness({
|
||||
flags: { bootID: 'old', activationTracking: true },
|
||||
responses: {
|
||||
'GET /api/system/update/job': [
|
||||
{ body: { job: { ...runningJob, restartRequested: true } } },
|
||||
{ body: { job: null } },
|
||||
],
|
||||
},
|
||||
probes: [
|
||||
{ status: 200, bootID: 'old', activationID: '-' },
|
||||
'down',
|
||||
{ status: 200, bootID: 'unrelated', activationID: '-' },
|
||||
{ status: 200, bootID: 'new', activationID: 'upd-1' },
|
||||
],
|
||||
});
|
||||
await h.api.startUpdateApply();
|
||||
assert.match(h.html(), /开始拉取更新源/);
|
||||
await h.tick(2000);
|
||||
await h.tick(2000);
|
||||
await h.tick(1500);
|
||||
await h.tick(2000);
|
||||
assert.equal(h.location.replaced.length, 0);
|
||||
|
||||
await h.tick(2000);
|
||||
assert.equal(h.location.replaced.length, 1);
|
||||
assert.match(h.location.replaced[0], /#system-update$/);
|
||||
assert.equal(h.liveTimers().length, 0);
|
||||
});
|
||||
|
||||
test('a confirmed target commit must match the running build before reloading', async () => {
|
||||
const h = await harness({
|
||||
flags: { bootID: 'old', activationTracking: true },
|
||||
responses: { 'GET /api/system/update/job': [{ body: { job: succeededJob } }] },
|
||||
probes: [
|
||||
{ status: 200, bootID: 'new', activationID: 'upd-1', revision: 'a1b2c3d' },
|
||||
{ status: 200, bootID: 'new', activationID: 'upd-1', revision: 'f9e8d7c000000' },
|
||||
],
|
||||
});
|
||||
await h.api.startUpdateApply();
|
||||
await h.tick(2000);
|
||||
assert.equal(h.location.replaced.length, 0);
|
||||
await h.tick(2000);
|
||||
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 },
|
||||
|
||||
+113
-64
@@ -26,11 +26,11 @@ let updateBusy = false; // 页面自己发着的动作请求(检查 /
|
||||
let updateChecking = false; // 只影响「检查更新」按钮的措辞
|
||||
let updatePollTimer = null;
|
||||
let updatePollInFlight = null;
|
||||
let updateRestartExpected = false; // 更新结束后预计重启,构建阶段仍照常读进度
|
||||
let updateActivation = null; // 已接受的切换请求,独立于任务进度
|
||||
let updateRestartPending = false; // 正在等待服务恢复
|
||||
let updateWatchdogTimer = null; // 重启守侧自己的定时器,跟任务进度轮询互不干扰
|
||||
let updateWatchdogDeadline = 0;
|
||||
let updateWatchdogSlow = null; // null=还没超时;'running'=仍由旧进程应答;'down'=服务没回来
|
||||
let updateWatchdogSlow = null; // 超时后说明服务未恢复、仍在旧进程或启动了非目标构建
|
||||
let updateAutoCheckDone = false; // 每个会话只自动查一次远端,不每次重绘都去打扰
|
||||
let updateSourceDraft = null; // 编辑中的更新源仓库地址;null=还贴着服务端保存的值
|
||||
let updateSourceError = ''; // 最近一次保存/接入失败的原因,画在区块里
|
||||
@@ -75,7 +75,7 @@ function scheduleUpdateAutoCheck() {
|
||||
if (!status || !status.installed || isUpdateJobRunning()) return false;
|
||||
updateAutoCheckDone = true;
|
||||
setTimeout(() => {
|
||||
if (!isUpdateConsoleActive() || isUpdateJobRunning() || updateRestartPending) return;
|
||||
if (!isUpdateConsoleActive() || isUpdateJobRunning() || updateActivation) return;
|
||||
checkForUpdates({ quiet: true });
|
||||
}, 1200);
|
||||
return true;
|
||||
@@ -160,6 +160,8 @@ async function fetchUpdateStatus() {
|
||||
job: r.data.job || null,
|
||||
canRestart: !!r.data.canRestart,
|
||||
bootID: r.data.bootID || '',
|
||||
buildRevision: r.data.buildRevision || '',
|
||||
activationTracking: !!r.data.activationTracking,
|
||||
needsRestart: !!r.data.needsRestart,
|
||||
binaryBuiltAt: r.data.binaryBuiltAt || '',
|
||||
source: r.data.source || { repo: '', configured: false, defaultRepo: '' },
|
||||
@@ -212,9 +214,11 @@ async function withUpdateBusy(fn, label) {
|
||||
|
||||
async function loadUpdateConsole() {
|
||||
stopUpdatePolling();
|
||||
// 重启已经发起:控制台让位给自恢复视图,不再去读一个正在退出的服务。
|
||||
if (updateRestartPending) {
|
||||
// Returning to this section must keep comparing against the original process.
|
||||
if (updateActivation) {
|
||||
startUpdateWatchdog();
|
||||
if (updateRestartPending) renderUpdateWatchdog();
|
||||
if (!updateActivation.scheduled) startUpdatePolling();
|
||||
return;
|
||||
}
|
||||
resetUpdateCheck();
|
||||
@@ -231,10 +235,12 @@ async function loadUpdateConsole() {
|
||||
}
|
||||
renderUpdateConsole();
|
||||
const job = updateJobOf();
|
||||
updateRestartExpected = !!(job && job.restartRequested && job.state === 'running');
|
||||
if (job && job.state === 'succeeded' && job.restartRequested && job.result?.binaryBuilt) {
|
||||
startUpdateWatchdog();
|
||||
return;
|
||||
if (job?.restartRequested) {
|
||||
watchUpdateActivation(job.id);
|
||||
if (job.state === 'succeeded' && job.result?.binaryBuilt) {
|
||||
waitForUpdateRestart(job.result.toCommit);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (isUpdateJobRunning()) startUpdatePolling();
|
||||
scheduleUpdateAutoCheck();
|
||||
@@ -255,63 +261,92 @@ function stopUpdatePolling() {
|
||||
// 重启守侧:请求重启之后,盯着"新进程是否已经接客",接上了就整页刷新
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// 服务启动标记变了才刷新。会话过期和代理的错误页都不代表换版。
|
||||
// 离开控制台就静默停表:不把已经走开的用户从别的页面拽回更新页。
|
||||
let updateWatchdogProbing = false;
|
||||
// Watch from acceptance so a missed job response cannot prevent the reload.
|
||||
let updateWatchdogInFlight = null;
|
||||
|
||||
function watchUpdateActivation(id, bootID = updateConsoleState?.bootID) {
|
||||
updateActivation = {
|
||||
id,
|
||||
bootID,
|
||||
tracked: !!(id && updateConsoleState?.activationTracking),
|
||||
scheduled: false,
|
||||
targetCommit: '',
|
||||
};
|
||||
updateWatchdogSlow = null;
|
||||
updateWatchdogDeadline = Date.now() + UPDATE_WATCHDOG_TIMEOUT_MS;
|
||||
startUpdateWatchdog();
|
||||
}
|
||||
|
||||
function waitForUpdateRestart(targetCommit = '') {
|
||||
stopUpdatePolling();
|
||||
updateActivation.scheduled = true;
|
||||
updateActivation.targetCommit = targetCommit;
|
||||
updateWatchdogSlow = null;
|
||||
updateWatchdogDeadline = Date.now() + UPDATE_WATCHDOG_TIMEOUT_MS;
|
||||
updateRestartPending = true;
|
||||
renderUpdateWatchdog();
|
||||
}
|
||||
|
||||
async function watchdogProbe() {
|
||||
if (!isUpdateConsoleActive()) {
|
||||
stopUpdateWatchdog();
|
||||
return;
|
||||
}
|
||||
if (updateWatchdogProbing) return;
|
||||
updateWatchdogProbing = true;
|
||||
const timer = updateWatchdogTimer;
|
||||
if (!timer || updateWatchdogInFlight === timer) return;
|
||||
const activation = updateActivation;
|
||||
updateWatchdogInFlight = timer;
|
||||
let status = 0;
|
||||
let bootID = '';
|
||||
let activationID = '';
|
||||
let revision = '';
|
||||
let legacyUnauthorized = false;
|
||||
const previousBoot = updateConsoleState && updateConsoleState.bootID;
|
||||
try {
|
||||
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.
|
||||
activationID = resp.headers?.get('X-CyberStrike-Activation-ID') || '';
|
||||
revision = resp.headers?.get('X-CyberStrike-Build-Revision') || '';
|
||||
if ((!activation.tracked || activation.scheduled) && status === 401 && !bootID && !activationID) {
|
||||
const data = await resp.json().catch(() => null);
|
||||
legacyUnauthorized = data?.error === '未授权访问,请先登录';
|
||||
}
|
||||
} catch (err) {
|
||||
} catch {
|
||||
status = 0;
|
||||
} finally {
|
||||
updateWatchdogProbing = false;
|
||||
if (updateWatchdogInFlight === timer) updateWatchdogInFlight = null;
|
||||
}
|
||||
if (!isUpdateConsoleActive() || !updateWatchdogTimer) return;
|
||||
if ((bootID && bootID !== previousBoot && (status === 200 || status === 401)) || legacyUnauthorized) {
|
||||
if (!isUpdateConsoleActive() || timer !== updateWatchdogTimer || activation !== updateActivation) return;
|
||||
|
||||
const answering = status === 200 || status === 401;
|
||||
const newBoot = answering && bootID && bootID !== activation.bootID;
|
||||
// Apply results may use the short SHA. The activation ID also covers rebuilds
|
||||
// of the same commit and builds without VCS metadata.
|
||||
const targetMatches = !activation.targetCommit || !revision || revision.startsWith(activation.targetCommit);
|
||||
// A confirmed handoff may boot an older binary, e.g. after rolling back.
|
||||
const matchesActivation = !activation.tracked || activationID === activation.id ||
|
||||
(activation.scheduled && !activationID);
|
||||
if ((newBoot && targetMatches && matchesActivation) || legacyUnauthorized) {
|
||||
stopUpdatePolling();
|
||||
stopUpdateWatchdog();
|
||||
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;
|
||||
const timedOut = Date.now() > updateWatchdogDeadline;
|
||||
if (!updateRestartPending && (newBoot || timedOut)) {
|
||||
updateRestartPending = true;
|
||||
renderUpdateWatchdog();
|
||||
}
|
||||
if (updateWatchdogSlow === null && Date.now() > updateWatchdogDeadline) {
|
||||
updateWatchdogSlow = bootID || status === 200 ? 'running' : 'down';
|
||||
if (updateRestartPending && updateWatchdogSlow === null && timedOut) {
|
||||
updateWatchdogSlow = newBoot ? 'unexpected' : bootID || status === 200 ? 'running' : 'down';
|
||||
renderUpdateWatchdog();
|
||||
}
|
||||
}
|
||||
|
||||
function startUpdateWatchdog() {
|
||||
if (updateWatchdogTimer) return;
|
||||
updateRestartPending = true;
|
||||
updateWatchdogSlow = null;
|
||||
updateWatchdogDeadline = Date.now() + UPDATE_WATCHDOG_TIMEOUT_MS;
|
||||
renderUpdateWatchdog();
|
||||
if (updateWatchdogTimer || !isUpdateConsoleActive()) return;
|
||||
updateWatchdogTimer = setInterval(watchdogProbe, UPDATE_WATCHDOG_INTERVAL_MS);
|
||||
}
|
||||
|
||||
@@ -325,17 +360,17 @@ async function pollUpdateJob() {
|
||||
// 页面已经切走了。router.js 只在进入时调 initPage,离开时没人喊,
|
||||
// 所以这里自己停,不留野定时器。
|
||||
stopUpdatePolling();
|
||||
stopUpdateWatchdog();
|
||||
return;
|
||||
}
|
||||
const timer = updatePollTimer;
|
||||
if (!timer || updatePollInFlight === timer) return;
|
||||
updatePollInFlight = timer;
|
||||
// 传输层死亡(连接被拒、进程已经退出)是 reject 而不是带状态码的回应:两者在这里
|
||||
// 归一——重启进行中它就是"进程退了",否则才是要播报的读取失败。
|
||||
// Handle socket errors and HTTP failures in the same progress path.
|
||||
let r;
|
||||
try {
|
||||
r = await runUpdateRequest('GET', '/api/system/update/job', undefined,
|
||||
updateRestartExpected ? fetchDuringUpdate : apiFetch);
|
||||
updateActivation ? fetchDuringUpdate : apiFetch);
|
||||
} catch (err) {
|
||||
r = { ok: false, error: err && err.message ? err.message : String(err) };
|
||||
} finally {
|
||||
@@ -343,34 +378,47 @@ async function pollUpdateJob() {
|
||||
}
|
||||
if (timer !== updatePollTimer || !isUpdateConsoleActive()) return;
|
||||
if (!r.ok) {
|
||||
stopUpdatePolling();
|
||||
if (updateRestartExpected) {
|
||||
// The page may have missed the last progress response before handoff.
|
||||
startUpdateWatchdog();
|
||||
// Keep both timers alive. If this was a brief network failure, the next
|
||||
// progress response restores the build view.
|
||||
if (updateActivation) {
|
||||
if (!updateRestartPending) {
|
||||
updateRestartPending = true;
|
||||
updateWatchdogDeadline = Date.now() + UPDATE_WATCHDOG_TIMEOUT_MS;
|
||||
renderUpdateWatchdog();
|
||||
}
|
||||
return;
|
||||
}
|
||||
stopUpdatePolling();
|
||||
notify(updateT('jobPollFailed', { reason: r.error }), 'error');
|
||||
return;
|
||||
}
|
||||
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();
|
||||
|
||||
const job = r.data?.job;
|
||||
if (updateActivation?.tracked && job?.id !== updateActivation.id) return;
|
||||
if (!job) {
|
||||
if (!updateActivation) stopUpdatePolling();
|
||||
return;
|
||||
}
|
||||
if (job && job.state !== 'running') {
|
||||
stopUpdatePolling();
|
||||
updateRestartExpected = false;
|
||||
if (job.state === 'succeeded' && job.restartRequested && job.result?.binaryBuilt) {
|
||||
startUpdateWatchdog();
|
||||
return;
|
||||
}
|
||||
if (updateConsoleState) updateConsoleState.job = job;
|
||||
if (job.state === 'running') {
|
||||
updateRestartPending = false;
|
||||
reportFinishedJob(job);
|
||||
updateWatchdogSlow = null;
|
||||
updateWatchdogDeadline = Date.now() + UPDATE_WATCHDOG_TIMEOUT_MS;
|
||||
renderUpdateConsole();
|
||||
return;
|
||||
}
|
||||
|
||||
if (job.state === 'succeeded' && job.restartRequested && job.result?.binaryBuilt) {
|
||||
if (!updateActivation) watchUpdateActivation(job.id);
|
||||
waitForUpdateRestart(job.result.toCommit);
|
||||
return;
|
||||
}
|
||||
stopUpdatePolling();
|
||||
stopUpdateWatchdog();
|
||||
updateActivation = null;
|
||||
updateRestartPending = false;
|
||||
renderUpdateConsole();
|
||||
reportFinishedJob(job);
|
||||
}
|
||||
|
||||
// 完成时的这一句只念服务端写下来的结论,但补编译与普通更新是两种完成:commits=0 却换了
|
||||
@@ -484,13 +532,13 @@ async function startUpdateApply() {
|
||||
if (r.status === 409 && r.data.job) {
|
||||
// 另一次更新真的在跑:把它的进度接下来并接着看,这才是"当前状态"。
|
||||
if (updateConsoleState) updateConsoleState.job = r.data.job;
|
||||
updateRestartExpected = !!r.data.job.restartRequested;
|
||||
if (r.data.job.restartRequested) watchUpdateActivation(r.data.job.id, r.data.bootID);
|
||||
renderUpdateConsole();
|
||||
startUpdatePolling();
|
||||
}
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
resetUpdateCheck();
|
||||
updateRestartExpected = restart;
|
||||
if (restart) watchUpdateActivation(r.data.job_id, r.data.bootID);
|
||||
startUpdatePolling();
|
||||
await pollUpdateJob();
|
||||
}, 'apply');
|
||||
@@ -506,9 +554,9 @@ async function startRestartNow() {
|
||||
const r = await runUpdateRequest('POST', '/api/system/update/restart');
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
stopUpdatePolling();
|
||||
updateRestartExpected = false;
|
||||
watchUpdateActivation(r.data.activation_id, r.data.bootID);
|
||||
notify(updateT('restartRequestedToast'), 'info');
|
||||
startUpdateWatchdog();
|
||||
waitForUpdateRestart();
|
||||
}, 'restart');
|
||||
}
|
||||
|
||||
@@ -533,7 +581,6 @@ async function rollbackUpdate() {
|
||||
}
|
||||
const to = (r.data.result && r.data.result.toCommit) || status.rollbackTo || '-';
|
||||
resetUpdateCheck();
|
||||
updateRestartChoice = false;
|
||||
await fetchUpdateStatus();
|
||||
renderUpdateConsole();
|
||||
notify(updateT('rollbackDone', { commit: to }), 'success');
|
||||
@@ -598,7 +645,7 @@ async function confirmAdoptSource() {
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
updateAdoptPlan = null;
|
||||
resetUpdateCheck();
|
||||
updateRestartExpected = restart;
|
||||
if (restart) watchUpdateActivation(r.data.job_id, r.data.bootID);
|
||||
startUpdatePolling();
|
||||
notify(updateT('adoptAccepted', { id: r.data.job_id || '-' }), 'success');
|
||||
await pollUpdateJob();
|
||||
@@ -689,10 +736,12 @@ function renderAdoptBody() {
|
||||
}
|
||||
|
||||
function renderUpdateInstallBody(status) {
|
||||
const revision = updateConsoleState?.buildRevision;
|
||||
const facts = [
|
||||
updateFact(updateT('root'), status.root),
|
||||
updateFact(updateT('branch'), status.branch),
|
||||
updateFact(updateT('commit'), status.commit),
|
||||
revision ? updateFact(updateT('runningBuild'), revision.slice(0, 12)) : '',
|
||||
updateFact(updateT('committedAt'), status.committedAt),
|
||||
updateFact(updateT('subject'), status.subject),
|
||||
].join('');
|
||||
@@ -967,7 +1016,7 @@ function renderUpdateWatchdog() {
|
||||
'<div class="update-overlay-body">' + escapeHtml(updateT('restartOverlayBody')) + '</div>'];
|
||||
if (updateWatchdogSlow) {
|
||||
parts.push('<div class="update-overlay-slow">' + escapeHtml(updateT(
|
||||
updateWatchdogSlow === 'running' ? 'restartOverlaySlowRunning' : 'restartOverlaySlowDown')) + '</div>');
|
||||
{ running: 'restartOverlaySlowRunning', down: 'restartOverlaySlowDown', unexpected: 'restartOverlayUnexpected' }[updateWatchdogSlow])) + '</div>');
|
||||
parts.push('<div class="update-overlay-actions">' +
|
||||
'<button class="btn-secondary" type="button" onclick="refreshUpdatePage()">' +
|
||||
escapeHtml(updateT('restartOverlayRefresh')) + '</button></div>');
|
||||
|
||||
@@ -7053,7 +7053,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}}-auto-switch"></script>
|
||||
<script src="/static/js/update.js?v={{.Version}}-activation"></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