继续初始化测试前保存启动链路与页面迁移检查点
This commit is contained in:
parent
34d1fb88e5
commit
199d727ecc
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
206
backend/internal/bootstrap/server.go
Normal file
206
backend/internal/bootstrap/server.go
Normal 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
|
||||
}
|
||||
115
backend/internal/bootstrap/server_test.go
Normal file
115
backend/internal/bootstrap/server_test.go
Normal 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("认证后无法读取初始化设置")
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
99
backend/internal/cli/serve.go
Normal file
99
backend/internal/cli/serve.go
Normal 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
|
||||
}
|
||||
99
backend/internal/cli/serve_test.go
Normal file
99
backend/internal/cli/serve_test.go
Normal 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("初始化未运行一次")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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=",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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=
|
||||
|
||||
@ -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;
|
||||
|
||||
26
frontend/src/views/SetupWizard/BootstrapPayload.js
Normal file
26
frontend/src/views/SetupWizard/BootstrapPayload.js
Normal 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,
|
||||
};
|
||||
}
|
||||
53
frontend/src/views/SetupWizard/DatabaseSetup.js
Normal file
53
frontend/src/views/SetupWizard/DatabaseSetup.js
Normal 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 };
|
||||
}
|
||||
47
frontend/src/views/SetupWizard/DatabaseSetup.vue
Normal file
47
frontend/src/views/SetupWizard/DatabaseSetup.vue
Normal 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>
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
? "下一步"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user