mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-07 08:06:37 +08:00
feat: add managed domain functionality with matching certificate feature
- Implemented managed domain CRUD operations in the backend with appropriate service and controller logic. - Added matching logic for managed domains to automatically suggest certificates based on domain input. - Enhanced the frontend to support managed domain management, including form handling and displaying match results. - Updated header component to include new managed domain routes. - Added tests for managed domain lifecycle and matching logic.
This commit is contained in:
@@ -1,9 +1,10 @@
|
|||||||
package common
|
package common
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/google/uuid"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
var StartTime = time.Now().Unix() // unit: second
|
var StartTime = time.Now().Unix() // unit: second
|
||||||
@@ -16,7 +17,7 @@ var HomePageLink = ""
|
|||||||
// Any options with "Secret", "Token" in its key won't be return by GetOptions
|
// Any options with "Secret", "Token" in its key won't be return by GetOptions
|
||||||
|
|
||||||
var SessionSecret = uuid.New().String()
|
var SessionSecret = uuid.New().String()
|
||||||
var SQLitePath = "gin-template.db"
|
var SQLitePath = "atsflare.db"
|
||||||
|
|
||||||
var OptionMap map[string]string
|
var OptionMap map[string]string
|
||||||
var OptionMapRWMutex sync.RWMutex
|
var OptionMapRWMutex sync.RWMutex
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"gin-template/service"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetManagedDomains(c *gin.Context) {
|
||||||
|
domains, err := service.ListManagedDomains()
|
||||||
|
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": domains,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateManagedDomain(c *gin.Context) {
|
||||||
|
var input service.ManagedDomainInput
|
||||||
|
if err := json.NewDecoder(c.Request.Body).Decode(&input); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "无效的参数",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
domain, err := service.CreateManagedDomain(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": domain,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateManagedDomain(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 service.ManagedDomainInput
|
||||||
|
if err = json.NewDecoder(c.Request.Body).Decode(&input); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "无效的参数",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
domain, err := service.UpdateManagedDomain(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": domain,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteManagedDomain(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
|
||||||
|
}
|
||||||
|
if err = service.DeleteManagedDomain(uint(id)); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func MatchManagedDomainCertificate(c *gin.Context) {
|
||||||
|
domain := strings.TrimSpace(c.Query("domain"))
|
||||||
|
result, err := service.MatchManagedDomainCertificate(domain)
|
||||||
|
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": result,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/glebarez/sqlite"
|
|
||||||
"gin-template/common"
|
"gin-template/common"
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
"gorm.io/driver/mysql"
|
"gorm.io/driver/mysql"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"os"
|
"os"
|
||||||
@@ -84,6 +84,10 @@ func InitDB() (err error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
err = db.AutoMigrate(&ManagedDomain{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
err = createRootAccountIfNeed()
|
err = createRootAccountIfNeed()
|
||||||
return err
|
return err
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type ManagedDomain struct {
|
||||||
|
ID uint `json:"id" gorm:"primaryKey"`
|
||||||
|
Domain string `json:"domain" gorm:"uniqueIndex;size:255;not null"`
|
||||||
|
CertID *uint `json:"cert_id"`
|
||||||
|
Enabled bool `json:"enabled" gorm:"not null;default:true"`
|
||||||
|
Remark string `json:"remark" gorm:"size:255"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListManagedDomains() (domains []*ManagedDomain, err error) {
|
||||||
|
err = DB.Order("id desc").Find(&domains).Error
|
||||||
|
return domains, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListEnabledManagedDomainsWithCertificate() (domains []*ManagedDomain, err error) {
|
||||||
|
err = DB.Where("enabled = ? AND cert_id IS NOT NULL", true).Order("id desc").Find(&domains).Error
|
||||||
|
return domains, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetManagedDomainByID(id uint) (*ManagedDomain, error) {
|
||||||
|
domain := &ManagedDomain{}
|
||||||
|
err := DB.First(domain, id).Error
|
||||||
|
return domain, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (domain *ManagedDomain) Insert() error {
|
||||||
|
return DB.Create(domain).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (domain *ManagedDomain) Update() error {
|
||||||
|
return DB.Save(domain).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (domain *ManagedDomain) Delete() error {
|
||||||
|
return DB.Delete(domain).Error
|
||||||
|
}
|
||||||
@@ -70,6 +70,15 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
proxyRoute.PUT("/:id", controller.UpdateProxyRoute)
|
proxyRoute.PUT("/:id", controller.UpdateProxyRoute)
|
||||||
proxyRoute.DELETE("/:id", controller.DeleteProxyRoute)
|
proxyRoute.DELETE("/:id", controller.DeleteProxyRoute)
|
||||||
}
|
}
|
||||||
|
managedDomainRoute := apiRouter.Group("/managed-domains")
|
||||||
|
managedDomainRoute.Use(middleware.AdminAuth())
|
||||||
|
{
|
||||||
|
managedDomainRoute.GET("/", controller.GetManagedDomains)
|
||||||
|
managedDomainRoute.GET("/match", controller.MatchManagedDomainCertificate)
|
||||||
|
managedDomainRoute.POST("/", controller.CreateManagedDomain)
|
||||||
|
managedDomainRoute.PUT("/:id", controller.UpdateManagedDomain)
|
||||||
|
managedDomainRoute.DELETE("/:id", controller.DeleteManagedDomain)
|
||||||
|
}
|
||||||
tlsCertificateRoute := apiRouter.Group("/tls-certificates")
|
tlsCertificateRoute := apiRouter.Group("/tls-certificates")
|
||||||
tlsCertificateRoute.Use(middleware.AdminAuth())
|
tlsCertificateRoute.Use(middleware.AdminAuth())
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package router_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gin-template/common"
|
||||||
|
"gin-template/router"
|
||||||
|
"github.com/gin-contrib/sessions"
|
||||||
|
"github.com/gin-contrib/sessions/cookie"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPhase2ManagedDomainLifecycle(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)
|
||||||
|
|
||||||
|
token := prepareRootToken(t)
|
||||||
|
wildcardCertPEM, wildcardKeyPEM := generateCertificatePairForRouterTest(t, []string{"*.example.com"})
|
||||||
|
exactCertPEM, exactKeyPEM := generateCertificatePairForRouterTest(t, []string{"api.example.com"})
|
||||||
|
|
||||||
|
wildcardResp := performJSONRequest(t, engine, token, http.MethodPost, "/api/tls-certificates/", map[string]any{
|
||||||
|
"name": "wildcard-cert",
|
||||||
|
"cert_pem": wildcardCertPEM,
|
||||||
|
"key_pem": wildcardKeyPEM,
|
||||||
|
})
|
||||||
|
var wildcardCertificate map[string]any
|
||||||
|
decodeResponseData(t, wildcardResp, &wildcardCertificate)
|
||||||
|
|
||||||
|
exactResp := performJSONRequest(t, engine, token, http.MethodPost, "/api/tls-certificates/", map[string]any{
|
||||||
|
"name": "exact-cert",
|
||||||
|
"cert_pem": exactCertPEM,
|
||||||
|
"key_pem": exactKeyPEM,
|
||||||
|
})
|
||||||
|
var exactCertificate map[string]any
|
||||||
|
decodeResponseData(t, exactResp, &exactCertificate)
|
||||||
|
|
||||||
|
wildcardID := uint(wildcardCertificate["id"].(float64))
|
||||||
|
exactID := uint(exactCertificate["id"].(float64))
|
||||||
|
|
||||||
|
createWildcard := performJSONRequest(t, engine, token, http.MethodPost, "/api/managed-domains/", map[string]any{
|
||||||
|
"domain": "*.example.com",
|
||||||
|
"cert_id": wildcardID,
|
||||||
|
"enabled": true,
|
||||||
|
"remark": "wildcard binding",
|
||||||
|
})
|
||||||
|
var wildcardDomain map[string]any
|
||||||
|
decodeResponseData(t, createWildcard, &wildcardDomain)
|
||||||
|
|
||||||
|
createExact := performJSONRequest(t, engine, token, http.MethodPost, "/api/managed-domains/", map[string]any{
|
||||||
|
"domain": "api.example.com",
|
||||||
|
"cert_id": exactID,
|
||||||
|
"enabled": true,
|
||||||
|
"remark": "exact binding",
|
||||||
|
})
|
||||||
|
var exactDomain map[string]any
|
||||||
|
decodeResponseData(t, createExact, &exactDomain)
|
||||||
|
|
||||||
|
listResp := performJSONRequest(t, engine, token, http.MethodGet, "/api/managed-domains/", nil)
|
||||||
|
var domains []map[string]any
|
||||||
|
decodeResponseData(t, listResp, &domains)
|
||||||
|
if len(domains) != 2 {
|
||||||
|
t.Fatalf("expected 2 managed domains, got %d", len(domains))
|
||||||
|
}
|
||||||
|
|
||||||
|
matchResp := performJSONRequest(t, engine, token, http.MethodGet, "/api/managed-domains/match?domain=api.example.com", nil)
|
||||||
|
var matchResult map[string]any
|
||||||
|
decodeResponseData(t, matchResp, &matchResult)
|
||||||
|
if matched, ok := matchResult["matched"].(bool); !ok || !matched {
|
||||||
|
t.Fatalf("expected exact domain to be matched, got %#v", matchResult)
|
||||||
|
}
|
||||||
|
candidate, ok := matchResult["candidate"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected candidate payload, got %#v", matchResult["candidate"])
|
||||||
|
}
|
||||||
|
if candidate["match_type"] != "exact" {
|
||||||
|
t.Fatalf("expected exact match type, got %#v", candidate["match_type"])
|
||||||
|
}
|
||||||
|
if uint(candidate["certificate_id"].(float64)) != exactID {
|
||||||
|
t.Fatalf("expected exact certificate id %d, got %#v", exactID, candidate["certificate_id"])
|
||||||
|
}
|
||||||
|
|
||||||
|
updateResp := performJSONRequest(t, engine, token, http.MethodPut, "/api/managed-domains/"+toString(uint(exactDomain["id"].(float64))), map[string]any{
|
||||||
|
"domain": "api.example.com",
|
||||||
|
"cert_id": exactID,
|
||||||
|
"enabled": false,
|
||||||
|
"remark": "disabled exact binding",
|
||||||
|
})
|
||||||
|
decodeResponseData(t, updateResp, &exactDomain)
|
||||||
|
|
||||||
|
matchResp = performJSONRequest(t, engine, token, http.MethodGet, "/api/managed-domains/match?domain=api.example.com", nil)
|
||||||
|
decodeResponseData(t, matchResp, &matchResult)
|
||||||
|
candidate, ok = matchResult["candidate"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected wildcard fallback candidate, got %#v", matchResult["candidate"])
|
||||||
|
}
|
||||||
|
if candidate["match_type"] != "wildcard" {
|
||||||
|
t.Fatalf("expected wildcard fallback, got %#v", candidate["match_type"])
|
||||||
|
}
|
||||||
|
if uint(candidate["certificate_id"].(float64)) != wildcardID {
|
||||||
|
t.Fatalf("expected wildcard certificate id %d, got %#v", wildcardID, candidate["certificate_id"])
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteResp := performJSONRequest(t, engine, token, http.MethodDelete, "/api/managed-domains/"+toString(uint(wildcardDomain["id"].(float64))), nil)
|
||||||
|
if !deleteResp.Success {
|
||||||
|
t.Fatalf("expected delete success, got %s", deleteResp.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"gin-template/model"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ManagedDomainMatchTypeExact = "exact"
|
||||||
|
ManagedDomainMatchTypeWildcard = "wildcard"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ManagedDomainInput struct {
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
CertID *uint `json:"cert_id"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ManagedDomainMatchCandidate struct {
|
||||||
|
ManagedDomainID uint `json:"managed_domain_id"`
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
MatchType string `json:"match_type"`
|
||||||
|
CertificateID uint `json:"certificate_id"`
|
||||||
|
CertificateName string `json:"certificate_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ManagedDomainMatchResult struct {
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
Matched bool `json:"matched"`
|
||||||
|
Candidate *ManagedDomainMatchCandidate `json:"candidate,omitempty"`
|
||||||
|
Candidates []ManagedDomainMatchCandidate `json:"candidates"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListManagedDomains() ([]*model.ManagedDomain, error) {
|
||||||
|
return model.ListManagedDomains()
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateManagedDomain(input ManagedDomainInput) (*model.ManagedDomain, error) {
|
||||||
|
domain, err := buildManagedDomain(nil, input)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err = domain.Insert(); err != nil {
|
||||||
|
if isUniqueConstraintError(err) {
|
||||||
|
return nil, errors.New("域名已存在")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return domain, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateManagedDomain(id uint, input ManagedDomainInput) (*model.ManagedDomain, error) {
|
||||||
|
domain, err := model.GetManagedDomainByID(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
domain, err = buildManagedDomain(domain, input)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err = domain.Update(); err != nil {
|
||||||
|
if isUniqueConstraintError(err) {
|
||||||
|
return nil, errors.New("域名已存在")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return domain, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteManagedDomain(id uint) error {
|
||||||
|
domain, err := model.GetManagedDomainByID(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return domain.Delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
func MatchManagedDomainCertificate(rawDomain string) (*ManagedDomainMatchResult, error) {
|
||||||
|
domain := normalizeManagedDomain(rawDomain)
|
||||||
|
if err := validateManagedDomainPattern(domain); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
managedDomains, err := model.ListEnabledManagedDomainsWithCertificate()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
candidates := make([]ManagedDomainMatchCandidate, 0)
|
||||||
|
for _, item := range managedDomains {
|
||||||
|
if item.CertID == nil || *item.CertID == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
matchType := detectManagedDomainMatchType(item.Domain, domain)
|
||||||
|
if matchType == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
certificate, err := model.GetTLSCertificateByID(*item.CertID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("托管域名 %s 关联证书不存在", item.Domain)
|
||||||
|
}
|
||||||
|
candidates = append(candidates, ManagedDomainMatchCandidate{
|
||||||
|
ManagedDomainID: item.ID,
|
||||||
|
Domain: item.Domain,
|
||||||
|
MatchType: matchType,
|
||||||
|
CertificateID: certificate.ID,
|
||||||
|
CertificateName: certificate.Name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sortManagedDomainCandidates(candidates)
|
||||||
|
result := &ManagedDomainMatchResult{
|
||||||
|
Domain: domain,
|
||||||
|
Matched: len(candidates) > 0,
|
||||||
|
Candidates: candidates,
|
||||||
|
}
|
||||||
|
if len(candidates) > 0 {
|
||||||
|
candidate := candidates[0]
|
||||||
|
result.Candidate = &candidate
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildManagedDomain(existing *model.ManagedDomain, input ManagedDomainInput) (*model.ManagedDomain, error) {
|
||||||
|
domain := normalizeManagedDomain(input.Domain)
|
||||||
|
remark := strings.TrimSpace(input.Remark)
|
||||||
|
if err := validateManagedDomainPattern(domain); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if input.CertID != nil && *input.CertID != 0 {
|
||||||
|
if _, err := model.GetTLSCertificateByID(*input.CertID); err != nil {
|
||||||
|
return nil, errors.New("所选证书不存在")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
input.CertID = nil
|
||||||
|
}
|
||||||
|
if existing == nil {
|
||||||
|
existing = &model.ManagedDomain{}
|
||||||
|
}
|
||||||
|
existing.Domain = domain
|
||||||
|
existing.CertID = input.CertID
|
||||||
|
existing.Enabled = input.Enabled
|
||||||
|
existing.Remark = remark
|
||||||
|
return existing, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeManagedDomain(domain string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(domain))
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateManagedDomainPattern(domain string) error {
|
||||||
|
if domain == "" {
|
||||||
|
return errors.New("域名不能为空")
|
||||||
|
}
|
||||||
|
if strings.Contains(domain, "://") || strings.Contains(domain, "/") {
|
||||||
|
return errors.New("域名格式不合法")
|
||||||
|
}
|
||||||
|
if strings.Contains(domain, "*") {
|
||||||
|
if !strings.HasPrefix(domain, "*.") || strings.Count(domain, "*") != 1 {
|
||||||
|
return errors.New("通配符域名仅支持 *.example.com 格式")
|
||||||
|
}
|
||||||
|
return validateHostname(strings.TrimPrefix(domain, "*."))
|
||||||
|
}
|
||||||
|
return validateHostname(domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateHostname(domain string) error {
|
||||||
|
if domain == "" {
|
||||||
|
return errors.New("域名不能为空")
|
||||||
|
}
|
||||||
|
if len(domain) > 253 {
|
||||||
|
return errors.New("域名格式不合法")
|
||||||
|
}
|
||||||
|
labels := strings.Split(domain, ".")
|
||||||
|
if len(labels) < 2 {
|
||||||
|
return errors.New("域名格式不合法")
|
||||||
|
}
|
||||||
|
for _, label := range labels {
|
||||||
|
if len(label) == 0 || len(label) > 63 {
|
||||||
|
return errors.New("域名格式不合法")
|
||||||
|
}
|
||||||
|
if label[0] == '-' || label[len(label)-1] == '-' {
|
||||||
|
return errors.New("域名格式不合法")
|
||||||
|
}
|
||||||
|
for _, r := range label {
|
||||||
|
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return errors.New("域名格式不合法")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectManagedDomainMatchType(pattern string, domain string) string {
|
||||||
|
if pattern == domain {
|
||||||
|
return ManagedDomainMatchTypeExact
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(pattern, "*.") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
suffix := strings.TrimPrefix(pattern, "*.")
|
||||||
|
if !strings.HasSuffix(domain, "."+suffix) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
prefix := strings.TrimSuffix(domain, "."+suffix)
|
||||||
|
if prefix == "" || strings.Contains(prefix, ".") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return ManagedDomainMatchTypeWildcard
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortManagedDomainCandidates(candidates []ManagedDomainMatchCandidate) {
|
||||||
|
sort.Slice(candidates, func(i int, j int) bool {
|
||||||
|
left := candidates[i]
|
||||||
|
right := candidates[j]
|
||||||
|
if left.MatchType != right.MatchType {
|
||||||
|
return left.MatchType == ManagedDomainMatchTypeExact
|
||||||
|
}
|
||||||
|
if len(left.Domain) != len(right.Domain) {
|
||||||
|
return len(left.Domain) > len(right.Domain)
|
||||||
|
}
|
||||||
|
return left.ManagedDomainID < right.ManagedDomainID
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestMatchManagedDomainCertificatePrefersExactMatch(t *testing.T) {
|
||||||
|
setupServiceTestDB(t)
|
||||||
|
|
||||||
|
wildcardCertPEM, wildcardKeyPEM := generateCertificatePair(t, []string{"*.example.com"})
|
||||||
|
wildcardCert, err := CreateTLSCertificate(TLSCertificateInput{
|
||||||
|
Name: "wildcard-cert",
|
||||||
|
CertPEM: wildcardCertPEM,
|
||||||
|
KeyPEM: wildcardKeyPEM,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create wildcard certificate: %v", err)
|
||||||
|
}
|
||||||
|
exactCertPEM, exactKeyPEM := generateCertificatePair(t, []string{"api.example.com"})
|
||||||
|
exactCert, err := CreateTLSCertificate(TLSCertificateInput{
|
||||||
|
Name: "exact-cert",
|
||||||
|
CertPEM: exactCertPEM,
|
||||||
|
KeyPEM: exactKeyPEM,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create exact certificate: %v", err)
|
||||||
|
}
|
||||||
|
if _, err = CreateManagedDomain(ManagedDomainInput{
|
||||||
|
Domain: "*.example.com",
|
||||||
|
CertID: &wildcardCert.ID,
|
||||||
|
Enabled: true,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("failed to create wildcard managed domain: %v", err)
|
||||||
|
}
|
||||||
|
if _, err = CreateManagedDomain(ManagedDomainInput{
|
||||||
|
Domain: "api.example.com",
|
||||||
|
CertID: &exactCert.ID,
|
||||||
|
Enabled: true,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("failed to create exact managed domain: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := MatchManagedDomainCertificate("api.example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MatchManagedDomainCertificate failed: %v", err)
|
||||||
|
}
|
||||||
|
if !result.Matched || result.Candidate == nil {
|
||||||
|
t.Fatal("expected exact domain to be matched")
|
||||||
|
}
|
||||||
|
if result.Candidate.MatchType != ManagedDomainMatchTypeExact {
|
||||||
|
t.Fatalf("expected exact match first, got %s", result.Candidate.MatchType)
|
||||||
|
}
|
||||||
|
if result.Candidate.CertificateID != exactCert.ID {
|
||||||
|
t.Fatalf("expected exact certificate %d, got %d", exactCert.ID, result.Candidate.CertificateID)
|
||||||
|
}
|
||||||
|
if len(result.Candidates) != 2 {
|
||||||
|
t.Fatalf("expected 2 match candidates, got %d", len(result.Candidates))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchManagedDomainCertificateSupportsWildcard(t *testing.T) {
|
||||||
|
setupServiceTestDB(t)
|
||||||
|
|
||||||
|
certPEM, keyPEM := generateCertificatePair(t, []string{"*.example.com"})
|
||||||
|
certificate, err := CreateTLSCertificate(TLSCertificateInput{
|
||||||
|
Name: "wildcard-cert",
|
||||||
|
CertPEM: certPEM,
|
||||||
|
KeyPEM: keyPEM,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create certificate: %v", err)
|
||||||
|
}
|
||||||
|
if _, err = CreateManagedDomain(ManagedDomainInput{
|
||||||
|
Domain: "*.example.com",
|
||||||
|
CertID: &certificate.ID,
|
||||||
|
Enabled: true,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("failed to create managed domain: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := MatchManagedDomainCertificate("edge.example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MatchManagedDomainCertificate failed: %v", err)
|
||||||
|
}
|
||||||
|
if !result.Matched || result.Candidate == nil {
|
||||||
|
t.Fatal("expected wildcard domain to be matched")
|
||||||
|
}
|
||||||
|
if result.Candidate.MatchType != ManagedDomainMatchTypeWildcard {
|
||||||
|
t.Fatalf("expected wildcard match, got %s", result.Candidate.MatchType)
|
||||||
|
}
|
||||||
|
|
||||||
|
deepResult, err := MatchManagedDomainCertificate("deep.edge.example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MatchManagedDomainCertificate failed: %v", err)
|
||||||
|
}
|
||||||
|
if deepResult.Matched {
|
||||||
|
t.Fatal("expected single-level wildcard not to match deep subdomain")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateManagedDomainRejectsInvalidWildcard(t *testing.T) {
|
||||||
|
setupServiceTestDB(t)
|
||||||
|
|
||||||
|
_, err := CreateManagedDomain(ManagedDomainInput{
|
||||||
|
Domain: "*.*.example.com",
|
||||||
|
Enabled: true,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected invalid wildcard domain to fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import ConfigVersion from './pages/ConfigVersion';
|
|||||||
import Node from './pages/Node';
|
import Node from './pages/Node';
|
||||||
import ApplyLog from './pages/ApplyLog';
|
import ApplyLog from './pages/ApplyLog';
|
||||||
import TLSCertificate from './pages/TLSCertificate';
|
import TLSCertificate from './pages/TLSCertificate';
|
||||||
|
import ManagedDomain from './pages/ManagedDomain';
|
||||||
|
|
||||||
const Home = lazy(() => import('./pages/Home'));
|
const Home = lazy(() => import('./pages/Home'));
|
||||||
const About = lazy(() => import('./pages/About'));
|
const About = lazy(() => import('./pages/About'));
|
||||||
@@ -109,6 +110,14 @@ function App() {
|
|||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path='/managed-domain'
|
||||||
|
element={
|
||||||
|
<PrivateRoute>
|
||||||
|
<ManagedDomain />
|
||||||
|
</PrivateRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path='/tls-certificate'
|
path='/tls-certificate'
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ const headerButtons = [
|
|||||||
icon: 'lock',
|
icon: 'lock',
|
||||||
admin: true,
|
admin: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: '域名',
|
||||||
|
to: '/managed-domain',
|
||||||
|
icon: 'globe',
|
||||||
|
admin: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: '文件',
|
name: '文件',
|
||||||
to: '/file',
|
to: '/file',
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Button, Dropdown, Form, Header, Label, Segment, Table } from 'semantic-ui-react';
|
||||||
|
import { API, formatDateTime, showError, showSuccess } from '../../helpers';
|
||||||
|
|
||||||
|
const initialForm = {
|
||||||
|
domain: '',
|
||||||
|
cert_id: '',
|
||||||
|
enabled: true,
|
||||||
|
remark: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ManagedDomain = () => {
|
||||||
|
const [domains, setDomains] = useState([]);
|
||||||
|
const [certificates, setCertificates] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [form, setForm] = useState(initialForm);
|
||||||
|
const [editingId, setEditingId] = useState(null);
|
||||||
|
|
||||||
|
const loadCertificates = async () => {
|
||||||
|
const res = await API.get('/api/tls-certificates/');
|
||||||
|
const { success, message, data } = res.data;
|
||||||
|
if (success) {
|
||||||
|
setCertificates(data || []);
|
||||||
|
} else {
|
||||||
|
showError(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadDomains = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await API.get('/api/managed-domains/');
|
||||||
|
const { success, message, data } = res.data;
|
||||||
|
if (success) {
|
||||||
|
setDomains(data || []);
|
||||||
|
} else {
|
||||||
|
showError(message);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadCertificates().then();
|
||||||
|
loadDomains().then();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const certificateMap = useMemo(() => {
|
||||||
|
const map = new Map();
|
||||||
|
certificates.forEach((certificate) => {
|
||||||
|
map.set(certificate.id, certificate);
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [certificates]);
|
||||||
|
|
||||||
|
const certificateOptions = certificates.map((certificate) => ({
|
||||||
|
key: certificate.id,
|
||||||
|
value: certificate.id,
|
||||||
|
text: certificate.name,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setForm(initialForm);
|
||||||
|
setEditingId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitManagedDomain = async () => {
|
||||||
|
setSubmitting(true);
|
||||||
|
const payload = {
|
||||||
|
domain: form.domain.trim(),
|
||||||
|
cert_id: form.cert_id ? Number(form.cert_id) : null,
|
||||||
|
enabled: form.enabled,
|
||||||
|
remark: form.remark.trim(),
|
||||||
|
};
|
||||||
|
const res = editingId
|
||||||
|
? await API.put(`/api/managed-domains/${editingId}`, payload)
|
||||||
|
: await API.post('/api/managed-domains/', payload);
|
||||||
|
const { success, message } = res.data;
|
||||||
|
if (success) {
|
||||||
|
showSuccess(editingId ? '域名规则已更新' : '域名规则已创建');
|
||||||
|
resetForm();
|
||||||
|
await loadDomains();
|
||||||
|
} else {
|
||||||
|
showError(message);
|
||||||
|
}
|
||||||
|
setSubmitting(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteManagedDomain = async (id) => {
|
||||||
|
const res = await API.delete(`/api/managed-domains/${id}`);
|
||||||
|
const { success, message } = res.data;
|
||||||
|
if (success) {
|
||||||
|
showSuccess('域名规则已删除');
|
||||||
|
await loadDomains();
|
||||||
|
} else {
|
||||||
|
showError(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const beginEdit = (domain) => {
|
||||||
|
setEditingId(domain.id);
|
||||||
|
setForm({
|
||||||
|
domain: domain.domain,
|
||||||
|
cert_id: domain.cert_id || '',
|
||||||
|
enabled: domain.enabled,
|
||||||
|
remark: domain.remark || '',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Segment loading={loading}>
|
||||||
|
<Header as='h3'>域名管理</Header>
|
||||||
|
<p className='page-subtitle'>维护精确域名与通配符域名,并为其绑定默认 TLS 证书。</p>
|
||||||
|
|
||||||
|
<Form onSubmit={submitManagedDomain}>
|
||||||
|
<Form.Group widths='equal'>
|
||||||
|
<Form.Input
|
||||||
|
label='域名'
|
||||||
|
placeholder='example.com 或 *.example.com'
|
||||||
|
value={form.domain}
|
||||||
|
onChange={(e, { value }) => setForm({ ...form, domain: value })}
|
||||||
|
/>
|
||||||
|
<Form.Field
|
||||||
|
control={Dropdown}
|
||||||
|
selection
|
||||||
|
clearable
|
||||||
|
label='默认证书'
|
||||||
|
placeholder='可选,绑定默认 TLS 证书'
|
||||||
|
options={certificateOptions}
|
||||||
|
value={form.cert_id}
|
||||||
|
onChange={(e, { value }) => setForm({ ...form, cert_id: value || '' })}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Group widths='equal'>
|
||||||
|
<Form.Input
|
||||||
|
label='备注'
|
||||||
|
placeholder='可选备注'
|
||||||
|
value={form.remark}
|
||||||
|
onChange={(e, { value }) => setForm({ ...form, remark: value })}
|
||||||
|
/>
|
||||||
|
<Form.Checkbox
|
||||||
|
toggle
|
||||||
|
label='启用规则'
|
||||||
|
checked={form.enabled}
|
||||||
|
onChange={(e, { checked }) => setForm({ ...form, enabled: checked })}
|
||||||
|
style={{ alignSelf: 'flex-end', marginBottom: '1rem' }}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
<Button primary type='submit' loading={submitting}>
|
||||||
|
{editingId ? '保存修改' : '新增域名'}
|
||||||
|
</Button>
|
||||||
|
{editingId ? (
|
||||||
|
<Button type='button' onClick={resetForm}>
|
||||||
|
取消编辑
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<Table celled stackable className='atsf-table'>
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Row>
|
||||||
|
<Table.HeaderCell>域名</Table.HeaderCell>
|
||||||
|
<Table.HeaderCell>绑定证书</Table.HeaderCell>
|
||||||
|
<Table.HeaderCell>状态</Table.HeaderCell>
|
||||||
|
<Table.HeaderCell>备注</Table.HeaderCell>
|
||||||
|
<Table.HeaderCell>更新时间</Table.HeaderCell>
|
||||||
|
<Table.HeaderCell>操作</Table.HeaderCell>
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body>
|
||||||
|
{domains.map((domain) => {
|
||||||
|
const certificate = domain.cert_id ? certificateMap.get(domain.cert_id) : null;
|
||||||
|
return (
|
||||||
|
<Table.Row key={domain.id}>
|
||||||
|
<Table.Cell>{domain.domain}</Table.Cell>
|
||||||
|
<Table.Cell>{certificate ? certificate.name : '未绑定'}</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
{domain.enabled ? <Label color='green'>启用</Label> : <Label>停用</Label>}
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>{domain.remark || '无'}</Table.Cell>
|
||||||
|
<Table.Cell>{formatDateTime(domain.updated_at)}</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Button size='small' onClick={() => beginEdit(domain)}>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Button size='small' negative onClick={() => deleteManagedDomain(domain.id)}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Table.Body>
|
||||||
|
</Table>
|
||||||
|
</Segment>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ManagedDomain;
|
||||||
@@ -27,6 +27,8 @@ const ProxyRoute = () => {
|
|||||||
const [certificates, setCertificates] = useState([]);
|
const [certificates, setCertificates] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [publishing, setPublishing] = useState(false);
|
const [publishing, setPublishing] = useState(false);
|
||||||
|
const [matching, setMatching] = useState(false);
|
||||||
|
const [matchResult, setMatchResult] = useState(null);
|
||||||
const [form, setForm] = useState(initialForm);
|
const [form, setForm] = useState(initialForm);
|
||||||
const [editingId, setEditingId] = useState(null);
|
const [editingId, setEditingId] = useState(null);
|
||||||
|
|
||||||
@@ -57,9 +59,45 @@ const ProxyRoute = () => {
|
|||||||
loadCertificates().then();
|
loadCertificates().then();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!form.enable_https) {
|
||||||
|
setMatchResult(null);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const domain = form.domain.trim().toLowerCase();
|
||||||
|
if (!domain) {
|
||||||
|
setMatchResult(null);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const timer = setTimeout(async () => {
|
||||||
|
setMatching(true);
|
||||||
|
const res = await API.get('/api/managed-domains/match', {
|
||||||
|
params: { domain },
|
||||||
|
});
|
||||||
|
const { success, message, data } = res.data;
|
||||||
|
if (success) {
|
||||||
|
setMatchResult(data || null);
|
||||||
|
if (data?.matched && data?.candidate?.certificate_id) {
|
||||||
|
setForm((current) => {
|
||||||
|
if (!current.enable_https || current.domain.trim().toLowerCase() !== domain || current.cert_id) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
return { ...current, cert_id: data.candidate.certificate_id };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setMatchResult(null);
|
||||||
|
showError(message);
|
||||||
|
}
|
||||||
|
setMatching(false);
|
||||||
|
}, 400);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [form.domain, form.enable_https]);
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setForm(initialForm);
|
setForm(initialForm);
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
|
setMatchResult(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitRoute = async () => {
|
const submitRoute = async () => {
|
||||||
@@ -117,6 +155,7 @@ const ProxyRoute = () => {
|
|||||||
redirect_http: route.redirect_http || false,
|
redirect_http: route.redirect_http || false,
|
||||||
remark: route.remark || '',
|
remark: route.remark || '',
|
||||||
});
|
});
|
||||||
|
setMatchResult(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const certificateOptions = certificates.map((certificate) => ({
|
const certificateOptions = certificates.map((certificate) => ({
|
||||||
@@ -180,6 +219,17 @@ const ProxyRoute = () => {
|
|||||||
onChange={(e, { value }) => setForm({ ...form, cert_id: value || '' })}
|
onChange={(e, { value }) => setForm({ ...form, cert_id: value || '' })}
|
||||||
/>
|
/>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
|
{form.enable_https ? (
|
||||||
|
<p className='page-subtitle' style={{ marginTop: '-0.5rem' }}>
|
||||||
|
{matching
|
||||||
|
? '正在按域名自动匹配证书...'
|
||||||
|
: matchResult?.matched
|
||||||
|
? `已匹配${matchResult.candidate?.match_type === 'exact' ? '精确' : '通配符'}规则 ${matchResult.candidate?.domain},推荐证书:${matchResult.candidate?.certificate_name}`
|
||||||
|
: form.domain.trim()
|
||||||
|
? '未找到匹配证书,可继续手动选择。'
|
||||||
|
: '输入域名后会自动匹配托管证书。'}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
<Form.Group widths='equal'>
|
<Form.Group widths='equal'>
|
||||||
<Form.Field
|
<Form.Field
|
||||||
control={TextArea}
|
control={TextArea}
|
||||||
|
|||||||
Reference in New Issue
Block a user