保存内置DNS续期与证书同步接续修复前基线
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-20 04:39:25 +08:00
parent 554a13ca84
commit 747f79a47a
23 changed files with 704 additions and 13 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View 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") }
}
}

View File

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

View File

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

View File

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

View 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}}, &current); 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
}

View 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") }
}
}

View File

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

View 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

View 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

View File

@ -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'] = '请选择证书验证方式'

View File

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

View File

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

View File

@ -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 = []