自动准备初始化证书目录并在提交前校验代理入口
This commit is contained in:
parent
d174fb66e0
commit
176bf6ed6b
63
backend/cmd/scert-storage/main_linux.go
Normal file
63
backend/cmd/scert-storage/main_linux.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
100
backend/internal/bootstrap/proxy_preflight.go
Normal file
100
backend/internal/bootstrap/proxy_preflight.go
Normal 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):
|
||||
}
|
||||
}
|
||||
}
|
||||
71
backend/internal/bootstrap/proxy_preflight_test.go
Normal file
71
backend/internal/bootstrap/proxy_preflight_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
86
backend/internal/bootstrap/proxy_transport_test.go
Normal file
86
backend/internal/bootstrap/proxy_transport_test.go
Normal 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()
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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:
|
||||
|
||||
96
backend/internal/certstorage/client_linux.go
Normal file
96
backend/internal/certstorage/client_linux.go
Normal 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
|
||||
}
|
||||
12
backend/internal/certstorage/client_other.go
Normal file
12
backend/internal/certstorage/client_other.go
Normal file
@ -0,0 +1,12 @@
|
||||
//go:build !linux
|
||||
|
||||
package certstorage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
func Prepare(context.Context, string) error {
|
||||
return errors.New("当前平台需要运行账户具有所选目录的创建权限")
|
||||
}
|
||||
233
backend/internal/certstorage/service_linux.go
Normal file
233
backend/internal/certstorage/service_linux.go
Normal 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})
|
||||
}
|
||||
60
backend/internal/certstorage/service_linux_test.go
Normal file
60
backend/internal/certstorage/service_linux_test.go
Normal 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 可写父目录")
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
33
deploy/systemd/Scert-storage.service
Normal file
33
deploy/systemd/Scert-storage.service
Normal 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
|
||||
15
deploy/systemd/Scert-storage.socket
Normal file
15
deploy/systemd/Scert-storage.socket
Normal 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
|
||||
@ -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
|
||||
|
||||
24
doc/初始化目录与代理校验说明.md
Normal file
24
doc/初始化目录与代理校验说明.md
Normal 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。
|
||||
|
||||
预检通过后关闭临时监听,保存配置并启动正式应用;预检失败则保留初始化未完成状态,并回滚候选管理证书。该检查不能替代正式应用启动、续期、推送、重启及完整生产部署验收。
|
||||
@ -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 = "请填写证书部署根目录。";
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 = [];
|
||||
|
||||
Loading…
Reference in New Issue
Block a user