fix(tls): reuse the generated HTTPS certificate across updates

(cherry picked from commit 4f2af3371bd5ff89721f222462713bc16beed4b2)
This commit is contained in:
Sycun committed 2026-11-13 11:14:04 +08:00
1 parent 48065ad75f
commit 83b0b7b909
10 files changed
+128 -26

No files matched your search

+1 -1
View File
@@ -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")
+1 -1
View File
@@ -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:
+2
View File
@@ -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:
+2
View File
@@ -43,6 +43,8 @@ https://127.0.0.1:8080/
自签证书会触发浏览器安全提示,这是本地测试的正常现象。生产环境建议配置真实证书。
自动生成的证书和私钥保存在配置目录下的 `data/tls/self-signed.pem`,后续启动和一键更新都会复用。首次从内存自签版本升级时会生成新的证书。需要轮换时,先停止服务并移走此文件,下次启动会生成新的证书。
`run.sh` 是最常用的启动入口,适合:
- 本机体验。
+6 -4
View File
@@ -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 {
@@ -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)
}
+60 -13
View File
@@ -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
}
+52
View File
@@ -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")
}
}
+1 -1
View File
@@ -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"`
+1 -1
View File
@@ -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