接入托管域名HTTP验证与隔离挑战并补充回归用例
This commit is contained in:
parent
e829d665ca
commit
5128b25e88
@ -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)
|
||||
},
|
||||
|
||||
34
backend/internal/certificate/dns_issue.go
Normal file
34
backend/internal/certificate/dns_issue.go
Normal 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
|
||||
}
|
||||
91
backend/internal/certificate/http_issue_test.go
Normal file
91
backend/internal/certificate/http_issue_test.go
Normal 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("拒绝前产生签发副作用")
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -24,6 +24,7 @@ type Issuer interface {
|
||||
}
|
||||
|
||||
type ObtainRequest struct {
|
||||
ChallengeType string
|
||||
Domains []string
|
||||
AccountEmail string
|
||||
Environment string
|
||||
|
||||
@ -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 {
|
||||
|
||||
91
backend/internal/database/domain_challenge_migration_test.go
Normal file
91
backend/internal/database/domain_challenge_migration_test.go
Normal 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("迁移版本记录错误")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -0,0 +1 @@
|
||||
ALTER TABLE domains ADD COLUMN challenge_type VARCHAR(16) NOT NULL DEFAULT 'dns-01';
|
||||
@ -0,0 +1 @@
|
||||
ALTER TABLE domains ADD COLUMN challenge_type VARCHAR(16) NOT NULL DEFAULT 'dns-01';
|
||||
@ -0,0 +1 @@
|
||||
ALTER TABLE domains ADD COLUMN challenge_type VARCHAR(16) NOT NULL DEFAULT 'dns-01';
|
||||
30
backend/internal/domain/challenge.go
Normal file
30
backend/internal/domain/challenge.go
Normal 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
|
||||
}
|
||||
@ -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,
|
||||
|
||||
33
backend/internal/httpapi/domain_challenge.go
Normal file
33
backend/internal/httpapi/domain_challenge.go
Normal 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)
|
||||
}
|
||||
112
backend/internal/httpapi/http_challenge_test.go
Normal file
112
backend/internal/httpapi/http_challenge_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
53
backend/internal/httpchallenge/handler.go
Normal file
53
backend/internal/httpchallenge/handler.go
Normal 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))
|
||||
}
|
||||
}
|
||||
155
backend/internal/httpchallenge/store.go
Normal file
155
backend/internal/httpchallenge/store.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
173
backend/internal/httpchallenge/store_test.go
Normal file
173
backend/internal/httpchallenge/store_test.go
Normal 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("并发订单清理后仍有残留")
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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},
|
||||
|
||||
@ -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 避免多角色账户重复返回资源,不按用户分组或全局普通管理员身份放行。
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
25
deploy/nginx/http-challenge.conf.example
Normal file
25
deploy/nginx/http-challenge.conf.example
Normal 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;
|
||||
}
|
||||
30
doc/HTTP验证部署说明.md
Normal file
30
doc/HTTP验证部署说明.md
Normal 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 或整体验收。
|
||||
@ -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 包构建及两端摘要、包内文件摘要校验通过,仅上传第一台。
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user