From 7be2da0c198e3981a8beaadb2d20446c0553b66e Mon Sep 17 00:00:00 2001 From: ryan Date: Tue, 10 Mar 2026 13:07:16 +0800 Subject: [PATCH] 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. --- atsf_server/common/constants.go | 5 +- atsf_server/controller/managed_domain.go | 121 +++++ atsf_server/model/main.go | 6 +- atsf_server/model/managed_domain.go | 41 ++ atsf_server/router/api-router.go | 9 + .../router/api_phase2_managed_domain_test.go | 112 +++++ atsf_server/service/managed_domain.go | 227 +++++++++ .../service/managed_domain_phase2_test.go | 109 ++++ atsf_server/web/src/App.js | 455 ++++++++--------- atsf_server/web/src/components/Header.js | 466 +++++++++--------- .../web/src/pages/ManagedDomain/index.js | 198 ++++++++ atsf_server/web/src/pages/ProxyRoute/index.js | 50 ++ 12 files changed, 1343 insertions(+), 456 deletions(-) create mode 100644 atsf_server/controller/managed_domain.go create mode 100644 atsf_server/model/managed_domain.go create mode 100644 atsf_server/router/api_phase2_managed_domain_test.go create mode 100644 atsf_server/service/managed_domain.go create mode 100644 atsf_server/service/managed_domain_phase2_test.go create mode 100644 atsf_server/web/src/pages/ManagedDomain/index.js diff --git a/atsf_server/common/constants.go b/atsf_server/common/constants.go index 51fc579f..9dfb4273 100644 --- a/atsf_server/common/constants.go +++ b/atsf_server/common/constants.go @@ -1,9 +1,10 @@ package common import ( - "github.com/google/uuid" "sync" "time" + + "github.com/google/uuid" ) 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 var SessionSecret = uuid.New().String() -var SQLitePath = "gin-template.db" +var SQLitePath = "atsflare.db" var OptionMap map[string]string var OptionMapRWMutex sync.RWMutex diff --git a/atsf_server/controller/managed_domain.go b/atsf_server/controller/managed_domain.go new file mode 100644 index 00000000..15ec4321 --- /dev/null +++ b/atsf_server/controller/managed_domain.go @@ -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, + }) +} diff --git a/atsf_server/model/main.go b/atsf_server/model/main.go index c1e29142..335e450e 100644 --- a/atsf_server/model/main.go +++ b/atsf_server/model/main.go @@ -1,8 +1,8 @@ package model import ( - "github.com/glebarez/sqlite" "gin-template/common" + "github.com/glebarez/sqlite" "gorm.io/driver/mysql" "gorm.io/gorm" "os" @@ -84,6 +84,10 @@ func InitDB() (err error) { if err != nil { return err } + err = db.AutoMigrate(&ManagedDomain{}) + if err != nil { + return err + } err = createRootAccountIfNeed() return err } else { diff --git a/atsf_server/model/managed_domain.go b/atsf_server/model/managed_domain.go new file mode 100644 index 00000000..2dacc761 --- /dev/null +++ b/atsf_server/model/managed_domain.go @@ -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 +} diff --git a/atsf_server/router/api-router.go b/atsf_server/router/api-router.go index 28a8d5d1..1e65b615 100644 --- a/atsf_server/router/api-router.go +++ b/atsf_server/router/api-router.go @@ -70,6 +70,15 @@ func SetApiRouter(router *gin.Engine) { proxyRoute.PUT("/:id", controller.UpdateProxyRoute) 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.Use(middleware.AdminAuth()) { diff --git a/atsf_server/router/api_phase2_managed_domain_test.go b/atsf_server/router/api_phase2_managed_domain_test.go new file mode 100644 index 00000000..e8efd228 --- /dev/null +++ b/atsf_server/router/api_phase2_managed_domain_test.go @@ -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) + } +} diff --git a/atsf_server/service/managed_domain.go b/atsf_server/service/managed_domain.go new file mode 100644 index 00000000..7543d9a5 --- /dev/null +++ b/atsf_server/service/managed_domain.go @@ -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 + }) +} diff --git a/atsf_server/service/managed_domain_phase2_test.go b/atsf_server/service/managed_domain_phase2_test.go new file mode 100644 index 00000000..ecc6af6e --- /dev/null +++ b/atsf_server/service/managed_domain_phase2_test.go @@ -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") + } +} diff --git a/atsf_server/web/src/App.js b/atsf_server/web/src/App.js index 2d9b9e99..38ed8f3f 100644 --- a/atsf_server/web/src/App.js +++ b/atsf_server/web/src/App.js @@ -1,223 +1,232 @@ -import React, { lazy, Suspense, useContext, useEffect } from 'react'; -import { Route, Routes } from 'react-router-dom'; -import Loading from './components/Loading'; -import User from './pages/User'; -import { PrivateRoute } from './components/PrivateRoute'; -import RegisterForm from './components/RegisterForm'; -import LoginForm from './components/LoginForm'; -import NotFound from './pages/NotFound'; -import Setting from './pages/Setting'; -import EditUser from './pages/User/EditUser'; -import AddUser from './pages/User/AddUser'; -import { API, showError, showNotice } from './helpers'; -import PasswordResetForm from './components/PasswordResetForm'; -import GitHubOAuth from './components/GitHubOAuth'; -import PasswordResetConfirm from './components/PasswordResetConfirm'; -import { UserContext } from './context/User'; -import { StatusContext } from './context/Status'; -import File from './pages/File'; -import ProxyRoute from './pages/ProxyRoute'; -import ConfigVersion from './pages/ConfigVersion'; -import Node from './pages/Node'; -import ApplyLog from './pages/ApplyLog'; -import TLSCertificate from './pages/TLSCertificate'; - -const Home = lazy(() => import('./pages/Home')); -const About = lazy(() => import('./pages/About')); - -function App() { - const [userState, userDispatch] = useContext(UserContext); - const [statusState, statusDispatch] = useContext(StatusContext); - - const loadUser = () => { - let user = localStorage.getItem('user'); - if (user) { - let data = JSON.parse(user); - userDispatch({ type: 'login', payload: data }); - } - }; - const loadStatus = async () => { - const res = await API.get('/api/status'); - const { success, data } = res.data; - if (success) { - console.log( - `GitHub 仓库地址:https://github.com/songquanpeng/gin-template` - ); - localStorage.setItem('status', JSON.stringify(data)); - statusDispatch({ type: 'set', payload: data }); - localStorage.setItem('system_name', data.system_name); - localStorage.setItem('footer_html', data.footer_html); - localStorage.setItem('home_page_link', data.home_page_link); - if ( - data.version !== process.env.REACT_APP_VERSION && - data.version !== 'v0.0.0' && - process.env.REACT_APP_VERSION !== '' - ) { - showNotice( - `新版本可用:${data.version},请使用快捷键 Shift + F5 刷新页面` - ); - } - } else { - showError('无法正常连接至服务器!'); - } - }; - - useEffect(() => { - loadUser(); - loadStatus().then(); - }, []); - - return ( - - }> - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - }> - - - } - /> - }> - - - } - /> - }> - - - } - /> - }> - - - } - /> - }> - - - } - /> - }> - - - } - /> - }> - - - } - /> - }> - - - } - /> - - }> - - - - } - /> - }> - - - } - /> - } /> - - ); -} - -export default App; +import React, { lazy, Suspense, useContext, useEffect } from 'react'; +import { Route, Routes } from 'react-router-dom'; +import Loading from './components/Loading'; +import User from './pages/User'; +import { PrivateRoute } from './components/PrivateRoute'; +import RegisterForm from './components/RegisterForm'; +import LoginForm from './components/LoginForm'; +import NotFound from './pages/NotFound'; +import Setting from './pages/Setting'; +import EditUser from './pages/User/EditUser'; +import AddUser from './pages/User/AddUser'; +import { API, showError, showNotice } from './helpers'; +import PasswordResetForm from './components/PasswordResetForm'; +import GitHubOAuth from './components/GitHubOAuth'; +import PasswordResetConfirm from './components/PasswordResetConfirm'; +import { UserContext } from './context/User'; +import { StatusContext } from './context/Status'; +import File from './pages/File'; +import ProxyRoute from './pages/ProxyRoute'; +import ConfigVersion from './pages/ConfigVersion'; +import Node from './pages/Node'; +import ApplyLog from './pages/ApplyLog'; +import TLSCertificate from './pages/TLSCertificate'; +import ManagedDomain from './pages/ManagedDomain'; + +const Home = lazy(() => import('./pages/Home')); +const About = lazy(() => import('./pages/About')); + +function App() { + const [userState, userDispatch] = useContext(UserContext); + const [statusState, statusDispatch] = useContext(StatusContext); + + const loadUser = () => { + let user = localStorage.getItem('user'); + if (user) { + let data = JSON.parse(user); + userDispatch({ type: 'login', payload: data }); + } + }; + const loadStatus = async () => { + const res = await API.get('/api/status'); + const { success, data } = res.data; + if (success) { + console.log( + `GitHub 仓库地址:https://github.com/songquanpeng/gin-template` + ); + localStorage.setItem('status', JSON.stringify(data)); + statusDispatch({ type: 'set', payload: data }); + localStorage.setItem('system_name', data.system_name); + localStorage.setItem('footer_html', data.footer_html); + localStorage.setItem('home_page_link', data.home_page_link); + if ( + data.version !== process.env.REACT_APP_VERSION && + data.version !== 'v0.0.0' && + process.env.REACT_APP_VERSION !== '' + ) { + showNotice( + `新版本可用:${data.version},请使用快捷键 Shift + F5 刷新页面` + ); + } + } else { + showError('无法正常连接至服务器!'); + } + }; + + useEffect(() => { + loadUser(); + loadStatus().then(); + }, []); + + return ( + + }> + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + }> + + + } + /> + }> + + + } + /> + }> + + + } + /> + }> + + + } + /> + }> + + + } + /> + }> + + + } + /> + }> + + + } + /> + }> + + + } + /> + + }> + + + + } + /> + }> + + + } + /> + } /> + + ); +} + +export default App; diff --git a/atsf_server/web/src/components/Header.js b/atsf_server/web/src/components/Header.js index 80062aac..f596580a 100644 --- a/atsf_server/web/src/components/Header.js +++ b/atsf_server/web/src/components/Header.js @@ -1,230 +1,236 @@ -import React, { useContext, useState } from 'react'; -import { Link, useNavigate } from 'react-router-dom'; -import { UserContext } from '../context/User'; - -import { - Button, - Container, - Dropdown, - Icon, - Menu, - Segment, -} from 'semantic-ui-react'; -import { API, getSystemName, isAdmin, isMobile, showSuccess } from '../helpers'; -import '../index.css'; - -// Header Buttons -const headerButtons = [ - { - name: '首页', - to: '/', - icon: 'home', - }, - { - name: '规则', - to: '/proxy-route', - icon: 'sitemap', - admin: true, - }, - { - name: '版本', - to: '/config-version', - icon: 'history', - admin: true, - }, - { - name: '节点', - to: '/node', - icon: 'server', - admin: true, - }, - { - name: '记录', - to: '/apply-log', - icon: 'tasks', - admin: true, - }, - { - name: '证书', - to: '/tls-certificate', - icon: 'lock', - admin: true, - }, - { - name: '文件', - to: '/file', - icon: 'file', - admin: true, - }, - { - name: '用户', - to: '/user', - icon: 'user', - admin: true, - }, - { - name: '设置', - to: '/setting', - icon: 'setting', - }, - { - name: '关于', - to: '/about', - icon: 'info circle', - }, -]; - -const Header = () => { - const [userState, userDispatch] = useContext(UserContext); - let navigate = useNavigate(); - - const [showSidebar, setShowSidebar] = useState(false); - const systemName = getSystemName(); - - async function logout() { - setShowSidebar(false); - await API.get('/api/user/logout'); - showSuccess('注销成功!'); - userDispatch({ type: 'logout' }); - localStorage.removeItem('user'); - navigate('/login'); - } - - const toggleSidebar = () => { - setShowSidebar(!showSidebar); - }; - - const renderButtons = (isMobile) => { - return headerButtons.map((button) => { - if (button.admin && !isAdmin()) return <>; - if (isMobile) { - return ( - { - navigate(button.to); - setShowSidebar(false); - }} - > - {button.name} - - ); - } - return ( - - - {button.name} - - ); - }); - }; - - if (isMobile()) { - return ( - <> - - - - logo -
- {systemName} -
-
- - - - - -
-
- {showSidebar ? ( - - - {renderButtons(true)} - - {userState.user ? ( - - ) : ( - <> - - - - )} - - - - ) : ( - <> - )} - - ); - } - - return ( - <> - - - - logo -
- {systemName} -
-
- {renderButtons(false)} - - {userState.user ? ( - - - 注销 - - - ) : ( - - )} - -
-
- - ); -}; - -export default Header; +import React, { useContext, useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { UserContext } from '../context/User'; + +import { + Button, + Container, + Dropdown, + Icon, + Menu, + Segment, +} from 'semantic-ui-react'; +import { API, getSystemName, isAdmin, isMobile, showSuccess } from '../helpers'; +import '../index.css'; + +// Header Buttons +const headerButtons = [ + { + name: '首页', + to: '/', + icon: 'home', + }, + { + name: '规则', + to: '/proxy-route', + icon: 'sitemap', + admin: true, + }, + { + name: '版本', + to: '/config-version', + icon: 'history', + admin: true, + }, + { + name: '节点', + to: '/node', + icon: 'server', + admin: true, + }, + { + name: '记录', + to: '/apply-log', + icon: 'tasks', + admin: true, + }, + { + name: '证书', + to: '/tls-certificate', + icon: 'lock', + admin: true, + }, + { + name: '域名', + to: '/managed-domain', + icon: 'globe', + admin: true, + }, + { + name: '文件', + to: '/file', + icon: 'file', + admin: true, + }, + { + name: '用户', + to: '/user', + icon: 'user', + admin: true, + }, + { + name: '设置', + to: '/setting', + icon: 'setting', + }, + { + name: '关于', + to: '/about', + icon: 'info circle', + }, +]; + +const Header = () => { + const [userState, userDispatch] = useContext(UserContext); + let navigate = useNavigate(); + + const [showSidebar, setShowSidebar] = useState(false); + const systemName = getSystemName(); + + async function logout() { + setShowSidebar(false); + await API.get('/api/user/logout'); + showSuccess('注销成功!'); + userDispatch({ type: 'logout' }); + localStorage.removeItem('user'); + navigate('/login'); + } + + const toggleSidebar = () => { + setShowSidebar(!showSidebar); + }; + + const renderButtons = (isMobile) => { + return headerButtons.map((button) => { + if (button.admin && !isAdmin()) return <>; + if (isMobile) { + return ( + { + navigate(button.to); + setShowSidebar(false); + }} + > + {button.name} + + ); + } + return ( + + + {button.name} + + ); + }); + }; + + if (isMobile()) { + return ( + <> + + + + logo +
+ {systemName} +
+
+ + + + + +
+
+ {showSidebar ? ( + + + {renderButtons(true)} + + {userState.user ? ( + + ) : ( + <> + + + + )} + + + + ) : ( + <> + )} + + ); + } + + return ( + <> + + + + logo +
+ {systemName} +
+
+ {renderButtons(false)} + + {userState.user ? ( + + + 注销 + + + ) : ( + + )} + +
+
+ + ); +}; + +export default Header; diff --git a/atsf_server/web/src/pages/ManagedDomain/index.js b/atsf_server/web/src/pages/ManagedDomain/index.js new file mode 100644 index 00000000..e02f129c --- /dev/null +++ b/atsf_server/web/src/pages/ManagedDomain/index.js @@ -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 ( + +
域名管理
+

维护精确域名与通配符域名,并为其绑定默认 TLS 证书。

+ +
+ + setForm({ ...form, domain: value })} + /> + setForm({ ...form, cert_id: value || '' })} + /> + + + setForm({ ...form, remark: value })} + /> + setForm({ ...form, enabled: checked })} + style={{ alignSelf: 'flex-end', marginBottom: '1rem' }} + /> + + + {editingId ? ( + + ) : null} +
+ + + + + 域名 + 绑定证书 + 状态 + 备注 + 更新时间 + 操作 + + + + {domains.map((domain) => { + const certificate = domain.cert_id ? certificateMap.get(domain.cert_id) : null; + return ( + + {domain.domain} + {certificate ? certificate.name : '未绑定'} + + {domain.enabled ? : } + + {domain.remark || '无'} + {formatDateTime(domain.updated_at)} + + + + + + ); + })} + +
+
+ ); +}; + +export default ManagedDomain; diff --git a/atsf_server/web/src/pages/ProxyRoute/index.js b/atsf_server/web/src/pages/ProxyRoute/index.js index bc1d6f0e..6881f64e 100644 --- a/atsf_server/web/src/pages/ProxyRoute/index.js +++ b/atsf_server/web/src/pages/ProxyRoute/index.js @@ -27,6 +27,8 @@ const ProxyRoute = () => { const [certificates, setCertificates] = useState([]); const [loading, setLoading] = useState(false); const [publishing, setPublishing] = useState(false); + const [matching, setMatching] = useState(false); + const [matchResult, setMatchResult] = useState(null); const [form, setForm] = useState(initialForm); const [editingId, setEditingId] = useState(null); @@ -57,9 +59,45 @@ const ProxyRoute = () => { 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 = () => { setForm(initialForm); setEditingId(null); + setMatchResult(null); }; const submitRoute = async () => { @@ -117,6 +155,7 @@ const ProxyRoute = () => { redirect_http: route.redirect_http || false, remark: route.remark || '', }); + setMatchResult(null); }; const certificateOptions = certificates.map((certificate) => ({ @@ -180,6 +219,17 @@ const ProxyRoute = () => { onChange={(e, { value }) => setForm({ ...form, cert_id: value || '' })} /> + {form.enable_https ? ( +

+ {matching + ? '正在按域名自动匹配证书...' + : matchResult?.matched + ? `已匹配${matchResult.candidate?.match_type === 'exact' ? '精确' : '通配符'}规则 ${matchResult.candidate?.domain},推荐证书:${matchResult.candidate?.certificate_name}` + : form.domain.trim() + ? '未找到匹配证书,可继续手动选择。' + : '输入域名后会自动匹配托管证书。'} +

+ ) : null}