Compare commits

...

10 Commits

Author SHA1 Message Date
ryan e9fb331214 [fix] 修复构建 2026-05-25 16:22:51 +08:00
ryan 5d6d68d0a1 [优化] 更新 Go 版本要求至 1.25+ 2026-05-25 16:18:07 +08:00
ryan c8e2c3620e [优化] 结构优化 2026-05-25 16:12:22 +08:00
ryan af8e9b477e [优化] 导航调整 2026-05-25 16:05:56 +08:00
ryan 314f6fd3f4 [优化] 移除注册相关功能的代码和配置 2026-05-25 16:03:38 +08:00
ryan 7eee788720 [优化] UI improve 2026-05-25 15:47:56 +08:00
ryan f6e4967a9a [新增] 添加 ACME 和 DNS 账号管理功能,支持证书申请与续期 2026-05-25 14:56:05 +08:00
ryan 7afe4e5d78 [新增] 添加 ACME 和 DNS 账号管理功能,支持证书申请与续期 2026-05-25 14:53:22 +08:00
Ryan c6a055d5d3 Update README.md 2026-05-13 14:00:53 +08:00
ryan 9a89428405 [修复] 个人设置查看第三方认证源与增加解绑功能 2026-05-13 12:09:15 +08:00
60 changed files with 2098 additions and 811 deletions
+1 -1
View File
@@ -19,7 +19,7 @@
</p>
> [!WARNING]
> 使用 `root` 用户初次登录系统后,务必修改默认密码 `123456`,并按需关闭新用户注册功能。
> 使用 `root` 用户初次登录系统后,务必修改默认密码 `123456`。
## 文档
+2 -3
View File
@@ -40,15 +40,14 @@
Server:
* Go 1.24+
* Go 1.25+
* Gin
* GORM
* SQLite / PostgreSQL
* 现有登录体系
Agent:
* Go 1.24+
*
* 单二进制
* 节点本地执行
* `openresty_path` 优先
+2 -2
View File
@@ -6,13 +6,13 @@ This page summarizes the OpenFlare deployment baseline, integration flow, upgrad
Server:
* Go 1.24+
* Go 1.25+
* Node.js 18+
* Writable SQLite directory or reachable PostgreSQL instance
Agent:
* Go 1.24+
* Go 1.25+
* Writable Agent data directory
* Local mode requires `openresty -t` and `openresty -s reload`
* Docker mode requires Docker access
+4 -4
View File
@@ -4,10 +4,10 @@ OpenFlare Server is the Gin + GORM control plane. It owns the web console, manag
## Requirements
| Item | Requirement |
| --- | --- |
| Go | `1.24+` |
| Node.js | `18+` |
| Item | Requirement |
| --- |-------------------------------------------------------|
| Go | `1.25+` |
| Node.js | `18+` |
| Database | Writable SQLite path or reachable PostgreSQL instance |
Set `SESSION_SECRET` explicitly in production and prefer PostgreSQL.
-2
View File
@@ -44,8 +44,6 @@ The settings page maintains these hot-updatable options:
| `NodeOfflineThreshold` | Node offline threshold in milliseconds | `120000` |
| `AgentUpdateRepo` | Agent update repository | `Rain-kl/OpenFlare` |
| `GeoIPProvider` | Node/IP region provider | `ipinfo` |
| `RegisterEnabled` | Allow new user registration | `false` |
| `PasswordRegisterEnabled` | Allow password registration | `true` |
| `DatabaseAutoCleanupEnabled` | Enable daily observability cleanup | `false` |
| `DatabaseAutoCleanupRetentionDays` | Retention days | `30` |
+2 -2
View File
@@ -6,13 +6,13 @@
Server:
* Go 1.24+
* Go 1.25+
* Node.js 18+
* 可写 SQLite 文件目录,或可访问的 PostgreSQL 实例
Agent:
* Go 1.24+
* Go 1.25+
* 对 Agent 数据目录有写权限
* 本机模式下可执行 `openresty -t` 与 `openresty -s reload`
* Docker 模式下具备 Docker 执行权限
+4 -4
View File
@@ -4,10 +4,10 @@ OpenFlare Server 是 Gin + GORM 单体控制面,负责管理端 UI、管理 AP
## 前置条件
| 项目 | 要求 |
| --- | --- |
| Go | `1.24+` |
| Node.js | `18+` |
| 项目 | 要求 |
| --- |-----------------------------------|
| Go | `1.25+` |
| Node.js | `18+` |
| 数据库 | SQLite 文件目录可写,或可访问的 PostgreSQL 实例 |
生产环境建议显式配置 `SESSION_SECRET`,并优先使用 PostgreSQL。
+2
View File
@@ -16,6 +16,8 @@ OpenFlare 支持通过认证源配置第三方登录入口。当前支持 GitHub
| Client Secret | 第三方平台创建应用后提供 |
| OIDC Discovery URL | 仅 OIDC 需要,例如 `https://idp.example.com/.well-known/openid-configuration` |
**确认系统设置->通用设置->服务器地址能正确和域名匹配**
认证源名称只能包含字母、数字、短横线或下划线,并且必须以字母或数字开头。认证源名称会出现在回调地址中,保存后如需修改名称,也必须同步修改第三方平台中的回调地址。
## 回调地址
-2
View File
@@ -63,8 +63,6 @@ go run . --port 3000 --log-dir ./logs
| `NodeOfflineThreshold` | 节点离线阈值(毫秒) | `120000` |
| `AgentUpdateRepo` | Agent 自更新仓库 | `Rain-kl/OpenFlare` |
| `GeoIPProvider` | 节点/IP 归属解析方式 | `ipinfo` |
| `RegisterEnabled` | 是否允许新用户注册 | `false` |
| `PasswordRegisterEnabled` | 是否允许通过密码方式注册 | `true` |
| `DatabaseAutoCleanupEnabled` | 是否启用每日自动清理观测数据 | `false` |
| `DatabaseAutoCleanupRetentionDays` | 自动清理保留天数,至少 1 天 | `30` |
| `GlobalApiRateLimitNum` / `GlobalApiRateLimitDuration` | 全局 API 限流次数 / 时间窗口 | `300` / `180` |
+1 -1
View File
@@ -1,6 +1,6 @@
module openflare-agent
go 1.24.0
go 1.25.0
require openflare v0.0.0
+1 -1
View File
@@ -11,7 +11,7 @@ RUN corepack enable && pnpm install --frozen-lockfile
COPY ./web ./
RUN NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build
FROM golang:1.24 AS builder2
FROM golang:1.25 AS builder2
ARG VERSION
@@ -0,0 +1,30 @@
package controller
import (
"github.com/gin-gonic/gin"
"net/http"
"openflare/model"
)
// GetDefaultAcmeAccount godoc
// @Summary Get default ACME account
// @Tags AcmeAccounts
// @Produce json
// @Security BearerAuth
// @Success 200 {object} map[string]interface{}
// @Router /api/acme-accounts/default [get]
func GetDefaultAcmeAccount(c *gin.Context) {
account, err := model.GetDefaultAcmeAccount()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": account,
})
}
@@ -269,6 +269,30 @@ func LinkExistingOAuthAccount(c *gin.Context) {
respondSuccess(c, service.OAuthCallbackResult{Status: "linked", User: cleanUser})
}
func ListExternalAccounts(c *gin.Context) {
userID := c.GetInt("id")
accounts, err := model.ListExternalAccountsByUserID(userID)
if err != nil {
respondFailure(c, err.Error())
return
}
respondSuccess(c, accounts)
}
func DeleteExternalAccount(c *gin.Context) {
rawID := strings.TrimSpace(c.Param("id"))
parsedID, err := strconv.ParseUint(rawID, 10, 64)
if err != nil || parsedID == 0 {
respondBadRequest(c, "绑定记录 ID 无效")
return
}
if err := model.DeleteExternalAccountForUser(uint(parsedID), c.GetInt("id")); err != nil {
respondFailure(c, err.Error())
return
}
respondSuccessMessage(c, "")
}
func parseAuthSourceID(c *gin.Context) (uint, error) {
raw := c.Param("source_id")
if raw == "" {
+187
View File
@@ -0,0 +1,187 @@
package controller
import (
"encoding/json"
"github.com/gin-gonic/gin"
"net/http"
"openflare/model"
"strconv"
)
type DnsAccountInput struct {
Name string `json:"name"`
Type string `json:"type"`
Authorization string `json:"authorization"`
}
// GetDnsAccounts godoc
// @Summary List DNS accounts
// @Tags DnsAccounts
// @Produce json
// @Security BearerAuth
// @Success 200 {object} map[string]interface{}
// @Router /api/dns-accounts/ [get]
func GetDnsAccounts(c *gin.Context) {
accounts, err := model.ListDnsAccounts()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": accounts,
})
}
// CreateDnsAccount godoc
// @Summary Create DNS account
// @Tags DnsAccounts
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param payload body DnsAccountInput true "DNS account payload"
// @Success 200 {object} map[string]interface{}
// @Router /api/dns-accounts/ [post]
func CreateDnsAccount(c *gin.Context) {
var input DnsAccountInput
if err := json.NewDecoder(c.Request.Body).Decode(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的参数",
})
return
}
account := &model.DnsAccount{
Name: input.Name,
Type: input.Type,
Authorization: input.Authorization,
}
if err := account.Insert(); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": account,
})
}
// UpdateDnsAccount godoc
// @Summary Update DNS account
// @Tags DnsAccounts
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path int true "DNS Account ID"
// @Param payload body DnsAccountInput true "DNS account payload"
// @Success 200 {object} map[string]interface{}
// @Router /api/dns-accounts/{id}/update [post]
func UpdateDnsAccount(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的参数",
})
return
}
var input DnsAccountInput
if err := json.NewDecoder(c.Request.Body).Decode(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的参数",
})
return
}
account, err := model.GetDnsAccountByID(uint(id))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
account.Name = input.Name
account.Type = input.Type
account.Authorization = input.Authorization
if err := account.Update(); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": account,
})
}
// DeleteDnsAccount godoc
// @Summary Delete DNS account
// @Tags DnsAccounts
// @Produce json
// @Security BearerAuth
// @Param id path int true "DNS Account ID"
// @Success 200 {object} map[string]interface{}
// @Router /api/dns-accounts/{id}/delete [post]
func DeleteDnsAccount(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的参数",
})
return
}
account, err := model.GetDnsAccountByID(uint(id))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
// Verify no cert uses this before deleting
var count int64
model.DB.Model(&model.TLSCertificate{}).Where("dns_account_id = ?", id).Count(&count)
if count > 0 {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "该 DNS 账号已被证书使用,无法删除",
})
return
}
if err := account.Delete(); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
}
-160
View File
@@ -1,160 +0,0 @@
package controller
import (
"github.com/gin-gonic/gin"
"net/http"
"openflare/common"
"openflare/model"
"openflare/utils"
"path/filepath"
"strconv"
"strings"
"time"
)
func GetAllFiles(c *gin.Context) {
p, _ := strconv.Atoi(c.Query("p"))
if p < 0 {
p = 0
}
files, err := model.GetAllFiles(p*common.ItemsPerPage, common.ItemsPerPage)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": files,
})
return
}
func SearchFiles(c *gin.Context) {
keyword := c.Query("keyword")
files, err := model.SearchFiles(keyword)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": files,
})
return
}
func UploadFile(c *gin.Context) {
form, err := c.MultipartForm()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
uploadPath := common.UploadPath
description := c.PostForm("description")
if description == "" {
description = "无描述信息"
}
uploader := c.GetString("username")
if uploader == "" {
uploader = "访客用户"
}
uploaderId := c.GetInt("id")
currentTime := time.Now().Format("2006-01-02 15:04:05")
files := form.File["file"]
for _, file := range files {
filename := filepath.Base(file.Filename)
ext := filepath.Ext(filename)
link := utils.GetUUID() + ext
savePath := filepath.Join(uploadPath, link) // both parts are checked, so this path should be safe to use
if err := c.SaveUploadedFile(file, savePath); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
// save to database
fileObj := &model.File{
Description: description,
Uploader: uploader,
UploadTime: currentTime,
UploaderId: uploaderId,
Link: link,
Filename: filename,
}
err = fileObj.Insert()
if err != nil {
_ = err
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
return
}
func DeleteFile(c *gin.Context) {
fileIdStr := c.Param("id")
fileId, err := strconv.Atoi(fileIdStr)
if err != nil || fileId == 0 {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的参数",
})
return
}
fileObj := &model.File{
Id: fileId,
}
model.DB.Where("id = ?", fileId).First(&fileObj)
if fileObj.Link == "" {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "文件不存在!",
})
return
}
err = fileObj.Delete()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": err.Error(),
})
return
} else {
message := "文件删除成功"
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": message,
})
}
}
func DownloadFile(c *gin.Context) {
path := c.Param("file")
fullPath := filepath.Join(common.UploadPath, path)
if !strings.HasPrefix(fullPath, common.UploadPath) {
// We may being attacked!
c.Status(403)
return
}
c.File(fullPath)
// Update download counter
go func() {
model.UpdateDownloadCounter(path)
}()
}
@@ -255,3 +255,114 @@ func DeleteTLSCertificate(c *gin.Context) {
"message": "",
})
}
// ApplyTLSCertificate godoc
// @Summary Apply TLS certificate via ACME
// @Tags TLSCertificates
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param payload body service.TLSApplyInput true "TLS apply payload"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/tls-certificates/apply [post]
func ApplyTLSCertificate(c *gin.Context) {
var input service.TLSApplyInput
if err := json.NewDecoder(c.Request.Body).Decode(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的参数",
})
return
}
certificate, err := service.ApplyTLSCertificate(input)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": certificate,
})
}
// UpdateAcmeCertificate godoc
// @Summary Update ACME TLS certificate
// @Tags TLSCertificates
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path int true "Certificate ID"
// @Param payload body service.TLSApplyInput true "TLS apply payload"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/tls-certificates/{id}/update-acme [post]
func UpdateAcmeCertificate(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "invalid request",
})
return
}
var input service.TLSApplyInput
if err := json.NewDecoder(c.Request.Body).Decode(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的参数",
})
return
}
certificate, err := service.UpdateAcmeCertificate(uint(id), input)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": certificate,
})
}
// RenewTLSCertificate godoc
// @Summary Renew TLS certificate
// @Tags TLSCertificates
// @Produce json
// @Security BearerAuth
// @Param id path int true "Certificate ID"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/tls-certificates/{id}/renew [post]
func RenewTLSCertificate(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的参数",
})
return
}
certificate, err := service.RenewTLSCertificate(uint(id))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": certificate,
})
}
+6 -66
View File
@@ -2,9 +2,6 @@ package controller
import (
"encoding/json"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"net/http"
"openflare/common"
"openflare/model"
@@ -12,6 +9,10 @@ import (
"openflare/utils/validation"
"strconv"
"strings"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type LoginRequest struct {
@@ -115,70 +116,9 @@ func Logout(c *gin.Context) {
}
func Register(c *gin.Context) {
if !common.RegisterEnabled {
c.JSON(http.StatusOK, gin.H{
"message": "管理员关闭了新用户注册",
"success": false,
})
return
}
if !common.PasswordRegisterEnabled {
c.JSON(http.StatusOK, gin.H{
"message": "管理员关闭了通过密码进行注册,请使用第三方账户验证的形式进行注册",
"success": false,
})
return
}
var user model.User
err := json.NewDecoder(c.Request.Body).Decode(&user)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无效的参数",
})
return
}
if err := validation.Validate.Struct(&user); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "输入不合法 " + err.Error(),
})
return
}
if common.EmailVerificationEnabled {
if user.Email == "" || user.VerificationCode == "" {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "管理员开启了邮箱验证,请输入邮箱地址和验证码",
})
return
}
if !security.VerifyCodeWithKey(user.Email, user.VerificationCode, security.EmailVerificationPurpose) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "验证码错误或已过期",
})
return
}
}
cleanUser := model.User{
Username: user.Username,
Password: user.Password,
DisplayName: user.Username,
}
if common.EmailVerificationEnabled {
cleanUser.Email = user.Email
}
if err := cleanUser.Insert(); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"message": "非法请求",
"success": false,
})
return
}
+23 -17
View File
@@ -1,26 +1,29 @@
module openflare
// +heroku goVersion go1.24
go 1.24.0
go 1.25.0
require (
github.com/bwmarrin/snowflake v0.3.0
github.com/dgraph-io/ristretto/v2 v2.2.0
github.com/gin-contrib/cors v1.6.0
github.com/gin-contrib/sessions v0.0.5
github.com/gin-contrib/static v0.0.1
github.com/gin-gonic/gin v1.9.1
github.com/glebarez/sqlite v1.11.0
github.com/go-playground/validator/v10 v10.19.0
github.com/go-acme/lego/v4 v4.35.2
github.com/go-playground/validator/v10 v10.23.0
github.com/go-redis/redis/v8 v8.11.5
github.com/google/uuid v1.3.0
github.com/google/uuid v1.6.0
github.com/oschwald/maxminddb-golang v1.13.1
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.1
github.com/swaggo/swag v1.16.4
golang.org/x/crypto v0.45.0
golang.org/x/net v0.47.0
golang.org/x/crypto v0.50.0
golang.org/x/net v0.53.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.25.10
gorm.io/sharding v0.6.2
)
require (
@@ -28,16 +31,17 @@ require (
github.com/PuerkitoBio/purell v1.1.1 // indirect
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff // indirect
github.com/bwmarrin/snowflake v0.3.0 // indirect
github.com/bytedance/sonic v1.11.2 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
github.com/chenzhuoyu/iasm v0.9.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect
github.com/go-openapi/jsonreference v0.19.6 // indirect
github.com/go-openapi/spec v0.20.4 // indirect
@@ -56,28 +60,30 @@ require (
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/longbridgeapp/sqlparser v0.3.1 // indirect
github.com/mailru/easyjson v0.7.6 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
github.com/miekg/dns v1.1.72 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/pelletier/go-toml/v2 v2.1.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.7.0 // indirect
golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/tools v0.38.0 // indirect
google.golang.org/protobuf v1.33.0 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/tools v0.44.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gorm.io/sharding v0.6.2 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
+62 -40
View File
@@ -12,6 +12,8 @@ github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1
github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
github.com/bytedance/sonic v1.11.2 h1:ywfwo0a/3j9HR8wsYGWsIWl2mvRsI950HyoxiBERw5A=
github.com/bytedance/sonic v1.11.2/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
@@ -23,8 +25,9 @@ github.com/chenzhuoyu/iasm v0.9.1 h1:tUHQJXo3NhBqw6s33wkGn9SP3bvrWLdlVIJ3hQBL7P0
github.com/chenzhuoyu/iasm v0.9.1/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM=
github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
@@ -33,10 +36,10 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/cors v1.6.0 h1:0Z7D/bVhE6ja07lI8CTjTonp6SB07o8bNuFyRbsBUQg=
github.com/gin-contrib/cors v1.6.0/go.mod h1:cI+h6iOAyxKRtUtC6iF/Si1KSFvGm/gK+kshxlCi8ro=
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
@@ -54,6 +57,10 @@ github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9g
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-acme/lego/v4 v4.35.2 h1:uVQg+KC/yj9R2g7Q9W5wDqhvQvxV5SMu5eqFVoN5xZU=
github.com/go-acme/lego/v4 v4.35.2/go.mod h1:pX2jN5n8OphMGY1IaMjYm5DAEzguBaKRt8AvJAgJXpc=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
@@ -74,23 +81,26 @@ github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
github.com/go-playground/validator/v10 v10.19.0 h1:ol+5Fu+cSq9JD7SoSqe04GMI92cbn0+wvQ3bZ8b/AU4=
github.com/go-playground/validator/v10 v10.19.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o=
github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-test/deep v1.0.7 h1:/VSMRlnY/JSyqxQUzQLKVMAskpY/NZKFA5j2P+0pP2M=
github.com/go-test/deep v1.0.7/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/gomodule/redigo v2.0.0+incompatible h1:K/R+8tc58AaqLkqG2Ol3Qk+DR/TlNuhuh457pBFPtt0=
github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
@@ -113,8 +123,8 @@ github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 h1:9Nu54bhS/H/Kgo2/7xNSUuC5G28VR8ljfrLKU2G4IjU=
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12/go.mod h1:TBzl5BIHNXfS9+C35ZyJaklL7mLDbgUkcgXzSLa8Tk0=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
@@ -129,6 +139,8 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/longbridgeapp/assert v1.1.0 h1:L+/HISOhuGbNAAmJNXgk3+Tm5QmSB70kwdktJXgjL+I=
github.com/longbridgeapp/assert v1.1.0/go.mod h1:UOI7O3rzlzlz715lQm0atWs6JbrYGuIJUEeOekutL6o=
github.com/longbridgeapp/sqlparser v0.3.1 h1:iWOZWGIFgQrJRgobLXUNJdvqGRpbVXkyKUKUA5CNJBE=
github.com/longbridgeapp/sqlparser v0.3.1/go.mod h1:GIHaUq8zvYyHLCLMJJykx1CdM6LHtkUih/QaJXySSx4=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
@@ -136,14 +148,17 @@ github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
@@ -155,14 +170,16 @@ github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI=
github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -174,8 +191,8 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY=
@@ -194,24 +211,24 @@ golang.org/x/arch v0.7.0 h1:pskyeJh/3AmoQ8CPE95vxHLqp1G1GfGNXTmcl9NEKTc=
golang.org/x/arch v0.7.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63 h1:m64FZMko/V45gv0bNmrNYoDEq8U5YUhetc9cBWKS1TQ=
golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63/go.mod h1:0v4NqG35kSWCMzLaMeX+IQrlSnVE/bqGSyC2cz/9Le8=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -220,9 +237,8 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -232,16 +248,16 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -257,10 +273,16 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.5.1 h1:WUEH5VF9obL/lTtzjmML/5e6VfFR/788coz2uaVCAZw=
gorm.io/driver/mysql v1.5.1/go.mod h1:Jo3Xu7mMhCyj8dlrb3WoCaRd1FhsVh+yMXb1jUInf5o=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s=
gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/hints v1.1.2 h1:b5j0kwk5p4+3BtDtYqqfY+ATSxjj+6ptPgVveuynn9o=
gorm.io/hints v1.1.2/go.mod h1:/ARdpUHAtyEMCh5NNi3tI7FsGh+Cj/MIUlvNxCNCFWg=
gorm.io/plugin/dbresolver v1.5.1 h1:s9Dj9f7r+1rE3nx/Ywzc85nXptUEaeOO0pt27xdopM8=
gorm.io/plugin/dbresolver v1.5.1/go.mod h1:l4Cn87EHLEYuqUncpEeTC2tTJQkjngPSD+lo8hIvcT0=
gorm.io/sharding v0.6.2 h1:V9inmbdhN+RfWPEKTvbKKKv7qxLz1CneBDQvuL5P7jg=
gorm.io/sharding v0.6.2/go.mod h1:dXaAZv0qyUmLkLAciQ+NH2O1D1A4/ttrrZ/XK4xW9HU=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
+28
View File
@@ -0,0 +1,28 @@
package job
import (
"github.com/robfig/cron/v3"
"log/slog"
)
var cronRunner *cron.Cron
func InitCronJobs() {
cronRunner = cron.New()
// Register SSL renew job
_, err := cronRunner.AddJob("0 0 * * *", &SSLRenewJob{})
if err != nil {
slog.Error("failed to register SSL renew cron job", "error", err)
} else {
slog.Info("registered SSL renew cron job")
}
cronRunner.Start()
}
func StopCronJobs() {
if cronRunner != nil {
cronRunner.Stop()
}
}
+43
View File
@@ -0,0 +1,43 @@
package job
import (
"log/slog"
"openflare/model"
"openflare/service"
"time"
)
type SSLRenewJob struct {
}
func (j *SSLRenewJob) Run() {
slog.Info("The scheduled certificate update task is currently in progress ...")
certificates, err := model.ListTLSCertificates()
if err != nil {
slog.Error("failed to list certificates in SSL renew job", "error", err)
return
}
now := time.Now()
for _, cert := range certificates {
if !cert.AutoRenew || cert.Provider != "acme" || cert.ApplyStatus == "applying" {
continue
}
sub := cert.NotAfter.Sub(now)
// Expiring in less than 7 days (7 * 24 hours)
if sub.Hours() < 168 {
slog.Info("Update the SSL certificate for the domain", "domain", cert.PrimaryDomain)
// Invoke renew process (async go-routine handles Lego inside)
_, err := service.RenewTLSCertificate(cert.ID)
if err != nil {
slog.Error("Failed to update the SSL certificate", "domain", cert.PrimaryDomain, "error", err)
continue
}
slog.Info("Triggered the SSL certificate renew for domain", "domain", cert.PrimaryDomain)
}
}
slog.Info("The scheduled certificate update task has completed")
}
+4
View File
@@ -11,6 +11,7 @@ import (
"log/slog"
"openflare/common"
_ "openflare/docs"
"openflare/job"
"openflare/middleware"
"openflare/model"
"openflare/router"
@@ -73,6 +74,9 @@ func main() {
defer cancelBackgroundTasks()
service.StartDatabaseAutoCleanupScheduler(backgroundCtx)
job.InitCronJobs()
defer job.StopCronJobs()
// Initialize HTTP server
server := gin.Default()
//server.Use(gzip.Gzip(gzip.DefaultCompression))
+41
View File
@@ -0,0 +1,41 @@
package model
import "time"
type AcmeAccount struct {
ID uint `json:"id" gorm:"primaryKey"`
Email string `json:"email" gorm:"size:255"`
URL string `json:"url" gorm:"size:255"`
PrivateKey string `json:"-" gorm:"type:text;not null"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func GetAcmeAccountByID(id uint) (*AcmeAccount, error) {
account := &AcmeAccount{}
err := DB.First(account, id).Error
return account, err
}
func GetDefaultAcmeAccount() (*AcmeAccount, error) {
account := &AcmeAccount{}
err := DB.Order("id asc").First(account).Error
if err != nil {
// Auto-create a default account placeholder if none exists
account.Email = "admin@openflare.dev"
err = DB.Create(account).Error
}
return account, err
}
func (account *AcmeAccount) Insert() error {
return DB.Create(account).Error
}
func (account *AcmeAccount) Update() error {
return DB.Save(account).Error
}
func (account *AcmeAccount) Delete() error {
return DB.Delete(account).Error
}
+56
View File
@@ -44,6 +44,17 @@ type ExternalAccount struct {
UpdatedAt time.Time `json:"updated_at"`
}
type ExternalAccountView struct {
ID uint `json:"id"`
AuthSourceID uint `json:"auth_source_id"`
AuthSourceName string `json:"auth_source_name"`
AuthSourceType string `json:"auth_source_type"`
AuthSourceLabel string `json:"auth_source_label"`
ExternalUsername string `json:"external_username"`
Email string `json:"email"`
CreatedAt time.Time `json:"created_at"`
}
func (source *AuthSource) Normalize() {
source.Name = strings.TrimSpace(source.Name)
source.Type = strings.TrimSpace(strings.ToLower(source.Type))
@@ -216,3 +227,48 @@ func LinkExternalAccount(account *ExternalAccount) error {
ExternalID: account.ExternalID,
}).FirstOrCreate(account).Error
}
func ListExternalAccountsByUserID(userID int) ([]ExternalAccountView, error) {
if userID <= 0 {
return nil, errors.New("用户 ID 不能为空")
}
var accounts []ExternalAccount
if err := DB.Preload("AuthSource").Where("user_id = ?", userID).Order("id asc").Find(&accounts).Error; err != nil {
return nil, err
}
views := make([]ExternalAccountView, 0, len(accounts))
for _, account := range accounts {
label := account.AuthSource.DisplayName
if label == "" {
label = account.AuthSource.Name
}
views = append(views, ExternalAccountView{
ID: account.ID,
AuthSourceID: account.AuthSourceID,
AuthSourceName: account.AuthSource.Name,
AuthSourceType: account.AuthSource.Type,
AuthSourceLabel: label,
ExternalUsername: account.ExternalUsername,
Email: account.Email,
CreatedAt: account.CreatedAt,
})
}
return views, nil
}
func DeleteExternalAccountForUser(id uint, userID int) error {
if id == 0 {
return errors.New("绑定记录 ID 不能为空")
}
if userID <= 0 {
return errors.New("用户 ID 不能为空")
}
result := DB.Where("id = ? AND user_id = ?", id, userID).Delete(&ExternalAccount{})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return errors.New("绑定记录不存在")
}
return nil
}
@@ -4,7 +4,7 @@ import "time"
const (
legacyDatabaseSchemaVersion = 1
currentDatabaseSchemaVersion = 10
currentDatabaseSchemaVersion = 11
databaseSchemaVersionRowID = 1
)
+35
View File
@@ -0,0 +1,35 @@
package model
import "time"
type DnsAccount struct {
ID uint `json:"id" gorm:"primaryKey"`
Name string `json:"name" gorm:"size:255;not null"`
Type string `json:"type" gorm:"size:64;not null"`
Authorization string `json:"-" gorm:"type:text;not null"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func ListDnsAccounts() (accounts []*DnsAccount, err error) {
err = DB.Order("id desc").Find(&accounts).Error
return accounts, err
}
func GetDnsAccountByID(id uint) (*DnsAccount, error) {
account := &DnsAccount{}
err := DB.First(account, id).Error
return account, err
}
func (account *DnsAccount) Insert() error {
return DB.Create(account).Error
}
func (account *DnsAccount) Update() error {
return DB.Save(account).Error
}
func (account *DnsAccount) Delete() error {
return DB.Delete(account).Error
}
+2
View File
@@ -41,6 +41,8 @@ func registeredModels() []any {
&NodeHealthEvent{},
&TLSCertificate{},
&ManagedDomain{},
&AcmeAccount{},
&DnsAccount{},
}
}
+1 -1
View File
@@ -863,7 +863,7 @@ func TestRunDatabaseSchemaMigrationDoesNotAdvanceVersionWhenValidationFails(t *t
err := runDatabaseSchemaMigration(db, "sqlite", databaseSchemaMigration{
fromVersion: legacyDatabaseSchemaVersion,
toVersion: currentDatabaseSchemaVersion,
toVersion: 11,
migrate: func(tx *gorm.DB, backend string) error {
return autoMigrateSchemaMetadata(tx)
},
+27 -1
View File
@@ -1344,6 +1344,31 @@ func validateDatabaseSchemaV10(db *gorm.DB, backend string) error {
return nil
}
// migrateV11 adds acme and dns accounts and extends tls_certificates.
func migrateV11(db *gorm.DB, backend string) error {
if err := applyCurrentSchema(db, backend); err != nil {
return err
}
// Default values will be applied by gorm for new columns automatically during AutoMigrate.
return nil
}
func validateDatabaseSchemaV11(db *gorm.DB, backend string) error {
if err := validateDatabaseSchemaV10(db, backend); err != nil {
return err
}
if !db.Migrator().HasTable(&AcmeAccount{}) {
return fmt.Errorf("table acme_accounts is missing")
}
if !db.Migrator().HasTable(&DnsAccount{}) {
return fmt.Errorf("table dns_accounts is missing")
}
if !db.Migrator().HasColumn(&TLSCertificate{}, "provider") {
return fmt.Errorf("column tls_certificates.provider is missing")
}
return nil
}
func databaseSchemaMigrations() []databaseSchemaMigration {
return []databaseSchemaMigration{
{fromVersion: 1, toVersion: 2, migrate: migrateV2, validate: validateDatabaseSchemaV2},
@@ -1355,6 +1380,7 @@ func databaseSchemaMigrations() []databaseSchemaMigration {
{fromVersion: 7, toVersion: 8, migrate: migrateV8, validate: validateDatabaseSchemaV8},
{fromVersion: 8, toVersion: 9, migrate: migrateV9, validate: validateDatabaseSchemaV9},
{fromVersion: 9, toVersion: 10, migrate: migrateV10, validate: validateDatabaseSchemaV10},
{fromVersion: 10, toVersion: 11, migrate: migrateV11, validate: validateDatabaseSchemaV11},
}
}
@@ -1440,7 +1466,7 @@ func initializeFreshDatabaseSchema(db *gorm.DB, backend string) error {
if err := ensureDefaultGitHubAuthSource(db); err != nil {
return err
}
if err := validateDatabaseSchemaV10(db, backend); err != nil {
if err := validateDatabaseSchemaV11(db, backend); err != nil {
return err
}
return saveDatabaseSchemaVersion(db, currentDatabaseSchemaVersion)
-3
View File
@@ -35,7 +35,6 @@ func InitOptionMap() {
common.OptionMap["GitHubOAuthEnabled"] = strconv.FormatBool(common.GitHubOAuthEnabled)
common.OptionMap["WeChatAuthEnabled"] = strconv.FormatBool(common.WeChatAuthEnabled)
common.OptionMap["TurnstileCheckEnabled"] = strconv.FormatBool(common.TurnstileCheckEnabled)
common.OptionMap["RegisterEnabled"] = strconv.FormatBool(common.RegisterEnabled)
common.OptionMap["SMTPServer"] = ""
common.OptionMap["SMTPPort"] = strconv.Itoa(common.SMTPPort)
common.OptionMap["SMTPAccount"] = ""
@@ -187,8 +186,6 @@ func updateOptionMap(key string, value string) {
common.WeChatAuthEnabled = boolValue
case "TurnstileCheckEnabled":
common.TurnstileCheckEnabled = boolValue
case "RegisterEnabled":
common.RegisterEnabled = boolValue
}
}
switch key {
-30
View File
@@ -1,30 +0,0 @@
package model
import (
"openflare/common"
"testing"
)
func TestInitOptionMapDefaultsRegisterDisabled(t *testing.T) {
previousRegisterEnabled := common.RegisterEnabled
previousOptionMap := common.OptionMap
previousDB := DB
t.Cleanup(func() {
common.RegisterEnabled = previousRegisterEnabled
common.OptionMap = previousOptionMap
DB = previousDB
})
DB = openTestSQLiteDB(t, "options-defaults.db")
common.RegisterEnabled = false
common.OptionMap = nil
InitOptionMap()
if got := common.OptionMap["RegisterEnabled"]; got != "false" {
t.Fatalf("expected RegisterEnabled default to be false, got %q", got)
}
if common.RegisterEnabled {
t.Fatal("expected RegisterEnabled to remain false after InitOptionMap")
}
}
+22 -9
View File
@@ -3,15 +3,28 @@ package model
import "time"
type TLSCertificate struct {
ID uint `json:"id" gorm:"primaryKey"`
Name string `json:"name" gorm:"uniqueIndex;size:255;not null"`
CertPEM string `json:"-" gorm:"type:text;not null"`
KeyPEM string `json:"-" gorm:"type:text;not null"`
NotBefore time.Time `json:"not_before"`
NotAfter time.Time `json:"not_after"`
Remark string `json:"remark" gorm:"size:255"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uint `json:"id" gorm:"primaryKey"`
Name string `json:"name" gorm:"uniqueIndex;size:255;not null"`
CertPEM string `json:"-" gorm:"type:text;not null"`
KeyPEM string `json:"-" gorm:"type:text;not null"`
NotBefore time.Time `json:"not_before"`
NotAfter time.Time `json:"not_after"`
Remark string `json:"remark" gorm:"size:255"`
Provider string `json:"provider" gorm:"size:64;default:'upload'"` // upload, acme
AcmeAccountID uint `json:"acme_account_id"`
DnsAccountID uint `json:"dns_account_id"`
KeyAlgorithm string `json:"key_algorithm" gorm:"size:32"`
AutoRenew bool `json:"auto_renew"`
PrimaryDomain string `json:"primary_domain" gorm:"size:255"`
OtherDomains string `json:"other_domains" gorm:"type:text"`
DisableCNAME bool `json:"disable_cname"`
SkipDNS bool `json:"skip_dns"`
DNS1 string `json:"dns1" gorm:"size:128"`
DNS2 string `json:"dns2" gorm:"size:128"`
ApplyStatus string `json:"apply_status" gorm:"size:64;default:'ready'"`
ApplyMessage string `json:"apply_message" gorm:"type:text"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func ListTLSCertificates() (certificates []*TLSCertificate, err error) {
+22 -8
View File
@@ -24,6 +24,12 @@ func SetApiRouter(router *gin.Engine) {
apiRouter.GET("/oauth/:source/authorize", middleware.CriticalRateLimit(), controller.OAuthAuthorize)
apiRouter.GET("/oauth/:source/callback", middleware.CriticalRateLimit(), controller.OAuthCallback)
apiRouter.POST("/oauth/link-existing", middleware.CriticalRateLimit(), controller.LinkExistingOAuthAccount)
externalAccountRoute := apiRouter.Group("/oauth/external-accounts")
externalAccountRoute.Use(middleware.UserAuth(), middleware.NoTokenAuth())
{
externalAccountRoute.GET("/", controller.ListExternalAccounts)
externalAccountRoute.POST("/:id/delete", controller.DeleteExternalAccount)
}
userRoute := apiRouter.Group("/user")
{
@@ -79,14 +85,6 @@ func SetApiRouter(router *gin.Engine) {
updateRoute.POST("/manual-upgrade", controller.ConfirmManualServerUpgrade)
updateRoute.POST("/upgrade", controller.UpgradeServer)
}
fileRoute := apiRouter.Group("/file")
fileRoute.Use(middleware.AdminAuth())
{
fileRoute.GET("/", controller.GetAllFiles)
fileRoute.GET("/search", controller.SearchFiles)
fileRoute.POST("/", middleware.UploadRateLimit(), controller.UploadFile)
fileRoute.POST("/:id/delete", controller.DeleteFile)
}
proxyRoute := apiRouter.Group("/proxy-routes")
proxyRoute.Use(middleware.AdminAuth())
{
@@ -122,8 +120,24 @@ func SetApiRouter(router *gin.Engine) {
tlsCertificateRoute.GET("/:id/content", controller.GetTLSCertificateContent)
tlsCertificateRoute.POST("/", controller.CreateTLSCertificate)
tlsCertificateRoute.POST("/:id/update", controller.UpdateTLSCertificate)
tlsCertificateRoute.POST("/:id/update-acme", controller.UpdateAcmeCertificate)
tlsCertificateRoute.POST("/import-file", controller.ImportTLSCertificateFile)
tlsCertificateRoute.POST("/:id/delete", controller.DeleteTLSCertificate)
tlsCertificateRoute.POST("/apply", controller.ApplyTLSCertificate)
tlsCertificateRoute.POST("/:id/renew", controller.RenewTLSCertificate)
}
acmeAccountRoute := apiRouter.Group("/acme-accounts")
acmeAccountRoute.Use(middleware.AdminAuth())
{
acmeAccountRoute.GET("/default", controller.GetDefaultAcmeAccount)
}
dnsAccountRoute := apiRouter.Group("/dns-accounts")
dnsAccountRoute.Use(middleware.AdminAuth())
{
dnsAccountRoute.GET("/", controller.GetDnsAccounts)
dnsAccountRoute.POST("/", controller.CreateDnsAccount)
dnsAccountRoute.POST("/:id/update", controller.UpdateDnsAccount)
dnsAccountRoute.POST("/:id/delete", controller.DeleteDnsAccount)
}
configVersionRoute := apiRouter.Group("/config-versions")
configVersionRoute.Use(middleware.AdminAuth())
+2 -2
View File
@@ -382,8 +382,8 @@ func TestPhase1HTTPSAndCertificateImportLifecycle(t *testing.T) {
t.Fatalf("expected active config to expose main_config, got %#v", activeConfig["main_config"])
}
supportFiles, ok := activeConfig["support_files"].([]any)
if !ok || len(supportFiles) != 2 {
t.Fatalf("expected active config to expose 2 support files, got %#v", activeConfig["support_files"])
if !ok || len(supportFiles) != 3 {
t.Fatalf("expected active config to expose 3 support files, got %#v", activeConfig["support_files"])
}
}
@@ -269,6 +269,57 @@ func TestAuthSourceUpdateAcceptsClientSecret(t *testing.T) {
})
}
func TestExternalAccountBindingsCanBeListedAndDeleted(t *testing.T) {
gin.SetMode(gin.TestMode)
common.RedisEnabled = false
setupTestDB(t)
engine := gin.New()
engine.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret"))))
router.SetApiRouter(engine)
loginCookie := loginAsRoot(t, engine)
source := &model.AuthSource{
Name: "logto",
Type: model.AuthSourceTypeOIDC,
DisplayName: "Logto",
ClientID: "logto-client-id",
ClientSecret: "logto-client-secret",
OpenIDDiscoveryURL: "https://auth.example.com/.well-known/openid-configuration",
}
if err := model.CreateAuthSource(source); err != nil {
t.Fatalf("create auth source: %v", err)
}
if err := model.LinkExternalAccount(&model.ExternalAccount{
AuthSourceID: source.ID,
UserID: 1,
ExternalID: "logto-user-1",
ExternalUsername: "ryan",
Email: "ryan@example.com",
}); err != nil {
t.Fatalf("link external account: %v", err)
}
listResp := performSessionJSONRequest(t, engine, loginCookie, http.MethodGet, "/api/oauth/external-accounts/", nil)
var bindings []model.ExternalAccountView
decodeResponseData(t, listResp, &bindings)
if len(bindings) != 1 {
t.Fatalf("expected 1 binding, got %d", len(bindings))
}
if bindings[0].AuthSourceName != "logto" || bindings[0].ExternalUsername != "ryan" {
t.Fatalf("unexpected binding view: %+v", bindings[0])
}
performSessionJSONRequest(t, engine, loginCookie, http.MethodPost, "/api/oauth/external-accounts/1/delete", nil)
listResp = performSessionJSONRequest(t, engine, loginCookie, http.MethodGet, "/api/oauth/external-accounts/", nil)
decodeResponseData(t, listResp, &bindings)
if len(bindings) != 0 {
t.Fatalf("expected binding to be deleted, got %+v", bindings)
}
}
func loginAsRoot(t *testing.T, engine http.Handler) *http.Cookie {
t.Helper()
payload, err := json.Marshal(map[string]any{
+3 -5
View File
@@ -2,15 +2,15 @@ package router
import (
"embed"
"github.com/gin-contrib/static"
"github.com/gin-gonic/gin"
"io/fs"
"net/http"
"openflare/controller"
"openflare/middleware"
"openflare/utils/embedfs"
pathpkg "path"
"strings"
"github.com/gin-contrib/static"
"github.com/gin-gonic/gin"
)
func setWebRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) {
@@ -20,8 +20,6 @@ func setWebRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) {
}
router.Use(middleware.GlobalWebRateLimit())
fileDownloadRoute := router.Group("/")
fileDownloadRoute.GET("/upload/:file", middleware.DownloadRateLimit(), controller.DownloadFile)
router.Use(normalizeStaticExportDataNavigation())
router.Use(middleware.Cache())
router.Use(static.Serve("/", embedfs.EmbedFolder(buildFS, "web/build")))
+1 -40
View File
@@ -3,17 +3,11 @@ package service
import (
"testing"
"openflare/common"
"openflare/model"
)
func TestCompleteOAuthLoginRequiresLinkWhenRegistrationDisabled(t *testing.T) {
setupServiceTestDB(t)
previousRegisterEnabled := common.RegisterEnabled
common.RegisterEnabled = false
t.Cleanup(func() {
common.RegisterEnabled = previousRegisterEnabled
})
source := createTestAuthSource(t)
result, pending, err := CompleteOAuthLogin(source, &OAuthProfile{
@@ -48,43 +42,10 @@ func TestCompleteOAuthLoginRequiresLinkWhenRegistrationDisabled(t *testing.T) {
}
}
func TestCompleteOAuthLoginAutoRegistersWhenEnabled(t *testing.T) {
setupServiceTestDB(t)
previousRegisterEnabled := common.RegisterEnabled
common.RegisterEnabled = true
t.Cleanup(func() {
common.RegisterEnabled = previousRegisterEnabled
})
source := createTestAuthSource(t)
result, pending, err := CompleteOAuthLogin(source, &OAuthProfile{
ExternalID: "external-2",
ExternalUsername: "oidc-user",
DisplayName: "OIDC User",
Email: "oidc@example.com",
}, nil)
if err != nil {
t.Fatalf("CompleteOAuthLogin failed: %v", err)
}
if pending != nil {
t.Fatalf("expected no pending account when registration is enabled")
}
if result.Status != "registered" || result.User == nil {
t.Fatalf("expected registered user result, got %#v", result)
}
account, err := model.FindExternalAccount(source.ID, "external-2")
if err != nil {
t.Fatalf("expected external account to be linked: %v", err)
}
if account.UserID != result.User.Id {
t.Fatalf("expected external account user %d, got %d", result.User.Id, account.UserID)
}
}
func createTestAuthSource(t *testing.T) *model.AuthSource {
t.Helper()
source := &model.AuthSource{
Name: "Test OIDC",
Name: "test-oidc",
Type: model.AuthSourceTypeOIDC,
DisplayName: "Test OIDC",
ClientID: "client-id",
+298
View File
@@ -0,0 +1,298 @@
package service
import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"strings"
"time"
"openflare/model"
"github.com/go-acme/lego/v4/acme"
"github.com/go-acme/lego/v4/certcrypto"
"github.com/go-acme/lego/v4/certificate"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/lego"
"github.com/go-acme/lego/v4/providers/dns/cloudflare"
"github.com/go-acme/lego/v4/registration"
)
type AcmeUser struct {
Email string
Registration *registration.Resource
key crypto.PrivateKey
}
func (u *AcmeUser) GetEmail() string {
return u.Email
}
func (u *AcmeUser) GetRegistration() *registration.Resource {
return u.Registration
}
func (u *AcmeUser) GetPrivateKey() crypto.PrivateKey {
return u.key
}
func parsePrivateKey(pemData string) (crypto.PrivateKey, error) {
block, _ := pem.Decode([]byte(pemData))
if block == nil {
return nil, errors.New("failed to parse PEM block containing the key")
}
if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
return key, nil
}
if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil {
return key, nil
}
if key, err := x509.ParseECPrivateKey(block.Bytes); err == nil {
return key, nil
}
return nil, errors.New("failed to parse private key")
}
func encodePrivateKey(key crypto.PrivateKey) (string, error) {
var pemBlock *pem.Block
switch k := key.(type) {
case *rsa.PrivateKey:
pemBlock = &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k)}
case *ecdsa.PrivateKey:
b, err := x509.MarshalECPrivateKey(k)
if err != nil {
return "", err
}
pemBlock = &pem.Block{Type: "EC PRIVATE KEY", Bytes: b}
default:
return "", errors.New("unsupported key type")
}
return string(pem.EncodeToMemory(pemBlock)), nil
}
func GetOrCreateLegoClient(account *model.AcmeAccount, keyAlgorithm string) (*lego.Client, *AcmeUser, error) {
var privateKey crypto.PrivateKey
var err error
if account.PrivateKey == "" {
privateKey, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, nil, err
}
pemStr, err := encodePrivateKey(privateKey)
if err != nil {
return nil, nil, err
}
account.PrivateKey = pemStr
// Don't save it to DB yet, wait for successful registration.
} else {
privateKey, err = parsePrivateKey(account.PrivateKey)
if err != nil {
return nil, nil, err
}
}
user := &AcmeUser{
Email: account.Email,
key: privateKey,
}
if account.URL != "" {
user.Registration = &registration.Resource{
Body: acme.Account{
Status: "valid",
Contact: []string{"mailto:" + account.Email},
},
URI: account.URL,
}
}
config := lego.NewConfig(user)
// Use Let's Encrypt production environment by default
config.CADirURL = lego.LEDirectoryProduction
switch keyAlgorithm {
case "RSA2048":
config.Certificate.KeyType = certcrypto.RSA2048
case "RSA4096":
config.Certificate.KeyType = certcrypto.RSA4096
case "EC256":
config.Certificate.KeyType = certcrypto.EC256
case "EC384":
config.Certificate.KeyType = certcrypto.EC384
default:
config.Certificate.KeyType = certcrypto.RSA2048
}
client, err := lego.NewClient(config)
if err != nil {
return nil, nil, err
}
if account.URL == "" {
reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
if err != nil {
return nil, nil, err
}
user.Registration = reg
account.URL = reg.URI
if account.ID == 0 {
err = model.DB.Create(account).Error
} else {
err = model.DB.Save(account).Error
}
if err != nil {
return nil, nil, err
}
}
return client, user, nil
}
func SetupDNSProvider(client *lego.Client, dnsAccount *model.DnsAccount, dns1, dns2 string, disableCNAME, skipDNS bool) error {
var provider challengeProvider
switch dnsAccount.Type {
case "cloudflare":
var creds map[string]string
if err := json.Unmarshal([]byte(dnsAccount.Authorization), &creds); err != nil {
return fmt.Errorf("failed to parse cloudflare credentials: %v", err)
}
config := cloudflare.NewDefaultConfig()
config.AuthToken = creds["api_token"]
p, err := cloudflare.NewDNSProviderConfig(config)
if err != nil {
return err
}
provider = p
default:
return fmt.Errorf("unsupported DNS provider: %s", dnsAccount.Type)
}
// We can use custom DNS servers to verify challenges if provided
var resolvers []string
if dns1 != "" {
resolvers = append(resolvers, dns1+":53")
}
if dns2 != "" {
resolvers = append(resolvers, dns2+":53")
}
var opts []dns01.ChallengeOption
if len(resolvers) > 0 {
opts = append(opts, dns01.AddRecursiveNameservers(resolvers))
}
if disableCNAME {
opts = append(opts, dns01.DisableCompletePropagationRequirement())
}
if skipDNS {
opts = append(opts, dns01.WrapPreCheck(func(domain, fqdn, value string, check dns01.PreCheckFunc) (bool, error) {
// If we skip the local DNS check entirely, we might trigger Let's Encrypt to verify
// BEFORE Cloudflare's edge servers have actually synced the TXT record (which takes 5-15 seconds).
// So we add a safe 20-second artificial delay before forcing the true return.
time.Sleep(20 * time.Second)
return true, nil
}))
}
return client.Challenge.SetDNS01Provider(provider, opts...)
}
// challengeProvider interface helps to bypass the strict type definition of SetDNS01Provider
type challengeProvider interface {
Present(domain, token, keyAuth string) error
CleanUp(domain, token, keyAuth string) error
}
func ObtainSSL(cert *model.TLSCertificate) error {
cert.ApplyStatus = "applying"
model.DB.Save(cert)
acmeAccount, err := model.GetAcmeAccountByID(cert.AcmeAccountID)
if err != nil {
// Fallback to default ACME account if the specified one is not found (e.g. ID 0 during testing)
acmeAccount, err = model.GetDefaultAcmeAccount()
if err != nil {
updateCertError(cert, fmt.Sprintf("Failed to get ACME account: %v", err))
return err
}
// Self-heal the certificate
cert.AcmeAccountID = acmeAccount.ID
model.DB.Save(cert)
}
dnsAccount, err := model.GetDnsAccountByID(cert.DnsAccountID)
if err != nil {
updateCertError(cert, fmt.Sprintf("Failed to get DNS account: %v", err))
return err
}
client, _, err := GetOrCreateLegoClient(acmeAccount, cert.KeyAlgorithm)
if err != nil {
updateCertError(cert, fmt.Sprintf("Failed to create ACME client: %v", err))
return err
}
err = SetupDNSProvider(client, dnsAccount, cert.DNS1, cert.DNS2, cert.DisableCNAME, cert.SkipDNS)
if err != nil {
updateCertError(cert, fmt.Sprintf("Failed to setup DNS provider: %v", err))
return err
}
domains := []string{cert.PrimaryDomain}
if cert.OtherDomains != "" {
for _, d := range strings.Split(cert.OtherDomains, "\n") {
d = strings.TrimSpace(d)
if d != "" {
domains = append(domains, d)
}
}
}
request := certificate.ObtainRequest{
Domains: domains,
Bundle: true,
}
certificates, err := client.Certificate.Obtain(request)
if err != nil {
updateCertError(cert, fmt.Sprintf("Failed to obtain certificate: %v", err))
return err
}
cert.CertPEM = string(certificates.Certificate)
cert.KeyPEM = string(certificates.PrivateKey)
// Parse validity dates
certBlock, _ := pem.Decode(certificates.Certificate)
if certBlock != nil {
parsedCert, err := x509.ParseCertificate(certBlock.Bytes)
if err == nil {
cert.NotBefore = parsedCert.NotBefore
cert.NotAfter = parsedCert.NotAfter
}
}
cert.ApplyStatus = "ready"
cert.ApplyMessage = ""
return model.DB.Save(cert).Error
}
func updateCertError(cert *model.TLSCertificate, message string) {
cert.ApplyStatus = "error"
cert.ApplyMessage = message
model.DB.Save(cert)
}
+86
View File
@@ -0,0 +1,86 @@
package service
import (
"openflare/model"
"testing"
"time"
)
func TestAcmeAndDnsIntegration(t *testing.T) {
setupServiceTestDB(t)
// 1. Create a DNS Account
dnsAccount := &model.DnsAccount{
Name: "Test Cloudflare",
Type: "cloudflare",
Authorization: `{"api_token": "dummy_token"}`,
}
if err := dnsAccount.Insert(); err != nil {
t.Fatalf("Failed to insert DNS Account: %v", err)
}
// 2. Apply for TLS Certificate (using the new ApplyTLSCertificate function)
certInput := TLSApplyInput{
Name: "Test ACME Cert",
PrimaryDomain: "example.com",
OtherDomains: "*.example.com",
DnsAccountID: dnsAccount.ID,
KeyAlgorithm: "RSA2048",
AutoRenew: true,
}
cert, err := ApplyTLSCertificate(certInput)
if err != nil {
t.Fatalf("ApplyTLSCertificate failed: %v", err)
}
if cert.ApplyStatus != "applying" {
t.Fatalf("Expected cert ApplyStatus to be applying, got %s", cert.ApplyStatus)
}
if cert.Provider != "acme" {
t.Fatalf("Expected cert Provider to be acme, got %s", cert.Provider)
}
// 3. Try to delete the DNS account (should fail since it's used by the cert)
// Actually, the delete logic is in the controller for the foreign key check.
// But let's check if the controller logic can be tested here, or we just trust the DB setup.
var count int64
model.DB.Model(&model.TLSCertificate{}).Where("dns_account_id = ?", dnsAccount.ID).Count(&count)
if count != 1 {
t.Fatalf("Expected 1 certificate associated with DNS account, got %d", count)
}
// 4. Test RenewTLSCertificate
renewedCert, err := RenewTLSCertificate(cert.ID)
if err != nil {
t.Fatalf("RenewTLSCertificate failed: %v", err)
}
if renewedCert.ApplyStatus != "applying" {
t.Fatalf("Expected renewed cert ApplyStatus to be applying, got %s", renewedCert.ApplyStatus)
}
// Wait for the async goroutine to fail (it now registers an LE account, which takes longer)
time.Sleep(5 * time.Second)
// Reload cert and verify error status
finalCert, err := model.GetTLSCertificateByID(renewedCert.ID)
if err != nil {
t.Fatalf("Failed to reload cert: %v", err)
}
if finalCert.ApplyStatus != "error" {
t.Fatalf("Expected final cert ApplyStatus to be error, got %s", finalCert.ApplyStatus)
}
if finalCert.ApplyMessage == "" {
t.Fatalf("Expected final cert ApplyMessage to be populated, got empty")
}
// Clean up
if err := DeleteTLSCertificate(cert.ID); err != nil {
t.Fatalf("DeleteTLSCertificate failed: %v", err)
}
if err := dnsAccount.Delete(); err != nil {
t.Fatalf("Failed to delete DNS Account after cert cleanup: %v", err)
}
}
+116
View File
@@ -25,6 +25,21 @@ type TLSCertificateContent struct {
Remark string `json:"remark"`
}
type TLSApplyInput struct {
Name string `json:"name"`
Remark string `json:"remark"`
AcmeAccountID uint `json:"acme_account_id"`
DnsAccountID uint `json:"dns_account_id"`
KeyAlgorithm string `json:"key_algorithm"`
AutoRenew bool `json:"auto_renew"`
PrimaryDomain string `json:"primary_domain"`
OtherDomains string `json:"other_domains"`
DisableCNAME bool `json:"disable_cname"`
SkipDNS bool `json:"skip_dns"`
DNS1 string `json:"dns1"`
DNS2 string `json:"dns2"`
}
func ListTLSCertificates() ([]*model.TLSCertificate, error) {
return model.ListTLSCertificates()
}
@@ -143,6 +158,107 @@ func DeleteTLSCertificate(id uint) error {
return certificate.Delete()
}
func ApplyTLSCertificate(input TLSApplyInput) (*model.TLSCertificate, error) {
cert := &model.TLSCertificate{
Name: strings.TrimSpace(input.Name),
Remark: strings.TrimSpace(input.Remark),
Provider: "acme",
AcmeAccountID: input.AcmeAccountID,
DnsAccountID: input.DnsAccountID,
KeyAlgorithm: input.KeyAlgorithm,
AutoRenew: input.AutoRenew,
PrimaryDomain: strings.TrimSpace(input.PrimaryDomain),
OtherDomains: strings.TrimSpace(input.OtherDomains),
DisableCNAME: input.DisableCNAME,
SkipDNS: input.SkipDNS,
DNS1: strings.TrimSpace(input.DNS1),
DNS2: strings.TrimSpace(input.DNS2),
ApplyStatus: "applying",
CertPEM: " ", // Temporary empty value, since gorm may prevent empty insert
KeyPEM: " ", // Temporary empty value
}
if cert.Name == "" {
return nil, errors.New("certificate name cannot be empty")
}
if err := cert.Insert(); err != nil {
if isUniqueConstraintError(err) {
return nil, errors.New("certificate name already exists")
}
return nil, err
}
// Async obtain SSL
go func(c *model.TLSCertificate) {
_ = ObtainSSL(c)
}(cert)
return cert, nil
}
func UpdateAcmeCertificate(id uint, input TLSApplyInput) (*model.TLSCertificate, error) {
cert, err := model.GetTLSCertificateByID(id)
if err != nil {
return nil, err
}
if cert.Provider != "acme" {
return nil, errors.New("only acme certificates can be updated via this endpoint")
}
cert.Name = strings.TrimSpace(input.Name)
if cert.Name == "" {
return nil, errors.New("certificate name cannot be empty")
}
cert.Remark = strings.TrimSpace(input.Remark)
cert.AcmeAccountID = input.AcmeAccountID
cert.DnsAccountID = input.DnsAccountID
cert.KeyAlgorithm = input.KeyAlgorithm
cert.AutoRenew = input.AutoRenew
cert.PrimaryDomain = strings.TrimSpace(input.PrimaryDomain)
cert.OtherDomains = strings.TrimSpace(input.OtherDomains)
cert.DisableCNAME = input.DisableCNAME
cert.SkipDNS = input.SkipDNS
cert.DNS1 = strings.TrimSpace(input.DNS1)
cert.DNS2 = strings.TrimSpace(input.DNS2)
cert.ApplyStatus = "applying"
if err := cert.Update(); err != nil {
if isUniqueConstraintError(err) {
return nil, errors.New("certificate name already exists")
}
return nil, err
}
// Async obtain SSL with updated config
go func(c *model.TLSCertificate) {
_ = ObtainSSL(c)
}(cert)
return cert, nil
}
func RenewTLSCertificate(id uint) (*model.TLSCertificate, error) {
cert, err := model.GetTLSCertificateByID(id)
if err != nil {
return nil, err
}
if cert.Provider != "acme" {
return nil, errors.New("only acme certificates can be renewed")
}
// Async obtain SSL
go func(c *model.TLSCertificate) {
_ = ObtainSSL(c)
}(cert)
cert.ApplyStatus = "applying"
cert.Update()
return cert, nil
}
func buildTLSCertificate(existing *model.TLSCertificate, input TLSCertificateInput) (*model.TLSCertificate, error) {
name := strings.TrimSpace(input.Name)
certPEM := strings.TrimSpace(input.CertPEM)
@@ -0,0 +1,5 @@
import { DnsAccountsPage } from '@/features/dns-accounts/components/dns-accounts-page';
export default function Page() {
return <DnsAccountsPage />;
}
@@ -1,12 +0,0 @@
import { Suspense } from 'react';
import { LoadingState } from '@/components/feedback/loading-state';
import { RegisterForm } from '@/features/auth/components/register-form';
export default function RegisterPage() {
return (
<Suspense fallback={<LoadingState />}>
<RegisterForm />
</Suspense>
);
}
@@ -0,0 +1,6 @@
import { apiRequest } from '@/lib/api/client';
import type { AcmeAccountItem } from '@/features/acme-accounts/types';
export function getDefaultAcmeAccount() {
return apiRequest<AcmeAccountItem>('/acme-accounts/default');
}
@@ -0,0 +1,7 @@
export interface AcmeAccountItem {
id: number;
email: string;
url: string;
created_at: string;
updated_at: string;
}
@@ -63,10 +63,6 @@ export function LoginForm() {
queryFn: getPublicStatus,
});
const canUsePasswordRegister =
(statusQuery.data?.register_enabled ?? false) &&
(statusQuery.data?.password_register_enabled ?? false);
const loginMutation = useMutation({
mutationFn: login,
onSuccess: (user) => {
@@ -166,17 +162,6 @@ export function LoginForm() {
>
{TEXT.forgotPassword}
</Link>
{canUsePasswordRegister ? (
<>
<span>|</span>
<Link
href="/register"
className="text-[var(--brand-primary)] transition hover:opacity-80"
>
{TEXT.register}
</Link>
</>
) : null}
</div>
</AppCard>
</PublicAuthGuard>
@@ -1,332 +0,0 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery } from '@tanstack/react-query';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { InlineMessage } from '@/components/feedback/inline-message';
import { TurnstileWidget } from '@/components/forms/turnstile-widget';
import { AppCard } from '@/components/ui/app-card';
import {
register as registerUser,
sendEmailVerification,
} from '@/features/auth/api/auth';
import { getPublicStatus } from '@/features/auth/api/public';
import {
AuthButton,
AuthFormField,
AuthInput,
SecondaryButton,
} from '@/features/auth/components/auth-form-primitives';
import { PublicAuthGuard } from '@/features/auth/components/public-auth-guard';
const TEXT = {
title: '\u65b0\u7528\u6237\u6ce8\u518c',
description:
'\u517c\u5bb9\u73b0\u6709\u5bc6\u7801\u6ce8\u518c\u94fe\u8def\uff0c\u540e\u7eed\u53ef\u7ee7\u7eed\u6269\u5c55\u7b2c\u4e09\u65b9\u6ce8\u518c\u3002',
usernameRequired: '\u8bf7\u8f93\u5165\u7528\u6237\u540d',
usernameTooLong: '\u7528\u6237\u540d\u6700\u957f 12 \u4f4d',
passwordTooShort: '\u5bc6\u7801\u81f3\u5c11 8 \u4f4d',
passwordTooLong: '\u5bc6\u7801\u6700\u957f 20 \u4f4d',
passwordRepeatRequired: '\u8bf7\u518d\u6b21\u8f93\u5165\u5bc6\u7801',
passwordMismatch: '\u4e24\u6b21\u8f93\u5165\u7684\u5bc6\u7801\u4e0d\u4e00\u81f4',
emailInvalid: '\u8bf7\u8f93\u5165\u6709\u6548\u90ae\u7bb1\u5730\u5740',
codeRequired: '\u8bf7\u8f93\u5165\u9a8c\u8bc1\u7801',
registerFailed: '\u6ce8\u518c\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002',
emailRequired: '\u8bf7\u8f93\u5165\u90ae\u7bb1\u5730\u5740',
verificationSent:
'\u9a8c\u8bc1\u7801\u53d1\u9001\u6210\u529f\uff0c\u8bf7\u68c0\u67e5\u90ae\u7bb1\u3002',
verificationFailed:
'\u9a8c\u8bc1\u7801\u53d1\u9001\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002',
turnstileRequired: '\u8bf7\u5148\u5b8c\u6210\u4eba\u673a\u9a8c\u8bc1\u3002',
registerClosed:
'\u7ba1\u7406\u5458\u5df2\u5173\u95ed\u65b0\u7528\u6237\u6ce8\u518c\u3002',
passwordRegisterClosed:
'\u7ba1\u7406\u5458\u5df2\u5173\u95ed\u5bc6\u7801\u6ce8\u518c\uff0c\u8bf7\u4f7f\u7528\u7b2c\u4e09\u65b9\u767b\u5f55\u5165\u53e3\u5b8c\u6210\u6ce8\u518c\u3002',
hasAccount: '\u5df2\u6709\u8d26\u53f7\uff1f',
backToLogin:
'\u8fd4\u56de\u767b\u5f55\u9875\u67e5\u770b\u53ef\u7528\u5165\u53e3\uff1a',
clickLogin: '\u70b9\u51fb\u767b\u5f55',
username: '\u7528\u6237\u540d',
usernameHint: '\u6700\u957f 12 \u4f4d',
password: '\u5bc6\u7801',
passwordHint: '\u6700\u77ed 8 \u4f4d\uff0c\u6700\u957f 20 \u4f4d',
passwordConfirm: '\u786e\u8ba4\u5bc6\u7801',
email: '\u90ae\u7bb1\u5730\u5740',
emailCode: '\u90ae\u7bb1\u9a8c\u8bc1\u7801',
getCode: '\u83b7\u53d6\u9a8c\u8bc1\u7801',
gettingCode: '\u53d1\u9001\u4e2d...',
register: '\u6ce8\u518c',
registering: '\u6ce8\u518c\u4e2d...',
};
const baseSchemaObject = z.object({
username: z.string().min(1, TEXT.usernameRequired).max(12, TEXT.usernameTooLong),
password: z.string().min(8, TEXT.passwordTooShort).max(20, TEXT.passwordTooLong),
password2: z.string().min(8, TEXT.passwordRepeatRequired),
email: z.string().optional(),
verification_code: z.string().optional(),
});
const baseSchema = baseSchemaObject.refine(
(data) => data.password === data.password2,
{
message: TEXT.passwordMismatch,
path: ['password2'],
},
);
type RegisterFormValues = z.infer<typeof baseSchema>;
export function RegisterForm() {
const router = useRouter();
const [turnstileToken, setTurnstileToken] = useState('');
const [message, setMessage] = useState<{
tone: 'success' | 'danger' | 'info';
text: string;
} | null>(null);
const statusQuery = useQuery({
queryKey: ['public-status'],
queryFn: getPublicStatus,
});
const needsEmailVerification = statusQuery.data?.email_verification ?? false;
const needsTurnstile = statusQuery.data?.turnstile_check ?? false;
const registerEnabled = statusQuery.data?.register_enabled ?? false;
const passwordRegisterEnabled =
statusQuery.data?.password_register_enabled ?? false;
const schema = useMemo(() => {
if (!needsEmailVerification) {
return baseSchema;
}
return baseSchemaObject
.extend({
email: z.string().email(TEXT.emailInvalid),
verification_code: z.string().min(1, TEXT.codeRequired),
})
.refine((data) => data.password === data.password2, {
message: TEXT.passwordMismatch,
path: ['password2'],
});
}, [needsEmailVerification]);
const form = useForm<RegisterFormValues>({
resolver: zodResolver(schema),
defaultValues: {
username: '',
password: '',
password2: '',
email: '',
verification_code: '',
},
});
const registerMutation = useMutation({
mutationFn: (values: RegisterFormValues) =>
registerUser(
{
username: values.username,
password: values.password,
email: values.email,
verification_code: values.verification_code,
},
turnstileToken || undefined,
),
onSuccess: () => {
router.replace('/login');
},
onError: (error: Error) => {
setMessage({ tone: 'danger', text: error.message || TEXT.registerFailed });
},
});
const verificationMutation = useMutation({
mutationFn: async () => {
const email = form.getValues('email');
if (!email) {
form.setError('email', { message: TEXT.emailRequired });
return;
}
await sendEmailVerification(email, turnstileToken || undefined);
},
onSuccess: () => {
setMessage({ tone: 'success', text: TEXT.verificationSent });
},
onError: (error: Error) => {
setMessage({
tone: 'danger',
text: error.message || TEXT.verificationFailed,
});
},
});
const handleSubmit = form.handleSubmit((values) => {
setMessage(null);
if (needsTurnstile && !turnstileToken) {
setMessage({ tone: 'info', text: TEXT.turnstileRequired });
return;
}
registerMutation.mutate(values);
});
return (
<PublicAuthGuard>
<AppCard title={TEXT.title} description={TEXT.description}>
{!registerEnabled ? (
<div className='space-y-4'>
<InlineMessage tone='info' message={TEXT.registerClosed} />
<div className='text-sm text-[var(--foreground-secondary)]'>
{TEXT.hasAccount}
<Link
href='/login'
className='ml-2 text-[var(--brand-primary)] transition hover:opacity-80'
>
{TEXT.clickLogin}
</Link>
</div>
</div>
) : !passwordRegisterEnabled ? (
<div className='space-y-4'>
<InlineMessage tone='info' message={TEXT.passwordRegisterClosed} />
<div className='text-sm text-[var(--foreground-secondary)]'>
{TEXT.backToLogin}
<Link
href='/login'
className='ml-2 text-[var(--brand-primary)] transition hover:opacity-80'
>
{TEXT.clickLogin}
</Link>
</div>
</div>
) : (
<>
<form className='space-y-4' onSubmit={handleSubmit}>
<AuthFormField label={TEXT.username} hint={TEXT.usernameHint}>
<AuthInput
placeholder={TEXT.username}
{...form.register('username')}
/>
{form.formState.errors.username ? (
<span className='text-xs text-[var(--status-danger-foreground)]'>
{form.formState.errors.username.message}
</span>
) : null}
</AuthFormField>
<AuthFormField label={TEXT.password} hint={TEXT.passwordHint}>
<AuthInput
type='password'
placeholder={TEXT.password}
{...form.register('password')}
/>
{form.formState.errors.password ? (
<span className='text-xs text-[var(--status-danger-foreground)]'>
{form.formState.errors.password.message}
</span>
) : null}
</AuthFormField>
<AuthFormField label={TEXT.passwordConfirm}>
<AuthInput
type='password'
placeholder={TEXT.passwordConfirm}
{...form.register('password2')}
/>
{form.formState.errors.password2 ? (
<span className='text-xs text-[var(--status-danger-foreground)]'>
{form.formState.errors.password2.message}
</span>
) : null}
</AuthFormField>
{needsEmailVerification ? (
<>
<AuthFormField label={TEXT.email}>
<AuthInput
type='email'
placeholder={TEXT.email}
{...form.register('email')}
/>
{form.formState.errors.email ? (
<span className='text-xs text-[var(--status-danger-foreground)]'>
{form.formState.errors.email.message}
</span>
) : null}
</AuthFormField>
<AuthFormField label={TEXT.emailCode}>
<div className='flex flex-col gap-3 sm:flex-row'>
<AuthInput
placeholder={TEXT.emailCode}
className='flex-1'
{...form.register('verification_code')}
/>
<SecondaryButton
type='button'
onClick={() => {
if (needsTurnstile && !turnstileToken) {
setMessage({
tone: 'info',
text: TEXT.turnstileRequired,
});
return;
}
setMessage(null);
verificationMutation.mutate();
}}
disabled={verificationMutation.isPending}
>
{verificationMutation.isPending
? TEXT.gettingCode
: TEXT.getCode}
</SecondaryButton>
</div>
{form.formState.errors.verification_code ? (
<span className='text-xs text-[var(--status-danger-foreground)]'>
{form.formState.errors.verification_code.message}
</span>
) : null}
</AuthFormField>
</>
) : null}
{needsTurnstile && statusQuery.data?.turnstile_site_key ? (
<TurnstileWidget
siteKey={statusQuery.data.turnstile_site_key}
onVerify={(token) => setTurnstileToken(token)}
onExpire={() => setTurnstileToken('')}
onError={() => setTurnstileToken('')}
/>
) : null}
{message ? (
<InlineMessage tone={message.tone} message={message.text} />
) : null}
<AuthButton type='submit' disabled={registerMutation.isPending}>
{registerMutation.isPending ? TEXT.registering : TEXT.register}
</AuthButton>
</form>
<div className='mt-6 text-sm text-[var(--foreground-secondary)]'>
{TEXT.hasAccount}
<Link
href='/login'
className='ml-2 text-[var(--brand-primary)] transition hover:opacity-80'
>
{TEXT.clickLogin}
</Link>
</div>
</>
)}
</AppCard>
</PublicAuthGuard>
);
}
@@ -0,0 +1,29 @@
import { apiRequest } from '@/lib/api/client';
import type {
DnsAccountItem,
DnsAccountMutationPayload,
} from '@/features/dns-accounts/types';
export function getDnsAccounts() {
return apiRequest<DnsAccountItem[]>('/dns-accounts/');
}
export function createDnsAccount(payload: DnsAccountMutationPayload) {
return apiRequest<DnsAccountItem>('/dns-accounts/', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function updateDnsAccount(id: number, payload: DnsAccountMutationPayload) {
return apiRequest<DnsAccountItem>(`/dns-accounts/${id}/update`, {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function deleteDnsAccount(id: number) {
return apiRequest<void>(`/dns-accounts/${id}/delete`, {
method: 'POST',
});
}
@@ -0,0 +1,177 @@
'use client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import Link from 'next/link';
import { useForm } from 'react-hook-form';
import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state';
import { InlineMessage } from '@/components/feedback/inline-message';
import { LoadingState } from '@/components/feedback/loading-state';
import { PageHeader } from '@/components/layout/page-header';
import { AppCard } from '@/components/ui/app-card';
import { AppModal } from '@/components/ui/app-modal';
import {
deleteDnsAccount,
getDnsAccounts,
createDnsAccount,
} from '@/features/dns-accounts/api/dns-accounts';
import type { DnsAccountItem } from '@/features/dns-accounts/types';
import { getErrorMessage } from '@/features/websites/utils';
import {
DangerButton,
PrimaryButton,
ResourceField,
ResourceInput,
ResourceSelect,
} from '@/features/shared/components/resource-primitives';
import { formatDateTime } from '@/lib/utils/date';
export function DnsAccountsPage() {
const queryClient = useQueryClient();
const [feedback, setFeedback] = useState<{ tone: 'info' | 'success' | 'danger'; message: string } | null>(null);
const [isCreateOpen, setIsCreateOpen] = useState(false);
const dnsAccountsQuery = useQuery({
queryKey: ['dns-accounts'],
queryFn: getDnsAccounts,
});
const deleteMutation = useMutation({
mutationFn: deleteDnsAccount,
onSuccess: async () => {
setFeedback({ tone: 'success', message: 'DNS 账号已删除。' });
await queryClient.invalidateQueries({ queryKey: ['dns-accounts'] });
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const handleDelete = (account: DnsAccountItem) => {
if (!window.confirm(`确认删除 DNS 账号 ${account.name} 吗?`)) {
return;
}
setFeedback(null);
deleteMutation.mutate(account.id);
};
const accounts = useMemo(() => dnsAccountsQuery.data ?? [], [dnsAccountsQuery.data]);
return (
<>
<div className="space-y-6">
<PageHeader
title="DNS 账号"
description="统一管理 DNS 服务商账号,用于 ACME 证书的 DNS 验证申请。"
action={
<div className="flex flex-wrap gap-3">
<Link
href="/website/certificate"
className="inline-flex min-h-[46px] items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
>
返回
</Link>
<PrimaryButton type="button" onClick={() => setIsCreateOpen(true)}>
添加账号
</PrimaryButton>
</div>
}
/>
{feedback ? <InlineMessage tone={feedback.tone} message={feedback.message} /> : null}
<AppCard title="DNS 账号列表">
{dnsAccountsQuery.isLoading ? (
<LoadingState />
) : dnsAccountsQuery.isError ? (
<ErrorState title="加载失败" description={getErrorMessage(dnsAccountsQuery.error)} />
) : accounts.length === 0 ? (
<EmptyState title="暂无 DNS 账号" description="点击右上角“添加账号”开始录入。" />
) : (
<div className="space-y-3">
{accounts.map((account) => (
<div key={account.id} className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<div className="flex items-start justify-between gap-4">
<div className="space-y-2">
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
{account.name} <span className="ml-2 text-xs font-normal text-[var(--foreground-secondary)]">({account.type})</span>
</p>
<div className="text-xs leading-5 text-[var(--foreground-secondary)]">
<p>创建于:{formatDateTime(account.created_at)}</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
<DangerButton
type="button"
onClick={() => handleDelete(account)}
disabled={deleteMutation.isPending}
className="px-3 py-2 text-xs"
>
删除
</DangerButton>
</div>
</div>
</div>
))}
</div>
)}
</AppCard>
</div>
{isCreateOpen && (
<DnsAccountCreateModal isOpen={isCreateOpen} onClose={() => setIsCreateOpen(false)} onCreated={() => {
setFeedback({ tone: 'success', message: 'DNS 账号已添加。' });
setIsCreateOpen(false);
queryClient.invalidateQueries({ queryKey: ['dns-accounts'] });
}} />
)}
</>
);
}
function DnsAccountCreateModal({ isOpen, onClose, onCreated }: { isOpen: boolean; onClose: () => void; onCreated: () => void }) {
const [error, setError] = useState('');
const { register, handleSubmit, formState } = useForm({
defaultValues: { name: '', type: 'cloudflare', authorization: '' },
});
const createMutation = useMutation({
mutationFn: createDnsAccount,
onSuccess: onCreated,
onError: (err) => setError(getErrorMessage(err)),
});
const onSubmit = handleSubmit((values) => {
setError('');
// for cloudflare we wrap the token in JSON if it isn't already (the backend expects JSON)
let auth = values.authorization;
if (!auth.startsWith('{')) {
auth = JSON.stringify({ api_token: values.authorization });
}
createMutation.mutate({ ...values, authorization: auth });
});
return (
<AppModal isOpen={isOpen} onClose={onClose} title="添加 DNS 账号">
<form onSubmit={onSubmit} className="space-y-5">
{error && <InlineMessage tone="danger" message={error} />}
<ResourceField label="账号名称" error={formState.errors.name?.message as string}>
<ResourceInput placeholder="例如:我的 Cloudflare" {...register('name', { required: '请输入名称' })} />
</ResourceField>
<ResourceField label="DNS 服务商">
<ResourceSelect {...register('type')}>
<option value="cloudflare">Cloudflare</option>
</ResourceSelect>
</ResourceField>
<ResourceField label="API Token (Authorization)" hint="输入对应 DNS 平台提供的 API Token。">
<ResourceInput placeholder="xxxxxxxxxxxxxxxxxxxxxxxxxxx" {...register('authorization', { required: '请输入 Token' })} />
</ResourceField>
<PrimaryButton type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? '提交中...' : '提交'}
</PrimaryButton>
</form>
</AppModal>
);
}
@@ -0,0 +1,13 @@
export interface DnsAccountItem {
id: number;
name: string;
type: string;
created_at: string;
updated_at: string;
}
export interface DnsAccountMutationPayload {
name: string;
type: string;
authorization: string;
}
@@ -718,14 +718,6 @@ type PowListValues = {
user_agents: string;
};
const defaultPowList: PowListValues = {
ips: '',
ip_cidrs: '',
paths: '',
path_regexes: '',
user_agents: '',
};
const powSchema = z
.object({
pow_enabled: z.boolean(),
@@ -6,6 +6,7 @@ import type {
AuthSourcePayload,
DatabaseCleanupPayload,
DatabaseCleanupResult,
ExternalAccountBinding,
GeoIPLookupResult,
OptionBatchPayload,
OptionItem,
@@ -76,6 +77,16 @@ export function deleteAuthSource(id: number) {
});
}
export function getExternalAccountBindings() {
return apiRequest<ExternalAccountBinding[]>('/oauth/external-accounts/');
}
export function deleteExternalAccountBinding(id: number) {
return apiRequest<void>(`/oauth/external-accounts/${id}/delete`, {
method: 'POST',
});
}
export function getBootstrapToken() {
return apiRequest<BootstrapTokenPayload>('/nodes/bootstrap-token');
}
@@ -21,9 +21,11 @@ import { getPublicStatus } from '@/features/auth/api/public';
import {
bindEmail,
cleanupDatabaseObservability,
deleteExternalAccountBinding,
generateAccessToken,
getAuthSources,
getBootstrapToken,
getExternalAccountBindings,
getOptions,
getSettingsProfile,
lookupGeoIP,
@@ -55,6 +57,10 @@ import { formatDateTime } from '@/lib/utils/date';
const settingsQueryKey = ['settings', 'options'] as const;
const authSourcesQueryKey = ['settings', 'auth-sources'] as const;
const externalAccountBindingsQueryKey = [
'settings',
'external-accounts',
] as const;
const installerScriptUrl =
'https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh';
@@ -66,7 +72,6 @@ const defaultSystemFields = {
GitHubOAuthEnabled: false,
WeChatAuthEnabled: false,
TurnstileCheckEnabled: false,
RegisterEnabled: false,
SMTPServer: '',
SMTPPort: '587',
SMTPAccount: '',
@@ -270,6 +275,11 @@ export function SettingsPage() {
queryFn: getSettingsProfile,
});
const externalAccountsQuery = useQuery({
queryKey: externalAccountBindingsQueryKey,
queryFn: getExternalAccountBindings,
});
const optionsQuery = useQuery({
queryKey: settingsQueryKey,
queryFn: getOptions,
@@ -354,7 +364,6 @@ export function SettingsPage() {
GitHubOAuthEnabled: toBoolean(optionMap.GitHubOAuthEnabled, false),
WeChatAuthEnabled: toBoolean(optionMap.WeChatAuthEnabled, false),
TurnstileCheckEnabled: toBoolean(optionMap.TurnstileCheckEnabled, false),
RegisterEnabled: toBoolean(optionMap.RegisterEnabled, false),
SMTPServer: optionMap.SMTPServer ?? '',
SMTPPort: optionMap.SMTPPort ?? '587',
SMTPAccount: optionMap.SMTPAccount ?? '',
@@ -642,6 +651,19 @@ export function SettingsPage() {
});
};
const handleUnbindAuthSource = (id: number, label: string) => {
if (!window.confirm(`确定解绑「${label}」吗?`)) {
return;
}
void runBusyAction(`auth-source-unbind-${id}`, async () => {
await deleteExternalAccountBinding(id);
await queryClient.invalidateQueries({
queryKey: externalAccountBindingsQueryKey,
});
setFeedback({ tone: 'success', message: '第三方账号已解绑。' });
});
};
const handleToggleOption = (
key: keyof typeof systemFields,
nextValue: boolean,
@@ -676,8 +698,21 @@ export function SettingsPage() {
);
}
if (externalAccountsQuery.isError) {
return (
<ErrorState
title="账号绑定加载失败"
description={getErrorMessage(externalAccountsQuery.error)}
/>
);
}
const publicStatus = publicStatusQuery.data;
const profile = profileQuery.data;
const externalAccounts = externalAccountsQuery.data ?? [];
const externalAccountMap = new Map(
externalAccounts.map((account) => [account.auth_source_name, account]),
);
if (!publicStatus || !profile) {
return (
@@ -832,18 +867,73 @@ export function SettingsPage() {
登录状态下发起授权会直接绑定到当前账号。
</p>
</div>
<div className="flex flex-wrap gap-3">
<div className="space-y-3">
{(publicStatus.auth_sources ?? []).length > 0 ? (
publicStatus.auth_sources.map((source) => (
<PrimaryButton
key={source.id}
type="button"
onClick={() => handleBindAuthSource(source.name)}
disabled={busyKey === `auth-source-bind-${source.name}`}
>
绑定 {source.display_name || source.name}
</PrimaryButton>
))
publicStatus.auth_sources.map((source) => {
const binding = externalAccountMap.get(source.name);
const label = source.display_name || source.name;
return (
<div
key={source.id}
className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-card)] px-4 py-3"
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 space-y-1">
<p className="text-sm font-medium text-[var(--foreground-primary)]">
{label}
</p>
{binding ? (
<>
<p className="text-sm break-all text-[var(--foreground-secondary)]">
已绑定:
{binding.external_username ||
binding.email ||
'第三方账号'}
</p>
{binding.email ? (
<p className="text-xs break-all text-[var(--foreground-muted)]">
邮箱:{binding.email}
</p>
) : null}
<p className="text-xs text-[var(--foreground-muted)]">
绑定时间:
{formatDateTime(binding.created_at)}
</p>
</>
) : (
<p className="text-sm text-[var(--foreground-secondary)]">
未绑定
</p>
)}
</div>
{binding ? (
<DangerButton
type="button"
onClick={() =>
handleUnbindAuthSource(binding.id, label)
}
disabled={
busyKey === `auth-source-unbind-${binding.id}`
}
>
解绑
</DangerButton>
) : (
<PrimaryButton
type="button"
onClick={() => handleBindAuthSource(source.name)}
disabled={
busyKey === `auth-source-bind-${source.name}`
}
>
绑定 {label}
</PrimaryButton>
)}
</div>
</div>
);
})
) : (
<span className="text-sm text-[var(--foreground-secondary)]">
当前未启用认证源。
@@ -1479,15 +1569,7 @@ export function SettingsPage() {
}
disabled={busyKey === 'toggle-EmailVerificationEnabled'}
/>
<ToggleField
label="允许新用户注册"
description="关闭后将禁止所有新用户注册入口。"
checked={systemFields.RegisterEnabled}
onChange={(checked) =>
handleToggleOption('RegisterEnabled', checked)
}
disabled={busyKey === 'toggle-RegisterEnabled'}
/>
</div>
<div className="mt-5 text-sm text-[var(--foreground-secondary)]">
当前已配置 {authSourcesQuery.data?.length ?? 0} 个认证源。
@@ -25,6 +25,17 @@ export interface AuthSource {
icon_url: string;
}
export interface ExternalAccountBinding {
id: number;
auth_source_id: number;
auth_source_name: string;
auth_source_type: AuthSourceType;
auth_source_label: string;
external_username: string;
email: string;
created_at: string;
}
export type AuthSourcePayload = Omit<
AuthSource,
'id' | 'client_secret_configured'
@@ -9,8 +9,8 @@ import type {
import { cn } from '@/lib/utils/cn';
interface ResourceFieldProps {
label: string;
hint?: string;
label: ReactNode;
hint?: ReactNode;
error?: string;
className?: string;
tooltip?: string;
@@ -6,6 +6,7 @@ import type {
TlsCertificateFileImportPayload,
TlsCertificateItem,
TlsCertificateMutationPayload,
TlsCertificateApplyPayload,
} from '@/features/tls-certificates/types';
export function getTlsCertificates() {
@@ -52,6 +53,26 @@ export function importTlsCertificateFiles(payload: TlsCertificateFileImportPaylo
export function deleteTlsCertificate(id: number) {
return apiRequest<void>(`/tls-certificates/${id}/delete`, {
method: 'POST',
});
}
export function applyTlsCertificate(payload: TlsCertificateApplyPayload) {
return apiRequest<TlsCertificateItem>('/tls-certificates/apply', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function renewTlsCertificate(id: number) {
return apiRequest<TlsCertificateItem>(`/tls-certificates/${id}/renew`, {
method: 'POST',
});
}
export function updateAcmeCertificate(id: number, payload: TlsCertificateApplyPayload) {
return apiRequest<TlsCertificateItem>(`/tls-certificates/${id}/update-acme`, {
method: 'POST',
body: JSON.stringify(payload),
});
}
@@ -14,11 +14,13 @@ import { StatusBadge } from '@/components/ui/status-badge';
import {
deleteTlsCertificate,
getTlsCertificates,
renewTlsCertificate,
} from '@/features/tls-certificates/api/tls-certificates';
import type { TlsCertificateItem } from '@/features/tls-certificates/types';
import { CertificateDetailModal } from '@/features/websites/components/certificate-detail-modal';
import { CertificateEditorModal } from '@/features/websites/components/certificate-editor-modal';
import { CertificateImportModal } from '@/features/websites/components/certificate-import-modal';
import { CertificateApplyModal } from '@/features/websites/components/certificate-apply-modal';
import { getCertificateStatus, getErrorMessage } from '@/features/websites/utils';
import {
DangerButton,
@@ -38,11 +40,13 @@ export function TlsCertificatesPage() {
const queryClient = useQueryClient();
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
const [isImportOpen, setIsImportOpen] = useState(false);
const [isApplyOpen, setIsApplyOpen] = useState(false);
const [selectedCertificateId, setSelectedCertificateId] = useState<
number | null
>(null);
const [isDetailOpen, setIsDetailOpen] = useState(false);
const [isEditorOpen, setIsEditorOpen] = useState(false);
const [editAcmeCertificate, setEditAcmeCertificate] = useState<TlsCertificateItem | null>(null);
const certificatesQuery = useQuery({
queryKey: certificatesQueryKey,
@@ -60,6 +64,17 @@ export function TlsCertificatesPage() {
},
});
const renewCertificateMutation = useMutation({
mutationFn: renewTlsCertificate,
onSuccess: async (cert) => {
setFeedback({ tone: 'success', message: `证书 ${cert.name} 续期任务已提交。` });
await queryClient.invalidateQueries({ queryKey: ['tls-certificates'] });
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const certificates = useMemo(
() => certificatesQuery.data ?? [],
[certificatesQuery.data],
@@ -74,14 +89,27 @@ export function TlsCertificatesPage() {
deleteCertificateMutation.mutate(certificate.id);
};
const handleRenewCertificate = (certificate: TlsCertificateItem) => {
if (!window.confirm(`确认提交证书 ${certificate.name} 的续期申请吗?`)) {
return;
}
setFeedback(null);
renewCertificateMutation.mutate(certificate.id);
};
const handleOpenCertificateDetail = (certificate: TlsCertificateItem) => {
setSelectedCertificateId(certificate.id);
setIsDetailOpen(true);
};
const handleOpenCertificateEditor = (certificate: TlsCertificateItem) => {
setSelectedCertificateId(certificate.id);
setIsEditorOpen(true);
if (certificate.provider === 'acme') {
setEditAcmeCertificate(certificate);
} else {
setSelectedCertificateId(certificate.id);
setIsEditorOpen(true);
}
};
return (
@@ -108,8 +136,17 @@ export function TlsCertificatesPage() {
>
刷新证书
</SecondaryButton>
<Link
href="/website/dns-account"
className="inline-flex min-h-[46px] items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
>
DNS 账号
</Link>
<PrimaryButton type="button" onClick={() => setIsImportOpen(true)}>
添加证书
导入证书
</PrimaryButton>
<PrimaryButton type="button" onClick={() => setIsApplyOpen(true)}>
申请证书
</PrimaryButton>
</div>
}
@@ -159,6 +196,9 @@ export function TlsCertificatesPage() {
<div className="text-xs leading-5 text-[var(--foreground-secondary)]">
<p>生效:{formatDateTime(certificate.not_before)}</p>
<p>到期:{formatDateTime(certificate.not_after)}</p>
<p>来源:{certificate.provider === 'acme' ? 'ACME 申请' : '手动上传'}</p>
{certificate.apply_status === 'applying' && <p className="text-blue-500">状态:申请中...</p>}
{certificate.apply_status === 'error' && <p className="text-red-500">状态:申请失败 ({certificate.apply_message})</p>}
<p>备注:{certificate.remark || '暂无备注'}</p>
</div>
</div>
@@ -178,6 +218,16 @@ export function TlsCertificatesPage() {
>
编辑
</SecondaryButton>
{certificate.provider === 'acme' && (
<SecondaryButton
type="button"
onClick={() => handleRenewCertificate(certificate)}
disabled={renewCertificateMutation.isPending}
className="px-3 py-2 text-xs"
>
续期
</SecondaryButton>
)}
<DangerButton
type="button"
onClick={() => handleDeleteCertificate(certificate)}
@@ -209,6 +259,33 @@ export function TlsCertificatesPage() {
/>
) : null}
{isApplyOpen ? (
<CertificateApplyModal
isOpen={isApplyOpen}
onClose={() => setIsApplyOpen(false)}
onApplied={(certificate) => {
setFeedback({
tone: 'success',
message: `证书 ${certificate.name} 申请任务已提交。`,
});
}}
/>
) : null}
{editAcmeCertificate ? (
<CertificateApplyModal
isOpen={true}
onClose={() => setEditAcmeCertificate(null)}
editCertificate={editAcmeCertificate}
onApplied={(certificate) => {
setFeedback({
tone: 'success',
message: `证书 ${certificate.name} 配置已更新,重新申请中...`,
});
}}
/>
) : null}
{isDetailOpen ? (
<CertificateDetailModal
certificateId={selectedCertificateId}
@@ -216,7 +293,12 @@ export function TlsCertificatesPage() {
onClose={() => setIsDetailOpen(false)}
onEdit={() => {
setIsDetailOpen(false);
setIsEditorOpen(true);
const certificate = certificates.find(
(item) => item.id === selectedCertificateId,
);
if (certificate) {
handleOpenCertificateEditor(certificate);
}
}}
onDelete={() => {
const certificate = certificates.find(
@@ -3,6 +3,19 @@ export interface TlsCertificateItem {
name: string;
cert_pem?: string;
key_pem?: string;
provider: string;
acme_account_id: number;
dns_account_id: number;
key_algorithm: string;
auto_renew: boolean;
primary_domain: string;
other_domains: string;
disable_cname: boolean;
skip_dns: boolean;
dns1: string;
dns2: string;
apply_status: string;
apply_message: string;
not_before: string;
not_after: string;
remark: string;
@@ -27,6 +40,21 @@ export interface TlsCertificateMutationPayload {
remark: string;
}
export interface TlsCertificateApplyPayload {
name: string;
remark: string;
acme_account_id: number;
dns_account_id: number;
key_algorithm: string;
auto_renew: boolean;
primary_domain: string;
other_domains: string;
disable_cname: boolean;
skip_dns: boolean;
dns1: string;
dns2: string;
}
export interface TlsCertificateFileImportPayload {
name: string;
remark: string;
@@ -0,0 +1,259 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { InlineMessage } from '@/components/feedback/inline-message';
import { AppModal } from '@/components/ui/app-modal';
import { applyTlsCertificate, updateAcmeCertificate } from '@/features/tls-certificates/api/tls-certificates';
import type { TlsCertificateItem } from '@/features/tls-certificates/types';
import { getDnsAccounts } from '@/features/dns-accounts/api/dns-accounts';
import { getDefaultAcmeAccount } from '@/features/acme-accounts/api/acme-accounts';
import {
acmeApplySchema,
defaultAcmeApplyValues,
type AcmeApplyFormValues,
} from '@/features/websites/schemas';
import { getErrorMessage } from '@/features/websites/utils';
import {
PrimaryButton,
ResourceField,
ResourceInput,
ResourceSelect,
ToggleField,
} from '@/features/shared/components/resource-primitives';
interface CertificateApplyModalProps {
isOpen: boolean;
onClose: () => void;
onApplied?: (certificate: TlsCertificateItem) => void;
editCertificate?: TlsCertificateItem | null;
}
export function CertificateApplyModal({
isOpen,
onClose,
onApplied,
editCertificate,
}: CertificateApplyModalProps) {
const queryClient = useQueryClient();
const [feedback, setFeedback] = useState<{ tone: 'success' | 'danger'; message: string } | null>(null);
const [showAdvanced, setShowAdvanced] = useState(false);
const dnsAccountsQuery = useQuery({
queryKey: ['dns-accounts'],
queryFn: getDnsAccounts,
enabled: isOpen,
});
const defaultAcmeAccountQuery = useQuery({
queryKey: ['acme-accounts', 'default'],
queryFn: getDefaultAcmeAccount,
enabled: isOpen,
});
const form = useForm<AcmeApplyFormValues>({
resolver: zodResolver(acmeApplySchema),
defaultValues: defaultAcmeApplyValues,
});
useEffect(() => {
if (!isOpen) return;
setFeedback(null);
setShowAdvanced(false);
if (editCertificate) {
form.reset({
name: editCertificate.name,
primary_domain: editCertificate.primary_domain || '',
other_domains: editCertificate.other_domains || '',
remark: editCertificate.remark || '',
acme_account_id: editCertificate.acme_account_id,
dns_account_id: editCertificate.dns_account_id,
key_algorithm: editCertificate.key_algorithm as any || 'EC256',
auto_renew: editCertificate.auto_renew,
dns1: editCertificate.dns1 || '',
dns2: editCertificate.dns2 || '',
disable_cname: editCertificate.disable_cname,
skip_dns: editCertificate.skip_dns,
});
if (editCertificate.dns1 || editCertificate.dns2 || editCertificate.disable_cname || editCertificate.skip_dns) {
setShowAdvanced(true);
}
} else {
form.reset(defaultAcmeApplyValues);
}
}, [isOpen, form, editCertificate]);
useEffect(() => {
if (defaultAcmeAccountQuery.data) {
form.setValue('acme_account_id', defaultAcmeAccountQuery.data.id);
}
}, [defaultAcmeAccountQuery.data, form]);
const applyMutation = useMutation({
mutationFn: (values: AcmeApplyFormValues) =>
editCertificate
? updateAcmeCertificate(editCertificate.id, values)
: applyTlsCertificate(values),
onSuccess: async (certificate) => {
await queryClient.invalidateQueries({ queryKey: ['tls-certificates'] });
onApplied?.(certificate);
onClose();
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const onSubmit = form.handleSubmit((values) => {
setFeedback(null);
applyMutation.mutate(values);
});
return (
<AppModal
isOpen={isOpen}
onClose={onClose}
title={editCertificate ? "编辑并重新申请证书" : "申请证书"}
description={editCertificate ? "修改 ACME 证书配置。保存后将使用新配置重新申请证书。" : "使用 ACME (Let's Encrypt 等) 自动申请和续期证书,支持通配符域名。"}
size="xl"
>
<form className="space-y-5" onSubmit={onSubmit}>
{feedback ? (
<InlineMessage tone={feedback.tone} message={feedback.message} />
) : null}
<div className="grid gap-4 md:grid-cols-2">
<ResourceField
label="证书名称"
error={form.formState.errors.name?.message}
>
<ResourceInput placeholder="例如:主站证书" {...form.register('name')} />
</ResourceField>
<ResourceField
label="主域名"
error={form.formState.errors.primary_domain?.message}
>
<ResourceInput placeholder="example.com 或 *.example.com" {...form.register('primary_domain')} />
</ResourceField>
</div>
<ResourceField
label="其他域名"
hint="每行一个域名。如申请通配符证书,请填写对应的根域名以便一并签发。"
error={form.formState.errors.other_domains?.message}
>
<textarea
className="w-full rounded-xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm text-[var(--foreground-primary)] outline-none transition focus:border-[var(--brand-primary)]"
rows={3}
placeholder="example.net"
{...form.register('other_domains')}
/>
</ResourceField>
<div className="grid gap-4 md:grid-cols-2">
<ResourceField
label="DNS 服务商账号"
error={form.formState.errors.dns_account_id?.message}
>
<ResourceSelect {...form.register('dns_account_id')}>
<option value={0}>请选择 DNS 账号</option>
{dnsAccountsQuery.data?.map((acc) => (
<option key={acc.id} value={acc.id}>
{acc.name} ({acc.type})
</option>
))}
</ResourceSelect>
</ResourceField>
<ResourceField
label="密钥算法"
error={form.formState.errors.key_algorithm?.message}
>
<ResourceSelect {...form.register('key_algorithm')}>
<option value="RSA2048">RSA 2048</option>
<option value="RSA4096">RSA 4096</option>
<option value="EC256">ECC 256</option>
<option value="EC384">ECC 384</option>
</ResourceSelect>
</ResourceField>
</div>
<div className="grid gap-4 md:grid-cols-1">
<ResourceField
label="备注"
error={form.formState.errors.remark?.message}
>
<ResourceInput placeholder="可选,用于记录证书用途。" {...form.register('remark')} />
</ResourceField>
<ToggleField
label="开启自动续签"
description="开启后,将在证书过期前 7 天自动续期。"
checked={form.watch('auto_renew')}
onChange={(checked) => form.setValue('auto_renew', checked)}
/>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] overflow-hidden">
<button
type="button"
className="flex w-full items-center justify-between px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--surface-muted)]"
onClick={() => setShowAdvanced(!showAdvanced)}
>
<span>高级选项</span>
<svg
className={`h-4 w-4 transition-transform duration-200 ${showAdvanced ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{showAdvanced && (
<div className="space-y-4 border-t border-[var(--border-default)] px-4 py-4">
<div className="grid gap-4 md:grid-cols-2">
<ResourceField
label="DNS 验证服务器 1"
hint="可选,如 8.8.8.8"
error={form.formState.errors.dns1?.message}
>
<ResourceInput placeholder="为空则使用默认权威 DNS" {...form.register('dns1')} />
</ResourceField>
<ResourceField
label="DNS 验证服务器 2"
hint="可选,如 1.1.1.1"
error={form.formState.errors.dns2?.message}
>
<ResourceInput placeholder="为空则使用默认权威 DNS" {...form.register('dns2')} />
</ResourceField>
</div>
<div className="grid gap-4 md:grid-cols-2">
<ToggleField
label="跳过 CNAME 检查"
description="在执行 DNS-01 验证时不追踪 CNAME 记录。"
checked={form.watch('disable_cname')}
onChange={(checked) => form.setValue('disable_cname', checked)}
/>
<ToggleField
label="跳过 DNS 前置检查"
description="直接请求 Let's Encrypt 验证而不做本地校验。"
checked={form.watch('skip_dns')}
onChange={(checked) => form.setValue('skip_dns', checked)}
/>
</div>
</div>
)}
</div>
<PrimaryButton type="submit" disabled={applyMutation.isPending}>
{applyMutation.isPending ? '提交中...' : (editCertificate ? '保存并申请' : '开始申请')}
</PrimaryButton>
</form>
</AppModal>
);
}
@@ -65,3 +65,36 @@ export const defaultFileImportValues: FileImportFormValues = {
name: '',
remark: '',
};
export const acmeApplySchema = z.object({
name: z.string().trim().min(1, '请输入证书名称').max(255),
primary_domain: z.string().trim().min(1, '请输入主域名'),
other_domains: z.string(),
dns_account_id: z.coerce.number().min(1, '请选择 DNS 账号'),
acme_account_id: z.coerce.number(),
key_algorithm: z.string(),
auto_renew: z.boolean(),
disable_cname: z.boolean().default(false),
skip_dns: z.boolean().default(false),
dns1: z.string().default(''),
dns2: z.string().default(''),
remark: z.string().max(255),
});
export type AcmeApplyFormValues = z.infer<typeof acmeApplySchema>;
export const defaultAcmeApplyValues: AcmeApplyFormValues = {
name: '',
primary_domain: '',
other_domains: '',
dns_account_id: 0,
acme_account_id: 0,
key_algorithm: 'RSA2048',
auto_renew: true,
disable_cname: false,
skip_dns: false,
dns1: '',
dns2: '',
remark: '',
};
@@ -11,6 +11,11 @@ export const dashboardNavigation: NavigationItem[] = [
label: '节点',
icon: 'node',
},
{
href: '/proxy-route',
label: '规则',
icon: 'proxy',
},
{
href: '/website',
label: '网站',
@@ -21,11 +26,6 @@ export const dashboardNavigation: NavigationItem[] = [
label: '源站',
icon: 'origin',
},
{
href: '/proxy-route',
label: '规则',
icon: 'proxy',
},
{
href: '/config-version',
label: '发布',
@@ -42,12 +42,6 @@ export const dashboardNavigation: NavigationItem[] = [
label: '性能',
icon: 'performance',
},
{
href: '/user',
label: '用户',
icon: 'user',
},
{
href: '/setting',
label: '设置',