diff --git a/backend/cmd/scert-storage/main_linux.go b/backend/cmd/scert-storage/main_linux.go
new file mode 100644
index 0000000..92343ea
--- /dev/null
+++ b/backend/cmd/scert-storage/main_linux.go
@@ -0,0 +1,63 @@
+package main
+
+import (
+ "context"
+ "errors"
+ "flag"
+ "fmt"
+ "net"
+ "os"
+ "os/signal"
+ "os/user"
+ "strconv"
+ "syscall"
+
+ "git.pcux.cn/xuans/Scert/backend/internal/certstorage"
+)
+
+func run(ctx context.Context) error {
+ root := flag.String("root", "/ssl", "安装时额外批准的证书存储根目录")
+ flag.Parse()
+ if flag.NArg() != 0 || os.Geteuid() != 0 || os.Getenv("LISTEN_PID") != strconv.Itoa(os.Getpid()) || os.Getenv("LISTEN_FDS") != "1" {
+ return errors.New("目录服务必须由 root 的单个 systemd socket 启动")
+ }
+ account, err := user.Lookup("Scert")
+ if err != nil {
+ return err
+ }
+ uid, err := strconv.ParseUint(account.Uid, 10, 32)
+ if err != nil {
+ return err
+ }
+ gid, err := strconv.ParseUint(account.Gid, 10, 32)
+ if err != nil {
+ return err
+ }
+ file := os.NewFile(3, "storage-socket")
+ if file == nil {
+ return errors.New("目录服务监听描述符不存在")
+ }
+ defer file.Close()
+ listener, err := net.FileListener(file)
+ if err != nil {
+ return err
+ }
+ defer listener.Close()
+ local, ok := listener.(*net.UnixListener)
+ if !ok || listener.Addr().Network() != "unix" || listener.Addr().String() != certstorage.SocketPath {
+ return errors.New("目录服务拒绝非固定 Unix 通道")
+ }
+ return (certstorage.Service{
+ UID: uint32(uid), GID: uint32(gid), Roots: []string{"/ssl", "/var/lib/scert/certificates", *root},
+ Log: func(err error) { fmt.Fprintln(os.Stderr, "证书目录自动准备失败:", err) },
+ }).Serve(ctx, local)
+}
+
+func main() {
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer stop()
+ if err := run(ctx); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+}
diff --git a/backend/internal/bootstrap/certificate_root.go b/backend/internal/bootstrap/certificate_root.go
index f39e210..e38933d 100644
--- a/backend/internal/bootstrap/certificate_root.go
+++ b/backend/internal/bootstrap/certificate_root.go
@@ -1,17 +1,23 @@
package bootstrap
import (
+ "context"
"crypto/rand"
"errors"
"os"
"path/filepath"
"strings"
+ "git.pcux.cn/xuans/Scert/backend/internal/certstorage"
"git.pcux.cn/xuans/Scert/backend/internal/config"
)
// prepareCertificateRoot 在申请证书前验证路径,并以实际运行身份探测创建、写入和清理权限。
func prepareCertificateRoot(paths config.PathsConfig, requested string) (string, error) {
+ return prepareCertificateRootContext(context.Background(), paths, requested)
+}
+
+func prepareCertificateRootContext(ctx context.Context, paths config.PathsConfig, requested string) (string, error) {
root := strings.TrimSpace(requested)
if root == "" {
root = paths.Certificate
@@ -48,7 +54,9 @@ func prepareCertificateRoot(paths config.PathsConfig, requested string) (string,
}
}
if err := os.MkdirAll(root, 0o700); err != nil {
- return unwritable(err)
+ if prepareErr := certstorage.Prepare(ctx, root); prepareErr != nil {
+ return unwritable(errors.Join(err, prepareErr))
+ }
}
directory, err := os.OpenRoot(root)
if err != nil {
@@ -59,7 +67,20 @@ func prepareCertificateRoot(paths config.PathsConfig, requested string) (string,
name := ".scert-write-check-" + rand.Text()
probe, err := directory.OpenFile(name, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
- return unwritable(err)
+ if prepareErr := certstorage.Prepare(ctx, root); prepareErr != nil {
+ return unwritable(errors.Join(err, prepareErr))
+ }
+ // systemd 已替换该挂载点;必须重新打开目录,不能复用旧的只读描述符。
+ _ = directory.Close()
+ directory, err = os.OpenRoot(root)
+ if err != nil {
+ return unwritable(err)
+ }
+ defer directory.Close()
+ probe, err = directory.OpenFile(name, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
+ if err != nil {
+ return unwritable(err)
+ }
}
_, writeErr := probe.Write([]byte("scert"))
syncErr := probe.Sync()
diff --git a/backend/internal/bootstrap/complete.go b/backend/internal/bootstrap/complete.go
index 246bce4..ea8a351 100644
--- a/backend/internal/bootstrap/complete.go
+++ b/backend/internal/bootstrap/complete.go
@@ -35,6 +35,8 @@ const (
CompleteErrorInternal CompleteErrorKind = "internal"
CompleteErrorCertificatePath CompleteErrorKind = "certificate_path"
CompleteErrorCertificateAccess CompleteErrorKind = "certificate_access"
+ CompleteErrorProxyTrust CompleteErrorKind = "proxy_trust"
+ CompleteErrorProxyReadiness CompleteErrorKind = "proxy_readiness"
)
// CompleteError 只向 HTTP 层公开安全分类和通用说明,底层错误不得直接返回浏览器。
@@ -120,6 +122,7 @@ type CompleteDependencies struct {
IssueIP completionIPIssuer
SaveConfig completionConfigSaver
ValidateFiles completionCertificateValidator
+ VerifyProxy func(context.Context, config.Config) error
RollbackDomain func(context.Context, certificate.SavedResource) error
RollbackIP func(context.Context, ManagementCertificate) error
RemoveFile func(string) error
@@ -144,6 +147,7 @@ type Completer struct {
issueIP completionIPIssuer
saveConfig completionConfigSaver
validateFiles completionCertificateValidator
+ verifyProxy func(context.Context, config.Config) error
rollbackDomain func(context.Context, certificate.SavedResource) error
rollbackIP func(context.Context, ManagementCertificate) error
removeFile func(string) error
@@ -152,6 +156,9 @@ type Completer struct {
}
func NewCompleter(dependencies CompleteDependencies) *Completer {
+ if dependencies.VerifyProxy == nil {
+ dependencies.VerifyProxy = verifySetupProxy
+ }
if dependencies.OpenDatabase == nil {
dependencies.OpenDatabase = database.Open
}
@@ -193,6 +200,7 @@ func NewCompleter(dependencies CompleteDependencies) *Completer {
openDatabase: dependencies.OpenDatabase, migrate: dependencies.Migrate,
ensureAdmin: dependencies.EnsureAdmin, issueIP: dependencies.IssueIP,
saveConfig: dependencies.SaveConfig, validateFiles: dependencies.ValidateFiles,
+ verifyProxy: dependencies.VerifyProxy,
rollbackDomain: dependencies.RollbackDomain, rollbackIP: dependencies.RollbackIP,
removeFile: dependencies.RemoveFile, warn: dependencies.Warn,
completed: dependencies.BaseConfig.SetupReady,
@@ -214,6 +222,10 @@ func (completer *Completer) Complete(ctx context.Context, request CompleteReques
return result, err
}
request = normalizeCompleteRequest(request)
+ // 在消耗数据库和 DNS 验证令牌、创建目录或申请证书之前拒绝不受支持的信任组合。
+ if request.HTTPSMode == "proxy" && (request.ACMEEnvironment != "production" || request.WebHostname == "") {
+ return result, completeError(CompleteErrorProxyTrust, "代理管理入口必须使用正式域名证书", nil)
+ }
if err := validateCompleteRequest(request); err != nil {
return result, completeError(CompleteErrorInput, "初始化参数无效", err)
}
@@ -246,7 +258,7 @@ func (completer *Completer) Complete(ctx context.Context, request CompleteReques
candidate := completer.base
candidate.Normalize()
- certificateRoot, err := prepareCertificateRoot(candidate.Paths, request.CertificateRoot)
+ certificateRoot, err := prepareCertificateRootContext(ctx, candidate.Paths, request.CertificateRoot)
if err != nil {
return result, err
}
@@ -331,6 +343,11 @@ func (completer *Completer) Complete(ctx context.Context, request CompleteReques
}
candidate.Server.TLSCert = certificateResult.fullChainPath
candidate.Server.TLSKey = certificateResult.privateKeyPath
+ if candidate.Server.HTTPSMode == "proxy" {
+ if err := completer.verifyProxy(ctx, candidate); err != nil {
+ return result, completeError(CompleteErrorProxyReadiness, "管理代理入口验证失败,初始化尚未提交", err)
+ }
+ }
firewallContext, cancelFirewall := context.WithTimeout(ctx, 30*time.Second)
firewallResult, err = completer.firewall.OpenTCP(firewallContext, candidate.Server.Port)
diff --git a/backend/internal/bootstrap/proxy_port_test.go b/backend/internal/bootstrap/proxy_port_test.go
index 8207d97..d7969fc 100644
--- a/backend/internal/bootstrap/proxy_port_test.go
+++ b/backend/internal/bootstrap/proxy_port_test.go
@@ -11,9 +11,12 @@ func TestCompleterPreservesExternalProxyPort(t *testing.T) {
fixture := newCompletionFixture(t, "admin.example.test")
request := fixture.request("admin.example.test")
request.HTTPSMode = "proxy"
+ request.ACMEEnvironment = "production"
request.ServerPort = 9004
request.ProxyHTTPSPort = 8004
- result, err := fixture.completer(nil).Complete(context.Background(), request)
+ result, err := fixture.completer(func(deps *CompleteDependencies) {
+ deps.VerifyProxy = func(context.Context, config.Config) error { return nil }
+ }).Complete(context.Background(), request)
if err != nil {
t.Fatal(err)
}
diff --git a/backend/internal/bootstrap/proxy_preflight.go b/backend/internal/bootstrap/proxy_preflight.go
new file mode 100644
index 0000000..2de8ecb
--- /dev/null
+++ b/backend/internal/bootstrap/proxy_preflight.go
@@ -0,0 +1,100 @@
+package bootstrap
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/tls"
+ "errors"
+ "io"
+ "net"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "git.pcux.cn/xuans/Scert/backend/internal/config"
+)
+
+// verifySetupProxy 使用候选证书临时监听真实上游端口,再从公开域名穿过代理核验随机响应。
+// 不跟随重定向、不使用环境代理、不关闭 TLS 校验;失败时不提交初始化配置。
+func verifySetupProxy(ctx context.Context, candidate config.Config) error {
+ transport := &http.Transport{
+ TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
+ DialContext: (&net.Dialer{Timeout: 3 * time.Second}).DialContext,
+ TLSHandshakeTimeout: 3 * time.Second, DisableKeepAlives: true,
+ }
+ return verifySetupProxyTransport(ctx, candidate, transport)
+}
+
+func verifySetupProxyTransport(ctx context.Context, candidate config.Config, transport *http.Transport) error {
+ defer transport.CloseIdleConnections()
+ if candidate.Server.HTTPSMode != "proxy" {
+ return nil
+ }
+ material, err := tls.LoadX509KeyPair(candidate.Server.TLSCert, candidate.Server.TLSKey)
+ if err != nil {
+ return err
+ }
+ host := strings.Trim(strings.TrimSpace(candidate.Server.Host), "[]")
+ if !strings.EqualFold(host, "localhost") {
+ ip := net.ParseIP(host)
+ if ip == nil || !ip.IsLoopback() {
+ host = "127.0.0.1"
+ }
+ }
+ listener, err := net.Listen("tcp", net.JoinHostPort(host, strconv.Itoa(candidate.Server.Port)))
+ if err != nil {
+ return err
+ }
+ defer listener.Close()
+ token := rand.Text()
+ path := "/.well-known/scert-setup/" + rand.Text()
+ server := &http.Server{
+ Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", "no-store")
+ if r.Method != http.MethodGet || r.URL.Path != path {
+ http.Error(w, "管理入口正在验证", http.StatusServiceUnavailable)
+ return
+ }
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ _, _ = io.WriteString(w, token)
+ }),
+ TLSConfig: &tls.Config{MinVersion: tls.VersionTLS12, Certificates: []tls.Certificate{material}},
+ ReadHeaderTimeout: 3 * time.Second, WriteTimeout: 3 * time.Second, MaxHeaderBytes: 8192,
+ }
+ done := make(chan struct{})
+ go func() { defer close(done); _ = server.ServeTLS(listener, "", "") }()
+ defer func() { _ = server.Close(); <-done }()
+ ctx, cancel := context.WithTimeout(ctx, 12*time.Second)
+ defer cancel()
+ client := &http.Client{Transport: transport, Timeout: 4 * time.Second,
+ CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
+ }
+ address := strings.TrimRight(candidate.ManagementURL(), "/") + path
+ for successes := 0; ; {
+ request, err := http.NewRequestWithContext(ctx, http.MethodGet, address, nil)
+ if err != nil {
+ return err
+ }
+ response, err := client.Do(request)
+ valid := false
+ if err == nil {
+ body, readErr := io.ReadAll(io.LimitReader(response.Body, 256))
+ _ = response.Body.Close()
+ valid = readErr == nil && response.StatusCode == http.StatusOK && string(body) == token
+ }
+ if valid {
+ successes++
+ } else {
+ successes = 0
+ }
+ if successes >= 3 {
+ return nil
+ }
+ select {
+ case <-ctx.Done():
+ return errors.New("公开代理入口未能连续通过候选上游证书及随机响应校验")
+ case <-time.After(150 * time.Millisecond):
+ }
+ }
+}
diff --git a/backend/internal/bootstrap/proxy_preflight_test.go b/backend/internal/bootstrap/proxy_preflight_test.go
new file mode 100644
index 0000000..8e2fe18
--- /dev/null
+++ b/backend/internal/bootstrap/proxy_preflight_test.go
@@ -0,0 +1,71 @@
+package bootstrap
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "strings"
+ "testing"
+
+ "git.pcux.cn/xuans/Scert/backend/internal/config"
+)
+
+func TestProxyRejectsUntrustedModeBeforeConsumingVerification(t *testing.T) {
+ for _, mode := range []string{"staging", "ip"} {
+ t.Run(mode, func(t *testing.T) {
+ fixture := newCompletionFixture(t, "admin.example.test")
+ request := fixture.request("admin.example.test")
+ request.HTTPSMode = "proxy"
+ if mode == "ip" {
+ request.WebHostname = ""
+ request.WebPublicIP = "203.0.113.10"
+ request.ACMEEnvironment = "production"
+ }
+ _, err := fixture.completer(nil).Complete(context.Background(), request)
+ var failure *CompleteError
+ if !errors.As(err, &failure) || failure.Kind != CompleteErrorProxyTrust || fixture.issuer.calls != 0 {
+ t.Fatalf("信任模式未提前拒绝:%v", err)
+ }
+ if _, _, err := fixture.database.Claim(fixture.databaseToken); err != nil {
+ t.Fatalf("数据库令牌被消耗:%v", err)
+ }
+ if _, _, err := fixture.dns.Claim(fixture.dnsToken); err != nil {
+ t.Fatalf("DNS 令牌被消耗:%v", err)
+ }
+ })
+ }
+}
+
+func TestProxyFailureDoesNotCommitInitialization(t *testing.T) {
+ fixture := newCompletionFixture(t, "admin.example.test")
+ request := fixture.request("admin.example.test")
+ request.HTTPSMode = "proxy"
+ request.ACMEEnvironment = "production"
+ checked := false
+ _, err := fixture.completer(func(deps *CompleteDependencies) {
+ deps.VerifyProxy = func(_ context.Context, candidate config.Config) error {
+ checked = candidate.Server.TLSCert != "" && candidate.Server.TLSKey != ""
+ return errors.New("secret diagnostic")
+ }
+ }).Complete(context.Background(), request)
+ var failure *CompleteError
+ if !checked || !errors.As(err, &failure) || failure.Kind != CompleteErrorProxyReadiness {
+ t.Fatalf("未验证候选代理:%v", err)
+ }
+ if _, err := os.Stat(fixture.configPath); !errors.Is(err, os.ErrNotExist) {
+ t.Fatal("代理失败仍提交了初始化配置")
+ }
+ response := httptest.NewRecorder()
+ writeBootstrapCompletionError(response, failure)
+ if response.Code != http.StatusUnprocessableEntity || strings.Contains(response.Body.String(), "secret") {
+ t.Fatal("代理错误未脱敏")
+ }
+}
+
+func TestDirectModeDoesNotRequireProxy(t *testing.T) {
+ if err := verifySetupProxy(context.Background(), config.Config{Server: config.ServerConfig{HTTPSMode: "direct"}}); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/backend/internal/bootstrap/proxy_transport_test.go b/backend/internal/bootstrap/proxy_transport_test.go
new file mode 100644
index 0000000..3a85876
--- /dev/null
+++ b/backend/internal/bootstrap/proxy_transport_test.go
@@ -0,0 +1,86 @@
+package bootstrap
+
+import (
+ "context"
+ "crypto/tls"
+ "crypto/x509"
+ "io"
+ "log"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "net/http/httputil"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strconv"
+ "testing"
+ "time"
+
+ "git.pcux.cn/xuans/Scert/backend/internal/config"
+)
+
+func TestProxyPreflightStrictTLSAndResponseProof(t *testing.T) {
+ for _, mode := range []string{"trusted", "untrusted-upstream", "untrusted-public", "wrong-response", "redirect"} {
+ t.Run(mode, func(t *testing.T) {
+ material := completionDomainResource("admin.example.test")
+ directory := t.TempDir()
+ cert := filepath.Join(directory, "chain.pem")
+ key := filepath.Join(directory, "key.pem")
+ if err := os.WriteFile(cert, material.Certificate, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(key, material.PrivateKey, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ reservation, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ port := reservation.Addr().(*net.TCPAddr).Port
+ _ = reservation.Close()
+ target, _ := url.Parse("https://127.0.0.1:" + strconv.Itoa(port))
+ upstreamRoots := x509.NewCertPool()
+ if mode != "untrusted-upstream" {
+ upstreamRoots.AppendCertsFromPEM(material.Certificate)
+ }
+ reverse := httputil.NewSingleHostReverseProxy(target)
+ reverse.ErrorLog = log.New(io.Discard, "", 0)
+ upstream := &http.Transport{TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12, RootCAs: upstreamRoots, ServerName: "admin.example.test"}}
+ defer upstream.CloseIdleConnections()
+ reverse.Transport = upstream
+ proxy := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if mode == "wrong-response" {
+ _, _ = io.WriteString(w, "unrelated-service")
+ return
+ }
+ if mode == "redirect" {
+ http.Redirect(w, r, "/login", http.StatusFound)
+ return
+ }
+ reverse.ServeHTTP(w, r)
+ }))
+ defer proxy.Close()
+ publicRoots := x509.NewCertPool()
+ if mode != "untrusted-public" {
+ publicRoots.AddCert(proxy.Certificate())
+ }
+ transport := &http.Transport{TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12, RootCAs: publicRoots}, DisableKeepAlives: true}
+ candidate := config.Config{
+ Server: config.ServerConfig{Host: "127.0.0.1", Port: port, HTTPSMode: "proxy", ProxyHTTPSPort: proxy.Listener.Addr().(*net.TCPAddr).Port, TLSCert: cert, TLSKey: key},
+ Web: config.WebConfig{Hostname: "127.0.0.1"},
+ }
+ ctx, cancel := context.WithTimeout(t.Context(), time.Second)
+ defer cancel()
+ err = verifySetupProxyTransport(ctx, candidate, transport)
+ if (err == nil) != (mode == "trusted") {
+ t.Fatalf("代理校验结果不正确:%s %v", mode, err)
+ }
+ listener, err := net.Listen("tcp", target.Host)
+ if err != nil {
+ t.Fatalf("预检未释放正式端口:%v", err)
+ }
+ _ = listener.Close()
+ })
+ }
+}
diff --git a/backend/internal/bootstrap/router.go b/backend/internal/bootstrap/router.go
index 595a25f..d1a7880 100644
--- a/backend/internal/bootstrap/router.go
+++ b/backend/internal/bootstrap/router.go
@@ -278,10 +278,14 @@ func writeBootstrapCompletionError(writer http.ResponseWriter, err error) {
case errors.As(err, &completionErr):
// 不转发底层错误或可注入的说明,防止密码、凭据引用及验证令牌泄露。
switch completionErr.Kind {
+ case CompleteErrorProxyTrust:
+ writeBootstrapError(writer, http.StatusUnprocessableEntity, "反向代理管理入口需要管理域名和正式环境证书;测试环境证书或 IP 自签名证书不能用于此模式。请使用直接 HTTPS 测试,或改用正式域名证书")
+ case CompleteErrorProxyReadiness:
+ writeBootstrapError(writer, http.StatusUnprocessableEntity, "公开管理入口未通过代理连通验证,初始化未提交。请检查域名解析、代理公开端口、上游端口、上游证书信任及转发规则,修正后重新验证并提交")
case CompleteErrorCertificatePath:
writeBootstrapError(writer, http.StatusBadRequest, "证书部署根目录必须是服务器上的独立绝对路径,不能使用根目录、符号链接或与配置、数据、日志、备份目录重叠")
case CompleteErrorCertificateAccess:
- writeBootstrapError(writer, http.StatusUnprocessableEntity, "证书部署根目录无法创建或写入,请检查 Scert 运行账户权限及服务的可写目录限制")
+ writeBootstrapError(writer, http.StatusUnprocessableEntity, "证书目录自动准备未完成,请检查目录是否位于安装时批准的范围内,以及目录准备服务是否运行。不会接管其他用户的目录或放开整个系统的写入权限")
case CompleteErrorInput:
writeBootstrapError(writer, http.StatusBadRequest, "初始化参数无效,请检查管理员、管理入口和服务配置")
case CompleteErrorVerification:
diff --git a/backend/internal/certstorage/client_linux.go b/backend/internal/certstorage/client_linux.go
new file mode 100644
index 0000000..3342520
--- /dev/null
+++ b/backend/internal/certstorage/client_linux.go
@@ -0,0 +1,96 @@
+package certstorage
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net"
+ "os"
+ "path/filepath"
+ "syscall"
+ "time"
+
+ "golang.org/x/sys/unix"
+)
+
+const SocketPath = "/run/scert-storage/prepare.sock"
+
+type request struct {
+ Path string `json:"path"`
+}
+
+type response struct {
+ Ready bool `json:"ready"`
+}
+
+func peerUID(connection *net.UnixConn) (uint32, error) {
+ raw, err := connection.SyscallConn()
+ if err != nil {
+ return 0, err
+ }
+ var credentials *unix.Ucred
+ var socketErr error
+ err = raw.Control(func(fd uintptr) {
+ credentials, socketErr = unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED)
+ })
+ if err != nil || socketErr != nil {
+ return 0, errors.Join(err, socketErr)
+ }
+ if credentials == nil || credentials.Pid <= 0 {
+ return 0, errors.New("本地目录服务缺少内核身份")
+ }
+ return credentials.Uid, nil
+}
+
+// Prepare 只使用受保护的本机通道;不接受远程地址,也不传递命令或服务名称。
+func Prepare(ctx context.Context, path string) error {
+ for current := SocketPath; ; current = filepath.Dir(current) {
+ info, err := os.Lstat(current)
+ if err != nil {
+ return err
+ }
+ stat, ok := info.Sys().(*syscall.Stat_t)
+ if !ok || stat.Uid != 0 || info.Mode()&os.ModeSymlink != 0 {
+ return errors.New("本地目录服务路径不可信")
+ }
+ if current == SocketPath {
+ if info.Mode()&os.ModeSocket == 0 || info.Mode().Perm()&0o007 != 0 {
+ return errors.New("本地目录服务通道权限无效")
+ }
+ } else if !info.IsDir() || info.Mode().Perm()&0o022 != 0 {
+ return errors.New("本地目录服务父目录权限无效")
+ }
+ if current == "/" {
+ break
+ }
+ }
+ ctx, cancel := context.WithTimeout(ctx, 25*time.Second)
+ defer cancel()
+ connection, err := (&net.Dialer{}).DialContext(ctx, "unix", SocketPath)
+ if err != nil {
+ return err
+ }
+ defer connection.Close()
+ stop := context.AfterFunc(ctx, func() { _ = connection.Close() })
+ defer stop()
+ uid, err := peerUID(connection.(*net.UnixConn))
+ if err != nil || uid != 0 {
+ return errors.New("本地目录服务不是受信任的系统进程")
+ }
+ deadline, _ := ctx.Deadline()
+ _ = connection.SetDeadline(deadline)
+ if err := json.NewEncoder(connection).Encode(request{Path: path}); err != nil {
+ return err
+ }
+ var result response
+ decoder := json.NewDecoder(io.LimitReader(connection, 1024))
+ decoder.DisallowUnknownFields()
+ if err := decoder.Decode(&result); err != nil {
+ return err
+ }
+ if !result.Ready {
+ return errors.New("目录自动准备失败,请检查获准范围、父目录权限及目录服务状态")
+ }
+ return nil
+}
diff --git a/backend/internal/certstorage/client_other.go b/backend/internal/certstorage/client_other.go
new file mode 100644
index 0000000..4838ef5
--- /dev/null
+++ b/backend/internal/certstorage/client_other.go
@@ -0,0 +1,12 @@
+//go:build !linux
+
+package certstorage
+
+import (
+ "context"
+ "errors"
+)
+
+func Prepare(context.Context, string) error {
+ return errors.New("当前平台需要运行账户具有所选目录的创建权限")
+}
diff --git a/backend/internal/certstorage/service_linux.go b/backend/internal/certstorage/service_linux.go
new file mode 100644
index 0000000..e6910b5
--- /dev/null
+++ b/backend/internal/certstorage/service_linux.go
@@ -0,0 +1,233 @@
+package certstorage
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "time"
+
+ "golang.org/x/sys/unix"
+)
+
+var safePath = regexp.MustCompile(`^/[A-Za-z0-9._/-]+$`)
+
+// Service 的策略仅来自 root 所有的 systemd unit,不读取应用可改写的配置。
+type Service struct {
+ UID uint32
+ GID uint32
+ Roots []string
+ Log func(error)
+}
+
+func within(parent, child string) bool {
+ return parent == child || strings.HasPrefix(child, parent+"/")
+}
+
+func validatePath(path string, roots []string) error {
+ if !safePath.MatchString(path) || len(path) > 1024 || filepath.Clean(path) != path || path == "/" {
+ return errors.New("证书目录必须是规范绝对路径")
+ }
+ for _, protected := range []string{"/bin", "/boot", "/dev", "/etc", "/home", "/lib", "/lib64", "/proc", "/root", "/run", "/sbin", "/scert", "/sys", "/tmp", "/usr"} {
+ if within(protected, path) || within(path, protected) {
+ return errors.New("证书目录不能覆盖系统或应用文件")
+ }
+ }
+ if within("/var", path) && !within("/var/lib/scert", path) {
+ return errors.New("证书目录不能覆盖其他服务的数据")
+ }
+ for _, root := range roots {
+ if root != "/" && filepath.Clean(root) == root && within(root, path) {
+ return nil
+ }
+ }
+ return errors.New("证书目录不在安装时批准的范围内")
+}
+
+// prepareDirectory 逐层使用不跟随链接的目录描述符。特权操作要求父目录仅 root 可写,
+// 防止运行账户在校验后替换路径;已存在的目录不被夺取所有权或递归修改权限。
+func prepareDirectory(path string, uid, gid uint32) error {
+ if uid == 0 || gid == 0 {
+ return errors.New("目录所有者不能是 root")
+ }
+ fd, err := unix.Open("/", unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
+ if err != nil {
+ return err
+ }
+ defer func() { _ = unix.Close(fd) }()
+ parts := strings.Split(strings.TrimPrefix(path, "/"), "/")
+ for index, part := range parts {
+ var parent unix.Stat_t
+ if err := unix.Fstat(fd, &parent); err != nil {
+ return err
+ }
+ if parent.Uid != 0 || parent.Mode&0o022 != 0 {
+ return errors.New("特权创建要求父目录仅由 root 写入")
+ }
+ created := false
+ next, err := unix.Openat(fd, part, unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
+ if errors.Is(err, unix.ENOENT) {
+ if err = unix.Mkdirat(fd, part, 0o750); err != nil {
+ return err
+ }
+ created = true
+ next, err = unix.Openat(fd, part, unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
+ }
+ if err != nil {
+ return err
+ }
+ _ = unix.Close(fd)
+ fd = next
+ last := index == len(parts)-1
+ if created {
+ if last {
+ if err := unix.Fchown(fd, int(uid), int(gid)); err != nil {
+ return err
+ }
+ if err := unix.Fchmod(fd, 0o700); err != nil {
+ return err
+ }
+ } else {
+ if err := unix.Fchmod(fd, 0o755); err != nil {
+ return err
+ }
+ }
+ if err := unix.Fsync(fd); err != nil {
+ return err
+ }
+ }
+ if last {
+ var info unix.Stat_t
+ if err := unix.Fstat(fd, &info); err != nil {
+ return err
+ }
+ if info.Uid != uid || info.Gid != gid || info.Mode&0o027 != 0 {
+ return errors.New("现有目录不属于 Scert 或权限过宽,拒绝接管")
+ }
+ }
+ }
+ return nil
+}
+
+func systemctl(ctx context.Context, args ...string) error {
+ // 参数为固定动作、固定服务及校验后的路径,不经过 shell。
+ output, err := exec.CommandContext(ctx, "/usr/bin/systemctl", args...).CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("目录服务调用 systemd 失败:%w:%s", err, output)
+ }
+ return nil
+}
+
+func (s Service) prepare(ctx context.Context, path string) error {
+ if err := validatePath(path, s.Roots); err != nil {
+ return err
+ }
+ if err := prepareDirectory(path, s.UID, s.GID); err != nil {
+ return err
+ }
+ // 不重启正在初始化的进程,保留数据库、DNS 和浏览器的一次性验证会话。
+ if err := systemctl(ctx, "bind", "--mkdir", "Scert.service", path, path); err != nil {
+ return err
+ }
+ return persistPermission(ctx, path)
+}
+
+func persistPermission(ctx context.Context, path string) error {
+ const directory = "/etc/systemd/system/Scert.service.d"
+ // 写入位置的全部父目录必须受 root 保护,不能被应用替换成链接。
+ for current := directory; ; current = filepath.Dir(current) {
+ var stat unix.Stat_t
+ if err := unix.Lstat(current, &stat); err != nil {
+ return err
+ }
+ if stat.Mode&unix.S_IFMT != unix.S_IFDIR || stat.Uid != 0 || stat.Mode&0o022 != 0 {
+ return errors.New("服务权限配置目录不可信")
+ }
+ if current == "/" {
+ break
+ }
+ }
+ name := fmt.Sprintf("60-storage-%x.conf", sha256.Sum256([]byte(path)))
+ content := []byte("[Service]\nReadWritePaths=" + path + "\n")
+ target := filepath.Join(directory, name)
+ if existing, err := os.ReadFile(target); err == nil {
+ if !bytes.Equal(existing, content) {
+ return errors.New("已有目录授权配置不匹配,拒绝覆盖")
+ }
+ return systemctl(ctx, "daemon-reload")
+ } else if !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
+ file, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
+ if err != nil {
+ return err
+ }
+ _, writeErr := file.Write(content)
+ syncErr := file.Sync()
+ closeErr := file.Close()
+ if err := errors.Join(writeErr, syncErr, closeErr); err != nil {
+ return err
+ }
+ return systemctl(ctx, "daemon-reload")
+}
+
+func (s Service) Serve(ctx context.Context, listener *net.UnixListener) error {
+ if os.Geteuid() != 0 || s.UID == 0 || s.GID == 0 {
+ return errors.New("目录服务身份配置无效")
+ }
+ listener.SetUnlinkOnClose(false)
+ defer listener.Close()
+ stop := context.AfterFunc(ctx, func() { _ = listener.Close() })
+ defer stop()
+ // 顺序处理,避免同时修改挂载点和持久授权。
+ for {
+ connection, err := listener.AcceptUnix()
+ if err != nil {
+ if ctx.Err() != nil {
+ return nil
+ }
+ return err
+ }
+ s.handle(ctx, connection)
+ }
+}
+
+func (s Service) handle(ctx context.Context, connection *net.UnixConn) {
+ defer connection.Close()
+ uid, err := peerUID(connection)
+ if err != nil || uid != s.UID {
+ return
+ }
+ _ = connection.SetDeadline(time.Now().Add(25 * time.Second))
+ line, err := bufio.NewReaderSize(connection, 2048).ReadSlice('\n')
+ if err != nil {
+ return
+ }
+ var message request
+ decoder := json.NewDecoder(bytes.NewReader(line))
+ decoder.DisallowUnknownFields()
+ if decoder.Decode(&message) != nil {
+ return
+ }
+ var extra any
+ if decoder.Decode(&extra) != io.EOF {
+ return
+ }
+ operation, cancel := context.WithTimeout(ctx, 20*time.Second)
+ defer cancel()
+ err = s.prepare(operation, message.Path)
+ if err != nil && s.Log != nil {
+ s.Log(err)
+ }
+ _ = json.NewEncoder(connection).Encode(response{Ready: err == nil})
+}
diff --git a/backend/internal/certstorage/service_linux_test.go b/backend/internal/certstorage/service_linux_test.go
new file mode 100644
index 0000000..f20b94b
--- /dev/null
+++ b/backend/internal/certstorage/service_linux_test.go
@@ -0,0 +1,60 @@
+package certstorage
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "golang.org/x/sys/unix"
+)
+
+func TestStoragePolicyRejectsSystemPathsAndInjection(t *testing.T) {
+ for _, path := range []string{"/", "relative", "/ssl/../etc", "/ssl/link/../../etc", "/ssl/new\nReadWritePaths=/", "/ssl/a b", "/ssl/%n", "/etc/scert", "/scert/conf", "/var/lib/mysql", "/other/new"} {
+ if validatePath(path, []string{"/ssl", "/var/lib/scert/certificates"}) == nil {
+ t.Fatalf("危险路径被接受:%q", path)
+ }
+ }
+ for _, path := range []string{"/ssl/new", "/ssl/a/b", "/var/lib/scert/certificates/new"} {
+ if err := validatePath(path, []string{"/ssl", "/var/lib/scert/certificates"}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if validatePath("/etc/unsafe", []string{"/etc"}) == nil {
+ t.Fatal("策略不能放开系统目录")
+ }
+}
+
+func TestPrivilegedDirectoryPreparation(t *testing.T) {
+ if os.Geteuid() != 0 {
+ t.Skip("需要隔离的 Linux root 测试环境")
+ }
+ // /tmp 本身可被所有用户写入,不能用作特权遍历的可信父目录。
+ base, err := os.MkdirTemp("/root", "scert-storage-test-")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = os.RemoveAll(base) })
+ path := filepath.Join(base, "new", "certificates")
+ if err := prepareDirectory(path, 65534, 65534); err != nil {
+ t.Fatal(err)
+ }
+ var info unix.Stat_t
+ if err := unix.Stat(path, &info); err != nil || info.Uid != 65534 || info.Gid != 65534 || info.Mode&0o777 != 0o700 {
+ t.Fatalf("目录权限不正确:%+v %v", info, err)
+ }
+ if err := prepareDirectory(path, 65534, 65534); err != nil {
+ t.Fatal(err)
+ }
+ if err := prepareDirectory(base, 65534, 65534); err == nil {
+ t.Fatal("接管了已有 root 目录")
+ }
+ if err := os.Symlink("/etc", filepath.Join(base, "link")); err != nil {
+ t.Fatal(err)
+ }
+ if err := prepareDirectory(filepath.Join(base, "link", "scert-test"), 65534, 65534); err == nil {
+ t.Fatal("跟随了符号链接")
+ }
+ if err := prepareDirectory(filepath.Join(path, "nested"), 65534, 65534); err == nil {
+ t.Fatal("特权操作接受了非 root 可写父目录")
+ }
+}
diff --git a/build/build-release.ps1 b/build/build-release.ps1
index 1d74e20..8c040c7 100644
--- a/build/build-release.ps1
+++ b/build/build-release.ps1
@@ -284,6 +284,8 @@ foreach ($requiredDeploymentFile in @(
"README.md",
"systemd\Scert.service",
"systemd\Scert-admin.service",
+ "systemd\Scert-storage.service",
+ "systemd\Scert-storage.socket",
"systemd\scert-nginx.socket",
"systemd\scert-nginx.service",
"nginx\policy.json.example",
@@ -388,6 +390,8 @@ try {
if ($goos -eq "linux") {
Invoke-GoBuild -OperatingSystem $goos -Architecture $goarch -Destination (Join-Path $targetDirectory "scert-nginx") -Package "./cmd/scert-nginx"
$binaries += "scert-nginx"
+ Invoke-GoBuild -OperatingSystem $goos -Architecture $goarch -Destination (Join-Path $targetDirectory "scert-storage") -Package "./cmd/scert-storage"
+ $binaries += "scert-storage"
}
$webPath = Join-Path $targetDirectory "web"
diff --git a/deploy/install-linux.sh b/deploy/install-linux.sh
index eeaee50..299d283 100644
--- a/deploy/install-linux.sh
+++ b/deploy/install-linux.sh
@@ -140,11 +140,14 @@ validate_managed_paths() {
"$INSTALL_DIR/web" \
"$INSTALL_DIR/scert" \
"$INSTALL_DIR/scert-admin" \
+ "$INSTALL_DIR/scert-storage" \
"$INSTALL_METADATA_FILE" \
"$INSTALL_DIR/docs/Scert-service-management.md" \
"$INSTALL_DIR/docs/Scert-admin-service-management.md" \
"$SERVICE_DIR/Scert.service" \
"$SERVICE_DIR/Scert-admin.service" \
+ "$SERVICE_DIR/Scert-storage.service" \
+ "$SERVICE_DIR/Scert-storage.socket" \
"$SERVICE_DROP_IN_DIR" \
"$SERVICE_DROP_IN_DIR/10-certificate-path.conf"; do
reject_symlink_path "$managed_path" "Scert 受管路径"
@@ -154,10 +157,10 @@ validate_managed_paths() {
"$INSTALL_DIR/web" "$SERVICE_DIR" "$SERVICE_DROP_IN_DIR"; do
[[ ! -e "$managed_path" || -d "$managed_path" ]] || fail "Scert 目录路径不是普通目录:$managed_path"
done
- for managed_path in "$INSTALL_DIR/scert" "$INSTALL_DIR/scert-admin" \
+ for managed_path in "$INSTALL_DIR/scert" "$INSTALL_DIR/scert-admin" "$INSTALL_DIR/scert-storage" \
"$INSTALL_METADATA_FILE" "$INSTALL_DIR/docs/Scert-service-management.md" \
"$INSTALL_DIR/docs/Scert-admin-service-management.md" \
- "$SERVICE_DIR/Scert.service" "$SERVICE_DIR/Scert-admin.service" \
+ "$SERVICE_DIR/Scert.service" "$SERVICE_DIR/Scert-admin.service" "$SERVICE_DIR/Scert-storage.service" "$SERVICE_DIR/Scert-storage.socket" \
"$SERVICE_DROP_IN_DIR/10-certificate-path.conf"; do
[[ ! -e "$managed_path" || -f "$managed_path" ]] || fail "Scert 文件路径不是普通文件:$managed_path"
done
@@ -187,7 +190,7 @@ validate_artifacts() {
"$INSTALL_DIR/"|"$INSTALL_DIR/web/"*) fail "不能从正在替换的安装文件或 Web 目录安装" ;;
esac
local artifact
- for artifact in scert scert-admin; do
+ for artifact in scert scert-admin scert-storage; do
[[ -f "$source_dir/$artifact" && ! -L "$source_dir/$artifact" ]] || fail "安装产物必须是普通文件:$source_dir/$artifact"
[[ -r "$source_dir/$artifact" && -x "$source_dir/$artifact" ]] || fail "安装产物不可读取或执行:$source_dir/$artifact"
local artifact_description
@@ -200,6 +203,8 @@ validate_artifacts() {
[[ -z "$(find "$source_dir/web" ! -type f ! -type d -print -quit)" ]] || fail "Web 安装产物包含不支持的特殊文件"
[[ -f "$SCRIPT_DIR/systemd/Scert.service" && ! -L "$SCRIPT_DIR/systemd/Scert.service" ]] || fail "缺少普通 Scert systemd unit"
[[ -f "$SCRIPT_DIR/systemd/Scert-admin.service" && ! -L "$SCRIPT_DIR/systemd/Scert-admin.service" ]] || fail "缺少普通 Scert-admin systemd unit"
+ [[ -f "$SCRIPT_DIR/systemd/Scert-storage.service" && ! -L "$SCRIPT_DIR/systemd/Scert-storage.service" ]] || fail "缺少证书目录服务单元"
+ [[ -f "$SCRIPT_DIR/systemd/Scert-storage.socket" && ! -L "$SCRIPT_DIR/systemd/Scert-storage.socket" ]] || fail "缺少证书目录本机通道单元"
[[ -f "$SCRIPT_DIR/systemd/Scert-admin服务管理说明.md" && ! -L "$SCRIPT_DIR/systemd/Scert-admin服务管理说明.md" ]] || fail "缺少普通 Scert-admin 服务管理说明"
[[ -f "$SCRIPT_DIR/systemd/Scert服务管理说明.md" && ! -L "$SCRIPT_DIR/systemd/Scert服务管理说明.md" ]] || fail "缺少普通 Scert 服务管理说明"
}
@@ -210,6 +215,7 @@ begin_install_transaction() {
chmod 0700 "$transaction_dir"
transaction_targets=(
"$INSTALL_METADATA_FILE" "$INSTALL_DIR/scert" "$INSTALL_DIR/scert-admin"
+ "$INSTALL_DIR/scert-storage" "$SERVICE_DIR/Scert-storage.service" "$SERVICE_DIR/Scert-storage.socket"
"$INSTALL_DIR/web" "$SERVICE_DIR/Scert.service" "$SERVICE_DIR/Scert-admin.service"
"$SERVICE_DROP_IN_DIR/10-certificate-path.conf"
"$INSTALL_DIR/docs/Scert-service-management.md"
@@ -266,7 +272,7 @@ finish_install_transaction() {
ensure_services_stopped() {
local unit properties key value
local load_state active_state sub_state main_pid control_pid job
- for unit in Scert.service Scert-admin.service; do
+ for unit in Scert.service Scert-admin.service Scert-storage.service Scert-storage.socket; do
if ! properties="$(systemctl show "$unit" -p LoadState -p ActiveState -p SubState -p MainPID -p ControlPID -p Job)"; then
fail "无法查询 $unit 的停服状态,拒绝安装"
fi
@@ -360,6 +366,9 @@ install_binaries() {
install -o root -g root -m 0755 "$source_dir/scert-admin" "$admin_temporary"
mv -fT -- "$main_temporary" "$INSTALL_DIR/scert"
mv -fT -- "$admin_temporary" "$INSTALL_DIR/scert-admin"
+ local storage_temporary="$INSTALL_DIR/.scert-storage.new.$$"
+ install -o root -g root -m 0755 "$source_dir/scert-storage" "$storage_temporary"
+ mv -fT -- "$storage_temporary" "$INSTALL_DIR/scert-storage"
}
install_web() {
@@ -374,6 +383,10 @@ install_web() {
install_service_files() {
install -o root -g root -m 0644 "$SCRIPT_DIR/systemd/Scert.service" "$SERVICE_DIR/Scert.service"
install -o root -g root -m 0644 "$SCRIPT_DIR/systemd/Scert-admin.service" "$SERVICE_DIR/Scert-admin.service"
+ install -o root -g root -m 0644 "$SCRIPT_DIR/systemd/Scert-storage.socket" "$SERVICE_DIR/Scert-storage.socket"
+ install -o root -g root -m 0644 "$SCRIPT_DIR/systemd/Scert-storage.service" "$SERVICE_DIR/Scert-storage.service"
+ # 路径已通过 ASCII 白名单验证;追加固定命令的单个参数,不执行用户提供的命令。
+ printf '\n[Service]\nExecStart=\nExecStart=/scert/scert-storage --root %s\n' "$certificate_dir" >> "$SERVICE_DIR/Scert-storage.service"
install -d -o root -g root -m 0755 "$SERVICE_DROP_IN_DIR"
printf '[Service]\nReadWritePaths=\nReadWritePaths=%s /scert/conf /scert/data /scert/log /scert/backups\n' \
"$certificate_dir" > "$SERVICE_DROP_IN_DIR/10-certificate-path.conf"
diff --git a/deploy/systemd/Scert-storage.service b/deploy/systemd/Scert-storage.service
new file mode 100644
index 0000000..aa8f4a1
--- /dev/null
+++ b/deploy/systemd/Scert-storage.service
@@ -0,0 +1,33 @@
+[Unit]
+Description=Scert 受限证书目录准备服务
+Requires=Scert-storage.socket
+After=Scert-storage.socket
+
+[Service]
+Type=simple
+User=root
+Group=root
+ExecStart=/scert/scert-storage
+UMask=0077
+NoNewPrivileges=yes
+CapabilityBoundingSet=CAP_CHOWN CAP_DAC_OVERRIDE CAP_FOWNER
+AmbientCapabilities=
+ProtectSystem=full
+ReadWritePaths=/etc/systemd/system/Scert.service.d
+ProtectHome=yes
+PrivateTmp=yes
+PrivateDevices=yes
+ProtectHostname=yes
+ProtectClock=yes
+ProtectKernelTunables=yes
+ProtectKernelModules=yes
+ProtectKernelLogs=yes
+ProtectControlGroups=yes
+LockPersonality=yes
+MemoryDenyWriteExecute=yes
+RestrictNamespaces=yes
+RestrictRealtime=yes
+RestrictSUIDSGID=yes
+RestrictAddressFamilies=AF_UNIX
+SystemCallArchitectures=native
+TimeoutStopSec=30
diff --git a/deploy/systemd/Scert-storage.socket b/deploy/systemd/Scert-storage.socket
new file mode 100644
index 0000000..eae2ab7
--- /dev/null
+++ b/deploy/systemd/Scert-storage.socket
@@ -0,0 +1,15 @@
+[Unit]
+Description=Scert 证书目录准备通道
+
+[Socket]
+ListenStream=/run/scert-storage/prepare.sock
+Accept=no
+SocketUser=root
+SocketGroup=Scert
+SocketMode=0660
+DirectoryMode=0755
+RemoveOnStop=no
+Service=Scert-storage.service
+
+[Install]
+WantedBy=sockets.target
diff --git a/deploy/systemd/Scert.service b/deploy/systemd/Scert.service
index 7dae22f..53ffcc6 100644
--- a/deploy/systemd/Scert.service
+++ b/deploy/systemd/Scert.service
@@ -3,6 +3,8 @@ Description=Scert SSL 证书管理核心服务
Documentation=file:///scert/docs/Scert-service-management.md
After=network-online.target
Wants=network-online.target
+Wants=Scert-storage.socket
+After=Scert-storage.socket
[Service]
Type=simple
diff --git a/doc/初始化目录与代理校验说明.md b/doc/初始化目录与代理校验说明.md
new file mode 100644
index 0000000..0ad3a4b
--- /dev/null
+++ b/doc/初始化目录与代理校验说明.md
@@ -0,0 +1,24 @@
+# 初始化目录与代理校验
+
+## 证书目录自动创建
+
+第四步先校验证书目录,再验证实际写入。已有写权限时直接创建缺失目录;Linux 权限不足时调用随正式发布包安装的 `Scert-storage.socket` 和 `Scert-storage.service`。它们是正式产品组件,不是临时测试脚本。
+
+- 主服务仍使用普通 `Scert` 账户,不增加 root 身份、系统管理能力或整个文件系统的写权限。
+- 目录服务仅监听 `/run/scert-storage/prepare.sock`,通过内核身份验证调用者;主服务也验证服务端为 root。
+- 只接受证书路径,不接受 shell 命令、其他服务名称或任意配置位置。
+- 默认批准 `/ssl`、`/var/lib/scert/certificates`,安装器的 `--certificate-dir` 可额外批准一个存储范围。输入其他路径不会自动扩大批准范围。
+- 新目录由服务创建,最终目录归 `Scert` 所有、权限为 `0700`。不递归修改已有文件,不接管其他账户的目录。
+- 特权操作的父目录必须仅 root 可写,逐层拒绝符号链接。已可写的证书子目录由普通账户直接创建,不需要特权操作。
+- 通过 systemd 的 `bind` 操作只开放所选目录,随后保存对应的 `ReadWritePaths`,不重启初始化服务、不丢失在线验证会话。需要支持该操作的 systemd;不支持时明确失败,不降级放开权限。
+- 安装器升级前须停止目录服务及 socket,正式主服务启动时会拉起 socket。无需手动创建用户填入的证书叶目录。
+
+## 代理与证书信任
+
+测试环境证书不受系统公共 CA 信任。当前代理管理模式要求管理域名及正式环境证书;测试环境和 IP 自签名管理入口应使用直接 HTTPS。前后端均在提交前拒绝不支持的组合,后端不会消耗验证令牌或申请证书。
+
+正式代理模式申请候选证书后,先临时监听配置的上游 HTTPS 端口,再访问真实管理域名与代理公开端口。连续三次请求必须通过公共 TLS 验证,且返回本次随机生成的响应。代理返回 502、错误服务的 200、重定向、证书链不可信或端口不一致均不能提交初始化。
+
+代理需要将管理入口路径转发到所选上游端口,包括 `/.well-known/scert-setup/`。预检不关闭 TLS 校验、不修改代理信任库、不使用环境代理、不强制解析到测试 IP。
+
+预检通过后关闭临时监听,保存配置并启动正式应用;预检失败则保留初始化未完成状态,并回滚候选管理证书。该检查不能替代正式应用启动、续期、推送、重启及完整生产部署验收。
diff --git a/frontend/src/views/SetupWizard/SetupWizard.js b/frontend/src/views/SetupWizard/SetupWizard.js
index 5584911..84f9d1f 100644
--- a/frontend/src/views/SetupWizard/SetupWizard.js
+++ b/frontend/src/views/SetupWizard/SetupWizard.js
@@ -587,6 +587,10 @@ export function useSetupWizard(props, emit) {
error.value = "代理公开 HTTPS 端口必须为 1 至 65535 的整数。";
return false;
}
+ if (isBootstrap && (form.value.acme_environment !== "production" || !form.value.web_hostname.trim())) {
+ error.value = "反向代理管理入口需要管理域名和正式环境证书。测试环境或 IP 自签名证书请使用直接 HTTPS 模式,避免初始化后出现 502。";
+ return false;
+ }
}
if (!form.value.certificate_root.trim()) {
error.value = "请填写证书部署根目录。";
diff --git a/frontend/src/views/SetupWizard/SetupWizard.vue b/frontend/src/views/SetupWizard/SetupWizard.vue
index e7b6650..a08a6f8 100644
--- a/frontend/src/views/SetupWizard/SetupWizard.vue
+++ b/frontend/src/views/SetupWizard/SetupWizard.vue
@@ -733,7 +733,7 @@ const {
证书部署根目录
- 可填写服务器上的绝对路径。Scert 运行账户必须有写入权限;Linux 自定义目录还需位于服务的可写范围内。
+ 填写服务器绝对路径,缺失目录将自动创建。Linux 会通过本机目录服务配置最小写入权限;支持 /ssl、/var/lib/scert/certificates 及安装时批准的存储范围,不接管其他账户或系统目录。