From 383a0393381f8a49c761904349467035b97377b7 Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 30 Mar 2026 14:45:28 +0800 Subject: [PATCH] =?UTF-8?q?[=E5=8A=9F=E8=83=BD]=20=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E4=B8=8E=E6=A0=A1=E9=AA=8C=E6=94=B9=E9=80=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openflare_server/controller/proxy_route.go | 44 ++- .../model/database_schema_version.go | 2 +- openflare_server/model/main_test.go | 89 ++++++ openflare_server/model/migrations.go | 33 ++- openflare_server/model/proxy_route.go | 76 ++--- openflare_server/router/api-router.go | 1 + openflare_server/router/api_phase1_test.go | 8 +- openflare_server/router/api_phase2_test.go | 69 ++++- openflare_server/service/config_version.go | 169 ++++++++--- openflare_server/service/https_phase1_test.go | 75 ++++- openflare_server/service/proxy_route.go | 274 ++++++++++++++---- .../web/features/config-versions/types.ts | 6 + .../features/proxy-routes/api/proxy-routes.ts | 16 +- .../web/features/proxy-routes/types.ts | 15 + 14 files changed, 718 insertions(+), 159 deletions(-) diff --git a/openflare_server/controller/proxy_route.go b/openflare_server/controller/proxy_route.go index 233512d4..556d83ce 100644 --- a/openflare_server/controller/proxy_route.go +++ b/openflare_server/controller/proxy_route.go @@ -2,10 +2,11 @@ package controller import ( "encoding/json" - "github.com/gin-gonic/gin" "net/http" "openflare/service" "strconv" + + "github.com/gin-gonic/gin" ) // GetProxyRoutes godoc @@ -31,6 +32,39 @@ func GetProxyRoutes(c *gin.Context) { }) } +// GetProxyRoute godoc +// @Summary Get proxy route detail +// @Tags ProxyRoutes +// @Produce json +// @Security BearerAuth +// @Param id path int true "Route ID" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]interface{} +// @Router /api/proxy-routes/{id} [get] +func GetProxyRoute(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": "invalid id", + }) + return + } + route, err := service.GetProxyRoute(uint(id)) + 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": route, + }) +} + // CreateProxyRoute godoc // @Summary Create proxy route // @Tags ProxyRoutes @@ -46,7 +80,7 @@ func CreateProxyRoute(c *gin.Context) { if err := json.NewDecoder(c.Request.Body).Decode(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{ "success": false, - "message": "无效的参数", + "message": "invalid payload", }) return } @@ -81,7 +115,7 @@ func UpdateProxyRoute(c *gin.Context) { if err != nil || id == 0 { c.JSON(http.StatusBadRequest, gin.H{ "success": false, - "message": "无效的参数", + "message": "invalid id", }) return } @@ -89,7 +123,7 @@ func UpdateProxyRoute(c *gin.Context) { if err = json.NewDecoder(c.Request.Body).Decode(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{ "success": false, - "message": "无效的参数", + "message": "invalid payload", }) return } @@ -122,7 +156,7 @@ func DeleteProxyRoute(c *gin.Context) { if err != nil || id == 0 { c.JSON(http.StatusBadRequest, gin.H{ "success": false, - "message": "无效的参数", + "message": "invalid id", }) return } diff --git a/openflare_server/model/database_schema_version.go b/openflare_server/model/database_schema_version.go index d3d869be..c2627bb7 100644 --- a/openflare_server/model/database_schema_version.go +++ b/openflare_server/model/database_schema_version.go @@ -4,7 +4,7 @@ import "time" const ( legacyDatabaseSchemaVersion = 1 - currentDatabaseSchemaVersion = 5 + currentDatabaseSchemaVersion = 6 databaseSchemaVersionRowID = 1 ) diff --git a/openflare_server/model/main_test.go b/openflare_server/model/main_test.go index e8b9e7ad..9c14a349 100644 --- a/openflare_server/model/main_test.go +++ b/openflare_server/model/main_test.go @@ -34,6 +34,32 @@ func (legacyProxyRouteV4) TableName() string { return "proxy_routes" } +type legacyProxyRouteV5 struct { + ID uint `gorm:"primaryKey"` + SiteName string `gorm:"size:255;not null;default:''"` + Domain string `gorm:"uniqueIndex;size:255;not null"` + Domains string `gorm:"type:text;not null;default:'[]'"` + OriginID *uint `gorm:"index"` + OriginURL string `gorm:"size:2048;not null"` + OriginHost string `gorm:"size:255"` + Upstreams string `gorm:"type:text;not null;default:'[]'"` + Enabled bool `gorm:"not null;default:true"` + EnableHTTPS bool `gorm:"column:enable_https;not null;default:false"` + CertID *uint + RedirectHTTP bool `gorm:"not null;default:false"` + CacheEnabled bool `gorm:"not null;default:false"` + CachePolicy string `gorm:"size:32;not null;default:''"` + CacheRules string `gorm:"type:text;not null;default:'[]'"` + CustomHeaders string `gorm:"type:text;not null;default:'[]'"` + Remark string `gorm:"size:255"` + CreatedAt time.Time + UpdatedAt time.Time +} + +func (legacyProxyRouteV5) TableName() string { + return "proxy_routes" +} + func openBareTestSQLiteDB(t *testing.T, name string) *gorm.DB { t.Helper() @@ -560,6 +586,69 @@ func TestEnsureDatabaseSchemaUpToDateBackfillsProxyRouteSiteFields(t *testing.T) } } +func TestEnsureDatabaseSchemaUpToDateAddsProxyRouteRateLimitFields(t *testing.T) { + db := openBareTestSQLiteDB(t, "legacy-proxy-route-rate-limits.db") + if err := registerSharding(db, "sqlite"); err != nil { + t.Fatalf("register sharding: %v", err) + } + if err := autoMigrateSchemaMetadata(db); err != nil { + t.Fatalf("auto migrate schema metadata: %v", err) + } + + for _, item := range registeredModels() { + if _, ok := item.(*ProxyRoute); ok { + continue + } + if err := db.AutoMigrate(item); err != nil { + t.Fatalf("auto migrate supporting table: %v", err) + } + } + if err := db.AutoMigrate(&legacyProxyRouteV5{}); err != nil { + t.Fatalf("auto migrate legacy proxy_routes v5: %v", err) + } + + now := time.Now().UTC() + if err := db.Create(&legacyProxyRouteV5{ + SiteName: "main-site", + Domain: "app.example.com", + Domains: `["app.example.com","www.example.com"]`, + OriginURL: "https://origin-a.internal:8443", + Upstreams: `["https://origin-a.internal:8443"]`, + Enabled: true, + EnableHTTPS: false, + RedirectHTTP: false, + CacheEnabled: false, + CachePolicy: "", + CacheRules: `[]`, + CustomHeaders: `[]`, + CreatedAt: now, + UpdatedAt: now, + }).Error; err != nil { + t.Fatalf("seed legacy proxy route v5: %v", err) + } + if err := saveDatabaseSchemaVersion(db, 5); err != nil { + t.Fatalf("save schema version: %v", err) + } + + previousDB := DB + DB = db + t.Cleanup(func() { + DB = previousDB + }) + + if err := ensureDatabaseSchemaUpToDate(db, "sqlite"); err != nil { + t.Fatalf("ensureDatabaseSchemaUpToDate: %v", err) + } + + var route ProxyRoute + if err := db.First(&route).Error; err != nil { + t.Fatalf("query migrated proxy route: %v", err) + } + if route.LimitConnPerServer != 0 || route.LimitConnPerIP != 0 || route.LimitRate != "" { + t.Fatalf("expected new rate limit fields to default to disabled values, got %+v", route) + } +} + func TestRunDatabaseSchemaMigrationDoesNotAdvanceVersionWhenValidationFails(t *testing.T) { db := openBareTestSQLiteDB(t, "failed-validation.db") diff --git a/openflare_server/model/migrations.go b/openflare_server/model/migrations.go index 32317010..bb494af1 100644 --- a/openflare_server/model/migrations.go +++ b/openflare_server/model/migrations.go @@ -384,6 +384,22 @@ func validateDatabaseSchemaV5(db *gorm.DB, backend string) error { return nil } +func validateDatabaseSchemaV6(db *gorm.DB, backend string) error { + if err := validateDatabaseSchemaV5(db, backend); err != nil { + return err + } + if !db.Migrator().HasColumn(&ProxyRoute{}, "limit_conn_per_server") { + return fmt.Errorf("column proxy_routes.limit_conn_per_server is missing") + } + if !db.Migrator().HasColumn(&ProxyRoute{}, "limit_conn_per_ip") { + return fmt.Errorf("column proxy_routes.limit_conn_per_ip is missing") + } + if !db.Migrator().HasColumn(&ProxyRoute{}, "limit_rate") { + return fmt.Errorf("column proxy_routes.limit_rate is missing") + } + return nil +} + func renameLegacyObservabilityShardTables(db *gorm.DB) error { for _, baseTable := range shardedObservabilityBaseTables() { for _, table := range observabilityShardTables(baseTable) { @@ -738,12 +754,27 @@ func migrateV5(db *gorm.DB, backend string) error { return ensureProxyRouteSiteNameUniqueIndex(db) } +// migrateV6 adds structured website-level rate limit fields to proxy_routes. +func migrateV6(db *gorm.DB, backend string) error { + if err := applyCurrentSchema(db, backend); err != nil { + return err + } + if err := backfillOriginsFromProxyRoutes(db); err != nil { + return err + } + if err := backfillProxyRouteSiteFields(db); err != nil { + return err + } + return ensureProxyRouteSiteNameUniqueIndex(db) +} + func databaseSchemaMigrations() []databaseSchemaMigration { return []databaseSchemaMigration{ {fromVersion: 1, toVersion: 2, migrate: migrateV2, validate: validateDatabaseSchemaV2}, {fromVersion: 2, toVersion: 3, migrate: migrateV3, validate: validateDatabaseSchemaV3}, {fromVersion: 3, toVersion: 4, migrate: migrateV4, validate: validateDatabaseSchemaV4}, {fromVersion: 4, toVersion: 5, migrate: migrateV5, validate: validateDatabaseSchemaV5}, + {fromVersion: 5, toVersion: 6, migrate: migrateV6, validate: validateDatabaseSchemaV6}, } } @@ -820,7 +851,7 @@ func initializeFreshDatabaseSchema(db *gorm.DB, backend string) error { if err := ensureProxyRouteSiteNameUniqueIndex(db); err != nil { return err } - if err := validateDatabaseSchemaV5(db, backend); err != nil { + if err := validateDatabaseSchemaV6(db, backend); err != nil { return err } return saveDatabaseSchemaVersion(db, currentDatabaseSchemaVersion) diff --git a/openflare_server/model/proxy_route.go b/openflare_server/model/proxy_route.go index 7d5e3a2d..4d844d85 100644 --- a/openflare_server/model/proxy_route.go +++ b/openflare_server/model/proxy_route.go @@ -3,25 +3,28 @@ package model import "time" type ProxyRoute struct { - ID uint `json:"id" gorm:"primaryKey"` - SiteName string `json:"site_name" gorm:"size:255;not null;default:''"` - Domain string `json:"domain" gorm:"uniqueIndex;size:255;not null"` - Domains string `json:"domains" gorm:"type:text;not null;default:'[]'"` - OriginID *uint `json:"origin_id" gorm:"index"` - OriginURL string `json:"origin_url" gorm:"size:2048;not null"` - OriginHost string `json:"origin_host" gorm:"size:255"` - Upstreams string `json:"upstreams" gorm:"type:text;not null;default:'[]'"` - Enabled bool `json:"enabled" gorm:"not null;default:true"` - EnableHTTPS bool `json:"enable_https" gorm:"column:enable_https;not null;default:false"` - CertID *uint `json:"cert_id"` - RedirectHTTP bool `json:"redirect_http" gorm:"not null;default:false"` - CacheEnabled bool `json:"cache_enabled" gorm:"not null;default:false"` - CachePolicy string `json:"cache_policy" gorm:"size:32;not null;default:''"` - CacheRules string `json:"cache_rules" gorm:"type:text;not null;default:'[]'"` - 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"` + ID uint `json:"id" gorm:"primaryKey"` + SiteName string `json:"site_name" gorm:"size:255;not null;default:''"` + Domain string `json:"domain" gorm:"uniqueIndex;size:255;not null"` + Domains string `json:"domains" gorm:"type:text;not null;default:'[]'"` + OriginID *uint `json:"origin_id" gorm:"index"` + OriginURL string `json:"origin_url" gorm:"size:2048;not null"` + OriginHost string `json:"origin_host" gorm:"size:255"` + Upstreams string `json:"upstreams" gorm:"type:text;not null;default:'[]'"` + Enabled bool `json:"enabled" gorm:"not null;default:true"` + EnableHTTPS bool `json:"enable_https" gorm:"column:enable_https;not null;default:false"` + CertID *uint `json:"cert_id"` + RedirectHTTP bool `json:"redirect_http" gorm:"not null;default:false"` + LimitConnPerServer int `json:"limit_conn_per_server" gorm:"not null;default:0"` + LimitConnPerIP int `json:"limit_conn_per_ip" gorm:"not null;default:0"` + LimitRate string `json:"limit_rate" gorm:"size:32;not null;default:''"` + CacheEnabled bool `json:"cache_enabled" gorm:"not null;default:false"` + CachePolicy string `json:"cache_policy" gorm:"size:32;not null;default:''"` + CacheRules string `json:"cache_rules" gorm:"type:text;not null;default:'[]'"` + 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) { @@ -51,22 +54,25 @@ func (route *ProxyRoute) Insert() error { func (route *ProxyRoute) Update() error { return DB.Model(&ProxyRoute{}).Where("id = ?", route.ID).Updates(map[string]any{ - "site_name": route.SiteName, - "domain": route.Domain, - "domains": route.Domains, - "origin_id": route.OriginID, - "origin_url": route.OriginURL, - "origin_host": route.OriginHost, - "upstreams": route.Upstreams, - "enabled": route.Enabled, - "enable_https": route.EnableHTTPS, - "cert_id": route.CertID, - "redirect_http": route.RedirectHTTP, - "cache_enabled": route.CacheEnabled, - "cache_policy": route.CachePolicy, - "cache_rules": route.CacheRules, - "custom_headers": route.CustomHeaders, - "remark": route.Remark, + "site_name": route.SiteName, + "domain": route.Domain, + "domains": route.Domains, + "origin_id": route.OriginID, + "origin_url": route.OriginURL, + "origin_host": route.OriginHost, + "upstreams": route.Upstreams, + "enabled": route.Enabled, + "enable_https": route.EnableHTTPS, + "cert_id": route.CertID, + "redirect_http": route.RedirectHTTP, + "limit_conn_per_server": route.LimitConnPerServer, + "limit_conn_per_ip": route.LimitConnPerIP, + "limit_rate": route.LimitRate, + "cache_enabled": route.CacheEnabled, + "cache_policy": route.CachePolicy, + "cache_rules": route.CacheRules, + "custom_headers": route.CustomHeaders, + "remark": route.Remark, }).Error } diff --git a/openflare_server/router/api-router.go b/openflare_server/router/api-router.go index c0105ab2..9950c18c 100644 --- a/openflare_server/router/api-router.go +++ b/openflare_server/router/api-router.go @@ -78,6 +78,7 @@ func SetApiRouter(router *gin.Engine) { proxyRoute.Use(middleware.AdminAuth()) { proxyRoute.GET("/", controller.GetProxyRoutes) + proxyRoute.GET("/:id", controller.GetProxyRoute) proxyRoute.POST("/", controller.CreateProxyRoute) proxyRoute.POST("/:id/update", controller.UpdateProxyRoute) proxyRoute.POST("/:id/delete", controller.DeleteProxyRoute) diff --git a/openflare_server/router/api_phase1_test.go b/openflare_server/router/api_phase1_test.go index b718baf6..d023b27f 100644 --- a/openflare_server/router/api_phase1_test.go +++ b/openflare_server/router/api_phase1_test.go @@ -55,7 +55,7 @@ func TestPhase1PublishLifecycle(t *testing.T) { "remark": "primary route", } resp := performJSONRequest(t, engine, token, http.MethodPost, "/api/proxy-routes/", createBody) - var createdRoute model.ProxyRoute + var createdRoute service.ProxyRouteView decodeResponseData(t, resp, &createdRoute) if createdRoute.Domain != "app.example.com" { t.Fatalf("unexpected created route domain: %s", createdRoute.Domain) @@ -74,7 +74,7 @@ func TestPhase1PublishLifecycle(t *testing.T) { } resp = performJSONRequest(t, engine, token, http.MethodGet, "/api/proxy-routes/", nil) - var routes []model.ProxyRoute + var routes []service.ProxyRouteView decodeResponseData(t, resp, &routes) if len(routes) != 1 { t.Fatalf("expected 1 route, got %d", len(routes)) @@ -289,7 +289,7 @@ func TestPhase1HTTPSAndCertificateImportLifecycle(t *testing.T) { "redirect_http": true, "remark": "https route", }) - var route model.ProxyRoute + var route service.ProxyRouteView decodeResponseData(t, resp, &route) if !route.EnableHTTPS || route.CertID == nil || *route.CertID != manualCertificate.ID { t.Fatal("expected route to persist https certificate binding") @@ -324,7 +324,7 @@ func TestPhase1HTTPSAndCertificateImportLifecycle(t *testing.T) { } listResp := performJSONRequest(t, engine, token, http.MethodGet, "/api/proxy-routes/", nil) - var routes []model.ProxyRoute + var routes []service.ProxyRouteView decodeResponseData(t, listResp, &routes) if len(routes) != 1 || !routes[0].EnableHTTPS || routes[0].CertID == nil || *routes[0].CertID != manualCertificate.ID || !routes[0].RedirectHTTP { t.Fatalf("expected route list to reflect https update, got %+v", routes) diff --git a/openflare_server/router/api_phase2_test.go b/openflare_server/router/api_phase2_test.go index 3e36cbbb..ae9f2225 100644 --- a/openflare_server/router/api_phase2_test.go +++ b/openflare_server/router/api_phase2_test.go @@ -460,7 +460,7 @@ func TestPhase2CustomHeadersPreviewAndDiffLifecycle(t *testing.T) { {"key": "X-Trace-Id", "value": "$request_id"}, }, }) - var createdRoute model.ProxyRoute + var createdRoute service.ProxyRouteView 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) @@ -468,6 +468,9 @@ func TestPhase2CustomHeadersPreviewAndDiffLifecycle(t *testing.T) { if createdRoute.OriginHost != "preview-origin.internal" { t.Fatalf("expected origin_host to be stored, got %s", createdRoute.OriginHost) } + if createdRoute.SiteName != "preview.example.com" || createdRoute.PrimaryDomain != "preview.example.com" || createdRoute.DomainCount != 1 { + t.Fatalf("expected website identity fields in create response, got %+v", createdRoute) + } performJSONRequest(t, engine, token, http.MethodPost, "/api/config-versions/publish", nil) @@ -491,6 +494,9 @@ func TestPhase2CustomHeadersPreviewAndDiffLifecycle(t *testing.T) { var preview map[string]any decodeResponseData(t, previewResp, &preview) renderedConfig, _ := preview["rendered_config"].(string) + if websiteCount, ok := preview["website_count"].(float64); !ok || int(websiteCount) != 2 { + t.Fatalf("expected preview website_count=2, got %#v", preview["website_count"]) + } if !strings.Contains(renderedConfig, `proxy_set_header X-Release "candidate";`) { t.Fatalf("expected preview endpoint to return custom header, got %s", renderedConfig) } @@ -515,6 +521,67 @@ func TestPhase2CustomHeadersPreviewAndDiffLifecycle(t *testing.T) { if !ok || len(addedDomains) != 1 || addedDomains[0].(string) != "new-preview.example.com" { t.Fatalf("unexpected added domains: %#v", diff["added_domains"]) } + modifiedSites, ok := diff["modified_sites"].([]any) + if !ok || len(modifiedSites) != 1 || modifiedSites[0].(string) != "preview.example.com" { + t.Fatalf("unexpected modified sites: %#v", diff["modified_sites"]) + } + addedSites, ok := diff["added_sites"].([]any) + if !ok || len(addedSites) != 1 || addedSites[0].(string) != "new-preview.example.com" { + t.Fatalf("unexpected added sites: %#v", diff["added_sites"]) + } +} + +func TestPhase2ProxyRouteWebsiteDetailAndLimits(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{ + "site_name": "marketing-site", + "domains": []string{"app.example.com", "www.example.com"}, + "origin_url": "https://origin.internal", + "enabled": true, + "limit_conn_per_server": 120, + "limit_conn_per_ip": 12, + "limit_rate": "512K", + }) + var createdRoute service.ProxyRouteView + decodeResponseData(t, createResp, &createdRoute) + if createdRoute.SiteName != "marketing-site" || createdRoute.PrimaryDomain != "app.example.com" { + t.Fatalf("unexpected create payload: %+v", createdRoute) + } + if createdRoute.DomainCount != 2 || len(createdRoute.Domains) != 2 || createdRoute.Domains[1] != "www.example.com" { + t.Fatalf("expected multi-domain website view, got %+v", createdRoute) + } + if createdRoute.LimitConnPerServer != 120 || createdRoute.LimitConnPerIP != 12 || createdRoute.LimitRate != "512k" { + t.Fatalf("expected normalized rate limit fields, got %+v", createdRoute) + } + if len(createdRoute.UpstreamList) != 1 || createdRoute.UpstreamList[0] != "https://origin.internal" { + t.Fatalf("expected structured upstream list, got %+v", createdRoute.UpstreamList) + } + + detailResp := performJSONRequest(t, engine, token, http.MethodGet, "/api/proxy-routes/"+toString(createdRoute.ID), nil) + var detail service.ProxyRouteView + decodeResponseData(t, detailResp, &detail) + if detail.ID != createdRoute.ID || detail.SiteName != "marketing-site" || detail.LimitRate != "512k" { + t.Fatalf("unexpected detail response: %+v", detail) + } + if len(detail.Domains) != 2 || detail.Domains[0] != "app.example.com" || detail.Domains[1] != "www.example.com" { + t.Fatalf("expected detail response to expose full domain list, got %+v", detail.Domains) + } + + listResp := performJSONRequest(t, engine, token, http.MethodGet, "/api/proxy-routes/", nil) + var routes []service.ProxyRouteView + decodeResponseData(t, listResp, &routes) + if len(routes) != 1 || routes[0].SiteName != "marketing-site" || routes[0].LimitConnPerServer != 120 { + t.Fatalf("unexpected proxy route list response: %+v", routes) + } } func TestPhase2GlobalDiscoveryRegistration(t *testing.T) { diff --git a/openflare_server/service/config_version.go b/openflare_server/service/config_version.go index ecedf426..ac255636 100644 --- a/openflare_server/service/config_version.go +++ b/openflare_server/service/config_version.go @@ -35,6 +35,7 @@ type ConfigPreviewResult struct { SupportFiles []SupportFile `json:"support_files"` Checksum string `json:"checksum"` RouteCount int `json:"route_count"` + WebsiteCount int `json:"website_count"` } type ConfigVersionSummary = model.ConfigVersionSummary @@ -43,12 +44,17 @@ type ConfigVersionDetail = model.ConfigVersion type ConfigDiffResult struct { ActiveVersion string `json:"active_version,omitempty"` + AddedSites []string `json:"added_sites"` + RemovedSites []string `json:"removed_sites"` + ModifiedSites []string `json:"modified_sites"` AddedDomains []string `json:"added_domains"` RemovedDomains []string `json:"removed_domains"` ModifiedDomains []string `json:"modified_domains"` MainConfigChanged bool `json:"main_config_changed"` ChangedOptionKeys []string `json:"changed_option_keys"` ChangedOptionDetails []ConfigOptionDiffItem `json:"changed_option_details"` + CurrentWebsiteCount int `json:"current_website_count"` + ActiveWebsiteCount int `json:"active_website_count"` } type ConfigOptionDiffItem struct { @@ -58,21 +64,24 @@ type ConfigOptionDiffItem struct { } type snapshotRoute struct { - SiteName string `json:"site_name,omitempty"` - Domain string `json:"domain"` - Domains []string `json:"domains,omitempty"` - OriginURL string `json:"origin_url"` - OriginHost string `json:"origin_host,omitempty"` - Upstreams []string `json:"upstreams,omitempty"` - Enabled bool `json:"enabled"` - EnableHTTPS bool `json:"enable_https"` - CertID *uint `json:"cert_id,omitempty"` - RedirectHTTP bool `json:"redirect_http"` - CacheEnabled bool `json:"cache_enabled"` - CachePolicy string `json:"cache_policy,omitempty"` - CacheRules []string `json:"cache_rules,omitempty"` - CustomHeaders []ProxyRouteCustomHeaderInput `json:"custom_headers,omitempty"` - Remark string `json:"remark,omitempty"` + SiteName string `json:"site_name,omitempty"` + Domain string `json:"domain"` + Domains []string `json:"domains,omitempty"` + OriginURL string `json:"origin_url"` + OriginHost string `json:"origin_host,omitempty"` + Upstreams []string `json:"upstreams,omitempty"` + Enabled bool `json:"enabled"` + EnableHTTPS bool `json:"enable_https"` + CertID *uint `json:"cert_id,omitempty"` + RedirectHTTP bool `json:"redirect_http"` + LimitConnPerServer int `json:"limit_conn_per_server,omitempty"` + LimitConnPerIP int `json:"limit_conn_per_ip,omitempty"` + LimitRate string `json:"limit_rate,omitempty"` + CacheEnabled bool `json:"cache_enabled"` + CachePolicy string `json:"cache_policy,omitempty"` + CacheRules []string `json:"cache_rules,omitempty"` + CustomHeaders []ProxyRouteCustomHeaderInput `json:"custom_headers,omitempty"` + Remark string `json:"remark,omitempty"` } type routeCacheConfig struct { @@ -81,6 +90,12 @@ type routeCacheConfig struct { Rules []string } +type routeLimitConfig struct { + LimitConnPerServer int + LimitConnPerIP int + LimitRate string +} + type routeUpstreamConfig struct { Name string Scheme string @@ -207,6 +222,7 @@ func PreviewConfigVersion() (*ConfigPreviewResult, error) { SupportFiles: bundle.SupportFiles, Checksum: bundle.Checksum, RouteCount: len(bundle.Routes), + WebsiteCount: len(bundle.SnapshotRoutes), }, nil } @@ -216,21 +232,29 @@ func DiffConfigVersion() (*ConfigDiffResult, error) { return nil, err } result := &ConfigDiffResult{ + AddedSites: []string{}, + RemovedSites: []string{}, + ModifiedSites: []string{}, AddedDomains: []string{}, RemovedDomains: []string{}, ModifiedDomains: []string{}, ChangedOptionKeys: []string{}, ChangedOptionDetails: []ConfigOptionDiffItem{}, + CurrentWebsiteCount: len(bundle.SnapshotRoutes), } activeVersion, err := model.GetActiveConfigVersion() if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { for _, route := range bundle.SnapshotRoutes { + result.AddedSites = append(result.AddedSites, route.SiteName) result.AddedDomains = append(result.AddedDomains, route.Domains...) } result.MainConfigChanged = true result.ChangedOptionKeys = openRestyOptionKeys() result.ChangedOptionDetails = buildInitialOpenRestyOptionDiffs(bundle.OpenRestyConfig) + sort.Strings(result.AddedSites) + sort.Strings(result.AddedDomains) + sort.Strings(result.ChangedOptionKeys) return result, nil } return nil, err @@ -240,6 +264,24 @@ func DiffConfigVersion() (*ConfigDiffResult, error) { if err != nil { return nil, err } + result.ActiveWebsiteCount = len(activeSnapshot.Routes) + currentSiteMap := flattenSnapshotRoutesBySite(bundle.SnapshotRoutes) + activeSiteMap := flattenSnapshotRoutesBySite(activeSnapshot.Routes) + for siteName, currentRoute := range currentSiteMap { + activeRoute, ok := activeSiteMap[siteName] + if !ok { + result.AddedSites = append(result.AddedSites, siteName) + continue + } + if !snapshotRouteConfigEqual(activeRoute, currentRoute) { + result.ModifiedSites = append(result.ModifiedSites, siteName) + } + } + for siteName := range activeSiteMap { + if _, ok := currentSiteMap[siteName]; !ok { + result.RemovedSites = append(result.RemovedSites, siteName) + } + } currentMap := flattenSnapshotRoutesByDomain(bundle.SnapshotRoutes) activeMap := flattenSnapshotRoutesByDomain(activeSnapshot.Routes) for domain, currentRoute := range currentMap { @@ -260,6 +302,9 @@ func DiffConfigVersion() (*ConfigDiffResult, error) { result.MainConfigChanged = activeVersion.MainConfig != bundle.MainConfig result.ChangedOptionDetails = diffOpenRestyOptionDetails(activeSnapshot.OpenRestyConfig, bundle.OpenRestyConfig) result.ChangedOptionKeys = extractOptionDiffKeys(result.ChangedOptionDetails) + sort.Strings(result.AddedSites) + sort.Strings(result.RemovedSites) + sort.Strings(result.ModifiedSites) sort.Strings(result.AddedDomains) sort.Strings(result.RemovedDomains) sort.Strings(result.ModifiedDomains) @@ -416,21 +461,24 @@ func buildSnapshotRoutes(routes []*model.ProxyRoute) ([]snapshotRoute, error) { return nil, fmt.Errorf("路由 %s 缓存规则无效", route.Domain) } items = append(items, snapshotRoute{ - SiteName: normalizeProxyRouteSiteNameInput(route, route.SiteName, domains[0]), - Domain: domains[0], - Domains: domains, - OriginURL: route.OriginURL, - OriginHost: route.OriginHost, - Upstreams: upstreams, - Enabled: route.Enabled, - EnableHTTPS: route.EnableHTTPS, - CertID: route.CertID, - RedirectHTTP: route.RedirectHTTP, - CacheEnabled: route.CacheEnabled, - CachePolicy: route.CachePolicy, - CacheRules: cacheRules, - CustomHeaders: customHeaders, - Remark: route.Remark, + SiteName: normalizeProxyRouteSiteNameInput(route, route.SiteName, domains[0]), + Domain: domains[0], + Domains: domains, + OriginURL: route.OriginURL, + OriginHost: route.OriginHost, + Upstreams: upstreams, + Enabled: route.Enabled, + EnableHTTPS: route.EnableHTTPS, + CertID: route.CertID, + RedirectHTTP: route.RedirectHTTP, + LimitConnPerServer: route.LimitConnPerServer, + LimitConnPerIP: route.LimitConnPerIP, + LimitRate: route.LimitRate, + CacheEnabled: route.CacheEnabled, + CachePolicy: route.CachePolicy, + CacheRules: cacheRules, + CustomHeaders: customHeaders, + Remark: route.Remark, }) } return items, nil @@ -488,10 +536,22 @@ func normalizeSnapshotRoutes(routes []snapshotRoute) []snapshotRoute { routes[index].CachePolicy = normalizeCachePolicy(routes[index].CacheEnabled, routes[index].CachePolicy) routes[index].CacheRules = normalizedCacheRules } + normalizedLimitRate, err := normalizeProxyRouteLimitRate(routes[index].LimitRate) + if err == nil { + routes[index].LimitRate = normalizedLimitRate + } } return routes } +func flattenSnapshotRoutesBySite(routes []snapshotRoute) map[string]snapshotRoute { + siteMap := make(map[string]snapshotRoute) + for _, route := range normalizeSnapshotRoutes(routes) { + siteMap[route.SiteName] = route + } + return siteMap +} + func flattenSnapshotRoutesByDomain(routes []snapshotRoute) map[string]snapshotRoute { domainMap := make(map[string]snapshotRoute) for _, route := range normalizeSnapshotRoutes(routes) { @@ -505,7 +565,7 @@ func flattenSnapshotRoutesByDomain(routes []snapshotRoute) map[string]snapshotRo } func snapshotRouteConfigEqual(left snapshotRoute, right snapshotRoute) bool { - if left.SiteName != right.SiteName || left.Domain != right.Domain || left.OriginURL != right.OriginURL || left.OriginHost != right.OriginHost || left.EnableHTTPS != right.EnableHTTPS || left.RedirectHTTP != right.RedirectHTTP || left.CacheEnabled != right.CacheEnabled || left.CachePolicy != right.CachePolicy || !uintPointerEqual(left.CertID, right.CertID) { + if left.SiteName != right.SiteName || left.Domain != right.Domain || left.OriginURL != right.OriginURL || left.OriginHost != right.OriginHost || left.EnableHTTPS != right.EnableHTTPS || left.RedirectHTTP != right.RedirectHTTP || left.LimitConnPerServer != right.LimitConnPerServer || left.LimitConnPerIP != right.LimitConnPerIP || left.LimitRate != right.LimitRate || left.CacheEnabled != right.CacheEnabled || left.CachePolicy != right.CachePolicy || !uintPointerEqual(left.CertID, right.CertID) { return false } if len(left.Domains) != len(right.Domains) { @@ -719,12 +779,17 @@ func renderRouteConfig(routes []*model.ProxyRoute, cfg openRestyConfigSnapshot) Policy: route.CachePolicy, Rules: cacheRules, } + limitConfig := routeLimitConfig{ + LimitConnPerServer: route.LimitConnPerServer, + LimitConnPerIP: route.LimitConnPerIP, + LimitRate: route.LimitRate, + } upstreamConfig := buildRouteUpstreamConfig(route, upstreams) if upstreamConfig.UsesNamedUpstream { builder.WriteString(renderNamedUpstreamBlock(upstreamConfig)) } if !route.EnableHTTPS { - builder.WriteString(renderHTTPProxyServer(serverNames, route.OriginURL, route.OriginHost, customHeaders, cacheConfig, upstreamConfig, cfg)) + builder.WriteString(renderHTTPProxyServer(serverNames, route.OriginURL, route.OriginHost, customHeaders, cacheConfig, limitConfig, upstreamConfig, cfg)) continue } if route.CertID == nil || *route.CertID == 0 { @@ -744,9 +809,9 @@ func renderRouteConfig(routes []*model.ProxyRoute, cfg openRestyConfigSnapshot) if route.RedirectHTTP { builder.WriteString(renderHTTPRedirectServer(serverNames)) } else { - builder.WriteString(renderHTTPProxyServer(serverNames, route.OriginURL, route.OriginHost, customHeaders, cacheConfig, upstreamConfig, cfg)) + builder.WriteString(renderHTTPProxyServer(serverNames, route.OriginURL, route.OriginHost, customHeaders, cacheConfig, limitConfig, upstreamConfig, cfg)) } - builder.WriteString(renderHTTPSServer(serverNames, route.OriginURL, route.OriginHost, certificate.ID, customHeaders, cacheConfig, upstreamConfig, cfg)) + builder.WriteString(renderHTTPSServer(serverNames, route.OriginURL, route.OriginHost, certificate.ID, customHeaders, cacheConfig, limitConfig, upstreamConfig, cfg)) } return builder.String(), dedupeSupportFiles(supportFiles), nil } @@ -819,7 +884,8 @@ func renderTemplateDirective(enabled bool, statement string) string { } func renderOpenRestyCacheTemplateBlock(cfg openRestyConfigSnapshot) string { - lines := make([]string, 0, 8) + lines := make([]string, 0, 12) + lines = append(lines, renderOpenRestyLimitZoneBlock()) if !cfg.CacheEnabled { lines = append(lines, renderOpenRestyObservabilityTemplateBlock()) return strings.Join(lines, "") @@ -836,6 +902,14 @@ func renderOpenRestyCacheTemplateBlock(cfg openRestyConfigSnapshot) string { return strings.Join(lines, "") } +func renderOpenRestyLimitZoneBlock() string { + return strings.Join([]string{ + " limit_conn_zone $server_name zone=openflare_conn_per_server:10m;", + " limit_conn_zone $binary_remote_addr zone=openflare_conn_per_ip:10m;", + "", + }, "\n") +} + func onOff(value bool) string { if value { return "on" @@ -883,18 +957,18 @@ func nextVersionNumber(now time.Time) (string, error) { return fmt.Sprintf("%s-%03d", prefix, count+1), nil } -func renderHTTPProxyServer(serverNames string, originURL string, originHost string, customHeaders []ProxyRouteCustomHeaderInput, cacheConfig routeCacheConfig, upstreamConfig routeUpstreamConfig, cfg openRestyConfigSnapshot) string { - return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n\n location / {\n%s%s%s }\n}\n\n", serverNames, renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig), renderRouteCacheBlock(cacheConfig, cfg), renderProxyPassBlock(originURL, upstreamConfig)) +func renderHTTPProxyServer(serverNames string, originURL string, originHost string, customHeaders []ProxyRouteCustomHeaderInput, cacheConfig routeCacheConfig, limitConfig routeLimitConfig, upstreamConfig routeUpstreamConfig, cfg openRestyConfigSnapshot) string { + return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n\n location / {\n%s%s%s%s }\n}\n\n", serverNames, renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig), renderRouteLimitBlock(limitConfig), renderRouteCacheBlock(cacheConfig, cfg), renderProxyPassBlock(originURL, upstreamConfig)) } func renderHTTPRedirectServer(serverNames string) string { return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n\n return 301 https://$host$request_uri;\n}\n\n", serverNames) } -func renderHTTPSServer(serverNames string, originURL string, originHost string, certificateID uint, customHeaders []ProxyRouteCustomHeaderInput, cacheConfig routeCacheConfig, upstreamConfig routeUpstreamConfig, cfg openRestyConfigSnapshot) string { +func renderHTTPSServer(serverNames string, originURL string, originHost string, certificateID uint, customHeaders []ProxyRouteCustomHeaderInput, cacheConfig routeCacheConfig, limitConfig routeLimitConfig, upstreamConfig routeUpstreamConfig, cfg openRestyConfigSnapshot) 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 http2 on;\n server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n\n location / {\n%s%s%s }\n}\n\n", serverNames, certPath, keyPath, renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig), renderRouteCacheBlock(cacheConfig, cfg), renderProxyPassBlock(originURL, upstreamConfig)) + return fmt.Sprintf("server {\n listen 443 ssl;\n http2 on;\n server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n\n location / {\n%s%s%s%s }\n}\n\n", serverNames, certPath, keyPath, renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig), renderRouteLimitBlock(limitConfig), renderRouteCacheBlock(cacheConfig, cfg), renderProxyPassBlock(originURL, upstreamConfig)) } func renderServerNames(domains []string) string { @@ -973,6 +1047,23 @@ func renderRouteCacheBlock(cacheConfig routeCacheConfig, cfg openRestyConfigSnap return builder.String() } +func renderRouteLimitBlock(limitConfig routeLimitConfig) string { + if limitConfig.LimitConnPerServer <= 0 && limitConfig.LimitConnPerIP <= 0 && strings.TrimSpace(limitConfig.LimitRate) == "" { + return "" + } + var builder strings.Builder + if limitConfig.LimitConnPerServer > 0 { + builder.WriteString(fmt.Sprintf(" limit_conn openflare_conn_per_server %d;\n", limitConfig.LimitConnPerServer)) + } + if limitConfig.LimitConnPerIP > 0 { + builder.WriteString(fmt.Sprintf(" limit_conn openflare_conn_per_ip %d;\n", limitConfig.LimitConnPerIP)) + } + if strings.TrimSpace(limitConfig.LimitRate) != "" { + builder.WriteString(fmt.Sprintf(" limit_rate %s;\n", limitConfig.LimitRate)) + } + return builder.String() +} + func renderRouteCachePolicyCondition(cacheConfig routeCacheConfig) string { switch cacheConfig.Policy { case proxyRouteCachePolicySuffix: diff --git a/openflare_server/service/https_phase1_test.go b/openflare_server/service/https_phase1_test.go index b6550073..9b2c794f 100644 --- a/openflare_server/service/https_phase1_test.go +++ b/openflare_server/service/https_phase1_test.go @@ -110,7 +110,7 @@ func TestCreateProxyRouteRejectsHTTPSWithoutCertificate(t *testing.T) { Enabled: true, EnableHTTPS: true, }) - if err == nil || !strings.Contains(err.Error(), "必须选择证书") { + if err == nil || !strings.Contains(err.Error(), "must select a certificate") { t.Fatalf("expected certificate validation error, got %v", err) } } @@ -133,8 +133,8 @@ func TestCreateProxyRouteSupportsWebsiteDomains(t *testing.T) { if route.Domain != "app.example.com" { t.Fatalf("expected primary domain mirror, got %s", route.Domain) } - if !strings.Contains(route.Domains, "www.example.com") { - t.Fatalf("expected domains payload to contain alias, got %s", route.Domains) + if len(route.Domains) != 2 || route.Domains[1] != "www.example.com" { + t.Fatalf("expected domains payload to contain alias, got %#v", route.Domains) } } @@ -203,7 +203,7 @@ func TestCreateProxyRouteRejectsCachePolicyWithoutRules(t *testing.T) { CacheEnabled: true, CachePolicy: proxyRouteCachePolicySuffix, }) - if err == nil || !strings.Contains(err.Error(), "至少填写一个后缀") { + if err == nil || !strings.Contains(err.Error(), "at least one suffix") { t.Fatalf("expected cache rule validation error, got %v", err) } } @@ -409,6 +409,73 @@ func TestDiffConfigVersionTracksAddedDomainWithinWebsite(t *testing.T) { if len(diff.ModifiedDomains) != 1 || diff.ModifiedDomains[0] != "app.example.com" { t.Fatalf("unexpected modified domains: %#v", diff.ModifiedDomains) } + if len(diff.ModifiedSites) != 1 || diff.ModifiedSites[0] != "main-site" { + t.Fatalf("unexpected modified sites: %#v", diff.ModifiedSites) + } +} + +func TestCreateProxyRouteRejectsInvalidRateLimitFields(t *testing.T) { + setupServiceTestDB(t) + + _, err := CreateProxyRoute(ProxyRouteInput{ + Domain: "limit.example.com", + OriginURL: "https://origin.internal", + Enabled: true, + LimitConnPerServer: -1, + }) + if err == nil || !strings.Contains(err.Error(), "limit_conn_per_server") { + t.Fatalf("expected limit_conn_per_server validation error, got %v", err) + } + + _, err = CreateProxyRoute(ProxyRouteInput{ + Domain: "limit.example.com", + OriginURL: "https://origin.internal", + Enabled: true, + LimitRate: "12x", + }) + if err == nil || !strings.Contains(err.Error(), "limit_rate") { + t.Fatalf("expected limit_rate validation error, got %v", err) + } +} + +func TestPublishConfigVersionRendersRouteRateLimits(t *testing.T) { + setupServiceTestDB(t) + + _, err := CreateProxyRoute(ProxyRouteInput{ + SiteName: "limited-site", + Domains: []string{"limit.example.com", "www.limit.example.com"}, + OriginURL: "https://origin.internal", + Enabled: true, + LimitConnPerServer: 120, + LimitConnPerIP: 12, + LimitRate: "512K", + }) + 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.MainConfig, "limit_conn_zone $server_name zone=openflare_conn_per_server:10m;") { + t.Fatal("expected main config to include server limit_conn_zone") + } + if !strings.Contains(result.Version.MainConfig, "limit_conn_zone $binary_remote_addr zone=openflare_conn_per_ip:10m;") { + t.Fatal("expected main config to include ip limit_conn_zone") + } + if !strings.Contains(result.Version.RenderedConfig, "limit_conn openflare_conn_per_server 120;") { + t.Fatal("expected rendered config to include per-server limit_conn") + } + if !strings.Contains(result.Version.RenderedConfig, "limit_conn openflare_conn_per_ip 12;") { + t.Fatal("expected rendered config to include per-ip limit_conn") + } + if !strings.Contains(result.Version.RenderedConfig, "limit_rate 512k;") { + t.Fatal("expected rendered config to include normalized limit_rate") + } + if !strings.Contains(result.Version.SnapshotJSON, `"limit_rate":"512k"`) { + t.Fatal("expected snapshot to include normalized limit_rate") + } } func TestPublishConfigVersionRendersHostnameLoadBalancingUpstream(t *testing.T) { diff --git a/openflare_server/service/proxy_route.go b/openflare_server/service/proxy_route.go index 6e4d86d3..403536fe 100644 --- a/openflare_server/service/proxy_route.go +++ b/openflare_server/service/proxy_route.go @@ -8,11 +8,13 @@ import ( "openflare/model" "regexp" "strings" + "time" "gorm.io/gorm" ) var proxyHeaderKeyPattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) +var proxyRouteLimitRatePattern = regexp.MustCompile(`^\d+(?:[kKmM])?$`) const ( proxyRouteCachePolicyURL = "url" @@ -27,47 +29,92 @@ type ProxyRouteCustomHeaderInput struct { } type ProxyRouteInput struct { - SiteName string `json:"site_name"` - Domain string `json:"domain"` - Domains []string `json:"domains"` - OriginID *uint `json:"origin_id"` - OriginURL string `json:"origin_url"` - OriginScheme string `json:"origin_scheme"` - OriginAddress string `json:"origin_address"` - OriginPort string `json:"origin_port"` - OriginURI string `json:"origin_uri"` - OriginHost string `json:"origin_host"` - Upstreams []string `json:"upstreams"` - Enabled bool `json:"enabled"` - EnableHTTPS bool `json:"enable_https"` - CertID *uint `json:"cert_id"` - RedirectHTTP bool `json:"redirect_http"` - CacheEnabled bool `json:"cache_enabled"` - CachePolicy string `json:"cache_policy"` - CacheRules []string `json:"cache_rules"` - CustomHeaders []ProxyRouteCustomHeaderInput `json:"custom_headers"` - Remark string `json:"remark"` + SiteName string `json:"site_name"` + Domain string `json:"domain"` + Domains []string `json:"domains"` + OriginID *uint `json:"origin_id"` + OriginURL string `json:"origin_url"` + OriginScheme string `json:"origin_scheme"` + OriginAddress string `json:"origin_address"` + OriginPort string `json:"origin_port"` + OriginURI string `json:"origin_uri"` + OriginHost string `json:"origin_host"` + Upstreams []string `json:"upstreams"` + Enabled bool `json:"enabled"` + EnableHTTPS bool `json:"enable_https"` + CertID *uint `json:"cert_id"` + RedirectHTTP bool `json:"redirect_http"` + LimitConnPerServer int `json:"limit_conn_per_server"` + LimitConnPerIP int `json:"limit_conn_per_ip"` + LimitRate string `json:"limit_rate"` + CacheEnabled bool `json:"cache_enabled"` + CachePolicy string `json:"cache_policy"` + CacheRules []string `json:"cache_rules"` + CustomHeaders []ProxyRouteCustomHeaderInput `json:"custom_headers"` + Remark string `json:"remark"` } -func ListProxyRoutes() ([]*model.ProxyRoute, error) { - return model.ListProxyRoutes() +type ProxyRouteView struct { + ID uint `json:"id"` + SiteName string `json:"site_name"` + Domain string `json:"domain"` + Domains []string `json:"domains"` + PrimaryDomain string `json:"primary_domain"` + DomainCount int `json:"domain_count"` + OriginID *uint `json:"origin_id"` + OriginURL string `json:"origin_url"` + OriginHost string `json:"origin_host"` + Upstreams string `json:"upstreams"` + UpstreamList []string `json:"upstream_list"` + Enabled bool `json:"enabled"` + EnableHTTPS bool `json:"enable_https"` + CertID *uint `json:"cert_id"` + RedirectHTTP bool `json:"redirect_http"` + LimitConnPerServer int `json:"limit_conn_per_server"` + LimitConnPerIP int `json:"limit_conn_per_ip"` + LimitRate string `json:"limit_rate"` + CacheEnabled bool `json:"cache_enabled"` + CachePolicy string `json:"cache_policy"` + CacheRules string `json:"cache_rules"` + CacheRuleList []string `json:"cache_rule_list"` + CustomHeaders string `json:"custom_headers"` + CustomHeaderList []ProxyRouteCustomHeaderInput `json:"custom_header_list"` + Remark string `json:"remark"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } -func CreateProxyRoute(input ProxyRouteInput) (*model.ProxyRoute, error) { +func ListProxyRoutes() ([]*ProxyRouteView, error) { + routes, err := model.ListProxyRoutes() + if err != nil { + return nil, err + } + return buildProxyRouteViews(routes) +} + +func GetProxyRoute(id uint) (*ProxyRouteView, error) { + route, err := model.GetProxyRouteByID(id) + if err != nil { + return nil, err + } + return buildProxyRouteView(route) +} + +func CreateProxyRoute(input ProxyRouteInput) (*ProxyRouteView, error) { route, err := buildProxyRoute(nil, input) if err != nil { return nil, err } if err = route.Insert(); err != nil { if isUniqueConstraintError(err) { - return nil, errors.New("域名已存在") + return nil, errors.New("proxy route identity already exists") } return nil, err } - return route, nil + return buildProxyRouteView(route) } -func UpdateProxyRoute(id uint, input ProxyRouteInput) (*model.ProxyRoute, error) { +func UpdateProxyRoute(id uint, input ProxyRouteInput) (*ProxyRouteView, error) { route, err := model.GetProxyRouteByID(id) if err != nil { return nil, err @@ -78,11 +125,11 @@ func UpdateProxyRoute(id uint, input ProxyRouteInput) (*model.ProxyRoute, error) } if err = route.Update(); err != nil { if isUniqueConstraintError(err) { - return nil, errors.New("域名已存在") + return nil, errors.New("proxy route identity already exists") } return nil, err } - return route, nil + return buildProxyRouteView(route) } func DeleteProxyRoute(id uint) error { @@ -120,6 +167,19 @@ func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.Pro if err != nil { return nil, err } + limitConnPerServer, err := normalizeProxyRouteLimitConnValue(input.LimitConnPerServer, "limit_conn_per_server") + if err != nil { + return nil, err + } + limitConnPerIP, err := normalizeProxyRouteLimitConnValue(input.LimitConnPerIP, "limit_conn_per_ip") + if err != nil { + return nil, err + } + limitRate, err := normalizeProxyRouteLimitRate(input.LimitRate) + if err != nil { + return nil, err + } + cacheRulesJSON, err := json.Marshal(cacheRules) if err != nil { return nil, err @@ -152,14 +212,14 @@ func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.Pro } if input.EnableHTTPS { if input.CertID == nil || *input.CertID == 0 { - return nil, errors.New("启用 HTTPS 时必须选择证书") + return nil, errors.New("must select a certificate when HTTPS is enabled") } if _, err := model.GetTLSCertificateByID(*input.CertID); err != nil { - return nil, errors.New("所选证书不存在") + return nil, errors.New("selected certificate does not exist") } } if input.RedirectHTTP && !input.EnableHTTPS { - return nil, errors.New("仅启用 HTTPS 后才能开启 HTTP 重定向") + return nil, errors.New("redirect_http requires enable_https") } if route == nil { @@ -176,6 +236,9 @@ func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.Pro route.EnableHTTPS = input.EnableHTTPS route.CertID = input.CertID route.RedirectHTTP = input.RedirectHTTP + route.LimitConnPerServer = limitConnPerServer + route.LimitConnPerIP = limitConnPerIP + route.LimitRate = limitRate route.CacheEnabled = input.CacheEnabled route.CachePolicy = normalizeCachePolicy(input.CacheEnabled, cachePolicy) route.CacheRules = string(cacheRulesJSON) @@ -184,6 +247,70 @@ func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.Pro return route, nil } +func buildProxyRouteViews(routes []*model.ProxyRoute) ([]*ProxyRouteView, error) { + views := make([]*ProxyRouteView, 0, len(routes)) + for _, route := range routes { + view, err := buildProxyRouteView(route) + if err != nil { + return nil, err + } + views = append(views, view) + } + return views, nil +} + +func buildProxyRouteView(route *model.ProxyRoute) (*ProxyRouteView, error) { + if route == nil { + return nil, errors.New("proxy route is nil") + } + domains, err := decodeStoredDomains(route.Domains, route.Domain) + if err != nil { + return nil, err + } + upstreams, err := decodeStoredUpstreams(route.Upstreams, route.OriginURL) + if err != nil { + return nil, err + } + cacheRules, err := decodeStoredCacheRules(route.CacheRules) + if err != nil { + return nil, err + } + customHeaders, err := decodeStoredCustomHeaders(route.CustomHeaders) + if err != nil { + return nil, err + } + primaryDomain := domains[0] + return &ProxyRouteView{ + ID: route.ID, + SiteName: normalizeProxyRouteSiteNameInput(route, route.SiteName, primaryDomain), + Domain: primaryDomain, + Domains: domains, + PrimaryDomain: primaryDomain, + DomainCount: len(domains), + OriginID: route.OriginID, + OriginURL: route.OriginURL, + OriginHost: route.OriginHost, + Upstreams: route.Upstreams, + UpstreamList: upstreams, + Enabled: route.Enabled, + EnableHTTPS: route.EnableHTTPS, + CertID: route.CertID, + RedirectHTTP: route.RedirectHTTP, + LimitConnPerServer: route.LimitConnPerServer, + LimitConnPerIP: route.LimitConnPerIP, + LimitRate: route.LimitRate, + CacheEnabled: route.CacheEnabled, + CachePolicy: route.CachePolicy, + CacheRules: route.CacheRules, + CacheRuleList: cacheRules, + CustomHeaders: route.CustomHeaders, + CustomHeaderList: customHeaders, + Remark: route.Remark, + CreatedAt: route.CreatedAt, + UpdatedAt: route.UpdatedAt, + }, nil +} + func normalizeProxyRouteSiteNameInput(route *model.ProxyRoute, raw string, primaryDomain string) string { siteName := strings.TrimSpace(raw) if siteName != "" { @@ -234,7 +361,7 @@ func normalizeProxyRouteDomains(rawDomains []string) ([]string, error) { continue } if strings.Contains(domain, "://") || strings.Contains(domain, "/") { - return nil, errors.New("域名格式不合法") + return nil, errors.New("domain format is invalid") } if _, ok := seen[domain]; ok { continue @@ -243,14 +370,14 @@ func normalizeProxyRouteDomains(rawDomains []string) ([]string, error) { normalized = append(normalized, domain) } if len(normalized) == 0 { - return nil, errors.New("至少填写一个域名") + return nil, errors.New("at least one domain is required") } return normalized, nil } func validateProxyRouteSiteName(siteName string) error { if strings.TrimSpace(siteName) == "" { - return errors.New("站点标识不能为空") + return errors.New("site_name cannot be empty") } return nil } @@ -272,7 +399,7 @@ func validateProxyRouteIdentityUniqueness(route *model.ProxyRoute, siteName stri } existingSiteName := normalizeProxyRouteSiteNameInput(item, item.SiteName, item.Domain) if existingSiteName == siteName { - return errors.New("站点标识已存在") + return errors.New("site_name already exists") } existingDomains, err := decodeStoredDomains(item.Domains, item.Domain) @@ -285,7 +412,7 @@ func validateProxyRouteIdentityUniqueness(route *model.ProxyRoute, siteName stri } for _, domain := range domains { if _, ok := existingSet[domain]; ok { - return fmt.Errorf("域名 %s 已存在", domain) + return fmt.Errorf("domain %s already exists", domain) } } } @@ -293,6 +420,27 @@ func validateProxyRouteIdentityUniqueness(route *model.ProxyRoute, siteName stri return nil } +func normalizeProxyRouteLimitConnValue(value int, field string) (int, error) { + if value < 0 { + return 0, fmt.Errorf("%s must be greater than or equal to 0", field) + } + return value, nil +} + +func normalizeProxyRouteLimitRate(raw string) (string, error) { + normalized := strings.ToLower(strings.TrimSpace(raw)) + if normalized == "" || normalized == "0" { + return "", nil + } + if !proxyRouteLimitRatePattern.MatchString(normalized) { + return "", errors.New("limit_rate must be a number or use the 512k / 1m format") + } + if strings.TrimRight(normalized, "km") == "" { + return "", nil + } + return normalized, nil +} + func resolveProxyRoutePrimaryOrigin(input ProxyRouteInput) (string, *uint, error) { if hasStructuredOriginInput(input) { scheme, err := normalizeOriginScheme(input.OriginScheme) @@ -310,7 +458,7 @@ func resolveProxyRoutePrimaryOrigin(input ProxyRouteInput) (string, *uint, error if input.OriginID != nil && *input.OriginID != 0 { origin, err := model.GetOriginByID(*input.OriginID) if err != nil { - return "", nil, errors.New("所选源站不存在") + return "", nil, errors.New("selected origin does not exist") } originURL, err := buildOriginURLFromParts( scheme, @@ -341,7 +489,7 @@ func resolveProxyRoutePrimaryOrigin(input ProxyRouteInput) (string, *uint, error originURL := strings.TrimSpace(input.OriginURL) if originURL == "" { - return "", nil, errors.New("源站地址不能为空") + return "", nil, errors.New("origin_url cannot be empty") } address, err := extractOriginAddress(originURL) if err != nil { @@ -377,13 +525,13 @@ func normalizeCustomHeaders(headers []ProxyRouteCustomHeaderInput) ([]ProxyRoute continue } if key == "" { - return nil, errors.New("自定义请求头名称不能为空") + return nil, errors.New("custom header key cannot be empty") } if !proxyHeaderKeyPattern.MatchString(key) { - return nil, errors.New("自定义请求头名称格式不合法") + return nil, errors.New("custom header key format is invalid") } if strings.ContainsAny(key, "\r\n") || strings.ContainsAny(value, "\r\n") { - return nil, errors.New("自定义请求头不能包含换行") + return nil, errors.New("custom headers cannot contain newlines") } normalized = append(normalized, ProxyRouteCustomHeaderInput{ Key: key, @@ -425,23 +573,23 @@ func normalizeUpstreams(originURL string, upstreams []string) ([]string, error) } parsed, err := url.ParseRequestURI(item) if err != nil { - return nil, errors.New("源站地址格式不合法") + return nil, errors.New("origin URL format is invalid") } if multiUpstream && parsed.Path != "" && parsed.Path != "/" { - return nil, errors.New("多上游模式暂不支持带路径的源站地址") + return nil, errors.New("multi-upstream mode does not support origin paths") } if multiUpstream && parsed.RawQuery != "" { - return nil, errors.New("多上游模式暂不支持带查询参数的源站地址") + return nil, errors.New("multi-upstream mode does not support origin query strings") } if scheme == "" { scheme = parsed.Scheme } else if scheme != parsed.Scheme { - return nil, errors.New("同一规则的多个上游必须使用相同协议") + return nil, errors.New("all upstreams must use the same scheme") } normalized = append(normalized, item) } if len(normalized) == 0 { - return nil, errors.New("至少填写一个上游地址") + return nil, errors.New("at least one upstream is required") } return normalized, nil } @@ -453,7 +601,7 @@ func decodeStoredCustomHeaders(raw string) ([]ProxyRouteCustomHeaderInput, error } var headers []ProxyRouteCustomHeaderInput if err := json.Unmarshal([]byte(text), &headers); err != nil { - return nil, errors.New("自定义请求头配置格式不合法") + return nil, errors.New("custom_headers payload is invalid") } return normalizeCustomHeaders(headers) } @@ -484,7 +632,7 @@ func normalizeCacheRules(enabled bool, rawPolicy string, rules []string) ([]stri case proxyRouteCachePolicyPathExact: return normalizeCachePathRules(rules, false) default: - return nil, errors.New("缓存策略不支持") + return nil, errors.New("cache policy is not supported") } } @@ -497,7 +645,7 @@ func normalizeCacheSuffixRules(rules []string) ([]string, error) { continue } if strings.ContainsAny(item, "/\\ \t\r\n") { - return nil, errors.New("缓存后缀格式不合法") + return nil, errors.New("cache suffix format is invalid") } if _, ok := seen[item]; ok { continue @@ -506,7 +654,7 @@ func normalizeCacheSuffixRules(rules []string) ([]string, error) { normalized = append(normalized, item) } if len(normalized) == 0 { - return nil, errors.New("按后缀缓存时至少填写一个后缀") + return nil, errors.New("at least one suffix is required") } return normalized, nil } @@ -520,7 +668,7 @@ func normalizeCachePathRules(rules []string, allowPrefix bool) ([]string, error) continue } if !strings.HasPrefix(item, "/") || strings.Contains(item, "://") || strings.ContainsAny(item, " \t\r\n") { - return nil, errors.New("缓存路径规则格式不合法") + return nil, errors.New("cache path rule format is invalid") } if !allowPrefix && strings.HasSuffix(item, "/") && len(item) > 1 { item = strings.TrimRight(item, "/") @@ -533,9 +681,9 @@ func normalizeCachePathRules(rules []string, allowPrefix bool) ([]string, error) } if len(normalized) == 0 { if allowPrefix { - return nil, errors.New("按路径前缀缓存时至少填写一个路径") + return nil, errors.New("at least one path prefix is required") } - return nil, errors.New("按精确路径缓存时至少填写一个路径") + return nil, errors.New("at least one exact path is required") } return normalized, nil } @@ -547,7 +695,7 @@ func decodeStoredCacheRules(raw string) ([]string, error) { } var rules []string if err := json.Unmarshal([]byte(text), &rules); err != nil { - return nil, errors.New("缓存规则格式不合法") + return nil, errors.New("cache_rules payload is invalid") } normalized := make([]string, 0, len(rules)) for _, rule := range rules { @@ -567,7 +715,7 @@ func decodeStoredUpstreams(raw string, fallbackOriginURL string) ([]string, erro } var upstreams []string if err := json.Unmarshal([]byte(text), &upstreams); err != nil { - return nil, errors.New("上游配置格式不合法") + return nil, errors.New("upstreams payload is invalid") } return normalizeUpstreams(fallbackOriginURL, upstreams) } @@ -579,24 +727,24 @@ func decodeStoredDomains(raw string, fallbackDomain string) ([]string, error) { } var domains []string if err := json.Unmarshal([]byte(text), &domains); err != nil { - return nil, errors.New("域名配置格式不合法") + return nil, errors.New("domains payload is invalid") } return normalizeProxyRouteDomains(domains) } func validateOriginURL(raw string) error { if raw == "" { - return errors.New("源站地址不能为空") + return errors.New("origin URL cannot be empty") } parsed, err := url.ParseRequestURI(raw) if err != nil { - return errors.New("源站地址格式不合法") + return errors.New("origin URL format is invalid") } if parsed.Scheme != "http" && parsed.Scheme != "https" { - return errors.New("源站地址必须以 http:// 或 https:// 开头") + return errors.New("origin URL must start with http:// or https://") } if parsed.Host == "" { - return errors.New("源站地址格式不合法") + return errors.New("origin URL format is invalid") } return nil } @@ -606,14 +754,14 @@ func validateOriginHost(raw string) error { return nil } if strings.ContainsAny(raw, "/\\ \t\r\n") || strings.Contains(raw, "://") { - return errors.New("回源主机名格式不合法") + return errors.New("origin_host format is invalid") } parsed, err := url.Parse("//" + raw) if err != nil || parsed.Host == "" || parsed.Host != raw { - return errors.New("回源主机名格式不合法") + return errors.New("origin_host format is invalid") } if parsed.Hostname() == "" { - return errors.New("回源主机名格式不合法") + return errors.New("origin_host format is invalid") } return nil } diff --git a/openflare_server/web/features/config-versions/types.ts b/openflare_server/web/features/config-versions/types.ts index b5607ecb..2a595f20 100644 --- a/openflare_server/web/features/config-versions/types.ts +++ b/openflare_server/web/features/config-versions/types.ts @@ -27,16 +27,22 @@ export interface ConfigPreviewResult { support_files: SupportFile[]; checksum: string; route_count: number; + website_count: number; } export interface ConfigDiffResult { active_version?: string; + added_sites: string[]; + removed_sites: string[]; + modified_sites: string[]; added_domains: string[]; removed_domains: string[]; modified_domains: string[]; main_config_changed: boolean; changed_option_keys: string[]; changed_option_details: ConfigOptionDiffItem[]; + current_website_count: number; + active_website_count: number; } export interface ConfigOptionDiffItem { diff --git a/openflare_server/web/features/proxy-routes/api/proxy-routes.ts b/openflare_server/web/features/proxy-routes/api/proxy-routes.ts index 5f6a25ef..a188f5e7 100644 --- a/openflare_server/web/features/proxy-routes/api/proxy-routes.ts +++ b/openflare_server/web/features/proxy-routes/api/proxy-routes.ts @@ -7,12 +7,16 @@ import type { TlsCertificateItem, } from '@/features/proxy-routes/types'; -export function getProxyRoutes() { - return apiRequest('/proxy-routes/'); -} - -export function createProxyRoute(payload: ProxyRouteMutationPayload) { - return apiRequest('/proxy-routes/', { +export function getProxyRoutes() { + return apiRequest('/proxy-routes/'); +} + +export function getProxyRoute(id: number) { + return apiRequest(`/proxy-routes/${id}`); +} + +export function createProxyRoute(payload: ProxyRouteMutationPayload) { + return apiRequest('/proxy-routes/', { method: 'POST', body: JSON.stringify(payload), }); diff --git a/openflare_server/web/features/proxy-routes/types.ts b/openflare_server/web/features/proxy-routes/types.ts index 78d9c65d..11adbb32 100644 --- a/openflare_server/web/features/proxy-routes/types.ts +++ b/openflare_server/web/features/proxy-routes/types.ts @@ -5,26 +5,38 @@ export interface ProxyRouteCustomHeader { export interface ProxyRouteItem { id: number; + site_name: string; domain: string; + domains: string[]; + primary_domain: string; + domain_count: number; origin_id: number | null; origin_url: string; origin_host: string; upstreams: string; + upstream_list: string[]; enabled: boolean; enable_https: boolean; cert_id: number | null; redirect_http: boolean; + limit_conn_per_server: number; + limit_conn_per_ip: number; + limit_rate: string; cache_enabled: boolean; cache_policy: string; cache_rules: string; + cache_rule_list: string[]; custom_headers: string; + custom_header_list: ProxyRouteCustomHeader[]; remark: string; created_at: string; updated_at: string; } export interface ProxyRouteMutationPayload { + site_name?: string; domain: string; + domains?: string[]; origin_id: number | null; origin_url: string; origin_scheme: 'http' | 'https'; @@ -37,6 +49,9 @@ export interface ProxyRouteMutationPayload { enable_https: boolean; cert_id: number | null; redirect_http: boolean; + limit_conn_per_server?: number; + limit_conn_per_ip?: number; + limit_rate?: string; cache_enabled: boolean; cache_policy: string; cache_rules: string[];