249 lines
9.3 KiB
Go
249 lines
9.3 KiB
Go
package config
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"errors"
|
|
"math/big"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func customSSLFixture(t *testing.T) (string, *ConfigManager, UpdateRequest, []byte) {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
runtime := validRuntimeConfig()
|
|
runtime.Root.Domains = DomainConfig{Primary: "example.com", Web: "example.com", Accepted: []string{"example.com"}}
|
|
writeRuntimeFixture(t, root, runtime)
|
|
manager, err := NewConfigManager(root, ConfigLoaderOptions{Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: true}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
certificate := &x509.Certificate{
|
|
SerialNumber: big.NewInt(1), DNSNames: []string{"example.com", "*.example.com"},
|
|
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour),
|
|
KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
|
}
|
|
der, err := x509.CreateCertificate(rand.Reader, certificate, certificate, &key.PublicKey, key)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
|
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
|
|
external := filepath.Join(root, "external")
|
|
if err := os.Mkdir(external, 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for name, content := range map[string][]byte{"public.crt": certPEM, "private.key": keyPEM} {
|
|
if err := os.WriteFile(filepath.Join(external, name), content, 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
section := runtime.SSL
|
|
section.TLS = SSLConfig{
|
|
Management: SSLManagementCustom, Mode: ManagedSSLModeUpload,
|
|
DefaultDirectory: "config/ssl", CustomDirectory: "config/ssl",
|
|
CertificateRef: filepath.Join(external, "public.crt"), PrivateKeyRef: filepath.Join(external, "private.key"),
|
|
}
|
|
payload, err := json.Marshal(section)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return root, manager, UpdateRequest{Section: SectionSSL, ExpectedGeneration: runtime.Generation, Payload: payload, Actor: authorizedConfigActor()}, certPEM
|
|
}
|
|
|
|
func assertNoCustomSSLVersions(t *testing.T, root string) {
|
|
t.Helper()
|
|
if _, err := os.Stat(filepath.Join(root, "data", "ssl", "managed", "upload")); !os.IsNotExist(err) {
|
|
t.Fatalf("失败或只读操作产生了证书存储副作用: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCustomSSLManagerValidateSaveApplyReload(t *testing.T) {
|
|
for _, relative := range []bool{false, true} {
|
|
t.Run(map[bool]string{false: "absolute", true: "relative"}[relative], func(t *testing.T) {
|
|
root, manager, request, certificate := customSSLFixture(t)
|
|
if relative {
|
|
var section SSLSectionConfig
|
|
if err := json.Unmarshal(request.Payload, §ion); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
section.TLS.CertificateRef = "external/public.crt"
|
|
section.TLS.PrivateKeyRef = "external/private.key"
|
|
request.Payload, _ = json.Marshal(section)
|
|
}
|
|
result, err := manager.Validate(context.Background(), ValidateRequest{Section: SectionSSL, ExpectedGeneration: request.ExpectedGeneration, Payload: request.Payload})
|
|
if err != nil || !result.Valid {
|
|
t.Fatalf("只读校验失败: %+v, %v", result, err)
|
|
}
|
|
assertNoCustomSSLVersions(t, root)
|
|
oldKey, oldCertificate := testManagedCertificate(t, "old.example.org", time.Now().Add(-time.Hour), time.Now().Add(time.Hour), 2048)
|
|
oldReference, err := importManagedSSLAtRoot(root, ManagedSSLModeUpload, oldKey, oldCertificate, nil, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
updated, err := manager.Update(context.Background(), request)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if manager.Snapshot().Generation != request.ExpectedGeneration {
|
|
t.Fatal("暂存提前切换了当前配置")
|
|
}
|
|
if updated.Candidate.SSL.TLS.CertificateRef != "external/public.crt" || updated.Candidate.SSL.TLS.PrivateKeyRef != "external/private.key" {
|
|
t.Fatal("候选未直接引用原始证书")
|
|
}
|
|
currentBytes, err := os.ReadFile(oldReference.CertificatePath)
|
|
if err != nil || string(currentBytes) != string(oldCertificate) {
|
|
t.Fatal("暂存不应覆盖原有 current 证书")
|
|
}
|
|
if _, err := manager.ApplyPendingAuthorized(context.Background(), updated.Generation, updated.CandidateID, SectionSSL, request.Actor); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := manager.Load(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
reference, err := resolveManagedSSLReferencesAtRoot(root, manager.Snapshot().SSL.TLS)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pair, err := tls.LoadX509KeyPair(reference.CertificatePath, reference.PrivateKeyPath)
|
|
if err != nil || len(pair.Certificate) == 0 {
|
|
t.Fatalf("重新加载 TLS 证书失败: %v", err)
|
|
}
|
|
stored, err := os.ReadFile(reference.CertificatePath)
|
|
if err != nil || string(stored) != string(certificate) {
|
|
t.Fatal("导入改变了证书内容")
|
|
}
|
|
// 再次保存受管引用不应重复导入。
|
|
payload, _ := json.Marshal(manager.Snapshot().SSL)
|
|
again, err := manager.Update(context.Background(), UpdateRequest{Section: SectionSSL, ExpectedGeneration: updated.Generation, Payload: payload, Actor: request.Actor})
|
|
if err != nil || again.Candidate.SSL.TLS.CertificateRef != reference.CertificateRef {
|
|
t.Fatalf("受管引用回填无法再次保存: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCustomSSLManagerRejectsBeforeWriting(t *testing.T) {
|
|
for _, name := range []string{"unauthorized", "confirmation", "invalid_config", "cancelled"} {
|
|
t.Run(name, func(t *testing.T) {
|
|
root, manager, request, _ := customSSLFixture(t)
|
|
ctx := context.Background()
|
|
switch name {
|
|
case "unauthorized":
|
|
request.Actor = AuditActor{}
|
|
case "confirmation":
|
|
manager.security.Confirm = func(context.Context, AuditActor, StepUpConfirmation, ConfigOperation, Section) error {
|
|
return ErrStepUpRequired
|
|
}
|
|
case "invalid_config":
|
|
manager.options.HasSuperAdmin = false
|
|
case "cancelled":
|
|
cancelled, cancel := context.WithCancel(ctx)
|
|
cancel()
|
|
ctx = cancelled
|
|
}
|
|
if _, err := manager.Update(ctx, request); err == nil {
|
|
t.Fatal("无效操作意外成功")
|
|
}
|
|
assertNoCustomSSLVersions(t, root)
|
|
if manager.Pending(request.Actor) != nil {
|
|
t.Fatal("无效操作留下候选")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCustomSSLApplyRechecksFilesAndCancelPreservesActive(t *testing.T) {
|
|
root, manager, request, _ := customSSLFixture(t)
|
|
updated, err := manager.Update(context.Background(), request)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
reference, err := resolveManagedSSLReferencesAtRoot(root, updated.Candidate.SSL.TLS)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(reference.CertificatePath, []byte("invalid"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := manager.ApplyPendingAuthorized(context.Background(), updated.Generation, updated.CandidateID, SectionSSL, request.Actor); err == nil {
|
|
t.Fatal("损坏候选证书不应应用成功")
|
|
}
|
|
if manager.Snapshot().Generation != request.ExpectedGeneration {
|
|
t.Fatal("失败应用改变了当前代次")
|
|
}
|
|
if err := manager.ManagePending(context.Background(), updated.Generation, updated.CandidateID, request.Actor, StepUpConfirmation{}, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if manager.Pending(request.Actor) != nil || manager.Snapshot().Generation != request.ExpectedGeneration {
|
|
t.Fatal("取消候选影响了现用配置")
|
|
}
|
|
}
|
|
|
|
func TestCustomSSLRejectsInvalidMaterialsAndPaths(t *testing.T) {
|
|
root, manager, request, _ := customSSLFixture(t)
|
|
var original SSLSectionConfig
|
|
if err := json.Unmarshal(request.Payload, &original); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, name := range []string{"missing", "traversal", "mismatch", "domain", "expired", "weak"} {
|
|
t.Run(name, func(t *testing.T) {
|
|
section := original
|
|
want := ErrSSLFileUnavailable
|
|
switch name {
|
|
case "missing":
|
|
section.TLS.CertificateRef = filepath.Join(root, "missing.crt")
|
|
case "traversal":
|
|
section.TLS.CertificateRef = "external/../external/public.crt"
|
|
want = ErrInvalidSSLReference
|
|
default:
|
|
bits, expires := 2048, time.Now().Add(time.Hour)
|
|
want = ErrSSLCertificateDomain
|
|
if name == "expired" {
|
|
expires = time.Now().Add(-time.Minute)
|
|
want = ErrSSLCertificateExpired
|
|
}
|
|
if name == "weak" {
|
|
bits = 1024
|
|
want = ErrInvalidSSLMaterial
|
|
}
|
|
key, cert := testManagedCertificate(t, "other.example.org", time.Now().Add(-time.Hour), expires, bits)
|
|
section.TLS.CertificateRef = filepath.Join(root, name+".crt")
|
|
if err := os.WriteFile(section.TLS.CertificateRef, cert, 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if name == "mismatch" {
|
|
want = ErrSSLCertificateMismatch
|
|
} else {
|
|
section.TLS.PrivateKeyRef = filepath.Join(root, name+".key")
|
|
if err := os.WriteFile(section.TLS.PrivateKeyRef, key, 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
request.Payload, _ = json.Marshal(section)
|
|
if _, err := manager.Update(context.Background(), request); !errors.Is(err, want) {
|
|
t.Fatalf("证书错误类型不符: %v, want %v", err, want)
|
|
}
|
|
result, err := manager.Validate(context.Background(), ValidateRequest{Section: SectionSSL, Payload: request.Payload})
|
|
if err != nil || result.Valid || len(result.Issues) != 1 || strings.Contains(result.Issues[0].Message, root) {
|
|
t.Fatalf("校验接口应返回安全的具体错误: %+v, %v", result, err)
|
|
}
|
|
assertNoCustomSSLVersions(t, root)
|
|
})
|
|
}
|
|
}
|