diff --git a/cmd/server/main.go b/cmd/server/main.go index 1c4d2e4a..27df42b3 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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) } diff --git a/config.example.yaml b/config.example.yaml index a85b8a8b..c1bc6fdb 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -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: diff --git a/docs/en-US/deployment.md b/docs/en-US/deployment.md index 8c6debf0..8f5ac142 100644 --- a/docs/en-US/deployment.md +++ b/docs/en-US/deployment.md @@ -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: diff --git a/docs/zh-CN/deployment.md b/docs/zh-CN/deployment.md index 2f179e63..6f499fa4 100644 --- a/docs/zh-CN/deployment.md +++ b/docs/zh-CN/deployment.md @@ -43,6 +43,8 @@ https://127.0.0.1:8080/ 自签证书会触发浏览器安全提示,这是本地测试的正常现象。生产环境建议配置真实证书。 +自动生成的证书和私钥保存在配置目录下的 `data/tls/self-signed.pem`,后续启动和一键更新都会复用。首次从内存自签版本升级时会生成新的证书。需要轮换时,先停止服务并移走此文件,下次启动会生成新的证书。 + `run.sh` 是最常用的启动入口,适合: - 本机体验。 diff --git a/internal/app/app.go b/internal/app/app.go index 6dec1669..77dd0f91 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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 { diff --git a/internal/app/main_server_http_redirect_test.go b/internal/app/main_server_http_redirect_test.go index 99037f29..7dba415d 100644 --- a/internal/app/main_server_http_redirect_test.go +++ b/internal/app/main_server_http_redirect_test.go @@ -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) } diff --git a/internal/app/main_server_tls.go b/internal/app/main_server_tls.go index 19b546d6..da6cef08 100644 --- a/internal/app/main_server_tls.go +++ b/internal/app/main_server_tls.go @@ -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 } diff --git a/internal/app/main_server_tls_test.go b/internal/app/main_server_tls_test.go new file mode 100644 index 00000000..1572ee80 --- /dev/null +++ b/internal/app/main_server_tls_test.go @@ -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") + } +} diff --git a/internal/app/update_restart_other.go b/internal/app/update_restart_other.go index dc621f5a..600285ed 100644 --- a/internal/app/update_restart_other.go +++ b/internal/app/update_restart_other.go @@ -6,4 +6,6 @@ import "os" func TakeUpdateSessionHandoff() *os.File { return nil } -func (a *App) updateRestart(string) func() (func(), error) { return nil } +func TakeUpdateActivation() string { return "" } + +func (a *App) updateRestart(string) func(string) (func(), error) { return nil } diff --git a/internal/app/update_restart_unix.go b/internal/app/update_restart_unix.go index 94b31add..21a39f03 100644 --- a/internal/app/update_restart_unix.go +++ b/internal/app/update_restart_unix.go @@ -18,6 +18,19 @@ import ( ) const updateSessionsEnv = "CYBERSTRIKE_UPDATE_SESSIONS_FD" +const updateActivationEnv = "CYBERSTRIKE_UPDATE_ACTIVATION" + +// TakeUpdateActivation identifies the update that launched this process. +// A fresh start by a supervisor has no activation ID. +func TakeUpdateActivation() string { + value := os.Getenv(updateActivationEnv) + _ = os.Unsetenv(updateActivationEnv) + pid, id, ok := strings.Cut(value, ":") + if !ok || pid != strconv.Itoa(os.Getpid()) { + return "" + } + return id +} // TakeUpdateSessionHandoff claims the descriptor left by an in-place exec. // Do this before startup runs any subprocesses. @@ -61,7 +74,7 @@ func writeUpdateSessionHandoff(payload []byte) (*os.File, error) { // updateRestart only replaces the executable this instance was started from. // For example, go run and installs with a separate config directory must not // start a different executable just because the updater wrote one there. -func (a *App) updateRestart(root string) func() (func(), error) { +func (a *App) updateRestart(root string) func(string) (func(), error) { dir, err := filepath.Abs(root) if err != nil { return nil @@ -79,7 +92,7 @@ func (a *App) updateRestart(root string) func() (func(), error) { if err != nil || self != path { return nil } - return func() (func(), error) { + return func(activationID string) (func(), error) { info, err := os.Lstat(path) if err != nil { return nil, err @@ -95,7 +108,7 @@ func (a *App) updateRestart(root string) func() (func(), error) { // Keep the same PID, arguments, environment and working directory. // This works both in a terminal and under an existing supervisor. exec := func(payload []byte) error { - env := os.Environ() + env := append(os.Environ(), fmt.Sprintf("%s=%d:%s", updateActivationEnv, os.Getpid(), activationID)) if len(payload) != 0 { f, err := writeUpdateSessionHandoff(payload) if err == nil { diff --git a/internal/app/update_restart_unix_test.go b/internal/app/update_restart_unix_test.go index 1dbcdf55..62a307d7 100644 --- a/internal/app/update_restart_unix_test.go +++ b/internal/app/update_restart_unix_test.go @@ -105,6 +105,9 @@ func TestUpdateSessionHandoffChild(t *testing.T) { t.Fatal(err) } if token := os.Getenv("CSAI_RESTART_TEST_TOKEN"); token != "" { + if got := TakeUpdateActivation(); got != "upd-session-test" || os.Getenv(updateActivationEnv) != "" { + t.Fatalf("replacement did not consume the update activation: %q", got) + } if handoff == nil || os.Getenv(updateSessionsEnv) != "" { t.Fatal("replacement did not claim the private handoff") } @@ -142,7 +145,7 @@ func TestUpdateSessionHandoffChild(t *testing.T) { if err := os.Rename("next", update.Binary); err != nil { t.Fatal(err) } - restart, err := prepare() + restart, err := prepare("upd-session-test") if err != nil { t.Fatal(err) } @@ -162,7 +165,7 @@ func TestUpdateRestartChild(t *testing.T) { if err := os.Rename("next", update.Binary); err != nil { t.Fatal(err) } - restart, err := prepare() + restart, err := prepare("upd-standalone-test") if err != nil { t.Fatal(err) } diff --git a/internal/config/config.go b/internal/config/config.go index 727a0653..b0e593f6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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"` diff --git a/internal/config/server_https_bootstrap.go b/internal/config/server_https_bootstrap.go index 6f67a601..45f3202f 100644 --- a/internal/config/server_https_bootstrap.go +++ b/internal/config/server_https_bootstrap.go @@ -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 diff --git a/internal/handler/update.go b/internal/handler/update.go index 657be816..4e15f8e5 100644 --- a/internal/handler/update.go +++ b/internal/handler/update.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "os" + "runtime/debug" "strings" "sync" "time" @@ -34,8 +35,10 @@ type UpdateHandler struct { logger *zap.Logger audit *audit.Service // prepareRestart validates the replacement before the running service shuts down. - prepareRestart func() (func(), error) + prepareRestart func(activationID string) (func(), error) bootID string + activationID string + buildRevision string // source reports the configured update source (config.yaml update.repo) as the // operator last saved it; an empty string means "not configured" and the official // repository is used. A function rather than a value because the config can change @@ -82,14 +85,25 @@ func (j *updateJob) view() *updateJob { // NewUpdateHandler takes the audit service as a constructor argument rather than through // a SetAudit the assembly has to remember: forgetting here is a compile error instead of // a privileged endpoint that writes no audit records at all. -func NewUpdateHandler(root string, logger *zap.Logger, auditSvc *audit.Service, prepareRestart func() (func(), error), +func NewUpdateHandler(root string, logger *zap.Logger, auditSvc *audit.Service, prepareRestart func(string) (func(), error), activationID string, source func() string, saveSource func(repo string) error) *UpdateHandler { + var revision string + if info, ok := debug.ReadBuildInfo(); ok { + for _, setting := range info.Settings { + if setting.Key == "vcs.revision" { + revision = setting.Value + break + } + } + } return &UpdateHandler{ root: root, logger: logger, audit: auditSvc, prepareRestart: prepareRestart, bootID: uuid.NewString(), + activationID: activationID, + buildRevision: revision, source: source, saveSource: saveSource, baseline: update.StampBinary(root, update.Options{Root: root}), @@ -145,6 +159,7 @@ func (h *UpdateHandler) sourceView() gin.H { // GetStatus answers GET /api/system/update: the install tree as it is on disk, without a // network round trip, so opening the page is never gated on GitHub being reachable. func (h *UpdateHandler) GetStatus(c *gin.Context) { + c.Header("Cache-Control", "no-store") snap, err := update.Status(c.Request.Context(), h.options()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) @@ -152,15 +167,17 @@ func (h *UpdateHandler) GetStatus(c *gin.Context) { } needsRestart, binaryBuiltAt := h.restartPending() c.JSON(http.StatusOK, gin.H{ - "status": snap, - "job": h.latest(), - "canRestart": h.prepareRestart != nil, - "autoRestart": h.prepareRestart != nil, - "bootID": h.bootID, - "supervised": restartSupervised(), - "needsRestart": needsRestart, - "binaryBuiltAt": binaryBuiltAt, - "source": h.sourceView(), + "status": snap, + "job": h.latest(), + "canRestart": h.prepareRestart != nil, + "autoRestart": h.prepareRestart != nil, + "bootID": h.bootID, + "buildRevision": h.buildRevision, + "activationTracking": true, + "supervised": restartSupervised(), + "needsRestart": needsRestart, + "binaryBuiltAt": binaryBuiltAt, + "source": h.sourceView(), }) } @@ -168,6 +185,12 @@ func (h *UpdateHandler) GetStatus(c *gin.Context) { // a new boot from a revoked or expired session on the current boot. func (h *UpdateHandler) SetBootHeader(c *gin.Context) { c.Header("X-CyberStrike-Boot-ID", h.bootID) + c.Header("X-CyberStrike-Build-Revision", h.buildRevision) + activationID := h.activationID + if activationID == "" { + activationID = "-" + } + c.Header("X-CyberStrike-Activation-ID", activationID) c.Header("Cache-Control", "no-store") } @@ -260,11 +283,11 @@ func (h *UpdateHandler) Adopt(c *gin.Context) { } job, started := h.startJob("adopt", body.Restart) if !started { - c.JSON(http.StatusConflict, gin.H{"error": "已有一次更新在进行中", "job": job}) + c.JSON(http.StatusConflict, gin.H{"error": "已有一次更新在进行中", "job": job, "bootID": h.bootID}) return } h.record(c, "update.adopt", "started", "开始把目录接入更新源", map[string]interface{}{"job": job.ID, "restart": body.Restart}) - c.JSON(http.StatusAccepted, gin.H{"job_id": job.ID, "state": job.State}) + c.JSON(http.StatusAccepted, gin.H{"job_id": job.ID, "state": job.State, "bootID": h.bootID}) } // Apply answers POST /api/system/update/apply. It starts one job and returns immediately; @@ -287,12 +310,12 @@ func (h *UpdateHandler) Apply(c *gin.Context) { job, started := h.startJob("apply", body.Restart) if !started { - c.JSON(http.StatusConflict, gin.H{"error": "已有一次更新在进行中", "job": job}) + c.JSON(http.StatusConflict, gin.H{"error": "已有一次更新在进行中", "job": job, "bootID": h.bootID}) return } h.record(c, "update.apply", "started", "开始一键更新源码并重编译", map[string]interface{}{"job": job.ID, "restart": body.Restart}) - c.JSON(http.StatusAccepted, gin.H{"job_id": job.ID, "state": job.State}) + c.JSON(http.StatusAccepted, gin.H{"job_id": job.ID, "state": job.State, "bootID": h.bootID}) } // Restart answers POST /api/system/update/restart: replace this process so the binary @@ -327,14 +350,15 @@ func (h *UpdateHandler) Restart(c *gin.Context) { c.JSON(http.StatusConflict, gin.H{"error": "磁盘上的二进制不存在,无法重启", "reason": "no_binary"}) return } - restart, err := h.prepareRestart() + activationID := uuid.NewString() + restart, err := h.prepareRestart(activationID) if err != nil { failure := &update.Error{Reason: "restart_unavailable", Message: "无法启动磁盘上的二进制:" + err.Error()} c.JSON(statusForError(failure), errorBody(failure)) return } h.record(c, "update.restart", "started", "重启安装以运行磁盘上的新二进制", nil) - c.JSON(http.StatusAccepted, gin.H{"restarting": true}) + c.JSON(http.StatusAccepted, gin.H{"restarting": true, "activation_id": activationID, "bootID": h.bootID}) scheduled = true time.AfterFunc(updateRestartDelay, func() { defer release(); restart() }) } @@ -342,6 +366,7 @@ func (h *UpdateHandler) Restart(c *gin.Context) { // Job answers GET /api/system/update/job: the running job, or the most recent one, with // every progress line so far. func (h *UpdateHandler) Job(c *gin.Context) { + c.Header("Cache-Control", "no-store") c.JSON(http.StatusOK, gin.H{"job": h.latest()}) } @@ -429,7 +454,7 @@ func (h *UpdateHandler) startJob(kind string, restart bool) (*updateJob, bool) { if job.Restart && finish == "succeeded" && res != nil && res.BinaryBuilt && h.prepareRestart != nil { release, lockErr := update.LockInstall(opts) if lockErr == nil { - restart, prepareErr := h.prepareRestart() + restart, prepareErr := h.prepareRestart(job.ID) if prepareErr == nil { h.mu.Unlock() // Keep both CLI and HTTP writers out until the old executable is replaced. diff --git a/internal/handler/update_http_test.go b/internal/handler/update_http_test.go index 4a164be6..478ff765 100644 --- a/internal/handler/update_http_test.go +++ b/internal/handler/update_http_test.go @@ -36,11 +36,11 @@ func newUpdateRouter(root string, restart func()) (*gin.Engine, *UpdateHandler) func newUpdateRouterWithSource(root string, restart func(), source func() string, save func(string) error) (*gin.Engine, *UpdateHandler) { gin.SetMode(gin.TestMode) - var prepare func() (func(), error) + var prepare func(string) (func(), error) if restart != nil { - prepare = func() (func(), error) { return restart, nil } + prepare = func(string) (func(), error) { return restart, nil } } - h := NewUpdateHandler(root, nil, nil, prepare, source, save) + h := NewUpdateHandler(root, nil, nil, prepare, "", source, save) router := gin.New() router.GET("/api/system/update", h.GetStatus) router.GET("/api/system/update/job", h.Job) diff --git a/web/static/i18n/ar-SA.json b/web/static/i18n/ar-SA.json index a65b41d1..bfbfb047 100644 --- a/web/static/i18n/ar-SA.json +++ b/web/static/i18n/ar-SA.json @@ -112,6 +112,7 @@ "rollbackTitle": "التراجع", "root": "دليل التثبيت", "branch": "الفرع المحلّي", + "runningBuild": "البنية قيد التشغيل", "commit": "الإيداع الحالي", "committedAt": "أُودِع في", "subject": "موضوع الإيداع", @@ -180,6 +181,7 @@ "restartOverlayBody": "ستُعاد الصفحة تلقائيًا عندما يصبح الإصدار الجديد جاهزًا.", "restartOverlaySlowRunning": "لا تزال الخدمة تستجيب بوصفها العملية القديمة - يبدو أنّ إعادة التشغيل لم تحدث. استخدم الزرّ أدناه لمعاينة الحالة الحالية.", "restartOverlaySlowDown": "لم تعد الخدمة بعد. ستواصل الصفحة المحاولة. راجع سجلّات الخدمة إذا استمر الانقطاع.", + "restartOverlayUnexpected": "عادت الخدمة، لكن لم يُؤكّد تثبيت الإصدار المستهدف. ستستمر الصفحة في التحقق، ويمكنك تحديثها لمعرفة الإصدار الحالي.", "restartOverlayRefresh": "تحديث هذه الصفحة", "failureNoMessage": "لم يقدّم الخادم أيّ رسالة فشل", "resultTitle": "نتيجة التحديث", diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index 084bf6d8..4e3ba1a8 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -112,6 +112,7 @@ "rollbackTitle": "Rollback", "root": "Install directory", "branch": "Local branch", + "runningBuild": "Running build", "commit": "Current commit", "committedAt": "Committed at", "subject": "Commit subject", @@ -180,6 +181,7 @@ "restartOverlayBody": "This page will reload automatically when the new version is ready.", "restartOverlaySlowRunning": "The service is still answering as the old process - the restart does not seem to have happened. Use the button below to look at the current state.", "restartOverlaySlowDown": "The service is taking longer to come back. This page will keep trying. Check the service logs if it remains unavailable.", + "restartOverlayUnexpected": "The service is back, but this update has not been confirmed. This page will keep checking. You can also refresh to see the current version.", "restartOverlayRefresh": "Refresh this page", "failureNoMessage": "The server gave no failure message", "resultTitle": "Update result", diff --git a/web/static/i18n/ru-RU.json b/web/static/i18n/ru-RU.json index 2bc46f5d..d754136e 100644 --- a/web/static/i18n/ru-RU.json +++ b/web/static/i18n/ru-RU.json @@ -112,6 +112,7 @@ "rollbackTitle": "Откат", "root": "Каталог установки", "branch": "Локальная ветка", + "runningBuild": "Запущенная сборка", "commit": "Текущий коммит", "committedAt": "Дата коммита", "subject": "Заголовок коммита", @@ -180,6 +181,7 @@ "restartOverlayBody": "Когда новая версия будет готова, страница обновится автоматически.", "restartOverlaySlowRunning": "Служба по-прежнему отвечает старым процессом — перезапуск, похоже, не произошёл. Нажмите кнопку ниже, чтобы увидеть текущее состояние.", "restartOverlaySlowDown": "Служба пока недоступна. Страница продолжит попытки. Если служба долго не отвечает, проверьте её журналы.", + "restartOverlayUnexpected": "Служба снова доступна, но установка нужной сборки не подтверждена. Проверка продолжится. Текущую версию можно увидеть, обновив страницу.", "restartOverlayRefresh": "Обновить страницу", "failureNoMessage": "Сервер не передал сообщение об ошибке", "resultTitle": "Результат обновления", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index 115fbf48..e0a55649 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -112,6 +112,7 @@ "rollbackTitle": "回滚", "root": "安装目录", "branch": "本机分支", + "runningBuild": "运行中的构建", "commit": "当前提交", "committedAt": "提交时间", "subject": "提交说明", @@ -180,6 +181,7 @@ "restartOverlayBody": "切换完成后,页面会自动恢复。", "restartOverlaySlowRunning": "服务仍在以旧进程应答——重启似乎没有发生。可点下方按钮刷新页面查看当前状态。", "restartOverlaySlowDown": "服务仍未恢复,页面会继续重试。如果长时间无法连接,请检查服务日志。", + "restartOverlayUnexpected": "服务已恢复,但未能确认本次更新的目标构建已生效。页面会继续检查,也可以刷新查看当前版本。", "restartOverlayRefresh": "立即刷新页面", "failureNoMessage": "服务端没有给出失败说明", "resultTitle": "更新结果", diff --git a/web/static/js/update-ui.test.cjs b/web/static/js/update-ui.test.cjs index 4c885bd3..2d90d74a 100644 --- a/web/static/js/update-ui.test.cjs +++ b/web/static/js/update-ui.test.cjs @@ -96,6 +96,7 @@ function envelope(status, job, canRestart, source, flags) { supervised: !!(flags && flags.supervised), autoRestart: flags?.autoRestart, bootID: flags?.bootID, + activationTracking: flags?.activationTracking, needsRestart: !!(flags && flags.needsRestart), binaryBuiltAt: (flags && flags.binaryBuiltAt) || '', source: source || emptySource, @@ -316,7 +317,11 @@ function harness(options) { if (typeof answer === 'object') { return Promise.resolve({ status: answer.status, - headers: { get: name => name === 'X-CyberStrike-Boot-ID' ? answer.bootID : null }, + headers: { get: name => ({ + 'X-CyberStrike-Boot-ID': answer.bootID, + 'X-CyberStrike-Activation-ID': answer.activationID, + 'X-CyberStrike-Build-Revision': answer.revision, + })[name] }, json: () => Promise.resolve(answer.body || {}), }); } @@ -407,8 +412,8 @@ function harness(options) { }, setConfirm(v) { confirmAnswer = v; }, liveTimers() { return timers.filter(t => !t.cleared && !t.deferred); }, - tick() { - const live = h.liveTimers(); + tick(ms) { + const live = h.liveTimers().filter(t => ms === undefined || t.ms === ms); if (!live.length) throw new Error('no poll timer is armed'); return live[live.length - 1].fn(); }, @@ -439,7 +444,7 @@ test('the console lives inside the settings page as its own section', () => { 'settings-section-update must live inside page-settings'); assert.match(template, /id="settings-section-update"[\s\S]{0,700}?id="update-console"/); assert.match(template, /id="update-console"/); - assert.match(template, / - +