From 7d2465ec898c3096012f2c10739a836ea08d09aa Mon Sep 17 00:00:00 2001
From: Sycun <165354365+Sycun@users.noreply.github.com>
Date: Fri, 13 Nov 2026 10:05:42 +0800
Subject: [PATCH 1/2] fix(update): reload after confirming the accepted update
is running
(cherry picked from commit aa7a533497a6b524fbd4d7d330ca06b04414e2fb)
---
cmd/server/main.go | 3 +-
internal/app/app.go | 4 +-
internal/app/update_restart_other.go | 4 +-
internal/app/update_restart_unix.go | 19 ++-
internal/app/update_restart_unix_test.go | 7 +-
internal/handler/update.go | 61 +++++---
internal/handler/update_http_test.go | 6 +-
web/static/i18n/ar-SA.json | 2 +
web/static/i18n/en-US.json | 2 +
web/static/i18n/ru-RU.json | 2 +
web/static/i18n/zh-CN.json | 2 +
web/static/js/update-ui.test.cjs | 72 +++++++--
web/static/js/update.js | 177 +++++++++++++++--------
web/templates/index.html | 2 +-
14 files changed, 257 insertions(+), 106 deletions(-)
diff --git a/cmd/server/main.go b/cmd/server/main.go
index 1c4d2e4a..3c78bd75 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -22,6 +22,7 @@ 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")
@@ -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/internal/app/app.go b/internal/app/app.go
index 36de93a6..83aeae35 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -79,7 +79,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) {
toolGuard, err := toolguard.NewManager(cfg.EffectiveToolGuard())
if err != nil {
return nil, fmt.Errorf("初始化调用拦截规则: %w", err)
@@ -613,7 +613,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(
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/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 6f87e584..53d2ee59 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 d225707f..651c1ad4 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 eb2fe9a1..cdfb2523 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 7f55a325..05374ddf 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, /
-
+
From 83b0b7b909e0d96a045b1d4e75456e0b0bc7cfea Mon Sep 17 00:00:00 2001
From: Sycun <165354365+Sycun@users.noreply.github.com>
Date: Fri, 13 Nov 2026 11:09:49 +0800
Subject: [PATCH 2/2] fix(tls): reuse the generated HTTPS certificate across
updates
(cherry picked from commit 4f2af3371bd5ff89721f222462713bc16beed4b2)
---
cmd/server/main.go | 2 +-
config.example.yaml | 2 +-
docs/en-US/deployment.md | 2 +
docs/zh-CN/deployment.md | 2 +
internal/app/app.go | 10 ++-
.../app/main_server_http_redirect_test.go | 7 +-
internal/app/main_server_tls.go | 73 +++++++++++++++----
internal/app/main_server_tls_test.go | 52 +++++++++++++
internal/config/config.go | 2 +-
internal/config/server_https_bootstrap.go | 2 +-
10 files changed, 128 insertions(+), 26 deletions(-)
create mode 100644 internal/app/main_server_tls_test.go
diff --git a/cmd/server/main.go b/cmd/server/main.go
index 3c78bd75..27df42b3 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -25,7 +25,7 @@ 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")
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 373124aa..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
@@ -516,6 +517,7 @@ func New(cfg *config.Config, log *logger.Logger, configPath, updateActivation st
// 创建 App 实例(部分字段稍后填充)
app := &App{
config: cfg,
+ configDir: configDir,
logger: log,
router: router,
mcpServer: mcpServer,
@@ -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/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