From 5128b25e88875f3b9e78396c8813dbd1c12a80c2 Mon Sep 17 00:00:00 2001 From: xuans Date: Sun, 4 Oct 2026 02:37:00 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=85=A5=E6=89=98=E7=AE=A1=E5=9F=9F?= =?UTF-8?q?=E5=90=8DHTTP=E9=AA=8C=E8=AF=81=E4=B8=8E=E9=9A=94=E7=A6=BB?= =?UTF-8?q?=E6=8C=91=E6=88=98=E5=B9=B6=E8=A1=A5=E5=85=85=E5=9B=9E=E5=BD=92?= =?UTF-8?q?=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/app/app.go | 6 +- backend/internal/certificate/dns_issue.go | 34 ++++ .../internal/certificate/http_issue_test.go | 91 +++++++++ backend/internal/certificate/lego.go | 21 ++- backend/internal/certificate/lifecycle.go | 1 + backend/internal/certificate/service.go | 84 +++++---- .../domain_challenge_migration_test.go | 91 +++++++++ backend/internal/database/migrate.go | 13 +- .../mysql/0026_domain_challenge.sql | 1 + .../postgres/0026_domain_challenge.sql | 1 + .../sqlite/0026_domain_challenge.sql | 1 + backend/internal/domain/challenge.go | 30 +++ backend/internal/domain/domain.go | 2 + backend/internal/httpapi/domain_challenge.go | 33 ++++ .../internal/httpapi/http_challenge_test.go | 112 ++++++++++++ backend/internal/httpapi/server.go | 50 +++-- backend/internal/httpapi/task_api.go | 16 +- .../httpapi/task_execution_authorization.go | 9 +- backend/internal/httpchallenge/handler.go | 53 ++++++ backend/internal/httpchallenge/store.go | 155 ++++++++++++++++ backend/internal/httpchallenge/store_test.go | 173 ++++++++++++++++++ .../repository/project_domain_repository.go | 18 +- .../project_domain_repository_test.go | 9 + .../internal/repository/project_resources.go | 2 +- .../repository/sql_domain_repository.go | 24 ++- .../repository/sql_domain_repository_test.go | 4 + deploy/nginx/Scert.conf.example | 9 +- deploy/nginx/http-challenge.conf.example | 25 +++ doc/HTTP验证部署说明.md | 30 +++ doc/Scert整改任务执行清单.md | 11 ++ frontend/src/views/Dashboard/DomainForm.js | 12 +- .../src/views/Dashboard/DomainForm.test.js | 37 ++++ frontend/src/views/Dashboard/DomainModal.vue | 14 +- 33 files changed, 1096 insertions(+), 76 deletions(-) create mode 100644 backend/internal/certificate/dns_issue.go create mode 100644 backend/internal/certificate/http_issue_test.go create mode 100644 backend/internal/database/domain_challenge_migration_test.go create mode 100644 backend/internal/database/migrations/mysql/0026_domain_challenge.sql create mode 100644 backend/internal/database/migrations/postgres/0026_domain_challenge.sql create mode 100644 backend/internal/database/migrations/sqlite/0026_domain_challenge.sql create mode 100644 backend/internal/domain/challenge.go create mode 100644 backend/internal/httpapi/domain_challenge.go create mode 100644 backend/internal/httpapi/http_challenge_test.go create mode 100644 backend/internal/httpchallenge/handler.go create mode 100644 backend/internal/httpchallenge/store.go create mode 100644 backend/internal/httpchallenge/store_test.go create mode 100644 deploy/nginx/http-challenge.conf.example create mode 100644 doc/HTTP验证部署说明.md diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index ad5787f..56fb463 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -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) }, diff --git a/backend/internal/certificate/dns_issue.go b/backend/internal/certificate/dns_issue.go new file mode 100644 index 0000000..d85bf52 --- /dev/null +++ b/backend/internal/certificate/dns_issue.go @@ -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 +} diff --git a/backend/internal/certificate/http_issue_test.go b/backend/internal/certificate/http_issue_test.go new file mode 100644 index 0000000..657bcac --- /dev/null +++ b/backend/internal/certificate/http_issue_test.go @@ -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("拒绝前产生签发副作用") + } +} diff --git a/backend/internal/certificate/lego.go b/backend/internal/certificate/lego.go index 8a97103..6c7deeb 100644 --- a/backend/internal/certificate/lego.go +++ b/backend/internal/certificate/lego.go @@ -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) diff --git a/backend/internal/certificate/lifecycle.go b/backend/internal/certificate/lifecycle.go index 4ef0b97..7cc4574 100644 --- a/backend/internal/certificate/lifecycle.go +++ b/backend/internal/certificate/lifecycle.go @@ -24,6 +24,7 @@ type Issuer interface { } type ObtainRequest struct { + ChallengeType string Domains []string AccountEmail string Environment string diff --git a/backend/internal/certificate/service.go b/backend/internal/certificate/service.go index 0dca5b8..3efdc5d 100644 --- a/backend/internal/certificate/service.go +++ b/backend/internal/certificate/service.go @@ -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 { diff --git a/backend/internal/database/domain_challenge_migration_test.go b/backend/internal/database/domain_challenge_migration_test.go new file mode 100644 index 0000000..8833ca6 --- /dev/null +++ b/backend/internal/database/domain_challenge_migration_test.go @@ -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("迁移版本记录错误") + } + }) + } +} diff --git a/backend/internal/database/migrate.go b/backend/internal/database/migrate.go index e9004a6..28944d1 100644 --- a/backend/internal/database/migrate.go +++ b/backend/internal/database/migrate.go @@ -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" } diff --git a/backend/internal/database/migrations/mysql/0026_domain_challenge.sql b/backend/internal/database/migrations/mysql/0026_domain_challenge.sql new file mode 100644 index 0000000..fc580e2 --- /dev/null +++ b/backend/internal/database/migrations/mysql/0026_domain_challenge.sql @@ -0,0 +1 @@ +ALTER TABLE domains ADD COLUMN challenge_type VARCHAR(16) NOT NULL DEFAULT 'dns-01'; diff --git a/backend/internal/database/migrations/postgres/0026_domain_challenge.sql b/backend/internal/database/migrations/postgres/0026_domain_challenge.sql new file mode 100644 index 0000000..fc580e2 --- /dev/null +++ b/backend/internal/database/migrations/postgres/0026_domain_challenge.sql @@ -0,0 +1 @@ +ALTER TABLE domains ADD COLUMN challenge_type VARCHAR(16) NOT NULL DEFAULT 'dns-01'; diff --git a/backend/internal/database/migrations/sqlite/0026_domain_challenge.sql b/backend/internal/database/migrations/sqlite/0026_domain_challenge.sql new file mode 100644 index 0000000..fc580e2 --- /dev/null +++ b/backend/internal/database/migrations/sqlite/0026_domain_challenge.sql @@ -0,0 +1 @@ +ALTER TABLE domains ADD COLUMN challenge_type VARCHAR(16) NOT NULL DEFAULT 'dns-01'; diff --git a/backend/internal/domain/challenge.go b/backend/internal/domain/challenge.go new file mode 100644 index 0000000..b7edc0c --- /dev/null +++ b/backend/internal/domain/challenge.go @@ -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 +} diff --git a/backend/internal/domain/domain.go b/backend/internal/domain/domain.go index 4394978..c854de0 100644 --- a/backend/internal/domain/domain.go +++ b/backend/internal/domain/domain.go @@ -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, diff --git a/backend/internal/httpapi/domain_challenge.go b/backend/internal/httpapi/domain_challenge.go new file mode 100644 index 0000000..6ed9124 --- /dev/null +++ b/backend/internal/httpapi/domain_challenge.go @@ -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) +} diff --git a/backend/internal/httpapi/http_challenge_test.go b/backend/internal/httpapi/http_challenge_test.go new file mode 100644 index 0000000..6abc3ee --- /dev/null +++ b/backend/internal/httpapi/http_challenge_test.go @@ -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) + } + }) + } +} diff --git a/backend/internal/httpapi/server.go b/backend/internal/httpapi/server.go index 4e586d5..6744bbe 100644 --- a/backend/internal/httpapi/server.go +++ b/backend/internal/httpapi/server.go @@ -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) { diff --git a/backend/internal/httpapi/task_api.go b/backend/internal/httpapi/task_api.go index dbcc932..7520d8e 100644 --- a/backend/internal/httpapi/task_api.go +++ b/backend/internal/httpapi/task_api.go @@ -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) diff --git a/backend/internal/httpapi/task_execution_authorization.go b/backend/internal/httpapi/task_execution_authorization.go index ffcba47..c771912 100644 --- a/backend/internal/httpapi/task_execution_authorization.go +++ b/backend/internal/httpapi/task_execution_authorization.go @@ -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) diff --git a/backend/internal/httpchallenge/handler.go b/backend/internal/httpchallenge/handler.go new file mode 100644 index 0000000..9499cd2 --- /dev/null +++ b/backend/internal/httpchallenge/handler.go @@ -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)) + } +} diff --git a/backend/internal/httpchallenge/store.go b/backend/internal/httpchallenge/store.go new file mode 100644 index 0000000..689a5e6 --- /dev/null +++ b/backend/internal/httpchallenge/store.go @@ -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) + } + } +} diff --git a/backend/internal/httpchallenge/store_test.go b/backend/internal/httpchallenge/store_test.go new file mode 100644 index 0000000..329ee4d --- /dev/null +++ b/backend/internal/httpchallenge/store_test.go @@ -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("并发订单清理后仍有残留") + } +} diff --git a/backend/internal/repository/project_domain_repository.go b/backend/internal/repository/project_domain_repository.go index 1b42ebd..8d04645 100644 --- a/backend/internal/repository/project_domain_repository.go +++ b/backend/internal/repository/project_domain_repository.go @@ -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) } diff --git a/backend/internal/repository/project_domain_repository_test.go b/backend/internal/repository/project_domain_repository_test.go index 8a3c8c8..19c052c 100644 --- a/backend/internal/repository/project_domain_repository_test.go +++ b/backend/internal/repository/project_domain_repository_test.go @@ -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}, diff --git a/backend/internal/repository/project_resources.go b/backend/internal/repository/project_resources.go index cd403a2..7803487 100644 --- a/backend/internal/repository/project_resources.go +++ b/backend/internal/repository/project_resources.go @@ -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 避免多角色账户重复返回资源,不按用户分组或全局普通管理员身份放行。 diff --git a/backend/internal/repository/sql_domain_repository.go b/backend/internal/repository/sql_domain_repository.go index 4be88fc..1e4a995 100644 --- a/backend/internal/repository/sql_domain_repository.go +++ b/backend/internal/repository/sql_domain_repository.go @@ -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 } diff --git a/backend/internal/repository/sql_domain_repository_test.go b/backend/internal/repository/sql_domain_repository_test.go index b067214..24e90af 100644 --- a/backend/internal/repository/sql_domain_repository_test.go +++ b/backend/internal/repository/sql_domain_repository_test.go @@ -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) } diff --git a/deploy/nginx/Scert.conf.example b/deploy/nginx/Scert.conf.example index 0e710b2..4aba5b9 100644 --- a/deploy/nginx/Scert.conf.example +++ b/deploy/nginx/Scert.conf.example @@ -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 { diff --git a/deploy/nginx/http-challenge.conf.example b/deploy/nginx/http-challenge.conf.example new file mode 100644 index 0000000..2ad4e22 --- /dev/null +++ b/deploy/nginx/http-challenge.conf.example @@ -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; +} diff --git a/doc/HTTP验证部署说明.md b/doc/HTTP验证部署说明.md new file mode 100644 index 0000000..d41a1ee --- /dev/null +++ b/doc/HTTP验证部署说明.md @@ -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 或整体验收。 diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md index f956b04..ec34052 100644 --- a/doc/Scert整改任务执行清单.md +++ b/doc/Scert整改任务执行清单.md @@ -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 包构建及两端摘要、包内文件摘要校验通过,仅上传第一台。 diff --git a/frontend/src/views/Dashboard/DomainForm.js b/frontend/src/views/Dashboard/DomainForm.js index cc7a017..abc180c 100644 --- a/frontend/src/views/Dashboard/DomainForm.js +++ b/frontend/src/views/Dashboard/DomainForm.js @@ -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; diff --git a/frontend/src/views/Dashboard/DomainForm.test.js b/frontend/src/views/Dashboard/DomainForm.test.js index 91f3d88..bb22141 100644 --- a/frontend/src/views/Dashboard/DomainForm.test.js +++ b/frontend/src/views/Dashboard/DomainForm.test.js @@ -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; diff --git a/frontend/src/views/Dashboard/DomainModal.vue b/frontend/src/views/Dashboard/DomainModal.vue index f6d4533..6d48e44 100644 --- a/frontend/src/views/Dashboard/DomainModal.vue +++ b/frontend/src/views/Dashboard/DomainModal.vue @@ -96,7 +96,19 @@ const { - +