mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-30 22:26:38 +08:00
feat(api): integrate TLS certificate renewal into async task framework
Replace native goroutines in RenewCertificate logic with Asynq task dispatching to support queue execution, retry capability, and detailed task execution logs.
This commit is contained in:
@@ -16,6 +16,10 @@ sidebar: false
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 新增
|
||||
|
||||
- 将 TLS 证书续签逻辑接入 Asynq 异步任务框架。新增单证书续期任务 `of_ssl_single_renew`(`openflare:ssl_single_renew`),支持在管理后台查看每步的申请状态和详细日志,并提供失败重试能力。
|
||||
|
||||
### 变更
|
||||
|
||||
- 前端页面鉴权改为默认私域:除 `/login`、`/register`、`/callback` 外,未登录访问任意页面(含数据看板 `/`)均重定向至登录页。
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user