diff --git a/docs/changelog/index.md b/docs/changelog/index.md index bd97f600..ea4a9164 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -16,6 +16,10 @@ sidebar: false ## [Unreleased] +### 新增 + +- 将 TLS 证书续签逻辑接入 Asynq 异步任务框架。新增单证书续期任务 `of_ssl_single_renew`(`openflare:ssl_single_renew`),支持在管理后台查看每步的申请状态和详细日志,并提供失败重试能力。 + ### 变更 - 前端页面鉴权改为默认私域:除 `/login`、`/register`、`/callback` 外,未登录访问任意页面(含数据看板 `/`)均重定向至登录页。 diff --git a/frontend/app/(main)/websites/certificates/page.tsx b/frontend/app/(main)/certificates/page.tsx similarity index 100% rename from frontend/app/(main)/websites/certificates/page.tsx rename to frontend/app/(main)/certificates/page.tsx diff --git a/frontend/app/(main)/websites/dns-accounts/page.tsx b/frontend/app/(main)/dns-accounts/page.tsx similarity index 100% rename from frontend/app/(main)/websites/dns-accounts/page.tsx rename to frontend/app/(main)/dns-accounts/page.tsx diff --git a/frontend/app/(main)/waf/ip-groups/page.tsx b/frontend/app/(main)/ip-groups/page.tsx similarity index 100% rename from frontend/app/(main)/waf/ip-groups/page.tsx rename to frontend/app/(main)/ip-groups/page.tsx diff --git a/internal/apps/openflare/tls/logics.go b/internal/apps/openflare/tls/logics.go index 61842687..3fe462c7 100644 --- a/internal/apps/openflare/tls/logics.go +++ b/internal/apps/openflare/tls/logics.go @@ -13,6 +13,7 @@ import ( "strings" "github.com/Rain-kl/Wavelet/internal/model" + "github.com/Rain-kl/Wavelet/internal/task" ) // CertificateInput TLS 证书创建/更新请求。 @@ -282,10 +283,15 @@ func RenewCertificate(ctx context.Context, id uint) (*model.TLSCertificate, erro return nil, errors.New(errCertificateOnlyACMERenew) } - go func(c *model.TLSCertificate) { - asyncCtx := context.WithoutCancel(ctx) - _ = obtainTLSCertificate(asyncCtx, c) - }(cert) + payload, err := json.Marshal(SSLSingleRenewPayload{ID: id}) + if err != nil { + return nil, err + } + + _, err = task.DispatchTask(ctx, TaskTypeSSLSingleRenew, payload, "manual") + if err != nil { + return nil, err + } cert.ApplyStatus = tlsApplyStatusApplying cert.ApplyMessage = "" diff --git a/internal/apps/openflare/tls/logics_test.go b/internal/apps/openflare/tls/logics_test.go index 00909c08..7b1ca131 100644 --- a/internal/apps/openflare/tls/logics_test.go +++ b/internal/apps/openflare/tls/logics_test.go @@ -19,7 +19,10 @@ import ( "github.com/Rain-kl/Wavelet/internal/config" "github.com/Rain-kl/Wavelet/internal/db" "github.com/Rain-kl/Wavelet/internal/model" + "github.com/Rain-kl/Wavelet/internal/task" + "github.com/alicebob/miniredis/v2" "github.com/glebarez/sqlite" + "github.com/hibiken/asynq" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/gorm" @@ -40,12 +43,28 @@ func setupTLSTestDB(t *testing.T) func() { &model.ManagedDomain{}, &model.DNSAccount{}, &model.AcmeAccount{}, + &model.TaskExecution{}, // 异步任务执行记录也需要 migrate )) db.SetDB(sqliteDB) oldSecret := config.Config.App.SessionSecret config.Config.App.SessionSecret = "test_session_secret_for_tls_encryption" + + mr, err := miniredis.Run() + require.NoError(t, err) + + task.AsynqClient = asynq.NewClient(asynq.RedisClientOpt{ + Addr: mr.Addr(), + }) + + task.RegisterTaskMeta(SSLSingleRenewMeta) + return func() { + if task.AsynqClient != nil { + _ = task.AsynqClient.Close() + task.AsynqClient = nil + } + mr.Close() db.SetDB(nil) config.Config.App.SessionSecret = oldSecret tlsTestDBMu.Unlock() diff --git a/internal/apps/openflare/tls/obtain.go b/internal/apps/openflare/tls/obtain.go index 1904f29f..f572fcdd 100644 --- a/internal/apps/openflare/tls/obtain.go +++ b/internal/apps/openflare/tls/obtain.go @@ -11,6 +11,7 @@ import ( "github.com/Rain-kl/Wavelet/internal/apps/openflare/tls/acme" "github.com/Rain-kl/Wavelet/internal/model" + "github.com/Rain-kl/Wavelet/internal/task" ) const ( @@ -32,21 +33,25 @@ func SetObtainCertificateFuncForTest(fn func(context.Context, *model.TLSCertific } func obtainCertificate(ctx context.Context, cert *model.TLSCertificate) error { + task.AppendLog(ctx, "【续签任务】开始续签,设置申请状态为 applying...") cert.ApplyStatus = tlsApplyStatusApplying if err := model.SaveTLSCertificate(ctx, cert); err != nil { return err } + task.AppendLog(ctx, "【续签任务】正在解析 ACME 账户...") acmeAccount, err := resolveAcmeAccount(ctx, cert) if err != nil { return updateCertError(ctx, cert, fmt.Sprintf("Failed to get ACME account: %v", err)) } + task.AppendLog(ctx, "【续签任务】正在解析 DNS 账户信息 (ID=%d)...", cert.DNSAccountID) dnsAccount, err := model.GetDNSAccountByID(ctx, cert.DNSAccountID) if err != nil { return updateCertError(ctx, cert, fmt.Sprintf("Failed to get DNS account: %v", err)) } + task.AppendLog(ctx, "【续签任务】正在解密 DNS 账号凭据及 ACME 账户私钥...") dnsAuth, err := openSensitive(dnsAccount.Authorization) if err != nil { return updateCertError(ctx, cert, fmt.Sprintf("Failed to decrypt DNS credentials: %v", err)) @@ -58,7 +63,9 @@ func obtainCertificate(ctx context.Context, cert *model.TLSCertificate) error { } domains := splitAcmeDomains(cert.PrimaryDomain, cert.OtherDomains) + task.AppendLog(ctx, "【续签任务】待申请的域名列表: %v", domains) + task.AppendLog(ctx, "【续签任务】正在调用 ACME 客户端(通过 DNS-01 挑战)发起 SSL 证书签发请求,请稍候...") newAccountURL, newPrivateKeyPEM, result, err := acme.ObtainSSL( acmeAccount.Email, acmePrivateKey, @@ -73,6 +80,7 @@ func obtainCertificate(ctx context.Context, cert *model.TLSCertificate) error { domains, ) + task.AppendLog(ctx, "【续签任务】正在保存 ACME 账户可能的变更...") if err := persistAcmeAccountUpdates(ctx, cert, acmeAccount, newAccountURL, newPrivateKeyPEM, acmePrivateKey); err != nil { return updateCertError(ctx, cert, err.Error()) } @@ -81,9 +89,11 @@ func obtainCertificate(ctx context.Context, cert *model.TLSCertificate) error { return updateCertError(ctx, cert, err.Error()) } + task.AppendLog(ctx, "【续签任务】证书签发成功,正在将证书内容与私钥安全写入数据库...") if err := saveObtainedCertificate(ctx, cert, result); err != nil { return updateCertError(ctx, cert, err.Error()) } + task.AppendLog(ctx, "【续签任务】证书数据存储完成!") return nil } diff --git a/internal/apps/openflare/tls/tasks.go b/internal/apps/openflare/tls/tasks.go new file mode 100644 index 00000000..dd65b6de --- /dev/null +++ b/internal/apps/openflare/tls/tasks.go @@ -0,0 +1,100 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package tls + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/Rain-kl/Wavelet/internal/model" + "github.com/Rain-kl/Wavelet/internal/task" +) + +const ( + // SSLSingleRenewTask renews a single ACME TLS certificate. + SSLSingleRenewTask = "openflare:ssl_single_renew" + // TaskTypeSSLSingleRenew is the admin task type for single SSL renewal. + TaskTypeSSLSingleRenew = "of_ssl_single_renew" +) + +// SSLSingleRenewMeta describes the single SSL renewal task. +var SSLSingleRenewMeta = task.TaskMeta{ + Type: TaskTypeSSLSingleRenew, + AsynqTask: SSLSingleRenewTask, + Name: "OpenFlare 单证书 SSL 续期", + Description: "对单个指定的 ACME 证书执行续期", + SupportsTime: false, + MaxRetry: task.DefaultMaxRetry, + Queue: task.QueueDefault, + Retryable: true, + Params: []task.TaskParam{ + { + Name: "id", + Label: "证书 ID", + Type: "number", + Required: true, + Placeholder: "请输入证书 ID", + Description: "待续期的 TLS 证书 ID", + }, + }, +} + +// SSLSingleRenewPayload is the payload structure for SSLSingleRenewTask. +type SSLSingleRenewPayload struct { + ID uint `json:"id"` +} + +// SSLSingleRenewHandler renews a specific TLS certificate. +type SSLSingleRenewHandler struct{} + +// ValidatePayload validates and normalizes the task payload. +func (h *SSLSingleRenewHandler) ValidatePayload(payload []byte) ([]byte, error) { + if len(payload) == 0 { + return nil, errors.New("任务参数不能为空") + } + + var req SSLSingleRenewPayload + if err := json.Unmarshal(payload, &req); err != nil { + return nil, fmt.Errorf("无效的 JSON 格式: %w", err) + } + + if req.ID == 0 { + return nil, errors.New("证书 ID 不能为空或零") + } + + return json.Marshal(req) +} + +// Execute runs the certificate renewal for the specified ID. +func (h *SSLSingleRenewHandler) Execute(ctx context.Context, payload []byte) (*task.TaskResult, error) { + var req SSLSingleRenewPayload + if err := json.Unmarshal(payload, &req); err != nil { + return nil, fmt.Errorf("解析任务参数: %w", err) + } + + task.AppendLog(ctx, "开始续期证书,ID: %d", req.ID) + + cert, err := model.GetTLSCertificateByID(ctx, req.ID) + if err != nil { + task.AppendLog(ctx, "获取证书记录失败 ID=%d: %v", req.ID, err) + return nil, fmt.Errorf("获取证书记录失败: %w", err) + } + + if cert.Provider != tlsProviderACME { + task.AppendLog(ctx, "证书 %s (ID=%d) 不是 ACME 托管证书,无法自动续期 (Provider: %s)", cert.PrimaryDomain, req.ID, cert.Provider) + return nil, fmt.Errorf("证书 %s 不是 ACME 托管证书,无法自动续期", cert.PrimaryDomain) + } + + task.AppendLog(ctx, "准备为域名 [%s] 申请/续期证书", cert.PrimaryDomain) + if err := obtainTLSCertificate(ctx, cert); err != nil { + task.AppendLog(ctx, "申请证书失败: %v", err) + return nil, err + } + + msg := fmt.Sprintf("证书 %s 续签成功", cert.PrimaryDomain) + task.AppendLog(ctx, "%s", msg) + return &task.TaskResult{Message: msg}, nil +} diff --git a/internal/apps/openflare/tls/tasks_test.go b/internal/apps/openflare/tls/tasks_test.go new file mode 100644 index 00000000..49499432 --- /dev/null +++ b/internal/apps/openflare/tls/tasks_test.go @@ -0,0 +1,155 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package tls + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/Rain-kl/Wavelet/internal/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSSLSingleRenewHandler_ValidatePayload(t *testing.T) { + tests := []struct { + name string + payload []byte + wantErr bool + errMsg string + }{ + { + name: "empty payload", + payload: nil, + wantErr: true, + errMsg: "任务参数不能为空", + }, + { + name: "invalid JSON", + payload: []byte(`{`), + wantErr: true, + errMsg: "无效的 JSON 格式", + }, + { + name: "zero ID", + payload: []byte(`{"id":0}`), + wantErr: true, + errMsg: "证书 ID 不能为空或零", + }, + { + name: "valid ID", + payload: []byte(`{"id":123}`), + wantErr: false, + }, + } + + handler := &SSLSingleRenewHandler{} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := handler.ValidatePayload(tt.payload) + if tt.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errMsg) + assert.Nil(t, got) + } else { + require.NoError(t, err) + var payload SSLSingleRenewPayload + err = json.Unmarshal(got, &payload) + require.NoError(t, err) + assert.Equal(t, uint(123), payload.ID) + } + }) + } +} + +func TestSSLSingleRenewHandler_Execute(t *testing.T) { + cleanup := setupTLSTestDB(t) + defer cleanup() + ctx := context.Background() + + handler := &SSLSingleRenewHandler{} + + t.Run("invalid payload", func(t *testing.T) { + _, err := handler.Execute(ctx, []byte(`{`)) + require.Error(t, err) + }) + + t.Run("certificate not found", func(t *testing.T) { + payload, err := json.Marshal(SSLSingleRenewPayload{ID: 999}) + require.NoError(t, err) + _, err = handler.Execute(ctx, payload) + require.Error(t, err) + assert.Contains(t, err.Error(), "获取证书记录失败") + }) + + t.Run("certificate provider is not ACME", func(t *testing.T) { + cert := &model.TLSCertificate{ + Name: "custom-cert", + Provider: "custom", + PrimaryDomain: "example.com", + } + err := model.CreateTLSCertificateRecord(ctx, cert) + require.NoError(t, err) + + payload, err := json.Marshal(SSLSingleRenewPayload{ID: cert.ID}) + require.NoError(t, err) + + _, err = handler.Execute(ctx, payload) + require.Error(t, err) + assert.Contains(t, err.Error(), "不是 ACME 托管证书") + }) + + t.Run("successful renewal", func(t *testing.T) { + cert := &model.TLSCertificate{ + Name: "acme-cert-success", + Provider: tlsProviderACME, + PrimaryDomain: "success.example.com", + } + err := model.CreateTLSCertificateRecord(ctx, cert) + require.NoError(t, err) + + // Mock obtainCertificate to succeed + restore := SetObtainCertificateFuncForTest(func(ctx context.Context, c *model.TLSCertificate) error { + c.ApplyStatus = tlsApplyStatusReady + return model.SaveTLSCertificate(ctx, c) + }) + defer restore() + + payload, err := json.Marshal(SSLSingleRenewPayload{ID: cert.ID}) + require.NoError(t, err) + + res, err := handler.Execute(ctx, payload) + require.NoError(t, err) + assert.Contains(t, res.Message, "续签成功") + + updated, err := model.GetTLSCertificateByID(ctx, cert.ID) + require.NoError(t, err) + assert.Equal(t, tlsApplyStatusReady, updated.ApplyStatus) + }) + + t.Run("failed renewal in obtain", func(t *testing.T) { + cert := &model.TLSCertificate{ + Name: "acme-cert-fail", + Provider: tlsProviderACME, + PrimaryDomain: "fail.example.com", + } + err := model.CreateTLSCertificateRecord(ctx, cert) + require.NoError(t, err) + + // Mock obtainCertificate to fail + restore := SetObtainCertificateFuncForTest(func(ctx context.Context, c *model.TLSCertificate) error { + return errors.New("ACME server timeout") + }) + defer restore() + + payload, err := json.Marshal(SSLSingleRenewPayload{ID: cert.ID}) + require.NoError(t, err) + + _, err = handler.Execute(ctx, payload) + require.Error(t, err) + assert.Equal(t, "ACME server timeout", err.Error()) + }) +} diff --git a/internal/task/handlers/register.go b/internal/task/handlers/register.go index 656b9873..ade65f8c 100644 --- a/internal/task/handlers/register.go +++ b/internal/task/handlers/register.go @@ -8,6 +8,7 @@ package handlers import ( "github.com/Rain-kl/Wavelet/internal/apps/admin/push" "github.com/Rain-kl/Wavelet/internal/apps/openflare" + "github.com/Rain-kl/Wavelet/internal/apps/openflare/tls" "github.com/Rain-kl/Wavelet/internal/apps/upload" "github.com/Rain-kl/Wavelet/internal/apps/user" "github.com/Rain-kl/Wavelet/internal/task" @@ -49,4 +50,8 @@ func Register() { task.RegisterHandler(openflare.UptimeKumaSyncTask, &openflare.UptimeKumaSyncHandler{}) task.RegisterTaskMeta(openflare.UptimeKumaSyncMeta) + + // tls single renew + task.RegisterHandler(tls.SSLSingleRenewTask, &tls.SSLSingleRenewHandler{}) + task.RegisterTaskMeta(tls.SSLSingleRenewMeta) }