diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index 32f0b5e..f5478b7 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -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, diff --git a/backend/internal/app/management_renewal.go b/backend/internal/app/management_renewal.go new file mode 100644 index 0000000..7dcbdbd --- /dev/null +++ b/backend/internal/app/management_renewal.go @@ -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) +} diff --git a/backend/internal/app/management_renewal_test.go b/backend/internal/app/management_renewal_test.go new file mode 100644 index 0000000..fc31bea --- /dev/null +++ b/backend/internal/app/management_renewal_test.go @@ -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) + } +} diff --git a/backend/internal/certificate/management_renewal_test.go b/backend/internal/certificate/management_renewal_test.go new file mode 100644 index 0000000..89408ee --- /dev/null +++ b/backend/internal/certificate/management_renewal_test.go @@ -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("临期规则不应将较短检查间隔延长为一天") + } + } +} diff --git a/backend/internal/certificate/renewal.go b/backend/internal/certificate/renewal.go index c309eed..28bb902 100644 --- a/backend/internal/certificate/renewal.go +++ b/backend/internal/certificate/renewal.go @@ -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 } diff --git a/backend/internal/httpapi/task_api.go b/backend/internal/httpapi/task_api.go index d02c9c8..efe7bbe 100644 --- a/backend/internal/httpapi/task_api.go +++ b/backend/internal/httpapi/task_api.go @@ -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) diff --git a/backend/internal/httpapi/task_api_test.go b/backend/internal/httpapi/task_api_test.go index 2922050..d72d167 100644 --- a/backend/internal/httpapi/task_api_test.go +++ b/backend/internal/httpapi/task_api_test.go @@ -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) { diff --git a/backend/internal/httpapi/tls_certificate.go b/backend/internal/httpapi/tls_certificate.go index 5b2916d..069e69e 100644 --- a/backend/internal/httpapi/tls_certificate.go +++ b/backend/internal/httpapi/tls_certificate.go @@ -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) diff --git a/backend/internal/httpapi/tls_certificate_test.go b/backend/internal/httpapi/tls_certificate_test.go index 84a1f2b..46a506a 100644 --- a/backend/internal/httpapi/tls_certificate_test.go +++ b/backend/internal/httpapi/tls_certificate_test.go @@ -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() diff --git a/backend/internal/notification/service.go b/backend/internal/notification/service.go index 51af458..92a8b00 100644 --- a/backend/internal/notification/service.go +++ b/backend/internal/notification/service.go @@ -467,6 +467,8 @@ func eventTypeName(value string) string { return "证书申请" case "certificate_renewal": return "证书续期" + case "management_renewal": + return "管理证书续期" case "certificate_import": return "证书导入" default: diff --git a/frontend/src/views/Dashboard/Dashboard.js b/frontend/src/views/Dashboard/Dashboard.js index f4f6c1d..3b9e635 100644 --- a/frontend/src/views/Dashboard/Dashboard.js +++ b/frontend/src/views/Dashboard/Dashboard.js @@ -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: "申请证书", diff --git a/frontend/src/views/Dashboard/Dashboard.test.js b/frontend/src/views/Dashboard/Dashboard.test.js index ef40254..3ee7382 100644 --- a/frontend/src/views/Dashboard/Dashboard.test.js +++ b/frontend/src/views/Dashboard/Dashboard.test.js @@ -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); diff --git a/frontend/src/views/Dashboard/Dashboard.vue b/frontend/src/views/Dashboard/Dashboard.vue index 7363d36..b57da4f 100644 --- a/frontend/src/views/Dashboard/Dashboard.vue +++ b/frontend/src/views/Dashboard/Dashboard.vue @@ -231,7 +231,7 @@ const { 查看详情