From 7afe4e5d78eb745aad6fa497788a8e51e4377e95 Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 25 May 2026 14:02:05 +0800 Subject: [PATCH] =?UTF-8?q?[=E6=96=B0=E5=A2=9E]=20=E6=B7=BB=E5=8A=A0=20ACM?= =?UTF-8?q?E=20=E5=92=8C=20DNS=20=E8=B4=A6=E5=8F=B7=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF=E6=8C=81=E8=AF=81=E4=B9=A6?= =?UTF-8?q?=E7=94=B3=E8=AF=B7=E4=B8=8E=E7=BB=AD=E6=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openflare_agent/go.mod | 2 +- openflare_server/controller/acme_account.go | 30 ++ openflare_server/controller/dns_account.go | 187 +++++++++++ .../controller/tls_certificate.go | 111 +++++++ openflare_server/go.mod | 40 ++- openflare_server/go.sum | 102 +++--- openflare_server/job/cron.go | 28 ++ openflare_server/job/ssl_renew.go | 43 +++ openflare_server/main.go | 4 + openflare_server/model/acme_account.go | 41 +++ .../model/database_schema_version.go | 2 +- openflare_server/model/dns_account.go | 35 ++ openflare_server/model/main.go | 2 + openflare_server/model/main_test.go | 2 +- openflare_server/model/migrations.go | 28 +- openflare_server/model/tls_certificate.go | 31 +- openflare_server/router/api-router.go | 16 + openflare_server/service/lego_client.go | 298 ++++++++++++++++++ openflare_server/service/tls_acme_test.go | 86 +++++ openflare_server/service/tls_certificate.go | 116 +++++++ .../web/app/(dashboard)/dns-account/page.tsx | 10 + .../acme-accounts/api/acme-accounts.ts | 6 + .../web/features/acme-accounts/types.ts | 7 + .../features/dns-accounts/api/dns-accounts.ts | 29 ++ .../components/dns-accounts-page.tsx | 177 +++++++++++ .../web/features/dns-accounts/types.ts | 13 + .../shared/components/resource-primitives.tsx | 4 +- .../tls-certificates/api/tls-certificates.ts | 21 ++ .../components/tls-certificates-page.tsx | 90 +++++- .../web/features/tls-certificates/types.ts | 28 ++ .../components/certificate-apply-modal.tsx | 250 +++++++++++++++ .../web/features/websites/schemas.ts | 33 ++ 32 files changed, 1796 insertions(+), 76 deletions(-) create mode 100644 openflare_server/controller/acme_account.go create mode 100644 openflare_server/controller/dns_account.go create mode 100644 openflare_server/job/cron.go create mode 100644 openflare_server/job/ssl_renew.go create mode 100644 openflare_server/model/acme_account.go create mode 100644 openflare_server/model/dns_account.go create mode 100644 openflare_server/service/lego_client.go create mode 100644 openflare_server/service/tls_acme_test.go create mode 100644 openflare_server/web/app/(dashboard)/dns-account/page.tsx create mode 100644 openflare_server/web/features/acme-accounts/api/acme-accounts.ts create mode 100644 openflare_server/web/features/acme-accounts/types.ts create mode 100644 openflare_server/web/features/dns-accounts/api/dns-accounts.ts create mode 100644 openflare_server/web/features/dns-accounts/components/dns-accounts-page.tsx create mode 100644 openflare_server/web/features/dns-accounts/types.ts create mode 100644 openflare_server/web/features/websites/components/certificate-apply-modal.tsx diff --git a/openflare_agent/go.mod b/openflare_agent/go.mod index 264152ac..be41283e 100644 --- a/openflare_agent/go.mod +++ b/openflare_agent/go.mod @@ -1,6 +1,6 @@ module openflare-agent -go 1.24.0 +go 1.25.0 require openflare v0.0.0 diff --git a/openflare_server/controller/acme_account.go b/openflare_server/controller/acme_account.go new file mode 100644 index 00000000..33903151 --- /dev/null +++ b/openflare_server/controller/acme_account.go @@ -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, + }) +} diff --git a/openflare_server/controller/dns_account.go b/openflare_server/controller/dns_account.go new file mode 100644 index 00000000..050ce3bb --- /dev/null +++ b/openflare_server/controller/dns_account.go @@ -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": "", + }) +} diff --git a/openflare_server/controller/tls_certificate.go b/openflare_server/controller/tls_certificate.go index 4c1101cc..dda205b3 100644 --- a/openflare_server/controller/tls_certificate.go +++ b/openflare_server/controller/tls_certificate.go @@ -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, + }) +} diff --git a/openflare_server/go.mod b/openflare_server/go.mod index b4ce448f..926498f8 100644 --- a/openflare_server/go.mod +++ b/openflare_server/go.mod @@ -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 diff --git a/openflare_server/go.sum b/openflare_server/go.sum index e1428452..7a07a4f8 100644 --- a/openflare_server/go.sum +++ b/openflare_server/go.sum @@ -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= diff --git a/openflare_server/job/cron.go b/openflare_server/job/cron.go new file mode 100644 index 00000000..cd1eb2d3 --- /dev/null +++ b/openflare_server/job/cron.go @@ -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() + } +} diff --git a/openflare_server/job/ssl_renew.go b/openflare_server/job/ssl_renew.go new file mode 100644 index 00000000..4c4af7e4 --- /dev/null +++ b/openflare_server/job/ssl_renew.go @@ -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") +} diff --git a/openflare_server/main.go b/openflare_server/main.go index 00b98ea1..0aad5090 100644 --- a/openflare_server/main.go +++ b/openflare_server/main.go @@ -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)) diff --git a/openflare_server/model/acme_account.go b/openflare_server/model/acme_account.go new file mode 100644 index 00000000..863319e2 --- /dev/null +++ b/openflare_server/model/acme_account.go @@ -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 +} diff --git a/openflare_server/model/database_schema_version.go b/openflare_server/model/database_schema_version.go index 11cf1219..065446a6 100644 --- a/openflare_server/model/database_schema_version.go +++ b/openflare_server/model/database_schema_version.go @@ -4,7 +4,7 @@ import "time" const ( legacyDatabaseSchemaVersion = 1 - currentDatabaseSchemaVersion = 10 + currentDatabaseSchemaVersion = 11 databaseSchemaVersionRowID = 1 ) diff --git a/openflare_server/model/dns_account.go b/openflare_server/model/dns_account.go new file mode 100644 index 00000000..0d8e7295 --- /dev/null +++ b/openflare_server/model/dns_account.go @@ -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 +} diff --git a/openflare_server/model/main.go b/openflare_server/model/main.go index 0b35e857..87f590e3 100644 --- a/openflare_server/model/main.go +++ b/openflare_server/model/main.go @@ -41,6 +41,8 @@ func registeredModels() []any { &NodeHealthEvent{}, &TLSCertificate{}, &ManagedDomain{}, + &AcmeAccount{}, + &DnsAccount{}, } } diff --git a/openflare_server/model/main_test.go b/openflare_server/model/main_test.go index da389d0b..73206aa0 100644 --- a/openflare_server/model/main_test.go +++ b/openflare_server/model/main_test.go @@ -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) }, diff --git a/openflare_server/model/migrations.go b/openflare_server/model/migrations.go index e9892147..fcfa9950 100644 --- a/openflare_server/model/migrations.go +++ b/openflare_server/model/migrations.go @@ -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) diff --git a/openflare_server/model/tls_certificate.go b/openflare_server/model/tls_certificate.go index cfd5f972..9a7f029b 100644 --- a/openflare_server/model/tls_certificate.go +++ b/openflare_server/model/tls_certificate.go @@ -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) { diff --git a/openflare_server/router/api-router.go b/openflare_server/router/api-router.go index 08dd89f2..9ac9b389 100644 --- a/openflare_server/router/api-router.go +++ b/openflare_server/router/api-router.go @@ -128,8 +128,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()) diff --git a/openflare_server/service/lego_client.go b/openflare_server/service/lego_client.go new file mode 100644 index 00000000..1347b022 --- /dev/null +++ b/openflare_server/service/lego_client.go @@ -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 = ®istration.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) +} diff --git a/openflare_server/service/tls_acme_test.go b/openflare_server/service/tls_acme_test.go new file mode 100644 index 00000000..8c01ac4b --- /dev/null +++ b/openflare_server/service/tls_acme_test.go @@ -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) + } +} diff --git a/openflare_server/service/tls_certificate.go b/openflare_server/service/tls_certificate.go index 6bcbfa05..cd870a0f 100644 --- a/openflare_server/service/tls_certificate.go +++ b/openflare_server/service/tls_certificate.go @@ -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) diff --git a/openflare_server/web/app/(dashboard)/dns-account/page.tsx b/openflare_server/web/app/(dashboard)/dns-account/page.tsx new file mode 100644 index 00000000..34b936bc --- /dev/null +++ b/openflare_server/web/app/(dashboard)/dns-account/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from 'next'; +import { DnsAccountsPage } from '@/features/dns-accounts/components/dns-accounts-page'; + +export const metadata: Metadata = { + title: 'DNS 账号 - OpenFlare', +}; + +export default function Page() { + return ; +} diff --git a/openflare_server/web/features/acme-accounts/api/acme-accounts.ts b/openflare_server/web/features/acme-accounts/api/acme-accounts.ts new file mode 100644 index 00000000..72217613 --- /dev/null +++ b/openflare_server/web/features/acme-accounts/api/acme-accounts.ts @@ -0,0 +1,6 @@ +import { apiRequest } from '@/lib/api/client'; +import type { AcmeAccountItem } from '@/features/acme-accounts/types'; + +export function getDefaultAcmeAccount() { + return apiRequest('/acme-accounts/default'); +} diff --git a/openflare_server/web/features/acme-accounts/types.ts b/openflare_server/web/features/acme-accounts/types.ts new file mode 100644 index 00000000..d535d2ca --- /dev/null +++ b/openflare_server/web/features/acme-accounts/types.ts @@ -0,0 +1,7 @@ +export interface AcmeAccountItem { + id: number; + email: string; + url: string; + created_at: string; + updated_at: string; +} diff --git a/openflare_server/web/features/dns-accounts/api/dns-accounts.ts b/openflare_server/web/features/dns-accounts/api/dns-accounts.ts new file mode 100644 index 00000000..f7e01829 --- /dev/null +++ b/openflare_server/web/features/dns-accounts/api/dns-accounts.ts @@ -0,0 +1,29 @@ +import { apiRequest } from '@/lib/api/client'; +import type { + DnsAccountItem, + DnsAccountMutationPayload, +} from '@/features/dns-accounts/types'; + +export function getDnsAccounts() { + return apiRequest('/dns-accounts/'); +} + +export function createDnsAccount(payload: DnsAccountMutationPayload) { + return apiRequest('/dns-accounts/', { + method: 'POST', + body: JSON.stringify(payload), + }); +} + +export function updateDnsAccount(id: number, payload: DnsAccountMutationPayload) { + return apiRequest(`/dns-accounts/${id}/update`, { + method: 'POST', + body: JSON.stringify(payload), + }); +} + +export function deleteDnsAccount(id: number) { + return apiRequest(`/dns-accounts/${id}/delete`, { + method: 'POST', + }); +} diff --git a/openflare_server/web/features/dns-accounts/components/dns-accounts-page.tsx b/openflare_server/web/features/dns-accounts/components/dns-accounts-page.tsx new file mode 100644 index 00000000..36994c58 --- /dev/null +++ b/openflare_server/web/features/dns-accounts/components/dns-accounts-page.tsx @@ -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 ( + <> +
+ + + 返回网站 + + setIsCreateOpen(true)}> + 添加账号 + +
+ } + /> + + {feedback ? : null} + + + {dnsAccountsQuery.isLoading ? ( + + ) : dnsAccountsQuery.isError ? ( + + ) : accounts.length === 0 ? ( + + ) : ( +
+ {accounts.map((account) => ( +
+
+
+

+ {account.name} ({account.type}) +

+
+

创建于:{formatDateTime(account.created_at)}

+
+
+
+ handleDelete(account)} + disabled={deleteMutation.isPending} + className="px-3 py-2 text-xs" + > + 删除 + +
+
+
+ ))} +
+ )} +
+ + + {isCreateOpen && ( + 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 ( + +
+ {error && } + + + + + + + + + + + + + {createMutation.isPending ? '提交中...' : '提交'} + + +
+ ); +} diff --git a/openflare_server/web/features/dns-accounts/types.ts b/openflare_server/web/features/dns-accounts/types.ts new file mode 100644 index 00000000..04754aa8 --- /dev/null +++ b/openflare_server/web/features/dns-accounts/types.ts @@ -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; +} diff --git a/openflare_server/web/features/shared/components/resource-primitives.tsx b/openflare_server/web/features/shared/components/resource-primitives.tsx index d6333c4a..07a7f854 100644 --- a/openflare_server/web/features/shared/components/resource-primitives.tsx +++ b/openflare_server/web/features/shared/components/resource-primitives.tsx @@ -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; diff --git a/openflare_server/web/features/tls-certificates/api/tls-certificates.ts b/openflare_server/web/features/tls-certificates/api/tls-certificates.ts index d35a195b..1bbf8803 100644 --- a/openflare_server/web/features/tls-certificates/api/tls-certificates.ts +++ b/openflare_server/web/features/tls-certificates/api/tls-certificates.ts @@ -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(`/tls-certificates/${id}/delete`, { + method: 'POST', + }); +} + +export function applyTlsCertificate(payload: TlsCertificateApplyPayload) { + return apiRequest('/tls-certificates/apply', { + method: 'POST', + body: JSON.stringify(payload), + }); +} + +export function renewTlsCertificate(id: number) { + return apiRequest(`/tls-certificates/${id}/renew`, { method: 'POST', }); } + +export function updateAcmeCertificate(id: number, payload: TlsCertificateApplyPayload) { + return apiRequest(`/tls-certificates/${id}/update-acme`, { + method: 'POST', + body: JSON.stringify(payload), + }); +} diff --git a/openflare_server/web/features/tls-certificates/components/tls-certificates-page.tsx b/openflare_server/web/features/tls-certificates/components/tls-certificates-page.tsx index fbf5cb8e..daf57458 100644 --- a/openflare_server/web/features/tls-certificates/components/tls-certificates-page.tsx +++ b/openflare_server/web/features/tls-certificates/components/tls-certificates-page.tsx @@ -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(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(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() { > 刷新证书 + + DNS 账号 + setIsImportOpen(true)}> - 添加证书 + 导入证书 + + setIsApplyOpen(true)}> + 申请证书 } @@ -159,6 +196,9 @@ export function TlsCertificatesPage() {

生效:{formatDateTime(certificate.not_before)}

到期:{formatDateTime(certificate.not_after)}

+

来源:{certificate.provider === 'acme' ? 'ACME 申请' : '手动上传'}

+ {certificate.apply_status === 'applying' &&

状态:申请中...

} + {certificate.apply_status === 'error' &&

状态:申请失败 ({certificate.apply_message})

}

备注:{certificate.remark || '暂无备注'}

@@ -178,6 +218,16 @@ export function TlsCertificatesPage() { > 编辑 + {certificate.provider === 'acme' && ( + handleRenewCertificate(certificate)} + disabled={renewCertificateMutation.isPending} + className="px-3 py-2 text-xs" + > + 续期 + + )} handleDeleteCertificate(certificate)} @@ -209,6 +259,33 @@ export function TlsCertificatesPage() { /> ) : null} + {isApplyOpen ? ( + setIsApplyOpen(false)} + onApplied={(certificate) => { + setFeedback({ + tone: 'success', + message: `证书 ${certificate.name} 申请任务已提交。`, + }); + }} + /> + ) : null} + + {editAcmeCertificate ? ( + setEditAcmeCertificate(null)} + editCertificate={editAcmeCertificate} + onApplied={(certificate) => { + setFeedback({ + tone: 'success', + message: `证书 ${certificate.name} 配置已更新,重新申请中...`, + }); + }} + /> + ) : null} + {isDetailOpen ? ( setIsDetailOpen(false)} onEdit={() => { setIsDetailOpen(false); - setIsEditorOpen(true); + const certificate = certificates.find( + (item) => item.id === selectedCertificateId, + ); + if (certificate) { + handleOpenCertificateEditor(certificate); + } }} onDelete={() => { const certificate = certificates.find( diff --git a/openflare_server/web/features/tls-certificates/types.ts b/openflare_server/web/features/tls-certificates/types.ts index d65f4e75..bd9d1d44 100644 --- a/openflare_server/web/features/tls-certificates/types.ts +++ b/openflare_server/web/features/tls-certificates/types.ts @@ -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; diff --git a/openflare_server/web/features/websites/components/certificate-apply-modal.tsx b/openflare_server/web/features/websites/components/certificate-apply-modal.tsx new file mode 100644 index 00000000..38331010 --- /dev/null +++ b/openflare_server/web/features/websites/components/certificate-apply-modal.tsx @@ -0,0 +1,250 @@ +'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({ + 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 ( + +
+ {feedback ? ( + + ) : null} + +
+ + + + + + +
+ + +