接入管理证书续期调度并记录任务审计通知
This commit is contained in:
parent
5597dd7cc2
commit
c8c712131d
@ -134,15 +134,22 @@ func NewWithConfigPath(ctx context.Context, cfg config.Config, configPath string
|
||||
NotifyError: func(err error) { logger.Error("发送邮件通知失败", "error", err) },
|
||||
CurrentCertificate: certificates.Current,
|
||||
}
|
||||
renewal := certificate.NewRenewalSchedulerWithPolicy(repo, workflow, certificate.RenewalPolicy{
|
||||
var server *httpapi.Server
|
||||
management := &managementRenewal{
|
||||
readState: func() (httpapi.ManagementCertificateState, error) { return server.ManagementCertificate() },
|
||||
issue: certificates.IssueForSetup,
|
||||
tasks: taskRepo, audit: auditRepo, notifier: notifier,
|
||||
notifyError: func(err error) { logger.Error("管理证书续期或通知失败", "error", err) },
|
||||
}
|
||||
renewal := certificate.NewRenewalSchedulerWithManagement(repo, workflow, certificate.RenewalPolicy{
|
||||
CheckInterval: time.Duration(cfg.ACME.RenewalCheckIntervalDays) * 24 * time.Hour,
|
||||
NearExpiryThreshold: time.Duration(cfg.ACME.RenewalNearExpiryDays) * 24 * time.Hour,
|
||||
RenewalThreshold: time.Duration(cfg.ACME.RenewalThresholdDays) * 24 * time.Hour,
|
||||
UrgentThreshold: time.Duration(cfg.ACME.RenewalUrgentThresholdDays) * 24 * time.Hour,
|
||||
MaxRetries: cfg.ACME.RenewalMaxRetries,
|
||||
RetryInterval: time.Duration(cfg.ACME.RenewalRetryIntervalMins) * time.Minute,
|
||||
})
|
||||
server := httpapi.New(httpapi.Dependencies{
|
||||
}, management)
|
||||
server = httpapi.New(httpapi.Dependencies{
|
||||
Config: cfg, ConfigPath: configPath, Logger: logger, Repository: repo, Servers: serverRepo, Users: userRepo, SSHKeys: sshKeys,
|
||||
SetupDNS: dnsVerification, Secrets: secretStore, Firewall: firewall.NewManager(), AliyunChecker: aliyunAuthChecker,
|
||||
IssueCertificate: workflow.IssueForHTTP,
|
||||
@ -173,6 +180,7 @@ func NewWithConfigPath(ctx context.Context, cfg config.Config, configPath string
|
||||
Tasks: taskRepo,
|
||||
ReadinessCheck: db.DB.PingContext,
|
||||
})
|
||||
management.activator = directManagementActivator{server: server}
|
||||
application := &App{
|
||||
Config: cfg, Logger: logger, Database: db, Repository: repo,
|
||||
Servers: serverRepo,
|
||||
|
||||
258
backend/internal/app/management_renewal.go
Normal file
258
backend/internal/app/management_renewal.go
Normal file
@ -0,0 +1,258 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/certificate"
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/domain"
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/httpapi"
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/notification"
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/repository"
|
||||
)
|
||||
|
||||
const managementResourceID = "management-certificate"
|
||||
|
||||
type managementActivator interface {
|
||||
Ready(context.Context, httpapi.ManagementCertificateState) error
|
||||
Activate(context.Context, httpapi.ManagementCertificateState, certificate.SavedResource) error
|
||||
}
|
||||
|
||||
type managementRenewal struct {
|
||||
readState func() (httpapi.ManagementCertificateState, error)
|
||||
issue func(context.Context, certificate.SetupCertificateRequest) (certificate.SavedResource, error)
|
||||
activator managementActivator
|
||||
tasks repository.TaskRepository
|
||||
audit repository.AuditRepository
|
||||
notifier notification.Notifier
|
||||
notifyError func(error)
|
||||
wait func(context.Context, time.Duration) error
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (m *managementRenewal) RunRenewal(ctx context.Context, policy certificate.RenewalPolicy, now time.Time) (certificate.RenewalReport, error) {
|
||||
var report certificate.RenewalReport
|
||||
if m == nil || m.readState == nil || m.issue == nil || m.activator == nil || m.tasks == nil || m.audit == nil {
|
||||
return report, errors.New("管理证书续期服务未初始化")
|
||||
}
|
||||
if !m.mu.TryLock() {
|
||||
return report, errors.New("管理证书续期任务正在执行")
|
||||
}
|
||||
defer m.mu.Unlock()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return report, err
|
||||
}
|
||||
state, err := m.readState()
|
||||
// 公网 IP 自签证书不进入域名的 ACME DNS 续期流程。
|
||||
if state.Hostname == "" {
|
||||
return report, nil
|
||||
}
|
||||
report.Checked = 1
|
||||
if err == nil && state.NotAfter.IsZero() {
|
||||
err = errors.New("管理证书到期时间无效")
|
||||
}
|
||||
if err == nil {
|
||||
remaining := state.NotAfter.Sub(now)
|
||||
if remaining <= policy.NearExpiryThreshold {
|
||||
report.NearExpiry = 1
|
||||
}
|
||||
if remaining > policy.RenewalThreshold {
|
||||
return report, nil
|
||||
}
|
||||
report.Selected = 1
|
||||
if remaining <= policy.UrgentThreshold {
|
||||
report.Urgent = 1
|
||||
}
|
||||
}
|
||||
saved, err := m.renew(ctx, state, policy, report.Urgent != 0, err)
|
||||
if err != nil {
|
||||
report.Failed = 1
|
||||
report.Errors = []string{state.Hostname + ":管理证书续期未完成,请检查任务记录"}
|
||||
if m.notifyError != nil {
|
||||
m.notifyError(err)
|
||||
}
|
||||
} else {
|
||||
report.Succeeded = 1
|
||||
}
|
||||
m.notify(ctx, state, saved, err)
|
||||
return report, err
|
||||
}
|
||||
|
||||
func (m *managementRenewal) renew(ctx context.Context, state httpapi.ManagementCertificateState, policy certificate.RenewalPolicy, urgent bool, stateErr error) (saved certificate.SavedResource, resultErr error) {
|
||||
started := time.Now().UTC()
|
||||
task := repository.Task{
|
||||
ID: domain.NewID(), DomainID: managementResourceID, TaskType: "management_renewal",
|
||||
Status: "running", Stage: "preflight", Message: "正在检查管理证书续期条件",
|
||||
StartedAt: &started, CreatedAt: started,
|
||||
}
|
||||
if err := m.tasks.CreateTask(ctx, task); err != nil {
|
||||
return saved, err
|
||||
}
|
||||
stage := "preflight"
|
||||
defer func() {
|
||||
// 取消请求或关闭服务后仍需保存终态,但不无限等待数据库。
|
||||
finishCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
|
||||
defer cancel()
|
||||
status, message := "success", "管理证书续期及入口激活完成"
|
||||
if resultErr != nil {
|
||||
status, message = "failed", "管理证书续期未完成,请检查服务日志"
|
||||
if errors.Is(resultErr, context.Canceled) {
|
||||
status, message = "cancelled", "管理证书续期已取消"
|
||||
}
|
||||
}
|
||||
finished := time.Now().UTC()
|
||||
taskErr := m.tasks.UpdateTask(finishCtx, task.ID, status, stage, message, &started, &finished)
|
||||
auditErr := m.audit.CreateAuditLog(finishCtx, repository.AuditLog{
|
||||
Action: "management_renewal", ResourceType: "management_certificate", ResourceID: managementResourceID,
|
||||
Status: status, Message: state.Hostname + ":" + message,
|
||||
})
|
||||
resultErr = errors.Join(resultErr, taskErr, auditErr)
|
||||
}()
|
||||
if err := m.audit.CreateAuditLog(ctx, repository.AuditLog{
|
||||
Action: "management_renewal", ResourceType: "management_certificate", ResourceID: managementResourceID,
|
||||
Status: "running", Message: state.Hostname + ":开始管理证书续期",
|
||||
}); err != nil {
|
||||
return saved, err
|
||||
}
|
||||
if stateErr != nil {
|
||||
return saved, fmt.Errorf("读取管理证书续期状态失败:%w", stateErr)
|
||||
}
|
||||
if !state.DNSVerified {
|
||||
return saved, errors.New("管理证书 DNS 凭据尚未验证")
|
||||
}
|
||||
if err := m.activator.Ready(ctx, state); err != nil {
|
||||
return saved, err
|
||||
}
|
||||
attempts := 1
|
||||
if urgent && policy.MaxRetries > 1 {
|
||||
attempts = policy.MaxRetries
|
||||
}
|
||||
for number := 1; number <= attempts; number++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return saved, err
|
||||
}
|
||||
saved, stage, resultErr = m.attempt(ctx, task, state, number)
|
||||
// 激活和持久化失败不重复向 CA 申请,候选文件保留供诊断或回退。
|
||||
if resultErr == nil || stage != "issue" || number == attempts {
|
||||
return saved, resultErr
|
||||
}
|
||||
stage = "retry_wait"
|
||||
if err := m.tasks.UpdateTask(ctx, task.ID, "running", stage, "管理证书申请失败,等待重试", &started, nil); err != nil {
|
||||
return saved, errors.Join(resultErr, err)
|
||||
}
|
||||
wait := m.wait
|
||||
if wait == nil {
|
||||
wait = waitForManagementRetry
|
||||
}
|
||||
if err := wait(ctx, policy.RetryInterval); err != nil {
|
||||
return saved, errors.Join(resultErr, err)
|
||||
}
|
||||
}
|
||||
return saved, resultErr
|
||||
}
|
||||
|
||||
func (m *managementRenewal) attempt(ctx context.Context, task repository.Task, state httpapi.ManagementCertificateState, number int) (saved certificate.SavedResource, stage string, resultErr error) {
|
||||
stage = "record"
|
||||
started := time.Now().UTC()
|
||||
attempt := repository.TaskAttempt{
|
||||
ID: domain.NewID(), TaskID: task.ID, AttemptNumber: number, Stage: "management_renewal",
|
||||
Status: "running", Message: "正在申请并激活管理证书", StartedAt: &started, CreatedAt: started,
|
||||
}
|
||||
if err := m.tasks.CreateAttempt(ctx, attempt); err != nil {
|
||||
return saved, stage, err
|
||||
}
|
||||
defer func() {
|
||||
finishCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
|
||||
defer cancel()
|
||||
finished := time.Now().UTC()
|
||||
status, message := "success", "管理证书已激活"
|
||||
if resultErr != nil {
|
||||
status, message = "failed", "管理证书处理失败,阶段:"+stage
|
||||
if errors.Is(resultErr, context.Canceled) {
|
||||
status = "cancelled"
|
||||
}
|
||||
}
|
||||
if err := m.tasks.FinishAttempt(finishCtx, attempt.ID, status, message, &finished); err != nil {
|
||||
stage = "record"
|
||||
resultErr = errors.Join(resultErr, err)
|
||||
}
|
||||
}()
|
||||
if err := m.tasks.UpdateTask(ctx, task.ID, "running", "issue", "正在申请管理证书", task.StartedAt, nil); err != nil {
|
||||
return saved, stage, err
|
||||
}
|
||||
stage = "issue"
|
||||
issueCtx, cancel := context.WithTimeout(ctx, 30*time.Minute)
|
||||
saved, resultErr = m.issue(issueCtx, state.Request)
|
||||
cancel()
|
||||
if resultErr != nil {
|
||||
return saved, stage, resultErr
|
||||
}
|
||||
stage = "activate"
|
||||
if err := ctx.Err(); err != nil {
|
||||
return saved, stage, err
|
||||
}
|
||||
if !saved.NotAfter.After(state.NotAfter) {
|
||||
return saved, stage, errors.New("候选管理证书未延长有效期,已拒绝激活")
|
||||
}
|
||||
if err := m.tasks.UpdateTask(ctx, task.ID, "running", stage, "正在激活管理证书", task.StartedAt, nil); err != nil {
|
||||
return saved, stage, err
|
||||
}
|
||||
return saved, stage, m.activator.Activate(ctx, state, saved)
|
||||
}
|
||||
|
||||
func (m *managementRenewal) notify(ctx context.Context, state httpapi.ManagementCertificateState, saved certificate.SavedResource, renewalErr error) {
|
||||
if m.notifier == nil {
|
||||
return
|
||||
}
|
||||
event := notification.Event{
|
||||
Type: "management_renewal", Status: "success", DomainID: managementResourceID,
|
||||
Domain: state.Hostname, Domains: []string{state.Hostname}, ManagementURL: state.ManagementURL,
|
||||
ExpiresAt: &saved.NotAfter, CertificateSerial: saved.SerialNumber,
|
||||
}
|
||||
if renewalErr != nil {
|
||||
event.Status = "failed"
|
||||
event.Error = "管理证书续期未完成,请检查任务记录和服务日志"
|
||||
event.ExpiresAt = &state.NotAfter
|
||||
event.CertificateSerial = ""
|
||||
}
|
||||
notifyCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Minute)
|
||||
defer cancel()
|
||||
if err := m.notifier.Send(notifyCtx, event); err != nil && m.notifyError != nil {
|
||||
m.notifyError(fmt.Errorf("管理证书通知发送失败:%w", err))
|
||||
}
|
||||
}
|
||||
|
||||
func waitForManagementRetry(ctx context.Context, interval time.Duration) error {
|
||||
timer := time.NewTimer(interval)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
type directManagementActivator struct {
|
||||
server *httpapi.Server
|
||||
}
|
||||
|
||||
func (a directManagementActivator) Ready(ctx context.Context, state httpapi.ManagementCertificateState) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if state.HTTPSMode != "direct" {
|
||||
return errors.New("管理证书代理激活器尚未配置,已阻止申请和上游单独切换")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a directManagementActivator) Activate(ctx context.Context, state httpapi.ManagementCertificateState, saved certificate.SavedResource) error {
|
||||
if err := a.Ready(ctx, state); err != nil {
|
||||
return err
|
||||
}
|
||||
return a.server.ActivateManagementCertificate(state, saved)
|
||||
}
|
||||
374
backend/internal/app/management_renewal_test.go
Normal file
374
backend/internal/app/management_renewal_test.go
Normal file
@ -0,0 +1,374 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/certificate"
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/httpapi"
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/notification"
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/repository"
|
||||
)
|
||||
|
||||
type managementTestActivator struct {
|
||||
readyErr error
|
||||
activateErr error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (a *managementTestActivator) Ready(context.Context, httpapi.ManagementCertificateState) error {
|
||||
return a.readyErr
|
||||
}
|
||||
|
||||
func (a *managementTestActivator) Activate(context.Context, httpapi.ManagementCertificateState, certificate.SavedResource) error {
|
||||
a.calls++
|
||||
return a.activateErr
|
||||
}
|
||||
|
||||
type managementTestNotifier struct {
|
||||
events []notification.Event
|
||||
err error
|
||||
}
|
||||
|
||||
func (n *managementTestNotifier) Send(_ context.Context, event notification.Event) error {
|
||||
n.events = append(n.events, event)
|
||||
return n.err
|
||||
}
|
||||
|
||||
type managementFixture struct {
|
||||
m *managementRenewal
|
||||
app *App
|
||||
state httpapi.ManagementCertificateState
|
||||
policy certificate.RenewalPolicy
|
||||
activator *managementTestActivator
|
||||
notifier *managementTestNotifier
|
||||
now time.Time
|
||||
issues int
|
||||
}
|
||||
|
||||
func newManagementFixture(t *testing.T) *managementFixture {
|
||||
t.Helper()
|
||||
cfg := readyApplicationConfig(t)
|
||||
a, err := New(t.Context(), cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = a.Close() })
|
||||
f := &managementFixture{
|
||||
app: a, now: time.Now().UTC(), activator: &managementTestActivator{}, notifier: &managementTestNotifier{},
|
||||
policy: certificate.RenewalPolicy{
|
||||
NearExpiryThreshold: 10 * 24 * time.Hour, RenewalThreshold: 6 * 24 * time.Hour,
|
||||
UrgentThreshold: 3 * 24 * time.Hour, MaxRetries: 3, RetryInterval: time.Nanosecond,
|
||||
},
|
||||
}
|
||||
f.state = httpapi.ManagementCertificateState{
|
||||
Hostname: "random-admin.example.test", NotAfter: f.now.Add(4 * 24 * time.Hour),
|
||||
HTTPSMode: "direct", DNSVerified: true, ManagementURL: "https://random-admin.example.test/",
|
||||
Request: certificate.SetupCertificateRequest{
|
||||
Domains: []string{"random-admin.example.test"}, CertificateType: "single",
|
||||
ProviderID: "aliyun", CredentialRef: "secret://dns/test", Environment: "production",
|
||||
},
|
||||
}
|
||||
f.m = &managementRenewal{
|
||||
readState: func() (httpapi.ManagementCertificateState, error) { return f.state, nil },
|
||||
issue: func(ctx context.Context, request certificate.SetupCertificateRequest) (certificate.SavedResource, error) {
|
||||
if ctx.Err() != nil || len(request.Domains) != 1 || request.Domains[0] != f.state.Hostname || request.CredentialRef != f.state.Request.CredentialRef {
|
||||
t.Fatalf("签发请求未使用当前管理配置:%+v", request)
|
||||
}
|
||||
f.issues++
|
||||
return certificate.SavedResource{NotAfter: f.now.Add(90 * 24 * time.Hour), SerialNumber: "123"}, nil
|
||||
},
|
||||
activator: f.activator, tasks: a.Tasks,
|
||||
audit: repository.NewSQLAuditRepository(a.Database.DB), notifier: f.notifier,
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *managementFixture) assertTask(t *testing.T, status string, attempts int) repository.Task {
|
||||
t.Helper()
|
||||
items, err := f.app.Tasks.ListTasks(t.Context(), managementResourceID, 10)
|
||||
if err != nil || len(items) != 1 {
|
||||
t.Fatalf("管理任务未独立持久化:%+v %v", items, err)
|
||||
}
|
||||
item := items[0]
|
||||
if item.Status != status || item.FinishedAt == nil || item.TaskType != "management_renewal" {
|
||||
t.Fatalf("管理任务终态异常:%+v", item)
|
||||
}
|
||||
trials, err := f.app.Tasks.ListAttempts(t.Context(), item.ID)
|
||||
if err != nil || len(trials) != attempts {
|
||||
t.Fatalf("管理任务尝试异常:%+v %v", trials, err)
|
||||
}
|
||||
for _, trial := range trials {
|
||||
if trial.Status == "running" || trial.FinishedAt == nil {
|
||||
t.Fatalf("尝试未收尾:%+v", trial)
|
||||
}
|
||||
}
|
||||
var count int
|
||||
if err := f.app.Database.DB.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM audit_logs WHERE action = ? AND status = ?", "management_renewal", status).Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("管理续期审计缺失:%d %v", count, err)
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func TestManagementRenewalPersistsWithoutBusinessDomain(t *testing.T) {
|
||||
f := newManagementFixture(t)
|
||||
report, err := f.m.RunRenewal(t.Context(), f.policy, f.now)
|
||||
if err != nil || report.Checked != 1 || report.Selected != 1 || report.Succeeded != 1 || f.issues != 1 || f.activator.calls != 1 {
|
||||
t.Fatalf("管理续期未完成:%+v %v", report, err)
|
||||
}
|
||||
f.assertTask(t, "success", 1)
|
||||
items, err := f.app.Repository.List(t.Context())
|
||||
if err != nil || len(items) != 0 {
|
||||
t.Fatalf("管理续期不得创建业务域名:%+v %v", items, err)
|
||||
}
|
||||
if len(f.notifier.events) != 1 || f.notifier.events[0].Domain != f.state.Hostname || f.notifier.events[0].CertificateSerial != "123" || f.notifier.events[0].ManagementURL != f.state.ManagementURL {
|
||||
t.Fatalf("管理续期通知字段不完整:%+v", f.notifier.events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagementRenewalSkipsFreshAndIPCertificates(t *testing.T) {
|
||||
for _, name := range []string{"fresh", "ip", "near"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
f := newManagementFixture(t)
|
||||
f.state.NotAfter = f.now.Add(40 * 24 * time.Hour)
|
||||
if name == "ip" {
|
||||
f.state.Hostname = ""
|
||||
}
|
||||
if name == "near" {
|
||||
f.state.NotAfter = f.now.Add(8 * 24 * time.Hour)
|
||||
}
|
||||
report, err := f.m.RunRenewal(t.Context(), f.policy, f.now)
|
||||
if err != nil || report.Selected != 0 || f.issues != 0 || f.activator.calls != 0 || len(f.notifier.events) != 0 {
|
||||
t.Fatalf("不应执行签发:%+v %v", report, err)
|
||||
}
|
||||
if name == "near" && report.NearExpiry != 1 {
|
||||
t.Fatal("临期证书未纳入每日检查")
|
||||
}
|
||||
items, err := f.app.Tasks.ListTasks(t.Context(), "", 10)
|
||||
if err != nil || len(items) != 0 {
|
||||
t.Fatalf("跳过时不应产生任务:%v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagementRenewalPreflightRejectsBeforeIssuance(t *testing.T) {
|
||||
for _, name := range []string{"dns", "proxy"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
f := newManagementFixture(t)
|
||||
if name == "dns" {
|
||||
f.state.DNSVerified = false
|
||||
} else {
|
||||
f.state.HTTPSMode = "proxy"
|
||||
f.m.activator = directManagementActivator{server: f.app.HTTP}
|
||||
}
|
||||
report, err := f.m.RunRenewal(t.Context(), f.policy, f.now)
|
||||
if err == nil || report.Failed != 1 || f.issues != 0 || f.activator.calls != 0 {
|
||||
t.Fatalf("前置检查失败仍执行了签发:%+v %v", report, err)
|
||||
}
|
||||
f.assertTask(t, "failed", 0)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagementRenewalRetriesOnlyIssuance(t *testing.T) {
|
||||
for _, name := range []string{"recover", "exhaust", "activation", "shorter"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
f := newManagementFixture(t)
|
||||
f.state.NotAfter = f.now.Add(2 * 24 * time.Hour)
|
||||
baseIssue := f.m.issue
|
||||
calls := 0
|
||||
f.m.issue = func(ctx context.Context, request certificate.SetupCertificateRequest) (certificate.SavedResource, error) {
|
||||
calls++
|
||||
if name == "exhaust" || (name == "recover" && calls < 3) {
|
||||
return certificate.SavedResource{}, errors.New("测试凭据不得出现在通知中")
|
||||
}
|
||||
saved, err := baseIssue(ctx, request)
|
||||
if name == "shorter" {
|
||||
saved.NotAfter = f.state.NotAfter
|
||||
}
|
||||
return saved, err
|
||||
}
|
||||
if name == "activation" {
|
||||
f.activator.activateErr = errors.New("模拟代理激活失败")
|
||||
}
|
||||
report, err := f.m.RunRenewal(t.Context(), f.policy, f.now)
|
||||
wantAttempts, status := 1, "failed"
|
||||
if name == "recover" || name == "exhaust" {
|
||||
wantAttempts = 3
|
||||
}
|
||||
if name == "recover" {
|
||||
status = "success"
|
||||
}
|
||||
if calls != wantAttempts || (err == nil) != (status == "success") || report.Urgent != 1 {
|
||||
t.Fatalf("重试范围错误:calls=%d report=%+v err=%v", calls, report, err)
|
||||
}
|
||||
f.assertTask(t, status, wantAttempts)
|
||||
if len(f.notifier.events) != 1 || f.notifier.events[0].Status != status || strings.Contains(f.notifier.events[0].Error, "测试凭据") {
|
||||
t.Fatalf("通知状态错误或泄露错误细节:%+v", f.notifier.events)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagementRenewalCancellationPersistsTerminalState(t *testing.T) {
|
||||
for _, name := range []string{"issue", "retry_wait"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
f := newManagementFixture(t)
|
||||
f.state.NotAfter = f.now.Add(time.Hour)
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
f.m.issue = func(context.Context, certificate.SetupCertificateRequest) (certificate.SavedResource, error) {
|
||||
f.issues++
|
||||
if name == "issue" {
|
||||
cancel()
|
||||
return certificate.SavedResource{}, ctx.Err()
|
||||
}
|
||||
return certificate.SavedResource{}, errors.New("模拟签发失败")
|
||||
}
|
||||
f.m.wait = func(ctx context.Context, _ time.Duration) error { cancel(); return ctx.Err() }
|
||||
report, err := f.m.RunRenewal(ctx, f.policy, f.now)
|
||||
if !errors.Is(err, context.Canceled) || report.Failed != 1 || f.issues != 1 || f.activator.calls != 0 {
|
||||
t.Fatalf("取消后仍继续执行:%+v %v", report, err)
|
||||
}
|
||||
f.assertTask(t, "cancelled", 1)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type managementFailTasks struct {
|
||||
repository.TaskRepository
|
||||
failure string
|
||||
}
|
||||
|
||||
func (r managementFailTasks) CreateTask(ctx context.Context, item repository.Task) error {
|
||||
if r.failure == "create" {
|
||||
return errors.New("模拟任务写入失败")
|
||||
}
|
||||
return r.TaskRepository.CreateTask(ctx, item)
|
||||
}
|
||||
|
||||
func (r managementFailTasks) CreateAttempt(ctx context.Context, item repository.TaskAttempt) error {
|
||||
if r.failure == "attempt" {
|
||||
return errors.New("模拟尝试写入失败")
|
||||
}
|
||||
return r.TaskRepository.CreateAttempt(ctx, item)
|
||||
}
|
||||
|
||||
func TestManagementRenewalDoesNotIssueWithoutPersistentTask(t *testing.T) {
|
||||
for _, failure := range []string{"create", "attempt"} {
|
||||
t.Run(failure, func(t *testing.T) {
|
||||
f := newManagementFixture(t)
|
||||
f.m.tasks = managementFailTasks{TaskRepository: f.app.Tasks, failure: failure}
|
||||
report, err := f.m.RunRenewal(t.Context(), f.policy, f.now)
|
||||
if err == nil || report.Failed != 1 || f.issues != 0 || f.activator.calls != 0 {
|
||||
t.Fatalf("无任务记录仍签发:%+v %v", report, err)
|
||||
}
|
||||
if failure == "attempt" {
|
||||
f.assertTask(t, "failed", 0)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagementRenewalRejectsConcurrentRuns(t *testing.T) {
|
||||
f := newManagementFixture(t)
|
||||
f.m.mu.Lock()
|
||||
defer f.m.mu.Unlock()
|
||||
if _, err := f.m.RunRenewal(t.Context(), f.policy, f.now); err == nil || f.issues != 0 {
|
||||
t.Fatal("未拒绝重复续期")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagementRenewalNotificationFailureDoesNotRepeatIssuance(t *testing.T) {
|
||||
f := newManagementFixture(t)
|
||||
f.notifier.err = errors.New("模拟邮件发送失败")
|
||||
logged := false
|
||||
f.m.notifyError = func(error) { logged = true }
|
||||
report, err := f.m.RunRenewal(t.Context(), f.policy, f.now)
|
||||
if err != nil || report.Succeeded != 1 || !logged || f.issues != 1 {
|
||||
t.Fatalf("通知失败不应重复签发:%+v %v", report, err)
|
||||
}
|
||||
f.assertTask(t, "success", 1)
|
||||
}
|
||||
|
||||
func TestManagementRenewalRecordsUnreadableCertificate(t *testing.T) {
|
||||
f := newManagementFixture(t)
|
||||
f.m.readState = func() (httpapi.ManagementCertificateState, error) {
|
||||
return f.state, errors.New("模拟管理证书文件读取失败")
|
||||
}
|
||||
logged := false
|
||||
f.m.notifyError = func(error) { logged = true }
|
||||
report, err := f.m.RunRenewal(t.Context(), f.policy, f.now)
|
||||
if err == nil || report.Checked != 1 || report.Failed != 1 || !logged || f.issues != 0 {
|
||||
t.Fatalf("读取错误没有持久化失败证据:%+v %v", report, err)
|
||||
}
|
||||
f.assertTask(t, "failed", 0)
|
||||
if len(f.notifier.events) != 1 || f.notifier.events[0].Status != "failed" {
|
||||
t.Fatal("证书读取失败没有通知")
|
||||
}
|
||||
}
|
||||
|
||||
type managementFailAudit struct {
|
||||
repository.AuditRepository
|
||||
}
|
||||
|
||||
func (r managementFailAudit) CreateAuditLog(ctx context.Context, item repository.AuditLog) error {
|
||||
if item.Status == "running" {
|
||||
return errors.New("模拟审计写入失败")
|
||||
}
|
||||
return r.AuditRepository.CreateAuditLog(ctx, item)
|
||||
}
|
||||
|
||||
func TestManagementRenewalRejectsBeforeSideEffectsOnAuditFailure(t *testing.T) {
|
||||
f := newManagementFixture(t)
|
||||
f.m.audit = managementFailAudit{AuditRepository: f.m.audit}
|
||||
report, err := f.m.RunRenewal(t.Context(), f.policy, f.now)
|
||||
if err == nil || report.Failed != 1 || f.issues != 0 || f.activator.calls != 0 {
|
||||
t.Fatalf("审计失败后仍签发:%+v %v", report, err)
|
||||
}
|
||||
f.assertTask(t, "failed", 0)
|
||||
}
|
||||
|
||||
type managementFailFinish struct {
|
||||
repository.TaskRepository
|
||||
}
|
||||
|
||||
func (r managementFailFinish) FinishAttempt(context.Context, string, string, string, *time.Time) error {
|
||||
return errors.New("模拟尝试终态写入失败")
|
||||
}
|
||||
|
||||
func TestManagementRenewalReportsPersistenceFailureWithoutAnotherIssue(t *testing.T) {
|
||||
f := newManagementFixture(t)
|
||||
f.state.NotAfter = f.now.Add(time.Hour)
|
||||
f.m.tasks = managementFailFinish{TaskRepository: f.app.Tasks}
|
||||
report, err := f.m.RunRenewal(t.Context(), f.policy, f.now)
|
||||
if err == nil || report.Succeeded != 0 || report.Failed != 1 || f.issues != 1 || f.activator.calls != 1 {
|
||||
t.Fatalf("记录失败被误报为成功或重复签发:%+v %v", report, err)
|
||||
}
|
||||
items, err := f.app.Tasks.ListTasks(t.Context(), managementResourceID, 10)
|
||||
if err != nil || len(items) != 1 || items[0].Status != "failed" || items[0].Stage != "record" {
|
||||
t.Fatalf("持久化失败没有失败终态:%+v %v", items, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplicationWiresManagementRenewalEvenWithEmptyBusinessList(t *testing.T) {
|
||||
cfg := readyApplicationConfig(t)
|
||||
a, err := New(t.Context(), cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = a.Close() })
|
||||
// 此配置没有管理证书文件,应记录读取失败,不能假装空列表全部成功。
|
||||
report, err := a.Renewal.RunOnce(t.Context())
|
||||
if err == nil || report.Checked != 1 || report.Failed != 1 {
|
||||
t.Fatalf("应用仍未接入管理续期:%+v %v", report, err)
|
||||
}
|
||||
items, err := a.Tasks.ListTasks(t.Context(), managementResourceID, 10)
|
||||
if err != nil || len(items) != 1 || items[0].Status != "failed" {
|
||||
t.Fatalf("应用管理续期没有任务:%+v %v", items, err)
|
||||
}
|
||||
}
|
||||
101
backend/internal/certificate/management_renewal_test.go
Normal file
101
backend/internal/certificate/management_renewal_test.go
Normal file
@ -0,0 +1,101 @@
|
||||
package certificate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/domain"
|
||||
)
|
||||
|
||||
type managementRenewalFunc func(context.Context, RenewalPolicy, time.Time) (RenewalReport, error)
|
||||
|
||||
func (f managementRenewalFunc) RunRenewal(ctx context.Context, policy RenewalPolicy, now time.Time) (RenewalReport, error) {
|
||||
return f(ctx, policy, now)
|
||||
}
|
||||
|
||||
func TestRenewalSchedulerChecksManagementWithoutBusinessDomains(t *testing.T) {
|
||||
calls := 0
|
||||
management := managementRenewalFunc(func(_ context.Context, policy RenewalPolicy, now time.Time) (RenewalReport, error) {
|
||||
calls++
|
||||
if policy.MaxRetries != 3 || now.IsZero() {
|
||||
t.Fatal("管理续期未收到当前策略和时间")
|
||||
}
|
||||
return RenewalReport{Checked: 1, Selected: 1, Succeeded: 1}, nil
|
||||
})
|
||||
s := NewRenewalSchedulerWithManagement(renewalLister{}, &renewalService{}, RenewalPolicy{}, management)
|
||||
report, err := s.RunOnce(t.Context())
|
||||
if err != nil || calls != 1 || report.Checked != 1 || report.Succeeded != 1 {
|
||||
t.Fatalf("空业务列表漏掉管理证书:%+v %v", report, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewalSchedulerKeepsBusinessRenewalAfterManagementFailure(t *testing.T) {
|
||||
managementErr := errors.New("模拟管理证书读取失败")
|
||||
management := managementRenewalFunc(func(context.Context, RenewalPolicy, time.Time) (RenewalReport, error) {
|
||||
return RenewalReport{Checked: 1, Selected: 1, Failed: 1}, managementErr
|
||||
})
|
||||
issuer := &renewalService{}
|
||||
s := NewRenewalSchedulerWithManagement(renewalLister{items: []domain.Domain{{ID: "business", AutoRenew: true}}}, issuer, RenewalPolicy{}, management)
|
||||
report, err := s.RunOnce(t.Context())
|
||||
if !errors.Is(err, managementErr) || report.Checked != 2 || report.Succeeded != 1 || report.Failed != 1 || len(issuer.ids) != 1 {
|
||||
t.Fatalf("管理错误阻断业务续期:%+v %v", report, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewalSchedulerChecksManagementBeforeFailedBusinessQuery(t *testing.T) {
|
||||
management := managementRenewalFunc(func(context.Context, RenewalPolicy, time.Time) (RenewalReport, error) {
|
||||
return RenewalReport{Checked: 1, Selected: 1, Succeeded: 1}, nil
|
||||
})
|
||||
s := NewRenewalSchedulerWithManagement(renewalLister{err: errors.New("模拟业务查询失败")}, &renewalService{}, RenewalPolicy{}, management)
|
||||
report, err := s.RunOnce(t.Context())
|
||||
if err == nil || report.Succeeded != 1 {
|
||||
t.Fatalf("业务查询失败导致遗漏管理续期:%+v %v", report, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewalSchedulerStopsManagementBeforeClosing(t *testing.T) {
|
||||
started := make(chan struct{})
|
||||
stopped := make(chan struct{})
|
||||
management := managementRenewalFunc(func(ctx context.Context, _ RenewalPolicy, _ time.Time) (RenewalReport, error) {
|
||||
close(started)
|
||||
<-ctx.Done()
|
||||
close(stopped)
|
||||
return RenewalReport{}, ctx.Err()
|
||||
})
|
||||
s := NewRenewalSchedulerWithManagement(renewalLister{}, &renewalService{}, RenewalPolicy{}, management)
|
||||
s.Start(t.Context())
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("管理调度未启动")
|
||||
}
|
||||
s.Stop()
|
||||
select {
|
||||
case <-stopped:
|
||||
default:
|
||||
t.Fatal("停止调度未等待管理任务退出")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewalSchedulerDoesNotLengthenShortCheckInterval(t *testing.T) {
|
||||
calls := make(chan struct{}, 2)
|
||||
management := managementRenewalFunc(func(context.Context, RenewalPolicy, time.Time) (RenewalReport, error) {
|
||||
select {
|
||||
case calls <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return RenewalReport{Checked: 1, NearExpiry: 1}, nil
|
||||
})
|
||||
s := NewRenewalSchedulerWithManagement(renewalLister{}, &renewalService{}, RenewalPolicy{CheckInterval: 10 * time.Millisecond}, management)
|
||||
s.Start(t.Context())
|
||||
t.Cleanup(s.Stop)
|
||||
for i := 0; i < 2; i++ {
|
||||
select {
|
||||
case <-calls:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("临期规则不应将较短检查间隔延长为一天")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -18,6 +18,11 @@ type CertificateIssuer interface {
|
||||
Issue(ctx context.Context, domainID string) (IssueResult, error)
|
||||
}
|
||||
|
||||
// ManagementRenewalCheck 独立检查管理入口,不将其伪装为业务域名。
|
||||
type ManagementRenewalCheck interface {
|
||||
RunRenewal(context.Context, RenewalPolicy, time.Time) (RenewalReport, error)
|
||||
}
|
||||
|
||||
type RenewalPolicy struct {
|
||||
CheckInterval time.Duration
|
||||
NearExpiryThreshold time.Duration
|
||||
@ -28,17 +33,18 @@ type RenewalPolicy struct {
|
||||
}
|
||||
|
||||
type RenewalScheduler struct {
|
||||
lister DomainLister
|
||||
issuer CertificateIssuer
|
||||
policy RenewalPolicy
|
||||
now func() time.Time
|
||||
mu sync.Mutex
|
||||
policyMu sync.RWMutex
|
||||
runMu sync.Mutex
|
||||
running bool
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
wake chan struct{}
|
||||
lister DomainLister
|
||||
issuer CertificateIssuer
|
||||
management ManagementRenewalCheck
|
||||
policy RenewalPolicy
|
||||
now func() time.Time
|
||||
mu sync.Mutex
|
||||
policyMu sync.RWMutex
|
||||
runMu sync.Mutex
|
||||
running bool
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
wake chan struct{}
|
||||
}
|
||||
|
||||
type RenewalReport struct {
|
||||
@ -70,6 +76,12 @@ func NewRenewalSchedulerWithPolicy(lister DomainLister, issuer CertificateIssuer
|
||||
return &RenewalScheduler{lister: lister, issuer: issuer, policy: policy, now: time.Now, wake: make(chan struct{}, 1)}
|
||||
}
|
||||
|
||||
func NewRenewalSchedulerWithManagement(lister DomainLister, issuer CertificateIssuer, policy RenewalPolicy, management ManagementRenewalCheck) *RenewalScheduler {
|
||||
s := NewRenewalSchedulerWithPolicy(lister, issuer, policy)
|
||||
s.management = management
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *RenewalScheduler) UpdatePolicy(policy RenewalPolicy) {
|
||||
if s == nil {
|
||||
return
|
||||
@ -103,14 +115,28 @@ func (s *RenewalScheduler) RunOnce(ctx context.Context) (RenewalReport, error) {
|
||||
return RenewalReport{}, errors.New("自动续期任务正在执行")
|
||||
}
|
||||
defer s.runMu.Unlock()
|
||||
items, err := s.lister.List(ctx)
|
||||
if err != nil {
|
||||
return RenewalReport{}, fmt.Errorf("查询续期域名失败:%w", err)
|
||||
}
|
||||
report := RenewalReport{Checked: len(items)}
|
||||
policy := s.Policy()
|
||||
now := s.now().UTC()
|
||||
var report RenewalReport
|
||||
var managementErr error
|
||||
if s.management != nil {
|
||||
report, managementErr = s.management.RunRenewal(ctx, policy, now)
|
||||
if managementErr != nil {
|
||||
report.Errors = append(report.Errors, "管理证书续期检查失败")
|
||||
}
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return report, errors.Join(managementErr, err)
|
||||
}
|
||||
items, err := s.lister.List(ctx)
|
||||
if err != nil {
|
||||
return report, errors.Join(managementErr, fmt.Errorf("查询续期域名失败:%w", err))
|
||||
}
|
||||
report.Checked += len(items)
|
||||
for _, item := range items {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return report, errors.Join(managementErr, err)
|
||||
}
|
||||
if !item.AutoRenew || item.Status == "issuing" {
|
||||
continue
|
||||
}
|
||||
@ -133,7 +159,7 @@ func (s *RenewalScheduler) RunOnce(ctx context.Context) (RenewalReport, error) {
|
||||
}
|
||||
report.Succeeded++
|
||||
}
|
||||
return report, nil
|
||||
return report, managementErr
|
||||
}
|
||||
|
||||
func (s *RenewalScheduler) Start(ctx context.Context) {
|
||||
@ -167,7 +193,7 @@ func (s *RenewalScheduler) Start(ctx context.Context) {
|
||||
report, err := s.RunOnce(runCtx)
|
||||
for {
|
||||
interval := s.Policy().CheckInterval
|
||||
if err == nil && report.NearExpiry > 0 {
|
||||
if (report.NearExpiry > 0 || err != nil) && interval > 24*time.Hour {
|
||||
interval = 24 * time.Hour
|
||||
}
|
||||
timer := time.NewTimer(interval)
|
||||
@ -180,6 +206,7 @@ func (s *RenewalScheduler) Start(ctx context.Context) {
|
||||
case <-timer.C:
|
||||
case <-s.wake:
|
||||
}
|
||||
timer.Stop()
|
||||
if runCtx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
@ -201,6 +201,10 @@ func (s *Server) cancelTask(c *gin.Context) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "任务当前无法取消", "task": item})
|
||||
return
|
||||
}
|
||||
if item.TaskType == "management_renewal" {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "管理证书任务由续期调度控制,不能单独修改取消状态", "task": item})
|
||||
return
|
||||
}
|
||||
finished := time.Now().UTC()
|
||||
if err := s.tasks.CancelTask(c.Request.Context(), taskID, "任务已由用户取消", &finished); err != nil {
|
||||
s.writeError(c, http.StatusConflict, "取消任务失败", err)
|
||||
|
||||
@ -195,6 +195,24 @@ func TestTaskAPIRejectsUnknownTask(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskAPICannotCancelManagementTaskWithoutExecutionControl(t *testing.T) {
|
||||
server, tasks, _, closeDB := newTaskAPITestServer(t, nil)
|
||||
defer closeDB()
|
||||
item := repository.Task{ID: "management-task", DomainID: "management-certificate", TaskType: "management_renewal", Status: "running", Stage: "activate"}
|
||||
if err := tasks.CreateTask(t.Context(), item); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
serveAdminHTTP(server, response, httptest.NewRequest(http.MethodDelete, "/api/v1/tasks/"+item.ID, nil))
|
||||
if response.Code != http.StatusConflict {
|
||||
t.Fatalf("不应仅修改管理任务取消状态:%d %s", response.Code, response.Body.String())
|
||||
}
|
||||
stored, err := tasks.GetTask(t.Context(), item.ID)
|
||||
if err != nil || stored.Status != "running" {
|
||||
t.Fatalf("拒绝取消后任务状态变化:%+v %v", stored, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskAPICanCancelRunningIssue(t *testing.T) {
|
||||
started := make(chan struct{})
|
||||
server, taskRepo, _, closeDB := newTaskAPITestServer(t, func(ctx context.Context, id string) (certificate.IssueResult, deployment.Report, error) {
|
||||
|
||||
@ -7,6 +7,7 @@ import (
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@ -18,17 +19,17 @@ type ManagementCertificateState struct {
|
||||
CertificatePath string
|
||||
PrivateKeyPath string
|
||||
NotAfter time.Time
|
||||
HTTPSMode string
|
||||
ManagementURL string
|
||||
DNSVerified bool
|
||||
Request certificate.SetupCertificateRequest
|
||||
}
|
||||
|
||||
// ManagementCertificate 返回实际配置的管理证书,不依赖业务域名仓储。
|
||||
func (s *Server) ManagementCertificate() (ManagementCertificateState, error) {
|
||||
s.configMu.RLock()
|
||||
defer s.configMu.RUnlock()
|
||||
state := ManagementCertificateState{
|
||||
Hostname: strings.TrimSpace(s.config.Web.Hostname),
|
||||
CertificatePath: s.config.Server.TLSCert,
|
||||
PrivateKeyPath: s.config.Server.TLSKey,
|
||||
}
|
||||
state := s.managementCertificateState()
|
||||
pair, err := tls.LoadX509KeyPair(state.CertificatePath, state.PrivateKeyPath)
|
||||
if err != nil {
|
||||
return state, fmt.Errorf("读取管理证书失败:%w", err)
|
||||
@ -41,13 +42,47 @@ func (s *Server) ManagementCertificate() (ManagementCertificateState, error) {
|
||||
return state, nil
|
||||
}
|
||||
|
||||
// 调用方持有配置锁;只返回续期需要的值,不暴露完整配置或通知凭据。
|
||||
func (s *Server) managementCertificateState() ManagementCertificateState {
|
||||
return ManagementCertificateState{
|
||||
Hostname: strings.TrimSpace(s.config.Web.Hostname),
|
||||
CertificatePath: s.config.Server.TLSCert,
|
||||
PrivateKeyPath: s.config.Server.TLSKey,
|
||||
HTTPSMode: s.config.Server.HTTPSMode,
|
||||
ManagementURL: s.config.ManagementURL(),
|
||||
DNSVerified: s.config.DNS.VerificationState == "verified",
|
||||
Request: certificate.SetupCertificateRequest{
|
||||
Domains: []string{strings.TrimSpace(s.config.Web.Hostname)}, CertificateType: "single",
|
||||
ProviderID: s.config.DNS.Provider, CredentialRef: s.config.DNS.CredentialRef,
|
||||
AccountEmail: s.config.ACME.AccountEmail, Environment: s.config.ACME.Environment,
|
||||
CertificateRoot: s.config.Paths.Certificate, BackupRoot: s.config.Paths.Backup,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ActivateTLSCertificate 先持久化完整证书路径,再切换不可变快照;失败不影响旧连接。
|
||||
// 此方法仅供进程内部续期流程调用,不提供上传或任意路径的 HTTP 接口。
|
||||
func (s *Server) ActivateTLSCertificate(certificatePath, privateKeyPath string) error {
|
||||
return s.activateTLSCertificate(certificatePath, privateKeyPath, nil)
|
||||
}
|
||||
|
||||
// ActivateManagementCertificate 拒绝覆盖签发期间发生的配置或证书变化。
|
||||
func (s *Server) ActivateManagementCertificate(expected ManagementCertificateState, saved certificate.SavedResource) error {
|
||||
return s.activateTLSCertificate(saved.FullChainPath, saved.PrivateKeyPath, &expected)
|
||||
}
|
||||
|
||||
func (s *Server) activateTLSCertificate(certificatePath, privateKeyPath string, expected *ManagementCertificateState) error {
|
||||
s.tlsMu.Lock()
|
||||
defer s.tlsMu.Unlock()
|
||||
s.configMu.Lock()
|
||||
defer s.configMu.Unlock()
|
||||
if expected != nil {
|
||||
previous := *expected
|
||||
previous.NotAfter = time.Time{}
|
||||
if !reflect.DeepEqual(previous, s.managementCertificateState()) {
|
||||
return errors.New("管理证书签发期间配置已变化,已拒绝覆盖")
|
||||
}
|
||||
}
|
||||
if !s.config.SetupReady || strings.TrimSpace(s.configPath) == "" {
|
||||
return errors.New("管理证书热更新需要已初始化的持久化配置")
|
||||
}
|
||||
@ -65,6 +100,9 @@ func (s *Server) ActivateTLSCertificate(certificatePath, privateKeyPath string)
|
||||
if now.Before(leaf.NotBefore) || !now.Before(leaf.NotAfter) {
|
||||
return errors.New("新的管理证书不在有效期内")
|
||||
}
|
||||
if expected != nil && !leaf.NotAfter.After(expected.NotAfter) {
|
||||
return errors.New("新的管理证书未延长实际有效期")
|
||||
}
|
||||
host := strings.TrimSpace(s.config.Web.Hostname)
|
||||
if host == "" {
|
||||
host = strings.TrimSpace(s.config.Web.PublicIP)
|
||||
|
||||
@ -20,6 +20,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/certificate"
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/config"
|
||||
)
|
||||
|
||||
@ -176,6 +177,75 @@ func TestTLSCertificateRotationPersistsAndServesWithoutRestart(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagementActivationRejectsChangedConfiguration(t *testing.T) {
|
||||
for _, field := range []string{"hostname", "dns", "mode", "certificate", "root"} {
|
||||
t.Run(field, func(t *testing.T) {
|
||||
f := newRotationFixture(t)
|
||||
state, err := f.server.ManagementCertificate()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
certificatePath, keyPath := f.issue(t, 3, state.Hostname, time.Now().Add(-time.Hour), time.Now().Add(30*time.Hour))
|
||||
f.server.configMu.Lock()
|
||||
switch field {
|
||||
case "hostname":
|
||||
f.server.config.Web.Hostname = "changed.example.test"
|
||||
case "dns":
|
||||
f.server.config.DNS.CredentialRef = "secret://dns/changed"
|
||||
case "mode":
|
||||
f.server.config.Server.HTTPSMode = "proxy"
|
||||
case "certificate":
|
||||
f.server.config.Server.TLSCert = "changed.pem"
|
||||
case "root":
|
||||
f.server.config.Paths.Certificate = "changed-root"
|
||||
}
|
||||
f.server.configMu.Unlock()
|
||||
if err := f.server.ActivateManagementCertificate(state, certificate.SavedResource{FullChainPath: certificatePath, PrivateKeyPath: keyPath}); err == nil {
|
||||
t.Fatal("签发期间配置变化未阻止覆盖")
|
||||
}
|
||||
if serial, err := f.serial(); err != nil || serial != 2 {
|
||||
t.Fatalf("拒绝更新后旧证书不可用:%d %v", serial, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagementActivationUsesCurrentSnapshot(t *testing.T) {
|
||||
f := newRotationFixture(t)
|
||||
state, err := f.server.ManagementCertificate()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !state.DNSVerified || len(state.Request.Domains) != 1 || state.Request.Domains[0] != state.Hostname {
|
||||
t.Fatalf("管理续期状态不完整:%+v", state)
|
||||
}
|
||||
certificatePath, keyPath := f.issue(t, 3, state.Hostname, time.Now().Add(-time.Hour), time.Now().Add(30*time.Hour))
|
||||
if err := f.server.ActivateManagementCertificate(state, certificate.SavedResource{FullChainPath: certificatePath, PrivateKeyPath: keyPath}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if serial, err := f.serial(); err != nil || serial != 3 {
|
||||
t.Fatalf("管理证书未激活:%d %v", serial, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagementActivationChecksActualExpiryInsteadOfSavedMetadata(t *testing.T) {
|
||||
f := newRotationFixture(t)
|
||||
state, err := f.server.ManagementCertificate()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
certificatePath, keyPath := f.issue(t, 3, state.Hostname, time.Now().Add(-time.Hour), state.NotAfter.Add(-time.Hour))
|
||||
err = f.server.ActivateManagementCertificate(state, certificate.SavedResource{
|
||||
FullChainPath: certificatePath, PrivateKeyPath: keyPath, NotAfter: state.NotAfter.Add(time.Hour),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("候选元数据不能替代证书实际有效期检查")
|
||||
}
|
||||
if serial, err := f.serial(); err != nil || serial != 2 {
|
||||
t.Fatalf("拒绝短有效期候选后旧证书不可用:%d %v", serial, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTLSCertificateRotationRejectsInvalidCandidates(t *testing.T) {
|
||||
f := newRotationFixture(t)
|
||||
now := time.Now()
|
||||
|
||||
@ -467,6 +467,8 @@ func eventTypeName(value string) string {
|
||||
return "证书申请"
|
||||
case "certificate_renewal":
|
||||
return "证书续期"
|
||||
case "management_renewal":
|
||||
return "管理证书续期"
|
||||
case "certificate_import":
|
||||
return "证书导入"
|
||||
default:
|
||||
|
||||
@ -125,6 +125,7 @@ export function useDashboard() {
|
||||
cancelled: "已取消",
|
||||
};
|
||||
const taskTypeLabels = {
|
||||
management_renewal: "管理证书续期",
|
||||
certificate_issue: "申请证书",
|
||||
certificate_import: "导入证书",
|
||||
certificate_deploy: "部署证书",
|
||||
@ -134,6 +135,7 @@ export function useDashboard() {
|
||||
dns_test: "DNS 在线测试",
|
||||
};
|
||||
const auditActionLabels = {
|
||||
management_renewal: "管理证书续期",
|
||||
certificate_issue: "申请证书",
|
||||
certificate_import: "导入证书",
|
||||
certificate_deploy: "部署证书",
|
||||
@ -152,6 +154,12 @@ export function useDashboard() {
|
||||
renewal_run: "执行自动续期",
|
||||
};
|
||||
const taskStageLabels = {
|
||||
management_renewal: "管理证书续期",
|
||||
preflight: "续期前置检查",
|
||||
issue: "申请管理证书",
|
||||
activate: "激活管理证书",
|
||||
record: "保存任务记录",
|
||||
retry_wait: "等待续期重试",
|
||||
service_restart: "服务重启恢复",
|
||||
queued: "排队中",
|
||||
certificate_issue: "申请证书",
|
||||
|
||||
@ -23,6 +23,14 @@ async function fill(state) {
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
test("管理续期的任务类型、审计和阶段使用中文", async (t) => {
|
||||
const { state } = await setup(t, () => verifiedResponse());
|
||||
assert.equal(state.taskTypeLabel("management_renewal"), "管理证书续期");
|
||||
assert.equal(state.auditActionLabel("management_renewal"), "管理证书续期");
|
||||
assert.equal(state.taskStageLabel("activate"), "激活管理证书");
|
||||
assert.equal(state.taskStageLabel("retry_wait"), "等待续期重试");
|
||||
});
|
||||
|
||||
test("已保存凭据不变可保存,输入新凭据必须重新验证", async (t) => {
|
||||
const { state } = await setup(t, () => verifiedResponse());
|
||||
assert.equal(state.dnsReadyToSave.value, true);
|
||||
|
||||
@ -231,7 +231,7 @@ const {
|
||||
查看详情
|
||||
</button>
|
||||
<button
|
||||
v-if="item.status === 'queued' || item.status === 'running'"
|
||||
v-if="item.task_type !== 'management_renewal' && (item.status === 'queued' || item.status === 'running')"
|
||||
class="text-button"
|
||||
type="button"
|
||||
@click="cancelTask(item)"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user