This commit is contained in:
parent
554a13ca84
commit
747f79a47a
@ -24,6 +24,7 @@ func run(args []string) error {
|
||||
apply := flags.Bool("apply", false, "确认已停止后端,备份并执行迁移;默认只检查")
|
||||
runtimeData := flags.Bool("runtime-data", false, "将运行数据迁到 data,保留当前五个配置文件")
|
||||
historySource := flags.String("deployment-history", "", "停服复制完成后,从原部署导入配置历史并转换证书引用")
|
||||
exportTLS := flags.String("export-project-tls", "", "将项目托管证书导出为本机 TLS 入口使用的合并 PEM 文件")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
@ -34,6 +35,16 @@ func run(args []string) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("服务根目录无效")
|
||||
}
|
||||
if *exportTLS != "" {
|
||||
if *apply || *runtimeData || *historySource != "" || !filepath.IsAbs(*exportTLS) {
|
||||
return fmt.Errorf("证书导出必须单独执行,目标必须是绝对路径")
|
||||
}
|
||||
if err := config.ExportProjectTLS(absolute, filepath.Clean(*exportTLS)); err != nil {
|
||||
return fmt.Errorf("项目证书导出失败,原有入口证书保留:%w", err)
|
||||
}
|
||||
fmt.Println("项目证书同步检查完成。")
|
||||
return nil
|
||||
}
|
||||
options := config.RuntimeValidationOptions{HasSuperAdmin: true, Production: true}
|
||||
if *historySource != "" {
|
||||
if !*apply || *runtimeData {
|
||||
|
||||
@ -97,7 +97,7 @@ func flattenAudit(result map[string]any, path string, value any) {
|
||||
|
||||
func auditSensitive(path string) bool {
|
||||
name := strings.ToLower(path[strings.LastIndex(path, ".")+1:])
|
||||
for _, word := range []string{"password", "secret", "token", "apikey", "api_key", "credential", "ciphertext", "authorization", "cookie"} {
|
||||
for _, word := range []string{"password", "secret", "token", "apikey", "api_key", "accesskey", "credential", "ciphertext", "authorization", "cookie"} {
|
||||
if strings.Contains(name, word) {
|
||||
return true
|
||||
}
|
||||
|
||||
@ -931,11 +931,20 @@ func applySectionPayload(current RuntimeConfig, section Section, payload json.Ra
|
||||
}
|
||||
candidate.Email.Policy.LoginVerificationPolicy = candidate.SMTP.Relay.LoginPolicy
|
||||
case SectionSSL:
|
||||
var value SSLSectionConfig
|
||||
var value struct {
|
||||
SSLSectionConfig
|
||||
DNSAccessKeyStatus json.RawMessage `json:"dnsAccessKeyStatus"`
|
||||
DNSSecretKeyStatus json.RawMessage `json:"dnsSecretKeyStatus"`
|
||||
}
|
||||
if err := ParseStrict(payload, &value); err != nil {
|
||||
return RuntimeConfig{}, err
|
||||
}
|
||||
candidate.SSL = value
|
||||
if storedSecretHasMaterial(value.TLS.DNSAccessKey) || storedSecretHasMaterial(value.TLS.DNSSecretKey) {
|
||||
return RuntimeConfig{}, ErrInvalidSecretChange
|
||||
}
|
||||
value.TLS.DNSAccessKey = current.SSL.TLS.DNSAccessKey
|
||||
value.TLS.DNSSecretKey = current.SSL.TLS.DNSSecretKey
|
||||
candidate.SSL = value.SSLSectionConfig
|
||||
case SectionPlugins:
|
||||
var value PluginsSectionConfig
|
||||
if err := ParseStrict(payload, &value); err != nil {
|
||||
@ -1018,6 +1027,17 @@ func applySecretChanges(candidate *RuntimeConfig, section Section, changes map[s
|
||||
return err
|
||||
}
|
||||
candidate.SMTP.Relay.Password = stored
|
||||
case section == SectionSSL && (name == "tls.dnsAccessKey" || name == "tls.dnsSecretKey"):
|
||||
target := &candidate.SSL.TLS.DNSAccessKey
|
||||
if name == "tls.dnsSecretKey" {
|
||||
target = &candidate.SSL.TLS.DNSSecretKey
|
||||
}
|
||||
required := candidate.SSL.TLS.DNSProvider != ""
|
||||
stored, err := ApplySecretChange(vault, *target, change, SecretPolicy{Required: required, AllowClear: !required, MaxLength: 4096}, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*target = stored
|
||||
default:
|
||||
return ErrInvalidSecretChange
|
||||
}
|
||||
|
||||
@ -113,6 +113,10 @@ type SSLConfig struct {
|
||||
PrivateKeyRef string `json:"privateKeyRef,omitempty"`
|
||||
PreviousCertificateRef string `json:"previousCertificateRef,omitempty"`
|
||||
PreviousPrivateKeyRef string `json:"previousPrivateKeyRef,omitempty"`
|
||||
DNSProvider string `json:"dnsProvider,omitempty"`
|
||||
DNSZone string `json:"dnsZone,omitempty"`
|
||||
DNSAccessKey StoredSecret `json:"dnsAccessKey,omitempty"`
|
||||
DNSSecretKey StoredSecret `json:"dnsSecretKey,omitempty"`
|
||||
}
|
||||
|
||||
type PluginConfig struct {
|
||||
|
||||
@ -55,6 +55,12 @@ func ValidateRuntimeConfig(runtime RuntimeConfig, options RuntimeValidationOptio
|
||||
if err := validateDomains(runtime.Root.Domains); err != nil {
|
||||
return err
|
||||
}
|
||||
if tls := runtime.SSL.TLS; tls.DNSProvider != "" && tls.DNSZone != "" {
|
||||
primary := runtime.Root.Domains.Primary
|
||||
if primary != tls.DNSZone && !strings.HasSuffix(primary, "."+tls.DNSZone) {
|
||||
return ErrInvalidSSL
|
||||
}
|
||||
}
|
||||
if err := validateMailListeners(runtime.SMTP, runtime.Email); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@ -145,7 +145,7 @@ func OpenStoredSecret(vault *credential.Vault, stored StoredSecret) (string, err
|
||||
|
||||
// ValidateRuntimeSecrets 验证运行时配置中的持久化 Secret 可以由当前密钥解密。
|
||||
func ValidateRuntimeSecrets(vault *credential.Vault, runtime RuntimeConfig) error {
|
||||
for _, stored := range []StoredSecret{runtime.Root.Database.Password, runtime.SMTP.Relay.Password} {
|
||||
for _, stored := range []StoredSecret{runtime.Root.Database.Password, runtime.SMTP.Relay.Password, runtime.SSL.TLS.DNSAccessKey, runtime.SSL.TLS.DNSSecretKey} {
|
||||
if !stored.Configured() {
|
||||
continue
|
||||
}
|
||||
|
||||
@ -31,6 +31,10 @@ var sensitiveFields = []SensitiveField{
|
||||
{Path: "plugins.*.secret", Policy: PersistenceEncryptedOnly, Description: "插件 Secret"},
|
||||
{Path: "plugins.*.api_key", Policy: PersistenceEncryptedOnly, Description: "插件 API 密钥"},
|
||||
{Path: "ssl.private_key", Policy: PersistenceReferenceOnly, Description: "SSL 私钥材料"},
|
||||
{Path: "ssl.tls.dnsAccessKey", Policy: PersistenceEncryptedOnly, Description: "DNS 服务商访问凭据"},
|
||||
{Path: "ssl.tls.dnsSecretKey", Policy: PersistenceEncryptedOnly, Description: "DNS 服务商访问密钥"},
|
||||
{Path: "tls.dnsAccessKey", Policy: PersistenceEncryptedOnly, Description: "DNS 服务商访问凭据"},
|
||||
{Path: "tls.dnsSecretKey", Policy: PersistenceEncryptedOnly, Description: "DNS 服务商访问密钥"},
|
||||
{Path: "security.encryption_master_key", Policy: PersistenceReferenceOnly, Description: "配置加密主密钥"},
|
||||
{Path: "security.question_answer", Policy: PersistenceEncryptedOnly, Description: "密保答案原文"},
|
||||
{Path: "security.offline_recovery_code", Policy: PersistenceMemoryOnly, Description: "离线恢复码原文"},
|
||||
|
||||
43
backend/config/ssl_dns.go
Normal file
43
backend/config/ssl_dns.go
Normal file
@ -0,0 +1,43 @@
|
||||
package config
|
||||
|
||||
import "git.pcux.cn/xuans/Smail/utils/credential"
|
||||
|
||||
const DNSProviderAliDNS = "alidns"
|
||||
|
||||
func validateDNSProvider(ssl SSLConfig) error {
|
||||
if ssl.DNSProvider != "" && ssl.DNSProvider != DNSProviderAliDNS {
|
||||
return ErrInvalidSSL
|
||||
}
|
||||
if ssl.DNSZone != "" {
|
||||
domain, err := normalizeConfigDomain(ssl.DNSZone)
|
||||
if err != nil || domain != ssl.DNSZone {
|
||||
return ErrInvalidSSL
|
||||
}
|
||||
}
|
||||
for _, stored := range []StoredSecret{ssl.DNSAccessKey, ssl.DNSSecretKey} {
|
||||
if err := ValidateStoredSecret(stored, ssl.DNSProvider != ""); err != nil {
|
||||
return ErrInvalidSSL
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DNSProviderCredentials 只在签发进程内解密,调用方不得记录返回值。
|
||||
func DNSProviderCredentials(ssl SSLConfig) (string, string, error) {
|
||||
if ssl.DNSProvider != DNSProviderAliDNS || validateDNSProvider(ssl) != nil {
|
||||
return "", "", ErrSecretUnavailable
|
||||
}
|
||||
vault, err := credential.NewVault(ROOT_PATH)
|
||||
if err != nil {
|
||||
return "", "", ErrSecretUnavailable
|
||||
}
|
||||
accessKey, err := OpenStoredSecret(vault, ssl.DNSAccessKey)
|
||||
if err != nil {
|
||||
return "", "", ErrSecretUnavailable
|
||||
}
|
||||
secretKey, err := OpenStoredSecret(vault, ssl.DNSSecretKey)
|
||||
if err != nil {
|
||||
return "", "", ErrSecretUnavailable
|
||||
}
|
||||
return accessKey, secretKey, nil
|
||||
}
|
||||
51
backend/config/ssl_dns_test.go
Normal file
51
backend/config/ssl_dns_test.go
Normal file
@ -0,0 +1,51 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.pcux.cn/xuans/Smail/utils/credential"
|
||||
)
|
||||
|
||||
func TestDNSCredentialsEncryptedPreservedAndRedacted(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
previousRoot := ROOT_PATH
|
||||
ROOT_PATH = root
|
||||
t.Cleanup(func() { ROOT_PATH = previousRoot })
|
||||
vault, err := credential.NewVault(root)
|
||||
if err != nil { t.Fatal(err) }
|
||||
before := validRuntimeConfig()
|
||||
candidate := cloneRuntimeConfig(before)
|
||||
candidate.SSL.TLS.DNSProvider = DNSProviderAliDNS
|
||||
changes := map[string]SecretChange{
|
||||
"tls.dnsAccessKey": {Operation: SecretReplace, Value: &SecretInput{Plaintext: "dns-access-fixture"}},
|
||||
"tls.dnsSecretKey": {Operation: SecretReplace, Value: &SecretInput{Plaintext: "dns-secret-fixture"}},
|
||||
}
|
||||
if err := applySecretChanges(&candidate, SectionSSL, changes, vault); err != nil { t.Fatal(err) }
|
||||
if err := validateDNSProvider(candidate.SSL.TLS); err != nil { t.Fatal(err) }
|
||||
if err := ValidateRuntimeSecrets(vault, candidate); err != nil { t.Fatal(err) }
|
||||
access, secret, err := DNSProviderCredentials(candidate.SSL.TLS)
|
||||
if err != nil || access != "dns-access-fixture" || secret != "dns-secret-fixture" { t.Fatal("DNS credentials did not round trip through vault") }
|
||||
data, _ := json.Marshal(candidate)
|
||||
for _, value := range []string{access, secret} { if strings.Contains(string(data), value) { t.Fatal("plaintext persisted") } }
|
||||
audit, _ := json.Marshal(AuditChanges(before, candidate))
|
||||
public, _ := json.Marshal(RedactPublicValue(candidate.SSL))
|
||||
for _, data := range [][]byte{audit, public} {
|
||||
for _, value := range []string{access, secret, candidate.SSL.TLS.DNSAccessKey.Ciphertext, candidate.SSL.TLS.DNSSecretKey.Ciphertext} {
|
||||
if strings.Contains(string(data), value) { t.Fatal("DNS secret leaked into audit or public view") }
|
||||
}
|
||||
}
|
||||
section := candidate.SSL
|
||||
section.TLS.DNSAccessKey, section.TLS.DNSSecretKey = StoredSecret{}, StoredSecret{}
|
||||
payload, _ := json.Marshal(section)
|
||||
kept, err := applySectionPayload(candidate, SectionSSL, payload, nil, vault)
|
||||
if err != nil || kept.SSL.TLS.DNSAccessKey != candidate.SSL.TLS.DNSAccessKey || kept.SSL.TLS.DNSSecretKey != candidate.SSL.TLS.DNSSecretKey { t.Fatal("public edit lost stored DNS credentials") }
|
||||
payload, _ = json.Marshal(candidate.SSL)
|
||||
if _, err := applySectionPayload(candidate, SectionSSL, payload, nil, vault); !errors.Is(err, ErrInvalidSecretChange) { t.Fatal("direct stored-secret injection accepted") }
|
||||
clear := map[string]SecretChange{"tls.dnsSecretKey": {Operation: SecretClear, Confirmed: true}}
|
||||
if err := applySecretChanges(&candidate, SectionSSL, clear, vault); !errors.Is(err, ErrSecretClearDenied) { t.Fatal("enabled DNS provider allowed clearing its secret") }
|
||||
candidate.SSL.TLS.DNSProvider = ""
|
||||
if err := applySecretChanges(&candidate, SectionSSL, clear, vault); err != nil { t.Fatal(err) }
|
||||
}
|
||||
68
backend/config/ssl_export.go
Normal file
68
backend/config/ssl_export.go
Normal file
@ -0,0 +1,68 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"git.pcux.cn/xuans/Smail/utils/atomicfile"
|
||||
)
|
||||
|
||||
// ExportProjectTLS 为本机 TLS 入口导出一个原子更新的证书私钥合并文件。
|
||||
// 仅接受项目生成的平铺证书;不允许高权限调用者按配置读取任意服务器路径。
|
||||
func ExportProjectTLS(root, destination string) error {
|
||||
runtime, err := NewConfigLoader(root, ConfigLoaderOptions{Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: true}}).Load()
|
||||
if err != nil {
|
||||
return ErrInvalidSSL
|
||||
}
|
||||
if EffectiveSSLManagement(runtime.SSL.TLS) != SSLManagementProject || runtime.Root.HTTP.TLSMode == HTTPSTLSModeProxy {
|
||||
return nil
|
||||
}
|
||||
publicPath := filepath.Join(root, filepath.FromSlash(runtime.SSL.TLS.CertificateRef))
|
||||
privatePath := filepath.Join(root, filepath.FromSlash(runtime.SSL.TLS.PrivateKeyRef))
|
||||
if !flatSSLPair(root, publicPath, privatePath) {
|
||||
return ErrInvalidSSLReference
|
||||
}
|
||||
sourceDirectory := filepath.Join(root, "ssl")
|
||||
resolvedSource, err := filepath.EvalSymlinks(sourceDirectory)
|
||||
if err != nil || resolvedSource != sourceDirectory { return ErrUnsafeSSLFile }
|
||||
storage, err := os.OpenRoot(sourceDirectory)
|
||||
if err != nil { return ErrUnsafeSSLFile }
|
||||
defer storage.Close()
|
||||
read := func(name string) ([]byte, error) {
|
||||
info, err := storage.Lstat(name)
|
||||
if err != nil || !info.Mode().IsRegular() || info.Size() > maxManagedSSLFileSize || info.Mode().Perm() & 0077 != 0 { return nil, ErrUnsafeSSLFile }
|
||||
file, err := storage.Open(name)
|
||||
if err != nil { return nil, ErrUnsafeSSLFile }
|
||||
defer file.Close()
|
||||
opened, err := file.Stat()
|
||||
if err != nil || !os.SameFile(info, opened) { return nil, ErrUnsafeSSLFile }
|
||||
data, err := io.ReadAll(io.LimitReader(file, maxManagedSSLFileSize+1))
|
||||
if err != nil || len(data) > maxManagedSSLFileSize { return nil, ErrUnsafeSSLFile }
|
||||
return data, nil
|
||||
}
|
||||
certificate, err := read(filepath.Base(publicPath))
|
||||
if err != nil { return err }
|
||||
privateKey, err := read(filepath.Base(privatePath))
|
||||
if err != nil { return err }
|
||||
if err := validateSSLMaterial(privateKey, certificate, nil, validationCertificateDomains(&runtime)); err != nil { return err }
|
||||
combined := append(append(append([]byte{}, certificate...), '\n'), privateKey...)
|
||||
// 输出目录由部署管理员创建,拒绝符号链接和其他用户可写目录。
|
||||
directory := filepath.Dir(destination)
|
||||
resolved, err := filepath.EvalSymlinks(directory)
|
||||
if err != nil || resolved != directory { return ErrUnsafeSSLFile }
|
||||
info, err := os.Stat(directory)
|
||||
if err != nil || !info.IsDir() || info.Mode().Perm() & 0022 != 0 { return ErrUnsafeSSLFile }
|
||||
output, err := os.OpenRoot(directory)
|
||||
if err != nil { return ErrUnsafeSSLFile }
|
||||
defer output.Close()
|
||||
name := filepath.Base(destination)
|
||||
if info, err := output.Lstat(name); err == nil {
|
||||
if !info.Mode().IsRegular() || info.Mode().Perm() & 0077 != 0 { return ErrUnsafeSSLFile }
|
||||
previous, err := output.ReadFile(name)
|
||||
if err != nil { return ErrUnsafeSSLFile }
|
||||
if bytes.Equal(previous, combined) { return nil }
|
||||
} else if !os.IsNotExist(err) { return ErrUnsafeSSLFile }
|
||||
return atomicfile.WriteRoot(output, name, combined, 0600)
|
||||
}
|
||||
45
backend/config/ssl_export_test.go
Normal file
45
backend/config/ssl_export_test.go
Normal file
@ -0,0 +1,45 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestExportProjectTLSKeepsOldBundleOnInvalidInput(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outputDirectory := t.TempDir()
|
||||
if err := os.Chmod(outputDirectory, 0700); err != nil { t.Fatal(err) }
|
||||
destination := filepath.Join(outputDirectory, "tls.pem")
|
||||
runtime := validRuntimeConfig()
|
||||
runtime.Root.Domains = DomainConfig{Primary: "example.com", Web: "web.example.com", Accepted: []string{"example.com"}}
|
||||
key, certificate := testManagedCertificate(t, "example.com", time.Now().Add(-time.Hour), time.Now().Add(time.Hour), 2048, "*.example.com")
|
||||
ref, err := stageFlatSSLAtRoot(root, ManagedSSLModeDefault, key, certificate, nil, nil)
|
||||
if err != nil { t.Fatal(err) }
|
||||
defer releaseManagedSSL(root, SSLConfig{CertificateRef: ref.CertificateRef})
|
||||
runtime.SSL.TLS = SSLConfig{Management: SSLManagementProject, Challenge: SSLChallengeDNS, Mode: ManagedSSLModeDefault, DefaultDirectory: "ssl", CertificateRef: ref.CertificateRef, PrivateKeyRef: ref.PrivateKeyRef}
|
||||
writeRuntimeFixture(t, root, runtime)
|
||||
if err := ExportProjectTLS(root, destination); err != nil { t.Fatal(err) }
|
||||
bundle, err := os.ReadFile(destination)
|
||||
if err != nil { t.Fatal(err) }
|
||||
if _, err := tls.X509KeyPair(bundle, bundle); err != nil { t.Fatal(err) }
|
||||
if err := ExportProjectTLS(root, destination); err != nil { t.Fatal(err) }
|
||||
if err := os.WriteFile(ref.PrivateKeyPath, []byte("invalid"), 0600); err != nil { t.Fatal(err) }
|
||||
if err := ExportProjectTLS(root, destination); err == nil { t.Fatal("invalid key was exported") }
|
||||
preserved, _ := os.ReadFile(destination)
|
||||
if !bytes.Equal(bundle, preserved) { t.Fatal("failed export replaced valid bundle") }
|
||||
runtime.SSL.TLS.CertificateRef = "ssl/unmanaged.crt"
|
||||
runtime.SSL.TLS.PrivateKeyRef = "ssl/unmanaged.key"
|
||||
writeRuntimeFixture(t, root, runtime)
|
||||
if err := ExportProjectTLS(root, destination); err == nil { t.Fatal("unmanaged input accepted") }
|
||||
for _, mode := range []string{SSLManagementCustom, SSLManagementWebServer} {
|
||||
runtime.SSL.TLS.Management = mode
|
||||
writeRuntimeFixture(t, root, runtime)
|
||||
if err := ExportProjectTLS(root, destination); err != nil { t.Fatal(err) }
|
||||
preserved, _ := os.ReadFile(destination)
|
||||
if !bytes.Equal(bundle, preserved) { t.Fatal("external mode changed exported bundle") }
|
||||
}
|
||||
}
|
||||
@ -173,6 +173,9 @@ func smtpRelayConfigured(smtp SMTPConfig) bool {
|
||||
}
|
||||
|
||||
func validateSSL(ssl SSLConfig) error {
|
||||
if err := validateDNSProvider(ssl); err != nil {
|
||||
return err
|
||||
}
|
||||
if (ssl.PreviousCertificateRef == "") != (ssl.PreviousPrivateKeyRef == "") {
|
||||
return ErrInvalidSSL
|
||||
}
|
||||
|
||||
@ -498,7 +498,13 @@ func publicConfigSection(runtime config.RuntimeConfig, section config.Section) (
|
||||
case config.SectionSSL:
|
||||
value := runtime.SSL
|
||||
value.TLS.Management = config.EffectiveSSLManagement(value.TLS)
|
||||
return value, nil
|
||||
accessStatus, secretStatus := value.TLS.DNSAccessKey.Status(), value.TLS.DNSSecretKey.Status()
|
||||
value.TLS.DNSAccessKey, value.TLS.DNSSecretKey = config.StoredSecret{}, config.StoredSecret{}
|
||||
return struct {
|
||||
config.SSLSectionConfig
|
||||
DNSAccessKeyStatus config.SecretStatus `json:"dnsAccessKeyStatus"`
|
||||
DNSSecretKeyStatus config.SecretStatus `json:"dnsSecretKeyStatus"`
|
||||
}{value, accessStatus, secretStatus}, nil
|
||||
case config.SectionPlugins:
|
||||
plugins := make(map[string]any, len(runtime.Plugins.Plugins))
|
||||
for id, item := range runtime.Plugins.Plugins {
|
||||
|
||||
@ -39,7 +39,14 @@ func TestPublicConfigSectionsDoNotExposeSecretsAndRemainStrictlyDecodable(t *tes
|
||||
},
|
||||
},
|
||||
Email: config.EmailSectionConfig{Meta: config.SectionMeta{Schema: config.CurrentSectionSchema, Generation: 7}},
|
||||
SSL: config.SSLSectionConfig{Meta: config.SectionMeta{Schema: config.CurrentSectionSchema, Generation: 7}},
|
||||
SSL: config.SSLSectionConfig{
|
||||
Meta: config.SectionMeta{Schema: config.CurrentSectionSchema, Generation: 7},
|
||||
TLS: config.SSLConfig{
|
||||
DNSProvider: config.DNSProviderAliDNS,
|
||||
DNSAccessKey: config.StoredSecret{Ciphertext: "enc:v1:dns-access", Version: credential.CurrentVersion, UpdatedAt: time.Now().UTC()},
|
||||
DNSSecretKey: config.StoredSecret{Ciphertext: "enc:v1:dns-secret", Version: credential.CurrentVersion, UpdatedAt: time.Now().UTC()},
|
||||
},
|
||||
},
|
||||
Plugins: config.PluginsSectionConfig{
|
||||
Meta: config.SectionMeta{Schema: config.CurrentSectionSchema, Generation: 7},
|
||||
Plugins: map[string]config.PluginInstance{
|
||||
@ -103,6 +110,14 @@ func TestPublicConfigSectionsDoNotExposeSecretsAndRemainStrictlyDecodable(t *tes
|
||||
}
|
||||
delete(writableData, "databasePasswordStatus")
|
||||
delete(writableData, "relayPasswordStatus")
|
||||
if test.section == config.SectionSSL {
|
||||
for _, name := range []string{"dnsAccessKeyStatus", "dnsSecretKeyStatus"} {
|
||||
if !strings.Contains(string(writableData[name]), `"configured":true`) {
|
||||
t.Fatalf("DNS secret status missing: %s", name)
|
||||
}
|
||||
delete(writableData, name)
|
||||
}
|
||||
}
|
||||
if test.section == config.SectionMail {
|
||||
var relay map[string]json.RawMessage
|
||||
if err := json.Unmarshal(writableData["relay"], &relay); err != nil {
|
||||
|
||||
186
backend/services/setup/ssl/dns_automatic.go
Normal file
186
backend/services/setup/ssl/dns_automatic.go
Normal file
@ -0,0 +1,186 @@
|
||||
package ssl
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.pcux.cn/xuans/Smail/config"
|
||||
"github.com/go-acme/lego/v4/challenge"
|
||||
"github.com/go-acme/lego/v4/challenge/dns01"
|
||||
)
|
||||
|
||||
type aliDNSRecord struct {
|
||||
ID string
|
||||
Name string
|
||||
Value string
|
||||
}
|
||||
|
||||
// 每次签发独立记录创建结果,清理时只删除本实例创建且内容未被修改的记录。
|
||||
type aliDNSChallenge struct {
|
||||
mu sync.Mutex
|
||||
zone string
|
||||
accessKey string
|
||||
secretKey string
|
||||
endpoint string
|
||||
client *http.Client
|
||||
records map[string]aliDNSRecord
|
||||
}
|
||||
|
||||
func automaticDNSProvider(cfg *config.Config) (challenge.Provider, error) {
|
||||
if cfg == nil || cfg.EffectiveSSLType() != config.SSLTypeAutoDNS {
|
||||
return nil, newUserCertificateError("当前配置未启用项目 DNS 证书")
|
||||
}
|
||||
if cfg.SSL.DNSProvider == "" {
|
||||
return GetDnsChallengeInstance(), nil
|
||||
}
|
||||
accessKey, secretKey, err := config.DNSProviderCredentials(cfg.SSL)
|
||||
if err != nil {
|
||||
return nil, newUserCertificateError("DNS 服务商凭据不可用,请在 SSL 配置中重新设置")
|
||||
}
|
||||
zone := cfg.SSL.DNSZone
|
||||
if zone == "" {
|
||||
zone = strings.ToLower(strings.TrimSuffix(cfg.Domain, "."))
|
||||
}
|
||||
if cfg.Domain != zone && !strings.HasSuffix(cfg.Domain, "."+zone) {
|
||||
return nil, newUserCertificateError("DNS 托管区域必须包含邮件主域名")
|
||||
}
|
||||
return &aliDNSChallenge{
|
||||
zone: zone, accessKey: accessKey, secretKey: secretKey,
|
||||
endpoint: "https://alidns.aliyuncs.com/",
|
||||
client: &http.Client{Timeout: 15 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}},
|
||||
records: make(map[string]aliDNSRecord),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (provider *aliDNSChallenge) Timeout() (time.Duration, time.Duration) {
|
||||
return 5 * time.Minute, 5 * time.Second
|
||||
}
|
||||
|
||||
func (provider *aliDNSChallenge) record(domain, keyAuth string) (aliDNSRecord, error) {
|
||||
domain = strings.TrimPrefix(strings.TrimSuffix(strings.ToLower(domain), "."), "*.")
|
||||
info := dns01.GetChallengeInfo(domain, keyAuth)
|
||||
fqdn := strings.TrimSuffix(info.EffectiveFQDN, ".")
|
||||
if !strings.HasSuffix(fqdn, "."+provider.zone) {
|
||||
return aliDNSRecord{}, errors.New("DNS 挑战不属于配置的托管区域")
|
||||
}
|
||||
name := strings.TrimSuffix(fqdn, "."+provider.zone)
|
||||
if name != "_acme-challenge" && !strings.HasPrefix(name, "_acme-challenge.") {
|
||||
return aliDNSRecord{}, errors.New("DNS 挑战目标不是允许的 ACME 记录")
|
||||
}
|
||||
return aliDNSRecord{Name: name, Value: info.Value}, nil
|
||||
}
|
||||
|
||||
func (provider *aliDNSChallenge) Present(domain, token, keyAuth string) error {
|
||||
provider.mu.Lock()
|
||||
defer provider.mu.Unlock()
|
||||
key := domain + ":" + keyAuth
|
||||
if _, exists := provider.records[key]; exists {
|
||||
return nil
|
||||
}
|
||||
record, err := provider.record(domain, keyAuth)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var response struct{ RecordID string `json:"RecordId"` }
|
||||
err = provider.call("AddDomainRecord", url.Values{
|
||||
"DomainName": {provider.zone}, "RR": {record.Name}, "Type": {"TXT"},
|
||||
"Value": {record.Value}, "TTL": {"600"},
|
||||
}, &response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if response.RecordID == "" {
|
||||
return errors.New("DNS 服务商未返回新建记录编号")
|
||||
}
|
||||
record.ID = response.RecordID
|
||||
provider.records[key] = record
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *aliDNSChallenge) CleanUp(domain, token, keyAuth string) error {
|
||||
provider.mu.Lock()
|
||||
defer provider.mu.Unlock()
|
||||
// 清理使用创建时保存的目标,避免 CNAME 变化后误删或遗漏原记录。
|
||||
key := domain + ":" + keyAuth
|
||||
created, exists := provider.records[key]
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
var current struct {
|
||||
RecordID string `json:"RecordId"`
|
||||
Domain string `json:"DomainName"`
|
||||
Name string `json:"RR"`
|
||||
Type string `json:"Type"`
|
||||
Value string `json:"Value"`
|
||||
}
|
||||
if err := provider.call("DescribeDomainRecordInfo", url.Values{"RecordId": {created.ID}}, ¤t); err != nil {
|
||||
return err
|
||||
}
|
||||
if current.RecordID != created.ID || current.Domain != provider.zone || current.Name != created.Name || current.Type != "TXT" || current.Value != created.Value {
|
||||
return errors.New("DNS 挑战记录已被修改,保留记录并停止自动清理")
|
||||
}
|
||||
if err := provider.call("DeleteDomainRecord", url.Values{"RecordId": {created.ID}}, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
delete(provider.records, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
func aliDNSPercentEncode(value string) string {
|
||||
return strings.ReplaceAll(url.QueryEscape(value), "+", "%20")
|
||||
}
|
||||
|
||||
func (provider *aliDNSChallenge) call(action string, values url.Values, target any) error {
|
||||
nonce := make([]byte, 16)
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return errors.New("DNS 请求随机数生成失败")
|
||||
}
|
||||
values.Set("Action", action)
|
||||
values.Set("Version", "2015-01-09")
|
||||
values.Set("Format", "JSON")
|
||||
values.Set("AccessKeyId", provider.accessKey)
|
||||
values.Set("SignatureMethod", "HMAC-SHA1")
|
||||
values.Set("SignatureVersion", "1.0")
|
||||
values.Set("SignatureNonce", hex.EncodeToString(nonce))
|
||||
values.Set("Timestamp", time.Now().UTC().Format("2006-01-02T15:04:05Z"))
|
||||
canonical := strings.ReplaceAll(values.Encode(), "+", "%20")
|
||||
mac := hmac.New(sha1.New, []byte(provider.secretKey+"&"))
|
||||
_, _ = mac.Write([]byte("POST&%2F&" + aliDNSPercentEncode(canonical)))
|
||||
values.Set("Signature", base64.StdEncoding.EncodeToString(mac.Sum(nil)))
|
||||
request, err := http.NewRequest(http.MethodPost, provider.endpoint, strings.NewReader(values.Encode()))
|
||||
if err != nil {
|
||||
return errors.New("DNS 请求创建失败")
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
response, err := provider.client.Do(request)
|
||||
if err != nil {
|
||||
return errors.New("DNS 服务商连接失败或超时")
|
||||
}
|
||||
defer response.Body.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(response.Body, (1<<20)+1))
|
||||
if err != nil || len(data) > 1<<20 {
|
||||
return errors.New("DNS 服务商响应无效")
|
||||
}
|
||||
var status struct{ Code string `json:"Code"` }
|
||||
if json.Unmarshal(data, &status) != nil || response.StatusCode != http.StatusOK || status.Code != "" {
|
||||
// 服务商错误可能包含凭据、签名或请求原文,不能直接交给日志。
|
||||
return errors.New("DNS 服务商拒绝请求,请检查凭据、区域和记录管理权限")
|
||||
}
|
||||
if target != nil && json.Unmarshal(data, target) != nil {
|
||||
return errors.New("DNS 服务商响应解析失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
96
backend/services/setup/ssl/dns_automatic_test.go
Normal file
96
backend/services/setup/ssl/dns_automatic_test.go
Normal file
@ -0,0 +1,96 @@
|
||||
package ssl
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.pcux.cn/xuans/Smail/config"
|
||||
)
|
||||
|
||||
func TestAutomaticDNSCleansOnlyOwnedUnchangedRecords(t *testing.T) {
|
||||
t.Setenv("LEGO_DISABLE_CNAME_SUPPORT", "true")
|
||||
var mu sync.Mutex
|
||||
records := map[string]aliDNSRecord{"existing": {ID: "existing", Name: "_acme-challenge", Value: "unrelated-record"}}
|
||||
adds, deletes := 0, 0
|
||||
failDelete, tamper := false, false
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if r.Method != http.MethodPost || r.ParseForm() != nil || r.Form.Get("AccessKeyId") != "test-access" || r.Form.Get("Signature") == "" {
|
||||
t.Error("invalid DNS API request")
|
||||
w.WriteHeader(400)
|
||||
return
|
||||
}
|
||||
id := r.Form.Get("RecordId")
|
||||
switch r.Form.Get("Action") {
|
||||
case "AddDomainRecord":
|
||||
adds++
|
||||
id = fmt.Sprint(adds)
|
||||
records[id] = aliDNSRecord{ID: id, Name: r.Form.Get("RR"), Value: r.Form.Get("Value")}
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"RecordId": id})
|
||||
case "DescribeDomainRecordInfo":
|
||||
record := records[id]
|
||||
if tamper { record.Value = "changed-by-user" }
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"RecordId": id, "DomainName": "example.com", "RR": record.Name, "Type": "TXT", "Value": record.Value})
|
||||
case "DeleteDomainRecord":
|
||||
if failDelete { w.WriteHeader(503); _, _ = w.Write([]byte(`{"Code":"Busy"}`)); return }
|
||||
deletes++
|
||||
delete(records, id)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"RecordId": id})
|
||||
default:
|
||||
t.Error("unexpected DNS action")
|
||||
w.WriteHeader(400)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
provider := &aliDNSChallenge{zone: "example.com", accessKey: "test-access", secretKey: "test-secret", endpoint: server.URL, client: server.Client(), records: map[string]aliDNSRecord{}}
|
||||
var workers sync.WaitGroup
|
||||
for _, domain := range []string{"example.com", "*.example.com"} {
|
||||
for i := 0; i < 3; i++ {
|
||||
workers.Add(1)
|
||||
go func(domain string) {
|
||||
defer workers.Done()
|
||||
if err := provider.Present(domain, "token", "key-"+domain); err != nil { t.Error(err) }
|
||||
}(domain)
|
||||
}
|
||||
}
|
||||
workers.Wait()
|
||||
if adds != 2 || len(records) != 3 { t.Fatalf("same-name TXT records lost or duplicated: adds=%d records=%d", adds, len(records)) }
|
||||
if err := provider.CleanUp("other.example.com", "token", "unknown"); err != nil || deletes != 0 { t.Fatal("unknown challenge must not touch DNS") }
|
||||
mu.Lock(); tamper = true; mu.Unlock()
|
||||
if err := provider.CleanUp("example.com", "token", "key-example.com"); err == nil || deletes != 0 { t.Fatal("modified record must be retained") }
|
||||
mu.Lock(); tamper = false; failDelete = true; mu.Unlock()
|
||||
if err := provider.CleanUp("example.com", "token", "key-example.com"); err == nil || len(provider.records) != 2 { t.Fatal("failed deletion must retain ownership for retry") }
|
||||
mu.Lock(); failDelete = false; mu.Unlock()
|
||||
if err := provider.CleanUp("example.com", "token", "key-example.com"); err != nil { t.Fatal(err) }
|
||||
if len(records) != 2 { t.Fatal("cleaning root challenge removed wildcard or unrelated TXT") }
|
||||
if err := provider.CleanUp("*.example.com", "token", "key-*.example.com"); err != nil { t.Fatal(err) }
|
||||
if len(records) != 1 || records["existing"].Value != "unrelated-record" { t.Fatal("existing TXT must survive all challenge cleanup") }
|
||||
if err := provider.CleanUp("*.example.com", "token", "key-*.example.com"); err != nil || deletes != 2 { t.Fatal("cleanup must be idempotent") }
|
||||
}
|
||||
|
||||
func TestAutomaticDNSRejectsOutsideZoneAndRedactsErrors(t *testing.T) {
|
||||
t.Setenv("LEGO_DISABLE_CNAME_SUPPORT", "true")
|
||||
calls := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
w.WriteHeader(403)
|
||||
_, _ = w.Write([]byte(`{"Code":"Denied","Message":"test-access test-secret signature"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
provider := &aliDNSChallenge{zone: "example.com", accessKey: "test-access", secretKey: "test-secret", endpoint: server.URL, client: server.Client(), records: map[string]aliDNSRecord{}}
|
||||
if err := provider.Present("unrelated.com", "token", "key"); err == nil || calls != 0 { t.Fatal("out-of-zone write attempted") }
|
||||
err := provider.Present("example.com", "token", "key")
|
||||
if err == nil || calls != 1 { t.Fatal("provider refusal not reported") }
|
||||
for _, secret := range []string{"test-access", "test-secret", "signature"} {
|
||||
if strings.Contains(err.Error(), secret) { t.Fatal("error leaked provider response") }
|
||||
}
|
||||
for _, management := range []string{config.SSLManagementCustom, config.SSLManagementWebServer} {
|
||||
if _, err := automaticDNSProvider(&config.Config{SSL: config.SSLConfig{Management: management, Challenge: config.SSLChallengeDNS}}); err == nil { t.Fatal("external certificate mode allowed automatic DNS") }
|
||||
}
|
||||
}
|
||||
@ -112,11 +112,16 @@ func SetSSL(sslType, priKey, crtKey string) error {
|
||||
}
|
||||
|
||||
if cfg.SSLType != config.SSLTypeUser {
|
||||
previousSSL := cfg.SSL
|
||||
clearLegacySSLPaths(cfg)
|
||||
cfg.SSL = config.SSLConfig{
|
||||
Management: config.SSLManagementProject,
|
||||
Mode: config.ManagedSSLModeDefault,
|
||||
DefaultDirectory: runtimepath.SSLReference(config.ROOT_PATH, "data/ssl"),
|
||||
DNSProvider: previousSSL.DNSProvider,
|
||||
DNSZone: previousSSL.DNSZone,
|
||||
DNSAccessKey: previousSSL.DNSAccessKey,
|
||||
DNSSecretKey: previousSSL.DNSSecretKey,
|
||||
}
|
||||
if cfg.SSLType == config.SSLTypeAutoDNS {
|
||||
cfg.SSL.Challenge = config.SSLChallengeDNS
|
||||
@ -174,6 +179,7 @@ func applyManagedSSLReference(cfg *config.Config, reference config.ManagedSSLRef
|
||||
}
|
||||
clearLegacySSLPaths(cfg)
|
||||
sslType := cfg.EffectiveSSLType()
|
||||
previousSSL := cfg.SSL
|
||||
management := cfg.SSL.Management
|
||||
if reference.Mode == config.ManagedSSLModeDefault {
|
||||
management = config.SSLManagementProject
|
||||
@ -186,6 +192,10 @@ func applyManagedSSLReference(cfg *config.Config, reference config.ManagedSSLRef
|
||||
DefaultDirectory: "ssl",
|
||||
CertificateRef: reference.CertificateRef,
|
||||
PrivateKeyRef: reference.PrivateKeyRef,
|
||||
DNSProvider: previousSSL.DNSProvider,
|
||||
DNSZone: previousSSL.DNSZone,
|
||||
DNSAccessKey: previousSSL.DNSAccessKey,
|
||||
DNSSecretKey: previousSSL.DNSSecretKey,
|
||||
}
|
||||
if reference.Mode == config.ManagedSSLModeUpload {
|
||||
cfg.SSL.CustomDirectory = "ssl"
|
||||
@ -403,16 +413,15 @@ func renewCertificate(privateKey *ecdsa.PrivateKey, cfg *config.Config) error {
|
||||
return errors.Wrap(err)
|
||||
}
|
||||
} else if cfg.EffectiveSSLType() == config.SSLTypeAutoDNS {
|
||||
err = client.Challenge.SetDNS01Provider(GetDnsChallengeInstance(), dns01.AddDNSTimeout(60*time.Minute))
|
||||
provider, providerErr := automaticDNSProvider(cfg)
|
||||
if providerErr != nil {
|
||||
return providerErr
|
||||
}
|
||||
err = client.Challenge.SetDNS01Provider(provider, dns01.AddDNSTimeout(15*time.Second))
|
||||
if err != nil {
|
||||
return errors.Wrap(err)
|
||||
}
|
||||
|
||||
log.Errorf("Please Set DNS Record/请将以下内容添加到DNS记录中:\n")
|
||||
for _, item := range GetDnsChallengeInstance().GetDNSSettings(nil) {
|
||||
log.Errorf("Type:%s\tHost:%s\tValue generated\n", item.Type, item.Host)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
var reg *registration.Resource
|
||||
@ -466,7 +475,11 @@ func generateCertificate(privateKey *ecdsa.PrivateKey, cfg *config.Config, newAc
|
||||
return errors.Wrap(err)
|
||||
}
|
||||
} else if cfg.EffectiveSSLType() == config.SSLTypeAutoDNS {
|
||||
err = client.Challenge.SetDNS01Provider(GetDnsChallengeInstance(), dns01.AddDNSTimeout(60*time.Minute))
|
||||
provider, providerErr := automaticDNSProvider(cfg)
|
||||
if providerErr != nil {
|
||||
return providerErr
|
||||
}
|
||||
err = client.Challenge.SetDNS01Provider(provider, dns01.AddDNSTimeout(15*time.Second))
|
||||
if err != nil {
|
||||
return errors.Wrap(err)
|
||||
}
|
||||
|
||||
9
deploy/systemd/smail-tls-sync.path
Normal file
9
deploy/systemd/smail-tls-sync.path
Normal file
@ -0,0 +1,9 @@
|
||||
[Unit]
|
||||
Description=Smail 项目证书入口同步监听
|
||||
|
||||
[Path]
|
||||
PathChanged=/opt/smail/config/config.json
|
||||
Unit=smail-tls-sync.service
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
24
deploy/systemd/smail-tls-sync.service
Normal file
24
deploy/systemd/smail-tls-sync.service
Normal file
@ -0,0 +1,24 @@
|
||||
[Unit]
|
||||
Description=Smail 项目证书同步至 Nginx
|
||||
After=smail.service nginx.service
|
||||
StartLimitIntervalSec=60
|
||||
StartLimitBurst=5
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=root
|
||||
UMask=0077
|
||||
ExecStart=/opt/smail/smail-config --root /opt/smail --export-project-tls /etc/nginx/smail/tls.pem
|
||||
ExecStart=/usr/sbin/nginx -t
|
||||
ExecStart=/usr/bin/systemctl reload nginx.service
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectHome=true
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/etc/nginx/smail /opt/smail/data /var/log/nginx
|
||||
TimeoutStartSec=30
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@ -72,6 +72,8 @@ const certificateManagement = config => {
|
||||
return ['managed_default', 'default'].includes(config.tls?.mode) ? 'project' : 'custom'
|
||||
}
|
||||
const projectManagedCertificate = config => certificateManagement(config) === 'project'
|
||||
const projectDNSCertificate = config => projectManagedCertificate(config) && config.tls?.challenge === 'dns'
|
||||
const automaticDNSCertificate = config => projectDNSCertificate(config) && config.tls?.dnsProvider === 'alidns'
|
||||
const externalManagedCertificate = config => !projectManagedCertificate(config)
|
||||
const absoluteCertificatePath = value => typeof value === 'string'
|
||||
&& value === value.trim()
|
||||
@ -180,6 +182,26 @@ const sslGroups = [
|
||||
fallback: 'http',
|
||||
options: options([['http', 'HTTP 验证'], ['dns', 'DNS 验证']])
|
||||
}),
|
||||
field('tls.dnsProvider', 'DNS 记录管理', 'select', {
|
||||
show: projectDNSCertificate,
|
||||
fallback: '',
|
||||
options: options([['', '手动设置 TXT'], ['alidns', '阿里云 DNS(自动续期)']]),
|
||||
hint: '自动续期需配置 DNS 服务商;手动模式每次签发都需自行设置 TXT。'
|
||||
}),
|
||||
field('tls.dnsZone', 'DNS 托管域名', 'text', {
|
||||
show: automaticDNSCertificate,
|
||||
placeholder: '留空使用主邮件域名'
|
||||
}),
|
||||
field('tls.dnsAccessKeyInput', '访问密钥编号', 'password', {
|
||||
show: automaticDNSCertificate,
|
||||
placeholder: '留空保持原值',
|
||||
dynamicHint: config => config.dnsAccessKeyStatus?.configured ? '已设置,输入新值后替换。' : '未设置,请填写阿里云 AccessKey ID。'
|
||||
}),
|
||||
field('tls.dnsSecretKeyInput', '访问密钥', 'password', {
|
||||
show: automaticDNSCertificate,
|
||||
placeholder: '留空保持原值',
|
||||
dynamicHint: config => config.dnsSecretKeyStatus?.configured ? '已设置,输入新值后替换。' : '未设置,请填写阿里云 AccessKey Secret。'
|
||||
}),
|
||||
field('tls.certificateRef', '证书文件', 'text', {
|
||||
wide: true,
|
||||
show: externalManagedCertificate,
|
||||
@ -227,6 +249,7 @@ export function getFieldGroups(section, config) {
|
||||
.map(input => ({
|
||||
...input,
|
||||
label: input.dynamicLabel ? input.dynamicLabel(config) : input.label,
|
||||
hint: input.dynamicHint ? input.dynamicHint(config) : input.hint,
|
||||
recommendedPort: getRecommendedPort(config, input.path)
|
||||
}))
|
||||
}))
|
||||
@ -367,6 +390,17 @@ export function validateFormConfig(section, config) {
|
||||
}
|
||||
if (section === 'ssl') {
|
||||
const tls = config.tls || {}
|
||||
if (projectDNSCertificate(config)) {
|
||||
if (!['', 'alidns'].includes(tls.dnsProvider || '')) errors['tls.dnsProvider'] = '请选择支持的 DNS 服务商'
|
||||
if (automaticDNSCertificate(config)) {
|
||||
if (tls.dnsZone && (!validDomain(tls.dnsZone) || normalizedDomain(tls.dnsZone) !== tls.dnsZone)) errors['tls.dnsZone'] = '请填写小写域名,不包含协议、路径或末尾点'
|
||||
for (const name of ['dnsAccessKey', 'dnsSecretKey']) {
|
||||
const value = tls[`${name}Input`]
|
||||
if (!value && !config[`${name}Status`]?.configured) errors[`tls.${name}Input`] = '请填写 DNS 服务商凭据'
|
||||
if (value && (typeof value !== 'string' || value !== value.trim() || value.length > 4096)) errors[`tls.${name}Input`] = '凭据不能包含首尾空格或超过 4096 个字符'
|
||||
}
|
||||
}
|
||||
}
|
||||
const management = certificateManagement(config)
|
||||
if (!['web_server', 'project', 'custom'].includes(management)) errors['tls.management'] = '请选择证书管理方式'
|
||||
if (management === 'project' && !['http', 'dns'].includes(tls.challenge || 'http')) errors['tls.challenge'] = '请选择证书验证方式'
|
||||
|
||||
@ -485,6 +485,15 @@ export function usePage(pageKey = 'base') {
|
||||
if (selectedSection.value === 'base') {
|
||||
delete payload.databasePasswordStatus
|
||||
}
|
||||
if (apiSection() === 'ssl') {
|
||||
for (const name of ['dnsAccessKey', 'dnsSecretKey']) {
|
||||
delete payload[`${name}Status`]
|
||||
if (payload.tls) {
|
||||
delete payload.tls[`${name}Input`]
|
||||
delete payload.tls[name]
|
||||
}
|
||||
}
|
||||
}
|
||||
if (apiSection() === 'mail') {
|
||||
delete payload.relayPasswordStatus
|
||||
if (payload.relay && 'password' in payload.relay) {
|
||||
@ -521,6 +530,12 @@ export function usePage(pageKey = 'base') {
|
||||
if (relayPassword && selectedSection.value === 'mail') {
|
||||
secretChanges['relay.password'] = { operation: 'replace', value: { plaintext: relayPassword } }
|
||||
}
|
||||
if (selectedSection.value === 'ssl' && formConfig.value?.tls?.dnsProvider === 'alidns') {
|
||||
for (const name of ['dnsAccessKey', 'dnsSecretKey']) {
|
||||
const input = formConfig.value.tls[`${name}Input`]
|
||||
if (input) secretChanges[`tls.${name}`] = { operation: 'replace', value: { plaintext: input } }
|
||||
}
|
||||
}
|
||||
return {
|
||||
expectedGeneration: Number(status.activeGeneration),
|
||||
payload: parsePayload(),
|
||||
|
||||
@ -13,6 +13,21 @@ const { readFormConfig, getFieldGroups, updateFormConfig, validateFormConfig } =
|
||||
'\nreturn { readFormConfig, getFieldGroups, updateFormConfig, validateFormConfig }'
|
||||
)()
|
||||
|
||||
test('项目 DNS 自动续期需要凭据,已设置的凭据可留空且外部托管隐藏 DNS 字段', () => {
|
||||
const config = { tls: { management: 'project', challenge: 'dns', mode: 'managed_default', defaultDirectory: 'ssl', dnsProvider: 'alidns' } }
|
||||
assert.ok(validateFormConfig('ssl', config)['tls.dnsAccessKeyInput'])
|
||||
assert.ok(validateFormConfig('ssl', config)['tls.dnsSecretKeyInput'])
|
||||
config.dnsAccessKeyStatus = { configured: true }
|
||||
config.tls.dnsSecretKeyInput = 'test-secret'
|
||||
assert.deepEqual(validateFormConfig('ssl', config), {})
|
||||
config.tls.dnsZone = 'https://example.com'
|
||||
assert.ok(validateFormConfig('ssl', config)['tls.dnsZone'])
|
||||
for (const management of ['custom', 'web_server']) {
|
||||
config.tls.management = management
|
||||
assert.ok(getFieldGroups('ssl', config).flatMap(group => group.fields).every(field => !field.path.join('.').includes('dns')))
|
||||
}
|
||||
})
|
||||
|
||||
test('公网 HTTPS 端口独立编辑且保留内部监听与模式切换', () => {
|
||||
const config = {
|
||||
database: { type: 'sqlite' },
|
||||
|
||||
@ -66,6 +66,29 @@ function deferred() {
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
test('DNS 凭据只通过专用密钥变更提交,留空保持原值', async () => {
|
||||
const sent = []
|
||||
const current = {
|
||||
tls: { management: 'project', mode: 'managed_default', defaultDirectory: 'ssl', challenge: 'dns', dnsProvider: 'alidns', dnsAccessKey: {}, dnsSecretKey: {} },
|
||||
dnsAccessKeyStatus: { configured: true }, dnsSecretKeyStatus: { configured: true }
|
||||
}
|
||||
const { page } = fixture(async path => ({ errorNo: 0, data: path.endsWith('/status') ? { available: true, activeGeneration: 1 } : current }), 'ssl', {
|
||||
http: { post: async (path, body) => { sent.push(body); return { errorNo: 0, data: { valid: true } } } }
|
||||
})
|
||||
await page.loadAll()
|
||||
page.updateField(['tls', 'dnsSecretKeyInput'], 'new-test-secret')
|
||||
await page.validateConfig()
|
||||
assert.equal(sent.length, 1)
|
||||
assert.deepEqual(sent[0].secretChanges, { 'tls.dnsSecretKey': { operation: 'replace', value: { plaintext: 'new-test-secret' } } })
|
||||
assert.equal(sent[0].payload.dnsAccessKeyStatus, undefined)
|
||||
assert.equal(sent[0].payload.dnsSecretKeyStatus, undefined)
|
||||
assert.equal(sent[0].payload.tls.dnsSecretKeyInput, undefined)
|
||||
assert.equal(sent[0].payload.tls.dnsSecretKey, undefined)
|
||||
page.updateField(['tls', 'dnsSecretKeyInput'], '')
|
||||
await page.validateConfig()
|
||||
assert.deepEqual(sent[1].secretChanges, {})
|
||||
})
|
||||
|
||||
for (const cancelled of [false, true]) {
|
||||
test(`共享身份确认保留单次重试,移除审计不影响其他操作:取消=${cancelled}`, async () => {
|
||||
const requests = []
|
||||
|
||||
Loading…
Reference in New Issue
Block a user