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