From 7358008936cd81541f0fc4d4199d1662ab802c9d Mon Sep 17 00:00:00 2001 From: YANG JIANKUAN Date: Sat, 3 Oct 2026 19:33:03 +0800 Subject: [PATCH] feat: opt ai group --- .claude/skills/deploy/scripts/deploy.sh | 8 +- .claude/skills/update-db/SKILL.md | 91 +++++++++++++++++++ .claude/skills/update-db/evals/evals.json | 23 +++++ .claude/skills/update-db/scripts/api.sh | 81 +++++++++++++++++ docs/SURGE-PRIVACY.md | 25 +++++ server/assets/surge-template.conf | 3 + server/src/services/generators/clash.ts | 9 ++ .../generators/protocol-rules.test.ts | 40 ++++++++ .../services/generators/shadowrocket-conf.ts | 2 +- .../services/generators/surge-ai-dns.test.ts | 51 +++++++++++ .../src/services/generators/surge-ai-dns.ts | 46 ++++++++++ server/src/services/generators/surge.ts | 10 +- shared/src/schemas.ts | 1 + 13 files changed, 382 insertions(+), 8 deletions(-) create mode 100644 .claude/skills/update-db/SKILL.md create mode 100644 .claude/skills/update-db/evals/evals.json create mode 100755 .claude/skills/update-db/scripts/api.sh create mode 100644 docs/SURGE-PRIVACY.md create mode 100644 server/src/services/generators/protocol-rules.test.ts create mode 100644 server/src/services/generators/surge-ai-dns.test.ts create mode 100644 server/src/services/generators/surge-ai-dns.ts diff --git a/.claude/skills/deploy/scripts/deploy.sh b/.claude/skills/deploy/scripts/deploy.sh index 153bf25..1489a36 100755 --- a/.claude/skills/deploy/scripts/deploy.sh +++ b/.claude/skills/deploy/scripts/deploy.sh @@ -96,10 +96,10 @@ if [ "$TARGET" != web ]; then step "推送后端源码与锁文件 → ${REMOTE_DIR}(旧源码保留为 src.old 以便回滚)" tar czf - "${TAR_EXCLUDES[@]}" package.json pnpm-workspace.yaml pnpm-lock.yaml \ shared/package.json shared/tsconfig.json shared/src \ - server/package.json server/tsconfig.json server/src \ + server/package.json server/tsconfig.json server/src server/assets \ web/package.json \ | "${SSH[@]}" "set -e; cd '$REMOTE_DIR'; rm -rf .staging; mkdir .staging; $UNTAR -C .staging; - for d in server/src shared/src; do rm -rf \"\$d.old\"; [ -d \"\$d\" ] && mv \"\$d\" \"\$d.old\"; mv \".staging/\$d\" \"\$d\"; done; + for d in server/src shared/src server/assets; do rm -rf \"\$d.old\"; [ -d \"\$d\" ] && mv \"\$d\" \"\$d.old\"; mv \".staging/\$d\" \"\$d\"; done; cp .staging/package.json .staging/pnpm-workspace.yaml .staging/pnpm-lock.yaml .; cp .staging/shared/package.json .staging/shared/tsconfig.json shared/; cp .staging/server/package.json .staging/server/tsconfig.json server/; @@ -115,7 +115,7 @@ if [ "$TARGET" != web ]; then echo "健康检查 20 秒未通过,最近日志:" "${SSH[@]}" "journalctl -u $SERVICE -n 30 --no-pager -o cat" || true step "回滚到上一版源码" - "${SSH[@]}" "set -e; cd '$REMOTE_DIR'; for d in server/src shared/src; do [ -d \"\$d.old\" ] && rm -rf \"\$d\" && mv \"\$d.old\" \"\$d\"; done; chown -R $RUN_USER:$RUN_USER .; systemctl restart $SERVICE" + "${SSH[@]}" "set -e; cd '$REMOTE_DIR'; for d in server/src shared/src server/assets; do [ -d \"\$d.old\" ] && rm -rf \"\$d\" && mv \"\$d.old\" \"\$d\"; done; chown -R $RUN_USER:$RUN_USER .; systemctl restart $SERVICE" fail "已回滚到旧源码并重启(锁文件与依赖未回滚)。请在本地复现后再部署" fi fi @@ -131,7 +131,7 @@ if [ "$TARGET" != server ]; then if curl -sf -m 10 "$PUBLIC_URL/" | grep -q "$ASSET"; then echo "公网首页已引用新入口脚本 $ASSET"; else echo "公网首页未引用 ${ASSET}(缓存或部署失败)"; OK=0; fi fi if [ "$TARGET" != web ]; then - L="$(tree_hash_local server/src shared/src)"; R="$(tree_hash_remote server/src shared/src)" + L="$(tree_hash_local server/src shared/src server/assets)"; R="$(tree_hash_remote server/src shared/src server/assets)" [ "$L" = "$R" ] && echo "后端源码指纹一致:${L:0:12}" || { echo "后端源码指纹不一致:本地 ${L:0:12} 远端 ${R:0:12}"; OK=0; } "${SSH[@]}" "systemctl is-active --quiet $SERVICE" && echo "systemd 服务 active" || { echo "systemd 服务未运行"; OK=0; } fi diff --git a/.claude/skills/update-db/SKILL.md b/.claude/skills/update-db/SKILL.md new file mode 100644 index 0000000..fc647fd --- /dev/null +++ b/.claude/skills/update-db/SKILL.md @@ -0,0 +1,91 @@ +--- +name: update-db +description: 对 LA 生产机上的 proxy-station 数据做规范化变更——增删节点、导入分享链接、调整策略组与成员、规则、规则集引用、订阅 token、Surge 设置段落。凡是用户要动线上数据的(「线上加/删个节点」「把这个组改成 url-test」「调一下规则」「建个订阅 token」「删掉那个 token」「改下生产上的设置」),都用这个 Skill,即使没提到「数据库」三个字。它只改数据不碰代码:代码更新与部署归 deploy Skill。 +--- + +# 变更 proxy-station 生产数据 + +线上数据(节点、策略组、规则、token、设置)住在 la 机的 SQLite(`/opt/proxy-station/data/proxy-station.db`),由常驻的 `proxy-station` 服务直连。本 Skill 的做法是:**走服务自己的管理 API 做变更,变更前用 sqlite3 在线备份做快照**,不直接写库、不重启服务。 + +## 为什么必须走 API,不能直写 SQLite + +API 不是简单转发,直写会绕过它的全部防护: + +- zod 校验拦住坏数据(VMess 必有 UUID、WS 必有 path、SS 必有 method) +- 节点名唯一冲突返回 409;规则的 policy 必须指向存在的组或内置策略,否则规则整条失效 +- 策略组改名会级联更新引用它的组成员与规则;members 是「整组替换」语义,API 帮你做对 +- 订阅 token 由 crypto 随机生成、常量时间比对——手工 INSERT 塞进 null 或坏格式,`/sub` 全部当场 500 + +生成器每次请求实时读库,**API 改完订阅即刻生效**,不需要重启服务。直接动 SQLite 文件仅限两种只读场景:`api.sh counts` 的行数盘点,以及从备份快照里查旧值。 + +## 用法 + +```bash +bash .claude/skills/update-db/scripts/api.sh api GET /api/nodes +bash .claude/skills/update-db/scripts/api.sh api POST /api/nodes '{"name":"…","protocol":"…",…}' +bash .claude/skills/update-db/scripts/api.sh api GET /sub//clash # 拉订阅做校验(公开端点) +bash .claude/skills/update-db/scripts/api.sh backup # context 用小写连字符 slug,如 add-jp-hy2 +bash .claude/skills/update-db/scripts/api.sh counts +bash .claude/skills/update-db/scripts/api.sh health +``` + +- `api` 经 ssh 在**远端本机回环**调接口:`ADMIN_TOKEN` 只在远端 `/etc/proxy-station/env` 里展开,不经本地 shell、不落对话记录;JSON 走 stdin,无需转义 +- 响应末尾带 `__HTTP_STATUS:200__` 一行。**非 2xx 时先读响应体里的错误信息再决定下一步**,不要盲目重试;要管道给 jq 时先剥掉这行:`| sed '$d' | jq …` +- 连不上 la(解析出 198.18.x.x)是本地 fake-IP 劫持了 ssh,与远端无关,等网络恢复再试 +- 环境变量可覆盖 `DEPLOY_HOST`/`PS_DATA_DIR`/`PS_ENV_FILE`,默认即生产值 + +## 标准流程 + +1. **摸现状**(只读):`api GET /api/groups`、`/api/rules`、`/api/nodes`、`/api/tokens`,把要动的对象当前值拿到手。改组成员、调规则顺序这类「全量替换」操作,不先拿全量必出错。 +2. **备份**:`api.sh backup `。一个变更主题一个备份,同主题内的多条变更共用一份。备份落在远端 `data/backups/`(root 属主),不要清理。 +3. **执行**:逐条调 API,每条核对 HTTP_STATUS 与返回体。某条失败就停下排查,不要跳过继续。 +4. **校验**: + - `counts` 与变更前对比,行数增减要能对上意图 + - `api GET` 变更对象,确认落库的是新值 + - 涉及下发面的变更(节点/组/规则/token 范围):拉一次 `api GET /sub//clash` 确认 200;本地装了 mihomo 的话,`mihomo -t -f <文件>` 是最有说服力的端到端验证 + - `excluded`/`skippedRules` 只存在于生成器内部、不在 HTTP 响应里;要核对静默排除时,用 `/api/nodes` 列表对比 yaml 里的 `proxies` +5. **汇报**:向用户复述三件事——变更清单、备份文件名、校验结果。三者齐了才算完成。 + +## 各实体的操作要点 + +### 节点 `/api/nodes` +- 新节点来自分享链接:先 `POST /api/nodes/import` 带 `{"text":"…","commit":false}` 预览(返回每条的解析结果与自动去重后的名字),确认后再 `commit:true` 真正入库 +- 节点名是分流的关键:必须保留出口词尾缀(客户端里没有标签,用户靠名字区分直连/WARP 线路);地区组按名称正则收编,涉及新地区先读 `docs/ADDING-REGIONS.md` +- 临时下线用 `PATCH /:id/enabled {"enabled":false}`,不要删——IR 层会自动剔除停用节点,删了凭据就没了 +- 真要删除:先 GET 全量字段向用户展示将消失的内容,确认后 DELETE + +### 策略组 `/api/groups` +- `members` 是**整体替换**:GET 全量 → 改数组 → PUT 回去,别只发改动项 +- 成员三态:`{kind:"node", nodeId}`/`{kind:"group", refName}`/`{kind:"builtin", refName}` +- `filterRegex` 与 `includeAllNodes` 可共存,**filterRegex 优先**(动态筛选用它,新增同地区节点自动进组) +- 改名后端会级联引用,但已导入客户端的旧组名会失配——改名前先向用户点明 +- url-test/fallback 组别忘了 `testUrl`/`interval`/`tolerance` + +### 规则 `/api/rules` +- 顺序由 `PUT /api/rules/reorder` 的**全量 ids 数组**决定(GET 拿现有顺序 → 调整 → 全量提交);自定义规则置顶是既有约定 +- FINAL 规则永远保留;其策略组失效时 IR 层降级 DIRECT——兜底存在,但别依赖 +- 新规则的 policy 必须已存在(内置 DIRECT/REJECT… 或现有组名),后端会校验,报错就先建组 +- `POST /api/rules/reset` 会重置回模板初版并**清掉全部自定义规则**——除非用户明说「重置」,否则永远不调用 + +### 规则集 `/api/rulesets`(只读) +- `GET /api/rulesets` 看清单,`GET /:id/content` 看自托管转换结果 +- `ruleset_cache` 是 24h 缓存(etag + 陈旧回落)自动管理,不要手动碰;「改规则集」实际是改引用它的规则 + +### 订阅 token `/api/tokens` +- 输入 `{name, nodeIds}`:`nodeIds` 空数组 = 下发全部启用节点;非空 = 只下发这些节点(给不同设备发不同范围的入口就在这里配) +- token 值由后端随机生成,创建响应里拿一次;`lastAccessAt`/`accessCount` 可判断是否仍在被使用 +- **删除 token 会即刻断供所有用它的订阅 URL**——删除前必须向用户点明后果 + +### 设置 `/api/settings` +- `surgeSections` 是 Surge 原文段落透传,改错直接毁掉 Surge 配置;动它之前先 GET 存档 + backup,改完拉 `/sub//surge` 全文核对 + +## 回滚 + +- 单条改错:用 API 反向改回。旧值可以从备份快照只读查出:远端 `sqlite3 data/backups/.db 'SELECT …'` +- 整体回滚:`api.sh restore data/backups/.db --confirm`——停服换库再拉起,订阅中断约 10–20 秒,只在用户明确要求时执行 + +## 边界 + +- 不改代码、不做部署(那是 deploy Skill 的事);不动 nginx/证书/`ADMIN_TOKEN`/systemd 单元 +- 删除类操作与 reset:先列出将删除/重置的完整清单,用户确认后再执行 +- 节点凭据是真凭据:汇报里不要粘贴密码/UUID 全文,用节点名指代 diff --git a/.claude/skills/update-db/evals/evals.json b/.claude/skills/update-db/evals/evals.json new file mode 100644 index 0000000..3910240 --- /dev/null +++ b/.claude/skills/update-db/evals/evals.json @@ -0,0 +1,23 @@ +{ + "skill_name": "update-db", + "evals": [ + { + "id": 1, + "prompt": "帮我盘一下线上现在一共有多少节点、多少个策略组、多少条规则,节点按地区归归类", + "expected_output": "走 update-db Skill 的只读路径:api GET /api/nodes、/api/groups、/api/rules(或 counts),汇总出准确的行数与按地区分组的节点清单;不产生任何写操作、不建备份也行(纯读)", + "files": [] + }, + { + "id": 2, + "prompt": "在线上建一个临时测试订阅 token,名字叫 eval-test,确认订阅拉得通,然后把它删掉", + "expected_output": "走标准流程:backup → POST /api/tokens {name:eval-test} → GET /sub/<新token>/clash 验证 200 → DELETE → GET /api/tokens 确认已删;全程经 API,不直写 SQLite,最后汇报变更清单+备份名+校验结果", + "files": [] + }, + { + "id": 3, + "prompt": "我想把这条分享链接加到线上,先帮我看看它会解析成什么、别真的提交:vmess://eyJ2IjoiMiIsInBzIjoic2VsZi10ZXN0LWR1bW15IiwiYWRkIjoiZXhhbXBsZS5pbnZhbGlkIiwicG9ydCI6IjQ0MyIsImlkIjoiMDAwMDAwMDAtMDAwMC0wMDAwLTAwMDAtMDAwMDAwMDAwMDAwIiwiYWlkIjoiMCIsIm5ldCI6InRjcCIsInR5cGUiOiJub25lIiwidGxzIjoidGxzIn0=", + "expected_output": "POST /api/nodes/import 带 commit:false 干跑预览,展示解析出的节点字段(协议/地址/端口/UUID/TLS),明确说明未入库;不执行 commit:true", + "files": [] + } + ] +} diff --git a/.claude/skills/update-db/scripts/api.sh b/.claude/skills/update-db/scripts/api.sh new file mode 100755 index 0000000..c2b4f7b --- /dev/null +++ b/.claude/skills/update-db/scripts/api.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# update-db 的唯一执行通道:对生产库的一切操作经它走 ssh(在远端本机回环上调 API / 做快照)。 +# +# 用法: +# api.sh api METHOD PATH [JSON] # 调管理接口;PATH 以 /api/ 或 /sub/ 开头;JSON 作第三参 +# api.sh backup # 变更前快照 → data/backups/before--<时间戳>.db +# api.sh backups # 列出最近 20 个备份 +# api.sh counts # 各表行数,变更前后对比用 +# api.sh health # systemd 状态 + /api/health +# api.sh restore --confirm # 整体回滚:停服换库再拉起(仅在用户明确要求时执行) +# +# 可用环境变量覆盖:DEPLOY_HOST(默认 la)、DEPLOY_SERVICE(默认 proxy-station)、 +# PS_DATA_DIR(默认 /opt/proxy-station/data)、PS_ENV_FILE(默认 /etc/proxy-station/env) +set -euo pipefail + +# 会话注入的 HTTP 代理只供模型 API 使用,ssh 一律直连(同 deploy.sh) +unset HTTPS_PROXY HTTP_PROXY https_proxy http_proxy ALL_PROXY all_proxy 2>/dev/null || true + +HOST="${DEPLOY_HOST:-la}" +SERVICE="${DEPLOY_SERVICE:-proxy-station}" +DATA_DIR="${PS_DATA_DIR:-/opt/proxy-station/data}" +DB_FILE="$DATA_DIR/proxy-station.db" +BACKUP_DIR="$DATA_DIR/backups" +ENV_FILE="${PS_ENV_FILE:-/etc/proxy-station/env}" + +SSH=(ssh -o BatchMode=yes -o ConnectTimeout=15 "$HOST") + +die() { echo "错误:$*" >&2; exit 1; } + +[ $# -ge 1 ] || die "缺少子命令(api/backup/backups/counts/health/restore)" +cmd="$1"; shift + +case "$cmd" in + api) + [ $# -ge 2 ] || die "用法:api.sh api METHOD PATH [JSON]" + METHOD="$1"; APIPATH="$2"; BODY="${3:-}" + case "$METHOD" in GET|POST|PUT|PATCH|DELETE) ;; *) die "METHOD 只能是 GET/POST/PUT/PATCH/DELETE";; esac + case "$APIPATH" in /api/*|/sub/*) ;; *) die "PATH 必须以 /api/ 或 /sub/ 开头";; esac + # 单引号段内的 $ADMIN_TOKEN 与 ${PORT:-3000} 留给远端 shell 展开:token 不经本地 shell、不落本地磁盘 + RCMD='. '"$ENV_FILE"' 2>/dev/null; curl -sS -m 60 -X '"$METHOD"' -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" -w "\n__HTTP_STATUS:%{http_code}__" http://127.0.0.1:${PORT:-3000}'"$APIPATH" + if [ -n "$BODY" ]; then + printf '%s' "$BODY" | "${SSH[@]}" "$RCMD --data-binary @-" + else + "${SSH[@]}" "$RCMD" + fi + ;; + backup) + CTX="${1:-}"; [ -n "$CTX" ] || die "用法:api.sh backup (小写字母/数字/连字符,如 add-jp-hy2)" + case "$CTX" in *[!a-z0-9-]*) die "context 只允许小写字母、数字与连字符";; esac + # .backup 是 SQLite 在线备份,WAL 下安全,产出独立完整快照(无需 wal/shm) + "${SSH[@]}" 'set -e; ts=$(date +%Y%m%d-%H%M%S); f='"$BACKUP_DIR"'/before-'"$CTX"'-$ts.db; sqlite3 '"$DB_FILE"' ".backup $f"; if [ -s "$f" ]; then ls -la "$f"; else echo "备份为空" >&2; exit 1; fi' + ;; + backups) + "${SSH[@]}" "ls -la '$BACKUP_DIR' | tail -20" + ;; + counts) + # 表清单来自 BOOTSTRAP_SQL;新增表时应同步更新这里 + SQL="SELECT 'nodes='||COUNT(*) FROM nodes" + for t in policy_groups group_members rules rulesets ruleset_cache sub_tokens settings; do + SQL="$SQL UNION ALL SELECT '$t='||COUNT(*) FROM $t" + done + "${SSH[@]}" "sqlite3 '$DB_FILE' \"$SQL\"" + ;; + health) + "${SSH[@]}" "systemctl is-active $SERVICE; curl -sS -m 5 http://127.0.0.1:3000/api/health; echo" + ;; + restore) + FILE="${1:-}"; [ -n "$FILE" ] || die "用法:api.sh restore <备份文件> --confirm" + [ "${2:-}" = "--confirm" ] || die "restore 会停服换库(订阅中断约 10–20 秒),必须由用户明确要求后追加 --confirm 执行" + case "$FILE" in "$BACKUP_DIR"/*.db) ;; *) die "备份文件必须是 $BACKUP_DIR 下的 .db 快照";; esac + "${SSH[@]}" 'set -e; f='"$FILE"'; [ -f "$f" ] || { echo "备份不存在:$f" >&2; exit 1; } + systemctl stop '"$SERVICE"' + cp "$f" '"$DB_FILE"'.new && mv '"$DB_FILE"'.new '"$DB_FILE"' + rm -f '"$DB_FILE"'-wal '"$DB_FILE"'-shm + chown '"$SERVICE"':'"$SERVICE"' '"$DB_FILE"' + systemctl start '"$SERVICE"' + for i in $(seq 1 20); do curl -sf -m 2 http://127.0.0.1:3000/api/health >/dev/null && break; sleep 1; done + systemctl is-active '"$SERVICE"' && curl -s http://127.0.0.1:3000/api/health && echo && echo "已回滚自 $(basename "$f")"' + ;; + *) die "未知子命令:$cmd(可用:api/backup/backups/counts/health/restore)" ;; +esac diff --git a/docs/SURGE-PRIVACY.md b/docs/SURGE-PRIVACY.md new file mode 100644 index 0000000..1420594 --- /dev/null +++ b/docs/SURGE-PRIVACY.md @@ -0,0 +1,25 @@ +# Surge AI 域名 DNS 分流 + +默认 DNS 基于 https://github.com/ClashConnectRules/Surge 的 Surge.conf:阿里、腾讯 DoH 与原来的国内 Host 映射保持直连;不再将全局 DNS 送到 AI,也不再新增全端口 53 接管。 + +Surge 生成器从启用的 AI 策略规则自动生成 Host 映射。组名支持 `AI` 和 `🤖 AI`。DOMAIN 精确匹配;DOMAIN-SUFFIX 同时生成根域与子域匹配;DOMAIN-KEYWORD 生成通配符;RULE-SET / DOMAIN-SET 直接绑定相同远程列表,由 Surge 更新,无需维护国内域名集合。IP、进程及复合规则无法直接转换为域名 DNS 映射,不生成。Host 与连接规则是独立匹配系统,复杂优先级或例外可能不同。 + +生成配置打开 encrypted-dns-follow-outbound-mode,并置顶: + +```ini +IP-CIDR,1.1.1.1/32,"🤖 AI",no-resolve +DOMAIN,doh.pub,DIRECT +DOMAIN,dns.alidns.com,DIRECT +``` + +AI Host 映射使用 `server:https://1.1.1.1/dns-query`,放在普通 Host 映射之前。选择其他 AI 线路后 Cloudflare 查询也跟随同一策略组。该 IP 规则也会将访问 1.1.1.1 的其他连接交给 AI 组。 + +本功能控制 Surge 自己执行的 DNS 查询。代理目标域名可能由远端代理解析;官方说明 use-local-host-item-for-proxy 仅适用于 IP 映射,不适用于 server: 映射,因此不能声称远端代理的解析器被改为 Cloudflare。浏览器自带 DoH 也不自动按 AI 域名匹配这些 Host 项。 + +代理服务器域名的解析可能触发加密 DNS 循环依赖,需核对 Surge 日志。线上保留已验证的节点 Host IP 映射供引导使用;节点 IP 变化后更新映射,私有节点 IP 不提交仓库。 + +数据库 settings/rules 是已有订阅的来源,模板仅首次初始化导入。修改默认 DNS 时使用 update-db 技能备份并经管理 API 更新;不重置规则。移除上一版全局 PROTOCOL,DOH,AI;保留 STUN 阻断及 UDP REJECT,不影响已授权的 WebRTC 防护。 + +导入时关联 General、Proxy、Proxy Group、Rule、Host,并开启增强模式。无需 MITM 证书。更新后清除 DNS 缓存并复测 Surge DNS 请求日志;生成配置验证不是设备泄漏测试。 + +参考:[Host DNS 映射](https://manual.nssurge.com/dns/local-dns-mapping.html)、[加密 DNS](https://manual.nssurge.com/dns/encrypted-dns.html)。 diff --git a/server/assets/surge-template.conf b/server/assets/surge-template.conf index 671fd30..6751c6b 100644 --- a/server/assets/surge-template.conf +++ b/server/assets/surge-template.conf @@ -111,6 +111,9 @@ Speedtest = select, Mainland, Automatic, AllServer, no-alert=0, hidden=0, includ [Rule] +# 阻断 STUN,避免未匹配请求落到直连兜底 +PROTOCOL,STUN,REJECT + # >> 规则修正 (Unbreak Rules) RULE-SET,https://raw.githubusercontent.com/zxfccmm4/Profiles/refs/heads/main/Surge/Ruleset/Unbreak.list,DIRECT diff --git a/server/src/services/generators/clash.ts b/server/src/services/generators/clash.ts index 7bea8d5..4fba4f3 100644 --- a/server/src/services/generators/clash.ts +++ b/server/src/services/generators/clash.ts @@ -147,6 +147,15 @@ export function generateClash(ir: ProfileIR, opts: { convertBaseUrl: string }): skippedRules.push(`${r.type},${r.value},${r.policy}`); continue; } + // Surge 的应用协议识别不能直接作为 mihomo 规则类型输出。 + if (r.type === 'PROTOCOL') { + if (r.value === 'TCP' || r.value === 'UDP') { + ruleLines.push(`NETWORK,${r.value},${mapPolicy(r.policy)}`); + } else { + skippedRules.push(`${r.type},${r.value},${r.policy}`); + } + continue; + } const policy = mapPolicy(r.policy); if (r.ruleset) { ruleLines.push(`RULE-SET,${providerNameByRulesetId.get(r.ruleset.id)},${policy}`); diff --git a/server/src/services/generators/protocol-rules.test.ts b/server/src/services/generators/protocol-rules.test.ts new file mode 100644 index 0000000..34f7e5d --- /dev/null +++ b/server/src/services/generators/protocol-rules.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import YAML from 'yaml'; +import { generateSurge } from './surge.js'; +import { generateClash } from './clash.js'; +import { generateShadowrocketConf } from './shadowrocket-conf.js'; +import type { ProfileIR } from '../ir.js'; +import { ruleInputSchema } from '@proxy-station/shared'; + +const ir: ProfileIR = { + nodes: [], groups: [], sections: {}, + rules: [ + { type: 'PROTOCOL', value: 'DOH', policy: '🤖 AI', params: [], ruleset: null }, + { type: 'PROTOCOL', value: 'STUN', policy: 'REJECT', params: [], ruleset: null }, + { type: 'PROTOCOL', value: 'UDP', policy: 'REJECT', params: [], ruleset: null }, + { type: 'FINAL', value: null, policy: 'DIRECT', params: [], ruleset: null }, + ], +}; + +describe('client-specific protocol rules', () => { + it('accepts privacy rules through the management API schema', () => { + for (const rule of ir.rules.slice(0, 2)) { + expect(ruleInputSchema.safeParse(rule).success).toBe(true); + } + }); + it('preserves Surge DNS/STUN rules before FINAL', () => { + const { content } = generateSurge(ir, { selfUrl: 'https://example.com/sub/test/surge' }); + expect(content).toContain('PROTOCOL,DOH,"🤖 AI"\nPROTOCOL,STUN,REJECT\nPROTOCOL,UDP,REJECT\nFINAL,DIRECT'); + }); + it('maps transport rules and skips Surge application protocols in Clash', () => { + const result = generateClash(ir, { convertBaseUrl: 'https://example.com/sub/test' }); + expect(YAML.parse(result.content).rules).toEqual(['NETWORK,UDP,REJECT', 'MATCH,DIRECT']); + expect(result.skippedRules).toEqual(['PROTOCOL,DOH,🤖 AI', 'PROTOCOL,STUN,REJECT']); + }); + it('does not emit Surge PROTOCOL rules in Shadowrocket', () => { + const result = generateShadowrocketConf(ir); + expect(result.content).not.toContain('PROTOCOL,'); + expect(result.content).toContain('FINAL,DIRECT'); + expect(result.skippedRules).toHaveLength(3); + }); +}); diff --git a/server/src/services/generators/shadowrocket-conf.ts b/server/src/services/generators/shadowrocket-conf.ts index 478cdcf..6321550 100644 --- a/server/src/services/generators/shadowrocket-conf.ts +++ b/server/src/services/generators/shadowrocket-conf.ts @@ -45,7 +45,7 @@ function shadowrocketGeneral(surgeGeneral: string): string { } /** ShadowRocket 不认识的规则类型 */ -const SR_UNSUPPORTED_RULE_TYPES = new Set(['DOMAIN-SET', 'AND', 'OR', 'NOT', 'URL-REGEX']); +const SR_UNSUPPORTED_RULE_TYPES = new Set(['DOMAIN-SET', 'AND', 'OR', 'NOT', 'URL-REGEX', 'PROTOCOL']); export interface ShadowrocketConfOutput { content: string; diff --git a/server/src/services/generators/surge-ai-dns.test.ts b/server/src/services/generators/surge-ai-dns.test.ts new file mode 100644 index 0000000..b32460d --- /dev/null +++ b/server/src/services/generators/surge-ai-dns.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { surgeAiDns } from './surge-ai-dns.js'; +import { generateSurge } from './surge.js'; +import { generateClash } from './clash.js'; +import { generateShadowrocketConf } from './shadowrocket-conf.js'; +import type { ProfileIR, RuleIR } from '../ir.js'; + +const rule = (type: string, value: string, policy = '🤖 AI'): RuleIR => ({ type, value, policy, params: [], ruleset: null }); +const ir: ProfileIR = { + nodes: [], + groups: [{ name: '🤖 AI', type: 'select', testUrl: null, interval: null, tolerance: null, filterRegex: null, includeAllNodes: false, members: [{ kind: 'builtin', name: 'REJECT' }] }], + rules: [rule('DOMAIN', 'exact.example'), rule('DOMAIN-SUFFIX', 'ai.example'), rule('DOMAIN-KEYWORD', 'assistant'), + rule('RULE-SET', 'set-id'), rule('IP-CIDR', '192.0.2.0/24'), rule('DOMAIN', 'local.example', 'DIRECT')], + sections: { General: 'encrypted-dns-follow-outbound-mode = false\nencrypted-dns-server = https://doh.pub/dns-query, https://dns.alidns.com/dns-query\nhijack-dns = 8.8.8.8:53, 8.8.4.4:53', Host: 'local.example = server:223.5.5.5' }, +}; +ir.rules[3].ruleset = { id: 'set-id', name: 'ai', surgeUrl: 'https://example.com/ai.list', clashUrl: null, clashBehavior: 'classical', clashFormat: 'yaml', needsConvert: true }; + +describe('AI-only Surge DNS', () => { + it('derives exact, suffix, keyword and remote set mappings without changing normal DNS', () => { + const dns = surgeAiDns(ir); + for (const key of ['exact.example', 'ai.example', '*.ai.example', '*assistant*', 'RULE-SET:https://example.com/ai.list']) { + expect(dns.host).toContain(`${key} = server:https://1.1.1.1/dns-query`); + } + expect(dns.host).not.toContain('192.0.2.0/24'); + expect(dns.host).not.toContain('local.example = server:https://'); + expect(dns.host).toContain(ir.sections.Host); + expect(dns.general).toContain('encrypted-dns-follow-outbound-mode = true'); + expect(dns.general).toContain('encrypted-dns-server = https://doh.pub/dns-query, https://dns.alidns.com/dns-query'); + expect(dns.general).toContain('hijack-dns = 8.8.8.8:53, 8.8.4.4:53'); + }); + it('routes only Cloudflare through the same AI group and defaults directly before ordinary rules', () => { + const { content } = generateSurge(ir, { selfUrl: 'https://example.com/sub/test/surge' }); + expect(content).toContain('[Rule]\nIP-CIDR,1.1.1.1/32,"🤖 AI",no-resolve\nDOMAIN,doh.pub,DIRECT\nDOMAIN,dns.alidns.com,DIRECT'); + expect(content).not.toContain('PROTOCOL,DOH'); + }); + it('does not leak Surge-specific DNS mappings into other clients', () => { + expect(generateClash(ir, { convertBaseUrl: 'https://example.com' }).content).not.toContain('server:https://1.1.1.1'); + expect(generateShadowrocketConf(ir).content).not.toContain('server:https://1.1.1.1'); + }); + it('removes derived DNS when AI is disabled or all its domain rules are removed', () => { + expect(surgeAiDns({ ...ir, groups: [] })).toEqual({ general: ir.sections.General, host: ir.sections.Host, rules: [] }); + expect(surgeAiDns({ ...ir, rules: [] }).rules).toEqual([]); + }); + it('supports the upstream AI group name, deduplicates mappings and honors changed policy', () => { + const base = { ...ir, groups: [{ ...ir.groups[0], name: 'AI' }], rules: [rule('DOMAIN', 'new.example', 'AI'), rule('DOMAIN', 'new.example', 'AI'), rule('DOMAIN', 'old.example', 'DIRECT')] }; + const dns = surgeAiDns(base); + expect(dns.rules[0]).toBe('IP-CIDR,1.1.1.1/32,AI,no-resolve'); + expect(dns.host.match(/new\.example =/g)).toHaveLength(1); + expect(dns.host).not.toContain('old.example'); + }); +}); diff --git a/server/src/services/generators/surge-ai-dns.ts b/server/src/services/generators/surge-ai-dns.ts new file mode 100644 index 0000000..59251e3 --- /dev/null +++ b/server/src/services/generators/surge-ai-dns.ts @@ -0,0 +1,46 @@ +import type { ProfileIR } from '../ir.js'; + +const AI_DNS = 'https://1.1.1.1/dns-query'; + +/** Derive DNS coverage from the same enabled AI rules used for connection routing. */ +export function surgeAiDns(ir: ProfileIR): { general: string; host: string; rules: string[] } { + const general = ir.sections.General ?? ''; + const host = ir.sections.Host ?? ''; + const ai = ir.groups.find((g) => /^[^\p{L}\p{N}]*AI$/u.test(g.name)); + if (!ai) return { general, host, rules: [] }; + + const mappings = new Set(); + for (const rule of ir.rules) { + if (rule.policy !== ai.name || !rule.value) continue; + const value = rule.ruleset?.surgeUrl ?? rule.value; + switch (rule.type) { + case 'DOMAIN': mappings.add(value); break; + // *.example.com alone does not include the apex hostname in Surge Host syntax. + case 'DOMAIN-SUFFIX': mappings.add(value); mappings.add(`*.${value}`); break; + case 'DOMAIN-KEYWORD': mappings.add(`*${value}*`); break; + case 'RULE-SET': + case 'DOMAIN-SET': + if (/^https?:\/\//.test(value)) mappings.add(`${rule.type}:${value}`); + break; + } + } + if (!mappings.size) return { general, host, rules: [] }; + + const updatedGeneral = /^encrypted-dns-follow-outbound-mode\s*=/mi.test(general) + ? general.replace(/^encrypted-dns-follow-outbound-mode\s*=.*$/gmi, 'encrypted-dns-follow-outbound-mode = true') + : `${general}\nencrypted-dns-follow-outbound-mode = true`; + const generatedHost = [...mappings].map((pattern) => `${pattern} = server:${AI_DNS}`).join('\n'); + const name = /[\s,]/.test(ai.name) ? `"${ai.name}"` : ai.name; + + return { + general: updatedGeneral, + // AI mappings take precedence over the template's ordinary per-domain DNS mappings. + host: `# AI DNS: generated from enabled AI routing rules\n${generatedHost}\n${host}`.trim(), + rules: [ + `IP-CIDR,1.1.1.1/32,${name},no-resolve`, + // Keep the upstream template's default DoH on the local path. + 'DOMAIN,doh.pub,DIRECT', + 'DOMAIN,dns.alidns.com,DIRECT', + ], + }; +} diff --git a/server/src/services/generators/surge.ts b/server/src/services/generators/surge.ts index 914a659..58b82d9 100644 --- a/server/src/services/generators/surge.ts +++ b/server/src/services/generators/surge.ts @@ -1,5 +1,6 @@ import { clientSupports, normalizeSsCipher, type ExcludedNode, type NodeDto } from '@proxy-station/shared'; import type { ProfileIR } from '../ir.js'; +import { surgeAiDns } from './surge-ai-dns.js'; /** 成员名含空格/逗号时按 Surge 习惯加引号 */ function quoteName(name: string): string { @@ -56,6 +57,7 @@ export interface SurgeOutput { } export function generateSurge(ir: ProfileIR, opts: { selfUrl: string }): SurgeOutput { + const dns = surgeAiDns(ir); const excluded: ExcludedNode[] = []; const usable = ir.nodes.filter((n) => { const verdict = clientSupports('surge', n); @@ -69,7 +71,7 @@ export function generateSurge(ir: ProfileIR, opts: { selfUrl: string }): SurgeOu lines.push('# 由 Proxy Station 生成,请勿手动编辑(改动会在下次更新时被覆盖)'); lines.push(''); lines.push('[General]'); - lines.push(ir.sections['General'] ?? ''); + lines.push(dns.general); lines.push('', '[Proxy]'); for (const n of usable) lines.push(surgeNodeLine(n)); @@ -105,6 +107,7 @@ export function generateSurge(ir: ProfileIR, opts: { selfUrl: string }): SurgeOu } lines.push('', '[Rule]'); + lines.push(...dns.rules); for (const r of ir.rules) { if (r.type === 'FINAL') { lines.push(['FINAL', quoteName(r.policy), ...r.params].join(',')); @@ -115,8 +118,9 @@ export function generateSurge(ir: ProfileIR, opts: { selfUrl: string }): SurgeOu } for (const section of ['Host', 'URL Rewrite', 'Header Rewrite', 'MITM', 'Script']) { - if (ir.sections[section]) { - lines.push('', `[${section}]`, ir.sections[section]); + const content = section === 'Host' ? dns.host : ir.sections[section]; + if (content) { + lines.push('', `[${section}]`, content); } } diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index e22447d..16afcb4 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -77,6 +77,7 @@ export const RULE_TYPES = [ 'IP-ASN', 'GEOIP', 'PROCESS-NAME', + 'PROTOCOL', 'USER-AGENT', 'URL-REGEX', 'AND',