[优化] POW 与 WAF 合并

This commit is contained in:
ryan
2026-05-30 17:27:23 +08:00
parent c74b70b62e
commit 631d32e5d0
11 changed files with 526 additions and 365 deletions
+10
View File
@@ -74,6 +74,16 @@ HTTPS is bound per domain, not forced for the whole site.
If a site contains multiple domains, the Server groups HTTPS output by certificate while keeping all domains in the same site snapshot.
## Configure WAF and PoW
Security controls are managed from the **WAF** sidebar entry:
* The WAF page manages the global rule group and custom rule groups. The global rule group always applies to every site. Custom rule groups can be applied to selected sites from the rule group drawer or bound from the site detail `WAF` section.
* `PoW` is a tab inside the selected rule group, between `Allow / Block Lists` and `Block Response`. It reuses the existing per-site PoW execution logic and can apply the current PoW policy to every site or the sites bound to the current rule group.
* Site details no longer edit PoW directly. They show the always-on global WAF group and let you bind custom WAF rule groups. PoW rule content and scope should be maintained from the WAF page.
After changing WAF or PoW settings, publish and activate a new configuration version so Agents can apply the updated OpenResty runtime.
## Release, Activate, and Roll Back
Standard flow:
+10
View File
@@ -76,6 +76,16 @@ HTTPS 按域名绑定证书,而不是按整个网站统一强制启用。
如果一个网站包含多个域名,Server 发布时会按证书分组渲染 HTTPS 配置,同时保持这些域名属于同一份网站快照。
## 配置 WAF 与 PoW
安全防护统一从管理端侧边栏的 **WAF** 入口进入:
* WAF 页面维护全局规则组和自定义规则组。全局规则组始终应用到全部网站;自定义规则组可以在规则组内一键选择网站,也可以在网站详情的 `WAF` 分区绑定。
* `PoW` 是规则组内的一个配置 Tab,位于 `黑白名单` 与 `拦截返回` 之间,复用站点已有 PoW 执行逻辑,可将当前 PoW 配置应用到全部网站或当前规则组绑定的网站。
* 网站详情页不再单独编辑 PoW 规则,只展示全局 WAF 规则组并绑定自定义 WAF 规则组。PoW 的启用范围和规则内容应回到 WAF 页面统一维护。
WAF 或 PoW 配置修改后,都需要重新发布并激活配置版本,Agent 才会拉取并应用到 OpenResty。
## 发布、激活与回滚
标准链路:
@@ -4,7 +4,7 @@ import "time"
const (
legacyDatabaseSchemaVersion = 1
currentDatabaseSchemaVersion = 13
currentDatabaseSchemaVersion = 14
databaseSchemaVersionRowID = 1
)
+24
View File
@@ -1413,6 +1413,8 @@ func ensureDefaultWAFRuleGroup(db *gorm.DB) error {
CountryBlacklist: "[]",
RegionWhitelist: "[]",
RegionBlacklist: "[]",
PoWEnabled: false,
PoWConfig: "{}",
BlockResponseBody: "",
}
if err := db.Create(&group).Error; err != nil {
@@ -1449,6 +1451,27 @@ func validateDatabaseSchemaV13(db *gorm.DB, backend string) error {
return nil
}
// migrateV14 adds PoW policy fields to WAF rule groups.
func migrateV14(db *gorm.DB, backend string) error {
if err := applyCurrentSchema(db, backend); err != nil {
return err
}
return ensureDefaultWAFRuleGroup(db)
}
func validateDatabaseSchemaV14(db *gorm.DB, backend string) error {
if err := validateDatabaseSchemaV13(db, backend); err != nil {
return err
}
if !db.Migrator().HasColumn(&WAFRuleGroup{}, "pow_enabled") {
return fmt.Errorf("column waf_rule_groups.pow_enabled is missing")
}
if !db.Migrator().HasColumn(&WAFRuleGroup{}, "pow_config") {
return fmt.Errorf("column waf_rule_groups.pow_config is missing")
}
return nil
}
func databaseSchemaMigrations() []databaseSchemaMigration {
return []databaseSchemaMigration{
{fromVersion: 1, toVersion: 2, migrate: migrateV2, validate: validateDatabaseSchemaV2},
@@ -1463,6 +1486,7 @@ func databaseSchemaMigrations() []databaseSchemaMigration {
{fromVersion: 10, toVersion: 11, migrate: migrateV11, validate: validateDatabaseSchemaV11},
{fromVersion: 11, toVersion: 12, migrate: migrateV12, validate: validateDatabaseSchemaV12},
{fromVersion: 12, toVersion: 13, migrate: migrateV13, validate: validateDatabaseSchemaV13},
{fromVersion: 13, toVersion: 14, migrate: migrateV14, validate: validateDatabaseSchemaV14},
}
}
+4
View File
@@ -15,6 +15,8 @@ type WAFRuleGroup struct {
CountryBlacklist string `json:"country_blacklist" gorm:"type:text;not null;default:'[]'"`
RegionWhitelist string `json:"region_whitelist" gorm:"type:text;not null;default:'[]'"`
RegionBlacklist string `json:"region_blacklist" gorm:"type:text;not null;default:'[]'"`
PoWEnabled bool `json:"pow_enabled" gorm:"not null;default:false"`
PoWConfig string `json:"pow_config" 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"`
@@ -62,6 +64,8 @@ func (group *WAFRuleGroup) Update() error {
"country_blacklist": group.CountryBlacklist,
"region_whitelist": group.RegionWhitelist,
"region_blacklist": group.RegionBlacklist,
"pow_enabled": group.PoWEnabled,
"pow_config": group.PoWConfig,
"remark": group.Remark,
}).Error
}
+20 -15
View File
@@ -95,18 +95,20 @@ type snapshotRoute struct {
}
type snapshotWAFRuleGroup struct {
ID uint `json:"id"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
IsGlobal bool `json:"is_global"`
BlockStatusCode int `json:"block_status_code"`
BlockResponseBody string `json:"block_response_body,omitempty"`
IPWhitelist []string `json:"ip_whitelist,omitempty"`
IPBlacklist []string `json:"ip_blacklist,omitempty"`
CountryWhitelist []string `json:"country_whitelist,omitempty"`
CountryBlacklist []string `json:"country_blacklist,omitempty"`
RegionWhitelist []string `json:"region_whitelist,omitempty"`
RegionBlacklist []string `json:"region_blacklist,omitempty"`
ID uint `json:"id"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
IsGlobal bool `json:"is_global"`
BlockStatusCode int `json:"block_status_code"`
BlockResponseBody string `json:"block_response_body,omitempty"`
IPWhitelist []string `json:"ip_whitelist,omitempty"`
IPBlacklist []string `json:"ip_blacklist,omitempty"`
CountryWhitelist []string `json:"country_whitelist,omitempty"`
CountryBlacklist []string `json:"country_blacklist,omitempty"`
RegionWhitelist []string `json:"region_whitelist,omitempty"`
RegionBlacklist []string `json:"region_blacklist,omitempty"`
PoWEnabled bool `json:"pow_enabled,omitempty"`
PoWConfig *ProxyRoutePoWConfig `json:"pow_config,omitempty"`
}
type snapshotWAFBinding struct {
@@ -504,7 +506,7 @@ func buildCurrentConfigBundle(requireRoutes bool) (*configBundle, error) {
if err != nil {
return nil, err
}
routeConfig, supportFiles, err := renderRouteConfig(routes, openRestyConfig)
routeConfig, supportFiles, err := renderRouteConfig(routes, openRestyConfig, wafSnapshot)
if err != nil {
return nil, err
}
@@ -512,7 +514,7 @@ func buildCurrentConfigBundle(requireRoutes bool) (*configBundle, error) {
if err != nil {
return nil, err
}
powConfigJSON, powSupportFiles, err := renderPowConfigBundle(routes)
powConfigJSON, powSupportFiles, err := renderPowConfigBundle(routes, wafSnapshot)
if err != nil {
return nil, err
}
@@ -617,6 +619,8 @@ func buildSnapshotWAFDocument(routes []*model.ProxyRoute) (snapshotWAFDocument,
CountryBlacklist: view.CountryBlacklist,
RegionWhitelist: view.RegionWhitelist,
RegionBlacklist: view.RegionBlacklist,
PoWEnabled: view.PoWEnabled,
PoWConfig: view.PoWConfig,
})
}
enabledRouteIDs := make(map[uint]string, len(routes))
@@ -1029,10 +1033,11 @@ func openRestyOptionKeys() []string {
}
}
func renderRouteConfig(routes []*model.ProxyRoute, cfg openRestyConfigSnapshot) (string, []SupportFile, error) {
func renderRouteConfig(routes []*model.ProxyRoute, cfg openRestyConfigSnapshot, wafSnapshot snapshotWAFDocument) (string, []SupportFile, error) {
var builder strings.Builder
builder.WriteString("# This file is generated by OpenFlare. Do not edit manually.\n")
supportFiles := make([]SupportFile, 0)
powEnabledByRoute := wafPowEnabledByRoute(wafSnapshot)
for _, route := range routes {
domains, err := decodeStoredDomains(route.Domains, route.Domain)
if err != nil {
+49 -28
View File
@@ -20,37 +20,41 @@ const (
)
type WAFRuleGroupInput struct {
Name string `json:"name"`
Enabled bool `json:"enabled"`
BlockStatusCode int `json:"block_status_code"`
BlockResponseBody string `json:"block_response_body"`
IPWhitelist []string `json:"ip_whitelist"`
IPBlacklist []string `json:"ip_blacklist"`
CountryWhitelist []string `json:"country_whitelist"`
CountryBlacklist []string `json:"country_blacklist"`
RegionWhitelist []string `json:"region_whitelist"`
RegionBlacklist []string `json:"region_blacklist"`
Remark string `json:"remark"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
BlockStatusCode int `json:"block_status_code"`
BlockResponseBody string `json:"block_response_body"`
IPWhitelist []string `json:"ip_whitelist"`
IPBlacklist []string `json:"ip_blacklist"`
CountryWhitelist []string `json:"country_whitelist"`
CountryBlacklist []string `json:"country_blacklist"`
RegionWhitelist []string `json:"region_whitelist"`
RegionBlacklist []string `json:"region_blacklist"`
Remark string `json:"remark"`
PoWEnabled bool `json:"pow_enabled"`
PoWConfig json.RawMessage `json:"pow_config"`
}
type WAFRuleGroupView struct {
ID uint `json:"id"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
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"`
CountryWhitelist []string `json:"country_whitelist"`
CountryBlacklist []string `json:"country_blacklist"`
RegionWhitelist []string `json:"region_whitelist"`
RegionBlacklist []string `json:"region_blacklist"`
Remark string `json:"remark"`
AppliedSiteIDs []uint `json:"applied_site_ids"`
AppliedSiteCount int `json:"applied_site_count"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
ID uint `json:"id"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
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"`
CountryWhitelist []string `json:"country_whitelist"`
CountryBlacklist []string `json:"country_blacklist"`
RegionWhitelist []string `json:"region_whitelist"`
RegionBlacklist []string `json:"region_blacklist"`
Remark string `json:"remark"`
PoWEnabled bool `json:"pow_enabled"`
PoWConfig *ProxyRoutePoWConfig `json:"pow_config"`
AppliedSiteIDs []uint `json:"applied_site_ids"`
AppliedSiteCount int `json:"applied_site_count"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type WAFSiteRuleGroupsView struct {
@@ -275,6 +279,8 @@ func EnsureDefaultWAFRuleGroup() error {
CountryBlacklist: "[]",
RegionWhitelist: "[]",
RegionBlacklist: "[]",
PoWEnabled: false,
PoWConfig: "{}",
BlockResponseBody: "",
}
return group.Insert()
@@ -313,6 +319,15 @@ func buildWAFRuleGroup(group *model.WAFRuleGroup, input WAFRuleGroupInput) (*mod
}
regionWhitelist := normalizeStringList(input.RegionWhitelist)
regionBlacklist := normalizeStringList(input.RegionBlacklist)
powConfigRaw := strings.TrimSpace(string(input.PoWConfig))
if powConfigRaw == "" {
powConfigRaw = "{}"
}
powConfig, err := normalizePoWConfig(input.PoWEnabled, powConfigRaw)
if err != nil {
return nil, err
}
powConfigJSON, _ := json.Marshal(powConfig)
ipWhitelistJSON, _ := json.Marshal(ipWhitelist)
ipBlacklistJSON, _ := json.Marshal(ipBlacklist)
@@ -334,6 +349,8 @@ func buildWAFRuleGroup(group *model.WAFRuleGroup, input WAFRuleGroupInput) (*mod
group.CountryBlacklist = string(countryBlacklistJSON)
group.RegionWhitelist = string(regionWhitelistJSON)
group.RegionBlacklist = string(regionBlacklistJSON)
group.PoWEnabled = input.PoWEnabled
group.PoWConfig = string(powConfigJSON)
group.Remark = strings.TrimSpace(input.Remark)
return group, nil
}
@@ -351,6 +368,7 @@ func buildWAFRuleGroupView(group *model.WAFRuleGroup, appliedSiteIDs []uint) (WA
BlockStatusCode: group.BlockStatusCode,
BlockResponseBody: group.BlockResponseBody,
Remark: group.Remark,
PoWEnabled: group.PoWEnabled,
AppliedSiteIDs: appliedSiteIDs,
AppliedSiteCount: len(appliedSiteIDs),
CreatedAt: group.CreatedAt.Format(time.RFC3339),
@@ -375,6 +393,9 @@ func buildWAFRuleGroupView(group *model.WAFRuleGroup, appliedSiteIDs []uint) (WA
if view.RegionBlacklist, err = decodeStringList(group.RegionBlacklist); err != nil {
return view, err
}
if view.PoWConfig, err = decodeStoredPoWConfig(group.PoWEnabled, group.PoWConfig); err != nil {
return view, err
}
return view, nil
}
@@ -19,6 +19,10 @@ import {
getProxyRoute,
updateProxyRoute,
} from '@/features/proxy-routes/api/proxy-routes';
import {
getWAFSiteRuleGroups,
replaceWAFSiteRuleGroups,
} from '@/features/waf/api/waf';
import {
buildDomainRowsFromRoute,
DomainListInput,
@@ -729,318 +733,137 @@ function CacheSection({
);
}
type PowListValues = {
ips: string;
ip_cidrs: string;
paths: string;
path_regexes: string;
user_agents: string;
};
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],
});
}
}
function WAFBindingSection({ route }: { route: ProxyRouteItem }) {
const queryClient = useQueryClient();
const [selectedIDs, setSelectedIDs] = useState<number[]>([]);
const wafQuery = useQuery({
queryKey: ['waf', 'site-rule-groups', route.id],
queryFn: () => getWAFSiteRuleGroups(route.id),
});
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 ?? 600,
challenge_ttl: powConfig?.challenge_ttl ?? 300,
whitelist: buildPowListFromConfig(powConfig?.whitelist),
blacklist: buildPowListFromConfig(powConfig?.blacklist),
const wafMutation = useMutation({
mutationFn: (ids: number[]) => replaceWAFSiteRuleGroups(route.id, ids),
onSuccess: async (result) => {
setSelectedIDs(result.applied_ids);
await Promise.all([
queryClient.invalidateQueries({
queryKey: ['waf', 'site-rule-groups', route.id],
}),
queryClient.invalidateQueries({ queryKey: ['waf', 'rule-groups'] }),
queryClient.invalidateQueries({
queryKey: ['config-versions', 'diff'],
}),
]);
},
});
useEffect(() => {
form.reset({
pow_enabled: route.pow_enabled,
difficulty: powConfig?.difficulty ?? 4,
algorithm: powConfig?.algorithm ?? 'fast',
session_ttl: powConfig?.session_ttl ?? 600,
challenge_ttl: powConfig?.challenge_ttl ?? 300,
whitelist: buildPowListFromConfig(powConfig?.whitelist),
blacklist: buildPowListFromConfig(powConfig?.blacklist),
});
}, [form, route, powConfig]);
if (wafQuery.data) {
setSelectedIDs(wafQuery.data.applied_ids);
}
}, [wafQuery.data]);
const watchedEnabled = form.watch('pow_enabled');
const parseList = (text: string): string[] =>
linesFromTextarea(text).filter(Boolean);
const selectedSet = useMemo(() => new Set(selectedIDs), [selectedIDs]);
return (
<ConfigSectionShell
title="PoW 防护"
description="启用 Proof-of-Work 反爬虫验证。首次访问的浏览器需要完成计算挑战才能继续。"
formId="proxy-route-pow-form"
saving={saving}
title="WAF"
description="全局规则组始终生效;这里可以为当前网站叠加自定义规则组。PoW 规则请在 WAF 页面统一配置和批量应用。"
formId="proxy-route-waf-form"
saving={wafMutation.isPending}
>
<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 })
}
{wafQuery.isLoading ? (
<LoadingState />
) : wafQuery.isError ? (
<ErrorState
title="WAF 规则加载失败"
description={getErrorMessage(wafQuery.error)}
/>
) : (
<div className="space-y-5">
{wafMutation.isError ? (
<InlineMessage
tone="danger"
message={getErrorMessage(wafMutation.error)}
/>
) : null}
{wafMutation.isSuccess ? (
<InlineMessage tone="success" message="WAF 规则组已更新。" />
) : null}
<ResourceField label="验证算法">
<ResourceSelect
disabled={!watchedEnabled}
{...form.register('algorithm')}
>
<option value="fast">Fast(WebCrypto SHA-256)</option>
<option value="slow">Slow(兼容模式)</option>
</ResourceSelect>
</ResourceField>
{wafQuery.data?.global_rule_group ? (
<div className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs font-medium tracking-[0.18em] text-[var(--foreground-muted)] uppercase">
Global Rule Group
</p>
<p className="mt-2 text-sm font-semibold text-[var(--foreground-primary)]">
{wafQuery.data.global_rule_group.name}
</p>
</div>
<span className="rounded-full border border-[var(--border-default)] px-3 py-1.5 text-xs text-[var(--foreground-secondary)]">
始终生效
</span>
</div>
</div>
) : null}
<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>
<div className="grid gap-3 md:grid-cols-2">
{(wafQuery.data?.rule_groups ?? []).map((group) => (
<label
key={group.id}
className={cn(
'flex cursor-pointer items-start gap-3 rounded-[22px] border p-4 transition',
selectedSet.has(group.id)
? 'border-[var(--border-strong)] bg-[var(--accent-soft)]'
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] hover:bg-[var(--surface-muted)]',
)}
>
<input
type="checkbox"
checked={selectedSet.has(group.id)}
onChange={(event) => {
const checked = event.target.checked;
setSelectedIDs((current) =>
checked
? [...current, group.id].sort((left, right) => left - right)
: current.filter((id) => id !== group.id),
);
}}
className="mt-1 h-4 w-4 rounded border-[var(--border-default)] accent-[var(--brand-primary)]"
/>
<span className="min-w-0">
<span className="block text-sm font-semibold text-[var(--foreground-primary)]">
{group.name}
</span>
<span className="mt-1 block text-xs leading-5 text-[var(--foreground-secondary)]">
{group.enabled ? '启用中' : '已停用'} ·{' '}
{group.ip_whitelist.length +
group.ip_blacklist.length +
group.country_whitelist.length +
group.country_blacklist.length}{' '}
条规则
</span>
</span>
</label>
))}
</div>
<ResourceField
label="会话空闲有效期(秒)"
hint="通过验证后,若在此时间内没有新请求,Cookie 会失效;每次访问会自动续期。默认 600 秒。"
error={form.formState.errors.session_ttl?.message}
>
<ResourceInput
type="number"
min={60}
disabled={!watchedEnabled}
{...form.register('session_ttl')}
/>
</ResourceField>
{(wafQuery.data?.rule_groups ?? []).length === 0 ? (
<EmptyState title="暂无自定义 WAF 规则组" />
) : null}
<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="mb-2 text-sm font-medium text-[var(--foreground-primary)]">
白名单(匹配的请求跳过 PoW)
</legend>
<ResourceField label="IP" hint="每行一个 IP 地址">
<ResourceTextarea
className="min-h-20"
placeholder="1.2.3.4&#10;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&#10;192.168.0.0/16"
{...form.register('whitelist.ip_cidrs')}
/>
</ResourceField>
<ResourceField label="路径" hint="每行一个路径通配符">
<ResourceTextarea
className="min-h-20"
placeholder="/.well-known/*&#10;/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&#10;bingbot"
{...form.register('whitelist.user_agents')}
/>
</ResourceField>
</fieldset>
<fieldset disabled={!watchedEnabled} className="space-y-4">
<legend className="mb-2 text-sm font-medium text-[var(--foreground-primary)]">
黑名单(匹配的请求必须 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&#10;crawler"
{...form.register('blacklist.user_agents')}
/>
</ResourceField>
</fieldset>
<div className="flex justify-end">
<PrimaryButton
type="button"
disabled={wafMutation.isPending}
onClick={() => wafMutation.mutate(selectedIDs)}
>
{wafMutation.isPending ? '保存中...' : '保存 WAF 绑定'}
</PrimaryButton>
</div>
</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>
);
}
@@ -1370,14 +1193,8 @@ export function ProxyRouteConfigPage({
/>
) : null}
{currentSection === 'pow' ? (
<PowSection
route={route}
saving={saveMutation.isPending}
onSave={(payload, context) =>
saveMutation.mutate({ payload, context })
}
/>
{currentSection === 'waf' ? (
<WAFBindingSection route={route} />
) : null}
{currentSection === 'auth' ? (
@@ -26,9 +26,9 @@ export const websiteConfigSections = [
description: '配置站点缓存策略。',
},
{
key: 'pow',
label: 'PoW 防护',
description: '配置 Proof-of-Work 反爬虫策略。',
key: 'waf',
label: 'WAF',
description: '绑定 WAF 规则组,并查看当前站点生效策略。',
},
{
key: 'auth',
@@ -5,8 +5,8 @@ import type { ReactNode } from 'react';
import { useEffect, useMemo, useState } from 'react';
import {
Check,
Cpu,
Globe2,
ListFilter,
type LucideIcon,
Plus,
Save,
@@ -23,8 +23,15 @@ import { PageHeader } from '@/components/layout/page-header';
import { AppCard } from '@/components/ui/app-card';
import { AppModal } from '@/components/ui/app-modal';
import { Drawer } from '@/components/ui/drawer';
import { getProxyRoutes } from '@/features/proxy-routes/api/proxy-routes';
import type { ProxyRouteItem } from '@/features/proxy-routes/types';
import {
getProxyRoutes,
updateProxyRoute,
} from '@/features/proxy-routes/api/proxy-routes';
import { buildPayloadFromRoute } from '@/features/proxy-routes/helpers';
import type {
ProxyRouteItem,
ProxyRoutePoWConfig,
} from '@/features/proxy-routes/types';
import {
DangerButton,
PrimaryButton,
@@ -49,7 +56,7 @@ type FeedbackState = {
message: string;
};
type WAFTab = 'basic' | 'lists' | 'block';
type WAFTab = 'basic' | 'lists' | 'pow' | 'block';
type RuleListType = 'whitelist' | 'blacklist';
type RuleDimension = 'ip' | 'country';
type ListFieldKey =
@@ -83,6 +90,27 @@ type RuleListRenderable = Pick<
| 'region_blacklist'
>;
const defaultPowConfig: ProxyRoutePoWConfig = {
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: [],
},
};
const emptyDraft: WAFRuleGroupPayload = {
name: '',
enabled: true,
@@ -94,6 +122,8 @@ const emptyDraft: WAFRuleGroupPayload = {
country_blacklist: [],
region_whitelist: [],
region_blacklist: [],
pow_enabled: false,
pow_config: defaultPowConfig,
remark: '',
};
@@ -117,6 +147,10 @@ const tabItems: Array<{
id: 'lists',
label: '黑白名单',
},
{
id: 'pow',
label: 'PoW',
},
{
id: 'block',
label: '拦截返回',
@@ -134,6 +168,17 @@ function textToList(text: string) {
.filter(Boolean);
}
function listToText(items: string[] | undefined) {
return (items ?? []).join('\n');
}
function parseTextareaList(text: string) {
return text
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean);
}
function normalizeItems(items: string[]) {
return Array.from(
new Set(items.map((item) => item.trim()).filter(Boolean)),
@@ -155,6 +200,8 @@ function buildDraft(group: WAFRuleGroup | null): WAFRuleGroupPayload {
country_blacklist: group.country_blacklist ?? [],
region_whitelist: group.region_whitelist ?? [],
region_blacklist: group.region_blacklist ?? [],
pow_enabled: group.pow_enabled ?? false,
pow_config: group.pow_config ?? defaultPowConfig,
remark: group.remark ?? '',
};
}
@@ -731,6 +778,214 @@ function SiteApplyDrawer({
);
}
function PowTabPanel({
enabled,
config,
onChange,
}: {
enabled: boolean;
config: ProxyRoutePoWConfig;
onChange: (enabled: boolean, config: ProxyRoutePoWConfig) => void;
}) {
const [draft, setDraft] = useState(() => ({
whitelist: {
ips: listToText(config.whitelist?.ips),
ip_cidrs: listToText(config.whitelist?.ip_cidrs),
paths: listToText(config.whitelist?.paths),
path_regexes: listToText(config.whitelist?.path_regexes),
user_agents: listToText(config.whitelist?.user_agents),
},
blacklist: {
ips: listToText(config.blacklist?.ips),
ip_cidrs: listToText(config.blacklist?.ip_cidrs),
paths: listToText(config.blacklist?.paths),
path_regexes: listToText(config.blacklist?.path_regexes),
user_agents: listToText(config.blacklist?.user_agents),
},
}));
useEffect(() => {
setDraft({
whitelist: {
ips: listToText(config.whitelist?.ips),
ip_cidrs: listToText(config.whitelist?.ip_cidrs),
paths: listToText(config.whitelist?.paths),
path_regexes: listToText(config.whitelist?.path_regexes),
user_agents: listToText(config.whitelist?.user_agents),
},
blacklist: {
ips: listToText(config.blacklist?.ips),
ip_cidrs: listToText(config.blacklist?.ip_cidrs),
paths: listToText(config.blacklist?.paths),
path_regexes: listToText(config.blacklist?.path_regexes),
user_agents: listToText(config.blacklist?.user_agents),
},
});
}, [config]);
const updateConfig = (
newEnabled: boolean,
newConfig: Partial<ProxyRoutePoWConfig>,
newDraft?: typeof draft,
) => {
const nextConfig = { ...config, ...newConfig };
if (newDraft) {
setDraft(newDraft);
nextConfig.whitelist = {
ips: parseTextareaList(newDraft.whitelist.ips),
ip_cidrs: parseTextareaList(newDraft.whitelist.ip_cidrs),
paths: parseTextareaList(newDraft.whitelist.paths),
path_regexes: parseTextareaList(newDraft.whitelist.path_regexes),
user_agents: parseTextareaList(newDraft.whitelist.user_agents),
};
nextConfig.blacklist = {
ips: parseTextareaList(newDraft.blacklist.ips),
ip_cidrs: parseTextareaList(newDraft.blacklist.ip_cidrs),
paths: parseTextareaList(newDraft.blacklist.paths),
path_regexes: parseTextareaList(newDraft.blacklist.path_regexes),
user_agents: parseTextareaList(newDraft.blacklist.user_agents),
};
}
onChange(newEnabled, nextConfig);
};
const updateList = (
scope: 'whitelist' | 'blacklist',
key: keyof ProxyRoutePoWConfig['whitelist'],
value: string,
) => {
const nextDraft = {
...draft,
[scope]: {
...draft[scope],
[key]: value,
},
};
updateConfig(enabled, {}, nextDraft);
};
return (
<div className="space-y-6">
<ToggleField
label="启用 PoW 防护"
description="启用后,命中该规则组的请求需要完成浏览器计算挑战。"
checked={enabled}
onChange={(newEnabled) => updateConfig(newEnabled, {})}
/>
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-4">
<ResourceField label="算法">
<select
value={config.algorithm}
onChange={(event) =>
updateConfig(enabled, { algorithm: event.target.value as 'fast' | 'slow' })
}
className="w-full rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm text-[var(--foreground-primary)] outline-none transition focus:border-[var(--border-strong)]"
>
<option value="fast">Fast</option>
<option value="slow">Slow</option>
</select>
</ResourceField>
<ResourceField label="难度">
<ResourceInput
type="number"
min={1}
max={16}
value={config.difficulty}
onChange={(event) =>
updateConfig(enabled, { difficulty: Number(event.target.value) })
}
/>
</ResourceField>
<ResourceField label="会话 TTL">
<ResourceInput
type="number"
min={60}
value={config.session_ttl}
onChange={(event) =>
updateConfig(enabled, { session_ttl: Number(event.target.value) })
}
/>
</ResourceField>
<ResourceField label="挑战 TTL">
<ResourceInput
type="number"
min={30}
value={config.challenge_ttl}
onChange={(event) =>
updateConfig(enabled, { challenge_ttl: Number(event.target.value) })
}
/>
</ResourceField>
</div>
<div className="grid gap-5 xl:grid-cols-2">
{(['whitelist', 'blacklist'] as const).map((scope) => (
<div
key={scope}
className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5"
>
<h3 className="text-sm font-semibold text-[var(--foreground-primary)]">
{scope === 'whitelist'
? '白名单(跳过 PoW)'
: '黑名单(必须 PoW)'}
</h3>
<div className="mt-4 space-y-4">
<ResourceField label="IP">
<ResourceTextarea
className="min-h-20"
value={draft[scope].ips}
onChange={(event) =>
updateList(scope, 'ips', event.target.value)
}
/>
</ResourceField>
<ResourceField label="IP CIDR">
<ResourceTextarea
className="min-h-20"
value={draft[scope].ip_cidrs}
onChange={(event) =>
updateList(scope, 'ip_cidrs', event.target.value)
}
/>
</ResourceField>
<ResourceField label="路径">
<ResourceTextarea
className="min-h-20"
value={draft[scope].paths}
onChange={(event) =>
updateList(scope, 'paths', event.target.value)
}
/>
</ResourceField>
<ResourceField label="路径正则">
<ResourceTextarea
className="min-h-20"
value={draft[scope].path_regexes}
onChange={(event) =>
updateList(scope, 'path_regexes', event.target.value)
}
/>
</ResourceField>
<ResourceField label="User-Agent">
<ResourceTextarea
className="min-h-20"
value={draft[scope].user_agents}
onChange={(event) =>
updateList(scope, 'user_agents', event.target.value)
}
/>
</ResourceField>
</div>
</div>
))}
</div>
</div>
);
}
export function WAFPage() {
const queryClient = useQueryClient();
const [selectedID, setSelectedID] = useState<number | null>(null);
@@ -825,6 +1080,8 @@ export function WAFPage() {
},
});
if (groupsQuery.isLoading || routesQuery.isLoading) {
return <LoadingState />;
}
@@ -853,14 +1110,6 @@ export function WAFPage() {
);
}
const enabledGroups = groups.filter((group) => group.enabled);
const protectedSites = new Set(
groups.flatMap((group) => group.applied_site_ids),
);
const totalRules = groups.reduce(
(sum, group) => sum + countRuleEntries(group),
0,
);
const currentRuleCount = countRuleEntries(draft);
const appliedSiteNames = selectedGroup?.is_global
? ['全部网站']
@@ -950,7 +1199,7 @@ export function WAFPage() {
<div className="space-y-6">
<PageHeader
title="WAF"
description="按规则组维护 IP 与地域黑白名单,全局规则始终应用到所有网站。"
description="按规则组维护 WAF 与 PoW 防护规则,全局规则组始终应用到所有网站。"
action={
<PrimaryButton
type="button"
@@ -1039,7 +1288,7 @@ export function WAFPage() {
}
>
<div className="space-y-6">
<div className="grid gap-3 md:grid-cols-3">
<div className="grid gap-3 md:grid-cols-4">
{tabItems.map((tab) => (
<TabButton
key={tab.id}
@@ -1210,6 +1459,21 @@ export function WAFPage() {
</div>
) : null}
{activeTab === 'pow' ? (
<PowTabPanel
key={selectedID}
enabled={draft.pow_enabled}
config={draft.pow_config}
onChange={(enabled, config) =>
setDraft((current) => ({
...current,
pow_enabled: enabled,
pow_config: config,
}))
}
/>
) : null}
{activeTab === 'block' ? (
<div className="grid gap-5 xl:grid-cols-[360px_minmax(0,1fr)]">
<div className="space-y-5">
@@ -1,3 +1,5 @@
import type { ProxyRoutePoWConfig } from '@/features/proxy-routes/types';
export interface WAFRuleGroup {
id: number;
name: string;
@@ -11,6 +13,8 @@ export interface WAFRuleGroup {
country_blacklist: string[];
region_whitelist: string[];
region_blacklist: string[];
pow_enabled: boolean;
pow_config: ProxyRoutePoWConfig;
remark: string;
applied_site_ids: number[];
applied_site_count: number;
@@ -29,6 +33,8 @@ export interface WAFRuleGroupPayload {
country_blacklist: string[];
region_whitelist: string[];
region_blacklist: string[];
pow_enabled: boolean;
pow_config: ProxyRoutePoWConfig;
remark: string;
}