From 176bf6ed6b067a50869569eb3f13009863bb10e3 Mon Sep 17 00:00:00 2001 From: xuans Date: Sun, 27 Sep 2026 00:03:31 +0800 Subject: [PATCH] =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=87=86=E5=A4=87=E5=88=9D?= =?UTF-8?q?=E5=A7=8B=E5=8C=96=E8=AF=81=E4=B9=A6=E7=9B=AE=E5=BD=95=E5=B9=B6?= =?UTF-8?q?=E5=9C=A8=E6=8F=90=E4=BA=A4=E5=89=8D=E6=A0=A1=E9=AA=8C=E4=BB=A3?= =?UTF-8?q?=E7=90=86=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/scert-storage/main_linux.go | 63 +++++ .../internal/bootstrap/certificate_root.go | 25 +- backend/internal/bootstrap/complete.go | 19 +- backend/internal/bootstrap/proxy_port_test.go | 5 +- backend/internal/bootstrap/proxy_preflight.go | 100 ++++++++ .../bootstrap/proxy_preflight_test.go | 71 ++++++ .../bootstrap/proxy_transport_test.go | 86 +++++++ backend/internal/bootstrap/router.go | 6 +- backend/internal/certstorage/client_linux.go | 96 ++++++++ backend/internal/certstorage/client_other.go | 12 + backend/internal/certstorage/service_linux.go | 233 ++++++++++++++++++ .../certstorage/service_linux_test.go | 60 +++++ build/build-release.ps1 | 4 + deploy/install-linux.sh | 21 +- deploy/systemd/Scert-storage.service | 33 +++ deploy/systemd/Scert-storage.socket | 15 ++ deploy/systemd/Scert.service | 2 + doc/初始化目录与代理校验说明.md | 24 ++ frontend/src/views/SetupWizard/SetupWizard.js | 4 + .../src/views/SetupWizard/SetupWizard.vue | 5 +- .../SetupWizard/SetupWizardBootstrap.test.js | 17 ++ 21 files changed, 889 insertions(+), 12 deletions(-) create mode 100644 backend/cmd/scert-storage/main_linux.go create mode 100644 backend/internal/bootstrap/proxy_preflight.go create mode 100644 backend/internal/bootstrap/proxy_preflight_test.go create mode 100644 backend/internal/bootstrap/proxy_transport_test.go create mode 100644 backend/internal/certstorage/client_linux.go create mode 100644 backend/internal/certstorage/client_other.go create mode 100644 backend/internal/certstorage/service_linux.go create mode 100644 backend/internal/certstorage/service_linux_test.go create mode 100644 deploy/systemd/Scert-storage.service create mode 100644 deploy/systemd/Scert-storage.socket create mode 100644 doc/初始化目录与代理校验说明.md 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 及安装时批准的存储范围,不接管其他账户或系统目录。