mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-05 07:26:36 +08:00
[功能] POW 集成
This commit is contained in:
@@ -4,7 +4,7 @@ import "time"
|
||||
|
||||
const (
|
||||
legacyDatabaseSchemaVersion = 1
|
||||
currentDatabaseSchemaVersion = 8
|
||||
currentDatabaseSchemaVersion = 9
|
||||
databaseSchemaVersionRowID = 1
|
||||
)
|
||||
|
||||
|
||||
@@ -1196,6 +1196,39 @@ func migrateV8(db *gorm.DB, backend string) error {
|
||||
return backfillProxyRouteDomainCertificateFields(db)
|
||||
}
|
||||
|
||||
// migrateV9 adds PoW (Proof-of-Work) anti-bot protection fields to proxy_routes.
|
||||
func migrateV9(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
|
||||
}
|
||||
if err := ensureProxyRouteSiteNameUniqueIndex(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := backfillProxyRouteCertificateFields(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return backfillProxyRouteDomainCertificateFields(db)
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
if !db.Migrator().HasColumn(&ProxyRoute{}, "pow_config") {
|
||||
return fmt.Errorf("column proxy_routes.pow_config is missing")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func databaseSchemaMigrations() []databaseSchemaMigration {
|
||||
return []databaseSchemaMigration{
|
||||
{fromVersion: 1, toVersion: 2, migrate: migrateV2, validate: validateDatabaseSchemaV2},
|
||||
@@ -1205,6 +1238,7 @@ func databaseSchemaMigrations() []databaseSchemaMigration {
|
||||
{fromVersion: 5, toVersion: 6, migrate: migrateV6, validate: validateDatabaseSchemaV6},
|
||||
{fromVersion: 6, toVersion: 7, migrate: migrateV7, validate: validateDatabaseSchemaV7},
|
||||
{fromVersion: 7, toVersion: 8, migrate: migrateV8, validate: validateDatabaseSchemaV8},
|
||||
{fromVersion: 8, toVersion: 9, migrate: migrateV9, validate: validateDatabaseSchemaV9},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1287,7 +1321,7 @@ func initializeFreshDatabaseSchema(db *gorm.DB, backend string) error {
|
||||
if err := backfillProxyRouteDomainCertificateFields(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateDatabaseSchemaV8(db, backend); err != nil {
|
||||
if err := validateDatabaseSchemaV9(db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
return saveDatabaseSchemaVersion(db, currentDatabaseSchemaVersion)
|
||||
|
||||
@@ -24,6 +24,8 @@ 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:'{}'"`
|
||||
Remark string `json:"remark" gorm:"size:255"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
@@ -76,6 +78,8 @@ 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,
|
||||
"remark": route.Remark,
|
||||
}).Error
|
||||
}
|
||||
|
||||
@@ -217,7 +217,7 @@ func GetActiveConfigForAgent() (*AgentConfigResponse, error) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
supportFiles = filterCertificateSupportFiles(supportFiles)
|
||||
supportFiles = filterAgentSupportFiles(supportFiles)
|
||||
slog.Debug("agent fetched active config", "version", version.Version, "checksum", version.Checksum)
|
||||
return &AgentConfigResponse{
|
||||
Version: version.Version,
|
||||
@@ -230,7 +230,7 @@ func GetActiveConfigForAgent() (*AgentConfigResponse, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func filterCertificateSupportFiles(files []SupportFile) []SupportFile {
|
||||
func filterAgentSupportFiles(files []SupportFile) []SupportFile {
|
||||
if len(files) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -240,6 +240,8 @@ func filterCertificateSupportFiles(files []SupportFile) []SupportFile {
|
||||
switch {
|
||||
case strings.HasSuffix(path, ".crt"), strings.HasSuffix(path, ".key"), strings.HasSuffix(path, ".pem"):
|
||||
filtered = append(filtered, file)
|
||||
case path == "pow_config.json":
|
||||
filtered = append(filtered, file)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
|
||||
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"); err != nil {
|
||||
t.Fatalf("PublishConfigVersion failed: %v", err)
|
||||
}
|
||||
|
||||
activeConfig, err := GetActiveConfigForAgent()
|
||||
if err != nil {
|
||||
t.Fatalf("GetActiveConfigForAgent failed: %v", err)
|
||||
}
|
||||
|
||||
foundPowConfig := false
|
||||
for _, file := range activeConfig.SupportFiles {
|
||||
if file.Path != "pow_config.json" {
|
||||
continue
|
||||
}
|
||||
foundPowConfig = true
|
||||
if file.Content == "" {
|
||||
t.Fatal("expected pow_config.json content to be populated")
|
||||
}
|
||||
}
|
||||
if !foundPowConfig {
|
||||
t.Fatal("expected agent config to include pow_config.json support file")
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,8 @@ 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"`
|
||||
Remark string `json:"remark,omitempty"`
|
||||
}
|
||||
|
||||
@@ -429,7 +431,13 @@ func buildCurrentConfigBundle(requireRoutes bool) (*configBundle, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
powConfigJSON, powSupportFiles, err := renderPowConfigBundle(routes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
supportFiles = append(supportFiles, powSupportFiles...)
|
||||
mainConfig := renderMainConfig(openRestyConfig)
|
||||
supportFiles = append(supportFiles, SupportFile{Path: "pow_config.json", Content: powConfigJSON})
|
||||
return &configBundle{
|
||||
Routes: routes,
|
||||
SnapshotRoutes: snapshotRoutes,
|
||||
@@ -462,6 +470,13 @@ 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{
|
||||
SiteName: normalizeProxyRouteSiteNameInput(route, route.SiteName, domains[0]),
|
||||
Domain: domains[0],
|
||||
@@ -482,6 +497,8 @@ func buildSnapshotRoutes(routes []*model.ProxyRoute) ([]snapshotRoute, error) {
|
||||
CachePolicy: route.CachePolicy,
|
||||
CacheRules: cacheRules,
|
||||
CustomHeaders: customHeaders,
|
||||
PoWEnabled: route.PoWEnabled,
|
||||
PoWConfig: powConfig,
|
||||
Remark: route.Remark,
|
||||
})
|
||||
}
|
||||
@@ -586,6 +603,17 @@ 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
|
||||
}
|
||||
}
|
||||
return routes
|
||||
}
|
||||
@@ -611,7 +639,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 || !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.PoWEnabled != right.PoWEnabled || !uintSliceEqual(left.CertIDs, right.CertIDs) || !uintSliceEqual(left.DomainCertIDs, right.DomainCertIDs) {
|
||||
return false
|
||||
}
|
||||
if len(left.Domains) != len(right.Domains) {
|
||||
@@ -646,6 +674,41 @@ func snapshotRouteConfigEqual(left snapshotRoute, right snapshotRoute) bool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if !snapshotPoWConfigEqual(left.PoWConfig, right.PoWConfig) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
for index := range left {
|
||||
if left[index] != right[index] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -835,7 +898,7 @@ func renderRouteConfig(routes []*model.ProxyRoute, cfg openRestyConfigSnapshot)
|
||||
builder.WriteString(renderNamedUpstreamBlock(upstreamConfig))
|
||||
}
|
||||
if !route.EnableHTTPS {
|
||||
builder.WriteString(renderHTTPProxyServer(serverNames, route.OriginURL, route.OriginHost, customHeaders, cacheConfig, limitConfig, upstreamConfig, cfg))
|
||||
builder.WriteString(renderHTTPProxyServer(serverNames, route.OriginURL, route.OriginHost, customHeaders, cacheConfig, limitConfig, upstreamConfig, route.PoWEnabled, cfg))
|
||||
continue
|
||||
}
|
||||
certIDs, err := decodeStoredCertIDs(route.CertIDs, route.CertID)
|
||||
@@ -896,7 +959,7 @@ func renderRouteConfig(routes []*model.ProxyRoute, cfg openRestyConfigSnapshot)
|
||||
|
||||
if route.RedirectHTTP {
|
||||
if len(httpOnlyDomains) > 0 {
|
||||
builder.WriteString(renderHTTPProxyServer(renderServerNames(httpOnlyDomains), route.OriginURL, route.OriginHost, customHeaders, cacheConfig, limitConfig, upstreamConfig, cfg))
|
||||
builder.WriteString(renderHTTPProxyServer(renderServerNames(httpOnlyDomains), route.OriginURL, route.OriginHost, customHeaders, cacheConfig, limitConfig, upstreamConfig, route.PoWEnabled, cfg))
|
||||
}
|
||||
for _, certID := range certIDs {
|
||||
assignedDomains := domainsByCertID[certID]
|
||||
@@ -906,14 +969,14 @@ func renderRouteConfig(routes []*model.ProxyRoute, cfg openRestyConfigSnapshot)
|
||||
builder.WriteString(renderHTTPRedirectServer(renderServerNames(assignedDomains)))
|
||||
}
|
||||
} else {
|
||||
builder.WriteString(renderHTTPProxyServer(serverNames, route.OriginURL, route.OriginHost, customHeaders, cacheConfig, limitConfig, upstreamConfig, cfg))
|
||||
builder.WriteString(renderHTTPProxyServer(serverNames, route.OriginURL, route.OriginHost, customHeaders, cacheConfig, limitConfig, upstreamConfig, route.PoWEnabled, cfg))
|
||||
}
|
||||
for _, certID := range certIDs {
|
||||
assignedDomains := domainsByCertID[certID]
|
||||
if len(assignedDomains) == 0 {
|
||||
continue
|
||||
}
|
||||
builder.WriteString(renderHTTPSServer(renderServerNames(assignedDomains), route.OriginURL, route.OriginHost, certID, customHeaders, cacheConfig, limitConfig, upstreamConfig, cfg))
|
||||
builder.WriteString(renderHTTPSServer(renderServerNames(assignedDomains), route.OriginURL, route.OriginHost, certID, customHeaders, cacheConfig, limitConfig, upstreamConfig, route.PoWEnabled, cfg))
|
||||
}
|
||||
}
|
||||
return builder.String(), dedupeSupportFiles(supportFiles), nil
|
||||
@@ -1020,6 +1083,32 @@ func onOff(value bool) string {
|
||||
return "off"
|
||||
}
|
||||
|
||||
const nginxPowStaticDirPlaceholder = "__OPENFLARE_POW_STATIC_DIR__"
|
||||
|
||||
func renderPowAccessBlock(powEnabled bool) string {
|
||||
if !powEnabled {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(" access_by_lua_file %s/pow/check.lua;\n", nginxLuaDirPlaceholder)
|
||||
}
|
||||
|
||||
func renderPowLocationBlocks(powEnabled bool) string {
|
||||
if !powEnabled {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("\n location = %spass-challenge {\n content_by_lua_file %s/pow/verify.lua;\n }\n\n location = %smake-challenge {\n content_by_lua_file %s/pow/challenge.lua;\n }\n\n", anubisAPIPrefix, nginxLuaDirPlaceholder, anubisAPIPrefix, nginxLuaDirPlaceholder)
|
||||
}
|
||||
|
||||
func renderPowStaticLocationBlock(powEnabled bool) string {
|
||||
if !powEnabled {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(" location %s {\n alias %s/;\n }\n\n", anubisStaticPrefix, nginxPowStaticDirPlaceholder)
|
||||
}
|
||||
|
||||
const anubisStaticPrefix = "/.within.website/x/cmd/anubis/static/"
|
||||
const anubisAPIPrefix = "/.within.website/x/cmd/anubis/api/"
|
||||
|
||||
func normalizeSnapshotCertificateIDs(primaryCertID *uint, certIDs []uint) ([]uint, *uint, error) {
|
||||
candidates := make([]uint, 0, len(certIDs)+1)
|
||||
if primaryCertID != nil && *primaryCertID != 0 {
|
||||
@@ -1130,18 +1219,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, 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 renderHTTPProxyServer(serverNames string, originURL string, originHost string, customHeaders []ProxyRouteCustomHeaderInput, cacheConfig routeCacheConfig, limitConfig routeLimitConfig, upstreamConfig routeUpstreamConfig, powEnabled bool, cfg openRestyConfigSnapshot) string {
|
||||
return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n%s location / {\n%s%s%s%s%s }\n%s}\n\n", serverNames, renderPowLocationBlocks(powEnabled), renderPowAccessBlock(powEnabled), renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig), renderRouteLimitBlock(limitConfig), renderRouteCacheBlock(cacheConfig, cfg), renderProxyPassBlock(originURL, upstreamConfig), renderPowStaticLocationBlock(powEnabled))
|
||||
}
|
||||
|
||||
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, limitConfig routeLimitConfig, upstreamConfig routeUpstreamConfig, cfg openRestyConfigSnapshot) string {
|
||||
func renderHTTPSServer(serverNames string, originURL string, originHost string, certificateID uint, customHeaders []ProxyRouteCustomHeaderInput, cacheConfig routeCacheConfig, limitConfig routeLimitConfig, upstreamConfig routeUpstreamConfig, powEnabled bool, 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%s }\n}\n\n", serverNames, certPath, keyPath, renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig), renderRouteLimitBlock(limitConfig), 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%s location / {\n%s%s%s%s%s }\n%s}\n\n", serverNames, certPath, keyPath, renderPowLocationBlocks(powEnabled), renderPowAccessBlock(powEnabled), renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig), renderRouteLimitBlock(limitConfig), renderRouteCacheBlock(cacheConfig, cfg), renderProxyPassBlock(originURL, upstreamConfig), renderPowStaticLocationBlock(powEnabled))
|
||||
}
|
||||
|
||||
func renderHTTPSServerWithCertificates(serverNames string, originURL string, originHost string, certificateIDs []uint, customHeaders []ProxyRouteCustomHeaderInput, cacheConfig routeCacheConfig, limitConfig routeLimitConfig, upstreamConfig routeUpstreamConfig, cfg openRestyConfigSnapshot) string {
|
||||
@@ -1505,3 +1594,40 @@ func dedupeSupportFiles(files []SupportFile) []SupportFile {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func renderPowConfigBundle(routes []*model.ProxyRoute) (string, []SupportFile, error) {
|
||||
type domainEntry struct {
|
||||
Domains []string `json:"domains"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Config map[string]interface{} `json:"config"`
|
||||
}
|
||||
entries := make([]domainEntry, 0)
|
||||
hasPow := false
|
||||
for _, route := range routes {
|
||||
if !route.PoWEnabled {
|
||||
continue
|
||||
}
|
||||
hasPow = true
|
||||
domains, err := decodeStoredDomains(route.Domains, route.Domain)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
var cfg map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(route.PoWConfig), &cfg); err != nil {
|
||||
return "", nil, fmt.Errorf("route %s pow_config is invalid", route.Domain)
|
||||
}
|
||||
entries = append(entries, domainEntry{
|
||||
Domains: domains,
|
||||
Enabled: true,
|
||||
Config: cfg,
|
||||
})
|
||||
}
|
||||
if !hasPow {
|
||||
return "{}", nil, nil
|
||||
}
|
||||
data, err := json.Marshal(entries)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return string(data), nil, nil
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"openflare/common"
|
||||
@@ -923,6 +924,90 @@ func TestPreviewAndDiffConfigVersion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishConfigVersionDetectsPoWChanges(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
|
||||
route, err := CreateProxyRoute(ProxyRouteInput{
|
||||
Domain: "pow.example.com",
|
||||
OriginURL: "https://origin.internal",
|
||||
Enabled: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProxyRoute failed: %v", err)
|
||||
}
|
||||
|
||||
firstRelease, err := PublishConfigVersion("root")
|
||||
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")
|
||||
}
|
||||
|
||||
_, 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,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateProxyRoute 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)
|
||||
}
|
||||
|
||||
secondRelease, err := PublishConfigVersion("root")
|
||||
if err != nil {
|
||||
t.Fatalf("PublishConfigVersion after PoW change failed: %v", err)
|
||||
}
|
||||
if firstRelease.Version.Checksum == secondRelease.Version.Checksum {
|
||||
t.Fatal("expected PoW change to alter published checksum")
|
||||
}
|
||||
if !strings.Contains(secondRelease.Version.SnapshotJSON, `"pow_enabled":true`) {
|
||||
t.Fatal("expected snapshot to persist PoW enabled state")
|
||||
}
|
||||
if !strings.Contains(secondRelease.Version.MainConfig, "lua_shared_dict openflare_pow_config 1m;") {
|
||||
t.Fatal("expected main config to declare shared dict for pow config")
|
||||
}
|
||||
if !strings.Contains(secondRelease.Version.RenderedConfig, "location /.within.website/x/cmd/anubis/static/ {") {
|
||||
t.Fatal("expected rendered config to expose anubis static location")
|
||||
}
|
||||
if strings.Contains(secondRelease.Version.RenderedConfig, "location /.within.website/x/cmd/anubis/static/static/ {") {
|
||||
t.Fatal("expected rendered config to avoid duplicate static path segment")
|
||||
}
|
||||
if !strings.Contains(secondRelease.Version.SnapshotJSON, `"difficulty":5`) {
|
||||
t.Fatal("expected snapshot to persist PoW config")
|
||||
}
|
||||
var supportFiles []SupportFile
|
||||
if err := json.Unmarshal([]byte(secondRelease.Version.SupportFilesJSON), &supportFiles); err != nil {
|
||||
t.Fatalf("failed to decode support files: %v", err)
|
||||
}
|
||||
foundPowSupportFile := false
|
||||
for _, file := range supportFiles {
|
||||
if file.Path != "pow_config.json" {
|
||||
continue
|
||||
}
|
||||
foundPowSupportFile = true
|
||||
if !strings.Contains(file.Content, `"difficulty":5`) {
|
||||
t.Fatalf("expected pow support file to persist config, got %s", file.Content)
|
||||
}
|
||||
}
|
||||
if !foundPowSupportFile {
|
||||
t.Fatal("expected publish to include pow_config.json support file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderConfigUsesDefaultServerFallback(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ const (
|
||||
func renderOpenRestyObservabilityTemplateBlock() string {
|
||||
return stringsJoinLines(
|
||||
" lua_shared_dict openflare_observability 10m;",
|
||||
" lua_shared_dict openflare_pow_config 1m;",
|
||||
" lua_shared_dict openflare_pow_challenges 10m;",
|
||||
" lua_shared_dict openflare_pow_sessions 20m;",
|
||||
fmt.Sprintf(" init_worker_by_lua_file %s/%s;", nginxLuaDirPlaceholder, openRestyObservabilityInitLuaPath),
|
||||
fmt.Sprintf(" log_by_lua_file %s/%s;", nginxLuaDirPlaceholder, openRestyObservabilityLogLuaPath),
|
||||
"",
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"openflare/model"
|
||||
"regexp"
|
||||
@@ -53,6 +54,8 @@ 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"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
@@ -83,6 +86,8 @@ 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"`
|
||||
Remark string `json:"remark"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
@@ -196,6 +201,16 @@ func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.Pro
|
||||
if err != nil {
|
||||
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
|
||||
@@ -267,6 +282,8 @@ 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.Remark = remark
|
||||
return route, nil
|
||||
}
|
||||
@@ -303,6 +320,10 @@ 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
|
||||
@@ -343,6 +364,8 @@ func buildProxyRouteView(route *model.ProxyRoute) (*ProxyRouteView, error) {
|
||||
CacheRuleList: cacheRules,
|
||||
CustomHeaders: route.CustomHeaders,
|
||||
CustomHeaderList: customHeaders,
|
||||
PoWEnabled: route.PoWEnabled,
|
||||
PoWConfig: powConfig,
|
||||
Remark: route.Remark,
|
||||
CreatedAt: route.CreatedAt,
|
||||
UpdatedAt: route.UpdatedAt,
|
||||
@@ -1048,3 +1071,132 @@ func validateOriginHost(raw string) error {
|
||||
func isUniqueConstraintError(err error) bool {
|
||||
return err != nil && strings.Contains(strings.ToLower(err.Error()), "unique")
|
||||
}
|
||||
|
||||
// PoW configuration types and validation
|
||||
|
||||
type ProxyRoutePoWListConfig struct {
|
||||
IPs []string `json:"ips"`
|
||||
IPCidrs []string `json:"ip_cidrs"`
|
||||
Paths []string `json:"paths"`
|
||||
PathRegexes []string `json:"path_regexes"`
|
||||
UserAgents []string `json:"user_agents"`
|
||||
}
|
||||
|
||||
type ProxyRoutePoWConfig struct {
|
||||
Difficulty int `json:"difficulty"`
|
||||
Algorithm string `json:"algorithm"`
|
||||
SessionTTL int `json:"session_ttl"`
|
||||
ChallengeTTL int `json:"challenge_ttl"`
|
||||
Whitelist ProxyRoutePoWListConfig `json:"whitelist"`
|
||||
Blacklist ProxyRoutePoWListConfig `json:"blacklist"`
|
||||
}
|
||||
|
||||
var powAlgorithmValues = map[string]bool{"fast": true, "slow": true}
|
||||
|
||||
func defaultPoWConfig() ProxyRoutePoWConfig {
|
||||
return ProxyRoutePoWConfig{
|
||||
Difficulty: 4,
|
||||
Algorithm: "fast",
|
||||
SessionTTL: 86400,
|
||||
ChallengeTTL: 300,
|
||||
Whitelist: ProxyRoutePoWListConfig{IPs: []string{}, IPCidrs: []string{}, Paths: []string{}, PathRegexes: []string{}, UserAgents: []string{}},
|
||||
Blacklist: ProxyRoutePoWListConfig{IPs: []string{}, IPCidrs: []string{}, Paths: []string{}, PathRegexes: []string{}, UserAgents: []string{}},
|
||||
}
|
||||
}
|
||||
|
||||
func normalizePoWConfig(enabled bool, raw string) (ProxyRoutePoWConfig, error) {
|
||||
if !enabled {
|
||||
return defaultPoWConfig(), nil
|
||||
}
|
||||
|
||||
cfg := defaultPoWConfig()
|
||||
text := strings.TrimSpace(raw)
|
||||
if text != "" && text != "{}" {
|
||||
if err := json.Unmarshal([]byte(text), &cfg); err != nil {
|
||||
return cfg, errors.New("pow_config 格式无效")
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.Difficulty < 1 || cfg.Difficulty > 16 {
|
||||
return cfg, errors.New("pow_config.difficulty 必须在 1-16 之间")
|
||||
}
|
||||
if !powAlgorithmValues[cfg.Algorithm] {
|
||||
return cfg, errors.New("pow_config.algorithm 必须为 fast 或 slow")
|
||||
}
|
||||
if cfg.SessionTTL < 60 {
|
||||
return cfg, errors.New("pow_config.session_ttl 不能小于 60 秒")
|
||||
}
|
||||
if cfg.ChallengeTTL < 30 {
|
||||
return cfg, errors.New("pow_config.challenge_ttl 不能小于 30 秒")
|
||||
}
|
||||
|
||||
for _, cidr := range cfg.Whitelist.IPCidrs {
|
||||
if _, _, err := net.ParseCIDR(cidr); err != nil {
|
||||
return cfg, fmt.Errorf("pow_config 白名单 IP CIDR 格式无效: %s", cidr)
|
||||
}
|
||||
}
|
||||
for _, cidr := range cfg.Blacklist.IPCidrs {
|
||||
if _, _, err := net.ParseCIDR(cidr); err != nil {
|
||||
return cfg, fmt.Errorf("pow_config 黑名单 IP CIDR 格式无效: %s", cidr)
|
||||
}
|
||||
}
|
||||
|
||||
for _, re := range cfg.Whitelist.PathRegexes {
|
||||
if _, err := regexp.Compile(re); err != nil {
|
||||
return cfg, fmt.Errorf("pow_config 白名单路径正则格式无效: %s", re)
|
||||
}
|
||||
}
|
||||
for _, re := range cfg.Blacklist.PathRegexes {
|
||||
if _, err := regexp.Compile(re); err != nil {
|
||||
return cfg, fmt.Errorf("pow_config 黑名单路径正则格式无效: %s", re)
|
||||
}
|
||||
}
|
||||
|
||||
for _, ip := range cfg.Whitelist.IPs {
|
||||
if net.ParseIP(ip) == nil {
|
||||
return cfg, fmt.Errorf("pow_config 白名单 IP 格式无效: %s", ip)
|
||||
}
|
||||
}
|
||||
for _, ip := range cfg.Blacklist.IPs {
|
||||
if net.ParseIP(ip) == nil {
|
||||
return cfg, fmt.Errorf("pow_config 黑名单 IP 格式无效: %s", ip)
|
||||
}
|
||||
}
|
||||
|
||||
type dimension struct {
|
||||
name string
|
||||
wl []string
|
||||
bl []string
|
||||
}
|
||||
dimensions := []dimension{
|
||||
{"IP", cfg.Whitelist.IPs, cfg.Blacklist.IPs},
|
||||
{"IP CIDR", cfg.Whitelist.IPCidrs, cfg.Blacklist.IPCidrs},
|
||||
{"路径", cfg.Whitelist.Paths, cfg.Blacklist.Paths},
|
||||
{"路径正则", cfg.Whitelist.PathRegexes, cfg.Blacklist.PathRegexes},
|
||||
{"User-Agent", cfg.Whitelist.UserAgents, cfg.Blacklist.UserAgents},
|
||||
}
|
||||
for _, dim := range dimensions {
|
||||
if len(dim.wl) > 0 && len(dim.bl) > 0 {
|
||||
return cfg, fmt.Errorf("pow_config %s 不能同时配置白名单和黑名单", dim.name)
|
||||
}
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func decodeStoredPoWConfig(enabled bool, raw string) (*ProxyRoutePoWConfig, error) {
|
||||
if !enabled {
|
||||
cfg := defaultPoWConfig()
|
||||
return &cfg, nil
|
||||
}
|
||||
text := strings.TrimSpace(raw)
|
||||
if text == "" || text == "{}" {
|
||||
cfg := defaultPoWConfig()
|
||||
return &cfg, nil
|
||||
}
|
||||
var cfg ProxyRoutePoWConfig
|
||||
if err := json.Unmarshal([]byte(text), &cfg); err != nil {
|
||||
return nil, errors.New("pow_config 格式无效")
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
@@ -5,18 +5,18 @@ import type { ReactNode } from 'react';
|
||||
import { AppProviders } from '@/components/providers/app-providers';
|
||||
import { getThemeInitScript } from '@/lib/theme/theme';
|
||||
|
||||
import './globals.css';
|
||||
import { Geist } from "next/font/google";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const geist = Geist({subsets:['latin'],variable:'--font-sans'});
|
||||
|
||||
import './globals.css';
|
||||
import { Geist } from "next/font/google";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const geist = Geist({subsets:['latin'],variable:'--font-sans'});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: 'OpenFlare 控制台',
|
||||
template: '%s | OpenFlare',
|
||||
},
|
||||
description: 'OpenFlare 管理端新版工程骨架',
|
||||
description: 'OpenFlare 管理端',
|
||||
applicationName: 'OpenFlare',
|
||||
};
|
||||
|
||||
@@ -24,8 +24,8 @@ interface RootLayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: RootLayoutProps) {
|
||||
return (
|
||||
export default function RootLayout({ children }: RootLayoutProps) {
|
||||
return (
|
||||
<html lang='zh-CN' suppressHydrationWarning className={cn("font-sans", geist.variable)}>
|
||||
<body>
|
||||
<Script id='theme-init' strategy='beforeInteractive'>
|
||||
@@ -34,5 +34,5 @@ export default function RootLayout({ children }: RootLayoutProps) {
|
||||
<AppProviders>{children}</AppProviders>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -710,6 +710,324 @@ function CacheSection({
|
||||
);
|
||||
}
|
||||
|
||||
type PowListValues = {
|
||||
ips: string;
|
||||
ip_cidrs: string;
|
||||
paths: string;
|
||||
path_regexes: string;
|
||||
user_agents: string;
|
||||
};
|
||||
|
||||
const defaultPowList: PowListValues = {
|
||||
ips: '',
|
||||
ip_cidrs: '',
|
||||
paths: '',
|
||||
path_regexes: '',
|
||||
user_agents: '',
|
||||
};
|
||||
|
||||
const powSchema = z
|
||||
.object({
|
||||
pow_enabled: z.boolean(),
|
||||
difficulty: z.coerce.number().int().min(1).max(16),
|
||||
algorithm: z.enum(['fast', 'slow']),
|
||||
session_ttl: z.coerce.number().int().min(60),
|
||||
challenge_ttl: z.coerce.number().int().min(30),
|
||||
whitelist: z.object({
|
||||
ips: z.string(),
|
||||
ip_cidrs: z.string(),
|
||||
paths: z.string(),
|
||||
path_regexes: z.string(),
|
||||
user_agents: z.string(),
|
||||
}),
|
||||
blacklist: z.object({
|
||||
ips: z.string(),
|
||||
ip_cidrs: z.string(),
|
||||
paths: z.string(),
|
||||
path_regexes: z.string(),
|
||||
user_agents: z.string(),
|
||||
}),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
if (!value.pow_enabled) return;
|
||||
const dimensions: { key: string; label: string }[] = [
|
||||
{ key: 'ips', label: 'IP' },
|
||||
{ key: 'ip_cidrs', label: 'IP CIDR' },
|
||||
{ key: 'paths', label: '路径' },
|
||||
{ key: 'path_regexes', label: '路径正则' },
|
||||
{ key: 'user_agents', label: 'User-Agent' },
|
||||
];
|
||||
for (const dim of dimensions) {
|
||||
const wl = linesFromTextarea(
|
||||
(value.whitelist as Record<string, string>)[dim.key] || '',
|
||||
);
|
||||
const bl = linesFromTextarea(
|
||||
(value.blacklist as Record<string, string>)[dim.key] || '',
|
||||
);
|
||||
if (wl.length > 0 && bl.length > 0) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `${dim.label} 不能同时配置白名单和黑名单`,
|
||||
path: ['blacklist', dim.key],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
type PowValues = z.infer<typeof powSchema>;
|
||||
|
||||
function buildPowListFromConfig(
|
||||
list:
|
||||
| { ips?: string[]; ip_cidrs?: string[]; paths?: string[]; path_regexes?: string[]; user_agents?: string[] }
|
||||
| undefined,
|
||||
): PowListValues {
|
||||
return {
|
||||
ips: (list?.ips ?? []).join('\n'),
|
||||
ip_cidrs: (list?.ip_cidrs ?? []).join('\n'),
|
||||
paths: (list?.paths ?? []).join('\n'),
|
||||
path_regexes: (list?.path_regexes ?? []).join('\n'),
|
||||
user_agents: (list?.user_agents ?? []).join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
function PowSection({
|
||||
route,
|
||||
saving,
|
||||
onSave,
|
||||
}: {
|
||||
route: ProxyRouteItem;
|
||||
saving: boolean;
|
||||
onSave: SaveHandler;
|
||||
}) {
|
||||
const powConfig = route.pow_config;
|
||||
const form = useForm<PowValues>({
|
||||
resolver: zodResolver(powSchema),
|
||||
defaultValues: {
|
||||
pow_enabled: route.pow_enabled,
|
||||
difficulty: powConfig?.difficulty ?? 4,
|
||||
algorithm: powConfig?.algorithm ?? 'fast',
|
||||
session_ttl: powConfig?.session_ttl ?? 86400,
|
||||
challenge_ttl: powConfig?.challenge_ttl ?? 300,
|
||||
whitelist: buildPowListFromConfig(powConfig?.whitelist),
|
||||
blacklist: buildPowListFromConfig(powConfig?.blacklist),
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form.reset({
|
||||
pow_enabled: route.pow_enabled,
|
||||
difficulty: powConfig?.difficulty ?? 4,
|
||||
algorithm: powConfig?.algorithm ?? 'fast',
|
||||
session_ttl: powConfig?.session_ttl ?? 86400,
|
||||
challenge_ttl: powConfig?.challenge_ttl ?? 300,
|
||||
whitelist: buildPowListFromConfig(powConfig?.whitelist),
|
||||
blacklist: buildPowListFromConfig(powConfig?.blacklist),
|
||||
});
|
||||
}, [form, route, powConfig]);
|
||||
|
||||
const watchedEnabled = form.watch('pow_enabled');
|
||||
|
||||
const parseList = (text: string): string[] =>
|
||||
linesFromTextarea(text).filter(Boolean);
|
||||
|
||||
return (
|
||||
<ConfigSectionShell
|
||||
title="PoW 防护"
|
||||
description="启用 Proof-of-Work 反爬虫验证。首次访问的浏览器需要完成计算挑战才能继续。"
|
||||
formId="proxy-route-pow-form"
|
||||
saving={saving}
|
||||
>
|
||||
<form
|
||||
id="proxy-route-pow-form"
|
||||
className="space-y-5"
|
||||
onSubmit={form.handleSubmit((values) => {
|
||||
const powConfigPayload = JSON.stringify({
|
||||
difficulty: values.difficulty,
|
||||
algorithm: values.algorithm,
|
||||
session_ttl: values.session_ttl,
|
||||
challenge_ttl: values.challenge_ttl,
|
||||
whitelist: {
|
||||
ips: parseList(values.whitelist.ips),
|
||||
ip_cidrs: parseList(values.whitelist.ip_cidrs),
|
||||
paths: parseList(values.whitelist.paths),
|
||||
path_regexes: parseList(values.whitelist.path_regexes),
|
||||
user_agents: parseList(values.whitelist.user_agents),
|
||||
},
|
||||
blacklist: {
|
||||
ips: parseList(values.blacklist.ips),
|
||||
ip_cidrs: parseList(values.blacklist.ip_cidrs),
|
||||
paths: parseList(values.blacklist.paths),
|
||||
path_regexes: parseList(values.blacklist.path_regexes),
|
||||
user_agents: parseList(values.blacklist.user_agents),
|
||||
},
|
||||
});
|
||||
onSave(
|
||||
buildPayloadFromRoute(route, {
|
||||
pow_enabled: values.pow_enabled,
|
||||
pow_config: powConfigPayload,
|
||||
}),
|
||||
{ message: 'PoW 防护设置已保存。' },
|
||||
);
|
||||
})}
|
||||
>
|
||||
<ToggleField
|
||||
label="启用 PoW 防护"
|
||||
description="对访问此站点的请求进行 Proof-of-Work 验证,阻止自动化爬虫。"
|
||||
checked={watchedEnabled}
|
||||
onChange={(checked) =>
|
||||
form.setValue('pow_enabled', checked, { shouldDirty: true })
|
||||
}
|
||||
/>
|
||||
|
||||
<ResourceField label="验证算法">
|
||||
<ResourceSelect
|
||||
disabled={!watchedEnabled}
|
||||
{...form.register('algorithm')}
|
||||
>
|
||||
<option value="fast">Fast(WebCrypto SHA-256)</option>
|
||||
<option value="slow">Slow(兼容模式)</option>
|
||||
</ResourceSelect>
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField
|
||||
label="难度"
|
||||
hint="数值越高验证越慢,1-16。推荐 3-5。"
|
||||
error={form.formState.errors.difficulty?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
type="number"
|
||||
min={1}
|
||||
max={16}
|
||||
disabled={!watchedEnabled}
|
||||
{...form.register('difficulty')}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField
|
||||
label="会话有效期(秒)"
|
||||
hint="通过验证后 Cookie 的有效期。"
|
||||
error={form.formState.errors.session_ttl?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
type="number"
|
||||
min={60}
|
||||
disabled={!watchedEnabled}
|
||||
{...form.register('session_ttl')}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField
|
||||
label="挑战有效期(秒)"
|
||||
hint="挑战令牌的有效期。"
|
||||
error={form.formState.errors.challenge_ttl?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
type="number"
|
||||
min={30}
|
||||
disabled={!watchedEnabled}
|
||||
{...form.register('challenge_ttl')}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
|
||||
<fieldset disabled={!watchedEnabled} className="space-y-4">
|
||||
<legend className="text-sm font-medium text-[var(--foreground-primary)] mb-2">
|
||||
白名单(匹配的请求跳过 PoW)
|
||||
</legend>
|
||||
<ResourceField label="IP" hint="每行一个 IP 地址">
|
||||
<ResourceTextarea
|
||||
className="min-h-20"
|
||||
placeholder="1.2.3.4 5.6.7.8"
|
||||
{...form.register('whitelist.ips')}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="IP CIDR" hint="每行一个 CIDR 范围">
|
||||
<ResourceTextarea
|
||||
className="min-h-20"
|
||||
placeholder="10.0.0.0/8 192.168.0.0/16"
|
||||
{...form.register('whitelist.ip_cidrs')}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="路径" hint="每行一个路径通配符">
|
||||
<ResourceTextarea
|
||||
className="min-h-20"
|
||||
placeholder="/.well-known/* /favicon.ico"
|
||||
{...form.register('whitelist.paths')}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="路径正则" hint="每行一个正则表达式">
|
||||
<ResourceTextarea
|
||||
className="min-h-20"
|
||||
placeholder="^/api/public/"
|
||||
{...form.register('whitelist.path_regexes')}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="User-Agent" hint="每行一个关键字">
|
||||
<ResourceTextarea
|
||||
className="min-h-20"
|
||||
placeholder="Googlebot bingbot"
|
||||
{...form.register('whitelist.user_agents')}
|
||||
/>
|
||||
</ResourceField>
|
||||
</fieldset>
|
||||
|
||||
<fieldset disabled={!watchedEnabled} className="space-y-4">
|
||||
<legend className="text-sm font-medium text-[var(--foreground-primary)] mb-2">
|
||||
黑名单(匹配的请求必须 PoW)
|
||||
</legend>
|
||||
<ResourceField label="IP" hint="每行一个 IP 地址">
|
||||
<ResourceTextarea
|
||||
className="min-h-20"
|
||||
placeholder="1.2.3.4"
|
||||
{...form.register('blacklist.ips')}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="IP CIDR" hint="每行一个 CIDR 范围">
|
||||
<ResourceTextarea
|
||||
className="min-h-20"
|
||||
placeholder="10.0.0.0/8"
|
||||
{...form.register('blacklist.ip_cidrs')}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="路径" hint="每行一个路径通配符">
|
||||
<ResourceTextarea
|
||||
className="min-h-20"
|
||||
placeholder="/admin/*"
|
||||
{...form.register('blacklist.paths')}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="路径正则" hint="每行一个正则表达式">
|
||||
<ResourceTextarea
|
||||
className="min-h-20"
|
||||
placeholder="^/private/"
|
||||
{...form.register('blacklist.path_regexes')}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="User-Agent" hint="每行一个关键字">
|
||||
<ResourceTextarea
|
||||
className="min-h-20"
|
||||
placeholder="bot crawler"
|
||||
{...form.register('blacklist.user_agents')}
|
||||
/>
|
||||
</ResourceField>
|
||||
</fieldset>
|
||||
</div>
|
||||
{form.formState.errors.blacklist && (
|
||||
<p className="text-sm text-[var(--color-danger)]">
|
||||
{Object.values(form.formState.errors.blacklist)
|
||||
.flatMap((e) =>
|
||||
e && typeof e === 'object' && 'message' in e
|
||||
? [e.message as string]
|
||||
: [],
|
||||
)
|
||||
.join('; ')}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</ConfigSectionShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProxyRouteConfigPage({
|
||||
routeId,
|
||||
initialSection,
|
||||
@@ -920,6 +1238,16 @@ export function ProxyRouteConfigPage({
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{currentSection === 'pow' ? (
|
||||
<PowSection
|
||||
route={route}
|
||||
saving={saveMutation.isPending}
|
||||
onSave={(payload, context) =>
|
||||
saveMutation.mutate({ payload, context })
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -190,6 +190,8 @@ export function ProxyRouteCreateDrawer({
|
||||
cache_policy: 'url',
|
||||
cache_rules: [],
|
||||
custom_headers: [],
|
||||
pow_enabled: false,
|
||||
pow_config: '{}',
|
||||
remark: values.remark.trim(),
|
||||
});
|
||||
},
|
||||
|
||||
@@ -25,6 +25,11 @@ export const websiteConfigSections = [
|
||||
label: '缓存',
|
||||
description: '配置站点缓存策略。',
|
||||
},
|
||||
{
|
||||
key: 'pow',
|
||||
label: 'PoW 防护',
|
||||
description: '配置 Proof-of-Work 反爬虫策略。',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type WebsiteConfigSectionKey =
|
||||
@@ -285,6 +290,8 @@ export function buildPayloadFromRoute(
|
||||
cache_rules: route.cache_rule_list,
|
||||
custom_headers: route.custom_header_list,
|
||||
remark: route.remark || '',
|
||||
pow_enabled: route.pow_enabled,
|
||||
pow_config: JSON.stringify(route.pow_config),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,23 @@ export interface ProxyRouteCustomHeader {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ProxyRoutePoWListConfig {
|
||||
ips: string[];
|
||||
ip_cidrs: string[];
|
||||
paths: string[];
|
||||
path_regexes: string[];
|
||||
user_agents: string[];
|
||||
}
|
||||
|
||||
export interface ProxyRoutePoWConfig {
|
||||
difficulty: number;
|
||||
algorithm: 'fast' | 'slow';
|
||||
session_ttl: number;
|
||||
challenge_ttl: number;
|
||||
whitelist: ProxyRoutePoWListConfig;
|
||||
blacklist: ProxyRoutePoWListConfig;
|
||||
}
|
||||
|
||||
export interface ProxyRouteItem {
|
||||
id: number;
|
||||
site_name: string;
|
||||
@@ -30,6 +47,8 @@ export interface ProxyRouteItem {
|
||||
cache_rule_list: string[];
|
||||
custom_headers: string;
|
||||
custom_header_list: ProxyRouteCustomHeader[];
|
||||
pow_enabled: boolean;
|
||||
pow_config: ProxyRoutePoWConfig;
|
||||
remark: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -60,6 +79,8 @@ export interface ProxyRouteMutationPayload {
|
||||
cache_policy: string;
|
||||
cache_rules: string[];
|
||||
custom_headers: ProxyRouteCustomHeader[];
|
||||
pow_enabled: boolean;
|
||||
pow_config: string;
|
||||
remark: string;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user