Compare commits

...

3 Commits

Author SHA1 Message Date
ryan 68d70bbbe8 [新增] 界面优化 2026-06-03 19:53:54 +08:00
ryan 08ec945e59 [新增] 界面优化 2026-06-03 19:52:38 +08:00
ryan 4401cb0d66 [新增] POW 与 WAF 规则合并 2026-06-03 19:09:01 +08:00
18 changed files with 437 additions and 254 deletions
@@ -549,7 +549,7 @@ func TestManagerEnsureLuaAssetsWritesReadableFiles(t *testing.T) {
if err != nil {
t.Fatalf("failed to read pow lua file: %v", err)
}
if !strings.Contains(string(data), filepath.ToSlash(manager.RuntimeConfigDir)+"/pow_config.json") {
if !strings.Contains(string(data), filepath.ToSlash(manager.RuntimeConfigDir)+"/waf_config.json") {
t.Fatalf("expected pow lua to read runtime config dir, got %s", string(data))
}
}
+48 -15
View File
@@ -39,9 +39,9 @@ end
-- Lazy-load pow_config from file; reload when content changes
local function load_pow_config()
local config_paths = {
"__OPENFLARE_RUNTIME_CONFIG_DIR__/pow_config.json",
"/etc/nginx/openflare-lua/pow_config.json",
"/usr/local/openresty/nginx/conf/pow_config.json"
"__OPENFLARE_RUNTIME_CONFIG_DIR__/waf_config.json",
"/etc/nginx/openflare-lua/waf_config.json",
"/usr/local/openresty/nginx/conf/waf_config.json"
}
for _, config_path in ipairs(config_paths) do
local f = io.open(config_path, "r")
@@ -54,7 +54,7 @@ local function load_pow_config()
return
end
-- Clear old domain entries
-- Clear old domain/site entries
local old_keys = pow_config_dict:get("_domain_keys")
if old_keys then
for domain in string.gmatch(old_keys, "[^\n]+") do
@@ -64,15 +64,38 @@ local function load_pow_config()
local domain_keys = {}
if content and content ~= "" and content ~= "{}" then
local ok, entries = pcall(cjson.decode, content)
if ok and entries and type(entries) == "table" then
for _, entry in ipairs(entries) do
if entry.domains then
for _, domain in ipairs(entry.domains) do
pow_config_dict:set(domain, cjson.encode(entry), 0)
domain_keys[#domain_keys+1] = domain
local ok, decoded = pcall(cjson.decode, content)
if ok and decoded and decoded.rule_groups and decoded.site_rule_groups then
-- Build rule groups map (group ID -> PoWConfig)
local groups = {}
for _, group in ipairs(decoded.rule_groups) do
if group.pow_enabled then
groups[tostring(group.id)] = group.pow_config
end
end
-- Build site name to pow_config map
for site, group_ids in pairs(decoded.site_rule_groups) do
local pow_config = nil
-- Check custom group IDs first
for _, id in ipairs(group_ids) do
pow_config = groups[tostring(id)]
if pow_config then
break
end
end
-- If not found, check global group IDs
if not pow_config then
for _, group in ipairs(decoded.rule_groups) do
if group.is_global and group.pow_enabled then
pow_config = group.pow_config
break
end
end
end
if pow_config then
pow_config_dict:set(site, cjson.encode({enabled = true, config = pow_config}), 0)
domain_keys[#domain_keys+1] = site
end
end
end
end
@@ -91,7 +114,12 @@ if not host or host == "" then
return
end
local config_raw = pow_config_dict:get(host)
local site = ngx.var.openflare_waf_site or ""
if site == "" then
site = host
end
local config_raw = pow_config_dict:get(site)
if not config_raw then
return
end
@@ -199,17 +227,22 @@ local args = ngx.req.get_uri_args()
local host = args["host"] or ngx.var.host or ""
local redir = args["redir"] or ""
local config_raw = pow_config_dict:get(host)
local site = ngx.var.openflare_waf_site or ""
if site == "" then
site = host
end
local config_raw = pow_config_dict:get(site)
if not config_raw then
ngx.status = 403
ngx.say("PoW not configured for this host")
ngx.say("PoW not configured for this site")
return
end
local ok, route_config = pcall(cjson.decode, config_raw)
if not ok or not route_config or not route_config.enabled then
ngx.status = 403
ngx.say("PoW not enabled for this host")
ngx.say("PoW not enabled for this site")
return
end
@@ -190,7 +190,7 @@ func TestSyncOnceSuccess(t *testing.T) {
if client.reports[0].MainConfigChecksum == "" || client.reports[0].RouteConfigChecksum == "" {
t.Fatal("expected main and route config checksums to be reported")
}
if client.reports[0].SupportFileCount != 4 {
if client.reports[0].SupportFileCount != 3 {
t.Fatalf("expected support file count to be reported, got %d", client.reports[0].SupportFileCount)
}
}
@@ -326,7 +326,7 @@ func TestSyncOnceRollbackOnNginxFailure(t *testing.T) {
if client.reports[0].MainConfigChecksum == "" || client.reports[0].RouteConfigChecksum == "" {
t.Fatal("expected failed report to include main and route config checksums")
}
if client.reports[0].SupportFileCount != 4 {
if client.reports[0].SupportFileCount != 3 {
t.Fatalf("expected failed report to include support file count, got %d", client.reports[0].SupportFileCount)
}
}
@@ -0,0 +1,41 @@
package goose
import (
"fmt"
presslygoose "github.com/pressly/goose/v3"
"gorm.io/gorm"
)
const versionDropProxyRouteLegacyPoW int64 = 202606030003
// migration202606030003 drops the legacy pow_enabled and pow_config columns
// from proxy_routes table, since PoW is now entirely managed under WAF rule groups.
func migration202606030003(backend string, ctx Context) *presslygoose.Migration {
return newGORMMigration(
versionDropProxyRouteLegacyPoW,
"202606030003_drop_proxy_route_legacy_pow.go",
backend,
ctx,
migrateDropProxyRouteLegacyPoW,
)
}
func migrateDropProxyRouteLegacyPoW(ctx Context, db *gorm.DB, backend string) error {
if err := ctx.ApplyCurrentSchema(db, backend); err != nil {
return err
}
// Drop pow_enabled column if exists
if db.Migrator().HasColumn("proxy_routes", "pow_enabled") {
if err := db.Exec("ALTER TABLE proxy_routes DROP COLUMN pow_enabled").Error; err != nil {
return fmt.Errorf("drop proxy_routes.pow_enabled: %w", err)
}
}
// Drop pow_config column if exists
if db.Migrator().HasColumn("proxy_routes", "pow_config") {
if err := db.Exec("ALTER TABLE proxy_routes DROP COLUMN pow_config").Error; err != nil {
return fmt.Errorf("drop proxy_routes.pow_config: %w", err)
}
}
return nil
}
@@ -43,6 +43,7 @@ func registeredMigrations(backend string, ctx Context) []*presslygoose.Migration
migration202606020001(backend, ctx),
migration202606030001(backend, ctx),
migration202606030002(backend, ctx),
migration202606030003(backend, ctx),
}
}
+32
View File
@@ -205,6 +205,12 @@ func TestUpgradeDatabaseSchemaV15ToV16AppliesCompressedReleaseSchema(t *testing.
if err := applyCurrentSchema(db, "sqlite"); err != nil {
t.Fatalf("apply current schema: %v", err)
}
if err := db.Exec("ALTER TABLE proxy_routes ADD COLUMN pow_enabled BOOLEAN NOT NULL DEFAULT 0").Error; err != nil {
t.Fatalf("failed to add legacy pow_enabled: %v", err)
}
if err := db.Exec("ALTER TABLE proxy_routes ADD COLUMN pow_config TEXT NOT NULL DEFAULT '{}'").Error; err != nil {
t.Fatalf("failed to add legacy pow_config: %v", err)
}
if err := ensureDefaultWAFRuleGroup(db); err != nil {
t.Fatalf("ensure default waf rule group: %v", err)
}
@@ -329,6 +335,13 @@ func TestEnsureDatabaseSchemaUpToDateUpgradesLegacyDatabase(t *testing.T) {
if err := autoMigrateAll(db); err != nil {
t.Fatalf("auto migrate db: %v", err)
}
// Add legacy PoW columns manually to proxy_routes table to simulate legacy schema v9-v17 state
if err := db.Exec("ALTER TABLE proxy_routes ADD COLUMN pow_enabled BOOLEAN NOT NULL DEFAULT 0").Error; err != nil {
t.Fatalf("failed to add legacy pow_enabled: %v", err)
}
if err := db.Exec("ALTER TABLE proxy_routes ADD COLUMN pow_config TEXT NOT NULL DEFAULT '{}'").Error; err != nil {
t.Fatalf("failed to add legacy pow_config: %v", err)
}
if err := db.Create(&User{
Username: "legacy",
Password: "secret",
@@ -439,6 +452,13 @@ func TestEnsureDatabaseSchemaUpToDateAddsProxyRouteDomainCertificateFields(t *te
if err := db.AutoMigrate(&legacyProxyRouteV7{}); err != nil {
t.Fatalf("auto migrate legacy proxy_routes v7: %v", err)
}
// Add legacy PoW columns manually to proxy_routes table to simulate legacy schema v9-v17 state
if err := db.Exec("ALTER TABLE proxy_routes ADD COLUMN pow_enabled BOOLEAN NOT NULL DEFAULT 0").Error; err != nil {
t.Fatalf("failed to add legacy pow_enabled: %v", err)
}
if err := db.Exec("ALTER TABLE proxy_routes ADD COLUMN pow_config TEXT NOT NULL DEFAULT '{}'").Error; err != nil {
t.Fatalf("failed to add legacy pow_config: %v", err)
}
now := time.Now().UTC()
certID := uint(9)
@@ -527,6 +547,12 @@ func TestEnsureDatabaseSchemaUpToDateAddsNodeIPManualOverride(t *testing.T) {
if err := applyCurrentSchema(db, "sqlite"); err != nil {
t.Fatalf("apply current schema: %v", err)
}
if err := db.Exec("ALTER TABLE proxy_routes ADD COLUMN pow_enabled BOOLEAN NOT NULL DEFAULT 0").Error; err != nil {
t.Fatalf("failed to add legacy pow_enabled: %v", err)
}
if err := db.Exec("ALTER TABLE proxy_routes ADD COLUMN pow_config TEXT NOT NULL DEFAULT '{}'").Error; err != nil {
t.Fatalf("failed to add legacy pow_config: %v", err)
}
if err := ensureDefaultWAFRuleGroup(db); err != nil {
t.Fatalf("ensure default waf rule group: %v", err)
}
@@ -570,6 +596,12 @@ func TestEnsureDatabaseSchemaUpToDateV16BackfillsNodeColumnsWhenNewColumnsAlread
if err := applyCurrentSchema(db, "sqlite"); err != nil {
t.Fatalf("apply current schema: %v", err)
}
if err := db.Exec("ALTER TABLE proxy_routes ADD COLUMN pow_enabled BOOLEAN NOT NULL DEFAULT 0").Error; err != nil {
t.Fatalf("failed to add legacy pow_enabled: %v", err)
}
if err := db.Exec("ALTER TABLE proxy_routes ADD COLUMN pow_config TEXT NOT NULL DEFAULT '{}'").Error; err != nil {
t.Fatalf("failed to add legacy pow_config: %v", err)
}
if err := ensureDefaultWAFRuleGroup(db); err != nil {
t.Fatalf("ensure default waf rule group: %v", err)
}
+16 -4
View File
@@ -1131,11 +1131,23 @@ func validateDatabaseSchemaV9(db *gorm.DB, backend string) error {
if err := validateDatabaseSchemaV8(db, backend); err != nil {
return err
}
if !db.Migrator().HasColumn(&ProxyRoute{}, "pow_enabled") {
return fmt.Errorf("column proxy_routes.pow_enabled is missing")
hasAppliedDropPoW := false
if db.Migrator().HasTable("goose_db_version") {
var count int64
_ = db.Table("goose_db_version").
Where("version_id = ? AND is_applied = ?", 202606030003, true).
Count(&count).Error
if count > 0 {
hasAppliedDropPoW = true
}
}
if !db.Migrator().HasColumn(&ProxyRoute{}, "pow_config") {
return fmt.Errorf("column proxy_routes.pow_config is missing")
if !hasAppliedDropPoW {
if !db.Migrator().HasColumn(&ProxyRoute{}, "pow_enabled") {
return fmt.Errorf("column proxy_routes.pow_enabled is missing")
}
if !db.Migrator().HasColumn(&ProxyRoute{}, "pow_config") {
return fmt.Errorf("column proxy_routes.pow_config is missing")
}
}
return nil
}
-4
View File
@@ -24,8 +24,6 @@ type ProxyRoute struct {
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:'[]'"`
PoWEnabled bool `json:"pow_enabled" gorm:"column:pow_enabled;not null;default:false"`
PoWConfig string `json:"pow_config" gorm:"column:pow_config;type:text;not null;default:'{}'"`
BasicAuthEnabled bool `json:"basic_auth_enabled" gorm:"not null;default:false"`
BasicAuthUsername string `json:"basic_auth_username" gorm:"size:255;not null;default:''"`
BasicAuthPassword string `json:"basic_auth_password" gorm:"size:255;not null;default:''"`
@@ -86,8 +84,6 @@ func (route *ProxyRoute) Update() error {
"cache_policy": route.CachePolicy,
"cache_rules": route.CacheRules,
"custom_headers": route.CustomHeaders,
"pow_enabled": route.PoWEnabled,
"pow_config": route.PoWConfig,
"basic_auth_enabled": route.BasicAuthEnabled,
"basic_auth_username": route.BasicAuthUsername,
"basic_auth_password": route.BasicAuthPassword,
-66
View File
@@ -11,39 +11,6 @@ import (
"gorm.io/gorm"
)
func TestGetActiveConfigForAgentIncludesPoWConfig(t *testing.T) {
setupServiceTestDB(t)
_, err := CreateProxyRoute(ProxyRouteInput{
Domain: "pow-agent.example.com",
OriginURL: "https://origin.internal",
Enabled: true,
PoWEnabled: true,
PoWConfig: `{"difficulty":4,"algorithm":"fast","session_ttl":86400,"challenge_ttl":300,"whitelist":{"paths":["/.well-known/*","/favicon.ico","/robots.txt"],"user_agents":["Googlebot","bingbot","Baiduspider"]},"blacklist":{"ips":[],"ip_cidrs":[],"paths":[],"path_regexes":[],"user_agents":[]}}`,
})
if err != nil {
t.Fatalf("CreateProxyRoute failed: %v", err)
}
if _, err := PublishConfigVersion("root", false); err != nil {
t.Fatalf("PublishConfigVersion failed: %v", err)
}
activeConfig, err := GetActiveConfigForAgent()
if err != nil {
t.Fatalf("GetActiveConfigForAgent failed: %v", err)
}
for _, file := range activeConfig.SupportFiles {
if file.Path == "pow_config.json" || file.Path == "waf_config.json" {
t.Fatalf("agent config should not receive rendered runtime config file %s", file.Path)
}
}
if !strings.Contains(activeConfig.SourceConfigJSON, `"pow_enabled":true`) {
t.Fatal("expected agent config source json to include PoW source configuration")
}
}
func TestGetActiveConfigForAgentIncludesWAFConfig(t *testing.T) {
setupServiceTestDB(t)
@@ -144,39 +111,6 @@ func TestChangedWAFIPGroupsForAgentReturnsChecksumDelta(t *testing.T) {
}
}
func TestGetActiveConfigForAgentUsesTenMinutePoWSessionDefault(t *testing.T) {
setupServiceTestDB(t)
_, err := CreateProxyRoute(ProxyRouteInput{
Domain: "pow-default.example.com",
OriginURL: "https://origin.internal",
Enabled: true,
PoWEnabled: true,
PoWConfig: `{}`,
})
if err != nil {
t.Fatalf("CreateProxyRoute failed: %v", err)
}
if _, err := PublishConfigVersion("root", false); err != nil {
t.Fatalf("PublishConfigVersion failed: %v", err)
}
activeConfig, err := GetActiveConfigForAgent()
if err != nil {
t.Fatalf("GetActiveConfigForAgent failed: %v", err)
}
for _, file := range activeConfig.SupportFiles {
if file.Path == "pow_config.json" {
t.Fatal("agent config should not receive rendered pow_config.json")
}
}
if !strings.Contains(activeConfig.SourceConfigJSON, `"session_ttl":600`) {
t.Fatalf("expected default PoW session TTL to be in source json, got %s", activeConfig.SourceConfigJSON)
}
}
func TestRegisterNodeWithAccessToken(t *testing.T) {
setupServiceTestDB(t)
+1 -46
View File
@@ -83,8 +83,6 @@ type snapshotRoute struct {
CachePolicy string `json:"cache_policy,omitempty"`
CacheRules []string `json:"cache_rules,omitempty"`
CustomHeaders []ProxyRouteCustomHeaderInput `json:"custom_headers,omitempty"`
PoWEnabled bool `json:"pow_enabled,omitempty"`
PoWConfig *ProxyRoutePoWConfig `json:"pow_config,omitempty"`
BasicAuthEnabled bool `json:"basic_auth_enabled,omitempty"`
BasicAuthUsername string `json:"basic_auth_username,omitempty"`
BasicAuthPassword string `json:"basic_auth_password,omitempty"`
@@ -551,13 +549,6 @@ func buildSnapshotRoutes(routes []*model.ProxyRoute) ([]snapshotRoute, error) {
if err != nil {
return nil, fmt.Errorf("路由 %s 缓存规则无效", route.Domain)
}
powConfig, err := decodeStoredPoWConfig(route.PoWEnabled, route.PoWConfig)
if err != nil {
return nil, fmt.Errorf("路由 %s PoW 配置无效", route.Domain)
}
if !route.PoWEnabled {
powConfig = nil
}
items = append(items, snapshotRoute{
ID: route.ID,
SiteName: normalizeProxyRouteSiteNameInput(route, route.SiteName, domains[0]),
@@ -579,8 +570,6 @@ func buildSnapshotRoutes(routes []*model.ProxyRoute) ([]snapshotRoute, error) {
CachePolicy: route.CachePolicy,
CacheRules: cacheRules,
CustomHeaders: customHeaders,
PoWEnabled: route.PoWEnabled,
PoWConfig: powConfig,
BasicAuthEnabled: route.BasicAuthEnabled,
BasicAuthUsername: route.BasicAuthUsername,
BasicAuthPassword: route.BasicAuthPassword,
@@ -861,17 +850,6 @@ func normalizeSnapshotRoutes(routes []snapshotRoute) []snapshotRoute {
if err == nil {
routes[index].LimitRate = normalizedLimitRate
}
if routes[index].PoWEnabled {
raw, err := json.Marshal(routes[index].PoWConfig)
if err == nil {
normalizedPoWConfig, err := normalizePoWConfig(true, string(raw))
if err == nil {
routes[index].PoWConfig = &normalizedPoWConfig
}
}
} else {
routes[index].PoWConfig = nil
}
if !routes[index].BasicAuthEnabled {
routes[index].BasicAuthUsername = ""
routes[index].BasicAuthPassword = ""
@@ -918,7 +896,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.LimitConnPerServer != right.LimitConnPerServer || left.LimitConnPerIP != right.LimitConnPerIP || left.LimitRate != right.LimitRate || left.CacheEnabled != right.CacheEnabled || left.CachePolicy != right.CachePolicy || left.PoWEnabled != right.PoWEnabled || left.BasicAuthEnabled != right.BasicAuthEnabled || left.BasicAuthUsername != right.BasicAuthUsername || left.BasicAuthPassword != right.BasicAuthPassword || left.UpstreamType != right.UpstreamType || !uintPtrEqual(left.TunnelNodeID, right.TunnelNodeID) || left.TunnelTargetAddr != right.TunnelTargetAddr || left.TunnelTargetProto != right.TunnelTargetProto || !uintPtrEqual(left.PagesProjectID, right.PagesProjectID) || !snapshotPagesDeploymentEqual(left.PagesDeployment, right.PagesDeployment) || !uintSliceEqual(left.CertIDs, right.CertIDs) || !uintSliceEqual(left.DomainCertIDs, right.DomainCertIDs) {
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 || left.BasicAuthEnabled != right.BasicAuthEnabled || left.BasicAuthUsername != right.BasicAuthUsername || left.BasicAuthPassword != right.BasicAuthPassword || left.UpstreamType != right.UpstreamType || !uintPtrEqual(left.TunnelNodeID, right.TunnelNodeID) || left.TunnelTargetAddr != right.TunnelTargetAddr || left.TunnelTargetProto != right.TunnelTargetProto || !uintPtrEqual(left.PagesProjectID, right.PagesProjectID) || !snapshotPagesDeploymentEqual(left.PagesDeployment, right.PagesDeployment) || !uintSliceEqual(left.CertIDs, right.CertIDs) || !uintSliceEqual(left.DomainCertIDs, right.DomainCertIDs) {
return false
}
if len(left.Domains) != len(right.Domains) {
@@ -953,9 +931,6 @@ func snapshotRouteConfigEqual(left snapshotRoute, right snapshotRoute) bool {
return false
}
}
if !snapshotPoWConfigEqual(left.PoWConfig, right.PoWConfig) {
return false
}
return true
}
@@ -986,26 +961,6 @@ func snapshotWAFConfigEqual(left snapshotWAFDocument, right snapshotWAFDocument)
return string(leftJSON) == string(rightJSON)
}
func snapshotPoWConfigEqual(left *ProxyRoutePoWConfig, right *ProxyRoutePoWConfig) bool {
if left == nil || right == nil {
return left == nil && right == nil
}
return left.Difficulty == right.Difficulty &&
left.Algorithm == right.Algorithm &&
left.SessionTTL == right.SessionTTL &&
left.ChallengeTTL == right.ChallengeTTL &&
stringSliceEqual(left.Whitelist.IPs, right.Whitelist.IPs) &&
stringSliceEqual(left.Whitelist.IPCidrs, right.Whitelist.IPCidrs) &&
stringSliceEqual(left.Whitelist.Paths, right.Whitelist.Paths) &&
stringSliceEqual(left.Whitelist.PathRegexes, right.Whitelist.PathRegexes) &&
stringSliceEqual(left.Whitelist.UserAgents, right.Whitelist.UserAgents) &&
stringSliceEqual(left.Blacklist.IPs, right.Blacklist.IPs) &&
stringSliceEqual(left.Blacklist.IPCidrs, right.Blacklist.IPCidrs) &&
stringSliceEqual(left.Blacklist.Paths, right.Blacklist.Paths) &&
stringSliceEqual(left.Blacklist.PathRegexes, right.Blacklist.PathRegexes) &&
stringSliceEqual(left.Blacklist.UserAgents, right.Blacklist.UserAgents)
}
func stringSliceEqual(left []string, right []string) bool {
if len(left) != len(right) {
return false
+37 -24
View File
@@ -982,31 +982,31 @@ func TestPublishConfigVersionDetectsPoWChanges(t *testing.T) {
if err != nil {
t.Fatalf("initial PublishConfigVersion failed: %v", err)
}
if !strings.Contains(firstRelease.Version.SupportFilesJSON, `"path":"pow_config.json"`) {
t.Fatal("expected publish to include pow_config.json support file")
if !strings.Contains(firstRelease.Version.SupportFilesJSON, `"path":"waf_config.json"`) {
t.Fatal("expected publish to include waf_config.json support file")
}
_, err = UpdateProxyRoute(route.ID, ProxyRouteInput{
Domain: route.Domain,
OriginURL: route.OriginURL,
Enabled: true,
PoWEnabled: true,
PoWConfig: `{"difficulty":5,"algorithm":"slow","session_ttl":7200,"challenge_ttl":180,"whitelist":{"ips":["127.0.0.1"],"ip_cidrs":[],"paths":[],"path_regexes":[],"user_agents":[]},"blacklist":{"ips":[],"ip_cidrs":[],"paths":["/login"],"path_regexes":[],"user_agents":[]}}`,
RedirectHTTP: false,
group, err := CreateWAFRuleGroup(WAFRuleGroupInput{
Name: "pow group",
Enabled: true,
BlockStatusCode: 418,
PoWEnabled: true,
PoWConfig: json.RawMessage(`{"difficulty":5,"algorithm":"slow","session_ttl":7200,"challenge_ttl":180,"whitelist":{"ips":["127.0.0.1"],"ip_cidrs":[],"paths":[],"path_regexes":[],"user_agents":[]},"blacklist":{"ips":[],"ip_cidrs":[],"paths":["/login"],"path_regexes":[],"user_agents":[]}}`),
})
if err != nil {
t.Fatalf("UpdateProxyRoute failed: %v", err)
t.Fatalf("CreateWAFRuleGroup failed: %v", err)
}
if _, err = ReplaceWAFSiteRuleGroups(route.ID, []uint{group.ID}); err != nil {
t.Fatalf("ReplaceWAFSiteRuleGroups failed: %v", err)
}
diff, err := DiffConfigVersion()
if err != nil {
t.Fatalf("DiffConfigVersion failed: %v", err)
}
if len(diff.ModifiedDomains) != 1 || diff.ModifiedDomains[0] != "pow.example.com" {
t.Fatalf("expected PoW change to mark domain as modified, got %#v", diff.ModifiedDomains)
}
if len(diff.ModifiedSites) != 1 || diff.ModifiedSites[0] != "pow.example.com" {
t.Fatalf("expected PoW change to mark site as modified, got %#v", diff.ModifiedSites)
if !diff.WAFConfigChanged {
t.Fatal("expected PoW change (via WAF Rule Group) to trigger WAF config change")
}
secondRelease, err := PublishConfigVersion("root", false)
@@ -1053,18 +1053,18 @@ func TestPublishConfigVersionDetectsPoWChanges(t *testing.T) {
if err := json.Unmarshal([]byte(secondRelease.Version.SupportFilesJSON), &supportFiles); err != nil {
t.Fatalf("failed to decode support files: %v", err)
}
foundPowSupportFile := false
foundWafSupportFile := false
for _, file := range supportFiles {
if file.Path != "pow_config.json" {
if file.Path != "waf_config.json" {
continue
}
foundPowSupportFile = true
foundWafSupportFile = true
if !strings.Contains(file.Content, `"difficulty":5`) {
t.Fatalf("expected pow support file to persist config, got %s", file.Content)
t.Fatalf("expected waf support file to persist pow config, got %s", file.Content)
}
}
if !foundPowSupportFile {
t.Fatal("expected publish to include pow_config.json support file")
if !foundWafSupportFile {
t.Fatal("expected publish to include waf_config.json support file")
}
}
@@ -1081,15 +1081,13 @@ func TestPublishConfigVersionRendersBasicAuthWithPoW(t *testing.T) {
t.Fatalf("CreateTLSCertificate failed: %v", err)
}
_, err = CreateProxyRoute(ProxyRouteInput{
route, err := CreateProxyRoute(ProxyRouteInput{
Domain: "xbot.example.com",
OriginURL: "http://c1:36185",
Enabled: true,
EnableHTTPS: true,
CertID: &certificate.ID,
RedirectHTTP: true,
PoWEnabled: true,
PoWConfig: `{"difficulty":4,"algorithm":"fast","session_ttl":600,"challenge_ttl":300,"whitelist":{"ips":[],"ip_cidrs":[],"paths":[],"path_regexes":[],"user_agents":[]},"blacklist":{"ips":[],"ip_cidrs":[],"paths":[],"path_regexes":[],"user_agents":[]}}`,
BasicAuthEnabled: true,
BasicAuthUsername: "admin",
BasicAuthPassword: "123",
@@ -1098,6 +1096,21 @@ func TestPublishConfigVersionRendersBasicAuthWithPoW(t *testing.T) {
t.Fatalf("CreateProxyRoute failed: %v", err)
}
group, err := CreateWAFRuleGroup(WAFRuleGroupInput{
Name: "pow group",
Enabled: true,
BlockStatusCode: 418,
PoWEnabled: true,
PoWConfig: json.RawMessage(`{"difficulty":4,"algorithm":"fast","session_ttl":600,"challenge_ttl":300,"whitelist":{"ips":[],"ip_cidrs":[],"paths":[],"path_regexes":[],"user_agents":[]},"blacklist":{"ips":[],"ip_cidrs":[],"paths":[],"path_regexes":[],"user_agents":[]}}`),
})
if err != nil {
t.Fatalf("CreateWAFRuleGroup failed: %v", err)
}
if _, err = ReplaceWAFSiteRuleGroups(route.ID, []uint{group.ID}); err != nil {
t.Fatalf("ReplaceWAFSiteRuleGroups failed: %v", err)
}
result, err := PublishConfigVersion("root", false)
if err != nil {
t.Fatalf("PublishConfigVersion failed: %v", err)
-21
View File
@@ -55,8 +55,6 @@ type ProxyRouteInput struct {
CachePolicy string `json:"cache_policy"`
CacheRules []string `json:"cache_rules"`
CustomHeaders []ProxyRouteCustomHeaderInput `json:"custom_headers"`
PoWEnabled bool `json:"pow_enabled"`
PoWConfig string `json:"pow_config"`
BasicAuthEnabled bool `json:"basic_auth_enabled"`
BasicAuthUsername string `json:"basic_auth_username"`
BasicAuthPassword string `json:"basic_auth_password"`
@@ -96,8 +94,6 @@ type ProxyRouteView struct {
CacheRuleList []string `json:"cache_rule_list"`
CustomHeaders string `json:"custom_headers"`
CustomHeaderList []ProxyRouteCustomHeaderInput `json:"custom_header_list"`
PoWEnabled bool `json:"pow_enabled"`
PoWConfig *ProxyRoutePoWConfig `json:"pow_config"`
BasicAuthEnabled bool `json:"basic_auth_enabled"`
BasicAuthUsername string `json:"basic_auth_username"`
BasicAuthPassword string `json:"basic_auth_password"`
@@ -239,15 +235,6 @@ func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.Pro
return nil, err
}
powConfig, err := normalizePoWConfig(input.PoWEnabled, input.PoWConfig)
if err != nil {
return nil, err
}
powConfigJSON, err := json.Marshal(powConfig)
if err != nil {
return nil, err
}
if !input.EnableHTTPS {
input.RedirectHTTP = false
input.CertID = nil
@@ -330,8 +317,6 @@ func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.Pro
route.CachePolicy = normalizeCachePolicy(input.CacheEnabled, cachePolicy)
route.CacheRules = string(cacheRulesJSON)
route.CustomHeaders = string(customHeadersJSON)
route.PoWEnabled = input.PoWEnabled
route.PoWConfig = string(powConfigJSON)
route.BasicAuthEnabled = input.BasicAuthEnabled
route.BasicAuthUsername = input.BasicAuthUsername
route.BasicAuthPassword = input.BasicAuthPassword
@@ -395,10 +380,6 @@ func buildProxyRouteView(route *model.ProxyRoute) (*ProxyRouteView, error) {
if err != nil {
return nil, err
}
powConfig, err := decodeStoredPoWConfig(route.PoWEnabled, route.PoWConfig)
if err != nil {
return nil, err
}
certIDs, err := decodeStoredCertIDs(route.CertIDs, route.CertID)
if err != nil {
return nil, err
@@ -439,8 +420,6 @@ func buildProxyRouteView(route *model.ProxyRoute) (*ProxyRouteView, error) {
CacheRuleList: cacheRules,
CustomHeaders: route.CustomHeaders,
CustomHeaderList: customHeaders,
PoWEnabled: route.PoWEnabled,
PoWConfig: powConfig,
BasicAuthEnabled: route.BasicAuthEnabled,
BasicAuthUsername: route.BasicAuthUsername,
BasicAuthPassword: route.BasicAuthPassword,
@@ -34,12 +34,7 @@ func Render(doc Document, certificateFiles []SupportFile) (*Result, error) {
if err != nil {
return nil, err
}
powConfig, err := RenderPoWConfig(doc)
if err != nil {
return nil, err
}
files := append([]SupportFile(nil), certificateFiles...)
files = append(files, SupportFile{Path: "pow_config.json", Content: powConfig})
files = append(files, SupportFile{Path: "waf_config.json", Content: wafConfig})
files = DedupeSupportFiles(files)
return &Result{
@@ -88,9 +83,6 @@ func RenderRouteConfig(doc Document, certificateFiles []SupportFile) (string, er
cacheConfig := routeCacheConfig{Enabled: route.CacheEnabled, Policy: route.CachePolicy, Rules: route.CacheRules}
limitConfig := routeLimitConfig{LimitConnPerServer: route.LimitConnPerServer, LimitConnPerIP: route.LimitConnPerIP, LimitRate: route.LimitRate}
powEnabled, _ := getPoWConfigForRoute(route.ID, doc.WAF)
if route.PoWEnabled {
powEnabled = true
}
if normalizeRouteUpstreamType(route.UpstreamType) == "pages" {
if route.PagesDeployment == nil {
return "", fmt.Errorf("route %s pages deployment is missing", route.Domain)
@@ -214,12 +206,6 @@ func RenderPoWConfig(doc Document) (string, error) {
entries := make([]domainEntry, 0)
for _, route := range doc.Routes {
powEnabled, powConfig := getPoWConfigForRoute(route.ID, doc.WAF)
if route.PoWEnabled {
powEnabled = true
if route.PoWConfig != nil {
powConfig = route.PoWConfig
}
}
if !powEnabled {
continue
}
@@ -234,19 +220,21 @@ func RenderPoWConfig(doc Document) (string, error) {
func RenderWAFConfig(snapshot WAFDocument) (string, error) {
type wafRuntimeRuleGroup struct {
ID uint `json:"id"`
Name string `json:"name"`
IsGlobal bool `json:"is_global"`
BlockStatusCode int `json:"block_status_code"`
BlockResponseBody string `json:"block_response_body"`
IPWhitelist []string `json:"ip_whitelist"`
IPBlacklist []string `json:"ip_blacklist"`
IPWhitelistGroups []uint `json:"ip_whitelist_group_ids,omitempty"`
IPBlacklistGroups []uint `json:"ip_blacklist_group_ids,omitempty"`
CountryWhitelist []string `json:"country_whitelist"`
CountryBlacklist []string `json:"country_blacklist"`
RegionWhitelist []string `json:"region_whitelist"`
RegionBlacklist []string `json:"region_blacklist"`
ID uint `json:"id"`
Name string `json:"name"`
IsGlobal bool `json:"is_global"`
BlockStatusCode int `json:"block_status_code"`
BlockResponseBody string `json:"block_response_body"`
IPWhitelist []string `json:"ip_whitelist"`
IPBlacklist []string `json:"ip_blacklist"`
IPWhitelistGroups []uint `json:"ip_whitelist_group_ids,omitempty"`
IPBlacklistGroups []uint `json:"ip_blacklist_group_ids,omitempty"`
CountryWhitelist []string `json:"country_whitelist"`
CountryBlacklist []string `json:"country_blacklist"`
RegionWhitelist []string `json:"region_whitelist"`
RegionBlacklist []string `json:"region_blacklist"`
PoWEnabled bool `json:"pow_enabled"`
PoWConfig *PoWConfig `json:"pow_config,omitempty"`
}
type wafRuntimeConfig struct {
DefaultBlockStatusCode int `json:"default_block_status_code"`
@@ -268,6 +256,10 @@ func RenderWAFConfig(snapshot WAFDocument) (string, error) {
globalGroupIDs = append(globalGroupIDs, group.ID)
}
enabledGroupIDs[group.ID] = struct{}{}
powConfig := group.PoWConfig
if !group.PoWEnabled {
powConfig = nil
}
groups = append(groups, wafRuntimeRuleGroup{
ID: group.ID,
Name: group.Name,
@@ -282,6 +274,8 @@ func RenderWAFConfig(snapshot WAFDocument) (string, error) {
CountryBlacklist: group.CountryBlacklist,
RegionWhitelist: group.RegionWhitelist,
RegionBlacklist: group.RegionBlacklist,
PoWEnabled: group.PoWEnabled,
PoWConfig: powConfig,
})
}
sort.Slice(groups, func(i, j int) bool {
+11
View File
@@ -176,3 +176,14 @@ button {
@apply font-sans;
}
}
/* Hide scrollbar for Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.no-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
@@ -221,19 +221,17 @@ function SidebarContent({
) : null}
</div>
<nav className="flex-1 space-y-2">
<div className="flex max-h-full min-h-0 flex-col gap-2 overflow-y-auto pr-1">
{dashboardNavigation.map((item) => (
<SidebarNavItem
key={item.href}
item={item}
currentPath={currentPath}
isSidebarCollapsed={isSidebarCollapsed}
forceExpanded={forceExpanded}
onNavigate={onNavigate}
/>
))}
</div>
<nav className="min-h-0 flex-1 overflow-y-auto no-scrollbar pr-1 flex flex-col gap-2">
{dashboardNavigation.map((item) => (
<SidebarNavItem
key={item.href}
item={item}
currentPath={currentPath}
isSidebarCollapsed={isSidebarCollapsed}
forceExpanded={forceExpanded}
onNavigate={onNavigate}
/>
))}
</nav>
</div>
);
@@ -60,7 +60,6 @@ import {
HealthEventFilter,
NodeDetailTab,
MetricBar,
SummaryStat,
} from './node-shared';
export function RelayDetailPage({ node }: { node: NodeItem }) {
@@ -318,7 +317,6 @@ export function RelayDetailPage({ node }: { node: NodeItem }) {
: stableAgentReleaseQuery.isFetching;
const applyLogs = applyLogsQuery.data?.rows ?? [];
const latestHealthEvent = activeHealthEvents[0] ?? null;
const memoryUsageRatio = formatUsageRatio(
latestMetricSnapshot?.memory_used_bytes,
latestMetricSnapshot?.memory_total_bytes,
@@ -1,4 +1,5 @@
import { apiRequest } from '@/lib/api/client';
import { apiRequest, getApiUrl, ApiError } from '@/lib/api/client';
import type { ApiEnvelope } from '@/types/api';
import type {
PagesDeployment,
@@ -42,13 +43,58 @@ export function uploadPagesDeployment(
projectId: number,
file: File,
entryFile = 'index.html',
onProgress?: (percent: number) => void,
) {
const formData = new FormData();
formData.append('package', file);
formData.append('entry_file', entryFile);
return apiRequest<PagesDeployment>(`/pages/${projectId}/deployments/upload`, {
method: 'POST',
body: formData,
if (!onProgress) {
const formData = new FormData();
formData.append('package', file);
formData.append('entry_file', entryFile);
return apiRequest<PagesDeployment>(`/pages/${projectId}/deployments/upload`, {
method: 'POST',
body: formData,
});
}
return new Promise<PagesDeployment>((resolve, reject) => {
const formData = new FormData();
formData.append('package', file);
formData.append('entry_file', entryFile);
const xhr = new XMLHttpRequest();
xhr.open('POST', getApiUrl(`/pages/${projectId}/deployments/upload`));
xhr.withCredentials = true;
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
const percent = Math.round((event.loaded / event.total) * 100);
onProgress(percent);
}
});
xhr.onload = () => {
let payload: ApiEnvelope<PagesDeployment> | null = null;
try {
payload = JSON.parse(xhr.responseText) as ApiEnvelope<PagesDeployment>;
} catch {
payload = null;
}
if (xhr.status >= 200 && xhr.status < 300) {
if (payload && payload.success) {
resolve(payload.data);
} else {
reject(new ApiError(payload?.message || '请求失败', xhr.status));
}
} else {
reject(new ApiError(payload?.message || `请求失败(${xhr.status})`, xhr.status));
}
};
xhr.onerror = () => {
reject(new ApiError('网络请求失败', 0));
};
xhr.send(formData);
});
}
@@ -3,7 +3,7 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useState, type FormEvent } from 'react';
import { useEffect, useState, type FormEvent } from 'react';
import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state';
@@ -20,6 +20,7 @@ import {
getPagesProject,
getPagesDeployments,
getPagesProjects,
updatePagesProject,
uploadPagesDeployment,
} from '@/features/pages/api/pages';
import type { PagesProject } from '@/features/pages/types';
@@ -135,6 +136,8 @@ export function PagesProjectDetailPage({ projectId }: { projectId: string }) {
const queryClient = useQueryClient();
const [file, setFile] = useState<File | null>(null);
const [entryFile, setEntryFile] = useState('index.html');
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
const [isEditModalOpen, setEditModalOpen] = useState(false);
const parsedProjectId = Number(projectId);
const projectQuery = useQuery({
@@ -153,16 +156,23 @@ export function PagesProjectDetailPage({ projectId }: { projectId: string }) {
if (!file) {
throw new Error('请选择 zip 文件');
}
return uploadPagesDeployment(parsedProjectId, file, entryFile);
setUploadProgress(0);
return uploadPagesDeployment(parsedProjectId, file, entryFile, (percent) => {
setUploadProgress(percent);
});
},
onSuccess: () => {
setFile(null);
setUploadProgress(null);
queryClient.invalidateQueries({
queryKey: deploymentsQueryKey(parsedProjectId),
});
queryClient.invalidateQueries({ queryKey: projectQueryKey(projectId) });
queryClient.invalidateQueries({ queryKey: projectsQueryKey });
},
onError: () => {
setUploadProgress(null);
},
});
const activateMutation = useMutation({
mutationFn: (deploymentId: number) =>
@@ -246,6 +256,12 @@ export function PagesProjectDetailPage({ projectId }: { projectId: string }) {
>
返回列表
</Link>
<SecondaryButton
type="button"
onClick={() => setEditModalOpen(true)}
>
编辑项目
</SecondaryButton>
<DangerButton
type="button"
disabled={deleteProjectMutation.isPending}
@@ -280,12 +296,28 @@ export function PagesProjectDetailPage({ projectId }: { projectId: string }) {
onChange={(event) => setEntryFile(event.target.value)}
/>
</ResourceField>
{uploadProgress !== null && (
<div className="space-y-2">
<div className="flex items-center justify-between text-xs font-medium text-[var(--foreground-secondary)]">
<span>上传进度</span>
<span>{uploadProgress}%</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-[var(--surface-muted)]">
<div
className="h-full rounded-full bg-[var(--brand-primary)] transition-all duration-300 ease-out"
style={{ width: `${uploadProgress}%` }}
/>
</div>
</div>
)}
<PrimaryButton
type="button"
disabled={!file || uploadMutation.isPending}
onClick={() => uploadMutation.mutate()}
>
{uploadMutation.isPending ? '上传中...' : '上传部署'}
{uploadMutation.isPending
? `上传中 (${uploadProgress ?? 0}%)...`
: '上传部署'}
</PrimaryButton>
{uploadMutation.error ? (
<p className="text-sm text-[var(--status-danger-foreground)]">
@@ -374,10 +406,137 @@ export function PagesProjectDetailPage({ projectId }: { projectId: string }) {
)}
</AppCard>
</div>
<PagesProjectEditModal
isOpen={isEditModalOpen}
onClose={() => setEditModalOpen(false)}
project={project}
/>
</div>
);
}
function PagesProjectEditModal({
isOpen,
onClose,
project,
}: {
isOpen: boolean;
onClose: () => void;
project: PagesProject;
}) {
const queryClient = useQueryClient();
const [name, setName] = useState(project.name);
const [slug, setSlug] = useState(project.slug);
const [description, setDescription] = useState(project.description || '');
const [spaFallbackEnabled, setSpaFallbackEnabled] = useState(project.spa_fallback_enabled);
const [spaFallbackPath, setSpaFallbackPath] = useState(project.spa_fallback_path);
useEffect(() => {
setName(project.name);
setSlug(project.slug);
setDescription(project.description || '');
setSpaFallbackEnabled(project.spa_fallback_enabled);
setSpaFallbackPath(project.spa_fallback_path);
}, [project]);
const updateMutation = useMutation({
mutationFn: () =>
updatePagesProject(project.id, {
name,
slug,
description,
enabled: project.enabled,
spa_fallback_enabled: spaFallbackEnabled,
spa_fallback_path: spaFallbackPath,
}),
onSuccess: () => {
onClose();
queryClient.invalidateQueries({ queryKey: projectQueryKey(project.id) });
queryClient.invalidateQueries({ queryKey: projectsQueryKey });
},
});
function handleUpdate(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
updateMutation.mutate();
}
return (
<AppModal
isOpen={isOpen}
onClose={onClose}
title="编辑 Pages 项目"
description="修改静态站点项目的基础配置。"
footer={
<div className="flex flex-wrap justify-end gap-3">
<SecondaryButton type="button" onClick={onClose}>
取消
</SecondaryButton>
<PrimaryButton
type="submit"
form="pages-project-edit-form"
disabled={updateMutation.isPending || name.trim() === ''}
>
{updateMutation.isPending ? '保存中...' : '保存修改'}
</PrimaryButton>
</div>
}
>
<form
id="pages-project-edit-form"
className="grid gap-4 md:grid-cols-2"
onSubmit={handleUpdate}
>
<ResourceField label="项目名称">
<ResourceInput
value={name}
placeholder="Marketing Site"
onChange={(event) => setName(event.target.value)}
required
/>
</ResourceField>
<ResourceField label="项目标识" hint="留空时会按名称自动生成。">
<ResourceInput
value={slug}
placeholder="marketing-site"
onChange={(event) => setSlug(event.target.value)}
/>
</ResourceField>
<ResourceField label="描述" className="md:col-span-2">
<ResourceInput
value={description}
placeholder="这个项目托管的静态站点用途"
onChange={(event) => setDescription(event.target.value)}
/>
</ResourceField>
<ToggleField
label="启用 SPA fallback"
description="开启后未命中的路径会回退到指定文件,适合 React/Vue history 路由。"
checked={spaFallbackEnabled}
onChange={setSpaFallbackEnabled}
/>
<ResourceField
label="SPA 回退路径"
hint="以 / 开头,例如 /index.html 或 /app.html。关闭 fallback 时不会生效。"
>
<ResourceInput
value={spaFallbackPath}
placeholder="/index.html"
disabled={!spaFallbackEnabled}
onChange={(event) => setSpaFallbackPath(event.target.value)}
/>
</ResourceField>
{updateMutation.error ? (
<p className="text-sm text-[var(--status-danger-foreground)] md:col-span-2">
{updateMutation.error.message}
</p>
) : null}
</form>
</AppModal>
);
}
function PagesProjectCreateModal({
isOpen,
onClose,
@@ -569,22 +728,3 @@ function PagesProjectListItem({ project }: { project: PagesProject }) {
);
}
function OverviewItem({
label,
value,
hint,
}: {
label: string;
value: string;
hint: string;
}) {
return (
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-3">
<p className="text-xs text-[var(--foreground-secondary)]">{label}</p>
<p className="mt-2 truncate text-sm font-semibold text-[var(--foreground-primary)]">
{value}
</p>
<p className="mt-2 text-xs text-[var(--foreground-secondary)]">{hint}</p>
</div>
);
}