diff --git a/docs/superpowers/specs/2026-04-27-per-ip-rule-limits-design.md b/docs/superpowers/specs/2026-04-27-per-ip-rule-limits-design.md new file mode 100644 index 0000000..4a65085 --- /dev/null +++ b/docs/superpowers/specs/2026-04-27-per-ip-rule-limits-design.md @@ -0,0 +1,321 @@ +# 规则每 IP 连接数与限速设计 + +**日期**: 2026-04-27 +**状态**: 待审核 +**作者**: AI Assistant + +## 概述 + +在转发规则的高级设置中新增两类每客户端 IP 限制:每 IP 最大连接数、每 IP 带宽限速。保留现有总量限制语义不变,新增字段只在用户显式配置时生效。 + +实现优先复用 GOST 已有能力:`climiters` 的 `$$ N` 表示每个客户端 IP 独立最大连接数;`limiters` 支持 IP/CIDR 级带宽桶,可用 `0.0.0.0/0` 和 `::/0` 实现默认覆盖所有 IPv4/IPv6 客户端的每 IP 带宽限速。 + +## 背景 + +当前 FLVX 已经支持规则级最大连接数和规则级限速,但这两个限制都是规则总量: + +- `maxConn` 下发为 GOST `climiters` 的 `$ N`,限制整条规则的总并发连接数。 +- `speedId` 下发为 GOST `limiters` 的 `$ in out`,限制整条规则的总带宽。 + +用户需要的是按客户端 IP 隔离的限制,例如每个 IP 最多 5 个连接、每个 IP 最多 10 Mbps,而不是所有客户端共享同一个总量。 + +## GOST 能力确认 + +### 连接数限制 + +`go-gost/x/limiter/conn/conn.go` 已内置以下语义: + +| Key | 含义 | +|-----|------| +| `$` | 全局连接数限制,所有客户端共享一个 limiter | +| `$$` | 每个客户端 IP 独立连接数限制,每个 IP 创建自己的 limiter | +| `IP` / `CIDR` | 指定 IP 或 CIDR 的连接数限制 | + +因此每 IP 连接数无需新增 agent 限制器,只需后端下发 `$$ N`。 + +### 带宽限制 + +`go-gost/x/limiter/traffic/traffic.go` 已内置以下语义: + +| Key | 含义 | +|-----|------| +| `$` | 服务级总带宽限制 | +| `$$` | 连接级带宽限制 | +| `IP` / `CIDR` | 客户端 IP 或 CIDR 级带宽限制 | + +CIDR 级限制使用 generator,为命中的客户端 IP 创建独立 limiter。使用 `0.0.0.0/0` 和 `::/0` 可以覆盖所有 IPv4/IPv6 客户端,实现每 IP 带宽限速。 + +### 现有缺口 + +TCP listener 已在 Accept 后用客户端地址包装连接级 traffic limiter,路径可用于每 IP 带宽。UDP listener 当前只在 PacketConn 上应用服务级 limiter,没有在 `Accept()` 后按客户端 UDP pseudo-connection 包装 limiter,也没有挂接 connection limiter。因此要让 UDP 与 TCP 语义一致,需要补齐 UDP listener 的 per-client wrapper。 + +## 目标 + +1. 保留现有 `maxConn` 和 `speedId` 的总量语义。 +2. 在规则上新增每 IP 最大连接数。 +3. 在规则上新增每 IP 带宽限速。 +4. 同一规则允许同时配置总量限制和每 IP 限制。 +5. 普通用户不能设置或修改限速规则字段,保持现有权限模型。 +6. TCP 和 UDP 入口都尽量遵循相同限制语义。 + +## 非目标 + +1. 不新增按用户组、节点组、国家地区、ASN 的限制。 +2. 不新增请求频率限制;本次“每个 IP 限速”指带宽限速,不是新建连接频率。 +3. 不改变已有 speed limit 规则表的单位和含义。 +4. 不把用户级默认最大连接数改成每 IP 语义;用户级 `maxConn` 继续作为默认总连接数。 + +## 数据模型 + +在 `forward` 表新增两个字段: + +| 字段 | 类型 | 默认 | 说明 | +|------|------|------|------| +| `ip_max_conn` | int | `0` | 每 IP 最大连接数,`0` 表示不启用 | +| `ip_speed_id` | nullable int64 | `NULL` | 每 IP 带宽限速规则 ID,`NULL` 表示不启用 | + +Go 模型新增: + +```go +IPMaxConn int `gorm:"column:ip_max_conn;not null;default:0"` +IPSpeedID sql.NullInt64 `gorm:"column:ip_speed_id"` +``` + +字段会通过现有 auto-migrate 机制创建,保持 SQLite/PostgreSQL 兼容,不使用 SQLite 不兼容的 GORM tags。 + +## API 行为 + +### 创建规则 + +`/forward/create` 新增入参: + +```json +{ + "ipMaxConn": 5, + "ipSpeedId": 123 +} +``` + +规则: + +- `ipMaxConn` 缺省或小于等于 `0` 时按 `0` 存储,不启用每 IP 连接数限制。 +- `ipSpeedId` 缺省或不存在时存为 `NULL`,不启用每 IP 带宽限速。 +- `ipSpeedId` 指向不存在的限速规则时按 `NULL` 处理,沿用现有 `speedId` 的容错策略。 +- 普通用户提交非空 `ipSpeedId` 时返回错误,保持与 `speedId` 一致的权限边界。 + +### 更新规则 + +`/forward/update` 新增入参: + +```json +{ + "ipMaxConn": 5, + "ipSpeedId": 123 +} +``` + +规则: + +- 未提交 `ipMaxConn` 时保留原值;提交空值或 `0` 时清除每 IP 连接数限制。 +- 未提交 `ipSpeedId` 时保留原值;提交 `null` 时清除每 IP 带宽限速。 +- 普通用户不能把 `ipSpeedId` 改成不同的非空值。 +- 更新后重新同步运行时服务和 limiter。 + +### 列表返回 + +`/forward/list` 返回项新增: + +```json +{ + "ipMaxConn": 5, + "ipSpeedId": 123, + "ipSpeedLimitName": "每IP 10Mbps" +} +``` + +`ipSpeedLimitName` 可选,但建议返回,便于前端显示缺失或已删除的限速规则。 + +## 后端运行时同步 + +### 连接数限制器 + +将现有连接限制器构建从单一总量扩展为组合规则。 + +当前行为: + +```json +{ + "name": "rule_conn_limit_42", + "limits": ["$ 100"] +} +``` + +新增行为: + +```json +{ + "name": "rule_conn_limit_42", + "limits": ["$ 100", "$$ 5"] +} +``` + +规则: + +- `maxConn > 0` 时追加 `$ maxConn`。 +- `ipMaxConn > 0` 时追加 `$$ ipMaxConn`。 +- 如果规则未配置 `maxConn` 且用户有 `MaxConn > 0`,继续继承用户级总连接数,追加 `$ user.MaxConn`。 +- 如果两者都没有,则不下发 `climiter`,服务不引用 `climiter`。 +- limiter 名称继续优先使用 `rule_conn_limit_`;只有用户级默认总连接数且规则没有任何连接限制时可继续使用 `user_conn_limit_`,避免不必要的 per-rule limiter。 + +### 带宽限制器 + +将现有规则限速从单一 `speedId` 扩展为组合 limiter。 + +当前行为: + +```json +{ + "name": "123", + "limits": ["$ 1.3MB 1.3MB"] +} +``` + +新增每 IP 行为: + +```json +{ + "name": "rule_traffic_limit_42", + "limits": [ + "$ 1.3MB 1.3MB", + "0.0.0.0/0 1.3MB 1.3MB", + "::/0 1.3MB 1.3MB" + ] +} +``` + +规则: + +- 只有总量 `speedId` 时,保持现有名称和下发路径,服务继续引用 `speedId` 字符串。 +- 只有每 IP `ipSpeedId` 时,创建 `rule_traffic_limit_`,只包含 IPv4/IPv6 CIDR 行。 +- 总量和每 IP 同时存在时,创建 `rule_traffic_limit_`,同时包含 `$` 和 CIDR 行。 +- 如果规则没有 `speedId`,则总量仍可继承 user tunnel 的 `speedId`,保持现有 fallback 语义;当继承的总量限速与 `ipSpeedId` 同时存在时,也使用 `rule_traffic_limit_` 组合 limiter。 +- 每 IP 限速不从 user tunnel 继承,只由规则字段控制。 +- `AddLimiters` 失败且提示已存在时,使用 `UpdateLimiters` 更新。 + +### 服务配置 + +`buildForwardServiceConfigs` 需要从当前 `limiterID *int64` / `cLimiterName string` 扩展为更明确的运行时限制描述,例如: + +```go +type forwardRuntimeLimiters struct { + TrafficLimiter string + ConnLimiter string +} +``` + +服务配置只关心最终引用的 limiter 名称: + +- `service["limiter"] = runtimeLimiters.TrafficLimiter` +- `service["climiter"] = runtimeLimiters.ConnLimiter` + +这样可以把“如何构建 limiter payload”的逻辑和“如何构建 service JSON”的逻辑分开。 + +## Agent/GOST 调整 + +### WebSocket 命令 + +当前 agent WebSocket 已支持: + +- `AddLimiters` / `UpdateLimiters` / `DeleteLimiters` +- `AddCLimiters` / `UpdateCLimiters` / `DeleteCLimiters` + +本设计无需新增命令类型。 + +### UDP listener + +补齐 `go-gost/x/listener/udp/listener.go` 的 `Accept()` 包装逻辑,使 UDP pseudo-connection 与 TCP listener 一致: + +- 对 `l.options.ConnLimiter` 按客户端地址应用连接数限制。 +- 对 `l.options.TrafficLimiter` 按 `conn.RemoteAddr().String()` 应用连接级 traffic wrapper。 + +需要注意 UDP pseudo-connection 的生命周期由内部 UDP listener 的 TTL/keepalive 控制;connection limiter 必须在 pseudo-connection 关闭时释放计数。 + +## 前端设计 + +在 `vite-frontend/src/pages/forward.tsx` 的规则高级设置中新增两个控件: + +1. `每 IP 最大连接数` +- 类型:number input。 +- 文案:`每个客户端 IP 可同时建立的最大连接数;0 或空表示不限制。` +- 字段:`ipMaxConn`。 + +2. `每 IP 限速` +- 类型:Select,复用现有限速规则列表。 +- 文案:`每个客户端 IP 独享该带宽限制;不选择表示不限制。` +- 字段:`ipSpeedId`。 +- 只对管理员显示,保持与 `规则限速` 一致。 + +前端类型需要同步更新: + +- `ForwardApiItem` +- `ForwardMutationPayload` +- `ForwardForm` 或页面内等价类型 + +## 错误处理与兼容性 + +1. 旧数据默认 `ip_max_conn=0`、`ip_speed_id=NULL`,行为与当前版本一致。 +2. 现有 agent 已支持 limiter 命令和 GOST limiter 语法;发布时需要包含 UDP 修复,才能让 TCP/UDP 都获得完整语义。 +3. 节点离线时沿用现有 warning 行为,规则仍可保存,在线节点跳过下发。 +4. 如果每 IP speed limit ID 被删除,更新时按 `NULL` 处理,列表页可提示或自动清除,和现有 `speedId` 行为一致。 +5. 如果 IPv6 CIDR 在某些监听路径未命中,IPv4 行仍正常生效;测试应覆盖 IPv4,IPv6 通过 payload 合同保证下发。 + +## 测试计划 + +### 后端 contract 测试 + +新增或扩展 `go-backend/tests/contract/max_conn_limit_contract_test.go`: + +1. 创建规则时设置 `ipMaxConn=5`,断言 `AddCLimiters` payload 包含 `$$ 5`。 +2. 同时设置 `maxConn=100` 和 `ipMaxConn=5`,断言 payload 包含 `$ 100` 和 `$$ 5`。 +3. 用户级 `MaxConn` 存在且规则 `ipMaxConn=5` 时,断言 payload 包含 `$ userMaxConn` 和 `$$ 5`。 + +新增每 IP 限速 contract 测试: + +1. 创建规则时设置 `ipSpeedId`,断言 `AddLimiters` payload 包含 `0.0.0.0/0 ...` 和 `::/0 ...`。 +2. 同时设置 `speedId` 和 `ipSpeedId`,断言组合 limiter 包含 `$ ...` 与两个 CIDR 行,服务引用 `rule_traffic_limit_`。 +3. 普通用户提交 `ipSpeedId` 返回错误。 + +### Repository/API 测试 + +1. `CreateForwardTx`、`UpdateForward`、列表查询读写 `ip_max_conn` 和 `ip_speed_id`。 +2. `/forward/list` 返回 `ipMaxConn`、`ipSpeedId`。 + +### GOST/x 测试 + +1. `go-gost/x/limiter/conn`:验证 `$$ N` 为不同 IP 创建独立 limiter。 +2. `go-gost/x/limiter/traffic`:验证 `0.0.0.0/0` 为不同 IPv4 创建独立 limiter。 +3. UDP listener:验证 Accept 返回的 UDP pseudo-connection 关闭后释放 connection limiter。 + +### 验证命令 + +```bash +(cd go-backend && go test ./...) +(cd go-gost/x && go test ./limiter/... ./listener/udp/...) +(cd vite-frontend && pnpm run build) +``` + +## 推荐实施顺序 + +1. 后端模型、repo DTO、API 字段读写。 +2. 后端 limiter payload 构建与服务引用重构。 +3. Contract 测试覆盖连接数和带宽 payload。 +4. GOST UDP listener per-client wrapper 与相关测试。 +5. 前端高级设置表单和类型更新。 +6. 运行后端测试、GOST/x 相关测试、前端构建。 + +## 风险 + +1. UDP pseudo-connection 生命周期和 TCP 连接不同,连接数释放必须依赖 Close 包装正确执行。 +2. 总带宽和每 IP 带宽组合时 limiter 名称从纯 speed ID 变为 rule-level 名称,需要确保更新已有规则时不会留下错误引用。 +3. 旧节点如果没有 UDP wrapper 修复,TCP 生效但 UDP 每 IP 语义可能不完整;发布时应要求 agent 同步升级。 +4. 每 IP 带宽是每个入口节点本地独立限制,不是跨节点全局聚合限制。