自动准备初始化证书目录并在提交前校验代理入口

This commit is contained in:
xuans 2026-09-27 00:03:31 +08:00
parent d174fb66e0
commit 176bf6ed6b
21 changed files with 889 additions and 12 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,12 @@
//go:build !linux
package certstorage
import (
"context"
"errors"
)
func Prepare(context.Context, string) error {
return errors.New("当前平台需要运行账户具有所选目录的创建权限")
}

View File

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

View File

@ -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 可写父目录")
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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。
预检通过后关闭临时监听,保存配置并启动正式应用;预检失败则保留初始化未完成状态,并回滚候选管理证书。该检查不能替代正式应用启动、续期、推送、重启及完整生产部署验收。

View File

@ -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 = "请填写证书部署根目录。";

View File

@ -733,7 +733,7 @@ const {
<span>
证书部署根目录
<HelpTip>
可填写服务器上的绝对路径。Scert 运行账户必须有写入权限;Linux 自定义目录还需位于服务的可写范围内。
填写服务器绝对路径,缺失目录将自动创建。Linux 会通过本机目录服务配置最小写入权限;支持 /ssl、/var/lib/scert/certificates 及安装时批准的存储范围,不接管其他账户或系统目录。
</HelpTip>
</span>
<input
@ -745,12 +745,11 @@ const {
/>
</label>
<label v-if="isBootstrap">
<span>证书签发环境</span>
<span>证书签发环境<HelpTip>反向代理管理入口需要正式域名证书。测试证书仅用于直接 HTTPS 测试,浏览器不会公共信任该证书。</HelpTip></span>
<select v-model="form.acme_environment">
<option value="staging">测试环境(证书不受公共信任)</option>
<option value="production">正式环境(实际申请受信任域名证书)</option>
</select>
<small>IP 管理入口使用自签名证书,不会因选择正式环境而自动受信任。</small>
</label>
<label>
<span>HTTPS 模式</span>

View File

@ -11,6 +11,12 @@ test("代理模式提交公开端口并跳转至对应入口", async (t) => {
const address = "https://admin.example.test:8004/";
const { state, submitted, emitted } = await setup(t, () => Response.json({ completed: true, management_url: address }));
state.form.value.https_mode = "proxy";
state.form.value.acme_environment = "production";
state.form.value.web_hostname = "admin.example.test";
state.form.value.web_public_ip = "";
state.targetType.value = "hostname";
state.dnsTestDomain.value = "admin.example.test";
await state.testDNS();
state.form.value.proxy_https_port = 8004;
state.currentStep.value = 4;
await state.handleSubmit();
@ -30,6 +36,17 @@ for (const port of [0, -1, 65536, 8004.5, ""]) {
});
}
test("测试环境代理模式在提交前阻止不受信任的管理证书", async (t) => {
const { state, submitted } = await setup(t);
state.form.value.https_mode = "proxy";
state.form.value.proxy_https_port = 443;
state.form.value.acme_environment = "staging";
state.currentStep.value = 4;
await state.handleSubmit();
assert.equal(submitted.length, 0);
assert.match(state.error.value, /正式环境证书/);
});
async function setup(t, complete = completion, driver = "sqlite") {
const submitted = [];
const emitted = [];