diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index 42ea7c9..32f0b5e 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -53,6 +53,9 @@ func New(ctx context.Context, cfg config.Config) (*App, error) { } func NewWithConfigPath(ctx context.Context, cfg config.Config, configPath string) (*App, error) { + if !cfg.SetupReady { + return nil, fmt.Errorf("系统尚未初始化,请先启动 scert serve 完成首次配置") + } if err := cfg.Validate(); err != nil { return nil, err } diff --git a/backend/internal/app/app_test.go b/backend/internal/app/app_test.go index 09780eb..bc57116 100644 --- a/backend/internal/app/app_test.go +++ b/backend/internal/app/app_test.go @@ -2,6 +2,7 @@ package app import ( "context" + "os" "path/filepath" "testing" "time" @@ -9,11 +10,11 @@ import ( "git.pcux.cn/xuans/Scert/backend/internal/config" "git.pcux.cn/xuans/Scert/backend/internal/domain" "git.pcux.cn/xuans/Scert/backend/internal/repository" + "git.pcux.cn/xuans/Scert/backend/internal/setup" ) func TestNewWiresApplicationServices(t *testing.T) { - baseDir := t.TempDir() - cfg := config.Default(baseDir) + cfg := readyApplicationConfig(t) application, err := NewWithConfigPath(context.Background(), cfg, filepath.Join(cfg.Paths.Config, "scert.json")) if err != nil { t.Fatalf("初始化应用失败:%v", err) @@ -31,8 +32,7 @@ func TestNewWiresApplicationServices(t *testing.T) { } } func TestNewWithConfigPathRecoversInterruptedTasks(t *testing.T) { - baseDir := t.TempDir() - cfg := config.Default(baseDir) + cfg := readyApplicationConfig(t) configPath := filepath.Join(cfg.Paths.Config, "scert.json") first, err := NewWithConfigPath(context.Background(), cfg, configPath) @@ -108,3 +108,37 @@ func TestNewWithConfigPathRecoversInterruptedTasks(t *testing.T) { t.Fatalf("已完成任务不应被重启恢复逻辑修改:%+v", completed) } } + +func readyApplicationConfig(t *testing.T) config.Config { + t.Helper() + root := t.TempDir() + cfg := config.Default(root) + cfg.Paths = config.PathsConfig{ + Install: root, Config: filepath.Join(root, "conf"), Data: filepath.Join(root, "data"), + Certificate: filepath.Join(root, "ssl"), Backup: filepath.Join(root, "backups"), Log: filepath.Join(root, "logs"), + } + cfg.Database.DSN = filepath.Join(cfg.Paths.Data, "scert.db") + if err := setup.Apply(&cfg, setup.Input{ + AdminUsername: "admin", AdminEmail: "admin@example.test", AdminPassword: "Test1234!", + ServerPort: 8080, WebHostname: "scert.example.test", WebEnabled: true, + DNSProvider: "aliyun", DNSCredentialRef: "secret://dns/test", DNSVerified: true, + CertificateRoot: cfg.Paths.Certificate, HTTPSMode: "direct", + }); err != nil { + t.Fatal(err) + } + cfg.SetupReady = true + cfg.Web.InitReady = true + return cfg +} + +func TestUninitializedApplicationHasNoSideEffects(t *testing.T) { + root := t.TempDir() + cfg := config.Default(root) + cfg.Paths.Install = filepath.Join(root, "must-not-exist") + if _, err := NewWithConfigPath(context.Background(), cfg, filepath.Join(root, "config.json")); err == nil { + t.Fatal("未初始化配置未被拒绝") + } + if _, err := os.Stat(cfg.Paths.Install); !os.IsNotExist(err) { + t.Fatalf("拒绝启动前产生了文件副作用:%v", err) + } +} diff --git a/backend/internal/bootstrap/router.go b/backend/internal/bootstrap/router.go index d8ce26f..d38b09c 100644 --- a/backend/internal/bootstrap/router.go +++ b/backend/internal/bootstrap/router.go @@ -9,6 +9,7 @@ import ( "mime" "net/http" "strings" + "sync" "time" "git.pcux.cn/xuans/Scert/backend/internal/dnsprovider" @@ -37,10 +38,12 @@ type bootstrapCompleter interface { // Router 只承载首次初始化接口,不注册正式管理接口。 type Router struct { - sessions *SessionManager - database bootstrapDatabaseVerifier - dns bootstrapDNSVerifier - completer bootstrapCompleter + sessions *SessionManager + database bootstrapDatabaseVerifier + dns bootstrapDNSVerifier + completer bootstrapCompleter + completed chan struct{} + completeOnce sync.Once } type bootstrapSessionRequest struct { @@ -55,9 +58,12 @@ type bootstrapDNSTestRequest struct { // NewRouter 创建与正式管理服务完全分离的首次初始化路由。 func NewRouter(sessions *SessionManager, database bootstrapDatabaseVerifier, dns bootstrapDNSVerifier, completer bootstrapCompleter) *Router { - return &Router{sessions: sessions, database: database, dns: dns, completer: completer} + return &Router{sessions: sessions, database: database, dns: dns, completer: completer, completed: make(chan struct{})} } +// Completed 在成功响应写入后通知临时服务开始优雅关闭。 +func (router *Router) Completed() <-chan struct{} { return router.completed } + func (router *Router) ServeHTTP(writer http.ResponseWriter, request *http.Request) { setBootstrapSecurityHeaders(writer) if request == nil { @@ -254,6 +260,7 @@ func (router *Router) complete(writer http.ResponseWriter, request *http.Request router.sessions.RevokeAll() http.SetCookie(writer, ClearBootstrapSessionCookie()) writeBootstrapJSON(writer, http.StatusOK, result) + router.completeOnce.Do(func() { close(router.completed) }) } func writeBootstrapCompletionError(writer http.ResponseWriter, err error) { diff --git a/backend/internal/bootstrap/server.go b/backend/internal/bootstrap/server.go new file mode 100644 index 0000000..ef7a3ac --- /dev/null +++ b/backend/internal/bootstrap/server.go @@ -0,0 +1,206 @@ +package bootstrap + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "io" + "io/fs" + "net" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/certificate" + "git.pcux.cn/xuans/Scert/backend/internal/config" + "git.pcux.cn/xuans/Scert/backend/internal/dnsprovider" + "git.pcux.cn/xuans/Scert/backend/internal/firewall" + "git.pcux.cn/xuans/Scert/backend/internal/secretstore" + "git.pcux.cn/xuans/Scert/backend/internal/setup" +) + +type ServerOptions struct { + Config config.Config + ConfigPath string + PublicIP string + URLPath string + Output io.Writer +} + +// Run 只创建初始化所需组件;正式数据库、业务路由和后台任务由切换后的应用创建。 +func Run(ctx context.Context, options ServerOptions) (bool, error) { + if options.Config.SetupReady { + return false, errors.New("已经初始化,不能启动临时初始化服务") + } + material, err := NewTemporaryTLSMaterial(options.PublicIP) + if err != nil { + return false, err + } + sessions, token, err := NewSessionManager() + if err != nil { + return false, err + } + defer sessions.RevokeAll() + ip, err := advertisedBootstrapIP(options.PublicIP, material.IPAddresses) + if err != nil { + return false, err + } + address, err := bootstrapURL(ip, options.Config.Server.Port, token) + if err != nil { + return false, err + } + store, err := secretstore.Open(options.Config.Paths.Config) + if err != nil { + return false, fmt.Errorf("打开初始化敏感信息存储失败:%w", err) + } + databases := NewDatabaseVerificationManager(options.Config, store) + dns := setup.NewDNSVerificationManager(dnsprovider.NewTester(dnsprovider.NewLegoFactory()), store) + // 服务完全停止后再清理未领取的凭据,避免在途验证重新留下临时凭据。 + defer databases.DiscardAllExcept() + defer dns.DiscardAll() + issuer := certificate.NewService(options.Config, nil, store, dnsprovider.NewLegoFactory(), certificate.NewAccountRepository(store)) + issuer.SetDNSAuthorizationChecker(dnsprovider.NewAliyunAuthorizationChecker()) + completer := NewCompleter(CompleteDependencies{ + BaseConfig: options.Config, ConfigPath: options.ConfigPath, BootstrapURLPath: options.URLPath, + DatabaseSessions: databases, DNSSessions: dns, Credentials: store, Sessions: sessions, + Firewall: firewall.NewManager(), Certificate: issuer, + Warn: func(message string) { + if options.Output != nil { + fmt.Fprintln(options.Output, message) + } + }, + }) + router := NewRouter(sessions, databases, dns, completer) + handler, closeFiles := bootstrapHandler(router, options.Config) + defer closeFiles() + listener, err := net.Listen("tcp", net.JoinHostPort(options.Config.Server.Host, strconv.Itoa(options.Config.Server.Port))) + if err != nil { + return false, fmt.Errorf("监听初始化端口失败:%w", err) + } + defer listener.Close() + if err := WriteBootstrapURL(options.URLPath, address); err != nil { + return false, err + } + defer func() { + if err := os.Remove(options.URLPath); err != nil && !errors.Is(err, os.ErrNotExist) && options.Output != nil { + fmt.Fprintln(options.Output, "初始化地址文件清理失败,请检查运行目录权限。") + } + }() + if options.Output != nil { + // 一次性令牌只能写入受保护文件,不进入终端日志或 systemd journal。 + fmt.Fprintf(options.Output, "首次初始化 HTTPS 服务已启动,端口 %d。请读取 %s 获取一次性访问地址;临时自签名证书需人工核对。\n", options.Config.Server.Port, options.URLPath) + } + return serveTemporaryHTTPS(ctx, listener, material, handler, router.Completed()) +} + +func serveTemporaryHTTPS(ctx context.Context, listener net.Listener, material TLSMaterial, handler http.Handler, completed <-chan struct{}) (bool, error) { + requestContext, cancelRequests := context.WithCancel(context.Background()) + defer cancelRequests() + var active sync.WaitGroup + server := &http.Server{ + Handler: http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + active.Add(1) + defer active.Done() + handler.ServeHTTP(writer, request) + }), + ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, IdleTimeout: 30 * time.Second, MaxHeaderBytes: 16 * 1024, + TLSConfig: &tls.Config{MinVersion: tls.VersionTLS12, Certificates: []tls.Certificate{material.Certificate}}, + BaseContext: func(net.Listener) context.Context { return requestContext }, + } + errorsChannel := make(chan error, 1) + go func() { errorsChannel <- server.ServeTLS(listener, "", "") }() + success := false + var serveErr error + select { + case <-completed: + success = true + case <-ctx.Done(): + cancelRequests() + case serveErr = <-errorsChannel: + cancelRequests() + } + shutdownContext, cancelShutdown := context.WithTimeout(context.Background(), 15*time.Second) + defer cancelShutdown() + shutdownErr := server.Shutdown(shutdownContext) + if shutdownErr != nil { + cancelRequests() + _ = server.Close() + } + // Shutdown 已停止接收新请求;等待在途处理完成后才能清理其凭据。 + active.Wait() + if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) { + return false, serveErr + } + if shutdownErr != nil { + return false, fmt.Errorf("关闭初始化服务失败:%w", shutdownErr) + } + return success, nil +} + +func bootstrapHandler(router *Router, cfg config.Config) (http.Handler, func()) { + root, _ := os.OpenRoot(filepath.Join(cfg.Paths.Install, "web")) + closeFiles := func() { + if root != nil { + _ = root.Close() + } + } + return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + setBootstrapSecurityHeaders(writer) + if request.URL.Path == "/api/v1/bootstrap/settings" { + if !requireBootstrapMethod(writer, request, http.MethodGet) || !router.authenticate(writer, request) { + return + } + writeBootstrapJSON(writer, http.StatusOK, map[string]any{ + "mode": "bootstrap", "initialized": false, "server_port": cfg.Server.Port, + "web_enabled": true, "web_hostname": cfg.Web.Hostname, "web_public_ip": cfg.Web.PublicIP, + "https_mode": cfg.Server.HTTPSMode, "certificate_root": cfg.Paths.Certificate, + "dns_provider": dnsprovider.Aliyun, "acme_environment": cfg.ACME.Environment, + "propagation_timeout_secs": cfg.ACME.PropagationTimeoutSecs, + "propagation_polling_interval_secs": cfg.ACME.PropagationPollingIntervalSecs, + }) + return + } + path := request.URL.Path + if strings.HasPrefix(path, "/api/") { + router.ServeHTTP(writer, request) + return + } + if request.Method != http.MethodGet && request.Method != http.MethodHead { + http.NotFound(writer, request) + return + } + name := strings.TrimPrefix(path, "/") + if path == "/" { + name = "index.html" + } else if path != "/Scert.png" && path != "/favicon.ico" && !strings.HasPrefix(path, "/assets/") { + http.NotFound(writer, request) + return + } + if !fs.ValidPath(name) { + writeBootstrapError(writer, http.StatusBadRequest, "资源路径无效") + return + } + if root == nil { + http.Error(writer, "初始化页面资源尚未安装,请检查 web 目录。", http.StatusServiceUnavailable) + return + } + // os.Root 限制符号链接逃逸,不暴露配置目录或任意业务文件。 + file, err := root.Open(name) + if err != nil { + writeBootstrapError(writer, http.StatusNotFound, "资源不存在") + return + } + defer file.Close() + info, err := file.Stat() + if err != nil || !info.Mode().IsRegular() { + writeBootstrapError(writer, http.StatusNotFound, "资源不存在") + return + } + http.ServeContent(writer, request, info.Name(), info.ModTime(), file) + }), closeFiles +} diff --git a/backend/internal/bootstrap/server_test.go b/backend/internal/bootstrap/server_test.go new file mode 100644 index 0000000..e7dd503 --- /dev/null +++ b/backend/internal/bootstrap/server_test.go @@ -0,0 +1,115 @@ +package bootstrap + +import ( + "context" + "crypto/tls" + "crypto/x509" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/config" +) + +func TestTemporaryHTTPSDrainsCompletionResponse(t *testing.T) { + material, err := NewTemporaryTLSMaterial("127.0.0.1") + if err != nil { + t.Fatal(err) + } + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + completed := make(chan struct{}) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + result := make(chan error, 1) + body := strings.Repeat("response-data", 10000) + go func() { + ok, err := serveTemporaryHTTPS(ctx, listener, material, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + close(completed) + time.Sleep(30 * time.Millisecond) + _, _ = io.WriteString(w, body) + }), completed) + if !ok && err == nil { + err = context.Canceled + } + result <- err + }() + roots := x509.NewCertPool() + roots.AddCert(material.Leaf) + transport := &http.Transport{TLSClientConfig: &tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS12}} + defer transport.CloseIdleConnections() + client := &http.Client{Transport: transport, Timeout: 5 * time.Second} + response, err := client.Get("https://" + listener.Addr().String() + "/") + if err != nil { + t.Fatal(err) + } + data, err := io.ReadAll(response.Body) + response.Body.Close() + if err != nil || string(data) != body { + t.Fatalf("成功响应被关闭截断:%v,长度 %d", err, len(data)) + } + select { + case err := <-result: + if err != nil { + t.Fatal(err) + } + case <-time.After(5 * time.Second): + t.Fatal("临时服务没有关闭") + } + if conn, err := net.DialTimeout("tcp", listener.Addr().String(), 100*time.Millisecond); err == nil { + conn.Close() + t.Fatal("关闭后仍可连接") + } +} + +func TestBootstrapHandlerIsolatesBusinessRoutesAndSettings(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "web"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "web", "index.html"), []byte("bootstrap-page"), 0o600); err != nil { + t.Fatal(err) + } + cfg := config.Default(root) + cfg.Paths.Install = root + sessions, token, err := NewSessionManager() + if err != nil { + t.Fatal(err) + } + router := NewRouter(sessions, nil, nil, nil) + handler, closeFiles := bootstrapHandler(router, cfg) + defer closeFiles() + for path, status := range map[string]int{ + "/": 200, "/api/v1/bootstrap/status": 200, "/api/v1/bootstrap/settings": 401, + "/api/v1/setup": 404, "/api/v1/domains": 404, "/reset": 404, "/scert.json": 404, + "/assets/../../config.json": 400, + } { + response := httptest.NewRecorder() + handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, path, nil)) + if response.Code != status { + t.Fatalf("%s 状态码 %d,期望 %d", path, response.Code, status) + } + if response.Header().Get("Cache-Control") != "no-store" { + t.Fatal("临时页面允许缓存") + } + } + session, err := sessions.Exchange(token, "127.0.0.1") + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodGet, "/api/v1/bootstrap/settings", nil) + request.AddCookie(BootstrapSessionCookie(session.Token, session.ExpiresAt)) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != 200 || !strings.Contains(response.Body.String(), `"mode":"bootstrap"`) { + t.Fatal("认证后无法读取初始化设置") + } +} diff --git a/backend/internal/bootstrap/tls.go b/backend/internal/bootstrap/tls.go index 4c3807b..709f9da 100644 --- a/backend/internal/bootstrap/tls.go +++ b/backend/internal/bootstrap/tls.go @@ -186,9 +186,15 @@ func WriteBootstrapURL(path, value string) error { return errors.New("Bootstrap 地址不能为空") } directory := filepath.Dir(path) + if directory == filepath.VolumeName(directory)+string(filepath.Separator) { + return errors.New("Bootstrap 地址文件不能直接放在文件系统根目录") + } if err := os.MkdirAll(directory, 0o700); err != nil { return fmt.Errorf("创建 Bootstrap 运行目录失败:%w", err) } + if info, err := os.Lstat(directory); err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return errors.New("Bootstrap 运行目录必须是普通目录") + } if err := os.Chmod(directory, 0o700); err != nil { return fmt.Errorf("设置 Bootstrap 运行目录权限失败:%w", err) } @@ -197,24 +203,24 @@ func WriteBootstrapURL(path, value string) error { } else if err != nil && !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("检查 Bootstrap 地址文件失败:%w", err) } - temporaryPath := path + ".tmp" - if info, err := os.Lstat(temporaryPath); err == nil && info.Mode()&os.ModeSymlink != 0 { - return errors.New("Bootstrap 临时地址文件不能是符号链接") - } else if err != nil && !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("检查 Bootstrap 临时地址文件失败:%w", err) - } - if err := os.WriteFile(temporaryPath, []byte(value+"\n"), 0o600); err != nil { + temporary, err := os.CreateTemp(directory, ".bootstrap-url-") + if err != nil { return fmt.Errorf("创建 Bootstrap 临时地址文件失败:%w", err) } + temporaryPath := temporary.Name() + defer os.Remove(temporaryPath) + defer temporary.Close() + if _, err := temporary.WriteString(value+"\n"); err != nil { return fmt.Errorf("写入 Bootstrap 临时地址文件失败:%w", err) } - if err := os.Chmod(temporaryPath, 0o600); err != nil { - _ = os.Remove(temporaryPath) + if err := temporary.Chmod(0o600); err != nil { return fmt.Errorf("设置 Bootstrap 临时地址文件权限失败:%w", err) } + if err := temporary.Sync(); err != nil { return fmt.Errorf("同步 Bootstrap 地址文件失败:%w", err) } + if err := temporary.Close(); err != nil { return fmt.Errorf("关闭 Bootstrap 地址文件失败:%w", err) } if err := os.Rename(temporaryPath, path); err != nil { _ = os.Remove(temporaryPath) return fmt.Errorf("替换 Bootstrap 地址文件失败:%w", err) } - return os.Chmod(path, 0o600) + return nil } func cloneIPs(values []net.IP) []net.IP { diff --git a/backend/internal/cli/root.go b/backend/internal/cli/root.go index ea36e0a..1923b58 100644 --- a/backend/internal/cli/root.go +++ b/backend/internal/cli/root.go @@ -9,7 +9,6 @@ import ( "errors" "fmt" "io" - "net/http" "os" "os/signal" "path/filepath" @@ -21,6 +20,7 @@ import ( "github.com/spf13/cobra" "git.pcux.cn/xuans/Scert/backend/internal/app" + "git.pcux.cn/xuans/Scert/backend/internal/bootstrap" "git.pcux.cn/xuans/Scert/backend/internal/buildinfo" "git.pcux.cn/xuans/Scert/backend/internal/certificate" "git.pcux.cn/xuans/Scert/backend/internal/config" @@ -653,45 +653,45 @@ func commandStatus(cfg config.Config) map[string]any { } func newServeCommand() *cobra.Command { - return &cobra.Command{Use: "serve", Short: "启动网站服务", RunE: func(cmd *cobra.Command, args []string) error { - cfg, err := loadConfig() + var publicIP string + command := &cobra.Command{Use: "serve", Short: "启动网站服务", RunE: func(cmd *cobra.Command, args []string) error { + path, err := filepath.Abs(resolveConfigPath()) if err != nil { return err } - if !cfg.SetupReady { - cfg.Server.Host = "127.0.0.1" - fmt.Fprintln(cmd.OutOrStdout(), "系统尚未初始化,临时服务仅监听本机;请完成初始化后重启服务。") - } - if cfg.SetupReady && !cfg.Web.Enabled { - return fmt.Errorf("管理页面已关闭") + reload := func() (config.Config, error) { return loadServeConfig(path, config.Default(baseDir)) } + cfg, err := reload() + if err != nil { + return err } lock, err := acquireProcessLock(cfg, "Scert 服务已在运行或备份恢复任务正在执行") if err != nil { return err } defer lock.Close() - service, err := app.NewWithConfigPath(cmd.Context(), cfg, resolveConfigPath()) + lockedDataPath := cfg.Paths.Data + cfg, err = reload() if err != nil { return err } - defer service.Close() + if filepath.Clean(cfg.Paths.Data) != filepath.Clean(lockedDataPath) { + return fmt.Errorf("配置的数据目录已经变更,请重新启动服务") + } ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM) defer stop() - if service.Renewal != nil && cfg.SetupReady { - service.Renewal.Start(ctx) - } - errCh := make(chan error, 1) - go func() { errCh <- service.HTTP.ListenAndServe() }() - select { - case <-ctx.Done(): - return nil - case err := <-errCh: - if errors.Is(err, http.ErrServerClosed) { - return nil - } - return err + advertisedIP := publicIP + if !cmd.Flags().Changed("bootstrap-public-ip") { + advertisedIP = os.Getenv("SCERT_BOOTSTRAP_PUBLIC_IP") } + return serveLifecycle(ctx, cfg, reload, + func(ctx context.Context, initial config.Config) (bool, error) { + return bootstrap.Run(ctx, bootstrap.ServerOptions{Config: initial, ConfigPath: path, PublicIP: advertisedIP, URLPath: bootstrapURLPath(initial), Output: cmd.OutOrStdout()}) + }, + func(ctx context.Context, ready config.Config) (bool, error) { return runApplication(ctx, ready, path) }, + ) }} + command.Flags().StringVar(&publicIP, "bootstrap-public-ip", "", "首次初始化地址中的公网 IP;也可设置 SCERT_BOOTSTRAP_PUBLIC_IP") + return command } func newDomainCommand() *cobra.Command { diff --git a/backend/internal/cli/serve.go b/backend/internal/cli/serve.go new file mode 100644 index 0000000..5b932e4 --- /dev/null +++ b/backend/internal/cli/serve.go @@ -0,0 +1,99 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "runtime" + + "git.pcux.cn/xuans/Scert/backend/internal/app" + "git.pcux.cn/xuans/Scert/backend/internal/config" +) + +type serveStage func(context.Context, config.Config) (bool, error) + +// loadServeConfig 只允许真正不存在的文件进入新初始化;损坏、无权限或符号链接均失败关闭。 +func loadServeConfig(path string, defaults config.Config) (config.Config, error) { + info, err := os.Lstat(path) + if err == nil { + if !info.Mode().IsRegular() { + return config.Config{}, errors.New("服务配置必须是普通文件") + } + return config.LoadWithEnv(path) + } + if !errors.Is(err, os.ErrNotExist) { + return config.Config{}, err + } + defaults.Paths.Config = filepath.Dir(path) + if err := defaults.ApplyEnvironment(); err != nil { + return config.Config{}, err + } + if err := defaults.Validate(); err != nil { + return config.Config{}, err + } + return defaults, nil +} + +func serveLifecycle(ctx context.Context, cfg config.Config, reload func() (config.Config, error), bootstrapStage, applicationStage serveStage) error { + if !cfg.SetupReady { + completed, err := bootstrapStage(ctx, cfg) + if err != nil { + return err + } + if !completed || ctx.Err() != nil { + return nil + } + next, err := reload() + if err != nil { + return fmt.Errorf("重新读取正式配置失败:%w", err) + } + if !next.SetupReady { + return errors.New("初始化服务已退出,但正式配置尚未完成;拒绝启动业务服务") + } + if filepath.Clean(next.Paths.Data) != filepath.Clean(cfg.Paths.Data) { + return errors.New("初始化不能更改进程锁所在数据目录") + } + cfg = next + } + if ctx.Err() != nil { + return nil + } + if !cfg.Web.Enabled { + return errors.New("管理页面已关闭") + } + _, err := applicationStage(ctx, cfg) + return err +} + +func runApplication(ctx context.Context, cfg config.Config, path string) (bool, error) { + service, err := app.NewWithConfigPath(ctx, cfg, path) + if err != nil { + return false, err + } + defer service.Close() + if service.Renewal != nil { + service.Renewal.Start(ctx) + } + errorsChannel := make(chan error, 1) + go func() { errorsChannel <- service.HTTP.ListenAndServe() }() + select { + case <-ctx.Done(): + return false, nil + case err := <-errorsChannel: + if errors.Is(err, http.ErrServerClosed) { + return false, nil + } + return false, err + } +} + +func bootstrapURLPath(cfg config.Config) string { + if runtime.GOOS == "linux" { + return "/run/scert/bootstrap-url" + } + path, _ := filepath.Abs(filepath.Join(cfg.Paths.Data, "run", "bootstrap-url")) + return path +} diff --git a/backend/internal/cli/serve_test.go b/backend/internal/cli/serve_test.go new file mode 100644 index 0000000..0e79f26 --- /dev/null +++ b/backend/internal/cli/serve_test.go @@ -0,0 +1,99 @@ +package cli + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + "git.pcux.cn/xuans/Scert/backend/internal/config" +) + +func TestLoadServeConfigFailsClosed(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "conf", "scert.json") + defaults := config.Default(root) + cfg, err := loadServeConfig(path, defaults) + if err != nil || cfg.SetupReady || cfg.Paths.Config != filepath.Dir(path) { + t.Fatalf("缺失配置应进入初始化:%v", err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatal("仅加载不应保存默认配置") + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + for _, data := range []string{"{", "null", "{}", `{"setup_ready":true}`, "[]", ""} { + if err := os.WriteFile(path, []byte(data), 0o600); err != nil { + t.Fatal(err) + } + if _, err := loadServeConfig(path, defaults); err == nil { + t.Fatalf("损坏配置被降级成初始化:%q", data) + } + } + if _, err := loadServeConfig(root, defaults); err == nil { + t.Fatal("目录不应被视为缺失配置") + } +} + +func TestServeLifecycle(t *testing.T) { + for _, name := range []string{"missing", "ready", "cancel", "bootstrap-error", "reload-error", "not-ready", "path-changed", "web-disabled", "application-error"} { + t.Run(name, func(t *testing.T) { + cfg := config.Default(t.TempDir()) + cfg.SetupReady = name == "ready" || name == "web-disabled" || name == "application-error" + cfg.Web.Enabled = name != "web-disabled" + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + bootstrapCalls, reloadCalls, appCalls := 0, 0, 0 + err := serveLifecycle(ctx, cfg, func() (config.Config, error) { + reloadCalls++ + if name == "reload-error" { + return config.Config{}, errors.New("test") + } + next := cfg + next.SetupReady = name != "not-ready" + if name == "path-changed" { + next.Paths.Data += "-changed" + } + return next, nil + }, func(context.Context, config.Config) (bool, error) { + bootstrapCalls++ + if name == "bootstrap-error" { + return false, errors.New("test") + } + if name == "cancel" { + cancel() + return false, nil + } + return true, nil + }, func(_ context.Context, ready config.Config) (bool, error) { + appCalls++ + if !ready.SetupReady { + t.Fatal("未初始化时构造业务应用") + } + if name == "application-error" { + return false, errors.New("test") + } + return false, nil + }) + wantError := name != "missing" && name != "ready" && name != "cancel" + if (err != nil) != wantError { + t.Fatalf("错误边界不符:%v", err) + } + wantApp := 0 + if name == "missing" || name == "ready" || name == "application-error" { + wantApp = 1 + } + if appCalls != wantApp { + t.Fatalf("业务启动次数:%d", appCalls) + } + if cfg.SetupReady && (bootstrapCalls != 0 || reloadCalls != 0) { + t.Fatal("已完成配置进入初始化") + } + if !cfg.SetupReady && bootstrapCalls != 1 { + t.Fatal("初始化未运行一次") + } + }) + } +} diff --git a/backend/internal/servicehost/linux.go b/backend/internal/servicehost/linux.go index 6be121b..1bf3bb0 100644 --- a/backend/internal/servicehost/linux.go +++ b/backend/internal/servicehost/linux.go @@ -165,7 +165,6 @@ func renderUnitFile(options Options) (string, error) { "Description=" + normalized.Description, "After=network-online.target", "Wants=network-online.target", - "ConditionPathExists=" + quoteUnitValue(normalized.ConfigPath), "", "[Service]", "Type=simple", @@ -180,6 +179,8 @@ func renderUnitFile(options Options) (string, error) { "TimeoutStopSec=30", "Restart=on-failure", "UMask=0077", + "RuntimeDirectory=scert", + "RuntimeDirectoryMode=0700", "NoNewPrivileges=yes", "CapabilityBoundingSet=", "AmbientCapabilities=", diff --git a/backend/internal/servicehost/linux_test.go b/backend/internal/servicehost/linux_test.go index ff075f0..f95aa46 100644 --- a/backend/internal/servicehost/linux_test.go +++ b/backend/internal/servicehost/linux_test.go @@ -26,7 +26,8 @@ func TestRenderUnitFileIncludesExecutableConfigurationAndWorkDir(t *testing.T) { "[Unit]", "Description=Scert SSL 证书管理服务", "After=network-online.target", - "ConditionPathExists=\"/opt/Scert data/scert.json\"", + "RuntimeDirectory=scert", + "RuntimeDirectoryMode=0700", "[Service]", "Type=simple", "User=Scert", diff --git a/backend/service/runtime.go b/backend/service/runtime.go index 06f4c8d..4a86d6c 100644 --- a/backend/service/runtime.go +++ b/backend/service/runtime.go @@ -11,11 +11,19 @@ import ( "git.pcux.cn/xuans/Scert/backend/internal/app" "git.pcux.cn/xuans/Scert/backend/internal/config" + "git.pcux.cn/xuans/Scert/backend/internal/certificate" + "git.pcux.cn/xuans/Scert/backend/internal/dnsprovider" + "git.pcux.cn/xuans/Scert/backend/internal/firewall" + "git.pcux.cn/xuans/Scert/backend/internal/httpapi" + "git.pcux.cn/xuans/Scert/backend/internal/secretstore" + "git.pcux.cn/xuans/Scert/backend/internal/setup" ) type Runtime struct { core *app.App cancel context.CancelFunc + initial http.Handler + initialDNS *setup.DNSVerificationManager } func Open(configPath, baseDir string) (*Runtime, error) { @@ -43,6 +51,27 @@ func Open(configPath, baseDir string) (*Runtime, error) { } ctx, cancel := context.WithCancel(context.Background()) + if !cfg.SetupReady { + // 桌面端保留本机向导,但不再为初始化预先创建正式数据库和后台任务。 + store, err := secretstore.Open(cfg.Paths.Config) + if err != nil { cancel(); return nil, err } + dns := setup.NewDNSVerificationManager(dnsprovider.NewTester(dnsprovider.NewLegoFactory()), store) + issuer := certificate.NewService(cfg, nil, store, dnsprovider.NewLegoFactory(), certificate.NewAccountRepository(store)) + issuer.SetDNSAuthorizationChecker(dnsprovider.NewAliyunAuthorizationChecker()) + server := httpapi.New(httpapi.Dependencies{ + Config: cfg, ConfigPath: configPath, SetupDNS: dns, Secrets: store, + Firewall: firewall.NewManager(), IssueSetupCertificate: issuer.IssueForSetup, + RollbackSetupCertificate: certificate.RollbackSavedResource, + }) + handler := http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path == "/api/v1/setup" || strings.HasPrefix(request.URL.Path, "/api/v1/setup/") { + server.Handler().ServeHTTP(writer, request) + return + } + http.NotFound(writer, request) + }) + return &Runtime{cancel: cancel, initial: handler, initialDNS: dns}, nil + } core, err := app.NewWithConfigPath(ctx, cfg, configPath) if err != nil { cancel() @@ -55,6 +84,7 @@ func Open(configPath, baseDir string) (*Runtime, error) { } func (r *Runtime) Handler() http.Handler { + if r != nil && r.initial != nil { return r.initial } if r == nil || r.core == nil || r.core.HTTP == nil { return http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { http.Error(writer, "Scert 核心服务未初始化", http.StatusServiceUnavailable) @@ -70,6 +100,7 @@ func (r *Runtime) Close() error { if r.cancel != nil { r.cancel() } + if r.initialDNS != nil { r.initialDNS.DiscardAll() } if r.core != nil { return r.core.Close() } diff --git a/backend/service/runtime_test.go b/backend/service/runtime_test.go index 03962fd..0507499 100644 --- a/backend/service/runtime_test.go +++ b/backend/service/runtime_test.go @@ -14,6 +14,8 @@ func TestOpenCreatesUninitializedRuntime(t *testing.T) { t.Fatalf("打开桌面核心服务失败:%v", err) } defer runtime.Close() + if runtime.core != nil { t.Fatal("桌面首次初始化不应创建正式应用") } + if _, err := os.Stat(defaultConfig(baseDir).Database.DSN); !os.IsNotExist(err) { t.Fatalf("桌面初始化提前创建数据库:%v", err) } request, err := http.NewRequest(http.MethodGet, "http://127.0.0.1/api/v1/setup/status", nil) if err != nil { diff --git a/deploy/systemd/Scert.service b/deploy/systemd/Scert.service index 7d213c3..997c855 100644 --- a/deploy/systemd/Scert.service +++ b/deploy/systemd/Scert.service @@ -3,7 +3,6 @@ Description=Scert SSL 证书管理核心服务 Documentation=file:///scert/docs/Scert-service-management.md After=network-online.target Wants=network-online.target -ConditionPathExists=/scert/conf/scert.json [Service] Type=simple @@ -15,6 +14,8 @@ KillSignal=SIGTERM TimeoutStopSec=30 Restart=on-failure UMask=0077 +RuntimeDirectory=scert +RuntimeDirectoryMode=0700 NoNewPrivileges=yes CapabilityBoundingSet= AmbientCapabilities= diff --git a/frontend/src/App.js b/frontend/src/App.js index 83259c0..8bb4282 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -13,9 +13,30 @@ export function useAppShell() { bootstrapping.value = true; error.value = ""; try { - const response = await fetch("/api/v1/setup/status"); - const result = await response.json().catch(() => ({})); + let response = await fetch("/api/v1/bootstrap/status", { cache: "no-store" }); + if (response.status === 404) { + response = await fetch("/api/v1/setup/status", { cache: "no-store" }); + } + let result = await response.json().catch(() => ({})); if (!response.ok) throw new Error(result.error || "读取初始化状态失败"); + if (result.mode === "bootstrap") { + const fragment = new URLSearchParams(window.location.hash.slice(1)); + const token = fragment.get("token"); + if (token) { + // 令牌不进入后续导航、浏览器历史或普通日志;兑换后仅保留 HttpOnly Cookie。 + window.history.replaceState(null, "", window.location.pathname + window.location.search); + const exchange = await fetch("/api/v1/bootstrap/session", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token }), + }); + if (!exchange.ok) throw new Error("初始化地址无效或已经过期,请在服务器上重新获取一次性地址。"); + } + const settings = await fetch("/api/v1/bootstrap/settings", { cache: "no-store", credentials: "same-origin" }); + if (!settings.ok) throw new Error("请使用服务器 bootstrap-url 文件中的完整一次性地址访问,或在原浏览器中继续初始化。"); + result = await settings.json(); + } setupState.value = result; if (result.initialized) { await checkAuth(); @@ -48,7 +69,17 @@ export function useAppShell() { isAdmin.value = result.is_admin === true || ["super_admin", "admin"].includes(result.role); } - function handleInitialized() { + function handleInitialized(result) { + if (result?.completed && result?.management_url) { + const destination = new URL(result.management_url); + if (destination.protocol !== "https:" || destination.username || destination.password) { + error.value = "正式管理地址无效,请检查服务器配置。"; + return; + } + // 后端此时正在优雅切换监听端口,短暂等待后访问正式入口。 + window.setTimeout(() => window.location.assign(destination.href), 2000); + return; + } setupState.value = { ...setupState.value, initialized: true }; authenticated.value = false; isAdmin.value = false; diff --git a/frontend/src/views/SetupWizard/BootstrapPayload.js b/frontend/src/views/SetupWizard/BootstrapPayload.js new file mode 100644 index 0000000..7c31ca5 --- /dev/null +++ b/frontend/src/views/SetupWizard/BootstrapPayload.js @@ -0,0 +1,26 @@ +// 首次初始化只提交验证令牌,不接受浏览器指定敏感凭据引用或服务端路径。 +export function bootstrapPayload(legacy, databaseToken, environment) { + const notification = {}; + for (const [key, value] of Object.entries(legacy)) { + if (key.startsWith("email_") && key !== "email_http_credential_ref") { + notification[key.slice(6)] = value; + } + } + notification.recipients = legacy.recipients; + return { + database_verification_token: databaseToken, + dns_verification_token: legacy.dns_verification_token, + admin_username: legacy.admin_username, + admin_email: legacy.admin_email, + admin_password: legacy.admin_password, + server_port: legacy.server_port, + web_hostname: legacy.web_hostname, + web_public_ip: legacy.web_public_ip, + web_enabled: legacy.web_enabled, + https_mode: legacy.https_mode, + acme_environment: environment, + propagation_timeout_secs: legacy.propagation_timeout_secs, + propagation_polling_interval_secs: legacy.propagation_polling_interval_secs, + notification, + }; +} diff --git a/frontend/src/views/SetupWizard/DatabaseSetup.js b/frontend/src/views/SetupWizard/DatabaseSetup.js new file mode 100644 index 0000000..dc36420 --- /dev/null +++ b/frontend/src/views/SetupWizard/DatabaseSetup.js @@ -0,0 +1,53 @@ +import { computed, onUnmounted, ref, watch } from "vue"; + +export function useDatabaseSetup(clock) { + const databaseForm = ref({ driver: "sqlite", host: "", port: 3306, database: "scert", username: "", password: "", root_certificate: "" }); + const databaseTesting = ref(false); + const databaseError = ref(""); + const databaseVerification = ref(null); + let version = 0; + const databaseVerified = computed(() => { + clock.value; + return databaseVerification.value?.verified === true && + !!databaseVerification.value?.verification_token && + new Date(databaseVerification.value.expires_at).getTime() > Date.now(); + }); + function invalidateDatabase() { + version += 1; + databaseVerification.value = null; + databaseTesting.value = false; + databaseError.value = ""; + } + watch(() => databaseForm.value.driver, (driver) => { + databaseForm.value.port = driver === "postgres" ? 5432 : 3306; + databaseForm.value.root_certificate = ""; + }, { flush: "sync" }); + watch(databaseForm, invalidateDatabase, { deep: true, flush: "sync" }); + async function testDatabase() { + invalidateDatabase(); + const current = version; + databaseTesting.value = true; + const form = databaseForm.value; + const payload = form.driver === "sqlite" ? { driver: "sqlite" } : { + ...form, port: Number(form.port), tls_mode: form.driver === "mysql" ? "true" : "verify-full", + }; + try { + const response = await fetch("/api/v1/bootstrap/database/test", { + method: "POST", credentials: "same-origin", + headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), + }); + const result = await response.json().catch(() => ({})); + if (current !== version) return; + if (!response.ok || !result.verified || !result.verification_token) { + throw new Error(result.error || "数据库连接和写权限验证失败。"); + } + databaseVerification.value = result; + } catch (err) { + if (current === version) databaseError.value = err.message || "数据库验证失败。"; + } finally { + if (current === version) databaseTesting.value = false; + } + } + onUnmounted(invalidateDatabase); + return { databaseForm, databaseTesting, databaseError, databaseVerification, databaseVerified, testDatabase, invalidateDatabase }; +} diff --git a/frontend/src/views/SetupWizard/DatabaseSetup.vue b/frontend/src/views/SetupWizard/DatabaseSetup.vue new file mode 100644 index 0000000..e2d6714 --- /dev/null +++ b/frontend/src/views/SetupWizard/DatabaseSetup.vue @@ -0,0 +1,47 @@ + + + + + + 数据库存储 + 选择一种存储方式,验证连接和写权限后继续。不会清空已有数据。 + + + + 数据库类型 + + SQLite(本机文件,无需额外服务) + MySQL + PostgreSQL + + + + 数据库主机 + 数据库端口 + 数据库名称 + 数据库用户名 + 数据库密码 + + 根证书路径(服务器绝对路径,可选) + + + + + 外部数据库必须启用 TLS 并校验证书和主机名,不支持跳过验证。请使用专属数据库与最小权限账号。 + + + {{ testing ? "正在验证数据库,请等待(最长约 20 秒)……" : "验证数据库" }} + + 数据库连接及写权限已验证。 + + {{ error }} + + diff --git a/frontend/src/views/SetupWizard/SetupWizard.js b/frontend/src/views/SetupWizard/SetupWizard.js index 0044aca..4131ec6 100644 --- a/frontend/src/views/SetupWizard/SetupWizard.js +++ b/frontend/src/views/SetupWizard/SetupWizard.js @@ -1,4 +1,6 @@ import { computed, onMounted, onUnmounted, ref, watch } from "vue"; +import { useDatabaseSetup } from "./DatabaseSetup.js"; +import { bootstrapPayload } from "./BootstrapPayload.js"; const defaultDNSVerificationTimeoutSeconds = 60; const aliyunDNSVerificationTimeoutSeconds = 6; @@ -26,6 +28,8 @@ const commonEmailDomains = [ export function useSetupWizard(props, emit) { const scertLogo = "/Scert.png"; + const isBootstrap = props.status.mode === "bootstrap"; + const apiBase = isBootstrap ? "/api/v1/bootstrap" : "/api/v1/setup"; const submitting = ref(false); const error = ref(""); @@ -44,6 +48,8 @@ export function useSetupWizard(props, emit) { const dnsTestDomain = ref(props.status.web_hostname || ""); const dnsVerification = ref(null); const verificationClock = ref(Date.now()); + const database = useDatabaseSetup(verificationClock); + const completed = ref(false); const currentStep = ref(1); const notificationAdvancedVisible = ref(false); const selectedEmailDomain = ref(""); @@ -96,6 +102,7 @@ export function useSetupWizard(props, emit) { web_public_ip: props.status.web_public_ip || "", web_enabled: props.status.web_enabled !== false, https_mode: props.status.https_mode || "direct", + acme_environment: props.status.acme_environment || "staging", }); const targetLabel = computed(() => @@ -146,8 +153,8 @@ export function useSetupWizard(props, emit) { const dnsVerificationValid = computed(() => { verificationClock.value; if ( - !dnsVerification.value?.verification_token || - !dnsVerification.value?.credential_ref + dnsVerification.value?.verified !== true || !dnsVerification.value?.verification_token || + (!isBootstrap && !dnsVerification.value?.credential_ref) ) return false; return new Date(dnsVerification.value.expires_at).getTime() > Date.now(); @@ -377,7 +384,7 @@ export function useSetupWizard(props, emit) { dnsCatalogLoading.value = true; dnsCatalogError.value = ""; try { - const response = await fetch("/api/v1/setup/dns/providers"); + const response = await fetch(`${apiBase}/dns/providers`); const result = await response.json().catch(() => ({})); if (!response.ok) throw new Error(result.error || "读取 DNS 服务商列表失败"); @@ -465,7 +472,7 @@ export function useSetupWizard(props, emit) { dnsTesting.value = true; startDNSVerificationTimer(requestVersion, provider.id); try { - const response = await fetch("/api/v1/setup/dns/test", { + const response = await fetch(`${apiBase}/dns/test`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -535,6 +542,10 @@ export function useSetupWizard(props, emit) { error.value = `请填写${targetLabel.value}。`; return false; } + if (isBootstrap && !database.databaseVerified.value) { + error.value = "请先验证所选数据库的连接和写权限。"; + return false; + } } if (currentStep.value === 2) { @@ -572,10 +583,10 @@ export function useSetupWizard(props, emit) { if ( form.value.email_http_enabled && form.value.email_http_auth_mode !== "none" && - !form.value.email_http_credential_ref.trim() && + (isBootstrap || !form.value.email_http_credential_ref.trim()) && !emailCredentials.value.trim() ) { - error.value = "启用 HTTP 通知认证后必须填写明文凭据或已有凭据引用。"; + error.value = "启用 HTTP 通知认证后必须填写有效凭据。"; return false; } if ( @@ -674,11 +685,12 @@ export function useSetupWizard(props, emit) { } function handleSubmit() { + if (submitting.value || completed.value) return; if (currentStep.value < 4) { nextStep(); return; } - submit(); + return submit(); } function recipients() { @@ -703,6 +715,10 @@ export function useSetupWizard(props, emit) { async function submit() { error.value = ""; resultMessage.value = ""; + if (isBootstrap && !database.databaseVerified.value) { + error.value = "数据库验证结果无效或已过期,请重新验证。"; + return; + } if ( form.value.admin_password.length < 8 || form.value.admin_password.length > 12 @@ -755,13 +771,17 @@ export function useSetupWizard(props, emit) { error.value = `请填写${targetLabel.value}。`; return; } + if (isBootstrap && hostname && hostname.toLowerCase().replace(/\.$/, "") !== dnsTestDomain.value.trim().toLowerCase().replace(/\.$/, "")) { + error.value = "DNS 验证域名必须与管理页面域名一致,请重新验证。"; + return; + } submitting.value = true; try { - const response = await fetch("/api/v1/setup", { + const response = await fetch(isBootstrap ? `${apiBase}/complete` : apiBase, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ + body: JSON.stringify((isBootstrap ? (payload) => bootstrapPayload(payload, database.databaseVerification.value.verification_token, form.value.acme_environment) : (payload) => payload)({ admin_username: form.value.admin_username, admin_email: form.value.admin_email, admin_password: form.value.admin_password, @@ -809,14 +829,20 @@ export function useSetupWizard(props, emit) { web_public_ip: publicIP, web_enabled: form.value.web_enabled, https_mode: form.value.https_mode, - }), + })), }); const result = await response.json().catch(() => ({})); if (!response.ok) throw new Error(result.error || "保存初始化配置失败"); - resultMessage.value = result.message || "初始化配置已保存。"; + completed.value = true; + resultMessage.value = isBootstrap ? `初始化配置已保存,正在切换至 ${result.management_url}。请稍候;IP 自签名证书或测试证书需要人工核对。` : result.message || "初始化配置已保存。"; emit("initialized", result); } catch (err) { error.value = err.message || "保存初始化配置失败"; + if (isBootstrap) { + // 服务端可能已领取令牌;失败重试必须重新验证,不能复用已消费状态。 + database.invalidateDatabase(); + invalidateDNSVerification(); + } } finally { submitting.value = false; } @@ -834,6 +860,10 @@ export function useSetupWizard(props, emit) { watch(dnsCredentials, invalidateDNSVerification, { deep: true, flush: "sync" }); watch(dnsTestDomain, invalidateDNSVerification, { flush: "sync" }); + watch(() => [targetType.value, form.value.web_hostname], ([target, hostname]) => { + if (isBootstrap && target === "hostname") dnsTestDomain.value = hostname; + }, { flush: "sync" }); + onMounted(() => { loadDNSProviders(); emailAutofillTimer = window.setInterval(() => { @@ -851,6 +881,9 @@ export function useSetupWizard(props, emit) { }); return { + isBootstrap, + completed, + ...database, scertLogo, submitting, error, diff --git a/frontend/src/views/SetupWizard/SetupWizard.vue b/frontend/src/views/SetupWizard/SetupWizard.vue index db2e9e2..841dbdb 100644 --- a/frontend/src/views/SetupWizard/SetupWizard.vue +++ b/frontend/src/views/SetupWizard/SetupWizard.vue @@ -1,6 +1,7 @@
外部数据库必须启用 TLS 并校验证书和主机名,不支持跳过验证。请使用专属数据库与最小权限账号。
{{ error }}