server_config: 新增 RRPP 订阅后端规则集下发文档(PAC → classical 规则集)

- 新增 §12:PAC 不能直接下发的原因、三个规则集产物、PAC 更新后的再生成流程、
  mihomo/Shadowrocket 两种客户端接入方式与验证口径
- 新增 rrpp-subscription/:API.md(订阅端点/method/rules 参数/规则集说明)
  与 gen_rulesets.py(PAC → 客户端规则集转换脚本,幂等、原子替换)
- method 新增 shadowrocket(.conf:远程 RULE-SET + FINAL,不猜测 REALITY 参数)
- method=clash 新增 &rules=provider|inline|global
This commit is contained in:
DSH Agent
2026-09-19 18:46:54 +08:00
parent bdd9d3498d
commit 9a64853db4
3 changed files with 436 additions and 0 deletions
+152
View File
@@ -0,0 +1,152 @@
# RRPP 订阅服务 · SSO 认证接入文档
架构:`客户端 → nginx(api.rrrosmontis.icu) → RRPP 后端(127.0.0.1:5010) → SSO(127.0.0.1:4550) → nodes.json(国家分层)`
## 1. 客户端公开端点
### 统一订阅端点
```
GET https://api.rrrosmontis.icu/v1/rrpp/subscription?method=<类型>&user=<b64url邮箱>&password=<b64url密码>[&prefer=<协议>][&rules=<模式>]
```
| 参数 | 必填 | 说明 |
|---|---|---|
| `method` | 是 | `clash` / `v2ray` / `shadowrocket` / `rrpp_client` |
| `user` | 是 | URL-safe base64 编码的邮箱 |
| `password` | 是 | URL-safe base64 编码的密码 |
| `prefer` | 否 | `vless` / `ss` / `http`,指定优先协议;缺省 `vless`,无则回退 vless→ss→http |
| `rules` | 否 | 仅 `method=clash`:`provider`(默认) / `inline` / `global`,见 §7 |
| `token` | 否 | SSO 会话 token(免密,代登录态);与 `user`/`password` 二选一 |
### 旧端点(兼容)
```
GET /v1/rrpp/nodes_update?user=<b64>&password=<b64>
```
等价于 `method=rrpp_client`。
## 2. method 说明
| method | Content-Type | 内容 | 适用客户端 |
|---|---|---|---|
| `clash` | `text/yaml` | Clash YAML:`proxies` + `proxy-groups(RRPP)` + `rule-providers` + `rules`(备注 `R.R.P.P. - 国家代码`) | mihomo / Clash Verge / ClashX Meta |
| `v2ray` | `text/plain` | base64 节点串(`vless://` / `ss://` / `http://`) | v2rayN / Shadowrocket / Quantumult |
| `shadowrocket` | `text/plain` | Shadowrocket `.conf`:`[General]` + 远程 `RULE-SET` + `FINAL`(**不含节点**) | Shadowrocket |
| `rrpp_client` | `application/json` | 旧格式节点 JSON(仅 http,remark = `服务商 流量`) | RRPP 自建客户端 |
## 3. 返回码
| 码 | 场景 |
|---|---|
| 200 | 成功 |
| 401 | 凭据缺失/SSO 认证失败 |
| 403 | tier=0(RRPP 未开通) |
| 404 | method 未知,或 `rules` 取值非法 |
| 502 | SSO 不可达 |
| 503 | nodes.json 缺失/损坏,或该 tier 无可用节点 |
## 4. nodes.json 格式(国家分层)
`C:/BtSoft/nginx/others/htmlResources/private/RRPP/nodes.json`,顶层为国家数组:
```jsonc
[
{
"country": "JP", // 国家代码
"available_level": [1, 2, 3], // 可见等级
"provider": "Azure", // 服务商
"traffic": "15G", // 流量限额
"domain": "iam.aeolianchi.me", // 域名
"ipv6_only": false, // 可选,true 时备注加 (IPv6)
"methods": { // 可用协议
"http": { "port": ..., "username": ..., "password": ... },
"vless": { "port": ..., "uuid": ..., "flow": ..., "sni": ..., "public_key": ..., "short_id": ... },
"ss": { "port": ..., "method": ..., "password": ... }
}
},
...
]
```
## 5. 协议选择逻辑
- `prefer` 指定则优先用该协议
- 缺省默认 `vless`(最好用)
- 该国家无 `prefer` 协议时按 `vless → ss → http` 回退
- `rrpp_client` 固定只用 `http`(兼容性),备注 = `provider + traffic`(IPv6 加 `(IPv6)`)
## 6. SSO 认证
- 后端调 SSO `POST http://127.0.0.1:4550/v1/sso/service/rrpp/nodes`(带 `X-SSO-Service-Token` + `X-Real-IP`)
- SSO 返回 `{tier: 0|1|2|3}`,0=未开通
- 服务密钥读 `sso-rrrosmontis-icu/secrets.json` 的 `service_tokens.rrpp`
## 7. 分流规则(PAC → 规则集,随订阅下发)
### 7.1 为什么不是直接下发 PAC
PAC 是给浏览器/操作系统用的 JS 自动配置;**mihomo/Clash 与 Shadowrocket 都不能读 PAC**。
两者唯一共同能吃的格式是「每行自带规则类型的规则行文本」(Clash 术语 `behavior: classical`):
- mihomo:`rule-providers`(`behavior: classical` + `format: text`)
- Shadowrocket:`RULE-SET,<url>,<策略>`(官方手册要求「规则集的内容需包含规则类型」)
因此把 PAC 数据**在后端一次性转换成三个规则集文件**,两种客户端共用同一份文件。
### 7.2 规则集文件(CDN 公开)
生成目录即 CDN 根下的 `RRPP/PAC/`,公开可访问、无鉴权(规则集本身不含节点信息):
| 规则集 | 文件 | 规则数 | 组成 | 策略 |
|---|---|---|---|---|
| `rrpp-proxy` | `rrpp-proxy.list` | 3943 | 539 `DOMAIN` + 3404 `DOMAIN-SUFFIX`(强制代理域名) | `RRPP` / `PROXY` |
| `rrpp-direct` | `rrpp-direct.list` | 16 | 16 `DOMAIN-SUFFIX`(保守直连种子) | `DIRECT` |
| `rrpp-cn-ip` | `rrpp-cn-ip.list` | 7456 | 7456 `IP-CIDR`(中国 IP 段) | `DIRECT` |
URL:`https://cdn.rrrosmontis.icu/RRPP/PAC/<文件名>`
体积:120366 / 386 / 177443 字节,合计 11415 条规则。
### 7.3 规则顺序(与 PAC 语义一致)
```
RULE-SET,rrpp-proxy → RRPP # 强制代理优先(PAC 中强制代理规则优先级更高)
RULE-SET,rrpp-direct → DIRECT # 直连种子
RULE-SET,rrpp-cn-ip → DIRECT # 中国 IP 直连
MATCH → RRPP # 其余全走代理
```
### 7.4 维护流程(PAC 更新后)
转换脚本 `gen_rulesets.py`(与本文件同目录,另在仓库 `server_config/rrpp-subscription/` 存有副本):
```bash
python gen_rulesets.py # 读 PAC 目录 → 原子覆写三个 .list(幂等,内容不变则不写)
python gen_rulesets.py --check # 只检查将有哪些变化,不写盘
python gen_rulesets.py --pac-dir <目录> --out-dir <目录> --quiet
```
- 输入:`pac_domains.json` / `pac_direct_domains.json` / `pac_ip_cn.json`
- 转换规则:`full:x`→`DOMAIN,x`;`keyword:x`→`DOMAIN-KEYWORD,x`;`regexp:`/`include:`→跳过并计数;行内 `#` 注释剥离;`*.x`/普通域名→`DOMAIN-SUFFIX,x`
- 输出排序去重、LF 换行、无注释,写盘用临时文件 + `os.replace` 原子替换
- **订阅端无需任何改动**:文件名与 URL 固定,客户端按 `interval` 自动拉新规则
### 7.5 客户端接入
| 场景 | 用法 |
|---|---|
| mihomo / Clash Verge(推荐) | `&method=clash` → 规则走 `rule-providers`,YAML 约 2.4KB,规则每 24h 自动更新 |
| 客户端不认 `rule-providers`,或想「导入一次到位」 | `&method=clash&rules=inline` → 规则内联进 YAML(约 440KB),导入即含节点+全部规则 |
| Shadowrocket(只想要规则,节点另行导入) | `&method=shadowrocket` → `.conf`,规则走远程 `RULE-SET`(在「配置 → 规则集URL」中可见并自动更新) |
| Shadowrocket(想一次导入节点+规则) | 用 `&method=clash&rules=inline`(小火箭兼容 Clash YAML,含节点信息的标准 Clash 链接可同时导入配置与节点) |
| 兜底 / 排障 | `&rules=global` → 只有 `MATCH,RRPP`,全流量走代理(等价旧行为) |
> `method=shadowrocket` 的 `.conf` **不写 `[Proxy]` 段**:Shadowrocket 的 REALITY 参数写法官方未文档化,不猜测;
> 该配置的策略 `PROXY` 指向首页当前选中的节点,节点请用 `clash`/`v2ray` 订阅导入。
### 7.6 验证记录(2026-09-19)
- 生成:11415 条规则;`regexp:` 跳过 7 条、非法 1 条
- CDN:三个 URL 均 HTTP 200,`md5` 与本地生成一致(`806808b8` / `8f89397e` / `3e75093d`)
- **mihomo v1.19.31 真实内核运行验证**:`-t` 校验通过;运行时 `GET /providers/rules` 显示
`rrpp-proxy=3943`、`rrpp-direct=16`、`rrpp-cn-ip=7456`(`behavior=Classical`),落地文件字节数与生成完全一致;
`&rules=inline` 配置亦通过 `-t` 校验
- Shadowrocket 为 iOS 应用,**未做实机验证**,仅依据官方手册的 `RULE-SET` / Clash YAML 兼容说明实现
@@ -0,0 +1,217 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
RRPP PAC → 规则集转换器
把 PAC 数据文件(JSON)转换成 **mihomo(Clash) 与 Shadowrocket 通用**的
"classical 规则行"文本规则集——每行都含规则类型:
pac_direct_domains.json -> rrpp-direct.list (DOMAIN-SUFFIX,<域名>)
pac_domains.json -> rrpp-proxy.list (DOMAIN-SUFFIX,<域名>)
pac_ip_cn.json -> rrpp-cn-ip.list (IP-CIDR,<网段>)
为什么用这个格式(两端通吃):
* mihomo : rule-providers { behavior: classical, format: text }
* Shadowrocket: RULE-SET,<url>,<策略>
(官方要求"规则集的内容需包含规则类型")
三种规则集由客户端按各自策略引用:
rrpp-proxy.list -> 代理(强制代理域名)
rrpp-direct.list -> 直连(保守直连种子)
rrpp-cn-ip.list -> 直连(中国 IP 段)
用法
----
python gen_rulesets.py # 就地生成(内容有变化才写盘)
python gen_rulesets.py --check # 只检查/报告,不写盘
python gen_rulesets.py --pac-dir DIR --out-dir DIR
python gen_rulesets.py --quiet
维护
----
PAC 更新后重新执行本脚本即可。脚本是**幂等**的:内容与现有文件一致时不会重写;
写入采用 临时文件 + 原子替换,失败不会损坏已有文件。输出不含注释,只有规则行。
"""
import argparse
import hashlib
import ipaddress
import json
import os
import sys
# ---------- 默认路径(bj 生产环境) ----------
DEFAULT_PAC_DIR = r'C:/BtSoft/nginx/others/htmlResources/cdn/RRPP/PAC'
DEFAULT_OUT_DIR = DEFAULT_PAC_DIR # 与 PAC 同目录,由 CDN 直接对外提供
# 映射:PAC 文件 -> (输出文件, JSON 中的列表字段, 类型)
# 类型 domains = 域名表(含 v2fly 前缀:full:/keyword:/regexp:/行内注释)
# 类型 cidrs = IP 网段表
RULESETS = [
('pac_domains.json', 'rrpp-proxy.list', 'domains'),
('pac_direct_domains.json', 'rrpp-direct.list', 'domains'),
('pac_ip_cn.json', 'rrpp-cn-ip.list', 'cidrs'),
]
def log(msg, quiet=False):
if not quiet:
print(msg, flush=True)
def read_json(path):
"""读取 JSON;容忍 BOM。"""
with open(path, 'r', encoding='utf-8-sig') as f:
return json.load(f)
def _valid_domain(s):
if not s or len(s) > 253 or '.' not in s:
return False
if any(c in s for c in ' /\\:@?#[]'):
return False
return True
def convert_domain_entry(v):
"""把一条域名表条目转成规则行。
支持 v2fly geosite 前缀与行内注释(PAC 数据实测存在):
full:<域名> -> DOMAIN,<域名> (精确匹配)
keyword:<关键词> -> DOMAIN-KEYWORD,<关键词>
regexp:<正则> -> 跳过(两端都无法表达域名正则)
<域名> # 注释 -> DOMAIN-SUFFIX,<域名>
返回 (规则行 或 None, 跳过原因 或 None)。
"""
s = str(v or '')
if '#' in s: # 行内注释(如 "apple.xn--czr694b # apple.商标")
s = s.split('#', 1)[0]
s = s.strip().lower()
if not s:
return None, 'empty'
if s.startswith('regexp:'):
return None, 'regexp'
if s.startswith('include:'):
return None, 'include'
if s.startswith('full:'):
dom = s[5:].strip().lstrip('*.').rstrip('.')
return (f'DOMAIN,{dom}', None) if _valid_domain(dom) else (None, 'invalid')
if s.startswith('keyword:'):
kw = s[8:].strip()
return (f'DOMAIN-KEYWORD,{kw}', None) if kw else (None, 'invalid')
if s.startswith('domain:'):
s = s[7:].strip()
dom = s.lstrip('*.').rstrip('.') # 通配前缀统一按后缀匹配
return (f'DOMAIN-SUFFIX,{dom}', None) if _valid_domain(dom) else (None, 'invalid')
def norm_cidr(v):
"""CIDR 规范化/校验:返回规范化网段,非法返回 None。"""
s = str(v or '').strip()
if not s:
return None
try:
net = ipaddress.ip_network(s, strict=False)
except ValueError:
return None
return str(net.network_address) + '/' + str(net.prefixlen)
def build_lines(kind, data, quiet=False):
"""由 PAC JSON 生成规则行(排序去重),返回 (lines, stats)。"""
field = 'domains' if kind == 'domains' else 'cidrs'
raw = data.get(field) if isinstance(data, dict) else None
if not isinstance(raw, list):
raise ValueError(f'字段 {field} 不是列表')
seen, stats, out = set(), {}, []
for item in raw:
if kind == 'domains':
line, why = convert_domain_entry(item)
else:
c = norm_cidr(item)
line, why = (f'IP-CIDR,{c}', None) if c else (None, 'invalid')
if line is None:
stats[why] = stats.get(why, 0) + 1
continue
if line not in seen:
seen.add(line)
out.append(line)
out.sort()
if stats:
detail = ' '.join(f'{k}={v}' for k, v in sorted(stats.items()))
log(f' ! 跳过条目: {detail}', quiet)
return out, stats
def write_if_changed(path, text, check_only=False):
"""内容有变化才原子写入。返回 (changed, md5)。"""
data = text.encode('utf-8')
md5 = hashlib.md5(data).hexdigest()
old = None
if os.path.exists(path):
with open(path, 'rb') as f:
old = f.read()
if old == data:
return False, md5
if check_only:
return True, md5
tmp = path + '.tmp'
with open(tmp, 'wb') as f: # 二进制写:确保 LF、无 BOM
f.write(data)
os.replace(tmp, path)
return True, md5
def main():
ap = argparse.ArgumentParser(description='RRPP PAC -> 规则集转换器')
ap.add_argument('--pac-dir', default=DEFAULT_PAC_DIR, help='PAC JSON 所在目录')
ap.add_argument('--out-dir', default=DEFAULT_OUT_DIR, help='规则集输出目录')
ap.add_argument('--check', action='store_true', help='只检查,不写盘')
ap.add_argument('--quiet', action='store_true')
args = ap.parse_args()
if not os.path.isdir(args.pac_dir):
print(f'错误:PAC 目录不存在: {args.pac_dir}', file=sys.stderr)
return 2
os.makedirs(args.out_dir, exist_ok=True)
total, changed_cnt, failed = 0, 0, 0
log(f'PAC 目录 : {args.pac_dir}')
log(f'输出目录 : {args.out_dir}{" (--check 模式,不写盘)" if args.check else ""}')
log('')
for src_name, out_name, kind in RULESETS:
src_path = os.path.join(args.pac_dir, src_name)
out_path = os.path.join(args.out_dir, out_name)
if not os.path.exists(src_path):
print(f' [跳过] {src_name} 不存在', file=sys.stderr)
failed += 1
continue
try:
data = read_json(src_path)
lines, _stats = build_lines(kind, data, args.quiet)
text = '\n'.join(lines) + '\n'
changed, md5 = write_if_changed(out_path, text, args.check)
size = len(text.encode('utf-8'))
ver = data.get('version', '-') if isinstance(data, dict) else '-'
state = ('已更新' if changed else '无变化') if not args.check else ('需更新' if changed else '无变化')
log(f' {src_name:26s} -> {out_name:18s} {len(lines):5d} 行 {size/1024:7.1f} KB '
f'ver={ver:12s} md5={md5[:8]} [{state}]')
total += len(lines)
changed_cnt += 1 if changed else 0
except Exception as e:
print(f' [失败] {src_name}: {type(e).__name__}: {e}', file=sys.stderr)
failed += 1
log('')
log(f'合计 {total} 条规则,{changed_cnt} 个文件{"需更新" if args.check else "已更新"},{failed} 个失败')
return 1 if failed else 0
if __name__ == '__main__':
sys.exit(main())