package service import ( "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "gin-template/model" "sort" "strings" "time" "gorm.io/gorm" ) type ReleaseResult struct { Version *model.ConfigVersion `json:"version"` Routes []*model.ProxyRoute `json:"routes"` } type SupportFile struct { Path string `json:"path"` Content string `json:"content"` } const nginxCertDirPlaceholder = "__ATSF_CERT_DIR__" func ListConfigVersions() ([]*model.ConfigVersion, error) { return model.ListConfigVersions() } func GetActiveConfigVersion() (*model.ConfigVersion, error) { return model.GetActiveConfigVersion() } func PublishConfigVersion(createdBy string) (*ReleaseResult, error) { routes, err := model.GetEnabledProxyRoutes() if err != nil { return nil, err } if len(routes) == 0 { return nil, errors.New("没有可发布的启用规则") } snapshotJSON, err := renderSnapshot(routes) if err != nil { return nil, err } renderedConfig, supportFiles, err := renderNginxConfig(routes) if err != nil { return nil, err } supportFilesJSON, err := json.Marshal(supportFiles) if err != nil { return nil, err } version, err := nextVersionNumber(time.Now()) if err != nil { return nil, err } record := &model.ConfigVersion{ Version: version, SnapshotJSON: snapshotJSON, RenderedConfig: renderedConfig, SupportFilesJSON: string(supportFilesJSON), Checksum: checksumBundle(renderedConfig, supportFiles), IsActive: true, CreatedBy: createdBy, } err = model.DB.Transaction(func(tx *gorm.DB) error { if err := tx.Model(&model.ConfigVersion{}).Where("is_active = ?", true).Update("is_active", false).Error; err != nil { return err } if err := tx.Create(record).Error; err != nil { return err } return nil }) if err != nil { if isUniqueConstraintError(err) { return nil, errors.New("版本号生成冲突,请重试") } return nil, err } return &ReleaseResult{ Version: record, Routes: routes, }, nil } func ActivateConfigVersion(id uint) (*model.ConfigVersion, error) { version, err := model.GetConfigVersionByID(id) if err != nil { return nil, err } err = model.DB.Transaction(func(tx *gorm.DB) error { if err := tx.Model(&model.ConfigVersion{}).Where("is_active = ?", true).Update("is_active", false).Error; err != nil { return err } if err := tx.Model(version).Update("is_active", true).Error; err != nil { return err } return nil }) if err != nil { return nil, err } version.IsActive = true return version, nil } func renderSnapshot(routes []*model.ProxyRoute) (string, error) { type snapshotRoute struct { Domain string `json:"domain"` OriginURL string `json:"origin_url"` Enabled bool `json:"enabled"` EnableHTTPS bool `json:"enable_https"` CertID *uint `json:"cert_id,omitempty"` RedirectHTTP bool `json:"redirect_http"` Remark string `json:"remark,omitempty"` } items := make([]snapshotRoute, 0, len(routes)) for _, route := range routes { items = append(items, snapshotRoute{ Domain: route.Domain, OriginURL: route.OriginURL, Enabled: route.Enabled, EnableHTTPS: route.EnableHTTPS, CertID: route.CertID, RedirectHTTP: route.RedirectHTTP, Remark: route.Remark, }) } data, err := json.Marshal(items) if err != nil { return "", err } return string(data), nil } func renderNginxConfig(routes []*model.ProxyRoute) (string, []SupportFile, error) { var builder strings.Builder builder.WriteString("# This file is generated by ATSFlare. Do not edit manually.\n") supportFiles := make([]SupportFile, 0) for _, route := range routes { if !route.EnableHTTPS { builder.WriteString(renderHTTPProxyServer(route.Domain, route.OriginURL)) continue } if route.CertID == nil || *route.CertID == 0 { return "", nil, fmt.Errorf("路由 %s 未配置证书", route.Domain) } certificate, err := model.GetTLSCertificateByID(*route.CertID) if err != nil { return "", nil, fmt.Errorf("路由 %s 关联证书不存在", route.Domain) } supportFiles = append(supportFiles, SupportFile{Path: certificateCertFileName(certificate.ID), Content: normalizePEM(certificate.CertPEM)}, SupportFile{Path: certificateKeyFileName(certificate.ID), Content: normalizePEM(certificate.KeyPEM)}, ) if route.RedirectHTTP { builder.WriteString(renderHTTPRedirectServer(route.Domain)) } else { builder.WriteString(renderHTTPProxyServer(route.Domain, route.OriginURL)) } builder.WriteString(renderHTTPSServer(route.Domain, route.OriginURL, certificate.ID)) } return builder.String(), dedupeSupportFiles(supportFiles), nil } func checksum(content string) string { sum := sha256.Sum256([]byte(content)) return hex.EncodeToString(sum[:]) } func checksumBundle(renderedConfig string, supportFiles []SupportFile) string { var builder strings.Builder builder.WriteString(renderedConfig) builder.WriteString("\n--support-files--\n") files := dedupeSupportFiles(supportFiles) sort.Slice(files, func(i int, j int) bool { return files[i].Path < files[j].Path }) for _, file := range files { builder.WriteString(file.Path) builder.WriteString("\n") builder.WriteString(file.Content) builder.WriteString("\n") } return checksum(builder.String()) } func nextVersionNumber(now time.Time) (string, error) { prefix := now.Format("20060102") var count int64 if err := model.DB.Model(&model.ConfigVersion{}).Where("version LIKE ?", prefix+"-%").Count(&count).Error; err != nil { return "", err } return fmt.Sprintf("%s-%03d", prefix, count+1), nil } func renderHTTPProxyServer(domain string, originURL string) string { return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n\n location / {\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto $scheme;\n proxy_pass %s;\n }\n}\n\n", domain, originURL) } func renderHTTPRedirectServer(domain string) string { return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n\n return 301 https://$host$request_uri;\n}\n\n", domain) } func renderHTTPSServer(domain string, originURL string, certificateID uint) string { certPath := fmt.Sprintf("%s/%s", nginxCertDirPlaceholder, certificateCertFileName(certificateID)) keyPath := fmt.Sprintf("%s/%s", nginxCertDirPlaceholder, certificateKeyFileName(certificateID)) return fmt.Sprintf("server {\n listen 443 ssl;\n server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n\n location / {\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto $scheme;\n proxy_pass %s;\n }\n}\n\n", domain, certPath, keyPath, originURL) } func certificateCertFileName(id uint) string { return fmt.Sprintf("%d.crt", id) } func certificateKeyFileName(id uint) string { return fmt.Sprintf("%d.key", id) } func normalizePEM(content string) string { return strings.TrimSpace(content) + "\n" } func dedupeSupportFiles(files []SupportFile) []SupportFile { if len(files) == 0 { return nil } unique := make(map[string]SupportFile, len(files)) for _, file := range files { unique[file.Path] = file } result := make([]SupportFile, 0, len(unique)) for _, file := range unique { result = append(result, file) } return result }