新增三库第13号迁移和历史范围回填,保留已删除资源的任务与审计。补充仓储、调用链、身份来源及迁移失败重试回归,同步任务清单、支持矩阵和运维边界。项目成员授权与生产验收仍待完成。
275 lines
9.4 KiB
Go
275 lines
9.4 KiB
Go
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 = repository.ManagementResourceID
|
||
|
||
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{
|
||
TaskID: task.ID,
|
||
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{
|
||
TaskID: task.ID,
|
||
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 requireManagementListener(ctx context.Context, server *httpapi.Server) error {
|
||
if err := ctx.Err(); err != nil {
|
||
return err
|
||
}
|
||
if server != nil {
|
||
select {
|
||
case <-server.Listening():
|
||
return nil
|
||
default:
|
||
}
|
||
}
|
||
return errors.New("当前进程尚未启动管理 HTTPS 监听,已阻止申请管理证书")
|
||
}
|
||
|
||
func (a directManagementActivator) Ready(ctx context.Context, state httpapi.ManagementCertificateState) error {
|
||
if err := requireManagementListener(ctx, a.server); 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)
|
||
}
|