继续初始化测试前保存启动链路与页面迁移检查点

This commit is contained in:
xuans 2026-09-16 19:53:34 +08:00
parent 34d1fb88e5
commit 199d727ecc
20 changed files with 891 additions and 67 deletions

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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) {

View File

@ -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
}

View File

@ -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("认证后无法读取初始化设置")
}
}

View File

@ -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 {

View File

@ -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 {

View File

@ -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
}

View File

@ -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("初始化未运行一次")
}
})
}
}

View File

@ -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=",

View File

@ -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",

View File

@ -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()
}

View File

@ -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 {

View File

@ -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=

View File

@ -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;

View File

@ -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,
};
}

View File

@ -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 };
}

View File

@ -0,0 +1,47 @@
<script setup>
defineProps({
form: { type: Object, required: true },
testing: Boolean,
verified: Boolean,
error: { type: String, default: "" },
});
const emit = defineEmits(["test"]);
</script>
<template>
<section class="database-setup" aria-label="数据库存储配置">
<div class="setup-section-heading">
<strong>数据库存储</strong>
<span>选择一种存储方式,验证连接和写权限后继续。不会清空已有数据。</span>
</div>
<div class="setup-grid two-columns">
<label>
<span>数据库类型</span>
<select v-model="form.driver" :disabled="testing">
<option value="sqlite">SQLite(本机文件,无需额外服务)</option>
<option value="mysql">MySQL</option>
<option value="postgres">PostgreSQL</option>
</select>
</label>
<template v-if="form.driver !== 'sqlite'">
<label><span>数据库主机</span><input v-model.trim="form.host" autocomplete="off" placeholder="db.example.com" /></label>
<label><span>数据库端口</span><input v-model.number="form.port" type="number" min="1" max="65535" /></label>
<label><span>数据库名称</span><input v-model.trim="form.database" autocomplete="off" /></label>
<label><span>数据库用户名</span><input v-model.trim="form.username" autocomplete="off" /></label>
<label><span>数据库密码</span><input v-model="form.password" type="password" autocomplete="new-password" /></label>
<label v-if="form.driver === 'postgres'">
<span>根证书路径(服务器绝对路径,可选)</span>
<input v-model.trim="form.root_certificate" placeholder="使用系统信任库时留空" />
</label>
</template>
</div>
<p v-if="form.driver !== 'sqlite'">外部数据库必须启用 TLS 并校验证书和主机名,不支持跳过验证。请使用专属数据库与最小权限账号。</p>
<div class="dns-test-actions">
<button type="button" class="dns-test-button" :disabled="testing" @click="emit('test')">
{{ testing ? "正在验证数据库,请等待(最长约 20 秒)……" : "验证数据库" }}
</button>
<span v-if="verified" role="status">数据库连接及写权限已验证。</span>
</div>
<p v-if="error" class="inline-error" role="alert">{{ error }}</p>
</section>
</template>

View File

@ -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,

View File

@ -1,6 +1,7 @@
<script setup>
import { useSetupWizard } from "./SetupWizard.js";
import AccessShell from "../components/AccessShell/AccessShell.vue";
import DatabaseSetup from "./DatabaseSetup.vue";
const props = defineProps({
status: {
@ -12,6 +13,13 @@ const props = defineProps({
const emit = defineEmits(["initialized"]);
const {
isBootstrap,
completed,
databaseForm,
databaseTesting,
databaseError,
databaseVerified,
testDatabase,
scertLogo,
submitting,
error,
@ -38,6 +46,8 @@ const {
selectedEmailDomain,
emailLocalPart,
emailInputElement,
emailAutofillElement,
handleEmailAutofill,
targetLabel,
resetURLPreview,
emailCredentialLabel,
@ -81,7 +91,7 @@ const {
<p class="setup-eyebrow">初始化向导</p>
<h2>基础配置</h2>
</div>
<span class="local-badge">仅限本机</span>
<span class="local-badge">{{ isBootstrap ? "受保护的 HTTPS 初始化" : "仅限本机" }}</span>
</header>
<nav class="setup-progress" aria-label="初始化步骤">
@ -115,7 +125,7 @@ const {
@submit.prevent="handleSubmit"
>
<div class="setup-step-grid">
<fieldset v-if="currentStep === 1" class="setup-step-card">
<fieldset v-if="currentStep === 1" class="setup-step-card" :disabled="submitting || completed">
<legend><span>01</span>超级管理员</legend>
<div class="setup-grid two-columns">
<label
@ -264,6 +274,14 @@ const {
/>
</label>
</div>
<DatabaseSetup
v-if="isBootstrap"
:form="databaseForm"
:testing="databaseTesting"
:verified="databaseVerified"
:error="databaseError"
@test="testDatabase"
/>
<div class="provider-description reset-preview">
<strong>密码重置地址预览</strong>
<span>{{ resetURLPreview }}</span>
@ -513,7 +531,7 @@ const {
placeholder="/api/login"
/>
</label>
<label>
<label v-if="!isBootstrap">
<span>已有凭据引用(可选)</span>
<input
id="email-http-credential-ref"
@ -524,7 +542,7 @@ const {
</div>
<div v-else class="notification-row notification-auth-row">
<label>
<label v-if="!isBootstrap">
<span>已有凭据引用(可选)</span>
<input
id="email-http-credential-ref"
@ -532,7 +550,7 @@ const {
placeholder="已有安全凭据引用"
/>
</label>
<div class="notification-auth-note">
<div v-if="form.email_http_auth_mode === 'none'" class="notification-auth-note">
当前接口不启用认证,不需要填写凭据。
</div>
</div>
@ -865,9 +883,18 @@ const {
id="certificate-root"
name="certificate_root"
v-model.trim="form.certificate_root"
:readonly="isBootstrap"
required
/>
</label>
<label v-if="isBootstrap">
<span>证书签发环境</span>
<select v-model="form.acme_environment">
<option value="staging">测试环境(证书不受公共信任)</option>
<option value="production">正式环境(实际申请受信任域名证书)</option>
</select>
<small>IP 管理入口使用自签名证书,不会因选择正式环境而自动受信任。</small>
</label>
<label>
<span>HTTPS 模式</span>
<select
@ -887,6 +914,7 @@ const {
id="web-enabled"
name="web_enabled"
v-model="form.web_enabled"
:disabled="isBootstrap"
type="checkbox"
/>
<span>
@ -901,17 +929,18 @@ const {
<p v-if="resultMessage" class="setup-success">{{ resultMessage }}</p>
<footer>
<span
>初始化程序将自动配置本机防火墙;云安全组只需放行主服务端口。配置完成前仅保存在内存中。</span
>请放行初始化及正式服务端口。验证凭据加密保存;完成后关闭临时入口并切换正式管理地址。</span
>
<div class="wizard-actions">
<button
v-if="currentStep > 1"
class="back-button"
type="button"
:disabled="submitting || completed"
@click="previousStep"
>
上一步</button
><button type="submit" :disabled="submitting || dnsTesting">
><button type="submit" :disabled="submitting || dnsTesting || databaseTesting || completed">
{{
currentStep < 4
? "下一步"