Compare commits

..

13 Commits

Author SHA1 Message Date
ryan 68d730a388 chore(release): v3.4.3
### 🛠 修复
- 修复了 IP 组自动抓取使用预设规则时未写入 ttl 的问题,避免配置缺少封禁时长。
- 修复了限流相关数据库迁移中的表名错误,确保升级脚本正确执行。

### ⚡️ 优化与改进
- 边缘缓存对齐 Cloudflare 默认模型:不再因登录 Cookie 等请求头一律跳过缓存,登录用户可命中静态资源;响应 Set-Cookie 不入库,并补充默认 Edge TTL。生效需重新发布节点配置。
- 新增全局与站点级单 IP 请求频率限制,触发时返回 429,并支持继承、关闭与按站点隔离。
- IP 组自动规则支持 2xx/4xx/5xx 类状态码写法,同步间隔下限降至 1 分钟,回看窗口支持 60m/1h 等时长写法。
- 限流页请求压力图 RPS 纵轴按可见窗口峰值动态缩放,低流量更易读。

### 💄 其他/体验
- 补充边缘缓存运维与故障排查说明,并对「所有可缓存 GET」策略增加风险提示。
2026-07-24 00:04:20 +08:00
ryan f94767fbc7 perf(cache): 边缘缓存对齐 Cloudflare 默认模型 2026-07-23 23:39:15 +08:00
ryan 5b1e27d0a3 feat(frontend): biome 2026-07-22 22:25:30 +08:00
ryan f28aa6520e fix(lint): 消除 linter 告警 2026-07-20 15:53:53 +08:00
ryan d58b4b6b0e feat(waf): 自动 IP 组 lookback 支持 60m/1h 时长写法
将 lookback_minutes 替换为 lookback,移除最小 5 分钟回看限制,并兼容旧字段。
2026-07-20 15:48:16 +08:00
ryan 866f1df5e3 fix(waf): IP 组同步间隔下限改为 1 分钟
移除同步周期 5 分钟限制;回看窗口仍保持最小 5 分钟。
2026-07-20 15:41:31 +08:00
ryan 351e8ce78c feat(waf): StatusRatio/StatusCount 支持 2xx/4xx/5xx 类写法
自动 IP 组表达式可按状态码类汇总占比与计数,兼容原有精确状态码。
2026-07-20 15:39:14 +08:00
ryan 67a30eb5ed fix(waf): IP 组预设规则写入默认 ttl
点击自动抓取预设规则时补齐 ttl=-1,并规范化自动配置默认 JSON。
2026-07-20 15:36:06 +08:00
ryan 80c47f6ff3 feat(rate-limit): 站点级请求频率限制支持继承与自定义
在站点详情流量限制中配置 limit_req_per_ip;渲染按 effective rate 生成多 limit_req_zone,并以站点+IP 隔离计数。
2026-07-20 15:02:38 +08:00
ryan a261c01a9c fix(db): correct table name to of_proxy_routes in migration 2026-07-20 14:06:07 +08:00
ryan ae5345c03e feat(rate-limit): add default request rate limit configuration
- Support openresty_default_limit_req_per_ip in system_configs.
- Add limit_req and limit_req_status 429 directive generation in openresty renderer.
- Implement route-level limit_req_per_ip override and explicit disable.
- Add frontend UI inputs and validation in rate limits tab config.
- Update swagger API docs and changelog for v3.4.3-beta.3.
2026-07-20 10:58:13 +08:00
ryan fda8d7fcb1 fix(rate-limits): 请求压力纵轴随 dataZoom 可见区间缩放
拖动底部时间范围条时按可见窗口最高 RPS×1.5 更新纵轴,访客轴同步按可见数据重算。
2026-07-20 09:02:31 +08:00
ryan b91c848256 fix(rate-limits): RPS 纵轴按峰值 1.5 倍动态缩放
请求压力图不再使用固定美化刻度上限,改为当前时段最高 RPS × 1.5,低流量更易读、高峰不易裁切。
2026-07-20 08:49:46 +08:00
53 changed files with 1896 additions and 355 deletions
+1 -1
View File
@@ -56,7 +56,7 @@
- 所有 HTTP 路由仅在 `internal/router/router.go` 中注册。
- 当 API Handler 发生变化时,更新 Swagger 文档(运行 `make swagger`)。
- 在完成代码开发后必须运行 `make code-check`, 并修复报错。
- 在完成代码开发后或者 git 提交前必须运行 `make prettier` 格式化代码。
- 在完成代码开发后或者 git 提交前必须运行 `make format` 格式化代码。
- 需要缓存或文件管理能力时,必须复用现有平台实现,禁止在业务包中自行创建缓存目录、直接管理缓存文件或重复封装存储后端。
- 文件摄取必须通过 `upload.Ingest`(`upload.PolicyCreate` / `PolicyDedupNewRecord` / `PolicyResolveExisting`);删除必须通过 `upload.Remove` 或 `upload.RemoveOwned`。禁止业务模块直接调用 `repository.CreateUpload` / `repository.SoftDeleteUpload`,禁止 `db.Create(&model.Upload{})` 旁路写 `w_uploads`。
- 禁止在 `init()` 中注册跨模块集成(任务 Handler、推送内置事件、域事件监听器、任务完成钩子)。统一通过 `internal/bootstrap` 在 `internal/cmd` 入口显式装配。
+26 -2
View File
@@ -1,4 +1,4 @@
.PHONY: swagger license license-check prettier build-embedded build-test cross-build code-check build-backend build-frontend build-agent build-relay build-flared build-all
.PHONY: swagger license license-check format build-embedded build-test cross-build code-check build-backend build-frontend build-agent build-relay build-flared build-all
VERSION ?= dev
BUILD_DATE ?= $(shell date -u +'%Y-%m-%dT%H:%M:%SZ')
@@ -13,7 +13,7 @@ license:
license-check:
scripts/update_go_license.sh --check
prettier:
format:
@echo "==> Formatting backend Go source and removing unused imports..."
@command -v goimports >/dev/null 2>&1 || { \
echo "goimports not found, installing..."; \
@@ -111,3 +111,27 @@ cross-build:
.
@echo "==> Done. Binaries written to ./bin/"
@ls -lh bin/
dev-f:
@echo "==> Starting frontend development server..."
cd frontend && pnpm dev
dev-b:
@echo "==> Starting backend development server..."
go run main.go all
dev:
@echo "==> Starting frontend and backend development servers in parallel..."
@PIDS=""; \
STATUS=0; \
( cd frontend && pnpm dev 2>&1 | sed 's/^/[frontend] /' ) & PIDS="$$PIDS $$!"; \
( go run main.go all 2>&1 | sed 's/^/[backend] /' ) & PIDS="$$PIDS $$!"; \
for PID in $$PIDS; do \
wait $$PID || STATUS=1; \
done; \
if [ $$STATUS -eq 0 ]; then \
echo "==> All development servers exited successfully."; \
else \
echo "==> Development servers exited with errors." >&2; \
exit 1; \
fi
+20
View File
@@ -22,6 +22,26 @@ sidebar: false
## [unreleased]
## [v3.4.3] - 2026-07-24
### 新增
- 安全性限流支持全局与站点级单 IP 请求频率限制(如 10r/s、100r/m):站点可空/0 继承全局、-1 关闭或自定义;触发时边缘返回 429,并按站点隔离计数。
- 反代站点「流量限制」页可直接配置上述请求频率策略。
### 改进
- 边缘缓存对齐 Cloudflare 默认模型:不再因请求会话 Cookie、Authorization 或客户端 Cache-Control 一律跳过缓存;响应带 Set-Cookie 时不写入边缘;无源站缓存头时按状态码使用默认 Edge TTL;标准静态扩展名默认不再包含 JSON。生效需重新发布节点配置。
- IP 组自动规则中的 `StatusCount` / `StatusRatio` 支持状态码类写法(如 `"2xx"`、`"4xx"`、`"5xx"`),便于按整类错误率匹配。
- IP 组同步间隔下限由 5 分钟调整为 1 分钟,便于更频繁同步自动/订阅名单。
- 自动 IP 组回看窗口字段由 `lookback_minutes` 调整为 `lookback`,支持 `60m`、`1h` 等时长写法,并移除最小 5 分钟限制(兼容旧字段)。
- 限流页请求压力图的 RPS 纵轴按可见时间窗口最高值的 1.5 倍动态缩放,拖动底部时间范围条时同步更新。
### 修复
- 修复 IP 组自动抓取使用预设规则时未写入 `ttl` 字段的问题,避免配置 JSON 缺少封禁时长。
- 修复限流相关迁移中表名错误,确保升级脚本正确执行。
## [v3.4.2] - 2026-07-19
### 新增
+168 -68
View File
@@ -1,6 +1,6 @@
# 边缘缓存策略设计(对标 Cloudflare 默认可缓存范围)
# 边缘缓存策略设计
你会学到:OpenFlare 边缘 `proxy_cache` 的产品边界、默认可缓存范围如何对齐 Cloudflare「静态资源默认可缓存」、策略枚举与渲染规则、兼容迁移,以及本阶段明确不做的能力。
你会学到:OpenFlare 边缘 `proxy_cache` 如何在「该缓存」与「不该缓存」之间对齐 Cloudflare 默认闭环:请求 eligible(扩展名/策略)× 响应可共享缓存(源站 `Cache-Control` / `Expires` / `Set-Cookie`),以及与过往过严请求旁路的差异。
本设计是 [系统架构](./architecture.md) 中「基础缓存」的产品化专章;访问日志中的缓存结果见 [观测数据模型 §3.5.1](./observability-data-model.md)。
@@ -8,35 +8,61 @@
## 1. 目标与非目标
### 1.1 目标(第一期)
### 1.1 目标
* **开箱接近 CF 默认**:路由开启缓存后,**默认只缓存静态扩展名**,不默认缓存 HTML/无扩展名动态路径。
* **行为可解释**:与现有安全旁路(非 GET、Authorization、会话 Cookie、请求 `Cache-Control`)叠加,不削弱安全。
* **开箱接近 CF 默认**:路由开启缓存后,**默认只缓存静态扩展名**,不默认缓存 HTML;**不因请求会话 Cookie / Authorization / 客户端 Cache-Control 一律 BYPASS**。
* **该缓存的能命中**:带登录 Cookie 的用户访问 `/_app/**/*.js` 等静态资源可出现 `MISS` → `HIT`。
* **不该缓存的仍挡住**:策略不 eligible(等价 CF `DYNAMIC`);源站 `private` / `no-store`;响应带 **`Set-Cookie` 不入库**(对齐 CF OCC 默认);`all` 为高级选项并文档警示。
* **无源站 freshness 时有默认 Edge TTL**:对齐 CF 按状态码的默认 TTL(见 §3.5)。
* **可观测一致**:继续依赖 `$upstream_cache_status` → `cache_status` 明细三态。
* **兼容存量**:旧路由 `cache_policy=url`(近似「过旁路即可缓存」)迁移为显式策略 `all`,行为不变。
* **兼容存量**:旧路由 `cache_policy=url` 映射为 `all`;策略枚举与迁移规则保持 [§5](#5-兼容与迁移)。
### 1.2 非目标(后续迭代)
* Cache Rules 表达式引擎
* Edge TTL / `proxy_cache_valid` / 忽略源站 `Cache-Control`
* 可配置 Cookie 旁路列表、Query 忽略列表
* 忽略源站 `Cache-Control` 的强制 Edge TTL(CF Cache Rules「Ignore cache-control」)
* Purge(按 URL/前缀/全站)
* 浏览器 TTL 改写、客户端 `CF-Cache-Status` 响应头
* 完整 RFC 条件:`Authorization` 仅当响应含 `public`/`s-maxage`/`must-revalidate` 才缓存(需 Lua;本期删除请求侧一律旁路,依赖策略 + 源站头)
* HEAD 转 GET 再缓存
* 命中率看板
---
## 2. 现状摘要
## 2. Cloudflare 判定闭环(对齐基准)
| 层 | 现状 |
CF 默认是 **两段决策**,**不是**「请求带 Cookie 就不缓存」。
### 2.1 阶段 A — 请求时 Eligible
| 条件 | CF 结果 |
| --- | --- |
| 全局 | `proxy_cache_path` / key / lock / stale(Performance 部分字段) |
| 路由 | `cache_enabled` + `cache_policy`:`url` \| `suffix` \| `path_prefix` \| `path_exact` |
| 旁路 | 渲染器硬编码:非 GET、Authorization、会话 Cookie、请求 Cache-Control |
| TTL | **无** `proxy_cache_valid`;存多久主要看源站头 + `inactive` |
| 观测 | 已上报 `cache_status`,UI 三态:命中 / 回源 / 未缓存 |
| 非 GET | 默认不缓存 |
| 扩展名不在默认可缓存表,且无 Rules 强制 Eligible | **`DYNAMIC`**(不查缓存) |
| 扩展名在默认表,或 Rules Eligible | 继续阶段 B |
| **请求 Cookie** | **默认不影响** |
| Cache Rules Bypass | `DYNAMIC` |
问题:默认策略 `url` 对「过旁路的 GET」范围过宽,与 CF「默认主要缓存静态扩展名、默认不缓存 HTML」不一致。
CF 默认可缓存扩展名按 **扩展名** 而非 MIME;**默认不缓存 HTML / JSON**。
### 2.2 阶段 B — 响应是否可入库(OCC on,Free/Pro/Biz 默认)
| 条件 | 结果 |
| --- | --- |
| `Cache-Control: no-store` / `private` | 不入库 |
| `public` + `max-age>0`,或未来 `Expires` | 可缓存 |
| 无 Cache-Control / Expires | 按状态码 **默认 Edge TTL** 仍可缓存(如 200 → 120m) |
| 响应 **`Set-Cookie`**(默认缓存级别 + OCC) | **不入库**,状态倾向 **BYPASS** |
| 请求 `Authorization` | 仅当响应另有 `public` / `s-maxage` / `must-revalidate` 才可缓存(完整条件本期用 Nginx 简化,见 §3.4) |
### 2.3 状态语义(对照观测)
| CF | 含义 | OpenFlare `cache_status` |
| --- | --- | --- |
| HIT / STALE / UPDATING / REVALIDATED | 命中类 | 同名或等价 |
| MISS / EXPIRED | 回源取内容 | 同名 |
| BYPASS | 请求时 eligible,响应不可缓存 | `BYPASS` → UI「未缓存」 |
| DYNAMIC | 请求时不 eligible | 策略 skip 多为 `BYPASS` 或空 → UI「未缓存」 |
---
@@ -49,24 +75,24 @@
两者均开启时才进入缓存逻辑。
### 3.2 策略枚举(第一期)
### 3.2 策略枚举
| `cache_policy` | 含义 | 新建默认 | 旧值兼容 |
| --- | --- | --- | --- |
| **`static`** | 仅 URI 匹配**标准静态扩展名**(内置表)才允许缓存 | **是** | — |
| **`all`** | 过安全旁路后,不限制路径/扩展名(等同今日 `url`) | 否 | 存量 `url` → `all` |
| **`static`** | 仅 URI 匹配**标准静态扩展名**才 eligible | **是** | — |
| **`all`** | 过方法旁路后,不限制路径/扩展名(高级,风险类似 CF Cache Everything) | 否 | 存量 `url` → `all` |
| **`suffix`** | 自定义扩展名列表(`cache_rules`) | 否 | 保持 |
| **`path_prefix`** | 自定义路径前缀 | 否 | 保持 |
| **`path_exact`** | 自定义精确路径 | 否 | 保持 |
> 渲染层:读到历史值 `url` 时按 `all` 处理,避免未迁移数据行为突变;API 校验与 UI 只暴露上表枚举(写入时可将 `url` 规范为 `all`)。
渲染层:历史值 `url` 按 `all` 处理;API/UI 只暴露上表枚举。
### 3.3 标准静态扩展名(内置,V1 硬编码)
### 3.3 标准静态扩展名(内置)
对齐 Cloudflare 常见「默认可缓存静态」集合,**默认不包含** `html` / `htm`:
对齐 CF 默认「不缓存 HTML/JSON」;保留现代前端常用增强项:
```text
css js mjs map json
css js mjs map
ico cur gif jpg jpeg png webp avif svg svgz
ttf otf woff woff2 eot
mp3 mp4 webm ogg flac
@@ -74,26 +100,68 @@ wasm pdf
zip 7z gz tar
```
* 匹配对象:`$uri` 的扩展名(大小写不敏感),实现上与现有 `suffix` 策略相同:
`if ($uri !~* \.(?:css|js|…)$) { set $openflare_skip_cache 1; }`
* **V1.1(可选)**:全局配置项覆盖该列表;第一期不强制。
* **不含** `html` / `htm` / **`json`**(对齐 CF 默认不缓存 JSON)。
* **含** `map` / `mjs` / `wasm`(有意增强,提高 sourcemap / ES module / WASM 命中)。
* 匹配:`$uri` 扩展名,大小写不敏感:
`if ($uri !~* \.(?:css|js|…)$) { set $openflare_skip_cache 1; }`
### 3.4 安全旁路(保持硬编码)
### 3.4 请求侧旁路(对齐 CF 后)
在策略匹配之前/之外,仍设置 `$openflare_skip_cache=1`:
仅保留:
1. `$request_method != GET`(含 HEAD,与现网一致)
2. `$http_authorization != ""`
3. 会话类 Cookie 正则(现网列表)
4. 请求 `$http_cache_control` 匹配 `no-cache|no-store|private`
1. `$request_method != GET`(含 HEAD,与现网一致;不做 CF 的 HEAD→GET)
`proxy_cache_bypass` / `proxy_no_cache` 均绑定 `$openflare_skip_cache`。
**删除(过往过严,导致缓存率过低):**
### 3.5 与源站头的关系(本阶段不改)
* 会话类 Cookie 正则
* `$http_authorization != ""`
* 请求 `$http_cache_control` 匹配 `no-cache|no-store|private`
* 仍不输出 `proxy_cache_valid`。
* 对象**是否进入缓存流程**由策略 + 旁路决定;**存多久**继续依赖源站 `Cache-Control` / `Expires` 等及全局 `inactive`。
* Edge TTL / 强制忽略源站头 → 后续专项。
**安全如何仍成立:**
| 威胁 | 闸门 |
| --- | --- |
| 误缓存 HTML/API | 默认 `static` 扩展名(不含 html/json) |
| 个性化内容 | 源站 `private` / `no-store`(Nginx 尊重) |
| 响应写会话 | **`Set-Cookie` → 不入库**(§3.6) |
| `all` 过宽 | UI/文档警告:需源站正确 Cache-Control |
| 带 Bearer 的 API | 依赖策略(勿对 API 用 `all`)+ 源站头;完整 Auth 条件缓存为后续 |
### 3.5 默认 Edge TTL(无源站 freshness 时)
对齐 CF 无 `Cache-Control`/`Expires` 时的状态码默认 TTL,在启用缓存的 location 输出:
| 状态码 | TTL |
| --- | --- |
| 200, 206, 301 | 120m |
| 302, 303 | 20m |
| 404, 410 | 3m |
```nginx
proxy_cache_valid 200 206 301 120m;
proxy_cache_valid 302 303 20m;
proxy_cache_valid 404 410 3m;
```
* 源站提供合法 `Cache-Control` / `Expires` 时,仍以源站 freshness 为准(不 `proxy_ignore_headers`)。
* **不做**强制忽略源站头的 Edge TTL 覆盖。
### 3.6 响应侧:Set-Cookie 不入库
对齐 CF OCC 默认:eligible 请求若源站返回 **`Set-Cookie`**,**不写入** `proxy_cache`(可读路径仍可能 MISS/BYPASS 语义)。
```nginx
proxy_no_cache $openflare_skip_cache $upstream_http_set_cookie;
```
(`proxy_no_cache` 多参数:任一非空且非 `"0"` 则不写入。)
`proxy_cache_bypass` 仍仅绑定 `$openflare_skip_cache`(请求侧 skip);响应侧只影响**写入**,与 CF「eligible 但响应不可缓存」一致。
### 3.7 与源站头的关系
* **是否 eligible**:策略 + 方法旁路。
* **是否入库 / 存多久**:源站 `Cache-Control` / `Expires` + 默认 `proxy_cache_valid` + Set-Cookie 闸门 + 全局 `inactive`。
---
@@ -107,11 +175,13 @@ zip 7z gz tar
│ no → location 无 proxy_cache
▼ yes
set $openflare_skip_cache 0
→ 安全旁路 if → 置 1
→ 非 GET → 置 1
→ 策略 if(static/all/suffix/…)→ 可置 1
proxy_cache openflare_cache
proxy_cache_methods GET
proxy_cache_bypass / proxy_no_cache $openflare_skip_cache
proxy_cache_bypass $openflare_skip_cache
proxy_no_cache $openflare_skip_cache $upstream_http_set_cookie
proxy_cache_valid …
→
access.log cache_status=$upstream_cache_status
```
@@ -125,16 +195,16 @@ access.log cache_status=$upstream_cache_status
| `suffix` | 不匹配 `cache_rules` 扩展名 → skip |
| `path_prefix` / `path_exact` | 同现实现 |
### 4.2 涉及代码面(实现时)
### 4.2 涉及代码面
| 区域 | 路径 |
| --- | --- |
| 渲染 | `pkg/render/openresty/render.go`(策略分支 + 内置扩展名常量) |
| 渲染 | `pkg/render/openresty/render.go`(旁路、Set-Cookie、`proxy_cache_valid`、扩展名常量) |
| 校验 | `internal/apps/openflare/proxy_route/helpers.go` |
| 模型/默认 | 创建路由默认 `cache_policy=static`;读写时 `url`→`all` |
| 快照 | `config_version/snapshot.go` |
| 快照 | `config_version` 快照规范化 |
| UI | `proxy-routes/detail/components/cache-section.tsx` |
| 测试 | `pkg/render/openresty/render_test.go`、proxy_route helpers 测试 |
| 测试 | `pkg/render/openresty/render_test.go` 等 |
---
@@ -142,49 +212,79 @@ access.log cache_status=$upstream_cache_status
| 数据 | 处理 |
| --- | --- |
| DB 中 `cache_policy=''` 或 `url`(且已启用缓存) | 读取 / 快照 / 渲染均规范为 **`all`**,保证存量「宽缓存」不变 |
| API 写入时 `enabled` 且 policy 为空 | 规范为 **`all`**(兼容旧客户端);UI 新建开启时**显式提交** `static` |
| 新建路由 | 默认 `cache_enabled=false`;表单开启缓存时默认策略 **`static`** |
| 已开启且 `url` 的站点 | 显示与发布为 `all`,**缓存范围不变** |
| 期望「只缓存静态」的旧站点 | 用户在 UI 改为 `static` 或自定义 `suffix` |
| DB 中 `cache_policy=''` 或 `url`(且已启用缓存) | 读 / 快照 / 渲染 → **`all`** |
| API 写入 enabled 且 policy 为空 | 规范为 **`all`**;UI 新建开启时**显式提交** `static` |
| 新建路由 | 开启缓存时默认 **`static`** |
| 旁路行为变更 | **破坏性相对旧实现**:带 Cookie/Auth 的流量从「未缓存」变为可 HIT;需 **重新发布节点配置** 后生效 |
| 默认扩展名 | 自表中 **移除 `json`**;已依赖缓存 `*.json` 的站点可改 `suffix` 自定义或 `all` |
**发布说明建议:** 说明默认策略变更仅影响**新配置**;存量 `url` 视为 `all`。
**发布说明:** 说明本次对齐 CF 默认模型;命中率预期上升;`all` 与错误源站头风险需运维自查。
---
## 6. UI 文案要点(缓存 Tab)
* 开启缓存后默认:**标准静态资源**(列出扩展名摘要,并写明不含 HTML)。
* 选项:**标准静态资源** / **所有可缓存 GET(高级)** / 自定义后缀 / 路径前缀 / 精确路径。
* 固定说明:非 GET、带 Authorization、常见登录 Cookie、请求禁止缓存头时跳过缓存。
* 提示:全局 Performance 中缓存总开关须开启,否则站点开关无效。
* 开启缓存后默认:**标准静态资源**(摘要扩展名,**不含 HTML/JSON**;含 map/mjs 等)。
* 选项:标准静态 / 所有可缓存 GET(高级)/ 自定义后缀 / 路径前缀 / 精确路径。
* 说明对齐 CF:
* 登录 Cookie **不会**单独跳过缓存;
* 源站 `private` / `no-store` / 响应 **`Set-Cookie`** 不会写入边缘缓存;
* 无源站缓存头时使用默认 Edge TTL。
* **高级 `all`**:警告「类似 Cache Everything,个性化页面必须由源站声明 private/no-store」。
* 全局 Performance 缓存总开关须开启。
---
## 7. 验证要点
* 渲染:`static` 生成扩展名 `if`;`all`/`url` 无路径限制;旁路四条仍在。
* 单测:内置表含 `css`/`js`/`woff2`,不含 `html`。
* 手动:开启 `static` 后请求 `/a.css` 可出现 HIT/MISS;`/index.html` 或 `/api` 多为未缓存/BYPASS。
* 观测:access log `cache_status` 与列表三态一致。
* 渲染:无 Cookie/Auth/请求 Cache-Control 旁路;含 `proxy_cache_valid` 三行;`proxy_no_cache` 含 `$upstream_http_set_cookie`。
* 单测:内置表含 `css`/`js`/`map`/`mjs`,**不含** `html`/`json`。
* 手动:
* 带 session Cookie 请求 `/a.js` → 第二次 `HIT`;
* `/index.html` + `static` → 未缓存;
* 源站对 eligible 路径返回 `Set-Cookie` → 不入库(持续 MISS/不 HIT);
* 源站 `Cache-Control: private` → 不入库。
* 观测:access log 三态与原始 `cache_status` 一致。
* 生效:配置版本发布并节点应用后验证。
---
## 8. 后续路线图(非本设计交付)
## 8. 决策矩阵(防漏判)
1. **Edge TTL / 尊重源站开关**(`proxy_cache_valid`、`proxy_ignore_headers`)
2. **可配置旁路**(Cookie/Query)
3. **Purge API**
4. **Cache Rules**(有序规则 + 动作)
5. **全局默认可缓存扩展名配置**
| 场景 | CF | OpenFlare(本设计) |
| --- | --- | --- |
| GET 静态 + session Cookie + 源站 public max-age | HIT | HIT |
| GET HTML + static 策略 | DYNAMIC | 策略 skip → 未缓存 |
| GET + all + 源站 private | 不入库 | 不入库 |
| GET 静态 + 响应 Set-Cookie | BYPASS(OCC) | 不入库 |
| GET + Authorization + 静态 public | 条件缓存 | 可缓存(简化;依赖源站勿对敏感 API 乱标 public) |
| GET + 无 CC 的 200 静态 | 默认 120m | `proxy_cache_valid` 120m |
| DevTools Disable cache(请求 no-cache) | 边缘默认可仍 HIT | 边缘默认可仍 HIT |
| POST | 不缓存 | 非 GET skip |
---
## 9. 决策记录
## 9. 后续路线图
1. Auth 完整 RFC/CF 条件缓存(Lua)
2. 强制 Edge TTL / `proxy_ignore_headers`(Cache Rules 级)
3. Purge API
4. Cache Rules(有序规则 + 动作)
5. 全局默认可缓存扩展名可配置;可选对齐 CF 更长扩展名表
6. HEAD→GET
---
## 10. 决策记录
| 决策 | 选择 | 原因 |
| --- | --- | --- |
| 默认可缓存范围 | 开启缓存默认 `static` 扩展名表 | 对标 CF 开箱行为,降低 HTML/API 被误缓存 |
| 旧 `url` | 映射为 `all` | 避免存量站点行为变化 |
| HTML | 默认不在白名单 | 对齐 CF 默认不缓存 HTML |
| 第一期不做 Edge TTL/Purge | 明确 Out of Scope | 先收敛「谁可以进缓存」再优化「存多久/怎么清」 |
| 请求 Cookie 旁路 | **删除** | 对齐 CF;恢复登录用户静态命中率 |
| 请求 Authorization / Cache-Control 旁路 | **删除** | 对齐 CF 请求 eligible 模型;响应闸门兜底 |
| Set-Cookie | **proxy_no_cache 绑定** | 对齐 CF OCC「响应 Set-Cookie 不入库」 |
| 默认 Edge TTL | **按状态码 proxy_cache_valid** | 对齐 CF 无头时默认 TTL,避免「永不入库」 |
| 默认表去掉 json | **是** | 对齐 CF 默认不缓存 JSON |
| 保留 map/mjs/wasm | **是** | 现代前端有用命中,有意增强 |
| 默认可缓存范围 | 开启缓存默认 `static` | 对标 CF,降低 HTML/API 误缓存 |
| 旧 `url` | 映射 `all` | 存量行为不收窄 |
| 完整 Auth 条件 / Purge / Rules | 后续 | 先闭合默认闭环再扩展 |
+1 -1
View File
@@ -22,7 +22,7 @@ OpenFlare 适合需要统一管理多台 OpenResty 代理节点的团队,具
| 能力 | 说明 | 详细设计/使用指南 |
| --- | --- | --- |
| **反代配置管理** | 以网站规则(Proxy Route)为聚合边界,支持多域名与多上游负载均衡 | [新建反代配置](../guide/proxy-config.md) |
| **边缘缓存** | 单节点 OpenResty `proxy_cache`;开启后默认仅缓存标准静态扩展名(对标 CF 默认可缓存范围) | [边缘缓存策略设计](./edge-cache-design.md) |
| **边缘缓存** | 单节点 OpenResty `proxy_cache`;默认 static 扩展名 + 源站头/Set-Cookie 闸门 + 默认 Edge TTL(对标 CF 默认模型) | [边缘缓存策略设计](./edge-cache-design.md) |
| **Zone 与域名管理** | 以可注册根域为管理入口,聚合明确域名、域名证书与反代路由 | [Zone 与域名资源设计](./zone-design.md) |
| **配置版本控制** | 支持全局单一激活版本的预览、发布、不可变快照历史与秒级一键回滚 | [Agent 与发布模型](./agent-design.md) |
| **WAF 安全防护** | 支持可视化 DAG 编排规则、手动/自动/订阅型 IP 组、GeoIP 匹配与 PoW CC 防护 | [WAF 设计](./waf-design.md) / [WAF 可编排规则设计](./waf-orchestration-design.md) / [WAF 使用指南](../guide/waf-usage.md) |
+9 -3
View File
@@ -5571,7 +5571,7 @@ const docTemplate = `{
},
{
"type": "integer",
"description": "趋势桶分钟数(1、5 或 60,默认 60)",
"description": "趋势桶分钟数(1、3、5 或 60,默认 60)",
"name": "bucket_minutes",
"in": "query"
}
@@ -18272,6 +18272,9 @@ const docTemplate = `{
"limit_rate": {
"type": "string"
},
"limit_req_per_ip": {
"type": "string"
},
"origin_address": {
"type": "string"
},
@@ -18388,6 +18391,9 @@ const docTemplate = `{
"limit_rate": {
"type": "string"
},
"limit_req_per_ip": {
"type": "string"
},
"origin_host": {
"type": "string"
},
@@ -19669,8 +19675,8 @@ const docTemplate = `{
"waf.IPGroupAutoTestResult": {
"type": "object",
"properties": {
"lookback_minutes": {
"type": "integer"
"lookback": {
"type": "string"
},
"matched_count": {
"type": "integer"
+4 -3
View File
@@ -10,7 +10,7 @@ OpenFlare 是一套自托管的 OpenResty 控制面。它把反向代理网站
1. [快速开始](./quick-start.md):用 Docker Compose 启动 Server,登录管理端,并接入第一个 Agent。
2. [发布第一份配置](./first-site.md):快速新建一条最基础的 HTTP 反代站点规则,并验证节点生效状态。
3. [新建反代配置](./proxy-config.md):一步一步了解如何从证书导入与申请开始,配置 HTTPS 加密与上游源站管理。
3. [新建反代配置](./proxy-config.md):一步一步了解如何从证书导入与申请开始,配置 HTTPS 加密、上游源站与边缘缓存。
4. [Zone 域名迁移](./zone-domain-migration.md):从旧托管域名/路由内嵌域名升级到 Zone 模型(goose 自动导入),含备份、验收与回滚说明。
5. [Pages 静态托管使用](./pages-usage.md):了解静态项目 ZIP 上传限制、SPA Fallback、以及内置 API 反向代理配置。
6. [内网穿透与隧道使用](./tunnel-usage.md):部署 Relay 与 Client,实现安全、无公网 IP 反向穿透。
@@ -18,7 +18,7 @@ OpenFlare 是一套自托管的 OpenResty 控制面。它把反向代理网站
8. [WAF 自动 IP 组语法](./waf-ip-group-expr.md):编写自动 IP 组 Expr 规则,了解关键字含义和预设规则。
9. [Uptime Kuma 监控同步](./uptime-kuma.md):配置并使用 Uptime Kuma 自动差分同步和监控范围控制。
10. [SSO 登录配置](./sso.md):配置 GitHub 或 OIDC 实现第三方单点登录 (SSO) 接入。
11. [故障排查](./troubleshooting.md):按症状排查登录、数据库、节点同步、OpenResty 应用和前端构建问题。
11. [故障排查](./troubleshooting.md):按症状排查登录、数据库、节点同步、OpenResty、边缘缓存命中与前端构建问题。
12. [引用与致谢](./credits.md):查看系统依赖的优秀开源项目与社区致谢清单。
## 按角色查找
@@ -27,7 +27,8 @@ OpenFlare 是一套自托管的 OpenResty 控制面。它把反向代理网站
| --- | --- |
| 5 分钟内跑起管理端 | [快速开始](./quick-start.md) |
| 发布第一条反向代理配置 | [发布第一份配置](./first-site.md) |
| 配置域名证书与高级反代 | [新建反代配置](./proxy-config.md) |
| 配置域名证书、反代与边缘缓存 | [新建反代配置](./proxy-config.md)(含缓存说明) |
| 静态资源不命中缓存 | [故障排查 · 边缘缓存](./troubleshooting.md#边缘缓存命中率异常) |
| 托管单页应用或静态网站 | [Pages 静态托管使用](./pages-usage.md) |
| 配置内网穿透映射 | [内网穿透与隧道使用](./tunnel-usage.md) |
| 配置防 CC 与 IP 组拦截 | [WAF 安全防护使用](./waf-usage.md) |
+31
View File
@@ -84,3 +84,34 @@
2. 在历史列表中找到发布前的上一个稳定版本。
3. 点击 **「激活此版本」**。
4. 所有在线 Agent 节点将在秒级自动重载回历史配置,实现秒级避险。
---
## 边缘缓存(可选)
站点详情 **「缓存」** 页可开启边缘 `proxy_cache`(须同时开启 **性能设置 → 全局 OpenResty 缓存**)。行为对标 Cloudflare 默认模型,详见 [边缘缓存策略设计](../design/edge-cache-design.md)。
### 推荐设置
| 项 | 建议 |
| --- | --- |
| 策略 | **标准静态资源**(默认推荐):仅 css/js/map/图片/字体等,**不含 HTML/JSON** |
| 登录 Cookie | **不会**单独跳过缓存;带会话的用户仍可命中静态资源 |
| 源站 | 静态资源建议 `Cache-Control: public, max-age=…`;动态/个性化必须 `private` 或 `no-store` |
| 响应 Set-Cookie | 不会写入边缘缓存 |
| 无源站缓存头 | 按状态码使用默认 Edge TTL(如 200 约 120 分钟) |
### 高级策略「所有可缓存 GET」
类似 Cloudflare Cache Everything:路径不再限制扩展名。若源站对 HTML 未声明 `private`/`no-store`,**可能把个性化页面缓存并串用户**。仅在源站缓存头正确、或内容全局一致时使用。
### 生效方式
缓存开关与策略写在配置快照中。保存站点后须 **发布并激活配置版本**,Agent 应用后才生效。仅改 UI 不发布则节点仍用旧规则。
### 快速自检
1. 全局缓存已开,站点缓存已开,策略为「标准静态资源」。
2. 发布配置并确认节点应用成功。
3. 带登录 Cookie 连续两次请求同一 `/assets/app.js`(或带 hash 的 immutable 路径),访问日志中 `cache_status` 第二次应为 **HIT**(或 UI「命中」)。
4. 若仍为「未缓存」:确认策略是否匹配该路径扩展名、是否非 GET、源站是否返回 `Set-Cookie` / `private`,以及节点是否已应用新版本。更多见 [故障排查 · 边缘缓存](./troubleshooting.md#边缘缓存命中率异常)。
+31
View File
@@ -15,6 +15,7 @@
| 发布后节点未更新 | 激活版本、节点 heartbeat、应用记录 |
| OpenResty 应用失败 | 应用记录、Agent 日志、证书、上游地址、端口占用 |
| 访问分析无数据 | OpenResty 容器状态、观测端口、Agent 补报日志 |
| 静态资源总不命中缓存 | 全局/站点缓存开关、策略扩展名、配置是否已发布、访问日志 `cache_status`、源站 Set-Cookie / Cache-Control |
## Server 无法启动
@@ -238,6 +239,36 @@ pnpm build
| API 类型不一致 | 检查 `lib/api/` 和 `types/` 中的响应结构 |
| E2E 失败 | 确认 Server 和前端开发服务器都已启动 |
## 边缘缓存命中率异常
访问日志中缓存三态:**命中**(HIT/STALE/REVALIDATED/UPDATING)、**回源**(MISS/EXPIRED)、**未缓存**(BYPASS 或空,请求时未进入可缓存路径或响应未入库)。设计说明见 [边缘缓存策略设计](../design/edge-cache-design.md)。
### 检查清单
1. **性能设置** 中全局 OpenResty 缓存已开启。
2. 站点 **缓存** 已启用,策略与路径匹配(「标准静态资源」只覆盖内置扩展名,**不含** HTML/JSON;`.js.map` 的扩展名是 `map`,在默认表内)。
3. 已 **发布并激活** 配置版本,对应节点应用记录成功(改缓存规则不发布则节点仍用旧旁路逻辑)。
4. 请求方法为 **GET**(非 GET 一律不缓存)。
5. 源站未对目标 URL 返回 **`Set-Cookie`**(有则不会写入边缘)。
6. 源站未声明 **`Cache-Control: private` / `no-store`**(共享缓存不会存)。
7. 浏览器 DevTools「禁用缓存」只影响浏览器;边缘是否 HIT 看访问日志 `cache_status`,不要只看 Network 面板。
### 常见误解
| 现象 | 说明 |
| --- | --- |
| 登录后全是「未缓存」且从未发布新版本 | 旧配置曾因会话 Cookie 旁路;升级后须重新发布节点配置 |
| `static` 下 `/api/foo` 或 `/index.html` 未缓存 | 预期行为(扩展名不在默认可缓存表) |
| 策略为 `all` 后 HTML 被串用户 | 源站未禁止共享缓存;改回 `static` 或给动态响应加 `private`/`no-store` |
| 带 `?v=` 的 URL 命中率低 | 默认缓存键含完整 `$request_uri`,query 不同即不同对象 |
| 第一次 MISS、第二次仍 MISS | 查源站是否每次 `Set-Cookie`、是否 `private`,或节点磁盘/缓存 inactive 过短 |
### 期望行为(对齐 Cloudflare 默认)
* 带登录 Cookie 的用户访问 `/_app/**/*.js` 等静态资源:**可以 HIT**。
* 响应带 `Set-Cookie` 或 `private`:**不入库**。
* 无源站缓存头的可缓存状态码:使用默认 Edge TTL(如 200 约 120 分钟)。
## 文档站构建失败
```bash
+29 -9
View File
@@ -8,7 +8,7 @@
```json
{
"lookback_minutes": 60,
"lookback": "1h",
"rules": [
{
"name": "单 IP 404 高频扫描",
@@ -22,7 +22,7 @@
| 字段 | 类型 | 作用 |
| --- | --- | --- |
| `lookback_minutes` | number | 每次执行时回看多少分钟内的请求日志。未填写时默认 60 分钟,最小 5 分钟,最大 43200 分钟。 |
| `lookback` | string | 回看窗口时长,使用 Go Duration 写法,例如 `30m`、`1h`、`90m`。未填写时默认 `1h`,最大 30 天。兼容旧字段 `lookback_minutes`(整数分钟)。 |
| `rules` | array | 自动规则列表。任意一条规则命中时,该 IP 会进入自动 IP 组名单。 |
| `rules[].name` | string | 规则名称,只用于界面展示和错误提示。 |
| `rules[].expr` | string | Expr 表达式,必须返回布尔值。 |
@@ -31,7 +31,7 @@
自动规则不是逐条请求判断,而是先按单个客户端 IP 聚合:
1. Server 读取最近 `lookback_minutes` 分钟内的请求日志。
1. Server 读取最近 `lookback` 时长内的请求日志。
2. 按 `remote_addr` 归一化后的 IP 分组。
3. 为每个 IP 计算请求数、404 数、直连 IP Host 次数等指标。
4. 逐个 IP 执行 `rules[].expr`。
@@ -61,8 +61,14 @@ Host 是否为“通过 IP 访问”按请求日志中的 `Host` 字段判断:
如果内置的 `status_404_count` 和 `status_404_ratio` 不能满足您的需求,您可以使用以下内置方法来匹配任意状态码的请求数与占比:
* **`StatusCount(code)`**: 获取当前 IP 在回看窗口内返回指定状态码的请求数(如 `StatusCount(403) > 10`)
* **`StatusRatio(code)`**: 获取当前 IP 在回看窗口内返回指定状态码的请求数占该 IP 总请求数的比例(如 `StatusRatio(502) >= 0.5`)
* **`StatusCount(code)`**: 获取当前 IP 在回看窗口内返回指定状态码(或状态码类)的请求数。
* 精确状态码:`StatusCount(403) > 10`
* 状态码类(`1xx`–`5xx`,大小写不敏感):`StatusCount("4xx") > 50`
* **`StatusRatio(code)`**: 获取上述计数占该 IP 总请求数的比例。
* 精确状态码:`StatusRatio(502) >= 0.5`
* 状态码类:`StatusRatio("4xx") >= 0.8`、`StatusRatio("5xx") >= 0.3`
状态码类会汇总该百位区间内全部状态码,例如 `"4xx"` 包含 400–499,`"2xx"` 包含 200–299。
## Expr 常用写法
@@ -109,7 +115,7 @@ Host 是否为“通过 IP 访问”按请求日志中的 `Host` 字段判断:
```json
{
"lookback_minutes": 60,
"lookback": "1h",
"rules": [
{
"name": "高频 404 扫描",
@@ -123,7 +129,7 @@ IP 直连访问异常:
```json
{
"lookback_minutes": 30,
"lookback": "30m",
"rules": [
{
"name": "IP 直连访问异常",
@@ -137,7 +143,7 @@ IP 直连访问异常:
```json
{
"lookback_minutes": 120,
"lookback": "2h",
"rules": [
{
"name": "异常错误率",
@@ -147,11 +153,25 @@ IP 直连访问异常:
}
```
使用状态码类写法(与 `client_error_count` / `server_error_count` 等价思路):
```json
{
"lookback": "2h",
"rules": [
{
"name": "高 4xx 或 5xx 占比",
"expr": "request_count > 100 && (StatusRatio(\"4xx\") >= 0.8 || StatusRatio(\"5xx\") >= 0.3)"
}
]
}
```
排除可信 IP:
```json
{
"lookback_minutes": 60,
"lookback": "1h",
"rules": [
{
"name": "排除可信 IP 的 404 扫描",
+85
View File
@@ -0,0 +1,85 @@
# 边缘缓存对齐 Cloudflare 默认模型 — 实现计划
对应设计:[edge-cache-design.md](../design/edge-cache-design.md)
## 1. 目标与背景
* **需求背景**:现网对会话 Cookie / Authorization / 请求 Cache-Control 一律旁路,登录用户静态资源几乎全是「未缓存」,命中率远低于 Cloudflare 默认。需对齐 CF 两段闭环:请求 eligible × 响应可共享缓存。
* **Scope(必做)**
1. 删除请求侧 Cookie、Authorization、请求 Cache-Control 旁路
2. 响应侧:`proxy_no_cache` 绑定 `$upstream_http_set_cookie`
3. 默认 `proxy_cache_valid`(200/206/301→120m,302/303→20m,404/410→3m)
4. 默认静态扩展名移除 `json`;保留 `map`/`mjs`/`wasm`
5. 渲染单测 + UI 文案 + 设计/changelog
* **Out of Scope**:Purge、Cache Rules、强制忽略源站 CC、Auth RFC 条件缓存、HEAD→GET
## 2. 设计决策摘要
| 决策 | 选择 |
| --- | --- |
| 请求 Cookie | 不旁路(对齐 CF) |
| Set-Cookie | 不入库 |
| 无源站 CC | 状态码默认 Edge TTL |
| json | 默认表移除 |
| 兼容 | `url`→`all` 不变;行为变更需重新发布配置 |
## 3. 修改清单
### 边缘渲染
* #### [MODIFY] `pkg/render/openresty/render.go`
* `renderRouteCacheBlock`:仅保留非 GET 旁路;`proxy_no_cache $openflare_skip_cache $upstream_http_set_cookie`;追加三行 `proxy_cache_valid`
* #### [MODIFY] `pkg/render/openresty/types.go`
* `DefaultStaticCacheExtensions`:去掉 `json`
* #### [MODIFY] `pkg/render/openresty/render_test.go`
* 断言:无 cookie/auth/cache_control 旁路;含 set_cookie 与 proxy_cache_valid;表不含 json、含 map
### 前端
* #### [MODIFY] `frontend/app/(main)/proxy-routes/detail/components/cache-section.tsx`
* 去掉「绕过登录 Cookie / Authorization」类文案
* 改为 CF 对齐说明:源站 private/no-store、Set-Cookie 不入库、默认静态不含 HTML/JSON
### 文档
* #### [MODIFY] `docs/design/edge-cache-design.md`(已更新)
* #### [MODIFY] `docs/changelog/index.md` `[Unreleased]`
* #### [MODIFY] `docs/plan/index.md` 登记本计划
### 不改
* 无 DB 迁移
* 无 API 字段变更(策略枚举不变)
## 4. 验证计划
### 自动化
```bash
go test ./pkg/render/openresty/
make format
make code-check
```
### 数据面(配置发布后)
1. 站点 `cache_enabled` + `static`,全局缓存开
2. 带 session Cookie:`GET /static/app.js` 第二次应 HIT
3. `GET /index.html` 应为未缓存
4. 源站返回 `Set-Cookie` 的静态 URL 不应出现稳定 HIT
5. 访问日志 `cache_status` 与三态一致
## 5. 发布注意
* 节点需 **重新发布/拉取配置版本** 后旁路变更才生效
* 若站点依赖边缘缓存 `*.json`,改为 `suffix` 含 json 或 `all`
## 6. 状态
- [x] 设计定稿(用户确认:全量对齐 CF)
- [x] 渲染与单测
- [x] UI 文案
- [x] changelog / plan index
- [x] `make format` + `make code-check`
- [x] 复查补强:`all` 策略 UI 警告;proxy-config / troubleshooting 运维说明
- [ ] 用户确认后提交
+1
View File
@@ -20,6 +20,7 @@
* [边缘缓存默认 static 策略](./20260718-edge-cache-static-default.md):开启缓存默认仅静态扩展名;存量 url→all。
* [访问日志 IP 明细 Tab](./20260719-access-log-ip-tab.md):第三 Tab 按 IP 聚合列表(时间窗/流量/2xx 比例);IP 情报迁入独立详情;日志详情仅请求字段。
* [边缘限流全局默认](./20260719-http-default-rate-limit.md):全局默认并发/带宽;站点 0 继承、-1 关闭;RenderRouteConfig 合并。
* [边缘缓存对齐 Cloudflare 默认模型](./20260723-edge-cache-cf-align.md):删除过严请求旁路;Set-Cookie 不入库;默认 Edge TTL;扩展名去 json。
## 已完成的计划
@@ -0,0 +1,159 @@
# 站点级访问频率限制设计
日期: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 |
+9 -3
View File
@@ -5564,7 +5564,7 @@
},
{
"type": "integer",
"description": "趋势桶分钟数(1、5 或 60,默认 60)",
"description": "趋势桶分钟数(1、3、5 或 60,默认 60)",
"name": "bucket_minutes",
"in": "query"
}
@@ -18265,6 +18265,9 @@
"limit_rate": {
"type": "string"
},
"limit_req_per_ip": {
"type": "string"
},
"origin_address": {
"type": "string"
},
@@ -18381,6 +18384,9 @@
"limit_rate": {
"type": "string"
},
"limit_req_per_ip": {
"type": "string"
},
"origin_host": {
"type": "string"
},
@@ -19662,8 +19668,8 @@
"waf.IPGroupAutoTestResult": {
"type": "object",
"properties": {
"lookback_minutes": {
"type": "integer"
"lookback": {
"type": "string"
},
"matched_count": {
"type": "integer"
+7 -3
View File
@@ -2928,6 +2928,8 @@ definitions:
type: integer
limit_rate:
type: string
limit_req_per_ip:
type: string
origin_address:
type: string
origin_host:
@@ -3005,6 +3007,8 @@ definitions:
type: integer
limit_rate:
type: string
limit_req_per_ip:
type: string
origin_host:
type: string
origin_id:
@@ -3868,8 +3872,8 @@ definitions:
type: object
waf.IPGroupAutoTestResult:
properties:
lookback_minutes:
type: integer
lookback:
type: string
matched_count:
type: integer
matched_ips:
@@ -7522,7 +7526,7 @@ paths:
in: query
name: hours
type: integer
- description: 趋势桶分钟数(1、5 或 60,默认 60)
- description: 趋势桶分钟数(1、3、5 或 60,默认 60)
in: query
name: bucket_minutes
type: integer
-7
View File
@@ -1,7 +0,0 @@
.next/
node_modules/
out/
coverage/
public/
next-env.d.ts
pnpm-lock.yaml
-14
View File
@@ -1,14 +0,0 @@
{
"semi": true,
"singleQuote": true,
"jsxSingleQuote": true,
"trailingComma": "all",
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"bracketSpacing": true,
"bracketSameLine": false,
"arrowParens": "always",
"endOfLine": "lf",
"proseWrap": "preserve"
}
@@ -97,7 +97,8 @@ function SparklineMetricCard({
formatter: (params: unknown) => {
const items = Array.isArray(params) ? params : [];
const item = items[0] as
{ axisValueLabel?: string; value?: number } | undefined;
| { axisValueLabel?: string; value?: number }
| undefined;
if (!item) return '';
const raw = typeof item.value === 'number' ? item.value : 0;
const formatted = valueFormatter
@@ -278,6 +278,25 @@ export function normalizeLimitRate(value: string) {
return normalized;
}
const limitReqPattern = /^\d+r\/[sm]$/i;
export function validateLimitReqPerIP(value: string) {
const normalized = value.trim();
if (!normalized || normalized === '0' || normalized === '-1') {
return null;
}
if (!limitReqPattern.test(normalized)) {
return '请求频率格式不合法,请使用 10r/s、100r/m,或 -1 关闭';
}
return null;
}
export function normalizeLimitReqPerIP(value: string) {
const normalized = value.trim().toLowerCase();
if (!normalized || normalized === '0') return '';
return normalized;
}
export function validateCacheRules(
policy: 'static' | 'all' | 'url' | 'suffix' | 'path_prefix' | 'path_exact',
rules: string[],
@@ -335,6 +354,7 @@ export function buildPayloadFromRoute(
limit_conn_per_server: route.limit_conn_per_server,
limit_conn_per_ip: route.limit_conn_per_ip,
limit_rate: route.limit_rate,
limit_req_per_ip: route.limit_req_per_ip,
cache_enabled: route.cache_enabled,
cache_policy: (() => {
if (!route.cache_enabled) {
@@ -228,6 +228,7 @@ export function ProxyRouteCreateSheet({
limit_conn_per_server: 0,
limit_conn_per_ip: 0,
limit_rate: '',
limit_req_per_ip: '',
cache_enabled: true,
cache_policy: 'static',
cache_rules: [],
@@ -2,9 +2,12 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { CircleHelp, TriangleAlert } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import {
Form,
FormControl,
@@ -14,6 +17,11 @@ import {
FormLabel,
FormMessage,
} from '@/components/ui/form';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Select,
SelectContent,
@@ -92,6 +100,55 @@ function needsRulesForPolicy(policy: string) {
);
}
function CacheHelpButton() {
return (
<Popover>
<PopoverTrigger asChild>
<Button
type='button'
variant='ghost'
size='icon-sm'
className='size-7 text-muted-foreground'
aria-label='缓存使用说明'
>
<CircleHelp />
</Button>
</PopoverTrigger>
<PopoverContent align='start' className='w-80 flex flex-col gap-3 p-4'>
<div className='text-sm font-medium'>缓存使用说明</div>
<div className='flex flex-col gap-2 text-xs text-muted-foreground leading-relaxed'>
<p>
扩展名/策略决定是否可缓存,源站头与Set-Cookie
决定是否入库。须同时开启性能设置中的全局 OpenResty 缓存。
</p>
<p>
<span className='font-medium text-foreground'>推荐策略:</span>
新建站点建议使用「标准静态资源」(含
css/js/map/图片/字体/媒体等,不含 HTML/JSON)。
</p>
<p>
<span className='font-medium text-foreground'>通用规则:</span>非
GET 不缓存;登录 Cookie 不会单独跳过缓存;源站 private/no-store
或响应 Set-Cookie 不会写入边缘。
</p>
<p>
<span className='font-medium text-foreground'>
所有可缓存 GET:
</span>
高级选项,类似 Cache Everything。个性化页面须由源站声明
private/no-store,否则 HTML 可能被边缘缓存并串给其他用户。
</p>
<p>
<span className='font-medium text-foreground'>自定义规则:</span>
后缀填 jpg/css/js;路径前缀填 /assets;精确路径填 /robots.txt。
保存后须重新发布配置版本才会在节点生效。
</p>
</div>
</PopoverContent>
</Popover>
);
}
export function CacheSection({
route,
onRouteUpdate,
@@ -141,8 +198,8 @@ export function CacheSection({
: watchedPolicy === 'path_exact'
? '每行一个精确路径,例如 /robots.txt。'
: watchedPolicy === 'static'
? '标准静态资源使用内置扩展名列表(不含 HTML),无需填写规则。'
: '所有可缓存 GET 无需额外规则(仍会绕过登录态与 Authorization)。';
? '标准静态资源使用内置扩展名列表,无需填写规则。'
: '当前策略无需额外路径规则。';
const rulesPlaceholder =
watchedPolicy === 'suffix'
@@ -156,7 +213,8 @@ export function CacheSection({
return (
<SectionShell
title='缓存'
description='保留现有安全绕过逻辑,只对当前站点生效。'
description='配置站点边缘缓存策略。'
titleExtra={<CacheHelpButton />}
formId={proxyRouteFormIds.cache}
saving={saving}
>
@@ -187,11 +245,7 @@ export function CacheSection({
<FormItem className='flex items-center justify-between rounded-lg border p-3'>
<div className='space-y-0.5'>
<FormLabel>启用站点缓存</FormLabel>
<FormDescription>
新建推荐「标准静态资源」(不含
HTML)。须同时开启性能设置中的全局 OpenResty
缓存。仍会绕过非 GET、Authorization 与常见登录 Cookie。
</FormDescription>
<FormDescription>新建推荐「标准静态资源」。</FormDescription>
</div>
<FormControl>
<Switch
@@ -227,15 +281,22 @@ export function CacheSection({
<SelectItem value='path_exact'>精确路径</SelectItem>
</SelectContent>
</Select>
<FormDescription>
标准静态资源含 css/js/图片/字体/媒体等,默认不缓存 HTML
与接口路径。
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{watchedEnabled && watchedPolicy === 'all' ? (
<Alert>
<TriangleAlert />
<AlertTitle>高级策略风险</AlertTitle>
<AlertDescription>
个性化 HTML 在源站未声明 private / no-store
时可能被边缘缓存并串给其他用户。详情见标题旁帮助。
</AlertDescription>
</Alert>
) : null}
<FormField
control={form.control}
name='cache_rules_text'
@@ -19,7 +19,9 @@ import type { ProxyRouteItem } from '@/lib/services/openflare';
import {
normalizeLimitRate,
normalizeLimitReqPerIP,
validateLimitRate,
validateLimitReqPerIP,
} from '../../components/helpers';
import { proxyRouteFormIds } from '../helpers';
import { useRouteSectionSave } from '../hooks/use-route-section-save';
@@ -30,6 +32,7 @@ const rateLimitSchema = z
limit_conn_per_server: z.string(),
limit_conn_per_ip: z.string(),
limit_rate: z.string(),
limit_req_per_ip: z.string(),
})
.superRefine((value, context) => {
for (const field of [
@@ -57,6 +60,15 @@ const rateLimitSchema = z
message: limitRateError,
});
}
const limitReqError = validateLimitReqPerIP(value.limit_req_per_ip);
if (limitReqError) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['limit_req_per_ip'],
message: limitReqError,
});
}
});
type RateLimitValues = z.infer<typeof rateLimitSchema>;
@@ -99,6 +111,7 @@ export function LimitsSection({
limit_conn_per_server: formatConnValue(route.limit_conn_per_server),
limit_conn_per_ip: formatConnValue(route.limit_conn_per_ip),
limit_rate: route.limit_rate || '',
limit_req_per_ip: route.limit_req_per_ip || '',
},
});
@@ -107,6 +120,7 @@ export function LimitsSection({
limit_conn_per_server: formatConnValue(route.limit_conn_per_server),
limit_conn_per_ip: formatConnValue(route.limit_conn_per_ip),
limit_rate: route.limit_rate || '',
limit_req_per_ip: route.limit_req_per_ip || '',
});
}, [form, route]);
@@ -129,6 +143,9 @@ export function LimitsSection({
),
limit_conn_per_ip: parseConnValue(values.limit_conn_per_ip),
limit_rate: normalizeLimitRate(values.limit_rate),
limit_req_per_ip: normalizeLimitReqPerIP(
values.limit_req_per_ip,
),
},
'流量限制已保存',
);
@@ -172,7 +189,7 @@ export function LimitsSection({
control={form.control}
name='limit_rate'
render={({ field }) => (
<FormItem className='md:col-span-2'>
<FormItem>
<FormLabel>限速</FormLabel>
<FormControl>
<Input placeholder='512k/1m 或 -1' {...field} />
@@ -184,6 +201,24 @@ export function LimitsSection({
</FormItem>
)}
/>
<FormField
control={form.control}
name='limit_req_per_ip'
render={({ field }) => (
<FormItem>
<FormLabel>单 IP 请求频率</FormLabel>
<FormControl>
<Input placeholder='10r/s / 100r/m 或 -1' {...field} />
</FormControl>
<FormDescription>
空或 0 继承全局默认;-1 关闭;例如 10r/s、100r/m
为自定义频率。
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SectionShell>
@@ -14,6 +14,7 @@ import {
interface SectionShellProps {
title: string;
description: string;
titleExtra?: ReactNode;
formId: string;
saving?: boolean;
children: ReactNode;
@@ -22,6 +23,7 @@ interface SectionShellProps {
export function SectionShell({
title,
description,
titleExtra,
formId,
saving = false,
children,
@@ -30,7 +32,10 @@ export function SectionShell({
<Card>
<CardHeader className='flex flex-row items-start justify-between gap-4 space-y-0'>
<div className='space-y-1'>
<CardTitle className='text-sm font-semibold'>{title}</CardTitle>
<div className='flex items-center gap-1.5'>
<CardTitle className='text-sm font-semibold'>{title}</CardTitle>
{titleExtra}
</div>
<CardDescription>{description}</CardDescription>
</div>
<Button
@@ -25,6 +25,7 @@ const optionsQueryKey = ['openflare', 'options'] as const;
const KEY_CONN_PER_SERVER = 'openresty_default_limit_conn_per_server';
const KEY_CONN_PER_IP = 'openresty_default_limit_conn_per_ip';
const KEY_LIMIT_RATE = 'openresty_default_limit_rate';
const KEY_LIMIT_REQ_PER_IP = 'openresty_default_limit_req_per_ip';
const limitRatePattern = /^\d+(?:[kKmM])?$/;
@@ -32,12 +33,14 @@ type RateLimitFields = {
openresty_default_limit_conn_per_server: string;
openresty_default_limit_conn_per_ip: string;
openresty_default_limit_rate: string;
openresty_default_limit_req_per_ip: string;
};
const defaultFields: RateLimitFields = {
openresty_default_limit_conn_per_server: '0',
openresty_default_limit_conn_per_ip: '0',
openresty_default_limit_rate: '',
openresty_default_limit_req_per_ip: '',
};
function optionsToMap(options: Array<{ key: string; value: string }>) {
@@ -55,6 +58,7 @@ function mapOptionsToFields(
optionMap[KEY_CONN_PER_SERVER] ?? '0',
openresty_default_limit_conn_per_ip: optionMap[KEY_CONN_PER_IP] ?? '0',
openresty_default_limit_rate: optionMap[KEY_LIMIT_RATE] ?? '',
openresty_default_limit_req_per_ip: optionMap[KEY_LIMIT_REQ_PER_IP] ?? '',
};
}
@@ -71,6 +75,13 @@ function validateFields(fields: RateLimitFields) {
if (rate && rate !== '0' && !limitRatePattern.test(rate)) {
throw new Error('限速格式不合法,请使用 512k、1m、纯数字,或留空关闭');
}
const reqRate = fields.openresty_default_limit_req_per_ip.trim();
if (reqRate && reqRate !== '0' && !/^\d+r\/[sm]$/i.test(reqRate)) {
throw new Error(
'请求频率限制格式不合法,请使用类似 10r/s、100r/m,或留空关闭',
);
}
}
function normalizeConnValue(value: string) {
@@ -119,6 +130,10 @@ export function ConfigTab() {
key: KEY_LIMIT_RATE,
value: normalizeRateValue(fields.openresty_default_limit_rate),
},
{
key: KEY_LIMIT_REQ_PER_IP,
value: normalizeRateValue(fields.openresty_default_limit_req_per_ip),
},
]);
},
onSuccess: async () => {
@@ -256,6 +271,25 @@ export function ConfigTab() {
单请求带宽默认值,例如 512k 或 1m
</p>
</div>
<div className='space-y-1.5'>
<Label className='text-xs text-muted-foreground'>
默认单 IP 请求频率限制
</Label>
<Input
value={fields.openresty_default_limit_req_per_ip}
placeholder='10r/s / 100r/m'
onChange={(e) =>
updateField(
'openresty_default_limit_req_per_ip',
e.target.value,
)
}
className='h-9 text-xs'
/>
<p className='text-xs text-muted-foreground'>
单个 IP 请求频率默认值,例如 10r/s 或 100r/m,留空关闭
</p>
</div>
</CardContent>
</Card>
</div>
@@ -1,7 +1,8 @@
'use client';
import { useMemo } from 'react';
import { useCallback, useMemo, useRef } from 'react';
import type { EChartsOption } from 'echarts';
import type { EChartsType } from 'echarts/core';
import ReactECharts from 'echarts-for-react';
import { Clock } from 'lucide-react';
@@ -52,12 +53,120 @@ function formatRps(value: number) {
});
}
function peakOf(values: number[], startPercent = 0, endPercent = 100) {
if (values.length === 0) return 0;
const last = values.length - 1;
const startIdx = Math.max(
0,
Math.min(last, Math.floor((startPercent / 100) * last)),
);
const endIdx = Math.max(
startIdx,
Math.min(last, Math.ceil((endPercent / 100) * last)),
);
let peak = 0;
for (let i = startIdx; i <= endIdx; i += 1) {
const value = values[i];
if (Number.isFinite(value) && value > peak) {
peak = value;
}
}
return peak;
}
function rpsAxisMax(values: number[], startPercent = 0, endPercent = 100) {
const peak = peakOf(values, startPercent, endPercent);
return peak > 0 ? peak * 1.5 : 1;
}
function visitAxisMax(values: number[], startPercent = 0, endPercent = 100) {
if (values.length === 0) {
return calculateNiceAxisMax([]);
}
const last = values.length - 1;
const startIdx = Math.max(
0,
Math.min(last, Math.floor((startPercent / 100) * last)),
);
const endIdx = Math.max(
startIdx,
Math.min(last, Math.ceil((endPercent / 100) * last)),
);
return calculateNiceAxisMax(values.slice(startIdx, endIdx + 1));
}
function clampPercent(value: number) {
if (!Number.isFinite(value)) return 0;
return Math.min(100, Math.max(0, value));
}
function readZoomPercent(
params: {
start?: number;
end?: number;
startValue?: number | string;
endValue?: number | string;
batch?: Array<{
start?: number;
end?: number;
startValue?: number | string;
endValue?: number | string;
}>;
},
dataLength: number,
chart?: EChartsType,
): { start: number; end: number } {
const source = params.batch?.[0] ?? params;
if (
typeof source.start === 'number' &&
Number.isFinite(source.start) &&
typeof source.end === 'number' &&
Number.isFinite(source.end)
) {
const start = clampPercent(source.start);
const end = clampPercent(source.end);
return { start: Math.min(start, end), end: Math.max(start, end) };
}
const startValue =
typeof source.startValue === 'number' ? source.startValue : undefined;
const endValue =
typeof source.endValue === 'number' ? source.endValue : undefined;
if (startValue !== undefined && endValue !== undefined && dataLength > 1) {
const last = dataLength - 1;
const start = clampPercent((startValue / last) * 100);
const end = clampPercent((endValue / last) * 100);
return { start: Math.min(start, end), end: Math.max(start, end) };
}
const option = chart?.getOption() as
| {
dataZoom?: Array<{ start?: number; end?: number }>;
}
| undefined;
const zoom = option?.dataZoom?.[0];
if (
typeof zoom?.start === 'number' &&
typeof zoom?.end === 'number' &&
Number.isFinite(zoom.start) &&
Number.isFinite(zoom.end)
) {
const start = clampPercent(zoom.start);
const end = clampPercent(zoom.end);
return { start: Math.min(start, end), end: Math.max(start, end) };
}
return { start: 0, end: 100 };
}
type RatePressureChartProps = {
data?: AccessLogOverview;
hours: RateLimitRangeHours;
};
export function RatePressureChart({ data, hours }: RatePressureChartProps) {
const chartRef = useRef<InstanceType<typeof ReactECharts> | null>(null);
const bucketSeconds =
(data?.bucket_minutes && data.bucket_minutes > 0
? data.bucket_minutes
@@ -94,9 +203,62 @@ export function RatePressureChart({ data, hours }: RatePressureChartProps) {
return { labels, rpsValues, visitValues, rawTimes };
}, [bucketSeconds, data?.trends.requests, data?.trends.visits, hours]);
const applyVisibleAxisMax = useCallback(
(startPercent: number, endPercent: number) => {
const instance = chartRef.current?.getEchartsInstance?.() as
| EChartsType
| undefined;
if (!instance) return;
instance.setOption(
{
yAxis: [
{ max: rpsAxisMax(chartModel.rpsValues, startPercent, endPercent) },
{
max: visitAxisMax(
chartModel.visitValues,
startPercent,
endPercent,
),
},
],
},
{ lazyUpdate: true },
);
},
[chartModel.rpsValues, chartModel.visitValues],
);
const onChartEvents = useMemo(
() => ({
datazoom: (params: {
start?: number;
end?: number;
startValue?: number | string;
endValue?: number | string;
batch?: Array<{
start?: number;
end?: number;
startValue?: number | string;
endValue?: number | string;
}>;
}) => {
const instance = chartRef.current?.getEchartsInstance?.() as
| EChartsType
| undefined;
const { start, end } = readZoomPercent(
params,
chartModel.rpsValues.length,
instance,
);
applyVisibleAxisMax(start, end);
},
}),
[applyVisibleAxisMax, chartModel.rpsValues.length],
);
const option = useMemo<EChartsOption>(() => {
const rpsMax = calculateNiceAxisMax(chartModel.rpsValues);
const visitMax = calculateNiceAxisMax(chartModel.visitValues);
const rpsMax = rpsAxisMax(chartModel.rpsValues);
const visitMax = visitAxisMax(chartModel.visitValues);
return {
animationDuration: 500,
@@ -262,9 +424,11 @@ export function RatePressureChart({ data, hours }: RatePressureChartProps) {
color: `${RPS_COLOR}33`,
},
},
filterMode: 'none',
},
{
type: 'inside',
filterMode: 'none',
},
],
series: [
@@ -330,9 +494,11 @@ export function RatePressureChart({ data, hours }: RatePressureChartProps) {
</div>
) : (
<ReactECharts
ref={chartRef}
option={option}
notMerge
lazyUpdate
onEvents={onChartEvents}
style={{ height: 360, width: '100%' }}
/>
)}
@@ -58,7 +58,7 @@ const ipGroupSchema = z
subscription_url: z.string(),
subscription_format: z.enum(['text', 'json']),
subscription_mapping_rule: z.string(),
sync_interval_minutes: z.number().int().min(5),
sync_interval_minutes: z.number().int().min(1),
})
.superRefine((value, context) => {
if (value.type === 'subscription' && !value.subscription_url.trim()) {
@@ -88,7 +88,11 @@ const defaultValues: IPGroupFormValues = {
type: 'manual',
enabled: true,
ip_list_text: '',
auto_config_text: '{}',
auto_config_text: JSON.stringify(
{ lookback: '1h', ttl: -1, rules: [] },
null,
2,
),
subscription_url: '',
subscription_format: 'text',
subscription_mapping_rule: '',
@@ -142,13 +146,23 @@ function appendAutomaticPresetRule(
(item as { expr?: unknown }).expr === rule.expr,
);
const nextRules = exists ? rules : [...rules, rule];
const lookback =
typeof config.lookback === 'string' && config.lookback.trim()
? config.lookback
: typeof config.lookback_minutes === 'number'
? `${config.lookback_minutes}m`
: '1h';
// strip legacy field so saved JSON only keeps lookback duration string
const {
lookback_minutes: _legacyLookbackMinutes,
lookback: _existingLookback,
...rest
} = config;
return JSON.stringify(
{
lookback_minutes:
typeof config.lookback_minutes === 'number'
? config.lookback_minutes
: 60,
...config,
...rest,
lookback,
ttl: typeof rest.ttl === 'number' ? rest.ttl : -1,
rules: nextRules,
},
null,
@@ -324,7 +338,7 @@ export function IPGroupDialog({
<FormControl>
<Input
type='number'
min={5}
min={1}
value={field.value}
onChange={(event) =>
field.onChange(Number(event.target.value))
@@ -332,7 +346,7 @@ export function IPGroupDialog({
/>
</FormControl>
<FormDescription>
最小 5 分钟,默认 1440 分钟。
最小 1 分钟,默认 1440 分钟。
</FormDescription>
<FormMessage />
</FormItem>
@@ -371,7 +385,7 @@ export function IPGroupDialog({
<FormControl>
<Input
type='number'
min={5}
min={1}
value={field.value}
onChange={(event) =>
field.onChange(Number(event.target.value))
@@ -379,7 +393,8 @@ export function IPGroupDialog({
/>
</FormControl>
<FormDescription>
定时从请求日志挖掘恶意 IP 的周期。最小 5 分钟。
定时从请求日志挖掘恶意 IP 的周期。最小 1 分钟,默认 1440
分钟。
</FormDescription>
<FormMessage />
</FormItem>
@@ -42,8 +42,8 @@ export function IPGroupTestDialog({
<div className='space-y-4'>
<div className='rounded-lg border border-dashed p-4 text-sm'>
<p>
回看 {result.lookback_minutes} 分钟 · 规则 {result.rule_count}{' '}
条 · 命中 {result.matched_count} 个 IP
回看 {result.lookback} · 规则 {result.rule_count} 条 · 命中{' '}
{result.matched_count} 个 IP
</p>
<p className='text-xs text-muted-foreground mt-1'>
测试时间:{new Date(result.tested_at).toLocaleString()}
@@ -335,7 +335,7 @@ export function RuleFlowCanvas({
);
const contextCanDeleteEdge = Boolean(
contextMenu?.kind === 'edge' &&
graph.edges.some((edge) => edge.id === contextMenu.id),
graph.edges.some((edge) => edge.id === contextMenu.id),
);
return (
+5 -5
View File
@@ -1,5 +1,5 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@import "tailwindcss";
@import "tw-animate-css";
@plugin "@tailwindcss/typography";
@custom-variant dark (&:is(.dark *));
@@ -7,10 +7,10 @@
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: 'Inter', 'PingFang SC', 'Microsoft YaHei', sans-serif;
--font-sans: "Inter", "PingFang SC", "Microsoft YaHei", sans-serif;
--font-mono:
var(--font-geist-mono), 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono',
'PingFang SC', 'Microsoft YaHei', monospace;
var(--font-geist-mono), "SF Mono", "Monaco", "Inconsolata", "Roboto Mono",
"PingFang SC", "Microsoft YaHei", monospace;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+62
View File
@@ -0,0 +1,62 @@
{
"$schema": "https://biomejs.dev/schemas/2.5.5/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": false,
"root": ".."
},
"files": {
"ignoreUnknown": true,
"includes": [
"**",
"!!**/.next",
"!!**/node_modules",
"!!**/out",
"!!**/coverage",
"!!**/public",
"!!**/next-env.d.ts",
"!!**/pnpm-lock.yaml",
"!!**/.pnpm-store"
]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 80,
"lineEnding": "lf"
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"jsxQuoteStyle": "single",
"semicolons": "always",
"trailingCommas": "all",
"arrowParentheses": "always",
"bracketSpacing": true,
"bracketSameLine": false
}
},
"css": {
"parser": {
"cssModules": false,
"allowWrongLineComments": false,
"tailwindDirectives": true
},
"formatter": {
"enabled": true
}
},
"json": {
"formatter": {
"trailingCommas": "none"
}
},
"linter": {
"enabled": false
},
"assist": {
"enabled": false
}
}
+4 -1
View File
@@ -177,7 +177,10 @@ export interface DispatchTaskRequest {
}
export type TaskExecutionStatus =
'pending' | 'running' | 'succeeded' | 'failed';
| 'pending'
| 'running'
| 'succeeded'
| 'failed';
/**
* 任务执行记录
@@ -59,7 +59,8 @@ export class DbManageService extends BaseService {
} as InternalAxiosRequestConfig);
const disposition = response.headers['content-disposition'] as
string | undefined;
| string
| undefined;
let filename = 'openflare_export';
if (disposition) {
const match = disposition.match(/filename="?([^";]+)"?/);
@@ -167,7 +167,9 @@ function normalizeDiskIOTrendPoints(
function normalizeDashboardNodes(
items:
Array<DashboardNodeHealth | CompactDashboardNodeHealth> | null | undefined,
| Array<DashboardNodeHealth | CompactDashboardNodeHealth>
| null
| undefined,
): DashboardNodeHealth[] {
return arrayOrEmpty(items).map((item) =>
isCompactDashboardNode(item)
+32 -10
View File
@@ -185,7 +185,12 @@ export interface NodeObservability {
}
export type ProxyRouteConfigSection =
'domains' | 'limits' | 'proxy' | 'cache' | 'waf' | 'auth';
| 'domains'
| 'limits'
| 'proxy'
| 'cache'
| 'waf'
| 'auth';
export interface ProxyRouteCustomHeader {
key: string;
@@ -233,6 +238,7 @@ export interface ProxyRouteItem {
limit_conn_per_server: number;
limit_conn_per_ip: number;
limit_rate: string;
limit_req_per_ip: string;
cache_enabled: boolean;
cache_policy: string;
cache_rules: string;
@@ -269,6 +275,7 @@ export interface ProxyRouteMutationPayload {
limit_conn_per_server?: number;
limit_conn_per_ip?: number;
limit_rate?: string;
limit_req_per_ip?: string;
cache_enabled: boolean;
cache_policy: string;
cache_rules: string[];
@@ -398,7 +405,10 @@ export interface PagesDeployment {
source_type: 'manual_upload' | 'manual_url' | 'remote_url' | 'github_release';
source_label: string;
trigger_type:
'manual_upload' | 'manual_url' | 'manual_sync' | 'scheduled_auto_update';
| 'manual_upload'
| 'manual_url'
| 'manual_sync'
| 'scheduled_auto_update';
created_at: string;
activated_at?: string | null;
}
@@ -458,7 +468,12 @@ export interface PagesDeploymentUploadFromURLPayload {
}
export type PagesSourceStatus =
'idle' | 'checking' | 'update_available' | 'syncing' | 'failed' | 'attention';
| 'idle'
| 'checking'
| 'update_available'
| 'syncing'
| 'failed'
| 'attention';
export type PagesGitHubReleaseSelector = 'latest' | 'tag';
@@ -510,14 +525,17 @@ interface PagesGitHubTagReleaseSource extends PagesGitHubReleaseSourceBase {
}
export type PagesGitHubReleaseSource =
PagesGitHubLatestReleaseSource | PagesGitHubTagReleaseSource;
| PagesGitHubLatestReleaseSource
| PagesGitHubTagReleaseSource;
/**
* 部署源使用判别联合,后续仓库构建来源只需增加独立 git_repository variant,
* 不需要向 Remote 或 GitHub Release 填入构建字段。
*/
export type PagesSource =
PagesManualSource | PagesRemoteURLSource | PagesGitHubReleaseSource;
| PagesManualSource
| PagesRemoteURLSource
| PagesGitHubReleaseSource;
export interface PagesRemoteSourceUpdatePayload {
source_type: 'remote_url';
@@ -531,14 +549,16 @@ interface PagesGitHubSourceUpdateBase {
asset_name: string;
}
export interface PagesGitHubLatestSourceUpdatePayload extends PagesGitHubSourceUpdateBase {
export interface PagesGitHubLatestSourceUpdatePayload
extends PagesGitHubSourceUpdateBase {
release_selector: 'latest';
release_tag: '';
auto_update_enabled: boolean;
check_interval_minutes: number;
}
export interface PagesGitHubTagSourceUpdatePayload extends PagesGitHubSourceUpdateBase {
export interface PagesGitHubTagSourceUpdatePayload
extends PagesGitHubSourceUpdateBase {
release_selector: 'tag';
release_tag: string;
auto_update_enabled: false;
@@ -546,14 +566,16 @@ export interface PagesGitHubTagSourceUpdatePayload extends PagesGitHubSourceUpda
}
export type PagesGitHubSourceUpdatePayload =
PagesGitHubLatestSourceUpdatePayload | PagesGitHubTagSourceUpdatePayload;
| PagesGitHubLatestSourceUpdatePayload
| PagesGitHubTagSourceUpdatePayload;
/**
* Source 更新请求保持 Provider 判别联合;未来仓库拉取构建使用独立 git_repository variant,
* 不向 Remote URL 或 GitHub Release payload 混入构建字段。
*/
export type PagesSourceUpdatePayload =
PagesRemoteSourceUpdatePayload | PagesGitHubSourceUpdatePayload;
| PagesRemoteSourceUpdatePayload
| PagesGitHubSourceUpdatePayload;
export interface PagesSourceActionPayload {
confirmed_revision?: string;
@@ -1114,7 +1136,7 @@ export interface WAFIPGroupAutoTestPayload {
export interface WAFIPGroupAutoTestResult {
matched_ips: string[];
matched_count: number;
lookback_minutes: number;
lookback: string;
rule_count: number;
tested_at: string;
}
+3 -2
View File
@@ -10,7 +10,8 @@
"build:embed": "NEXT_STANDALONE_EXPORT=true next build",
"start": "next start -p 3010",
"lint": "eslint",
"format": "eslint . --fix --max-warnings 0 && prettier --write ."
"format": "biome format --write .",
"format:check": "biome format ."
},
"dependencies": {
"@codemirror/lang-sql": "^6.10.0",
@@ -82,6 +83,7 @@
"zod": "^4.1.12"
},
"devDependencies": {
"@biomejs/biome": "^2.5.5",
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.19",
@@ -95,7 +97,6 @@
"eslint-config-next": "15.5.6",
"eslint-plugin-unused-imports": "^4.4.1",
"jsdom": "^29.1.1",
"prettier": "^3.9.5",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
+220 -129
View File
@@ -109,7 +109,7 @@ importers:
version: 12.11.2(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
axios:
specifier: ^1.15.0
version: 1.15.0
version: 1.15.0(debug@4.4.3(supports-color@7.2.0))
babel-plugin-react-compiler:
specifier: ^1.0.0
version: 1.0.0
@@ -181,7 +181,7 @@ importers:
version: 11.0.10(react@19.2.3)
react-markdown:
specifier: ^10.1.0
version: 10.1.0(@types/react@19.2.2)(react@19.2.3)
version: 10.1.0(@types/react@19.2.2)(react@19.2.3)(supports-color@7.2.0)
react-syntax-highlighter:
specifier: ^16.1.0
version: 16.1.0(react@19.2.3)
@@ -196,7 +196,7 @@ importers:
version: 6.0.0
remark-gfm:
specifier: ^4.0.1
version: 4.0.1
version: 4.0.1(supports-color@7.2.0)
sonner:
specifier: ^2.0.7
version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -210,9 +210,12 @@ importers:
specifier: ^4.1.12
version: 4.1.12
devDependencies:
'@biomejs/biome':
specifier: ^2.5.5
version: 2.5.5
'@eslint/eslintrc':
specifier: ^3
version: 3.3.1
version: 3.3.1(supports-color@7.2.0)
'@tailwindcss/postcss':
specifier: ^4
version: 4.1.16
@@ -239,19 +242,16 @@ importers:
version: 19.2.2(@types/react@19.2.2)
eslint:
specifier: ^9
version: 9.39.1(jiti@2.6.1)
version: 9.39.1(jiti@2.6.1)(supports-color@7.2.0)
eslint-config-next:
specifier: 15.5.6
version: 15.5.6(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
version: 15.5.6(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
eslint-plugin-unused-imports:
specifier: ^4.4.1
version: 4.4.1(@typescript-eslint/eslint-plugin@8.46.3(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))
version: 4.4.1(@typescript-eslint/eslint-plugin@8.46.3(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))
jsdom:
specifier: ^29.1.1
version: 29.1.1
prettier:
specifier: ^3.9.5
version: 3.9.5
tailwindcss:
specifier: ^4
version: 4.1.16
@@ -313,6 +313,63 @@ packages:
resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==}
engines: {node: '>=6.9.0'}
'@biomejs/biome@2.5.5':
resolution: {integrity: sha512-r1S8nFsAG1MY+vJFZALzIvwXAJv6ejDQ0mxP21Tgr9YK3ZFtjrvbBwDdNhx1rUqvccEIeNg20cYCNzl6Cr69pQ==}
engines: {node: '>=14.21.3'}
hasBin: true
'@biomejs/cli-darwin-arm64@2.5.5':
resolution: {integrity: sha512-kUrAhXVWUrwmAUnV2iXSK7umxKFysTwvqK+Ty6ptUcLY/7T3SnCAjUowE4uvwaEej6nXZ7hu/dTtbokKdsPeag==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [darwin]
'@biomejs/cli-darwin-x64@2.5.5':
resolution: {integrity: sha512-DamiYc5bUYZ2uxlfc+RLEPtz1Abb6PO5eTbOkufLpSGwd/7AMQAdxhFYiXmwwkJL8IsT8S7GvdgwDHqaMFAvKw==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [darwin]
'@biomejs/cli-linux-arm64-musl@2.5.5':
resolution: {integrity: sha512-U4WMl/sy/E/Q73vf15VspakLRRs2LDFcCeBxJnQfXzssb88zpV6PJPaQ3ezhQ7H6Ht2/8bvuZeHgJWzmoxllZg==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@biomejs/cli-linux-arm64@2.5.5':
resolution: {integrity: sha512-lRKF/pH/1RiYiBKExi3TCZVAtvzEm77aifrvcNiDFrR9WxeAnDUjDnseb6y2XV85mjitLs6SILGm2XG77cHtSQ==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@biomejs/cli-linux-x64-musl@2.5.5':
resolution: {integrity: sha512-m7wC7tjX5Lrmo69dc4md8FeKpPU1NTCY1v7xUoQQ2vadWwNnBS0KZOG8471otFPHrTHihQJAjQPgMObpLvDe6A==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
libc: [musl]
'@biomejs/cli-linux-x64@2.5.5':
resolution: {integrity: sha512-H/O39nJEw/2Zm/fm7hrmxxoF8kK/aU1uCoPp70ruXVbomaAdLpJJnCmL11Q2JotT8QVHH06So04Oq53lCSwSwQ==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@biomejs/cli-win32-arm64@2.5.5':
resolution: {integrity: sha512-7BryINPuYypLUAH3o/o5ZdgomJ4zn3EDR0ChZJst7n32S6ZhKbgHXuYydLu+YAnx59ehGFR0z/MG6qnzQi3Yyw==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [win32]
'@biomejs/cli-win32-x64@2.5.5':
resolution: {integrity: sha512-bIBFo+n6MIxdNcVFy5CrurbKiZQiUciK3bt8+O9I4wjFZNTfXLpi+giq47522eXqW5NBc9ulx7dR1SlZKi2J5g==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [win32]
'@bramus/specificity@2.4.2':
resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
hasBin: true
@@ -3828,11 +3885,6 @@ packages:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
prettier@3.9.5:
resolution: {integrity: sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==}
engines: {node: '>=14'}
hasBin: true
pretty-format@27.5.1:
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
@@ -4631,6 +4683,41 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@biomejs/biome@2.5.5':
optionalDependencies:
'@biomejs/cli-darwin-arm64': 2.5.5
'@biomejs/cli-darwin-x64': 2.5.5
'@biomejs/cli-linux-arm64': 2.5.5
'@biomejs/cli-linux-arm64-musl': 2.5.5
'@biomejs/cli-linux-x64': 2.5.5
'@biomejs/cli-linux-x64-musl': 2.5.5
'@biomejs/cli-win32-arm64': 2.5.5
'@biomejs/cli-win32-x64': 2.5.5
'@biomejs/cli-darwin-arm64@2.5.5':
optional: true
'@biomejs/cli-darwin-x64@2.5.5':
optional: true
'@biomejs/cli-linux-arm64-musl@2.5.5':
optional: true
'@biomejs/cli-linux-arm64@2.5.5':
optional: true
'@biomejs/cli-linux-x64-musl@2.5.5':
optional: true
'@biomejs/cli-linux-x64@2.5.5':
optional: true
'@biomejs/cli-win32-arm64@2.5.5':
optional: true
'@biomejs/cli-win32-x64@2.5.5':
optional: true
'@bramus/specificity@2.4.2':
dependencies:
css-tree: 3.2.1
@@ -4787,17 +4874,17 @@ snapshots:
tslib: 2.8.1
optional: true
'@eslint-community/eslint-utils@4.9.0(eslint@9.39.1(jiti@2.6.1))':
'@eslint-community/eslint-utils@4.9.0(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))':
dependencies:
eslint: 9.39.1(jiti@2.6.1)
eslint: 9.39.1(jiti@2.6.1)(supports-color@7.2.0)
eslint-visitor-keys: 3.4.3
'@eslint-community/regexpp@4.12.2': {}
'@eslint/config-array@0.21.1':
'@eslint/config-array@0.21.1(supports-color@7.2.0)':
dependencies:
'@eslint/object-schema': 2.1.7
debug: 4.4.3
debug: 4.4.3(supports-color@7.2.0)
minimatch: 3.1.2
transitivePeerDependencies:
- supports-color
@@ -4810,10 +4897,10 @@ snapshots:
dependencies:
'@types/json-schema': 7.0.15
'@eslint/eslintrc@3.3.1':
'@eslint/eslintrc@3.3.1(supports-color@7.2.0)':
dependencies:
ajv: 6.12.6
debug: 4.4.3
debug: 4.4.3(supports-color@7.2.0)
espree: 10.4.0
globals: 14.0.0
ignore: 5.3.2
@@ -6186,15 +6273,15 @@ snapshots:
'@types/unist@3.0.3': {}
'@typescript-eslint/eslint-plugin@8.46.3(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)':
'@typescript-eslint/eslint-plugin@8.46.3(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/parser': 8.46.3(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
'@typescript-eslint/scope-manager': 8.46.3
'@typescript-eslint/type-utils': 8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/utils': 8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/type-utils': 8.46.3(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
'@typescript-eslint/utils': 8.46.3(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.46.3
eslint: 9.39.1(jiti@2.6.1)
eslint: 9.39.1(jiti@2.6.1)(supports-color@7.2.0)
graphemer: 1.4.0
ignore: 7.0.5
natural-compare: 1.4.0
@@ -6203,23 +6290,23 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)':
'@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)':
dependencies:
'@typescript-eslint/scope-manager': 8.46.3
'@typescript-eslint/types': 8.46.3
'@typescript-eslint/typescript-estree': 8.46.3(typescript@5.9.3)
'@typescript-eslint/typescript-estree': 8.46.3(supports-color@7.2.0)(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.46.3
debug: 4.4.3
eslint: 9.39.1(jiti@2.6.1)
debug: 4.4.3(supports-color@7.2.0)
eslint: 9.39.1(jiti@2.6.1)(supports-color@7.2.0)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/project-service@8.46.3(typescript@5.9.3)':
'@typescript-eslint/project-service@8.46.3(supports-color@7.2.0)(typescript@5.9.3)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.46.3(typescript@5.9.3)
'@typescript-eslint/types': 8.46.3
debug: 4.4.3
debug: 4.4.3(supports-color@7.2.0)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
@@ -6233,13 +6320,13 @@ snapshots:
dependencies:
typescript: 5.9.3
'@typescript-eslint/type-utils@8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)':
'@typescript-eslint/type-utils@8.46.3(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)':
dependencies:
'@typescript-eslint/types': 8.46.3
'@typescript-eslint/typescript-estree': 8.46.3(typescript@5.9.3)
'@typescript-eslint/utils': 8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
debug: 4.4.3
eslint: 9.39.1(jiti@2.6.1)
'@typescript-eslint/typescript-estree': 8.46.3(supports-color@7.2.0)(typescript@5.9.3)
'@typescript-eslint/utils': 8.46.3(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
debug: 4.4.3(supports-color@7.2.0)
eslint: 9.39.1(jiti@2.6.1)(supports-color@7.2.0)
ts-api-utils: 2.1.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
@@ -6247,13 +6334,13 @@ snapshots:
'@typescript-eslint/types@8.46.3': {}
'@typescript-eslint/typescript-estree@8.46.3(typescript@5.9.3)':
'@typescript-eslint/typescript-estree@8.46.3(supports-color@7.2.0)(typescript@5.9.3)':
dependencies:
'@typescript-eslint/project-service': 8.46.3(typescript@5.9.3)
'@typescript-eslint/project-service': 8.46.3(supports-color@7.2.0)(typescript@5.9.3)
'@typescript-eslint/tsconfig-utils': 8.46.3(typescript@5.9.3)
'@typescript-eslint/types': 8.46.3
'@typescript-eslint/visitor-keys': 8.46.3
debug: 4.4.3
debug: 4.4.3(supports-color@7.2.0)
fast-glob: 3.3.3
is-glob: 4.0.3
minimatch: 9.0.5
@@ -6263,13 +6350,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/utils@8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)':
'@typescript-eslint/utils@8.46.3(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)':
dependencies:
'@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1))
'@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))
'@typescript-eslint/scope-manager': 8.46.3
'@typescript-eslint/types': 8.46.3
'@typescript-eslint/typescript-estree': 8.46.3(typescript@5.9.3)
eslint: 9.39.1(jiti@2.6.1)
'@typescript-eslint/typescript-estree': 8.46.3(supports-color@7.2.0)(typescript@5.9.3)
eslint: 9.39.1(jiti@2.6.1)(supports-color@7.2.0)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
@@ -6547,9 +6634,9 @@ snapshots:
axe-core@4.11.0: {}
axios@1.15.0:
axios@1.15.0(debug@4.4.3(supports-color@7.2.0)):
dependencies:
follow-redirects: 1.15.11
follow-redirects: 1.15.11(debug@4.4.3(supports-color@7.2.0))
form-data: 4.0.5
proxy-from-env: 2.1.0
transitivePeerDependencies:
@@ -6809,13 +6896,17 @@ snapshots:
date-fns@4.1.0: {}
debug@3.2.7:
debug@3.2.7(supports-color@7.2.0):
dependencies:
ms: 2.1.3
optionalDependencies:
supports-color: 7.2.0
debug@4.4.3:
debug@4.4.3(supports-color@7.2.0):
dependencies:
ms: 2.1.3
optionalDependencies:
supports-color: 7.2.0
decimal.js-light@2.5.1: {}
@@ -7015,19 +7106,19 @@ snapshots:
escape-string-regexp@5.0.0: {}
eslint-config-next@15.5.6(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3):
eslint-config-next@15.5.6(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3):
dependencies:
'@next/eslint-plugin-next': 15.5.6
'@rushstack/eslint-patch': 1.14.1
'@typescript-eslint/eslint-plugin': 8.46.3(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/parser': 8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
eslint: 9.39.1(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1))
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1))
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.1(jiti@2.6.1))
eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@2.6.1))
eslint-plugin-react-hooks: 5.2.0(eslint@9.39.1(jiti@2.6.1))
'@typescript-eslint/eslint-plugin': 8.46.3(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
'@typescript-eslint/parser': 8.46.3(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
eslint: 9.39.1(jiti@2.6.1)(supports-color@7.2.0)
eslint-import-resolver-node: 0.3.9(supports-color@7.2.0)
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))
eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))
eslint-plugin-react-hooks: 5.2.0(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))
optionalDependencies:
typescript: 5.9.3
transitivePeerDependencies:
@@ -7035,52 +7126,52 @@ snapshots:
- eslint-plugin-import-x
- supports-color
eslint-import-resolver-node@0.3.9:
eslint-import-resolver-node@0.3.9(supports-color@7.2.0):
dependencies:
debug: 3.2.7
debug: 3.2.7(supports-color@7.2.0)
is-core-module: 2.16.1
resolve: 1.22.11
transitivePeerDependencies:
- supports-color
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)):
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.3
eslint: 9.39.1(jiti@2.6.1)
debug: 4.4.3(supports-color@7.2.0)
eslint: 9.39.1(jiti@2.6.1)(supports-color@7.2.0)
get-tsconfig: 4.13.0
is-bun-module: 2.0.0
stable-hash: 0.0.5
tinyglobby: 0.2.15
unrs-resolver: 1.11.1
optionalDependencies:
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1))
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)):
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9(supports-color@7.2.0))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0):
dependencies:
debug: 3.2.7
debug: 3.2.7(supports-color@7.2.0)
optionalDependencies:
'@typescript-eslint/parser': 8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
eslint: 9.39.1(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1))
'@typescript-eslint/parser': 8.46.3(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
eslint: 9.39.1(jiti@2.6.1)(supports-color@7.2.0)
eslint-import-resolver-node: 0.3.9(supports-color@7.2.0)
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)
transitivePeerDependencies:
- supports-color
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)):
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
array.prototype.findlastindex: 1.2.6
array.prototype.flat: 1.3.3
array.prototype.flatmap: 1.3.3
debug: 3.2.7
debug: 3.2.7(supports-color@7.2.0)
doctrine: 2.1.0
eslint: 9.39.1(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1))
eslint: 9.39.1(jiti@2.6.1)(supports-color@7.2.0)
eslint-import-resolver-node: 0.3.9(supports-color@7.2.0)
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9(supports-color@7.2.0))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3
@@ -7092,13 +7183,13 @@ snapshots:
string.prototype.trimend: 1.0.9
tsconfig-paths: 3.15.0
optionalDependencies:
'@typescript-eslint/parser': 8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/parser': 8.46.3(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
transitivePeerDependencies:
- eslint-import-resolver-typescript
- eslint-import-resolver-webpack
- supports-color
eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.1(jiti@2.6.1)):
eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0)):
dependencies:
aria-query: 5.3.2
array-includes: 3.1.9
@@ -7108,7 +7199,7 @@ snapshots:
axobject-query: 4.1.0
damerau-levenshtein: 1.0.8
emoji-regex: 9.2.2
eslint: 9.39.1(jiti@2.6.1)
eslint: 9.39.1(jiti@2.6.1)(supports-color@7.2.0)
hasown: 2.0.2
jsx-ast-utils: 3.3.5
language-tags: 1.0.9
@@ -7117,11 +7208,11 @@ snapshots:
safe-regex-test: 1.1.0
string.prototype.includes: 2.0.1
eslint-plugin-react-hooks@5.2.0(eslint@9.39.1(jiti@2.6.1)):
eslint-plugin-react-hooks@5.2.0(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0)):
dependencies:
eslint: 9.39.1(jiti@2.6.1)
eslint: 9.39.1(jiti@2.6.1)(supports-color@7.2.0)
eslint-plugin-react@7.37.5(eslint@9.39.1(jiti@2.6.1)):
eslint-plugin-react@7.37.5(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0)):
dependencies:
array-includes: 3.1.9
array.prototype.findlast: 1.2.5
@@ -7129,7 +7220,7 @@ snapshots:
array.prototype.tosorted: 1.1.4
doctrine: 2.1.0
es-iterator-helpers: 1.2.1
eslint: 9.39.1(jiti@2.6.1)
eslint: 9.39.1(jiti@2.6.1)(supports-color@7.2.0)
estraverse: 5.3.0
hasown: 2.0.2
jsx-ast-utils: 3.3.5
@@ -7143,11 +7234,11 @@ snapshots:
string.prototype.matchall: 4.0.12
string.prototype.repeat: 1.0.0
eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.46.3(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)):
eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.46.3(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0)):
dependencies:
eslint: 9.39.1(jiti@2.6.1)
eslint: 9.39.1(jiti@2.6.1)(supports-color@7.2.0)
optionalDependencies:
'@typescript-eslint/eslint-plugin': 8.46.3(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/eslint-plugin': 8.46.3(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
eslint-scope@8.4.0:
dependencies:
@@ -7158,14 +7249,14 @@ snapshots:
eslint-visitor-keys@4.2.1: {}
eslint@9.39.1(jiti@2.6.1):
eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0):
dependencies:
'@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1))
'@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1)(supports-color@7.2.0))
'@eslint-community/regexpp': 4.12.2
'@eslint/config-array': 0.21.1
'@eslint/config-array': 0.21.1(supports-color@7.2.0)
'@eslint/config-helpers': 0.4.2
'@eslint/core': 0.17.0
'@eslint/eslintrc': 3.3.1
'@eslint/eslintrc': 3.3.1(supports-color@7.2.0)
'@eslint/js': 9.39.1
'@eslint/plugin-kit': 0.4.1
'@humanfs/node': 0.16.7
@@ -7175,7 +7266,7 @@ snapshots:
ajv: 6.12.6
chalk: 4.1.2
cross-spawn: 7.0.6
debug: 4.4.3
debug: 4.4.3(supports-color@7.2.0)
escape-string-regexp: 4.0.0
eslint-scope: 8.4.0
eslint-visitor-keys: 4.2.1
@@ -7297,7 +7388,9 @@ snapshots:
flatted@3.3.3: {}
follow-redirects@1.15.11: {}
follow-redirects@1.15.11(debug@4.4.3(supports-color@7.2.0)):
optionalDependencies:
debug: 4.4.3(supports-color@7.2.0)
for-each@0.3.5:
dependencies:
@@ -7427,7 +7520,7 @@ snapshots:
dependencies:
'@types/hast': 3.0.4
hast-util-to-jsx-runtime@2.3.6:
hast-util-to-jsx-runtime@2.3.6(supports-color@7.2.0):
dependencies:
'@types/estree': 1.0.8
'@types/hast': 3.0.4
@@ -7436,9 +7529,9 @@ snapshots:
devlop: 1.1.0
estree-util-is-identifier-name: 3.0.0
hast-util-whitespace: 3.0.0
mdast-util-mdx-expression: 2.0.1
mdast-util-mdx-jsx: 3.2.0
mdast-util-mdxjs-esm: 2.0.1
mdast-util-mdx-expression: 2.0.1(supports-color@7.2.0)
mdast-util-mdx-jsx: 3.2.0(supports-color@7.2.0)
mdast-util-mdxjs-esm: 2.0.1(supports-color@7.2.0)
property-information: 7.1.0
space-separated-tokens: 2.0.2
style-to-js: 1.1.21
@@ -7883,14 +7976,14 @@ snapshots:
unist-util-is: 6.0.1
unist-util-visit-parents: 6.0.2
mdast-util-from-markdown@2.0.2:
mdast-util-from-markdown@2.0.2(supports-color@7.2.0):
dependencies:
'@types/mdast': 4.0.4
'@types/unist': 3.0.3
decode-named-character-reference: 1.2.0
devlop: 1.1.0
mdast-util-to-string: 4.0.0
micromark: 4.0.2
micromark: 4.0.2(supports-color@7.2.0)
micromark-util-decode-numeric-character-reference: 2.0.2
micromark-util-decode-string: 2.0.1
micromark-util-normalize-identifier: 2.0.1
@@ -7908,67 +8001,67 @@ snapshots:
mdast-util-find-and-replace: 3.0.2
micromark-util-character: 2.1.1
mdast-util-gfm-footnote@2.1.0:
mdast-util-gfm-footnote@2.1.0(supports-color@7.2.0):
dependencies:
'@types/mdast': 4.0.4
devlop: 1.1.0
mdast-util-from-markdown: 2.0.2
mdast-util-from-markdown: 2.0.2(supports-color@7.2.0)
mdast-util-to-markdown: 2.1.2
micromark-util-normalize-identifier: 2.0.1
transitivePeerDependencies:
- supports-color
mdast-util-gfm-strikethrough@2.0.0:
mdast-util-gfm-strikethrough@2.0.0(supports-color@7.2.0):
dependencies:
'@types/mdast': 4.0.4
mdast-util-from-markdown: 2.0.2
mdast-util-from-markdown: 2.0.2(supports-color@7.2.0)
mdast-util-to-markdown: 2.1.2
transitivePeerDependencies:
- supports-color
mdast-util-gfm-table@2.0.0:
mdast-util-gfm-table@2.0.0(supports-color@7.2.0):
dependencies:
'@types/mdast': 4.0.4
devlop: 1.1.0
markdown-table: 3.0.4
mdast-util-from-markdown: 2.0.2
mdast-util-from-markdown: 2.0.2(supports-color@7.2.0)
mdast-util-to-markdown: 2.1.2
transitivePeerDependencies:
- supports-color
mdast-util-gfm-task-list-item@2.0.0:
mdast-util-gfm-task-list-item@2.0.0(supports-color@7.2.0):
dependencies:
'@types/mdast': 4.0.4
devlop: 1.1.0
mdast-util-from-markdown: 2.0.2
mdast-util-from-markdown: 2.0.2(supports-color@7.2.0)
mdast-util-to-markdown: 2.1.2
transitivePeerDependencies:
- supports-color
mdast-util-gfm@3.1.0:
mdast-util-gfm@3.1.0(supports-color@7.2.0):
dependencies:
mdast-util-from-markdown: 2.0.2
mdast-util-from-markdown: 2.0.2(supports-color@7.2.0)
mdast-util-gfm-autolink-literal: 2.0.1
mdast-util-gfm-footnote: 2.1.0
mdast-util-gfm-strikethrough: 2.0.0
mdast-util-gfm-table: 2.0.0
mdast-util-gfm-task-list-item: 2.0.0
mdast-util-gfm-footnote: 2.1.0(supports-color@7.2.0)
mdast-util-gfm-strikethrough: 2.0.0(supports-color@7.2.0)
mdast-util-gfm-table: 2.0.0(supports-color@7.2.0)
mdast-util-gfm-task-list-item: 2.0.0(supports-color@7.2.0)
mdast-util-to-markdown: 2.1.2
transitivePeerDependencies:
- supports-color
mdast-util-mdx-expression@2.0.1:
mdast-util-mdx-expression@2.0.1(supports-color@7.2.0):
dependencies:
'@types/estree-jsx': 1.0.5
'@types/hast': 3.0.4
'@types/mdast': 4.0.4
devlop: 1.1.0
mdast-util-from-markdown: 2.0.2
mdast-util-from-markdown: 2.0.2(supports-color@7.2.0)
mdast-util-to-markdown: 2.1.2
transitivePeerDependencies:
- supports-color
mdast-util-mdx-jsx@3.2.0:
mdast-util-mdx-jsx@3.2.0(supports-color@7.2.0):
dependencies:
'@types/estree-jsx': 1.0.5
'@types/hast': 3.0.4
@@ -7976,7 +8069,7 @@ snapshots:
'@types/unist': 3.0.3
ccount: 2.0.1
devlop: 1.1.0
mdast-util-from-markdown: 2.0.2
mdast-util-from-markdown: 2.0.2(supports-color@7.2.0)
mdast-util-to-markdown: 2.1.2
parse-entities: 4.0.2
stringify-entities: 4.0.4
@@ -7985,13 +8078,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
mdast-util-mdxjs-esm@2.0.1:
mdast-util-mdxjs-esm@2.0.1(supports-color@7.2.0):
dependencies:
'@types/estree-jsx': 1.0.5
'@types/hast': 3.0.4
'@types/mdast': 4.0.4
devlop: 1.1.0
mdast-util-from-markdown: 2.0.2
mdast-util-from-markdown: 2.0.2(supports-color@7.2.0)
mdast-util-to-markdown: 2.1.2
transitivePeerDependencies:
- supports-color
@@ -8202,10 +8295,10 @@ snapshots:
micromark-util-types@2.0.2: {}
micromark@4.0.2:
micromark@4.0.2(supports-color@7.2.0):
dependencies:
'@types/debug': 4.1.12
debug: 4.4.3
debug: 4.4.3(supports-color@7.2.0)
decode-named-character-reference: 1.2.0
devlop: 1.1.0
micromark-core-commonmark: 2.0.3
@@ -8438,8 +8531,6 @@ snapshots:
prelude-ls@1.2.1: {}
prettier@3.9.5: {}
pretty-format@27.5.1:
dependencies:
ansi-regex: 5.0.1
@@ -8563,17 +8654,17 @@ snapshots:
react-is@18.3.1: {}
react-markdown@10.1.0(@types/react@19.2.2)(react@19.2.3):
react-markdown@10.1.0(@types/react@19.2.2)(react@19.2.3)(supports-color@7.2.0):
dependencies:
'@types/hast': 3.0.4
'@types/mdast': 4.0.4
'@types/react': 19.2.2
devlop: 1.1.0
hast-util-to-jsx-runtime: 2.3.6
hast-util-to-jsx-runtime: 2.3.6(supports-color@7.2.0)
html-url-attributes: 3.0.1
mdast-util-to-hast: 13.2.1
react: 19.2.3
remark-parse: 11.0.0
remark-parse: 11.0.0(supports-color@7.2.0)
remark-rehype: 11.1.2
unified: 11.0.5
unist-util-visit: 5.0.0
@@ -8705,21 +8796,21 @@ snapshots:
hast-util-to-string: 3.0.1
unist-util-visit: 5.0.0
remark-gfm@4.0.1:
remark-gfm@4.0.1(supports-color@7.2.0):
dependencies:
'@types/mdast': 4.0.4
mdast-util-gfm: 3.1.0
mdast-util-gfm: 3.1.0(supports-color@7.2.0)
micromark-extension-gfm: 3.0.0
remark-parse: 11.0.0
remark-parse: 11.0.0(supports-color@7.2.0)
remark-stringify: 11.0.0
unified: 11.0.5
transitivePeerDependencies:
- supports-color
remark-parse@11.0.0:
remark-parse@11.0.0(supports-color@7.2.0):
dependencies:
'@types/mdast': 4.0.4
mdast-util-from-markdown: 2.0.2
mdast-util-from-markdown: 2.0.2(supports-color@7.2.0)
micromark-util-types: 2.0.2
unified: 11.0.5
transitivePeerDependencies:
@@ -531,6 +531,7 @@ func diffOpenRestyOptionDetails(left openRestyConfigSnapshot, right openRestyCon
appendIfChanged("OpenRestyDefaultLimitConnPerServer", fmt.Sprintf("%d", left.DefaultLimitConnPerServer), fmt.Sprintf("%d", right.DefaultLimitConnPerServer))
appendIfChanged("OpenRestyDefaultLimitConnPerIP", fmt.Sprintf("%d", left.DefaultLimitConnPerIP), fmt.Sprintf("%d", right.DefaultLimitConnPerIP))
appendIfChanged("OpenRestyDefaultLimitRate", left.DefaultLimitRate, right.DefaultLimitRate)
appendIfChanged("OpenRestyDefaultLimitReqPerIP", left.DefaultLimitReqPerIP, right.DefaultLimitReqPerIP)
return changes
}
@@ -582,5 +583,6 @@ func openRestyOptionKeys() []string {
"OpenRestyDefaultLimitConnPerServer",
"OpenRestyDefaultLimitConnPerIP",
"OpenRestyDefaultLimitRate",
"OpenRestyDefaultLimitReqPerIP",
}
}
@@ -54,6 +54,7 @@ type snapshotRoute struct {
LimitConnPerServer int `json:"limit_conn_per_server,omitempty"`
LimitConnPerIP int `json:"limit_conn_per_ip,omitempty"`
LimitRate string `json:"limit_rate,omitempty"`
LimitReqPerIP string `json:"limit_req_per_ip,omitempty"`
CacheEnabled bool `json:"cache_enabled"`
CachePolicy string `json:"cache_policy,omitempty"`
CacheRules []string `json:"cache_rules,omitempty"`
@@ -138,6 +139,7 @@ type openRestyConfigSnapshot struct {
DefaultLimitConnPerServer int `json:"default_limit_conn_per_server,omitempty"`
DefaultLimitConnPerIP int `json:"default_limit_conn_per_ip,omitempty"`
DefaultLimitRate string `json:"default_limit_rate,omitempty"`
DefaultLimitReqPerIP string `json:"default_limit_req_per_ip,omitempty"`
}
type snapshotDocument struct {
@@ -285,6 +287,7 @@ func buildSnapshotRoutes(ctx context.Context, routes []*model.ProxyRoute) ([]sna
LimitConnPerServer: route.LimitConnPerServer,
LimitConnPerIP: route.LimitConnPerIP,
LimitRate: route.LimitRate,
LimitReqPerIP: route.LimitReqPerIP,
CacheEnabled: route.CacheEnabled,
CachePolicy: route.CachePolicy,
CacheRules: cacheRules,
@@ -548,10 +551,14 @@ func buildOpenRestyConfigSnapshot(ctx context.Context) openRestyConfigSnapshot {
DefaultLimitConnPerServer: getNonNegIntConfig(model.ConfigKeyOpenRestyDefaultLimitConnPerServer, 0),
DefaultLimitConnPerIP: getNonNegIntConfig(model.ConfigKeyOpenRestyDefaultLimitConnPerIP, 0),
DefaultLimitRate: strings.ToLower(strings.TrimSpace(getStringConfig(model.ConfigKeyOpenRestyDefaultLimitRate, ""))),
DefaultLimitReqPerIP: strings.ToLower(strings.TrimSpace(getStringConfig(model.ConfigKeyOpenRestyDefaultLimitReqPerIP, ""))),
}
if snapshot.DefaultLimitRate == "0" {
snapshot.DefaultLimitRate = ""
}
if snapshot.DefaultLimitReqPerIP == "0" {
snapshot.DefaultLimitReqPerIP = ""
}
snapshot.CachePath = normalizeProxyCachePathForSnapshot(snapshot.CacheEnabled, snapshot.CachePath)
return snapshot
}
@@ -53,6 +53,7 @@ var openRestyOptionValidators = map[string]func(key, value string) error{
model.ConfigKeyOpenRestyDefaultLimitConnPerServer: validateNonNegativeIntegerOption,
model.ConfigKeyOpenRestyDefaultLimitConnPerIP: validateNonNegativeIntegerOption,
model.ConfigKeyOpenRestyDefaultLimitRate: validateOpenRestyDefaultLimitRate,
model.ConfigKeyOpenRestyDefaultLimitReqPerIP: validateOpenRestyDefaultLimitReqPerIP,
}
var openRestyDefaultLimitRatePattern = regexp.MustCompile(`^\d+[kKmM]?$`)
@@ -194,3 +195,15 @@ func validateOpenRestyDefaultLimitRate(key, trimmed string) error {
}
return nil
}
var openRestyDefaultLimitReqPerIPPattern = regexp.MustCompile(`^\d+r/[sm]$`)
func validateOpenRestyDefaultLimitReqPerIP(key, trimmed string) error {
if trimmed == "" || trimmed == "0" {
return nil
}
if !openRestyDefaultLimitReqPerIPPattern.MatchString(strings.ToLower(trimmed)) {
return fmt.Errorf("%s 格式不合法,请输入类似 10r/s、100r/m,或留空关闭", key)
}
return nil
}
@@ -90,7 +90,7 @@ func populateProxyRouteFields(
upstreams []string,
originHost, cachePolicy string,
limitConnPerServer, limitConnPerIP int,
limitRate, upstreamType string,
limitRate, limitReqPerIP, upstreamType string,
) {
route.SiteName = siteName
route.OriginID = originID
@@ -103,6 +103,7 @@ func populateProxyRouteFields(
route.LimitConnPerServer = limitConnPerServer
route.LimitConnPerIP = limitConnPerIP
route.LimitRate = limitRate
route.LimitReqPerIP = limitReqPerIP
route.CacheEnabled = input.CacheEnabled
route.CachePolicy = normalizeCachePolicy(input.CacheEnabled, cachePolicy)
route.CacheRules = jsonFields.cacheRulesJSON
@@ -23,6 +23,7 @@ import (
var proxyHeaderKeyPattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
var proxyRouteLimitRatePattern = regexp.MustCompile(`^\d+[kKmM]?$`)
var proxyRouteLimitReqPattern = regexp.MustCompile(`^\d+r/[sm]$`)
const (
proxyRouteCachePolicyStatic = "static"
@@ -349,6 +350,20 @@ func normalizeProxyRouteLimitRate(raw string) (string, error) {
return normalized, nil
}
func normalizeProxyRouteLimitReqPerIP(raw string) (string, error) {
normalized := strings.ToLower(strings.TrimSpace(raw))
if normalized == "" || normalized == "0" {
return "", nil
}
if normalized == "-1" {
return "-1", nil
}
if !proxyRouteLimitReqPattern.MatchString(normalized) {
return "", errors.New("请求频率格式不合法,请使用类似 10r/s、100r/m,或 -1 禁用")
}
return normalized, nil
}
func hasStructuredOriginInput(input Input) bool {
return (input.OriginID != nil && *input.OriginID != 0) ||
strings.TrimSpace(input.OriginScheme) != "" ||
@@ -40,6 +40,7 @@ type Input struct {
LimitConnPerServer int `json:"limit_conn_per_server"`
LimitConnPerIP int `json:"limit_conn_per_ip"`
LimitRate string `json:"limit_rate"`
LimitReqPerIP string `json:"limit_req_per_ip"`
CacheEnabled bool `json:"cache_enabled"`
CachePolicy string `json:"cache_policy"`
CacheRules []string `json:"cache_rules"`
@@ -72,6 +73,7 @@ type View struct {
LimitConnPerServer int `json:"limit_conn_per_server"`
LimitConnPerIP int `json:"limit_conn_per_ip"`
LimitRate string `json:"limit_rate"`
LimitReqPerIP string `json:"limit_req_per_ip"`
CacheEnabled bool `json:"cache_enabled"`
CachePolicy string `json:"cache_policy"`
CacheRules string `json:"cache_rules"`
@@ -267,6 +269,10 @@ func buildProxyRoute(ctx context.Context, route *model.ProxyRoute, input Input)
if err != nil {
return nil, nil, err
}
limitReqPerIP, err := normalizeProxyRouteLimitReqPerIP(input.LimitReqPerIP)
if err != nil {
return nil, nil, err
}
if err := validateProxyRouteZoneDomainCertificates(ctx, domains, input.EnableHTTPS); err != nil {
return nil, nil, err
}
@@ -307,6 +313,7 @@ func buildProxyRoute(ctx context.Context, route *model.ProxyRoute, input Input)
limitConnPerServer,
limitConnPerIP,
limitRate,
limitReqPerIP,
upstreamType,
)
if err := applyProxyRouteUpstreamType(ctx, route, upstreamType, input); err != nil {
@@ -369,6 +376,7 @@ func buildProxyRouteView(ctx context.Context, route *model.ProxyRoute) (*View, e
LimitConnPerServer: route.LimitConnPerServer,
LimitConnPerIP: route.LimitConnPerIP,
LimitRate: route.LimitRate,
LimitReqPerIP: route.LimitReqPerIP,
CacheEnabled: route.CacheEnabled,
CachePolicy: displayCachePolicy(route.CacheEnabled, route.CachePolicy),
CacheRules: route.CacheRules,
+73 -5
View File
@@ -40,18 +40,82 @@ type ipGroupAutoRuleEnv struct {
statusCounts map[int]int
}
func (env ipGroupAutoRuleEnv) StatusCount(code int) int {
func (env ipGroupAutoRuleEnv) StatusCount(code any) int {
if env.statusCounts == nil {
return 0
}
return env.statusCounts[code]
return countStatusMatches(env.statusCounts, code)
}
func (env ipGroupAutoRuleEnv) StatusRatio(code int) float64 {
func (env ipGroupAutoRuleEnv) StatusRatio(code any) float64 {
if env.RequestCount <= 0 || env.statusCounts == nil {
return 0.0
}
return float64(env.statusCounts[code]) / float64(env.RequestCount)
return float64(countStatusMatches(env.statusCounts, code)) / float64(env.RequestCount)
}
const maxHTTPStatusCodeDigits = 999
// countStatusMatches sums status counts for an exact code or class token.
// Accepted forms:
// - int / int64 / float64: exact status code (e.g. 404)
// - string digits: exact status code (e.g. "404")
// - string class: "1xx".."5xx" (case-insensitive), matching that hundred range
func countStatusMatches(statusCounts map[int]int, code any) int {
if statusCounts == nil {
return 0
}
switch v := code.(type) {
case int:
return statusCounts[v]
case int64:
if v < 0 || v > int64(maxHTTPStatusCodeDigits) {
return 0
}
return statusCounts[int(v)]
case float64:
if v != float64(int64(v)) || v < 0 || v > float64(maxHTTPStatusCodeDigits) {
return 0
}
return statusCounts[int(v)]
case string:
return countStatusMatchesString(statusCounts, v)
default:
return 0
}
}
func countStatusMatchesString(statusCounts map[int]int, raw string) int {
token := strings.TrimSpace(strings.ToLower(raw))
if token == "" {
return 0
}
if len(token) == 3 && token[1] == 'x' && token[2] == 'x' {
classDigit := token[0]
if classDigit < '1' || classDigit > '5' {
return 0
}
base := int(classDigit-'0') * 100
total := 0
for code, count := range statusCounts {
if code >= base && code < base+100 {
total += count
}
}
return total
}
// exact numeric string, e.g. "404"
var code int
for _, ch := range token {
if ch < '0' || ch > '9' {
return 0
}
code = code*10 + int(ch-'0')
if code > maxHTTPStatusCodeDigits {
return 0
}
}
return statusCounts[code]
}
type ipGroupAutoAccumulator struct {
@@ -234,8 +298,12 @@ func evaluateParsedIPGroupAutoConfig(ctx context.Context, config ipGroupAutoConf
}
programs = append(programs, program)
}
lookback := config.lookbackDuration
if lookback <= 0 {
lookback = defaultWAFIPGroupAutoLookbackDur
}
aggregates, err := model.ListOpenFlareAccessLogWAFIPAggregates(ctx, model.OpenFlareAccessLogQuery{
Since: now.Add(-time.Duration(config.LookbackMinutes) * time.Minute),
Since: now.Add(-lookback),
Until: now,
})
if err != nil {
@@ -96,7 +96,7 @@ func TestSyncIPGroupAutomaticExprRules(t *testing.T) {
Type: wafIPGroupTypeAutomatic,
Enabled: true,
AutoConfig: json.RawMessage(`{
"lookback_minutes": 60,
"lookback": "60m",
"rules": [
{"name":"单 IP 404 高频扫描","expr":"request_count > 100 && StatusRatio(404) >= 0.8"},
{"name":"单 IP 直连访问异常","expr":"ip_host_count > 50 && ip_host_ratio > 0.5"}
@@ -129,7 +129,7 @@ func TestTestIPGroupAutoConfigReturnsMatchedIPs(t *testing.T) {
result, err := TestIPGroupAutoConfig(ctx, IPGroupAutoTestInput{
AutoConfig: json.RawMessage(`{
"lookback_minutes": 60,
"lookback": "1h",
"rules": [
{"name":"单 IP 404 高频扫描","expr":"request_count > 100 && StatusRatio(404) >= 0.8"},
{"name":"单 IP 直连访问异常","expr":"ip_host_count > 50 && ip_host_ratio > 0.5"}
@@ -139,7 +139,7 @@ func TestTestIPGroupAutoConfigReturnsMatchedIPs(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, 2, result.MatchedCount)
assert.Equal(t, 2, result.RuleCount)
assert.Equal(t, 60, result.LookbackMinutes)
assert.Equal(t, "1h", result.Lookback)
want := map[string]bool{"203.0.113.10": true, "203.0.113.11": true}
for _, item := range result.MatchedIPs {
@@ -209,3 +209,101 @@ func seedWAFAccessLogs(t *testing.T, ctx context.Context, loggedAt time.Time, re
}
require.NoError(t, model.InsertOpenFlareAccessLogsBatch(ctx, records))
}
func TestParseIPGroupAutoConfigLookback(t *testing.T) {
cases := []struct {
name string
raw string
want string
wantDur time.Duration
wantErr bool
}{
{name: "duration 60m", raw: `{"lookback":"60m","rules":[]}`, want: "1h", wantDur: time.Hour},
{name: "duration 1h", raw: `{"lookback":"1h","rules":[]}`, want: "1h", wantDur: time.Hour},
{name: "duration 30m", raw: `{"lookback":"30m","rules":[]}`, want: "30m", wantDur: 30 * time.Minute},
{name: "duration 1m no min floor", raw: `{"lookback":"1m","rules":[]}`, want: "1m", wantDur: time.Minute},
{name: "legacy minutes", raw: `{"lookback_minutes":45,"rules":[]}`, want: "45m", wantDur: 45 * time.Minute},
{name: "default empty", raw: `{"rules":[]}`, want: "1h", wantDur: time.Hour},
{name: "invalid", raw: `{"lookback":"abc","rules":[]}`, wantErr: true},
{name: "zero lookback uses default", raw: `{"lookback":"","rules":[]}`, want: "1h", wantDur: time.Hour},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cfg, err := parseIPGroupAutoConfig(json.RawMessage(tc.raw))
if tc.wantErr {
if err == nil {
t.Fatalf("parseIPGroupAutoConfig(%s) error = nil, want error", tc.raw)
}
return
}
if err != nil {
t.Fatalf("parseIPGroupAutoConfig(%s) error = %v", tc.raw, err)
}
if cfg.Lookback != tc.want {
t.Errorf("Lookback = %q, want %q", cfg.Lookback, tc.want)
}
if cfg.lookbackDuration != tc.wantDur {
t.Errorf("lookbackDuration = %v, want %v", cfg.lookbackDuration, tc.wantDur)
}
})
}
}
func TestCountStatusMatchesSupportsClassTokens(t *testing.T) {
counts := map[int]int{
200: 10,
201: 5,
404: 20,
403: 10,
500: 4,
502: 1,
}
cases := []struct {
name string
code any
want int
}{
{name: "exact int", code: 404, want: 20},
{name: "exact string", code: "403", want: 10},
{name: "2xx class", code: "2xx", want: 15},
{name: "4xx class upper", code: "4XX", want: 30},
{name: "5xx class", code: "5xx", want: 5},
{name: "unknown class", code: "9xx", want: 0},
{name: "invalid token", code: "abc", want: 0},
{name: "float exact", code: float64(200), want: 10},
{name: "float non-int", code: 200.5, want: 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := countStatusMatches(counts, tc.code)
if got != tc.want {
t.Errorf("countStatusMatches(%v) = %d, want %d", tc.code, got, tc.want)
}
})
}
}
func TestStatusRatioClassTokenInExpr(t *testing.T) {
cleanup := setupIPGroupSyncTestDB(t)
defer cleanup()
ctx := context.Background()
now := time.Now().UTC()
// 100 requests, 80 of which are 404 → 4xx ratio 0.8
seedWAFAccessLogs(t, ctx, now, "203.0.113.40", "app.example.com", 100, 80)
// mostly OK → should not match
seedWAFAccessLogs(t, ctx, now, "203.0.113.41", "app.example.com", 100, 10)
result, err := TestIPGroupAutoConfig(ctx, IPGroupAutoTestInput{
AutoConfig: json.RawMessage(`{
"lookback": "60m",
"rules": [
{"name":"高 4xx 占比","expr":"request_count >= 100 && StatusRatio(\"4xx\") >= 0.8"}
]
}`),
})
require.NoError(t, err)
assert.Equal(t, 1, result.MatchedCount)
require.Len(t, result.MatchedIPs, 1)
assert.Equal(t, "203.0.113.40", result.MatchedIPs[0])
}
+161 -26
View File
@@ -31,9 +31,11 @@ const (
wafIPGroupSubscriptionFormatJSON = "json"
defaultWAFIPGroupSyncIntervalMinutes = 1440
defaultWAFIPGroupAutoLookbackMinutes = 60
minWAFIPGroupSyncIntervalMinutes = 5
defaultWAFIPGroupAutoLookback = "1h"
defaultWAFIPGroupAutoLookbackDur = time.Hour
minWAFIPGroupSyncIntervalMinutes = 1
maxWAFIPGroupSyncIntervalMinutes = 43200
maxWAFIPGroupAutoLookback = 30 * 24 * time.Hour
minPoWSessionTTLSeconds = 60
minPoWChallengeTTLSeconds = 30
powAlgorithmFast = "fast"
@@ -112,17 +114,19 @@ type IPGroupAutoTestInput struct {
// IPGroupAutoTestResult is the response for automatic IP group test.
type IPGroupAutoTestResult struct {
MatchedIPs []string `json:"matched_ips"`
MatchedCount int `json:"matched_count"`
LookbackMinutes int `json:"lookback_minutes"`
RuleCount int `json:"rule_count"`
TestedAt string `json:"tested_at"`
MatchedIPs []string `json:"matched_ips"`
MatchedCount int `json:"matched_count"`
Lookback string `json:"lookback"`
RuleCount int `json:"rule_count"`
TestedAt string `json:"tested_at"`
}
type ipGroupAutoConfig struct {
LookbackMinutes int `json:"lookback_minutes"`
TTL int `json:"ttl"`
Rules []ipGroupAutoRule `json:"rules"`
Lookback string `json:"lookback"`
TTL int `json:"ttl"`
Rules []ipGroupAutoRule `json:"rules"`
// lookbackDuration is resolved from Lookback (and legacy lookback_minutes) for runtime queries.
lookbackDuration time.Duration `json:"-"`
}
type ipGroupAutoRule struct {
@@ -328,11 +332,11 @@ func TestIPGroupAutoConfig(ctx context.Context, input IPGroupAutoTestInput) (*IP
return nil, err
}
return &IPGroupAutoTestResult{
MatchedIPs: ips,
MatchedCount: len(ips),
LookbackMinutes: config.LookbackMinutes,
RuleCount: len(config.Rules),
TestedAt: now.Format(time.RFC3339),
MatchedIPs: ips,
MatchedCount: len(ips),
Lookback: config.Lookback,
RuleCount: len(config.Rules),
TestedAt: now.Format(time.RFC3339),
}, nil
}
@@ -496,23 +500,20 @@ func parseIPGroupAutoConfig(raw json.RawMessage) (ipGroupAutoConfig, error) {
if text == "" {
text = "{}"
}
var config ipGroupAutoConfig
if err := json.Unmarshal([]byte(text), &config); err != nil {
return ipGroupAutoConfig{}, errors.New("自动 IP 组配置必须是 JSON 对象")
}
var object map[string]any
if err := json.Unmarshal([]byte(text), &object); err != nil || object == nil {
return ipGroupAutoConfig{}, errors.New("自动 IP 组配置必须是 JSON 对象")
}
if config.LookbackMinutes <= 0 {
config.LookbackMinutes = defaultWAFIPGroupAutoLookbackMinutes
var config ipGroupAutoConfig
if err := json.Unmarshal([]byte(text), &config); err != nil {
return ipGroupAutoConfig{}, errors.New("自动 IP 组配置必须是 JSON 对象")
}
if config.LookbackMinutes < minWAFIPGroupSyncIntervalMinutes {
config.LookbackMinutes = minWAFIPGroupSyncIntervalMinutes
}
if config.LookbackMinutes > maxWAFIPGroupSyncIntervalMinutes {
config.LookbackMinutes = maxWAFIPGroupSyncIntervalMinutes
lookbackDur, lookbackText, err := resolveIPGroupAutoLookback(object)
if err != nil {
return ipGroupAutoConfig{}, err
}
config.Lookback = lookbackText
config.lookbackDuration = lookbackDur
if config.TTL == 0 {
config.TTL = -1
}
@@ -533,6 +534,140 @@ func parseIPGroupAutoConfig(raw json.RawMessage) (ipGroupAutoConfig, error) {
return config, nil
}
// resolveIPGroupAutoLookback accepts lookback as duration string (60m/1h) or legacy lookback_minutes number.
func resolveIPGroupAutoLookback(object map[string]any) (time.Duration, string, error) {
if raw, ok := object["lookback"]; ok && raw != nil {
dur, text, err := parseIPGroupLookbackValue(raw)
if err != nil {
return 0, "", err
}
return dur, text, nil
}
if raw, ok := object["lookback_minutes"]; ok && raw != nil {
// legacy: minutes as number or numeric string
minutes, err := parsePositiveNumber(raw)
if err != nil {
return 0, "", fmt.Errorf("lookback_minutes 无效: %w", err)
}
if minutes <= 0 {
return defaultWAFIPGroupAutoLookbackDur, defaultWAFIPGroupAutoLookback, nil
}
dur := time.Duration(minutes) * time.Minute
if dur > maxWAFIPGroupAutoLookback {
return 0, "", fmt.Errorf("回看窗口不能超过 %s", formatLookbackDuration(maxWAFIPGroupAutoLookback))
}
return dur, formatLookbackDuration(dur), nil
}
return defaultWAFIPGroupAutoLookbackDur, defaultWAFIPGroupAutoLookback, nil
}
func parseIPGroupLookbackValue(raw any) (time.Duration, string, error) {
switch v := raw.(type) {
case string:
trimmed := strings.TrimSpace(v)
if trimmed == "" {
return defaultWAFIPGroupAutoLookbackDur, defaultWAFIPGroupAutoLookback, nil
}
// bare integer string → minutes
if isAllDigits(trimmed) {
minutes, err := parsePositiveNumber(trimmed)
if err != nil || minutes <= 0 {
return 0, "", errors.New("lookback 格式不合法,请使用 60m、1h 等时长")
}
dur := time.Duration(minutes) * time.Minute
if dur > maxWAFIPGroupAutoLookback {
return 0, "", fmt.Errorf("回看窗口不能超过 %s", formatLookbackDuration(maxWAFIPGroupAutoLookback))
}
return dur, formatLookbackDuration(dur), nil
}
dur, err := time.ParseDuration(strings.ToLower(trimmed))
if err != nil || dur <= 0 {
return 0, "", errors.New("lookback 格式不合法,请使用 60m、1h 等时长")
}
if dur > maxWAFIPGroupAutoLookback {
return 0, "", fmt.Errorf("回看窗口不能超过 %s", formatLookbackDuration(maxWAFIPGroupAutoLookback))
}
return dur, formatLookbackDuration(dur), nil
case float64:
if v <= 0 {
return defaultWAFIPGroupAutoLookbackDur, defaultWAFIPGroupAutoLookback, nil
}
if v != float64(int64(v)) {
return 0, "", errors.New("lookback 数值必须为整数分钟")
}
dur := time.Duration(int64(v)) * time.Minute
if dur > maxWAFIPGroupAutoLookback {
return 0, "", fmt.Errorf("回看窗口不能超过 %s", formatLookbackDuration(maxWAFIPGroupAutoLookback))
}
return dur, formatLookbackDuration(dur), nil
case json.Number:
return parseIPGroupLookbackValue(string(v))
default:
return 0, "", errors.New("lookback 格式不合法,请使用 60m、1h 等时长")
}
}
func parsePositiveNumber(raw any) (int, error) {
switch v := raw.(type) {
case float64:
if v != float64(int(v)) {
return 0, errors.New("必须为整数")
}
return int(v), nil
case int:
return v, nil
case int64:
return int(v), nil
case json.Number:
i, err := v.Int64()
if err != nil {
return 0, err
}
return int(i), nil
case string:
trimmed := strings.TrimSpace(v)
if trimmed == "" || !isAllDigits(trimmed) {
return 0, errors.New("必须为整数")
}
n := 0
for _, ch := range trimmed {
n = n*10 + int(ch-'0')
}
return n, nil
default:
return 0, errors.New("必须为整数")
}
}
func isAllDigits(s string) bool {
if s == "" {
return false
}
for _, ch := range s {
if ch < '0' || ch > '9' {
return false
}
}
return true
}
func formatLookbackDuration(d time.Duration) string {
if d <= 0 {
return defaultWAFIPGroupAutoLookback
}
// Prefer compact human units used in config examples.
if d%time.Hour == 0 {
return fmt.Sprintf("%dh", int(d/time.Hour))
}
if d%time.Minute == 0 {
return fmt.Sprintf("%dm", int(d/time.Minute))
}
if d%time.Second == 0 {
return fmt.Sprintf("%ds", int(d/time.Second))
}
return d.String()
}
func validateSubscriptionURL(rawURL string) error {
parsed, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil || parsed.Host == "" {
+1 -1
View File
@@ -53,7 +53,7 @@ func TestUpdateIPGroupPrunesAutomaticExtIPs(t *testing.T) {
Name: "auto group",
Type: wafIPGroupTypeAutomatic,
Enabled: true,
AutoConfig: []byte(`{"lookback_minutes":60,"ttl":-1,"rules":[{"name":"scan","expr":"request_count > 1"}]}`),
AutoConfig: []byte(`{"lookback":"60m","ttl":-1,"rules":[{"name":"scan","expr":"request_count > 1"}]}`),
})
require.NoError(t, err)
@@ -0,0 +1,12 @@
-- +goose Up
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
VALUES
('openresty_default_limit_req_per_ip', '', 'business', 0, '默认单 IP 请求频率限制(空关闭,例如 10r/s、100r/m)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (key) DO NOTHING;
ALTER TABLE of_proxy_routes ADD COLUMN limit_req_per_ip VARCHAR(32) NOT NULL DEFAULT '';
-- +goose Down
ALTER TABLE of_proxy_routes DROP COLUMN limit_req_per_ip;
DELETE FROM w_system_configs WHERE key = 'openresty_default_limit_req_per_ip';
@@ -0,0 +1,12 @@
-- +goose Up
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
VALUES
('openresty_default_limit_req_per_ip', '', 'business', 0, '默认单 IP 请求频率限制(空关闭,例如 10r/s、100r/m)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (key) DO NOTHING;
ALTER TABLE of_proxy_routes ADD COLUMN limit_req_per_ip VARCHAR(32) NOT NULL DEFAULT '';
-- +goose Down
ALTER TABLE of_proxy_routes DROP COLUMN limit_req_per_ip;
DELETE FROM w_system_configs WHERE key = 'openresty_default_limit_req_per_ip';
+2
View File
@@ -25,6 +25,7 @@ type ProxyRoute struct {
LimitConnPerServer int `json:"limit_conn_per_server" gorm:"not null;default:0"`
LimitConnPerIP int `json:"limit_conn_per_ip" gorm:"not null;default:0"`
LimitRate string `json:"limit_rate" gorm:"size:32;not null;default:''"`
LimitReqPerIP string `json:"limit_req_per_ip" gorm:"size:32;not null;default:''"`
CacheEnabled bool `json:"cache_enabled" gorm:"not null;default:false"`
CachePolicy string `json:"cache_policy" gorm:"size:32;not null;default:''"`
CacheRules string `json:"cache_rules" gorm:"type:text;not null;default:'[]'"`
@@ -84,6 +85,7 @@ func UpdateProxyRouteRecord(ctx context.Context, route *ProxyRoute) error {
"limit_conn_per_server": route.LimitConnPerServer,
"limit_conn_per_ip": route.LimitConnPerIP,
"limit_rate": route.LimitRate,
"limit_req_per_ip": route.LimitReqPerIP,
"cache_enabled": route.CacheEnabled,
"cache_policy": route.CachePolicy,
"cache_rules": route.CacheRules,
+1
View File
@@ -108,6 +108,7 @@ const (
ConfigKeyOpenRestyDefaultLimitConnPerServer = "openresty_default_limit_conn_per_server" // 默认站点并发连接
ConfigKeyOpenRestyDefaultLimitConnPerIP = "openresty_default_limit_conn_per_ip" // 默认单 IP 并发连接
ConfigKeyOpenRestyDefaultLimitRate = "openresty_default_limit_rate" // 默认单请求带宽
ConfigKeyOpenRestyDefaultLimitReqPerIP = "openresty_default_limit_req_per_ip" // 默认单 IP 请求频率限制
)
const (
+91 -14
View File
@@ -35,7 +35,7 @@ func RenderJSON(sourceJSON string, certificateFiles []SupportFile) (*Result, err
// Render produces a complete OpenResty configuration Result from a Document and
// a set of certificate support files.
func Render(doc Document, certificateFiles []SupportFile) (*Result, error) {
mainConfig := RenderMainConfig(doc.OpenRestyConfig)
mainConfig := RenderMainConfig(doc)
routeConfig, err := RenderRouteConfig(doc, certificateFiles)
if err != nil {
return nil, err
@@ -56,13 +56,15 @@ func Render(doc Document, certificateFiles []SupportFile) (*Result, error) {
}
// RenderMainConfig renders the nginx main configuration string from the given
// ConfigSnapshot, falling back to the built-in default template when none is set.
func RenderMainConfig(cfg ConfigSnapshot) string {
// Document, falling back to the built-in default template when none is set.
// Limit-req zones are derived from each route's effective rate after merge.
func RenderMainConfig(doc Document) string {
cfg := doc.OpenRestyConfig
templateText := cfg.MainConfigTemplate
if strings.TrimSpace(templateText) == "" {
templateText = defaultMainConfigTemplate
}
return renderMainConfigTemplate(templateText, cfg)
return renderMainConfigTemplate(templateText, cfg, collectEffectiveLimitReqRates(doc.Routes, cfg))
}
// ValidateMainConfigTemplate checks that the provided template text is non-empty
@@ -157,7 +159,7 @@ func DedupeSupportFiles(files []SupportFile) []SupportFile {
return result
}
func renderMainConfigTemplate(templateText string, cfg ConfigSnapshot) string {
func renderMainConfigTemplate(templateText string, cfg ConfigSnapshot, limitReqRates []string) string {
replacer := strings.NewReplacer(
"{{OpenRestyWorkerProcesses}}", cfg.WorkerProcesses,
"{{OpenRestyWorkerConnections}}", fmt.Sprintf("%d", cfg.WorkerConnections),
@@ -187,7 +189,7 @@ func renderMainConfigTemplate(templateText string, cfg ConfigSnapshot) string {
"{{OpenRestyGzipMinLength}}", fmt.Sprintf("%d", cfg.GzipMinLength),
"{{OpenRestyGzipCompLevel}}", fmt.Sprintf("%d", cfg.GzipCompLevel),
"{{OpenRestyResolverDirective}}", renderTemplateDirective(cfg.Resolvers != "", fmt.Sprintf("resolver %s;", cfg.Resolvers)),
"{{OpenRestyCacheBlock}}", renderOpenRestyCacheTemplateBlock(cfg),
"{{OpenRestyCacheBlock}}", renderOpenRestyCacheTemplateBlock(cfg, limitReqRates),
"{{OpenRestyRouteConfigInclude}}", RouteConfigPlaceholder,
)
return replacer.Replace(templateText)
@@ -200,8 +202,8 @@ func renderTemplateDirective(enabled bool, statement string) string {
return fmt.Sprintf(" %s\n", statement)
}
func renderOpenRestyCacheTemplateBlock(cfg ConfigSnapshot) string {
lines := []string{renderOpenRestyLimitZoneBlock()}
func renderOpenRestyCacheTemplateBlock(cfg ConfigSnapshot, limitReqRates []string) string {
lines := []string{renderOpenRestyLimitZoneBlock(limitReqRates)}
if !cfg.CacheEnabled {
lines = append(lines, renderOpenRestyObservabilityTemplateBlock())
return strings.Join(lines, "")
@@ -222,8 +224,45 @@ func renderOpenRestyCacheTemplateBlock(cfg ConfigSnapshot) string {
return strings.Join(lines, "")
}
func renderOpenRestyLimitZoneBlock() string {
return " limit_conn_zone $server_name zone=openflare_conn_per_server:10m;\n limit_conn_zone $binary_remote_addr zone=openflare_conn_per_ip:10m;\n"
func renderOpenRestyLimitZoneBlock(limitReqRates []string) string {
var builder strings.Builder
builder.WriteString(" limit_conn_zone $server_name zone=openflare_conn_per_server:10m;\n")
builder.WriteString(" limit_conn_zone $binary_remote_addr zone=openflare_conn_per_ip:10m;\n")
for _, rate := range limitReqRates {
fmt.Fprintf(
&builder,
" limit_req_zone $openflare_waf_site$binary_remote_addr zone=%s:10m rate=%s;\n",
limitReqZoneName(rate),
rate,
)
}
return builder.String()
}
func collectEffectiveLimitReqRates(routes []Route, cfg ConfigSnapshot) []string {
seen := make(map[string]struct{}, len(routes))
for _, route := range routes {
rate := strings.TrimSpace(mergeRouteLimitConfig(route, cfg).LimitReqPerIP)
if rate == "" {
continue
}
seen[rate] = struct{}{}
}
if len(seen) == 0 {
return nil
}
rates := make([]string, 0, len(seen))
for rate := range seen {
rates = append(rates, rate)
}
sort.Strings(rates)
return rates
}
func limitReqZoneName(rate string) string {
normalized := strings.ToLower(strings.TrimSpace(rate))
normalized = strings.ReplaceAll(normalized, "/", "")
return "openflare_req_" + normalized
}
func renderOpenRestyObservabilityTemplateBlock() string {
@@ -451,16 +490,16 @@ func renderRouteCacheBlock(cacheConfig routeCacheConfig, cfg ConfigSnapshot) str
var builder strings.Builder
builder.WriteString(" set $openflare_skip_cache 0;\n")
builder.WriteString(" if ($request_method != GET) {\n set $openflare_skip_cache 1;\n }\n")
builder.WriteString(" if ($http_authorization != \"\") {\n set $openflare_skip_cache 1;\n }\n")
builder.WriteString(" if ($http_cookie ~* \"(session|sess|token|auth|jwt|logged_in|remember|laravel_session|connect\\\\.sid|_session)\") {\n set $openflare_skip_cache 1;\n }\n")
builder.WriteString(" if ($http_cache_control ~* \"(no-cache|no-store|private)\") {\n set $openflare_skip_cache 1;\n }\n")
if condition := renderRouteCachePolicyCondition(cacheConfig); condition != "" {
builder.WriteString(condition)
}
builder.WriteString(" proxy_cache openflare_cache;\n")
builder.WriteString(" proxy_cache_methods GET;\n")
builder.WriteString(" proxy_cache_bypass $openflare_skip_cache;\n")
builder.WriteString(" proxy_no_cache $openflare_skip_cache;\n")
builder.WriteString(" proxy_no_cache $openflare_skip_cache $upstream_http_set_cookie;\n")
builder.WriteString(" proxy_cache_valid 200 206 301 120m;\n")
builder.WriteString(" proxy_cache_valid 302 303 20m;\n")
builder.WriteString(" proxy_cache_valid 404 410 3m;\n")
return builder.String()
}
@@ -475,6 +514,12 @@ func renderRouteLimitBlock(limitConfig routeLimitConfig) string {
if strings.TrimSpace(limitConfig.LimitRate) != "" {
fmt.Fprintf(&builder, " limit_rate %s;\n", limitConfig.LimitRate)
}
if strings.TrimSpace(limitConfig.LimitReqPerIP) != "" {
rate := strings.TrimSpace(limitConfig.LimitReqPerIP)
burst := calculateBurst(rate)
fmt.Fprintf(&builder, " limit_req zone=%s burst=%d nodelay;\n", limitReqZoneName(rate), burst)
fmt.Fprintf(&builder, " limit_req_status 429;\n")
}
return builder.String()
}
@@ -483,6 +528,7 @@ func mergeRouteLimitConfig(route Route, cfg ConfigSnapshot) routeLimitConfig {
LimitConnPerServer: mergeLimitConn(route.LimitConnPerServer, cfg.DefaultLimitConnPerServer),
LimitConnPerIP: mergeLimitConn(route.LimitConnPerIP, cfg.DefaultLimitConnPerIP),
LimitRate: mergeLimitRate(route.LimitRate, cfg.DefaultLimitRate),
LimitReqPerIP: mergeLimitRate(route.LimitReqPerIP, cfg.DefaultLimitReqPerIP),
}
}
@@ -864,3 +910,34 @@ func buildPathExactMatchPattern(rules []string) string {
}
return fmt.Sprintf("^(?:%s)$", strings.Join(parts, "|"))
}
const (
limitReqDefaultBurst = 5
limitReqPerSecondBurstMul = 2
limitReqPerMinuteBurstDiv = 5
)
func calculateBurst(rateStr string) int {
rateStr = strings.ToLower(strings.TrimSpace(rateStr))
if rateStr == "" {
return 0
}
var val int
var unit string
_, err := fmt.Sscanf(rateStr, "%dr/%s", &val, &unit)
if err != nil || val <= 0 {
return limitReqDefaultBurst
}
switch unit {
case "s":
return val * limitReqPerSecondBurstMul
case "m":
b := val / limitReqPerMinuteBurstDiv
if b < limitReqDefaultBurst {
return limitReqDefaultBurst
}
return b
default:
return limitReqDefaultBurst
}
}
+108 -8
View File
@@ -407,11 +407,18 @@ func TestRenderRouteCachePolicyConditionStaticDefault(t *testing.T) {
if !strings.Contains(staticBlock, "css") || !strings.Contains(staticBlock, "woff2") {
t.Fatalf("static policy should include default extensions, got:\n%s", staticBlock)
}
if !strings.Contains(staticBlock, "map") || !strings.Contains(staticBlock, "mjs") {
t.Fatalf("static policy should include map and mjs, got:\n%s", staticBlock)
}
if strings.Contains(staticBlock, "html") {
t.Fatalf("static policy must not include html, got:\n%s", staticBlock)
}
// Pattern is \.(?:css|js|...)$ — reject bare "json" as an alternation token.
if strings.Contains(staticBlock, "|json|") || strings.Contains(staticBlock, "|json)") || strings.Contains(staticBlock, "(?:json|") {
t.Fatalf("static policy must not include json (CF default), got:\n%s", staticBlock)
}
// Legacy empty/url = all (wide cache after security bypass).
// Legacy empty/url = all (wide cache after method bypass).
emptyPolicy := renderRouteCachePolicyCondition(routeCacheConfig{Enabled: true, Policy: ""})
if emptyPolicy != "" {
t.Fatalf("empty policy should map to all (no path filter), got %q", emptyPolicy)
@@ -427,7 +434,7 @@ func TestRenderRouteCachePolicyConditionStaticDefault(t *testing.T) {
}
}
func TestRenderRouteCacheBlockIncludesStaticWhenEnabled(t *testing.T) {
func TestRenderRouteCacheBlockAlignsCloudflareDefaults(t *testing.T) {
block := renderRouteCacheBlock(
routeCacheConfig{Enabled: true, Policy: "static"},
ConfigSnapshot{CacheEnabled: true},
@@ -439,7 +446,31 @@ func TestRenderRouteCacheBlockIncludesStaticWhenEnabled(t *testing.T) {
t.Fatalf("expected static suffix pattern, got:\n%s", block)
}
if !strings.Contains(block, "request_method != GET") {
t.Fatalf("expected security bypass for non-GET, got:\n%s", block)
t.Fatalf("expected method bypass for non-GET, got:\n%s", block)
}
if strings.Contains(block, "$http_authorization") {
t.Fatalf("must not bypass on Authorization (CF-aligned), got:\n%s", block)
}
if strings.Contains(block, "$http_cookie") {
t.Fatalf("must not bypass on Cookie (CF-aligned), got:\n%s", block)
}
if strings.Contains(block, "$http_cache_control") {
t.Fatalf("must not bypass on request Cache-Control (CF-aligned), got:\n%s", block)
}
if !strings.Contains(block, "proxy_no_cache $openflare_skip_cache $upstream_http_set_cookie") {
t.Fatalf("expected Set-Cookie no-cache gate, got:\n%s", block)
}
if !strings.Contains(block, "proxy_cache_valid 200 206 301 120m") {
t.Fatalf("expected default Edge TTL for 200/206/301, got:\n%s", block)
}
if !strings.Contains(block, "proxy_cache_valid 302 303 20m") {
t.Fatalf("expected default Edge TTL for 302/303, got:\n%s", block)
}
if !strings.Contains(block, "proxy_cache_valid 404 410 3m") {
t.Fatalf("expected default Edge TTL for 404/410, got:\n%s", block)
}
if !strings.Contains(block, "proxy_cache_bypass $openflare_skip_cache") {
t.Fatalf("expected proxy_cache_bypass on skip flag only, got:\n%s", block)
}
}
@@ -464,16 +495,18 @@ func TestMergeRouteLimitConfig(t *testing.T) {
DefaultLimitConnPerServer: 100,
DefaultLimitConnPerIP: 10,
DefaultLimitRate: "512k",
DefaultLimitReqPerIP: "10r/s",
},
want: routeLimitConfig{LimitConnPerServer: 100, LimitConnPerIP: 10, LimitRate: "512k"},
want: routeLimitConfig{LimitConnPerServer: 100, LimitConnPerIP: 10, LimitRate: "512k", LimitReqPerIP: "10r/s"},
},
{
name: "explicit off ignores default",
route: Route{LimitConnPerServer: -1, LimitConnPerIP: -1, LimitRate: "-1"},
route: Route{LimitConnPerServer: -1, LimitConnPerIP: -1, LimitRate: "-1", LimitReqPerIP: "-1"},
cfg: ConfigSnapshot{
DefaultLimitConnPerServer: 100,
DefaultLimitConnPerIP: 10,
DefaultLimitRate: "512k",
DefaultLimitReqPerIP: "10r/s",
},
want: routeLimitConfig{},
},
@@ -484,8 +517,9 @@ func TestMergeRouteLimitConfig(t *testing.T) {
DefaultLimitConnPerServer: 100,
DefaultLimitConnPerIP: 10,
DefaultLimitRate: "512k",
DefaultLimitReqPerIP: "10r/s",
},
want: routeLimitConfig{LimitConnPerServer: 50, LimitConnPerIP: 5, LimitRate: "1m"},
want: routeLimitConfig{LimitConnPerServer: 50, LimitConnPerIP: 5, LimitRate: "1m", LimitReqPerIP: "10r/s"},
},
{
name: "partial inherit",
@@ -494,8 +528,9 @@ func TestMergeRouteLimitConfig(t *testing.T) {
DefaultLimitConnPerServer: 100,
DefaultLimitConnPerIP: 10,
DefaultLimitRate: "256k",
DefaultLimitReqPerIP: "10r/s",
},
want: routeLimitConfig{LimitConnPerServer: 100, LimitConnPerIP: 0, LimitRate: "256k"},
want: routeLimitConfig{LimitConnPerServer: 100, LimitConnPerIP: 0, LimitRate: "256k", LimitReqPerIP: "10r/s"},
},
}
for _, tc := range cases {
@@ -522,6 +557,7 @@ func TestRenderRouteConfigAppliesDefaultLimits(t *testing.T) {
DefaultLimitConnPerServer: 120,
DefaultLimitConnPerIP: 12,
DefaultLimitRate: "512k",
DefaultLimitReqPerIP: "10r/s",
},
}
rendered, err := RenderRouteConfig(doc, nil)
@@ -532,6 +568,8 @@ func TestRenderRouteConfigAppliesDefaultLimits(t *testing.T) {
"limit_conn openflare_conn_per_server 120;",
"limit_conn openflare_conn_per_ip 12;",
"limit_rate 512k;",
"limit_req zone=openflare_req_10rs burst=20 nodelay;",
"limit_req_status 429;",
} {
if !strings.Contains(rendered, want) {
t.Fatalf("expected %q in route config, got:\n%s", want, rendered)
@@ -539,6 +577,66 @@ func TestRenderRouteConfigAppliesDefaultLimits(t *testing.T) {
}
}
func TestRenderMainConfigEmitsLimitReqZonesByEffectiveRate(t *testing.T) {
doc := Document{
Routes: []Route{
{
SiteName: "a.example.com",
Domains: []string{"a.example.com"},
Enabled: true,
OriginURL: "http://127.0.0.1:8080",
Upstreams: []string{"http://127.0.0.1:8080"},
},
{
SiteName: "b.example.com",
Domains: []string{"b.example.com"},
Enabled: true,
OriginURL: "http://127.0.0.1:8081",
Upstreams: []string{"http://127.0.0.1:8081"},
LimitReqPerIP: "5r/s",
},
{
SiteName: "c.example.com",
Domains: []string{"c.example.com"},
Enabled: true,
OriginURL: "http://127.0.0.1:8082",
Upstreams: []string{"http://127.0.0.1:8082"},
LimitReqPerIP: "-1",
},
},
OpenRestyConfig: ConfigSnapshot{
DefaultLimitReqPerIP: "10r/s",
},
}
mainConfig := RenderMainConfig(doc)
for _, want := range []string{
"limit_req_zone $openflare_waf_site$binary_remote_addr zone=openflare_req_10rs:10m rate=10r/s;",
"limit_req_zone $openflare_waf_site$binary_remote_addr zone=openflare_req_5rs:10m rate=5r/s;",
} {
if !strings.Contains(mainConfig, want) {
t.Fatalf("expected %q in main config, got:\n%s", want, mainConfig)
}
}
if strings.Contains(mainConfig, "openflare_req_per_ip") {
t.Fatalf("unexpected legacy zone name in main config:\n%s", mainConfig)
}
routeConfig, err := RenderRouteConfig(doc, nil)
if err != nil {
t.Fatalf("RenderRouteConfig() error = %v", err)
}
if !strings.Contains(routeConfig, "limit_req zone=openflare_req_10rs burst=20 nodelay;") {
t.Fatalf("expected inherited zone on route a, got:\n%s", routeConfig)
}
if !strings.Contains(routeConfig, "limit_req zone=openflare_req_5rs burst=10 nodelay;") {
t.Fatalf("expected custom zone on route b, got:\n%s", routeConfig)
}
// route c is off: count limit_req lines should equal 2 routes * (http+https? depends) — assert c server has no limit_req by site name block is hard; ensure -1 route does not force extra zones
if strings.Count(mainConfig, "limit_req_zone") != 2 {
t.Fatalf("expected exactly 2 limit_req_zone lines, got main:\n%s", mainConfig)
}
}
func TestRenderRouteConfigExplicitOffSkipsDefaultLimits(t *testing.T) {
doc := Document{
Routes: []Route{{
@@ -550,18 +648,20 @@ func TestRenderRouteConfigExplicitOffSkipsDefaultLimits(t *testing.T) {
LimitConnPerServer: -1,
LimitConnPerIP: -1,
LimitRate: "-1",
LimitReqPerIP: "-1",
}},
OpenRestyConfig: ConfigSnapshot{
DefaultLimitConnPerServer: 120,
DefaultLimitConnPerIP: 12,
DefaultLimitRate: "512k",
DefaultLimitReqPerIP: "10r/s",
},
}
rendered, err := RenderRouteConfig(doc, nil)
if err != nil {
t.Fatalf("RenderRouteConfig() error = %v", err)
}
if strings.Contains(rendered, "limit_conn") || strings.Contains(rendered, "limit_rate") {
if strings.Contains(rendered, "limit_conn") || strings.Contains(rendered, "limit_rate") || strings.Contains(rendered, "limit_req") {
t.Fatalf("expected no limit directives, got:\n%s", rendered)
}
}
+5 -2
View File
@@ -37,9 +37,9 @@ const (
)
// DefaultStaticCacheExtensions is the built-in suffix allowlist for cache_policy=static.
// HTML is intentionally excluded (Cloudflare-like default).
// HTML and JSON are excluded (Cloudflare default). map/mjs/wasm are intentional extras.
var DefaultStaticCacheExtensions = []string{
"css", "js", "mjs", "map", "json",
"css", "js", "mjs", "map",
"ico", "cur", "gif", "jpg", "jpeg", "png", "webp", "avif", "svg", "svgz",
"ttf", "otf", "woff", "woff2", "eot",
"mp3", "mp4", "webm", "ogg", "flac",
@@ -165,6 +165,7 @@ type Route struct {
LimitConnPerServer int `json:"limit_conn_per_server,omitempty"`
LimitConnPerIP int `json:"limit_conn_per_ip,omitempty"`
LimitRate string `json:"limit_rate,omitempty"`
LimitReqPerIP string `json:"limit_req_per_ip,omitempty"`
CacheEnabled bool `json:"cache_enabled"`
CachePolicy string `json:"cache_policy,omitempty"`
CacheRules []string `json:"cache_rules,omitempty"`
@@ -312,6 +313,7 @@ type ConfigSnapshot struct {
DefaultLimitConnPerServer int `json:"default_limit_conn_per_server,omitempty"`
DefaultLimitConnPerIP int `json:"default_limit_conn_per_ip,omitempty"`
DefaultLimitRate string `json:"default_limit_rate,omitempty"`
DefaultLimitReqPerIP string `json:"default_limit_req_per_ip,omitempty"`
}
// Document is the top-level input structure for the OpenResty renderer,
@@ -341,6 +343,7 @@ type routeLimitConfig struct {
LimitConnPerServer int
LimitConnPerIP int
LimitRate string
LimitReqPerIP string
}
type routeUpstreamConfig struct {