mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 05:56:38 +08:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 68d730a388 | |||
| f94767fbc7 | |||
| 5b1e27d0a3 | |||
| f28aa6520e | |||
| d58b4b6b0e | |||
| 866f1df5e3 | |||
| 351e8ce78c | |||
| 67a30eb5ed | |||
| 80c47f6ff3 | |||
| a261c01a9c | |||
| ae5345c03e | |||
| fda8d7fcb1 | |||
| b91c848256 |
@@ -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` 入口显式装配。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
### 新增
|
||||
|
||||
@@ -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 | 后续 | 先闭合默认闭环再扩展 |
|
||||
|
||||
@@ -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
@@ -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
@@ -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) |
|
||||
|
||||
@@ -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#边缘缓存命中率异常)。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 扫描",
|
||||
|
||||
@@ -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 运维说明
|
||||
- [ ] 用户确认后提交
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
.next/
|
||||
node_modules/
|
||||
out/
|
||||
coverage/
|
||||
public/
|
||||
next-env.d.ts
|
||||
pnpm-lock.yaml
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+220
-129
@@ -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,
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+12
@@ -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';
|
||||
+12
@@ -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';
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user