接入托管域名HTTP验证与隔离挑战并补充回归用例

This commit is contained in:
xuans 2026-10-04 02:37:00 +08:00
parent e829d665ca
commit 5128b25e88
33 changed files with 1096 additions and 76 deletions

View File

@ -16,6 +16,7 @@ import (
"git.pcux.cn/xuans/Scert/backend/internal/dnsprovider"
"git.pcux.cn/xuans/Scert/backend/internal/firewall"
"git.pcux.cn/xuans/Scert/backend/internal/httpapi"
"git.pcux.cn/xuans/Scert/backend/internal/httpchallenge"
"git.pcux.cn/xuans/Scert/backend/internal/logging"
"git.pcux.cn/xuans/Scert/backend/internal/notification"
"git.pcux.cn/xuans/Scert/backend/internal/repository"
@ -143,6 +144,8 @@ func NewWithConfigPath(ctx context.Context, cfg config.Config, configPath string
}
accounts := certificate.NewAccountRepository(secretStore)
certificates := certificate.NewService(cfg, repo, secretStore, dnsprovider.NewLegoFactory(), accounts)
httpChallenges := httpchallenge.NewStore()
certificates.SetHTTPChallenges(httpChallenges)
aliyunAuthChecker := dnsprovider.NewAliyunAuthorizationChecker()
certificates.SetDNSAuthorizationChecker(aliyunAuthChecker)
@ -178,7 +181,7 @@ func NewWithConfigPath(ctx context.Context, cfg config.Config, configPath string
}, management)
server = httpapi.New(httpapi.Dependencies{
RecoveryInspection: deploymentService,
Config: cfg, ConfigPath: configPath, Logger: logger, Repository: repo, Servers: serverRepo, Users: userRepo, DeploymentFactory: deployment.SSHFactory{Secrets: secretStore},
Config: cfg, ConfigPath: configPath, Logger: logger, Repository: repo, Servers: serverRepo, Users: userRepo, DeploymentFactory: deployment.SSHFactory{Secrets: secretStore},
ProjectMembers: repository.NewSQLProjectMemberRepository(db.DB, runtimeDatabaseConfig.Driver),
ProjectDirectory: repository.NewSQLProjectMemberRepository(db.DB, runtimeDatabaseConfig.Driver),
ProjectLifecycle: repository.NewSQLProjectMemberRepository(db.DB, runtimeDatabaseConfig.Driver),
@ -187,6 +190,7 @@ func NewWithConfigPath(ctx context.Context, cfg config.Config, configPath string
ProjectDomains: repository.NewSQLProjectMemberRepository(db.DB, runtimeDatabaseConfig.Driver),
SetupDNS: dnsVerification, Secrets: secretStore, Firewall: firewall.NewManager(), AliyunChecker: aliyunAuthChecker,
IssueCertificate: workflow.IssueForHTTP,
HTTPChallenges: httpChallenges,
IssueSetupCertificate: func(ctx context.Context, request certificate.SetupCertificateRequest) (certificate.SavedResource, error) {
return certificates.IssueForSetup(ctx, request)
},

View File

@ -0,0 +1,34 @@
package certificate
import (
"errors"
"fmt"
"strings"
"git.pcux.cn/xuans/Scert/backend/internal/dnsprovider"
"git.pcux.cn/xuans/Scert/backend/internal/domain"
)
func (s *Service) validateDNSIssueConfiguration(item domain.Domain, names []string) (string, error) {
if s.credentials == nil || s.providers == nil {
return "", errors.New("证书申请服务未初始化")
}
providerID := strings.ToLower(strings.TrimSpace(item.DNSProvider))
if providerID == "" {
providerID = strings.ToLower(strings.TrimSpace(s.config.DNS.Provider))
}
if providerID == "" || strings.TrimSpace(s.config.DNS.CredentialRef) == "" {
return "", errors.New("DNS Provider 或凭据引用未配置")
}
// 必须在读取凭据、构造 Provider 或访问 ACME 前拒绝受保护的任意 SAN。
if err := dnsprovider.ValidateAliyunTargetDomains(providerID, names); err != nil {
return "", err
}
if configured := strings.ToLower(strings.TrimSpace(s.config.DNS.Provider)); configured != "" && providerID != configured {
return "", fmt.Errorf("域名配置的 DNS Provider %s 尚未绑定独立凭据,当前初始化凭据属于 %s", providerID, configured)
}
if s.config.DNS.VerificationState != "verified" {
return "", errors.New("DNS API 尚未通过验证")
}
return providerID, nil
}

View File

@ -0,0 +1,91 @@
package certificate
import (
"context"
"errors"
"net/http/httptest"
"testing"
"github.com/go-acme/lego/v5/challenge"
"git.pcux.cn/xuans/Scert/backend/internal/config"
"git.pcux.cn/xuans/Scert/backend/internal/domain"
"git.pcux.cn/xuans/Scert/backend/internal/httpchallenge"
)
type httpTestIssuer func(context.Context, ObtainRequest) (Resource, error)
func (f httpTestIssuer) Obtain(ctx context.Context, request ObtainRequest) (Resource, error) {
return f(ctx, request)
}
func TestServiceHTTPChallengeDoesNotNeedDNSAndCleansAfterIssue(t *testing.T) {
for _, failure := range []bool{false, true} {
t.Run(map[bool]string{false: "success", true: "failure"}[failure], func(t *testing.T) {
cfg := certificateTestConfig(t.TempDir())
cfg.DNS = config.DNSConfig{}
item, err := domain.NewDomain("example.com", domain.CertificateSingle, nil)
if err != nil {
t.Fatal(err)
}
item.ChallengeType = domain.ChallengeHTTP01
repo := &serviceRepository{item: item}
service := NewService(cfg, repo, nil, nil, serviceAccounts{})
store := httpchallenge.NewStore()
service.SetHTTPChallenges(store)
resource := testResource(t, "example.com")
service.acmeRoots = testResourceRoots(t, resource)
const token = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG"
const auth = token + "." + token
checker := &serviceAuthorizationChecker{}
service.SetDNSAuthorizationChecker(checker)
service.newIssuer = func(_ *AccountUser, provider challenge.Provider, _ string) Issuer {
return httpTestIssuer(func(ctx context.Context, request ObtainRequest) (Resource, error) {
if request.ChallengeType != domain.ChallengeHTTP01 {
t.Fatal("未传递 HTTP 验证方式")
}
if err := provider.Present(ctx, item.Name, token, auth); err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
store.ServeHTTP(w, httptest.NewRequest("GET", "http://example.com"+httpchallenge.PathPrefix+token, nil))
if w.Code != 200 || w.Body.String() != auth {
t.Fatal("签发期间挑战不可读")
}
// 模拟签发器遗漏清理,服务自身仍须在任意返回路径回收订单。
if failure {
return Resource{}, errors.New("模拟签发失败")
}
return resource, nil
})
}
_, err = service.Issue(context.Background(), item.ID)
if (err != nil) != failure {
t.Fatal("签发结果不符合预期:", err)
}
if checker.verifyCalls != 0 || checker.invalidateCalls != 0 {
t.Fatal("HTTP 验证触碰 DNS 授权")
}
w := httptest.NewRecorder()
store.ServeHTTP(w, httptest.NewRequest("GET", "http://example.com"+httpchallenge.PathPrefix+token, nil))
if w.Code != 404 {
t.Fatal("签发结束后仍残留挑战")
}
})
}
}
func TestServiceHTTPRejectsWildcardSANBeforeDependencies(t *testing.T) {
item := domain.Domain{ID: "wild", Name: "example.com", CertificateType: domain.CertificateSAN, SANs: []string{"example.com", "*.example.com"}, ChallengeType: domain.ChallengeHTTP01}
repo := &serviceRepository{item: item}
service := NewService(certificateTestConfig(t.TempDir()), repo, nil, nil, serviceAccounts{})
service.SetHTTPChallenges(httpchallenge.NewStore())
called := false
service.newIssuer = func(*AccountUser, challenge.Provider, string) Issuer { called = true; return nil }
if _, err := service.Issue(context.Background(), item.ID); err == nil {
t.Fatal("未拒绝泛域名 SAN")
}
if called || len(repo.states) != 0 {
t.Fatal("拒绝前产生签发副作用")
}
}

View File

@ -9,6 +9,7 @@ import (
"strings"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/domain"
acme "github.com/go-acme/lego/v5/acme"
"github.com/go-acme/lego/v5/certcrypto"
legoCertificate "github.com/go-acme/lego/v5/certificate"
@ -59,12 +60,16 @@ func (i *LegoIssuer) Obtain(ctx context.Context, request ObtainRequest) (Resourc
return Resource{}, errors.New("ACME 账户未配置")
}
if i.Provider == nil {
return Resource{}, errors.New("DNS 验证服务商未配置")
return Resource{}, errors.New("证书验证服务未配置")
}
domains, err := NormalizeDomains(request.Domains)
if err != nil {
return Resource{}, err
}
challengeType, err := domain.NormalizeChallengeType(request.ChallengeType, domains)
if err != nil {
return Resource{}, err
}
environment := strings.ToLower(strings.TrimSpace(request.Environment))
if environment == "" {
environment = "production"
@ -85,9 +90,15 @@ func (i *LegoIssuer) Obtain(ctx context.Context, request ObtainRequest) (Resourc
if err != nil {
return Resource{}, fmt.Errorf("创建 ACME 客户端失败:%w", err)
}
provider := withPropagationSettings(i.Provider, i.PropagationTimeout, i.PropagationInterval)
if err := client.Challenge.SetDNS01Provider(provider); err != nil {
return Resource{}, fmt.Errorf("配置 DNS-01 验证失败:%w", err)
if challengeType == domain.ChallengeHTTP01 {
if err := client.Challenge.SetHTTP01Provider(i.Provider); err != nil {
return Resource{}, fmt.Errorf("配置 HTTP-01 验证失败:%w", err)
}
} else {
provider := withPropagationSettings(i.Provider, i.PropagationTimeout, i.PropagationInterval)
if err := client.Challenge.SetDNS01Provider(provider); err != nil {
return Resource{}, fmt.Errorf("配置 DNS-01 验证失败:%w", err)
}
}
if i.User.Registration == nil {
account, err := client.Registration.Register(ctx, registration.RegisterOptions{TermsOfServiceAgreed: true})
@ -102,7 +113,7 @@ func (i *LegoIssuer) Obtain(ctx context.Context, request ObtainRequest) (Resourc
PreferredChain: request.PreferredChain,
})
if err != nil {
if i.PropagationTimeout > 0 && i.PropagationInterval > 0 && i.PropagationInterval <= i.PropagationTimeout {
if challengeType == domain.ChallengeDNS01 && i.PropagationTimeout > 0 && i.PropagationInterval > 0 && i.PropagationInterval <= i.PropagationTimeout {
return Resource{}, fmt.Errorf("申请证书失败(DNS 传播等待时间 %s,检测间隔 %s):%w", i.PropagationTimeout, i.PropagationInterval, err)
}
return Resource{}, fmt.Errorf("申请证书失败:%w", err)

View File

@ -24,6 +24,7 @@ type Issuer interface {
}
type ObtainRequest struct {
ChallengeType string
Domains []string
AccountEmail string
Environment string

View File

@ -15,6 +15,7 @@ import (
"git.pcux.cn/xuans/Scert/backend/internal/config"
"git.pcux.cn/xuans/Scert/backend/internal/dnsprovider"
"git.pcux.cn/xuans/Scert/backend/internal/domain"
"git.pcux.cn/xuans/Scert/backend/internal/httpchallenge"
)
type DomainStateRepository interface {
@ -79,6 +80,13 @@ type Service struct {
newIssuer IssuerFactory
preferredChain string
acmeRoots *x509.CertPool
httpChallenges *httpchallenge.Store
}
func (s *Service) SetHTTPChallenges(store *httpchallenge.Store) {
if s != nil {
s.httpChallenges = store
}
}
func (s *Service) SetDNSAuthorizationChecker(checker DNSAuthorizationChecker) {
@ -131,7 +139,7 @@ func NewService(cfg config.Config, repository DomainStateRepository, credentials
}
func (s *Service) Issue(ctx context.Context, domainID string) (IssueResult, error) {
if s == nil || s.repository == nil || s.credentials == nil || s.providers == nil || s.accounts == nil || s.newIssuer == nil {
if s == nil || s.repository == nil || s.accounts == nil || s.newIssuer == nil {
return IssueResult{}, errors.New("证书申请服务未初始化")
}
domainID = strings.TrimSpace(domainID)
@ -146,22 +154,23 @@ func (s *Service) Issue(ctx context.Context, domainID string) (IssueResult, erro
if err != nil {
return IssueResult{}, err
}
providerID := strings.ToLower(strings.TrimSpace(item.DNSProvider))
if providerID == "" {
providerID = strings.ToLower(strings.TrimSpace(s.config.DNS.Provider))
}
if providerID == "" || strings.TrimSpace(s.config.DNS.CredentialRef) == "" {
return IssueResult{}, errors.New("DNS Provider 或凭据引用未配置")
}
// 必须在读取凭据、构造 Provider 或访问 ACME 前拒绝受保护的任意 SAN。
if err := dnsprovider.ValidateAliyunTargetDomains(providerID, domains); err != nil {
challengeType, err := domain.NormalizeChallengeType(item.ChallengeType, domains)
if err != nil {
return IssueResult{}, err
}
if configured := strings.ToLower(strings.TrimSpace(s.config.DNS.Provider)); configured != "" && providerID != configured {
return IssueResult{}, fmt.Errorf("域名配置的 DNS Provider %s 尚未绑定独立凭据,当前初始化凭据属于 %s", providerID, configured)
}
if s.config.DNS.VerificationState != "verified" {
return IssueResult{}, errors.New("DNS API 尚未通过验证")
providerID := ""
var lease *httpchallenge.Lease
if challengeType == domain.ChallengeHTTP01 {
lease, err = s.httpChallenges.NewLease(ctx, domains)
if err != nil {
return IssueResult{}, err
}
defer lease.Close()
} else {
providerID, err = s.validateDNSIssueConfiguration(item, domains)
if err != nil {
return IssueResult{}, err
}
}
if err := s.repository.UpdateCertificateState(ctx, item.ID, "issuing", item.ExpiresAt); err != nil {
return IssueResult{}, fmt.Errorf("更新证书申请状态失败:%w", err)
@ -173,22 +182,31 @@ func (s *Service) Issue(ctx context.Context, domainID string) (IssueResult, erro
return IssueResult{}, cause
}
credentials, err := s.credentials.Get(ctx, s.config.DNS.CredentialRef)
if err != nil {
return fail(fmt.Errorf("读取 DNS 凭据失败:%w", err))
}
if err := ReportProgress(ctx, "dns_validation", "正在验证 DNS 凭据权限"); err != nil {
return fail(err)
}
if err := s.verifyDNSAuthorization(ctx, providerID, domains, credentials); err != nil {
return fail(err)
}
if err := ReportProgress(ctx, "certificate_issue", "DNS 凭据验证通过,正在申请证书"); err != nil {
return fail(err)
}
provider, err := s.providers.Create(providerID, credentials)
if err != nil {
return fail(fmt.Errorf("创建 DNS Provider 失败:%w", err))
var credentials map[string]string
var provider challenge.Provider
if lease != nil {
provider = lease
if err := ReportProgress(ctx, "certificate_issue", "正在通过 HTTP-01 申请证书,请确保全部域名的 80 端口挑战路径已接入"); err != nil {
return fail(err)
}
} else {
credentials, err = s.credentials.Get(ctx, s.config.DNS.CredentialRef)
if err != nil {
return fail(fmt.Errorf("读取 DNS 凭据失败:%w", err))
}
if err := ReportProgress(ctx, "dns_validation", "正在验证 DNS 凭据权限"); err != nil {
return fail(err)
}
if err := s.verifyDNSAuthorization(ctx, providerID, domains, credentials); err != nil {
return fail(err)
}
if err := ReportProgress(ctx, "certificate_issue", "DNS 凭据验证通过,正在申请证书"); err != nil {
return fail(err)
}
provider, err = s.providers.Create(providerID, credentials)
if err != nil {
return fail(fmt.Errorf("创建 DNS Provider 失败:%w", err))
}
}
email := strings.TrimSpace(s.config.ACME.AccountEmail)
if email == "" && len(s.config.Notification.Recipients) > 0 {
@ -200,9 +218,9 @@ func (s *Service) Issue(ctx context.Context, domainID string) (IssueResult, erro
}
issuer := s.newIssuer(user, provider, item.ACMEEnvironment)
resource, issueErr := issuer.Obtain(ctx, ObtainRequest{
Domains: domains, AccountEmail: email, Environment: item.ACMEEnvironment, PreferredChain: s.preferredChain,
Domains: domains, AccountEmail: email, Environment: item.ACMEEnvironment, PreferredChain: s.preferredChain, ChallengeType: challengeType,
})
if issueErr != nil {
if issueErr != nil && challengeType == domain.ChallengeDNS01 {
s.invalidateDNSAuthorizationOnFailure(providerID, domains, credentials, issueErr)
}
if saveErr := s.accounts.Save(ctx, item.ACMEEnvironment, user); saveErr != nil {

View File

@ -0,0 +1,91 @@
package database
import (
"testing"
"git.pcux.cn/xuans/Scert/backend/internal/config"
"git.pcux.cn/xuans/Scert/backend/internal/testsupport/mysqltest"
"git.pcux.cn/xuans/Scert/backend/internal/testsupport/postgrestest"
)
func TestDomainChallengeMigration(t *testing.T) {
for _, driver := range []string{"sqlite", "mysql", "postgres"} {
t.Run(driver, func(t *testing.T) {
var cfg config.DatabaseConfig
switch driver {
case "sqlite":
cfg = sqliteBusinessUpgradeConfig(t)
case "mysql":
cfg = mysqltest.New(t).Database(t)
case "postgres":
cfg = postgrestest.New(t).Database(t)
}
db, err := Open(t.Context(), cfg)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.DB.ExecContext(t.Context(), migrationTableSQL(driver)); err != nil {
t.Fatal(err)
}
for version := 1; version <= 25; version++ {
if err := applyMigration(t.Context(), db.DB, driver, version); err != nil {
t.Fatal(err)
}
}
if _, err := db.DB.ExecContext(t.Context(), `INSERT INTO domains (id,name,certificate_type,sans_json,status) VALUES ('legacy','legacy.example.com','single','["legacy.example.com"]','valid')`); err != nil {
t.Fatal(err)
}
for round := 0; round < 2; round++ {
if err := Migrate(t.Context(), db.DB, driver); err != nil {
t.Fatal(err)
}
var kind, status string
if err := db.DB.QueryRowContext(t.Context(), `SELECT challenge_type,status FROM domains WHERE id='legacy'`).Scan(&kind, &status); err != nil {
t.Fatal(err)
}
if kind != "dns-01" || status != "valid" {
t.Fatal("升级改变历史域名验证方式或状态")
}
}
if _, err := db.DB.ExecContext(t.Context(), `UPDATE domains SET challenge_type='http-01' WHERE id='legacy'`); err != nil {
t.Fatal(err)
}
if err := Migrate(t.Context(), db.DB, driver); err != nil {
t.Fatal(err)
}
var kind string
if err := db.DB.QueryRowContext(t.Context(), `SELECT challenge_type FROM domains WHERE id='legacy'`).Scan(&kind); err != nil || kind != "http-01" {
t.Fatal("重复迁移覆盖 HTTP 验证选择:", err)
}
})
}
}
func TestMySQLDomainChallengeInterruptedMigration(t *testing.T) {
f := startRealMySQL(t)
for _, conflict := range []bool{false, true} {
t.Run(map[bool]string{false: "resume", true: "conflict"}[conflict], func(t *testing.T) {
db := f.database(t)
mysqlPrepareVersion(t, db.DB, 25)
statement := "ALTER TABLE domains ADD COLUMN challenge_type VARCHAR(16) NOT NULL DEFAULT 'dns-01'"
if conflict {
statement = "ALTER TABLE domains ADD COLUMN challenge_type VARCHAR(8) NULL DEFAULT NULL"
}
if _, err := db.DB.ExecContext(t.Context(), statement); err != nil {
t.Fatal(err)
}
err := Migrate(t.Context(), db.DB, "mysql")
if (err != nil) != conflict {
t.Fatal("中断迁移结构检查结果错误:", err)
}
version, err := currentMigrationVersion(t.Context(), db.DB)
if err != nil {
t.Fatal(err)
}
if conflict && version != 25 || !conflict && version != latestMigrationVersion {
t.Fatal("迁移版本记录错误")
}
})
}
}

View File

@ -26,6 +26,7 @@ import (
//go:embed migrations/sqlite/0023_recovery_claims.sql migrations/mysql/0023_recovery_claims.sql migrations/postgres/0023_recovery_claims.sql
//go:embed migrations/sqlite/0024_recovery_events.sql migrations/mysql/0024_recovery_events.sql migrations/postgres/0024_recovery_events.sql
//go:embed migrations/sqlite/0025_recovery_reconciliation.sql migrations/mysql/0025_recovery_reconciliation.sql migrations/postgres/0025_recovery_reconciliation.sql
//go:embed migrations/sqlite/0026_domain_challenge.sql migrations/mysql/0026_domain_challenge.sql migrations/postgres/0026_domain_challenge.sql
var migrationFiles embed.FS
func Migrate(ctx context.Context, db *sql.DB, driver string) error {
@ -50,7 +51,7 @@ func Migrate(ctx context.Context, db *sql.DB, driver string) error {
return nil
}
const latestMigrationVersion = 25
const latestMigrationVersion = 26
// currentMigrationVersion 在执行业务迁移前拒绝新版数据库及不连续的迁移历史。
func currentMigrationVersion(ctx context.Context, db *sql.DB) (int, error) {
@ -123,6 +124,14 @@ func applyMigration(ctx context.Context, db *sql.DB, driver string, version int)
return fmt.Errorf("人工处置来源字段与预期不一致,拒绝记录迁移版本")
}
}
if version == 26 {
var compatible int
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE()
AND table_name = 'domains' AND column_name = 'challenge_type'
AND data_type = 'varchar' AND character_maximum_length = 16 AND is_nullable = 'NO' AND column_default = 'dns-01'`).Scan(&compatible); err != nil || compatible != 1 {
return fmt.Errorf("域名验证方式字段与预期不一致,拒绝记录迁移版本")
}
}
if _, err := db.ExecContext(ctx, "INSERT INTO schema_migrations (version) VALUES (?)", version); err != nil {
return fmt.Errorf("记录迁移版本失败:%w", err)
}
@ -263,6 +272,8 @@ func migrationName(version int) string {
return "recovery_events"
case 25:
return "recovery_reconciliation"
case 26:
return "domain_challenge"
default:
return "unknown"
}

View File

@ -0,0 +1 @@
ALTER TABLE domains ADD COLUMN challenge_type VARCHAR(16) NOT NULL DEFAULT 'dns-01';

View File

@ -0,0 +1 @@
ALTER TABLE domains ADD COLUMN challenge_type VARCHAR(16) NOT NULL DEFAULT 'dns-01';

View File

@ -0,0 +1 @@
ALTER TABLE domains ADD COLUMN challenge_type VARCHAR(16) NOT NULL DEFAULT 'dns-01';

View File

@ -0,0 +1,30 @@
package domain
import (
"fmt"
"strings"
)
const (
ChallengeDNS01 = "dns-01"
ChallengeHTTP01 = "http-01"
)
// NormalizeChallengeType 保持历史空值使用 DNS 验证,拒绝未知方式及所有泛域名。
func NormalizeChallengeType(value string, names []string) (string, error) {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {
value = ChallengeDNS01
}
if value != ChallengeDNS01 && value != ChallengeHTTP01 {
return "", fmt.Errorf("证书验证方式无效:%s", value)
}
if value == ChallengeHTTP01 {
for _, name := range names {
if strings.HasPrefix(strings.TrimSpace(name), "*.") {
return "", fmt.Errorf("HTTP-01 不支持泛域名,请使用 DNS-01")
}
}
}
return value, nil
}

View File

@ -32,6 +32,7 @@ type Domain struct {
AutoRenew bool `json:"auto_renew"`
ACMEEnvironment string `json:"acme_environment"`
DNSProvider string `json:"dns_provider,omitempty"`
ChallengeType string `json:"challenge_type"`
Status string `json:"status"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
@ -87,6 +88,7 @@ func NewDomainWithOptions(name string, certificateType CertificateType, sans []s
AutoRenew: autoRenew,
ACMEEnvironment: acmeEnvironment,
DNSProvider: dnsProvider,
ChallengeType: ChallengeDNS01,
Status: "pending",
CreatedAt: now,
UpdatedAt: now,

View File

@ -0,0 +1,33 @@
package httpapi
import (
"net/http"
"github.com/gin-gonic/gin"
"git.pcux.cn/xuans/Scert/backend/internal/dnsprovider"
"git.pcux.cn/xuans/Scert/backend/internal/domain"
)
func (s *Server) authorizeDomainChallenge(c *gin.Context, item domain.Domain) bool {
if item.ChallengeType == domain.ChallengeHTTP01 {
return true
}
if s.currentDNSProvider() == dnsprovider.Aliyun && !s.authorizeDNSCredentialUse(c, item.ProjectID) {
return false
}
if err := s.validateDomainAliyunAuthorization(c.Request.Context(), append([]string{item.Name}, item.SANs...)...); err != nil {
s.writeError(c, http.StatusBadRequest, err.Error(), err)
return false
}
return true
}
func (s *Server) authorizeIssueChallenge(c *gin.Context, item domain.Domain) bool {
kind, err := domain.NormalizeChallengeType(item.ChallengeType, append([]string{item.Name}, item.SANs...))
if err != nil {
s.writeError(c, http.StatusBadRequest, "证书验证方式无效", err)
return false
}
return kind == domain.ChallengeHTTP01 || s.authorizeDNSCredentialUse(c, item.ResourceScope.Normalize().ProjectID)
}

View File

@ -0,0 +1,112 @@
package httpapi
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.pcux.cn/xuans/Scert/backend/internal/certificate"
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
"git.pcux.cn/xuans/Scert/backend/internal/domain"
"git.pcux.cn/xuans/Scert/backend/internal/httpchallenge"
"git.pcux.cn/xuans/Scert/backend/internal/repository"
)
func TestHTTPChallengeProductionHandler(t *testing.T) {
store := httpchallenge.NewStore()
lease, err := store.NewLease(t.Context(), []string{"issued.example.com"})
if err != nil {
t.Fatal(err)
}
defer lease.Close()
const token = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG"
if err := lease.Present(t.Context(), "issued.example.com", token, token+"."+token); err != nil {
t.Fatal(err)
}
server := New(Dependencies{Config: readyTestConfig(t.TempDir()), HTTPChallenges: store})
for _, tc := range []struct {
host, path string
status int
}{
{"issued.example.com", token, 200},
{"other.example.com", token, 404},
{"issued.example.com", "unknown", 404},
{"issued.example.com", token + "/", 404},
} {
w := httptest.NewRecorder()
server.Handler().ServeHTTP(w, httptest.NewRequest("GET", "http://"+tc.host+httpchallenge.PathPrefix+tc.path, nil))
if w.Code != tc.status || w.Header().Get("Location") != "" || w.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("正式挑战路由错误:%d %s", w.Code, w.Body.String())
}
}
server.httpChallenges = nil
w := httptest.NewRecorder()
server.Handler().ServeHTTP(w, httptest.NewRequest("GET", "http://issued.example.com"+httpchallenge.PathPrefix+token, nil))
if w.Code != 404 {
t.Fatal("缺失挑战存储时未拒绝")
}
}
func TestHTTPDomainCreateAndUpdateBypassDNSOnly(t *testing.T) {
cfg := readyTestConfig(t.TempDir())
cfg.DNS.Provider = "aliyun"
repo := &stubDomainRepository{loaded: domain.Domain{ID: "domain-1", Name: "example.com", ChallengeType: domain.ChallengeHTTP01}}
server := New(Dependencies{
Config: cfg, Repository: repo,
ProjectDomains: projectDomainRepositoryStub{DomainRepository: repo},
ProjectResources: projectResourceReaderStub{domains: repo},
})
for _, tc := range []struct {
method, path, body string
status int
}{
{"POST", "/api/v1/domains", `{"name":"example.com","certificate_type":"single","challenge_type":"http-01"}`, 201},
{"POST", "/api/v1/domains", `{"name":"example.com","certificate_type":"san","sans":["*.example.com"],"challenge_type":"http-01"}`, 400},
{"POST", "/api/v1/domains", `{"name":"example.com","certificate_type":"single","challenge_type":"unknown"}`, 400},
{"PUT", "/api/v1/domains/domain-1", `{"name":"example.com","certificate_type":"single"}`, 200},
} {
w := httptest.NewRecorder()
serveAdminHTTP(server, w, httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)))
if w.Code != tc.status {
t.Fatalf("域名接口状态 %d,预期 %d:%s", w.Code, tc.status, w.Body.String())
}
}
if repo.created.ChallengeType != domain.ChallengeHTTP01 || repo.updated.ChallengeType != domain.ChallengeHTTP01 {
t.Fatal("HTTP 验证方式未保存或旧客户端覆盖了现有方式")
}
}
func TestHTTPTaskAuthorizationRetainsProjectPermissions(t *testing.T) {
for _, denied := range []domain.ProjectPermission{domain.PermissionDNSCredentialUse, domain.PermissionCertificateManage, domain.PermissionDeploymentExecute} {
t.Run(string(denied), func(t *testing.T) {
repo := &stubDomainRepository{loaded: domain.Domain{ID: "domain-1", Name: "example.com", ChallengeType: domain.ChallengeHTTP01, ResourceScope: domain.ResourceScope{ProjectID: "project-a"}}}
calls := 0
server := New(Dependencies{
Config: readyTestConfig(t.TempDir()), Repository: repo,
ProjectResources: projectResourceReaderStub{domains: repo},
ProjectMembers: permissionProjectMemberRepository{allow: func(permission domain.ProjectPermission) error {
if permission == denied {
return repository.ErrProjectAccessDenied
}
return nil
}},
IssueCertificate: func(context.Context, string) (certificate.IssueResult, deployment.Report, error) {
calls++
return certificate.IssueResult{}, deployment.Report{}, nil
},
})
w := httptest.NewRecorder()
serveAdminHTTP(server, w, httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil))
want := denied == domain.PermissionDNSCredentialUse
if (calls == 1) != want {
t.Fatalf("HTTP 签发项目授权错误:%d %s", w.Code, w.Body.String())
}
err := server.authorizeCertificateTaskExecution(t.Context(), repository.Task{DomainID: "domain-1", ProjectID: "project-a", ScopeType: repository.ScopeProject, OperatorID: "actor", TaskType: "certificate_issue"}, repository.TaskAttempt{})
if (err == nil) != want {
t.Fatal("执行前授权与验证方式不匹配:", err)
}
})
}
}

View File

@ -25,6 +25,7 @@ import (
"git.pcux.cn/xuans/Scert/backend/internal/dnsprovider"
"git.pcux.cn/xuans/Scert/backend/internal/domain"
"git.pcux.cn/xuans/Scert/backend/internal/firewall"
"git.pcux.cn/xuans/Scert/backend/internal/httpchallenge"
"git.pcux.cn/xuans/Scert/backend/internal/logging"
"git.pcux.cn/xuans/Scert/backend/internal/repository"
)
@ -39,6 +40,7 @@ type readinessSecretReader interface {
}
type Dependencies struct {
HTTPChallenges *httpchallenge.Store
Config config.Config
ConfigPath string
FrontendDir string
@ -83,6 +85,7 @@ type Dependencies struct {
}
type Server struct {
httpChallenges *httpchallenge.Store
configMu sync.RWMutex
config config.Config
configPath string
@ -161,8 +164,9 @@ func New(deps Dependencies) *Server {
frontendDir = filepath.Join(deps.Config.Paths.Install, "web")
}
server := &Server{
httpListening: make(chan struct{}),
config: deps.Config, configPath: deps.ConfigPath, frontendDir: filepath.Clean(frontendDir), logger: deps.Logger,
httpChallenges: deps.HTTPChallenges,
httpListening: make(chan struct{}),
config: deps.Config, configPath: deps.ConfigPath, frontendDir: filepath.Clean(frontendDir), logger: deps.Logger,
repository: deps.Repository, setupDNS: deps.SetupDNS, secrets: deps.Secrets, firewall: deps.Firewall, router: router,
servers: deps.Servers,
users: deps.Users,
@ -260,7 +264,14 @@ func (s *Server) registerRoutes() {
}
func (s *Server) Handler() http.Handler {
return s.router
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 在路由纠正、访问日志及登录检查前接管挑战,未知令牌不能落入前端页面。
if strings.HasPrefix(r.URL.Path, strings.TrimSuffix(httpchallenge.PathPrefix, "/")) {
s.httpChallenges.ServeHTTP(w, r)
return
}
s.router.ServeHTTP(w, r)
})
}
func (s *Server) ListenAndServe() error {
@ -329,7 +340,7 @@ func (s *Server) prepareHTTPServer() (*http.Server, error) {
if err != nil {
return nil, fmt.Errorf("加载 HTTPS 证书与私钥失败:%w", err)
}
server := newHTTPServer(address, s.router)
server := newHTTPServer(address, s.Handler())
server.TLSConfig.GetCertificate = s.getTLSCertificate
s.httpMu.Lock()
if s.httpStopped {
@ -412,6 +423,7 @@ type domainRequest struct {
AutoRenew *bool `json:"auto_renew"`
ACMEEnvironment string `json:"acme_environment"`
DNSProvider string `json:"dns_provider"`
ChallengeType *string `json:"challenge_type"`
}
type serverRequest struct {
@ -634,11 +646,7 @@ func (s *Server) createDomain(c *gin.Context) {
if !s.authorizeDomainMutation(c, item.ProjectID) {
return
}
if s.currentDNSProvider() == dnsprovider.Aliyun && !s.authorizeDNSCredentialUse(c, item.ProjectID) {
return
}
if err := s.validateDomainAliyunAuthorization(c.Request.Context(), append([]string{item.Name}, item.SANs...)...); err != nil {
s.writeError(c, http.StatusBadRequest, err.Error(), err)
if !s.authorizeDomainChallenge(c, item) {
return
}
if err := s.projectDomains.CreateProjectDomain(c.Request.Context(), c.GetString(authenticatedActorKey), item); err != nil {
@ -673,16 +681,16 @@ func (s *Server) updateDomain(c *gin.Context) {
if !s.authorizeDomainMutation(c, existing.ProjectID) {
return
}
if s.currentDNSProvider() == dnsprovider.Aliyun && !s.authorizeDNSCredentialUse(c, existing.ProjectID) {
return
if request.ChallengeType == nil {
request.ChallengeType = &existing.ChallengeType
}
item, err := newDomainFromRequest(request)
if err != nil {
s.writeError(c, http.StatusBadRequest, "域名配置无效", err)
return
}
if err := s.validateDomainAliyunAuthorization(c.Request.Context(), append([]string{item.Name}, item.SANs...)...); err != nil {
s.writeError(c, http.StatusBadRequest, err.Error(), err)
item.ResourceScope = existing.ResourceScope
if !s.authorizeDomainChallenge(c, item) {
return
}
item.ID = existing.ID
@ -722,7 +730,19 @@ func newDomainFromRequest(request domainRequest) (domain.Domain, error) {
if acmeEnvironment == "" {
acmeEnvironment = domain.ACMEEnvironmentStaging
}
return domain.NewDomainWithOptions(request.Name, domain.CertificateType(request.CertificateType), request.SANs, autoRenew, acmeEnvironment, request.DNSProvider)
item, err := domain.NewDomainWithOptions(request.Name, domain.CertificateType(request.CertificateType), request.SANs, autoRenew, acmeEnvironment, request.DNSProvider)
if err != nil {
return domain.Domain{}, err
}
value := ""
if request.ChallengeType != nil {
value = *request.ChallengeType
}
item.ChallengeType, err = domain.NormalizeChallengeType(value, append([]string{item.Name}, item.SANs...))
if item.ChallengeType == domain.ChallengeHTTP01 {
item.DNSProvider = ""
}
return item, err
}
func (s *Server) deleteDomain(c *gin.Context) {
@ -758,7 +778,7 @@ func (s *Server) authorizeSynchronousCertificateIssue(c *gin.Context) bool {
s.writeProjectResourceError(c, err)
return false
}
return s.authorizeDNSCredentialUse(c, resource.ResourceScope.Normalize().ProjectID)
return s.authorizeIssueChallenge(c, resource)
}
func (s *Server) issueDomain(c *gin.Context) {

View File

@ -94,7 +94,7 @@ func (s *Server) submitPreparedDomainTask(c *gin.Context, taskType string, prepa
s.writeError(c, http.StatusInternalServerError, "检查证书部署权限失败", err)
return
}
if taskType == "certificate_issue" && !s.authorizeDNSCredentialUse(c, resource.ResourceScope.Normalize().ProjectID) {
if taskType == "certificate_issue" && !s.authorizeIssueChallenge(c, resource) {
return
}
s.issueTaskMu.Lock()
@ -577,8 +577,18 @@ func (s *Server) retryTask(c *gin.Context) {
return
}
}
if item.TaskType == "certificate_issue" && !s.authorizeDNSCredentialUse(c, projectID) {
return
if item.TaskType == "certificate_issue" {
if !s.requireProjectResourceReader(c) {
return
}
resource, err := s.projectResources.GetVisibleDomain(c.Request.Context(), actorID, item.DomainID)
if err != nil {
s.writeProjectResourceError(c, err)
return
}
if !s.authorizeIssueChallenge(c, resource) {
return
}
}
startedAt := time.Now().UTC()
retryTask, attempt, err := reader.RetryVisibleTask(c.Request.Context(), actorID, taskID, &startedAt)

View File

@ -24,7 +24,14 @@ func (s *Server) authorizeCertificateTaskExecution(ctx context.Context, task rep
permissions := []domain.ProjectPermission{domain.PermissionDeploymentExecute}
switch task.TaskType {
case "certificate_issue":
permissions = append(permissions, domain.PermissionCertificateManage, domain.PermissionDNSCredentialUse)
permissions = append(permissions, domain.PermissionCertificateManage)
kind, err := domain.NormalizeChallengeType(resource.ChallengeType, append([]string{resource.Name}, resource.SANs...))
if err != nil {
return err
}
if kind == domain.ChallengeDNS01 {
permissions = append(permissions, domain.PermissionDNSCredentialUse)
}
case "certificate_deploy":
case "certificate_restore":
permissions = append(permissions, domain.PermissionPrivateKeyRead)

View File

@ -0,0 +1,53 @@
package httpchallenge
import (
"net"
"net/http"
"strconv"
"strings"
)
func (s *Store) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
notFound := func() { w.WriteHeader(http.StatusNotFound) }
if r.Method != http.MethodGet && r.Method != http.MethodHead {
notFound()
return
}
if !strings.HasPrefix(r.URL.Path, PathPrefix) || r.URL.RawPath != "" || r.URL.RawQuery != "" {
notFound()
return
}
token := strings.TrimPrefix(r.URL.Path, PathPrefix)
host := r.Host
if strings.Contains(host, ":") {
var port string
var err error
host, port, err = net.SplitHostPort(host)
n, portErr := strconv.Atoi(port)
if err != nil || portErr != nil || n < 1 || n > 65535 {
notFound()
return
}
}
host, ok := normalizeHost(host)
if s == nil || !ok || !validToken(token) {
notFound()
return
}
s.mu.Lock()
s.prune(s.now())
current, exists := s.entries[entryKey{host: host, token: token}]
s.mu.Unlock()
if !exists {
notFound()
return
}
w.Header().Set("Content-Length", strconv.Itoa(len(current.value)))
w.WriteHeader(http.StatusOK)
if r.Method == http.MethodGet {
_, _ = w.Write([]byte(current.value))
}
}

View File

@ -0,0 +1,155 @@
package httpchallenge
import (
"context"
"errors"
"strings"
"sync"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/domain"
)
const PathPrefix = "/.well-known/acme-challenge/"
type entryKey struct {
host string
token string
}
type entry struct {
owner *Lease
value string
expires time.Time
}
// Store 仅在进程内保存短期挑战,不落盘、不跨订单共享挑战内容。
type Store struct {
mu sync.Mutex
entries map[entryKey]entry
ttl time.Duration
capacity int
now func() time.Time
}
func NewStore() *Store {
return &Store{entries: make(map[entryKey]entry), ttl: 15 * time.Minute, capacity: 4096, now: time.Now}
}
type Lease struct {
store *Store
hosts map[string]struct{}
ctx context.Context
closed bool
stop func() bool
}
func (s *Store) NewLease(ctx context.Context, names []string) (*Lease, error) {
if s == nil || len(names) == 0 || len(names) > 100 {
return nil, errors.New("HTTP 验证服务未初始化或域名数量无效")
}
if err := ctx.Err(); err != nil {
return nil, err
}
l := &Lease{store: s, ctx: ctx, hosts: make(map[string]struct{}, len(names))}
for _, name := range names {
host, ok := normalizeHost(name)
if !ok {
return nil, errors.New("HTTP 验证仅支持普通域名,不支持泛域名")
}
l.hosts[host] = struct{}{}
}
l.stop = context.AfterFunc(ctx, l.closeEntries)
return l, nil
}
func normalizeHost(name string) (string, bool) {
name = strings.ToLower(strings.TrimSuffix(name, "."))
return name, len(name) <= 253 && !strings.HasPrefix(name, "*.") && domain.IsValidName(name) && strings.TrimSpace(name) == name
}
func validToken(token string) bool {
if len(token) < 22 || len(token) > 256 {
return false
}
for _, c := range token {
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_') {
return false
}
}
return true
}
func (l *Lease) Present(ctx context.Context, name, token, keyAuth string) error {
if err := ctx.Err(); err != nil {
return err
}
host, ok := normalizeHost(name)
if !ok || !validToken(token) || !strings.HasPrefix(keyAuth, token+".") || !validToken(strings.TrimPrefix(keyAuth, token+".")) {
return errors.New("HTTP 挑战参数无效")
}
if _, ok := l.hosts[host]; !ok {
return errors.New("HTTP 挑战域名不属于当前订单")
}
s := l.store
s.mu.Lock()
defer s.mu.Unlock()
if l.closed || l.ctx.Err() != nil {
return errors.New("HTTP 验证订单已关闭")
}
now := s.now()
s.prune(now)
key := entryKey{host: host, token: token}
if existing, exists := s.entries[key]; exists {
if existing.owner != l || existing.value != keyAuth {
return errors.New("HTTP 挑战令牌已被其他验证占用")
}
return nil
}
if len(s.entries) >= s.capacity {
return errors.New("HTTP 挑战容量已满,请稍后重试")
}
s.entries[key] = entry{owner: l, value: keyAuth, expires: now.Add(s.ttl)}
return nil
}
// CleanUp 不受已取消上下文影响,且只清理当前订单拥有的挑战。
func (l *Lease) CleanUp(_ context.Context, name, token, keyAuth string) error {
host, _ := normalizeHost(name)
s := l.store
s.mu.Lock()
defer s.mu.Unlock()
key := entryKey{host: host, token: token}
if current, exists := s.entries[key]; exists && current.owner == l && current.value == keyAuth {
delete(s.entries, key)
}
return nil
}
func (l *Lease) Close() {
if l.stop != nil {
l.stop()
}
l.closeEntries()
}
func (l *Lease) closeEntries() {
s := l.store
s.mu.Lock()
defer s.mu.Unlock()
l.closed = true
for key, current := range s.entries {
if current.owner == l {
delete(s.entries, key)
}
}
}
// prune 的调用者必须持有锁;过期或取消的挑战不能继续对外提供。
func (s *Store) prune(now time.Time) {
for key, current := range s.entries {
if !now.Before(current.expires) || current.owner.ctx.Err() != nil {
delete(s.entries, key)
}
}
}

View File

@ -0,0 +1,173 @@
package httpchallenge
import (
"context"
"fmt"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
)
const testToken = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG"
const testAuth = testToken + ".abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG"
func newTestLease(t *testing.T, s *Store, ctx context.Context, names ...string) *Lease {
t.Helper()
l, err := s.NewLease(ctx, names)
if err != nil {
t.Fatal(err)
}
t.Cleanup(l.Close)
return l
}
func requestChallenge(s *Store, method, host, path string) *httptest.ResponseRecorder {
r := httptest.NewRequest(method, "http://"+host+path, nil)
r.Header.Set("X-Forwarded-Host", "a.example.com")
w := httptest.NewRecorder()
s.ServeHTTP(w, r)
return w
}
func TestChallengeHostTokenAndMethodIsolation(t *testing.T) {
s := NewStore()
l := newTestLease(t, s, context.Background(), "a.example.com")
if err := l.Present(context.Background(), "a.example.com", testToken, testAuth); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
method, host, path string
status int
}{
{"GET", "a.example.com", PathPrefix + testToken, 200},
{"HEAD", "A.EXAMPLE.COM:80", PathPrefix + testToken, 200},
{"GET", "b.example.com", PathPrefix + testToken, 404},
{"POST", "a.example.com", PathPrefix + testToken, 404},
{"GET", "a.example.com", PathPrefix + testToken + "x", 404},
{"GET", "a.example.com", PathPrefix + testToken + "/", 404},
{"GET", "a.example.com", PathPrefix + testToken + "?x=1", 404},
{"GET", "a.example.com", PathPrefix + "%61" + testToken[1:], 404},
{"GET", "a.example.com", "/unrelated/" + testToken, 404},
} {
w := requestChallenge(s, tc.method, tc.host, tc.path)
if w.Code != tc.status || w.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("挑战响应不正确:%+v,状态 %d", tc, w.Code)
}
if tc.status == 200 && tc.method == "GET" && w.Body.String() != testAuth {
t.Fatal("挑战内容不匹配")
}
if (tc.status == 404 || tc.method == "HEAD") && w.Body.Len() != 0 {
t.Fatal("拒绝响应或 HEAD 泄漏挑战内容")
}
}
}
func TestLeaseOwnershipCancellationAndExpiry(t *testing.T) {
s := NewStore()
now := time.Now()
s.now = func() time.Time { return now }
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
first := newTestLease(t, s, ctx, "a.example.com")
second := newTestLease(t, s, context.Background(), "a.example.com")
if err := first.Present(ctx, "a.example.com", testToken, testAuth); err != nil {
t.Fatal(err)
}
if err := second.Present(ctx, "a.example.com", testToken, testAuth); err == nil {
t.Fatal("不同订单不应覆盖同一挑战")
}
_ = second.CleanUp(ctx, "a.example.com", testToken, testAuth)
if w := requestChallenge(s, "GET", "a.example.com", PathPrefix+testToken); w.Code != 200 {
t.Fatal("其他订单清理删除了当前挑战")
}
cancel()
if w := requestChallenge(s, "GET", "a.example.com", PathPrefix+testToken); w.Code != 404 {
t.Fatal("已取消的挑战仍可读取")
}
if err := second.Present(context.Background(), "a.example.com", testToken, testAuth); err != nil {
t.Fatal(err)
}
_ = first.CleanUp(ctx, "a.example.com", testToken, testAuth)
first.Close()
if w := requestChallenge(s, "GET", "a.example.com", PathPrefix+testToken); w.Code != 200 {
t.Fatal("迟到清理误删了新订单挑战")
}
now = now.Add(s.ttl)
if w := requestChallenge(s, "GET", "a.example.com", PathPrefix+testToken); w.Code != 404 {
t.Fatal("到期挑战仍可读取")
}
second.Close()
if err := second.Present(context.Background(), "a.example.com", testToken, testAuth); err == nil {
t.Fatal("关闭后不应重新创建挑战")
}
}
func TestChallengeValidationAndCapacity(t *testing.T) {
s := NewStore()
s.capacity = 1
l := newTestLease(t, s, context.Background(), "a.example.com", "b.example.com")
for _, tc := range []struct{ host, token, auth string }{
{"c.example.com", testToken, testAuth},
{"*.a.example.com", testToken, testAuth},
{"a.example.com", "../token", testAuth},
{"a.example.com", testToken, "other.auth"},
{"a.example.com", testToken, testAuth + "\n"},
} {
if err := l.Present(context.Background(), tc.host, tc.token, tc.auth); err == nil {
t.Fatal("无效挑战未被拒绝")
}
}
if _, err := s.NewLease(context.Background(), []string{"*.example.com"}); err == nil {
t.Fatal("泛域名订单未被拒绝")
}
if err := l.Present(context.Background(), "a.example.com", testToken, testAuth); err != nil {
t.Fatal(err)
}
if err := l.Present(context.Background(), "b.example.com", testToken, testAuth); err == nil {
t.Fatal("容量限制未生效")
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
_ = l.CleanUp(ctx, "a.example.com", testToken, testAuth)
if err := l.Present(context.Background(), "b.example.com", testToken, testAuth); err != nil {
t.Fatal("取消上下文阻碍清理:", err)
}
}
func TestChallengeConcurrentOrders(t *testing.T) {
s := NewStore()
var wg sync.WaitGroup
for i := 0; i < 64; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
l, err := s.NewLease(ctx, []string{"a.example.com"})
if err != nil {
t.Error(err)
return
}
defer l.Close()
token := fmt.Sprintf("%s%d", testToken, i)
auth := token + "." + strings.Repeat("a", 43)
if err := l.Present(ctx, "a.example.com", token, auth); err != nil {
t.Error(err)
return
}
if w := requestChallenge(s, "GET", "a.example.com", PathPrefix+token); w.Code != 200 {
t.Error("并发订单内容丢失")
}
cancel()
_ = l.CleanUp(ctx, "a.example.com", token, auth)
}(i)
}
wg.Wait()
s.mu.Lock()
defer s.mu.Unlock()
if len(s.entries) != 0 {
t.Fatal("并发订单清理后仍有残留")
}
}

View File

@ -20,6 +20,10 @@ func (r *SQLProjectMemberRepository) CreateProjectDomain(ctx context.Context, ac
return err
}
item.ResourceScope = item.ResourceScope.Normalize()
challengeType, err := domain.NormalizeChallengeType(item.ChallengeType, append([]string{item.Name}, item.SANs...))
if err != nil {
return err
}
payload, err := json.Marshal(item.SANs)
if err != nil {
return fmt.Errorf("编码域名 SAN 失败:%w", err)
@ -33,10 +37,10 @@ func (r *SQLProjectMemberRepository) CreateProjectDomain(ctx context.Context, ac
return err
}
_, err = tx.ExecContext(ctx, Rebind(r.driver, `INSERT INTO domains
(id, name, certificate_type, sans_json, auto_renew, acme_environment, dns_provider, status, expires_at, created_at, updated_at, project_id, owner_id, created_by, updated_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), item.ID, item.Name, item.CertificateType, string(payload), item.AutoRenew,
(id, name, certificate_type, sans_json, auto_renew, acme_environment, dns_provider, status, expires_at, created_at, updated_at, project_id, owner_id, created_by, updated_by, challenge_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), item.ID, item.Name, item.CertificateType, string(payload), item.AutoRenew,
item.ACMEEnvironment, item.DNSProvider, item.Status, nullableTime(item.ExpiresAt), item.CreatedAt, item.UpdatedAt,
item.ProjectID, item.OwnerID, item.CreatedBy, item.UpdatedBy)
item.ProjectID, item.OwnerID, item.CreatedBy, item.UpdatedBy, challengeType)
if err != nil {
return fmt.Errorf("保存域名失败:%w", err)
}
@ -76,10 +80,14 @@ func (r *SQLProjectMemberRepository) UpdateProjectDomain(ctx context.Context, ac
if err := r.validateDomainPathsInTransaction(ctx, tx, item); err != nil {
return err
}
challengeType, err := domain.NormalizeChallengeType(item.ChallengeType, append([]string{item.Name}, item.SANs...))
if err != nil {
return err
}
result, err := tx.ExecContext(ctx, Rebind(r.driver, `UPDATE domains SET
name = ?, certificate_type = ?, sans_json = ?, auto_renew = ?, acme_environment = ?, dns_provider = ?, updated_at = ?, updated_by = ?
name = ?, certificate_type = ?, sans_json = ?, auto_renew = ?, acme_environment = ?, dns_provider = ?, updated_at = ?, updated_by = ?, challenge_type = ?
WHERE id = ? AND project_id = ?`), item.Name, item.CertificateType, string(payload), item.AutoRenew, item.ACMEEnvironment,
item.DNSProvider, item.UpdatedAt, item.UpdatedBy, item.ID, stored.ProjectID)
item.DNSProvider, item.UpdatedAt, item.UpdatedBy, challengeType, item.ID, stored.ProjectID)
if err != nil {
return fmt.Errorf("更新域名失败:%w", err)
}

View File

@ -45,11 +45,15 @@ func TestProjectDomainRepositorySQLite(t *testing.T) {
t.Fatal(err)
}
item.ID = "managed-domain"
item.ChallengeType = domain.ChallengeHTTP01
item.ProjectID = "domain-project"
if err := writer.CreateProjectDomain(t.Context(), "domain-manager", item); err != nil {
t.Fatal(err)
}
stored, err := NewSQLDomainRepository(db.DB, cfg.Driver).GetByID(t.Context(), item.ID)
if err == nil && stored.ChallengeType != domain.ChallengeHTTP01 {
t.Fatal("项目域名未保存 HTTP 验证方式")
}
if err != nil || stored.ProjectID != item.ProjectID {
t.Fatalf("项目域名未按真实归属保存:%+v,%v", stored, err)
}
@ -57,12 +61,17 @@ func TestProjectDomainRepositorySQLite(t *testing.T) {
t.Fatalf("越权创建应拒绝:%v", err)
}
item.Name = "updated.example.test"
item.ChallengeType = domain.ChallengeDNS01
if err := writer.UpdateProjectDomain(t.Context(), "domain-manager", item); err != nil {
t.Fatal(err)
}
if _, err := NewSQLDomainRepository(db.DB, cfg.Driver).GetByID(t.Context(), item.ID); err != nil {
t.Fatal(err)
}
visible, err := writer.GetVisibleDomain(t.Context(), "domain-manager", item.ID)
if err != nil || visible.ChallengeType != domain.ChallengeDNS01 {
t.Fatal("项目查询未返回更新后的验证方式:", err)
}
serverRepo := NewSQLServerRepository(db.DB, cfg.Driver)
server := deployment.Server{
ResourceScope: domain.ResourceScope{ProjectID: item.ProjectID},

View File

@ -26,7 +26,7 @@ var _ ProjectResourceReader = (*SQLProjectMemberRepository)(nil)
const visibleDomainSelect = `SELECT domains.id, domains.name, domains.certificate_type,
domains.sans_json, domains.auto_renew, domains.acme_environment, domains.dns_provider,
domains.status, domains.expires_at, domains.created_at, domains.updated_at,
domains.project_id, domains.owner_id, domains.created_by, domains.updated_by FROM domains`
domains.project_id, domains.owner_id, domains.created_by, domains.updated_by, domains.challenge_type FROM domains`
// 列名只接受内部固定值,角色能力复用领域模型;所有身份和角色值均使用绑定参数。
// EXISTS 避免多角色账户重复返回资源,不按用户分组或全局普通管理员身份放行。

View File

@ -33,11 +33,15 @@ func (r *SQLDomainRepository) Create(ctx context.Context, item domain.Domain) er
if err != nil {
return fmt.Errorf("编码 SAN 域名失败:%w", err)
}
challengeType, err := domain.NormalizeChallengeType(item.ChallengeType, append([]string{item.Name}, item.SANs...))
if err != nil {
return err
}
_, err = r.db.ExecContext(ctx, r.query(`INSERT INTO domains
(id, name, certificate_type, sans_json, auto_renew, acme_environment, dns_provider, status, expires_at, created_at, updated_at, project_id, owner_id, created_by, updated_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), item.ID, item.Name, item.CertificateType, string(payload), item.AutoRenew,
(id, name, certificate_type, sans_json, auto_renew, acme_environment, dns_provider, status, expires_at, created_at, updated_at, project_id, owner_id, created_by, updated_by, challenge_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), item.ID, item.Name, item.CertificateType, string(payload), item.AutoRenew,
item.ACMEEnvironment, item.DNSProvider, item.Status, nullableTime(item.ExpiresAt), item.CreatedAt, item.UpdatedAt,
item.ResourceScope.Normalize().ProjectID, item.OwnerID, item.CreatedBy, item.UpdatedBy)
item.ResourceScope.Normalize().ProjectID, item.OwnerID, item.CreatedBy, item.UpdatedBy, challengeType)
if err != nil {
return fmt.Errorf("保存域名失败:%w", err)
}
@ -52,10 +56,14 @@ func (r *SQLDomainRepository) Update(ctx context.Context, item domain.Domain) er
if err != nil {
return fmt.Errorf("编码 SAN 域名失败:%w", err)
}
challengeType, err := domain.NormalizeChallengeType(item.ChallengeType, append([]string{item.Name}, item.SANs...))
if err != nil {
return err
}
result, err := r.db.ExecContext(ctx, r.query(`UPDATE domains SET
name = ?, certificate_type = ?, sans_json = ?, auto_renew = ?, acme_environment = ?, dns_provider = ?, updated_at = ?, updated_by = ?
name = ?, certificate_type = ?, sans_json = ?, auto_renew = ?, acme_environment = ?, dns_provider = ?, updated_at = ?, updated_by = ?, challenge_type = ?
WHERE id = ? AND project_id = ?`), item.Name, item.CertificateType, string(payload), item.AutoRenew, item.ACMEEnvironment,
item.DNSProvider, item.UpdatedAt, item.UpdatedBy, item.ID, item.ResourceScope.Normalize().ProjectID)
item.DNSProvider, item.UpdatedAt, item.UpdatedBy, challengeType, item.ID, item.ResourceScope.Normalize().ProjectID)
if err != nil {
return fmt.Errorf("更新域名失败:%w", err)
}
@ -79,7 +87,7 @@ func (r *SQLDomainRepository) GetByID(ctx context.Context, id string) (domain.Do
if r == nil || r.db == nil {
return domain.Domain{}, errors.New("数据库连接不能为空")
}
row := r.db.QueryRowContext(ctx, r.query(`SELECT id, name, certificate_type, sans_json, auto_renew, acme_environment, dns_provider, status, expires_at, created_at, updated_at, project_id, owner_id, created_by, updated_by
row := r.db.QueryRowContext(ctx, r.query(`SELECT id, name, certificate_type, sans_json, auto_renew, acme_environment, dns_provider, status, expires_at, created_at, updated_at, project_id, owner_id, created_by, updated_by, challenge_type
FROM domains WHERE id = ?`), id)
return scanDomain(row)
}
@ -88,7 +96,7 @@ func (r *SQLDomainRepository) List(ctx context.Context) ([]domain.Domain, error)
if r == nil || r.db == nil {
return nil, errors.New("数据库连接不能为空")
}
rows, err := r.db.QueryContext(ctx, r.query(`SELECT id, name, certificate_type, sans_json, auto_renew, acme_environment, dns_provider, status, expires_at, created_at, updated_at, project_id, owner_id, created_by, updated_by
rows, err := r.db.QueryContext(ctx, r.query(`SELECT id, name, certificate_type, sans_json, auto_renew, acme_environment, dns_provider, status, expires_at, created_at, updated_at, project_id, owner_id, created_by, updated_by, challenge_type
FROM domains ORDER BY name`))
if err != nil {
return nil, fmt.Errorf("查询域名失败:%w", err)
@ -175,7 +183,7 @@ func scanDomain(row scanner) (domain.Domain, error) {
var acmeEnvironment string
var dnsProvider string
var expiresAt sql.NullTime
if err := row.Scan(&item.ID, &item.Name, &certificateType, &sansJSON, &autoRenew, &acmeEnvironment, &dnsProvider, &item.Status, &expiresAt, &item.CreatedAt, &item.UpdatedAt, &item.ProjectID, &item.OwnerID, &item.CreatedBy, &item.UpdatedBy); err != nil {
if err := row.Scan(&item.ID, &item.Name, &certificateType, &sansJSON, &autoRenew, &acmeEnvironment, &dnsProvider, &item.Status, &expiresAt, &item.CreatedAt, &item.UpdatedAt, &item.ProjectID, &item.OwnerID, &item.CreatedBy, &item.UpdatedBy, &item.ChallengeType); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return domain.Domain{}, sql.ErrNoRows
}

View File

@ -73,11 +73,15 @@ func TestRepositoryCRUD(t *testing.T) {
item.AutoRenew = false
item.ACMEEnvironment = domain.ACMEEnvironmentProduction
item.DNSProvider = "cloudflare"
item.ChallengeType = domain.ChallengeHTTP01
item.UpdatedAt = item.UpdatedAt.Add(time.Minute)
if err := repo.Update(context.Background(), item); err != nil {
t.Fatalf("更新域名失败:%v", err)
}
loaded, err = repo.GetByID(context.Background(), item.ID)
if err == nil && loaded.ChallengeType != domain.ChallengeHTTP01 {
t.Fatal("域名验证方式更新后未保持")
}
if err != nil || loaded.Name != item.Name || loaded.AutoRenew || loaded.ACMEEnvironment != domain.ACMEEnvironmentProduction || loaded.DNSProvider != "cloudflare" {
t.Fatalf("更新后的域名内容不正确:%v,%+v", err, loaded)
}

View File

@ -6,7 +6,14 @@ server {
listen __HTTP_PORT__;
listen [::]:__HTTP_PORT__;
server_name __MANAGEMENT_HOST__;
return 308 __MANAGEMENT_ORIGIN__$request_uri;
# 不能使用 server 级 return,否则会先于挑战路径反代执行。
# 将 http-challenge.conf.example 替换占位符后保存到此绝对路径。
include __HTTP_CHALLENGE_SNIPPET_PATH__;
location / {
return 308 __MANAGEMENT_ORIGIN__$request_uri;
}
}
server {

View File

@ -0,0 +1,25 @@
# 替换占位符后,将本文件 include 到每个 HTTP-01 域名现有的 80 端口 server 块。
# 只匹配挑战路径;不要新增 default_server,不改动现有业务 location。
# server 级 return/rewrite 必须移到非挑战 location 中,避免抢先重定向。
location ^~ /.well-known/acme-challenge/ {
proxy_pass https://127.0.0.1:__UPSTREAM_PORT__;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header Connection '';
proxy_set_header Cookie '';
proxy_set_header Authorization '';
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto http;
proxy_ssl_server_name on;
proxy_ssl_name __MANAGEMENT_HOST__;
proxy_ssl_verify on;
proxy_ssl_verify_depth 3;
proxy_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt;
proxy_cache off;
proxy_intercept_errors off;
proxy_connect_timeout 5s;
proxy_read_timeout 10s;
proxy_send_timeout 10s;
# 挑战令牌不进入常规访问日志;应用层仍检查 Host、令牌与订单有效期。
access_log off;
}

View File

@ -0,0 +1,30 @@
# HTTP-01 验证部署说明
## 范围与前提
- 在托管域名的编辑窗口选择“HTTP-01(网站验证)”;历史域名和未指定方式的新域名默认 DNS-01。
- 单域名和不含泛域名的 SAN 证书可选择 HTTP-01。主域名或任何 SAN 包含泛域名均拒绝,不能自动降级或改用其他验证方式。
- 这是托管证书的验证方式,不改变管理后台首次初始化的 DNS 验证、管理证书信任或就绪检查要求。
- 域名的全部有效 A/AAAA 入口都必须将该域名 80 端口的挑战路径交给运行 Scert 的同一实例。多节点入口需统一反代到该实例,不能把挑战随机分配给独立进程。
- 不自动改动 DNS、不覆盖业务站点、不新增公网应用端口。证书推送服务器不是挑战服务,除非其对应站点明确反代挑战路径。
## Nginx 接入
1. 将 `deploy/nginx/http-challenge.conf.example` 中的 `__UPSTREAM_PORT__` 替换为 Scert 回环 HTTPS 端口,将 `__MANAGEMENT_HOST__` 替换为上游管理证书覆盖的域名。所有占位符需替换后再加载。
2. 将生成的片段放到仅管理员可写的绝对路径;在每个待签发域名**已有的 80 端口 server 块**中 include 此文件。没有站点的域名由管理员创建精确 server_name,不使用 default_server。
3. 不允许保留 server 级的重定向 return 或会提前改写挑战的 rewrite;将普通 HTTPS 跳转移到 `location /`。挑战自身不依赖申请目标域名已经有 HTTPS 证书。
4. 管理站点示例 `Scert.conf.example` 新增 `__HTTP_CHALLENGE_SNIPPET_PATH__`,填写上一步绝对路径。该示例只覆盖管理域名,不能当作所有托管域名已接入。
5. 保留原始请求 Host;TLS 的 SNI 单独使用管理域名。保留 `proxy_ssl_verify on` 和可信 CA,不通过关闭校验绕过上游错误。
6. 检查 Nginx 配置后重载。未知令牌应直接返回 404、无缓存、无 SPA 页面或重定向。实际订单必须通过正常公共 DNS 执行签发,固定 IP、hosts 映射及内部测试不能替代生产验收。
## 安全与生命周期
- 挑战按订单、域名和令牌隔离,只允许 GET/HEAD,不使用客户端的 X-Forwarded-Host 决定域名。
- 签发结束、失败、取消均清理订单;存储上限 4096 条,单挑战有效期 15 分钟。进程重启后不恢复过期挑战,未完成订单按既有失败与重试机制处理。
- 应用常规访问日志不记录挑战令牌,Nginx 片段关闭挑战访问日志;未知请求不回显敏感内容。
- HTTP-01 不读取或校验 DNS 凭据,但不会放开项目证书管理、部署、重试及执行前的实时授权。
- API 字段为 `challenge_type`,允许 `dns-01` 或 `http-01`。创建时缺省为 DNS-01;更新时省略字段保留现有方式;非法值拒绝。HTTP-01 保存时清空不适用的 `dns_provider`。
## 当前验收边界
2026年10月4日:代码已接入,前端正式测试 279 项通过、Linux 目标编译通过;Linux 后端回归、三库迁移实测、Nginx 实测、正式部署及公共域名签发仍待完成。不得据此关闭 P1-03 或整体验收。

View File

@ -1,5 +1,16 @@
# Scert 整改任务执行清单
## 2026年10月4日 HTTP-01 开发任务
沿用已确认的 P1-03 要求,仅处理 Linux 与 Web。开发前检查点 `e829d66` 已推送;不修改公共 DNS,不重复第二台推送测试。
- [x] 按订单隔离 HTTP 挑战:域名、令牌、归属校验,取消、失败、超时清理;接入正式应用路由。等待 Linux 回归。
- [x] 持久化验证方式,历史数据默认 DNS-01,三库新增迁移二十六;MySQL 中断恢复检查字段结构,待实库复审。
- [x] 接入正式签发、重试及前端选择;HTTP-01 不读取 DNS 凭据,保留项目权限;拒绝任意泛域名 SAN。前端正式测试 279/279 通过,后端待回归。
- [x] Nginx 80 挑战反代模板保留 Host 与严格上游 TLS,重定向移入普通路径,不新增外部端口、不接管其他站点;逐域名实际配置待验。
- [~] Linux 目标编译通过;第一台后端、隔离实库回归及正式部署待执行。真实域名签发单独验收,不以单测或内部路由代替。
- [!] 管理域名仍指第二台,切换尚待确认;Smail 系统通知授权、三数据库首次 Web 及整体生产门禁继续保留。
## 2026年10月4日02:03 DNS 隔离发布收尾任务
- [✓] 干净 `2513f93` 正式 Linux 包构建及两端摘要、包内文件摘要校验通过,仅上传第一台。

View File

@ -9,6 +9,7 @@ function emptyForm() {
auto_renew: true,
acme_environment: "staging",
dns_provider: "",
challenge_type: "dns-01",
server_ids: [],
};
}
@ -20,7 +21,8 @@ function domainPayload(form) {
sans: form.sans.split(/[\n,,]/).map((item) => item.trim()).filter(Boolean),
auto_renew: form.auto_renew,
acme_environment: form.acme_environment,
dns_provider: form.dns_provider,
dns_provider: form.challenge_type === "http-01" ? "" : form.dns_provider,
challenge_type: form.challenge_type || "dns-01",
};
}
@ -71,6 +73,7 @@ export function useDomainForm({ fetchAdmin, domains, servers, domainServers, not
auto_renew: item.auto_renew !== false,
acme_environment: item.acme_environment || "staging",
dns_provider: item.dns_provider || "",
challenge_type: item.challenge_type || "dns-01",
};
formError.value = "";
savedPayload = "";
@ -128,6 +131,13 @@ export function useDomainForm({ fetchAdmin, domains, servers, domainServers, not
return;
}
const payload = domainPayload(form.value);
if (payload.challenge_type === "http-01" && (
payload.certificate_type === "wildcard" ||
[payload.name, ...payload.sans].some((name) => name.trim().startsWith("*."))
)) {
formError.value = "HTTP-01 不支持泛域名,请选择 DNS-01";
return;
}
if (!payload.name.trim()) {
formError.value = "请输入主域名";
return;

View File

@ -32,6 +32,43 @@ function startCreate(state) {
state.form.value.server_ids = [firstServer.id, secondServer.id];
}
test("HTTP 验证保存方式且不携带 DNS 服务商", async (t) => {
const requests = [];
const { state } = await mountForm(t, async (path, options) => {
requests.push({ path, ...options });
if (path.endsWith("/servers")) return Response.json({ items: [] });
return Response.json({ ...firstDomain, ...JSON.parse(options.body) });
});
startCreate(state);
state.form.value.challenge_type = "http-01";
state.form.value.dns_provider = "aliyun";
await state.saveDomain();
assert.equal(JSON.parse(requests[0].body).challenge_type, "http-01");
assert.equal(JSON.parse(requests[0].body).dns_provider, "");
});
test("HTTP 验证拒绝主泛域名和附加泛域名,不提交请求", async (t) => {
let calls = 0;
const { state } = await mountForm(t, async () => { calls += 1; return Response.json({}); });
for (const type of ["wildcard", "san"]) {
startCreate(state);
state.form.value.challenge_type = "http-01";
state.form.value.certificate_type = type;
state.form.value.sans = "*.example.com";
await state.saveDomain();
assert.match(state.formError.value, /不支持泛域名/);
}
assert.equal(calls, 0);
});
test("编辑时保留已有 HTTP 验证方式", async (t) => {
const { state } = await mountForm(t, async () => Response.json({ items: [] }));
await state.openEdit({ ...firstDomain, challenge_type: "http-01" });
assert.equal(state.form.value.challenge_type, "http-01");
await state.openEdit(firstDomain);
assert.equal(state.form.value.challenge_type, "dns-01");
});
test("创建成功但关联冲突后只重试关联,不重复创建且不改变另一域名", async (t) => {
const requests = [];
let attempts = 0;

View File

@ -96,7 +96,19 @@ const {
<option value="production">生产环境</option>
</select></label
>
<label class="field" for="dns-provider"
<label class="field" for="challenge-type">
<span>验证方式 <HelpTip>HTTP-01 要求全部域名的公网 80 端口挑战路径反代到 Scert,不支持泛域名;其余业务请求保持原站点配置。</HelpTip></span>
<select
id="challenge-type"
name="challenge_type"
v-model="form.challenge_type"
:disabled="saving || domainSaveUncertain"
>
<option value="dns-01">DNS-01(DNS 验证)</option>
<option value="http-01">HTTP-01(网站验证)</option>
</select>
</label>
<label v-if="form.challenge_type !== 'http-01'" class="field" for="dns-provider"
><span>DNS 验证服务商</span
><select
id="dns-provider"