mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-05 15:26:36 +08:00
chore(docs): purge
This commit is contained in:
@@ -1,13 +0,0 @@
|
||||
# Zone 域名重构规格
|
||||
|
||||
已确认的设计:
|
||||
|
||||
* `/websites` 展示可注册根域 Zone;详情 URL 使用 `/websites/:zoneId`。
|
||||
* `managed_domains` 将被彻底替换为 `of_zones` 与 `of_zone_domains`。
|
||||
* Zone 域名是 `of_proxy_routes` 域名与证书的规范化来源;一个域名至多连接一条路由,一条路由可含多个 Zone 的域名。
|
||||
* `of_proxy_routes` 移除 `cert_id`、`cert_ids` 与 `domain_cert_ids`,不再指定证书;配置编译只从关联 Zone 域名的 `cert_id` 查询证书。
|
||||
* Zone 域名只允许明确 FQDN;允许把含 `*.example.com` SAN 的 TLS 证书绑定到明确域名,但不允许通配符域名记录。
|
||||
* 路由仍拥有上游、缓存、限流、WAF 与 Pages;Zone 只提供聚合管理和展示。
|
||||
* 迁移先建新表、用 Public Suffix List 回填和验证,再在后续独立发布中移除旧表及冗余列。
|
||||
|
||||
完整设计、API、迁移与验证策略见 [Zone 与域名资源设计](../../design/zone-design.md)。
|
||||
@@ -1,7 +0,0 @@
|
||||
# Cloudflare DNS 指向 — Spec 指针
|
||||
|
||||
完整设计见项目设计基线:
|
||||
|
||||
**[docs/design/cloudflare-pointing.md](../../design/cloudflare-pointing.md)**
|
||||
|
||||
本文件仅作 brainstorming 工作流落点索引,避免与 `docs/design/` 双份正文漂移。
|
||||
@@ -1,219 +0,0 @@
|
||||
# 边缘限流全局默认设计
|
||||
|
||||
日期:2026-07-19
|
||||
状态:已评审待实现
|
||||
方案:渲染时按站点合并全局默认(方案 A)
|
||||
|
||||
## 背景
|
||||
|
||||
当前边缘限流仅挂在站点(Proxy Route)上,字段为:
|
||||
|
||||
- `limit_conn_per_server`:站点并发连接上限
|
||||
- `limit_conn_per_ip`:单 IP 并发连接上限
|
||||
- `limit_rate`:单请求带宽
|
||||
|
||||
OpenResty 渲染行为:
|
||||
|
||||
- `http {}` 始终声明共享 `limit_conn_zone`
|
||||
- 各站点 `location` 在字段 `>0` / 非空时输出 `limit_conn` / `limit_rate`
|
||||
- 站点值为 `0` 或空表示**关闭**,无全局默认
|
||||
|
||||
期望:在全局增加默认限流策略;站点未设置时继承默认,可覆盖或显式关闭。
|
||||
|
||||
## 目标
|
||||
|
||||
1. 提供三项全局默认限流配置,覆盖全部现有维度。
|
||||
2. 站点 `0`/空 = 继承全局;`-1` = 显式关闭;`>0`/合法带宽串 = 站点自定义。
|
||||
3. 合并发生在配置渲染路径,仍在各站点 `location` 输出生效指令(不在 `http {}` 写默认 `limit_conn`/`limit_rate`)。
|
||||
4. 管理入口:侧栏「安全性」下新增子页「限流」。
|
||||
5. 全局默认初始为 `0`/空,存量发布行为与现网一致。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 引入 `limit_req`(按 RPS 限流)
|
||||
- 在 `http {}` 上下文直接写默认 `limit_conn` / `limit_rate`
|
||||
- 按路径 / URI 差异化限流
|
||||
- 改变 `limit_conn` zone 键模型(仍为 `$server_name` 与 `$binary_remote_addr`)
|
||||
|
||||
## 语义
|
||||
|
||||
### 站点字段
|
||||
|
||||
| 值 | `limit_conn_*` | `limit_rate` |
|
||||
|----|----------------|--------------|
|
||||
| `0` / 空 | 继承全局默认 | 空或 `"0"` 规范化为空串后继承 |
|
||||
| `-1` | 显式关闭该维度 | 字面 `"-1"` 表示显式关闭 |
|
||||
| `>0` / 合法带宽 | 使用站点值 | 合法 `^\d+[kKmM]?$` 使用站点值 |
|
||||
|
||||
### 全局默认
|
||||
|
||||
| 值 | 含义 |
|
||||
|----|------|
|
||||
| `0` / 空 | 默认关闭;继承方亦不输出指令 |
|
||||
| `>0` / 合法带宽串 | 作为未配置站点的生效值 |
|
||||
|
||||
全局默认**不允许** `-1`(无意义);仅 `>=0` 或合法 rate / 空。
|
||||
|
||||
### 合并规则(逐字段)
|
||||
|
||||
```
|
||||
if route == -1: effective = off
|
||||
else if route is set: effective = route // conn > 0 或 rate 合法非空
|
||||
else: effective = global // route 为 0/空
|
||||
// global 为 0/空 → off(不输出)
|
||||
```
|
||||
|
||||
`limit_rate` 的「set」判定:规范化后非空且不等于 `"-1"`。
|
||||
|
||||
## 配置存储
|
||||
|
||||
沿用 `system_configs` + Option API + 发布快照,与其它 OpenResty 选项一致。
|
||||
|
||||
| Key | 类型语义 | 默认 |
|
||||
|-----|----------|------|
|
||||
| `openresty_default_limit_conn_per_server` | 非负整数 | `0` |
|
||||
| `openresty_default_limit_conn_per_ip` | 非负整数 | `0` |
|
||||
| `openresty_default_limit_rate` | 空或 `^\d+[kKmM]?$` | `""` |
|
||||
|
||||
实现要点:
|
||||
|
||||
- `internal/model/system_configs.go` 增加 `ConfigKeyOpenRestyDefaultLimit*` 常量
|
||||
- goose seed/升级迁移写入默认值
|
||||
- `internal/apps/openflare/option` 注册校验器(conn ≥ 0;rate 与站点同一套 pattern,允许空)
|
||||
- `openRestyConfigSnapshot` / `buildOpenRestyConfigSnapshot` 增加三字段
|
||||
- 变更进入 OpenResty option diff;**需重新发布配置版本后下发节点**
|
||||
|
||||
## 站点模型与 API
|
||||
|
||||
- DB 列类型不变(`INTEGER` / `VARCHAR(32)`),无 schema 变更
|
||||
- `normalizeProxyRouteLimitConnValue`:允许 `>= -1`(原 `>= 0`)
|
||||
- `normalizeProxyRouteLimitRate`:允许 `"-1"` 存为关闭标记;空/`0` → `""`(继承)
|
||||
- View / Input / 前端类型同步暴露 `-1` 语义
|
||||
- 错误文案更新(非法负数除 `-1` 外拒绝)
|
||||
|
||||
## 渲染路径
|
||||
|
||||
合并**唯一**发生在 `pkg/render/openresty.RenderRouteConfig`:该函数已接收完整 `Document`,可从 `doc.OpenRestyConfig` 读取全局默认,与各 `doc.Routes[i]` 的站点字段合并。Server 预览渲染与 Agent 落地渲染共用同一路径,禁止在 snapshot 构建或其它层再合一次。
|
||||
|
||||
步骤:
|
||||
|
||||
1. 对每个 route:用站点限流字段 + `doc.OpenRestyConfig` 中的默认三项 → `routeLimitConfig`
|
||||
2. `renderRouteLimitBlock` 保持「有值才输出」
|
||||
3. 应用范围不变:
|
||||
- HTTP/HTTPS 反代 `location /`
|
||||
- Pages 相关 location
|
||||
- **不含** HTTP→HTTPS 重定向-only server
|
||||
4. `http {}` 仍只输出现有 `limit_conn_zone` 两行
|
||||
|
||||
快照 JSON **保留站点原始值**(含 `0`/`-1`),不把合并结果写回 route;节点 conf 中只看到最终指令。
|
||||
|
||||
伪代码:
|
||||
|
||||
```go
|
||||
func mergeRouteLimit(route routeLimits, def defaultLimits) routeLimitConfig {
|
||||
return routeLimitConfig{
|
||||
LimitConnPerServer: mergeConn(route.LimitConnPerServer, def.LimitConnPerServer),
|
||||
LimitConnPerIP: mergeConn(route.LimitConnPerIP, def.LimitConnPerIP),
|
||||
LimitRate: mergeRate(route.LimitRate, def.LimitRate),
|
||||
}
|
||||
}
|
||||
|
||||
func mergeConn(route, def int) int {
|
||||
if route == -1 {
|
||||
return 0 // off
|
||||
}
|
||||
if route > 0 {
|
||||
return route
|
||||
}
|
||||
if def > 0 {
|
||||
return def
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func mergeRate(route, def string) string {
|
||||
r := strings.TrimSpace(strings.ToLower(route))
|
||||
if r == "-1" {
|
||||
return ""
|
||||
}
|
||||
if r != "" && r != "0" {
|
||||
return r
|
||||
}
|
||||
d := strings.TrimSpace(strings.ToLower(def))
|
||||
if d != "" && d != "0" {
|
||||
return d
|
||||
}
|
||||
return ""
|
||||
}
|
||||
```
|
||||
## 前端
|
||||
|
||||
### 安全性 → 限流
|
||||
|
||||
- 导航:`openflareSecurityNavGroup` 增加 `{ title: '限流', url: '/rate-limits' }`
|
||||
- 页面:`frontend/app/(main)/rate-limits/page.tsx`
|
||||
- 通过 `OptionService.list` / `updateBatch` 读写上述 3 个 key
|
||||
- UI 模式对齐性能页:标题规范、卡片分区、保存反馈
|
||||
- 文案说明:`0`/空 = 默认关闭;`>0` = 未单独配置站点的默认生效值;修改后需发布配置版本
|
||||
|
||||
### 站点流量限制
|
||||
|
||||
- 更新 `limits-section.tsx` 与校验 helpers:
|
||||
- `0`/空 = 继承全局默认
|
||||
- `-1` = 关闭
|
||||
- `>0` / 合法 rate = 自定义
|
||||
- 可选:展示当前全局默认值作提示(只读)
|
||||
- 创建站点默认仍为 `0`/空(即继承)
|
||||
|
||||
## 兼容性
|
||||
|
||||
| 场景 | 结果 |
|
||||
|------|------|
|
||||
| 升级后全局默认 0,站点全 0 | 与升级前一致:不限流 |
|
||||
| 管理员设置全局默认后发布 | 所有 `0`/空站点自动生效默认 |
|
||||
| 站点需保持关闭 | 将该项改为 `-1` 后保存并发布 |
|
||||
| 旧 API 客户端只写 `0` | 合法;语义变为继承 |
|
||||
| 旧快照无默认字段 | 按 0/空处理 |
|
||||
|
||||
## 边界说明
|
||||
|
||||
- `limit_conn_per_ip` zone 仍按 `$binary_remote_addr` 全局共享;各 location 的 N 可不同,计数空间共享(现网行为,本设计不改)。
|
||||
- 多域名共享一条路由 → 共享合并后策略(产品边界不变)。
|
||||
- 仅改全局默认不自动 reload 节点;走标准「选项变更 → 配置版本 diff → 发布」。
|
||||
|
||||
## 测试计划
|
||||
|
||||
1. **render 表驱动**:继承 / 显式关 / 覆盖 / 全局关 × 三字段
|
||||
2. **normalize**:`-1`、`0`、`>0`、非法负值、rate `"-1"` / 空 / 合法 / 非法
|
||||
3. **snapshot**:默认字段进入 `openresty_config`;option diff 可检测变更
|
||||
4. **option 校验**:非法全局 rate / 负 conn 拒绝
|
||||
5. 前端:限流页读写与站点文案(可选手测)
|
||||
|
||||
## 文档与变更记录
|
||||
|
||||
- 本设计文档:`docs/superpowers/specs/2026-07-19-http-default-rate-limit-design.md`
|
||||
- 实现时更新中文 changelog `[Unreleased]`(用户可见语义与新设置页)
|
||||
- 如有配置参考页,补充三个 key 的中文说明
|
||||
- 不要求同步英文文档
|
||||
|
||||
## 实现落点(文件索引)
|
||||
|
||||
| 区域 | 路径 |
|
||||
|------|------|
|
||||
| 配置键 / seed | `internal/model/system_configs.go`,goose 迁移 |
|
||||
| 校验 | `internal/apps/openflare/option/openresty_validators.go` |
|
||||
| 快照 | `internal/apps/openflare/config_version/snapshot.go` |
|
||||
| 站点规范化 | `internal/apps/openflare/proxy_route/helpers.go` |
|
||||
| 渲染合并 | `pkg/render/openresty/render.go`(及调用处传参) |
|
||||
| 导航 | `frontend/lib/navigation/openflare-nav.ts` |
|
||||
| 限流设置页 | `frontend/app/(main)/rate-limits/` |
|
||||
| 站点 UI | `frontend/app/(main)/proxy-routes/detail/components/limits-section.tsx` |
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. 全局默认可在「安全性 → 限流」读写,初始 0/空。
|
||||
2. 全局设为有效值并发布后,站点限流为 0/空的 location 出现对应指令。
|
||||
3. 站点 `-1` 在全局有默认时仍不输出该维度。
|
||||
4. 站点 `>0` 覆盖全局。
|
||||
5. 全局与站点均为 0/空时 conf 无 `limit_conn`/`limit_rate` 指令。
|
||||
6. `make code-check` 通过;相关单测覆盖合并与规范化。
|
||||
@@ -1,217 +0,0 @@
|
||||
# 限流页请求压力分析设计
|
||||
|
||||
日期:2026-07-19
|
||||
状态:已评审待实现
|
||||
方案:Tabs(分析 / 配置)+ 专用 ECharts 双轴压力图(方案 A)
|
||||
|
||||
## 背景
|
||||
|
||||
`/rate-limits` 当前仅为管理员配置全局 OpenResty 默认限流(`limit_conn_*` / `limit_rate`),无请求压力可视化。
|
||||
|
||||
访问日志概览已提供:
|
||||
|
||||
- 过滤:时间预设 `24h | 7d | 15d | 30d` + 域名多选 `hosts[]`
|
||||
- 数据:`GET /api/v1/d/access-logs/overview` → 小时桶 `trends.requests` / `trends.visits`,以及 `top_hosts` / `top_ips`(窗口总请求数)
|
||||
- 图表:共享 `TrendChart` 为**单 Y 轴**;仓库内无 ECharts `dataZoom`、无双轴指标图
|
||||
|
||||
需求:在限流页展示当前请求压力(RPS),默认 24 小时,图表样式对齐「双轴时序面积折线 + 底部缩放条」描述,过滤复用访问日志概览组件,并增加域名/IP 平均 RPS 排行。
|
||||
|
||||
## 目标
|
||||
|
||||
1. `/rate-limits` 改为 Tabs:**分析**(默认)/ **配置**。
|
||||
2. 分析 Tab:概览式过滤 + RPS/访客双轴主图 + 域名/IP 平均 RPS 排行。
|
||||
3. 配置 Tab:迁入现有全局默认限流表单,行为不变。
|
||||
4. 数据复用 `AccessLogService.getOverview`,不新增后端 API。
|
||||
5. 主图为**专用** ECharts 组件,不扩展共享 `TrendChart`。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 新 RPS 时序 API 或峰值桶 RPS 排行接口
|
||||
- 给通用 `TrendChart` 增加双轴 / dataZoom
|
||||
- 配置 Tab 限流语义变更
|
||||
- 分析过滤支持 node_id / IP / path(概览亦无)
|
||||
- 英文文档
|
||||
|
||||
## 页面信息架构
|
||||
|
||||
**路由:** `/rate-limits`(导航「安全性 → 限流」不变)
|
||||
|
||||
| Tab | 内容 |
|
||||
|-----|------|
|
||||
| **分析**(默认) | 过滤条 → `RatePressureChart` → 双排行榜 |
|
||||
| **配置** | 现有三项全局默认限流表单 + 保存 + 链到版本发布 |
|
||||
|
||||
可选:`?tab=config` 直达配置;默认 `analysis`。
|
||||
|
||||
**权限:** 仅管理员(与现页一致)。
|
||||
|
||||
**分析 Tab 自上而下:**
|
||||
|
||||
1. **过滤条**(与访问日志概览一致)
|
||||
- 时间:`24 | 168 | 360 | 720` 小时,默认 **24**
|
||||
- 域名:Zone 树多选 → `hosts[]`
|
||||
2. **主图卡片** `RatePressureChart`
|
||||
3. **排行榜**(并排)
|
||||
- 平均 RPS 最高域名
|
||||
- 平均 RPS 最高 IP
|
||||
|
||||
## 数据与状态
|
||||
|
||||
### 查询
|
||||
|
||||
```ts
|
||||
AccessLogService.getOverview({
|
||||
hours: overviewHours,
|
||||
hosts: overviewHosts.length > 0 ? overviewHosts : undefined,
|
||||
})
|
||||
// queryKey: ['openflare', 'rate-limits', 'overview', hours, hosts]
|
||||
```
|
||||
|
||||
- 过滤变更 → 重新请求 overview
|
||||
- 图表 `dataZoom` **仅**前端缩放已加载序列,**不**改 `hours`、**不**触发 refetch
|
||||
|
||||
### 指标定义
|
||||
|
||||
| 序列 | 源字段 | 换算 | 轴 |
|
||||
|------|--------|------|-----|
|
||||
| 请求速率 (RPS) | `trends.requests[].value` | `value / 3600`(概览固定 1h 桶) | 左 Y |
|
||||
| 独立访客 | `trends.visits[].value` | 桶内 UV,不换算 | 右 Y |
|
||||
|
||||
- 时间点:`bucket_started_at`
|
||||
- Tooltip:时间 + RPS(如 `12.3 req/s`)+ 访客数
|
||||
- 空数据 / 加载 / 错误:对齐访问日志概览空态与 `ErrorInline` / loading
|
||||
|
||||
### 排行口径
|
||||
|
||||
窗口**平均** RPS(与 dashboard `estimated_qps` 一致):
|
||||
|
||||
```
|
||||
avgRps = total_requests / (hours * 3600)
|
||||
```
|
||||
|
||||
- 域名:`top_hosts[]` 的 `value` 为窗口总请求数 → 换算后展示
|
||||
- IP:`top_ips[]` 同理
|
||||
- 标题:「平均 RPS 最高域名」「平均 RPS 最高 IP」
|
||||
- 副文案标明窗口(如「近 24 小时平均」)
|
||||
- UI 组件:现有 `RankCard` / `RankChart`
|
||||
|
||||
**不是**峰值小时桶 RPS;避免新 API。
|
||||
|
||||
## 主图组件 `RatePressureChart`
|
||||
|
||||
### 布局(对齐产品描述)
|
||||
|
||||
1. **外部卡片**:圆角、边框/轻阴影,扁平矩形
|
||||
2. **顶部控制栏**
|
||||
- 左:主标题「请求压力」(字号加粗)
|
||||
- 右:时钟图标 + 当前查询窗口起止(由 `hours` 与「现在」推算本地时间,`YYYY-MM-DD HH:mm:ss`)
|
||||
3. **图例与轴标识**
|
||||
- 左上:左轴属性「RPS」
|
||||
- 右上:图例圆点 +「请求速率」「独立访客」
|
||||
- 最右:右轴单位「访客 / 桶」
|
||||
4. **主绘制区**
|
||||
- 双 Y 轴:左 RPS 从 0 递增;右访客从 0 递增
|
||||
- X 轴:时间,标签两行(月-日 / 时:分),可复用 `formatOverviewTrendLabel` 思路
|
||||
- 水平等距虚线网格
|
||||
- 面积 + 折线,半透明填充,两序列可重叠
|
||||
5. **底部 dataZoom slider**
|
||||
- ECharts `dataZoom: [{ type: 'slider', ... }]`
|
||||
- 宽度对齐绘图区;左右手柄;内嵌缩略波动线
|
||||
- 仅影响可见区间
|
||||
|
||||
### 实现约束
|
||||
|
||||
- 新建专用组件,**不要**给 `TrendChart` 加 dualY/dataZoom
|
||||
- 库:`echarts` + `echarts-for-react`(与看板一致)
|
||||
- 颜色使用主题/CSS 变量或与访问日志趋势相近的语义色,避免硬编码与 shadcn 变体冲突时可参考现有 `TrendChart` 系列色
|
||||
|
||||
## 过滤组件复用
|
||||
|
||||
优先从 `frontend/app/(main)/access-logs/components/overview-tab.tsx` **抽出**:
|
||||
|
||||
- `OverviewToolbar`(或等价)
|
||||
- `OverviewHostFilter`
|
||||
- 依赖的 `OVERVIEW_RANGE_OPTIONS` / `OverviewRangeHours` 已在 `access-log-utils.ts`
|
||||
|
||||
落点建议:
|
||||
|
||||
- 仍放在 `access-logs/components/` 并 export,限流分析 import;或
|
||||
- 若跨模块更清晰,迁到 `frontend/components/common/`(仅当确实跨页面复用且避免循环依赖时)
|
||||
|
||||
**验收:** 访问日志概览过滤行为与抽出前一致。
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
frontend/app/(main)/rate-limits/
|
||||
page.tsx # Tabs、权限、分析/配置挂载
|
||||
components/
|
||||
analysis-tab.tsx # 过滤 + 图 + 排行 + overview query
|
||||
rate-pressure-chart.tsx # 双轴 + dataZoom
|
||||
config-tab.tsx # 现有 Option 表单逻辑迁入
|
||||
```
|
||||
|
||||
可选抽出:
|
||||
|
||||
```
|
||||
frontend/app/(main)/access-logs/components/
|
||||
overview-toolbar.tsx # 从 overview-tab 抽出
|
||||
overview-host-filter.tsx
|
||||
```
|
||||
|
||||
后端:无变更。
|
||||
|
||||
## 边界与兼容
|
||||
|
||||
| 场景 | 行为 |
|
||||
|------|------|
|
||||
| 无日志 / ClickHouse 空 | 图与排行空态 |
|
||||
| 仅选域名 | overview 带 `hosts[]` |
|
||||
| dataZoom 拖动 | 不请求后端 |
|
||||
| 非管理员 | 空态「权限不足」 |
|
||||
| 书签 `/rate-limits` | 默认分析 Tab |
|
||||
| 配置保存 | 仍 invalidate options / config-preview / config-versions |
|
||||
|
||||
## 测试与验收
|
||||
|
||||
### 自动化(按项目习惯)
|
||||
|
||||
- 若有 vitest:过滤 props 透传、`avgRps` 换算纯函数单测
|
||||
- 图表以手工/视觉验收为主(ECharts 难做快照)
|
||||
|
||||
### 验收标准
|
||||
|
||||
1. 默认进入分析 Tab,24h,主图展示 RPS + 访客
|
||||
2. 切换 7d / 域名后图与排行刷新
|
||||
3. dataZoom 仅改变可见时间范围
|
||||
4. 排行展示平均 RPS,不是原始请求总数(文案明确「平均」)
|
||||
5. 配置 Tab 可读写三项默认限流并保存
|
||||
6. 访问日志概览过滤不回归
|
||||
7. `make prettier`;相关 typecheck/lint 通过
|
||||
|
||||
## 文档
|
||||
|
||||
- 本设计:`docs/superpowers/specs/2026-07-19-rate-limit-analytics-design.md`
|
||||
- 实现时:`docs/changelog/index.md` `[Unreleased]` 补充用户可见条目
|
||||
- 纯 UI/分析展示,无新 system config 键
|
||||
|
||||
## 实现落点索引
|
||||
|
||||
| 区域 | 路径 |
|
||||
|------|------|
|
||||
| 限流页 | `frontend/app/(main)/rate-limits/` |
|
||||
| 概览过滤复用 | `access-logs/components/overview-tab.tsx` 等 |
|
||||
| Overview API | `AccessLogService.getOverview` |
|
||||
| 排行 UI | `components/data/rank-card.tsx` |
|
||||
| 趋势参考 | `components/data/trend-chart.tsx`(只参考样式,不扩展) |
|
||||
|
||||
## 决策摘要
|
||||
|
||||
| 决策 | 选择 |
|
||||
|------|------|
|
||||
| 页面结构 | Tabs:分析 / 配置 |
|
||||
| 双轴 | 左 RPS,右 独立访客/桶 |
|
||||
| 过滤 | 概览过滤 + 默认 24h 预设 |
|
||||
| 排行 | 窗口平均 RPS = 总请求 / 窗口秒数 |
|
||||
| 图表实现 | 专用 ECharts 组件(方案 A) |
|
||||
| 后端 | 无新 API |
|
||||
@@ -1,122 +0,0 @@
|
||||
# WAF 规则编辑器:节点命名与拖放添加
|
||||
|
||||
日期:2026-07-19
|
||||
范围:`/waf/rules/editor` 前端交互与类型对齐
|
||||
状态:已确认,待实现
|
||||
|
||||
## 背景
|
||||
|
||||
当前 WAF 规则流图编辑器有两处体验问题:
|
||||
|
||||
1. 画布节点只显示类型固定名称(如「IP 匹配」),无法自定义命名,复杂规则难以区分。
|
||||
2. 节点库通过点击添加,新节点落在固定偏移位置(`x: 240, y: 140 + n*24`),无法在目标位置放置。
|
||||
|
||||
后端 `RuleNode` 已具备 `label` 字段(`json:"label,omitempty"`),前端类型与 UI 尚未消费。
|
||||
|
||||
## 目标
|
||||
|
||||
1. 用户可为可编辑节点自定义**显示名称**(`label`),画布与属性栏一致展示。
|
||||
2. 从节点库**拖放到画布**,在鼠标松手处生成节点;**取消点击固定位置添加**。
|
||||
3. 不做备注字段、不做拖到连线中插入、不改后端 schema / `schema_version`。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 节点备注 / note / remark
|
||||
- 拖到边自动拆边插入
|
||||
- 系统节点 `start` / `allow` 可改名
|
||||
- 后端校验、编译或运行时语义变更
|
||||
- 侧栏式节点库大改版
|
||||
|
||||
## 数据模型
|
||||
|
||||
### 后端(已有,不改)
|
||||
|
||||
```go
|
||||
type RuleNode struct {
|
||||
ID string `json:"id"`
|
||||
Type RuleNodeType `json:"type"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Position RulePosition `json:"position"`
|
||||
Config json.RawMessage `json:"config"`
|
||||
}
|
||||
```
|
||||
|
||||
`label` 为空则 omit;现有大小限制与图校验保持不变。
|
||||
|
||||
### 前端
|
||||
|
||||
`WAFRuleNode` 各变体增加可选字段:
|
||||
|
||||
```ts
|
||||
label?: string;
|
||||
```
|
||||
|
||||
- 保存时:空字符串不写入或写 `undefined`,与 `omitempty` 对齐。
|
||||
- 显示时:`label?.trim() || typeDefaultLabel`。
|
||||
- 新建节点:不设 `label`(显示类型默认名)。
|
||||
- `start` / `allow`:属性栏仍为「系统节点无需配置」,不提供改名输入;若历史数据带 `label`,画布仍可按上述规则显示,但不提供编辑入口。
|
||||
|
||||
## UI 行为
|
||||
|
||||
### 画布节点(`rule-node.tsx`)
|
||||
|
||||
| 区域 | 行为 |
|
||||
|------|------|
|
||||
| 主标题 | `label` 去空白后非空则用 `label`,否则用类型默认中文名 |
|
||||
| 副标题 | 仍显示 `rule.id`(mono 小字) |
|
||||
| 图标 / handle | 不变 |
|
||||
|
||||
### 属性栏(`node-properties.tsx`)
|
||||
|
||||
对非系统节点(`ip_match` | `geo_match` | `pow` | `block`),在类型专属配置**之上**增加:
|
||||
|
||||
- 字段标签:`显示名称`
|
||||
- 控件:`Input`,受控绑定 `node.label ?? ''`
|
||||
- 变更:`onChange({ ...node, label: value })`;清空时写 `''` 或去掉字段(实现任选其一,保存序列化时不落空 label)
|
||||
|
||||
系统节点保持现有文案。
|
||||
|
||||
### 节点库与添加(`node-library.tsx` + `rule-flow-canvas.tsx`)
|
||||
|
||||
1. 节点库项设为 `draggable`,`dragstart` 写入节点类型(如 `application/openflare-waf-node` 或等价自定义 MIME + `text/plain` 回退)。
|
||||
2. 移除 `onClick` → `onAdd(type)` 的点击添加路径。
|
||||
3. React Flow 画布容器:
|
||||
- `onDragOver`:`preventDefault`,允许 drop
|
||||
- `onDrop`:读取类型 → `screenToFlowPosition({ x: clientX, y: clientY })` → 创建节点(默认 config 逻辑与现有 `addNode` 相同,但 `position` 为落点)
|
||||
4. 落点后选中新节点,清除边选中(与现有一致)。
|
||||
5. 工具栏仍在画布左上角浮动区域,仅改为拖源,不改为侧栏。
|
||||
|
||||
## 实现落点(文件)
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `frontend/lib/services/openflare/types.ts` | `WAFRuleNode` 增加 `label?` |
|
||||
| `frontend/app/(main)/waf/rules/editor/components/rule-node.tsx` | 标题显示逻辑 |
|
||||
| `frontend/app/(main)/waf/rules/editor/components/node-properties.tsx` | 「显示名称」字段 |
|
||||
| `frontend/app/(main)/waf/rules/editor/components/node-library.tsx` | 拖放源,去掉点击添加 |
|
||||
| `frontend/app/(main)/waf/rules/editor/components/rule-flow-canvas.tsx` | drop 落点创建;`addNode` 接受 position |
|
||||
| 相关 `*.test.tsx` / `*.test.ts` | label 展示/编辑、拖放 payload、落点 |
|
||||
|
||||
可选:若序列化路径有显式字段白名单,确认 `label` 会进入保存 payload。
|
||||
|
||||
## 错误与边界
|
||||
|
||||
- 未知 / 非法 drag type:忽略 drop。
|
||||
- 落在画布外:不创建。
|
||||
- 超长 `label`:依赖后端既有图大小/字段限制;前端可不设硬上限,或与常见 Input 一致(如 64–128 字符)——实现阶段若后端有明确上限则对齐。
|
||||
- Undo/脏检查:`label` 与 `position` 变更走现有 `onGraphChange` 路径,不新增独立历史机制。
|
||||
|
||||
## 测试要点
|
||||
|
||||
1. 有 `label` 的节点主标题为自定义名;无 `label` 为类型默认名。
|
||||
2. 属性栏修改 `label` 后 graph 节点更新且画布同步。
|
||||
3. 节点库项可拖;drop 后节点 `position` 接近 flow 坐标(允许测试中 mock `screenToFlowPosition`)。
|
||||
4. 不再通过点击节点库按钮创建节点(无 click-add 行为)。
|
||||
5. 系统节点属性栏仍无「显示名称」。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 可编辑节点可命名,保存再打开名称仍在。
|
||||
- [ ] 画布显示自定义名(空则类型名)。
|
||||
- [ ] 仅拖放添加,松手位置为节点位置。
|
||||
- [ ] 无后端 API / schema 变更;`make code-check` 与相关 vitest 通过。
|
||||
@@ -1,191 +0,0 @@
|
||||
# WAF 规则节点:安全防护(security_check)
|
||||
|
||||
日期:2026-07-19
|
||||
范围:WAF 编排图新节点 `security_check`(控制面校验/编译 + 边缘 Lua 特征检测 + 前端编辑器)
|
||||
状态:已确认,待实现
|
||||
|
||||
## 背景
|
||||
|
||||
现有节点覆盖 IP / 地域 / UA / PoW,缺少请求载荷侧的基础攻击特征检测。产品需要在图中提供可编排的「安全防护」单元:多项基础规则可开关,**命中任意已启用规则返回 false**。
|
||||
|
||||
检测深度采用 **Lua 内置特征规则**(非 ModSecurity/CRS),能拦截常见扫描与明显 payload,允许有限误报/漏报。
|
||||
|
||||
## 目标
|
||||
|
||||
1. 新增 match 型节点 **`security_check`**,句柄 `true` / `false`。
|
||||
2. 属性栏分组:**安全防护**说明 + **基础防护** 9 项 Switch。
|
||||
3. 语义:**任一已启用规则命中 → false**;全部未命中 → true。
|
||||
4. 默认仅开启误报较低的两项:**路径穿越**、**文件包含**;其余默认关闭。
|
||||
|
||||
## 非目标(v1)
|
||||
|
||||
- ModSecurity / OWASP CRS / libinjection 完整引擎
|
||||
- 响应侧 XSS 检测、机器学习
|
||||
- 自定义规则上传 / 严重级别评分 / 命中日志字段(可后续加)
|
||||
- 无限制大 Body 全量扫描
|
||||
|
||||
## 节点模型
|
||||
|
||||
### 类型
|
||||
|
||||
| 字段 | 值 |
|
||||
|------|-----|
|
||||
| `type` | `security_check` |
|
||||
| 句柄 | `true`, `false` |
|
||||
| 可删除 / 可命名 / 可拖放 | 是 |
|
||||
|
||||
### Config
|
||||
|
||||
```json
|
||||
{
|
||||
"sql_injection": false,
|
||||
"path_traversal": true,
|
||||
"command_injection": false,
|
||||
"xss": false,
|
||||
"ssrf": false,
|
||||
"file_inclusion": true,
|
||||
"malicious_upload": false,
|
||||
"xxe": false,
|
||||
"crlf_injection": false
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 默认 | UI 文案 | 检测面(v1) |
|
||||
|------|------|---------|--------------|
|
||||
| `sql_injection` | false | SQL 注入 | Query、Cookie、Referer、Body |
|
||||
| `path_traversal` | **true** | 路径穿越防护 | Path(`uri`)、Query、Body |
|
||||
| `command_injection` | false | 命令注入 | Query、Cookie、Referer、Body |
|
||||
| `xss` | false | XSS | Query、Cookie、Referer、Body |
|
||||
| `ssrf` | false | SSRF | Query、Cookie、Referer、Body 中 URL 形态 |
|
||||
| `file_inclusion` | **true** | 文件包含(LFI/RFI) | Path(`uri`)、Query、Body |
|
||||
| `malicious_upload` | false | 恶意文件上传 | Multipart Body |
|
||||
| `xxe` | false | XXE | Body(Content-Type 含 xml 时) |
|
||||
| `crlf_injection` | false | CRLF 注入 | Query、Cookie、Referer、Body |
|
||||
|
||||
全部关闭时:节点恒 **true**(空操作),合法。
|
||||
|
||||
## 求值语义
|
||||
|
||||
```
|
||||
inputs := collect_inspection_strings(request) // 见下
|
||||
for each enabled rule:
|
||||
if rule_matches(rule, inputs) → return false
|
||||
return true
|
||||
```
|
||||
|
||||
- **false** = 命中攻击特征(接阻止)
|
||||
- **true** = 未命中(接通过或其它节点)
|
||||
|
||||
### 采集与限制
|
||||
|
||||
| 来源 | 方式 |
|
||||
|------|------|
|
||||
| Path | 仅 `ngx.var.uri`(不重复扫完整 `request_uri`,避免与 Query 双计),URL 解码(含常见双重编码路径变体) |
|
||||
| Query | `get_uri_args` 键与值(仅当已启用规则需要 Query) |
|
||||
| Header | **不**扫描通用浏览器头(UA / Accept 等);注入类仅采 **Cookie、Referer** |
|
||||
| Cookie | `ngx.var.http_cookie` |
|
||||
| Body | 仅当已启用规则需要 Body 且 `Content-Length` > 0 且 ≤ **65536**;GET/零长度不 `read_body` |
|
||||
|
||||
Body 读取失败:跳过 Body 类检测并限频 warn(可用性优先,不 fail-closed 整图)。
|
||||
|
||||
### 规则特征方向(v1 模式包)
|
||||
|
||||
实现以可维护的模式表为准,下表为方向约束:
|
||||
|
||||
1. **SQL 注入**:`union select`、`or 1=1`、`sleep(`、`benchmark(`、注释符、十六进制/char 拼接等
|
||||
2. **路径穿越**:`../`、`..\\`、`%2e%2e`、`%252e`、绝对路径探测
|
||||
3. **命令注入**:`;` `|` `` ` `` `$()` 结合 shell 关键字、换行拼接
|
||||
4. **XSS**:`<script`、`javascript:`、事件处理器 `onerror=` 等
|
||||
5. **SSRF**:内网 IP、`localhost`、`169.254.`、`file://`、`gopher://`、`dict://`
|
||||
6. **文件包含**:`php://`、`file://`、`/etc/passwd`、`%00` 等(可与路径穿越重叠)
|
||||
7. **恶意上传**:multipart 文件名双扩展、危险扩展、可疑 Content-Type
|
||||
8. **XXE**:`<!ENTITY`、`SYSTEM`、外部实体(仅 XML 类 Content-Type)
|
||||
9. **CRLF**:`%0d%0a`、裸 `\r\n` 注入特征
|
||||
|
||||
模式在 worker 内缓存;大小写不敏感(除明确大小写敏感的协议串)。
|
||||
|
||||
## 控制面
|
||||
|
||||
### `graph_types.go`
|
||||
|
||||
- `RuleNodeSecurityCheck = "security_check"`
|
||||
- `SecurityCheckConfig` 九个 `bool` 字段(JSON snake_case 如上)
|
||||
|
||||
### `graph_validate.go`
|
||||
|
||||
- `requiredHandles`: `true`, `false`
|
||||
- 严格 JSON;仅允许已知布尔字段
|
||||
|
||||
### `graph_compile.go`
|
||||
|
||||
- 原样编译布尔字段进运行时配置
|
||||
|
||||
### 测试
|
||||
|
||||
- 合法全关 / 默认子集 / 全开
|
||||
- 未知字段拒绝
|
||||
- 编译保留默认
|
||||
|
||||
## 数据面
|
||||
|
||||
### `waf_runtime.lua`
|
||||
|
||||
```lua
|
||||
elseif node.type == "security_check" then
|
||||
handle = matches_security_check(node.config or {}) and "true" or "false"
|
||||
```
|
||||
|
||||
`matches_security_check` 返回 **true 表示安全通过**(未命中),与 `ip_match` 的「条件成立」命名不同,但句柄语义与产品一致:命中攻击 → 走 `false` 边。
|
||||
|
||||
建议将模式表与匹配函数放在同文件或 `waf/security.lua`(若体积过大再拆,并在 `waf_assets.go` 嵌入)。
|
||||
|
||||
### `waf_runtime_spec.lua`
|
||||
|
||||
覆盖:默认配置拦路径穿越;全关放行;SQL/XSS 样例;Body 超限不炸;multipart 文件名危险扩展(若开启)。
|
||||
|
||||
## 前端
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `types.ts` | `security_check` + `SecurityCheckConfig` |
|
||||
| `node-factory.ts` | 默认:path_traversal+file_inclusion true,其余 false |
|
||||
| `node-library.tsx` | 「安全防护」+ 图标 |
|
||||
| `rule-node.tsx` | `true`/`false` handles |
|
||||
| `node-properties.tsx` | 显示名称;分组说明 + 9 Switch(问号 Tooltip) |
|
||||
| `graph-validation.ts` / `editor-behavior.ts` | handles |
|
||||
|
||||
### 属性栏草图
|
||||
|
||||
```
|
||||
显示名称
|
||||
── 安全防护 ──
|
||||
命中任意已启用规则返回 False [?]
|
||||
── 基础防护 ──
|
||||
[Switch] 路径穿越防护 [?]
|
||||
[Switch] 文件包含(LFI/RFI) [?]
|
||||
[Switch] SQL 注入 [?]
|
||||
...
|
||||
```
|
||||
|
||||
Tooltip 文案包含检测面与简要说明(与产品表一致)。
|
||||
|
||||
## 文档
|
||||
|
||||
- 更新 `docs/design/waf-orchestration-design.md` 节点表
|
||||
- `docs/changelog/index.md` `[Unreleased]`
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 可拖入并配置 9 开关,默认仅路径穿越+文件包含
|
||||
- [ ] 保存/发布后 Agent 执行;命中 → false 边;未命中 → true
|
||||
- [ ] 全关恒 true
|
||||
- [ ] Lua/Go/前端相关测试与 `make code-check` 通过
|
||||
|
||||
## 风险
|
||||
|
||||
| 风险 | 缓解 |
|
||||
|------|------|
|
||||
| 误报 | 默认仅开低误报两项;模式偏保守 |
|
||||
| 漏报 | 文档标明特征检测边界;后续可加强模式 |
|
||||
| Body 性能 | 64KiB 上限;未启用 Body 规则不读 Body |
|
||||
| 与路径/包含重叠 | 允许重叠;任一命中即 false |
|
||||
@@ -1,228 +0,0 @@
|
||||
# WAF 规则节点:UA 检查(ua_check)
|
||||
|
||||
日期:2026-07-19
|
||||
范围:WAF 编排图新节点 `ua_check`(控制面校验/编译 + 边缘 Lua 运行时 + 前端编辑器)
|
||||
状态:已确认,待实现
|
||||
|
||||
## 背景
|
||||
|
||||
访问日志概览已按 User-Agent 分类浏览器与操作系统(`internal/repository/analytics/browser.go`),但 WAF 规则图尚无基于 UA 的分支节点。运营需要在图中:
|
||||
|
||||
1. 要求请求必须携带 UA;
|
||||
2. 按浏览器 / 操作系统做白名单匹配(and/or 可配);
|
||||
3. 优先屏蔽常见爬虫与非正常 UA。
|
||||
|
||||
## 目标
|
||||
|
||||
- 新增 match 型节点 **`ua_check`**,输出 `true` / `false` 句柄(与 `ip_match` / `geo_match` 一致)。
|
||||
- 属性栏交互与产品草图对齐:开启 UA 检查、匹配多选、屏蔽开关。
|
||||
- 边缘分类标签与访问日志概览一致(同一套 token 规则)。
|
||||
- 屏蔽逻辑优先级高于白名单匹配。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 设备类型(Mobile/Tablet)维度。
|
||||
- 原始 UA 正则 / 自由子串列表(PoW 列表已有,不并入本节点)。
|
||||
- 在 Server 请求路径上执行 WAF 图(仍仅 Agent OpenResty)。
|
||||
- 将 analytics 包直接 import 到 Agent(边缘用 Lua 复刻规则;Go 侧用同一规则表做校验与单测对拍)。
|
||||
|
||||
## 节点模型
|
||||
|
||||
### 类型
|
||||
|
||||
| 字段 | 值 |
|
||||
|------|-----|
|
||||
| `type` | `ua_check` |
|
||||
| 句柄 | `true`, `false` |
|
||||
| 可删除 | 是 |
|
||||
| 可命名 | 是(`label`) |
|
||||
| 可拖放添加 | 是 |
|
||||
|
||||
### Config(JSON)
|
||||
|
||||
```json
|
||||
{
|
||||
"require_ua": false,
|
||||
"browsers": [],
|
||||
"operating_systems": [],
|
||||
"match_mode": "or",
|
||||
"block_common_bots": false,
|
||||
"block_abnormal_ua": false,
|
||||
"block_custom_ua": false,
|
||||
"custom_ua_patterns": []
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `require_ua` | bool | 开启后:请求头无 UA(空 / 仅空白)→ **false** |
|
||||
| `browsers` | string[] | 白名单浏览器标签;空表示不限制浏览器 |
|
||||
| `operating_systems` | string[] | 白名单操作系统标签;空表示不限制 OS |
|
||||
| `match_mode` | `"and"` \| `"or"` | **浏览器条件与 OS 条件**之间的组合;默认 `"or"` |
|
||||
| `block_common_bots` | bool | 屏蔽常见爬虫:分类 browser 或 os 为 `Bot` → **false** |
|
||||
| `block_abnormal_ua` | bool | 屏蔽非正常 UA:browser ∈ `{Other, Unknown}`(**不含** Bot/搜索引擎爬虫)→ **false** |
|
||||
| `block_custom_ua` | bool | 屏蔽自定义 UA:原始 UA 命中 `custom_ua_patterns` 任一条 → **false** |
|
||||
| `custom_ua_patterns` | string[] | 正则列表(边缘为 Lua 模式);开启 `block_custom_ua` 时至少一条 |
|
||||
|
||||
默认值:开关全 `false`,列表空,`match_mode: "or"`。
|
||||
|
||||
### 允许的标签(封闭枚举)
|
||||
|
||||
与 `ParseBrowserName` / `ParseOSName` 输出对齐:
|
||||
|
||||
**browsers:**
|
||||
`Chrome`, `Safari`, `Firefox`, `Edge`, `Opera`, `Chromium`, `WeChat`, `Postman`, `CLI`, `Bot`, `Unknown`, `Other`
|
||||
|
||||
**operating_systems:**
|
||||
`Android`, `iOS`, `Windows`, `macOS`, `Chrome OS`, `Linux`, `Bot`, `Unknown`, `Other`
|
||||
|
||||
校验:列表元素必须属于上表;重复项编译时去重排序;未知字符串拒绝保存。
|
||||
|
||||
## 求值语义(边缘)
|
||||
|
||||
输入:`ua = http_user_agent`(trim 后判断空)。
|
||||
分类:`browser = ParseBrowserName(ua)`,`os = ParseOSName(ua)`(空 UA → 二者均为 `Unknown`,与 analytics 一致)。
|
||||
|
||||
**严格顺序:**
|
||||
|
||||
```
|
||||
1) if require_ua and ua 为空 → false
|
||||
2) browser, os := classify(ua)
|
||||
3) if block_common_bots and (browser == "Bot" or os == "Bot") → false
|
||||
4) if block_abnormal_ua and browser in {"Other","Unknown"} → false
|
||||
5) if block_custom_ua and UA matches any custom_ua_patterns → false
|
||||
6) has_browsers := browsers 非空; has_os := operating_systems 非空
|
||||
7) if not has_browsers and not has_os → true
|
||||
8) browser_hit := browser ∈ browsers; os_hit := os ∈ operating_systems
|
||||
9) if has_browsers and not has_os → browser_hit
|
||||
10) if has_os and not has_browsers → os_hit
|
||||
11) if both lists set:
|
||||
match_mode == "and" → browser_hit and os_hit
|
||||
match_mode == "or" → browser_hit or os_hit
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- **屏蔽优先于匹配**:步骤 3–5 在白名单之前。
|
||||
- **未配置匹配列表**:步骤 6 直接 true(仅受 require / block 约束)。
|
||||
- **仅一侧列表有值**:只校验该侧是否命中;`match_mode` 仅在两侧都有值时生效。
|
||||
- 节点本身不 allow/block,仅选句柄;下游连线决定动作。
|
||||
|
||||
### 示例
|
||||
|
||||
| 配置摘要 | 请求 | 结果 |
|
||||
|----------|------|------|
|
||||
| 仅 `require_ua` | 无 UA | false |
|
||||
| 仅 `require_ua` | 正常 Chrome | true |
|
||||
| `block_common_bots` | Googlebot | false |
|
||||
| `block_abnormal_ua` | 无法识别 UA | false |
|
||||
| browsers=`[Chrome]`, mode=or | Safari | false |
|
||||
| browsers=`[Chrome]`, os=`[iOS]`, mode=and | Chrome Desktop | false(os 未命中) |
|
||||
| browsers=`[Chrome]`, os=`[iOS]`, mode=or | Chrome Desktop | true |
|
||||
| 列表皆空,无 block | 任意有 UA | true |
|
||||
|
||||
## 分类规则来源
|
||||
|
||||
权威实现(analytics):`internal/repository/analytics/browser.go` 中 `browserRules` / `osRules`。
|
||||
|
||||
实现要求:
|
||||
|
||||
1. **Lua 运行时**复刻相同 token 顺序与 `contains` / `noneOf` 语义(lower-case 子串)。
|
||||
2. **Go 单测**用同一批样例 UA 对拍 `ParseBrowserName` / `ParseOSName` 与 Lua 或共享测试表,防止漂移。
|
||||
3. 不强制本迭代抽取共享包;若抽取,须保持 analytics 与 WAF 行为不变。
|
||||
|
||||
## 控制面
|
||||
|
||||
### `graph_types.go`
|
||||
|
||||
- `RuleNodeUACheck RuleNodeType = "ua_check"`
|
||||
- `UACheckConfig` 结构体对应上表 JSON 字段
|
||||
|
||||
### `graph_validate.go`
|
||||
|
||||
- `requiredHandles`: `true`, `false`
|
||||
- `validateUACheckNodeConfig`:
|
||||
- `match_mode` 仅 `and`/`or`(缺省按 `or` 或拒绝非法值)
|
||||
- browsers / OS 标签 ∈ 封闭枚举
|
||||
- 布尔字段默认 false
|
||||
- `DisallowUnknownFields`
|
||||
|
||||
### `graph_compile.go`
|
||||
|
||||
- 编译进 `RuntimeRuleNode`,列表 `sortedUniqueStrings`
|
||||
- 规范化 `match_mode`(非法不得编译成功)
|
||||
|
||||
### 测试
|
||||
|
||||
- validate:合法配置、非法标签、非法 mode、缺句柄
|
||||
- compile:列表排序去重、默认值
|
||||
|
||||
## 数据面(Agent)
|
||||
|
||||
### `waf_runtime.lua`
|
||||
|
||||
在 `execute_graph` 增加:
|
||||
|
||||
```lua
|
||||
elseif node.type == "ua_check" then
|
||||
handle = matches_ua_check(node.config) and "true" or "false"
|
||||
```
|
||||
|
||||
实现 `matches_ua_check` + 本地 classify 函数;读取 `ngx.var.http_user_agent`。
|
||||
|
||||
### `waf_runtime_spec.lua`
|
||||
|
||||
覆盖:空 UA + require;bot 屏蔽;abnormal;whitelist and/or;列表空;损坏边 fail-closed。
|
||||
|
||||
## 前端编辑器
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `types.ts` | `ua_check` 变体 + `UACheckConfig` |
|
||||
| `node-factory.ts` | 标签「UA 检查」、默认 config、`AddableNodeType` |
|
||||
| `node-library.tsx` | 拖放项 |
|
||||
| `rule-node.tsx` | 图标 + `true`/`false` handles |
|
||||
| `node-properties.tsx` | 属性 UI(见下) |
|
||||
| `graph-validation.ts` | handles + 标签/mode 校验 |
|
||||
| `editor-behavior.ts` | connection handles |
|
||||
|
||||
### 属性栏布局
|
||||
|
||||
```
|
||||
显示名称
|
||||
── UA 检查 ──
|
||||
[Switch] 开启 UA 检查
|
||||
说明:开启后如果请求头不携带 UA 返回 False
|
||||
── UA 匹配 ──
|
||||
匹配模式 Select: 或(or) / 且(and)
|
||||
浏览器 MultiSelect(封闭枚举)
|
||||
操作系统 MultiSelect(封闭枚举)
|
||||
── 屏蔽 ──
|
||||
说明:命中返回 false,优先级高于匹配
|
||||
[Switch] 屏蔽常见爬虫 UA
|
||||
[Switch] 屏蔽非正常 UA
|
||||
```
|
||||
|
||||
前端选项列表写死与封闭枚举一致;展示可用中文副标题,**写入 config 的值必须是英文标签**(与 analytics / 边缘一致)。
|
||||
|
||||
## 文档
|
||||
|
||||
- 更新 `docs/design/waf-orchestration-design.md` 节点表(中文)。
|
||||
- `docs/changelog/index.md` `[Unreleased]` 增加用户向说明。
|
||||
- 纯设计文档不写 changelog 以外的英文同步。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 编辑器可拖入 `ua_check`,配置保存再打开一致。
|
||||
- [ ] 图校验拒绝非法标签与非法 `match_mode`。
|
||||
- [ ] 发布后 Agent Lua 按求值顺序分支;spec 全绿。
|
||||
- [ ] 样例 UA 分类与访问日志 `ParseBrowserName`/`ParseOSName` 一致。
|
||||
- [ ] `make code-check` 与相关 Go/前端/Lua 测试通过。
|
||||
|
||||
## 风险与缓解
|
||||
|
||||
| 风险 | 缓解 |
|
||||
|------|------|
|
||||
| Go/Lua 分类漂移 | 共享样例表单测对拍 |
|
||||
| 「非正常」过严误伤 | 产品定义为 Bot/Other/Unknown;可关 switch |
|
||||
| 白名单 + or 过宽 | UI 说明 and/or;默认 or 且列表空不限制 |
|
||||
@@ -1,159 +0,0 @@
|
||||
# 站点级访问频率限制设计
|
||||
|
||||
日期:2026-07-20
|
||||
状态:已评审待实现
|
||||
方案:站点详情 Limits 暴露 `limit_req_per_ip`;渲染时按 effective rate 生成多 `limit_req_zone`,并用站点键隔离 IP 计数
|
||||
|
||||
## 背景
|
||||
|
||||
全局默认已有:
|
||||
|
||||
- `openresty_default_limit_conn_per_server`
|
||||
- `openresty_default_limit_conn_per_ip`
|
||||
- `openresty_default_limit_rate`
|
||||
- `openresty_default_limit_req_per_ip`
|
||||
|
||||
站点级并发/带宽已在「反代站点详情 → 流量限制」中配置,语义为:空/`0` 继承、`-1` 关闭、自定义覆盖。
|
||||
|
||||
请求频率(`limit_req`)后端字段与 merge 已存在,但:
|
||||
|
||||
1. 前端站点详情未暴露 `limit_req_per_ip`
|
||||
2. 渲染侧仅在全局默认非空时输出**单一** `limit_req_zone ... rate=全局值`,站点自定义 rate 无法真正独立生效(nginx 的 rate 写在 zone 上,不能仅靠 location 覆盖)
|
||||
|
||||
## 目标
|
||||
|
||||
1. 在**仅站点详情「流量限制」区块**配置单 IP 请求频率。
|
||||
2. 语义与现有三项一致:空/`0` 继承全局;`-1` 关闭;合法 `Nr/s` / `Nr/m` 为站点自定义。
|
||||
3. 站点自定义 rate **真正按该 rate 生效**(A 站 5r/s、B 站 10r/s 互不影响)。
|
||||
4. 同 IP 在不同站点的频率配额**按站点隔离**。
|
||||
5. 修改后仍需发布配置版本;Agent 使用与 Server 同源的 render 路径。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 在「安全性 → 限流」页增加按站点列表编辑
|
||||
- 新建站点表单中的频率字段
|
||||
- 按路径 / URI 差异化频率限制
|
||||
- 改变 `limit_conn_*` / `limit_rate` 的现有 zone 与合并模型
|
||||
- 业务 Zone(顶级域 + 二级域名资源)模型变更
|
||||
|
||||
## 语义
|
||||
|
||||
### 站点字段 `limit_req_per_ip`(字符串)
|
||||
|
||||
| 值 | 含义 |
|
||||
|----|------|
|
||||
| 空 / `"0"` | 继承全局 `openresty_default_limit_req_per_ip` |
|
||||
| `"-1"` | 本站显式关闭频率限制 |
|
||||
| `^\d+r/[sm]$`(大小写不敏感,存小写) | 本站自定义 rate |
|
||||
|
||||
### 全局默认
|
||||
|
||||
| 值 | 含义 |
|
||||
|----|------|
|
||||
| 空 / `"0"` | 默认关闭;继承方亦不输出 `limit_req` |
|
||||
| 合法 rate | 未配置站点的 effective rate |
|
||||
|
||||
### 合并(与现有 `mergeLimitRate` 一致)
|
||||
|
||||
```
|
||||
if route == -1: effective = off
|
||||
else if route is set: effective = route // 合法 rate
|
||||
else: effective = global // route 空/0
|
||||
// global 空/0 → off
|
||||
```
|
||||
|
||||
## 渲染
|
||||
|
||||
### 问题
|
||||
|
||||
nginx `limit_req_zone` 的 `rate=` 在 zone 声明时固定;多个站点若 effective rate 不同,必须使用不同 zone。
|
||||
|
||||
### 步骤
|
||||
|
||||
1. 在 `RenderRouteConfig` / main 配置生成前,对全部 route 计算 effective `LimitReqPerIP`。
|
||||
2. 收集非空 effective rate 的**去重集合**,在 `http {}`(`renderOpenRestyLimitZoneBlock` 扩展,需能访问 routes 或 precomputed rates)输出:
|
||||
|
||||
```nginx
|
||||
# 变量键:站点名 + IP,保证跨站点计数隔离
|
||||
# 实现可用 map 或在 server 内 set 后引用;zone key 采用组合键
|
||||
limit_req_zone $openflare_req_key zone=openflare_req_<rate_token>:10m rate=<rate>;
|
||||
```
|
||||
|
||||
`rate_token` 由 rate 规范化生成(如 `10r/s` → `10rs`,`100r/m` → `100rm`),仅作 zone 名片段,合法 nginx zone 名。
|
||||
|
||||
3. 每个业务 server 在 access 相关位置之前设置:
|
||||
|
||||
```nginx
|
||||
set $openflare_req_key "$openflare_waf_site$binary_remote_addr";
|
||||
```
|
||||
|
||||
(与现有 `set $openflare_waf_site "..."` 同源 site_name;若某 server 无 waf site 变量则用同一 displayName/site_name。)
|
||||
|
||||
4. `renderRouteLimitBlock` 在 effective rate 非空时输出:
|
||||
|
||||
```nginx
|
||||
limit_req zone=openflare_req_<rate_token> burst=<calculateBurst> nodelay;
|
||||
limit_req_status 429;
|
||||
```
|
||||
|
||||
5. **无任何** effective rate 时:不输出任何 `limit_req_zone` / `limit_req`(避免引用不存在的 zone)。
|
||||
|
||||
6. 应用范围与现有 limit 块一致:HTTP/HTTPS 反代 `location /`、Pages 相关 location;不含 HTTP→HTTPS 重定向-only server。
|
||||
|
||||
### 与旧行为差异
|
||||
|
||||
| 项 | 旧 | 新 |
|
||||
|----|----|----|
|
||||
| zone 数量 | 全局最多 1 个 | 按不同 effective rate 多个 |
|
||||
| zone key | `$binary_remote_addr` | `$openflare_req_key`(站点+IP) |
|
||||
| 站点自定义 rate | 无法真正独立 | 引用对应 rate 的 zone |
|
||||
|
||||
快照 JSON **仍保留站点原始值**(含空/`-1`),不把 merge 结果写回 route。
|
||||
|
||||
## 数据与 API
|
||||
|
||||
- 列 `of_proxy_routes.limit_req_per_ip` 已存在;无新迁移(若环境已跑过既有迁移)。
|
||||
- API `Input` / `View` 已有字段;normalize / 校验已存在。
|
||||
- 前端类型与详情表单补齐即可。
|
||||
|
||||
## 前端
|
||||
|
||||
仅改站点详情 `limits-section.tsx`:
|
||||
|
||||
- 增加「单 IP 请求频率」输入
|
||||
- 校验:空、`0`、`-1`、或 `^\d+r/[sm]$i`
|
||||
- 规范化:trim + lower;`0` → `""`
|
||||
- `ProxyRouteItem` / `ProxyRouteMutationPayload` 增加 `limit_req_per_ip`
|
||||
- `buildPayloadFromRoute` 带上该字段,避免其它区块保存时丢失
|
||||
|
||||
文案:与并发/带宽一致(空或 0 继承;-1 关闭;例如 10r/s、100r/m 自定义)。
|
||||
|
||||
## Agent / 发布
|
||||
|
||||
- 配置保存后须**发布配置版本**
|
||||
- Agent **本地** `RenderJSON`;必须部署含本设计 render 的 Agent,否则 source 有字段但 conf 无指令
|
||||
- 若 Agent 已记录同 version/checksum,升级二进制后需触发重新 apply(重启或强制重同步)
|
||||
|
||||
## 测试
|
||||
|
||||
- `mergeRouteLimitConfig`:继承 / 覆盖 / `-1`(已有则补 rate 断言)
|
||||
- 多站点不同 effective rate:main conf 含多个 `limit_req_zone`,各 location 引用正确 zone 名
|
||||
- 全关闭:无 `limit_req` 相关指令
|
||||
- 仅全局有值:一个 zone + 未自定义站点引用该 zone
|
||||
- 前端类型与表单校验(手工或既有模式)
|
||||
|
||||
## 验收
|
||||
|
||||
1. 全局 `10r/s`,站点空 → 该站 location 有 limit_req,zone rate=10r/s
|
||||
2. 站点改 `5r/s` 并发布 → 该站引用 5r/s zone
|
||||
3. 站点 `-1` → 该站无 limit_req
|
||||
4. 两站不同 rate,同 IP 压测互不抢同一配额
|
||||
|
||||
## 实现边界
|
||||
|
||||
| 层 | 工作量 |
|
||||
|----|--------|
|
||||
| 渲染 `pkg/render/openresty` | 多 zone + 站点键 + location 引用 |
|
||||
| 前端详情 Limits + types + payload | 补字段 |
|
||||
| 后端 API/DB | 已具备,仅回归 |
|
||||
| 文档/changelog | 用户可见变更记中文 changelog |
|
||||
@@ -1,253 +0,0 @@
|
||||
# Frontend i18n Design
|
||||
|
||||
Date: 2026-07-24
|
||||
Status: Implemented in OpenFlare (ported from Wavelet 1625cfb, extended to product console)
|
||||
Scope: Frontend UI only
|
||||
|
||||
## 1. Goals
|
||||
|
||||
Add bilingual UI support for Wavelet frontend:
|
||||
|
||||
- Languages: `zh-CN` and `en`
|
||||
- Default locale: `zh-CN`
|
||||
- Locale resolution: explicit user choice → browser language → default
|
||||
- Phase 1: infrastructure + core paths only (layout / auth / settings)
|
||||
- Must remain compatible with `NEXT_STANDALONE_EXPORT` static export
|
||||
|
||||
### Non-goals (Phase 1)
|
||||
|
||||
- Backend API error / message localization
|
||||
- Email / push notification localization
|
||||
- URL locale prefixes (`/en/...`, `/zh-CN/...`) and SEO hreflang
|
||||
- Full translation of all admin business pages
|
||||
|
||||
## 2. Context
|
||||
|
||||
Current state:
|
||||
|
||||
- Root layout hardcodes `lang='zh-CN'`
|
||||
- UI copy is mostly Chinese string literals across many TSX files
|
||||
- Date formatting often hardcodes `zh-CN` / `date-fns/locale` `zhCN`
|
||||
- No i18n library is installed
|
||||
- Frontend supports both normal Next rewrites mode and static export embed mode
|
||||
|
||||
## 3. Approach
|
||||
|
||||
Use **next-intl in non-routing / provider mode**.
|
||||
|
||||
Why this approach:
|
||||
|
||||
- Mature App Router integration and clear `useTranslations` API
|
||||
- ICU message format ready when needed
|
||||
- Avoids locale-prefixed routing, which conflicts with static-export simplicity and current route structure
|
||||
- Cookie + browser detection matches product preference without SEO path requirements
|
||||
|
||||
Rejected alternatives:
|
||||
|
||||
- Fully custom Context + JSON: lower dependency cost, but reimplements interpolation/plurals/type safety poorly
|
||||
- `i18next` + `react-i18next`: powerful, but heavier and less natural for this Next App Router setup
|
||||
|
||||
## 4. Architecture
|
||||
|
||||
```
|
||||
RootLayout
|
||||
html lang={locale}
|
||||
ThemeProvider
|
||||
CustomThemeProvider
|
||||
AppQueryProvider
|
||||
NextIntlClientProvider(locale, messages)
|
||||
existing User / Notification / Bell providers
|
||||
pages + components
|
||||
```
|
||||
|
||||
### Key files
|
||||
|
||||
| Path | Responsibility |
|
||||
| --- | --- |
|
||||
| `frontend/i18n/config.ts` | Supported locales, default locale, cookie name, normalize helpers |
|
||||
| `frontend/i18n/request.ts` | `getRequestConfig` for server-side locale/messages resolution when not in export mode |
|
||||
| `frontend/i18n/client.ts` | Client helpers to read/write locale preference |
|
||||
| `frontend/messages/zh-CN.json` | Chinese messages |
|
||||
| `frontend/messages/en.json` | English messages |
|
||||
| `frontend/components/common/language-switcher.tsx` (or under `layout/`) | Language switch UI |
|
||||
| `frontend/lib/i18n-format.ts` (optional location under `i18n/`) | Locale-aware date/number formatting helpers |
|
||||
|
||||
### Runtime flow
|
||||
|
||||
1. Resolve locale: cookie `NEXT_LOCALE` → browser languages → `zh-CN`
|
||||
2. Load `messages/{locale}.json`
|
||||
3. Provide locale + messages through `NextIntlClientProvider`
|
||||
4. Components call `useTranslations('<namespace>')`
|
||||
5. Language switcher writes cookie and refreshes locale/messages
|
||||
6. Update `document.documentElement.lang`
|
||||
|
||||
## 5. Locale Resolution
|
||||
|
||||
Supported locales: `zh-CN`, `en`
|
||||
|
||||
Normalization:
|
||||
|
||||
- `zh`, `zh-CN`, `zh-Hans*` → `zh-CN`
|
||||
- `en`, `en-US`, `en-GB`, other `en-*` → `en`
|
||||
- anything else → `zh-CN`
|
||||
|
||||
Priority:
|
||||
|
||||
1. User explicit choice stored in cookie `NEXT_LOCALE`
|
||||
2. Browser language (`Accept-Language` on server, `navigator.languages` on client)
|
||||
3. Default `zh-CN`
|
||||
|
||||
Invalid cookie values are normalized to a supported locale and may be rewritten to a valid value.
|
||||
|
||||
## 6. Static Export Compatibility
|
||||
|
||||
Constraints:
|
||||
|
||||
- No locale-segment routes
|
||||
- No middleware-based locale rewriting required for correctness
|
||||
- `build:embed` (`NEXT_STANDALONE_EXPORT=true`) must continue to work
|
||||
|
||||
Behavior:
|
||||
|
||||
- **Normal SSR/dev**: resolve locale on server when possible to reduce first-paint language flash
|
||||
- **Static export**: ship both message catalogs; resolve on client from cookie/browser; accept a brief default-language flash similar to theme hydration, using existing `suppressHydrationWarning` patterns where needed
|
||||
|
||||
## 7. Message Organization
|
||||
|
||||
Single catalog files with nested namespaces:
|
||||
|
||||
```json
|
||||
{
|
||||
"common": {
|
||||
"save": "保存",
|
||||
"cancel": "取消",
|
||||
"loading": "加载中..."
|
||||
},
|
||||
"layout": {
|
||||
"nav": {
|
||||
"home": "首页",
|
||||
"myFiles": "我的文件"
|
||||
},
|
||||
"userMenu": {
|
||||
"settings": "设置",
|
||||
"logout": "退出登录"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"login": {
|
||||
"title": "登录",
|
||||
"submit": "登录"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"appearance": {
|
||||
"language": "语言",
|
||||
"languageDesc": "选择界面显示语言"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Conventions:
|
||||
|
||||
- Keys use camelCase and hierarchical grouping
|
||||
- Prefer complete phrases as values; avoid assembling sentences in components
|
||||
- Use ICU only when needed (`{name}`, plural forms)
|
||||
- Backend `error_msg` values are shown as-is in Phase 1
|
||||
- Frontend-owned toast / validation copy is translated
|
||||
|
||||
Both locale files must keep the same key tree. A key-alignment check script is recommended.
|
||||
|
||||
## 8. Language Switcher UX
|
||||
|
||||
Placement:
|
||||
|
||||
- Header toolbar near theme controls
|
||||
- Appearance settings page as an explicit preference row
|
||||
|
||||
UI labels for language options use native names and do not themselves translate:
|
||||
|
||||
- `中文`
|
||||
- `English`
|
||||
|
||||
On change:
|
||||
|
||||
1. Persist `NEXT_LOCALE`
|
||||
2. Apply new locale/messages (via refresh or controlled provider update)
|
||||
3. Sync `document.documentElement.lang`
|
||||
4. Preserve unrelated UI state where practical (theme, auth session, sidebar collapse)
|
||||
|
||||
## 9. Phase 1 Migration Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- Install and wire `next-intl`
|
||||
- Message catalogs for core namespaces
|
||||
- Locale resolution + persistence
|
||||
- `LanguageSwitcher`
|
||||
- Translate:
|
||||
- layout shell: sidebar nav/user menu, header accessible labels / titles
|
||||
- auth: login / register / OTP labels, buttons, validation messages
|
||||
- settings: appearance (including language preference), profile, security, notifications, access-token visible copy
|
||||
- Replace date/number hardcoding only where touched by the above paths
|
||||
- Ensure `html lang` reflects active locale
|
||||
|
||||
### Out of scope
|
||||
|
||||
- Remaining admin pages and deep business modules
|
||||
- Backend localization
|
||||
- Route prefixing / SEO alternate links
|
||||
|
||||
Unmigrated pages may remain Chinese hard-coded; mixed-language UI is acceptable during incremental rollout.
|
||||
|
||||
## 10. Formatting Helpers
|
||||
|
||||
Introduce locale-aware helpers for dates/numbers used by migrated surfaces, e.g.:
|
||||
|
||||
- `formatDateTime(value, locale)`
|
||||
- `formatNumber(value, locale)`
|
||||
|
||||
`date-fns` locale objects should follow active locale (`zhCN` / `enUS`) when a migrated component uses them.
|
||||
|
||||
## 11. Error Handling & Fallbacks
|
||||
|
||||
| Case | Behavior |
|
||||
| --- | --- |
|
||||
| Missing message key | Dev warning; do not crash; show key or fallback language value |
|
||||
| Unsupported cookie locale | Normalize to supported locale / default |
|
||||
| Partial migration | Keep hard-coded Chinese on unmigrated screens |
|
||||
| Backend error strings | Display raw `error_msg` |
|
||||
|
||||
## 12. Testing & Acceptance
|
||||
|
||||
Manual:
|
||||
|
||||
1. No cookie + browser Chinese → Chinese UI
|
||||
2. No cookie + browser English → English UI
|
||||
3. Manual switch to English survives refresh
|
||||
4. Manual switch back to Chinese survives refresh
|
||||
5. Core paths (layout/auth/settings) have no major residual hard-coded Chinese UI copy
|
||||
6. `pnpm build` and `pnpm build:embed` both succeed
|
||||
7. Language switch does not break theme, session, or sidebar state
|
||||
|
||||
Automated (recommended):
|
||||
|
||||
- Unit tests for `normalizeLocale` / resolution priority
|
||||
- Script or test asserting `zh-CN.json` and `en.json` key parity
|
||||
|
||||
## 13. Rollout Plan (high level)
|
||||
|
||||
1. Add i18n infrastructure and empty/core message files
|
||||
2. Mount provider and language switcher
|
||||
3. Migrate layout shell copy
|
||||
4. Migrate auth copy
|
||||
5. Migrate settings copy + appearance language control
|
||||
6. Verify SSR and static-export builds
|
||||
7. Document how later pages should adopt `useTranslations`
|
||||
|
||||
## 14. Open Implementation Notes
|
||||
|
||||
- Prefer cookie name `NEXT_LOCALE` unless an existing project cookie convention conflicts during implementation
|
||||
- Prefer minimal surface-area integration with next-intl; avoid introducing locale-based routing APIs that break static export
|
||||
- Keep `internal/util` and backend packages untouched
|
||||
- After implementation, follow repo frontend conventions and existing provider composition style
|
||||
@@ -1,22 +0,0 @@
|
||||
# 源站错误页设计(Spec)
|
||||
|
||||
> 权威正文与产品文档索引见:[docs/design/origin-error-page.md](../../design/origin-error-page.md)
|
||||
> 本文为 brainstorming 流程落库副本,内容与上者保持一致。
|
||||
|
||||
---
|
||||
|
||||
## 摘要
|
||||
|
||||
源站/网关在用户配置的状态码(默认标签 `500-599`,支持 `522` 与 `500-599` 区间)上,返回全局可配置 HTML 错误页,替代当前透传行为。默认 Cloudflare 风格页;可在线自定义 HTML。HTTP **status 保持原错误码**,正文通过 `{{status}}` / `{{host}}` 展示。配置挂在 OpenFlare Option,随配置版本发布;侧栏「网站管理 → 错误页」。可关闭以恢复透传。
|
||||
|
||||
## 方案
|
||||
|
||||
**方案 A(已采纳)**:全局 Option → 配置快照 `ConfigSnapshot` → OpenResty 渲染 `proxy_intercept_errors` + `error_page` + internal location 模板替换。
|
||||
|
||||
非目标:按路由覆盖、上传文件、Pages 路由、改 WAF 自有页。
|
||||
|
||||
## 详细章节
|
||||
|
||||
完整章节(目标、状态码语法、配置模型、边缘渲染、前端、测试、实现清单、决策记录)见:
|
||||
|
||||
**[docs/design/origin-error-page.md](../../design/origin-error-page.md)**
|
||||
@@ -1,177 +0,0 @@
|
||||
# 日志数据库解耦设计(ClickHouse 可选化)
|
||||
|
||||
> 状态:已与用户逐段确认,待用户复核。
|
||||
> 日期:2026-08-08
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
当前系统日志/分析(访问日志、可观测时序)完全绑定 ClickHouse:`internal/repository/analytics` 直接操作 `db.ChConn`/`db.ChDB`,apps 层(`chwriter`、`risk_control`、`admin/logs`、`admin/status`)依赖 `config.ClickHouse.Enabled` 判断可用性。业务流量小、主机性能低时 ClickHouse 负担大。
|
||||
|
||||
目标:
|
||||
|
||||
1. **解耦**:ClickHouse 变为可选项;不启用时,主库(PostgreSQL;禁用时 SQLite)完整承接全部日志功能(写入、查询、聚合、清理)。
|
||||
2. **代码级约束**:上层应用写日志不能直接调用底层库(`analyticsrepo` / `db.ChConn`),用接口 + import-lint 测试保证,而非 AGENTS.md 口头约束。
|
||||
3. **可迁移**:提供用户触发的「切换日志数据库」任务,支持 PostgreSQL/SQLite ↔ ClickHouse 数据迁移。
|
||||
4. **表结构**:CH 日志表迁入 PG/SQLite;CH 保持只有日志表的 SQL 脚本;PG/SQLite 包含全部表。
|
||||
|
||||
## 2. 现状要点
|
||||
|
||||
- 连接:`internal/infra/persistence/clickhouse.go`(`ChConn` 原生批量写 + `ChDB` GORM 查询),`init()` 依据 `clickhouse.enabled`。
|
||||
- 分析域:`internal/repository/analytics/` 直接读写 CH;apps 通过 `batchwriter` 异步 flush(`chwriter`、`risk_control`)。
|
||||
- 已有抽象雏形:`internal/repository/openflare_access_log_store.go` / `openflare_observability_store.go` 中的未导出 `accessLogStore` / `observabilityStore` 接口,默认 `clickhouseAccessLogStore{}`,测试可换 memory 实现——默认写死 CH、不可配置切换、接口未导出。
|
||||
- 迁移:主库 goose(`goose/postgres` + `goose/sqlite` 双方言)与 CH 单方言(`goose/clickhouse`)分离。
|
||||
- 历史:PG/SQLite 曾有过 `of_node_metric_snapshots`、`of_node_access_logs` 等观测表(`202606190010_create_of_observability_tables.sql`),后由 `202606200005_drop_of_node_observability_timeseries.sql` 删除(迁去 CH)。**旧 DDL 可复活改造**。
|
||||
- 任务:Asynq + `task.RegisterHandler`/`RegisterTaskMeta`;`system_cleanup`(系统垃圾清理)每日任务已存在;`of_database_auto_cleanup`(可观测清理,schedule id=102)存在。
|
||||
- 系统配置:`system_configs` 表(key/type/visibility),现有 `database_auto_cleanup_enabled` / `database_auto_cleanup_retention_days`(business)。
|
||||
|
||||
## 3. 已确认的核心决策
|
||||
|
||||
| # | 决策 |
|
||||
|---|---|
|
||||
| 1 | 范围:CH 不启用时,PG(或 SQLite)承担**全部**日志功能;聚合在 PG/SQLite 查询时实时计算,不物理建 MV 同构表。 |
|
||||
| 2 | 实现:接口定义在 repository 层;PG 用 GORM 全新实现;CH 保留现有原生批量优化(`PrepareBatch`)包进同一接口。 |
|
||||
| 3 | SQLite 是一等公民:`log_database` ∈ {`postgres`, `sqlite`, `clickhouse`};迁移方向 PG→CH、SQLite→CH、CH→PG、CH→SQLite。 |
|
||||
| 4 | 日志库只有两种合法状态:**随主库**(`database.enabled` → postgres,否则 sqlite)或 **clickhouse**;不存在主库 PG + 日志 SQLite 的组合。 |
|
||||
| 5 | 迁移任务「切换日志数据库」:纯复制、**源数据不删除**、可重试;迁移期间**冻结日志写入**(拒绝,不排队积压);全部成功才翻转主库标记。 |
|
||||
| 6 | 清理统一到 `system_cleanup`(每日一次,日志过期无需实时);保留时间按**存储库**配置(`type=business`)。 |
|
||||
|
||||
## 4. 包结构与接口(方案一)
|
||||
|
||||
新增 `internal/repository/logstore/`,职责唯一:日志存储抽象。
|
||||
|
||||
```
|
||||
internal/repository/logstore/
|
||||
├── logstore.go # 导出接口:AccessLogStore / ObservabilityStore / UserAccessLogStore / CleanupStore / StatusStore
|
||||
├── provider.go # Open(ctx) 按当前日志主库返回实现;ActiveDatabase() 供状态/UI;测试可注入
|
||||
├── postgres_store.go # GORM 实现(PG 与 SQLite 共用一套,方言差异只在 goose DDL + dialect_* 小文件)
|
||||
├── dialect_postgres.go # PG 方言 SQL 片段(date_trunc / FILTER / 分区清理)
|
||||
├── dialect_sqlite.go # SQLite 方言 SQL 片段(strftime / unixepoch)
|
||||
└── clickhouse_store.go # 把现有 analyticsrepo 原生批量 + GORM 查询包进接口(零性能损耗)
|
||||
```
|
||||
|
||||
- **接口划分**(避免 40+ 方法巨型接口,合成 `logstore.Store` 结构体持有):
|
||||
- `AccessLogStore`:节点访问日志的 InsertBatch / List / Count / RegionCounts / BucketAggregates / CountBuckets / BucketDimensions / IPAggregates / IPSummaries / CountIPSummaries / WAFIPAggregates / IPTrend / TrafficSummary / ValueCounts / NodeAggregates / DeleteAll / DeleteBefore / DeleteByNodeBefore。
|
||||
- `ObservabilityStore`:4 表(metric snapshots / edge health / frps / frpc)的 Insert / List / Delete。
|
||||
- `UserAccessLogStore`:`w_user_access_logs` 的 BatchInsert / Count / List / 统计(DailyTrend / BrowserDistribution / TopActiveUsers 等)。
|
||||
- `CleanupStore`:按保留天数清理过期数据(PG=分区 DROP + 分批 DELETE;SQLite=分批 DELETE;CH=MODIFY TTL + materialize)。
|
||||
- `StatusStore`:当前库状态、CH 运行指标(激活时)、GORM 写入器状态。
|
||||
- **消费面**:`internal/repository` 现有公开函数(`ListOpenFlareAccessLogs`、`InsertOpenFlareAccessLogsBatch`、`InsertOpenFlareMetricSnapshot` 等)**保留签名、改为一行委托 `logstore`**,apps 调用面几乎不动;apps 里现有 `analyticsrepo` 直连(`risk_control`、`chwriter`、`tasks/database_cleanup.go`、`observability/access_log_logics.go`、`admin/logs`、`admin/status`)全部改走 repository/logstore。
|
||||
- **import-lint 测试**:新增 `go test`,扫描 `internal/apps/**` 的 import,发现 `internal/repository/analytics` 或 `internal/infra/persistence`(`batchwriter` 白名单除外)即失败。这是「代码层面规避」的验收。
|
||||
- `analyticsrepo` 保留,仅被 `logstore/clickhouse_store.go` 引用(CH 实现细节)。
|
||||
|
||||
### 主库标记与启动校验
|
||||
|
||||
- `system_configs` 新增内部 key:
|
||||
- `log_database`(`postgres`/`sqlite`/`clickhouse`):当前日志主库,仅迁移任务写入。
|
||||
- `log_db_migration`(`"migrating"`/空):迁移冻结标记,仅迁移任务写入。
|
||||
- **首次 seed**(bootstrap Go 侧,因依赖运行时主库选择):key 缺失时,`clickhouse.enabled` → `clickhouse`(保持现状、不丢现有 CH 数据);否则 → 当前主库(`database.enabled` → `postgres`,否则 `sqlite`)。
|
||||
- **启动校验**(bootstrap):
|
||||
- `log_database=clickhouse` 但 `clickhouse.enabled=false` → 启动报错:「当前日志主库为 ClickHouse 但 ClickHouse 未启用。请先重新启用 ClickHouse 配置并启动,在任务管理运行『切换日志数据库』迁移到 PostgreSQL/SQLite 后再禁用 ClickHouse」。
|
||||
- `log_database=postgres` 但 `database.enabled=false`,或 `log_database=sqlite` 但 `database.enabled=true` → 启动报错(违反「随主库或随 CH」规则)。
|
||||
- **key 保护**:`log_database`、`log_db_migration` 在配置更新接口(admin system-configs / option 校验)拒绝修改;仅迁移任务可写;启动校验兜底被篡改组合。
|
||||
- **热切换**:`logstore` 通过系统配置缓存(Redis,更新即失效)读取 `log_database`;翻转后 API 进程自动切到新实现,无需自定义跨进程协议。
|
||||
|
||||
## 5. PG/SQLite 表结构与优化
|
||||
|
||||
**新建原始日志表(PG + SQLite 双方言 goose,同版本号)**——只建原始表,**不建** CH 物化视图/聚合表(`of_access_log_hourly`、`of_node_metric_capacity_hourly` 等),PG/SQLite 查询时实时聚合:
|
||||
|
||||
| 表 | 说明 |
|
||||
|---|---|
|
||||
| `w_user_access_logs` | 用户访问日志 |
|
||||
| `of_node_access_logs` | 节点访问日志(含 user_agent/cache_status/bytes_sent/request_length/request_time_ms 现行列) |
|
||||
| `of_node_metric_snapshots` | 资源指标 |
|
||||
| `of_node_edge_health` | 边缘健康 |
|
||||
| `of_node_obs_frps` | FRPS 观测 |
|
||||
| `of_node_obs_frpc` | FRPC 观测 |
|
||||
|
||||
- **ID**:沿用 snowflake uint64(DDL 用 BIGINT,与 CH UInt64 对齐);不换自增,保证迁移 ID 原样保留、无冲突。
|
||||
- **时间**:PG `TIMESTAMPTZ`;SQLite `DATETIME`。
|
||||
- **复合主键**:分区表主键 `(id, 时间列)`(满足 PG 分区键进唯一索引要求)。
|
||||
|
||||
### PG 优化
|
||||
|
||||
1. **分区**:仅 `of_node_access_logs`、`w_user_access_logs` 两个高频表用 PG 原生 `PARTITION BY RANGE` **按月分区**;可观测 4 表数据量小,普通表 + 索引。SQLite 无原生分区 → 普通表 + 组合索引(方言差异只留在 goose DDL,运行时 GORM 代码共用)。
|
||||
2. **批量写入**:PG/SQLite 统一 GORM `CreateInBatches`(批次 500–1000);CH 维持原生 `PrepareBatch`。
|
||||
3. **索引**:
|
||||
- `of_node_access_logs`:`(logged_at DESC)`、`(node_id, logged_at DESC)`、`(host, logged_at DESC)`;
|
||||
- `w_user_access_logs`:`(created_at DESC)`、`(user_id, created_at DESC)`;
|
||||
- 可观测表:`(node_id, captured_at DESC)`。
|
||||
4. **聚合查询重写**:PG 用 `date_trunc` / `count(DISTINCT)` / `FILTER (WHERE ...)` 等价替换 CH 的 `toStartOfHour` / `uniqExact` / `countIf`;SQLite 用 `strftime` / `unixepoch`。时间分桶等少量方言 SQL 拆到 `dialect_postgres.go` / `dialect_sqlite.go`,store 主体方言中立。
|
||||
|
||||
### goose 迁移
|
||||
|
||||
- PG/SQLite 各新增一组建表迁移(复活并改造 `202606190010` 旧 DDL,按 database-migration 技能双方言、同版本号规则)。
|
||||
- CH 目录不动(本来就只有日志表脚本,满足「CH 保持只有日志表 SQL」)。
|
||||
|
||||
## 6. 清理(并入 system_cleanup)
|
||||
|
||||
- 日志过期清理并入 `system_cleanup`(系统垃圾清理)每日任务;`of_database_auto_cleanup` 专用 schedule(id=102)与任务下线。
|
||||
- 新增 `type=business` 配置(替换旧 `database_auto_cleanup_enabled` / `database_auto_cleanup_retention_days`):
|
||||
- `log_retention_days_postgres`(默认 90)
|
||||
- `log_retention_days_sqlite`(默认 90)
|
||||
- `log_retention_days_clickhouse`(默认 90)
|
||||
- `CleanupStore` 按当前生效库读取对应值执行:
|
||||
- PG:分区 DROP(整月)+ 分批 DELETE(不满月);
|
||||
- SQLite:分批 DELETE;
|
||||
- CH:`ALTER TABLE ... MODIFY TTL toDateTime(...) + INTERVAL N DAY` + materialize(保留期由配置驱动,不再依赖 DDL 写死)。
|
||||
- 旧 key `database_auto_cleanup_*` 由 goose 迁移删除,前端同步清理。
|
||||
|
||||
## 7. 迁移任务「切换日志数据库」
|
||||
|
||||
**元数据**:Asynq `openflare:log_db_switch`,管理类型 `of_log_db_switch`,名称「切换日志数据库」,参数 `target`(`postgres`/`sqlite`/`clickhouse`),`Retryable: true`。UI 按当前日志主库只展示合法目标(当前=CH → 「主库」;当前=主库 → 「ClickHouse」)。
|
||||
|
||||
**执行流程(worker 进程)**:
|
||||
|
||||
1. **校验**:`target == 当前主库` → 拒绝;`target=clickhouse` 但 CH 未启用 / `target=postgres` 但 `database.enabled=false` / `target=sqlite` 但 `database.enabled=true` → 拒绝。
|
||||
2. **写冻结**:写 `log_db_migration = "migrating"`;先让 batchwriter 把在途批次 flush 完;此后 API 进程所有日志写入路径(`risk_control`、`chwriter` 队列、agent 上报落库)检查该 key → 返回明确错误(HTTP 503「日志数据库迁移中,暂不可写」),不排队积压。
|
||||
3. **复制**:6 张原始日志表逐表、按 id 分批(每批 ~1000)读源 → 写目标(CH→主库用 GORM `CreateInBatches`;主库→CH 用原生 `PrepareBatch`);ID 原样保留;每表/每批 `task.AppendLog` 进度。
|
||||
- **幂等前提**:开始复制前**清空目标库日志表**(任务参数「覆盖目标库已有日志」默认开启;目标库通常为空,仅「切回去」场景有旧数据)——保证失败重试可重跑不重复。
|
||||
4. **翻转**:全部成功 → 更新 `log_database = target`、清除迁移标记 → `logstore` 缓存失效自动切到新实现 → 写入恢复(走新库)。
|
||||
5. **失败**:返回错误触发 Asynq 重试;**失败时清除迁移标记**,写入继续走源库(不丢功能);重试时重新清空目标 + 复制。
|
||||
|
||||
**双进程一致性**:迁移标记与主库标记落在 `system_configs`(Redis 缓存,worker 更新后 API 进程自动失效重读)。
|
||||
|
||||
## 8. API 与前端
|
||||
|
||||
**后端**:
|
||||
|
||||
- `GET /api/v1/admin/status/log-database`(改造现有 `/clickhouse` 状态端点):返回当前日志主库、迁移状态(`idle`/`migrating`)、各库保留天数、当前合法迁移目标;CH 为主时附带现有 CH 运行指标,主库为主时附带 GORM 写入器状态。
|
||||
- 任务「切换日志数据库」走现有任务管理通用派发 API(`RegisterTaskMeta` + Params),无需新派发接口;执行记录/进度复用任务框架。
|
||||
- 系统配置:新增 3 个 `log_retention_days_*`(business)图形化 + 参数表可见;新增内部 `log_database`、`log_db_migration`(system、visibility=0、受保护);下线 `database_auto_cleanup_*`。
|
||||
|
||||
**前端**:
|
||||
|
||||
- 任务管理页:出现「切换日志数据库」,参数下拉只显示合法目标;页面展示当前日志主库与迁移状态。
|
||||
- `/admin/settings` 业务配置:新增「日志保留时间」分组(PG/SQLite/CH 三个数字输入)。
|
||||
- 状态/仪表盘:日志库状态卡片(当前库 + 迁移中提示)。
|
||||
|
||||
## 9. 测试与验证
|
||||
|
||||
- **import-lint 测试**:`internal/apps/**` 不得 import `internal/repository/analytics`、`internal/infra/persistence`(`batchwriter` 白名单除外),违规即失败。
|
||||
- **logstore 单测**:GORM 实现用 SQLite 全量跑;PG 专属(分区 DROP 等)走既有集成测试路径;CH 实现复用现有 analyticsrepo 测试。
|
||||
- **迁移任务测试**:目标/组合校验、批处理与 ID 保留、清空目标、翻转标记、失败清标记回退、冻结期写入拒绝——用 memory/sqlite 双端模拟,不依赖真实 CH。
|
||||
- **清理测试**:`system_cleanup` 日志清理步骤(PG 分区 DROP / SQLite 分批 DELETE / CH TTL 修改)与保留配置读取。
|
||||
- **迁移验证**:goose 空库 Up 全量(PG/SQLite/CH 三套)、`go test ./...`、`make swagger`(API 变更)、`make code-check`、`make format`。
|
||||
|
||||
## 10. 非目标(YAGNI)
|
||||
|
||||
- 不在 PG/SQLite 物理建聚合/物化视图表(查询实时聚合)。
|
||||
- 不做 PG ↔ SQLite 日志互迁(非法组合,启动校验拒绝)。
|
||||
- 迁移成功不自动删除源库数据(保留,后续提供手动清理入口)。
|
||||
- 不引入 PG COPY 协议(GORM `CreateInBatches` 对低流量足够)。
|
||||
- 不引入自定义跨进程迁移协议(`system_configs` + Redis 缓存即可)。
|
||||
|
||||
## 11. 里程碑建议(供实现计划分解)
|
||||
|
||||
1. **M1 抽象与改造**:`logstore` 接口 + PG/SQLite 实现 + `clickhouse_store` 包装 + import-lint 测试 + repository 委托改造 + apps 直连改造 + `log_database`/`log_db_migration` key 与启动校验。
|
||||
2. **M2 表与清理**:goose 双方言建表迁移 + 保留配置 key + `system_cleanup` 日志清理步骤 + 下线 `of_database_auto_cleanup` 与旧配置。
|
||||
3. **M3 迁移任务与展示**:迁移任务 Handler + 状态端点 + 任务管理页/业务配置前端 + 日志库状态卡片。
|
||||
4. **M4 收尾**:全量验证(goose 三套、单测、`make code-check`/`swagger`/`format`)、文档同步(中文)、changelog `[Unreleased]`。
|
||||
|
||||
## 12. 实现归档说明(Task 18,2026-08-08)
|
||||
|
||||
- 设计稿第 4 节 provider 入口写作 `Open(ctx)`,实现命名为 `Active(ctx)`(按 `log_database` 解析并缓存,配置翻转后重建),另导出 `Build(ctx, database)` / `BuildForMigration(ctx, database)` 供迁移任务构造目标库 store;`ActiveDatabase(ctx)` 供状态端点。
|
||||
- 设计稿第 4 节列出的 `CleanupStore` 接口未单独落地:清理实现为包级 `CleanupExpired(ctx)`(按当前激活库保留天数删除过期日志并预建 PG 分区),由 `system_cleanup` 每日任务调用。
|
||||
- 设计稿第 4 节列举的 `tasks/database_cleanup.go` 已随 M2 下线(`of_database_auto_cleanup` 配置与前端 UI 一并移除),日志清理职责并入 `system_cleanup`。
|
||||
- 迁移复制按 id 升序分页,`copyObservability` 以每批最后一条 id 作为下一批游标(修正计划中 `lastID += n` 的近似写法);失败回退由 `defer setMigrationFlag("")` 保证源库恢复可写,重试前先清空目标库保证幂等。
|
||||
- 其余实现决策(`SetConfigReader` 注入、`ensureWritable` 统一冻结、解析 helper 迁至 `model/analytics` 等)见计划「自检记录」,与本文档一致。
|
||||
@@ -1,117 +0,0 @@
|
||||
# Service Worker 离线兜底设计(issue #23)
|
||||
|
||||
- 日期:2026-08-08
|
||||
- 状态:设计已确认
|
||||
- 范围:Proxy Route(反代)+ Pages 静态托管 全覆盖
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
当 CDN 域名被墙、浏览器对所有网络请求失败时,用户会直接流失。本功能通过给网站下发 Service Worker,缓存一个"联系站长"离线页;域名被墙后,SW 从缓存吐出该页,保留用户并引导联系站长。
|
||||
|
||||
核心约束:
|
||||
|
||||
- 平台一键批量下发,避免逐个 Agent 配置。
|
||||
- 不改源页代码,全部在 OpenResty 边缘层完成。
|
||||
- 覆盖反代(Proxy Route)与 Pages 静态托管两种网站类型。
|
||||
|
||||
## 2. 机制总览
|
||||
|
||||
采用「首次挑战页 + Cookie 放行 + UA 白名单」模式,替代 `sub_filter` 响应体重写。
|
||||
|
||||
| 环节 | 行为 |
|
||||
|---|---|
|
||||
| 真实浏览器 UA(含特征版本,如 `Chrome/120`)首次访问首页 | 返回 SW 挑战页(内嵌 `register('/sw.js')` 与离线页预缓存),设置长过期 Cookie |
|
||||
| 带 Cookie 的请求 | 直接放行到上游,正常返回真实页面 |
|
||||
| 未知 UA(爬虫、curl,无真实浏览器特征) | 直接放过,交给 WAF 处理,拿到真实内容 |
|
||||
|
||||
### 为什么不用 sub_filter
|
||||
|
||||
`sub_filter` 需处理上游 gzip / Content-Type / 大响应扫描 / 流式缓冲等多处坑。本方案不改上游 body,整体替换首次响应,以上问题全部规避;且爬虫(不匹配真实浏览器 UA)天然绕过挑战页,不伤 SEO。
|
||||
|
||||
## 3. 分层职责
|
||||
|
||||
```
|
||||
apps/proxy_route ─┐
|
||||
apps/pages ─┼─ model → repository → 渲染(pkg/render/openresty) → Agent(OpenResty)
|
||||
前端设置卡 ─┘ ↑ SW 挑战页 + sw.js/offline 落盘
|
||||
```
|
||||
|
||||
### 后端数据(全局 Option,与 origin error page 同模式)
|
||||
|
||||
`sw_offline` 相关配置作为**全局 SystemConfig / OpenRestyConfig snapshot 字段**,对所有启用 HTTPS 的路由生效,实现"一键批量下发"。新增字段:
|
||||
|
||||
- `sw_offline_enabled`:是否启用 SW 离线兜底
|
||||
- `sw_offline_html`:联系站长离线页 HTML 内容(默认提供内置模板)
|
||||
|
||||
### 渲染层(`pkg/render/openresty`)
|
||||
|
||||
新增 `renderServiceWorkerChallenger(cfg ConfigSnapshot)` 工具,为真实提供内容的 HTTPS server 块(`sw_offline_enabled` 且 `EnableHTTPS` 时)输出:
|
||||
|
||||
```nginx
|
||||
# SW 脚本 + 离线页(作为 support file 落盘)
|
||||
location = /sw.js { alias .../sw.js; add_header Service-Worker-Allowed /; }
|
||||
location = /offline.html { alias .../offline.html; }
|
||||
|
||||
# 仅首页拦截:真实浏览器 UA 且无 cookie → 返回 SW 挑战页
|
||||
# 否则(带 cookie / 未知 UA)→ 放行到上游
|
||||
location = / {
|
||||
if (真实浏览器UA && 无cookie) { content_by_lua 返回 SW 挑战页; }
|
||||
放行到上游;
|
||||
}
|
||||
```
|
||||
|
||||
- SW 逻辑:`install` 阶段缓存 `/offline.html`;`fetch` 事件在网络失败时返回 `caches.match('/offline.html')`。
|
||||
- 仅在 `EnableHTTPS` 时注入(SW 要求 HTTPS 安全上下文)。
|
||||
- 多域名 server 块:`/sw.js`、`/offline.html`、挑战页在各 `server_name` 下同源可达。
|
||||
- 仅对首页 `location = /` 触发;js/css/图片/API/子页面请求不拦,零额外开销。
|
||||
|
||||
## 4. 数据流
|
||||
|
||||
```
|
||||
用户首次访问首页(真实UA, 无cookie)
|
||||
→ OpenResty 判断:真实UA && 无cookie
|
||||
→ 返回 SW 挑战页 (内嵌 register + 预缓存 offline.html)
|
||||
→ 浏览器执行 → 注册 SW → 设置长过期 cookie
|
||||
→ 用户再次请求(带cookie)
|
||||
→ 放行到上游,正常返回真实页面
|
||||
域名被墙后
|
||||
→ 所有请求失败 → SW fetch 兜底 → 从缓存返回 /offline.html(联系页)
|
||||
```
|
||||
|
||||
## 5. 边界与风险
|
||||
|
||||
| 项 | 处理 |
|
||||
|---|---|
|
||||
| 首次即被墙的用户 | SW 未注册,兜底无效(所有 SW 方案共性,接受) |
|
||||
| HTTP-only 站点 | 跳过注入(SW 需 HTTPS) |
|
||||
| 反代多域名 | 各域名同源提供 sw.js / offline.html / 挑战页 |
|
||||
| Cookie 过期 | 设长过期(约 1 年),过期后重新走一次挑战页 |
|
||||
| 未知 UA | 放过并交给 WAF 处理,不重复拦截 |
|
||||
| 资源/API 请求 | 不拦,仅首页触发 |
|
||||
|
||||
## 6. 测试
|
||||
|
||||
- 渲染层单元测试:
|
||||
- `sw_offline_enabled` 时输出 sw.js / offline.html / 挑战页 location
|
||||
- 非 HTTPS 或未启用时不输出
|
||||
- 仅首页触发,子路径/资源不触发
|
||||
- UA 判定:真实浏览器 / 爬虫 / curl 三种 UA 的放行分支。
|
||||
- Cookie 有无的放行分支。
|
||||
- 现有 config snapshot checksum / rebind 测试不回归。
|
||||
|
||||
## 7. 前端命名与入口
|
||||
|
||||
离线联系页设置与现有 origin error page 设置合并为同一个功能模块,命名为**「响应页面」**(路由 `responses`),内含两个 tab:
|
||||
|
||||
- **错误页设置**:源站错误兜底页(现有 origin error page)
|
||||
- **联系页设置**:SW 离线兜底联系页(本功能)
|
||||
|
||||
两者同属「边缘层兜底展示页」语义,统一管理与入口。
|
||||
|
||||
## 8. 待实现确认项(写 plan 时细化)
|
||||
|
||||
- SW 挑战页与 sw.js 的具体 Lua 实现与落盘路径(对齐现有 support file 机制)。
|
||||
- `sw_offline_html` 默认内置模板样式(参考 origin error page 内置模板)。
|
||||
- 「响应页面」前端模块下错误页/联系页两个 tab 的具体位置与交互。
|
||||
- UA 白名单默认真实浏览器特征集合(Chrome / Firefox / Safari / Edge + 版本号正则)。
|
||||
- SW 落盘路径:sw.js / offline.html 通过 SupportFile 下发,Agent 替换占位符(类似 ErrorPageTmplPlaceholder 机制)。
|
||||
@@ -1,191 +0,0 @@
|
||||
# SW 离线兜底生效范围(域名作用域)设计
|
||||
|
||||
- 日期:2026-08-08
|
||||
- 状态:设计已确认
|
||||
- 前置:issue #23 Service Worker 离线兜底(`docs/superpowers/specs/2026-08-08-service-worker-offline-design.md`)
|
||||
- 范围:SW 注入从「全局所有 HTTPS 站点」细化为「总开关 + 域名作用域」
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
issue #23 实现后,`sw_offline_enabled` 为全局布尔开关:开启后对所有启用 HTTPS 的路由注入 Service Worker 离线兜底。本需求将其细化为可选的**生效域名范围**:
|
||||
|
||||
- 保留总开关(`sw_offline_enabled`)。
|
||||
- 新增作用域:管理员选择需要生效的域名,仅作用域内域名注入 SW。
|
||||
- 域名选择交互参考 `/cloudflare/groups/1` 的「添加域名成员」弹窗(搜索筛选、按 Zone 分组、批量勾选),但**与 Cloudflare 完全解耦**——仅复用交互模式,数据源为平台自身 zones/zone_domains,不涉及 A 记录同步。
|
||||
|
||||
核心约束:
|
||||
|
||||
- 语义为「总开关 && 域名 ∈ 作用域」交集:总开关关 → 全部不注入;总开关开 + 作用域空 → 不注入;总开关开 + 域名命中 → 注入。
|
||||
- 与 Cloudflare 指向分组(A 记录)无任何关联。
|
||||
- 联系页 HTML(`sw_offline_html`)仍为全局单份,不分域名定制。
|
||||
|
||||
## 2. 机制总览
|
||||
|
||||
```
|
||||
sw_offline_enabled (bool, 已有) 总开关
|
||||
sw_offline_html (string, 已有) 联系页 HTML(全局一份)
|
||||
sw_offline_domains (JSON 字符串数组, 新增) 生效域名作用域
|
||||
|
||||
渲染: routeSWEnabled(routeDomains, cfg)
|
||||
= SWOfflineEnabled && routeDomains ∩ SWOfflineDomains ≠ ∅
|
||||
命中 → HTTPS server 块注入 access 检查 + SW location
|
||||
未命中 → 与 feature 前字节一致
|
||||
```
|
||||
|
||||
Support files(`sw/sw.js`、`sw/offline.html`)仅在「总开关开 && 作用域非空」时下发,避免空作用域产生无用资源。
|
||||
|
||||
## 3. 数据层
|
||||
|
||||
### 3.1 配置 key
|
||||
|
||||
`model.ConfigKeySWOfflineDomains = "sw_offline_domains"`(business 类型,visibility 0),值存 JSON 域名字符串数组:
|
||||
|
||||
```json
|
||||
["example.com", "api.example.com"]
|
||||
```
|
||||
|
||||
### 3.2 goose 迁移(postgres + sqlite 各一份)
|
||||
|
||||
`INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at) VALUES ('sw_offline_domains', '[]', 'business', 0, 'SW 离线兜底生效域名列表(JSON 数组,空则仅总开关无效)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) ON CONFLICT (key) DO NOTHING;`
|
||||
|
||||
Down 删除该 key。migrator 测试计数 92 → 93,并更新注释。
|
||||
|
||||
### 3.3 validator
|
||||
|
||||
`validateSWOfflineDomains(key, value string) error`,注册进 `openRestyOptionValidators`:
|
||||
|
||||
- JSON 解析为 `[]string`,失败报「必须为 JSON 字符串数组」
|
||||
- 元素去重(重复报错)
|
||||
- 元素非空、小写规范化校验(复用/对齐 zone `normalizeDomain` 的域名格式约束:无 `*`、无 `://` `/` `?` `#` `@`、`publicsuffix.EffectiveTLDPlusOne` 可解析)
|
||||
- 数量上限 `maxSWOfflineDomains = 1000`(防滥用)
|
||||
|
||||
### 3.4 config_version snapshot
|
||||
|
||||
- `openRestyConfigSnapshot`(`snapshot.go`)新增 `SWOfflineDomains []string json:"sw_offline_domains,omitempty"`。
|
||||
- `buildOpenRestyConfigSnapshot` 新增 `getStringSliceConfig(key string, defaultVal []string) []string`(解析 JSON 数组,失败回退默认),赋值 `SWOfflineDomains: getStringSliceConfig(model.ConfigKeySWOfflineDomains, nil)`。
|
||||
- `logics.go`:`diffOpenRestyOptionDetails` 追加 `appendIfChanged("SWOfflineDomains", ...)`;`openRestyOptionKeys()` 追加 `"SWOfflineDomains"`。
|
||||
|
||||
## 4. 渲染层(pkg/render/openresty)
|
||||
|
||||
### 4.1 ConfigSnapshot
|
||||
|
||||
`types.go` 的 `ConfigSnapshot` 新增:
|
||||
|
||||
```go
|
||||
// SWOfflineDomains restricts the offline fallback to matching HTTPS routes.
|
||||
SWOfflineDomains []string `json:"sw_offline_domains,omitempty"`
|
||||
```
|
||||
|
||||
### 4.2 作用域判断
|
||||
|
||||
```go
|
||||
// routeSWEnabled returns true when SW offline fallback applies to this route.
|
||||
func routeSWEnabled(routeDomains []string, cfg ConfigSnapshot) bool {
|
||||
if !cfg.SWOfflineEnabled || len(cfg.SWOfflineDomains) == 0 {
|
||||
return false
|
||||
}
|
||||
scope := make(map[string]struct{}, len(cfg.SWOfflineDomains))
|
||||
for _, d := range cfg.SWOfflineDomains {
|
||||
scope[d] = struct{}{}
|
||||
}
|
||||
for _, d := range routeDomains {
|
||||
if _, ok := scope[d]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
```
|
||||
|
||||
域名精确匹配(存储时已小写规范化)。
|
||||
|
||||
### 4.3 server 渲染签名扩展
|
||||
|
||||
- `RenderRouteConfig`:每 route 计算 `swEnabled := routeSWEnabled(domains, doc.OpenRestyConfig)`,传入 `renderProxyRoute` / `renderPagesRoute`(新增 `swEnabled bool` 参数)。
|
||||
- 下传链路:`renderProxyRouteHTTPS` / `renderPagesRouteHTTPS` / `renderHTTPSServer` / `renderHTTPSPagesServer` 均新增 `swEnabled bool` 参数。
|
||||
- `swEnabled=true` → `renderAccessBlockWithSW(siteName, powEnabled, cfg)` + 追加 `renderServiceWorkerChallenger(cfg)`(现行为,两函数内部不再判断 `SWOfflineEnabled`,条件已上移到 route 层)。
|
||||
- `swEnabled=false` → 纯 `renderAccessBlock`,无 challenger(与 feature 前字节一致)。
|
||||
- HTTP(80)server 块保持不注入(issue #23 已定 HTTPS-only)。
|
||||
- `renderAccessBlockWithSW` / `renderServiceWorkerChallenger` 保留 `cfg` 参数(HTML 内容来自 `cfg.SWOfflineHTML`),仅移除其内部开关判断。
|
||||
|
||||
### 4.4 Support files
|
||||
|
||||
`Render` 中生成条件从 `if doc.OpenRestyConfig.SWOfflineEnabled` 改为:
|
||||
|
||||
```go
|
||||
if doc.OpenRestyConfig.SWOfflineEnabled && len(doc.OpenRestyConfig.SWOfflineDomains) > 0 {
|
||||
files = append(files, ServiceWorkerSupportFiles(doc.OpenRestyConfig)...)
|
||||
}
|
||||
```
|
||||
|
||||
### 4.5 测试
|
||||
|
||||
- `routeSWEnabled`:开关关 / 作用域空 / 无交集 / 单域名交集 / 多域名部分交集。
|
||||
- HTTPS server 渲染:命中 → 含 `require("sw.runtime").check()` + 三个 SW location;未命中 → 与旧输出字节一致。
|
||||
- `Render`:空作用域不下发 `sw/*` support files。
|
||||
- 现有 `TestRenderAccessBlockWithSWMergesSingleBlock` 等适配新签名(`cfg` 语义变化:禁用时不再由内部判断,改由上层传 `swEnabled`)。
|
||||
|
||||
## 5. 前端(frontend/app/(main)/responses)
|
||||
|
||||
### 5.1 联系页 tab 布局
|
||||
|
||||
联系页 tab 两张卡片:
|
||||
|
||||
**卡片 1:离线兜底(总开关)**
|
||||
- 标题「离线兜底」+ 描述。
|
||||
- 右上角「保存」按钮。
|
||||
- 「启用 Service Worker 离线兜底」Switch(`sw_offline_enabled`)。
|
||||
- 「生效范围」区块:当前已选域名 badge 列表(可移除)+「添加域名」按钮打开弹窗;开关关闭时整卡禁用/置灰。
|
||||
- 保存时 `updateBatch` 一次性提交三个 key:
|
||||
```ts
|
||||
{ key: KEY_SW_ENABLED, value: String(fields.enabled) },
|
||||
{ key: KEY_SW_HTML, value: fields.html },
|
||||
{ key: KEY_SW_DOMAINS, value: JSON.stringify(fields.domains) },
|
||||
```
|
||||
- 保存成功后 `invalidateResponseQueries`(toast 提示「请前往版本发布使配置生效」不变)。
|
||||
|
||||
**卡片 2:联系页 HTML**
|
||||
- 复用 `HtmlEditorWorkspace`(见 5.3),无占位符,实时预览原样 HTML。
|
||||
|
||||
### 5.2 域名选择弹窗(scope-domain-dialog.tsx)
|
||||
|
||||
- 交互复用 `member-add-dialog.tsx`:搜索框(域名/zone 模糊匹配)、按 Zone 分组折叠、组内勾选/取消、全选可见/清空、已选计数。
|
||||
- 无橙云开关、无 Cloudflare 依赖。
|
||||
- 数据源:`ZoneService.list()` + 每 zone `ZoneService.getOverview(id)` 并行拉取(`Promise.all`),zone 根域并入对应分组。**不新增后端 API**。
|
||||
- 弹窗预勾选当前已生效域名;确认后返回选中的域名字符串数组(覆盖式替换本地 fields.domains)。
|
||||
- 空态:无 zone 时提示「暂无可用域名,请先在 Zone 管理中注册」。
|
||||
|
||||
### 5.3 HtmlEditorWorkspace 复用(泛化)
|
||||
|
||||
`frontend/app/(main)/error-pages/components/html-editor-workspace.tsx` 泛化并移至 `frontend/components/common/html-editor-workspace.tsx`:
|
||||
|
||||
- Props 扩展:
|
||||
- `maxBytes?: number`(默认 `ORIGIN_ERROR_PAGE_HTML_MAX_BYTES` = 256 KiB,SW 同为 256 KiB 常量可共用)
|
||||
- `preview?: (html: string) => string`(默认 `previewOriginErrorPageHTML`;SW 传 `(html) => html` 原样预览)
|
||||
- `footerHint?: React.ReactNode`(预览 footer 提示文案,默认错误页的「`{{status}}`→502 · `{{host}}`→example.com」;SW 传 `null`)
|
||||
- 错误页 `edit/page.tsx` 改 import 路径,行为不变。
|
||||
- `frontend/components/common/` 若不存在则创建目录。
|
||||
|
||||
### 5.4 shared.ts 与表单
|
||||
|
||||
- `KEY_SW_DOMAINS = 'sw_offline_domains'`。
|
||||
- `ContactPageFields` 增加 `domains: string[]`;`defaultContactPageFields.domains = []`。
|
||||
- `mapOptionsToContactFields` 解析 `sw_offline_domains` JSON(容错:非法 JSON → `[]`)。
|
||||
|
||||
## 6. 验证
|
||||
|
||||
- 后端:`go test ./pkg/render/openresty/... ./internal/apps/openflare/option/... ./internal/apps/openflare/config_version/... ./internal/infra/persistence/migrator/...`
|
||||
- 前端:`pnpm tsc --noEmit` + `eslint`(联系页新字段/弹窗/多 zone 并行拉取)
|
||||
- 全量:`go test ./...`、`make code-check`、`make format`
|
||||
- `make swagger`:无新 API(验证无变更即可)
|
||||
|
||||
## 7. Changelog
|
||||
|
||||
`docs/changelog/index.md` `[Unreleased]` 更新 SW 条目:新增「可指定生效域名范围(仅对选中的 HTTPS 域名生效)」。
|
||||
|
||||
## 8. 已知边界
|
||||
|
||||
- 作用域存域名字符串数组:域名从 zone/zone_domain 改名后需手动同步作用域(与 `route.Domains` 精确匹配)。
|
||||
- 联系页 HTML 全局单份,不分域名定制。
|
||||
- 空作用域 + 总开关开 → 不注入(前端置灰提示先选域名)。
|
||||
- 匹配为精确匹配,不跨子域通配(选 `example.com` 不自动覆盖 `api.example.com`,需显式加入)。
|
||||
Reference in New Issue
Block a user