接入管理证书续期调度并记录任务审计通知

This commit is contained in:
xuans 2026-09-18 01:17:24 +08:00
parent 5597dd7cc2
commit c8c712131d
13 changed files with 943 additions and 27 deletions

View File

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

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

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

View 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("临期规则不应将较短检查间隔延长为一天")
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -467,6 +467,8 @@ func eventTypeName(value string) string {
return "证书申请"
case "certificate_renewal":
return "证书续期"
case "management_renewal":
return "管理证书续期"
case "certificate_import":
return "证书导入"
default:

View File

@ -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: "申请证书",

View File

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

View File

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