diff --git a/atsf_server/controller/config_version.go b/atsf_server/controller/config_version.go index f5750c1a..7a674fc1 100644 --- a/atsf_server/controller/config_version.go +++ b/atsf_server/controller/config_version.go @@ -39,6 +39,38 @@ func GetActiveConfigVersion(c *gin.Context) { }) } +func PreviewConfigVersion(c *gin.Context) { + preview, err := service.PreviewConfigVersion() + 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": preview, + }) +} + +func DiffConfigVersion(c *gin.Context) { + diff, err := service.DiffConfigVersion() + 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": diff, + }) +} + func PublishConfigVersion(c *gin.Context) { username := c.GetString("username") result, err := service.PublishConfigVersion(username) diff --git a/atsf_server/model/proxy_route.go b/atsf_server/model/proxy_route.go index f0fb707c..cc29682b 100644 --- a/atsf_server/model/proxy_route.go +++ b/atsf_server/model/proxy_route.go @@ -3,16 +3,17 @@ package model import "time" type ProxyRoute struct { - ID uint `json:"id" gorm:"primaryKey"` - Domain string `json:"domain" gorm:"uniqueIndex;size:255;not null"` - OriginURL string `json:"origin_url" gorm:"size:2048;not null"` - Enabled bool `json:"enabled" gorm:"not null;default:true"` - EnableHTTPS bool `json:"enable_https" gorm:"not null;default:false"` - CertID *uint `json:"cert_id"` - RedirectHTTP bool `json:"redirect_http" gorm:"not null;default:false"` - 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"` + Domain string `json:"domain" gorm:"uniqueIndex;size:255;not null"` + OriginURL string `json:"origin_url" gorm:"size:2048;not null"` + Enabled bool `json:"enabled" gorm:"not null;default:true"` + EnableHTTPS bool `json:"enable_https" gorm:"not null;default:false"` + CertID *uint `json:"cert_id"` + RedirectHTTP bool `json:"redirect_http" gorm:"not null;default:false"` + CustomHeaders string `json:"custom_headers" gorm:"type:text;not null;default:'[]'"` + Remark string `json:"remark" gorm:"size:255"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } func ListProxyRoutes() (routes []*ProxyRoute, err error) { @@ -36,7 +37,7 @@ func (route *ProxyRoute) Insert() error { } func (route *ProxyRoute) Update() error { - return DB.Model(route).Select("domain", "origin_url", "enabled", "enable_https", "cert_id", "redirect_http", "remark").Updates(route).Error + return DB.Model(route).Select("domain", "origin_url", "enabled", "enable_https", "cert_id", "redirect_http", "custom_headers", "remark").Updates(route).Error } func (route *ProxyRoute) Delete() error { diff --git a/atsf_server/router/api-router.go b/atsf_server/router/api-router.go index 6b060d3e..8bdf7396 100644 --- a/atsf_server/router/api-router.go +++ b/atsf_server/router/api-router.go @@ -92,6 +92,8 @@ func SetApiRouter(router *gin.Engine) { { configVersionRoute.GET("/", controller.GetConfigVersions) configVersionRoute.GET("/active", controller.GetActiveConfigVersion) + configVersionRoute.GET("/preview", controller.PreviewConfigVersion) + configVersionRoute.GET("/diff", controller.DiffConfigVersion) configVersionRoute.POST("/publish", controller.PublishConfigVersion) configVersionRoute.PUT("/:id/activate", controller.ActivateConfigVersion) } diff --git a/atsf_server/router/api_phase2_test.go b/atsf_server/router/api_phase2_test.go index 9afbd237..aad77515 100644 --- a/atsf_server/router/api_phase2_test.go +++ b/atsf_server/router/api_phase2_test.go @@ -12,6 +12,7 @@ import ( "github.com/gin-gonic/gin" "net/http" "net/http/httptest" + "strings" "testing" "time" ) @@ -154,6 +155,69 @@ func TestPhase2AgentLifecycle(t *testing.T) { } } +func TestPhase2CustomHeadersPreviewAndDiffLifecycle(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) + + createResp := performJSONRequest(t, engine, token, http.MethodPost, "/api/proxy-routes/", map[string]any{ + "domain": "preview.example.com", + "origin_url": "https://origin-a.internal", + "enabled": true, + "custom_headers": []map[string]any{ + {"key": "X-Trace-Id", "value": "$request_id"}, + }, + }) + var createdRoute model.ProxyRoute + decodeResponseData(t, createResp, &createdRoute) + if !strings.Contains(createdRoute.CustomHeaders, "X-Trace-Id") { + t.Fatalf("expected custom headers to be stored as json, got %s", createdRoute.CustomHeaders) + } + + performJSONRequest(t, engine, token, http.MethodPost, "/api/config-versions/publish", nil) + + performJSONRequest(t, engine, token, http.MethodPut, "/api/proxy-routes/"+toString(createdRoute.ID), map[string]any{ + "domain": "preview.example.com", + "origin_url": "https://origin-b.internal", + "enabled": true, + "custom_headers": []map[string]any{ + {"key": "X-Trace-Id", "value": "$request_id"}, + {"key": "X-Release", "value": "candidate"}, + }, + }) + performJSONRequest(t, engine, token, http.MethodPost, "/api/proxy-routes/", map[string]any{ + "domain": "new-preview.example.com", + "origin_url": "https://origin-new.internal", + "enabled": true, + }) + + previewResp := performJSONRequest(t, engine, token, http.MethodGet, "/api/config-versions/preview", nil) + var preview map[string]any + decodeResponseData(t, previewResp, &preview) + renderedConfig, _ := preview["rendered_config"].(string) + if !strings.Contains(renderedConfig, `proxy_set_header X-Release "candidate";`) { + t.Fatalf("expected preview endpoint to return custom header, got %s", renderedConfig) + } + + diffResp := performJSONRequest(t, engine, token, http.MethodGet, "/api/config-versions/diff", nil) + var diff map[string]any + decodeResponseData(t, diffResp, &diff) + modifiedDomains, ok := diff["modified_domains"].([]any) + if !ok || len(modifiedDomains) != 1 || modifiedDomains[0].(string) != "preview.example.com" { + t.Fatalf("unexpected modified domains: %#v", diff["modified_domains"]) + } + addedDomains, ok := diff["added_domains"].([]any) + if !ok || len(addedDomains) != 1 || addedDomains[0].(string) != "new-preview.example.com" { + t.Fatalf("unexpected added domains: %#v", diff["added_domains"]) + } +} + func TestPhase2GlobalDiscoveryRegistration(t *testing.T) { gin.SetMode(gin.TestMode) common.RedisEnabled = false diff --git a/atsf_server/service/config_version.go b/atsf_server/service/config_version.go index 068b2f8c..95dfe702 100644 --- a/atsf_server/service/config_version.go +++ b/atsf_server/service/config_version.go @@ -24,6 +24,41 @@ type SupportFile struct { Content string `json:"content"` } +type ConfigPreviewResult struct { + SnapshotJSON string `json:"snapshot_json"` + RenderedConfig string `json:"rendered_config"` + SupportFiles []SupportFile `json:"support_files"` + Checksum string `json:"checksum"` + RouteCount int `json:"route_count"` +} + +type ConfigDiffResult struct { + ActiveVersion string `json:"active_version,omitempty"` + AddedDomains []string `json:"added_domains"` + RemovedDomains []string `json:"removed_domains"` + ModifiedDomains []string `json:"modified_domains"` +} + +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"` + CustomHeaders []ProxyRouteCustomHeaderInput `json:"custom_headers,omitempty"` + Remark string `json:"remark,omitempty"` +} + +type configBundle struct { + Routes []*model.ProxyRoute + SnapshotRoutes []snapshotRoute + SnapshotJSON string + RenderedConfig string + SupportFiles []SupportFile + Checksum string +} + const nginxCertDirPlaceholder = "__ATSF_CERT_DIR__" func ListConfigVersions() ([]*model.ConfigVersion, error) { @@ -34,23 +69,83 @@ func GetActiveConfigVersion() (*model.ConfigVersion, error) { return model.GetActiveConfigVersion() } -func PublishConfigVersion(createdBy string) (*ReleaseResult, error) { - routes, err := model.GetEnabledProxyRoutes() +func PreviewConfigVersion() (*ConfigPreviewResult, error) { + bundle, err := buildCurrentConfigBundle(false) if err != nil { return nil, err } - if len(routes) == 0 { + return &ConfigPreviewResult{ + SnapshotJSON: bundle.SnapshotJSON, + RenderedConfig: bundle.RenderedConfig, + SupportFiles: bundle.SupportFiles, + Checksum: bundle.Checksum, + RouteCount: len(bundle.Routes), + }, nil +} + +func DiffConfigVersion() (*ConfigDiffResult, error) { + bundle, err := buildCurrentConfigBundle(false) + if err != nil { + return nil, err + } + result := &ConfigDiffResult{ + AddedDomains: []string{}, + RemovedDomains: []string{}, + ModifiedDomains: []string{}, + } + activeVersion, err := model.GetActiveConfigVersion() + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + for _, route := range bundle.SnapshotRoutes { + result.AddedDomains = append(result.AddedDomains, route.Domain) + } + return result, nil + } + return nil, err + } + result.ActiveVersion = activeVersion.Version + activeRoutes, err := parseSnapshotRoutes(activeVersion.SnapshotJSON) + if err != nil { + return nil, err + } + currentMap := make(map[string]snapshotRoute, len(bundle.SnapshotRoutes)) + for _, route := range bundle.SnapshotRoutes { + currentMap[route.Domain] = route + } + activeMap := make(map[string]snapshotRoute, len(activeRoutes)) + for _, route := range activeRoutes { + activeMap[route.Domain] = route + } + for domain, currentRoute := range currentMap { + activeRoute, ok := activeMap[domain] + if !ok { + result.AddedDomains = append(result.AddedDomains, domain) + continue + } + if !snapshotRouteConfigEqual(activeRoute, currentRoute) { + result.ModifiedDomains = append(result.ModifiedDomains, domain) + } + } + for domain := range activeMap { + if _, ok := currentMap[domain]; !ok { + result.RemovedDomains = append(result.RemovedDomains, domain) + } + } + sort.Strings(result.AddedDomains) + sort.Strings(result.RemovedDomains) + sort.Strings(result.ModifiedDomains) + return result, nil +} + +func PublishConfigVersion(createdBy string) (*ReleaseResult, error) { + bundle, err := buildCurrentConfigBundle(true) + if err != nil { + return nil, err + } + if len(bundle.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) + supportFilesJSON, err := json.Marshal(bundle.SupportFiles) if err != nil { return nil, err } @@ -60,10 +155,10 @@ func PublishConfigVersion(createdBy string) (*ReleaseResult, error) { } record := &model.ConfigVersion{ Version: version, - SnapshotJSON: snapshotJSON, - RenderedConfig: renderedConfig, + SnapshotJSON: bundle.SnapshotJSON, + RenderedConfig: bundle.RenderedConfig, SupportFilesJSON: string(supportFilesJSON), - Checksum: checksumBundle(renderedConfig, supportFiles), + Checksum: bundle.Checksum, IsActive: true, CreatedBy: createdBy, } @@ -84,7 +179,7 @@ func PublishConfigVersion(createdBy string) (*ReleaseResult, error) { } return &ReleaseResult{ Version: record, - Routes: routes, + Routes: bundle.Routes, }, nil } @@ -110,26 +205,9 @@ func ActivateConfigVersion(id uint) (*model.ConfigVersion, error) { } 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, - }) + items, err := buildSnapshotRoutes(routes) + if err != nil { + return "", err } data, err := json.Marshal(items) if err != nil { @@ -143,8 +221,12 @@ func renderNginxConfig(routes []*model.ProxyRoute) (string, []SupportFile, error builder.WriteString("# This file is generated by ATSFlare. Do not edit manually.\n") supportFiles := make([]SupportFile, 0) for _, route := range routes { + customHeaders, err := decodeStoredCustomHeaders(route.CustomHeaders) + if err != nil { + return "", nil, fmt.Errorf("路由 %s 自定义请求头无效", route.Domain) + } if !route.EnableHTTPS { - builder.WriteString(renderHTTPProxyServer(route.Domain, route.OriginURL)) + builder.WriteString(renderHTTPProxyServer(route.Domain, route.OriginURL, customHeaders)) continue } if route.CertID == nil || *route.CertID == 0 { @@ -161,13 +243,105 @@ func renderNginxConfig(routes []*model.ProxyRoute) (string, []SupportFile, error if route.RedirectHTTP { builder.WriteString(renderHTTPRedirectServer(route.Domain)) } else { - builder.WriteString(renderHTTPProxyServer(route.Domain, route.OriginURL)) + builder.WriteString(renderHTTPProxyServer(route.Domain, route.OriginURL, customHeaders)) } - builder.WriteString(renderHTTPSServer(route.Domain, route.OriginURL, certificate.ID)) + builder.WriteString(renderHTTPSServer(route.Domain, route.OriginURL, certificate.ID, customHeaders)) } return builder.String(), dedupeSupportFiles(supportFiles), nil } +func buildCurrentConfigBundle(requireRoutes bool) (*configBundle, error) { + routes, err := model.GetEnabledProxyRoutes() + if err != nil { + return nil, err + } + if requireRoutes && len(routes) == 0 { + return nil, errors.New("没有可发布的启用规则") + } + snapshotRoutes, err := buildSnapshotRoutes(routes) + if err != nil { + return nil, err + } + snapshotJSON, err := json.Marshal(snapshotRoutes) + if err != nil { + return nil, err + } + renderedConfig, supportFiles, err := renderNginxConfig(routes) + if err != nil { + return nil, err + } + return &configBundle{ + Routes: routes, + SnapshotRoutes: snapshotRoutes, + SnapshotJSON: string(snapshotJSON), + RenderedConfig: renderedConfig, + SupportFiles: supportFiles, + Checksum: checksumBundle(renderedConfig, supportFiles), + }, nil +} + +func buildSnapshotRoutes(routes []*model.ProxyRoute) ([]snapshotRoute, error) { + items := make([]snapshotRoute, 0, len(routes)) + for _, route := range routes { + customHeaders, err := decodeStoredCustomHeaders(route.CustomHeaders) + if err != nil { + return nil, fmt.Errorf("路由 %s 自定义请求头无效", route.Domain) + } + items = append(items, snapshotRoute{ + Domain: route.Domain, + OriginURL: route.OriginURL, + Enabled: route.Enabled, + EnableHTTPS: route.EnableHTTPS, + CertID: route.CertID, + RedirectHTTP: route.RedirectHTTP, + CustomHeaders: customHeaders, + Remark: route.Remark, + }) + } + return items, nil +} + +func parseSnapshotRoutes(snapshotJSON string) ([]snapshotRoute, error) { + text := strings.TrimSpace(snapshotJSON) + if text == "" { + return []snapshotRoute{}, nil + } + var routes []snapshotRoute + if err := json.Unmarshal([]byte(text), &routes); err != nil { + return nil, errors.New("历史版本快照格式不合法") + } + for index := range routes { + normalizedHeaders, err := normalizeCustomHeaders(routes[index].CustomHeaders) + if err != nil { + return nil, err + } + routes[index].CustomHeaders = normalizedHeaders + } + return routes, nil +} + +func snapshotRouteConfigEqual(left snapshotRoute, right snapshotRoute) bool { + if left.Domain != right.Domain || left.OriginURL != right.OriginURL || left.EnableHTTPS != right.EnableHTTPS || left.RedirectHTTP != right.RedirectHTTP || !uintPointerEqual(left.CertID, right.CertID) { + return false + } + if len(left.CustomHeaders) != len(right.CustomHeaders) { + return false + } + for index := range left.CustomHeaders { + if left.CustomHeaders[index] != right.CustomHeaders[index] { + return false + } + } + return true +} + +func uintPointerEqual(left *uint, right *uint) bool { + if left == nil || right == nil { + return left == nil && right == nil + } + return *left == *right +} + func checksum(content string) string { sum := sha256.Sum256([]byte(content)) return hex.EncodeToString(sum[:]) @@ -199,18 +373,36 @@ func nextVersionNumber(now time.Time) (string, error) { 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 renderHTTPProxyServer(domain string, originURL string, customHeaders []ProxyRouteCustomHeaderInput) string { + return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n\n location / {\n%s proxy_pass %s;\n }\n}\n\n", domain, renderProxyHeaderBlock(customHeaders), 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 { +func renderHTTPSServer(domain string, originURL string, certificateID uint, customHeaders []ProxyRouteCustomHeaderInput) 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) + 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%s proxy_pass %s;\n }\n}\n\n", domain, certPath, keyPath, renderProxyHeaderBlock(customHeaders), originURL) +} + +func renderProxyHeaderBlock(customHeaders []ProxyRouteCustomHeaderInput) string { + var builder strings.Builder + builder.WriteString(" proxy_set_header Host $host;\n") + builder.WriteString(" proxy_set_header X-Real-IP $remote_addr;\n") + builder.WriteString(" proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n") + builder.WriteString(" proxy_set_header X-Forwarded-Proto $scheme;\n") + for _, header := range customHeaders { + builder.WriteString(fmt.Sprintf(" proxy_set_header %s %s;\n", header.Key, quoteNginxHeaderValue(header.Value))) + } + return builder.String() +} + +func quoteNginxHeaderValue(value string) string { + escaped := strings.ReplaceAll(value, `\`, `\\`) + escaped = strings.ReplaceAll(escaped, `"`, `\"`) + return fmt.Sprintf(`"%s"`, escaped) } func certificateCertFileName(id uint) string { diff --git a/atsf_server/service/https_phase1_test.go b/atsf_server/service/https_phase1_test.go index 808fe377..71f87601 100644 --- a/atsf_server/service/https_phase1_test.go +++ b/atsf_server/service/https_phase1_test.go @@ -79,6 +79,127 @@ func TestCreateProxyRouteRejectsHTTPSWithoutCertificate(t *testing.T) { } } +func TestPublishConfigVersionRendersCustomHeaders(t *testing.T) { + setupServiceTestDB(t) + + _, err := CreateProxyRoute(ProxyRouteInput{ + Domain: "custom.example.com", + OriginURL: "https://origin.internal", + Enabled: true, + CustomHeaders: []ProxyRouteCustomHeaderInput{ + {Key: "X-Trace-Id", Value: "$request_id"}, + {Key: "X-Env", Value: "staging edge"}, + }, + }) + if err != nil { + t.Fatalf("CreateProxyRoute failed: %v", err) + } + + result, err := PublishConfigVersion("root") + if err != nil { + t.Fatalf("PublishConfigVersion failed: %v", err) + } + if !strings.Contains(result.Version.RenderedConfig, `proxy_set_header X-Trace-Id "$request_id";`) { + t.Fatal("expected rendered config to include custom header") + } + if !strings.Contains(result.Version.RenderedConfig, `proxy_set_header X-Env "staging edge";`) { + t.Fatal("expected rendered config to include quoted custom header value") + } + if !strings.Contains(result.Version.SnapshotJSON, "custom_headers") { + t.Fatal("expected snapshot to include custom headers") + } +} + +func TestPreviewAndDiffConfigVersion(t *testing.T) { + setupServiceTestDB(t) + + stableRoute, err := CreateProxyRoute(ProxyRouteInput{ + Domain: "stable.example.com", + OriginURL: "https://origin-a.internal", + Enabled: true, + }) + if err != nil { + t.Fatalf("CreateProxyRoute stable failed: %v", err) + } + modifiedRoute, err := CreateProxyRoute(ProxyRouteInput{ + Domain: "api.example.com", + OriginURL: "https://origin-api-a.internal", + Enabled: true, + }) + if err != nil { + t.Fatalf("CreateProxyRoute modified failed: %v", err) + } + removedRoute, err := CreateProxyRoute(ProxyRouteInput{ + Domain: "old.example.com", + OriginURL: "https://origin-old.internal", + Enabled: true, + }) + if err != nil { + t.Fatalf("CreateProxyRoute removed failed: %v", err) + } + if _, err = PublishConfigVersion("root"); err != nil { + t.Fatalf("initial PublishConfigVersion failed: %v", err) + } + + if _, err = UpdateProxyRoute(modifiedRoute.ID, ProxyRouteInput{ + Domain: "api.example.com", + OriginURL: "https://origin-api-b.internal", + Enabled: true, + CustomHeaders: []ProxyRouteCustomHeaderInput{ + {Key: "X-Release", Value: "candidate"}, + }, + }); err != nil { + t.Fatalf("UpdateProxyRoute failed: %v", err) + } + if _, err = UpdateProxyRoute(removedRoute.ID, ProxyRouteInput{ + Domain: "old.example.com", + OriginURL: "https://origin-old.internal", + Enabled: false, + }); err != nil { + t.Fatalf("disable removed route failed: %v", err) + } + if _, err = CreateProxyRoute(ProxyRouteInput{ + Domain: "new.example.com", + OriginURL: "https://origin-new.internal", + Enabled: true, + }); err != nil { + t.Fatalf("CreateProxyRoute new failed: %v", err) + } + if _, err = UpdateProxyRoute(stableRoute.ID, ProxyRouteInput{ + Domain: stableRoute.Domain, + OriginURL: stableRoute.OriginURL, + Enabled: true, + Remark: "remark only change", + }); err != nil { + t.Fatalf("UpdateProxyRoute stable failed: %v", err) + } + + preview, err := PreviewConfigVersion() + if err != nil { + t.Fatalf("PreviewConfigVersion failed: %v", err) + } + if !strings.Contains(preview.RenderedConfig, `proxy_set_header X-Release "candidate";`) { + t.Fatal("expected preview config to include modified custom header") + } + if preview.RouteCount != 3 { + t.Fatalf("expected 3 enabled routes in preview, got %d", preview.RouteCount) + } + + diff, err := DiffConfigVersion() + if err != nil { + t.Fatalf("DiffConfigVersion failed: %v", err) + } + if len(diff.AddedDomains) != 1 || diff.AddedDomains[0] != "new.example.com" { + t.Fatalf("unexpected added domains: %#v", diff.AddedDomains) + } + if len(diff.RemovedDomains) != 1 || diff.RemovedDomains[0] != "old.example.com" { + t.Fatalf("unexpected removed domains: %#v", diff.RemovedDomains) + } + if len(diff.ModifiedDomains) != 1 || diff.ModifiedDomains[0] != "api.example.com" { + t.Fatalf("unexpected modified domains: %#v", diff.ModifiedDomains) + } +} + func TestCreateTLSCertificateRejectsInvalidPEM(t *testing.T) { setupServiceTestDB(t) diff --git a/atsf_server/service/proxy_route.go b/atsf_server/service/proxy_route.go index 26f672e4..42fb6daa 100644 --- a/atsf_server/service/proxy_route.go +++ b/atsf_server/service/proxy_route.go @@ -1,20 +1,30 @@ package service import ( + "encoding/json" "errors" "gin-template/model" "net/url" + "regexp" "strings" ) +var proxyHeaderKeyPattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) + +type ProxyRouteCustomHeaderInput struct { + Key string `json:"key"` + Value string `json:"value"` +} + type ProxyRouteInput struct { - Domain string `json:"domain"` - OriginURL string `json:"origin_url"` - Enabled bool `json:"enabled"` - EnableHTTPS bool `json:"enable_https"` - CertID *uint `json:"cert_id"` - RedirectHTTP bool `json:"redirect_http"` - Remark string `json:"remark"` + Domain string `json:"domain"` + OriginURL string `json:"origin_url"` + Enabled bool `json:"enabled"` + EnableHTTPS bool `json:"enable_https"` + CertID *uint `json:"cert_id"` + RedirectHTTP bool `json:"redirect_http"` + CustomHeaders []ProxyRouteCustomHeaderInput `json:"custom_headers"` + Remark string `json:"remark"` } func ListProxyRoutes() ([]*model.ProxyRoute, error) { @@ -65,6 +75,14 @@ func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.Pro domain := strings.ToLower(strings.TrimSpace(input.Domain)) originURL := strings.TrimSpace(input.OriginURL) remark := strings.TrimSpace(input.Remark) + customHeaders, err := normalizeCustomHeaders(input.CustomHeaders) + if err != nil { + return nil, err + } + customHeadersJSON, err := json.Marshal(customHeaders) + if err != nil { + return nil, err + } if domain == "" { return nil, errors.New("域名不能为空") } @@ -98,10 +116,51 @@ func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.Pro route.EnableHTTPS = input.EnableHTTPS route.CertID = input.CertID route.RedirectHTTP = input.RedirectHTTP + route.CustomHeaders = string(customHeadersJSON) route.Remark = remark return route, nil } +func normalizeCustomHeaders(headers []ProxyRouteCustomHeaderInput) ([]ProxyRouteCustomHeaderInput, error) { + if len(headers) == 0 { + return []ProxyRouteCustomHeaderInput{}, nil + } + normalized := make([]ProxyRouteCustomHeaderInput, 0, len(headers)) + for _, header := range headers { + key := strings.TrimSpace(header.Key) + value := strings.TrimSpace(header.Value) + if key == "" && value == "" { + continue + } + if key == "" { + return nil, errors.New("自定义请求头名称不能为空") + } + if !proxyHeaderKeyPattern.MatchString(key) { + return nil, errors.New("自定义请求头名称格式不合法") + } + if strings.ContainsAny(key, "\r\n") || strings.ContainsAny(value, "\r\n") { + return nil, errors.New("自定义请求头不能包含换行") + } + normalized = append(normalized, ProxyRouteCustomHeaderInput{ + Key: key, + Value: value, + }) + } + return normalized, nil +} + +func decodeStoredCustomHeaders(raw string) ([]ProxyRouteCustomHeaderInput, error) { + text := strings.TrimSpace(raw) + if text == "" { + return []ProxyRouteCustomHeaderInput{}, nil + } + var headers []ProxyRouteCustomHeaderInput + if err := json.Unmarshal([]byte(text), &headers); err != nil { + return nil, errors.New("自定义请求头配置格式不合法") + } + return normalizeCustomHeaders(headers) +} + func validateOriginURL(raw string) error { if raw == "" { return errors.New("源站地址不能为空") diff --git a/atsf_server/web/src/pages/ConfigVersion/index.js b/atsf_server/web/src/pages/ConfigVersion/index.js index 70456dbe..36a7a885 100644 --- a/atsf_server/web/src/pages/ConfigVersion/index.js +++ b/atsf_server/web/src/pages/ConfigVersion/index.js @@ -1,125 +1,213 @@ -import React, { useEffect, useState } from 'react'; -import { - Button, - Header, - Icon, - Label, - Modal, - Segment, - Table, -} from 'semantic-ui-react'; -import { API, formatDateTime, showError, showSuccess } from '../../helpers'; - -const ConfigVersion = () => { - const [versions, setVersions] = useState([]); - const [loading, setLoading] = useState(false); - const [publishing, setPublishing] = useState(false); - const [preview, setPreview] = useState(null); - - const loadVersions = async () => { - setLoading(true); - const res = await API.get('/api/config-versions/'); - const { success, message, data } = res.data; - if (success) { - setVersions(data || []); - } else { - showError(message); - } - setLoading(false); - }; - - useEffect(() => { - loadVersions().then(); - }, []); - - const publishConfig = async () => { - setPublishing(true); - const res = await API.post('/api/config-versions/publish'); - const { success, message, data } = res.data; - if (success) { - showSuccess(`发布成功,版本 ${data.version}`); - await loadVersions(); - } else { - showError(message); - } - setPublishing(false); - }; - - const activateVersion = async (id) => { - const res = await API.put(`/api/config-versions/${id}/activate`); - const { success, message, data } = res.data; - if (success) { - showSuccess(`已激活版本 ${data.version}`); - await loadVersions(); - } else { - showError(message); - } - }; - - return ( - -
-
-
版本发布
-

查看历史快照,发布新版本,或重新激活旧版本。

-
- -
- - - - - 版本号 - 状态 - 创建人 - Checksum - 创建时间 - 操作 - - - - {versions.map((version) => ( - - {version.version} - - {version.is_active ? : } - - {version.created_by} - {(version.checksum || '').slice(0, 16)}... - {formatDateTime(version.created_at)} - - - {!version.is_active ? ( - - ) : null} - - - ))} - -
- - setPreview(null)} closeIcon> - 版本预览 - - {preview ? ( - <> -
快照 JSON
-
{preview.snapshot_json}
-
渲染结果
-
{preview.rendered_config}
- - ) : null} -
-
-
- ); -}; - -export default ConfigVersion; +import React, { useEffect, useState } from 'react'; +import { + Button, + Divider, + Header, + Icon, + Label, + Modal, + Segment, + Table, +} from 'semantic-ui-react'; +import { API, formatDateTime, showError, showSuccess } from '../../helpers'; + +const renderDomainList = (items) => { + if (!items || items.length === 0) { + return

无

; + } + return ( + + ); +}; + +const ConfigVersion = () => { + const [versions, setVersions] = useState([]); + const [loading, setLoading] = useState(false); + const [publishing, setPublishing] = useState(false); + const [preview, setPreview] = useState(null); + const [publishPreviewOpen, setPublishPreviewOpen] = useState(false); + const [previewLoading, setPreviewLoading] = useState(false); + const [pendingPreview, setPendingPreview] = useState(null); + const [pendingDiff, setPendingDiff] = useState(null); + + const loadVersions = async () => { + setLoading(true); + const res = await API.get('/api/config-versions/'); + const { success, message, data } = res.data; + if (success) { + setVersions(data || []); + } else { + showError(message); + } + setLoading(false); + }; + + useEffect(() => { + loadVersions().then(); + }, []); + + const publishConfig = async () => { + setPublishing(true); + const res = await API.post('/api/config-versions/publish'); + const { success, message, data } = res.data; + if (success) { + showSuccess(`发布成功,版本 ${data.version}`); + await loadVersions(); + } else { + showError(message); + } + setPublishing(false); + return success; + }; + + const openPublishPreview = async () => { + setPreviewLoading(true); + const [previewRes, diffRes] = await Promise.all([ + API.get('/api/config-versions/preview'), + API.get('/api/config-versions/diff'), + ]); + const previewPayload = previewRes.data; + const diffPayload = diffRes.data; + if (!previewPayload.success) { + showError(previewPayload.message); + setPreviewLoading(false); + return; + } + if (!diffPayload.success) { + showError(diffPayload.message); + setPreviewLoading(false); + return; + } + setPendingPreview(previewPayload.data || null); + setPendingDiff(diffPayload.data || null); + setPublishPreviewOpen(true); + setPreviewLoading(false); + }; + + const confirmPublish = async () => { + const success = await publishConfig(); + if (success) { + setPublishPreviewOpen(false); + setPendingPreview(null); + setPendingDiff(null); + } + }; + + const activateVersion = async (id) => { + const res = await API.put(`/api/config-versions/${id}/activate`); + const { success, message, data } = res.data; + if (success) { + showSuccess(`已激活版本 ${data.version}`); + await loadVersions(); + } else { + showError(message); + } + }; + + return ( + +
+
+
版本发布
+

查看历史快照,预览即将发布的配置与变更摘要,或重新激活旧版本。

+
+ +
+ + + + + 版本号 + 状态 + 创建人 + Checksum + 创建时间 + 操作 + + + + {versions.map((version) => ( + + {version.version} + + {version.is_active ? : } + + {version.created_by} + {(version.checksum || '').slice(0, 16)}... + {formatDateTime(version.created_at)} + + + {!version.is_active ? ( + + ) : null} + + + ))} + +
+ + setPreview(null)} closeIcon> + 版本预览 + + {preview ? ( + <> +
快照 JSON
+
{preview.snapshot_json}
+
渲染结果
+
{preview.rendered_config}
+ + ) : null} +
+
+ + setPublishPreviewOpen(false)} closeIcon> + 发布前预览 + + {pendingDiff ? ( + <> +
变更摘要
+

当前激活版本:{pendingDiff.active_version || '无'}

+ + + + +
新增域名
+ {renderDomainList(pendingDiff.added_domains)} +
删除域名
+ {renderDomainList(pendingDiff.removed_domains)} +
修改域名
+ {renderDomainList(pendingDiff.modified_domains)} + + ) : null} + {pendingPreview ? ( + <> + +
渲染结果
+

启用规则数:{pendingPreview.route_count},Checksum:{pendingPreview.checksum}

+
{pendingPreview.rendered_config}
+ + ) : null} +
+ + + + +
+
+ ); +}; + +export default ConfigVersion; diff --git a/atsf_server/web/src/pages/ProxyRoute/index.js b/atsf_server/web/src/pages/ProxyRoute/index.js index 6881f64e..cce9a42f 100644 --- a/atsf_server/web/src/pages/ProxyRoute/index.js +++ b/atsf_server/web/src/pages/ProxyRoute/index.js @@ -5,6 +5,7 @@ import { Form, Header, Icon, + Input, Label, Segment, Table, @@ -12,6 +13,23 @@ import { } from 'semantic-ui-react'; import { API, showError, showSuccess, formatDateTime } from '../../helpers'; +const emptyHeader = () => ({ key: '', value: '' }); + +const parseCustomHeaders = (rawValue) => { + if (Array.isArray(rawValue)) { + return rawValue; + } + if (!rawValue) { + return []; + } + try { + const parsed = JSON.parse(rawValue); + return Array.isArray(parsed) ? parsed : []; + } catch (error) { + return []; + } +}; + const initialForm = { domain: '', origin_url: '', @@ -19,6 +37,7 @@ const initialForm = { enable_https: false, cert_id: '', redirect_http: false, + custom_headers: [emptyHeader()], remark: '', }; @@ -106,6 +125,7 @@ const ProxyRoute = () => { domain: form.domain.trim(), origin_url: form.origin_url.trim(), cert_id: form.enable_https && form.cert_id ? Number(form.cert_id) : null, + custom_headers: (form.custom_headers || []).filter((item) => item.key.trim() || item.value.trim()), remark: form.remark.trim(), }; const res = editingId @@ -145,6 +165,7 @@ const ProxyRoute = () => { }; const beginEdit = (route) => { + const customHeaders = parseCustomHeaders(route.custom_headers); setEditingId(route.id); setForm({ domain: route.domain, @@ -153,11 +174,38 @@ const ProxyRoute = () => { enable_https: route.enable_https || false, cert_id: route.cert_id || '', redirect_http: route.redirect_http || false, + custom_headers: customHeaders.length > 0 ? customHeaders : [emptyHeader()], remark: route.remark || '', }); setMatchResult(null); }; + const updateCustomHeader = (index, field, value) => { + setForm((current) => ({ + ...current, + custom_headers: current.custom_headers.map((item, itemIndex) => ( + itemIndex === index ? { ...item, [field]: value } : item + )), + })); + }; + + const addCustomHeader = () => { + setForm((current) => ({ + ...current, + custom_headers: [...(current.custom_headers || []), emptyHeader()], + })); + }; + + const removeCustomHeader = (index) => { + setForm((current) => { + const nextHeaders = (current.custom_headers || []).filter((item, itemIndex) => itemIndex !== index); + return { + ...current, + custom_headers: nextHeaders.length > 0 ? nextHeaders : [emptyHeader()], + }; + }); + }; + const certificateOptions = certificates.map((certificate) => ({ key: certificate.id, text: `${certificate.name} (${certificate.not_after ? formatDateTime(certificate.not_after) : 'unknown'})`, @@ -254,6 +302,34 @@ const ProxyRoute = () => { style={{ alignSelf: 'flex-end', marginBottom: '1rem' }} /> + + + {(form.custom_headers || []).map((header, index) => ( + + updateCustomHeader(index, 'key', value)} + /> + updateCustomHeader(index, 'value', value)} + /> + + + @@ -270,6 +346,7 @@ const ProxyRoute = () => { 域名 源站地址 HTTPS + 自定义头 状态 备注 更新时间 @@ -288,6 +365,13 @@ const ProxyRoute = () => { )} + + {parseCustomHeaders(route.custom_headers).length > 0 ? ( + + ) : ( + + )} + {route.enabled ? : } diff --git a/docs/development-plan.md b/docs/development-plan.md index a5ac4f50..b3851d32 100644 --- a/docs/development-plan.md +++ b/docs/development-plan.md @@ -111,7 +111,7 @@ MVP 已于第一版完成。当前进入第二版迭代。 * 通配符证书可匹配子域名(如 `api.example.com` 匹配 `*.example.com`) * 无匹配证书时前端给出明确提示 -### V2 Phase 3: Agent 管理 +### V2 Phase 3: Agent 管理 ✅ 目标: