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 (
- 查看历史快照,发布新版本,或重新激活旧版本。
-
-
- {preview.snapshot_json}
- {preview.rendered_config}
- >
- ) : null}
-
无
; + } + return ( +查看历史快照,预览即将发布的配置与变更摘要,或重新激活旧版本。
+{preview.snapshot_json}
+ {preview.rendered_config}
+ >
+ ) : null}
+ 当前激活版本:{pendingDiff.active_version || '无'}
+ + + +启用规则数:{pendingPreview.route_count},Checksum:{pendingPreview.checksum}
+{pendingPreview.rendered_config}
+ >
+ ) : null}
+