From 9a64853db49cc35035746f998e4ed8e328da161b Mon Sep 17 00:00:00 2001 From: DSH Agent Date: Sat, 19 Sep 2026 18:46:54 +0800 Subject: [PATCH] =?UTF-8?q?server=5Fconfig:=20=E6=96=B0=E5=A2=9E=20RRPP=20?= =?UTF-8?q?=E8=AE=A2=E9=98=85=E5=90=8E=E7=AB=AF=E8=A7=84=E5=88=99=E9=9B=86?= =?UTF-8?q?=E4=B8=8B=E5=8F=91=E6=96=87=E6=A1=A3=EF=BC=88PAC=20=E2=86=92=20?= =?UTF-8?q?classical=20=E8=A7=84=E5=88=99=E9=9B=86=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 §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 --- server_config/README.md | 67 ++++++ server_config/rrpp-subscription/API.md | 152 ++++++++++++ .../rrpp-subscription/gen_rulesets.py | 217 ++++++++++++++++++ 3 files changed, 436 insertions(+) create mode 100644 server_config/rrpp-subscription/API.md create mode 100644 server_config/rrpp-subscription/gen_rulesets.py diff --git a/server_config/README.md b/server_config/README.md index 650a907..c962629 100644 --- a/server_config/README.md +++ b/server_config/README.md @@ -22,6 +22,7 @@ - [9. systemd 服务文件](#9-systemd-服务文件) - [10. 启动顺序与验证清单](#10-启动顺序与验证清单) - [11. 目录结构与文件索引](#11-目录结构与文件索引) +- [12. RRPP 订阅后端(PAC → 规则集下发)](#12-rrpp-订阅后端pac--规则集下发) --- @@ -564,8 +565,74 @@ server_config/ │ └── sync-nginx-local-ips.sh ← 本机公网 IP 规则自动维护 ├── cron/ │ └── sync-nginx-local-ips ← cron 定义(每 5 分钟) +├── rrpp-subscription/ ← RRPP 订阅后端(bj,见 §12) +│ ├── API.md ← 订阅端点 / method / 分层 / 规则集文档 +│ └── gen_rulesets.py ← PAC → 客户端规则集转换脚本 └── fake-site/ └── index.html ← 伪装站点页面(所有节点共用同一份) ``` > 旧的 `traffic-monitor/`、`pac/`、`conf.d/*.conf` 已下线或迁移,不再维护,已从本目录移除。 + +--- + +## 12. RRPP 订阅后端(PAC → 规则集下发) + +订阅服务运行在 **bj**(`C:/BtSoft/nginx/others/backEnd/api-rrrosmontis-icu/RRPP/`,nssm 服务 `RRPPSubBackend`,监听 `127.0.0.1:5010`,由 nginx `api.rrrosmontis.icu` 反代)。 +完整端点说明见 `rrpp-subscription/API.md`(bj 上同名文件为主副本,两处内容保持一致)。 + +### 12.1 PAC 为什么不能直接下发 + +PAC 是浏览器/系统用的 JS 自动配置,**mihomo/Clash 与 Shadowrocket 都读不了**。两者唯一共同能吃的格式是 +「每行自带规则类型的规则行文本」(Clash 的 `behavior: classical`): + +- mihomo → `rule-providers`(`behavior: classical` + `format: text`) +- Shadowrocket → `RULE-SET,,<策略>`(官方手册:规则集内容需包含规则类型) + +因此由后端 `gen_rulesets.py` 把 PAC 数据转成三个 `.list`,两种客户端共用同一份文件。 + +### 12.2 规则集产物 + +| 规则集 | 文件(`https://cdn.rrrosmontis.icu/RRPP/PAC/`) | 规则数 | 策略 | +|---|---|---|---| +| `rrpp-proxy` | `rrpp-proxy.list` | 3943(`DOMAIN` 539 + `DOMAIN-SUFFIX` 3404) | `RRPP` / `PROXY` | +| `rrpp-direct` | `rrpp-direct.list` | 16(`DOMAIN-SUFFIX`) | `DIRECT` | +| `rrpp-cn-ip` | `rrpp-cn-ip.list` | 7456(`IP-CIDR`) | `DIRECT` | + +顺序(与 PAC 语义一致):强制代理 → 直连种子 → 中国 IP 直连 → `MATCH` 全代理。 + +### 12.3 维护:PAC 更新后重新生成 + +```bash +# bj 上执行(Windows:C:/BtSoft/nginx/others/htmlResources/cdn/RRPP/PAC/ 为生成目录,同时即 CDN 根) +python gen_rulesets.py # 幂等:内容无变化则不写盘;原子替换 +python gen_rulesets.py --check # 只预览差异,不写盘 +``` + +产物文件名与 URL 固定,**订阅端与客户端均无需改动**:mihomo 按 `interval: 86400` 自动更新, +Shadowrocket 在「配置 → 规则集URL」中自动更新。 + +### 12.4 客户端接入 + +```bash +# mihomo / Clash Verge:规则走 rule-providers(YAML 约 2.4KB) +...&method=clash +# 不认 rule-providers,或要「导入一次到位」:规则内联(约 440KB) +...&method=clash&rules=inline +# Shadowrocket:只下发规则([General] + 远程 RULE-SET + FINAL,不含节点) +...&method=shadowrocket +# 兜底:全流量走代理(旧行为) +...&method=clash&rules=global +``` + +> `method=shadowrocket` 的 `.conf` 不写 `[Proxy]` 段——Shadowrocket 的 REALITY 参数写法官方未文档化,不猜测; +> 策略 `PROXY` 指向首页当前选中的节点,节点用 `clash`/`v2ray` 订阅导入。 +> 想在小火箭里「一次导入节点+规则」,用 `method=clash&rules=inline`。 + +### 12.5 验证口径 + +- 生成:11415 条规则(`regexp:` 跳过 7、非法 1) +- CDN:三个 URL HTTP 200,md5 与本地一致(`806808b8` / `8f89397e` / `3e75093d`) +- mihomo v1.19.31 真实内核:`-t` 通过;运行时 `GET /providers/rules` 报 `3943 / 16 / 7456`(`behavior=Classical`), + 落地文件字节数与生成一致;`rules=inline` 亦通过 `-t` +- Shadowrocket 为 iOS 应用,未做实机验证(依官方手册 `RULE-SET` 与 Clash YAML 兼容说明实现) diff --git a/server_config/rrpp-subscription/API.md b/server_config/rrpp-subscription/API.md new file mode 100644 index 0000000..67330d6 --- /dev/null +++ b/server_config/rrpp-subscription/API.md @@ -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=&password=[&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=&password= +``` +等价于 `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,,<策略>`(官方手册要求「规则集的内容需包含规则类型」) + +因此把 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 兼容说明实现 diff --git a/server_config/rrpp-subscription/gen_rulesets.py b/server_config/rrpp-subscription/gen_rulesets.py new file mode 100644 index 0000000..2686ced --- /dev/null +++ b/server_config/rrpp-subscription/gen_rulesets.py @@ -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,,<策略> + (官方要求"规则集的内容需包含规则类型") + +三种规则集由客户端按各自策略引用: + 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())