diff --git a/.agents/skills/deploy b/.agents/skills/deploy new file mode 120000 index 0000000..d9c2f80 --- /dev/null +++ b/.agents/skills/deploy @@ -0,0 +1 @@ +../../.claude/skills/deploy \ No newline at end of file diff --git a/.agents/skills/edge-node-multi-protocol b/.agents/skills/edge-node-multi-protocol new file mode 120000 index 0000000..13a2900 --- /dev/null +++ b/.agents/skills/edge-node-multi-protocol @@ -0,0 +1 @@ +../../.claude/skills/edge-node-multi-protocol \ No newline at end of file diff --git a/.agents/skills/update-db b/.agents/skills/update-db new file mode 120000 index 0000000..a85443f --- /dev/null +++ b/.agents/skills/update-db @@ -0,0 +1 @@ +../../.claude/skills/update-db \ No newline at end of file diff --git a/.claude/skills/deploy/SKILL.md b/.claude/skills/deploy/SKILL.md index a2a858d..dce0a68 100644 --- a/.claude/skills/deploy/SKILL.md +++ b/.claude/skills/deploy/SKILL.md @@ -1,12 +1,14 @@ --- name: deploy -description: 把 proxy-station 部署到 LA 生产服务器(ps.la.njcqtechaicoding.com)。可以只部署前端、只部署后端,或前后端一起。凡是用户提到「部署」「发布」「上线」「推到服务器」「更新线上」「让线上生效」「同步到 la」,或改完代码想在生产环境看效果,都用这个 Skill,即使没有明说「部署」二字。 +description: 将 proxy-station 前端、后端或两者部署到 LA 生产服务器。用于用户要求部署、发布、上线、更新线上或同步到 la;生产数据维护使用 update-db。 --- # 部署 proxy-station 到 LA 线上是一台 1 核 1.9G 的机器,没有 Docker:后端用 Node 22 直跑、systemd 守护(服务名 `proxy-station`,代码在 `/opt/proxy-station`),前端在本机构建后由后端静态托管,HTTPS 与反代由 aaPanel 的 nginx 负责。首次部署与一次性配置(systemd 单元、`/etc/proxy-station/env`、nginx 站点、证书、数据库迁移)见 `docs/DEPLOY.md`,本 Skill 只负责日常的代码更新。 +本技能由 Codex 与 Claude Code 共用,命令在仓库根目录运行;`.agents/skills/deploy` 链接到本目录。阅读或加载技能不代表用户授权上线;用户已明确要求本次部署时直接按授权执行,不重复确认。 + ## 用法 ```bash @@ -35,6 +37,7 @@ bash .claude/skills/deploy/scripts/deploy.sh server # 只推后端源码, 1. **预检**:拒绝大写 ssh 别名(大写会被当作域名走 fake-IP 解析,连不上);确认远端目录与 systemd 单元存在,否则提示先做首次部署。 2. **本地检查**:后端目标先跑 typecheck 与单测。线上没有测试环境,这是最后一道拦截。 + 节点/组/规则/DNS/生成器变更另按 [Surge 变更验证](../../../docs/SURGE-CHANGES.md) 验证候选订阅,使用本机官方 `surge-cli`,涉及规则集时通过 `scripts/check-surge.py --fetch-rulesets` 下载快照验证。单测中本地 Surge CLI 用例在未安装时会跳过,不能据此声称已通过官方校验。 3. **推送**:用 tar 管道走 ssh,不依赖远端 rsync。先解到暂存目录再整体换入,旧版本保留为 `*.old`,中途失败不会留下半新半旧的目录。 4. **后端**:`pnpm install --frozen-lockfile --prod` 只装 server 及其 workspace 依赖,然后重启服务,等 `/api/health` 最多 20 秒。不通过就打印日志、把源码换回 `.old` 并重启,让线上先恢复。 5. **校验**:不用「命令没报错」当成功。前端比对本地与远端 `web/dist` 的文件树指纹,并检查公网首页是否已引用新的入口脚本;后端比对 `server/src` 与 `shared/src` 的指纹并确认服务 active;最后从本机访问公网健康检查,并确认管理接口不带 token 时返回 401。任何一项不过,脚本以失败退出。 @@ -45,6 +48,7 @@ bash .claude/skills/deploy/scripts/deploy.sh server # 只推后端源码, - 最后一行 `部署完成:<目标> @ ` 才算成功。把这一行连同校验段落转述给用户。 - 输出里出现 `(工作区有未提交改动)` 时要提醒用户:线上跑的是未提交的代码,之后应当提交或有意识地保持。 +- 生成器或 shared 变更部署后,从实际 HTTPS 入口取订阅,用 `python3 scripts/check-surge.py <私有临时文件> --fetch-rulesets` 再验收;健康检查和源码指纹不能证明规则行为正确。受影响的 Clash 用独立目录下的 `mihomo -t` 验证。 - `前端产物指纹不一致` 几乎只有一种原因:推送中断。重跑一次 `web` 即可。 - `公网首页未引用 <入口脚本>` 而指纹一致:浏览器或中间缓存,稍等再试;aaPanel 的反代模板对 js 只缓存 1 分钟。 - `pnpm install 失败`:多半是本地 `package.json` 改了但锁文件没更新。本地 `pnpm install` 后连锁文件一起提交,再部署。 @@ -52,6 +56,6 @@ bash .claude/skills/deploy/scripts/deploy.sh server # 只推后端源码, ## 边界 -- 不迁移数据库、不改 `ADMIN_TOKEN`、不碰 nginx 与证书。这些操作见 `docs/DEPLOY.md`,且每一项都应先跟用户确认。 +- 数据库迁移、`ADMIN_TOKEN`、nginx 与证书属于另行授权的首次配置或维护任务,见 `docs/DEPLOY.md`;本次代码部署授权不包含这些操作。已有明确授权时不重复确认。 - 不做 git 提交。部署与提交是两件事,用户没要求就不要顺手提交。 - 如果用户要部署到 LA 之外的机器,先确认那台机器已按 `docs/DEPLOY.md` 完成首次部署,再用 `--host` 与环境变量指向它。 diff --git a/.claude/skills/edge-node-multi-protocol/.gitignore b/.claude/skills/edge-node-multi-protocol/.gitignore new file mode 100644 index 0000000..27ecf14 --- /dev/null +++ b/.claude/skills/edge-node-multi-protocol/.gitignore @@ -0,0 +1,6 @@ +scripts/config.env +scripts/config.env.* +!scripts/config.env.example +scripts/node-links/ +__pycache__/ +*.pyc diff --git a/.claude/skills/edge-node-multi-protocol/SKILL.md b/.claude/skills/edge-node-multi-protocol/SKILL.md new file mode 100644 index 0000000..8c15221 --- /dev/null +++ b/.claude/skills/edge-node-multi-protocol/SKILL.md @@ -0,0 +1,35 @@ +--- +name: edge-node-multi-protocol +description: 部署、重建或维护边缘 VPS 节点,管理 nginx、3x-ui/Xray、Cloudflare WARP、证书及可选 sing-box QUIC 线路,生成离线分享链接与交付文档。用于节点部署管理;Proxy Station 应用发布用 deploy,订阅数据库维护用 update-db。 +--- + +# 边缘节点部署与维护 + +本项目统一管理边缘节点与 Proxy Station。目录、站点清单和迁移说明见仓库根目录的 `edge-nodes/README.md`;项目约定以根目录 `AGENTS.md` 为准。 + +## 工作目录与输入 + +- 通用脚本、参考资料、交付模板在本技能的 `scripts/`、`references/`、`templates/`,只保留占位配置。 +- 当前舰队的真实配置、分享链接、二维码和交付文档在 `edge-nodes/private/-edge-node-docs/`;该目录被 Git 忽略。rn 当前部署使用 `rn2-edge-node-docs/`,`rn-edge-node-docs/` 留作历史交付。 +- 既有节点先读其交付文档与 `scripts/config.env`,确认实际服务、证书路径和脚本差异。新增节点把通用资源复制到新的私有节点目录,再从 `config.env.example` 填写 `config.env`。不要在技能源目录填写真实配置。 +- `.sh` 是本机执行的 SSH 编排脚本;`08-xui-bootstrap.py`、`08b-embed-settings-clients.py`、`13-singbox-config.py`、`16-add-socks5.py` 在 VPS 上运行;`14-*`、`15-*` 链接生成器在本机运行。不要按文件编号盲目执行所有文件。 + +## 按任务读取流程 + +执行前读 `references/pitfalls.md`。完整 aaPanel 部署步骤见 `references/workflow.md`;裸 nginx、QUIC、升级与脚本选择见 `references/standalone-and-quic.md`。3x-ui 数据结构见 `references/3x-ui-schema.md`,DNS 与集中签证书分别见 `references/dns-records.md`、`references/centralized-certs.md`。 + +部署或调整先明确目标站点与用户授权范围;已授权的步骤直接完成。现役节点修改前备份受影响的配置、3x-ui 数据库与证书续期钩子,只执行任务需要的步骤。不要因修改本地代码或迁移文件而自动上线。 + +## 关键约束 + +- TCP 线路是 VMess/Trojan/Shadowsocks × direct/WARP,3x-ui/Xray 承载,nginx 在 TCP 443 按 WS 路径反代。Hysteria2/TUIC 由 sing-box 独立承载 UDP,不写入 3x-ui;WARP 共用本机 SOCKS5 出口。 +- 3x-ui 的 `settings.clients` 必须保存真实客户端记录;不能只写 `clients`/`client_inbounds` 投影表,否则升级迁移会丢掉认证。新 bootstrap 已包含此修复,旧节点补齐使用 `08b-embed-settings-clients.py`。 +- aaPanel 使用自身的 `/etc/init.d/bt restart` 与 `/etc/init.d/nginx reload`;裸 nginx 才使用 systemd。更新证书续期动作时保留已有站点复制与 reload 动作,再增加 sing-box restart,不能覆盖整个旧钩子。 +- Surge 不支持 SS over WS;客户端下发能力以 `shared/src/protocols.ts` 为准。Proxy Station 当前仅导入 VMess/Trojan/Shadowsocks/Hysteria2,TUIC/SOCKS5 不能导入。 +- 凭据、真实域名与控制面路径不放入通用代码、示例、对话或在线二维码服务。生成链接前设 `umask 077`,二维码用本机 `qrencode`。 + +## 交付与验证 + +按实际服务跑对应 smoke test,分别核对 WS 101、面板与门面、直连 IP/WARP 出口;QUIC 另做客户端连接和出口验证,并核对云安全组。脚本语法或 `sing-box check` 通过不等于实际连接验收。 + +完成后更新私有节点文档及 `edge-nodes/private/fleet.json`,在仓库根目录执行 `python3 edge-nodes/build-delivery-doc.py`。分享链接经 Web 导入 Proxy Station;若任务包含生产订阅数据更新,继续使用 `update-db` 的备份与管理 API 流程,并按 `docs/SURGE-CHANGES.md` 校验实际订阅。报告已验证结果与尚未做的真机验证。 diff --git a/.claude/skills/edge-node-multi-protocol/references/3x-ui-schema.md b/.claude/skills/edge-node-multi-protocol/references/3x-ui-schema.md new file mode 100644 index 0000000..97e0932 --- /dev/null +++ b/.claude/skills/edge-node-multi-protocol/references/3x-ui-schema.md @@ -0,0 +1,142 @@ +# 3x-ui database schema notes + +Read this before writing a script that adds a new protocol or inbound by hand. +Everything here was confirmed by reading the live sqlite DB (`/etc/x-ui/x-ui.db`) +and the generated runtime config (`/usr/local/x-ui/bin/config.json`) on a real +box — not from 3x-ui's own documentation, which doesn't cover this. + +## Why DB-direct instead of the panel API + +The panel HTTP API 403s unauthenticated/scripted requests on the builds this +skill has been tested against (security middleware). Every inbound this skill +adds is written straight into the sqlite DB, followed by `systemctl restart +x-ui` to pick up the change. `08-xui-bootstrap.py` is the reference +implementation — read it before writing a new one-off script. + +## Relevant tables + +## The single-client model (why one `node-main` owns all six inbounds) + +This skill deliberately creates **one** client, `node-main`, linked to all six +inbounds — not one client per protocol. Because the `clients` row is +protocol-generic (it carries both a `uuid` and a `password`), that single row +authenticates VMess (by its `uuid`) *and* Trojan+Shadowsocks (by its +`password`) depending on which inbound the traffic hits. The payoff is entirely +in the panel/subscription UX: the Clients page shows one row, its Info dialog +lists all six share links + QR codes, and its one subscription (by `sub_id`) +carries all six nodes. Trojan and Shadowsocks therefore **share one password** +(`NODE_PASSWORD`) — intended for a single-user node. + +**`inbounds`** — one row per inbound (listener). Columns that matter: +`user_id` (**set to 1** — a NULL/empty user_id makes the inbound invisible in +the panel even though it works; see pitfalls.md), `port`, `protocol` +(`vmess`/`trojan`/`shadowsocks`/...), `listen` (`127.0.0.1` here — nginx does +the public-facing TLS), `settings` (protocol-specific JSON, see below), +`stream_settings` (transport JSON — this skill uses +`{"network":"ws","security":"none","wsSettings":{...},"externalProxy":[{...}]}`; +`security` is `"none"` because nginx already terminated TLS upstream, and the +`externalProxy` entry makes generated share links point at `:443` over +TLS instead of the useless internal `127.0.0.1:` — see pitfalls.md), +`tag` (used to target this inbound in routing rules). + +**`clients`** — a *protocol-generic* credential table, not one table per +protocol. Relevant columns: `email` (must be unique — this skill uses ONE email, +`node-main`, for all six inbounds), `uuid` (used by vmess/vless), `password` +(used by trojan/shadowsocks), `sub_id` (groups this client's inbounds into one +subscription), `security`/`flow` (vmess/vless-only). There is no column that +says which protocol a client belongs to — that's implied entirely by which +inbound(s) it's linked to via `client_inbounds`, and 3x-ui's backend picks +`uuid` vs `password` based on *that inbound's* `protocol` field when it builds +the live xray config. So node-main sets both `uuid` and `password`. + +**`client_inbounds`** — links a client to an inbound: `(client_id, inbound_id, +flow_override, created_at)`. One row per (client, inbound) pair — node-main gets +six rows (one per inbound). + +**`client_traffics`** — traffic-stat tracking, **one row per client `email`, +not per inbound** (email is globally unique in this table). Insert exactly one +row for `node-main`, pointing `inbound_id` at any one of its linked inbounds +(which one doesn't matter — it's just where the panel attributes the stats row). + +**`settings`** (key/value store) — the `xrayTemplateConfig` key holds the JSON +that 3x-ui actually feeds to xray-core (merged with the DB-derived inbounds at +runtime). This skill's WARP outbound + routing rule live here. **Read-modify-write +this, never overwrite it wholesale** — the WARP routing rule's `inboundTag` +array needs the new protocol's warp-inbound tag *appended*, not replaced, +or a previous protocol's warp routing silently stops working. + +## `settings` JSON shape per protocol (in the `inbounds.settings` column) + +```jsonc +// vmess +{"clients": [{"id": "", "email": "node-main", "enable": true, "subId": "", "alterId": 0}], "disableInsecureEncryption": false} +// trojan +{"clients": [{"password": "", "email": "node-main", "enable": true, "subId": ""}], "fallbacks": []} +// shadowsocks — note the TOP-LEVEL "password" (prevents the v3.5.0 link-gen panic, see pitfalls.md) +{"clients": [{"password": "", "method": "chacha20-poly1305", "email": "node-main", "enable": true, "subId": ""}], "method": "chacha20-poly1305", "password": "", "network": "tcp,udp"} +``` + +Keep the client embedded in `settings.clients` on BOTH schema generations. +The normalized `clients` and `client_inbounds` tables are derived projections; +upgrade/import migration rebuilds them from this JSON. An empty array may +appear to work until migration removes the projected credentials. The migrated +`08-xui-bootstrap.py` writes both representations; existing table-only nodes +are repaired by `08b-embed-settings-clients.py` after a database backup. +Use the full client shape in those scripts, including explicit `enable: true` +and consistent subscription/quota fields across all six inbounds. Verify the +generated `/usr/local/x-ui/bin/config.json` after restarting. Minimal protocol +credential fields (the common fields above are also required): + +```jsonc +// vmess client — all three use the SAME email "node-main" (one client) +{"id": "", "email": "node-main", "alterId": 0} +// trojan client +{"password": "", "email": "node-main"} +// shadowsocks client +{"password": "", "method": "chacha20-poly1305", "email": "node-main"} +``` + +## Adding a fourth protocol later (e.g. VLESS+REALITY) + +1. Add its ports/paths/credential to `config.env` (follow the `PORT__{DIRECT,WARP}` / + `WS_PATH__{DIRECT,WARP}` naming already used for vmess/trojan/ss). +2. Add a `(protocol, port_direct, port_warp, path_direct, path_warp)` tuple to + the `PROTOCOLS` list in `08-xui-bootstrap.py`, and its client-shape branch + to `inbound_settings()` (REALITY additionally needs `realitySettings` in + `stream_settings` with a real "borrowed" target site — this is per-inbound + TLS-mimicry config the outer nginx doesn't touch, so `security` in + `stream_settings` would need to be `"reality"` for that inbound specifically + instead of `"none"`, and you'd route it directly rather than through the + nginx WS+TLS cover this skill uses for the other three, since REALITY does + its own TLS handshake and can't be terminated by an upstream nginx first). +3. Add the new WS paths to `PROXIES` in `09-aapanel-sites.py` (skip this for + REALITY specifically, since it bypasses nginx as noted above and would need + its own firewall port opened instead). +4. Add the new protocol's outbound block to the smoke-test client generator in + `12-smoke-test.sh`. +5. Re-run `08-xui-bootstrap.sh` (idempotent — existing inbounds are skipped by + port) then `09-aapanel-sites.sh`, then `12-smoke-test.sh`. `08-xui-bootstrap.py` + already sets `user_id=1`, adds the `externalProxy` link hint, links the new + inbounds to the existing `node-main` client, and (for shadowsocks-family + protocols) writes the top-level `password` — so a new protocol inherits all + version-compat fixes as long as you extend `PROTOCOLS` and its client-shape + logic rather than hand-inserting rows. Keep `settings.clients` populated + for each added protocol as well as maintaining the projection tables. + +## Subscription (optional, not enabled by this skill) + +3x-ui's subscription server (`/sub/`) returns every share link for a +client's `sub_id` — since node-main's `sub_id` covers all six inbounds, one +subscription URL delivers all six nodes and stays in sync as you add/remove +inbounds. It listens on its own port (2096 by default, HTTP) and is **off by +default** in this skill. Two gotchas if you turn it on: +- The sub port is **not** opened by `10-ufw.sh` (only 443 + panel ports are). + Don't just `ufw allow 2096` — that exposes a plaintext-HTTP endpoint carrying + every node's credentials on a guessable `IP:port`. Instead reverse-proxy a + non-obvious subdomain (e.g. `sub.`) → `127.0.0.1:2096` through nginx on + 443 with the wildcard cert, same pattern as the panels, so the subscription + is HTTPS and hidden behind 443. +- "Empty reply from server" on the sub port is almost always the firewall + (connection reached UFW's default drop), not 3x-ui — verify by curling + `http://127.0.0.1:2096/sub/` **on the box** first (200 there = the + service and links are fine, the problem is purely how it's exposed). diff --git a/.claude/skills/edge-node-multi-protocol/references/centralized-certs.md b/.claude/skills/edge-node-multi-protocol/references/centralized-certs.md new file mode 100644 index 0000000..2ce2662 --- /dev/null +++ b/.claude/skills/edge-node-multi-protocol/references/centralized-certs.md @@ -0,0 +1,58 @@ +# Centralized wildcard certs (fleet option) + +`03-sign-wildcard-cert.sh` signs the wildcard **on the box** — simplest for a single node. +For a fleet you may prefer to sign every wildcard on **one** host and push to the rest, so +the Cloudflare DNS token lives in exactly one place and renewals are centralized. + +## Model + +``` + one signer host (acme.sh + CF token) + ┌───────────────────────────────────┐ + │ acme.sh --dns dns_cf │ issues *.nodeA.example.com, + │ --reloadcmd distribute.sh │ *.nodeB.example.com, … + └───────────────┬───────────────────┘ + │ scp fullchain+key on every renew + ┌─────────────┼─────────────┐ + nodeA nodeB nodeC (BT cert dirs + nginx reload) +``` + +## Signer setup (once) + +```bash +export CF_Token='' +acme.sh --set-default-ca --server letsencrypt +acme.sh --issue --dns dns_cf -d nodeA.example.com -d '*.nodeA.example.com' --keylength ec-256 +``` + +## Distribution reloadcmd + +A small script the signer runs after each renewal — for each target it scp's the cert into +the aaPanel cert dirs and reloads nginx. Register it once: + +```bash +acme.sh --install-cert -d nodeA.example.com --ecc --reloadcmd "/root/acme-deploy/distribute.sh" +``` + +`distribute.sh` (sketch — adapt hosts/sites to yours; **no real hosts here**): + +```bash +TARGETS=( + ":root:nodeA.example.com,.nodeA.example.com,.nodeA.example.com,.nodeA.example.com" +) +SRC=/root/.acme.sh/nodeA.example.com_ecc +for entry in "${TARGETS[@]}"; do + host="${entry%%:*}"; rest="${entry#*:}"; user="${rest%%:*}"; sites="${rest#*:}" + scp -o BatchMode=yes "$SRC/fullchain.cer" "$user@$host:/tmp/.fc.pem" + scp -o BatchMode=yes "$SRC/nodeA.example.com.key" "$user@$host:/tmp/.pk.pem" + ssh "$user@$host" "IFS=,; for s in \$sites; do d=/www/server/panel/vhost/cert/\$s; mkdir -p \$d; + install -m 644 /tmp/.fc.pem \$d/fullchain.pem; install -m 600 /tmp/.pk.pem \$d/privkey.pem; done; + rm -f /tmp/.fc.pem /tmp/.pk.pem; /www/server/nginx/sbin/nginx -s reload" +done +``` + +## Trust + notes + +- Put the signer's SSH **public** key in each target's `authorized_keys`; the signer needs write to the BT cert dirs + nginx reload only. +- To add a region to an existing wildcard, `--issue` again with the extra `-d` names (SAN change requires a re-issue, not `--renew`), then extend `TARGETS`. +- Keep the CF token on the signer only; targets never see it. diff --git a/.claude/skills/edge-node-multi-protocol/references/dns-records.md b/.claude/skills/edge-node-multi-protocol/references/dns-records.md new file mode 100644 index 0000000..7f20140 --- /dev/null +++ b/.claude/skills/edge-node-multi-protocol/references/dns-records.md @@ -0,0 +1,45 @@ +# DNS records this node needs (any provider) + +The wildcard cert (`03-sign-wildcard-cert.sh`) proves domain control via **DNS-01** using +whatever `ACME_DNS_PLUGIN` you set — acme.sh supports Cloudflare, DNSPod, Aliyun, Route53, +GoDaddy, and dozens more. That part is provider-agnostic. + +The only thing that is *not* automated for non-Cloudflare providers is creating the host +**A records**. The current fleet is IPv4-only: keep `SERVER_IPV6` empty and do not create +AAAA records. Create these two names in your DNS provider's console/API: + +| Name | Type | Value | Proxy/CDN | +|---|---|---|---| +| `` (e.g. `nodeA.example.com`) | A | `SERVER_IP` | **off** (DNS-only / grey cloud) | +| `*.` | A | `SERVER_IP` | **off** | + +Notes: +- Keep them **unproxied** — the node terminates its own TLS and needs the real client IP path; a proxy/CDN in front would break the WS cover and the panel anti-scan logic. +- TTL 300 is fine. +- The wildcard `*.` covers the landing / cover / panel subdomains, so you don't add per-subdomain records. + +## Cloudflare shortcut + +If your zone is on Cloudflare, skip the manual step — `02-cf-dns.py` upserts the two A +records when `SERVER_IPV6` is empty (`CF_API_TOKEN` with `Zone:DNS:Edit`). Run it from wherever the +token lives; it never touches the VPS. + +Leaving `SERVER_IPV6` empty does **not** delete existing AAAA records. For an existing +node, back up DNS records, remove the AAAA records under that node's domain, then check +the API and public DNS. See [IPv4-only fleet policy](../../../../docs/EDGE-IPV4-ONLY.md). + +## Picking the acme.sh plugin + creds + +Set `ACME_DNS_PLUGIN` and the matching credential vars in `config.env` (acme.sh reads them +from the environment; `03-sign-wildcard-cert.sh` forwards whichever are set): + +| Provider | `ACME_DNS_PLUGIN` | env vars | +|---|---|---| +| Cloudflare | `dns_cf` | `CF_API_TOKEN` (forwarded as `CF_Token`) | +| DNSPod | `dns_dp` | `DP_Id`, `DP_Key` | +| Aliyun | `dns_ali` | `Ali_Key`, `Ali_Secret` | +| AWS Route53 | `dns_aws` | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` | +| GoDaddy | `dns_gd` | `GD_Key`, `GD_Secret` | +| others | see acme.sh dnsapi | per its README | + +Full list: diff --git a/.claude/skills/edge-node-multi-protocol/references/pitfalls.md b/.claude/skills/edge-node-multi-protocol/references/pitfalls.md new file mode 100644 index 0000000..1440bfd --- /dev/null +++ b/.claude/skills/edge-node-multi-protocol/references/pitfalls.md @@ -0,0 +1,53 @@ +# Pitfalls (read before you run) + +Every item here cost real debugging time on a live deployment. They are ordered by how likely they are to bite you. + +## aaPanel + +- **Never `systemctl restart btpanel`.** `btpanel.service` is `enabled` but its `ExecStart` is `/etc/init.d/bt start` (SysV); the systemd unit shows `active: inactive (dead)` while the panel process (`webserver`) runs fine. `systemctl restart btpanel` stops the panel and it does **not** come back → your reverse-proxied panel returns **502**. To restart use `bt 1` or `/etc/init.d/bt start`. Same for nginx (`/etc/init.d/nginx`, not `systemctl`). +- **Reverse-proxy needs `CDN_PROXY=True`** in `/www/server/panel/config/cdn.conf`, or the panel rejects proxied requests. Restart the panel after setting it. +- **Anti-scan fake 404/403 — the UA has to look like a REAL browser, not just contain the word "Mozilla".** aaPanel returns a *fake* nginx 404 to requests it judges to be scanners. On aaPanel 7.x this check got noticeably stricter: a minimal UA like `Mozilla/5.0 (Macintosh)` (which used to be enough) now gets judged as a scanner and served the fake 404 — even hitting `/login` or `/` directly on the box over the unix socket gives the same fake 404 with that UA. Use a full, realistic UA string, e.g. `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/605.1.15`, and it works immediately. **If the entrance/domain/path all test as 404 no matter what you try, check the UA string completeness FIRST** — before you start doubting `CDN_PROXY`, domain binding, or the cert. This one costs the most debugging time of anything in this file. A real browser was never affected either way. +- Also **403** on direct `IP:port` access once a panel domain is bound — that one's real and expected, not a bug. +- **aaPanel 7.x moved several backend modules into a new `class_v2` directory** (e.g. `theme_config.py`). Scripts that do `sys.path.insert(0, "/www/server/panel/class")` and then `import panelSite` (or anything that transitively imports `common.py` → `theme_config`) will fail with `ModuleNotFoundError: theme_config` unless `/www/server/panel/class_v2` is *also* on `sys.path`. `09-aapanel-sites.py` already does this; if you hand-write a new script that touches panel internals, add the same `sys.path.insert(0, "/www/server/panel/class_v2")` line. +- **Build sites through the panel, never hand-write vhosts.** A hand-written `/www/server/panel/vhost/nginx/*.conf` works in nginx but is invisible/unmanageable in the panel UI. Use the panel classes (`AddSite`/`SetSSL`/`CreateProxy`/`HttpToHttps`) — that's what `09-aapanel-sites.py` does. The panel HTTP API can't be driven by curl (CSRF + anti-scan); drive the classes with the panel's bundled `pyenv` python. +- **nginx 1.24 uses `listen 443 ssl http2;`** — it does **not** support the newer `http2 on;` directive; that syntax fails `nginx -t`. +- **The aaPanel install-wait loop must not use `pgrep -f install_7.0` to detect "still installing".** A command like `until run 'test -f BT-Panel && ! pgrep -f install_7.0'; do sleep 15; done` has a self-match bug: the check command's own argv literally contains the string `"install_7.0"`, so `pgrep -f` matches the check itself every time, and the condition can never go true — the loop spins forever even though the install finished in ~1 minute. `04-install-aapanel.sh` instead waits for the literal string `Congratulations` in the install log plus the `BT-Panel` binary existing. If you ever add a similar poll-by-pgrep check, make sure the pattern you grep for doesn't appear in the checking command's own command line (the classic fix elsewhere is `pgrep -f '[i]nstall_7.0'` — the bracket trick — but matching a log string is simpler and was chosen here). + +## 3x-ui (newer versions, Xray 26.x) — multi-protocol notes + +- **Keep `inbounds.settings.clients` populated.** The normalized `clients` and `client_inbounds` tables are projections. Upgrades/migrations rebuild them from settings JSON; a table-only deployment can lose all credentials. The migrated `08-xui-bootstrap.py` includes this fix; repair older nodes with `08b-embed-settings-clients.py` on the VPS after a database backup. + +- **One client owns all six inbounds — don't create one client per protocol.** The whole point of the single-client design is that the panel's Clients page shows ONE row ("node-main") whose Info dialog lists all six share links + QR codes and a single subscription covering all six. That works because the `clients` table is protocol-generic (next bullet): one row carrying both a `uuid` and a `password`, linked via `client_inbounds` to all six inbounds. `08-xui-bootstrap.py` does exactly this. (An earlier iteration created three separate clients — node-main / trojan-main / ss-main — which "worked" for traffic but split the links across three panel rows and made the single-subscription view impossible; consolidating them later meant deleting two clients and re-pointing credentials. Build it as one client from the start.) +- **The `clients` table is protocol-generic, not protocol-specific.** It has both a `uuid` column and a `password` column (plus VMess/VLESS-only fields like `security`/`flow`). VMess/VLESS inbounds read `uuid`; Trojan/Shadowsocks inbounds read `password`. There's no "protocol" column on `clients` itself — which field 3x-ui's backend actually uses depends on the *inbound's* `protocol` value at the point it builds the live xray config, not on anything stored on the client row. This isn't documented anywhere obvious; it was confirmed by reading the live generated `/usr/local/x-ui/bin/config.json` after inserting rows and restarting `x-ui`, and seeing the right credential land in the right field for each protocol. **Consequence:** with one client, Trojan and Shadowsocks share ONE password (that client's `password` column); VMess uses its `uuid`. That's intended for a single-user node — it's what `NODE_PASSWORD` in `config.env` is. +- **`user_id` on an inbound must be set (1), or the inbound is INVISIBLE in the panel.** The panel's Inbounds and Clients lists filter by the logged-in admin's `user_id`. A DB-inserted inbound with a NULL/empty `user_id` exists in the DB and proxies traffic perfectly (xray loads it, smoke test passes), but the panel UI silently filters it out — you'll see "only the vmess inbounds show up" even though all six are there. `08-xui-bootstrap.py` sets `user_id=1` on every inserted inbound. If you hand-insert an inbound later, set it too (or `UPDATE inbounds SET user_id=1 WHERE user_id IS NULL OR user_id=''`). +- **Every inbound needs an `externalProxy` in streamSettings, or the generated share links are useless.** These inbounds listen on `127.0.0.1` with `security:"none"` (nginx terminates TLS at the edge on `:443`). 3x-ui doesn't know the public address, so by default it generates links pointing at `127.0.0.1:` with no TLS — which no client can use. Add `"externalProxy":[{"forceTls":"tls","dest":"","port":443,"sni":"","remark":""}]` to each inbound's streamSettings and every generated link/QR/subscription instead points at `:443` over TLS. `08-xui-bootstrap.py` does this. (This is a *link-generation* hint only — it's a 3x-ui-specific field that's stripped when building the live xray config, so it never affects the data path.) +- **Shadowsocks inbound `settings` needs a TOP-LEVEL `password` field, or the panel's "copy links" endpoint returns an empty 200 and the UI shows "No response data".** 3x-ui v3.5.0's share-link generator does `settings["password"].(string)` — an *unchecked* Go type assertion. A normal (non-2022) SS inbound has no top-level password (the real per-client password lives on the client), so that assertion hits `nil` and **panics**. gin's recovery swallows the panic (its error writer is `io.Discard`, so nothing reaches `journalctl`), the `/panel/api/clients/subLinks/` endpoint returns HTTP 200 with an EMPTY body, and the frontend — seeing no parseable JSON — reports "No response data" and drops *all* the links (not just SS). The subscription server returns 500 for the same reason. Fix: write a top-level `"password"` into the SS inbound's `settings` (auth still goes through the client table; for chacha20 ciphers this field doesn't even appear in the emitted `ss://` link — it exists only to keep the assertion from panicking). `08-xui-bootstrap.py` sets it. Debugging note: this presents as "200 empty body", not an error status — so watch for empty-but-200 API responses, and remember the sub server's Go logs are discarded. +- **`client_traffics.email` is GLOBALLY UNIQUE.** One client shared across two inbounds (e.g. a protocol's direct + warp inbound) gets **one** `client_traffics` row, not one per inbound — inserting two rows with the same email throws `UNIQUE constraint failed`. +- **`client_inbounds` has a `flow_override` column** (client_id, inbound_id, flow_override, created_at) — include it in the INSERT even though it's usually empty; some 3x-ui builds may reject a 3-column insert if the schema doesn't line up with what an older reference implementation expected. +- **Extend `xrayTemplateConfig`'s WARP routing rule, never overwrite the whole thing.** The WARP-egress inbounds' tags (and their `inbound-127.0.0.1:` fallback form) need to be appended to the *existing* rule's `inboundTag` array. Re-generating and writing the whole `xrayTemplateConfig` from scratch (instead of read-modify-write) silently deletes whatever protocols were added in a previous run. +- **Auth is by UUID/password**, so a mismatch between the traffic-stat email label and the running config email is cosmetic, not a fault. +- **Protocol mismatch when testing.** A VLESS test client against a `vmess+ws` inbound is correctly rejected (`websocket close 1000`) — that's not a server bug, it's the wrong protocol. Match the test client's protocol (vmess/trojan/shadowsocks) exactly to the inbound you're testing, with the right `security`/`method`/`password` for that protocol. +- **Panel API `/login` may 403** on newer builds (security middleware). Do DB-direct edits + `systemctl restart x-ui` instead of the HTTP API — confirmed true both for the original VMess bootstrap and again when adding Trojan/Shadowsocks later. +- **Shadowsocks 2022-blake3-\* ciphers need an exact-byte-length base64 password/PSK** — get the length wrong and xray fails to start with no obvious error pointing at the cipher. `chacha20-poly1305` (or `aes-256-gcm`) takes a plain password string, is much harder to misconfigure, and is still a modern AEAD cipher — prefer it unless you have a specific reason to use the 2022 series. + +## Cloudflare WARP + +- **Disabling host IPv6 does not disable IPv6 inside WARP's proxy tunnel.** Keep the IPv4-only guards in Xray and sing-box: force IPv4 domain resolution and reject `::/0` before WARP routing. `UseIPv4` can fall back to system resolution in Xray; use `ForceIPv4`. Do not pass unresolved target domains to WARP. See [fleet policy](../../../../docs/EDGE-IPV4-ONLY.md). +- **`gpg` must be installed first.** On a minimal Debian, `gpg --dearmor` for the repo keyring fails with `gpg: command not found`, and the later `apt update` then errors `Failed to parse keyring … sqv returned an error code`. `apt-get install -y gnupg` before adding the repo. +- Use the **`bookworm`** repo line even on Debian 13 (trixie) — the bookworm package works. +- **WARP can lie about being connected.** The control plane shows `Connected` while the SOCKS proxy stops egressing. The watchdog (`warp-healthcheck.*`) checks `warp=on` *through the proxy* and restarts `warp-svc` when it isn't. + +## nginx / cover + +- WS upgrade must return **101** with `--http1.1` + the `Upgrade`/`Connection` headers. If you test with HTTP/2 you'll get 400 (HTTP/2 doesn't carry the Upgrade frame). +- Default server for unmatched `server_name` should `return 444` so scanners hitting the bare IP / wrong host get dropped. + +## Monitoring + +- **Komari** auto-discovery registers the node with an auto-generated name (`Auto-`). Rename/group it in your panel afterward (panel-specific; often a small DB update or a UI action). The agent falls back to a v1 WebSocket if the panel has no v2 API — that's normal. +- **Nezha** agent install one-liners change over time. If the scripted install fails, drop the `nezha-agent` binary + a `config.yml` (server, `tls`, `client_secret`, `uuid`) and a small systemd unit — the agent connects out to `SERVER:PORT` (gRPC), so only outbound is needed. + +## SSH / access + +- Some providers ship a `.pem`/`.ppk` bundle; use the `.pem`. `chmod 600` it or ssh refuses it. +- Fresh boxes may run fail2ban; repeated failed auth from a dynamic IP can lock you out. Get your key in early (step 0) so you're not relying on password auth. diff --git a/.claude/skills/edge-node-multi-protocol/references/protocol-choice.md b/.claude/skills/edge-node-multi-protocol/references/protocol-choice.md new file mode 100644 index 0000000..0c606db --- /dev/null +++ b/.claude/skills/edge-node-multi-protocol/references/protocol-choice.md @@ -0,0 +1,61 @@ +# Why VMess + Trojan + Shadowsocks, and when to add a fourth + +This skill's default 6-inbound target (3 protocols × direct/WARP) was chosen for a specific +combination of constraints: client compatibility (Surge excludes the Shadowsocks-over-WS lines; see the project capability matrix), transport safety (no UDP, so nothing here is exposed to networks that +throttle UDP indiscriminately), and zero extra server software (all three are native to +3x-ui/Xray-core — no sing-box, no separate Hysteria2/TUIC binary to maintain). + +If your constraints are different, the right protocol set is different too. This file is the +reasoning, not a rule — read it before assuming the default 3 are the "right" choice for a new +node. + +## The three defaults + +| Protocol | Transport overhead | GFW-detection posture | Client support | +|---|---|---|---| +| VMess (+WS+TLS) | Two encryption layers (VMess's own AEAD, on top of the outer TLS) + WS framing — heaviest of the three | Weakest of the three; historically a specific target of active probing, and Xray-core itself now prints a deprecation warning for it | Nearly universal | +| Trojan (+WS+TLS) | One layer (just the outer TLS) + WS framing — noticeably lighter than VMess | Stronger: Trojan's whole design is "indistinguishable from a real HTTPS site to anyone without the password" | Nearly universal, including Surge | +| Shadowsocks (+WS+TLS) | Similar to Trojan — SS's own cipher is lightweight, WS+TLS is the same cover as the others | Comparable to VMess's cover level, but without VMess's specific deprecated-protocol baggage | Supported by compatible WS clients; Surge does not support SS over WS | + +All three are plain TCP end-to-end between client and server (the WS layer rides on TCP), so +none of them are exposed to networks that indiscriminately throttle UDP — that includes the +"UDP forwarding" toggle some clients show for VMess/Trojan/SS: it tunnels UDP *payloads* inside +the already-established TCP connection, it doesn't open a real UDP socket to the server. + +## What "UDP is faster" actually means (and doesn't) + +QUIC-based protocols (Hysteria2, TUIC) run on top of UDP, and that fact alone doesn't make them +faster — raw UDP has no reliability or congestion control built in. Their speed advantage comes +from what they build *on top of* UDP: no head-of-line blocking across multiplexed streams, +faster connection setup/resumption, connection migration across network changes, and (for +Hysteria2 specifically) an aggressive custom congestion-control algorithm designed for lossy +links. That advantage is real and large specifically on **high-latency or high-packet-loss** +networks. On a clean, low-latency network, well-implemented TCP+TLS (Trojan, VLESS) is often +just as fast, sometimes faster (QUIC's per-packet encryption has real per-byte CPU cost that +kernel-optimized TCP+TLS doesn't). Don't add a UDP-based protocol to this skill's target list on +the assumption that "UDP is just faster" — add it because you specifically need resilience on +bad networks or fast network-switching, and you've weighed that against UDP's other real risk: +plenty of networks throttle/block UDP indiscriminately, which no amount of protocol-level +cleverness fixes. + +## When to add a fourth protocol + +- **Client doesn't support any of the three (rare)** — e.g. something that only speaks VLESS or + Hysteria2. Add exactly what that client needs; see `3x-ui-schema.md` for the + mechanical steps (VLESS/VLESS+REALITY are native to 3x-ui/Xray-core, no extra software). +- **You specifically need the strongest current anti-active-probing posture** — VLESS+REALITY + (or Trojan+REALITY; Xray-core supports REALITY on both) borrows a real site's TLS handshake, + which is currently the hardest thing for active probing to distinguish from genuinely visiting + that site. Native to 3x-ui. Needs picking a "borrowed" target site with real TLS 1.3, decent + traffic, and no active blocking of its own in your target region. +- **You specifically need next-generation traffic-pattern resistance** — AnyTLS defends against + statistical traffic-shape analysis (packet size/timing), which REALITY's handshake-mimicry + doesn't address by itself. Not native to 3x-ui/Xray-core — needs sing-box installed + server-side, as a separate service alongside 3x-ui (different ports, no conflict). +- **You specifically need resilience on bad/mobile networks and already have a UDP fallback + elsewhere** — Hysteria2 (preferred over TUIC: larger community, more obfuscation options, + more battle-tested) if you don't already have a node covering this case. Also needs sing-box + or a standalone Hysteria2 binary, not native to 3x-ui. +- **Don't add a protocol "just in case."** Every inbound is one more thing to keep working + through aaPanel/3x-ui/Xray version upgrades — see `pitfalls.md` for how much + version drift alone (not malicious blocking) can break a working inbound. diff --git a/.claude/skills/edge-node-multi-protocol/references/standalone-and-quic.md b/.claude/skills/edge-node-multi-protocol/references/standalone-and-quic.md new file mode 100644 index 0000000..7a6a030 --- /dev/null +++ b/.claude/skills/edge-node-multi-protocol/references/standalone-and-quic.md @@ -0,0 +1,47 @@ +# 裸 nginx、QUIC 与既有节点维护 + +脚本从各节点原工作副本迁入,历史 `-sg`/`-rn` 后缀保留;这些后缀表示流程分支,不表示脚本只能在该站点使用。所有 `.sh` 从本机通过 SSH 修改 VPS。命令中的 `scripts/` 指私有节点工作目录里的副本。 + +## 选择部署分支 + +| 步骤 | aaPanel 管理 nginx | 裸 nginx | +|---|---|---| +| 准备机器 | `00-preflight.sh` | `00-preflight-sg.sh`(含 swap、BBR、apt nginx) | +| DNS | `02-cf-dns.py` | 同左 | +| 证书 | `03-sign-wildcard-cert.sh` | `03-sign-cert-sg.sh`,证书在 `/etc/nginx/ssl//` | +| nginx | `04-install-aapanel.sh`、`04b-install-nginx.sh`、`05-harden-aapanel.sh` | 已由 preflight 安装,跳过 aaPanel 步骤 | +| WARP | `06-install-warp.sh`、`06b-warp-watchdog.sh` | 同左 | +| TCP 节点 | `07-install-3xui.sh`、`08-xui-bootstrap.sh` | 同左 | +| 站点 | `09-aapanel-sites.sh`、`09b-default-444.sh` | `09-nginx-sites-sg.sh` | +| 防火墙 | `10-ufw.sh`(核对实际面板端口) | `10-ufw-sg.sh`(TCP 22/80/443) | +| TCP 验收 | `12-smoke-test.sh` | `12-smoke-test-sg.sh` | + +`11-monitoring.sh` 是可选监控安装。既有站点以自己的实际配置为准;不要把完整部署流水线当成日常维护命令。 + +## QUIC + +在私有 `scripts/config.env` 设置所需的 `PORT_HY2_DIRECT`/`PORT_HY2_WARP` 或 `PORT_TUIC_DIRECT`/`PORT_TUIC_WARP`,未设置的线路跳过。Hysteria2 使用 `NODE_PASSWORD`,可设置 `HY2_OBFS_PASSWORD`(salamander)及带宽;TUIC 使用 `XUI_UUID`+`NODE_PASSWORD`。 + +裸 nginx 分支执行 `bash scripts/13-install-singbox-quic.sh`,它推送 `13-singbox-config.py`、生成 sing-box 配置、做 `sing-box check`、开放配置的 UDP 端口并启动服务。aaPanel 节点需要先按实际证书目录适配,不能直接采用默认 `/etc/nginx/ssl//`。 + +该脚本的续期动作面向裸 nginx,会设为 `systemctl reload nginx; systemctl restart sing-box`。既有机器若已有集中证书复制或其他服务动作,先读取并保留旧钩子,再合并;不能直接用此步骤替换旧配置。UFW 之外还要核对供应商云安全组。 + +`10-ufw-rn.sh` 是四条 QUIC 线路都存在时的历史防火墙分支,要求两条 HY2 与两条 TUIC 端口,不用于仅 HY2 的机器。 + +生成链接在本机执行: + +```bash +umask 077 +python3 scripts/14-generate-links-and-qr.py +python3 scripts/15-generate-quic-links.py +``` + +第二个脚本读取 `HY2_OBFS_PASSWORD`;仅 TUIC 时也保留该字段为空。新增占位配置默认 QUIC 端口为空,不会隐式部署 QUIC。配置生成器能省略混淆,但现有链接生成器始终写 salamander;生成 HY2 链接时应提供非空混淆密码,或先同步修改链接与服务端逻辑。 + +## 3x-ui 升级与 SOCKS5 + +新 `08-xui-bootstrap.py` 已写入 `settings.clients`,旧部署用 `08b-embed-settings-clients.py` 补齐。该 Python 文件在 VPS 上执行,默认会重启 x-ui;`XUI_DB` 可指定数据库,`RESTART_XUI=0` 可由任务安排重启。修改前备份数据库,完成后重新核对六条 TCP 线路与 WARP 规则。 + +`16-add-socks5.py` 是在 VPS 上运行的可选内部 SOCKS5 入站工具,通过 `SOCKS_PORT`、`SOCKS_USER`、`SOCKS_PASS`、`SERVER_IP` 等环境变量配置,默认监听公网且会重启 x-ui。按访问意图设置 `LISTEN`;用户名密码认证本身不加密。它不是标准六条线路部署步骤,也不是 Proxy Station 可导入类型。 + +这里保存的是已有部署工具的流程说明。涉及新版本安装或升级时,先核对官方版本兼容性,再在授权范围执行;本次工程迁移没有对生产节点执行这些脚本。 diff --git a/.claude/skills/edge-node-multi-protocol/references/workflow.md b/.claude/skills/edge-node-multi-protocol/references/workflow.md new file mode 100644 index 0000000..9ab0b89 --- /dev/null +++ b/.claude/skills/edge-node-multi-protocol/references/workflow.md @@ -0,0 +1,175 @@ +# aaPanel deployment workflow + +This is the migrated aaPanel workflow. Run shell commands from the LOCAL private node workspace: Bash scripts invoke SSH and their Python companions run on the VPS. Standalone nginx, QUIC and upgrade repairs are documented in `standalone-and-quic.md`. Keep live config out of the skill source directory. + +Turn a bare Debian/Ubuntu VPS into a fully managed edge node running **three proxy protocols side by side, each with a direct-egress and a Cloudflare-WARP clean-exit inbound — six inbounds in total.** This is the standard target of this skill, not an optional extra: a single protocol is a fragile bet (if it gets fingerprinted or a client stops supporting it, the whole node goes dark), and three protocol families sharing the same domain/cert/firewall costs almost nothing extra to run once the panel infrastructure is up. + +**What it builds** + +| Layer | Component | Result | +|---|---|---| +| Panel | aaPanel International (7.x) + nginx | `.` reverse-proxied, secret-path entrance, hardened | +| Proxy | 3x-ui (Xray) | `.` panel; **6 inbounds** behind nginx: VMess/Trojan/Shadowsocks × direct/WARP | +| Clean exit | cloudflare-warp (proxy mode) | every protocol's WARP inbound egresses through Cloudflare WARP | +| TLS | acme.sh DNS-01 (any provider) | wildcard `*.` cert, auto-renew + reload, shared by all 6 inbounds | +| DNS | Cloudflare API (or your provider, by hand) | `` + `*.` A/AAAA (DNS-only) | +| Cover | nginx vhosts | landing page + node cover (6 WS paths) + default-server `444` | +| Watchdog | systemd timer | WARP proxy self-heal every 60s | +| Monitoring | Nezha + Komari agents (optional) | node appears on your panels | +| Access | SSH key + optional shortcut | passwordless login | + +> **This is a template, not a turnkey binary.** You supply your own domain, Cloudflare token, server IP, and passwords. No secrets, domains, or routing rules are baked in. + +## Why three protocols instead of one + +VMess, Trojan, and Shadowsocks(+WS/TLS) use TCP and are implemented by 3x-ui/Xray-core. Client support depends on the protocol/transport combination: Surge does not support Shadowsocks over WebSocket. Proxy Station filters unsupported combinations using `shared/src/protocols.ts`. Running all three from day one means: if a client only supports a subset (e.g. Surge doesn't do VLESS), you're already covered; if one protocol family gets fingerprinted, you have two independent fallbacks without touching the box again. See `protocol-choice.md` for the fuller reasoning (performance/detection tradeoffs, and how to add a fourth protocol like VLESS+REALITY later). + +## Inputs to collect first + +Fill a working copy of `scripts/config.env.example` → `config.env` (git-ignored by you). Collect these before touching the box: + +- `SERVER_IP` / `SERVER_IPV6` — the new VPS public addresses (amd64 or arm64) +- `SSH_USER` / `SSH_KEY` — `root` or a sudo user (scripts escalate via `sudo`), and the private key +- `TZ` — server timezone (default `UTC`) +- `DOMAIN_ROOT` — the sub-zone this node owns, e.g. `nodeA.example.com` (you get `*.nodeA.example.com`) +- `ACME_DNS_PLUGIN` + creds — DNS provider for the wildcard cert. Default `dns_cf` (Cloudflare). Any acme.sh-supported provider works (DNSPod/Aliyun/Route53/GoDaddy/…) — set the plugin + its env vars (`dns-records.md`). +- `CF_API_TOKEN` / `CF_ZONE_ID` — Cloudflare only: token with **Zone:DNS:Edit** (enables the turnkey A/AAAA record script). Non-CF users create the A/AAAA records themselves. +- `SUB_PANEL` / `SUB_XUI` / `SUB_COVER` — the subdomain labels for the three planes. **If your zone is public, choose non-obvious labels** — don't reuse a guessable convention or people can enumerate your control planes from DNS. +- `AAPANEL_USER` / `AAPANEL_ENTRANCE` — desired panel username + secret entrance path (e.g. `/somesecret`) +- `XUI_USER` / `XUI_PATH` — 3x-ui username + webBasePath (e.g. `/somesecret/`) +- **Six WS cover paths + two ports each** (`PORT__{DIRECT,WARP}` / `WS_PATH__{DIRECT,WARP}` for `VMESS`/`TROJAN`/`SS`) — pick your own, non-obvious, don't reuse a naming convention across them +- `XUI_UUID` + `NODE_PASSWORD` (+ `XUI_SUBID`, `SS_METHOD` default `chacha20-poly1305`) — the credentials for the **single `node-main` client** that owns all six inbounds: `XUI_UUID` authenticates the two VMess inbounds, `NODE_PASSWORD` authenticates the two Trojan **and** two Shadowsocks inbounds (they share it — it's one client). Generate strong values at runtime; **store them in your password manager**, never in git. +- monitoring (optional) — Nezha `SERVER:PORT` + shared key; Komari endpoint + auto-discovery key + +## Workflow + +For a fresh aaPanel node, follow the selected steps in order from the local private workspace. Shell scripts read `scripts/config.env` and invoke SSH; their Python companions run on the VPS. For an existing node, read its handover notes and run only the steps required by the task. + +### 0. Verify access & prep +```bash +scripts/00-preflight.sh # confirms SSH, records specs, sets timezone, installs base pkgs (curl/gnupg/ufw) +``` +Also drop **your** public key into the box's `~/.ssh/authorized_keys` for passwordless login. + +### 1. Wildcard DNS records +```bash +python3 scripts/02-cf-dns.py # Cloudflare: upserts + *. A/AAAA -> SERVER_IP/IPV6 (proxied=false) +``` +Non-Cloudflare users: create the same records by hand, see `dns-records.md`. + +### 2. Wildcard TLS cert (acme.sh DNS-01) +```bash +scripts/03-sign-wildcard-cert.sh # on-box acme.sh --dns dns_cf, issues *. (ECDSA P-256, Let's Encrypt) +``` +One cert, shared by all 6 inbounds via the configured `.` cover domain. Fleet cert signing: `centralized-certs.md`. + +### 3. aaPanel International + nginx +```bash +scripts/04-install-aapanel.sh # official install_7.0_en.sh (headless); waits for the log's success banner +scripts/05-harden-aapanel.sh # sets username/password/entrance, binds panel domain, CDN_PROXY=True +scripts/04b-install-nginx.sh # install_soft.sh 0 install nginx 1.24 (aaPanel-managed) +``` +`pitfalls.md` has three aaPanel-7.x-specific fixes baked into these scripts already (install-wait logic, `class_v2` import path, anti-scan UA strictness) — read it if you're editing these rather than just running them. + +### 4. Cloudflare WARP (clean exit) +```bash +scripts/06-install-warp.sh # cloudflare-warp, proxy mode SOCKS5 127.0.0.1:40000, verify warp=on +scripts/06b-warp-watchdog.sh # installs warp-healthcheck.sh + systemd timer (self-heal every 60s) +``` + +### 5. 3x-ui (Xray) + six inbounds under one client +```bash +scripts/07-install-3xui.sh # installs 3x-ui, sets panel 127.0.0.1:14533 + XUI_PATH + creds +scripts/08-xui-bootstrap.sh # adds ALL SIX inbounds under ONE node-main client, in one pass +``` +`08-xui-bootstrap.py` creates a **single `node-main` client** (carrying both the VMess UUID and the shared +Trojan/Shadowsocks password) linked to all six inbounds — so the panel's Clients page shows one row whose +Info dialog lists all six share links + QR codes and one subscription. It bakes in four version-compat +fixes that are easy to miss and each cost real debugging on a live v3.5.0 box: `user_id=1` (else inbounds +are invisible in the panel), an `externalProxy` link hint (else generated links point at the useless +internal `127.0.0.1:`), a top-level SS `password` (else the panel's copy-links endpoint panics and +returns empty → "No response data"), and read-modify-write of the WARP routing rule (else re-runs wipe it). +It writes directly into 3x-ui's sqlite DB rather than using the panel API (which 403s scripted requests on +this build) — see `3x-ui-schema.md` and `pitfalls.md` for +the table layout if you need to add a 4th protocol or debug a client not authenticating. + +### 6. aaPanel sites (panel-native, UI-manageable) +```bash +scripts/09-aapanel-sites.sh # AddSite + SetSSL + CreateProxy (x6 for cover) + HttpToHttps, via panel classes +scripts/09b-default-444.sh # unmatched server_name -> return 444; drop landing page +``` + +### 7. Firewall + monitoring (optional) +```bash +scripts/10-ufw.sh # allow 22/80/443 + panel port + aaPanel 888/39000-40000 +scripts/11-monitoring.sh # optional: install Nezha + Komari agents (placeholders in config.env) +``` +No extra firewall ports needed for the 6 inbounds — they're all `127.0.0.1`-only, reverse-proxied through nginx on 443. + +### 8. Smoke test — all six inbounds +```bash +scripts/12-smoke-test.sh +``` +Checks: WS upgrade returns 101 on all 6 cover paths; landing/3x-ui/aaPanel panels return 200 (aaPanel test +uses a full realistic UA string, see pitfalls); an unmatched hostname gets dropped (444); and — via a +temporary on-box xray client covering all 6 outbounds — every `*-direct` egress IP equals `SERVER_IP` with +`warp=off`, and every `*-warp` egress IP is a Cloudflare address with `warp=on`. + +### 9. Generate client links + QR codes +```bash +python3 scripts/14-generate-links-and-qr.py # run LOCALLY, not on the box +``` +Writes `vmess://`, `trojan://` and Xray-style `ss://` share links + QR codes via local `qrencode`, plus sing-box JSON snippets for Shadowsocks. Proxy Station parses these SS links; other clients may require transport-specific import. Nothing is sent to a third-party service. See +`templates/02-node-info-template.md` for how to hand these to a client. + +### 10. Documentation +Fill in the three templates in `templates/` (handover doc, node info, ops guide) with this node's real +values — see "Documentation templates" below. + +## Domains this creates + +With `DOMAIN_ROOT=nodeA.example.com` and example labels `SUB_COVER=edge`, `SUB_XUI=gateway`, `SUB_PANEL=console` (pick your own): + +| Domain | Role | Backend | +|---|---|---| +| `nodeA.example.com` | landing page | static | +| `.nodeA.example.com` | node cover: 6 WS paths, one per inbound | 127.0.0.1:57707-57712 | +| `.nodeA.example.com` | 3x-ui panel | 127.0.0.1:14533 | +| `.nodeA.example.com` | aaPanel | 127.0.0.1:\ | +| `*.nodeA.example.com` (default) | unmatched → `return 444` | — | + +> The labels above are just examples. **Never publish the real labels/paths you pick** — on a public zone they are the map to your control planes and tunnels. + +## Adding a protocol to an already-provisioned node + +You don't need to redo steps 0-4. Add the new protocol's env vars to `config.env`, extend the `PROTOCOLS` +list in `scripts/08-xui-bootstrap.py` and the `PROXIES` loop in `scripts/09-aapanel-sites.py` (both scripts +are idempotent — re-running them skips ports/sites that already exist), re-run `08-xui-bootstrap.sh` then +`09-aapanel-sites.sh`, then re-run the smoke test with the new outbound added. Full walkthrough with the +concrete steps used to add Trojan+Shadowsocks to a VMess-only node: `3x-ui-schema.md`. + +## Documentation templates + +`templates/01-handover-template.md`, `templates/02-node-info-template.md`, `templates/03-ops-guide-template.md` +are meant to be copied out and filled in with a real node's values after deployment (they're written with +`{{PLACEHOLDER}}` tokens, not a real node's domain/keys). See `../templates/README.md` for the full list of +tokens each one needs. + +## References + +- `pitfalls.md` — **read first.** aaPanel 7.x version-compatibility fixes (install-wait logic, `class_v2`, anti-scan UA strictness), 3x-ui multi-protocol schema gotchas, WARP/nginx/monitoring/SSH pitfalls. Skipping it costs hours. +- `3x-ui-schema.md` — the sqlite table layout 3x-ui uses, and how to add a 4th protocol. +- `protocol-choice.md` — why VMess+Trojan+Shadowsocks, performance/detection tradeoffs vs VLESS+REALITY/AnyTLS/Hysteria2/TUIC, and when you'd want a 4th protocol. +- `dns-records.md` — the exact records to create, and how to use a non-Cloudflare DNS provider. +- `centralized-certs.md` — sign wildcards on one host and push to a fleet. + +## Project entry points + +Use the project root `AGENTS.md` and the shared `edge-node-multi-protocol` skill. The former standalone agent guides are migration snapshots, not active instructions. + +## Privacy / safety notes + +- Never commit `config.env`, cert keys, panel/xui passwords, or any UUID/password/token. Add them to your password manager. +- The scripts contain **no** real domains, IPs, subscription rules, or routing policy — only placeholders. Keep it that way when you adapt them. +- WARP mode here is for **your own** clean egress; it is not a bypass for anyone else's controls. +- The link/QR-code generator (step 9) never sends anything to a third-party service — QR codes are generated fully offline with local `qrencode`. diff --git a/.claude/skills/edge-node-multi-protocol/scripts/00-preflight-sg.sh b/.claude/skills/edge-node-multi-protocol/scripts/00-preflight-sg.sh new file mode 100755 index 0000000..56279ef --- /dev/null +++ b/.claude/skills/edge-node-multi-protocol/scripts/00-preflight-sg.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# No-aaPanel preflight for the SG node: verify SSH, add swap (low-RAM box), +# set timezone, install nginx + acme deps directly from apt, authorize your key. +. "$(dirname "$0")/_lib.sh" + +echo "== SSH + specs ==" +run 'hostname; . /etc/os-release; echo "$PRETTY_NAME"; nproc; free -m | awk "/Mem/{print \$2\"MB\"}"; df -h / | tail -1' + +echo "== swap (2G, low-RAM box needs it for installs) ==" +run 'if ! swapon --show | grep -q /swapfile; then + fallocate -l 2G /swapfile || dd if=/dev/zero of=/swapfile bs=1M count=2048 + chmod 600 /swapfile; mkswap /swapfile >/dev/null; swapon /swapfile + grep -q "/swapfile" /etc/fstab || echo "/swapfile none swap sw 0 0" >> /etc/fstab + sysctl -w vm.swappiness=10 >/dev/null + grep -q "vm.swappiness" /etc/sysctl.conf || echo "vm.swappiness=10" >> /etc/sysctl.conf + fi + free -h | awk "/Swap/{print \"swap: \"\$2}"' + +echo "== timezone (${TZ:-UTC}) + base packages (nginx, acme deps) ==" +run "timedatectl set-timezone '${TZ:-UTC}' 2>/dev/null || true + export DEBIAN_FRONTEND=noninteractive + # Freshly-booted cloud images run unattended-upgrades, which holds the dpkg lock + # for minutes; without this wait apt fails while the script reports success. + for i in \$(seq 1 60); do + fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1 || break + [ \$i = 1 ] && echo 'waiting for dpkg lock (unattended-upgrades)...' + sleep 10 + done + apt-get update -qq + apt-get install -y -qq curl wget gnupg ufw ca-certificates socat cron nginx >/dev/null || { echo 'ERROR: apt install failed'; exit 1; } + command -v nginx >/dev/null || { echo 'ERROR: nginx missing after install'; exit 1; } + systemctl enable --now nginx >/dev/null 2>&1 || true + nginx -v 2>&1 + echo base-packages-ok" + +# BBR + fq. Stock cloud images generally do NOT have it: this AWS Ubuntu 24.04 AMI +# came up on cubic + fq_codel with tcp_bbr not even loaded, while the DMIT box ships +# BBR pre-enabled in its VM template. Lifts every TCP inbound at once, no client change. +echo "== kernel tuning: BBR congestion control ==" +run 'modprobe tcp_bbr 2>/dev/null || true + echo tcp_bbr > /etc/modules-load.d/tcp_bbr.conf + cat > /etc/sysctl.d/99-edge-node-bbr.conf </dev/null 2>&1 + CC=$(sysctl -n net.ipv4.tcp_congestion_control) + QD=$(sysctl -n net.core.default_qdisc) + echo "congestion=$CC qdisc=$QD" + [ "$CC" = bbr ] || echo "WARN: BBR not active — kernel may lack tcp_bbr (available: $(sysctl -n net.ipv4.tcp_available_congestion_control))"' + +# Authorize YOUR public key for passwordless login. +MYPUB="$(cat "$HOME/.ssh/id_ed25519.pub" 2>/dev/null || cat "$HOME/.ssh/id_rsa.pub" 2>/dev/null || true)" +if [ -n "$MYPUB" ]; then + run "mkdir -p ~/.ssh && chmod 700 ~/.ssh + grep -qF '$MYPUB' ~/.ssh/authorized_keys 2>/dev/null || echo '$MYPUB' >> ~/.ssh/authorized_keys + chmod 600 ~/.ssh/authorized_keys; echo authorized-key-added" +else + echo "WARN: no local public key found; add yours to the box manually" +fi +echo "preflight done" diff --git a/.claude/skills/edge-node-multi-protocol/scripts/00-preflight.sh b/.claude/skills/edge-node-multi-protocol/scripts/00-preflight.sh new file mode 100755 index 0000000..d90f37f --- /dev/null +++ b/.claude/skills/edge-node-multi-protocol/scripts/00-preflight.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Verify SSH, record specs, set timezone, install base packages, authorize your key. +. "$(dirname "$0")/_lib.sh" + +echo "== SSH + specs ==" +run 'hostname; . /etc/os-release; echo "$PRETTY_NAME"; nproc; free -m | awk "/Mem/{print \$2\"MB\"}"; df -h / | tail -1' + +echo "== timezone (${TZ:-UTC}) + base packages ==" +run "timedatectl set-timezone '${TZ:-UTC}' 2>/dev/null || true + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + apt-get install -y -qq curl wget gnupg ufw ca-certificates >/dev/null + echo base-packages-ok" + +# Authorize YOUR public key for passwordless login (edit path if needed). +MYPUB="$(cat "$HOME/.ssh/id_ed25519.pub" 2>/dev/null || cat "$HOME/.ssh/id_rsa.pub" 2>/dev/null || true)" +if [ -n "$MYPUB" ]; then + run "mkdir -p ~/.ssh && chmod 700 ~/.ssh + grep -qF '$MYPUB' ~/.ssh/authorized_keys 2>/dev/null || echo '$MYPUB' >> ~/.ssh/authorized_keys + chmod 600 ~/.ssh/authorized_keys; echo authorized-key-added" +else + echo "WARN: no local public key found; add yours to the box manually for passwordless login" +fi +echo "preflight done" diff --git a/.claude/skills/edge-node-multi-protocol/scripts/02-cf-dns.py b/.claude/skills/edge-node-multi-protocol/scripts/02-cf-dns.py new file mode 100755 index 0000000..2bb76be --- /dev/null +++ b/.claude/skills/edge-node-multi-protocol/scripts/02-cf-dns.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Upsert + *. A/AAAA records in Cloudflare (DNS-only). +Run wherever the CF token lives (your laptop / a bastion) — the token never touches the VPS. +Reads config.env from the same dir. No real domains/IPs are baked in. +""" +import json, os, re, sys, urllib.request + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +def load_env(): + env = {} + path = os.path.join(HERE, "config.env") + if not os.path.exists(path): + sys.exit("ERROR: copy config.env.example -> config.env") + for line in open(path): + m = re.match(r'\s*([A-Z0-9_]+)\s*=\s*"?([^"#\n]*)"?', line) + if m: + env[m.group(1)] = m.group(2).strip() + return env + + +E = load_env() +API = "https://api.cloudflare.com/client/v4" +TOKEN = E["CF_API_TOKEN"] +ROOT = E["DOMAIN_ROOT"] +IPV4 = E.get("SERVER_IP", "") +IPV6 = E.get("SERVER_IPV6", "") +NAMES = [ROOT, "*." + ROOT] + + +def req(method, path, body=None): + data = json.dumps(body).encode() if body is not None else None + r = urllib.request.Request(API + path, data=data, method=method) + r.add_header("Authorization", "Bearer " + TOKEN) + r.add_header("Content-Type", "application/json") + with urllib.request.urlopen(r, timeout=20) as f: + return json.load(f) + + +def zone_id(): + zid = E.get("CF_ZONE_ID", "") + if zid: + return zid + parent = E["PARENT_ZONE"] + res = req("GET", "/zones?name=%s" % parent) + if not res.get("result"): + sys.exit("ERROR: zone %s not found for this token" % parent) + return res["result"][0]["id"] + + +ZONE = zone_id() + + +def upsert(rtype, content): + if not content: + return + existing = req("GET", "/zones/%s/dns_records?per_page=200&type=%s" % (ZONE, rtype))["result"] + by_name = {r["name"]: r for r in existing} + for name in NAMES: + payload = {"type": rtype, "name": name, "content": content, "ttl": 300, "proxied": False} + if name in by_name: + res = req("PUT", "/zones/%s/dns_records/%s" % (ZONE, by_name[name]["id"]), payload) + print("UPDATE %-4s %-28s -> %s %s" % (rtype, name, content, "OK" if res.get("success") else res.get("errors"))) + else: + res = req("POST", "/zones/%s/dns_records" % ZONE, payload) + print("CREATE %-4s %-28s -> %s %s" % (rtype, name, content, "OK" if res.get("success") else res.get("errors"))) + + +upsert("A", IPV4) +upsert("AAAA", IPV6) +print("DNS done for *.%s (DNS-only / grey cloud)" % ROOT) diff --git a/.claude/skills/edge-node-multi-protocol/scripts/03-sign-cert-sg.sh b/.claude/skills/edge-node-multi-protocol/scripts/03-sign-cert-sg.sh new file mode 100755 index 0000000..eeadc78 --- /dev/null +++ b/.claude/skills/edge-node-multi-protocol/scripts/03-sign-cert-sg.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Issue a wildcard cert for *. via acme.sh DNS-01 and install it into +# a STANDALONE nginx cert dir (/etc/nginx/ssl//), with a renew reloadcmd +# that reloads the system (apt) nginx. No aaPanel. +. "$(dirname "$0")/_lib.sh" + +PLUGIN="${ACME_DNS_PLUGIN:-dns_cf}" +CERTDIR="/etc/nginx/ssl/$DOMAIN_ROOT" + +CRED_EXPORTS="" +for v in CF_API_TOKEN DP_Id DP_Key Ali_Key Ali_Secret AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY GD_Key GD_Secret; do + [ -n "${!v:-}" ] && CRED_EXPORTS+="export $v='${!v}'; " +done +# acme.sh's Cloudflare plugin expects CF_Token; alias it from CF_API_TOKEN. +[ -n "${CF_API_TOKEN:-}" ] && CRED_EXPORTS+="export CF_Token='${CF_API_TOKEN}'; " + +run "$CRED_EXPORTS + mkdir -p '$CERTDIR' + curl -s https://get.acme.sh | sh -s email=admin@$PARENT_ZONE >/dev/null 2>&1 || true + ~/.acme.sh/acme.sh --set-default-ca --server letsencrypt >/dev/null 2>&1 || true + ~/.acme.sh/acme.sh --issue --dns $PLUGIN -d '$DOMAIN_ROOT' -d '*.$DOMAIN_ROOT' --keylength ec-256 --server letsencrypt 2>&1 | tail -5 + ~/.acme.sh/acme.sh --install-cert -d '$DOMAIN_ROOT' --ecc \ + --key-file '$CERTDIR/privkey.pem' \ + --fullchain-file '$CERTDIR/fullchain.pem' \ + --reloadcmd 'systemctl reload nginx' 2>&1 | tail -3 + ls -l '$CERTDIR' + echo cert-done" diff --git a/.claude/skills/edge-node-multi-protocol/scripts/03-sign-wildcard-cert.sh b/.claude/skills/edge-node-multi-protocol/scripts/03-sign-wildcard-cert.sh new file mode 100755 index 0000000..3405b32 --- /dev/null +++ b/.claude/skills/edge-node-multi-protocol/scripts/03-sign-wildcard-cert.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Issue a wildcard cert for *. on the box via acme.sh DNS-01, +# deploy into the aaPanel BT cert dirs, and register a renew --reloadcmd. +# DNS provider is set by ACME_DNS_PLUGIN (default dns_cf); creds are passed through from config.env. +# Fleet alternative (sign once, scp to many): references/centralized-certs.md +. "$(dirname "$0")/_lib.sh" + +PLUGIN="${ACME_DNS_PLUGIN:-dns_cf}" +# Export whichever acme.sh DNS credential vars are set in config.env (only non-empty ones). +CRED_EXPORTS="" +for v in CF_API_TOKEN CF_Zone_ID DP_Id DP_Key Ali_Key Ali_Secret AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY GD_Key GD_Secret; do + [ -n "${!v:-}" ] && CRED_EXPORTS+="export $v='${!v}'; " +done +# acme.sh's Cloudflare plugin expects CF_Token, so alias it from CF_API_TOKEN. +[ -n "${CF_API_TOKEN:-}" ] && CRED_EXPORTS+="export CF_Token='${CF_API_TOKEN}'; " + +SITES=("$DOMAIN_ROOT" "$SUB_COVER.$DOMAIN_ROOT" "$SUB_XUI.$DOMAIN_ROOT" "$SUB_PANEL.$DOMAIN_ROOT") +SITES_CSV=$(IFS=,; echo "${SITES[*]}") + +run "$CRED_EXPORTS + curl -s https://get.acme.sh | sh -s email=admin@$PARENT_ZONE >/dev/null 2>&1 || true + ~/.acme.sh/acme.sh --set-default-ca --server letsencrypt >/dev/null 2>&1 || true + ~/.acme.sh/acme.sh --issue --dns $PLUGIN -d '$DOMAIN_ROOT' -d '*.$DOMAIN_ROOT' --keylength ec-256 --server letsencrypt 2>&1 | tail -4 + + # deploy into BT cert dirs + reload nginx + SRC=~/.acme.sh/${DOMAIN_ROOT}_ecc + for s in ${SITES_CSV//,/ }; do + d=/www/server/panel/vhost/cert/\$s; mkdir -p \$d + install -m 644 \$SRC/fullchain.cer \$d/fullchain.pem + install -m 600 \$SRC/${DOMAIN_ROOT}.key \$d/privkey.pem + done + ( /www/server/nginx/sbin/nginx -t && /www/server/nginx/sbin/nginx -s reload ) 2>/dev/null || echo 'nginx not up yet (fine — reload after aaPanel install)' + + # register reloadcmd so renewals re-deploy automatically + ~/.acme.sh/acme.sh --install-cert -d '$DOMAIN_ROOT' --ecc \ + --reloadcmd \"for s in ${SITES_CSV//,/ }; do d=/www/server/panel/vhost/cert/\\\$s; install -m 644 \\\$SRC/fullchain.cer \\\$d/fullchain.pem; install -m 600 \\\$SRC/${DOMAIN_ROOT}.key \\\$d/privkey.pem; done; /www/server/nginx/sbin/nginx -s reload\" 2>&1 | tail -2 + echo cert-done" diff --git a/.claude/skills/edge-node-multi-protocol/scripts/04-install-aapanel.sh b/.claude/skills/edge-node-multi-protocol/scripts/04-install-aapanel.sh new file mode 100755 index 0000000..8628b89 --- /dev/null +++ b/.claude/skills/edge-node-multi-protocol/scripts/04-install-aapanel.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Install aaPanel International (7.x) headless, then print the default credentials. +. "$(dirname "$0")/_lib.sh" + +run 'cd /root && curl -sSO https://www.aapanel.com/script/install_7.0_en.sh && ls -la install_7.0_en.sh' +echo "== installing aaPanel in background (5-10 min) ==" +run 'cd /root && (echo y | bash install_7.0_en.sh aapanel > /root/aapanel-install.log 2>&1 &) && echo started' + +echo "== waiting for panel binary ==" +# Do NOT check "! pgrep -f install_7.0" here -- the check command's own argv contains +# that literal string, so pgrep -f matches the check itself and this never goes false. +# The installer's own log prints a completion banner; watch for that instead. +until run 'test -f /www/server/panel/BT-Panel && grep -q "Congratulations" /root/aapanel-install.log 2>/dev/null'; do sleep 15; done +echo "== aaPanel installed. Default credentials (change in 05-harden): ==" +run 'grep -iE "Address|username|password" /root/aapanel-install.log | grep -ivE "wget|Resolving|Connecting|HTTP request|Saving|node.aapanel|systemd" | tail -6' diff --git a/.claude/skills/edge-node-multi-protocol/scripts/04b-install-nginx.sh b/.claude/skills/edge-node-multi-protocol/scripts/04b-install-nginx.sh new file mode 100755 index 0000000..9d149db --- /dev/null +++ b/.claude/skills/edge-node-multi-protocol/scripts/04b-install-nginx.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Install nginx 1.24 via aaPanel's install_soft.sh (compile; a few minutes). +. "$(dirname "$0")/_lib.sh" + +run 'cd /www/server/panel/install && (bash install_soft.sh 0 install nginx 1.24 > /root/nginx-install.log 2>&1 &) && echo started' +echo "== waiting for nginx binary (compile) ==" +until run 'test -x /www/server/nginx/sbin/nginx'; do sleep 20; done +run '/www/server/nginx/sbin/nginx -v 2>&1' diff --git a/.claude/skills/edge-node-multi-protocol/scripts/05-harden-aapanel.sh b/.claude/skills/edge-node-multi-protocol/scripts/05-harden-aapanel.sh new file mode 100755 index 0000000..29af5db --- /dev/null +++ b/.claude/skills/edge-node-multi-protocol/scripts/05-harden-aapanel.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Harden aaPanel: set username/password, secret entrance, bind panel domain, enable CDN_PROXY +# (required so the panel works behind an nginx reverse proxy). +. "$(dirname "$0")/_lib.sh" + +[ -n "${AAPANEL_PASS:-}" ] || AAPANEL_PASS="$(gen_pass 20)" +echo "AAPANEL PASSWORD (save to your password manager): $AAPANEL_PASS" + +run "cd /www/server/panel + /www/server/panel/pyenv/bin/python tools.py panel '$AAPANEL_PASS' >/dev/null 2>&1 && echo password-set + # username: set directly in the panel DB (tools.py username is interactive) + /www/server/panel/pyenv/bin/python -c \"import sqlite3;c=sqlite3.connect('/www/server/panel/data/default.db');c.execute(\\\"update users set username='$AAPANEL_USER'\\\");c.commit();print('username ->',c.execute('select username from users').fetchall())\" + echo '$AAPANEL_ENTRANCE' > data/admin_path.pl && echo entrance-set + echo '$SUB_PANEL.$DOMAIN_ROOT' > data/domain.conf && echo domain-bound + echo 'CDN_PROXY=True' > config/cdn.conf && echo cdn-proxy-on + /etc/init.d/bt restart >/dev/null 2>&1; sleep 3 + P=\$(cat /www/server/panel/data/port.pl | tr -d '[:space:]') + ss -lnt | grep -q \":\$P\" && echo \"panel-listening-\$P\" || echo 'WARN: panel not listening (check with: bt 14)'" +echo "NOTE: reverse-proxy access at https://$SUB_PANEL.$DOMAIN_ROOT$AAPANEL_ENTRANCE (after 09-aapanel-sites)." +echo "NOTE: direct IP:port returns 403 by design (domain binding). Restart panel with 'bt 1', never 'systemctl restart btpanel'." diff --git a/.claude/skills/edge-node-multi-protocol/scripts/06-install-warp.sh b/.claude/skills/edge-node-multi-protocol/scripts/06-install-warp.sh new file mode 100755 index 0000000..28c3651 --- /dev/null +++ b/.claude/skills/edge-node-multi-protocol/scripts/06-install-warp.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Install cloudflare-warp, register, set proxy (SOCKS5) mode, connect, verify warp=on. +. "$(dirname "$0")/_lib.sh" + +run " + # keyring needs gnupg present (else 'sqv' keyring-parse error on apt update) + apt-get install -y -qq gnupg >/dev/null + curl -fsSL https://pkg.cloudflareclient.com/pubkey.gpg | gpg --yes --dearmor --output /usr/share/keyrings/cloudflare-warp-archive-keyring.gpg + echo 'deb [signed-by=/usr/share/keyrings/cloudflare-warp-archive-keyring.gpg] https://pkg.cloudflareclient.com/ bookworm main' > /etc/apt/sources.list.d/cloudflare-client.list + apt-get update -qq + apt-get install -y -qq cloudflare-warp >/dev/null + warp-cli --accept-tos registration new >/dev/null 2>&1 || true + warp-cli --accept-tos mode proxy >/dev/null 2>&1 + warp-cli --accept-tos proxy port $WARP_SOCKS_PORT >/dev/null 2>&1 + warp-cli --accept-tos connect >/dev/null 2>&1 + sleep 5 + echo '== verify (expect warp=on) ==' + curl -s -x socks5h://127.0.0.1:$WARP_SOCKS_PORT --max-time 10 https://1.1.1.1/cdn-cgi/trace | grep -E 'ip=|warp='" diff --git a/.claude/skills/edge-node-multi-protocol/scripts/06b-warp-watchdog.sh b/.claude/skills/edge-node-multi-protocol/scripts/06b-warp-watchdog.sh new file mode 100755 index 0000000..e28ecce --- /dev/null +++ b/.claude/skills/edge-node-multi-protocol/scripts/06b-warp-watchdog.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Install the WARP health-check watchdog (systemd timer, every 60s). +. "$(dirname "$0")/_lib.sh" +HERE="$(cd "$(dirname "$0")" && pwd)" + +# adjust the proxy port in the shipped script to your configured one +sed "s#127.0.0.1:40000#127.0.0.1:$WARP_SOCKS_PORT#g" "$HERE/warp-healthcheck.sh" | run "cat > /usr/local/bin/warp-healthcheck.sh" +push "$HERE/warp-healthcheck.service" /etc/systemd/system/warp-healthcheck.service +push "$HERE/warp-healthcheck.timer" /etc/systemd/system/warp-healthcheck.timer +run 'chmod +x /usr/local/bin/warp-healthcheck.sh + systemctl daemon-reload + systemctl enable --now warp-healthcheck.timer 2>&1 | tail -1 + systemctl is-active warp-healthcheck.timer' diff --git a/.claude/skills/edge-node-multi-protocol/scripts/07-install-3xui.sh b/.claude/skills/edge-node-multi-protocol/scripts/07-install-3xui.sh new file mode 100755 index 0000000..a8aeba3 --- /dev/null +++ b/.claude/skills/edge-node-multi-protocol/scripts/07-install-3xui.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Install 3x-ui and set the panel to listen on 127.0.0.1:XUI_PORT with your path/creds. +. "$(dirname "$0")/_lib.sh" + +[ -n "${XUI_PASS:-}" ] || XUI_PASS="$(gen_pass 18)" +echo "3X-UI PASSWORD (save to your password manager): $XUI_PASS" + +run "echo -e 'n' | bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) >/dev/null 2>&1 || true + /usr/local/x-ui/x-ui setting -username '$XUI_USER' -password '$XUI_PASS' -port $XUI_PORT -webBasePath '$XUI_PATH' 2>&1 | tail -4 + /usr/local/x-ui/x-ui setting -listenIP 127.0.0.1 2>&1 | tail -1 + systemctl restart x-ui; sleep 4 + echo '== status =='; systemctl is-active x-ui + ss -lnt | grep -q ':$XUI_PORT' && echo panel-listening-$XUI_PORT || echo 'WARN: panel not listening'" diff --git a/.claude/skills/edge-node-multi-protocol/scripts/08-xui-bootstrap.py b/.claude/skills/edge-node-multi-protocol/scripts/08-xui-bootstrap.py new file mode 100755 index 0000000..9ea1e6d --- /dev/null +++ b/.claude/skills/edge-node-multi-protocol/scripts/08-xui-bootstrap.py @@ -0,0 +1,295 @@ +#!/usr/bin/env python3 +"""Runs ON the box, editing 3x-ui's own sqlite DB directly (the panel HTTP API +403s scripted requests on the builds this targets -- see references/pitfalls.md). + +Builds the standard target of this skill in ONE pass: SIX inbounds (vmess, +trojan, shadowsocks x direct-egress + WARP-clean-exit) all owned by ONE client +"node-main", so the panel's Clients page shows a single row whose Info dialog +lists all six share links / QR codes and one subscription that carries all six. + +Why a single client instead of one-per-protocol +------------------------------------------------ +3x-ui's `clients` table is protocol-generic: one client row carries BOTH a +`uuid` (used by vmess/vless inbounds) AND a `password` (used by trojan/ +shadowsocks inbounds). Which field an inbound reads is decided by that +*inbound's* protocol when 3x-ui builds the live xray config -- not by anything +on the client row. So one node-main row linked (via client_inbounds) to all six +inbounds authenticates vmess by its uuid and trojan+ss by its (shared) +password. Verified by reading the generated /usr/local/x-ui/bin/config.json. +Trojan and Shadowsocks therefore share ONE password (node-main's); vmess uses +the uuid. That's intended -- a single-user self node -- and is what makes the +"one client, six links" panel view work. + +Four version-compatibility fixes are baked in here (all cost real debugging on +a live v3.5.0 box; see references/pitfalls.md for the full story): + +1. user_id=1 on every inbound. The panel Clients/Inbounds lists filter by the + logged-in admin's user_id; an inbound with a NULL/empty user_id exists and + proxies traffic fine but is INVISIBLE in the UI. + +2. externalProxy on every inbound's streamSettings. These inbounds listen on + 127.0.0.1 with security:none (nginx terminates TLS at the edge), so without + a hint 3x-ui generates useless share links pointing at 127.0.0.1: with no TLS. An externalProxy entry {forceTls:"tls", dest:, + port:443, sni:} makes every generated link/QR/subscription point at + :443 over TLS -- i.e. actually usable. + +3. Shadowsocks inbound settings need a TOP-LEVEL `password` field. v3.5.0's + share-link generator does `settings["password"].(string)` -- an UNCHECKED + type assertion. A normal (non-2022) SS inbound has no top-level password + (the per-client password lives on the client), so that assertion panics, + the panic is swallowed by gin's recovery, and the whole subLinks endpoint + returns HTTP 200 with an EMPTY body -> the panel shows "No response data" + and drops every link. We write a top-level password (auth still goes through + the client table; this field only exists to keep the assertion from + panicking and does not appear in the emitted ss:// link for chacha20 ciphers). + +4. WARP routing rule is read-modify-write, never clobbered -- so re-running + this (e.g. to add a 4th protocol) doesn't delete the existing warp routing. + +5. settings.clients is ALWAYS embedded, on every schema -- never left as `[]`. + This one cost a live outage on two nodes. `inbounds.settings.clients` is + 3x-ui's AUTHORITATIVE copy of the credentials; the clients / client_inbounds + tables are a derived projection of that JSON, not an alternative to it: + * client_link.go SyncInbound() does an UNCONDITIONAL + `DELETE FROM client_inbounds WHERE inbound_id=?` and then rebuilds the + rows only from the clients slice handed to it -- empty slice means + delete-and-insert-nothing, with no guard distinguishing "this inbound + has no clients" from "the caller found no data". + * inbound_migration.go MigrationRequirements() feeds SyncInbound the + clients from GetClients(inbound), i.e. parsed out of the settings JSON. + * The panel UI path (client_inbound_apply.go addInboundClient) always + writes the client into the settings JSON first, then projects to the + tables -- which is why UI-managed installs never hit this. + So a node with `settings.clients == []` loses every client_inbounds row the + moment the migration path runs -- and `x-ui migrate` is exactly what + install.sh/update.sh invoke on upgrade (DB import/restore and the + SQLite->PostgreSQL copy call it too). The generated xray config then has + `"clients": []` on every inbound: TLS and the WS upgrade still succeed, so + the node looks healthy, but every connection fails authentication. + Verified empirically on a live 3.6.0 box: with settings.clients empty, + `x-ui migrate` emptied client_inbounds instantly; with it populated, the + same command left all six links intact. The logic is unchanged since 3.5.0, + so this is not a one-release regression -- it fires on ANY future upgrade. + To retrofit an already-broken node, run 08b-embed-settings-clients.py. + +Schema-adaptive: newer 3x-ui stores clients in separate tables (clients / +client_inbounds / client_traffics); older builds embed them in +inbound.settings.clients. The new-schema path is the live-verified one; the +embedded path is kept for older installs. +""" +import json, os, sqlite3, subprocess, time + +DB = os.environ.get("XUI_DB", "/etc/x-ui/x-ui.db") +WARP_SOCKS_PORT = int(os.environ.get("WARP_SOCKS_PORT", "40000")) +COVER = os.environ["COVER"] # e.g. cc. -- the public cover host for every link + +XUI_UUID = os.environ["XUI_UUID"] # vmess credential on node-main +XUI_SUBID = os.environ.get("XUI_SUBID", "") +NODE_PASSWORD = os.environ["NODE_PASSWORD"] # shared trojan+ss credential on node-main +SS_METHOD = os.environ.get("SS_METHOD", "chacha20-poly1305") + +EMAIL = "node-main" + +# (protocol, direct port, warp port, direct path, warp path) +PROTOCOLS = [ + ("vmess", int(os.environ["PORT_VMESS_DIRECT"]), int(os.environ["PORT_VMESS_WARP"]), + os.environ["WS_PATH_VMESS_DIRECT"], os.environ["WS_PATH_VMESS_WARP"]), + ("trojan", int(os.environ["PORT_TROJAN_DIRECT"]), int(os.environ["PORT_TROJAN_WARP"]), + os.environ["WS_PATH_TROJAN_DIRECT"], os.environ["WS_PATH_TROJAN_WARP"]), + ("shadowsocks", int(os.environ["PORT_SS_DIRECT"]), int(os.environ["PORT_SS_WARP"]), + os.environ["WS_PATH_SS_DIRECT"], os.environ["WS_PATH_SS_WARP"]), +] + +TEMPLATE = { + "log": {"access": "/var/log/v2ray/access.log", "error": "/var/log/v2ray/error.log", "loglevel": "warning"}, + "api": {"services": ["HandlerService", "LoggerService", "StatsService"], "tag": "api"}, + "inbounds": [{"listen": "127.0.0.1", "port": 62789, "protocol": "dokodemo-door", + "settings": {"address": "127.0.0.1"}, "tag": "api"}], + "outbounds": [ + {"protocol": "freedom", "settings": {"domainStrategy": "ForceIPv4"}, + "streamSettings": {"sockopt": {"domainStrategy": "ForceIPv4"}}}, + {"tag": "warp-outbound", "protocol": "freedom", + "settings": {"domainStrategy": "ForceIPv4"}, + "streamSettings": {"sockopt": {"domainStrategy": "ForceIPv4"}}, + "proxySettings": {"tag": "warp-socks"}}, + {"tag": "warp-socks", "protocol": "socks", + "settings": {"servers": [{"address": "127.0.0.1", "port": WARP_SOCKS_PORT}]}}, + {"protocol": "blackhole", "settings": {}, "tag": "blocked"}, + ], + "policy": {"system": {"statsInboundDownlink": True, "statsInboundUplink": True}}, + "routing": {"rules": [ + {"ip": ["::/0"], "outboundTag": "blocked", "type": "field"}, + {"inboundTag": ["api"], "outboundTag": "api", "type": "field"}, + {"inboundTag": [], "outboundTag": "warp-outbound", "type": "field"}, # tags filled in below + {"ip": ["geoip:private"], "outboundTag": "blocked", "type": "field"}, + {"protocol": ["bittorrent"], "outboundTag": "blocked", "type": "field"}, + ]}, + "stats": {}, + "dns": {"servers": ["1.1.1.1"], "queryStrategy": "UseIPv4"}, +} + + +def cols(cur, table): + return {r[1] for r in cur.execute("PRAGMA table_info(%s)" % table).fetchall()} + + +def has_table(cur, name): + return cur.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (name,)).fetchone() is not None + + +def stream_settings(path, remark): + # security:"none" -- nginx already terminates TLS at the edge and reverse-proxies + # plain HTTP to this inbound; don't handshake TLS a second time here. + # externalProxy -> share links point at the public cover host:443 over TLS (fix #2). + return json.dumps({ + "network": "ws", "security": "none", + "wsSettings": {"path": path, "headers": {}}, + "externalProxy": [{"forceTls": "tls", "dest": COVER, "port": 443, "sni": COVER, "remark": remark}], + }) + + +def embedded_client(protocol, now): + """One settings.clients entry (fix #5). + + ALWAYS embed, on every schema. settings.clients is 3x-ui's authoritative copy + of the credentials; clients/client_inbounds are a derived projection of it. + See fix #5 in the module docstring for why leaving this empty bricks the node + on the next upgrade. + + Field semantics (client_link.go applyClientRecordMerge): + * non-empty-only overwrite: id / password / security -> a vmess entry may + carry just `id` and a trojan/ss entry just `password`; merging the six + inbounds for one email yields the union rather than clobbering. + * unconditional overwrite: enable / subId / limitIp / totalGB / expiryTime + / tgId / comment / reset / flow -> must be IDENTICAL in all six entries. + * `enable` must be explicit: omitting it unmarshals to false, and xray.go + then skips the client on EVERY inbound. + """ + c = { + "email": EMAIL, "enable": True, "subId": XUI_SUBID, "flow": "", + "limitIp": 0, "totalGB": 0, "expiryTime": 0, "tgId": 0, + "comment": "", "reset": 0, "created_at": now, "updated_at": now, + } + if protocol == "vmess": + c.update({"id": XUI_UUID, "security": "auto", "alterId": 0}) + elif protocol == "trojan": + c["password"] = NODE_PASSWORD + elif protocol == "shadowsocks": + c.update({"password": NODE_PASSWORD, "method": SS_METHOD}) + else: + raise ValueError(protocol) + return c + + +def inbound_settings(protocol, now): + clients = [embedded_client(protocol, now)] + if protocol == "vmess": + return json.dumps({"clients": clients, "disableInsecureEncryption": False}) + if protocol == "trojan": + return json.dumps({"clients": clients, "fallbacks": []}) + if protocol == "shadowsocks": + # top-level "password" prevents the v3.5.0 link-gen panic (fix #3); auth is per-client. + return json.dumps({"clients": clients, "method": SS_METHOD, "password": NODE_PASSWORD, "network": "tcp,udp"}) + raise ValueError(protocol) + + +def main(): + conn = sqlite3.connect(DB) + cur = conn.cursor() + ic = cols(cur, "inbounds") + new_schema = has_table(cur, "clients") and has_table(cur, "client_inbounds") + existing_ports = {r[0] for r in cur.execute("SELECT port FROM inbounds").fetchall()} + now = int(time.time() * 1000) + + warp_tags = [] + inbound_ids = {} + + for protocol, port_direct, port_warp, path_direct, path_warp in PROTOCOLS: + for suffix, port, path in (("direct", port_direct, path_direct), ("warp", port_warp, path_warp)): + tag = "inbound-%s-%s" % (protocol, suffix) + remark = "%s-%s" % (protocol, suffix) + if port in existing_ports: + print(" inbound :%d (%s-%s) exists, skip" % (port, protocol, suffix)) + inbound_ids[tag] = cur.execute("SELECT id FROM inbounds WHERE port=?", (port,)).fetchone()[0] + else: + row = { + "user_id": 1, # fix #1: NULL user_id -> invisible in panel lists + "up": 0, "down": 0, "total": 0, "remark": remark, + "enable": 1, "expiry_time": 0, "listen": "127.0.0.1", "port": port, "protocol": protocol, + "settings": inbound_settings(protocol, now), + "stream_settings": stream_settings(path, remark), + "sniffing": json.dumps({"enabled": True, "destOverride": ["http", "tls"]}), + "tag": tag, + } + keys = [k for k in row if k in ic] + cur.execute("INSERT INTO inbounds (%s) VALUES (%s)" % (",".join(keys), ",".join("?" * len(keys))), + [row[k] for k in keys]) + inbound_ids[tag] = cur.lastrowid + print(" inbound :%d (%s-%s) %s added" % (port, protocol, suffix, path)) + if suffix == "warp": + warp_tags += [tag, "inbound-127.0.0.1:%d" % port] + + if new_schema: + # One node-main client carrying BOTH uuid (vmess) and password (trojan+ss), + # linked to all six inbounds. 3x-ui picks the right credential per inbound. + row = cur.execute("SELECT id FROM clients WHERE email=?", (EMAIL,)).fetchone() + if row: + cid = row[0] + cur.execute("UPDATE clients SET uuid=?, password=?, sub_id=?, security='auto', enable=1, updated_at=? " + "WHERE id=?", (XUI_UUID, NODE_PASSWORD, XUI_SUBID, now, cid)) + else: + cur.execute( + "INSERT INTO clients (email,sub_id,uuid,password,auth,flow,security,enable,group_name,total_gb," + "expiry_time,limit_ip,reset,created_at,updated_at) VALUES (?,?,?,?,'','','auto',1,'',0,0,0,0,?,?)", + (EMAIL, XUI_SUBID, XUI_UUID, NODE_PASSWORD, now, now), + ) + cid = cur.lastrowid + + for tag, iid in inbound_ids.items(): + if not cur.execute("SELECT 1 FROM client_inbounds WHERE client_id=? AND inbound_id=?", + (cid, iid)).fetchone(): + cur.execute("INSERT INTO client_inbounds (client_id,inbound_id,flow_override,created_at) " + "VALUES (?,?,?,?)", (cid, iid, "", now)) + + # client_traffics.email is GLOBALLY UNIQUE -> exactly one row for node-main. + if not cur.execute("SELECT 1 FROM client_traffics WHERE email=?", (EMAIL,)).fetchone(): + any_iid = inbound_ids["inbound-vmess-direct"] + cur.execute("INSERT INTO client_traffics (inbound_id,enable,email,up,down,expiry_time,total,reset) " + "VALUES (?,1,?,0,0,0,0,0)", (any_iid, EMAIL)) + print(" node-main client linked to all 6 inbounds (uuid=vmess, password=trojan+ss)") + + # WARP routing rule: read-modify-write (fix #4) -- append, never clobber. + row = cur.execute("SELECT value FROM settings WHERE key='xrayTemplateConfig'").fetchone() + tmpl = json.loads(row[0]) if row else TEMPLATE + # Enforce the fleet's IPv4-only policy when extending an existing template too. + for outbound in tmpl["outbounds"]: + if outbound.get("protocol") == "freedom": + outbound.setdefault("settings", {})["domainStrategy"] = "ForceIPv4" + outbound.setdefault("streamSettings", {}).setdefault("sockopt", {})["domainStrategy"] = "ForceIPv4" + tmpl.setdefault("dns", {})["queryStrategy"] = "UseIPv4" + blocked_tag = next(o["tag"] for o in tmpl["outbounds"] if o.get("protocol") == "blackhole") + ipv6_rule = {"ip": ["::/0"], "outboundTag": blocked_tag, "type": "field"} + if ipv6_rule not in tmpl["routing"]["rules"]: + tmpl["routing"]["rules"].insert(0, ipv6_rule) + for rule in tmpl["routing"]["rules"]: + if rule.get("outboundTag") == "warp-outbound": + for t in warp_tags: + if t not in rule["inboundTag"]: + rule["inboundTag"].append(t) + + cur.execute("UPDATE settings SET value=? WHERE key='xrayTemplateConfig'", (json.dumps(tmpl, ensure_ascii=False),)) + if cur.rowcount == 0: + cur.execute("INSERT INTO settings (key, value) VALUES ('xrayTemplateConfig', ?)", (json.dumps(tmpl, ensure_ascii=False),)) + conn.commit() + conn.close() + print(" xrayTemplateConfig written, warp rule tags:", + next(r["inboundTag"] for r in tmpl["routing"]["rules"] if r.get("outboundTag") == "warp-outbound")) + + subprocess.run(["systemctl", "restart", "x-ui"], check=True) + time.sleep(6) + print("OK — 3x-ui bootstrap complete (6 inbounds under one node-main client: vmess/trojan/shadowsocks x direct/warp)") + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/edge-node-multi-protocol/scripts/08-xui-bootstrap.sh b/.claude/skills/edge-node-multi-protocol/scripts/08-xui-bootstrap.sh new file mode 100755 index 0000000..371ccb8 --- /dev/null +++ b/.claude/skills/edge-node-multi-protocol/scripts/08-xui-bootstrap.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Push 08-xui-bootstrap.py to the box and run it with your config injected as env vars. +# Adds all SIX inbounds in one pass, under ONE node-main client: vmess/trojan/shadowsocks x direct/warp. +. "$(dirname "$0")/_lib.sh" +HERE="$(cd "$(dirname "$0")" && pwd)" + +require_vars XUI_UUID NODE_PASSWORD SUB_COVER DOMAIN_ROOT \ + PORT_VMESS_DIRECT PORT_VMESS_WARP WS_PATH_VMESS_DIRECT WS_PATH_VMESS_WARP \ + PORT_TROJAN_DIRECT PORT_TROJAN_WARP WS_PATH_TROJAN_DIRECT WS_PATH_TROJAN_WARP \ + PORT_SS_DIRECT PORT_SS_WARP WS_PATH_SS_DIRECT WS_PATH_SS_WARP + +COVER="$SUB_COVER.$DOMAIN_ROOT" + +push "$HERE/08-xui-bootstrap.py" /root/_xui-bootstrap.py +run "COVER='$COVER' XUI_UUID='$XUI_UUID' XUI_SUBID='$XUI_SUBID' \ + NODE_PASSWORD='$NODE_PASSWORD' SS_METHOD='${SS_METHOD:-chacha20-poly1305}' \ + PORT_VMESS_DIRECT='$PORT_VMESS_DIRECT' PORT_VMESS_WARP='$PORT_VMESS_WARP' \ + WS_PATH_VMESS_DIRECT='$WS_PATH_VMESS_DIRECT' WS_PATH_VMESS_WARP='$WS_PATH_VMESS_WARP' \ + PORT_TROJAN_DIRECT='$PORT_TROJAN_DIRECT' PORT_TROJAN_WARP='$PORT_TROJAN_WARP' \ + WS_PATH_TROJAN_DIRECT='$WS_PATH_TROJAN_DIRECT' WS_PATH_TROJAN_WARP='$WS_PATH_TROJAN_WARP' \ + PORT_SS_DIRECT='$PORT_SS_DIRECT' PORT_SS_WARP='$PORT_SS_WARP' \ + WS_PATH_SS_DIRECT='$WS_PATH_SS_DIRECT' WS_PATH_SS_WARP='$WS_PATH_SS_WARP' \ + WARP_SOCKS_PORT='$WARP_SOCKS_PORT' \ + python3 /root/_xui-bootstrap.py; rm -f /root/_xui-bootstrap.py" diff --git a/.claude/skills/edge-node-multi-protocol/scripts/08b-embed-settings-clients.py b/.claude/skills/edge-node-multi-protocol/scripts/08b-embed-settings-clients.py new file mode 100755 index 0000000..53bfc51 --- /dev/null +++ b/.claude/skills/edge-node-multi-protocol/scripts/08b-embed-settings-clients.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Runs ON the box. Makes a DB-provisioned node upgrade-safe. + +WHY THIS EXISTS +--------------- +3x-ui's real source of truth for client credentials is the per-inbound +`inbounds.settings.clients` JSON array -- NOT the normalized `clients` / +`client_inbounds` tables. Those tables are a *derived projection* of the JSON: + + * client_link.go SyncInbound(tx, inboundId, clients) does an UNCONDITIONAL + `DELETE FROM client_inbounds WHERE inbound_id = ?` and then rebuilds the + rows only from the `clients` slice it was handed. Empty slice -> delete, + insert nothing. + * inbound_migration.go MigrationRequirements() hands SyncInbound the clients + it got from GetClients(inbound), i.e. parsed from the settings JSON. + * The panel UI path (client_inbound_apply.go addInboundClient) ALWAYS writes + the client into the settings JSON first, saves the inbound, then projects + to the tables via SyncInbound. So a UI-managed install always has a + populated settings.clients and migration is a harmless no-op for it. + +A node provisioned by writing the tables directly (settings.clients left as +`[]`) is therefore off-model: any run of the migration path -- `x-ui migrate`, +which install.sh/update.sh invoke on upgrade, plus DB import/restore and the +SQLite->PostgreSQL copy -- wipes every client_inbounds row and silently drops +all credentials from the generated xray config. Verified empirically: running +`x-ui migrate` on a table-only node emptied client_inbounds immediately. +This is NOT specific to any one release; the logic is unchanged since 3.5.0. + +WHAT THIS DOES +-------------- +Backfills `settings.clients` for every proxy inbound from the node-main client +row, then re-asserts the client_inbounds links. Idempotent -- safe to re-run, +and re-running after any 3x-ui upgrade is the recommended belt-and-braces step. + +FIELD SEMANTICS (client_link.go applyClientRecordMerge) -- get these wrong and +you corrupt the merged client row: + * Credential-ish fields overwrite ONLY when non-empty: id, password, auth, + secret, security, privateKey, publicKey. That's why a vmess entry may carry + only `id` and a trojan/ss entry only `password` -- across the six inbounds + the same email merges to a union instead of clobbering. + * These overwrite UNCONDITIONALLY, so all six entries must agree: + enable, subId, limitIp, totalGB, expiryTime, tgId, comment, reset, flow. + * `enable` is the dangerous one: omit it and JSON unmarshals it to false, + which makes xray.go skip the client on EVERY inbound -> node dead. +""" +import json, os, sqlite3, subprocess, sys, time + +DB = os.environ.get("XUI_DB", "/etc/x-ui/x-ui.db") +EMAIL = os.environ.get("XUI_EMAIL", "node-main") +RESTART = os.environ.get("RESTART_XUI", "1") == "1" + +PROXY_PROTOCOLS = ("vmess", "vless", "trojan", "shadowsocks") + + +def build_entry(protocol, uuid, password, sub_id, method, now): + """One settings.clients entry. Unconditional fields identical across inbounds.""" + e = { + "email": EMAIL, + "enable": True, # MUST be explicit -- see module docstring + "subId": sub_id or "", + "flow": "", + "limitIp": 0, + "totalGB": 0, + "expiryTime": 0, + "tgId": 0, + "comment": "", + "reset": 0, + "created_at": now, + "updated_at": now, + } + if protocol in ("vmess", "vless"): + e["id"] = uuid + e["security"] = "auto" + if protocol == "vmess": + e["alterId"] = 0 + else: + e["password"] = password + if protocol == "shadowsocks": + e["method"] = method + return e + + +def main(): + con = sqlite3.connect(DB) + cur = con.cursor() + + row = cur.execute( + "SELECT id, uuid, password, sub_id FROM clients WHERE email=?", (EMAIL,) + ).fetchone() + if not row: + sys.exit("ERROR: no client row for email=%r -- nothing to embed" % EMAIL) + cid, uuid, password, sub_id = row + now = int(time.time() * 1000) + + inbounds = cur.execute( + "SELECT id, port, protocol, settings FROM inbounds ORDER BY port" + ).fetchall() + + changed = 0 + for iid, port, protocol, settings_raw in inbounds: + if protocol not in PROXY_PROTOCOLS: + continue + try: + st = json.loads(settings_raw) if settings_raw else {} + except (TypeError, ValueError): + print(" :%s %s -- settings not valid JSON, SKIPPED" % (port, protocol)) + continue + + method = st.get("method", "chacha20-poly1305") + entry = build_entry(protocol, uuid, password, sub_id, method, now) + + existing = st.get("clients") + if not isinstance(existing, list): + existing = [] + # Replace only our own email's entry; leave any other client untouched. + kept = [c for c in existing if isinstance(c, dict) and c.get("email") != EMAIL] + new_clients = kept + [entry] + + if existing == new_clients: + print(" :%s %-12s settings.clients already correct" % (port, protocol)) + else: + st["clients"] = new_clients + cur.execute( + "UPDATE inbounds SET settings=? WHERE id=?", + (json.dumps(st, indent=2, ensure_ascii=False), iid), + ) + changed += 1 + print(" :%s %-12s settings.clients embedded" % (port, protocol)) + + # Re-assert the derived link (harmless if already there). + cur.execute( + "INSERT OR IGNORE INTO client_inbounds (client_id,inbound_id,flow_override,created_at) " + "VALUES (?,?,?,?)", (cid, iid, "", now), + ) + + # client_traffics.email is globally unique -> exactly one row for this client. + if not cur.execute("SELECT 1 FROM client_traffics WHERE email=?", (EMAIL,)).fetchone(): + first = cur.execute("SELECT id FROM inbounds ORDER BY port").fetchone() + if first: + cur.execute( + "INSERT INTO client_traffics (inbound_id,enable,email,up,down,expiry_time,total,reset) " + "VALUES (?,1,?,0,0,0,0,0)", (first[0], EMAIL), + ) + + con.commit() + links = cur.execute("SELECT count(*) FROM client_inbounds").fetchone()[0] + con.close() + print(" settings.clients updated on %d inbound(s); client_inbounds rows now: %d" % (changed, links)) + + if RESTART: + subprocess.run(["systemctl", "restart", "x-ui"], check=True) + time.sleep(6) + print("OK -- x-ui restarted; node is now upgrade-safe (settings.clients is authoritative)") + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/edge-node-multi-protocol/scripts/09-aapanel-sites.py b/.claude/skills/edge-node-multi-protocol/scripts/09-aapanel-sites.py new file mode 100755 index 0000000..c782f29 --- /dev/null +++ b/.claude/skills/edge-node-multi-protocol/scripts/09-aapanel-sites.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Runs ON the box with aaPanel's bundled python: + /www/server/panel/pyenv/bin/python /root/_aapanel-sites.py +Registers 4 domains as panel-native sites (AddSite + SetSSL + CreateProxy + HttpToHttps) +so they are visible/manageable in the aaPanel UI (never hand-write vhosts — the UI won't +see those). Reads subdomain labels + cover paths from environment variables. +""" +import json, os, sys + +sys.path.insert(0, "/www/server/panel") +sys.path.insert(0, "/www/server/panel/class") +sys.path.insert(0, "/www/server/panel/class_v2") # aaPanel 7.x moved some modules here (e.g. theme_config) +import public # noqa: E402 +import panelSite # noqa: E402 + +ROOT = os.environ["DOMAIN_ROOT"] +SUB_PANEL = os.environ["SUB_PANEL"] +SUB_XUI = os.environ["SUB_XUI"] +SUB_COVER = os.environ["SUB_COVER"] +XUI_PORT = os.environ.get("XUI_PORT", "14533") +PANEL_PORT = public.readFile("/www/server/panel/data/port.pl").strip() + +D_LANDING = ROOT +D_COVER = "%s.%s" % (SUB_COVER, ROOT) +D_XUI = "%s.%s" % (SUB_XUI, ROOT) +D_PANEL = "%s.%s" % (SUB_PANEL, ROOT) + +site = panelSite.panelSite() + +SITES = [ + (D_LANDING, "/www/wwwroot/" + D_LANDING, "edge landing"), + (D_COVER, "/www/wwwroot/" + D_COVER, "node cover"), + (D_XUI, "/www/wwwroot/" + D_XUI, "3x-ui panel proxy"), + (D_PANEL, "/www/wwwroot/" + D_PANEL, "aaPanel proxy"), +] + +# 3 protocols x 2 egress = 6 cover proxy rules, all on the same D_COVER site/cert. +_PROTOCOLS = ["VMESS", "TROJAN", "SS"] +PROXIES = [ + ("xui-panel", D_XUI, "/", "http://127.0.0.1:%s" % XUI_PORT, D_XUI), + ("aapanel", D_PANEL, "/", "https://127.0.0.1:%s" % PANEL_PORT, D_PANEL), +] +for proto in _PROTOCOLS: + for suffix in ("DIRECT", "WARP"): + path = os.environ["WS_PATH_%s_%s" % (proto, suffix)] + port = os.environ["PORT_%s_%s" % (proto, suffix)] + PROXIES.append(("cover-%s-%s" % (proto.lower(), suffix.lower()), D_COVER, path, + "http://127.0.0.1:%s" % port, D_COVER)) + + +def obj(d): + return public.to_dict_obj(d) + + +def main(): + for name, path, ps in SITES: + get = obj({"webname": json.dumps({"domain": name, "domainlist": [], "count": 0}), + "path": path, "port": "80", "version": "00", "ps": ps, + "type_id": "0", "type": "static", "ftp": "false", "sql": "false"}) + print("AddSite", name, "->", site.AddSite(get).get("siteStatus", "?")) + for name, _p, _ps in SITES: + cdir = "/www/server/panel/vhost/cert/" + name + key = public.readFile(cdir + "/privkey.pem"); csr = public.readFile(cdir + "/fullchain.pem") + if key and csr: + print("SetSSL", name, "->", site.SetSSL(obj({"siteName": name, "key": key, "csr": csr})).get("status")) + for pname, sname, pdir, target, todomain in PROXIES: + get = obj({"proxyname": pname, "sitename": sname, "proxydir": pdir, "proxysite": target, + "todomain": todomain, "type": "1", "cache": "0", "subfilter": "[]", + "advanced": "0", "cachetime": "1", "nocheck": "1"}) + print("CreateProxy", sname, pdir, "->", site.CreateProxy(get).get("status")) + for name, _p, _ps in SITES: + print("HttpToHttps", name, "->", site.HttpToHttps(obj({"siteName": name})).get("status")) + print("DONE — reload nginx: /etc/init.d/nginx reload") + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/edge-node-multi-protocol/scripts/09-aapanel-sites.sh b/.claude/skills/edge-node-multi-protocol/scripts/09-aapanel-sites.sh new file mode 100755 index 0000000..76e6e6c --- /dev/null +++ b/.claude/skills/edge-node-multi-protocol/scripts/09-aapanel-sites.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Push 09-aapanel-sites.py and run it with aaPanel's bundled python + your config. +. "$(dirname "$0")/_lib.sh" +HERE="$(cd "$(dirname "$0")" && pwd)" + +push "$HERE/09-aapanel-sites.py" /root/_aapanel-sites.py +run "DOMAIN_ROOT='$DOMAIN_ROOT' SUB_PANEL='$SUB_PANEL' SUB_XUI='$SUB_XUI' SUB_COVER='$SUB_COVER' XUI_PORT='$XUI_PORT' \ + WS_PATH_VMESS_DIRECT='$WS_PATH_VMESS_DIRECT' WS_PATH_VMESS_WARP='$WS_PATH_VMESS_WARP' \ + PORT_VMESS_DIRECT='$PORT_VMESS_DIRECT' PORT_VMESS_WARP='$PORT_VMESS_WARP' \ + WS_PATH_TROJAN_DIRECT='$WS_PATH_TROJAN_DIRECT' WS_PATH_TROJAN_WARP='$WS_PATH_TROJAN_WARP' \ + PORT_TROJAN_DIRECT='$PORT_TROJAN_DIRECT' PORT_TROJAN_WARP='$PORT_TROJAN_WARP' \ + WS_PATH_SS_DIRECT='$WS_PATH_SS_DIRECT' WS_PATH_SS_WARP='$WS_PATH_SS_WARP' \ + PORT_SS_DIRECT='$PORT_SS_DIRECT' PORT_SS_WARP='$PORT_SS_WARP' \ + /www/server/panel/pyenv/bin/python /root/_aapanel-sites.py + rm -f /root/_aapanel-sites.py + /etc/init.d/nginx reload 2>&1 | tail -1" diff --git a/.claude/skills/edge-node-multi-protocol/scripts/09-nginx-sites-sg.sh b/.claude/skills/edge-node-multi-protocol/scripts/09-nginx-sites-sg.sh new file mode 100755 index 0000000..bbe7d55 --- /dev/null +++ b/.claude/skills/edge-node-multi-protocol/scripts/09-nginx-sites-sg.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# Hand-written nginx vhosts (no aaPanel): +# - landing : -> static page +# - cover : . -> 6 WS reverse proxies (one per inbound) + benign root +# - xui panel : . -> 127.0.0.1: +# - default : unmatched SNI -> return 444 +# All TLS via the standalone wildcard cert; HTTP :80 -> 301 HTTPS. +. "$(dirname "$0")/_lib.sh" +HERE="$(cd "$(dirname "$0")" && pwd)" + +COVER="$SUB_COVER.$DOMAIN_ROOT" +XUI_HOST="$SUB_XUI.$DOMAIN_ROOT" +CERTDIR="/etc/nginx/ssl/$DOMAIN_ROOT" + +# landing page html (shared by landing + cover root so neither looks dead) +LANDING_HTML="$(sed "s/__NODE_NAME__/$NODE_NAME/g" "$HERE/../templates/landing.html")" + +# build the 6 WS location blocks +WS_BLOCKS="" +add_ws() { # $1=path $2=port $3=name + WS_BLOCKS+=" + # $3 + location = $1 { + proxy_pass http://127.0.0.1:$2; + proxy_http_version 1.1; + proxy_set_header Upgrade \\\$http_upgrade; + proxy_set_header Connection \\\$connection_upgrade; + proxy_set_header Host \\\$host; + proxy_set_header X-Real-IP \\\$remote_addr; + proxy_set_header X-Forwarded-For \\\$proxy_add_x_forwarded_for; + proxy_read_timeout 300s; + proxy_send_timeout 300s; + }" +} +add_ws "$WS_PATH_VMESS_DIRECT" "$PORT_VMESS_DIRECT" "vmess-direct" +add_ws "$WS_PATH_VMESS_WARP" "$PORT_VMESS_WARP" "vmess-warp" +add_ws "$WS_PATH_TROJAN_DIRECT" "$PORT_TROJAN_DIRECT" "trojan-direct" +add_ws "$WS_PATH_TROJAN_WARP" "$PORT_TROJAN_WARP" "trojan-warp" +add_ws "$WS_PATH_SS_DIRECT" "$PORT_SS_DIRECT" "ss-direct" +add_ws "$WS_PATH_SS_WARP" "$PORT_SS_WARP" "ss-warp" + +# push landing content to the box +printf '%s' "$LANDING_HTML" | run "mkdir -p /var/www/$DOMAIN_ROOT /var/www/$COVER && cat > /var/www/$DOMAIN_ROOT/index.html && cp /var/www/$DOMAIN_ROOT/index.html /var/www/$COVER/index.html" + +# write the vhost config +run "cat > /etc/nginx/conf.d/edge.conf < HTTPS +server { + listen 80 default_server; + listen [::]:80 default_server; + server_name _; + return 301 https://\\\$host\\\$request_uri; +} + +# default TLS server: unmatched SNI -> drop +server { + listen 443 ssl default_server; + listen [::]:443 ssl default_server; + server_name _; + ssl_certificate $CERTDIR/fullchain.pem; + ssl_certificate_key $CERTDIR/privkey.pem; + return 444; +} + +# landing +server { + listen 443 ssl; + listen [::]:443 ssl; + server_name $DOMAIN_ROOT; + ssl_certificate $CERTDIR/fullchain.pem; + ssl_certificate_key $CERTDIR/privkey.pem; + root /var/www/$DOMAIN_ROOT; + index index.html; + location / { try_files \\\$uri \\\$uri/ =404; } +} + +# node cover: 6 WS reverse proxies + benign root +server { + listen 443 ssl; + listen [::]:443 ssl; + server_name $COVER; + ssl_certificate $CERTDIR/fullchain.pem; + ssl_certificate_key $CERTDIR/privkey.pem; + root /var/www/$COVER; + index index.html; +$WS_BLOCKS + + location / { try_files \\\$uri \\\$uri/ =404; } +} + +# 3x-ui panel +server { + listen 443 ssl; + listen [::]:443 ssl; + server_name $XUI_HOST; + ssl_certificate $CERTDIR/fullchain.pem; + ssl_certificate_key $CERTDIR/privkey.pem; + location / { + proxy_pass http://127.0.0.1:$XUI_PORT; + proxy_http_version 1.1; + proxy_set_header Upgrade \\\$http_upgrade; + proxy_set_header Connection \\\$connection_upgrade; + proxy_set_header Host \\\$host; + proxy_set_header X-Real-IP \\\$remote_addr; + proxy_set_header X-Forwarded-For \\\$proxy_add_x_forwarded_for; + proxy_read_timeout 300s; + } +} +CONF + +# apt nginx ships a default_server on :80 -> remove so ours wins +rm -f /etc/nginx/sites-enabled/default +nginx -t && systemctl reload nginx && echo nginx-reloaded" diff --git a/.claude/skills/edge-node-multi-protocol/scripts/09b-default-444.sh b/.claude/skills/edge-node-multi-protocol/scripts/09b-default-444.sh new file mode 100755 index 0000000..bcfb95d --- /dev/null +++ b/.claude/skills/edge-node-multi-protocol/scripts/09b-default-444.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Unmatched server_name -> return 444 (drop). Also drop the landing page. +. "$(dirname "$0")/_lib.sh" +HERE="$(cd "$(dirname "$0")" && pwd)" + +# landing page +sed "s/__NODE_NAME__/$NODE_NAME/g" "$HERE/../templates/landing.html" | run "mkdir -p /www/wwwroot/$DOMAIN_ROOT && cat > /www/wwwroot/$DOMAIN_ROOT/index.html" + +run "cat > /www/server/panel/vhost/nginx/default_444.conf <<'CONF' +server { + listen 443 ssl default_server; + server_name _; + ssl_certificate /www/server/panel/vhost/cert/$DOMAIN_ROOT/fullchain.pem; + ssl_certificate_key /www/server/panel/vhost/cert/$DOMAIN_ROOT/privkey.pem; + return 444; +} +CONF +/www/server/nginx/sbin/nginx -t && /etc/init.d/nginx reload 2>&1 | tail -1" diff --git a/.claude/skills/edge-node-multi-protocol/scripts/10-ufw-rn.sh b/.claude/skills/edge-node-multi-protocol/scripts/10-ufw-rn.sh new file mode 100755 index 0000000..9452f6e --- /dev/null +++ b/.claude/skills/edge-node-multi-protocol/scripts/10-ufw-rn.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Firewall for a node that runs BOTH planes: +# * TCP 22/80/443 — ssh + nginx (the six WS inbounds and the 3x-ui panel are all +# 127.0.0.1-only and reach the world only through nginx:443). +# * UDP x4 — Hysteria2 / TUIC terminate QUIC themselves, so unlike the TCP +# inbounds they need genuinely public UDP ports. UDP 443 (hy2-direct) coexists +# with nginx's TCP 443: different protocol, different socket. +# RackNerd KVM has no provider-side firewall, so ufw is the only gate here. +. "$(dirname "$0")/_lib.sh" + +require_vars PORT_HY2_DIRECT PORT_HY2_WARP PORT_TUIC_DIRECT PORT_TUIC_WARP + +run "for p in 22 80 443; do ufw allow \$p/tcp >/dev/null 2>&1; done + for p in $PORT_HY2_DIRECT $PORT_HY2_WARP $PORT_TUIC_DIRECT $PORT_TUIC_WARP; do + ufw allow \$p/udp >/dev/null 2>&1 + done + yes | ufw enable >/dev/null 2>&1 || true + ufw status verbose | grep -E 'Status|/tcp|/udp'" diff --git a/.claude/skills/edge-node-multi-protocol/scripts/10-ufw-sg.sh b/.claude/skills/edge-node-multi-protocol/scripts/10-ufw-sg.sh new file mode 100755 index 0000000..0434d7f --- /dev/null +++ b/.claude/skills/edge-node-multi-protocol/scripts/10-ufw-sg.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Firewall: only 22/80/443 need to be public (all 6 inbounds + 3x-ui are 127.0.0.1-only, +# reverse-proxied through nginx on 443). NOTE: on AWS the Security Group is the outer gate +# and must also allow inbound 22/80/443 — ufw alone is not enough. +. "$(dirname "$0")/_lib.sh" + +run 'for p in 22 80 443; do ufw allow "$p"/tcp >/dev/null 2>&1; done + yes | ufw enable >/dev/null 2>&1 || true + ufw status verbose | grep -E "22|80|443|Status"' diff --git a/.claude/skills/edge-node-multi-protocol/scripts/10-ufw.sh b/.claude/skills/edge-node-multi-protocol/scripts/10-ufw.sh new file mode 100755 index 0000000..ebf867d --- /dev/null +++ b/.claude/skills/edge-node-multi-protocol/scripts/10-ufw.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Allow web + panel + aaPanel FTP/passive ports. Reads the real panel port from the box. +. "$(dirname "$0")/_lib.sh" + +run 'P=$(cat /www/server/panel/data/port.pl 2>/dev/null | tr -d "[:space:]"); P=${P:-8888} + for p in 22 80 443 "$P" 888; do ufw allow "$p"/tcp >/dev/null 2>&1; done + ufw allow 39000:40000/tcp >/dev/null 2>&1 + yes | ufw enable >/dev/null 2>&1 || true + ufw status | grep -E "22|80|443|$P|888|39000"' diff --git a/.claude/skills/edge-node-multi-protocol/scripts/11-monitoring.sh b/.claude/skills/edge-node-multi-protocol/scripts/11-monitoring.sh new file mode 100755 index 0000000..29cf680 --- /dev/null +++ b/.claude/skills/edge-node-multi-protocol/scripts/11-monitoring.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Optional: install Nezha + Komari agents pointing at YOUR panels (placeholders in config.env). +# Both are skipped unless the relevant config.env fields are set. No panel URLs/keys ship here. +. "$(dirname "$0")/_lib.sh" + +# --- Komari (auto-discovery) --- +if [ -n "${KOMARI_ENDPOINT:-}" ] && [ -n "${KOMARI_DISCOVERY_KEY:-}" ]; then + run "curl -sSL https://raw.githubusercontent.com/komari-monitor/komari-agent/main/install.sh \ + | bash -s -- --endpoint '$KOMARI_ENDPOINT' --auto-discovery '$KOMARI_DISCOVERY_KEY' 2>&1 | tail -3 + systemctl is-active komari-agent 2>/dev/null || true" + echo "Komari: agent installed. Rename/group the node in your panel (it auto-registers as 'Auto-')." +else + echo "Komari: skipped (set KOMARI_ENDPOINT + KOMARI_DISCOVERY_KEY to enable)." +fi + +# --- Nezha (agent v2): download binary + write config.yml + systemd (robust across arch; +# the one-liner installer changes often, so we do it explicitly). --- +if [ -n "${NEZHA_SERVER:-}" ] && [ -n "${NEZHA_SECRET:-}" ]; then + ARCH="$(box_arch)" + [ -n "${NEZHA_UUID:-}" ] || NEZHA_UUID="$(run 'cat /proc/sys/kernel/random/uuid')" + run "set -e + mkdir -p /opt/nezha/agent + ver=\$(curl -sL https://api.github.com/repos/nezhahq/agent/releases/latest | grep -oE '\"tag_name\": *\"[^\"]+' | head -1 | grep -oE 'v[0-9.]+') + url=https://github.com/nezhahq/agent/releases/download/\$ver/nezha-agent_linux_${ARCH}.zip + curl -fsSL \"\$url\" -o /tmp/nezha-agent.zip + (command -v unzip >/dev/null || (apt-get update -qq && apt-get install -y -qq unzip >/dev/null)) + unzip -oq /tmp/nezha-agent.zip -d /opt/nezha/agent && rm -f /tmp/nezha-agent.zip + chmod +x /opt/nezha/agent/nezha-agent + cat > /opt/nezha/agent/config.yml < /etc/systemd/system/nezha-agent.service <<'SVC' +[Unit] +Description=Nezha Agent +After=network-online.target +Wants=network-online.target +[Service] +Type=simple +ExecStart=/opt/nezha/agent/nezha-agent -c /opt/nezha/agent/config.yml +Restart=always +RestartSec=5 +[Install] +WantedBy=multi-user.target +SVC + systemctl daemon-reload + systemctl enable --now nezha-agent + sleep 3; systemctl is-active nezha-agent" + echo "Nezha: agent installed (uuid $NEZHA_UUID, arch $ARCH). Name/group the node in your panel." +else + echo "Nezha: skipped (set NEZHA_SERVER + NEZHA_SECRET to enable)." +fi diff --git a/.claude/skills/edge-node-multi-protocol/scripts/12-smoke-test-sg.sh b/.claude/skills/edge-node-multi-protocol/scripts/12-smoke-test-sg.sh new file mode 100755 index 0000000..2d1be00 --- /dev/null +++ b/.claude/skills/edge-node-multi-protocol/scripts/12-smoke-test-sg.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# End-to-end smoke test (no aaPanel): all 6 inbounds (vmess/trojan/ss x direct/warp), +# landing page, 3x-ui panel, and the anti-scan default-444 catch-all. +. "$(dirname "$0")/_lib.sh" + +COVER="$SUB_COVER.$DOMAIN_ROOT" + +echo "== credential integrity (catches the post-upgrade client wipe) ==" +# `x-ui migrate` (run by install.sh/update.sh on every upgrade) rebuilds +# client_inbounds from each inbound's settings.clients JSON, deleting the old rows +# first. If settings.clients is empty the node keeps answering TLS + WS-101 while +# authenticating nobody -- so the transport checks below pass on a dead node. +# See fix #5 in 08-xui-bootstrap.py; retrofit with 08b-embed-settings-clients.py. +run 'python3 - <= %d" % (links, len(proxy_rows))) +cfg = json.load(open("/usr/local/x-ui/bin/config.json")) +for i in cfg.get("inbounds", []): + if i.get("protocol") not in PROXY: + continue + cl = (i.get("settings") or {}).get("clients") + if not isinstance(cl, list) or not cl: + bad.append("generated config: inbound :%s (%s) has NO clients" % (i.get("port"), i.get("protocol"))) +if bad: + print("FAIL -- credentials missing; every inbound will reject all traffic:") + for b in bad: + print(" -", b) + print(" fix: run scripts/08b-embed-settings-clients.py on the box") + sys.exit(1) +print("OK -- %d inbounds have embedded clients, %d client_inbounds links, config has credentials" + % (len(proxy_rows), links)) +PY' || echo "!! credential check FAILED (see above) -- transport checks below may still pass on a dead node" + +echo "== cover WS upgrade, all 6 paths (expect 101 each) ==" +for P in "$WS_PATH_VMESS_DIRECT" "$WS_PATH_VMESS_WARP" "$WS_PATH_TROJAN_DIRECT" "$WS_PATH_TROJAN_WARP" \ + "$WS_PATH_SS_DIRECT" "$WS_PATH_SS_WARP"; do + curl -sk --http1.1 --max-time 20 -o /dev/null -w "$P -> %{http_code}\n" \ + -H "Connection: Upgrade" -H "Upgrade: websocket" \ + -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" -H "Sec-WebSocket-Version: 13" \ + "https://$COVER$P" || true +done + +echo "== landing + 3x-ui panel (expect 200) ==" +curl -sk --max-time 10 -o /dev/null -w "landing -> %{http_code}\n" "https://$DOMAIN_ROOT/" || true +curl -sk --max-time 10 -o /dev/null -w "3x-ui -> %{http_code}\n" "https://$SUB_XUI.$DOMAIN_ROOT$XUI_PATH" || true + +echo "== unmatched host (expect 000/444, connection dropped) ==" +curl -sk --max-time 8 -o /dev/null -w "random -> %{http_code}\n" "https://nope-$RANDOM.$DOMAIN_ROOT/" || true + +echo "== e2e egress via temp xray client, all 6 outbounds (direct vs WARP per protocol) ==" +python3 - "$COVER" "$XUI_UUID" "$NODE_PASSWORD" "${SS_METHOD:-chacha20-poly1305}" \ + "$WS_PATH_VMESS_DIRECT" "$WS_PATH_VMESS_WARP" "$WS_PATH_TROJAN_DIRECT" "$WS_PATH_TROJAN_WARP" \ + "$WS_PATH_SS_DIRECT" "$WS_PATH_SS_WARP" <<'PYEOF' +import json, sys +(cover, uuid, node_pw, ss_method, + vd, vw, td, tw, sd, sw) = sys.argv[1:11] +trojan_pw = ss_pw = node_pw + +def ws(path): + return {"network": "ws", "security": "tls", + "tlsSettings": {"serverName": cover}, + "wsSettings": {"path": path, "headers": {"Host": cover}}} + +outbounds = [ + {"tag": "vmess-d", "protocol": "vmess", + "settings": {"vnext": [{"address": cover, "port": 443, "users": [{"id": uuid, "security": "auto"}]}]}, + "streamSettings": ws(vd)}, + {"tag": "vmess-w", "protocol": "vmess", + "settings": {"vnext": [{"address": cover, "port": 443, "users": [{"id": uuid, "security": "auto"}]}]}, + "streamSettings": ws(vw)}, + {"tag": "trojan-d", "protocol": "trojan", + "settings": {"servers": [{"address": cover, "port": 443, "password": trojan_pw}]}, + "streamSettings": ws(td)}, + {"tag": "trojan-w", "protocol": "trojan", + "settings": {"servers": [{"address": cover, "port": 443, "password": trojan_pw}]}, + "streamSettings": ws(tw)}, + {"tag": "ss-d", "protocol": "shadowsocks", + "settings": {"servers": [{"address": cover, "port": 443, "method": ss_method, "password": ss_pw}]}, + "streamSettings": ws(sd)}, + {"tag": "ss-w", "protocol": "shadowsocks", + "settings": {"servers": [{"address": cover, "port": 443, "method": ss_method, "password": ss_pw}]}, + "streamSettings": ws(sw)}, +] +inbounds = [] +rules = [] +for i, tag in enumerate(("vmess-d", "vmess-w", "trojan-d", "trojan-w", "ss-d", "ss-w")): + port = 10801 + i + inbounds.append({"port": port, "listen": "127.0.0.1", "protocol": "socks", + "tag": "in-%s" % tag, "settings": {"udp": True}}) + rules.append({"type": "field", "inboundTag": ["in-%s" % tag], "outboundTag": tag}) + +cfg = {"inbounds": inbounds, "outbounds": outbounds, "routing": {"rules": rules}} +json.dump(cfg, open("/tmp/_smoke_tc.json", "w")) +print("local socks ports: vmess-d=10801 vmess-w=10802 trojan-d=10803 trojan-w=10804 ss-d=10805 ss-w=10806") +PYEOF + +push /tmp/_smoke_tc.json /tmp/_smoke_tc.json +run ' +cd /usr/local/x-ui/bin +XRAY=$(ls xray-linux-* 2>/dev/null | head -1); [ -n "$XRAY" ] || XRAY=$(ls xray* 2>/dev/null | head -1) +./$XRAY -c /tmp/_smoke_tc.json >/tmp/_smoke_tc.log 2>&1 & +XPID=$!; sleep 4 +for pair in "vmess-direct:10801" "vmess-warp:10802" "trojan-direct:10803" "trojan-warp:10804" "ss-direct:10805" "ss-warp:10806"; do + name="${pair%%:*}"; port="${pair##*:}" + printf "%-14s " "$name" + curl -s -x socks5h://127.0.0.1:$port --max-time 15 https://1.1.1.1/cdn-cgi/trace | grep -E "ip=|warp=" | tr "\n" " " + echo +done +kill $XPID 2>/dev/null +rm -f /tmp/_smoke_tc.json /tmp/_smoke_tc.log +' +rm -f /tmp/_smoke_tc.json + +echo "Expected: *-direct -> ip=$SERVER_IP warp=off ; *-warp -> ip= warp=on" diff --git a/.claude/skills/edge-node-multi-protocol/scripts/12-smoke-test.sh b/.claude/skills/edge-node-multi-protocol/scripts/12-smoke-test.sh new file mode 100755 index 0000000..e6d4791 --- /dev/null +++ b/.claude/skills/edge-node-multi-protocol/scripts/12-smoke-test.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# End-to-end smoke test for all 6 inbounds (vmess/trojan/shadowsocks x direct/warp), +# plus the landing page, both panels, and the anti-scan default-444 catch-all. +# Run from anywhere with DNS resolving to the box. +. "$(dirname "$0")/_lib.sh" +HERE="$(cd "$(dirname "$0")" && pwd)" + +COVER="$SUB_COVER.$DOMAIN_ROOT" + +echo "== credential integrity (catches the post-upgrade client wipe) ==" +# An upgrade that runs `x-ui migrate` rebuilds client_inbounds from each inbound's +# settings.clients JSON, deleting the old rows first. If settings.clients is empty +# the node keeps answering TLS + WS-101 while silently authenticating nobody, so the +# transport checks below all pass on a completely dead node. Assert the credentials +# are actually present, in the DB and in the generated xray config. See fix #5 in +# 08-xui-bootstrap.py; retrofit with 08b-embed-settings-clients.py. +run 'python3 - <= %d" % (links, len(proxy_rows))) +cfg = json.load(open("/usr/local/x-ui/bin/config.json")) +for i in cfg.get("inbounds", []): + if i.get("protocol") not in PROXY: + continue + cl = (i.get("settings") or {}).get("clients") + if not isinstance(cl, list) or not cl: + bad.append("generated config: inbound :%s (%s) has NO clients" % (i.get("port"), i.get("protocol"))) +if bad: + print("FAIL -- credentials missing; every inbound will reject all traffic:") + for b in bad: + print(" -", b) + print(" fix: run scripts/08b-embed-settings-clients.py on the box") + sys.exit(1) +print("OK -- %d inbounds have embedded clients, %d client_inbounds links, config has credentials" + % (len(proxy_rows), links)) +PY' || echo "!! credential check FAILED (see above) -- transport checks below may still pass on a dead node" + +echo "== cover WS upgrade, all 6 paths (expect 101 each) ==" +# curl reports the upgrade status via -w before it ever exits, but the connection stays +# open as a live WS tunnel afterwards, so curl commonly hits --max-time and returns a +# non-zero exit even on a successful 101 -- `|| true` so one check's exit code can't +# abort the rest of the script under `set -e`. +for P in "$WS_PATH_VMESS_DIRECT" "$WS_PATH_VMESS_WARP" "$WS_PATH_TROJAN_DIRECT" "$WS_PATH_TROJAN_WARP" \ + "$WS_PATH_SS_DIRECT" "$WS_PATH_SS_WARP"; do + curl -sk --http1.1 --max-time 20 -o /dev/null -w "$P -> %{http_code}\n" \ + -H "Connection: Upgrade" -H "Upgrade: websocket" \ + -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" -H "Sec-WebSocket-Version: 13" \ + "https://$COVER$P" || true +done + +echo "== landing + panels (expect 200) ==" +curl -sk --max-time 10 -o /dev/null -w "landing -> %{http_code}\n" "https://$DOMAIN_ROOT/" || true +curl -sk --max-time 10 -o /dev/null -w "3x-ui -> %{http_code}\n" "https://$SUB_XUI.$DOMAIN_ROOT$XUI_PATH" || true +# aaPanel's anti-scan check needs a UA that actually looks like a real browser -- a bare +# "Mozilla/5.0 (Macintosh)" gets judged as a scanner and served a fake 404 (see +# references/pitfalls.md). Don't "fix" this by loosening CDN_PROXY or re-checking the +# domain binding; check the UA string first, every time. +curl -sk --max-time 10 -A "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/605.1.15" \ + -o /dev/null -w "aaPanel -> %{http_code}\n" "https://$SUB_PANEL.$DOMAIN_ROOT$AAPANEL_ENTRANCE" || true + +echo "== unmatched host (expect 000/444, connection dropped) ==" +curl -sk --max-time 8 -o /dev/null -w "random -> %{http_code}\n" "https://nope-$RANDOM.$DOMAIN_ROOT/" || true + +echo "== e2e egress via temp xray client, all 6 outbounds (direct vs WARP per protocol) ==" +# node-main carries one uuid (vmess) + one shared password (trojan+ss). +python3 - "$COVER" "$XUI_UUID" "$NODE_PASSWORD" "${SS_METHOD:-chacha20-poly1305}" \ + "$WS_PATH_VMESS_DIRECT" "$WS_PATH_VMESS_WARP" "$WS_PATH_TROJAN_DIRECT" "$WS_PATH_TROJAN_WARP" \ + "$WS_PATH_SS_DIRECT" "$WS_PATH_SS_WARP" <<'PYEOF' +import json, sys +(cover, uuid, node_pw, ss_method, + vd, vw, td, tw, sd, sw) = sys.argv[1:11] +trojan_pw = ss_pw = node_pw # single shared credential + +def ws(path): + return {"network": "ws", "security": "tls", + "tlsSettings": {"serverName": cover}, + "wsSettings": {"path": path, "headers": {"Host": cover}}} + +outbounds = [ + {"tag": "vmess-d", "protocol": "vmess", + "settings": {"vnext": [{"address": cover, "port": 443, "users": [{"id": uuid, "security": "auto"}]}]}, + "streamSettings": ws(vd)}, + {"tag": "vmess-w", "protocol": "vmess", + "settings": {"vnext": [{"address": cover, "port": 443, "users": [{"id": uuid, "security": "auto"}]}]}, + "streamSettings": ws(vw)}, + {"tag": "trojan-d", "protocol": "trojan", + "settings": {"servers": [{"address": cover, "port": 443, "password": trojan_pw}]}, + "streamSettings": ws(td)}, + {"tag": "trojan-w", "protocol": "trojan", + "settings": {"servers": [{"address": cover, "port": 443, "password": trojan_pw}]}, + "streamSettings": ws(tw)}, + {"tag": "ss-d", "protocol": "shadowsocks", + "settings": {"servers": [{"address": cover, "port": 443, "method": ss_method, "password": ss_pw}]}, + "streamSettings": ws(sd)}, + {"tag": "ss-w", "protocol": "shadowsocks", + "settings": {"servers": [{"address": cover, "port": 443, "method": ss_method, "password": ss_pw}]}, + "streamSettings": ws(sw)}, +] +inbounds = [] +rules = [] +for i, tag in enumerate(("vmess-d", "vmess-w", "trojan-d", "trojan-w", "ss-d", "ss-w")): + port = 10801 + i + inbounds.append({"port": port, "listen": "127.0.0.1", "protocol": "socks", + "tag": "in-%s" % tag, "settings": {"udp": True}}) + rules.append({"type": "field", "inboundTag": ["in-%s" % tag], "outboundTag": tag}) + +cfg = {"inbounds": inbounds, "outbounds": outbounds, "routing": {"rules": rules}} +json.dump(cfg, open("/tmp/_smoke_tc.json", "w")) +print("local socks ports: vmess-d=10801 vmess-w=10802 trojan-d=10803 trojan-w=10804 ss-d=10805 ss-w=10806") +PYEOF + +push /tmp/_smoke_tc.json /tmp/_smoke_tc.json +run ' +cd /usr/local/x-ui/bin +XRAY=$(ls xray-linux-* 2>/dev/null | head -1); [ -n "$XRAY" ] || XRAY=$(ls xray* 2>/dev/null | head -1) +./$XRAY -c /tmp/_smoke_tc.json >/tmp/_smoke_tc.log 2>&1 & +XPID=$!; sleep 4 +for pair in "vmess-direct:10801" "vmess-warp:10802" "trojan-direct:10803" "trojan-warp:10804" "ss-direct:10805" "ss-warp:10806"; do + name="${pair%%:*}"; port="${pair##*:}" + printf "%-14s " "$name" + curl -s -x socks5h://127.0.0.1:$port --max-time 15 https://1.1.1.1/cdn-cgi/trace | grep -E "ip=|warp=" | tr "\n" " " + echo +done +kill $XPID 2>/dev/null +rm -f /tmp/_smoke_tc.json /tmp/_smoke_tc.log +' +rm -f /tmp/_smoke_tc.json + +echo "Expected: *-direct -> ip=$SERVER_IP warp=off ; *-warp -> ip= warp=on" diff --git a/.claude/skills/edge-node-multi-protocol/scripts/13-install-singbox-quic.sh b/.claude/skills/edge-node-multi-protocol/scripts/13-install-singbox-quic.sh new file mode 100755 index 0000000..d2e8ac7 --- /dev/null +++ b/.claude/skills/edge-node-multi-protocol/scripts/13-install-singbox-quic.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# QUIC plane: install sing-box and serve whichever of Hysteria2 / TUIC has ports +# configured in config.env, each with a direct and a WARP-clean-exit inbound. +# +# Additive by design -- safe to run on a node already serving the six TCP inbounds: +# * sing-box is its own service on its own UDP ports; 3x-ui / Xray / nginx are +# never touched, and nothing here writes to 3x-ui's database or nginx's config. +# * The one shared resource is the acme.sh wildcard cert. sing-box reads it at +# startup only, so the renew hook is EXTENDED (nginx reload + sing-box restart) +# rather than replaced -- otherwise the QUIC plane quietly serves an expired +# cert about 60 days later. +# +# Ports: set PORT_HY2_DIRECT / PORT_HY2_WARP and/or PORT_TUIC_DIRECT / PORT_TUIC_WARP. +# Unset pairs are skipped. Putting a Hysteria2 inbound on UDP 443 is deliberate: it +# reads as ordinary HTTP/3 traffic to that host, and it does not collide with nginx, +# which holds TCP 443 (different protocol, different socket). +# +# Remember the firewall has two layers on some providers: this script opens the UDP +# ports in ufw, but a cloud security group (AWS/GCP/Azure) must allow them too. +. "$(dirname "$0")/_lib.sh" +HERE="$(cd "$(dirname "$0")" && pwd)" + +require_vars NODE_PASSWORD SUB_COVER DOMAIN_ROOT +[ -n "${PORT_HY2_DIRECT:-}${PORT_HY2_WARP:-}${PORT_TUIC_DIRECT:-}${PORT_TUIC_WARP:-}" ] || { + echo "ERROR: no QUIC ports set in config.env (PORT_HY2_* / PORT_TUIC_*)" >&2; exit 1; } +# TUIC authenticates with uuid+password, so it needs XUI_UUID. +if [ -n "${PORT_TUIC_DIRECT:-}${PORT_TUIC_WARP:-}" ]; then require_vars XUI_UUID; fi + +COVER="$SUB_COVER.$DOMAIN_ROOT" +CERTDIR="/etc/nginx/ssl/$DOMAIN_ROOT" + +echo "== install sing-box ==" +run 'if ! command -v sing-box >/dev/null 2>&1; then + export DEBIAN_FRONTEND=noninteractive + for i in $(seq 1 60); do fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1 || break; sleep 10; done + mkdir -p /etc/apt/keyrings + curl -fsSL https://sing-box.app/gpg.key -o /etc/apt/keyrings/sagernet.asc + chmod a+r /etc/apt/keyrings/sagernet.asc + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/sagernet.asc] https://deb.sagernet.org/ * *" > /etc/apt/sources.list.d/sagernet.list + apt-get update -qq + apt-get install -y -qq sing-box >/dev/null || { echo "ERROR: sing-box install failed"; exit 1; } + fi + sing-box version | head -1' + +echo "== verify the cert the QUIC plane will serve ==" +run "[ -s '$CERTDIR/fullchain.pem' ] && [ -s '$CERTDIR/privkey.pem' ] || { echo 'ERROR: wildcard cert missing at $CERTDIR — run 03-sign-*-cert.sh first'; exit 1; } + echo cert-present" + +echo "== generate config ==" +push "$HERE/13-singbox-config.py" /root/_singbox_config.py +run "COVER='$COVER' CERT_DIR='$CERTDIR' WARP_SOCKS_PORT='${WARP_SOCKS_PORT:-40000}' \ + NODE_PASSWORD='$NODE_PASSWORD' XUI_UUID='${XUI_UUID:-}' \ + PORT_HY2_DIRECT='${PORT_HY2_DIRECT:-}' PORT_HY2_WARP='${PORT_HY2_WARP:-}' \ + HY2_OBFS_PASSWORD='${HY2_OBFS_PASSWORD:-}' \ + HY2_UP_MBPS='${HY2_UP_MBPS:-500}' HY2_DOWN_MBPS='${HY2_DOWN_MBPS:-500}' \ + PORT_TUIC_DIRECT='${PORT_TUIC_DIRECT:-}' PORT_TUIC_WARP='${PORT_TUIC_WARP:-}' \ + TUIC_CC='${TUIC_CC:-bbr}' \ + python3 /root/_singbox_config.py + rm -f /root/_singbox_config.py + sing-box check -c /etc/sing-box/config.json && echo config-valid" + +echo "== open the UDP ports in ufw ==" +UDP_PORTS="${PORT_HY2_DIRECT:-} ${PORT_HY2_WARP:-} ${PORT_TUIC_DIRECT:-} ${PORT_TUIC_WARP:-}" +run "for p in $UDP_PORTS; do [ -n \"\$p\" ] && ufw allow \$p/udp >/dev/null 2>&1; done + ufw status | grep -E '/udp' || echo 'no udp rules (is ufw active?)'" + +echo "== enable + start ==" +run 'systemctl enable sing-box >/dev/null 2>&1 || true + systemctl restart sing-box + sleep 4 + systemctl is-active sing-box + ss -ulnp | grep sing-box || echo "WARN: sing-box has no UDP sockets"' + +echo "== extend the cert-renew hook to restart sing-box ==" +# Resolve acme.sh on the box rather than writing `~/.acme.sh`: on a sudo-user login +# the tilde expands in the *login* shell (HOME=/home/) before sudo runs, so a +# root-installed acme.sh is missed and the hook silently never gets updated. +run "ACME=\$(for c in /root/.acme.sh/acme.sh \"\$HOME/.acme.sh/acme.sh\"; do [ -x \"\$c\" ] && echo \"\$c\" && break; done) + [ -n \"\$ACME\" ] || { echo 'ERROR: acme.sh not found — cannot update the renew hook'; exit 1; } + echo \"using \$ACME\" + \$ACME --install-cert -d '$DOMAIN_ROOT' --ecc \ + --key-file '$CERTDIR/privkey.pem' \ + --fullchain-file '$CERTDIR/fullchain.pem' \ + --reloadcmd 'systemctl reload nginx; systemctl restart sing-box' 2>&1 | tail -2" + +echo +echo "NOTE: on AWS/GCP/Azure the cloud security group must also allow inbound UDP:$UDP_PORTS" diff --git a/.claude/skills/edge-node-multi-protocol/scripts/13-singbox-config.py b/.claude/skills/edge-node-multi-protocol/scripts/13-singbox-config.py new file mode 100755 index 0000000..f955737 --- /dev/null +++ b/.claude/skills/edge-node-multi-protocol/scripts/13-singbox-config.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Runs ON the box. Emits /etc/sing-box/config.json for the QUIC plane. + +Which inbounds get built is driven purely by which port variables are set, so the +same script serves a Hysteria2-only node, a TUIC-only node, or both -- no editing +required to add or drop a protocol. + +Why a second daemon at all: the TCP inbounds (VMess/Trojan/Shadowsocks) hide +behind nginx:443 as WebSocket paths. Hysteria2 and TUIC are QUIC over UDP -- they +terminate TLS themselves and cannot be reverse-proxied, so each needs a real +public UDP port. Xray-core (what 3x-ui drives) implements neither, hence sing-box +running alongside 3x-ui. Nothing here touches 3x-ui, nginx, or the existing +inbounds: separate service, separate ports, separate config file. + +WARP egress reuses the cloudflare-warp SOCKS5 proxy the Xray plane already uses, +routed by inbound tag -- one WARP install serves both planes. + +Env in: COVER, CERT_DIR, WARP_SOCKS_PORT, NODE_PASSWORD, XUI_UUID, + PORT_HY2_DIRECT / PORT_HY2_WARP / HY2_OBFS_PASSWORD / HY2_UP_MBPS / HY2_DOWN_MBPS, + PORT_TUIC_DIRECT / PORT_TUIC_WARP / TUIC_CC +Unset port pairs are simply skipped. +""" +import json, os, sys + +COVER = os.environ["COVER"] +CERT_DIR = os.environ.get("CERT_DIR", "/etc/nginx/ssl/" + COVER.split(".", 1)[1]) +WARP_PORT = int(os.environ.get("WARP_SOCKS_PORT", "40000")) +OUT = os.environ.get("SINGBOX_CONFIG", "/etc/sing-box/config.json") + + +def tls(): + return {"enabled": True, "server_name": COVER, "alpn": ["h3"], + "certificate_path": os.path.join(CERT_DIR, "fullchain.pem"), + "key_path": os.path.join(CERT_DIR, "privkey.pem")} + + +def port(name): + v = os.environ.get(name, "").strip() + return int(v) if v else None + + +inbounds = [] + +# --- Hysteria2 ------------------------------------------------------------- +# Brutal congestion control needs a DECLARED bandwidth; it ignores loss as a +# congestion signal, so an over-stated figure causes a loss storm rather than +# speed. up/down are the server's view of the link. +up = int(os.environ.get("HY2_UP_MBPS", "500")) +down = int(os.environ.get("HY2_DOWN_MBPS", "500")) +obfs_pw = os.environ.get("HY2_OBFS_PASSWORD", "").strip() +for tag, pvar in (("hy2-direct", "PORT_HY2_DIRECT"), ("hy2-warp", "PORT_HY2_WARP")): + p = port(pvar) + if not p: + continue + ib = {"type": "hysteria2", "tag": tag, "listen": "0.0.0.0", "listen_port": p, + "up_mbps": up, "down_mbps": down, + "users": [{"name": "node-main", "password": os.environ["NODE_PASSWORD"]}], + "tls": tls()} + if obfs_pw: + ib["obfs"] = {"type": "salamander", "password": obfs_pw} + inbounds.append(ib) + +# --- TUIC v5 -------------------------------------------------------------- +tuic_cc = os.environ.get("TUIC_CC", "bbr") +for tag, pvar in (("tuic-direct", "PORT_TUIC_DIRECT"), ("tuic-warp", "PORT_TUIC_WARP")): + p = port(pvar) + if not p: + continue + inbounds.append({ + "type": "tuic", "tag": tag, "listen": "0.0.0.0", "listen_port": p, + "users": [{"name": "node-main", "uuid": os.environ["XUI_UUID"], + "password": os.environ["NODE_PASSWORD"]}], + "congestion_control": tuic_cc, "auth_timeout": "3s", + "zero_rtt_handshake": False, "heartbeat": "10s", "tls": tls()}) + +if not inbounds: + sys.exit("ERROR: no QUIC ports configured (set PORT_HY2_* and/or PORT_TUIC_*)") + +warp_tags = [i["tag"] for i in inbounds if i["tag"].endswith("-warp")] +direct_tags = [i["tag"] for i in inbounds if i["tag"].endswith("-direct")] + +rules = [ + {"action": "resolve", "strategy": "ipv4_only", "server": "ipv4-dns"}, + {"ip_cidr": ["::/0"], "action": "reject"}, +] +if warp_tags: + rules.append({"inbound": warp_tags, "outbound": "warp"}) +if direct_tags: + rules.append({"inbound": direct_tags, "outbound": "direct"}) +rules.append({"protocol": "bittorrent", "outbound": "block"}) + +cfg = { + "log": {"level": "warn", "timestamp": True}, + "dns": {"servers": [{"type": "udp", "tag": "ipv4-dns", "server": "1.1.1.1", + "server_port": 53}], "strategy": "ipv4_only", "final": "ipv4-dns"}, + "inbounds": inbounds, + "outbounds": [ + {"type": "direct", "tag": "direct"}, + {"type": "socks", "tag": "warp", "server": "127.0.0.1", + "server_port": WARP_PORT, "version": "5"}, + {"type": "block", "tag": "block"}, + ], + "route": {"rules": rules, "final": "direct"}, +} + +os.makedirs(os.path.dirname(OUT), exist_ok=True) +with open(OUT, "w") as f: + json.dump(cfg, f, indent=2) +os.chmod(OUT, 0o600) +print("wrote %s with %d inbound(s): %s" % (OUT, len(inbounds), ", ".join(i["tag"] for i in inbounds))) diff --git a/.claude/skills/edge-node-multi-protocol/scripts/14-generate-links-and-qr.py b/.claude/skills/edge-node-multi-protocol/scripts/14-generate-links-and-qr.py new file mode 100755 index 0000000..051f136 --- /dev/null +++ b/.claude/skills/edge-node-multi-protocol/scripts/14-generate-links-and-qr.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""Run LOCALLY (not on the box) after 08/09 have succeeded. Reads config.env from +the same directory and writes share links + QR codes for all 6 nodes into +--out (default: ./node-links/ next to this script). Needs `qrencode` on PATH +(brew install qrencode / apt install qrencode) -- QR codes are generated fully +offline, nothing here is ever sent to a third-party service, because these +links carry live credentials. + +All three protocols get a share link + QR. Shadowsocks over ws+tls uses the +SIP002 ss:// URI with Xray-style transport query params -- userinfo is +base64(method:password) with padding stripped, and the transport is carried in +the query string (type=ws&path=...&security=tls&sni=...) rather than in a SIP003 +`plugin=` field. That is exactly what 3x-ui's own genShadowsocksLink emits, so +the links here match what the panel's subscription endpoint hands out; verified +against a live box by decoding /sub/. The sing-box JSON snippet is still +written alongside as an unambiguous fallback for clients that reject the URI. +""" +import base64, json, os, re, subprocess, sys, urllib.parse as up + +HERE = os.path.dirname(os.path.abspath(__file__)) +OUT = sys.argv[sys.argv.index("--out") + 1] if "--out" in sys.argv else os.path.join(HERE, "node-links") + + +def load_env(): + env = {} + path = os.path.join(HERE, "config.env") + if not os.path.exists(path): + sys.exit("ERROR: copy config.env.example -> config.env and fill it in") + for line in open(path): + m = re.match(r'\s*([A-Z0-9_]+)\s*=\s*"?([^"#\n]*)"?', line) + if m: + env[m.group(1)] = m.group(2).strip() + return env + + +E = load_env() +COVER = "%s.%s" % (E["SUB_COVER"], E["DOMAIN_ROOT"]) +NODE_NAME = E.get("NODE_NAME", "edge-node") + + +def vmess_link(remark, path): + cfg = { + "v": "2", "ps": remark, "add": COVER, "port": "443", "id": E["XUI_UUID"], "aid": "0", + "scy": "auto", "net": "ws", "type": "none", "host": COVER, "path": path, + "tls": "tls", "sni": COVER, "alpn": "", + } + raw = json.dumps(cfg, separators=(",", ":"), ensure_ascii=False) + return "vmess://" + base64.b64encode(raw.encode()).decode() + + +# trojan + shadowsocks share NODE_PASSWORD (one node-main client); vmess uses XUI_UUID. +def trojan_link(remark, path): + q = up.urlencode({"security": "tls", "type": "ws", "host": COVER, "path": path, "sni": COVER}) + return "trojan://%s@%s:443?%s#%s" % (E["NODE_PASSWORD"], COVER, q, up.quote(remark)) + + +def ss_link(remark, path): + """SIP002 ss:// with Xray transport params -- byte-identical to what 3x-ui emits. + Param order matches the panel's (alphabetical) so the two can be diffed.""" + method = E.get("SS_METHOD", "chacha20-poly1305") + userinfo = base64.b64encode(("%s:%s" % (method, E["NODE_PASSWORD"])).encode()).decode().rstrip("=") + q = up.urlencode({"host": "", "path": path, "security": "tls", "sni": COVER, "type": "ws"}) + return "ss://%s@%s:443?%s#%s" % (userinfo, COVER, q, up.quote(remark)) + + +def ss_singbox(tag, path): + return { + "type": "shadowsocks", "tag": tag, "server": COVER, "server_port": 443, + "method": E.get("SS_METHOD", "chacha20-poly1305"), "password": E["NODE_PASSWORD"], + "tls": {"enabled": True, "server_name": COVER}, + "transport": {"type": "ws", "path": path, "headers": {"Host": COVER}}, + } + + +def main(): + os.makedirs(OUT, exist_ok=True) + links = { + "vmess-direct.link": vmess_link("%s-vmess-direct" % NODE_NAME, E["WS_PATH_VMESS_DIRECT"]), + "vmess-warp.link": vmess_link("%s-vmess-warp-clean-exit" % NODE_NAME, E["WS_PATH_VMESS_WARP"]), + "trojan-direct.link": trojan_link("%s-trojan-direct" % NODE_NAME, E["WS_PATH_TROJAN_DIRECT"]), + "trojan-warp.link": trojan_link("%s-trojan-warp-clean-exit" % NODE_NAME, E["WS_PATH_TROJAN_WARP"]), + "ss-direct.link": ss_link("%s-ss-direct" % NODE_NAME, E["WS_PATH_SS_DIRECT"]), + "ss-warp.link": ss_link("%s-ss-warp-clean-exit" % NODE_NAME, E["WS_PATH_SS_WARP"]), + } + for name, link in links.items(): + path = os.path.join(OUT, name) + open(path, "w").write(link) + png = path.replace(".link", "-qr.png") + try: + subprocess.run(["qrencode", "-o", png, "-s", "8", "-m", "2"], input=link.encode(), check=True) + except FileNotFoundError: + print(" (qrencode not found on PATH -- skipping QR for %s; link file written)" % name) + print(" wrote", name) + + for tag, path, filename in [ + ("%s-ss-direct" % NODE_NAME, E["WS_PATH_SS_DIRECT"], "ss-direct-singbox.json"), + ("%s-ss-warp-clean-exit" % NODE_NAME, E["WS_PATH_SS_WARP"], "ss-warp-singbox.json"), + ]: + with open(os.path.join(OUT, filename), "w") as f: + json.dump(ss_singbox(tag, path), f, indent=2) + print(" wrote", filename) + + print("DONE — links + QR codes in", OUT) + print("Reminder: never upload these files/links to a third-party QR or paste service — they carry live credentials.") + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/edge-node-multi-protocol/scripts/15-generate-quic-links.py b/.claude/skills/edge-node-multi-protocol/scripts/15-generate-quic-links.py new file mode 100755 index 0000000..7f2283c --- /dev/null +++ b/.claude/skills/edge-node-multi-protocol/scripts/15-generate-quic-links.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Run LOCALLY. Emits share links + QR codes + sing-box snippets for the QUIC plane +(Hysteria2 / TUIC x direct/WARP). Companion to 14-generate-links-and-qr.py, which +covers the six TCP inbounds. + +QR codes are produced offline with the local `qrencode` binary -- these carry live +credentials and must never be pasted into an online QR generator. +""" +import json, os, re, subprocess, sys +from urllib.parse import quote + +HERE = os.path.dirname(os.path.abspath(__file__)) +OUT = os.path.join(HERE, "node-links") + + +def load_env(): + env, path = {}, os.path.join(HERE, "config.env") + if not os.path.exists(path): + sys.exit("ERROR: config.env not found") + for line in open(path): + m = re.match(r'\s*([A-Z0-9_]+)\s*=\s*"?([^"#\n]*)"?', line) + if m: + env[m.group(1)] = m.group(2).strip() + return env + + +E = load_env() +COVER = "%s.%s" % (E["SUB_COVER"], E["DOMAIN_ROOT"]) +NAME = E.get("NODE_NAME", "Edge Node") +PW = E["NODE_PASSWORD"] +UUID = E["XUI_UUID"] +OBFS = E["HY2_OBFS_PASSWORD"] +CC = E.get("TUIC_CC", "bbr") +os.makedirs(OUT, exist_ok=True) + + +def write(fname, content): + with open(os.path.join(OUT, fname), "w") as f: + f.write(content + "\n") + print(" wrote", fname) + + +def qr(fname, data): + try: + subprocess.run(["qrencode", "-o", os.path.join(OUT, fname), "-s", "6", data], check=True) + print(" wrote", fname) + except (FileNotFoundError, subprocess.CalledProcessError): + print(" (skipped %s -- install qrencode for QR codes)" % fname) + + +def p(name): + """Empty/absent port -> protocol not deployed on this node; skip it.""" + return (E.get(name) or "").strip() + + +# --- Hysteria2: hy2:// is the format sing-box / v2rayN / Shadowrocket accept --- +for suffix, port in (("direct", p("PORT_HY2_DIRECT")), ("warp", p("PORT_HY2_WARP"))): + if not port: + continue + tag = "%s-hy2-%s" % (NAME, suffix if suffix == "direct" else "warp-clean-exit") + link = ("hy2://%s@%s:%s/?sni=%s&alpn=h3&obfs=salamander&obfs-password=%s#%s" + % (quote(PW, safe=""), COVER, port, COVER, quote(OBFS, safe=""), quote(tag))) + write("hy2-%s.link" % suffix, link) + qr("hy2-%s-qr.png" % suffix, link) + write("hy2-%s-singbox.json" % suffix, json.dumps({ + "type": "hysteria2", "tag": tag, "server": COVER, "server_port": int(port), + "password": PW, + "obfs": {"type": "salamander", "password": OBFS}, + "up_mbps": int(E.get("HY2_UP_MBPS", 500)), "down_mbps": int(E.get("HY2_DOWN_MBPS", 500)), + "tls": {"enabled": True, "server_name": COVER, "alpn": ["h3"]}, + }, indent=2)) + +# --- TUIC v5 --- +for suffix, port in (("direct", p("PORT_TUIC_DIRECT")), ("warp", p("PORT_TUIC_WARP"))): + if not port: + continue + tag = "%s-tuic-%s" % (NAME, suffix if suffix == "direct" else "warp-clean-exit") + link = ("tuic://%s:%s@%s:%s?sni=%s&alpn=h3&congestion_control=%s&udp_relay_mode=native#%s" + % (UUID, quote(PW, safe=""), COVER, port, COVER, CC, quote(tag))) + write("tuic-%s.link" % suffix, link) + qr("tuic-%s-qr.png" % suffix, link) + write("tuic-%s-singbox.json" % suffix, json.dumps({ + "type": "tuic", "tag": tag, "server": COVER, "server_port": int(port), + "uuid": UUID, "password": PW, "congestion_control": CC, + "udp_relay_mode": "native", "zero_rtt_handshake": False, "heartbeat": "10s", + "tls": {"enabled": True, "server_name": COVER, "alpn": ["h3"]}, + }, indent=2)) + +print("DONE — QUIC links + QR + sing-box snippets in", OUT) +print("NOTE: hy2:// and tuic:// share-link formats are client-dependent; the sing-box") +print(" JSON snippets are the unambiguous fallback if a client rejects a link.") diff --git a/.claude/skills/edge-node-multi-protocol/scripts/16-add-socks5.py b/.claude/skills/edge-node-multi-protocol/scripts/16-add-socks5.py new file mode 100755 index 0000000..e8462ef --- /dev/null +++ b/.claude/skills/edge-node-multi-protocol/scripts/16-add-socks5.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Runs ON the box. Adds a password-authenticated SOCKS5 inbound to 3x-ui. + +Reuses the existing Xray instance -- no new daemon. Xray implements SOCKS5 with +RFC 1929 username/password auth natively, so this is purely a new inbound row. + +Additive and idempotent: skips if the port already exists, touches no other +inbound, writes nothing to nginx or sing-box. + +Two things worth knowing about this inbound versus the other ten on this node: + + * It is NOT wrapped in TLS. SOCKS5 auth per RFC 1929 sends the username and + password in CLEARTEXT, and the tunnelled traffic is unencrypted too. Anyone + on the path can read both. Raw SOCKS5 is also trivially fingerprinted. Treat + the credentials as disposable and prefer binding to 127.0.0.1 + an SSH tunnel + if the client can be pointed at a local port (see LISTEN below). + + * `socks` is not one of the protocols 3x-ui projects through its clients / + client_inbounds tables (that set is vmess/vless/trojan/shadowsocks/hysteria), + so the accounts live directly in this inbound's settings JSON and the + settings.clients migration hazard documented in 08-xui-bootstrap.py fix #5 + does not apply here. + +Env in: SOCKS_PORT, SOCKS_USER, SOCKS_PASS, SERVER_IP + LISTEN (default 0.0.0.0; set 127.0.0.1 for SSH-tunnel-only access) + REMARK (default socks5-direct), XUI_DB +""" +import json, os, sqlite3, subprocess, sys, time + +DB = os.environ.get("XUI_DB", "/etc/x-ui/x-ui.db") +PORT = int(os.environ["SOCKS_PORT"]) +USER = os.environ["SOCKS_USER"] +PASS = os.environ["SOCKS_PASS"] +LISTEN = os.environ.get("LISTEN", "0.0.0.0") +REMARK = os.environ.get("REMARK", "socks5-direct") +# Xray announces this address in the UDP ASSOCIATE reply. For a remote server it +# must be the public IP, or UDP-over-SOCKS5 breaks (TCP still works either way). +SERVER_IP = os.environ.get("SERVER_IP", "127.0.0.1") +TAG = "inbound-" + REMARK + + +def cols(cur, table): + return {r[1] for r in cur.execute("PRAGMA table_info(%s)" % table).fetchall()} + + +def main(): + con = sqlite3.connect(DB) + cur = con.cursor() + + if cur.execute("SELECT 1 FROM inbounds WHERE port=?", (PORT,)).fetchone(): + print(" inbound :%d already exists, nothing to do" % PORT) + return + + row = { + "user_id": 1, # NULL user_id -> proxies fine but INVISIBLE in the panel + "up": 0, "down": 0, "total": 0, "remark": REMARK, + "enable": 1, "expiry_time": 0, + "listen": LISTEN, "port": PORT, "protocol": "socks", + "settings": json.dumps({ + "auth": "password", + "accounts": [{"user": USER, "pass": PASS}], + "udp": True, + "ip": SERVER_IP, + }, indent=2), + "stream_settings": json.dumps({"network": "tcp", "security": "none"}, indent=2), + "sniffing": json.dumps({"enabled": True, "destOverride": ["http", "tls"]}), + "tag": TAG, + } + ic = cols(cur, "inbounds") + keys = [k for k in row if k in ic] + cur.execute("INSERT INTO inbounds (%s) VALUES (%s)" % (",".join(keys), ",".join("?" * len(keys))), + [row[k] for k in keys]) + con.commit() + con.close() + print(" inbound :%d socks5 added (listen=%s, auth=password, udp=on)" % (PORT, LISTEN)) + + subprocess.run(["systemctl", "restart", "x-ui"], check=True) + time.sleep(6) + print(" x-ui restarted") + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/edge-node-multi-protocol/scripts/_lib.sh b/.claude/skills/edge-node-multi-protocol/scripts/_lib.sh new file mode 100755 index 0000000..ac66f7e --- /dev/null +++ b/.claude/skills/edge-node-multi-protocol/scripts/_lib.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Shared helpers. Every script sources this, which sources config.env. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +[ -f "$HERE/config.env" ] || { echo "ERROR: copy config.env.example -> config.env and fill it in"; exit 1; } +# shellcheck disable=SC1091 +. "$HERE/config.env" + +# --- config sanity (fail fast) --- +require_vars() { + local miss=() + for v in "$@"; do [ -n "${!v:-}" ] || miss+=("$v"); done + if [ ${#miss[@]} -gt 0 ]; then echo "ERROR: config.env missing: ${miss[*]}" >&2; exit 1; fi +} +require_vars SERVER_IP SSH_USER DOMAIN_ROOT +if [ -n "${SSH_KEY:-}" ]; then + [ -f "$SSH_KEY" ] || { echo "ERROR: SSH_KEY not found: $SSH_KEY" >&2; exit 1; } + chmod 600 "$SSH_KEY" 2>/dev/null || true +fi + +SSH_OPTS=(-o StrictHostKeyChecking=accept-new -o ConnectTimeout=15) +[ -n "${SSH_KEY:-}" ] && SSH_OPTS+=(-i "$SSH_KEY") + +# Non-root login -> escalate on the box via sudo. Redirects/pipes must run as root too, +# so wrap the whole command string in `sudo bash -c`. +_ROOT_LOGIN=1; [ "$SSH_USER" != "root" ] && _ROOT_LOGIN=0 + +# run — execute on the VPS (as root, escalating if needed) +run() { + if [ "$_ROOT_LOGIN" = 1 ]; then + ssh "${SSH_OPTS[@]}" "$SSH_USER@$SERVER_IP" "$@" + else + ssh "${SSH_OPTS[@]}" "$SSH_USER@$SERVER_IP" "sudo bash -c $(printf '%q' "$*")" + fi +} + +# push — copy to the box (stages through /tmp + sudo install if non-root) +push() { + if [ "$_ROOT_LOGIN" = 1 ]; then + scp "${SSH_OPTS[@]}" "$1" "$SSH_USER@$SERVER_IP:$2" + else + local stage="/tmp/.stage.$$.$(basename "$2")" + scp "${SSH_OPTS[@]}" "$1" "$SSH_USER@$SERVER_IP:$stage" + run "install -D -m 644 '$stage' '$2'; rm -f '$stage'" + fi +} + +gen_pass() { python3 -c "import secrets,string;print(''.join(secrets.choice(string.ascii_letters+string.digits) for _ in range($1)))"; } + +# panel_port — read the aaPanel port the installer actually assigned (do not trust config guesses) +panel_port() { run 'cat /www/server/panel/data/port.pl 2>/dev/null | tr -d "[:space:]"'; } + +# box_arch — normalized arch token: amd64 | arm64 +box_arch() { run 'case "$(uname -m)" in x86_64) echo amd64;; aarch64|arm64) echo arm64;; *) uname -m;; esac'; } diff --git a/.claude/skills/edge-node-multi-protocol/scripts/config.env.example b/.claude/skills/edge-node-multi-protocol/scripts/config.env.example new file mode 100644 index 0000000..2d90c14 --- /dev/null +++ b/.claude/skills/edge-node-multi-protocol/scripts/config.env.example @@ -0,0 +1,120 @@ +# Copy to config.env and fill in. config.env is YOURS — keep it out of git. +# Nothing here ships with real values; every field is a placeholder. + +# --- target VPS --- +SERVER_IP="203.0.113.10" # public IPv4 +SERVER_IPV6="" # fleet is IPv4-only; do not create AAAA +SSH_USER="root" # root, or a sudo user (scripts escalate via sudo) +SSH_KEY="$HOME/.ssh/your-vps-key.pem" # private key for the VPS +TZ="UTC" # server timezone (e.g. UTC, Asia/Shanghai, America/Los_Angeles) + +# --- domain --- +PARENT_ZONE="example.com" # your DNS zone +DOMAIN_ROOT="nodeA.example.com" # this node owns *.nodeA.example.com + +# --- DNS provider --- +# Cloudflare is the default & has a turnkey record-creation script (02-cf-dns.py). +# Any acme.sh-supported DNS provider works for the wildcard cert — set ACME_DNS_PLUGIN and +# the matching credential vars below. For A/AAAA records on a non-CF provider, create them +# yourself (see references/dns-records.md — two names with A records, DNS-only). +DNS_PROVIDER="cloudflare" # informational: cloudflare | dnspod | aliyun | route53 | other +ACME_DNS_PLUGIN="dns_cf" # acme.sh plugin: dns_cf | dns_dp | dns_ali | dns_aws | dns_gd | ... + +# Cloudflare (default). Token needs Zone:DNS:Edit. NEVER commit. +CF_API_TOKEN="cf-token-with-Zone-DNS-Edit" +CF_ZONE_ID="" # optional; 02-cf-dns.py looks it up if empty + +# Other providers — uncomment the pair your ACME_DNS_PLUGIN needs (acme.sh reads these env names): +# DP_Id=""; DP_Key="" # dns_dp (DNSPod) +# Ali_Key=""; Ali_Secret="" # dns_ali (Aliyun) +# AWS_ACCESS_KEY_ID=""; AWS_SECRET_ACCESS_KEY="" # dns_aws (Route53) +# GD_Key=""; GD_Secret="" # dns_gd (GoDaddy) + +# --- subdomain labels (CHOOSE YOUR OWN, NON-OBVIOUS ONES) --- +# Your panels sit at .. If your zone is public, do NOT reuse an +# obvious convention (a label that names the service it fronts) — anyone can then +# enumerate your control planes from DNS. Pick random-ish labels and keep them to +# yourself. These examples are neutral placeholders, deliberately NOT a real scheme. +SUB_PANEL="console" # aaPanel -> console. +SUB_XUI="gateway" # 3x-ui panel -> gateway. +SUB_COVER="edge" # node cover -> edge. +# landing page uses the bare . + +# --- aaPanel --- +AAPANEL_USER="admin_you" +AAPANEL_ENTRANCE="/change-this-entrance" # secret path, e.g. /8f3a1c +AAPANEL_PORT="" # leave EMPTY — the installer assigns a random port; + # scripts read it from the box (/www/server/panel/data/port.pl). + # Only set this to force a specific value. +AAPANEL_PASS="" # generate at runtime; store in password manager + +# --- 3x-ui --- +XUI_USER="panel_you" +XUI_PATH="/change-this-path/" # webBasePath, trailing slash +XUI_PORT="14533" # panel listen 127.0.0.1:XUI_PORT +XUI_PASS="" # generate at runtime; store in password manager +WARP_SOCKS_PORT="40000" # cloudflare-warp proxy SOCKS5 port + +# --- three protocols x two egress paths = six inbounds, ONE client --- +# All six inbounds (vmess/trojan/shadowsocks x direct/warp) are owned by a single +# 3x-ui client "node-main", so the panel shows one client whose Info dialog lists +# all six share links + one subscription. That one client carries BOTH credentials: +# - XUI_UUID -> authenticates the two VMess inbounds +# - NODE_PASSWORD -> authenticates the two Trojan AND two Shadowsocks inbounds +# (trojan+ss share this one password by design — it's one client row; that's +# what makes the single-client panel view work. See references/3x-ui-schema.md.) +# Ports are internal (127.0.0.1, behind nginx) — only 443 is ever exposed publicly. +# Paths are the WS cover paths: CHOOSE YOUR OWN, NON-OBVIOUS ONES (don't reuse a +# convention, don't publish them — on a public zone they map your tunnels). These +# examples are neutral placeholders, deliberately NOT a real scheme. + +# Shared client credentials (node-main) +XUI_UUID="" # generate at runtime (uuidgen); VMess auth +NODE_PASSWORD="" # generate at runtime; Trojan + Shadowsocks auth; store in password manager +XUI_SUBID="" # generate at runtime; subscription id +SS_METHOD="chacha20-poly1305" # non-2022 cipher: plain-string password, no exact-length base64 PSK to misconfigure + +# VMess +PORT_VMESS_DIRECT="57707" +PORT_VMESS_WARP="57708" +WS_PATH_VMESS_DIRECT="/assets" +WS_PATH_VMESS_WARP="/media" + +# Trojan +PORT_TROJAN_DIRECT="57709" +PORT_TROJAN_WARP="57710" +WS_PATH_TROJAN_DIRECT="/static" +WS_PATH_TROJAN_WARP="/uploads" + +# Shadowsocks +PORT_SS_DIRECT="57711" +PORT_SS_WARP="57712" +WS_PATH_SS_DIRECT="/files" +WS_PATH_SS_WARP="/download" + +# --- monitoring (optional; leave empty to skip) --- +NEZHA_SERVER="" # e.g. report.example.com:8966 +NEZHA_TLS="false" +NEZHA_SECRET="" # shared agent key for your Nezha panel +NEZHA_UUID="" # generate at runtime +KOMARI_ENDPOINT="" # e.g. https://komari.example.com +KOMARI_DISCOVERY_KEY="" # auto-discovery key for your Komari panel + +# --- node display name (monitoring / labels) --- +NODE_NAME="Edge Node A" + +# --- optional QUIC plane (sing-box; blank ports skip that inbound) --- +PORT_HY2_DIRECT="" +PORT_HY2_WARP="" +HY2_OBFS_PASSWORD="" # generate privately for salamander links +HY2_UP_MBPS="500" # server-side bandwidth; match the actual link +HY2_DOWN_MBPS="500" +PORT_TUIC_DIRECT="" +PORT_TUIC_WARP="" +TUIC_CC="bbr" + +# --- optional internal SOCKS5 (16-add-socks5.py runs ON the VPS via env) --- +SOCKS_PORT="" +SOCKS_USER="" +SOCKS_PASS="" +LISTEN="127.0.0.1" # SSH-tunnel-only example diff --git a/.claude/skills/edge-node-multi-protocol/scripts/warp-healthcheck.service b/.claude/skills/edge-node-multi-protocol/scripts/warp-healthcheck.service new file mode 100644 index 0000000..636df5b --- /dev/null +++ b/.claude/skills/edge-node-multi-protocol/scripts/warp-healthcheck.service @@ -0,0 +1,8 @@ +[Unit] +Description=Check Cloudflare WARP proxy health and recover if needed +After=network-online.target warp-svc.service +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/warp-healthcheck.sh diff --git a/.claude/skills/edge-node-multi-protocol/scripts/warp-healthcheck.sh b/.claude/skills/edge-node-multi-protocol/scripts/warp-healthcheck.sh new file mode 100755 index 0000000..0c7e8db --- /dev/null +++ b/.claude/skills/edge-node-multi-protocol/scripts/warp-healthcheck.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# WARP proxy watchdog: WARP can show "Connected" while the SOCKS proxy stops egressing. +# Checks warp=on through the SOCKS proxy; restarts warp-svc + reconnects if not. +set -euo pipefail +PROXY_ADDR="${PROXY_ADDR:-socks5h://127.0.0.1:40000}" +TRACE_URL="${TRACE_URL:-https://www.cloudflare.com/cdn-cgi/trace}" +TIMEOUT="${CURL_TIMEOUT:-15}" + +log() { logger -t warp-healthcheck "$*"; echo "warp-healthcheck: $*"; } + +if ! ss -lnt | grep -q '127.0.0.1:40000'; then + log "SOCKS listener missing; restarting warp-svc" +elif trace="$(curl -k -sS --max-time "$TIMEOUT" --proxy "$PROXY_ADDR" "$TRACE_URL" 2>/dev/null)" && grep -q 'warp=on' <<<"$trace"; then + exit 0 # healthy +else + log "warp not on via proxy; recovering" +fi + +systemctl restart warp-svc 2>/dev/null || true +sleep 8 +warp-cli --accept-tos connect 2>/dev/null || true diff --git a/.claude/skills/edge-node-multi-protocol/scripts/warp-healthcheck.timer b/.claude/skills/edge-node-multi-protocol/scripts/warp-healthcheck.timer new file mode 100644 index 0000000..7a4cd8c --- /dev/null +++ b/.claude/skills/edge-node-multi-protocol/scripts/warp-healthcheck.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Run Cloudflare WARP health check every minute + +[Timer] +OnBootSec=90s +OnUnitActiveSec=60s +AccuracySec=15s +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/.claude/skills/edge-node-multi-protocol/templates/01-handover-template.md b/.claude/skills/edge-node-multi-protocol/templates/01-handover-template.md new file mode 100644 index 0000000..01b61aa --- /dev/null +++ b/.claude/skills/edge-node-multi-protocol/templates/01-handover-template.md @@ -0,0 +1,50 @@ +# {{HOST_LABEL}} 边缘节点 · 交付文档 + +服务器:`{{SSH_USER}}@{{SERVER_IP}}`(SSH 别名 `{{SSH_ALIAS}}`),{{VPS_PROVIDER_AND_SPECS}} +IPv6:`{{SERVER_IPV6}}` +根域名:`{{DOMAIN_ROOT}}`(Cloudflare 管理,泛域名 DNS-only / 灰云) +部署时间:{{DEPLOY_DATE}} + +--- + +## 服务清单 + +| 服务 | 作用 | 访问地址 | 用户名 | 密码 | +|---|---|---|---|---| +| **aaPanel** | 服务器控制面板:文件管理、防火墙、监控、站点管理 | `https://{{SUB_PANEL}}.{{DOMAIN_ROOT}}{{AAPANEL_ENTRANCE}}` | `{{AAPANEL_USER}}` | `{{AAPANEL_PASS}}` | +| **3x-ui** | Xray 代理面板:管理 inbound、客户端、流量统计 | `https://{{SUB_XUI}}.{{DOMAIN_ROOT}}{{XUI_PATH}}` | `{{XUI_USER}}` | `{{XUI_PASS}}` | +| **节点伪装页** | 承载六条 WS 隧道的域名,本身只是普通网页伪装 | `https://{{SUB_COVER}}.{{DOMAIN_ROOT}}` | — | — | +| **落地页** | 根域名首页,静态页面,无实际功能 | `https://{{DOMAIN_ROOT}}` | — | — | + +> ⚠️ **访问 aaPanel 面板务必使用真实浏览器**(Chrome/Safari/Edge 等)。用 curl 等工具测试时如果 UA 不像真实浏览器(比如只写默认 UA 或过于简单的 `Mozilla/5.0 (Macintosh)`),会被反爬虫机制拒绝,返回伪装的 404 页面——这是正常防护行为,不是故障。见 `references/pitfalls.md`。 + +## 后台服务(无独立管理界面,通过 aaPanel/3x-ui 或 SSH 管理) + +| 服务 | 作用 | +|---|---| +| **nginx 1.24** | 反向代理入口,承载所有 HTTPS 域名、TLS 终止、伪装 WS 转发 | +| **Cloudflare WARP(proxy 模式)** | 本机 SOCKS5 代理 `127.0.0.1:{{WARP_SOCKS_PORT}}`,给"WARP 出口"的 inbound 做净化出口 | +| **warp-healthcheck.timer** | systemd 定时器,每 60 秒检查 WARP 代理是否真的在转发流量,异常时自动重启 warp-svc | +| **acme.sh** | 自动续期泛域名证书 `*.{{DOMAIN_ROOT}}`,续期后自动重新部署证书并 reload nginx | +| **UFW 防火墙** | 只放行 22 / 80 / 443 / 888 / {{AAPANEL_PORT}}(面板端口)/ 39000-40000(aaPanel 被动端口范围) | + +## 六条代理 inbound(三种协议 × direct/WARP) + +| 协议 | 用途 | 域名 | WS 路径 | 内部端口 | 凭据 | +|---|---|---|---|---|---| +| VMess · direct | 直连出口 | `{{SUB_COVER}}.{{DOMAIN_ROOT}}` | `{{WS_PATH_VMESS_DIRECT}}` | {{PORT_VMESS_DIRECT}} | UUID `{{XUI_UUID}}` | +| VMess · WARP | 净出口 | `{{SUB_COVER}}.{{DOMAIN_ROOT}}` | `{{WS_PATH_VMESS_WARP}}` | {{PORT_VMESS_WARP}} | 同上 | +| Trojan · direct | 直连出口 | `{{SUB_COVER}}.{{DOMAIN_ROOT}}` | `{{WS_PATH_TROJAN_DIRECT}}` | {{PORT_TROJAN_DIRECT}} | 密码 `{{NODE_PASSWORD}}` | +| Trojan · WARP | 净出口 | `{{SUB_COVER}}.{{DOMAIN_ROOT}}` | `{{WS_PATH_TROJAN_WARP}}` | {{PORT_TROJAN_WARP}} | 同上 | +| Shadowsocks · direct | 直连出口 | `{{SUB_COVER}}.{{DOMAIN_ROOT}}` | `{{WS_PATH_SS_DIRECT}}` | {{PORT_SS_DIRECT}} | 密码 `{{NODE_PASSWORD}}`,method `{{SS_METHOD}}` | +| Shadowsocks · WARP | 净出口 | `{{SUB_COVER}}.{{DOMAIN_ROOT}}` | `{{WS_PATH_SS_WARP}}` | {{PORT_SS_WARP}} | 同上 | + +六个 inbound 全部归属于**同一个 3x-ui 客户端 `node-main`**:VMess 用 UUID 认证,Trojan 和 Shadowsocks 共用同一个 `NODE_PASSWORD` 认证(因为是同一个客户端行)。所以在 3x-ui"客户端"页只看到 node-main 一行,点开它的信息详情就能看到全部 6 条节点链接/二维码和一个订阅。三个协议共用同一个 `{{SUB_COVER}}.{{DOMAIN_ROOT}}` 站点和泛域名证书,没有新建站点/新申请证书。新增/管理 inbound 是直接写 3x-ui 数据库(`/etc/x-ui/x-ui.db`)+ 重启 x-ui 的方式(面板 HTTP API 在这个版本上会 403,参见 `references/pitfalls.md`)。 + +节点二维码 / 订阅链接见《02-节点信息》(`scripts/14-generate-links-and-qr.py` 生成)。 + +## 安全提醒 + +- `SUB_PANEL={{SUB_PANEL}}` / `SUB_XUI={{SUB_XUI}}` / `SUB_COVER={{SUB_COVER}}` 这三个子域名前缀、六条 WS 伪装路径、两个面板入口路径,都不要公开发布——它们是控制面的地图。 +- 所有密码、UUID 已生成并写在上表和 `config.env` 里,`config.env` 只保留在你的本机,没有上传到任何 git 仓库。建议尽快把密码转存进密码管理器。 +- WARP 模式是你自己的干净出口,不是绕过他人网络管控的工具。 diff --git a/.claude/skills/edge-node-multi-protocol/templates/02-node-info-template.md b/.claude/skills/edge-node-multi-protocol/templates/02-node-info-template.md new file mode 100644 index 0000000..ccd056a --- /dev/null +++ b/.claude/skills/edge-node-multi-protocol/templates/02-node-info-template.md @@ -0,0 +1,108 @@ +# {{HOST_LABEL}} 节点信息 · 客户端导入 + +用支持对应协议的客户端(v2rayN / v2rayNG / Shadowrocket / Nekobox / sing-box / Surge / Clash Meta 等)扫码或粘贴链接即可导入。运行 `scripts/14-generate-links-and-qr.py` 会自动生成本文档所需的全部 `.link` 文件和 `-qr.png` 二维码图片到同一个输出目录 —— 把对应文件内容/图片贴进下面每一节即可,不需要手抄。 + +六个节点使用同一个域名 `{{SUB_COVER}}.{{DOMAIN_ROOT}}`,同一个协议内的 direct/WARP 只有 WS 路径不同。 + +--- + +## 线路一:VMess 直连出口 + +出站走服务器自身公网 IP(`{{SERVER_IP}}`),速度最快,但暴露服务器真实 IP。 + +![VMess direct 节点二维码](vmess-direct-qr.png) + +``` +{{VMESS_DIRECT_LINK}} +``` + +## 线路二:VMess WARP 净出口 + +出站走 Cloudflare WARP,隐藏服务器真实 IP,出口 IP 是 Cloudflare 的地址,速度略慢于直连。 + +![VMess WARP 节点二维码](vmess-warp-qr.png) + +``` +{{VMESS_WARP_LINK}} +``` + +## 线路三:Trojan 直连出口 + +![Trojan direct 节点二维码](trojan-direct-qr.png) + +``` +{{TROJAN_DIRECT_LINK}} +``` + +## 线路四:Trojan WARP 净出口 + +![Trojan WARP 节点二维码](trojan-warp-qr.png) + +``` +{{TROJAN_WARP_LINK}} +``` + +## 线路五:Shadowsocks 直连出口 + +生成器会输出带 Xray 传输参数的 `ss://` 链接与二维码,Proxy Station 可解析导入。其他客户端若不接受该写法,可用下面的参数或 sing-box JSON 片段。Surge 不支持 SS over WS,这两条线路不会下发到 Surge。 + +| 参数 | 值 | +|---|---| +| 地址 | `{{SUB_COVER}}.{{DOMAIN_ROOT}}` | +| 端口 | `443` | +| 加密方式 (method) | `{{SS_METHOD}}` | +| 密码 | `{{NODE_PASSWORD}}`(与 Trojan 共用同一密码) | +| 传输 | `ws`,路径 `{{WS_PATH_SS_DIRECT}}` | +| TLS / SNI | 开启,`{{SUB_COVER}}.{{DOMAIN_ROOT}}` | + +sing-box outbound(`ss-direct-singbox.json`,由生成脚本自动写出): +```json +{{SS_DIRECT_SINGBOX_JSON}} +``` + +Surge 用户使用其他受支持线路;不能把 SS over WS 写成 Surge 的 `obfs=wss`。 + +## 线路六:Shadowsocks WARP 净出口 + +参数和线路五完全一样,公开端口仍为 443,只有出口与 WS 路径不同(`{{WS_PATH_SS_WARP}}`)。 + +sing-box outbound(`ss-warp-singbox.json`): +```json +{{SS_WARP_SINGBOX_JSON}} +``` + +--- + +## 手动填写参数 · VMess(如果客户端不支持扫码/粘贴链接) + +| 参数 | direct | WARP | +|---|---|---| +| 地址 (address) | `{{SUB_COVER}}.{{DOMAIN_ROOT}}` | 同左 | +| 端口 (port) | `443` | 同左 | +| 用户ID (UUID) | `{{XUI_UUID}}` | 同左 | +| 额外ID (AlterId) | `0` | 同左 | +| 加密方式 (security) | `auto` | 同左 | +| 传输协议 (network) | `ws` | 同左 | +| 伪装类型 (type) | `none` | 同左 | +| 路径 (path) | `{{WS_PATH_VMESS_DIRECT}}` | `{{WS_PATH_VMESS_WARP}}` | +| Host / SNI | `{{SUB_COVER}}.{{DOMAIN_ROOT}}` | 同左 | +| TLS | 开启 | 开启 | + +## 手动填写参数 · Trojan + +| 参数 | direct | WARP | +|---|---|---| +| 地址 / 端口 | `{{SUB_COVER}}.{{DOMAIN_ROOT}}` / `443` | 同左 | +| 密码 | `{{NODE_PASSWORD}}`(与 Shadowsocks 共用同一密码) | 同左 | +| 传输协议 (network) | `ws` | 同左 | +| 路径 (path) | `{{WS_PATH_TROJAN_DIRECT}}` | `{{WS_PATH_TROJAN_WARP}}` | +| Host / SNI | `{{SUB_COVER}}.{{DOMAIN_ROOT}}` | 同左 | +| TLS | 开启 | 开启 | + +## 验证方式 + +导入后连接,访问 `https://1.1.1.1/cdn-cgi/trace`,看返回的 `ip=` 和 `warp=`: +- direct 线路(任意协议)应显示 `ip={{SERVER_IP}}`,`warp=off` +- WARP 线路(任意协议)应显示一个 Cloudflare 段的 IP,`warp=on` + +服务器侧的 `scripts/12-smoke-test.sh` 已经对全部六条线路做过 WS-101 + 出口 IP 的实测验证;如果你这边导入后验证结果不对,大概率是客户端参数抄错了(尤其是 path 和 host 要跟表里完全一致,包括大小写),不是服务器端的问题。 diff --git a/.claude/skills/edge-node-multi-protocol/templates/03-ops-guide-template.md b/.claude/skills/edge-node-multi-protocol/templates/03-ops-guide-template.md new file mode 100644 index 0000000..3f42e4f --- /dev/null +++ b/.claude/skills/edge-node-multi-protocol/templates/03-ops-guide-template.md @@ -0,0 +1,71 @@ +# {{HOST_LABEL}} 运维指南(edge-node-multi-protocol) + +本指南基于 `edge-node-multi-protocol` skill 部署的真实节点整理,适用于维护这台节点或用同一个 skill 部署新节点。 + +--- + +## 一、这套系统是什么 + +一键把一台裸的 Debian/Ubuntu VPS 变成: + +``` +用户 ──HTTPS──▶ nginx(aaPanel管理) ──┬─▶ aaPanel 面板(反代到 127.0.0.1:面板端口) + ├─▶ 3x-ui 面板(反代到 127.0.0.1:14533) + └─▶ 节点伪装域名 {{SUB_COVER}}.{{DOMAIN_ROOT}} + ├─ VMess direct/WARP (2 inbound) + ├─ Trojan direct/WARP (2 inbound) + └─ Shadowsocks direct/WARP (2 inbound) + 每个 direct 直接出公网 + 每个 WARP 走 WARP SOCKS5(127.0.0.1:{{WARP_SOCKS_PORT}}) 出 Cloudflare +``` + +外层是泛域名 TLS(acme.sh 自动续期)+ Cloudflare DNS(灰云,不走 CF 代理),三个协议的六个 inbound 共用同一份证书和同一个 cover 域名。 + +## 二、部署一台新节点的标准流程 + +见 `SKILL.md` → Workflow,简要版: + +``` +00-preflight → 02-cf-dns → 03-sign-wildcard-cert +→ 04-install-aapanel → 05-harden-aapanel → 04b-install-nginx +→ 06-install-warp → 06b-warp-watchdog +→ 07-install-3xui → 08-xui-bootstrap(一次性建好全部6个inbound) +→ 09-aapanel-sites → 09b-default-444 +→ 10-ufw → 11-monitoring(可选) +→ 12-smoke-test(验证全部6条线路) +→ 14-generate-links-and-qr(本地运行,出分享链接+二维码) +``` + +## 三、这套 skill 已修复的版本兼容坑 + +来自实战踩坑,脚本里已经改好,这里记录原因方便你判断"要不要重新核实": + +### 坑 1:aaPanel 安装等待循环的 pgrep 自匹配 bug + +早期版本用 `pgrep -f install_7.0` 判断安装进程是否还在跑,但这条检查命令自己的命令行文本里就含有 `"install_7.0"` 字符串,导致 pgrep 把自己也匹配进去,判断条件永远为假,脚本卡死轮询——即使安装其实早就装完了(通常 1 分钟左右)。**已修复**:现在改成检查安装日志里的 `Congratulations! Installed successfully!` 字符串。 + +### 坑 2:aaPanel 7.x 把部分内部模块挪到了 `class_v2` 目录 + +建站脚本原本只把 `/www/server/panel` 和 `/www/server/panel/class` 加进 `sys.path`,缺了 `/www/server/panel/class_v2`,导致 `ModuleNotFoundError: theme_config`。**已修复**:`09-aapanel-sites.py` 已经加上了这一行。如果你手写新脚本要 import 面板内部模块,记得也加上。 + +### 坑 3:aaPanel 反爬虫策略比早期文档写的更严格 + +用 curl 测试面板入口时,UA 必须是完整真实的浏览器 UA(如 `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/605.1.15`),过于简单的 UA(哪怕带 "Mozilla" 字样)会被判定为扫描器,返回伪装的 404。**排查经验**:如果面板域名/入口路径怎么测都是 404,**先别怀疑域名绑定、CDN_PROXY、证书配置**——先换一个更真实的浏览器 UA 字符串重新 curl,或者直接用真实浏览器打开看看。`12-smoke-test.sh` 已经用了正确的 UA。 + +### 坑 4:3x-ui 的 client 表是"协议通用凭据表" + +VMess/VLESS 用 `uuid` 列,Trojan/Shadowsocks 用 `password` 列;`client_traffics.email` 全局唯一(一个 client 一行,不是一个 inbound 一行);WARP 路由规则要"追加"而不是"整个重写",否则会把已有协议的路由覆盖掉。完整表结构和新增协议的步骤见 `references/3x-ui-schema.md`。 + +## 四、日常运维 + +- **重启 aaPanel**:只能用 `/etc/init.d/bt restart`(或 `bt 1`),**绝对不能用** `systemctl restart btpanel`——systemd 单元的 `ExecStart` 走的是这条 SysV 脚本,`systemctl restart` 会把面板停掉且不会自动拉起,反代的面板域名直接 502。 +- **重启 nginx**:同理用 `/etc/init.d/nginx reload/restart`,不要用 systemctl。 +- **续期证书**:acme.sh 已经注册了 `--reloadcmd`,到期自动续期、自动重新部署到 4 个域名的证书目录、自动 reload nginx,不需要人工干预。可以用 `~/.acme.sh/acme.sh --list` 查看到期时间。 +- **加新的代理客户端**(同一协议下加一个用户):去 3x-ui 面板对已有 inbound 加 client,或者直接改数据库(面板 HTTP API 在这个版本上会 403,见坑4),参考 `references/3x-ui-schema.md`。 +- **加新的协议**(第4个协议):见 `SKILL.md` → "Adding a protocol to an already-provisioned node",以及 `references/3x-ui-schema.md` 最后一节和 `references/protocol-choice.md` 关于什么时候值得加。 +- **WARP 掉线自愈**:`warp-healthcheck.timer` 每 60 秒检查一次,通过 SOCKS5 代理实际发请求确认 `warp=on`,异常会自动重启 `warp-svc`。查看状态:`systemctl status warp-healthcheck.timer`。 +- **防火墙**:只放行了 22/80/443/888/面板端口/39000-40000,六个协议 inbound 全部是 `127.0.0.1`-only、走 nginx 反代 443,不需要额外开端口。 + +## 五、监控(可选) + +`11-monitoring.sh` 支持接入 Nezha / Komari。去 `config.env` 填 `NEZHA_SERVER`/`NEZHA_SECRET` 或 `KOMARI_ENDPOINT`/`KOMARI_DISCOVERY_KEY`,单独跑这一个脚本即可,不影响其他服务。 diff --git a/.claude/skills/edge-node-multi-protocol/templates/README.md b/.claude/skills/edge-node-multi-protocol/templates/README.md new file mode 100644 index 0000000..a72c015 --- /dev/null +++ b/.claude/skills/edge-node-multi-protocol/templates/README.md @@ -0,0 +1,50 @@ +# Documentation templates + +Three markdown templates to fill in with a real node's values after deployment, plus +`landing.html` (the public landing-page cover, used by `09b-default-444.sh`). + +- `01-handover-template.md` — service inventory: what's running, how to log in, what each thing does. +- `02-node-info-template.md` — client-facing: share links + QR codes for all 6 nodes, manual params as fallback. +- `03-ops-guide-template.md` — architecture + standard workflow + the version-compat pitfalls + day-2 ops. + +## How to fill them in + +1. Run `scripts/14-generate-links-and-qr.py` first — it writes `vmess-direct.link`, + `vmess-warp.link`, `trojan-direct.link`, `trojan-warp.link`, the matching `-qr.png` files, and + `ss-direct.link` / `ss-warp.link` with their QR codes plus `ss-direct-singbox.json` / + `ss-warp-singbox.json` into an output directory. Put the doc copies + in the same directory so the `![...](foo-qr.png)` image references in + `02-node-info-template.md` resolve. +2. Copy the three `*-template.md` files into that same output directory (drop the `-template` + suffix if you like, e.g. `01-handover.md`), and replace every `{{TOKEN}}` below with the real + value from `config.env` (or the node's live state, for things like `AAPANEL_PORT` which the + installer randomizes — read it from `/www/server/panel/data/port.pl` on the box). +3. For `{{VMESS_DIRECT_LINK}}` / `{{VMESS_WARP_LINK}}` / `{{TROJAN_DIRECT_LINK}}` / + `{{TROJAN_WARP_LINK}}`, paste the contents of the matching `.link` file. For + `{{SS_DIRECT_SINGBOX_JSON}}` / `{{SS_WARP_SINGBOX_JSON}}`, paste the matching `.json` file's + contents inside the fenced code block that's already there. + +## Token reference + +| Token | Source | +|---|---| +| `{{HOST_LABEL}}` | Whatever short name you use for this node (e.g. an SSH alias or hostname) | +| `{{SSH_ALIAS}}` / `{{SSH_USER}}` | Your local SSH config / `config.env` `SSH_USER` | +| `{{SERVER_IP}}` / `{{SERVER_IPV6}}` | `config.env` | +| `{{VPS_PROVIDER_AND_SPECS}}` | Whatever's useful to note (provider, region, vCPU/RAM) | +| `{{DEPLOY_DATE}}` | The date you ran through the workflow | +| `{{DOMAIN_ROOT}}` / `{{SUB_PANEL}}` / `{{SUB_XUI}}` / `{{SUB_COVER}}` | `config.env` | +| `{{AAPANEL_ENTRANCE}}` / `{{AAPANEL_USER}}` / `{{AAPANEL_PASS}}` / `{{AAPANEL_PORT}}` | `config.env` + live panel port from the box | +| `{{XUI_PATH}}` / `{{XUI_USER}}` / `{{XUI_PASS}}` | `config.env` | +| `{{XUI_UUID}}` | `config.env` (node-main's VMess credential) | +| `{{NODE_PASSWORD}}` | `config.env` (node-main's shared Trojan+Shadowsocks credential) | +| `{{SS_METHOD}}` | `config.env` | +| `{{PORT_VMESS_DIRECT}}` / `{{PORT_VMESS_WARP}}` / `{{PORT_TROJAN_DIRECT}}` / `{{PORT_TROJAN_WARP}}` / `{{PORT_SS_DIRECT}}` / `{{PORT_SS_WARP}}` | `config.env` | +| `{{WS_PATH_VMESS_DIRECT}}` / `{{WS_PATH_VMESS_WARP}}` / `{{WS_PATH_TROJAN_DIRECT}}` / `{{WS_PATH_TROJAN_WARP}}` / `{{WS_PATH_SS_DIRECT}}` / `{{WS_PATH_SS_WARP}}` | `config.env` | +| `{{WARP_SOCKS_PORT}}` | `config.env` (default 40000) | +| `{{VMESS_DIRECT_LINK}}` / `{{VMESS_WARP_LINK}}` / `{{TROJAN_DIRECT_LINK}}` / `{{TROJAN_WARP_LINK}}` | contents of the matching `.link` file from step 9 | +| `{{SS_DIRECT_SINGBOX_JSON}}` / `{{SS_WARP_SINGBOX_JSON}}` | contents of the matching `.json` file from step 9 | + +Never fill these in with placeholder/example values in a copy that leaves your machine — every +token above except `{{HOST_LABEL}}`/`{{DEPLOY_DATE}}`/`{{VPS_PROVIDER_AND_SPECS}}` is either a +secret or part of your control-plane map. diff --git a/.claude/skills/edge-node-multi-protocol/templates/landing.html b/.claude/skills/edge-node-multi-protocol/templates/landing.html new file mode 100644 index 0000000..8afcbae --- /dev/null +++ b/.claude/skills/edge-node-multi-protocol/templates/landing.html @@ -0,0 +1,19 @@ + + +__NODE_NAME__ + +
+

__NODE_NAME__

+

Edge service operational

+

Private research & networking endpoint. No public service is hosted on this page.

+

for authorized use only

+
diff --git a/.claude/skills/update-db/SKILL.md b/.claude/skills/update-db/SKILL.md index fc647fd..d5e94b6 100644 --- a/.claude/skills/update-db/SKILL.md +++ b/.claude/skills/update-db/SKILL.md @@ -1,12 +1,14 @@ --- name: update-db -description: 对 LA 生产机上的 proxy-station 数据做规范化变更——增删节点、导入分享链接、调整策略组与成员、规则、规则集引用、订阅 token、Surge 设置段落。凡是用户要动线上数据的(「线上加/删个节点」「把这个组改成 url-test」「调一下规则」「建个订阅 token」「删掉那个 token」「改下生产上的设置」),都用这个 Skill,即使没提到「数据库」三个字。它只改数据不碰代码:代码更新与部署归 deploy Skill。 +description: 经管理 API 查询或维护 LA 生产环境的节点、策略组、规则、订阅 token 与 Surge 设置,写入前在线备份。用于线上数据盘点、导入预览与数据变更;代码部署使用 deploy。 --- # 变更 proxy-station 生产数据 线上数据(节点、策略组、规则、token、设置)住在 la 机的 SQLite(`/opt/proxy-station/data/proxy-station.db`),由常驻的 `proxy-station` 服务直连。本 Skill 的做法是:**走服务自己的管理 API 做变更,变更前用 sqlite3 在线备份做快照**,不直接写库、不重启服务。 +本技能由 Codex 与 Claude Code 共用,命令在仓库根目录运行;`.agents/skills/update-db` 链接到本目录。先区分查询、导入预览与实际变更;纯查询/`commit:false` 预览无需备份,也不能借此执行写入。用户已明确授权的具体变更不重复确认,范围不清时先查询现状并明确目标。 + ## 为什么必须走 API,不能直写 SQLite API 不是简单转发,直写会绕过它的全部防护: @@ -23,6 +25,7 @@ API 不是简单转发,直写会绕过它的全部防护: ```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 PUT /api/settings --body-file /private/tmp/.json 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 @@ -30,6 +33,7 @@ bash .claude/skills/update-db/scripts/api.sh health ``` - `api` 经 ssh 在**远端本机回环**调接口:`ADMIN_TOKEN` 只在远端 `/etc/proxy-station/env` 里展开,不经本地 shell、不落对话记录;JSON 走 stdin,无需转义 +- 含凭据、私有 Host 或复杂 JSON 时使用权限 600 的临时文件及 `--body-file`,不把正文放到 shell 字符串或进程参数;响应也保存到私有文件并只汇报脱敏摘要 - 响应末尾带 `__HTTP_STATUS:200__` 一行。**非 2xx 时先读响应体里的错误信息再决定下一步**,不要盲目重试;要管道给 jq 时先剥掉这行:`| sed '$d' | jq …` - 连不上 la(解析出 198.18.x.x)是本地 fake-IP 劫持了 ssh,与远端无关,等网络恢复再试 - 环境变量可覆盖 `DEPLOY_HOST`/`PS_DATA_DIR`/`PS_ENV_FILE`,默认即生产值 @@ -37,35 +41,40 @@ bash .claude/skills/update-db/scripts/api.sh health ## 标准流程 1. **摸现状**(只读):`api GET /api/groups`、`/api/rules`、`/api/nodes`、`/api/tokens`,把要动的对象当前值拿到手。改组成员、调规则顺序这类「全量替换」操作,不先拿全量必出错。 + 涉及下发行为先读 [Surge 变更验证](../../../docs/SURGE-CHANGES.md)。检查业务例外与广告/服务集合的顺序、Host 的 DNS 例外、FINAL 和客户端节点范围;新防护依赖代码修复时先完成并验证代码部署。 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 <文件>` 是最有说服力的端到端验证 + - 涉及节点/组/规则/token 范围/Surge 设置:从实际 HTTPS 入口拉 Surge 订阅到私有临时文件,核对 self URL,运行 `python3 scripts/check-surge.py <文件> --fetch-rulesets`;审阅遮蔽提示。回环 API 拉取的内网 self URL 不能替代 HTTPS 验收,CLI 不等于节点连接或泄漏测试 + - 影响 Clash 时拉其产物并在独立临时目录运行 `mihomo -t -d <临时目录> -f <文件>`;不启动代理或覆盖活动配置 - `excluded`/`skippedRules` 只存在于生成器内部、不在 HTTP 响应里;要核对静默排除时,用 `/api/nodes` 列表对比 yaml 里的 `proxies` 5. **汇报**:向用户复述三件事——变更清单、备份文件名、校验结果。三者齐了才算完成。 ## 各实体的操作要点 ### 节点 `/api/nodes` -- 新节点来自分享链接:先 `POST /api/nodes/import` 带 `{"text":"…","commit":false}` 预览(返回每条的解析结果与自动去重后的名字),确认后再 `commit:true` 真正入库 +- 新节点来自分享链接:先 `POST /api/nodes/import` 带 `{"text":"…","commit":false}` 预览(返回每条的解析结果与自动去重后的名字);用户授权导入且结果符合目标时,备份后再 `commit:true` 入库。只要求预览时到预览为止 - 节点名是分流的关键:必须保留出口词尾缀(客户端里没有标签,用户靠名字区分直连/WARP 线路);地区组按名称正则收编,涉及新地区先读 `docs/ADDING-REGIONS.md` +- 名称不能含配置分隔符、引号、反斜线、控制字符或首尾空格,不能与组或内置策略重名;导入预览与 commit 使用同一字段验证。Surge 当前排除 SS over WS、非零 alterId 及不支持加密方式的 VMess,核对实际输出而非只看节点总数 - 临时下线用 `PATCH /:id/enabled {"enabled":false}`,不要删——IR 层会自动剔除停用节点,删了凭据就没了 -- 真要删除:先 GET 全量字段向用户展示将消失的内容,确认后 DELETE +- 真要删除:先 GET 全量字段核对对象,向用户展示名称及影响(不展示密码/UUID);删除范围已有明确授权时按流程 DELETE,否则先确认 ### 策略组 `/api/groups` - `members` 是**整体替换**:GET 全量 → 改数组 → PUT 回去,别只发改动项 - 成员三态:`{kind:"node", nodeId}`/`{kind:"group", refName}`/`{kind:"builtin", refName}` -- `filterRegex` 与 `includeAllNodes` 可共存,**filterRegex 优先**(动态筛选用它,新增同地区节点自动进组) +- `filterRegex` 与 `includeAllNodes` 可共存;Surge 保留显式节点/内置成员,全部成员组成为动态来源,正则只筛动态来源。新增同地区节点按名称进入组 +- 成员必须存在且类型正确,图无环;禁用组在 IR 中被剔除,不要新写指向停用组的规则 - 改名后端会级联引用,但已导入客户端的旧组名会失配——改名前先向用户点明 -- url-test/fallback 组别忘了 `testUrl`/`interval`/`tolerance` +- `testUrl` 用于 Clash/ShadowRocket;Surge 用 General 的 `proxy-test-url`,组级 `url=` 无效。fallback 不使用 tolerance,url-test 可显式设 0 ### 规则 `/api/rules` -- 顺序由 `PUT /api/rules/reorder` 的**全量 ids 数组**决定(GET 拿现有顺序 → 调整 → 全量提交);自定义规则置顶是既有约定 -- FINAL 规则永远保留;其策略组失效时 IR 层降级 DIRECT——兜底存在,但别依赖 +- 顺序由 `PUT /api/rules/reorder` 的**全量、无重复 ids 数组**决定,启用的唯一 FINAL 必须末尾。自定义创建置顶,但有明确顺序意图时按完整列表调整;业务例外在覆盖它的集合之前,特定 UDP 拦截在服务域名规则之前 +- FINAL 不可停用、删除、改类型或重复创建;失效策略由 IR 降级 DIRECT。不要借防御性兜底制造无效数据库状态 - 新规则的 policy 必须已存在(内置 DIRECT/REJECT… 或现有组名),后端会校验,报错就先建组 -- `POST /api/rules/reset` 会重置回模板初版并**清掉全部自定义规则**——除非用户明说「重置」,否则永远不调用 +- `POST /api/rules/reset` 事务内恢复模板并**保留自定义规则**;不是定向更新生产规则的方法,除非用户明确要求重置,否则不调用 +- 靠前 IP 集是否加 `no-resolve` 按意图决定;主动解析可能绕过后续业务规则,no-resolve 又会改变对未解析域名的覆盖。URL-REGEX 值只检查 HTTP URL,不能拿 SMTP 文字当邮件协议匹配 ### 规则集 `/api/rulesets`(只读) - `GET /api/rulesets` 看清单,`GET /:id/content` 看自托管转换结果 @@ -78,6 +87,8 @@ bash .claude/skills/update-db/scripts/api.sh health ### 设置 `/api/settings` - `surgeSections` 是 Surge 原文段落透传,改错直接毁掉 Surge 配置;动它之前先 GET 存档 + backup,改完拉 `/sub//surge` 全文核对 +- 保留手写 Host 的精确 DNS 例外,不将全局 DNS 或全部端口 53 送到 AI;设置变化也须实际 HTTPS 订阅与官方 CLI 验证 +- 共享订阅默认不启用控制接口,不使用公开模板口令;确需远程控制时使用设备独立凭据与访问限制。MITM 不关闭上游证书验证,启用效果另核对设备 CA 与开关 ## 回滚 @@ -87,5 +98,5 @@ bash .claude/skills/update-db/scripts/api.sh health ## 边界 - 不改代码、不做部署(那是 deploy Skill 的事);不动 nginx/证书/`ADMIN_TOKEN`/systemd 单元 -- 删除类操作与 reset:先列出将删除/重置的完整清单,用户确认后再执行 +- 删除类操作与 reset:先核对并说明对象清单与影响;未明确授权时先确认,已有明确授权且对象一致时直接按流程执行 - 节点凭据是真凭据:汇报里不要粘贴密码/UUID 全文,用节点名指代 diff --git a/.claude/skills/update-db/scripts/api.sh b/.claude/skills/update-db/scripts/api.sh index c2b4f7b..f562130 100755 --- a/.claude/skills/update-db/scripts/api.sh +++ b/.claude/skills/update-db/scripts/api.sh @@ -3,6 +3,7 @@ # # 用法: # api.sh api METHOD PATH [JSON] # 调管理接口;PATH 以 /api/ 或 /sub/ 开头;JSON 作第三参 +# api.sh api METHOD PATH --body-file FILE # 含凭据或复杂 JSON 优先用权限 600 的文件,避免放入 argv # api.sh backup # 变更前快照 → data/backups/before--<时间戳>.db # api.sh backups # 列出最近 20 个备份 # api.sh counts # 各表行数,变更前后对比用 @@ -33,12 +34,19 @@ cmd="$1"; shift case "$cmd" in api) [ $# -ge 2 ] || die "用法:api.sh api METHOD PATH [JSON]" - METHOD="$1"; APIPATH="$2"; BODY="${3:-}" + METHOD="$1"; APIPATH="$2"; BODY="${3:-}"; BODY_FILE="" + if [ "$BODY" = "--body-file" ]; then + BODY_FILE="${4:-}"; BODY="" + [ -n "$BODY_FILE" ] && [ -f "$BODY_FILE" ] || die "--body-file 需要可读的 JSON 文件" + fi 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 + case "$APIPATH" in *[!a-zA-Z0-9_./?=%\&~:+-]*) die "PATH 含不安全字符,请先做 URL 编码";; 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 + 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_FILE" ]; then + "${SSH[@]}" "$RCMD --data-binary @-" < "$BODY_FILE" + elif [ -n "$BODY" ]; then printf '%s' "$BODY" | "${SSH[@]}" "$RCMD --data-binary @-" else "${SSH[@]}" "$RCMD" diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d5f8243 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +.git/ +node_modules/ +**/node_modules/ +**/dist/ +data/ +**/data/ +*.db* +**/*.db* +.env +.env.* +**/.env +**/.env.* +*.log +.DS_Store +edge-nodes/ +.claude/ +.agents/ diff --git a/.gitignore b/.gitignore index d1b1f3f..b594955 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,12 @@ data/ .env.local .DS_Store *.log + +# Edge-node fleet: live configuration, delivery documents and offline subscriptions +/edge-nodes/private/ +config.env +config.env.* +!config.env.example +node-links/ +__pycache__/ +*.pyc diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..076cb08 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,215 @@ +# AGENTS.md + +本文件是 Codex 与 Claude Code 共用的项目开发约定。`CLAUDE.md` 用 `@AGENTS.md` 导入本文件;项目规则只在这里维护。双工具入口、技能发现和使用方法见 `docs/AI-COLLABORATION.md`。 + +## 协作方式 + +- 默认用中文沟通,代码标识符沿用项目现有英文命名。 +- 按任务读取相关代码与文档;以代码、测试及用户最新要求为准,发现说明过时就同步修正。 +- 本地阅读、编辑、类型检查、单测和构建按任务自主完成。部署或生产数据变更须属于用户授权的任务;已有明确授权不重复确认。普通代码修改不隐含上线或修改生产数据。 +- 保留用户已有改动。提交、推送与部署分别按用户要求执行。 +- 项目技能唯一源文件在 `.claude/skills/`;`.agents/skills/` 通过相对符号链接供 Codex 发现。修改技能时编辑源文件,避免维护两份副本。 + +## 这是什么 + +本仓库统一维护边缘 VPS 节点的部署管理与 Proxy Station 代理节点、分流规则管理平台。Web 界面管理多协议节点(VMess/Trojan/Shadowsocks/Hysteria2)与策略组、规则,向 macOS Surge、iOS ShadowRocket、Android ClashMeta 下发托管订阅。规则模板来自 [ClashConnectRules/Surge](https://github.com/ClashConnectRules/Surge),边缘舰队的工具与私有交付记录已从原 `../edge-nodes/` 迁入本项目。 + +### 当前生产约定(2026-10-04) + +最新盘点、完整组顺序与成员、节点启用范围和 Session 变更索引见 [当前生产状态](docs/CURRENT-STATE.md)。日期化体检/变更文档保留当时的数量与验证结果;维护前经管理 API 重读,不能用历史快照或初始化模板覆盖最新生产数据。 + +- 当前登记 30 个节点,12 个启用、18 个停用;12 个启用组、21 个显式成员、130 条规则(128 启用)。停用节点保留凭据,不因文档整理、排序、部署或导入清单核对而自动启用。应用的节点启用状态不等于边缘 VPS 的运行状态。 +- 普通代理流量交给 `🚀 Proxy`,只有它引用 `⚡️ Automatic`。Automatic 为 url-test,成员依次为新加坡、美国、日本;规则不直接指向 Automatic。`🤖 AI` 为 select,成员依次为美国、新加坡、日本、`🧠 AI Nodes`,不引用 Automatic。 +- `📋 All Nodes` 与 `🧠 AI Nodes` 都是独立 select 组,开启 includeAllNodes、没有筛选正则或显式成员;AI Nodes 紧接 AI 显示。两组收纳相同的订阅范围内可用节点,各自保存客户端选择,不能用引用 All Nodes 代替独立的 AI Nodes。 +- 两组按完整节点名称字符串升序输出,保留 Direct/Warp 出口词。现有节点 sortOrder 全为默认 0,IR 按 sortOrder、name 读取;新增/导入节点保持默认值。非零权重会先于名称排序,调整它影响节点声明及其他动态列表,不只是这两个组。Web 节点页仍按站点、协议与出口展示。 +- Streaming 当前承接 Disney、Netflix、TikTok、YouTube、GoogleSearch 及欧洲/韩国/香港/台湾流媒体集合;美国/日本流媒体集合直接交给对应地区组,Emby 交给 Proxy。组显示顺序不决定规则匹配顺序。 +- 规则优先级保持:STUN 与 googlevideo UDP 防护 → Unbreak DIRECT → 启用的 AI 规则 → 业务例外/广告与其余分流 → 唯一 FINAL。FINAL 仍为 `🎯 NoAuto,dns-failed`,NoAuto → Mainland → DIRECT。保留 Google 更新/翻译直连例外;全局 PROTOCOL DOH 与 Dia 关键词规则继续停用。 +- Apple/Global Services/Speedtest/Other 及无节点的香港/台湾/韩国/英国组已删除,不因模板仍存在而恢复。App Store DIRECT、Apple News 美国、Apple Relay AI 例外继续保留。 + +### 节点部署管理 + +- `edge-nodes/README.md` 是节点工作区入口;`edge-nodes/build-delivery-doc.py` 从 Git 忽略的 `edge-nodes/private/fleet.json` 读取清单并离线生成汇总交付页,不在源码里保存真实站点信息。 +- 通用部署脚本、参考资料与占位交付模板唯一源码在 `.claude/skills/edge-node-multi-protocol/`,Codex 从 `.agents/skills/edge-node-multi-protocol` 相对链接加载。部署/重建/维护 VPS 节点使用此技能;发布 Proxy Station 应用用 `deploy`,生产订阅数据查询与修改用 `update-db`。 +- 真实配置、面板入口、分享链接、二维码、交付文档和各站点脚本工作副本在 `edge-nodes/private/-edge-node-docs/`,全部禁止入 Git 或公开静态托管,也不发送到在线二维码服务。新增配置/产物用 `umask 077`,私有目录用受控备份转交,新克隆不会携带这些文件。 +- 当前舰队为 sg(AWS 新加坡)、la(DMIT 洛杉矶,同时承载 Proxy Station)、rn(RackNerd 洛杉矶,当前工作副本为 `rn2-edge-node-docs/`)、jp(VMISS 东京,裸 nginx)。`rn-edge-node-docs/` 与 `private/archive/` 是历史资料,不作为当前配置来源;原兄弟目录仅保留为迁移前备份,后续在本仓库维护。 +- 舰队自 2026-10-03 统一使用 IPv4:系统接口禁用 IPv6,Xray/sing-box 仅解析 IPv4 并拒绝 IPv6 目标(含 WARP),节点域名不保留 AAAA。重建或扩展线路须保留这些约束,详见 `docs/EDGE-IPV4-ONLY.md`;仅禁用网卡或删除 AAAA 不能阻止 WARP 隧道内的 IPv6 转发。 +- TCP 基础线路由 nginx+3x-ui/Xray 承载 VMess/Trojan/SS × direct/WARP,QUIC(HY2/TUIC)由 sing-box 独立承载。3x-ui 客户端不能只写投影表,必须保留 `settings.clients`,否则升级迁移会丢失认证。改远端前读取节点交付记录与技能参考,备份受影响配置和数据库,并保留已有证书续期动作。 +- 迁移工程、修改脚本或复制文件不会授权远端部署;节点与应用的部署各自按任务执行。应用运行时仍通过管理 API/Web 导入节点,不自动读取私有目录。TUIC/SOCKS5 交付线路不属于本系统当前支持的导入类型。 + +## 命令 + +```bash +CI=true pnpm install --frozen-lockfile # 按锁文件安装依赖 +pnpm dev # server :3000 + web :5173(都监听 0.0.0.0,局域网可访问) +pnpm -r typecheck # 三个包全量类型检查 +pnpm test # 服务端单测(vitest) +pnpm --filter @proxy-station/web build + +# macOS Surge:先取实际 HTTPS 订阅到私有临时文件,再用官方 CLI 与规则集快照验证 +python3 scripts/check-surge.py /private/tmp/.conf --fetch-rulesets + +# 单测过滤(在仓库根目录下) +pnpm --filter @proxy-station/server exec vitest run parse +pnpm --filter @proxy-station/server exec vitest run -t "round-trip" +pnpm --filter @proxy-station/server exec vitest # watch 模式 + +# 部署到 LA 生产机(web|server|all),详见 .claude/skills/deploy/SKILL.md 与 docs/DEPLOY.md +bash .claude/skills/deploy/scripts/deploy.sh all + +# 本机离线生成边缘舰队交付页(清单与产物均在 Git 忽略的私有目录) +python3 edge-nodes/build-delivery-doc.py + +# 校验实际 Clash 配置;生产验收改用实际 HTTPS 入口,真实 URL 仅在本机使用 +umask 077 +check_dir="$(mktemp -d /private/tmp/proxy-station-check.XXXXXX)" +curl -fsSL --max-time 45 "http://localhost:3000/sub//clash" -o "$check_dir/clash.yaml" && \ + test -s "$check_dir/clash.yaml" && mihomo -t -d "$check_dir" -f "$check_dir/clash.yaml" +``` + +本机开发及当前生产部署使用 Node 22。依赖使用 pnpm 与已提交的 `pnpm-lock.yaml`;不要混用 npm/yarn 或无故更新锁文件。pnpm 在无 TTY 时安装需要 `CI=true`,原生模块(better-sqlite3、esbuild、vue-demi)的构建许可写在 `pnpm-workspace.yaml` 的 `allowBuilds`。 + +本机会话注入的代理曾造成 git/安装/网络检查挂起;遇到此问题,在单条命令作用域内用 `env -u HTTPS_PROXY -u https_proxy -u HTTP_PROXY -u http_proxy -u ALL_PROXY -u all_proxy <命令>` 直连。现有运维脚本已自行处理代理。不依赖其他工具的全局指令文件,也不修改用户全局代理配置。 + +## 架构 + +应用部分为 pnpm monorepo:`shared/`(zod schema 与 DTO,前后端共用源码,非构建产物)+ `server/`(Hono + better-sqlite3 + Drizzle)+ `web/`(Vue 3 + Naive UI + Pinia)。生产模式由 server 静态托管 web 产物,单端口。`edge-nodes/` 与节点部署技能是 Bash/Python 运维工具,不加入 pnpm 包,也不随应用部署上传;Docker 构建上下文排除它们。 + +### 配置生成流水线(核心) + +``` +SQLite → buildProfileIR({nodeIds}) → ProfileIR → 四个序列化器 + ├─ generateSurge(ir, {selfUrl}) + ├─ generateShadowrocket(ir) 节点订阅 + ├─ generateShadowrocketConf(ir) 策略组+规则 + └─ generateClash(ir, {convertBaseUrl}) +``` + +`server/src/services/ir.ts` 是配置生成流水线的统一读库入口,把节点、策略组、规则、Surge 原文段落收成与客户端无关的 `ProfileIR`;四个生成器只读 IR,互不依赖。**新增客户端就是加一个序列化器**,不要在生成器里再查数据库。 + +IR 层承担三件防护,改动时不要绕过: + +- 停用或已删除的策略组会被剔除,引用它的成员与规则一并跳过 +- `FINAL` 规则永远保留,其策略组失效时降级为 `DIRECT` +- `nodeIds` 为空数组表示「不限制」,非空则按订阅分配的节点范围过滤 + +### 客户端能力矩阵 + +`shared/src/protocols.ts` 的 `clientSupports()` 决定某节点能否下发给某客户端,生成器据此排除并回报 `excluded`。**Surge 不支持 Shadowsocks over WebSocket**;本项目对 Surge 的 VMess 仅下发 `alterId=0`,加密方式只支持 auto/aes-128-gcm/chacha20-poly1305(转换为 IETF 名称)。其他客户端的能力独立判断。不要把无法表达的输入静默改成另一个协议组合。 + +### 协议字段差异 + +同一协议在三端写法不同,映射集中在 `shared/src/protocols.ts` 与各生成器: + +- SS cipher:分享链接用 Xray 命名 `chacha20-poly1305`,Surge/Clash 要 `chacha20-ietf-poly1305`(`normalizeSsCipher()`) +- VMess `alterId=0` 必须输出 Surge 的 `vmess-aead=true`,否则连不上且症状隐晦 +- Hysteria2 的 salamander 混淆:Surge 用 `salamander-password=`,Clash 用 `obfs`/`obfs-password` +- SS over WS 在 Clash 侧唯一可行方案是 `plugin: v2ray-plugin` + +这些写法参考了 `../sub-router` 项目(用户已在生产使用的同类系统),遇到新的客户端适配问题优先查它。 + +### 策略组的两种取节点方式 + +`includeAllNodes` 与 `filterRegex` 可以共存,生成器里 **filterRegex 分支优先**: + +- 有 `filterRegex` → 动态筛选:Surge 保留显式节点/内置成员,全部成员组作为 `include-other-group` 来源,并可同时启用 `include-all-proxies=true`;正则只筛动态来源,不筛显式成员。Clash 用 `include-all-proxies: true` + `filter` 并保留 `proxies` 显式成员;ShadowRocket 保留显式成员并用 `policy-regex-filter` 动态收节点。新增同地区节点会自动进组 +- 仅 `includeAllNodes` → Surge 静态展开全部节点名,Clash/ShadowRocket 仍用动态引用 + +**地区组靠节点名称正则匹配,不看 `region` 字段**。节点命名规范与新增地区的完整步骤见 `docs/ADDING-REGIONS.md`——命名不对就进不了地区组。 + +节点与组共用客户端的策略名称空间:禁止名称中的配置分隔符、引号、反斜线、控制字符和首尾空格;不能与内置策略或另一类实体重名。导入预览与实际写入都走同一 schema。组成员必须存在且类型正确,创建/更新(包括改名)校验完整引用图无环,并在事务内写组、替换成员与级联引用。 + +Surge 不支持组级 `url=`;组的 `testUrl` 仅用于 Clash/ShadowRocket。Surge 使用 General 的 `proxy-test-url` 或节点 `test-url`,fallback 不输出 tolerance。协议、规则和参数规范以 [Surge 官方手册](https://manual.nssurge.com/) 与本机 `surge-cli --check` 为依据,不能凭其他客户端语法推断。 + +### 规则集:映射与兜底转换 + +模板里的 `RULE-SET`/`DOMAIN-SET` 指向外部 URL。Surge/ShadowRocket 直接透传;Clash 需要 rule-providers,`services/rulesets/mapping.ts` 按序尝试映射到 skk.moe 的 Clash 版与 blackmatrix7 的 Clash 目录,映射不到的标记 `needsConvert`,provider URL 指向自托管端点 `/sub/:token/ruleset/:id.yaml`——该端点实时拉取 Surge 版并用 `convert.ts` 转成 classical provider。规则集拉取带 24h 缓存 + etag,失败回落陈旧缓存。 + +### ShadowRocket 分两步导入 + +ShadowRocket 的原生订阅格式只承载节点,因此拆成两个端点:`/shadowrocket`(节点 base64 URI 列表)与 `/shadowrocket-conf`(策略组与规则的 .conf)。节点订阅按完整名称字符串升序排列,保留出口词;该序列化器对副本排序,不修改 IR/数据库,跨客户端名称排序由上述节点权重约定保证。**ShadowRocket 没有 Surge 的 `use=` 订阅引用语法**(真机验证过:`use=true` 会被当成解析不了的成员残留),策略组动态收节点靠 `policy-regex-filter` 从 App 内全部节点里筛(「收纳全部节点」输出 `policy-regex-filter=.*`),显式成员也要保留。因此订阅名随意,但用户在 App 里挂的其他订阅的节点也会被筛进来;这些节点及 App 自定义排序不由本服务控制。本生成器当前保守跳过 `DOMAIN-SET`/`AND`/`OR`/`NOT`/`URL-REGEX`/`PROTOCOL`,并回报 `skippedRules`;这不是客户端不支持这些类型的断言。官方更新记录已确认存在逻辑规则与 QUIC 匹配,具体配置语法仍须以 App 内帮助和真机编译验证,详见 `docs/MOBILE-CLIENT-AUDIT-2026-10-04.md`。**ShadowRocket 的 conf 策略名不可用 Surge 引号机制**:策略名(含空格/emoji)一律裸写,加引号会被当作名字的一部分,导致规则指向不存在的策略组而整条失效、流量落到默认行为走代理——Surge 生成器可以加引号,ShadowRocket 生成器绝不可以。 + +Clash 节点必须明确下发 UDP 能力:VMess/Trojan/纯 TCP SS/HY2 输出 `udp: true`,SS WS 插件仅转发 TCP,输出 false,不能假设 nginx 的同端口提供 SS UDP。VMess cipher 按 mihomo 命名映射、未知 cipher 明确排除;SS plugin 的 TLS 参数在 `plugin-opts` 内。RULE-SET 保留 no-resolve;逻辑规则逐个转换子类型,仅把 PROTOCOL TCP/UDP 映射 NETWORK,任一子条件无法表达时整条跳过。两端 url-test 保留零 tolerance,fallback 不输出 tolerance。完整体检需分别核对客户端能力与生成器范围,原生解析成功不等于设备连接或隐私验收。 + +### 鉴权与订阅 + +除公开的 `/api/health` 外,`/api/*` 走 `adminAuth`:设置了 `ADMIN_TOKEN` 则要求 Bearer,未设置时仅放行 localhost 与 RFC1918 私有网段(公网 IP 一律拒绝)。`/sub/:token/*` 公开,token 用常量时间比对。**订阅 URL 完全由请求来源决定**——后端只返回路径,前端用 `window.location.origin` 拼接,`#!MANAGED-CONFIG` 与 Clash provider URL 取本次请求的 origin,所以没有也不需要「订阅基址」配置项。 + +未匹配的 `/api/*` 显式返回 404,不落到 SPA 回退(否则会拿到一页 HTML,调接口时极难排查)。 + +### Surge Mac 与 Tailscale 共存 + +修改增强模式、DNS、STUN 或 VPN 路由前,先读 [docs/SURGE-TAILSCALE.md](docs/SURGE-TAILSCALE.md)。2026-10-06 已实测:独立 Tailscale 客户端设置 `--accept-dns=false`,Surge 仅将具体 tailnet 域名交给 `100.100.100.100`,配合 always-real-ip 和实际 Tailscale 网络扩展/CLI 的优先 DIRECT 规则。保留原有公网代理、AI DNS 和其他应用的 STUN 阻断;不使用 Exit Node,不需要向项目保存 Tailscale auth key。 + +**不能机械套用 `tun-excluded-routes = 100.64.0.0/10`**:本机实测该项创建指向物理网关的路由,覆盖 Tailscale 路由。最终配置不添加这些排除项。skip-proxy、DIRECT 和 VIF 路由排除是不同层次,必须检查实际路由表及普通应用连接。 + +tailscale ping 可通不代表 curl/SSH 可通;客户端显示 Running 也不代表公网未受影响。测试必须同时覆盖原代理、AI DNS、MagicDNS、普通 tailnet 服务和外网入站 SSH。使用 Tailscale CLI 做 netcheck 时也需进程例外,避免把自身 STUN 被阻断误判为网络不支持 UDP。测试 VPN 重连前设置失败自动 tailscale down 的恢复措施,并确认用户未同时手动切换。 + +现有订阅的持久来源是生产 settings/rules,模板仅供首次初始化;通过 update-db 技能先备份再走 API,不直接改托管文件或重置规则。结论仅限实测环境与场景:两种启动顺序、IPv4/IPv6 服务和蜂窝网络入站 SSH 已通过;睡眠唤醒、长期运行及网络切换尚未验证,不宣称“完美兼容”。 + +同账号/同 Surge 订阅不会同步 Tailscale 的本机 `accept-dns` 与 Exit Node 设置。新 Mac 必须逐台检查;MacBook Air 使用相同配置时仍曾保留 accept-dns=true,需在该机单独关闭。不要仅凭 Surge 自带探测成功认定普通应用正常,须另外测不使用显式代理的 curl。 + +### Telegram 跨平台分流(2026-10-11) + +最新修复记录见 [docs/TELEGRAM-RULES.md](docs/TELEGRAM-RULES.md),规则快照见 [rules/telegram.list](rules/telegram.list)。生产已通过管理 API 添加 37 条高优先级自定义规则,Telegram 域名及 IPv4/IPv6 地址统一交给 `🚀 Proxy`;本机更新订阅后登录二维码恢复显示。仅配置 `osx.telegram.org` 不足以覆盖数据中心连接。此变更晚于上面的 2026-10-04 生产盘点,历史数量与规则顺序不能视为当前值;维护前仍须经 API 重读。规则快照不会自动跟随上游更新。 + +## 前端约定 + +UI 走「站牌导视」设计方向(轨道交通导视系统语言):白底 `#f7f7f4`、深藏青重点色 `#2b3f5c`、**琥珀 `#b26a00` = 直连出口、青 `#007a72` = WARP 净出口**,协议各有线路色圆标。颜色只在承载信息时出现——避免发光、亮边条、装饰性渐变。四个方向的设计对比留在 `docs/design-demos.html`。 + +两个共用工具: + +- `utils/nodeName.ts` 的 `nodeDisplayName()`:与出口标签并排显示时剥掉名字末尾的出口词。**下发给客户端的名字必须保留出口词**,客户端里没有标签,用户只能靠名字区分同协议的两条线路 +- `utils/clipboard.ts` 的 `copyText()`:`navigator.clipboard` 只在 HTTPS/localhost 可用,局域网 HTTP 访问时是 `undefined`,直接调用会静默失败。必须走这个带 `execCommand` 降级的封装 + +## 数据与安全 + +SQLite 落在 `server/data/`,存有节点真实凭据。`data/`、`*.db`、`.env`、`edge-nodes/private/`、`config.env` 及其备份、`node-links/` 已在 `.gitignore`——**绝不入 git**。可入库的示例、测试、模板一律用占位凭据;填写后的交付副本只放私有目录。节点通过 Web 界面粘贴分享链接导入,应用不直接读 `edge-nodes/private/`(见 `docs/SEEDING.md`)。 + +schema 变更同时更新 `shared/src/schemas.ts`/DTO(涉及对外字段时)、`server/src/db/schema.ts` 的 Drizzle 定义和 `server/src/db/index.ts` 的启动 SQL。新表在 `BOOTSTRAP_SQL` 中加 `CREATE TABLE IF NOT EXISTS`,旧库补列用同文件下方的轻量 `ALTER TABLE` 循环(吞掉「列已存在」错误)——没有引入 migration 工具。 + +公网部署必须设 `ADMIN_TOKEN` 并用反代提供 HTTPS:订阅 token 就在 URL 里,明文 HTTP 会泄露。 + +## 初始化与已有数据 + +`server/src/db/seed.ts` 只在策略组为空时从 `server/assets/surge-template.conf` 初始化策略组、规则和 Surge 段落。修改模板不会更新已有数据库;既有订阅仍以数据库为准。生产数据变更使用 `update-db` 技能,经管理 API 修改并先做 SQLite 在线备份,确认备份 root 属主、600 权限,避免绕过校验和级联更新。 + +组的 members 是整体替换;排序请求始终先读完整列表并提交全量、无重复 ids。规则 reorder API 强制校验完整性与末尾 FINAL;当前组 reorder API 只逐条写顺序,调用方须自行核对完整性。组 PUT 不更新 sortOrder,显示排序使用独立 `/api/groups/reorder`。节点 PUT 使用私有文件提交完整原字段,排序调整后重读确认除 sortOrder/updatedAt 外没有变化。 + +`server/src/env.ts` 直接读 `process.env`,没有 dotenv 加载;仅复制 `.env.example` 成 `.env` 并不会让 `pnpm dev` 自动读取它。变量须由启动环境提供(Docker Compose 与 systemd 有各自注入方式)。相对 `DATA_DIR` 始终基于 `server/` 解析。 + +## Surge AI DNS 与协议规则 + +改 DNS/隐私行为先读 `docs/SURGE-PRIVACY.md`、`server/src/services/generators/surge-ai-dns.ts` 及对应测试。AI 域名 DNS 映射来自 IR 内启用的 AI 路由规则;不要把全局 DNS 或所有端口 53 请求改送 AI。Surge 的 `PROTOCOL` 应用协议规则不能直接复制到其他客户端:Clash 只把 TCP/UDP 转成 `NETWORK`,其他应用协议跳过;ShadowRocket 跳过 `PROTOCOL`。生成配置校验不能替代设备 DNS/WebRTC 真机验证。 + +手写 Host 项优先;生成的域名 DNS 映射按连接规则顺序保留 AI 之前的非 AI 域名/规则集例外,不把 Host 当成能表达进程、协议或 IP 条件的路由系统。宽泛 AI 域名规则仍须审阅覆盖范围。 + +Net.Coffee 的 Claude 页通过随机 `*.d.ip.net.coffee` 探测递归 DNS 出口,不直接查询 Claude 域名;使用 `DOMAIN-SUFFIX,ip.net.coffee,🤖 AI` 同时覆盖页面和探测子域。Surge 派生根域/通配 Host 到 Cloudflare DoH,并随 AI 出口。本机 Chrome+Surge 已复测为美国 Cloudflare「未检测到泄露」,仅代表该探测路径的实际结果,不能推广为所有客户端/域名/远端解析器已验收,见 `docs/NET-COFFEE-DNS-2026-10-04.md`。 + +规则变更先读 `docs/SURGE-CHANGES.md`。API 必须保住一条启用且末尾的 FINAL;禁止停用、删除、改类型或创建重复 FINAL,排序必须提交完整且无重复的 ids。IR 对遗留坏数据防御性补齐 FINAL,其失效策略降级 DIRECT。重置规则在事务内保留自定义规则,不重复插入原主键,也不能用 reset 代替定向调整生产规则。 + +规则按首条匹配执行(另留意 pre-matching)。业务例外须在覆盖它的广告/通用规则集之前,特定 UDP 拦截须在对应服务域名规则之前。靠前的 IP 规则集是否加 `no-resolve` 必须按意图决定:它能避免主动解析,但会改变未解析域名的 IP 拦截覆盖。普通规则值与策略引用用 `surge-format.ts` 编码,逻辑表达式保留结构;不要手拼含逗号的正则或含换行的字段。 + +共享订阅不附公开默认控制口令;默认关闭 Remote Controller/HTTP API,确需使用时按设备配置独立凭据与访问限制。MITM 不关闭上游服务器证书验证;需要 MITM 时另做设备 CA、开关与 HTTPS 行为验证。 + +## 验证与完成标准 + +| 改动 | 建议验证 | +|---|---| +| 服务端逻辑/解析器/生成器 | `pnpm --filter @proxy-station/server typecheck` + 相关 Vitest;新增行为用占位凭据构造 IR/分享链接用例 | +| shared schema/DTO/协议能力 | `pnpm -r typecheck` + `pnpm test`,兼顾前后端消费者与各客户端产物 | +| Vue 页面/样式/状态 | `pnpm --filter @proxy-station/web typecheck` + `pnpm --filter @proxy-station/web build`;页面交互与局域网 HTTP 复制按需验证 | +| 数据库结构/启动行为 | 临时 `DATA_DIR` 或 `createDb(':memory:')` 验证新库与旧库升级;注意下述导入副作用 | +| 文档/协作入口/技能 | 检查路径、导入、符号链接、技能 frontmatter 与脚本语法;不为纯文档修改触发部署 | +| 节点部署工具/交付页生成器 | Bash 语法与 Python 编译检查、占位配置离线验证;迁移核对原文件、私有路径 Git 忽略及技能链接,不为迁移执行远端脚本 | + +涉及节点、组、规则、DNS 或 Surge 段落时,在上述检查之外对实际订阅运行 `scripts/check-surge.py`;涉及规则集或顺序用 `--fetch-rulesets`,审阅域名遮蔽提示。脚本调用官方 Surge CLI,不更换活动配置;输出 OK 不表示测速、业务连接或设备隐私已验收。敏感配置用权限 600 的临时文件,不进入仓库或对话。生产写入先在线备份,再 API 更新、重读、HTTPS 拉订阅与 CLI 验证;必要时同时用 mihomo 校验受影响的 Clash 产物。 + +取订阅后先核对下载成功、非空及预期结构,再交给原生解析器。mihomo 在目标配置不存在时可能创建默认配置并报告成功,不能用这个结果验收订阅;所有检查遇到失败即停止,避免前一步失败而最后一条命令成功掩盖错误。 + +区分数据库、实际 HTTPS 产物、客户端活动配置与设备网络结果。用户授权的同机复测需刷新既有托管配置时,先核对托管 URL、备份并读取最新组选择,用 `surge-cli managed-profile update` 后检查 effective 配置与实际选项。组改名会改变客户端保存选择的身份;必要时仅将原选定节点及父组路径迁移到新名称,保留 All Nodes/AI Nodes 的独立选择,不擅自切换出口或活动配置。客户端模块可覆盖生成的组类型与参数,不能把本机模块效果写回生产数据库。 + +`db/index.ts` 在模块加载时就执行 `export const db = createDb()`,包括只导入 `createDb` 的情形;临时数据库测试/启动验证应在加载模块前设置绝对路径的临时 `DATA_DIR`,不要拿本地真实 `server/data/` 做试验。现有生成器单测使用内存中的 IR;运行服务还会执行初始化。 + +改生成器时检查节点过滤、组成员引用、规则顺序和 FINAL 兜底;Clash 可用 mihomo 校验,Surge/ShadowRocket 的客户端语法与实际连接仍需真机验收。`excluded`/`skippedRules` 是生成器返回字段,订阅 HTTP 响应只返回配置正文。 + +完成后说明改动、已通过的验证与尚未完成的实际验证。新增业务约束或修正旧认识时同步更新这里或相应 `docs/`,让后续 Codex/Claude Code 会话能接续。 diff --git a/CLAUDE.md b/CLAUDE.md index 59b299b..3de74bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,114 +1,7 @@ -# CLAUDE.md +@AGENTS.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +# Claude Code 兼容入口 -## 这是什么 - -自托管的代理节点与分流规则管理平台:Web 界面管理多协议节点(VMess/Trojan/Shadowsocks/Hysteria2)与策略组、规则,向 macOS Surge、iOS ShadowRocket、Android ClashMeta 下发托管订阅。规则模板来自 [ClashConnectRules/Surge](https://github.com/ClashConnectRules/Surge),节点来自 `~/Development/Fusion/Projects/edge-nodes/` 交付的边缘节点舰队。 - -## 命令 - -```bash -pnpm dev # server :3000 + web :5173(都监听 0.0.0.0,局域网可访问) -pnpm -r typecheck # 三个包全量类型检查 -pnpm test # 服务端单测(vitest) -pnpm --filter @proxy-station/web build - -# 单测过滤(在 server/ 目录下) -npx vitest run parse # 按文件名 -npx vitest run -t "round-trip" # 按用例名 -npx vitest # watch 模式 - -# 部署到 LA 生产机(web|server|all),详见 .claude/skills/deploy/SKILL.md 与 docs/DEPLOY.md -bash .claude/skills/deploy/scripts/deploy.sh all - -# 校验生成的 Clash 配置(最有价值的一条端到端验证) -curl -s "http://localhost:3000/sub//clash" > /tmp/c.yaml && mihomo -t -f /tmp/c.yaml -``` - -**网络与安装的两个坑**:本机会话注入的 `HTTPS_PROXY` 会让 git/构建挂死,跑任何命令前先剥掉代理(见用户全局 CLAUDE.md)。pnpm 在无 TTY 时执行 `pnpm install` 需要 `CI=true`,原生模块(better-sqlite3、esbuild、vue-demi)的构建许可写在 `pnpm-workspace.yaml` 的 `allowBuilds`。 - -## 架构 - -pnpm monorepo:`shared/`(zod schema 与 DTO,前后端共用源码,非构建产物)+ `server/`(Hono + better-sqlite3 + Drizzle)+ `web/`(Vue 3 + Naive UI + Pinia)。生产模式由 server 静态托管 web 产物,单端口。 - -### 配置生成流水线(核心) - -``` -SQLite → buildProfileIR({nodeIds}) → ProfileIR → 四个序列化器 - ├─ generateSurge(ir, {selfUrl}) - ├─ generateShadowrocket(ir) 节点订阅 - ├─ generateShadowrocketConf(ir, …) 策略组+规则 - └─ generateClash(ir, {convertBaseUrl}) -``` - -`services/ir.ts` 是唯一的读库出口,把节点、策略组、规则、Surge 原文段落收成与客户端无关的 `ProfileIR`;四个生成器只读 IR,互不依赖。**新增客户端就是加一个序列化器**,不要在生成器里再查数据库。 - -IR 层承担三件防护,改动时不要绕过: -- 停用或已删除的策略组会被剔除,引用它的成员与规则一并跳过 -- `FINAL` 规则永远保留,其策略组失效时降级为 `DIRECT` -- `nodeIds` 为空数组表示「不限制」,非空则按订阅分配的节点范围过滤 - -### 客户端能力矩阵 - -`shared/src/protocols.ts` 的 `clientSupports()` 决定某节点能否下发给某客户端,生成器据此静默排除并回报 `excluded`。当前唯一规则:**Surge 不支持 Shadowsocks over WebSocket**(Surge 的 ss 类型只有 simple-obfs,没有 WS 传输)。这不是 bug,同机的 VMess/Trojan 线路凭据相同、覆盖等价。 - -### 协议字段差异 - -同一协议在三端写法不同,映射集中在 `shared/src/protocols.ts` 与各生成器: -- SS cipher:分享链接用 Xray 命名 `chacha20-poly1305`,Surge/Clash 要 `chacha20-ietf-poly1305`(`normalizeSsCipher()`) -- VMess `alterId=0` 必须输出 Surge 的 `vmess-aead=true`,否则连不上且症状隐晦 -- Hysteria2 的 salamander 混淆:Surge 用 `salamander-password=`,Clash 用 `obfs`/`obfs-password` -- SS over WS 在 Clash 侧唯一可行方案是 `plugin: v2ray-plugin` - -这些写法参考了 `../sub-router` 项目(用户已在生产使用的同类系统),遇到新的客户端适配问题优先查它。 - -### 策略组的两种取节点方式 - -`includeAllNodes` 与 `filterRegex` 可以共存,生成器里 **filterRegex 分支优先**: -- 有 `filterRegex` → 动态筛选:Surge `include-all-proxies=true, policy-regex-filter=…`/Clash `filter:`/ShadowRocket `use=true, policy-regex-filter=…`。新增同地区节点会自动进组 -- 仅 `includeAllNodes` → Surge 静态展开全部节点名,Clash/ShadowRocket 仍用动态引用 - -**地区组靠节点名称正则匹配,不看 `region` 字段**。节点命名规范与新增地区的完整步骤见 `docs/ADDING-REGIONS.md`——命名不对就进不了地区组。 - -### 规则集:映射与兜底转换 - -模板里的 `RULE-SET`/`DOMAIN-SET` 指向外部 URL。Surge/ShadowRocket 直接透传;Clash 需要 rule-providers,`services/rulesets/mapping.ts` 按序尝试映射到 skk.moe 的 Clash 版与 blackmatrix7 的 Clash 目录,映射不到的标记 `needsConvert`,provider URL 指向自托管端点 `/sub/:token/ruleset/:id.yaml`——该端点实时拉取 Surge 版并用 `convert.ts` 转成 classical provider。规则集拉取带 24h 缓存 + etag,失败回落陈旧缓存。 - -### ShadowRocket 分两步导入 - -ShadowRocket 的原生订阅格式只承载节点,因此拆成两个端点:`/shadowrocket`(节点 base64 URI 列表)与 `/shadowrocket-conf`(策略组与规则的 .conf)。**ShadowRocket 没有 Surge 的 `use=` 订阅引用语法**(真机验证过:`use=true` 会被当成解析不了的成员残留),策略组收节点只能靠 `policy-regex-filter` 从 App 内全部节点里筛(「收纳全部节点」输出 `policy-regex-filter=.*`),因此订阅名随意,但用户在 App 里挂的其他订阅的节点也会被筛进来。ShadowRocket 不支持 `DOMAIN-SET`/`AND`/`URL-REGEX`,生成时跳过并回报 `skippedRules`。**ShadowRocket 的 conf 语法没有引号机制**:策略名(含空格/emoji)一律裸写,加引号会被当作名字的一部分,导致规则指向不存在的策略组而整条失效、流量落到默认行为走代理——Surge 生成器可以加引号,ShadowRocket 生成器绝不可以。 - -### 鉴权与订阅 - -`/api/*` 走 `adminAuth`:设置了 `ADMIN_TOKEN` 则要求 Bearer,未设置时仅放行 localhost 与 RFC1918 私有网段(公网 IP 一律拒绝)。`/sub/:token/*` 公开,token 用常量时间比对。**订阅 URL 完全由请求来源决定**——后端只返回路径,前端用 `window.location.origin` 拼接,`#!MANAGED-CONFIG` 与 Clash provider URL 取本次请求的 origin,所以没有也不需要「订阅基址」配置项。 - -未匹配的 `/api/*` 显式返回 404,不落到 SPA 回退(否则会拿到一页 HTML,调接口时极难排查)。 - -### Surge Mac 与 Tailscale 共存 - -修改增强模式、DNS、STUN 或 VPN 路由前,先读 [docs/SURGE-TAILSCALE.md](docs/SURGE-TAILSCALE.md)。2026-10-06 已实测:独立 Tailscale 客户端设置 `--accept-dns=false`,Surge 仅将具体 tailnet 域名交给 `100.100.100.100`,配合 always-real-ip 和实际 Tailscale 网络扩展/CLI 的优先 DIRECT 规则。保留原有公网代理、AI DNS 和其他应用的 STUN 阻断;不使用 Exit Node,不需要向项目保存 Tailscale auth key。 - -**不能机械套用 `tun-excluded-routes = 100.64.0.0/10`**:本机实测该项创建指向物理网关的路由,覆盖 Tailscale 路由。最终配置不添加这些排除项。skip-proxy、DIRECT 和 VIF 路由排除是不同层次,必须检查实际路由表及普通应用连接。 - -tailscale ping 可通不代表 curl/SSH 可通;客户端显示 Running 也不代表公网未受影响。测试必须同时覆盖原代理、AI DNS、MagicDNS、普通 tailnet 服务和外网入站 SSH。使用 Tailscale CLI 做 netcheck 时也需进程例外,避免把自身 STUN 被阻断误判为网络不支持 UDP。测试 VPN 重连前设置失败自动 tailscale down 的恢复措施,并确认用户未同时手动切换。 - -现有订阅的持久来源是生产 settings/rules,模板仅供首次初始化;通过 update-db 技能先备份再走 API,不直接改托管文件或重置规则。结论仅限实测环境与场景:两种启动顺序、IPv4/IPv6 服务和蜂窝网络入站 SSH 已通过;睡眠唤醒、长期运行及网络切换尚未验证,不宣称“完美兼容”。 - -同账号/同 Surge 订阅不会同步 Tailscale 的本机 `accept-dns` 与 Exit Node 设置。新 Mac 必须逐台检查;MacBook Air 使用相同配置时仍曾保留 accept-dns=true,需在该机单独关闭。不要仅凭 Surge 自带探测成功认定普通应用正常,须另外测不使用显式代理的 curl。 - -## 前端约定 - -UI 走「站牌导视」设计方向(轨道交通导视系统语言):白底 `#f7f7f4`、深藏青重点色 `#2b3f5c`、**琥珀 `#b26a00` = 直连出口、青 `#007a72` = WARP 净出口**,协议各有线路色圆标。颜色只在承载信息时出现——避免发光、亮边条、装饰性渐变。四个方向的设计对比留在 `docs/design-demos.html`。 - -两个共用工具: -- `utils/nodeName.ts` 的 `nodeDisplayName()`:与出口标签并排显示时剥掉名字末尾的出口词。**下发给客户端的名字必须保留出口词**,客户端里没有标签,用户只能靠名字区分同协议的两条线路 -- `utils/clipboard.ts` 的 `copyText()`:`navigator.clipboard` 只在 HTTPS/localhost 可用,局域网 HTTP 访问时是 `undefined`,直接调用会静默失败。必须走这个带 `execCommand` 降级的封装 - -## 数据与安全 - -SQLite 落在 `server/data/`,存有节点真实凭据。`data/`、`*.db`、`.env` 已在 `.gitignore`——**绝不入 git**。仓库内所有示例、测试、模板一律用占位凭据。节点通过 Web 界面粘贴分享链接导入,不直接读 edge-nodes 目录(见 `docs/SEEDING.md`)。 - -schema 变更走 `db/index.ts` 里的 `BOOTSTRAP_SQL` 加 `CREATE TABLE IF NOT EXISTS`,旧库补列用同文件下方的轻量 `ALTER TABLE` 循环(吞掉「列已存在」错误)——没有引入 migration 工具。 - -公网部署必须设 `ADMIN_TOKEN` 并用反代提供 HTTPS:订阅 token 就在 URL 里,明文 HTTP 会泄露。 +项目开发约定统一维护在根目录 `AGENTS.md`,不要在本文件重复复制。 +项目技能仍从 `.claude/skills/` 加载;双工具使用说明见 `docs/AI-COLLABORATION.md`。 +节点部署管理已合入本项目,工作区入口见 `edge-nodes/README.md`,使用 `edge-node-multi-protocol` 技能。 diff --git a/README.md b/README.md index 9f93fdc..300cbaa 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Proxy Station -自托管的代理节点与分流规则管理平台。在 Web 界面里管理多协议节点与策略组、规则,向 macOS、iOS、Android 三端下发托管订阅——客户端填一次 URL,之后配置自动更新。 +统一维护边缘 VPS 节点部署管理与自托管的代理节点、分流规则管理平台。在 Web 界面里管理多协议节点与策略组、规则,向 macOS、iOS、Android 三端下发托管订阅——客户端填一次 URL,之后配置自动更新。 ``` 节点 ──┐ @@ -11,9 +11,11 @@ ## 能做什么 +**边缘节点部署管理**——节点部署脚本、版本兼容参考、交付模板与离线汇总页生成器已合入本仓库,使用项目技能 `edge-node-multi-protocol` 维护 nginx、3x-ui/Xray、WARP 与可选 sing-box 线路。真实配置与交付记录位于 Git 忽略的 `edge-nodes/private/`;入口见 [边缘节点工作区](edge-nodes/README.md)。 + **多协议节点管理**——VMess、Trojan、Shadowsocks、Hysteria2,支持粘贴分享链接批量导入(`vmess://`、`trojan://`、`ss://` 的 SIP002 与 Xray 两种写法、`hysteria2://`),也支持手动填写与导出。节点按站点分段展示。 -**分流规则与策略组**——首次启动自动从 [ClashConnectRules/Surge](https://github.com/ClashConnectRules/Surge) 模板导入(26 个策略组 + 88 条规则,含广告拦截、AI 服务、流媒体、国内直连等)。规则可排序、改策略、启停、增删;策略组可启停并调整顺序,顺序即客户端里的排列顺序。外部规则集可在界面上实时预览内容。 +**分流规则与策略组**——空库首次启动从内置 Surge 模板初始化广告拦截、AI 服务、流媒体、国内直连等配置。模板参考 [ClashConnectRules/Surge](https://github.com/ClashConnectRules/Surge),已有数据库以管理 API 的实际数据为准,修改模板不会同步更新。规则可排序、改策略、启停、增删;唯一启用的 FINAL 保持末尾,不可停用或删除。策略组可启停、调整成员与显示顺序;显示顺序不决定规则优先级。外部规则集可在界面上预览内容。 **按客户端自动适配**——同一份数据生成三端配置,客户端不支持的协议在下发时自动屏蔽(例如 Surge 的 ss 类型没有 WebSocket 传输,SS over WS 节点会被排除),字段差异自动映射(cipher 命名、VMess AEAD、Hysteria2 混淆参数等)。 @@ -31,14 +33,26 @@ ShadowRocket 的原生订阅格式只承载节点,因此拆成两步:先在 > ClashMeta 侧无 Clash 版本的规则集,由本服务实时拉取 Surge 版并转换后提供,无需外部转换服务。 +三端的规则与 DNS 表达范围有所不同。Shadowrocket 部分规则仍保守跳过;Surge 的 AI Host DNS 映射不自动等同于手机端 DNS 设置。能力、已验证范围与待验项见 [移动端体检](docs/MOBILE-CLIENT-AUDIT-2026-10-04.md)。 + +## 当前配置入口 + +最新节点启用范围、完整组成员、规则服务归属与验证范围集中在 [当前生产状态](docs/CURRENT-STATE.md)。目前普通代理由 **🚀 Proxy** 控制,只有它引用 **⚡️ Automatic**;**🤖 AI** 可选美国、新加坡、日本或 **🧠 AI Nodes**。 + +**📋 All Nodes** 与 **🧠 AI Nodes** 收纳相同的可用节点,按完整名称升序排列,分别保存手动选择。AI Nodes 紧接 AI 显示;在 AI 中选 AI Nodes,再在 AI Nodes 中选择具体节点,即可为 AI 保存独立出口。详见 [独立节点选择](docs/AI-NODES-2026-10-04.md)。 + +AI 规则位于广告与普通分流之前,保留更早的协议防护与 Unbreak 直连例外。Net.Coffee 的随机 DNS 探测子域已纳入 AI;本机 Chrome+Surge 复测显示美国 Cloudflare「未检测到泄露」,验证边界见 [DNS 复测](docs/NET-COFFEE-DNS-2026-10-04.md)。 + ## 快速开始 +使用 Node 22 与 pnpm,按已提交的锁文件安装: + ```bash -pnpm install +CI=true pnpm install --frozen-lockfile pnpm dev # server :3000 + web :5173 ``` -打开 http://localhost:3000 (或开发时的 :5173),然后: +开发时打开 http://localhost:5173,然后: 1. **节点** → 批量导入,粘贴分享链接。命名建议带地区词(如 `🇺🇸 US LA VMess Direct`),地区策略组靠节点名正则匹配 2. **订阅** → 新建订阅,勾选要开放的节点 @@ -48,6 +62,10 @@ pnpm dev # server :3000 + web :5173 ## 部署 +当前 LA 生产服务使用 Node 22+systemd,由 nginx 提供 HTTPS;日常应用发布使用 [deploy 技能](.claude/skills/deploy/SKILL.md),线上节点/组/规则数据通过 [update-db 技能](.claude/skills/update-db/SKILL.md)管理。部署代码不自动覆盖已有数据。详细步骤见 [生产部署](docs/DEPLOY.md)。 + +另提供 Docker Compose 部署方式: + ```bash ADMIN_TOKEN=<随机长字符串> docker compose up -d ``` @@ -59,27 +77,49 @@ SQLite 落在 `./data` 卷。订阅 URL 由请求来源自动推断,无需配 ## 校验 ```bash -# Clash 产物(最有价值的一条端到端验证) -curl -s "http://localhost:3000/sub//clash" > /tmp/c.yaml && mihomo -t -f /tmp/c.yaml - pnpm -r typecheck pnpm test + +# 下载成功后再校验真实产物;生产验收使用实际 HTTPS 订阅 +umask 077 +check_dir="$(mktemp -d /private/tmp/proxy-station-check.XXXXXX)" +curl -fsSL --max-time 45 "http://localhost:3000/sub//clash" -o "$check_dir/clash.yaml" && \ + test -s "$check_dir/clash.yaml" && mihomo -t -d "$check_dir" -f "$check_dir/clash.yaml" + +# 在安装 Surge 的 macOS 上检查实际配置与远程规则快照 +python3 scripts/check-surge.py /private/tmp/.conf --fetch-rulesets ``` -Surge 与 ShadowRocket 需真机导入验收。 +真实配置、URL 与凭据仅保存在私有文件。原生解析成功不等于节点连接或隐私验收;本机 Surge 的组与网站 DNS 复测已有记录,iOS/Android 业务、UDP 与完整 DNS/WebRTC 测试仍需真机完成。完整流程见 [订阅变更与验证](docs/SURGE-CHANGES.md)。 ## 文档 | 文件 | 内容 | |---|---| -| `docs/SEEDING.md` | 从 edge-nodes 交付文档导入真实节点、命名建议 | +| [docs/CURRENT-STATE.md](docs/CURRENT-STATE.md) | 最新生产节点、策略组、规则、客户端下发数量与 Session 接续入口 | +| `edge-nodes/README.md` | 节点部署工作区、当前舰队、私有资料与工程迁移记录 | +| `.claude/skills/edge-node-multi-protocol/SKILL.md` | 节点部署、重建、维护与交付的共用技能 | +| `docs/SEEDING.md` | 从本仓库私有交付文档导入真实节点、命名建议 | | `docs/ADDING-REGIONS.md` | 新增地区(香港、日本等)时的节点命名与策略组配套步骤 | -| `CLAUDE.md` | 架构要点与开发约定 | +| `AGENTS.md` | Codex/Claude Code 共用的架构要点与开发约定 | +| `CLAUDE.md` | 导入 `AGENTS.md` 的 Claude Code 兼容入口 | +| `docs/AI-COLLABORATION.md` | 双工具加载方式、共用技能与协作维护方法 | +| `docs/DEPLOY.md` | LA 生产环境部署与验证 | +| `docs/SURGE-PRIVACY.md` | Surge AI 域名 DNS 分流与验证边界 | +| `docs/SURGE-AUDIT-2026-10-03.md` | macOS Surge 生产订阅体检、复现结果与修复建议 | +| `docs/SURGE-FIX-2026-10-03.md` | 体检问题修复、生产备份与实际订阅验证结果 | +| `docs/SURGE-CHANGES.md` | 节点、组、规则与 DNS 变更的 Surge 官方 CLI 验证流程 | +| [docs/AI-NODES-2026-10-04.md](docs/AI-NODES-2026-10-04.md) | 独立 AI Nodes、emoji、名称排序与客户端选择保留 | +| [docs/PROXY-AUTOMATIC-2026-10-04.md](docs/PROXY-AUTOMATIC-2026-10-04.md) | 普通代理入口与 Automatic 的选择链 | +| [docs/AI-RULE-REVIEW-2026-10-04.md](docs/AI-RULE-REVIEW-2026-10-04.md) | AI 精确补充及 Apple/Global/Bing 专用分流调整 | +| [docs/AI-PRIORITY-2026-10-04.md](docs/AI-PRIORITY-2026-10-04.md) | AI、协议防护、直连例外与广告规则优先级 | +| [docs/NET-COFFEE-DNS-2026-10-04.md](docs/NET-COFFEE-DNS-2026-10-04.md) | 检测域名实现、AI 后缀规则及本机 DNS 复测 | +| [docs/MOBILE-CLIENT-AUDIT-2026-10-04.md](docs/MOBILE-CLIENT-AUDIT-2026-10-04.md) | Shadowrocket/ClashMeta 生成器差异、已部署修复与手机待验项 | ## 安全 -数据库存有节点真实凭据。`data/`、`*.db`、`.env` 已在 `.gitignore` 中,**绝不入 git**;仓库内所有示例、测试、模板一律使用占位凭据。订阅 token 可随时在「订阅」页禁用或删除。 +数据库和节点私有工作区存有真实凭据。`data/`、`*.db`、`.env`、`edge-nodes/private/`、`config.env` 及其备份、`node-links/` 已在 `.gitignore` 中,**绝不入 git**;可入库的示例、测试、模板一律使用占位凭据。节点私有资料通过受控备份恢复,Docker 构建上下文排除节点工具与私有资料。订阅 token 可随时在「订阅」页禁用或删除。 ## 技术栈 -pnpm monorepo:`server/`(Hono + better-sqlite3 + Drizzle)+ `web/`(Vue 3 + Vite + Naive UI + Pinia)+ `shared/`(zod schema 与 DTO,前后端共用)。生产模式由 server 静态托管前端产物,单端口运行。 +应用为 pnpm monorepo:`server/`(Hono + better-sqlite3 + Drizzle)+ `web/`(Vue 3 + Vite + Naive UI + Pinia)+ `shared/`(zod schema 与 DTO,前后端共用)。生产模式由 server 静态托管前端产物,单端口运行。节点部署管理使用独立 Bash/Python 工具,不加入应用运行包。 diff --git a/docs/ADDING-REGIONS.md b/docs/ADDING-REGIONS.md index 13bfa77..3ecb8d5 100644 --- a/docs/ADDING-REGIONS.md +++ b/docs/ADDING-REGIONS.md @@ -4,12 +4,16 @@ > 背景:策略组的地区筛选靠**节点名称正则**,不靠节点的 `region` 字段。所以节点命名是一切的前提。 -## 当前状态(2026-09-14) +## 当前状态(2026-10-04) + +节点登记/启用数量、完整策略组矩阵和服务规则归属集中见 [当前生产状态](CURRENT-STATE.md)。下面维护地区命名与扩展方法;新增站点不自动启用已有停用线路。 有美国、新加坡、日本三个地区的节点,策略组里对应 `🇺🇸 United States`、`🇸🇬 Singapore`、`🇯🇵 Japan` 三个地区组。 -日本组于 2026-09-14 随 jp 站点(VMISS 东京,八条线路)按本文步骤重建,站点码 `JP`,已加入所有成员里含美国或新加坡的服务组(Proxy/Automatic/Streaming/Apple/AI/Bybit/Global Services)。约定:**凡是成员含美国或新加坡的组,都应同时含日本**;Speedtest/NoAuto 只含大陆与 Automatic,不在此列。 +日本组于 2026-09-14 随 jp 站点(VMISS 东京,八条线路)按本文步骤重建,站点码 `JP`,当前加入的服务组为 Proxy/Automatic/Streaming/AI/Bybit。约定:**凡是成员含美国或新加坡的组,都应同时含日本**;NoAuto 当前只含大陆,不在此列。Speedtest 组及其专用规则已删除。 +2026-10-04 已删除线上 Apple/Global Services 组及指向它们的专用规则,并删除 AI 的 Bing 规则;不再给这两个已删除的服务组添加地区。同日进一步将所有原先直接指向 Automatic 的 20 条规则改为 Proxy,包括 `global`/`global_plus`、CDN 及 Facebook 等;x.com/bing.com 当前自然匹配 `global` → Proxy,由用户在 Proxy 中选择出口。Automatic 改为 `url-test`,仅 Proxy 保留其成员引用;AI/Speedtest/NoAuto 已移除 Automatic。具体约束与验证见 [Proxy 与 Automatic 选择链](PROXY-AUTOMATIC-2026-10-04.md)。App Store DIRECT、Apple News 美国及 Apple Relay AI 例外继续保留。原始初始化模板仍有旧组和旧引用;已有生产数据以管理 API 为准,不通过规则 reset 恢复旧分流。 香港、台湾、英国、韩国的地区组**仍处于删除状态**——留着会是空组,在客户端里显示为无可用节点。 兜底的 `🌍 Other` 组也已删除:所有节点都能被现有三个地区组收纳,它恒为空。 +当前组显示顺序为 Proxy、Automatic、All Nodes、美国、新加坡、日本、AI、🧠 AI Nodes、Streaming、Bybit、Mainland、NoAuto。🧠 AI Nodes 为独立收纳全部节点的 select 组,紧接 AI;其选择与 All Nodes 独立保存,见 [AI Nodes](AI-NODES-2026-10-04.md)。两组的节点按完整名称字符串升序输出;当前节点 sortOrder 统一为默认 0,新增节点保持默认值即可沿用名称排序。组显示顺序不决定分流优先级;实际规则保留协议防护与 Unbreak 例外在前,启用的 AI 规则集中置于广告及普通分流之前,见 [AI 与广告优先级](AI-PRIORITY-2026-10-04.md)。 ## 一、给新节点命名(决定它能被哪个组收纳) @@ -57,7 +61,7 @@ const STATION_LABELS: Record = { | 间隔 | `300` | | 容差 | `50` | -「收纳全部节点」+「筛选正则」的组合会生成 `include-all-proxies=true, policy-regex-filter=…`(Surge)、`include-all-proxies: true, filter: …`(Clash)、`use=true, policy-regex-filter=…`(ShadowRocket),三端都是**动态筛选**——之后再加同地区节点会自动进组,不用改配置。 +「收纳全部节点」+「筛选正则」的组合会生成 `include-all-proxies=true, policy-regex-filter=…`(Surge)、`include-all-proxies: true, filter: …`(Clash)、`policy-regex-filter=…`(ShadowRocket),三端都是**动态筛选**——之后再加同地区节点会自动进组,不用改配置。ShadowRocket 不使用 `use=true`,其正则从 App 内全部节点筛选。 各地区的正则(取自原模板,可直接复制): @@ -73,9 +77,9 @@ const STATION_LABELS: Record = { ## 四、兜底组 `🌍 Other`(当前不存在) -原模板有一个 `🌍 Other` 组,用否定前瞻正则收纳「不属于任何已建地区组」的节点。**当前已删除**,因为现有节点都能被美/新两个组收纳,它恒为空。 +原模板有一个 `🌍 Other` 组,用否定前瞻正则收纳「不属于任何已建地区组」的节点。**当前已删除**,因为现有节点都能被美/新/日三个组收纳,它恒为空。 -只有当你加入的节点**不属于任何已建地区组**(比如加了德国节点但没建德国组)时,才需要重建它: +加入尚无地区组的节点时,优先按本文新建对应地区组。若确需恢复 Other,以下仅是历史模板示例,必须先核对目标客户端的正则能力与实际产物: | 字段 | 值 | |---|---| @@ -87,6 +91,8 @@ const STATION_LABELS: Record = { ^((?!港|🇭🇰|香港|HK|Hong|日|🇯🇵|日本|JP|Japan|坡|🇸🇬|新加坡|狮城|SG|Singapore|美|🇺🇸|美国|US|States|American|Remain|Expired|官网|如需|套餐|去除|剩余|距离|Reset|重置|流量).)+$ ``` +否定前瞻不能视为三个客户端都支持的正则;本系统会把 filterRegex 透传给 Clash 的 filter,不自动改写成等价的排除条件。当前简单地区正则的验证结果不覆盖这个历史示例,跨端恢复需先验证,必要时调整数据模型与生成器。 + **如果重建了 Other,每次新增地区组都必须往这个否定前瞻里追加对应关键字**,否则新地区节点会同时出现在自己的组和 Other 里。 ## 五、把新地区加进服务组的可选项 @@ -95,36 +101,33 @@ const STATION_LABELS: Record = { | 组 | 当前成员 | 建议 | |---|---|---| -| `⚡️ Automatic` | SG、US | 加入所有地区组 | -| `🚀 Proxy` | Automatic、SG、US | 加入所有地区组 | -| `🎬 Streaming` | SG、US | 加入解锁能力强的地区(HK/TW/JP) | -| `🤖 AI` | Automatic、US、SG | 加入 JP、US 等 AI 服务可用地区 | -| `🌐 Global Services` | Automatic、US、SG | 同 AI | -| `🍎 Apple` | Mainland、US、JP | 按需加入 HK | +| `⚡️ Automatic`(url-test) | SG、US、JP | 加入所有地区组;仅由 Proxy 引用,不让规则直接指向它 | +| `🚀 Proxy` | Automatic、All Nodes、SG、US、JP | 加入所有地区组 | +| `🎬 Streaming` | SG、US、JP | 加入解锁能力强的地区(HK/TW/JP) | +| `🤖 AI` | US、SG、JP、🧠 AI Nodes | 加入 AI 服务可用地区;不加入 Automatic | +| `🧠 AI Nodes`(select) | 全部节点 | 保持 includeAllNodes,与 All Nodes 独立选择;新增节点自动进入,sortOrder 保持默认 0 | | `🪙 Bybit` | DIRECT、SG、US、JP | 同 Proxy | +| `🎯 NoAuto` | Mainland | 不加入 Automatic | -成员的顺序就是客户端里的排列顺序,**第一个成员是该组的默认选中项**。 +成员的顺序就是客户端里的排列顺序。对于 `select` 组,客户端没有有效的已保存选择时,**第一个成员是默认选中项**;Automatic 为 `url-test`,按测速结果与容差选择成员。 ## 六、地区流媒体解锁规则(可选) -模板原有 `stream_hk`、`stream_jp`、`stream_tw`、`stream_kr`、`stream_eu` 等按地区解锁的规则集。删除地区组时,这些规则被改指向了 `🎬 Streaming`。 +模板原有按地区解锁的规则集。当前 `stream_us` 的域名/IP 两条已指向美国,`stream_jp` 的两条已指向日本;`stream_hk`、`stream_tw`、`stream_kr`、`stream_eu` 的八条规则仍指向 `🎬 Streaming`,相关地区组当前不存在。 如果新建了对应地区组,可以在「规则」页把这些规则的策略改回该地区组,解锁效果更精准: | 规则集 | 建议策略 | |---|---| | `stream_hk`(non_ip + ip 两条) | `🇭🇰 Hong Kong` | -| `stream_jp`(两条) | `🇯🇵 Japan` | +| `stream_jp`(两条) | `🇯🇵 Japan`,当前已应用 | | `stream_tw`、`stream_kr`、`stream_eu` | 对应地区组 | ## 七、验证 1. 「策略组」页:新地区组的**命中**一栏应列出该地区的所有节点 2. 「节点」页:新站点应有独立分段,标题显示国旗 -3. 命令行校验 Clash 产物: - ```bash - curl -s "http:///sub//clash" > /tmp/c.yaml && mihomo -t -f /tmp/c.yaml - ``` +3. 从真实 HTTPS 订阅入口获取各端产物,先确认下载成功及配置结构,再按 [订阅变更与验证](SURGE-CHANGES.md)运行 Surge CLI/规则集快照与独立目录的 `mihomo -t`;结构通过后仍需客户端连接验收。 4. 若重建过 `🌍 Other`,确认它的命中里**不再包含**新地区节点 ## 删除某个地区(反向操作) diff --git a/docs/AI-COLLABORATION.md b/docs/AI-COLLABORATION.md new file mode 100644 index 0000000..bcdc0e8 --- /dev/null +++ b/docs/AI-COLLABORATION.md @@ -0,0 +1,67 @@ +# Codex 与 Claude Code 协作 + +项目开发约定以根目录 `AGENTS.md` 为唯一来源。原 `CLAUDE.md` 中的架构、协议适配、前端设计、数据与部署约定已迁入,并根据当前代码补充了初始化、环境变量、AI DNS 与验证说明。 + +最新生产盘点统一维护在 [CURRENT-STATE.md](CURRENT-STATE.md),较长的 Session 变更证据保存在其链接的专题文档。AGENTS.md 维护持续适用的约束;专题记录保留原阶段数量,不能用旧快照替代新的 API 盘点。 + +## 指令入口 + +| 工具 | 项目指令 | 项目技能 | +|---|---|---| +| Codex | 原生读取根目录 `AGENTS.md` | `.agents/skills//SKILL.md` | +| Claude Code | `CLAUDE.md` 中的 `@AGENTS.md` 导入 | `.claude/skills//SKILL.md` | + +Claude Code v2.1.277 起支持直接读取 `AGENTS.md`,但默认在当前目录或祖先目录已有 `CLAUDE.md`/`CLAUDE.local.md` 时优先读取 Claude 文件。保留 `CLAUDE.md` 的显式导入,使支持导入的旧版本与新版本都能读取同一份规则;无需修改用户全局配置。原生支持不可用的部分旧版本会话也可通过导入加载。 + +文件名使用大写、复数的 `AGENTS.md`。不要新增 `agent.md`、`AGENT.md`、`agents.jmd` 或复制一份独立的 Codex 规则。Claude 的 `@path` 是其导入语法,Codex 项目入口本身包含完整规则,无须依赖此语法。 + +新开会话时从仓库根目录启动工具。Claude Code 可用 `/memory` 或 `/context` 检查项目指令;Codex 可询问当前项目约定,并用 `/skills` 查看发现的技能。若当前会话未发现新技能,重开会话;不需要额外安装项目技能。全局个人指令与机器设置继续由各工具管理,不写入本仓库。 + +## 共用技能 + +保留现有 `.claude/skills/` 作为技能源目录,Codex 入口使用相对目录符号链接。Codex 官方支持扫描符号链接指向的技能目录,脚本及评估样例因此也共用一份: + +```text +.claude/skills/ +├── deploy/ # SKILL.md 与 scripts/deploy.sh +├── update-db/ # SKILL.md、scripts/api.sh 与 evals/ +└── edge-node-multi-protocol/ # 节点部署脚本、参考资料与交付模板 +.agents/skills/ +├── deploy -> ../../.claude/skills/deploy +├── update-db -> ../../.claude/skills/update-db +└── edge-node-multi-protocol -> ../../.claude/skills/edge-node-multi-protocol +``` + +| 任务 | 技能 | Codex 显式调用 | Claude Code 显式调用 | +|---|---|---|---| +| 部署前端/后端到 LA | `deploy` | `$deploy` | `/deploy` | +| 线上数据盘点、导入预览或修改 | `update-db` | `$update-db` | `/update-db` | +| 边缘 VPS 部署、重建、协议维护与交付 | `edge-node-multi-protocol` | `$edge-node-multi-protocol` | `/edge-node-multi-protocol` | + +自然语言请求也可以匹配技能。技能提供操作方法,具体外部操作范围仍以用户任务授权为准;阅读部署文档或修改代码不会自动触发上线。 + +脚本示例统一保留已有 `.claude/skills/` 路径,两种工具都可在仓库根目录执行。同一脚本也可从 `.agents/skills/` 链接路径执行。新增技能时只写一份源目录,并在 `.agents/skills/` 增加相对链接。 + +Git 应保存这些目录链接;在 Windows 上克隆时需要启用符号链接支持,推荐 WSL。若 checkout 将链接变成文本文件,Codex 无法自动发现技能,应恢复目录链接;不要复制出第二份技能。 + +## 维护与接续 + +- 架构或业务约束改动更新 `AGENTS.md`;较长的专题说明放入 `docs/` 并按任务引用。 +- 生产任务先经 update-db 只读盘点,区分登记总量、启用数量、token 范围和客户端排除;当前节点、组成员与显示顺序集中更新 CURRENT-STATE.md,阶段性的数量/备份/验收仍保留在原专题文档。 +- 运维流程改动更新 `.claude/skills//SKILL.md` 及其脚本;`.agents/skills/` 只维护链接。 +- 节点与应用在本仓库统一维护,节点目录与当前舰队见 `edge-nodes/README.md`。通用工具可入库,真实配置/交付产物放 `edge-nodes/private/` 并用受控备份转交;原 `../edge-nodes/` 仅保留迁移前备份。旧工程指令保存在私有归档中,不作为当前约定。 +- 普通开发从 `shared` 的类型/校验、`server` 的 API 与 IR、`web` 的状态与页面定位改动。新增客户端先扩展客户端类型与能力矩阵,再增加独立生成器和订阅端点。 +- 修改规则模板只影响初始化,已有库的配置更新走管理 API;代码部署与数据更新分别执行。 +- 本地验证按改动范围选择类型检查、单测或构建。临时启动与数据库验证在进程启动前设置临时 `DATA_DIR`,避免触碰真实节点数据。 +- 接续时区分本地源码、已部署源码、生产数据库、客户端活动配置/模块及实际网络结果。未提交源码可能已经在用户授权下部署,部署记录不是新一轮上线授权;文档修改也不触发业务写入、订阅刷新或节点启用。 +- All Nodes/AI Nodes 独立选择、零权重名称排序、Proxy 为唯一 Automatic 父组及 AI 规则优先级属于当前业务约定。改组名需跟踪 API 级联引用和客户端保存选择的迁移,不能仅看生成文本通过校验。 +- 交接说明记录已完成改动、验证结果与未完成项;可复用的认识写入项目文件,不依赖某个工具的私有会话记忆。 + +## 官方依据 + +- [Codex:AGENTS.md 加载规则](https://developers.openai.com/codex/guides/agents-md) +- [Codex:技能目录与符号链接支持](https://developers.openai.com/codex/skills) +- [Claude Code:AGENTS.md 支持与共用导入](https://code.claude.com/docs/en/memory#agentsmd) +- [Claude Code:项目技能](https://code.claude.com/docs/en/skills) + +支持行为核对于 2026-10-03;若工具升级后入口行为变化,按上述官方文档重新核对。 diff --git a/docs/AI-NODES-2026-10-04.md b/docs/AI-NODES-2026-10-04.md new file mode 100644 index 0000000..34476ff --- /dev/null +++ b/docs/AI-NODES-2026-10-04.md @@ -0,0 +1,33 @@ +# 🧠 AI Nodes 独立节点选择(2026-10-04) + +本记录包含创建、改名及排序两个阶段;完整最新盘点见 [当前生产状态](CURRENT-STATE.md)。登记节点为 30 个,其中 12 个启用、18 个停用;全节点组仍受启用状态、订阅范围与客户端能力过滤。 + +按用户要求,经生产管理 API 新增名为 `AI Nodes` 的启用 select 组,同日后续添加 emoji 改名为 `🧠 AI Nodes`,显示在 `🤖 AI` 下方。它与 `📋 All Nodes` 一样启用 `includeAllNodes`,没有正则和显式成员,直接收纳订阅范围内、该客户端支持的全部启用节点。 + +`🤖 AI` 的成员顺序现在为美国、新加坡、日本、🧠 AI Nodes;原 All Nodes 引用已替换为该独立组,改名时 API 级联更新引用。All Nodes 继续作为 Proxy 的选项。 + +两个组的节点列表相同,但组名和客户端策略身份独立,分别保存手动选择。用户在 AI 中选择 🧠 AI Nodes 后,再在 🧠 AI Nodes 中选择具体节点,即可让 AI 流量使用独立于 All Nodes 的节点选择。新增或停用节点时,两组由同一订阅生成流程更新,不维护两份静态节点清单。 + +当前组显示顺序:Proxy → Automatic → All Nodes → United States → Singapore → Japan → AI → 🧠 AI Nodes → Streaming → Bybit → Mainland → NoAuto。 + +## 名称排序与 emoji 优化 + +用户进一步要求给 AI Nodes 添加合适的 emoji,并将它与 All Nodes 中的节点按名称字符串排列。`🧠` 表示 AI 专用节点选择,和 `🤖 AI` 主服务策略组区分。 + +已有 IR 按节点 `sortOrder`、完整 `name` 升序读取。原排序权重偏向协议顺序,导致日本、新加坡节点交错;本次经节点管理 API 将 28 个非零权重改为默认 0,现有 30 个节点统一同权重,由完整名称决定顺序。节点名称、凭据、协议与开关不变,更新只影响 sortOrder 和 updatedAt。这也使节点声明及依赖同一节点来源的动态地区列表按名称排列。Web 节点页仍使用其既有站点、协议与出口排序。 + +Surge 的全部节点组继续静态展开支持的节点;Clash 从按名称排列的 proxies 动态收纳;Shadowrocket 的节点订阅原本已经按完整名称排序,conf 继续用 `policy-regex-filter=.*` 收纳 App 内节点。Shadowrocket 中其他订阅或 App 自定义排序的显示次序不由此服务控制。 + +新建或导入节点的 schema 默认 sortOrder 为 0,保持默认即可沿用名称排序;显式设置非零权重会先按权重排列。本次使用既有数据字段和生成流程,未修改或部署生成器代码。优化前另有在线备份:`/opt/proxy-station/data/backups/before-polish-ai-nodes-name-order-20261004-113612.db`,root 属主、600 权限。 + +优化后重读确认实体数量不变,规则与设置完全一致;组只发生名称及引用级联更新,节点只发生权重和更新时间调整。真实 HTTPS Surge 两组各 12 个支持的节点均按完整名称升序排列,Clash 的 proxies 来源与 Shadowrocket 节点订阅同样通过名称排序核对。Surge 官方 CLI、49/49 规则集快照与 `mihomo -t` 再次通过,域名遮蔽提示为 0。刷新本机同一托管配置后,有效配置及两个组的运行时选项都已确认顺序;为改名后的新客户端组身份恢复了之前选择的同一节点,原 All Nodes 选择与 AI 实际路径保持不变。 + +## 创建阶段的备份与验证 + +- 写入前 SQLite 在线备份:`/opt/proxy-station/data/backups/before-add-ai-nodes-selector-20261004-112540.db`,root 属主、600 权限。 +- 重读确认 12 个组、21 个成员、30 个节点、130 条规则;仅新增 AI Nodes、替换 AI 的该项引用及调整显示顺序。其他原组内容保持一致,原节点、规则和设置与变更前相同。 +- 从真实 HTTPS 入口读取 Surge、Clash、Shadowrocket conf,逐端确认 AI Nodes 与 All Nodes 的内容完全一致、AI 只引用 AI Nodes,且新组紧接 AI。Surge 当前两组各有 12 个客户端支持的节点,节点数不是数据库总节点数。 +- Surge 官方 CLI、49/49 远程规则集快照校验及 Clash `mihomo -t` 通过;静态域名遮蔽提示为 0。 +- 本机同一托管配置已确认生效,有效配置与运行时均提供独立 AI Nodes 手动选择组;其运行时节点选项与 All Nodes 完全一致,既有 AI 与 All Nodes 的已保存选择保持不变。没有通过切换节点来做独立性测试。 + +本次只修改生产数据与文档,未部署代码。Shadowrocket 仅检查订阅结构,未做手机真机编译或连接测试;其他设备刷新订阅后取得新组。 diff --git a/docs/AI-PRIORITY-2026-10-04.md b/docs/AI-PRIORITY-2026-10-04.md new file mode 100644 index 0000000..1fd71bc --- /dev/null +++ b/docs/AI-PRIORITY-2026-10-04.md @@ -0,0 +1,46 @@ +# AI、广告与其他规则的优先级(2026-10-04) + +本文保留删除 Speedtest、集中排列 AI 规则时的阶段记录。后续加入 Net.Coffee 规则及独立 `🧠 AI Nodes` 后,现状为 12 组、130 条规则、31 条启用 AI 规则;完整盘点见 [当前生产状态](CURRENT-STATE.md)。 + +用户要求删除 Speedtest 策略组及其规则,调整组显示顺序,并让 AI 优先于广告和普通分流。审阅现有宽泛 googleapis.com AI 规则与协议防护后,用户明确选择保留 STUN 阻断及 Google 更新/翻译直连例外。本次通过管理 API 删除与排序,未修改规则匹配范围、策略、参数或启用状态,未部署代码。 + +## 本阶段策略组显示顺序 + +Proxy → Automatic → All Nodes → United States → Singapore → Japan → AI → Streaming → Bybit → Mainland → NoAuto。 + +前六组相对顺序保持原状。Speedtest 组与其一条 Speedtest RULE-SET 已删除,测速服务继续匹配后续规则。组显示顺序不决定规则优先级;规则从上到下首条匹配生效,pre-matching 另有更高优先级,依据 [Surge 规则说明](https://manual.nssurge.com/rules/overview.html)。此次启用的顶层规则与已下载规则集都没有 pre-matching 参数。 + +## 本阶段实际连接规则顺序 + +1. STUN REJECT,以及 googlevideo.com 的 UDP REJECT-NO-DROP。 +2. Unbreak DIRECT;保留包括 update.googleapis.com、translate.googleapis.com 等 Google 例外。当前规则集与 AI 域名样例的交叉检查没有发现其他 AI 服务域名被 Unbreak 覆盖。 +3. 全部 30 条启用的 AI 规则集中排列,保留原有相对顺序。两条已停用的 AI 规则仍停用,全局 DOH 接管没有启用。 +4. 原有业务例外,如 PayPal 指定美国、Bybit 的 inapps.appsflyer.com、api.amplitude.com 的 Proxy 等。 +5. 广告与隐私规则。四条广告列表的相对顺序保持原状;IP 拦截集原有 no-resolve 参数保留,不新增泛化遥测放行。 +6. 其余服务、地区、国内、CDN 和 global 等规则,彼此相对顺序保持原状。 +7. 唯一启用 FINAL 末尾兜底,仍指向 NoAuto,并保留 dns-failed。 + +这是现有 AI 匹配范围的优先级调整,不将整个共享服务平台扩展为 AI。googleapis.com、api.github.com 及原有共享域名的范围沿用既有配置。外部规则集仍会动态更新,后续业务规则变更需重做覆盖审阅。 + +## DNS 与客户端范围 + +Surge 的 AI DNS 映射仍由连接规则派生;Unbreak 的默认 DNS 映射在 AI 映射之前,AI 域名使用 Cloudflare DoH 并随 AI 出口。手写 Host、节点引导 IP 和 General 均未改动,不改变全局 DNS 或端口 53 的路由。 + +STUN 的 PROTOCOL 规则仍仅在能表达它的客户端下发生效;现有 Clash/Shadowrocket 生成器跳过该应用协议类型,此次没有新增其他客户端的 STUN 防护。Host 不能表达协议/进程/IP 条件,生成配置与静态域名匹配也不能证明设备 DNS/WebRTC 没有泄漏。 + +## 本阶段备份与验证 + +写入前在线备份:`/opt/proxy-station/data/backups/before-remove-speedtest-ai-priority-20261004-105853.db`,root 属主、600 权限。敏感 API 快照和订阅仅保存在私有临时目录。 + +重读确认 11 个组、129 条规则、21 个组成员、30 个节点;除 Speedtest 删除与两个排序外,其余实体字段保持原状。当前没有直接指向 Automatic 的规则,只有 Proxy 引用它,Automatic 仍为 url-test。 + +候选 Surge 配置先通过官方 CLI,写入后从实际 HTTPS 入口读取三个客户端的订阅: + +- 三端组显示顺序均与上述一致,Speedtest 组及引用已消失,AI 均位于广告规则之前。 +- Surge/Shadowrocket 以当前规则集快照检查 126 个 AI 域名样例,全部首条域名匹配 AI;包括 Claude/OpenAI、附件/MCP、Statsig、已确认的 Datadog/Sentry/LiveKit 主机及 api.github.com。Google 更新/翻译例外为 DIRECT,x.com/bing.com 为 Proxy,doubleclick.net 仍 REJECT,Bybit 与 Amplitude 业务例外保持原策略。 +- Surge self URL 正确,Host 中 Unbreak 在 AI 集之前,Statsig 的 AI DNS 映射正确;官方 CLI 与 49/49 规则集快照再次通过,静态域名遮蔽提示为 0。 +- Clash 结构与 `mihomo -t` 通过,FINAL/MATCH 末尾兜底保留。 + +样例检查不模拟 IP、进程、协议、CNAME 或设备缓存。本阶段 Shadowrocket 未做真机编译,所有客户端均未做业务连接、测速或 DNS/WebRTC 实测;未切换本机活动配置或修改设备已保存选择。 + +同日后续已完成本机 Net.Coffee DNS 复测,见 [检测机制与结果](NET-COFFEE-DNS-2026-10-04.md);随后创建独立 `🧠 AI Nodes`、统一节点默认权重为 0,并验证两组完整名称排序与保存选择,见 [AI Nodes](AI-NODES-2026-10-04.md)。这些后续验证不构成手机或其他业务路径的验收。 diff --git a/docs/AI-RULE-REVIEW-2026-10-04.md b/docs/AI-RULE-REVIEW-2026-10-04.md new file mode 100644 index 0000000..4e4c536 --- /dev/null +++ b/docs/AI-RULE-REVIEW-2026-10-04.md @@ -0,0 +1,86 @@ +# 🤖 AI 规则审阅与精确补齐(2026-10-04) + +本文按阶段保留规则补齐及后续删组、调整优先级的证据,数量与选择链均以各段发生时为准。最新规则、组与客户端产物见 [当前生产状态](CURRENT-STATE.md),不按历史阶段恢复旧配置。 + +本次按用户提供的 [Claude 清单](https://ip.net.coffee/claude/site.html) 与 [ChatGPT 清单](https://ip.net.coffee/gpt/site.html) 审阅线上规则。用户选择「按 AI 专用域名精确补齐;共享服务只加入已确认的主机」。已有远程规则集继续自动更新,缺项通过管理 API 添加到同一 `🤖 AI` 策略。规则集 API 只读,不能修改第三方托管文件,也不通过直接写库替换它们。 + +## 原有覆盖 + +| 原有规则 | 审阅结果 | +|---|---| +| Sukka `non_ip/ai.conf` | 实际下载版本为 2026-10-03、53 条;已有 Anthropic、Claude、clau.de、ChatGPT、chat.com、Sora、oaistatic 等域名。`DOMAIN-KEYWORD,openai` 已覆盖所列 OpenAI/Azure/CDN 中包含 openai 的主机,但缺少 oaiusercontent、oaistatsig、MCP 和语音等域名。 | +| Blackmatrix7 `Bing.list` | 9 条,包含 Bing 搜索与相关服务;精确补齐时保留,随后按用户要求移除,见文末后续调整。不是 Claude/OpenAI 专项规则集。 | +| `DOMAIN-SUFFIX,googleapis.com` | 范围包括非 AI Google APIs;保留原业务行为。Unbreak 中靠前的 Google 更新、翻译等 DIRECT 例外继续优先。 | +| Apple Relay 两条后缀及 `apple-relay` 关键词 | 后缀与关键词重叠;保留现有 Apple Intelligence/Relay 行为。 | +| Dia 后缀与关键词 | 停用 `DOMAIN-KEYWORD,diabrowser.engineering`,保留 `DOMAIN-SUFFIX,diabrowser.engineering`,避免关键词匹配域名之外的同名片段。 | +| 已停用的全局 `PROTOCOL,DOH` | 继续停用;仅从 AI 域名规则生成 Surge DNS 映射。 | + +原有 Sukka 集还包含 `api.github.com`、多种其他 AI 服务及历史域名。此次保持原引用,精确原则用于新增规则,不声称整个原有 AI 组已收敛为 Claude/OpenAI 专属。 + +## 新增的 24 条规则 + +以下全部指向 `🤖 AI`。22 条域名规则放在现有 `api.amplitude.com` 之后、第一条广告拦截规则集之前;保留更早的协议阻断、业务例外和 Unbreak。2 条 IP 兜底放在原有 AI/Bing 域名规则之后,均使用 `no-resolve`。其他原有规则的相对顺序保持不变,唯一启用的 FINAL 保持末尾。 + +| 类型 | 匹配值 | +|---|---| +| DOMAIN-SUFFIX | claudemcpclient.com | +| DOMAIN-SUFFIX | claudemcpcontent.com | +| DOMAIN-SUFFIX | claudeusercontent.com | +| DOMAIN-SUFFIX | oaiusercontent.com | +| DOMAIN-SUFFIX | crixet.com | +| DOMAIN-SUFFIX | oaistatsig.com | +| DOMAIN-SUFFIX | chatgpt.livekit.cloud | +| DOMAIN | servd-anthropic-website.b-cdn.net | +| DOMAIN | anthropic.com.cdn.cloudflare.net | +| DOMAIN | anthropic.auth0.com | +| DOMAIN | anthropic-com.ghost.io | +| DOMAIN | browser-intake-us5-datadoghq.com | +| DOMAIN | http-intake.logs.us5.datadoghq.com | +| DOMAIN | client-api.arkoselabs.com | +| DOMAIN | host.livekit.cloud | +| DOMAIN | turn.livekit.cloud | +| DOMAIN | o33249.ingest.sentry.io | +| DOMAIN | o207216.ingest.sentry.io | +| DOMAIN | browser-intake-datadoghq.com | +| DOMAIN | rum.browser-intake-datadoghq.com | +| DOMAIN-SUFFIX | statsig.anthropic.com | +| DOMAIN-KEYWORD | chatgpt-async-webps-prod- | +| IP-CIDR | 160.79.104.0/23 | +| IP-CIDR6 | 2607:6bc0::/48 | + +`statsig.anthropic.com` 原本已经被 Anthropic 后缀覆盖,但更早的广告域名集包含它;这条是优先级例外,而非新增域名覆盖。`browser-intake-datadoghq.com` 及其 `rum` 主机也原先被广告域名集拦截,补充后优先交给 AI。用户选择接受这些已确认 AI 遥测主机,不据此放行所有遥测服务。 + +ChatGPT 动态 Azure 主机的范围参考 [v2fly OpenAI 原始清单](https://raw.githubusercontent.com/v2fly/domain-list-community/master/data/openai)。当前数据模型没有顶层 DOMAIN-WILDCARD;本次使用 ChatGPT 服务专用前缀关键词,避免添加整个 `webpubsub.azure.com` 后缀。关键词匹配不是锚定的完整域名正则,也会匹配其他包含相同字符串的主机;这是一项明确的表达能力限制。它可以在三个现有客户端产物中下发,并触发 Surge 域名 DNS 映射。 + +## 没有照搬的项目 + +- 不新增整个 `sentry.io`、`statsigapi.net`、`intercom.io`、`intercomcdn.com` 或泛化 `datadog`/`sentry`/`sift` 关键词;不将通用 `cdn.usefathom.com` 当作 AI 专属。这些会改变其他应用的流量。只添加上表确认的遥测主机;Claude CLI 的两个 Datadog 主机由 [官方网络要求](https://code.claude.com/docs/en/network-config#network-access-requirements) 确认,OpenAI 的两个 Sentry 与 rum 主机由 [官方网络建议](https://help.openai.com/en/articles/9247338-network-recommendations-for-chatgpt-errors-on-web-and-apps) 确认。 +- Claude 页面提供的 `/21` 和 IPv6 `/32` 比 API 接收请求的网段宽。[Anthropic 官方 IP 文档](https://platform.claude.com/docs/en/api/ip-addresses) 将 inbound 标为 `160.79.104.0/23`、`2607:6bc0::/48`,而 `/21` 是服务端向外发起工具请求的出口范围。本次使用 inbound 范围,不加扩大覆盖的 ASN 兜底。 +- 不添加全局 NTP 分流。页面称 NTP 返回代理所在地时区,这不符合 [RFC 5905](https://www.rfc-editor.org/rfc/rfc5905.html) 的 UTC 时间模型;转发 NTP 也不会修改设备时区。 +- IPv6 `/48` 是路由兜底,不启用客户端或边缘 IPv6。边缘继续遵守 IPv4-only,命中规则不保证 IPv6 字面目标可以连接。 + +## 验证与限制 + +变更前、候选配置与写入后的实际 HTTPS Surge 订阅均通过官方 Surge CLI;每次 60/60 远程规则集快照下载成功并再次通过 CLI,静态域名遮蔽提示为 0。写入前在线备份,新增规则先以停用状态创建、提交全量顺序后逐条启用,再停用 Dia 关键词,逐条核对 HTTP 状态。 + +线上已重读确认规则从 116 增至 140,其中启用 138 条、启用的 AI 规则 31 条;其他原有规则字段与相对顺序、节点、组、设置及规则集引用均未改变。实际 HTTPS 的 Surge、Clash、Shadowrocket conf 均包含全部 24 条新增规则,节点与组产物与变更前一致,FINAL/MATCH 末尾兜底验证通过;Surge 的 self URL 和新增域名 DNS 映射正确。38 个 AI 域名样例的首条域名匹配均为 AI,3 个 Google Unbreak DIRECT 例外仍保留。Clash 实际订阅另通过独立临时目录的 `mihomo -t`;Shadowrocket 为结构验证,未做真机编译。文档变更通过 `git diff --check`。 + +备份:`/opt/proxy-station/data/backups/before-ai-claude-chatgpt-precision-20261004-102138.db`,已收紧为 root 属主、600 权限。含凭据的 API 快照及订阅仅保存在权限受控的私有临时目录,不写入仓库。本次只有生产数据与文档变更,没有代码部署。 + +本次候选校验还发现:`AND,((DOMAIN-SUFFIX,webpubsub.azure.com),(DOMAIN-KEYWORD,chatgpt-async-webps-prod-)),"🤖 AI"` 被本机 Surge CLI 拒绝,同一规则将末尾策略裸写为 `🤖 AI` 则通过。当前生成器为含空格的策略输出引号,因此本次没有写入这条 AND。以后修改逻辑规则生成器时,应单独补原生 CLI 用例并核实带空格策略的处理;本次不部署代码。 + +DNS 映射随新域名规则生成,手写 Host 例外继续优先;IP 规则不生成域名 DNS 映射。现有全局 STUN 阻断可能影响语音 WebRTC/TURN,添加语音域名不表示高级语音已经可用。CLI 和静态覆盖验证不能替代设备上的登录、附件、MCP、语音与 DNS/WebRTC 验收。策略组成员和设备已保存的出口选择均保持现状。 + +## 后续调整:移除 Apple/Global Services 与 Bing 专用分流 + +2026-10-04 按用户要求,经管理 API 删除 `🍎 Apple`、`🌐 Global Services` 两个组,以及指向它们的 9 条规则(apple_cdn、AppleTV、Apple、Twitter、Telegram 域名/IP/ASN、OneDrive、microsoft);另删除 `🤖 AI` 的 Bing RULE-SET。没有将这些专用规则改挂其他策略,后续规则自然接管。用户明确选择保留 `global`/`global_plus` 的 Automatic 策略;当前规则集静态域名匹配确认 x.com、twitter.com、bing.com、www.bing.com 均由 `global` → Automatic。当前 Sukka AI 集不含 Bing/Copilot 名称规则。App Store DIRECT、Apple News 美国、Apple Relay AI 例外继续保留。 + +重读确认组从 14 减至 12、规则从 140 减至 130,组成员剩 25;其余组、规则字段与相对顺序、节点和设置均保持原状,唯一启用 FINAL 仍在末尾。实际 HTTPS Surge、Clash、Shadowrocket conf 均已无这两个组和 Bing 规则引用;Surge self URL 正确,官方 CLI 与 50/50 远程规则集快照校验通过,静态域名遮蔽提示为 0;Clash 的 `mihomo -t` 通过。Shadowrocket 仅验证产物结构,未进行真机编译或业务连接验证。 + +本次写入前在线备份为 `/opt/proxy-station/data/backups/before-remove-apple-global-bing-20261004-103623.db`。只调整生产数据与相关文档,未部署代码或修改初始化模板。 + +同日后续按用户明确要求统一代理入口:原先指向 Automatic 的 20 条规则(包括 global/global_plus)全部改为 Proxy,Automatic 从 select 改为 url-test;AI/Speedtest/NoAuto 去掉 Automatic 成员。因此 x.com/bing.com 的已验证路径变为 global → Proxy;当时 AI 的可选成员为美国、新加坡、日本、All Nodes。该调整覆盖上文“保留 Automatic”的阶段性状态,详见 [Proxy 与 Automatic 选择链](PROXY-AUTOMATIC-2026-10-04.md)。 + +同日再按用户要求删除 Speedtest 组与其一条专用规则,并调整组显示顺序。当时 30 条启用 AI 规则集中排在广告与普通分流之前;用户明确选择保留 STUN 阻断及 Google 更新/翻译直连例外,因此两条协议防护及 Unbreak 在 AI 之前。实际 HTTPS Surge/Shadowrocket 的 126 个 AI 域名样例均首条匹配 AI,Google 例外仍为 DIRECT,49/49 规则集快照与 Surge CLI、Clash 原生校验通过。该阶段为 11 组、129 条规则、21 个成员,详见 [AI 与广告优先级](AI-PRIORITY-2026-10-04.md)。 + +随后新增 `DOMAIN-SUFFIX,ip.net.coffee → AI`,本机 DNS 探测从中国电信变为美国 Cloudflare;再将 AI 中的 All Nodes 替换为独立 `🧠 AI Nodes`,紧接 AI 显示,两个全节点组按完整名称排列且各自保存选择。最新为 12 组、130 条规则、31 条启用 AI 规则,见 [DNS 复测](NET-COFFEE-DNS-2026-10-04.md)与 [AI Nodes](AI-NODES-2026-10-04.md)。 diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md new file mode 100644 index 0000000..eb4b83f --- /dev/null +++ b/docs/CURRENT-STATE.md @@ -0,0 +1,105 @@ +# 当前生产状态与接续入口 + +核对日期:2026-10-04(UTC+8;管理 API 盘点时间 11:46)。来源为 LA 管理 API 与四类实际 HTTPS 订阅;本次仅更新文档,未执行部署或生产业务数据变更。完整响应、token、凭据与配置仅保存在权限受限的本机临时目录。 + +本文件集中维护最新生产盘点;日期化体检与变更报告保留各阶段证据。后续任务先重读 API,再更新这里,不能把模板、历史数量或本机模块当作生产现状。项目协作约定见 [AGENTS.md](../AGENTS.md),技能入口见 [双工具协作](AI-COLLABORATION.md)。 + +## 节点与订阅范围 + +| 站点 | 登记节点 | 启用 | 停用 | 当前启用协议 | +|---|---:|---:|---:|---| +| JP(日本) | 8 | 4 | 4 | VMess/Trojan × Direct/Warp | +| SG(新加坡) | 8 | 4 | 4 | VMess/Trojan × Direct/Warp | +| LA(美国) | 6 | 4 | 2 | VMess/Trojan × Direct/Warp | +| RN(美国) | 8 | 0 | 8 | 无 | +| 合计 | 30 | 12 | 18 | VMess 6、Trojan 6,均为 WS | + +全部 8 个 SS、6 个 HY2 以及 RN 的 4 个 VMess/Trojan 当前停用。停用是应用数据库状态,不代表 VPS 或远端线路已经移除/故障;不因整理文档、部署代码或重新生成交付页而启用。边缘舰队仍为 SG/LA/RN/JP,部署资料与 IPv4-only 约束见 [节点工作区](../edge-nodes/README.md)及 [IPv4 约定](EDGE-IPV4-ONLY.md)。舰队交付线路数与本应用登记/启用数不是同一指标。 + +现有 1 个启用订阅,nodeIds 为空数组,即下发全部启用且客户端支持的节点;没有公开默认 token。Surge、Clash、Shadowrocket 节点订阅当前均输出 12 个节点;这不是客户端只能支持这两种协议的断言。将来启用 SS/HY2 后仍须按 [客户端能力矩阵](../shared/src/protocols.ts)重新核对过滤与连接。 + +所有节点 sortOrder 现为默认 0。[IR](../server/src/services/ir.ts)按 sortOrder、name 升序读取,当前完整名称顺序为日本 → 新加坡 → 美国,同地区内 Trojan → VMess,同协议 Direct → Warp。新增节点保持默认权重即可按名称自然插入;非零权重会改变该全局来源的排序。Web 节点页另按站点、协议与出口展示。 + +## 策略组:显示顺序与有序成员 + +12 个组全部启用,共 21 个显式成员。下表顺序就是数据库 sortOrder,也是生成配置的组显示顺序。 + +| 顺序 | 名称 | 类型 | 成员/动态节点来源 | +|---:|---|---|---| +| 1 | 🚀 Proxy | select | Automatic、All Nodes、新加坡、美国、日本 | +| 2 | ⚡️ Automatic | url-test | 新加坡、美国、日本 | +| 3 | 📋 All Nodes | select | includeAllNodes,全部可用节点 | +| 4 | 🇺🇸 United States | url-test | includeAllNodes+美国名称正则 | +| 5 | 🇸🇬 Singapore | url-test | includeAllNodes+新加坡名称正则 | +| 6 | 🇯🇵 Japan | url-test | includeAllNodes+日本名称正则 | +| 7 | 🤖 AI | select | 美国、新加坡、日本、🧠 AI Nodes | +| 8 | 🧠 AI Nodes | select | includeAllNodes,全部可用节点 | +| 9 | 🎬 Streaming | select | 新加坡、美国、日本 | +| 10 | 🪙 Bybit | select | DIRECT、新加坡、美国、日本 | +| 11 | 🇨🇳 Mainland | select | DIRECT | +| 12 | 🎯 NoAuto | select | Mainland | + +- 普通代理规则指向 Proxy,只有 Proxy 包含 Automatic;没有直接指向 Automatic 的规则。 +- AI 不引用 Automatic。All Nodes 与 AI Nodes 使用独立组身份保存节点选择;两组无正则、无显式成员,节点来源与名称升序列表相同。AI Nodes 紧接 AI,不能通过引用 All Nodes 来实现独立选择。 +- Automatic 和三个地区组的数据库类型均为 url-test,interval=300、tolerance=50。组 testUrl 为 `http://cp.cloudflare.com/generate_204`,仅 Clash/Shadowrocket 使用;Surge 使用 General/节点测速地址。客户端自身模块可能覆盖组类型或参数。 +- select 在没有有效保存选择时使用首个成员;名称排序和改名可能影响这个默认值。已保存的实际选择需在授权的客户端更新任务中保留。 +- FINAL → NoAuto → Mainland → DIRECT。Bybit 的首个候选同为 DIRECT,实际使用仍由客户端保存的选择决定。 + +Apple、Global Services、Speedtest、Other,以及香港/台湾/韩国/英国地区组当前不存在。App Store DIRECT、Apple News 美国、Apple Relay AI 例外保留。原初始化模板还有历史组,修改模板或 reset 规则不能代替定向更新生产数据。 + +## 规则与服务归属 + +数据库有 130 条规则:128 启用、2 停用;登记 62 个规则集,实际配置引用 49 个不同远程 URL。唯一启用 FINAL 位于末尾,保留 dns-failed。 + +连接规则按首条匹配,当前顺序为: + +1. STUN REJECT,以及 googlevideo.com 的 UDP REJECT-NO-DROP。 +2. Unbreak DIRECT,保留已授权的 Google 更新/翻译直连例外。 +3. 31 条启用的 AI 规则集中排列,包含原 AI 集、精确补充域名、Anthropic IP 兜底及 `ip.net.coffee` 后缀。 +4. 业务例外、广告/隐私规则及其余服务、地区、国内、CDN、global 分流。 +5. FINAL → NoAuto。 + +两条停用规则为全局 `PROTOCOL,DOH → AI` 和 `DOMAIN-KEYWORD,diabrowser.engineering → AI`;Dia 的 DOMAIN-SUFFIX 规则仍启用。普通代理入口统一到 Proxy;x.com/bing.com 的已验证路径是 global → Proxy,Bing 专用 AI RULE-SET 已删除。具体域名仍受更早规则及动态外部规则集影响,后续变更须重审遮蔽。 + +直接指向 Streaming 的启用 RULE-SET 共 13 条;相关服务的地区分流与 Emby 归属一并列在下表: + +| 来源 | 策略 | +|---|---| +| Disney、Netflix、TikTok、YouTube、GoogleSearch | 🎬 Streaming,各 1 条 | +| stream_eu、stream_kr、stream_hk、stream_tw | 🎬 Streaming,各域名/IP 两条 | +| stream_us | 🇺🇸 United States,域名/IP 两条 | +| stream_jp | 🇯🇵 Japan,域名/IP 两条 | +| Emby | 🚀 Proxy,1 条 | + +该表表示规则指定策略,不保证某服务的所有请求都走该组;例如先匹配 AI 的 Google API 请求或被协议防护拦截的请求仍执行先前规则。 + +## DNS、客户端差异与验收范围 + +Surge AI DNS 从启用 AI 域名规则派生 Host 到 `https://1.1.1.1/dns-query`,并生成 DoH 跟随出口开关与 1.1.1.1/32 → AI 规则。手写 Host、Unbreak 和默认国内 DNS 例外继续保持顺序。数据库 General 中原始开关为 false,生成配置派生为 true;原文设置与最终产物须分别核对。没有启用全局 DOH/全端口 53 AI 接管,边缘 IPv4-only 约束继续保留。 + +Net.Coffee 的 Claude 页通过随机 `*.d.ip.net.coffee` 记录递归解析器,使用 `DOMAIN-SUFFIX,ip.net.coffee → AI` 覆盖页面及子域。前面 Session 的本机 Chrome+Surge 实测已从中国电信「可能泄露」变为美国 Cloudflare「未检测到泄露」;`dns trace` 确认 Cloudflare DoH。此结果限于该探测路径,不代表其他设备、域名或远端代理解析器已验收。 + +| 实际 HTTPS 产物 | 节点 | 组 | 规则 | 说明 | +|---|---:|---:|---:|---| +| Surge | 12 | 12 | 131 | 128 条启用数据库规则+3 条派生 DNS 出口规则 | +| Clash | 12 | 12 | 126 | 49 个 provider;跳过 STUN 与 SMTP 文本 URL-REGEX | +| Shadowrocket 节点订阅 | 12 | — | — | 完整名称升序的 base64 URI 列表 | +| Shadowrocket conf | — | 12 | 122 | 保守跳过 6 条:STUN、googlevideo AND、reject/download/cdn DOMAIN-SET、SMTP 文本 URL-REGEX | + +HTTP 订阅只有正文,没有 excluded/skippedRules 字段;总节点数与实际节点数不同,要区分停用、token 范围与客户端过滤。Shadowrocket 的全节点正则会收纳 App 内其他订阅,App 排序和其他节点不由本服务控制。 + +前面 Session 最近一次验证已通过 Surge 官方 CLI、49/49 规则集快照及 Clash `mihomo -t`,静态域名遮蔽提示为 0;本机 Surge 已验证新组名、两个列表的名称顺序和独立选择,改名后保留原节点及 AI 路径。本轮仅重读最新数据/产物并验证文档一致性,没有重复进行网络泄漏测试。iOS/Android 真机编译、节点连接、UDP、完整业务与隐私验收仍有未完成项。 + +## Session 变更记录与接续 + +| 主题 | 记录 | 后续维护要点 | +|---|---|---| +| Surge 体检与防护修复 | [体检](SURGE-AUDIT-2026-10-03.md)、[修复](SURGE-FIX-2026-10-03.md) | 保留 API 校验、FINAL、引用图、配置编码及 Host 顺序防护 | +| 移动端生成器体检与已授权后端部署 | [移动端体检](MOBILE-CLIENT-AUDIT-2026-10-04.md) | 能力矩阵与生成器范围独立检查,保留手机真机待验项 | +| AI 精确补充与删除 Apple/Global/Bing 专用分流 | [AI 规则审阅](AI-RULE-REVIEW-2026-10-04.md) | 精确补充主机;历史阶段保留 Automatic 的记录已被后续 Proxy 入口调整覆盖 | +| Proxy 统一普通代理入口、Automatic 测速 | [选择链](PROXY-AUTOMATIC-2026-10-04.md) | Proxy 为唯一 Automatic 父组,AI/NoAuto 独立 | +| 删除 Speedtest、AI 优先于广告 | [AI 优先级](AI-PRIORITY-2026-10-04.md) | 该阶段为 11 组/129 规则,后续新增项以本文件为准 | +| Net.Coffee 探测域名与 DNS 复测 | [DNS 复测](NET-COFFEE-DNS-2026-10-04.md) | 覆盖随机子域,区分网站探测与 Claude 实际域名 | +| 独立 AI Nodes、emoji 与名称排序 | [AI Nodes](AI-NODES-2026-10-04.md) | 独立 select、同节点来源、默认零权重、保留客户端选择 | + +备份文件名与原阶段验收保留在各专题记录中,不清理远端备份。新增业务约束更新 AGENTS.md;当前盘点更新本文件;操作方法更新对应专题。代码部署、生产数据更新和设备配置更新分别核对授权,纯文档整理不触发这些动作。 diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index fbe8d2d..99c2b03 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -2,6 +2,10 @@ 服务器资源有限(1 核/1.9G),采用 Node 直跑 + systemd 守护,前端在本机构建后由服务端静态托管,反向代理与证书由 aaPanel 管理。 +本文描述 Proxy Station 应用发布。边缘 VPS 的部署、重建与协议维护已合入本仓库,见 [节点工作区](../edge-nodes/README.md) 与 `edge-node-multi-protocol` 技能;其私有资料不随应用部署上传。LA 同时承载节点与本应用,维护 nginx、证书或 WARP 时需核对共享服务影响。 + +当前配置与变更接续入口见 [生产状态](CURRENT-STATE.md)。应用部署只更新代码/产物,不重置生产数据库,不恢复已停用节点,也不自动修改客户端的活动配置与保存选择。 + ## 拓扑 ``` @@ -22,7 +26,7 @@ ## 更新代码 -日常更新用项目级 Skill `deploy`(对 Claude 说「部署」即可触发),也可直接跑脚本: +日常更新用 Codex/Claude Code 共用的项目级技能 `deploy`(说「部署」即可匹配;也可分别用 `$deploy`/`/deploy` 显式调用),也可在仓库根目录直接跑脚本: ```bash bash .claude/skills/deploy/scripts/deploy.sh all # 前后端一起(默认) @@ -36,24 +40,30 @@ SSH 别名必须用小写 `la`,大写会被当作域名解析。 ## 迁移数据库 -用 sqlite3 在线备份导出一致副本,避免直接拷贝带 WAL 的库文件: +普通节点、组、规则与设置调整使用 update-db 管理 API,并先做生产 SQLite 在线备份。整库迁移/恢复只属于用户明确授权的相应任务;按技能恢复流程安排停服窗口、回滚备份和后续 HTTPS 验收,不用替库代替定向业务修改。 + +需要从本机导出时,使用在线备份取得一致副本,文件保存在私有临时目录: ```bash -sqlite3 server/data/proxy-station.db ".backup /tmp/ps.db" -scp /tmp/ps.db la:/tmp/ps.db -ssh la 'systemctl stop proxy-station && install -m 644 -o proxy-station -g proxy-station /tmp/ps.db /opt/proxy-station/data/proxy-station.db && rm -f /opt/proxy-station/data/proxy-station.db-wal /opt/proxy-station/data/proxy-station.db-shm /tmp/ps.db && systemctl start proxy-station' +umask 077 +migration_dir="$(mktemp -d /private/tmp/proxy-station-migration.XXXXXX)" +sqlite3 server/data/proxy-station.db ".backup $migration_dir/proxy-station.db" +chmod 600 "$migration_dir/proxy-station.db" ``` +远端备份确认 root 属主、600 权限;恢复后的活动数据库属运行用户,保留仅必要的访问权限。不要直接拷贝运行中的主库而漏掉 WAL,也不把含真实凭据的库放进 Git 或共享日志。 + ## 反向代理注意点 - aaPanel 的反代模板不带 `X-Forwarded-Proto`/`X-Forwarded-Host`,已手工补进反代配置。服务端 `routes/sub.ts` 的 `baseUrl()` 依赖这两个头还原外部地址,缺失时 Surge 的 `#!MANAGED-CONFIG` 与 Clash 的 provider URL 会变成 `http://`。 -- 站点通过面板类 `AddSite`/`SetSSL`/`CreateProxy`/`HttpToHttps` 创建(脚本模式见 edge-nodes 的 `09-aapanel-sites.py`),不要手写 vhost,否则面板里看不到。 +- 站点通过面板类 `AddSite`/`SetSSL`/`CreateProxy`/`HttpToHttps` 创建(脚本模式见本仓库 `.claude/skills/edge-node-multi-protocol/scripts/09-aapanel-sites.py`),不要手写 vhost,否则面板里看不到。 - 泛域名 `*.la.njcqtechaicoding.com` 的 DNS 已指向服务器,新子域无需单独加记录。 - acme.sh 的 `--reloadcmd` 要把新站点加进复制循环,且证书源路径必须写死(原先引用未定义的 `$SRC`,续期后不会重新部署证书)。 ## 验证 ```bash -curl -s https://ps.la.njcqtechaicoding.com/api/health -curl -s "https://ps.la.njcqtechaicoding.com/sub//clash" > /tmp/c.yaml && mihomo -t -f /tmp/c.yaml +curl -fsS --max-time 10 https://ps.la.njcqtechaicoding.com/api/health ``` + +部署脚本核对服务状态、源码/前端指纹、公网健康及管理接口鉴权。涉及生成器或 shared 后,还要读取最新节点范围与真实 HTTPS 订阅,按 [订阅验证](SURGE-CHANGES.md)检查 Surge/Clash 及受影响的 Shadowrocket 产物;先确保下载成功及文件非空,不能把 mihomo 为缺失文件创建的默认配置当成验收结果。与部署前候选比对时使用同一最新生产快照。 diff --git a/docs/EDGE-IPV4-ONLY.md b/docs/EDGE-IPV4-ONLY.md new file mode 100644 index 0000000..76fe383 --- /dev/null +++ b/docs/EDGE-IPV4-ONLY.md @@ -0,0 +1,28 @@ +# 边缘舰队 IPv4-only + +2026-10-03 按用户授权对 SG、LA、RN、JP 四台现役节点统一禁用 IPv6。目标是经任何代理线路访问目标时,直连与 WARP 均只使用 IPv4;IPv6 地址及仅有 AAAA 的目标直接失败,不回退到 IPv6。 + +## 三层约束 + +1. 系统:`/etc/sysctl.d/99-z-proxy-station-ipv4-only.conf` 将 `net.ipv6.conf.all.disable_ipv6`、`default.disable_ipv6`、`lo.disable_ipv6` 设为 `1`。`proxy-station-ipv4-only.service` 已启用,在 `network-online.target` 之后、Xray/sing-box/WARP 之前重新应用。检查每个接口,不只检查 `all`;`ip -6 addr` 与 `ip -6 route` 应无地址/路由。 +2. 代理:Xray 的每个 freedom 出站使用 `settings.domainStrategy=ForceIPv4` 与 `streamSettings.sockopt.domainStrategy=ForceIPv4`,内置 DNS 使用 `queryStrategy=UseIPv4`;`::/0` 拒绝规则在 WARP 分流之前。持久配置写在 3x-ui 数据库的 `xrayTemplateConfig`,不能只改生成的 `bin/config.json`。sing-box 在路由开始先 `resolve`(`ipv4_only`),再拒绝 `::/0`,之后才进入原有 direct/WARP 规则;DNS 经 IPv4 UDP 服务器,QUIC 入站监听 `0.0.0.0`。 +3. DNS:节点根域、通配符及其他节点子域不保留 AAAA,A 记录为 DNS-only。私有 `config.env` 的 `SERVER_IPV6` 清空,防止重新创建 AAAA;清空变量不会删除已存在的记录。 + +WARP 在用户态代理隧道中仍有双栈能力,因此主机没有公网 IPv6 也不能替代代理层限制。Xray 的 `UseIPv4` 允许解析失败后回退;当前策略使用 `ForceIPv4`。依据:[Linux 内核 sysctl](https://www.kernel.org/doc/html/latest/networking/ip-sysctl.html)、[Xray sockopt](https://xtls.github.io/config/transports/sockopt.html)、[sing-box resolve](https://sing-box.sagernet.org/configuration/route/rule_action/#resolve)。 + +## 维护与回滚 + +通用 `08-xui-bootstrap.py` 与 `13-singbox-config.py` 已保留代理层防护;前者扩展已有模板时也应用 IPv4-only,后者需要 sing-box 1.12+。新建/重建服务器还须配置上述系统 sysctl 与开机服务,不要只依赖生成器。当前私有脚本工作副本已同步相关修改。 + +本次每台机器先通过 SQLite backup API 在线备份 3x-ui 数据库,并打包受影响配置、系统网络设置、systemd 单元、nginx、acme.sh 与 WARP 状态。备份在远端 `/root/ipv4-only-backup-/`,具体路径、Cloudflare 删除前的记录及验收结果在 Git 忽略的 `edge-nodes/private/ipv4-only-20261003/`。不要将这些内容公开托管或入 Git。证书续期动作未修改。 + +回滚应读取本机的私有维护记录和该服务器快照,仅恢复受影响的 3x-ui 模板、sing-box 配置及 sysctl/开机单元;不要用整个目录快照覆盖后来新增的配置。恢复 IPv6 或 AAAA 须有用户授权。 + +## 本次验证与边界 + +- 四台服务器全部接口的 `disable_ipv6=1`,无 IPv6 地址;持久文件与开机服务均已配置。未进行整机重启验收。 +- 服务器侧真实协议客户端验证 33 条线路(SG 8、LA 6、RN 11、JP 8):IPv4 出口正常,direct 的 `warp=off`、WARP 的 `warp=on`;IPv6 字面地址与仅 IPv6 域名均失败。 +- 从本机经公网验证线路:Trojan/Hysteria2/TUIC/SOCKS5 使用 sing-box,VMess/SS over WS 使用 mihomo。临时客户端只监听本机回环,不修改活动代理配置。具体结果见私有记录。 +- Cloudflare 删除 SG、LA 的 4 条 AAAA;RN、JP 原本无 AAAA。实际 HTTPS Surge 订阅通过官方 CLI 校验。 + +代理入站使用的 QUIC UDP 已做真实连接验证;未逐条进行被代理 UDP 负载、手机真机或本地直连 IPv6 泄漏验收。这项策略限制服务器代理转发,不改变客户端绕过代理的本地网络行为。当前不需要通过 VPS 厂商面板才能达到代理仅 IPv4 的目标。 diff --git a/docs/MOBILE-CLIENT-AUDIT-2026-10-04.md b/docs/MOBILE-CLIENT-AUDIT-2026-10-04.md new file mode 100644 index 0000000..862d03c --- /dev/null +++ b/docs/MOBILE-CLIENT-AUDIT-2026-10-04.md @@ -0,0 +1,73 @@ +# Shadowrocket/ClashMeta 体检:2026-10-04 + +本文保留体检开始时的快照、代码修复及随后已授权部署的证据。下文 18 个启用节点、14 个组等数量属于历史阶段;最新为 12 个启用节点、12 个组,完整盘点见 [当前生产状态](CURRENT-STATE.md)。 + +体检阶段只读盘点生产数据、拉取实际 HTTPS 订阅,对照官方资料审阅生成器,并在本地修复已确认的序列化问题。当时没有部署、修改生产业务数据、提交或推送;订阅访问会正常更新访问统计,自托管规则资源拉取可更新既有缓存。用户后续授权的部署及验收见末尾记录。 + +## 之前检查到哪里 + +[2026-10-03 验证记录](SURGE-FIX-2026-10-03.md) 已包含实际 Clash 订阅的 `mihomo -t`,以及 Shadowrocket conf 的 HTTPS 响应与候选一致性。已有测试覆盖两端的 PROTOCOL 处理、Surge AI DNS 不误传给其他端,以及 Shadowrocket 策略名不加引号;还没有两端节点、组、规则的系统体检或 iOS/Android 真机验收记录。 + +## 官方依据及边界 + +- Android 的目标是 **ClashMeta/mihomo**,不是早期 Clash 内核。依据 [mihomo 节点通用字段](https://wiki.metacubex.one/config/proxies/)、[VMess](https://wiki.metacubex.one/config/proxies/vmess/)、[SS](https://wiki.metacubex.one/en/config/proxies/ss/)、[TLS](https://wiki.metacubex.one/config/proxies/tls/)、[Hysteria2](https://wiki.metacubex.one/config/proxies/hysteria2/)、[策略组](https://wiki.metacubex.one/config/proxy-groups/)、[url-test](https://wiki.metacubex.one/config/proxy-groups/url-test/)、[fallback](https://wiki.metacubex.one/config/proxy-groups/fallback/)、[规则](https://wiki.metacubex.one/config/rules/)、[规则资源](https://wiki.metacubex.one/config/rule-providers/content/),必要时核对 [官方规则解析源码](https://github.com/MetaCubeX/mihomo/blob/Meta/rules/parser.go)。本机验证内核为 mihomo 1.19.32;它不能代替用户 Android App 内核版本及 VPN 接管测试。 +- Shadowrocket 官方来源从 [App Store 开发者页面](https://apps.apple.com/us/app/shadowrocket/id932747118) 指向 [shadowlaunch.com](https://shadowlaunch.com/)。开发者站只提供产品与联系入口,本次没有找到可核实的完整官方配置语法手册。`help.shadowrocket.net` 本次不可访问,也不能从开发者入口验证其归属;第三方手册不冒充官方依据。官方更新记录明确提到逻辑规则、QUIC 匹配和 Hysteria2,足以纠正“客户端不支持逻辑规则”的绝对断言,但不足以证明某一 Surge 写法可以直接透传。 +- SS 插件传输的依据为 [Shadowsocks 官方 SIP003](https://shadowsocks.org/doc/sip003.html):插件只转发 TCP。不能因客户端支持 UDP 就宣称 nginx WS 同端口也提供 SS UDP 服务。 + +## 体检开始时生产快照(历史) + +| 项目 | 结果 | +|---|---| +| 节点 | 30 个,18 个启用,12 个停用 | +| 实际订阅节点范围 | 日本、新加坡、美国各 6 个;VMess/Trojan/SS 各 6 个,均为 WS;当时 HY2 与 RN 节点停用 | +| 组与规则 | 14 个启用组;116 条规则,其中 115 启用;62 个规则集记录 | +| Shadowrocket 节点订阅 | 18 个,排除 0 个;原顺序中 JP/SG 按协议交错 | +| Shadowrocket conf | 14 个组,108 条规则,跳过 7 条 | +| Clash | 18 个节点,14 个组,113 条规则,60 个 provider;跳过 2 条 | + +四类实际 HTTPS 产物均成功拉取;修复前本地生成的 Surge/Clash/Shadowrocket 节点订阅/conf 与线上逐字节一致,确认审阅的是当前下发逻辑。真实密码、UUID、域名、Host IP、token、配置和快照只保存在权限受限的临时目录,不进入本报告或 Git。 + +## 已修复的问题 + +| 问题 | 修复及实际影响 | +|---|---| +| Shadowrocket JP/SG 混排 | 节点订阅对完整名称做确定性的字符串升序排序,不改变 IR、数据库排序或其他客户端顺序。当前顺序为 JP → SG → US;每国内 SS → Trojan → VMess,同协议 Direct → Warp。未来启用 HY2 时按名称自然排入 | +| Clash 未启用 UDP | 原产物 18 个节点均省略 `udp`,VMess/Trojan 默认不能承载 UDP。现在 12 个 VMess/Trojan 显式 `udp: true`;6 个 SS WS 显式 `false`。纯 TCP SS 和 HY2 的占位用例也覆盖;UDP 是否成功穿过远端仍需连接验证 | +| Clash 丢失 provider 的 no-resolve | 当前 IP reject RULE-SET 的 `no-resolve` 被丢弃,可能提前解析。现在保留这个参数,不改变规则顺序或业务策略 | +| Clash 逻辑规则只做文字替换 | 原来任意 PROTOCOL 都变成 NETWORK,遇到 STUN/DOH 子条件会产生无效规则,还可能改写匹配值中的文字。现在按结构递归映射 TCP/UDP,支持明确的等价子类型,任一子条件无法表达时跳过整个表达式;保留当前 googlevideo UDP AND 规则 | +| 动态组丢失显式成员 | Clash 正则分支、Shadowrocket 正则/全节点分支原来会丢节点、组或内置成员;现在保留完整有序显式成员,并继续动态收节点。当前生产动态组没有显式成员,所以这是潜在缺陷修复 | +| 测速容差 | 两端 url-test 显式零容差不再被省略;fallback 不输出 url-test 专用 tolerance。当前生产容差为 50,不受零值修复影响 | +| Clash TLS/VMess 字段 | 补上 VMess TLS 与 SS plugin 内层的 skip-cert-verify 映射;VMess IETF cipher 别名转回 mihomo 的名称,未知 cipher 明确排除并过滤组引用。当前节点均 auto、skipCertVerify=false,因此这些修复不额外排除生产节点 | + +Shadowrocket conf 的动态筛选仍以 App 内全部节点为来源;显式成员保留的文本和结构经过测试,但没有 iOS 编译器的实际验证。Clash 正则仅作用于动态引入来源,不能把显式成员误当作需要筛掉的动态节点。 + +## 仍存在的客户端差异与待验项 + +1. **体检时 Shadowrocket 的 7 条规则缺口**:本生成器保守跳过 `PROTOCOL,STUN`、googlevideo 的 UDP AND、4 个 DOMAIN-SET(reject/apple_cdn/download/cdn)和 SMTP 文本 URL-REGEX。同日后续删除 apple_cdn 规则后,最新产物跳过 6 条,生成器范围没有因此扩大。对应广告拦截、CDN 策略和 UDP 防护不能声称与 Surge 等价。官方版本记录表明逻辑规则支持实际存在;后续应以 App 内帮助和 iOS 编译/匹配日志核实具体语法,再缩小跳过范围。本次已修正源码注释和 AGENTS.md 的错误能力断言。 +2. **Shadowrocket RULE-SET,LAN 与 PROCESS-NAME**:当前产物保留一个 LAN 内置引用及一个 macOS 进程名规则,尚无官方语法证据或 iOS 匹配结果。56 个远程规则集 URL 可达并不证明这两个本地规则有效;iOS 也不能据 macOS 进程名期待相同匹配。 +3. **Clash 的 2 条缺口**:STUN 应用协议识别没有 NETWORK 等价物;SMTP 文本 URL-REGEX 被跳过。当前 googlevideo UDP AND 已正确转换。不能用全局拒绝 UDP 代替 STUN 规则,否则会改变业务。 +4. **SS WS 没有插件 UDP 转发**:选到这类节点时,UDP 无法由 WS 线路承载。mihomo 会继续向后匹配;可能走其他策略或 FINAL。未擅自新增全局 UDP 阻断、UDP-over-TCP 或改变默认选择。需在设备侧分别核对 VMess/Trojan 与 SS 候选的 UDP 行为。 +5. **DNS 与原文段落不等价**:Clash 使用自己的 fake-ip/DoH 配置,不读取 Surge Host、重写、MITM、Script;Shadowrocket 仅复制 General 白名单及 Host/URL Rewrite/MITM,不下发 Header Rewrite/Script。Surge 自动 AI DNS 映射不会应用到两端。当前保留的 Host/MITM 语法、证书安装、DNS/WebRTC 行为仍需设备侧检查。 +6. **节点 URI 的扩展字段**:VMess/Trojan 分享链接编码未完整保留 skipCertVerify;HY2 URI 未表达 up/down 限速;SS plugin 的独立 SNI/WS Host 不一定能同时表达。HY2 解析目前还把 pinSHA256 当成跳过证书验证,却没有保存指纹,应在后续协议字段修复中拒绝或完整保留这类输入。当前启用节点 skipCertVerify 全 false、HY2 停用,18 个节点的实际连接字段往返核对通过;这不构成这些潜在输入的支持证明。 +7. **特殊字符与资源语义**:Shadowrocket 正则内逗号、规则扩展参数及复用 Surge 原文段落没有完整官方语法验证;Clash 的 Go 正则也不完全等同 JS 正则。当前地区正则只是普通或分支,可以正常解析。映射到不同维护者的规则列表存在内容差异;资源可达/语法通过不保证逐条业务语义与 Surge 相同。 + +## 体检阶段验证 + +- 全仓类型检查通过,51 项 Vitest 通过;新增测试覆盖四种节点协议、完整名称排序与 IR 不变、动态组显式成员、零容差、provider 命名冲突/no-resolve、逻辑子规则映射/整条跳过、TLS/cipher、排除后的组引用,并调用本机 mihomo 原生解析器。 +- 线上旧 Clash 配置与本地修复候选都通过 `mihomo -t`。60/60 Clash provider 下载成功;把当前资源展开为 152,295 条辅助规则后也通过原生解析,覆盖资源内的 IP-ASN 等类型。展开检查用于核对子规则语法,不替代 Android provider 初始化、域名匹配语义或规则更新行为。 +- Shadowrocket 18 个 URI 与原始节点的相关协议字段逐项核对,名称顺序正确;14 个组、108 条规则引用完整,FINAL 唯一且末尾,没有 `use=`。56 个远程规则集属于 Surge 检查已下载的资源。没有官方 iOS CLI,所以这是结构和序列化检查,不能称为 iOS 编译通过。 +- 实际 HTTPS Surge 运行 `scripts/check-surge.py --fetch-rulesets`:官方 CLI OK,60/60 资源下载,本地快照 CLI OK,静态域名遮蔽提示 0。候选 Surge 与实际产物一致。 +- `git diff --check` 通过。未切换活动配置、启动代理服务、导入 CA、测速或测试节点连接;iOS/Android 的订阅更新、编译、默认选择、规则命中、DNS/WebRTC、TCP/UDP 连接仍需真机验收。 + +后续本地变更至少执行 `pnpm -r typecheck`、`pnpm test`;Clash 使用独立临时目录 `mihomo -t -d <目录> -f <配置>`。涉及真实订阅仍按 [Surge 变更验证](SURGE-CHANGES.md) 检查实际 HTTPS 产物,生产部署和数据变更分别遵循 deploy/update-db 技能。 + +## 后续部署与验收:2026-10-04 + +用户明确授权后,通过 deploy 技能执行 `deploy.sh server --skip-checks`。跳过脚本内重复检查前,已重新完成全仓类型检查、51 项测试、最新生产快照的候选 Surge/Clash 解析与规则集快照校验。 + +- 部署完成:`server @ 349ab83`,包含工作区未提交的修复;后端源码指纹前缀 `b3aaeb92cba2` 与远端一致,旧源码保留为 `src.old`。systemd active、公网健康及未带 token 的管理接口返回 401 均正常。 +- 部署前最新生产节点范围已变为 12 个:日本/新加坡/美国各 4 个 VMess/Trojan 节点。本次保留当前启用状态,没有恢复体检快照里的其他节点或修改业务数据。 +- 部署后四类实际 HTTPS 订阅与最新已验证候选逐字节一致。Shadowrocket 的 12 个节点按完整名称升序排列,国家顺序 JP → SG → US,国内 Trojan → VMess,同协议 Direct → Warp。 +- 实际 Surge:官方 CLI OK、60/60 规则资源下载成功、本地快照 CLI OK、静态域名遮蔽提示 0。实际 Clash:mihomo 解析通过。 + +设备需要重新更新节点订阅;此次确认的是线上响应顺序,尚未操作 iOS/Android 客户端。前述客户端能力缺口与真机验收边界仍然适用。没有创建 Git 提交或推送。 diff --git a/docs/NET-COFFEE-DNS-2026-10-04.md b/docs/NET-COFFEE-DNS-2026-10-04.md new file mode 100644 index 0000000..fcb87f8 --- /dev/null +++ b/docs/NET-COFFEE-DNS-2026-10-04.md @@ -0,0 +1,48 @@ +# Net.Coffee Claude 页面 DNS 检测与复测(2026-10-04) + +本文保留当日页面脚本和本机实测证据;后续独立 AI Nodes 与当前订阅范围见 [当前生产状态](CURRENT-STATE.md)。页面实现及解析器出口可能变化,后续复测仍需重新核对。 + +用户要求检查 [Claude 检测页](https://ip.net.coffee/claude/) 的 DNS 检测机制,若使用该站域名,则将它加入 AI 分流并在本机复测。本次仅新增一条生产规则并刷新本机既有托管订阅,未部署应用代码。 + +## 实际检测机制 + +检查当日页面引用的 [claude-page.js](https://ip.net.coffee/claude/claude-page.js?v=20260916a),`detectDNSLeak()` 的实现为: + +1. 用 `Math.random()` 生成本次测试 token。 +2. 依次加载 `https://-1.d.ip.net.coffee/pixel.gif` 与 `https://-2.d.ip.net.coffee/pixel.gif`,通过新主机名触发 DNS 查询。 +3. 等待后查询同源 `/api/dns/result/`,读取 `dns_servers` 数组,即该站权威 DNS 观察到的递归解析器 IP。 +4. 查询这些解析器的地理信息;若其中有中国解析器,而页面认定的 Claude 出口不在中国,则显示「可能泄露」。此判断不要求 DNS 解析器 IP 等于 Claude 连接出口 IP,也不要求两者来自同一家运营商。 + +源码前面的注释仍提到 ipleak.net,但实际请求已使用该站自托管的 `d.ip.net.coffee`。检测没有查询 Claude 域名本身;在按域名分流的环境里,探测域名使用普通国内 DNS,不能单凭此结果认定 Claude 域名也使用国内 DNS。若接口未返回解析器,该页面会显示「DNS 加密或未暴露出口」;空结果也不能证明没有泄漏。 + +## 生产规则 + +经管理 API 新增启用的自定义规则: + +```ini +DOMAIN-SUFFIX,ip.net.coffee,"🤖 AI" +``` + +后缀匹配同时覆盖页面根域和随机探测子域,精确 `DOMAIN,ip.net.coffee` 无法覆盖后者。新规则放在现有 AI 规则段末尾、广告和普通业务分流之前;既有协议防护、Unbreak、其他规则的相对顺序及唯一启用的末尾 FINAL 保持原状。 + +Surge 生成器由该规则自动派生: + +```ini +ip.net.coffee = server:https://1.1.1.1/dns-query +*.ip.net.coffee = server:https://1.1.1.1/dns-query +``` + +已有 `encrypted-dns-follow-outbound-mode` 与 `1.1.1.1/32 → AI` 规则使该 DoH 查询随 AI 出口。没有修改全局 DNS、手写 Host、策略组成员或节点。 + +## 备份与验收 + +写入前在线备份:`/opt/proxy-station/data/backups/before-add-ai-net-coffee-dns-20261004-111830.db`,已收紧为 root 属主、600 权限。敏感 API 快照、实际订阅和活动配置备份仅保存在权限受限的本机临时目录。 + +- 重读确认规则从 129 条变为 130 条,仅新增上述规则;原规则字段和相对顺序保持一致,组、节点及设置重读与变更前相同。 +- 从实际 HTTPS 入口拉取 Surge 与 Clash 订阅,核对托管 self URL、新规则和两条派生 Host 映射。 +- `scripts/check-surge.py --fetch-rulesets` 通过官方 Surge CLI 和 49/49 规则集快照校验,静态域名遮蔽提示为 0;Clash 的 `mihomo -t` 通过。 +- 本机原活动配置的托管 URL 与此次生产订阅一致。备份后用 `surge-cli managed-profile update` 更新同一托管配置,并在重载生效后确认有效配置包含新规则及 DNS 映射,原 AI 选择保持不变。 +- 本机 `rule explain` 确认根域与探测子域命中 AI;`dns trace` 确认探测子域使用 `https://1.1.1.1/dns-query`。 +- 同一个 Chrome 检测页修改前刷新显示中国电信 DNS「可能泄露」;修改后刷新显示美国 Cloudflare DNS「未检测到泄露」,Claude 连接出口保持原美国线路。WebRTC 显示「已禁用或无泄露」。 + +这是该网站、该浏览器和本机 Surge 的实际 DNS 复测;其他设备、其他客户端及远端代理解析器没有在此次测试中验收。网站测试实现与外部规则集后续可能变化,维护时应重新检查探测主机名。 diff --git a/docs/PROXY-AUTOMATIC-2026-10-04.md b/docs/PROXY-AUTOMATIC-2026-10-04.md new file mode 100644 index 0000000..0389a77 --- /dev/null +++ b/docs/PROXY-AUTOMATIC-2026-10-04.md @@ -0,0 +1,41 @@ +# Proxy 与 Automatic 选择链(2026-10-04) + +最新节点范围、组成员及规则数量集中在 [当前生产状态](CURRENT-STATE.md)。下文业务约束已衔接后续修改;操作与备份记录保留统一代理入口时的阶段证据。 + +当前生产配置按用户要求由 Proxy 控制普通代理流量的出口。已有数据库通过管理 API 调整,原始初始化模板未修改,不用规则 reset 恢复旧引用。 + +## 业务约束 + +- 不允许规则直接指向 `⚡️ Automatic`;普通代理规则指向 `🚀 Proxy`。 +- 只有 Proxy 的成员列表包含 Automatic。AI、NoAuto 和其他组不独立引用 Automatic。Speedtest 组与其专用规则在同日后续调整中已删除。 +- Proxy 保持 `select`,成员顺序为 Automatic、All Nodes、新加坡、美国、日本。用户在客户端选择 Automatic 时,匹配 Proxy 的业务流量才沿 Automatic 选出口;选择其他成员则使用对应成员。 +- Automatic 为 `url-test`,保留新加坡、美国、日本三个地区组;这三个地区组本身也为 url-test。沿用 `interval=300`、`tolerance=50`。Surge 用 General 的 `proxy-test-url` 或节点 `test-url`,不输出组级 `url=`;Clash/Shadowrocket 使用组的 `http://cp.cloudflare.com/generate_204`。 +- AI 保持 select,成员为美国、新加坡、日本、🧠 AI Nodes。🧠 AI Nodes 是同日后续新增的独立 select 组,直接收纳全部节点,节点列表与 All Nodes 相同,按完整名称升序输出,两个组各自保存选择;见 [AI Nodes](AI-NODES-2026-10-04.md)。NoAuto 保持 select,只含 Mainland。 +- AI、Streaming、Bybit、直连、拦截和 FINAL 等原有分流继续独立生效;此次没有把全部流量统一改到 Proxy。FINAL 仍指向 NoAuto。 + +以上约束针对业务请求的策略路径,不等于关闭客户端测速或手动测试。 + +当前组显示顺序为 Proxy、Automatic、All Nodes、美国、新加坡、日本、AI、🧠 AI Nodes、Streaming、Bybit、Mainland、NoAuto。同日后续删除 Speedtest、集中排列 AI 规则、加入 Net.Coffee 检测域名并新增 AI Nodes 后,现状为 12 组、130 条规则、21 个成员;详情见 [AI 与广告优先级](AI-PRIORITY-2026-10-04.md)、[DNS 复测](NET-COFFEE-DNS-2026-10-04.md)与 [AI Nodes](AI-NODES-2026-10-04.md)。下文备份与数量记录的是本次统一代理入口时的阶段性状态。 + +## 统一代理入口阶段的数据变更 + +将 20 条原先指向 Automatic 的规则改到 Proxy: + +| 类型/规则集 | 范围 | +|---|---| +| RULE-SET/DOMAIN-SET | Emby、Facebook、Instagram、Threads、Discord、GitHub、substore、CDN 两条、global_plus、global | +| DOMAIN/DOMAIN-SUFFIX | api.amplitude.com、vercel.app、nssurge.com、surgee.me、www.torrentmac.net、cloudflare.com、jable.tv、m.cmx.im、api.themoviedb.org | + +另将 Automatic 类型改为 url-test,从 AI/Speedtest/NoAuto 各移除一个 Automatic 成员。重读确认 12 个组、130 条规则、22 个组成员;没有直接指向 Automatic 的规则,唯一父组为 Proxy。其他规则字段与相对顺序、其他组配置、节点和设置均保持原状,唯一启用 FINAL 位于末尾。 + +x.com、twitter.com、bing.com、www.bing.com 原先由 global 接到 Automatic,当前 global 策略已改为 Proxy;后续出口由 Proxy 的客户端选择决定。Apple/Global Services 组及其专用规则、AI 的 Bing 专用规则仍维持已删除状态。 + +## 统一代理入口阶段的备份与验证 + +在线备份:`/opt/proxy-station/data/backups/before-proxy-gates-automatic-20261004-104820.db`,root 属主、600 权限。敏感 API 快照和实际订阅只存放在权限受控的私有临时目录。 + +实际 HTTPS Surge、Clash、Shadowrocket conf 均确认 Automatic 为 url-test、没有规则直接指向 Automatic、Proxy 为唯一父组、AI/Speedtest/NoAuto 已移除该选项,global 指向 Proxy。Surge self URL 正确;官方 Surge CLI 与 50/50 远程规则集快照校验通过,静态域名遮蔽提示为 0;Clash 结构与 `mihomo -t` 通过,FINAL/MATCH 末尾兜底保留。 + +Shadowrocket 仅做产物结构检查,没有真机编译或连接验收。未切换本机活动配置或修改设备已保存的策略选择;设备需要刷新订阅后获取新配置。此次只修改生产数据与文档,未部署代码。 + +测速参数含义以 [Surge url-test 官方说明](https://manual.nssurge.com/policy-groups/url-test.html) 为准:interval 是结果有效期,Surge 在组使用且结果过期等条件下重测,不表示固定每 300 秒执行;tolerance 用于减少相近延迟引起的反复切换。 diff --git a/docs/SEEDING.md b/docs/SEEDING.md index d136941..b8764c8 100644 --- a/docs/SEEDING.md +++ b/docs/SEEDING.md @@ -1,33 +1,53 @@ # 节点导入指引 -proxy-station 不直接读取 edge-nodes 工作区(那里全是真实凭据,保持解耦),节点通过 Web 界面批量粘贴分享链接导入。 +节点部署管理已迁入本仓库的 `edge-nodes/`,真实配置与交付产物位于 Git 忽略的 `edge-nodes/private/`。Proxy Station 应用运行时不自动读取该目录,节点仍通过 Web 界面批量粘贴分享链接导入。工作区说明见 [edge-nodes/README.md](../edge-nodes/README.md)。 -## 从 edge-nodes 交付文档导入 +最新登记、启用与订阅范围见 [当前生产状态](CURRENT-STATE.md)。已有库导入新节点前先读当前节点并预览,避免把整份交付清单重复导入;部署脚本、分享链接存在或重新生成交付页都不表示该节点应被启用。 -每个节点目录 `-edge-node-docs/` 已生成好分享链接: +## 从本仓库私有交付文档导入 -- `-.link`——VMess/Trojan 的分享链接文件(`vmess://`、`trojan://`) -- Shadowsocks 线路没有通用分享链接,用 sing-box JSON(`ss-*-singbox.json`)里的参数手动添加,或按 SIP002 + v2ray-plugin 格式自拼: +每个节点目录 `edge-nodes/private/-edge-node-docs/` 已生成好分享链接;以当前部署的 `scripts/node-links/` 为准,避免同时混入旧交付链接与重复副本。RN 当前使用 `rn2-edge-node-docs/`,旧 `rn-edge-node-docs/` 只作历史记录。 + +- `-.link`——VMess/Trojan/Shadowsocks 的分享链接文件(`vmess://`、`trojan://`、`ss://`);当前生成器会生成本系统可解析的 Xray 风格 SS over WS 链接 +- 旧交付若只有 Shadowsocks sing-box JSON(`ss-*-singbox.json`),用其中参数手动添加,或按 SIP002 + v2ray-plugin 格式自拼: `ss://base64url(chacha20-poly1305:密码)@域名:443?plugin=v2ray-plugin%3Btls%3Bmode%3Dwebsocket%3Bhost%3D域名%3Bpath%3D%2F路径#名称` -- Hysteria2 线路在 `scripts/node-links/` 下有 `hysteria2://` 链接(含 `obfs=salamander&obfs-password=…`) +- Hysteria2 线路在 `scripts/node-links/` 下有 `hy2://` 或 `hysteria2://` 链接(含 `obfs=salamander&obfs-password=…`),两种 scheme 均可解析 操作: -1. 在 edge-nodes 侧汇总链接:`cat ~/Development/Fusion/Projects/edge-nodes/*-edge-node-docs/*.link` +1. 在仓库根目录收集当前舰队支持的链接(输出含凭据,只在本机使用): + + ```bash + for site in sg la rn2 jp; do + for proto in vmess trojan ss hy2; do + for egress in direct warp; do + link="edge-nodes/private/$site-edge-node-docs/scripts/node-links/$proto-$egress.link" + [ ! -f "$link" ] || cat "$link" + done + done + done + ``` + + 也可从 `edge-nodes/private/边缘节点交付文档.html` 挑选对应线路。 2. 打开 proxy-station「节点」页 →「批量导入」→ 粘贴(每行一条)→「解析预览」确认字段 →「导入」 -3. SOCKS5 线路为内部使用,不要导入本系统 +3. TUIC/SOCKS5 线路不属于本系统当前支持的类型,单独使用,不导入。线上数据任务按 `update-db` 技能先备份再走管理 API ## 命名建议(影响地区组归类) 模板的地区策略组按**节点名正则**筛选(如 `坡|🇸🇬|新加坡|狮城|SG|Singapore`、`美|🇺🇸|美国|US`)。命名时带上地区词与出口词,例如: -- `SG-vmess-direct`、`SG-trojan-warp`(命中 Singapore 组) -- `US-LA-trojan-direct`、`US-RN-vmess-warp`(命中 United States 组;只写 LA/RN 不会命中任何地区组,会落入 Other) +- `🇸🇬 SG VMess Direct`、`🇸🇬 SG Trojan Warp`(命中 Singapore 组) +- `🇯🇵 JP VMess Direct`(命中 Japan 组) +- `🇺🇸 US LA Trojan Direct`、`🇺🇸 US RN VMess Warp`(命中 United States 组;只写 LA/RN 不会命中地区正则,当前也没有 Other 兜底组) 名称含 `direct`/`warp` 会自动打出口标签,线路矩阵按此归位(琥珀 = 直连,青 = WARP)。 +保留完整出口词;客户端没有 Web 的出口标签。新节点 sortOrder 保持默认 0,All Nodes/🧠 AI Nodes 从同一来源按完整名称升序收纳。名字决定地区正则,region 填站点码用于 Web 分段,详见 [新增地区](ADDING-REGIONS.md)。 + ## 导入后核对 - 「节点」页线路矩阵:每个站点的协议 × 出口格子是否齐全 - 「策略组」页:地区组的「命中」行是否圈中了预期节点 - 「订阅」页预览:Surge 端确认 SS over WS 节点已被自动屏蔽(预期行为,Surge 不支持该组合) +- 保留已有停用状态;当前 RN、SS、HY2 的停用记录不通过导入或整理清单恢复。明确授权启用后再分别核对各端节点范围、UDP 能力、动态组引用与实际连接。 +- 生产写入前在线备份,完成后 API 重读及真实 HTTPS 订阅校验,按 [Surge 变更验证](SURGE-CHANGES.md)执行;解析/导入成功不能代替设备连接验证。 diff --git a/docs/SURGE-AUDIT-2026-10-03.evidence.json b/docs/SURGE-AUDIT-2026-10-03.evidence.json new file mode 100644 index 0000000..7a9f627 --- /dev/null +++ b/docs/SURGE-AUDIT-2026-10-03.evidence.json @@ -0,0 +1,1453 @@ +{ + "schemaVersion": 1, + "auditDate": "2026-10-03", + "client": { + "product": "Surge Mac", + "version": "6.9.1", + "build": "12290" + }, + "scope": { + "productionMutations": "No management writes, deployments, restarts or device profile switches. Subscription GET updated access metadata normally.", + "sensitiveMaterial": "Raw API snapshots and credential-bearing profiles remain outside the repository; omitted from this evidence." + }, + "productionSnapshot": { + "nodes": 30, + "enabledNodes": 30, + "surgeNodes": 22, + "excludedShadowsocksWebSocketNodes": 8, + "groups": 14, + "groupMembers": 32, + "rules": 116, + "enabledRules": 115, + "customRules": 30, + "rulesets": 62, + "subscriptionTokens": 1, + "settings": 6, + "generatedRules": 118, + "preMatchingRuleCount": 0, + "regionGroupCandidates": { + "United States": 10, + "Singapore": 6, + "Japan": 6 + } + }, + "profile": { + "bytes": 21373, + "sha256": "bc78215ee1a7bd98cb8176973463ffac1c54e7ee2be54752ddb7aa96fcd9bb9a", + "localGeneratorExactByteMatch": true, + "managedSelfUrlMatchesRequest": true, + "nativeSyntaxCheck": { + "exitCode": 0, + "result": "OK" + }, + "nativeCheckWithLocalRulesetSnapshots": { + "exitCode": 0, + "result": "OK" + } + }, + "isolatedReproductions": { + "resetWithCustomRule": { + "createHttpStatus": 201, + "resetHttpStatus": 500, + "error": "UNIQUE constraint failed: rules.id", + "before": { + "total": 90, + "template": 89, + "custom": 1 + }, + "after": { + "total": 1, + "template": 0, + "custom": 1 + } + }, + "disableFinal": { + "createHttpStatus": 201, + "disableHttpStatus": 200, + "nativeExitCode": 1, + "error": "Rules must end with FINAL" + }, + "nodeNameContainingEquals": { + "createHttpStatus": 201, + "nativeExitCode": 1, + "error": "Unknown proxy type: Name = vmess" + }, + "unquotedRegexContainingComma": { + "createHttpStatus": 201, + "nativeExitCode": 1, + "error": "Rule #0 include an unknown policy name: ?c" + }, + "cyclicGroupReferences": { + "createHttpStatuses": [ + 201, + 201 + ], + "nativeExitCode": 1, + "error": "Loop reference is found in the policy group: A." + }, + "filteredGroupDropsExplicitDirect": { + "nativeExitCode": 0, + "result": "OK", + "semanticResult": "Explicit DIRECT member missing from generated group" + }, + "vmessSkipCertVerifyIgnored": { + "nativeExitCode": 0, + "result": "OK", + "semanticResult": "Input true is not emitted" + }, + "vmessLegacyAlterIdForcesAead": { + "nativeExitCode": 0, + "result": "OK", + "semanticResult": "alterId 64 still emits vmess-aead=true" + } + }, + "domainCoverageEvidence": { + "api.amplitude.com": [ + { + "seq": 11, + "type": "RULE-SET", + "set": "reject", + "policy": "REJECT", + "entry": "DOMAIN-SUFFIX,amplitude.com" + }, + { + "seq": 94, + "type": "DOMAIN", + "policy": "⚡️ Automatic", + "entry": "api.amplitude.com" + } + ], + "inapps.appsflyer.com": [ + { + "seq": 11, + "type": "RULE-SET", + "set": "reject", + "policy": "REJECT", + "entry": "DOMAIN-KEYWORD,appsflyer" + }, + { + "seq": 66, + "type": "DOMAIN-SUFFIX", + "policy": "🪙 Bybit", + "entry": "inapps.appsflyer.com" + } + ], + "r1---sn-test.googlevideo.com": [ + { + "seq": 37, + "type": "RULE-SET", + "set": "YouTube", + "policy": "🎬 Streaming", + "entry": "DOMAIN-SUFFIX,googlevideo.com" + }, + { + "seq": 109, + "type": "RULE-SET", + "set": "global", + "policy": "⚡️ Automatic", + "entry": "DOMAIN-KEYWORD,google" + } + ], + "youtube.googleapis.com": [ + { + "seq": 30, + "type": "DOMAIN-SUFFIX", + "policy": "🤖 AI", + "entry": "googleapis.com" + }, + { + "seq": 37, + "type": "RULE-SET", + "set": "YouTube", + "policy": "🎬 Streaming", + "entry": "DOMAIN-SUFFIX,youtube.googleapis.com" + }, + { + "seq": 109, + "type": "RULE-SET", + "set": "global", + "policy": "⚡️ Automatic", + "entry": "DOMAIN-KEYWORD,google" + } + ], + "update.googleapis.com": [ + { + "seq": 8, + "type": "RULE-SET", + "set": "Unbreak", + "policy": "DIRECT", + "entry": "DOMAIN,update.googleapis.com" + }, + { + "seq": 30, + "type": "DOMAIN-SUFFIX", + "policy": "🤖 AI", + "entry": "googleapis.com" + }, + { + "seq": 102, + "type": "DOMAIN-SET", + "set": "download", + "policy": "🇨🇳 Mainland", + "entry": "update.googleapis.com" + }, + { + "seq": 109, + "type": "RULE-SET", + "set": "global", + "policy": "⚡️ Automatic", + "entry": "DOMAIN-KEYWORD,google" + } + ] + }, + "remoteRulesets": { + "distinctUrls": 60, + "successfulDownloads": 60, + "nonCommentEntries": 166028, + "snapshots": [ + { + "url": "https://raw.githubusercontent.com/zxfccmm4/Profiles/refs/heads/main/Surge/Ruleset/Unbreak.list", + "types": [ + "RULE-SET" + ], + "rules": [ + 8 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 643, + "sha256": "58899410bfa74f61fd70375acff7770be7f65b40f5b0b2d71507a2782403bb52", + "entries": 19, + "rule_types": [ + "DOMAIN", + "DOMAIN-SUFFIX" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://ruleset.skk.moe/List/domainset/reject.conf", + "types": [ + "DOMAIN-SET" + ], + "rules": [ + 9 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 3000641, + "sha256": "6908bc8ffdccd9ad07adf49507d8ade3f941ce0f4a9c3ad82dfdefca90c2fe4d", + "entries": 135040, + "rule_types": [ + "domain-list" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://ruleset.skk.moe/List/non_ip/reject-drop.conf", + "types": [ + "RULE-SET" + ], + "rules": [ + 10 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 1360, + "sha256": "f5f3606947fdcb055fc1c937fc8e89b6692d5f63afc58f8a1bc45438853fcd11", + "entries": 29, + "rule_types": [ + "DOMAIN", + "DOMAIN-SUFFIX", + "DOMAIN-WILDCARD" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://ruleset.skk.moe/List/non_ip/reject.conf", + "types": [ + "RULE-SET" + ], + "rules": [ + 11 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 13524, + "sha256": "0620fd54346f03eb1a17cb18aeff45423cb801845aaa7a0eb7486277a1bff91a", + "entries": 370, + "rule_types": [ + "DOMAIN", + "DOMAIN-KEYWORD", + "DOMAIN-SUFFIX", + "DOMAIN-WILDCARD", + "IP-CIDR", + "IP-CIDR6", + "URL-REGEX" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://ruleset.skk.moe/List/ip/reject.conf", + "types": [ + "RULE-SET" + ], + "rules": [ + 12 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 22267, + "sha256": "7015f1e7ea42006c532b2644c9c90e0d6213a5d4ecfa9a49569d08d16f872228", + "entries": 779, + "rule_types": [ + "DOMAIN", + "IP-ASN", + "IP-CIDR", + "IP-CIDR6" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://ruleset.skk.moe/List/non_ip/neteasemusic.conf", + "types": [ + "RULE-SET" + ], + "rules": [ + 17 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 608, + "sha256": "8e3017a526f50ed80deddf4b26339f25cc0f60ad1c4ef0667ec7080f3ee1215e", + "entries": 5, + "rule_types": [ + "DOMAIN", + "DOMAIN-SUFFIX", + "USER-AGENT" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://ruleset.skk.moe/List/ip/neteasemusic.conf", + "types": [ + "RULE-SET" + ], + "rules": [ + 18 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 1094, + "sha256": "49783e54766c86852c17299e2aa0182e53fa31435e33f5b66a40c4e32705f29c", + "entries": 18, + "rule_types": [ + "DOMAIN", + "IP-CIDR" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/Weibo/Weibo.list", + "types": [ + "RULE-SET" + ], + "rules": [ + 19 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 269, + "sha256": "54f8a08d7f89498f4f5ec29737c0c82feff1c69faadbd0d4722e1cbd72156f75", + "entries": 4, + "rule_types": [ + "DOMAIN-KEYWORD", + "DOMAIN-SUFFIX" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/XiaoHongShu/XiaoHongShu.list", + "types": [ + "RULE-SET" + ], + "rules": [ + 20 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 272, + "sha256": "9fb20e80dc0962cfdf410ffbd72d6bf2a6a0ac0d930951880f98164ff229e8ff", + "entries": 4, + "rule_types": [ + "DOMAIN-SUFFIX" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/AppStore/AppStore.list", + "types": [ + "RULE-SET" + ], + "rules": [ + 21 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 196, + "sha256": "245ea6eb4c7fa160824476c937be4f15f3925d83fba1d94bb3b6cc58562feb90", + "entries": 2, + "rule_types": [ + "DOMAIN" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/AppleNews/AppleNews.list", + "types": [ + "RULE-SET" + ], + "rules": [ + 22 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 217, + "sha256": "ec57625e5b938396b92034c0d71eadbd0c4a1adbff8243f05d8bb97441ad1b26", + "entries": 2, + "rule_types": [ + "DOMAIN" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://ruleset.skk.moe/List/domainset/apple_cdn.conf", + "types": [ + "DOMAIN-SET" + ], + "rules": [ + 23 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 4628, + "sha256": "0c81cbe6af3a1c3befbe973441bb5667caf817a9447fca55022182d015669180", + "entries": 160, + "rule_types": [ + "domain-list" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/AppleTV/AppleTV.list", + "types": [ + "RULE-SET" + ], + "rules": [ + 24 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 474, + "sha256": "1078a83e65a4d65cf754e267c369f667fe4b30937747232f809aa03223148bbd", + "entries": 10, + "rule_types": [ + "DOMAIN", + "DOMAIN-SUFFIX", + "PROCESS-NAME", + "USER-AGENT" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/Apple/Apple.list", + "types": [ + "RULE-SET" + ], + "rules": [ + 25 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 1805, + "sha256": "4476d2a83fbda6cd89e1d2fdcfdbab038193dae62d02b0c35420bc2876efdd49", + "entries": 56, + "rule_types": [ + "DOMAIN-KEYWORD", + "IP-CIDR", + "IP-CIDR6", + "PROCESS-NAME", + "USER-AGENT" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://ruleset.skk.moe/List/non_ip/ai.conf", + "types": [ + "RULE-SET" + ], + "rules": [ + 31 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 2032, + "sha256": "0da587b18f3160ba0b34019a1906bfa1d3b9e47d2651ae7141195d8acb212cc3", + "entries": 53, + "rule_types": [ + "DOMAIN", + "DOMAIN-KEYWORD", + "DOMAIN-SUFFIX", + "URL-REGEX" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/Bing/Bing.list", + "types": [ + "RULE-SET" + ], + "rules": [ + 32 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 415, + "sha256": "4b9fe5e5f0cef4c4b09a2e109ca4db00f1cc67ec45aae5c50fca3bdb72b61968", + "entries": 9, + "rule_types": [ + "DOMAIN-SUFFIX" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/Disney/Disney.list", + "types": [ + "RULE-SET" + ], + "rules": [ + 34 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 5674, + "sha256": "d0cfd6aee89d9d3075aef9dfadd98a2a3c6a2dba487f5091c801d1bcd70e9884", + "entries": 175, + "rule_types": [ + "DOMAIN-SUFFIX", + "PROCESS-NAME", + "USER-AGENT" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/Netflix/Netflix.list", + "types": [ + "RULE-SET" + ], + "rules": [ + 35 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 40424, + "sha256": "aa03f015949e8f6f981d90a7d8b1acf7e88472ee48601632bd964b0bb740f8aa", + "entries": 1158, + "rule_types": [ + "DOMAIN", + "DOMAIN-KEYWORD", + "DOMAIN-SUFFIX", + "IP-CIDR", + "IP-CIDR6", + "PROCESS-NAME", + "USER-AGENT" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://raw.githubusercontent.com/Semporia/TikTok-Unlock/master/Surge/TikTok.list", + "types": [ + "RULE-SET" + ], + "rules": [ + 36 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 425, + "sha256": "34bbacbbb268ef20ace40dfeb3a5798c8a239b6407897f276b14398c7cf78a2b", + "entries": 15, + "rule_types": [ + "DOMAIN", + "DOMAIN-KEYWORD", + "DOMAIN-SUFFIX", + "USER-AGENT" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/YouTube/YouTube.list", + "types": [ + "RULE-SET" + ], + "rules": [ + 37 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 5451, + "sha256": "42274e143447066e26e1ebbf21ae03ce6efb40827f41e2775a84fb16d390713d", + "entries": 190, + "rule_types": [ + "DOMAIN-KEYWORD", + "DOMAIN-SUFFIX", + "IP-CIDR", + "IP-CIDR6", + "USER-AGENT" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/GoogleSearch/GoogleSearch.list", + "types": [ + "RULE-SET" + ], + "rules": [ + 38 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 189, + "sha256": "c35c6a5350ca6b2c1fb15147cc92c23739592dea9715125f767ed9b507878ade", + "entries": 1, + "rule_types": [ + "DOMAIN-KEYWORD" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/Emby/Emby.list", + "types": [ + "RULE-SET" + ], + "rules": [ + 39 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 1675, + "sha256": "48ae40d231b5429ef2c11459dd13b88c5ced78cee4dc964ce4e67291b8d59614", + "entries": 52, + "rule_types": [ + "DOMAIN", + "DOMAIN-KEYWORD", + "DOMAIN-SUFFIX", + "PROCESS-NAME" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://ruleset.skk.moe/List/non_ip/stream_us.conf", + "types": [ + "RULE-SET" + ], + "rules": [ + 40 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 1959, + "sha256": "54887849393eb274da450ac0479c6e054a2227811be810f99dc39a0c1072ee83", + "entries": 53, + "rule_types": [ + "DOMAIN", + "DOMAIN-SUFFIX", + "PROCESS-NAME", + "USER-AGENT" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://ruleset.skk.moe/List/ip/stream_us.conf", + "types": [ + "RULE-SET" + ], + "rules": [ + 41 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 605, + "sha256": "8362f4a41e9c0e9f3f03305de97ef3c3f5a66a663703cd4a49f858f7c8ca5846", + "entries": 1, + "rule_types": [ + "DOMAIN" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://ruleset.skk.moe/List/non_ip/stream_eu.conf", + "types": [ + "RULE-SET" + ], + "rules": [ + 42 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 704, + "sha256": "722e0d571b44a13a06f3d17a7d74206d9023d5f34a4702e6298e37c98566180d", + "entries": 11, + "rule_types": [ + "DOMAIN", + "DOMAIN-KEYWORD", + "DOMAIN-SUFFIX", + "USER-AGENT" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://ruleset.skk.moe/List/ip/stream_eu.conf", + "types": [ + "RULE-SET" + ], + "rules": [ + 43 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 454, + "sha256": "a68eb796f1b1044f78b15c8782782dfd88bb33454e2cc89c2ce00c56e8b39f88", + "entries": 1, + "rule_types": [ + "DOMAIN" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://ruleset.skk.moe/List/non_ip/stream_jp.conf", + "types": [ + "RULE-SET" + ], + "rules": [ + 44 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 1221, + "sha256": "ad79821bb7d7c381e28f8b6972d487218a6176b87f60671901d9e2473f5c3645", + "entries": 28, + "rule_types": [ + "DOMAIN", + "DOMAIN-SUFFIX", + "USER-AGENT" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://ruleset.skk.moe/List/ip/stream_jp.conf", + "types": [ + "RULE-SET" + ], + "rules": [ + 45 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 515, + "sha256": "69b1b205209c2694fcea0eeaa37560999406943aa7e21302d816acbfea55dd13", + "entries": 1, + "rule_types": [ + "DOMAIN" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://ruleset.skk.moe/List/non_ip/stream_kr.conf", + "types": [ + "RULE-SET" + ], + "rules": [ + 46 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 527, + "sha256": "40aa62a2122605612a5db88d51bb447d0d34cb95f8ab4b20cee4993f2aec33af", + "entries": 4, + "rule_types": [ + "DOMAIN", + "DOMAIN-SUFFIX", + "USER-AGENT" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://ruleset.skk.moe/List/ip/stream_kr.conf", + "types": [ + "RULE-SET" + ], + "rules": [ + 47 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 451, + "sha256": "4af571f5c42708cecbb7fcd2e5a699731b376c4cbf7d01eb726d896fdcb76810", + "entries": 1, + "rule_types": [ + "DOMAIN" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://ruleset.skk.moe/List/non_ip/stream_hk.conf", + "types": [ + "RULE-SET" + ], + "rules": [ + 48 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 1558, + "sha256": "7893cb28a7e5f5cea8a122445e3331c98227c18cd2e7ca8282ddbde2705176e4", + "entries": 37, + "rule_types": [ + "DOMAIN", + "DOMAIN-KEYWORD", + "DOMAIN-SUFFIX", + "PROCESS-NAME", + "USER-AGENT" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://ruleset.skk.moe/List/ip/stream_hk.conf", + "types": [ + "RULE-SET" + ], + "rules": [ + 49 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 516, + "sha256": "63137a000c89bb5ae5679ff91c14808ca41d6da5c09064f4e29079b8c3bb60a7", + "entries": 1, + "rule_types": [ + "DOMAIN" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://ruleset.skk.moe/List/non_ip/stream_tw.conf", + "types": [ + "RULE-SET" + ], + "rules": [ + 50 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 1612, + "sha256": "c3abeaed7b3783c55de5f7e8a718112f8fa477e919b3d7beaef6174433306025", + "entries": 39, + "rule_types": [ + "DOMAIN", + "DOMAIN-SUFFIX", + "PROCESS-NAME", + "USER-AGENT" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://ruleset.skk.moe/List/ip/stream_tw.conf", + "types": [ + "RULE-SET" + ], + "rules": [ + 51 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 529, + "sha256": "875e3fe79abca6702bbe96d7859cf7a163c6328fcc21928f30627dc3cf16ea0d", + "entries": 1, + "rule_types": [ + "DOMAIN" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/Twitter/Twitter.list", + "types": [ + "RULE-SET" + ], + "rules": [ + 73 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 1116, + "sha256": "64c836f2431ca0d4728cc545bb74280ee50c886e346040012e6cdd2f3eee3f97", + "entries": 33, + "rule_types": [ + "DOMAIN-KEYWORD", + "DOMAIN-SUFFIX", + "IP-CIDR" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://ruleset.skk.moe/List/non_ip/telegram.conf", + "types": [ + "RULE-SET" + ], + "rules": [ + 74 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 834, + "sha256": "bccbd162554d2cb56b16cdd59fbc2d1abec645bd53c9db18460137ffccbbc2a2", + "entries": 15, + "rule_types": [ + "DOMAIN", + "DOMAIN-SUFFIX" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://ruleset.skk.moe/List/ip/telegram.conf", + "types": [ + "RULE-SET" + ], + "rules": [ + 75 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 1116, + "sha256": "27f6bab896e98ec9631fd187ff5147fb101260fc7a322c0dc994bbd27ed0a81f", + "entries": 15, + "rule_types": [ + "DOMAIN", + "IP-CIDR", + "IP-CIDR6" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://ruleset.skk.moe/List/ip/telegram_asn.conf", + "types": [ + "RULE-SET" + ], + "rules": [ + 76 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 685, + "sha256": "d6d8f75ed1cae4c7de00c86a834c5f630abbc614f28f50f0a63e741ffa8d2cf9", + "entries": 6, + "rule_types": [ + "DOMAIN", + "IP-ASN" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/Facebook/Facebook.list", + "types": [ + "RULE-SET" + ], + "rules": [ + 77 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 17399, + "sha256": "747563c96825bbd5f868be955543b0ca1979064e7b094f8aab08aafbce577c36", + "entries": 570, + "rule_types": [ + "DOMAIN-KEYWORD", + "DOMAIN-SUFFIX", + "IP-ASN", + "IP-CIDR" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/Instagram/Instagram.list", + "types": [ + "RULE-SET" + ], + "rules": [ + 78 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 287, + "sha256": "65f28f9253b0dbb2a81485c788537896a0817766685e7bd91b2f90ec7aa19548", + "entries": 4, + "rule_types": [ + "DOMAIN-KEYWORD", + "DOMAIN-SUFFIX" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/Threads/Threads.list", + "types": [ + "RULE-SET" + ], + "rules": [ + 79 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 182, + "sha256": "e9b86a51952a33ee50ad3b31dc98bcb09699bbeb4d21371762b1124cb0091e86", + "entries": 1, + "rule_types": [ + "DOMAIN-SUFFIX" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/Discord/Discord.list", + "types": [ + "RULE-SET" + ], + "rules": [ + 80 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 1076, + "sha256": "3d18c8fe716de06891ba3d6c34dba254cb65138a10f79e1a1a0628b5237ecb2c", + "entries": 29, + "rule_types": [ + "DOMAIN-SUFFIX" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/OneDrive/OneDrive.list", + "types": [ + "RULE-SET" + ], + "rules": [ + 81 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 735, + "sha256": "c13255b88e542f92ce26a8cca4367adcea81f9f04e00a41d03dc4869caa070ea", + "entries": 18, + "rule_types": [ + "DOMAIN-KEYWORD", + "DOMAIN-SUFFIX", + "PROCESS-NAME" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://ruleset.skk.moe/List/non_ip/microsoft.conf", + "types": [ + "RULE-SET" + ], + "rules": [ + 82 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 2714, + "sha256": "1cc7293548c9f251adf9e373a57bfef3501f6e6756d8234aed5415a5ead4e439", + "entries": 84, + "rule_types": [ + "DOMAIN", + "DOMAIN-KEYWORD", + "DOMAIN-SUFFIX" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/GitHub/GitHub.list", + "types": [ + "RULE-SET" + ], + "rules": [ + 83 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 1117, + "sha256": "ced8ab0ecda5775d04ec8ee6a57b282181108565331a6a1d85e9a26fee25d79b", + "entries": 31, + "rule_types": [ + "DOMAIN-KEYWORD", + "DOMAIN-SUFFIX" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://raw.githubusercontent.com/getsomecat/GetSomeCats/Surge/rule/substore.list", + "types": [ + "RULE-SET" + ], + "rules": [ + 84 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 173, + "sha256": "3e909fe692ff73aea191ea852311e616c203c80c7e8f6a0e1fa725eb5aefc2f1", + "entries": 1, + "rule_types": [ + "DOMAIN-SUFFIX" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/Speedtest/Speedtest.list", + "types": [ + "RULE-SET" + ], + "rules": [ + 86 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 446, + "sha256": "d0f9a3b0befdfd209bf752b86e561094f7bb7e81d96e1df86c2ba585535ba755", + "entries": 9, + "rule_types": [ + "DOMAIN-KEYWORD", + "DOMAIN-SUFFIX", + "USER-AGENT" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/Riot/Riot.list", + "types": [ + "RULE-SET" + ], + "rules": [ + 87 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 1804, + "sha256": "ae276909536cb1c4d0296fe1151b06c6d6ca574cb0c0293d62ecf76613b73971", + "entries": 55, + "rule_types": [ + "DOMAIN-SUFFIX" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://ruleset.skk.moe/List/non_ip/direct.conf", + "types": [ + "RULE-SET" + ], + "rules": [ + 97 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 6317, + "sha256": "46c65d1a59289d07f52f7652f9d1695d2996f65c125a14593b3eb7322086f8ec", + "entries": 220, + "rule_types": [ + "DOMAIN", + "DOMAIN-KEYWORD", + "DOMAIN-SUFFIX", + "PROCESS-NAME", + "USER-AGENT" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://ruleset.skk.moe/List/non_ip/domestic.conf", + "types": [ + "RULE-SET" + ], + "rules": [ + 98 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 22709, + "sha256": "b939098609fd21d1e7e1efed05947fc7f55856d12e016944635bc64d2d8470c2", + "entries": 872, + "rule_types": [ + "DOMAIN", + "DOMAIN-SUFFIX", + "DOMAIN-WILDCARD", + "USER-AGENT" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://ruleset.skk.moe/List/ip/domestic.conf", + "types": [ + "RULE-SET" + ], + "rules": [ + 99 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 566, + "sha256": "99fd01e3b16bb3715dc2a07f5e3c4fde213c2031469256282c55aa7e1ed46494", + "entries": 3, + "rule_types": [ + "DOMAIN", + "IP-CIDR" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/ChinaMax/ChinaMax.list", + "types": [ + "RULE-SET" + ], + "rules": [ + 100 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 454473, + "sha256": "768d816af527ca99dfd93ddbdadd1187d62584bbc0f71fb8a13456da5e54fd58", + "entries": 12626, + "rule_types": [ + "DOMAIN-KEYWORD", + "DOMAIN-SUFFIX", + "IP-ASN", + "IP-CIDR", + "IP-CIDR6", + "PROCESS-NAME", + "USER-AGENT" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://raw.githubusercontent.com/VirgilClyne/GetSomeFries/main/ruleset/ASN.China.list", + "types": [ + "RULE-SET" + ], + "rules": [ + 101 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 130484, + "sha256": "50f106604b9da0b0adc3f17f0d004dd129684ae95811ac29f13471332c8bcafa", + "entries": 5076, + "rule_types": [ + "IP-ASN" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://ruleset.skk.moe/List/domainset/download.conf", + "types": [ + "DOMAIN-SET" + ], + "rules": [ + 102 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 45748, + "sha256": "65cea22ae2846709815a973c091c343d789423b50419d920f2853273d1055f90", + "entries": 1971, + "rule_types": [ + "domain-list" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://ruleset.skk.moe/List/non_ip/download.conf", + "types": [ + "RULE-SET" + ], + "rules": [ + 103 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 874, + "sha256": "741c1e7dda34bae2dd7611262c15431a66a50f6bdac24b96f070e1529479a594", + "entries": 11, + "rule_types": [ + "DOMAIN", + "DOMAIN-WILDCARD", + "URL-REGEX" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/Tesla/Tesla.list", + "types": [ + "RULE-SET" + ], + "rules": [ + 104 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 505, + "sha256": "ae77caa54b5da2ff4356f0aea156e2c3a99e54b6bf4dd2a28a8b3af657d5602c", + "entries": 12, + "rule_types": [ + "DOMAIN-SUFFIX" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://ruleset.skk.moe/List/domainset/cdn.conf", + "types": [ + "DOMAIN-SET" + ], + "rules": [ + 106 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 89698, + "sha256": "ddd6b1e2cf80bb3c6b4407909818a60498c01ac6764cd1268456e0589cde543b", + "entries": 4681, + "rule_types": [ + "domain-list" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://ruleset.skk.moe/List/non_ip/cdn.conf", + "types": [ + "RULE-SET" + ], + "rules": [ + 107 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 3447, + "sha256": "110f955569b55bb09ca837c77dc8c43c99c75c3bee139d9049f3a6a25931b5c6", + "entries": 84, + "rule_types": [ + "DOMAIN", + "DOMAIN-KEYWORD", + "DOMAIN-WILDCARD" + ], + "final_entries": [], + "html_response": false + }, + { + "url": "https://ruleset.skk.moe/List/non_ip/global_plus.conf", + "types": [ + "RULE-SET" + ], + "rules": [ + 108 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 162, + "sha256": "7074c88fff7f47fd1b9aa33b2642dbce6a8e6654127792c5d26bbdc9d2fcac35", + "entries": 0, + "rule_types": [], + "final_entries": [], + "html_response": false + }, + { + "url": "https://ruleset.skk.moe/List/non_ip/global.conf", + "types": [ + "RULE-SET" + ], + "rules": [ + 109 + ], + "http_status": 200, + "curl_exit": 0, + "bytes": 33289, + "sha256": "708404b9ea6a756e4015257bfb132782cd3ffbe318e788430e397ded10ec3de9", + "entries": 1272, + "rule_types": [ + "DOMAIN", + "DOMAIN-KEYWORD", + "DOMAIN-SUFFIX", + "USER-AGENT" + ], + "final_entries": [], + "html_response": false + } + ] + }, + "limitations": [ + "Static domain coverage is not full native traffic simulation.", + "CLI success does not establish connectivity, DNS privacy, actual saved selections or success of every ruleset entry.", + "Remote rulesets and device caches may change after this snapshot.", + "No live device traffic, MITM state, firewall reachability or business unlock tests were performed." + ] +} diff --git a/docs/SURGE-AUDIT-2026-10-03.md b/docs/SURGE-AUDIT-2026-10-03.md new file mode 100644 index 0000000..13ba457 --- /dev/null +++ b/docs/SURGE-AUDIT-2026-10-03.md @@ -0,0 +1,224 @@ +# Surge macOS 订阅体检:2026-10-03 + +本文保留修复前的体检结论;其中行号、缺陷与数量指体检时的源码与订阅快照。已实施的代码/生产修复和后续验证见 [修复记录](SURGE-FIX-2026-10-03.md),最新盘点见 [当前生产状态](CURRENT-STATE.md)。不要把下文历史缺陷与快照当作当前状态。 + +实际订阅通过 Surge 6.9.1 的原生语法校验,生产数据与当前生成器一致。但存在规则被遮蔽、DNS 与连接策略不一致,以及管理 API 能接受最终导致无效配置或丢失规则的数据操作。 + +本次只查询生产管理 API、盘点数据库行数、读取 HTTPS 订阅和公开规则集;没有修改生产节点、规则、设置,没有部署、重启或切换本机活动配置。订阅 GET 按现有业务逻辑正常更新访问计数和访问时间(`server/src/routes/sub.ts:30`)。所有 API 写入复现都使用 `/private/tmp/` 下的隔离数据库与占位凭据。 + +## 范围与证据 + +| 项目 | 实际结果 | +|---|---| +| 生产节点 | 30 个,均启用;Surge 输出 22 个,排除 8 个 SS over WS | +| 生产策略组 | 14 个,均启用;无循环引用;地区组有效候选为美国 10、新加坡 6、日本 6 | +| 生产规则 | 116 条,其中 115 条启用;旧 `PROTOCOL,DOH,AI` 已停用 | +| 最终订阅规则 | 118 条:115 条数据库规则 + 3 条生成的 DNS 出口规则 | +| 生产规则集 | 数据库登记 62 个;当前配置引用 60 个不同远程 URL | +| 生产订阅 | 1 个启用订阅,节点范围为空数组,即全部启用节点 | +| 原生语法校验 | Surge Mac 6.9.1(12290),`surge-cli --check` 返回 `OK`、退出码 0 | +| 代码与生产一致性 | 用生产 API 快照重建 IR,由本地实际生成器输出;与 HTTPS 订阅逐字节相同 | +| 远程依赖 | 60/60 URL 本次返回 HTTP 200,合计 166,028 个非空、非注释条目,无 FINAL 条目 | +| 使用本次规则集快照的校验 | 将远程 URL 替换为临时本地文件,再用官方 CLI 校验:`OK` | + +配置指纹、规则集指纹和复现结果见 [脱敏证据](SURGE-AUDIT-2026-10-03.evidence.json)。报告中的 `seq` 是数据库从 0 起算的顺序;“配置第 N 行”对应本次实际 HTTPS 响应。报告和证据不包含订阅 token、真实节点密码、UUID、节点地址或私有 Host IP。 + +校验能力以 [Surge 官方 CLI 文档](https://manual.nssurge.com/tools/cli.html) 为依据。CLI 返回 OK 证明配置可解析,不能证明每个规则都会被命中,也不能代替节点连接、解锁或泄漏测试。 + +P1 表示可导致配置不可用、规则丢失或重要控制失效;P2 表示行为偏差或需要修正的兼容性问题。静态推导的网络影响和依赖设备开关的风险明确另行说明。 + +## 已确认的业务代码缺陷 + +### C1 · P1:重置规则在存在自定义规则时失败,并先删除模板规则 + +`server/src/routes/rules.ts:60` 调用 `seedRulesFromTemplate(db, { keepCustom: true })`。`server/src/db/seed.ts:52` 读取自定义规则后只删除模板规则,随后在第 63 行把仍在表中的自定义规则再次 INSERT。自定义规则主键重复,且这些操作没有事务包裹。 + +隔离数据库复现结果: + +```text +首次初始化:89 条模板规则 +新增自定义规则:HTTP 201,合计 90 条 +POST /api/rules/reset:HTTP 500 +错误:UNIQUE constraint failed: rules.id +操作后:模板规则 0 条,自定义规则 1 条 +``` + +生产快照中存在自定义规则,具备触发条件。**本次没有在生产调用 reset**,没有认定生产发生过此故障。 + +修复建议:把整个重置过程放入事务;保留自定义规则时更新其顺序,或在事务内明确删除后重插,避免重复主键;同时明确“重置”究竟是否保留自定义规则。现有 update-db 技能描述为清掉自定义规则,与当前代码的 `keepCustom: true` 意图不一致,需要一起纠正。 + +### C2 · P1:FINAL 并未得到完整保护,普通操作可使全部订阅无效 + +`server/src/services/ir.ts:102` 先过滤 `enabled=true`;后面的 FINAL 特判只处理策略引用失效,不能保住已被停用、删除或改成其他类型的 FINAL。`server/src/routes/rules.ts:83` 和第 92 行允许停用及删除唯一 FINAL,排序接口也没有校验末尾位置或完整 ids。 + +隔离 API 复现:创建 FINAL 返回 201,停用返回 200,生成器输出中 FINAL 数量变为 0;官方 CLI 返回: + +```text +Rules must end with FINAL +``` + +当前生产恰有一条启用的 FINAL,位于末尾,因此当前配置通过校验。问题在后续编辑入口。官方要求规则以启用的 FINAL 结束;`dns-failed` 只是 DNS 失败时选用其策略,不会补回缺失的 FINAL。[FINAL 规范](https://manual.nssurge.com/rules/final.html) + +修复建议:在写入时保护唯一末尾 FINAL,完整校验排序,拒绝产生无效状态的修改;生成侧再验证,不要仅靠注释声称“永远保留”。 + +### C3 · P1:输入与序列化允许合法 API 请求生成无效 Surge 配置 + +`shared/src/schemas.ts:6` 对节点名只校验长度;`server/src/services/generators/surge.ts:51` 直接拼接声明名。规则值在第 117 行直接拼接;`quoteName()` 只为策略引用加引号,没有完整处理引号/反斜线。 + +实际路由的隔离复现: + +| 请求 | API 结果 | 官方 CLI 结果 | +|---|---|---| +| 新建名为 `Bad = Name` 的 VMess 节点 | 201 | `Unknown proxy type: Name = vmess` | +| 新建 URL-REGEX,值为 `^https://example.com/(a\|b),?c` | 201 | 把 `?c` 误当策略名,拒绝配置 | + +表中正则的 `\|` 仅为 Markdown 表格转义;实际测试值使用普通 `|`。 + +Surge 对含逗号的正则值要求引用;新版本还有明确的引号、反斜线转义规则。这是已用官方解析器复现的错误,不是按自建解析器猜测。[HTTP 规则](https://manual.nssurge.com/rules/http.html)、[配置引用格式](https://manual.nssurge.com/profile/format.html) + +修复建议:区分声明名、策略引用、普通规则值、逻辑规则表达式的编码规则;拒绝不能安全表达的名称和换行输入。逻辑规则内部的逗号不能简单按普通字符串一律引用。 + +### C4 · P1:策略组循环可通过 API 写入,导致整个配置被拒绝 + +`server/src/routes/groups.ts:21`、第 38 行和第 77 行没有图循环检查。隔离测试建立 `A → B → A`,两次新建均返回 201,IR 保留这些引用;官方 CLI 拒绝: + +```text +Loop reference is found in the policy group: A. +``` + +当前生产 14 个组没有循环。修复建议:新建、修改成员与改名时,在事务内校验引用存在、类型正确及图无环;不能把存在性检查当成循环检查。 + +### C5 · P2:filterRegex 分支丢弃显式成员,只保留第一个来源组 + +`server/src/services/generators/surge.ts:85` 在存在正则时不输出 `memberNames`,非 includeAllNodes 情形也只取第一个 group 成员。 + +占位 IR 复现:输入组同时有显式 `DIRECT`、`includeAllNodes=true` 和正则,输出只有动态来源与正则,DIRECT 被丢弃。官方规定正则只作用于动态导入的成员,显式成员不受过滤;`include-other-group` 可包含多个来源组。[策略成员导入](https://manual.nssurge.com/policy-groups/policy-including.html) + +当前生产的三个地区组没有显式成员,暂不触发。修复建议:完整保留显式成员和多个动态来源,分别应用 Surge 规定的语义。 + +### C6 · P2:VMess 参数映射不完整 + +`server/src/services/generators/surge.ts:13` 没有输出 VMess 的 `skipCertVerify`,忽略 `security`,并无条件输出 `vmess-aead=true`。占位测试确认 `skipCertVerify=true` 不出现在结果中;`alterId=64` 也仍输出 AEAD。 + +官方要求 AEAD 设置与服务端匹配,并支持 TLS 证书校验参数及指定的加密方式。当前生产 VMess 均为 `alterId=0`、`security=auto`、`skipCertVerify=false`,没有证据表明现有节点因此连接失败。[VMess 规范](https://manual.nssurge.com/policies/vmess.html)、[TLS 参数](https://manual.nssurge.com/policies/tls.html) + +修复建议:在能力层拒绝无法表达的旧协议组合,或按官方参数转换;不能让用户输入被静默忽略。所有输入字段都应有明确的支持/转换/拒绝行为。 + +## 体检时生产配置中的行为问题 + +### D1 · P1:两条例外规则被更早的拦截集覆盖 + +本次下载的 `https://ruleset.skk.moe/List/non_ip/reject.conf` 含有对 `amplitude.com` 的 DOMAIN-SUFFIX,以及对 `appsflyer` 的 DOMAIN-KEYWORD。 + +| 域名 | 更早匹配 | 后面的例外规则 | +|---|---|---| +| `api.amplitude.com` | seq 11,REJECT | seq 94,Automatic | +| `inapps.appsflyer.com` | seq 11,REJECT | seq 66,Bybit | + +现有例外不会覆盖早先的匹配。结论针对 Rule-Based 模式及本次规则集快照中的域名匹配;生产规则没有 pre-matching 标记,客户端资源可能存在缓存差异,未宣称已观察到设备上的请求失败。官方规则顺序是首条匹配决定策略。[规则顺序](https://manual.nssurge.com/rules/overview.html) + +修复建议:先确定这两项是需要放行的业务例外,还是需要保持阻断的跟踪服务,再决定是否提前。不能自动为全部跟踪域名放行。 + +### D2 · P2:YouTube UDP 拦截被提前的 YouTube 规则集遮蔽 + +数据库 seq 37/配置第 135 行的 YouTube 规则集含 `DOMAIN-SUFFIX,googlevideo.com`,策略为 Streaming;seq 110/配置第 208 行才是: + +```ini +AND,((PROTOCOL,UDP), (DOMAIN-SUFFIX,googlevideo.com)),REJECT-NO-DROP +``` + +对携带 googlevideo.com 主机名的普通 UDP 请求,先前的 DOMAIN-SUFFIX 已匹配,后面的 AND 不会生效。STUN 请求另有更前的拦截,所以不能由此认定 STUN 保护失效。逻辑表达式本身通过官方语法校验。[逻辑规则](https://manual.nssurge.com/rules/logical.html)、[规则顺序](https://manual.nssurge.com/rules/overview.html) + +修复建议:若确实要让这类视频流量回落 TCP,把特定 UDP 规则放到相关服务域名规则之前;不要无依据地扩大为全局 UDP 拦截。 + +### D3 · P2:AI DNS 映射忽略更早的连接例外,且 googleapis 覆盖整个服务族 + +`server/src/services/generators/surge-ai-dns.ts:13` 收集所有指向 AI 的规则,并把结果整体放在数据库 Host 原文之前,不计算其他策略的更早例外。 + +本次可确认的例子: + +```text +连接策略:seq 8 的 Unbreak 集匹配 update.googleapis.com → DIRECT +AI 域名规则:seq 30,DOMAIN-SUFFIX,googleapis.com → AI +Host 第 224 行:*.googleapis.com → Cloudflare DoH +Host 第 253 行:update.googleapis.com → 原来的国内 DNS +``` + +Host 首条匹配生效,所以后面的精确 DNS 设置被前面的通配覆盖;连接仍明确直连,而 Surge 自己解析该域名时按 AI Host 项处理。`youtube.googleapis.com` 也被 seq 30 送到 AI,而不会使用后面的 YouTube Streaming 策略。后一项是否符合业务意图需要确认。 + +官方允许 Host 绑定规则集,当前语法没有问题;问题是两个独立的有序匹配系统没有同样的例外优先级。[Host 映射](https://manual.nssurge.com/dns/local-dns-mapping.html) + +修复建议:明确 Google AI 与其他 Google API 的范围,并在生成 Host 时保留需要优先的精确例外。对于连接规则中的 IP、进程或复杂表达式,不能承诺 Host 能完全复刻。 + +### D4 · P2:靠前的 IP 拦截在业务域名规则前触发 DNS + +seq 12/配置第 110 行加载 `https://ruleset.skk.moe/List/ip/reject.conf`,位于 AI、YouTube、Bybit 等规则之前。本次文件含 778 条 IP/ASN 条目,其中 736 条没有 `no-resolve`。 + +按官方执行语义,没有在更前规则被决定、且目标还是域名的请求会在这些 IP 条目处触发解析;DNS 失败时转到 FINAL 的策略,不继续匹配后面的业务域名规则。这是规范推导的控制流影响,未测量延迟或宣称已抓到泄漏。[IP 与 DNS 规则](https://manual.nssurge.com/rules/ip.html) + +修复建议:先确定 IP 黑名单是否应当主动解析所有域名;可考虑在该 RULE-SET 上加 `no-resolve`,或调整域名与 IP 规则顺序。前者会改变对未解析域名的 IP 拦截覆盖,不能当成无代价优化。 + +### D5 · P2:三个地区测速地址在 Surge 中实际未生效 + +数据库三个 url-test 组的 `testUrl` 为 Cloudflare 的 HTTP 204 地址,配置第 83–85 行输出为组参数 `url=`。全局 `proxy-test-url` 却为 Apple 的地址,节点没有 `test-url`。 + +当前官方规范明确组上的旧 `url=` 无效,测速以节点 `test-url` 或全局地址为准。因此 UI 中组级测速地址与 Surge 实际测试依据不一致。[自动测速组](https://manual.nssurge.com/policy-groups/url-test.html) + +修复建议:把可用的设置模型改为官方支持的节点/全局测试参数;不要仅在组编辑界面保留一个看似可用但不会生效的字段。 + +### D6 · P1(取决于设备可达性):控制接口使用全接口监听和模板默认口令 + +生产 General 同时配置 Remote Controller 和 HTTP API,均绑定 `0.0.0.0`,认证值与公开模板一致,并开启 Web Dashboard。模板本身也在仓库中,因此这些认证值不能视为私密、独立的设备凭据。订阅生成器原样透传 General。 + +这表示网络可达的控制接口存在风险;没有检查设备防火墙或公网端口,**不认定当前已暴露互联网**。官方说明这两个入口具有控制 Surge 的能力,HTTPS 不能弥补已知认证值的问题。[控制接口规范](https://manual.nssurge.com/profile/general.html) + +修复建议:无远程控制需求时移除;本机需求绑定回环;有局域网控制需求时使用独立凭据与访问限制,避免所有订阅设备共享模板口令。 + +### D7 · P2(取决于设备开关):MITM 关闭服务端证书验证 + +实际订阅有 `skip-server-cert-verify=true` 和 5 个 MITM hostname,但没有 CA 内容或 Keystore 引用。官方说明此项会在 MITM 时关闭真实目标服务器的证书验证;HTTPS 重写需要 MITM 开启和受信任证书。[MITM 规范](https://manual.nssurge.com/http/mitm.html) + +未检查设备是否关联其他包含 CA 的本地配置、模块或证书设置,因此不能宣称现在的 HTTPS 重写有效,也不能宣称已经发生 MITM 安全事件。修复建议:若需要 MITM,保留上游验证并在设备侧核实证书与开关;若仅需要代理分流,评估是否应移除不需要的 MITM 与重写配置。 + +## 需要明确业务意图的配置 + +这些项目有官方支持,不直接判为错误: + +| 项目 | 当前实际行为 | 需要明确的意图 | +|---|---|---| +| `⚡️ Automatic` | 实际类型是 select,默认第一项 Singapore,不跨地区自动选最快 | 名称是否应改,或者确实需要跨地区自动选择 | +| `🎯 NoAuto` | 首项 Mainland → DIRECT;FINAL 指向它并带 dns-failed | 新设备/无保存选择时,未知流量与 DNS 失败是否应默认直连 | +| `🪙 Bybit` | 首项 DIRECT | 是否希望交易业务默认直连,还是应固定所选出口 | +| `📋 All Nodes` 与地区组 | Direct 和 Warp 节点混在一起,地区 url-test 可切换出口 | AI/交易业务是否需要限制出口类型及切换范围 | +| FFSB 的进程直连 | App Bundle 写法有效,但规则在所有服务/通用集合之后 | 是只对未被前面规则匹配的请求直连,还是要整个 App 强制直连 | +| `DOMAIN-KEYWORD,byb` | 对任何包含该短字符串的域名匹配 | 是否可以缩小为已知域名,降低误收范围 | +| SMTP 相关直连 | `DOMAIN-SUFFIX,smtp` 匹配域名后缀;`URL-REGEX,(Subject\|HELO\|SMTP)` 检查 HTTP URL | 是否原本想匹配邮件协议;这两条不会识别邮件正文或 SMTP 命令 | + +select 的首项是未选过或保存的选择已失效时的默认;不能据此断言用户当前选中的出口。[手动选择组](https://manual.nssurge.com/policy-groups/select.html) + +`PROCESS-NAME,/Applications/FFSB.app/` 的首尾斜线属于官方 App Bundle 前缀匹配模式,不应误报为非法目录规则。[进程规则](https://manual.nssurge.com/rules/process.html) + +URL-REGEX 的作用对象是经过 HTTP 引擎处理的 URL,HTTPS 还取决于 MITM;正则中出现 SMTP/HELO 不会使它变成邮件协议匹配规则。[HTTP 规则范围](https://manual.nssurge.com/rules/http.html) + +## 已核实正常与验证边界 + +- HTTPS 订阅的 `#!MANAGED-CONFIG` self URL 与实际请求完全一致;没有回落到内网 URL。当前 `interval=86400, strict=true` 受官方支持,但更新间隔不是推送保证,strict 也意味着更新失败时的可用性取舍。[托管配置](https://manual.nssurge.com/profile/managed-profile.html) +- 生产三个地区正则能收纳当前 22 个可用节点,组引用无环,没有悬空规则策略,FINAL 位于末尾。 +- 8 个 SS over WS 排除符合 Surge 的能力范围;不要为了使节点数相同强行输出。[Shadowsocks](https://manual.nssurge.com/policies/shadowsocks.html) +- Hysteria2 的 `salamander-password` 受支持,但官方注明需要 Mac 6.4.3+;实际检查使用 6.9.1。Host 规则集映射需要 Mac 5.10.0+。[Hysteria2](https://manual.nssurge.com/policies/hysteria2.html) +- 60 个外部资源本次均可下载。`global_plus.conf` 只有注释,没有有效条目;这是零覆盖,不是失效 HTTP 响应。资源可动态更新,设备缓存内容可能不同。 +- 官方允许规则集中的非法行被跳过并记录警告。因此本次快照检查和 CLI 返回 OK,不能证明全部 166,028 个条目均已被客户端接受;逐条加载警告仍需设备侧检查。[规则集规范](https://manual.nssurge.com/rules/ruleset.html) +- 现有 STUN 拦截的类型、关键字、顺序可用。生产没有全局 PROTOCOL,UDP,REJECT;`udp-policy-not-supported-behaviour=REJECT` 仅是匹配到不支持 UDP 的策略后的回落,不是全局禁用 UDP。[协议规则](https://manual.nssurge.com/rules/protocol-and-network.html) +- Host 的 `server:` 映射只管 Surge 自己的 DNS 客户端,不会因 `use-local-host-item-for-proxy=true` 而改变远端代理的解析器;该开关只作用于 IP 映射。[Host 代理行为](https://manual.nssurge.com/dns/local-dns-mapping.html) +- 当前四个站点的节点域名都有显式 Host IP 映射。没有因此假设永远不会出现 DNS 引导回退;仍需核对所选节点与设备日志。[加密 DNS 引导与回退](https://manual.nssurge.com/dns/encrypted-dns.html) +- 没有切换本机配置、修改保存的策略选择、发起节点测速/带宽测试、导入 MITM CA 或抓取设备业务流量。 + +## 建议处理顺序 + +1. 修复 C1–C4:事务、FINAL、字段编码与循环校验,防止普通管理操作毁掉订阅。 +2. 明确业务例外后修正规则顺序:D1、D2;同时处理 D3 的 DNS 例外、D4 的主动 DNS 与 FINAL 失败策略。 +3. 收紧实际需要的控制入口与 MITM 参数:D6、D7。 +4. 对齐测速与节点映射:D5、C5、C6,再明确跨地区/出口选择策略。 +5. 对修复后的配置重复官方校验,然后在保留用户选择的前提下进行设备侧请求匹配、DNS、节点连接和业务回归。 + +本报告完成体检与复现,未实施以上修复。后续修改应分别列出业务代码变更和生产 API 数据变更;当前不要通过 reset 路径更新生产规则。 diff --git a/docs/SURGE-CHANGES.md b/docs/SURGE-CHANGES.md new file mode 100644 index 0000000..5adc39a --- /dev/null +++ b/docs/SURGE-CHANGES.md @@ -0,0 +1,44 @@ +# Surge 订阅变更与验证 + +2026-10-03 的缺陷与证据见 [体检报告](SURGE-AUDIT-2026-10-03.md)。后续节点、组、规则和设置变更使用本文件的检查;具体 API 与生产备份操作见 update-db 技能。 + +最新节点启用范围、组成员、名称排序与规则优先级见 [当前生产状态](CURRENT-STATE.md)。历史验收成功不能替代新一轮 API 重读;本机模块和已保存策略选择也不等同于数据库配置。 + +## 写入前 + +- 获取最新全量节点、组、规则和设置,明确改动对象及受影响订阅范围。保留策略选择和出口语义;名称正则、节点范围、客户端排除不能只按数据库行数检查。 +- 分别核对节点登记总量、enabled、订阅 nodeIds 和客户端能力过滤。当前停用记录继续保留;All Nodes/AI Nodes 使用同一节点来源和独立选择,排序调整先核对 sortOrder,再看完整名称。 +- 节点/组名称不能包含配置分隔符、控制字符或与内置策略及另一类实体冲突。导入预览先验证字段;组更新替换完整 members,校验存在性、成员类型及完整图无环。 +- 保住唯一启用、末尾 FINAL;完整排序,不通过停用/删除 FINAL 处理兜底。不用 reset 调整生产;reset 的真实含义是事务内恢复模板并保留自定义规则。 +- 用官方规则顺序检查业务例外与广告/服务规则集的遮蔽,协议规则不放在提前覆盖其目标的域名规则后。IP 黑名单的 `no-resolve` 会改变未解析目标的拦截覆盖,要记录取舍。 +- DNS 对照连接规则、手写 Host、加密 DNS 出口和 FINAL 的 dns-failed 行为。Host 不能表达进程/协议/IP 路由,远端代理解析和浏览器自带 DoH 也不是同一条路径。 +- Surge 测速用节点 `test-url` 或 General 的 `proxy-test-url`,不用组级 `url=`。共享订阅不传播默认控制口令,MITM 保留上游证书验证。 + +## 本地与生产验证 + +代码修改运行相关单测与类型检查;共享字段或 IR 修改运行全量类型检查及单测,页面修改再构建。数据库/管理 API 测试必须在导入 db 模块之前设置绝对临时 DATA_DIR。 + +将候选配置存到私有临时目录,文件权限 600,然后运行: + +```bash +python3 scripts/check-surge.py /private/tmp/.conf +python3 scripts/check-surge.py /private/tmp/.conf --fetch-rulesets +``` + +脚本先调用本机官方 `surge-cli --check`,再检查 FINAL、无效组级 url、公开控制口令和 MITM 参数。第二条下载当前规则集,以临时本地快照再次调用官方 CLI,并提示更早不同策略对后面域名规则样例的覆盖。提示需要结合业务审阅,不能自动将所有广告域名放行。它不模拟 IP/协议/进程或客户端缓存,规则集非法行也可能被 Surge 跳过;有疑点时查看设备资源加载警告及请求匹配日志。 + +生产变更先在线备份,确认本次文件为 root 属主、600 权限;不要仅因脚本退出成功就假定权限正确。经管理 API 逐条核对状态,失败停止;在新代码防护部署后再写需要新防护的数据。组成员整体替换,排序使用完整且无重复的 ids;节点权重更新以私有文件保留全部原字段,重读排除排序/更新时间后比对,避免改变协议或凭据。写入后重读变更对象、核对数量与完整顺序,从实际 HTTPS 入口拉订阅并核对 MANAGED-CONFIG 的 self URL,然后重复以上校验。通过回环 API 拉的订阅可能带内网 self URL,不能拿它替代实际 HTTPS 验收。配置中的 token、密码、UUID、Host IP 不落仓库或共享日志。 + +下载使用 `curl -fsSL --max-time ...`,完成后检查文件存在、非空、预期段落或 YAML 结构,再运行原生校验。串行验证遇错停止;如果管道会隐藏前一步失败,启用 pipefail 或分别读取返回码。mihomo 在配置路径不存在时可能创建默认配置并报告成功,这种输出不能算真实订阅通过。 + +Clash 受影响时,用独立临时目录运行 `mihomo -t -d <临时目录> -f <配置>`;不要覆盖正在使用的配置或启动代理服务。没有客户端 CLI 的产物用对应单测和结构检查,明确尚缺的真机验收。HTTP 正文不包含 excluded/skippedRules,需将启用节点与实际产物逐项比对;配置规则条数也会受派生规则和客户端跳过类型影响。 + +Shadowrocket/Clash 的完整差异与待验项见 [2026-10-04 体检](MOBILE-CLIENT-AUDIT-2026-10-04.md)。除解析外还要检查节点完整名称顺序、UDP 能力、动态组显式成员、RULE-SET 的 no-resolve 和实际跳过规则。Shadowrocket 官方更新记录确认存在逻辑规则,不能把生成器的保守跳过范围当成客户端能力断言。 + +默认不切换活动 Surge 配置、修改保存的组选择或导入 CA。用户授权的本机复测需要更新既有托管配置时,先核对托管 URL 与当前配置,备份并读取最新组选择,再执行 `surge-cli managed-profile update`。该命令可能仅报告已安排重载;须随后读取 `dump profile effective` 和 `policy-group get` 确认真正生效,不把紧接更新的旧规则查询当作最终结果。 + +组改名时 API 会级联生产引用,但客户端保存选择按组身份存储。核对新组、父组路径和两个独立节点组的最新实际选择;仅迁移原选定节点到新组名,不借验证更换线路。用 `profile diff` 区分原订阅与模块覆盖;数据库 url-test 与设备有效 Smart 等类型可能不同,不直接用客户端覆盖修改数据库。 + +CLI 成功只证明解析;节点连接、测速结果、DNS/WebRTC 和业务连接仍需设备侧验证。Net.Coffee 的已完成复测限定于本机 Chrome+Surge 的随机探测域名路径,详见 [DNS 复测](NET-COFFEE-DNS-2026-10-04.md),不能代替其他设备或 Claude 全部业务请求的验收。 + +依据:[匹配与 DNS](https://manual.nssurge.com/rules/overview.html)、[FINAL](https://manual.nssurge.com/rules/final.html)、[成员导入](https://manual.nssurge.com/policy-groups/policy-including.html)、[测速](https://manual.nssurge.com/policy-groups/url-test.html)、[Host](https://manual.nssurge.com/dns/local-dns-mapping.html)、[引用格式](https://manual.nssurge.com/profile/format.html)、[CLI](https://manual.nssurge.com/tools/cli.html)。 diff --git a/docs/SURGE-FIX-2026-10-03.md b/docs/SURGE-FIX-2026-10-03.md new file mode 100644 index 0000000..822a002 --- /dev/null +++ b/docs/SURGE-FIX-2026-10-03.md @@ -0,0 +1,54 @@ +# Surge 订阅修复与验证:2026-10-03 + +本次完成 [体检报告](SURGE-AUDIT-2026-10-03.md) 中已确认缺陷的代码修复和生产定向调整。原体检报告保留为修复前证据,本文数量与验收属于修复当日;最新盘点见 [当前生产状态](CURRENT-STATE.md),后续变更流程见 [Surge 变更验证](SURGE-CHANGES.md)。 + +## 修复结果 + +| 原问题 | 已实施的处理 | +|---|---| +| C1 重置丢失规则 | 事务内恢复模板,更新保留自定义规则的顺序,不重新 INSERT 原主键;自定义 FINAL 不与模板重复;故障注入证明失败整体回滚 | +| C2 FINAL 缺乏保护 | API 拒绝停用、删除、改类型、重复创建、缺失/重复/不完整排序;IR 防御性保证唯一末尾 FINAL;页面关闭无效操作 | +| C3 配置字段编码 | 节点/组名称与控制字符校验,导入预览和写入一致;Surge 值、规则参数转义;逻辑表达式校验结构、无策略子规则和嵌套深度 | +| C4 组循环引用 | 写入前验证完整图、引用存在性与成员类型;组与成员替换、改名级联使用事务;节点/组/内置策略禁止名称冲突 | +| C5 丢成员及单来源 | 保留显式节点和内置成员;包含全部成员组来源;全节点展开去重且显式成员先于自动补充成员 | +| C6 VMess 映射 | 输出 TLS skip-cert-verify 与受支持 cipher;Surge 排除非零 alterId 和不支持 cipher 的 VMess,回报原因;当前生产节点不受额外排除 | +| D1 业务例外遮蔽 | 将 AppsFlyer/Amplitude 既有例外放到广告集合之前,保留原策略 | +| D2 视频 UDP 遮蔽 | googlevideo UDP 拦截移到 STUN 后、业务域名集合前,未扩大为全局 UDP 阻断 | +| D3 DNS 例外冲突 | 手写 Host 优先;按规则顺序保留 AI 之前的非 AI 域名/规则集 DNS 例外;update.googleapis.com 的原精确 DNS 映射不再被覆盖 | +| D4 提前主动解析 | 生产 IP reject RULE-SET 加 no-resolve;对尚未解析域名不再主动查 IP,已解析/IP 字面量仍可按黑名单处理 | +| D5 测速地址无效 | 删除 Surge 无效组级 url=;生产 General 的 proxy-test-url 改为 Cloudflare 204;UI 标明组级地址仅用于 Clash/ShadowRocket;fallback 不输出 tolerance | +| D6 默认控制口令 | 生产与初始化模板移除默认控制接口、Dashboard 及相关 TLS 参数;API/CLI 检查拒绝公开默认口令(包括带引号的写法);恢复 LAN 访问限制 | +| D7 MITM 上游验证 | 生产与模板改为 skip-server-cert-verify=false;API/CLI 检查阻止重新关闭上游验证 | + +`scripts/check-surge.py` 是新增的只读本地验证入口:官方 Surge CLI、FINAL、测速与安全参数、当前规则集快照,以及静态域名遮蔽提示。update-db helper 新增 `--body-file`,敏感 JSON 不进入命令参数,并校验/引用请求路径。它通过 mock SSH 验证 stdin 正文、URL 引用及危险路径拒绝。 + +## 生产变更 + +在线备份:`/opt/proxy-station/data/backups/before-surge-audit-fix-20261003-223218.db`,SQLite `PRAGMA quick_check` 返回 `ok`,备份含 116 条规则。 + +在最新快照上预演三项管理 API 操作,执行前再次确认节点、组、规则、规则集与设置未被其他修改。三个 PUT 均返回 200:IP 黑名单参数、完整规则排序、Surge 设置。没有调用生产 reset、直接写 SQLite 或改动节点凭据、组成员和订阅范围。 + +变更后盘点保持:30 个节点、14 个组、32 个成员、116 条规则(115 启用)、62 个规则集、1 个订阅、6 个设置。Surge 仍输出 22 个节点,8 个 SS over WS 按能力排除。 + +前后端已部署;前端/源码指纹、systemd active、公网健康和无 token 管理接口 401 均通过。工作区尚未提交,没有创建 git 提交或推送。 + +## 验证证据 + +- 全量类型检查通过,44 项 Vitest 通过;隔离 SQLite 验证真实 API、事务回滚、重置与 FINAL/循环/非法输入保护。 +- 本机 Surge Mac 6.9.1(12290)实际校验六组占位配置,覆盖带逗号正则与密码、引号/反斜线、动态成员、TLS 与 cipher、逻辑规则及通知参数、缺失 FINAL 防御。 +- 前端 production build 通过。 +- 生产 API 快照中的 160 个节点/组/规则对象通过新 schema;候选操作在隔离数据库通过实际 API 路由执行。 +- 真实 HTTPS 的 Surge、Clash 和 ShadowRocket conf 均返回 200,并与已验证候选逐字节一致。 +- Surge 实际配置官方 CLI:OK;60/60 当前远程资源下载成功,替换为本地快照后官方 CLI:OK,静态域名遮蔽提示 0。 +- Clash 实际配置在独立临时目录下 `mihomo -t` 通过;没有启动代理或覆盖活动配置。 +- 技能 frontmatter、相对引用、符号链接、脚本语法及 git diff 空白检查通过。技能校验器缺少 PyYAML,使用项目现有 YAML 库适配其解析接口运行原校验规则,没有安装全局依赖。 + +Surge HTTPS 正文 SHA-256:`0c12a56f72bbc0e1b116570bbcccd2208cdd1ca06568391a7769728ae5499caf`。脱敏校验结果见 [验证数据](SURGE-FIX-2026-10-03.verification.json)。原始 API 快照和含凭据配置仅在权限受限的临时目录,不进入仓库或报告。 + +## 保留的业务选择与验收边界 + +Automatic 仍为手动 select,Bybit 与 FINAL 的原默认直连语义、googleapis 的 AI 路由范围、Direct/Warp 混合候选、FFSB 和 SMTP 相关规则未擅自改成其他业务策略。googleapis 范围若要缩小,应另以业务域名范围为依据调整;本次修复其 DNS 例外优先级。 + +no-resolve 会降低对未解析域名的 IP 黑名单覆盖,这是避免提前主动 DNS 的明确取舍。控制接口默认移除;需要远程控制的设备应在设备侧配置独立凭据与访问限制。设备需更新订阅后应用配置;托管订阅并非即时推送。 + +本次没有切换设备活动配置、改变保存的组选择、测速连接、导入 CA 或抓取业务流量。CLI 成功不证明实际节点连通、测速结果、业务解锁、MITM 开关/CA 或 DNS/WebRTC 无泄漏;这些仍属于设备侧验收。 diff --git a/docs/SURGE-FIX-2026-10-03.verification.json b/docs/SURGE-FIX-2026-10-03.verification.json new file mode 100644 index 0000000..8ac8da1 --- /dev/null +++ b/docs/SURGE-FIX-2026-10-03.verification.json @@ -0,0 +1,68 @@ +{ + "surge": { + "httpStatus": 200, + "candidateExactByteMatch": true, + "bytes": 24963, + "sha256": "0c12a56f72bbc0e1b116570bbcccd2208cdd1ca06568391a7769728ae5499caf", + "nativeExitCode": 0, + "rulesetSnapshotCliExitCode": 0 + }, + "clash": { + "httpStatus": 200, + "candidateExactByteMatch": true, + "bytes": 25971, + "sha256": "98ff38b94544315bea8782344fa7a547c209be876653382fb3ebe78f45a002dd", + "nativeExitCode": 0 + }, + "shadowrocket-conf": { + "httpStatus": 200, + "candidateExactByteMatch": true, + "bytes": 12426, + "sha256": "90684cac8b1fb99df6b22b3629f5fab81a72f1bbf39e5fed4351494d819b783a" + }, + "backup": { + "file": "before-surge-audit-fix-20261003-223218.db" + }, + "schemaVersion": 1, + "date": "2026-10-03", + "client": { + "SurgeMac": "6.9.1", + "build": "12290" + }, + "checks": { + "typecheck": "all packages passed", + "vitestPassed": 44, + "nativeSyntheticProfiles": 6, + "webBuild": "passed", + "schemaValidatedObjects": 160, + "rulesetDownloads": 60, + "domainShadowingWarnings": 0, + "skillValidation": "passed", + "apiHelperMockValidation": "passed" + }, + "productionCounts": { + "nodes": 30, + "groups": 14, + "members": 32, + "rules": 116, + "enabledRules": 115, + "rulesets": 62, + "tokens": 1, + "settings": 6, + "surgeNodes": 22, + "excludedShadowsocksWebSocket": 8 + }, + "apiWrites": [ + 200, + 200, + 200 + ], + "backupQuickCheck": "ok", + "deployment": { + "workingTreeUncommitted": true, + "gitHeadBasis": "7358008", + "frontendFingerprintPrefix": "e11cf89c7ea8", + "backendFingerprintPrefix": "062622032ebd" + }, + "deviceTrafficValidated": false +} diff --git a/docs/SURGE-PRIVACY.md b/docs/SURGE-PRIVACY.md index 1420594..a5e7c5c 100644 --- a/docs/SURGE-PRIVACY.md +++ b/docs/SURGE-PRIVACY.md @@ -1,5 +1,13 @@ # Surge AI 域名 DNS 分流 +当前数据库与实际 HTTPS 产物、节点/组选择链见 [生产状态](CURRENT-STATE.md)。AI DNS 由生成器派生,检查时区分数据库 Surge 原文、最终订阅及设备模块后的有效配置。 + +Claude/ChatGPT 的精确补充域名、广告拦截优先级例外、官方 inbound IP 兜底与不采用全局 NTP 分流的理由,见 [2026-10-04 AI 规则审阅](AI-RULE-REVIEW-2026-10-04.md)。该次补充保留已有 AI 规则集;新增域名同样由下述流水线派生 DNS 映射。 + +同日后续已将全部启用的 AI 规则集中排到广告及普通分流之前,按用户选择保留 STUN 阻断和 Google 更新/翻译直连例外。Unbreak 仍在 AI 之前,生成的 Host 映射也保持该顺序;手写 Host 未改动。具体排序、验证及限制见 [AI 与广告优先级](AI-PRIORITY-2026-10-04.md)。策略组显示顺序不决定连接规则或 DNS 匹配优先级。 + +Net.Coffee 的 Claude 页面通过随机 `*.d.ip.net.coffee` 子域名检测 DNS,而不是查询 Claude 域名。线上已按用户要求加入 `DOMAIN-SUFFIX,ip.net.coffee,🤖 AI`;精确 DOMAIN 根域规则无法覆盖这些探测子域。页面与探测子域名采用 AI 连接及派生 DNS 映射,本机更新同一托管订阅后,由中国电信 DNS「可能泄露」变为美国 Cloudflare「未检测到泄露」。页面检测权威 DNS 观察到的递归解析器,并按地区判断,不要求解析器 IP 等于代理出口 IP;无返回结果也不能证明无泄漏。检测机制、备份与验收边界见 [Net.Coffee DNS 复测](NET-COFFEE-DNS-2026-10-04.md)。 + 默认 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 与连接规则是独立匹配系统,复杂优先级或例外可能不同。 @@ -12,13 +20,15 @@ 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 组。 +AI Host 映射使用 `server:https://1.1.1.1/dns-query`。手写 Host 项在前,保留精确 DNS 例外;随后按连接规则顺序生成至最后一条 AI 域名规则的映射,先前非 AI 域名/规则集使用 General 的普通 DNS。这样 Unbreak 等先前例外不会被后面的宽泛 AI 映射覆盖。选择其他 AI 线路后 Cloudflare 查询也跟随同一策略组。该 IP 规则也会将访问 1.1.1.1 的其他连接交给 AI 组。 + +AI 可以选择地区组或独立的 `🧠 AI Nodes`;后者与 All Nodes 的节点来源相同、保存选择独立,详见 [AI Nodes](AI-NODES-2026-10-04.md)。2026-10-04 数据库 General 原文中的 encrypted-dns-follow-outbound-mode 仍为 false,生成器将最终配置派生为 true;不需要为这个差异重复修改全局 DNS 设置。 本功能控制 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 防护。 +数据库 settings/rules 是已有订阅的来源,模板仅首次初始化导入。修改默认 DNS 时使用 update-db 技能备份并经管理 API 更新;不重置规则。全局 PROTOCOL,DOH,AI 已停用;保留 STUN 阻断,特定 googlevideo UDP 拦截放在服务域名规则之前。生产没有全局 UDP 拦截,不把 UDP 回落参数误认为全局阻断。 导入时关联 General、Proxy、Proxy Group、Rule、Host,并开启增强模式。无需 MITM 证书。更新后清除 DNS 缓存并复测 Surge DNS 请求日志;生成配置验证不是设备泄漏测试。 diff --git a/edge-nodes/AGENTS.md b/edge-nodes/AGENTS.md new file mode 100644 index 0000000..0b445ba --- /dev/null +++ b/edge-nodes/AGENTS.md @@ -0,0 +1,9 @@ +# 边缘节点目录约定 + +本目录继承根目录 `AGENTS.md`。目录结构、当前舰队和迁移记录见 `README.md`。 + +- `private/` 含真实节点凭据与控制面信息,是 Git 忽略的本机工作区;读取时避免向对话输出秘密,写入新文件用 `umask 077`。该目录和 `private/archive/` 不得提交、上传或部署为公开静态资源。 +- 通用部署工具唯一源码在 `.claude/skills/edge-node-multi-protocol/`;按任务读取该技能与目标节点的私有交付记录。历史站点脚本保留差异,不能用通用副本整体覆盖现役站点配置。 +- `private/archive/` 保存的原工程指令和脚本仅供追溯,不用它们替代本项目的协作约定。 +- 汇总清单在 `private/fleet.json`;生成离线页用本目录 `build-delivery-doc.py`。新增站点不在生成器里写真实域名、密码、UUID 或路径。 +- 节点部署、Proxy Station 应用发布、生产订阅数据更新分别按用户任务执行。本地迁移与文档更新不会授权远端变更。 diff --git a/edge-nodes/CLAUDE.md b/edge-nodes/CLAUDE.md new file mode 100644 index 0000000..e86b093 --- /dev/null +++ b/edge-nodes/CLAUDE.md @@ -0,0 +1,4 @@ +@../AGENTS.md +@AGENTS.md + +节点目录补充约定见本目录 `AGENTS.md`,部署流程使用项目级 `edge-node-multi-protocol` 技能。 diff --git a/edge-nodes/README.md b/edge-nodes/README.md new file mode 100644 index 0000000..cac460c --- /dev/null +++ b/edge-nodes/README.md @@ -0,0 +1,84 @@ +# 边缘节点工作区 + +节点部署管理与 Proxy Station 开发现在统一在本仓库进行。通用工具可随 Git 维护,真实配置与交付产物保存在本机的 `private/` 中。 + +```text +edge-nodes/ +├── build-delivery-doc.py # 离线汇总页生成器,不含站点凭据 +├── fleet.example.json # 占位清单,供新工作区参考 +└── private/ # Git 忽略,目录权限 700 + ├── fleet.json # 当前舰队清单,含凭据,权限 600 + ├── sg-edge-node-docs/ + ├── la-edge-node-docs/ + ├── rn2-edge-node-docs/ # RN 当前部署(2026-08-01 重建) + ├── jp-edge-node-docs/ + ├── rn-edge-node-docs/ # RN 历史交付 + ├── 边缘节点交付文档.html # 内嵌链接与二维码的私有离线页 + └── archive/ # 原 CLAUDE.md、生成器与 .skill 包的迁移快照 +.claude/skills/edge-node-multi-protocol/ +├── SKILL.md +├── scripts/ # 通用 Bash/Python 部署、检测与链接生成工具 +├── references/ # 部署流程、版本兼容、证书、DNS、3x-ui 结构 +└── templates/ # 未填入真实值的交付模板与门面页 +.agents/skills/edge-node-multi-protocol -> ../../.claude/skills/edge-node-multi-protocol +``` + +## 当前舰队 + +| 站点 | 服务商/地区 | 私有工作目录 | +|---|---|---| +| sg | AWS/新加坡 | `private/sg-edge-node-docs/` | +| la | DMIT/洛杉矶,承载 Proxy Station 生产服务 | `private/la-edge-node-docs/` | +| rn | RackNerd/洛杉矶 | `private/rn2-edge-node-docs/` | +| jp | VMISS/东京,裸 nginx | `private/jp-edge-node-docs/` | + +站点信息是迁移时的工作记录;维护前仍需读取私有交付文档并核对实际机器。`rn-edge-node-docs/` 的旧链接不作为当前 RN 节点导入来源。 + +应用内的登记/启用节点与舰队交付线路是不同统计范围。最新应用盘点为 30 个登记节点、12 个启用、18 个停用,见 [当前生产状态](../docs/CURRENT-STATE.md);停用不表示远端线路故障,维护节点或生成交付页不自动恢复应用开关。 + +2026-10-03 四台现役节点已统一使用 IPv4:系统接口禁用 IPv6,代理层拒绝 IPv6 目标(含 WARP),Cloudflare 不保留节点 AAAA。后续重建与扩展遵守 [IPv4-only 维护约定](../docs/EDGE-IPV4-ONLY.md);具体备份与验收在各站点的私有 `04-IPv4-only.md`。 + +## 日常工作 + +在仓库根目录启动 Codex/Claude Code。节点部署或维护使用 `edge-node-multi-protocol` 技能,应用发布使用 `deploy`,线上订阅数据使用 `update-db`。技能只维护一份源码,Codex 通过 `.agents/skills/` 相对链接发现。 + +已有节点使用其私有 `scripts/` 工作副本,以保留站点适配;修改通用能力时同步整理到技能源目录,按目标节点审阅后更新工作副本。原始副本不会在代码更新时自动覆盖。 + +新增节点可在仓库根目录准备私有目录: + +```bash +umask 077 +mkdir -p edge-nodes/private/-edge-node-docs +cp -R .claude/skills/edge-node-multi-protocol/{scripts,references,templates} \ + edge-nodes/private/-edge-node-docs/ +cp edge-nodes/private/-edge-node-docs/scripts/config.env.example \ + edge-nodes/private/-edge-node-docs/scripts/config.env +``` + +填写真实配置后,按技能选择 aaPanel 或裸 nginx 流程。`.sh` 从本机通过 SSH 操作 VPS,链接生成器在本机运行;哪些 Python 文件在 VPS 上执行见技能。工具依赖 Bash、Python 3、SSH/SCP;本机 `qrencode` 用于离线二维码。脚本不会因为复制到本项目而自动执行。 + +更新 `private/fleet.json` 后生成汇总页: + +```bash +umask 077 +python3 edge-nodes/build-delivery-doc.py +# 自定义私有清单或输出位置 +python3 edge-nodes/build-delivery-doc.py --fleet /private/tmp/fleet.json \ + --output /private/tmp/delivery.html +``` + +清单的 `updated` 是交付记录日期,`sites` 是站点数组。每条线路通过 `slug` 读取 `.link`、`-qr.png` 与可选 `-singbox.json`;`links_dir` 相对于清单所在目录解析。所有节点名称、订阅内容、二维码仍在本机计算并内联,生成器不访问网络,输出权限为 600。 + +新克隆不会携带 `private/`。从受控备份恢复该目录,或以 `fleet.example.json` 为结构参考新建私有清单和分享链接。示例故意没有可用凭据与链接,不能直接生成真实交付页。 + +分享链接通过 Web 导入 Proxy Station,见 [节点导入指引](../docs/SEEDING.md)。应用运行时不自动读取私有工作区;生产数据库修改继续走管理 API。 + +## 迁移记录与安全 + +2026-10-03 从原 `../edge-nodes/` 复制 284 个文件,逐文件核对字节一致;节点目录保留全部工作副本、真实配置、交付文档、链接与二维码。原生成器的 4 个站点、33 条线路迁成私有 JSON 清单;通用工具来自原 `.skill` 包及节点目录中的部署修复、裸 nginx、QUIC 与 SOCKS5 扩展。 + +原目录保留为迁移前备份。后续只在本仓库维护;`private/archive/` 的旧指令、旧生成器与技能包仅供追溯,不是当前工作入口。迁移未执行远端部署、生产数据库写入、Git 提交或推送。 + +本地验证已通过:9 个 Python 文件编译、23 个 Bash 文件语法、技能 frontmatter/相对链接、私有文件 Git 忽略与权限检查。离线汇总页包含 4 个站点、33 个唯一线路名,订阅内容与迁移前一致且不加载外部资源;占位配置验证了六条 TCP+四条 QUIC 分享链接和 HY2/TUIC 配置生成。没有执行远端连接、测速或真机客户端验收。 + +`private/`、`config.env` 及其备份、`node-links/` 均被 Git 忽略;Docker 构建上下文也排除节点工作区。私有目录应通过受控备份转交,不能使用 Git、在线二维码或公开交付页同步。不要将填写过的真实值放回技能源目录的脚本、参考资料或模板。 diff --git a/edge-nodes/build-delivery-doc.py b/edge-nodes/build-delivery-doc.py new file mode 100644 index 0000000..b5dc027 --- /dev/null +++ b/edge-nodes/build-delivery-doc.py @@ -0,0 +1,764 @@ +#!/usr/bin/env python3 +"""Build the offline fleet delivery page from a private JSON inventory. + +Share links and QR codes are read locally and embedded in the output. No network +requests are made. The default inventory and output live in Git-ignored private/. +Add sites to the inventory; do not embed live credentials in this source file. +""" +import argparse +import base64 +import html +import json +import os +import sys +import urllib.parse +from pathlib import Path + +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument("--fleet", type=Path, default=Path(__file__).resolve().parent / "private/fleet.json", + help="private inventory; relative links_dir paths resolve beside this file") +parser.add_argument("--output", type=Path, help="output HTML (default: beside the inventory)") +args = parser.parse_args() +fleet_path = args.fleet.resolve() +try: + fleet = json.loads(fleet_path.read_text(encoding="utf-8")) +except FileNotFoundError: + sys.exit("ERROR: fleet inventory not found; see edge-nodes/README.md") +except json.JSONDecodeError: + sys.exit("ERROR: fleet inventory is not valid JSON") +SITES = fleet.get("sites") +if not isinstance(SITES, list) or not SITES: + sys.exit("ERROR: fleet inventory must contain a non-empty sites list") +UPDATED = fleet["updated"] +for site in SITES: + site["links_dir"] = str((fleet_path.parent / site["links_dir"]).resolve()) +OUT = str(args.output.resolve() if args.output else fleet_path.parent / "边缘节点交付文档.html") + +# ---------------------------------------------------------------- asset loading + +def read_link(site, slug): + p = os.path.join(site["links_dir"], slug + ".link") + return open(p).read().strip() if os.path.exists(p) else None + + +def read_qr(site, slug): + p = os.path.join(site["links_dir"], slug + "-qr.png") + if not os.path.exists(p): + return None + return base64.b64encode(open(p, "rb").read()).decode() + + +def read_json(site, slug): + p = os.path.join(site["links_dir"], slug + "-singbox.json") + return open(p).read().strip() if os.path.exists(p) else None + + +missing = [] +for s in SITES: + for ln in s["lines"]: + ln["link"] = read_link(s, ln["slug"]) + ln["qr"] = read_qr(s, ln["slug"]) + ln["json"] = read_json(s, ln["slug"]) + if ln["has_link"] and not ln["link"]: + missing.append("%s/%s.link" % (s["id"], ln["slug"])) +if missing: + sys.exit("ERROR: missing link files: " + ", ".join(missing)) + +TOTAL = sum(len(s["lines"]) for s in SITES) + +# ---------------------------------------------------------------- subscription +# One aggregated subscription so the client picks the exit itself, instead of us +# baking a routing policy into a config. Node names follow a fixed grammar: +# [国家码] [站点码] [协议] [Direct|Warp] +# The site code is dropped when it would repeat the country code (SG's only node +# would otherwise read "SG SG ..."), so Singapore lines are three words wide. +# +# Everything here is computed at build time and inlined; the page never fetches +# anything. There is no hosted https:// endpoint on purpose -- see the "自建 URL" +# note in the rendered block. + + +def sub_name(site, ln): + parts = [site["country"]] + if site["code"] != site["country"]: + parts.append(site["code"]) + parts.append(ln["proto"]) + parts.append("Direct" if ln["egress"] == "direct" else "Warp") + return " ".join(parts) + + +def rename_link(link, name): + """Rewrite a share link's display name, leaving every transport field alone.""" + if link.startswith("vmess://"): + raw = link[len("vmess://"):] + raw += "=" * (-len(raw) % 4) + cfg = json.loads(base64.b64decode(raw).decode()) + cfg["ps"] = name + blob = json.dumps(cfg, ensure_ascii=False, separators=(",", ":")) + return "vmess://" + base64.b64encode(blob.encode()).decode() + return link.split("#", 1)[0] + "#" + urllib.parse.quote(name) + + +for s in SITES: + for ln in s["lines"]: + ln["sub_name"] = sub_name(s, ln) + ln["sub_link"] = rename_link(ln["link"], ln["sub_name"]) if ln["link"] else None + +names = [ln["sub_name"] for s in SITES for ln in s["lines"]] +dupes = sorted({n for n in names if names.count(n) > 1}) +if dupes: + sys.exit("ERROR: duplicate node names: " + ", ".join(dupes)) + + +def group(gid, label, sites): + lines = [ln for s in sites for ln in s["lines"] if ln["sub_link"]] + plain = "\n".join(ln["sub_link"] for ln in lines) + return { + "id": gid, "label": label, "n": len(lines), + "plain": plain, + "b64": base64.b64encode(plain.encode()).decode(), + "items": [{"name": ln["sub_name"], "egress": ln["egress"], + "transport": ln["transport"]} for ln in lines], + } + + +GROUPS = [group("all", "全部站点", SITES)] + \ + [group(s["id"], "%s %s" % (s["code"], s["city"]), [s]) for s in SITES] + +# ---------------------------------------------------------------- render helpers +e = html.escape + + +def kv_rows(pairs, mono_value=True): + cls = ' class="v mono"' if mono_value else ' class="v"' + return "\n".join( + '
%s
%s
' % (e(k), cls, e(v)) + for k, v in pairs) + + +def copy_btn(value, label="复制"): + return ('' + % (e(value), e(label), e(label))) + + +def line_card(ln, idx): + eg = ln["egress"] + egtxt = "直连出口" if eg == "direct" else "WARP 净出口" + qr_html = "" + if ln["qr"]: + qr_html = ('' + % (ln["qr"], e(ln["proto"] + " · " + egtxt), e(ln["link"] or ""), + e(ln["proto"]), ln["qr"])) + else: + qr_html = '' + + body = [] + body.append('
' + '%s' + '%s' + '
' % (e(ln["proto"]), eg, e(egtxt))) + body.append('
%s·%s
' + % (e(ln["transport"]), e(ln["endpoint"]))) + if ln.get("plaintext"): + body.append('

认证与流量均为明文,链路上可被读取,且特征易被识别。' + '仅适合可信网络内使用,不要用于穿越审查。

') + + if ln["link"]: + body.append('' + % (e(ln["link"]), copy_btn(ln["link"], "复制链接"))) + if ln["json"]: + body.append('
sing-box 配置片段' + '
%s
%s
' + % (e(ln["json"]), copy_btn(ln["json"], "复制配置"))) + cls = eg + (" plain" if ln.get("plaintext") else "") + return ('
\n %s\n
%s
\n
' + % (cls, qr_html, "".join(body))) + + +def matrix(site): + """Compact routing strip: one cell per line, colour = egress. The fleet's shape at a glance.""" + cells = [] + for ln in site["lines"]: + cls = "plain" if ln.get("plaintext") else ln["egress"] + label = "明文" if ln.get("plaintext") else ("直连" if ln["egress"] == "direct" else "WARP") + cells.append('%s' + % (cls, e(ln["proto"]), label, e(ln["proto"][:2]))) + return '
%s
' % "".join(cells) + + +def render_site(site): + p = [] + p.append('
' % (site["id"], site["id"])) + + # identity + p.append('
') + p.append('
%s' + '

%s%s

' % (site["flag"], e(site["code"]), e(site["city"]))) + p.append('
%s%s
' % (e(site["domain"]), e(site["tagline"]))) + p.append(' %s' % matrix(site)) + p.append('
') + + # 01 站点 + p.append('

01站点

') + p.append('
') + p.append(kv_rows([("服务商", site["provider"]), ("区域", site["region"])], mono_value=False)) + p.append(kv_rows(site["spec"], mono_value=False)) + p.append(kv_rows(site["net"])) + p.append(kv_rows(site["access"])) + p.append('
拥塞控制
%s
' % e(site["bbr"])) + p.append('
') + if site["notes"]: + p.append('
    %s
' % "".join("
  • %s
  • " % e(n) for n in site["notes"])) + p.append('
    ') + + # 02 账号 + p.append('

    02账号

    ') + for pan in site["panels"]: + p.append('
    ') + p.append(' ' % (e(pan["name"]), e(pan["url"]), e(pan["url"]))) + p.append('
    ' + '用户名%s%s' + '密码%s%s
    ' + % (e(pan["user"]), copy_btn(pan["user"], "复制用户名"), + e(pan["pass"]), copy_btn(pan["pass"], "复制密码"))) + if pan.get("note"): + p.append('

    %s

    ' % e(pan["note"])) + p.append('
    ') + p.append('
    ') + p.append("\n".join( + '
    %s
    %s' + '%s
    ' % (e(n), e(u), e(u), e(d)) + for n, u, d in site["sites_plain"])) + p.append('
    ') + p.append('
    ') + + # 03 节点 + p.append('

    03节点

    ') + p.append('
    共用凭据 · 全部 %d 条线路同一套身份
    ' % len(site["lines"])) + for label, val, scope in site["creds"]: + p.append('
    %s%s%s' + '%s
    ' % (e(label), e(val), copy_btn(val, "复制"+label), e(scope))) + p.append('
    实测出口' + '直连 %s' + 'WARP Cloudflare
    ' % e(site["egress_direct"])) + p.append('
    ') + p.append('
    ') + for i, ln in enumerate(site["lines"]): + p.append(line_card(ln, i)) + p.append('
    ') + p.append('
    ') + + p.append('
    ') + return "\n".join(p) + + +def render_subs(): + chips = "\n".join( + ' ' + % (g["id"], "true" if i == 0 else "false", e(g["label"]), g["n"]) + for i, g in enumerate(GROUPS)) + + data = json.dumps({g["id"]: {"plain": g["plain"], "b64": g["b64"], "items": g["items"]} + for g in GROUPS}, ensure_ascii=False).replace("<", "\\u003c") + + return """ +
    +
    +

    订阅

    +

    把全部线路打成一份订阅,节点名统一为 [国家码] [站点码] [协议] [Direct/Warp], + 导入后在客户端里自行挑线路。内容由本页离线生成,不访问任何服务器。

    +
    + +
    +%(chips)s +
    + +
    + + + + +
    + +
      + +
      + 怎么导入 +
      +

      v2rayN / v2rayNG / NekoBox 点「复制明文链接」,在客户端里选「从剪贴板导入批量 URL」。 + 也可先「下载订阅文件」,再用本地文件导入。

      +

      Shadowrocket / Stash 点「复制订阅内容(Base64)」,回到 App 会提示识别到订阅内容; + 或把下载的 .txt 通过 AirDrop / 文件 App 传到手机后用「打开方式」导入。

      +

      Clash 系(Verge / Meta) 不吃这种 Base64 订阅,需要先转成 Clash 配置; + 建议改用 sing-box 或 Xray 内核的客户端,或直接使用上方各线路的 sing-box 配置片段。

      +
      +
      + +
      + 使用 Proxy Station 托管订阅 +
      +

      在 Proxy Station 的「节点」页粘贴本页对应的 VMess、Trojan、Shadowsocks、Hysteria2 + 分享链接,解析预览并导入,再到「订阅」页创建订阅并复制客户端 URL。

      +

      SOCKS5 和 TUIC 线路目前不属于 Proxy Station 支持的节点类型,需单独使用。 + Shadowsocks over WebSocket 在 Surge 订阅中会被排除。

      +

      本页含真实凭据,请只在本机或通过受控文件交付使用。

      +
      +
      +
      + + +""" % {"chips": chips, "data": data} + + +subs = render_subs() + +tabs = "\n".join( + ' ' + % (s["id"], s["id"], "true" if i == 0 else "false", "0" if i == 0 else "-1", + s["flag"], e(s["code"]), e(s["city"]), len(s["lines"])) + for i, s in enumerate(SITES)) + +panels = "\n".join(render_site(s) for s in SITES) + +CSS = """ +:root{ + --ink:#0A0D13; --panel:#121722; --panel2:#171E2B; --rule:#222B3A; + --text:#D9E1EB; --dim:#78848F; --faint:#4C5666; + --direct:#E39B3D; --warp:#3FC7BB; --live:#56C86E; --risk:#E06C6C; + --mono:ui-monospace,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace; + --sans:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif; +} +*{box-sizing:border-box} +html{-webkit-text-size-adjust:100%} +body{margin:0;background:var(--ink);color:var(--text);font-family:var(--sans); + line-height:1.65;font-size:15px;padding:0 0 64px} +.mono,code,pre{font-family:var(--mono);font-variant-numeric:tabular-nums} +a{color:#6FB2F5;text-decoration:none} +a:hover{text-decoration:underline} +.wrap{max-width:1000px;margin:0 auto;padding:0 20px} + +/* masthead */ +.masthead{border-bottom:1px solid var(--rule);background:linear-gradient(180deg,#0D121B,#0A0D13); + padding:26px 0 20px;margin-bottom:0} +.mh-in{display:flex;align-items:baseline;justify-content:space-between;gap:16px;flex-wrap:wrap} +.brand{font-size:1.35rem;font-weight:700;letter-spacing:.06em} +.brand span{color:var(--faint);font-weight:400;margin:0 .5em} +.mh-meta{font-family:var(--mono);font-size:.76rem;color:var(--dim);letter-spacing:.04em} +.mh-meta b{color:var(--text);font-weight:600} + +/* tabs */ +.tabs{display:flex;gap:8px;padding:14px 0 0;flex-wrap:wrap; + position:sticky;top:0;z-index:20;background:var(--ink);border-bottom:1px solid var(--rule)} +.tab{appearance:none;background:transparent;border:1px solid transparent;border-bottom:none; + border-radius:9px 9px 0 0;color:var(--dim);font-family:var(--sans);font-size:.9rem; + padding:9px 16px 11px;cursor:pointer;display:flex;align-items:center;gap:8px; + transition:color .15s,background .15s;margin-bottom:-1px} +.tab:hover{color:var(--text);background:#0F141E} +.tab[aria-selected="true"]{color:var(--text);background:var(--panel); + border-color:var(--rule);box-shadow:inset 0 2px 0 var(--warp)} +.tab:focus-visible{outline:2px solid var(--warp);outline-offset:2px} +.t-flag{font-size:1rem;line-height:1} +.t-code{font-family:var(--mono);font-weight:700;letter-spacing:.06em} +.t-city{color:var(--faint);font-size:.82rem} +.tab[aria-selected="true"] .t-city{color:var(--dim)} +.t-n{font-family:var(--mono);font-size:.7rem;color:var(--faint); + border:1px solid var(--rule);border-radius:20px;padding:0 7px} + +/* panel */ +.panel{background:var(--panel);border:1px solid var(--rule);border-top:none; + border-radius:0 0 12px 12px;padding:26px 26px 30px} +.panel[hidden]{display:none} +.panel:focus-visible{outline:2px solid var(--warp);outline-offset:-2px} + +/* identity */ +.ident{border-bottom:1px solid var(--rule);padding-bottom:18px;margin-bottom:4px} +.ident-main{display:flex;align-items:center;gap:12px} +.ident-main .flag{font-size:1.6rem;line-height:1} +.ident h2{margin:0;font-size:1.5rem;letter-spacing:.08em;font-family:var(--mono);font-weight:700; + display:flex;align-items:baseline;gap:12px} +.ident h2 .city{font-family:var(--sans);font-size:.95rem;font-weight:400;letter-spacing:0;color:var(--dim)} +.ident-sub{display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin-top:6px} +.ident-sub code{font-size:.86rem;color:#9DB2C8;background:none;padding:0} +.tagline{font-size:.76rem;color:var(--faint);border-left:1px solid var(--rule);padding-left:14px} + +/* routing matrix */ +.matrix{display:flex;gap:4px;margin-top:14px;flex-wrap:wrap} +.matrix .cell{font-family:var(--mono);font-size:.66rem;letter-spacing:.04em; + padding:3px 7px;border-radius:4px;border:1px solid;line-height:1.4} +.matrix .cell.direct{color:var(--direct);border-color:rgba(227,155,61,.35);background:rgba(227,155,61,.08)} +.matrix .cell.warp{color:var(--warp);border-color:rgba(63,199,187,.35);background:rgba(63,199,187,.08)} +.matrix .cell.plain{color:var(--risk);border-color:rgba(224,108,108,.4);background:rgba(224,108,108,.09)} + +/* blocks */ +.block{margin-top:30px;scroll-margin-top:72px} /* clear the sticky tab bar */ +.block h3{display:flex;align-items:center;gap:12px;margin:0 0 14px;font-size:.95rem; + font-weight:600;letter-spacing:.14em;color:var(--text); + border-bottom:1px solid var(--rule);padding-bottom:9px} +.block h3 i{font-style:normal;font-family:var(--mono);font-size:.72rem;color:var(--faint); + border:1px solid var(--rule);border-radius:4px;padding:2px 6px;letter-spacing:.06em} + +/* key-value grid */ +.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1px;margin:0; + background:var(--rule);border:1px solid var(--rule);border-radius:9px;overflow:hidden} +.grid.plain{grid-template-columns:1fr;margin-top:14px} +.kv{background:var(--panel2);padding:9px 13px;display:flex;gap:12px;align-items:baseline;min-width:0} +.kv dt{color:var(--dim);font-size:.78rem;flex:0 0 84px;white-space:nowrap} +/* break long unbroken tokens (IPv6, UUID) but never mid-number in a spaced list + like "UDP 443 / 34941 / 52888" -- break-all would split 52888 across lines */ +.kv dd{margin:0;min-width:0;word-break:normal;overflow-wrap:break-word;font-size:.85rem} +.kv.full{grid-column:1/-1} +.kv dd.mono{font-family:var(--mono);font-size:.8rem} +.kv .hint{display:block;color:var(--faint);font-size:.74rem;font-family:var(--sans)} +.notes{margin:14px 0 0;padding:0;list-style:none;display:grid;gap:7px} +.notes li{position:relative;padding-left:18px;font-size:.8rem;color:var(--dim);line-height:1.6} +.notes li::before{content:"";position:absolute;left:4px;top:.62em;width:5px;height:5px; + border-radius:50%;background:var(--faint)} + +/* accounts */ +.acct{background:var(--panel2);border:1px solid var(--rule);border-radius:9px; + padding:14px 16px;margin-bottom:10px} +.acct-top{display:flex;gap:12px;align-items:baseline;flex-wrap:wrap;margin-bottom:9px} +.acct-name{font-weight:600;font-size:.92rem} +.acct-top a{font-family:var(--mono);font-size:.79rem;word-break:break-all} +.acct-cred{display:flex;align-items:center;gap:8px;flex-wrap:wrap} +.acct-cred code,.cred code{background:#0D121B;border:1px solid var(--rule);border-radius:5px; + padding:3px 9px;font-size:.8rem;color:#9FE0B0} +.lbl{color:var(--dim);font-size:.75rem} +.acct-note{margin:10px 0 0;font-size:.78rem;color:var(--direct);line-height:1.6} + +/* shared credentials */ +.shared{background:var(--panel2);border:1px solid var(--rule);border-radius:9px;padding:14px 16px} +.shared-hd{font-size:.78rem;color:var(--dim);margin-bottom:11px;letter-spacing:.03em} +.cred{display:flex;align-items:center;gap:9px;flex-wrap:wrap;padding:5px 0} +.cred .scope{font-size:.73rem;color:var(--faint)} + +/* pills */ +.pill{font-family:var(--mono);font-size:.68rem;letter-spacing:.04em;padding:2px 9px; + border-radius:20px;border:1px solid;white-space:nowrap} +.pill.direct{color:var(--direct);border-color:rgba(227,155,61,.4);background:rgba(227,155,61,.09)} +.pill.warp{color:var(--warp);border-color:rgba(63,199,187,.4);background:rgba(63,199,187,.09)} + +/* line cards */ +.lines{display:grid;gap:10px;margin-top:14px} +.line{display:flex;gap:15px;background:var(--panel2);border:1px solid var(--rule); + border-left:3px solid var(--rule);border-radius:9px;padding:13px 15px;align-items:flex-start} +.line.direct{border-left-color:var(--direct)} +.line.warp{border-left-color:var(--warp)} +.line.plain{border-left-color:var(--risk)} +.ln-warn{margin:8px 0 0;font-size:.75rem;color:var(--risk);line-height:1.6} +.line .qr{flex:0 0 84px;width:84px;height:84px;padding:5px;background:#fff;border:none; + border-radius:7px;cursor:zoom-in;display:block} +.line .qr img{width:100%;height:100%;display:block;image-rendering:pixelated} +.line .qr:focus-visible{outline:2px solid var(--warp);outline-offset:3px} +.line .qr.noqr{background:#0D121B;border:1px dashed var(--rule);cursor:default; + display:flex;align-items:center;justify-content:center;text-align:center} +.line .qr.noqr span{font-size:.66rem;color:var(--faint);line-height:1.5} +.ln-body{min-width:0;flex:1} +.ln-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap} +.proto{font-weight:600;font-size:.95rem} +.ln-meta{font-size:.77rem;color:var(--dim);margin-top:3px;display:flex;gap:7px;align-items:baseline;flex-wrap:wrap} +.ln-meta .sep{color:var(--faint)} +.ln-link{display:flex;align-items:flex-start;gap:8px;margin-top:9px} +/* a VMess link is a 500-char base64 blob; cap it so it can't dominate the card. + The QR and the copy button are the real affordances -- the text is for reference. */ +.ln-link code{flex:1;min-width:0;background:#0D121B;border:1px solid var(--rule);border-radius:6px; + padding:7px 10px;font-size:.71rem;line-height:1.55;word-break:break-all;color:#8FCBA0; + max-height:4.9em;overflow:auto} +.ln-json{margin-top:9px} +.ln-json summary{cursor:pointer;font-size:.77rem;color:var(--dim);width:fit-content} +.ln-json summary:hover{color:var(--text)} +.ln-json summary:focus-visible{outline:2px solid var(--warp);outline-offset:2px;border-radius:3px} +.ln-json pre{margin:8px 0 6px;background:#0D121B;border:1px solid var(--rule);border-radius:6px; + padding:10px 12px;font-size:.71rem;line-height:1.6;overflow-x:auto;color:#A8BDD4} + +/* copy button */ +.copy{appearance:none;background:#1B2432;border:1px solid var(--rule);color:var(--dim); + font-family:var(--sans);font-size:.71rem;padding:3px 10px;border-radius:5px;cursor:pointer; + white-space:nowrap;transition:color .15s,border-color .15s} +.copy:hover{color:var(--text);border-color:var(--faint)} +.copy:focus-visible{outline:2px solid var(--warp);outline-offset:2px} +.copy.done{color:var(--live);border-color:rgba(86,200,110,.5)} + +/* subscription */ +.subs{margin-top:26px;background:var(--panel);border:1px solid var(--rule); + border-radius:12px;padding:22px 24px 24px;scroll-margin-top:72px} /* clear the sticky tab bar */ +.subs-hd h2{margin:0;font-size:1.05rem;letter-spacing:.14em;font-weight:600} +.subs-hd p{margin:8px 0 0;font-size:.81rem;color:var(--dim);line-height:1.75;max-width:74ch} +.subs-hd code{font-size:.78rem;color:#9DB2C8;background:#0D121B;border:1px solid var(--rule); + border-radius:5px;padding:1px 7px} +.chips{display:flex;gap:7px;flex-wrap:wrap;margin:16px 0 14px} +.chip{appearance:none;background:#0F141E;border:1px solid var(--rule);border-radius:20px; + color:var(--dim);font-family:var(--sans);font-size:.79rem;padding:5px 8px 5px 13px; + cursor:pointer;display:flex;align-items:center;gap:8px;transition:color .15s,border-color .15s} +.chip:hover{color:var(--text);border-color:var(--faint)} +.chip[aria-pressed="true"]{color:var(--ink);background:var(--warp);border-color:var(--warp);font-weight:600} +.chip:focus-visible{outline:2px solid var(--warp);outline-offset:2px} +.chip-n{font-family:var(--mono);font-size:.68rem;border:1px solid currentColor;border-radius:20px; + padding:0 6px;opacity:.75} +.subs-act{display:flex;gap:9px;flex-wrap:wrap;align-items:center} +.subs-act .act{appearance:none;background:#1B2432;border:1px solid var(--rule);color:var(--text); + font-family:var(--sans);font-size:.8rem;padding:7px 15px;border-radius:7px;cursor:pointer; + white-space:nowrap;transition:border-color .15s,color .15s} +.subs-act .act:hover{border-color:var(--faint)} +.subs-act .act:focus-visible{outline:2px solid var(--warp);outline-offset:2px} +.subs-act .act.primary{background:rgba(63,199,187,.12);border-color:rgba(63,199,187,.45);color:var(--warp)} +.subs-act .act.done{color:var(--live);border-color:rgba(86,200,110,.5)} +.subs-count{font-family:var(--mono);font-size:.73rem;color:var(--faint)} +.sub-list{list-style:none;margin:16px 0 0;padding:0;display:grid;gap:1px; + grid-template-columns:repeat(2,minmax(0,1fr));background:var(--rule); + border:1px solid var(--rule);border-radius:9px;overflow:hidden} +.sub-list li{background:var(--panel2);padding:8px 13px;display:flex;align-items:baseline; + gap:10px;min-width:0} +.sub-list .dot{flex:0 0 6px;width:6px;height:6px;border-radius:50%;align-self:center} +.sub-list li.direct .dot{background:var(--direct)} +.sub-list li.warp .dot{background:var(--warp)} +.sub-list .nm{font-family:var(--mono);font-size:.79rem;white-space:nowrap;overflow:hidden; + text-overflow:ellipsis} +.sub-list .tr{margin-left:auto;font-size:.7rem;color:var(--faint);white-space:nowrap} +.subs-how{margin-top:14px;border-top:1px solid var(--rule);padding-top:12px} +.subs-how summary{cursor:pointer;font-size:.8rem;color:var(--dim);width:fit-content} +.subs-how summary:hover{color:var(--text)} +.subs-how summary:focus-visible{outline:2px solid var(--warp);outline-offset:2px;border-radius:3px} +.how-body{font-size:.79rem;color:var(--dim);line-height:1.8;max-width:78ch} +.how-body p{margin:9px 0 0} +.how-body b{color:var(--text);font-weight:600} +.how-body code{font-family:var(--mono);font-size:.75rem;color:#9DB2C8} +.how-body pre{margin:10px 0 0;background:#0D121B;border:1px solid var(--rule);border-radius:6px; + padding:10px 12px;font-size:.72rem;line-height:1.65;overflow-x:auto;color:#A8BDD4} +.mh-jump{font-family:var(--mono);font-size:.74rem;border:1px solid var(--rule);border-radius:6px; + padding:3px 11px;color:var(--warp)} +.mh-jump:hover{border-color:var(--warp);text-decoration:none} + +/* warn */ +.warn{margin-top:26px;background:rgba(227,155,61,.07);border:1px solid rgba(227,155,61,.28); + border-radius:11px;padding:15px 19px;font-size:.81rem;color:#DEBB86;line-height:1.7} +.warn b{color:var(--direct)} +.foot{text-align:center;color:var(--faint);font-size:.74rem;margin-top:22px} + +/* lightbox */ +.lb{position:fixed;inset:0;z-index:60;display:none;place-items:center;padding:24px; + background:rgba(5,7,11,.88);backdrop-filter:blur(3px)} +.lb[open]{display:grid} +.lb-card{background:var(--panel);border:1px solid var(--rule);border-radius:13px; + padding:22px;max-width:min(440px,100%);text-align:center} +.lb-card img{width:280px;max-width:100%;height:auto;background:#fff;padding:12px;border-radius:9px; + image-rendering:pixelated;display:block;margin:0 auto} +.lb-title{font-size:.95rem;font-weight:600;margin:14px 0 4px} +.lb-link{font-family:var(--mono);font-size:.68rem;color:var(--dim);word-break:break-all; + max-height:80px;overflow:auto;margin-bottom:14px;text-align:left; + background:#0D121B;border:1px solid var(--rule);border-radius:6px;padding:8px 10px} +.lb-act{display:flex;gap:9px;justify-content:center} +.lb-close{appearance:none;background:#1B2432;border:1px solid var(--rule);color:var(--text); + font-family:var(--sans);font-size:.8rem;padding:6px 16px;border-radius:6px;cursor:pointer} +.lb-close:hover{border-color:var(--faint)} +.lb-close:focus-visible,.lb .copy:focus-visible{outline:2px solid var(--warp);outline-offset:2px} + +@media(max-width:720px){ + .grid,.sub-list{grid-template-columns:1fr} + .panel,.subs{padding:20px 16px 24px} + .subs-act .act{flex:1 1 auto;text-align:center} + .block,.subs{scroll-margin-top:112px} /* the tab bar wraps to two rows here */ + .line{flex-direction:column} + .line .qr{flex:0 0 auto} + .kv dt{flex-basis:76px} +} +@media(prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}} +""" + +JS = """ +(function(){ + var tabs = Array.prototype.slice.call(document.querySelectorAll('.tab')); + var panels = Array.prototype.slice.call(document.querySelectorAll('.panel')); + + // writeHash is off during boot: overwriting the hash there would swallow a + // deep link like #subs before the browser has scrolled to it. + function select(i, focus, writeHash){ + tabs.forEach(function(t,j){ + var on = i===j; + t.setAttribute('aria-selected', on ? 'true':'false'); + t.tabIndex = on ? 0 : -1; + panels[j].hidden = !on; + }); + if(focus) tabs[i].focus(); + if(writeHash){ try{ location.hash = tabs[i].id.replace('tab-',''); }catch(e){} } + } + tabs.forEach(function(t,i){ + t.addEventListener('click', function(){ select(i,false,true); }); + t.addEventListener('keydown', function(ev){ + var k = ev.key, n = null; + if(k==='ArrowRight') n = (i+1)%tabs.length; + else if(k==='ArrowLeft') n = (i-1+tabs.length)%tabs.length; + else if(k==='Home') n = 0; + else if(k==='End') n = tabs.length-1; + if(n!==null){ ev.preventDefault(); select(n,true,true); } + }); + }); + var initial = 0, h = (location.hash||'').replace('#',''); + tabs.forEach(function(t,i){ if(t.id === 'tab-'+h) initial = i; }); + select(initial,false,false); + + // copy + document.addEventListener('click', function(ev){ + var b = ev.target.closest('.copy'); + if(!b) return; + var val = b.getAttribute('data-copy'), old = b.textContent; + function done(){ b.textContent='已复制'; b.classList.add('done'); + setTimeout(function(){ b.textContent=old; b.classList.remove('done'); },1400); } + if(navigator.clipboard && navigator.clipboard.writeText){ + navigator.clipboard.writeText(val).then(done, fallback); + } else fallback(); + function fallback(){ + var ta=document.createElement('textarea'); ta.value=val; + ta.style.position='fixed'; ta.style.opacity='0'; document.body.appendChild(ta); + ta.select(); try{ document.execCommand('copy'); done(); }catch(e){} + document.body.removeChild(ta); + } + }); + + // subscription: everything is inlined, so switching scope is a pure re-render + var SUB = JSON.parse(document.getElementById('sub-data').textContent), + chips = Array.prototype.slice.call(document.querySelectorAll('.chip')), + list = document.getElementById('sub-list'), + count = document.getElementById('sub-count'), + bB64 = document.getElementById('sub-b64'), + bPlain = document.getElementById('sub-plain'), + bDl = document.getElementById('sub-dl'), + cur = 'all'; + + function renderSub(){ + var g = SUB[cur]; + list.innerHTML = ''; + g.items.forEach(function(it){ + var li = document.createElement('li'); + li.className = it.egress; + li.innerHTML = ''; + li.querySelector('.nm').textContent = it.name; + li.querySelector('.tr').textContent = it.transport; + list.appendChild(li); + }); + count.textContent = g.items.length + ' 条线路'; + bB64.setAttribute('data-copy', g.b64); + bPlain.setAttribute('data-copy', g.plain); + } + chips.forEach(function(c){ + c.addEventListener('click', function(){ + cur = c.getAttribute('data-group'); + chips.forEach(function(o){ o.setAttribute('aria-pressed', o === c ? 'true':'false'); }); + renderSub(); + }); + }); + renderSub(); + + bDl.addEventListener('click', function(){ + var blob = new Blob([SUB[cur].b64], {type:'text/plain;charset=utf-8'}), + url = URL.createObjectURL(blob), a = document.createElement('a'); + a.href = url; a.download = 'edge-nodes-' + cur + '.txt'; + document.body.appendChild(a); a.click(); document.body.removeChild(a); + setTimeout(function(){ URL.revokeObjectURL(url); }, 1000); + var old = bDl.textContent; + bDl.textContent = '已下载'; bDl.classList.add('done'); + setTimeout(function(){ bDl.textContent = old; bDl.classList.remove('done'); }, 1400); + }); + + // QR lightbox + var lb = document.getElementById('lb'), + lbImg = document.getElementById('lb-img'), + lbTitle = document.getElementById('lb-title'), + lbLink = document.getElementById('lb-link'), + lbCopy = document.getElementById('lb-copy'), + lbClose = document.getElementById('lb-close'), + lastFocus = null; + + function openLb(btn){ + lastFocus = btn; + lbImg.src = 'data:image/png;base64,' + btn.getAttribute('data-qr'); + lbTitle.textContent = btn.getAttribute('data-title'); + var link = btn.getAttribute('data-link') || ''; + lbLink.textContent = link; + lbCopy.setAttribute('data-copy', link); + lb.setAttribute('open',''); + lbClose.focus(); + } + function closeLb(){ + lb.removeAttribute('open'); + if(lastFocus) lastFocus.focus(); + } + document.addEventListener('click', function(ev){ + var q = ev.target.closest('.qr[data-qr]'); + if(q){ openLb(q); return; } + if(ev.target === lb) closeLb(); + }); + lbClose.addEventListener('click', closeLb); + document.addEventListener('keydown', function(ev){ + if(ev.key === 'Escape' && lb.hasAttribute('open')) closeLb(); + }); +})(); +""" + +DOC = """ + + + + + +边缘节点 · 交付文档 + + + + +
      +
      +
      边缘节点/交付文档
      +
      %(nsite)d 站点 %(nline)d 条线路 更新于 %(updated)s + 订阅 ↓
      +
      +
      + +
      +
      +%(tabs)s +
      + +%(panels)s + +%(subs)s + +
      + 凭据安全 本页含全部站点的明文密码、UUID 与可直接导入的节点链接。子域前缀、面板入口路径、WS 路径与 QUIC 端口共同构成控制面的地图 —— 请勿提交到 git 或上传至任何在线服务;需要转交时直接发文件,不要粘贴内容。
      + 出口区分 直连 走服务器本机 IP,速度最快但暴露真实 IP;WARP 经 Cloudflare 净出口,隐藏真实 IP。连上后访问 https://1.1.1.1/cdn-cgi/trace 看 ip= 与 warp= 即可验证。 +
      +

      全部线路已在服务器侧实测通过(WS 握手 / QUIC 出口 / 直连与 WARP 分流)。二维码为本地离线生成,未经过任何第三方服务。

      +
      + + + + + + +""" + +output = Path(OUT) +output.parent.mkdir(parents=True, exist_ok=True) +fd = os.open(output, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) +os.fchmod(fd, 0o600) +with os.fdopen(fd, "w", encoding="utf-8") as stream: + stream.write(DOC % {"css": CSS, "js": JS, "tabs": tabs, "panels": panels, + "subs": subs, "nsite": len(SITES), "nline": TOTAL, + "updated": UPDATED}) +print("wrote %s (%.1f KB, %d sites, %d lines)" % (OUT, os.path.getsize(OUT)/1024, len(SITES), TOTAL)) diff --git a/edge-nodes/fleet.example.json b/edge-nodes/fleet.example.json new file mode 100644 index 0000000..4c3f26b --- /dev/null +++ b/edge-nodes/fleet.example.json @@ -0,0 +1,45 @@ +{ + "updated": "YYYY-MM-DD", + "sites": [ + { + "id": "example", + "code": "EX", + "country": "EX", + "city": "Example City", + "flag": "🌐", + "domain": "nodeA.example.com", + "provider": "Example Provider", + "region": "Example Region", + "tagline": "占位站点,填写后的副本放在 private/", + "spec": [["规格", "按实际填写"]], + "net": [["IPv4", "203.0.113.10"]], + "access": [["SSH 别名", "your-node"]], + "notes": ["此示例没有可用凭据;先在私有工作副本生成分享链接。"], + "bbr": "按实际记录", + "egress_direct": "203.0.113.10", + "panels": [ + { + "name": "3x-ui", + "url": "https://gateway.nodeA.example.com/change-this-path/", + "user": "panel_you", + "pass": "REPLACE_WITH_PRIVATE_PASSWORD", + "note": "占位入口" + } + ], + "sites_plain": [["门面", "https://nodeA.example.com", "占位域名"]], + "creds": [["VMess UUID", "00000000-0000-4000-8000-000000000001", "占位身份"]], + "links_dir": "example-edge-node-docs/scripts/node-links", + "lines": [ + { + "proto": "VMess", + "egress": "direct", + "transport": "TCP 443 · WebSocket", + "endpoint": "/assets", + "port": 57707, + "slug": "vmess-direct", + "has_link": true + } + ] + } + ] +} diff --git a/scripts/check-surge.py b/scripts/check-surge.py new file mode 100644 index 0000000..ab0898f --- /dev/null +++ b/scripts/check-surge.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""Read-only Surge profile validation. Credential-bearing files stay in a private temp dir.""" +import argparse +import concurrent.futures +import csv +import hashlib +import os +from pathlib import Path +import re +import shutil +import subprocess +import tempfile + + +def fields(line): + return [x.strip() for x in next(csv.reader([line], skipinitialspace=True, escapechar='\\'))] + + +def sections(text): + result = {} + section = None + for number, raw in enumerate(text.splitlines(), 1): + line = raw.strip() + if line.startswith('[') and line.endswith(']'): + section = line[1:-1] + elif section and line and not line.startswith(('#', ';', '//')): + result.setdefault(section, []).append((number, line)) + return result + + +def native_check(cli, profile): + result = subprocess.run([cli, '--check', str(profile)], capture_output=True, text=True, timeout=60) + if result.returncode: + # Native diagnostics may echo node passwords; do not print them to a shared log. + raise RuntimeError('Surge CLI 校验失败;请在本地对该文件运行 surge-cli --check 查看详情(输出可能含凭据)') + + +def domain_match(kind, value, host): + host, value = host.lower(), value.lower() + if kind == 'DOMAIN': + return host == value + if kind == 'DOMAIN-SUFFIX': + return host == value or host.endswith('.' + value) + if kind == 'DOMAIN-KEYWORD': + return value in host + return False + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('profile', type=Path) + parser.add_argument('--fetch-rulesets', action='store_true', help='下载远程规则集,替换为本地快照再校验,并检查域名遮蔽') + parser.add_argument('--cli', default='surge-cli') + args = parser.parse_args() + if not shutil.which(args.cli): + raise RuntimeError('找不到 surge-cli;请在安装 Surge 的 macOS 上校验,不能以其他客户端校验替代') + text = args.profile.read_text(encoding='utf-8-sig') + parsed = sections(text) + rules = [(number, fields(line)) for number, line in parsed.get('Rule', [])] + finals = [r for r in rules if r[1][0] == 'FINAL'] + if len(finals) != 1 or not rules or rules[-1][1][0] != 'FINAL': + raise RuntimeError('必须恰有一条 FINAL 且位于规则末尾') + for number, line in parsed.get('Proxy Group', []): + if re.search(r',\s*url\s*=', line): + raise RuntimeError(f'配置第 {number} 行含 Surge 无效的组级 url=') + for number, line in parsed.get('General', []): + if re.match(r'(?:http-api|external-controller-access)\s*=\s*["\']?(?:surge|clashconnectrules)@', line, re.I): + raise RuntimeError(f'配置第 {number} 行使用公开控制口令') + if any(re.match(r'skip-server-cert-verify\s*=\s*["\']?(?:true|1)["\']?\s*(?:$|[#;]|//)', line, re.I) for _, line in parsed.get('MITM', [])): + raise RuntimeError('MITM 关闭了上游证书验证') + native_check(args.cli, args.profile) + print('官方 Surge CLI:OK;FINAL、测速参数、控制口令与 MITM 检查通过') + if not args.fetch_rulesets: + return + + urls = {} + for _, parts in rules: + if parts[0] in ('RULE-SET', 'DOMAIN-SET') and parts[1].startswith(('https://', 'http://')): + urls[parts[1]] = parts[0] + for _, line in parsed.get('Host', []): + match = re.match(r'(RULE-SET|DOMAIN-SET):(https?://\S+)\s+=', line) + if match: + urls[match[2]] = match[1] + env = {k: v for k, v in os.environ.items() if k.lower() not in ('http_proxy', 'https_proxy', 'all_proxy')} + with tempfile.TemporaryDirectory(prefix='proxy-station-surge-check-') as directory: + root = Path(directory) + + def fetch(item): + url, kind = item + file = root / (hashlib.sha256(url.encode()).hexdigest() + '.txt') + result = subprocess.run(['curl', '--silent', '--show-error', '--location', '--max-time', '25', '--max-filesize', '10000000', '--output', str(file), '--write-out', '%{http_code}', url], capture_output=True, text=True, timeout=30, env=env) + if result.returncode or result.stdout != '200': + raise RuntimeError('规则集下载失败(URL 未输出,避免泄露私有资源 token)') + data = file.read_text(encoding='utf-8-sig') + if re.search(r'<(?:!DOCTYPE|html)', data[:200], re.I): + raise RuntimeError('规则集返回 HTML') + entries = [] + for line in data.splitlines(): + line = line.strip() + if not line or line.startswith(('#', ';', '//')): + continue + parts = fields(line) if kind == 'RULE-SET' else ['DOMAIN-SUFFIX' if line.startswith('.') else 'DOMAIN', line.lstrip('.')] + if parts[0] == 'FINAL': + raise RuntimeError('外部规则集不允许含 FINAL') + entries.append(parts) + return url, file, entries + + with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool: + snapshots = list(pool.map(fetch, urls.items())) + local = text + coverage = {} + for url, file, entries in snapshots: + local = local.replace(url, str(file)) + coverage[url] = entries + profile = root / 'profile.conf' + profile.write_text(local) + profile.chmod(0o600) + native_check(args.cli, profile) + print(f'远程规则集 {len(snapshots)}/{len(urls)} 下载成功;本地快照官方 CLI:OK') + warnings = 0 + for index, (number, current) in enumerate(rules): + if current[0] not in ('DOMAIN', 'DOMAIN-SUFFIX') or len(current) < 3: + continue + for previous_number, previous in rules[:index]: + if len(previous) < 3 or previous[2] == current[2]: + continue + entries = coverage.get(previous[1], []) if previous[0] in ('RULE-SET', 'DOMAIN-SET') else [previous] + if any(len(entry) >= 2 and domain_match(entry[0], entry[1], current[1]) for entry in entries): + print(f'需审阅:第 {number} 行域名规则的样例被第 {previous_number} 行的不同策略覆盖(仅静态域名判断)') + warnings += 1 + break + print(f'静态域名遮蔽提示:{warnings};不模拟 IP/进程/协议匹配或设备缓存') + + +if __name__ == '__main__': + try: + main() + except subprocess.TimeoutExpired: + raise SystemExit('校验或规则集下载超时;URL 与配置内容未输出') + except (RuntimeError, OSError, csv.Error) as error: + raise SystemExit(str(error)) diff --git a/server/assets/surge-template.conf b/server/assets/surge-template.conf index c8f4993..0e859f3 100644 --- a/server/assets/surge-template.conf +++ b/server/assets/surge-template.conf @@ -8,7 +8,7 @@ all-hybrid = false udp-priority = false # Latency Benchmark / 延迟基准测试 internet-test-url = http://connectivitycheck.gstatic.com/generate_204 -proxy-test-url = http://captive.apple.com/generate_204 +proxy-test-url = http://cp.cloudflare.com/generate_204 test-timeout = 3 # GeoIP Database / GeoIP 数据库 geoip-maxmind-url = https://github.com/Hackl0us/GeoIP2-CN/raw/release/Country.mmdb @@ -23,10 +23,6 @@ http-listen = 0.0.0.0 socks5-listen = 0.0.0.0 allow-hotspot-access = true # --- REMOTE CONTROLLER / 远程控制器 --- -external-controller-access = surge@0.0.0.0:6160 -http-api = clashconnectrules@0.0.0.0:6166 -http-api-tls = true -http-api-web-dashboard = true # --- COMPATIBILITY / 兼容性 --- compatibility-mode = 1 # --- NETWORK / 网络 --- @@ -49,7 +45,7 @@ force-http-engine-hosts = *.ott.cibntv.net, 123.59.31.1, 119.18.193.135, 122.14. always-real-ip = link-ip.nextdns.io, *.msftconnecttest.com, *.msftncsi.com, *.srv.nintendo.net, *.stun.playstation.net, xbox.*.microsoft.com, *.xboxlive.com, *.logon.battlenet.com.cn, *.logon.battle.net, stun.l.google.com hijack-dns = 8.8.8.8:53, 8.8.4.4:53 udp-policy-not-supported-behaviour = REJECT -proxy-restricted-to-lan = false +proxy-restricted-to-lan = true [Proxy] @@ -122,6 +118,7 @@ IP-CIDR,100.64.0.0/10,DIRECT,no-resolve IP-CIDR6,fd7a:115c:a1e0::/48,DIRECT,no-resolve # 阻断 STUN,避免未匹配请求落到直连兜底 PROTOCOL,STUN,REJECT +AND,((PROTOCOL,UDP), (DOMAIN-SUFFIX,googlevideo.com)),REJECT-NO-DROP # >> 规则修正 (Unbreak Rules) RULE-SET,https://raw.githubusercontent.com/zxfccmm4/Profiles/refs/heads/main/Surge/Ruleset/Unbreak.list,DIRECT @@ -130,7 +127,7 @@ RULE-SET,https://raw.githubusercontent.com/zxfccmm4/Profiles/refs/heads/main/Sur DOMAIN-SET,https://ruleset.skk.moe/List/domainset/reject.conf,REJECT RULE-SET,https://ruleset.skk.moe/List/non_ip/reject-drop.conf,REJECT-DROP RULE-SET,https://ruleset.skk.moe/List/non_ip/reject.conf,REJECT -RULE-SET,https://ruleset.skk.moe/List/ip/reject.conf,REJECT-DROP +RULE-SET,https://ruleset.skk.moe/List/ip/reject.conf,REJECT-DROP,no-resolve # >> 隐私保护 (Privacy Protection) DOMAIN,app-site-association.cdn-apple.com,REJECT @@ -231,7 +228,6 @@ RULE-SET,https://ruleset.skk.moe/List/non_ip/global_plus.conf,Automatic RULE-SET,https://ruleset.skk.moe/List/non_ip/global.conf,Automatic # >> 网络优化与特殊规则 (Network Optimization) -AND,((PROTOCOL,UDP), (DOMAIN-SUFFIX,googlevideo.com)),REJECT-NO-DROP DOMAIN-SUFFIX,smtp,DIRECT URL-REGEX,(Subject|HELO|SMTP),DIRECT @@ -307,7 +303,7 @@ http-request ^https:\/\/github\.com\/ header-replace Accept-Language en-us http-request ^https:\/\/(raw|gist)\.githubusercontent\.com\/ header-replace Accept-Language en-us [MITM] -skip-server-cert-verify = true +skip-server-cert-verify = false tcp-connection = true h2 = true hostname = www.google.cn, api.abema.io, *.zhihu.com, -CUSTOMMitM, sub.store diff --git a/server/src/db/seed.ts b/server/src/db/seed.ts index 7c92af7..01e4f05 100644 --- a/server/src/db/seed.ts +++ b/server/src/db/seed.ts @@ -51,56 +51,62 @@ function seedGroups(db: Db, groups: ParsedGroup[], source: 'template' | 'custom' export function seedRulesFromTemplate(db: Db, opts: { keepCustom: boolean }) { const text = fs.readFileSync(TEMPLATE_PATH, 'utf8'); const parsed = parseSurgeConf(text); + db.transaction(() => { + const custom = opts.keepCustom + ? db.select().from(rules).where(eq(rules.source, 'custom')).all() + : []; + db.delete(rules).where(eq(rules.source, 'template')).run(); + if (!opts.keepCustom) db.delete(rules).where(eq(rules.source, 'custom')).run(); - const custom = opts.keepCustom - ? db.select().from(rules).where(eq(rules.source, 'custom')).all() - : []; - db.delete(rules).where(eq(rules.source, 'template')).run(); - if (!opts.keepCustom) db.delete(rules).where(eq(rules.source, 'custom')).run(); - - let seq = 0; - // 规则首条匹配即生效:自定义规则保持原相对顺序,整体排在全部模板规则之前 - for (const c of custom.sort((a, b) => a.seq - b.seq)) { - db.insert(rules) - .values({ ...c, seq: seq++ }) - .run(); - } - for (const r of parsed.rules) { - let value = r.value; - if ((r.type === 'RULE-SET' || r.type === 'DOMAIN-SET') && value && /^https?:\/\//.test(value)) { - const existing = db.select().from(rulesets).where(eq(rulesets.surgeUrl, value)).get(); - if (existing) { - value = existing.id; - } else { - const mapping = mapRulesetUrl(value, r.type); - const rulesetId = nanoid(10); - db.insert(rulesets) - .values({ - id: rulesetId, - name: rulesetNameFromUrl(value), - surgeUrl: value, - clashUrl: mapping.clashUrl, - clashBehavior: mapping.clashBehavior, - clashFormat: mapping.clashFormat, - needsConvert: mapping.needsConvert, - }) - .run(); - value = rulesetId; - } + let seq = 0; + // 规则首条匹配即生效:自定义规则保持原相对顺序,整体排在全部模板规则之前 + for (const c of custom.filter((r) => r.type !== 'FINAL').sort((a, b) => a.seq - b.seq)) { + db.update(rules).set({ seq: seq++ }).where(eq(rules.id, c.id)).run(); } - db.insert(rules) - .values({ - id: nanoid(10), - seq: seq++, - type: r.type, - value, - policy: r.policy, - params: JSON.stringify(r.params), - enabled: true, - source: 'template', - }) - .run(); - } + const customFinal = custom.find((r) => r.type === 'FINAL'); + for (const r of parsed.rules.filter((r) => !customFinal || r.type !== 'FINAL')) { + let value = r.value; + if ((r.type === 'RULE-SET' || r.type === 'DOMAIN-SET') && value && /^https?:\/\//.test(value)) { + const existing = db.select().from(rulesets).where(eq(rulesets.surgeUrl, value)).get(); + if (existing) { + value = existing.id; + } else { + const mapping = mapRulesetUrl(value, r.type); + const rulesetId = nanoid(10); + db.insert(rulesets) + .values({ + id: rulesetId, + name: rulesetNameFromUrl(value), + surgeUrl: value, + clashUrl: mapping.clashUrl, + clashBehavior: mapping.clashBehavior, + clashFormat: mapping.clashFormat, + needsConvert: mapping.needsConvert, + }) + .run(); + value = rulesetId; + } + } + db.insert(rules) + .values({ + id: nanoid(10), + seq: seq++, + type: r.type, + value, + policy: r.policy, + params: JSON.stringify(r.params), + enabled: true, + source: 'template', + }) + .run(); + } + if (customFinal) { + for (const extra of custom.filter((r) => r.type === 'FINAL' && r.id !== customFinal.id)) { + db.delete(rules).where(eq(rules.id, extra.id)).run(); + } + db.update(rules).set({ seq, enabled: true }).where(eq(rules.id, customFinal.id)).run(); + } + }); } export function seedIfEmpty(db: Db) { diff --git a/server/src/routes/groups.ts b/server/src/routes/groups.ts index 8bdd68f..8707464 100644 --- a/server/src/routes/groups.ts +++ b/server/src/routes/groups.ts @@ -5,7 +5,12 @@ import { nanoid } from 'nanoid'; import { z } from 'zod'; import { groupInputSchema, type GroupDto } from '@proxy-station/shared'; import { db } from '../db/index.js'; -import { groupMembers, policyGroups, rules } from '../db/schema.js'; +import { groupMembers, policyGroups, rules, nodes } from '../db/schema.js'; +import { groupError } from '../services/group-validation.js'; + +function validate(input: z.output, id?: string): string | null { + return groupError(input, loadGroups(), db.select({ id: nodes.id, name: nodes.name }).from(nodes).all(), id); +} function loadGroups(): GroupDto[] { const groups = db.select().from(policyGroups).orderBy(asc(policyGroups.sortOrder)).all(); @@ -37,27 +42,31 @@ export const groupsRoute = new Hono() .get('/', (c) => c.json(loadGroups())) .post('/', zValidator('json', groupInputSchema), (c) => { const input = c.req.valid('json'); + const error = validate(input); + if (error) return c.json({ error }, error === '策略组名称已存在' ? 409 : 400); const id = nanoid(10); try { - db.insert(policyGroups) - .values({ - id, - name: input.name, - type: input.type, - testUrl: input.testUrl ?? null, - interval: input.interval ?? null, - tolerance: input.tolerance ?? null, - filterRegex: input.filterRegex || null, - includeAllNodes: input.includeAllNodes, - sortOrder: input.sortOrder, - source: 'custom', - }) - .run(); + db.transaction(() => { + db.insert(policyGroups) + .values({ + id, + name: input.name, + type: input.type, + testUrl: input.testUrl ?? null, + interval: input.interval ?? null, + tolerance: input.tolerance ?? null, + filterRegex: input.filterRegex || null, + includeAllNodes: input.includeAllNodes, + sortOrder: input.sortOrder, + source: 'custom', + }) + .run(); + replaceMembers(id, input.members); + }); } catch (e: any) { if (String(e.message).includes('UNIQUE')) return c.json({ error: `策略组「${input.name}」已存在` }, 409); throw e; } - replaceMembers(id, input.members); return c.json({ id }, 201); }) .put('/reorder', zValidator('json', z.object({ ids: z.array(z.string()) })), (c) => { @@ -79,25 +88,29 @@ export const groupsRoute = new Hono() const existing = db.select().from(policyGroups).where(eq(policyGroups.id, id)).get(); if (!existing) return c.json({ error: '策略组不存在' }, 404); const input = c.req.valid('json'); + const error = validate(input, id); + if (error) return c.json({ error }, error === '策略组名称已存在' ? 409 : 400); const oldName = existing.name; - db.update(policyGroups) - .set({ - name: input.name, - type: input.type, - testUrl: input.testUrl ?? null, - interval: input.interval ?? null, - tolerance: input.tolerance ?? null, - filterRegex: input.filterRegex || null, - includeAllNodes: input.includeAllNodes, - }) - .where(eq(policyGroups.id, id)) - .run(); - replaceMembers(id, input.members); - // 组名变更时同步引用它的成员与规则策略 - if (oldName !== input.name) { - db.update(groupMembers).set({ refName: input.name }).where(eq(groupMembers.refName, oldName)).run(); - db.update(rules).set({ policy: input.name }).where(eq(rules.policy, oldName)).run(); - } + db.transaction(() => { + db.update(policyGroups) + .set({ + name: input.name, + type: input.type, + testUrl: input.testUrl ?? null, + interval: input.interval ?? null, + tolerance: input.tolerance ?? null, + filterRegex: input.filterRegex || null, + includeAllNodes: input.includeAllNodes, + }) + .where(eq(policyGroups.id, id)) + .run(); + replaceMembers(id, input.members.map((m) => m.kind === 'group' && m.refName === oldName ? { ...m, refName: input.name } : m)); + // 组名变更时同步引用它的成员与规则策略 + if (oldName !== input.name) { + db.update(groupMembers).set({ refName: input.name }).where(eq(groupMembers.refName, oldName)).run(); + db.update(rules).set({ policy: input.name }).where(eq(rules.policy, oldName)).run(); + } + }); return c.json({ ok: true }); }) .delete('/:id', (c) => { diff --git a/server/src/routes/management-safety.test.ts b/server/src/routes/management-safety.test.ts new file mode 100644 index 0000000..b3266f5 --- /dev/null +++ b/server/src/routes/management-safety.test.ts @@ -0,0 +1,130 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { eq, sql } from 'drizzle-orm'; + +// db/index has an eager singleton: set an absolute temp path before imports. +const directory = mkdtempSync(path.join(tmpdir(), 'proxy-station-management-')); +process.env.DATA_DIR = directory; +const { db } = await import('../db/index.js'); +const { rules, policyGroups, groupMembers, nodes, settings, rulesets } = await import('../db/schema.js'); +const { rulesRoute } = await import('./rules.js'); +const { groupsRoute } = await import('./groups.js'); +const { nodesRoute } = await import('./nodes.js'); +const { settingsRoute } = await import('./settings.js'); +const { seedRulesFromTemplate } = await import('../db/seed.js'); +const { buildProfileIR } = await import('../services/ir.js'); + +const request = (app: typeof rulesRoute, url: string, method: string, body?: unknown) => app.request(url, { + method, headers: { 'content-type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body), +}); +const group = (name: string, members = [{ kind: 'builtin', refName: 'DIRECT' }]) => ({ name, type: 'select', members }); + +beforeAll(() => { + rulesRoute.onError((e, c) => c.json({ error: e.message }, 500)); + groupsRoute.onError((e, c) => c.json({ error: e.message }, 500)); +}); +beforeEach(() => { + for (const table of [groupMembers, rules, nodes, policyGroups, settings, rulesets]) db.delete(table).run(); +}); +afterAll(() => rmSync(directory, { recursive: true, force: true })); + +describe('management invariants with isolated SQLite and real routes', () => { + it('preserves custom rules on reset and keeps exactly one trailing FINAL', async () => { + seedRulesFromTemplate(db, { keepCustom: false }); + const before = db.select().from(rules).all(); + const res = await request(rulesRoute, '/', 'POST', { type: 'DOMAIN', value: 'example.invalid', policy: 'DIRECT' }); + const { id } = await res.json(); + expect(res.status).toBe(201); + expect((await request(rulesRoute, '/reset', 'POST')).status).toBe(200); + const after = (await (await request(rulesRoute, '/', 'GET')).json()) as { id: string; type: string }[]; + expect(after).toHaveLength(before.length + 1); + expect(after[0].id).toBe(id); + expect(after.at(-1)?.type).toBe('FINAL'); + expect(after.filter((r) => r.type === 'FINAL')).toHaveLength(1); + }); + + it('rolls back the entire reset when template insertion fails', async () => { + seedRulesFromTemplate(db, { keepCustom: false }); + await request(rulesRoute, '/', 'POST', { type: 'DOMAIN', value: 'example.invalid', policy: 'DIRECT' }); + const before = db.select().from(rules).all(); + db.run(sql.raw("CREATE TRIGGER reject_template BEFORE INSERT ON rules WHEN NEW.source = 'template' BEGIN SELECT RAISE(ABORT, 'fixture failure'); END")); + try { + expect((await request(rulesRoute, '/reset', 'POST')).status).toBe(500); + expect(db.select().from(rules).all()).toEqual(before); + } finally { db.run(sql.raw('DROP TRIGGER reject_template')); } + }); + + it('keeps a custom FINAL instead of adding a duplicate template FINAL', async () => { + const response = await request(rulesRoute, '/', 'POST', { type: 'FINAL', policy: 'DIRECT', params: ['dns-failed'] }); + const final = await response.json(); + expect((await request(rulesRoute, '/reset', 'POST')).status).toBe(200); + const rows = await (await request(rulesRoute, '/', 'GET')).json(); + expect(rows.filter((r: { type: string }) => r.type === 'FINAL')).toHaveLength(1); + expect(rows.at(-1).id).toBe(final.id); + }); + + it('protects FINAL and rejects partial, duplicate or unsafe reorder requests', async () => { + const final = await (await request(rulesRoute, '/', 'POST', { type: 'FINAL', policy: 'DIRECT' })).json(); + const first = await (await request(rulesRoute, '/', 'POST', { type: 'DOMAIN', value: 'example.invalid', policy: 'DIRECT' })).json(); + expect((await request(rulesRoute, '/', 'POST', { type: 'FINAL', policy: 'DIRECT' })).status).toBe(400); + expect((await request(rulesRoute, `/${final.id}/enabled`, 'PATCH', { enabled: false })).status).toBe(400); + expect((await request(rulesRoute, `/${final.id}`, 'DELETE')).status).toBe(400); + expect((await request(rulesRoute, `/${final.id}`, 'PUT', { type: 'DOMAIN', value: 'bad.invalid', policy: 'DIRECT' })).status).toBe(400); + for (const ids of [[first.id], [first.id, first.id], [final.id, first.id], [first.id, 'missing']]) { + expect((await request(rulesRoute, '/reorder', 'PUT', { ids })).status).toBe(400); + } + expect((await request(rulesRoute, '/reorder', 'PUT', { ids: [first.id, final.id] })).status).toBe(200); + expect(buildProfileIR().rules.at(-1)?.type).toBe('FINAL'); + db.update(rules).set({ enabled: false }).where(eq(rules.id, final.id)).run(); + expect(buildProfileIR().rules.at(-1)?.policy).toBe('DIRECT'); + db.delete(rules).where(eq(rules.id, final.id)).run(); + expect(buildProfileIR().rules.at(-1)?.type).toBe('FINAL'); + }); + + it('rejects cycles, dangling or wrongly typed members and reserved names', async () => { + const a = await (await request(groupsRoute, '/', 'POST', group('A'))).json(); + expect((await request(groupsRoute, '/', 'POST', group('B', [{ kind: 'group', refName: 'A' }]))).status).toBe(201); + const before = db.select().from(groupMembers).all(); + expect((await request(groupsRoute, `/${a.id}`, 'PUT', group('A', [{ kind: 'group', refName: 'B' }]))).status).toBe(400); + expect(db.select().from(groupMembers).all()).toEqual(before); + for (const input of [group('C', [{ kind: 'group', refName: 'C' }]), group('C', [{ kind: 'group', refName: 'missing' }]), group('C', [{ kind: 'builtin', refName: 'A' }]), group('DIRECT')]) { + expect((await request(groupsRoute, '/', 'POST', input)).status).toBe(400); + } + expect((await request(groupsRoute, `/${a.id}`, 'PUT', group('Renamed'))).status).toBe(200); + expect(db.select().from(groupMembers).all().some((m) => m.refName === 'Renamed')).toBe(true); + }); + + it('rolls back group creation when member replacement fails', async () => { + db.run(sql.raw("CREATE TRIGGER reject_member BEFORE INSERT ON group_members WHEN NEW.ref_name = 'REJECT' BEGIN SELECT RAISE(ABORT, 'fixture failure'); END")); + try { + expect((await request(groupsRoute, '/', 'POST', group('Atomic', [{ kind: 'builtin', refName: 'REJECT' }]))).status).toBe(500); + expect(db.select().from(policyGroups).all()).toHaveLength(0); + } finally { db.run(sql.raw('DROP TRIGGER reject_member')); } + }); + + it('rejects unsafe node names, newline injection and inactive rule policies', async () => { + const base = { protocol: 'vmess', server: 'example.com', port: 443, uuid: '00000000-0000-0000-0000-000000000000' }; + for (const name of ['Bad = Name', 'Bad,Name', 'Bad\n[Rule]', 'DIRECT']) { + expect((await request(nodesRoute, '/', 'POST', { ...base, name })).status).toBe(400); + } + expect((await request(nodesRoute, '/', 'POST', { ...base, name: 'Valid', password: 'x\nFINAL,DIRECT' })).status).toBe(400); + expect((await request(rulesRoute, '/', 'POST', { type: 'DOMAIN', value: 'a.invalid\nFINAL,DIRECT', policy: 'DIRECT' })).status).toBe(400); + for (const value of ['((DOMAIN,example.invalid,DIRECT))', '((DOMAIN,example.invalid)),REJECT,((DOMAIN,other.invalid))', '((PROTOCOL,udp))']) { + expect((await request(rulesRoute, '/', 'POST', { type: 'AND', value, policy: 'DIRECT' })).status).toBe(400); + } + expect((await request(rulesRoute, '/', 'POST', { type: 'URL-REGEX', value: '^https://example.com/(a|b),?c', policy: 'DIRECT' })).status).toBe(201); + const a = await (await request(groupsRoute, '/', 'POST', group('A'))).json(); + await request(groupsRoute, `/${a.id}/enabled`, 'PATCH', { enabled: false }); + expect((await request(rulesRoute, '/', 'POST', { type: 'DOMAIN', value: 'example.invalid', policy: 'A' })).status).toBe(400); + }); + + it('does not store public control credentials or disable MITM upstream verification', async () => { + for (const surgeSections of [{ General: 'http-api = clashconnectrules@0.0.0.0:6166' }, { General: 'http-api = "clashconnectrules@0.0.0.0:6166"' }, { MITM: 'skip-server-cert-verify = true' }, { MITM: 'skip-server-cert-verify = "true" // comment' }]) { + expect((await request(settingsRoute, '/', 'PUT', { surgeSections })).status).toBe(400); + } + expect(db.select().from(settings).all()).toHaveLength(0); + expect((await request(settingsRoute, '/', 'PUT', { surgeSections: { MITM: 'skip-server-cert-verify = false' } })).status).toBe(200); + }); +}); diff --git a/server/src/routes/nodes.ts b/server/src/routes/nodes.ts index eb52674..bf44144 100644 --- a/server/src/routes/nodes.ts +++ b/server/src/routes/nodes.ts @@ -3,9 +3,13 @@ import { zValidator } from '@hono/zod-validator'; import { asc, eq } from 'drizzle-orm'; import { nanoid } from 'nanoid'; import { z } from 'zod'; -import { nodeInputSchema, type NodeDto } from '@proxy-station/shared'; +import { BUILTIN_POLICIES, nodeInputSchema, type NodeDto } from '@proxy-station/shared'; import { db } from '../db/index.js'; -import { nodes } from '../db/schema.js'; +import { nodes, policyGroups } from '../db/schema.js'; + +function reservedName(name: string): boolean { + return (BUILTIN_POLICIES as readonly string[]).includes(name) || !!db.select().from(policyGroups).where(eq(policyGroups.name, name)).get(); +} import { encodeShareLink, parseShareLinkBatch } from '../services/sharelink/parse.js'; type NodeRow = typeof nodes.$inferSelect; @@ -58,6 +62,7 @@ export const nodesRoute = new Hono() }) .post('/', zValidator('json', nodeInputSchema), (c) => { const input = c.req.valid('json'); + if (reservedName(input.name)) return c.json({ error: '节点名称与策略组或内置策略冲突' }, 400); const now = Date.now(); const row = { id: nanoid(10), ...dtoToRowPatch(input), extra: '{}', createdAt: now, updatedAt: now }; try { @@ -76,18 +81,20 @@ export const nodesRoute = new Hono() const parsed = parseShareLinkBatch(text); const taken = new Set(db.select({ name: nodes.name }).from(nodes).all().map((r) => r.name)); const now = Date.now(); - const results = parsed.map((item) => { + const results = db.transaction(() => parsed.map((item) => { if (!item.node) return { ok: false as const, uri: item.uri, error: item.error }; const name = uniqueName(item.node.name, taken); taken.add(name); const node = { ...item.node, name }; + const validation = nodeInputSchema.safeParse(node); + if (!validation.success || reservedName(name)) return { ok: false as const, uri: item.uri, error: '节点字段或名称无效' }; if (commit) { - const validated = nodeInputSchema.parse(node); + const validated = validation.data; const row = { id: nanoid(10), ...dtoToRowPatch(validated), extra: '{}', createdAt: now, updatedAt: now }; db.insert(nodes).values(row).run(); } return { ok: true as const, uri: item.uri, node }; - }); + })); return c.json({ committed: commit, total: results.length, @@ -107,6 +114,7 @@ export const nodesRoute = new Hono() const existing = db.select().from(nodes).where(eq(nodes.id, id)).get(); if (!existing) return c.json({ error: '节点不存在' }, 404); const input = c.req.valid('json'); + if (reservedName(input.name)) return c.json({ error: '节点名称与策略组或内置策略冲突' }, 400); try { db.update(nodes) .set({ ...dtoToRowPatch(input), updatedAt: Date.now() }) diff --git a/server/src/routes/rules.ts b/server/src/routes/rules.ts index 8aa52c0..58687f4 100644 --- a/server/src/routes/rules.ts +++ b/server/src/routes/rules.ts @@ -25,36 +25,52 @@ function loadRules(): RuleDto[] { /** 引用不存在的策略组会被 IR 静默剔除,必须在写入前拦下 */ function policyError(policy: string): string | null { if ((BUILTIN_POLICIES as readonly string[]).includes(policy)) return null; - const exists = db.select({ id: policyGroups.id }).from(policyGroups).where(eq(policyGroups.name, policy)).get(); - return exists ? null : `策略「${policy}」不存在:请选择内置策略或已有策略组`; + const group = db.select().from(policyGroups).where(eq(policyGroups.name, policy)).get(); + return group?.enabled ? null : `策略「${policy}」不存在或已停用:请选择内置策略或启用的策略组`; +} + +function inputError(input: z.output, id?: string): string | null { + const err = policyError(input.policy); + if (err) return err; + if (input.type === 'FINAL' && db.select().from(rules).all().some((r) => r.type === 'FINAL' && r.id !== id)) return '只能有一条 FINAL 规则'; + if (['RULE-SET', 'DOMAIN-SET'].includes(input.type) && !db.select().from(rulesets).where(eq(rulesets.id, input.value!)).get()) return '规则集不存在'; + return null; } export const rulesRoute = new Hono() .get('/', (c) => c.json(loadRules())) .post('/', zValidator('json', ruleInputSchema), (c) => { const input = c.req.valid('json'); - const err = policyError(input.policy); + const err = inputError(input); if (err) return c.json({ error: err }, 400); // 规则首条匹配即生效,自定义规则必须压过模板里的宽泛规则集(如 download.conf),插到最前 - db.update(rules).set({ seq: sql`${rules.seq} + 1` }).run(); const id = nanoid(10); - db.insert(rules) - .values({ - id, - seq: 0, - type: input.type, - value: input.value ?? null, - policy: input.policy, - params: JSON.stringify(input.params), - enabled: input.enabled, - source: 'custom', - }) - .run(); + db.transaction(() => { + if (input.type !== 'FINAL') db.update(rules).set({ seq: sql`${rules.seq} + 1` }).run(); + const seq = input.type === 'FINAL' ? Math.max(-1, ...db.select().from(rules).all().map((r) => r.seq)) + 1 : 0; + db.insert(rules) + .values({ + id, + seq, + type: input.type, + value: input.value ?? null, + policy: input.policy, + params: JSON.stringify(input.params), + enabled: input.enabled, + source: 'custom', + }) + .run(); + }); return c.json({ id }, 201); }) .put('/reorder', zValidator('json', z.object({ ids: z.array(z.string()) })), (c) => { const { ids } = c.req.valid('json'); - ids.forEach((id, i) => db.update(rules).set({ seq: i }).where(eq(rules.id, id)).run()); + const rows = db.select().from(rules).all(); + const known = new Set(rows.map((r) => r.id)); + if (ids.length !== rows.length || new Set(ids).size !== ids.length || ids.some((id) => !known.has(id))) return c.json({ error: '排序必须包含全部规则且每条仅出现一次' }, 400); + const finals = rows.filter((r) => r.type === 'FINAL'); + if (finals.length !== 1 || !finals[0].enabled || ids.at(-1) !== finals[0].id) return c.json({ error: '启用的唯一 FINAL 必须位于末尾' }, 400); + db.transaction(() => ids.forEach((id, i) => db.update(rules).set({ seq: i }).where(eq(rules.id, id)).run())); return c.json({ ok: true }); }) .post('/reset', (c) => { @@ -66,7 +82,9 @@ export const rulesRoute = new Hono() const existing = db.select().from(rules).where(eq(rules.id, id)).get(); if (!existing) return c.json({ error: '规则不存在' }, 404); const input = c.req.valid('json'); - const err = policyError(input.policy); + if (existing.type === 'FINAL' && input.type !== 'FINAL') return c.json({ error: '不能将 FINAL 改成其他规则类型' }, 400); + if (existing.type !== 'FINAL' && input.type === 'FINAL') return c.json({ error: '请编辑已有 FINAL,不能改变普通规则的类型为 FINAL' }, 400); + const err = inputError(input, id); if (err) return c.json({ error: err }, 400); db.update(rules) .set({ @@ -81,6 +99,9 @@ export const rulesRoute = new Hono() return c.json({ ok: true }); }) .patch('/:id/enabled', zValidator('json', z.object({ enabled: z.boolean() })), (c) => { + const row = db.select().from(rules).where(eq(rules.id, c.req.param('id'))).get(); + if (!row) return c.json({ error: '规则不存在' }, 404); + if (row.type === 'FINAL' && !c.req.valid('json').enabled) return c.json({ error: '不能停用 FINAL' }, 400); const res = db .update(rules) .set({ enabled: c.req.valid('json').enabled }) @@ -90,6 +111,9 @@ export const rulesRoute = new Hono() return c.json({ ok: true }); }) .delete('/:id', (c) => { + const row = db.select().from(rules).where(eq(rules.id, c.req.param('id'))).get(); + if (!row) return c.json({ error: '规则不存在' }, 404); + if (row.type === 'FINAL') return c.json({ error: '不能删除 FINAL' }, 400); const res = db.delete(rules).where(eq(rules.id, c.req.param('id'))).run(); if (res.changes === 0) return c.json({ error: '规则不存在' }, 404); return c.json({ ok: true }); diff --git a/server/src/routes/settings.ts b/server/src/routes/settings.ts index 6c38822..7ee90d8 100644 --- a/server/src/routes/settings.ts +++ b/server/src/routes/settings.ts @@ -2,9 +2,19 @@ import { Hono } from 'hono'; import { zValidator } from '@hono/zod-validator'; import { z } from 'zod'; import { getSetting, setSetting } from '../services/ir.js'; +import { db } from '../db/index.js'; const SURGE_SECTION_KEYS = ['General', 'Host', 'URL Rewrite', 'Header Rewrite', 'MITM', 'Script']; +function unsafeSettings(sections: Record): string | null { + for (const line of (sections.General ?? '').split(/\r?\n/)) { + const match = line.match(/^\s*(http-api|external-controller-access)\s*=\s*([^@]+)@/i); + if (match && ['surge', 'clashconnectrules'].includes(match[2].trim().replace(/^["']|["']$/g, '').toLowerCase())) return '控制接口不能使用公开模板口令;请关闭接口或使用独立凭据与访问限制'; + } + if (/^\s*skip-server-cert-verify\s*=\s*["']?(?:true|1)["']?\s*(?:$|[#;]|\/\/)/mi.test(sections.MITM ?? '')) return 'MITM 必须验证上游服务器证书'; + return null; +} + export const settingsRoute = new Hono() .get('/', (c) => { const surgeSections: Record = {}; @@ -14,9 +24,13 @@ export const settingsRoute = new Hono() .put('/', zValidator('json', z.object({ surgeSections: z.record(z.string()).optional() })), (c) => { const input = c.req.valid('json'); if (input.surgeSections) { - for (const [key, value] of Object.entries(input.surgeSections)) { - if (SURGE_SECTION_KEYS.includes(key)) setSetting(`surge:${key}`, value); - } + const error = unsafeSettings(input.surgeSections); + if (error) return c.json({ error }, 400); + db.transaction(() => { + for (const [key, value] of Object.entries(input.surgeSections!)) { + if (SURGE_SECTION_KEYS.includes(key)) setSetting(`surge:${key}`, value); + } + }); } return c.json({ ok: true }); }); diff --git a/server/src/services/generators/clash.ts b/server/src/services/generators/clash.ts index 4fba4f3..d0f3843 100644 --- a/server/src/services/generators/clash.ts +++ b/server/src/services/generators/clash.ts @@ -1,15 +1,21 @@ import YAML from 'yaml'; -import { clientSupports, normalizeSsCipher, type ExcludedNode, type NodeDto } from '@proxy-station/shared'; -import type { ProfileIR, RuleIR } from '../ir.js'; +import { clientSupports, normalizeSsCipher, splitRuleExpression, type ExcludedNode, type NodeDto } from '@proxy-station/shared'; +import type { ProfileIR } from '../ir.js'; export function clashProxy(n: NodeDto): Record { - const base: Record = { name: n.name, server: n.server, port: n.port }; + const base: Record = { + name: n.name, server: n.server, port: n.port, + // SIP003 的 WS 插件只转发 TCP,不能把 UDP 发往 nginx 的同端口。 + udp: !(n.protocol === 'shadowsocks' && n.network === 'ws'), + }; switch (n.protocol) { case 'vmess': { - Object.assign(base, { type: 'vmess', uuid: n.uuid, alterId: n.alterId, cipher: n.security || 'auto' }); + const cipher = n.security === 'chacha20-ietf-poly1305' ? 'chacha20-poly1305' : n.security || 'auto'; + Object.assign(base, { type: 'vmess', uuid: n.uuid, alterId: n.alterId, cipher }); if (n.tls) { base.tls = true; if (n.sni) base.servername = n.sni; + if (n.skipCertVerify) base['skip-cert-verify'] = true; } if (n.network === 'ws') { base.network = 'ws'; @@ -42,6 +48,7 @@ export function clashProxy(n: NodeDto): Record { tls: n.tls, host: n.wsHost || n.sni || n.server, path: n.wsPath, + ...(n.skipCertVerify ? { 'skip-cert-verify': true } : {}), }; } break; @@ -68,9 +75,39 @@ function mapPolicy(policy: string): string { return policy; } -/** Surge 逻辑规则里的子类型 → mihomo 命名 */ -function mapLogicValue(value: string): string { - return value.replace(/\bPROTOCOL\b/g, 'NETWORK'); +/** 逐个转换逻辑子规则;任何不可表达的子条件使整条规则跳过,避免扩大匹配范围。 */ +function mapLogicValue(value: string): string | null { + value = value.trim(); + if (!value.startsWith('(') || !value.endsWith(')')) return null; + const children = splitRuleExpression(value.slice(1, -1)); + if (!children?.length) return null; + const mapped: string[] = []; + for (const child of children) { + if (!child.startsWith('(') || !child.endsWith(')')) return null; + const parts = splitRuleExpression(child.slice(1, -1)); + if (!parts || parts.length < 2) return null; + let [type, payload] = parts; + if (['AND', 'OR', 'NOT'].includes(type)) { + const nested = mapLogicValue(payload); + if (!nested) return null; + payload = nested; + } else if (type === 'PROTOCOL') { + if (payload !== 'TCP' && payload !== 'UDP') return null; + type = 'NETWORK'; + } else { + const types: Record = { + DOMAIN: 'DOMAIN', 'DOMAIN-SUFFIX': 'DOMAIN-SUFFIX', 'DOMAIN-KEYWORD': 'DOMAIN-KEYWORD', + 'DOMAIN-WILDCARD': 'DOMAIN-WILDCARD', 'IP-CIDR': 'IP-CIDR', 'IP-CIDR6': 'IP-CIDR6', + 'IP-ASN': 'IP-ASN', GEOIP: 'GEOIP', 'PROCESS-NAME': 'PROCESS-NAME', + 'SRC-IP': 'SRC-IP-CIDR', 'DEST-PORT': 'DST-PORT', 'SRC-PORT': 'SRC-PORT', + }; + if (!types[type] || /["'\\]/.test(payload)) return null; + type = types[type]; + } + if (parts.slice(2).some((p) => p !== 'no-resolve')) return null; + mapped.push(`(${[type, payload, ...parts.slice(2)].join(',')})`); + } + return `(${mapped.join(',')})`; } const CLASH_UNSUPPORTED_RULE_TYPES = new Set(['URL-REGEX', 'USER-AGENT']); @@ -100,17 +137,17 @@ export function generateClash(ir: ProfileIR, opts: { convertBaseUrl: string }): if (g.filterRegex) { group['include-all-proxies'] = true; group.filter = g.filterRegex; + if (members.length) group.proxies = [...new Set(members)]; } else if (g.includeAllNodes) { group['include-all-proxies'] = true; - const extra = members.filter((m) => !usableNames.has(m)); - if (extra.length) group.proxies = extra; + if (members.length) group.proxies = [...new Set(members)]; } else { group.proxies = members.length ? members : ['DIRECT']; } if (g.type !== 'select') { group.url = g.testUrl || 'http://cp.cloudflare.com/generate_204'; group.interval = g.interval ?? 300; - if (g.tolerance) group.tolerance = g.tolerance; + if (g.type === 'url-test' && g.tolerance !== null) group.tolerance = g.tolerance; } return group; }); @@ -158,7 +195,7 @@ export function generateClash(ir: ProfileIR, opts: { convertBaseUrl: string }): } const policy = mapPolicy(r.policy); if (r.ruleset) { - ruleLines.push(`RULE-SET,${providerNameByRulesetId.get(r.ruleset.id)},${policy}`); + ruleLines.push(['RULE-SET', providerNameByRulesetId.get(r.ruleset.id), policy, ...r.params.filter((p) => p === 'no-resolve')].join(',')); continue; } // Surge 内置规则集(非 URL):LAN → 私网 GEOIP;SYSTEM 无对应物,跳过 @@ -171,7 +208,9 @@ export function generateClash(ir: ProfileIR, opts: { convertBaseUrl: string }): continue; } if (r.type === 'AND' || r.type === 'OR' || r.type === 'NOT') { - ruleLines.push(`${r.type},${mapLogicValue(r.value || '')},${policy}`); + const value = mapLogicValue(r.value || ''); + if (value) ruleLines.push([r.type, value, policy, ...r.params.filter((p) => p === 'no-resolve')].join(',')); + else skippedRules.push(`${r.type},${r.value},${r.policy}`); continue; } const params = r.params.filter((p) => p === 'no-resolve'); diff --git a/server/src/services/generators/mobile-clients.test.ts b/server/src/services/generators/mobile-clients.test.ts new file mode 100644 index 0000000..05da042 --- /dev/null +++ b/server/src/services/generators/mobile-clients.test.ts @@ -0,0 +1,148 @@ +import { afterAll, describe, expect, it } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import YAML from 'yaml'; +import type { NodeDto } from '@proxy-station/shared'; +import type { GroupIR, ProfileIR, RuleIR } from '../ir.js'; +import { generateClash, clashProxy } from './clash.js'; +import { generateShadowrocket } from './shadowrocket.js'; +import { generateShadowrocketConf } from './shadowrocket-conf.js'; +import { parseShareLink } from '../sharelink/parse.js'; + +const node: NodeDto = { + id: 'placeholder', name: '🇯🇵 JP VMess Direct', protocol: 'vmess', + server: 'node.example.com', port: 443, uuid: '00000000-1111-2222-3333-444444444444', + password: 'placeholder:password', method: 'chacha20-poly1305', network: 'ws', + tls: true, sni: 'node.example.com', skipCertVerify: false, wsHost: 'node.example.com', wsPath: '/ws', + alterId: 0, security: 'auto', obfsType: null, obfsPassword: null, upMbps: null, downMbps: null, + region: null, tags: [], enabled: true, sortOrder: 0, createdAt: 0, updatedAt: 0, +}; +const group: GroupIR = { + name: 'Filtered', type: 'url-test', testUrl: 'https://example.com/generate_204', + interval: 300, tolerance: 0, filterRegex: '^🇯🇵', includeAllNodes: true, + members: [{ kind: 'builtin', name: 'DIRECT' }, { kind: 'node', name: node.name }, { kind: 'group', name: 'Source' }], +}; +const final: RuleIR = { type: 'FINAL', value: null, policy: 'DIRECT', params: ['dns-failed'], ruleset: null }; +const base: ProfileIR = { nodes: [node], groups: [], rules: [final], sections: {} }; +const generate = (ir: ProfileIR) => generateClash(ir, { convertBaseUrl: 'https://example.com/sub/placeholder' }); +const directory = mkdtempSync(path.join(tmpdir(), 'proxy-station-mobile-')); +const cli = process.env.MIHOMO_CLI ?? 'mihomo'; +const nativeAvailable = spawnSync(cli, ['-v'], { timeout: 10000 }).status === 0; +afterAll(() => rmSync(directory, { recursive: true, force: true })); + +describe('mobile client node subscriptions', () => { + it('sorts complete Shadowrocket names by string, retains all four protocols and does not mutate IR', () => { + const names = ['🇸🇬 SG VMess Warp', '🇯🇵 JP Trojan Warp', '🇺🇸 US LA SS Direct', + '🇸🇬 SG VMess Direct', '🇯🇵 JP HY2 Direct', '🇯🇵 JP Trojan Direct']; + const protocols: NodeDto['protocol'][] = ['vmess', 'trojan', 'shadowsocks', 'vmess', 'hysteria2', 'trojan']; + const nodes = names.map((name, i) => ({ ...node, name, protocol: protocols[i], + ...(protocols[i] === 'hysteria2' ? { network: 'tcp' as const, wsHost: null, wsPath: null, + obfsType: 'salamander', obfsPassword: 'placeholder-obfs' } : {}) })); + const output = generateShadowrocket({ ...base, nodes }); + const uris = Buffer.from(output.content, 'base64').toString('utf8').split('\n'); + expect(uris).toEqual(output.uris); + expect(uris.map((uri) => parseShareLink(uri).name)).toEqual([...names].sort()); + expect(nodes.map((n) => n.name)).toEqual(names); + expect(output.excluded).toEqual([]); + for (const uri of uris) { + const parsed = parseShareLink(uri); + const original = nodes.find((n) => n.name === parsed.name)!; + expect(parsed).toMatchObject({ name: original.name, protocol: original.protocol, + server: original.server, port: original.port, network: original.network, + tls: original.tls, sni: original.sni }); + if (original.protocol === 'vmess') expect(parsed.uuid).toBe(original.uuid); + else expect(parsed.password).toBe(original.password); + if (original.protocol === 'hysteria2') expect(parsed.obfsPassword).toBe(original.obfsPassword); + } + }); + + it('enables supported Clash UDP transports, keeps SS WS TCP-only, and preserves TLS flags', () => { + expect(clashProxy({ ...node, skipCertVerify: true })).toMatchObject({ type: 'vmess', udp: true, + tls: true, servername: node.sni, 'skip-cert-verify': true, 'ws-opts': { headers: { Host: node.wsHost } } }); + expect(clashProxy({ ...node, protocol: 'trojan' })).toMatchObject({ type: 'trojan', udp: true, sni: node.sni }); + expect(clashProxy({ ...node, protocol: 'shadowsocks', skipCertVerify: true })).toMatchObject({ + type: 'ss', udp: false, cipher: 'chacha20-ietf-poly1305', plugin: 'v2ray-plugin', + 'plugin-opts': { mode: 'websocket', tls: true, path: '/ws', 'skip-cert-verify': true }, + }); + expect(clashProxy({ ...node, protocol: 'shadowsocks', network: 'tcp' }).udp).toBe(true); + expect(clashProxy({ ...node, protocol: 'hysteria2', network: 'tcp', obfsType: 'salamander', + obfsPassword: 'placeholder-obfs', upMbps: 20, downMbps: 100 })).toMatchObject({ type: 'hysteria2', + udp: true, obfs: 'salamander', 'obfs-password': 'placeholder-obfs', up: '20 Mbps', down: '100 Mbps' }); + }); + + it('maps the VMess cipher alias and reports unsupported nodes without leaving dangling group references', () => { + expect(clashProxy({ ...node, security: 'chacha20-ietf-poly1305' }).cipher).toBe('chacha20-poly1305'); + const result = generate({ ...base, nodes: [{ ...node, security: 'unsupported-placeholder' }], + groups: [{ ...group, filterRegex: null, includeAllNodes: false, members: [{ kind: 'node', name: node.name }] }] }); + const doc = YAML.parse(result.content); + expect(result.excluded).toEqual([{ name: node.name, reason: 'Clash 不支持该 VMess 加密方式:unsupported-placeholder' }]); + expect(doc.proxies).toEqual([]); + expect(doc['proxy-groups'][0].proxies).toEqual(['DIRECT']); + }); +}); + +describe('mobile client groups and rule semantics', () => { + it('preserves explicit members in dynamic groups, zero tolerance, and omits fallback tolerance', () => { + const source = { ...group, name: 'Source', filterRegex: null, includeAllNodes: false, + members: [{ kind: 'node' as const, name: node.name }] }; + const all = { ...group, name: 'All', filterRegex: null }; + const fallback = { ...group, name: 'Fallback', type: 'fallback' as const, tolerance: 50 }; + const ir = { ...base, groups: [source, group, all, fallback] }; + const doc = YAML.parse(generate(ir).content); + expect(doc['proxy-groups'][1]).toMatchObject({ proxies: ['DIRECT', node.name, 'Source'], + 'include-all-proxies': true, filter: group.filterRegex, tolerance: 0, url: group.testUrl }); + expect(doc['proxy-groups'][2].proxies).toEqual(['DIRECT', node.name, 'Source']); + expect(doc['proxy-groups'][3]).not.toHaveProperty('tolerance'); + const sr = generateShadowrocketConf(ir).content; + expect(sr).toContain(`Filtered = url-test, DIRECT, ${node.name}, Source, url=${group.testUrl}, interval=300, tolerance=0, policy-regex-filter=^🇯🇵`); + expect(sr.split('\n').find((l) => l.startsWith('Fallback ='))).not.toContain('tolerance='); + expect(sr).not.toContain('use='); + }); + + it('preserves provider no-resolve, handles duplicate provider names, keeps rule order and FINAL mapping', () => { + const ruleset = { id: 'first', name: 'same', surgeUrl: 'https://example.com/ip.list', clashUrl: null, + clashBehavior: 'classical', clashFormat: 'yaml', needsConvert: true }; + const rules: RuleIR[] = [ + { type: 'RULE-SET', value: 'first', policy: 'REJECT-DROP', params: ['no-resolve'], ruleset }, + { type: 'DOMAIN', value: 'example.com', policy: 'DIRECT', params: [], ruleset: null }, + { type: 'RULE-SET', value: 'second', policy: 'DIRECT', params: [], ruleset: { ...ruleset, id: 'second' } }, final, + ]; + const doc = YAML.parse(generate({ ...base, rules }).content); + expect(doc.rules).toEqual(['RULE-SET,same,REJECT-DROP,no-resolve', 'DOMAIN,example.com,DIRECT', + 'RULE-SET,same-2,DIRECT', 'MATCH,DIRECT']); + expect(doc['rule-providers']['same-2'].url).toBe('https://example.com/sub/placeholder/ruleset/second.yaml'); + }); + + it('converts only transport subrules, preserves payload literals and skips whole unsupported logical expressions', () => { + const values = ['((PROTOCOL,UDP),(DOMAIN-SUFFIX,PROTOCOL.example.com))', + '((OR,((PROTOCOL,TCP),(PROTOCOL,UDP))),(DEST-PORT,443))', + '((PROTOCOL,STUN),(DOMAIN,example.com))', '((PROTOCOL,DOH),(DOMAIN,example.com))', + '((URL-REGEX,^https://example.com),(DOMAIN,example.com))', + '((DOMAIN,example.com,extended-matching),(PROTOCOL,UDP))']; + const result = generate({ ...base, rules: [...values.map((value): RuleIR => ({ + type: 'AND', value, policy: 'REJECT-NO-DROP', params: [], ruleset: null, + })), final] }); + expect(YAML.parse(result.content).rules).toEqual([ + 'AND,((NETWORK,UDP),(DOMAIN-SUFFIX,PROTOCOL.example.com)),REJECT', + 'AND,((OR,((NETWORK,TCP),(NETWORK,UDP))),(DST-PORT,443)),REJECT', 'MATCH,DIRECT', + ]); + expect(result.skippedRules).toHaveLength(4); + }); + + it.skipIf(!nativeAvailable)('passes the native mihomo parser with all node protocols, dynamic groups and nested logic', () => { + const nodes: NodeDto[] = [node, { ...node, name: 'Trojan', protocol: 'trojan' }, + { ...node, name: 'SS', protocol: 'shadowsocks' }, + { ...node, name: 'HY2', protocol: 'hysteria2', network: 'tcp', obfsType: 'salamander', obfsPassword: 'placeholder-obfs' }]; + const ir: ProfileIR = { ...base, nodes, groups: [ + { ...group, name: 'Source', filterRegex: null, includeAllNodes: false, members: [{ kind: 'node', name: node.name }] }, group, + ], rules: [{ type: 'AND', value: '((OR,((PROTOCOL,TCP),(PROTOCOL,UDP))),(DOMAIN,example.com))', + policy: 'REJECT', params: [], ruleset: null }, final] }; + const file = path.join(directory, 'fixture.yaml'); + writeFileSync(file, generate(ir).content, { mode: 0o600 }); + const result = spawnSync(cli, ['-t', '-d', directory, '-f', file], { encoding: 'utf8', timeout: 15000 }); + expect(result.stdout + result.stderr).toContain('test is successful'); + expect(result.status).toBe(0); + }); +}); diff --git a/server/src/services/generators/shadowrocket-conf.ts b/server/src/services/generators/shadowrocket-conf.ts index 6321550..2916bcc 100644 --- a/server/src/services/generators/shadowrocket-conf.ts +++ b/server/src/services/generators/shadowrocket-conf.ts @@ -44,7 +44,7 @@ function shadowrocketGeneral(surgeGeneral: string): string { return lines.join('\n'); } -/** ShadowRocket 不认识的规则类型 */ +/** 尚未验证 Shadowrocket 配置语法的类型;这是生成器的保守范围,不是客户端能力断言。 */ const SR_UNSUPPORTED_RULE_TYPES = new Set(['DOMAIN-SET', 'AND', 'OR', 'NOT', 'URL-REGEX', 'PROTOCOL']); export interface ShadowrocketConfOutput { @@ -70,14 +70,12 @@ export function generateShadowrocketConf(ir: ProfileIR): ShadowrocketConfOutput lines.push('', '[Proxy Group]'); for (const g of ir.groups) { const parts: string[] = [g.type]; - if (!g.filterRegex && !g.includeAllNodes) { - const members = g.members.map((m) => policyName(m.name)); - parts.push(...(members.length ? members : ['DIRECT'])); - } + const members = [...new Set(g.members.map((m) => policyName(m.name)))]; + parts.push(...(members.length ? members : !g.filterRegex && !g.includeAllNodes ? ['DIRECT'] : [])); if (g.type !== 'select') { parts.push(`url=${g.testUrl || 'http://cp.cloudflare.com/generate_204'}`); if (g.interval) parts.push(`interval=${g.interval}`); - if (g.tolerance) parts.push(`tolerance=${g.tolerance}`); + if (g.type === 'url-test' && g.tolerance !== null) parts.push(`tolerance=${g.tolerance}`); } // policy-regex-filter 从 App 内全部节点里筛选;「收纳全部节点」用全匹配正则 if (g.filterRegex) parts.push(`policy-regex-filter=${g.filterRegex}`); diff --git a/server/src/services/generators/shadowrocket.ts b/server/src/services/generators/shadowrocket.ts index 3aae084..f8369da 100644 --- a/server/src/services/generators/shadowrocket.ts +++ b/server/src/services/generators/shadowrocket.ts @@ -11,7 +11,9 @@ export interface ShadowrocketOutput { export function generateShadowrocket(ir: ProfileIR): ShadowrocketOutput { const excluded: ExcludedNode[] = []; const uris: string[] = []; - for (const n of ir.nodes) { + // 按完整名称的字符串顺序排列,保留国家、协议和出口后缀;不改 IR 的数据库顺序。 + const sortedNodes = [...ir.nodes].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0); + for (const n of sortedNodes) { const verdict = clientSupports('shadowrocket', n); if (!verdict.ok) { excluded.push({ name: n.name, reason: verdict.reason! }); diff --git a/server/src/services/generators/surge-ai-dns.test.ts b/server/src/services/generators/surge-ai-dns.test.ts index b32460d..97b2a81 100644 --- a/server/src/services/generators/surge-ai-dns.test.ts +++ b/server/src/services/generators/surge-ai-dns.test.ts @@ -48,4 +48,14 @@ describe('AI-only Surge DNS', () => { expect(dns.host.match(/new\.example =/g)).toHaveLength(1); expect(dns.host).not.toContain('old.example'); }); + it('keeps explicit Host and earlier routing exceptions ahead of broad AI mappings', () => { + const base = { ...ir, rules: [rule('RULE-SET', 'unbreak', 'DIRECT'), rule('DOMAIN', 'api.ai.example', 'DIRECT'), rule('DOMAIN-SUFFIX', 'ai.example')] }; + base.rules[0].ruleset = { ...ir.rules[3].ruleset!, surgeUrl: 'https://example.com/unbreak.list' }; + base.sections = { ...ir.sections, Host: 'update.ai.example = server:119.29.29.29' }; + const dns = surgeAiDns(base); + expect(dns.host.indexOf('update.ai.example =')).toBeLessThan(dns.host.indexOf('*.ai.example =')); + expect(dns.host.indexOf('RULE-SET:https://example.com/unbreak.list')).toBeLessThan(dns.host.indexOf('*.ai.example =')); + expect(dns.host).toContain('api.ai.example = server:https://doh.pub/dns-query, https://dns.alidns.com/dns-query'); + expect(dns.host).not.toContain('api.ai.example = server:https://1.1.1.1'); + }); }); diff --git a/server/src/services/generators/surge-ai-dns.ts b/server/src/services/generators/surge-ai-dns.ts index 59251e3..07940de 100644 --- a/server/src/services/generators/surge-ai-dns.ts +++ b/server/src/services/generators/surge-ai-dns.ts @@ -1,4 +1,5 @@ import type { ProfileIR } from '../ir.js'; +import { surgeValue } from './surge-format.js'; const AI_DNS = 'https://1.1.1.1/dns-query'; @@ -9,18 +10,26 @@ export function surgeAiDns(ir: ProfileIR): { general: string; host: string; rule 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 aiRules = ir.rules.map((r, i) => r.policy === ai.name && ['DOMAIN', 'DOMAIN-SUFFIX', 'DOMAIN-KEYWORD', 'RULE-SET', 'DOMAIN-SET'].includes(r.type) ? i : -1).filter((i) => i >= 0); + if (!aiRules.length) return { general, host, rules: [] }; + const defaultDns = general.match(/^encrypted-dns-server\s*=\s*(.+)$/mi)?.[1] + ?? general.match(/^dns-server\s*=\s*(.+)$/mi)?.[1] ?? 'system'; + const mappings = new Map(); + // Preserve the domain-only ordering before AI, including remote exceptions. + // Host cannot express protocol/process/IP conditions; those remain routing rules. + for (const rule of ir.rules.slice(0, aiRules.at(-1)! + 1)) { + if (!rule.value) continue; const value = rule.ruleset?.surgeUrl ?? rule.value; + const resolver = rule.policy === ai.name ? AI_DNS : defaultDns; + const add = (pattern: string) => { if (!mappings.has(pattern)) mappings.set(pattern, resolver); }; switch (rule.type) { - case 'DOMAIN': mappings.add(value); break; + case 'DOMAIN': 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 'DOMAIN-SUFFIX': add(value); add(`*.${value}`); break; + case 'DOMAIN-KEYWORD': add(`*${value}*`); break; case 'RULE-SET': case 'DOMAIN-SET': - if (/^https?:\/\//.test(value)) mappings.add(`${rule.type}:${value}`); + if (/^https?:\/\//.test(value)) add(`${rule.type}:${value}`); break; } } @@ -29,13 +38,13 @@ export function surgeAiDns(ir: ProfileIR): { general: string; host: string; rule 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; + const generatedHost = [...mappings].map(([pattern, resolver]) => `${pattern} = server:${resolver}`).join('\n'); + const name = surgeValue(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(), + // Deliberate Host overrides (including precise DNS exceptions) stay first. + host: `${host}\n# DNS: domain rule order through the last AI rule\n${generatedHost}`.trim(), rules: [ `IP-CIDR,1.1.1.1/32,${name},no-resolve`, // Keep the upstream template's default DoH on the local path. diff --git a/server/src/services/generators/surge-format.ts b/server/src/services/generators/surge-format.ts new file mode 100644 index 0000000..3778460 --- /dev/null +++ b/server/src/services/generators/surge-format.ts @@ -0,0 +1,21 @@ +/** Surge Mac 6.8+ quoted value escaping; logical-rule expressions stay structural. */ +export function surgeValue(value: string): string { + if (/[\x00-\x1f\x7f\u2028\u2029]/.test(value)) throw new Error('Surge value contains a control character'); + return /[\s,"'\\#;]/.test(value) + ? `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` + : value; +} + +export function surgeDeclaration(name: string): string { + if (!name || name !== name.trim() || /[=,"'\\#;\[\]\x00-\x1f\x7f\u2028\u2029]/.test(name)) { + throw new Error('Unsafe Surge policy declaration name'); + } + return name; +} + +export function surgeParam(param: string): string { + const index = param.indexOf('='); + const key = index < 0 ? param : param.slice(0, index); + if (!/^[a-z][a-z0-9-]*$/i.test(key)) throw new Error('Unsafe Surge parameter name'); + return index < 0 ? key : `${key}=${surgeValue(param.slice(index + 1))}`; +} diff --git a/server/src/services/generators/surge-safety.test.ts b/server/src/services/generators/surge-safety.test.ts new file mode 100644 index 0000000..9459798 --- /dev/null +++ b/server/src/services/generators/surge-safety.test.ts @@ -0,0 +1,67 @@ +import { afterAll, describe, expect, it } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { tmpdir } from 'node:os'; +import { spawnSync } from 'node:child_process'; +import { clientSupports, nodeInputSchema, type NodeDto } from '@proxy-station/shared'; +import { generateSurge, surgeNodeLine } from './surge.js'; +import type { ProfileIR } from '../ir.js'; + +const node: NodeDto = { + ...nodeInputSchema.parse({ name: 'JP VMess', protocol: 'vmess', server: 'example.com', port: 443, uuid: '00000000-0000-0000-0000-000000000000', tls: true }), + id: 'fixture', createdAt: 0, updatedAt: 0, extra: {}, +} as NodeDto; +const base: ProfileIR = { nodes: [node], groups: [], rules: [{ type: 'FINAL', value: null, policy: 'DIRECT', params: [], ruleset: null }], sections: {} }; +const directory = mkdtempSync(path.join(tmpdir(), 'proxy-station-surge-')); +const cli = process.env.SURGE_CLI ?? 'surge-cli'; +const nativeAvailable = spawnSync(cli, ['--help'], { timeout: 10000 }).status === 0; +afterAll(() => rmSync(directory, { recursive: true, force: true })); + +describe('Surge safe serialization and supported semantics', () => { + it('preserves literals and every filtered group source, including with include-all', () => { + const groups: ProfileIR['groups'] = ['Source A', 'Source B'].map((name) => ({ name, type: 'select', members: [{ kind: 'node', name: node.name }], includeAllNodes: false, filterRegex: null, testUrl: null, interval: null, tolerance: null })); + groups.push({ ...groups[0], name: 'Filtered', includeAllNodes: true, filterRegex: '^JP|SG', members: [{ kind: 'builtin', name: 'DIRECT' }, { kind: 'group', name: 'Source A' }, { kind: 'group', name: 'Source B' }] }); + const content = generateSurge({ ...base, groups }, { selfUrl: 'https://example.com/sub/test/surge' }).content; + expect(content).toContain('Filtered = select, DIRECT, include-all-proxies=true, include-other-group="Source A,Source B", policy-regex-filter=^JP|SG'); + const all = { ...groups[0], name: 'All', includeAllNodes: true, members: [{ kind: 'builtin' as const, name: 'DIRECT' }] }; + expect(generateSurge({ ...base, groups: [all] }, { selfUrl: 'https://example.com/sub/test/surge' }).content).toContain('All = select, DIRECT, "JP VMess"'); + }); + + it('maps TLS and cipher flags and reports unsupported VMess combinations', () => { + expect(surgeNodeLine({ ...node, skipCertVerify: true, security: 'chacha20-poly1305' })).toContain('skip-cert-verify=true, vmess-aead=true, encrypt-method=chacha20-ietf-poly1305'); + expect(clientSupports('surge', { ...node, alterId: 64 }).ok).toBe(false); + expect(clientSupports('surge', { ...node, security: 'none' }).ok).toBe(false); + const result = generateSurge({ ...base, nodes: [{ ...node, alterId: 64 }] }, { selfUrl: 'https://example.com/sub/test/surge' }); + expect(result.excluded).toHaveLength(1); + expect(result.content).not.toContain('JP VMess ='); + }); + + it('uses one trailing FINAL and omits obsolete group url and fallback tolerance', () => { + const groups: ProfileIR['groups'] = [{ name: 'Test', type: 'fallback', members: [{ kind: 'node', name: node.name }], includeAllNodes: false, filterRegex: null, testUrl: 'https://example.com/test', interval: 300, tolerance: 100 }]; + const content = generateSurge({ ...base, groups, rules: [base.rules[0], { type: 'DOMAIN', value: 'example.org', policy: 'DIRECT', params: [], ruleset: null }] }, { selfUrl: 'https://example.com/sub/test/surge' }).content; + expect(content).not.toContain('url='); + expect(content).not.toContain('tolerance='); + expect(content).toContain('DOMAIN,example.org,DIRECT\nFINAL,DIRECT'); + expect(generateSurge({ ...base, rules: [] }, { selfUrl: 'https://example.com/sub/test/surge' }).content).toContain('FINAL,DIRECT'); + }); + + it.skipIf(!nativeAvailable)('passes the official local CLI with commas, regex escaping, filtered imports and TLS flags', () => { + const groups: ProfileIR['groups'] = ['A', 'B'].map((name) => ({ name, type: 'select', members: [{ kind: 'node', name: node.name }], includeAllNodes: false, filterRegex: null, testUrl: null, interval: null, tolerance: null })); + groups.push({ ...groups[0], name: 'Filtered', includeAllNodes: true, filterRegex: '^(JP|SG),?', members: [{ kind: 'builtin', name: 'DIRECT' }, { kind: 'group', name: 'A' }, { kind: 'group', name: 'B' }] }); + const cases: ProfileIR[] = [ + { ...base, groups }, + { ...base, rules: [{ type: 'URL-REGEX', value: '^https://example.com/(a|b),?c\\d', policy: 'DIRECT', params: [], ruleset: null }, ...base.rules] }, + { ...base, nodes: [{ ...node, protocol: 'trojan', password: 'placeholder,with"quote\\backslash', network: 'ws', wsPath: '/a,b', wsHost: 'example.com' }] }, + { ...base, nodes: [{ ...node, skipCertVerify: true, security: 'chacha20-poly1305' }] }, + { ...base, rules: [{ type: 'AND', value: '((PROTOCOL,UDP),(DOMAIN-SUFFIX,example.com))', policy: 'REJECT-NO-DROP', params: ['notification-text=matched, example'], ruleset: null }, ...base.rules] }, + { ...base, rules: [] }, + ]; + for (const [i, ir] of cases.entries()) { + const file = path.join(directory, `${i}.conf`); + writeFileSync(file, generateSurge(ir, { selfUrl: 'https://example.com/sub/test/surge' }).content, { mode: 0o600 }); + const result = spawnSync(cli, ['--check', file], { encoding: 'utf8', timeout: 15000 }); + expect(result.stdout + result.stderr).toContain('OK'); + expect(result.status).toBe(0); + } + }); +}); diff --git a/server/src/services/generators/surge.ts b/server/src/services/generators/surge.ts index 58b82d9..3070026 100644 --- a/server/src/services/generators/surge.ts +++ b/server/src/services/generators/surge.ts @@ -1,54 +1,54 @@ 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 { - return /[\s,]/.test(name) ? `"${name}"` : name; -} +import { surgeDeclaration, surgeParam, surgeValue as quoteName } from './surge-format.js'; export function surgeNodeLine(n: NodeDto): string { const parts: string[] = []; switch (n.protocol) { case 'vmess': { - parts.push('vmess', n.server, String(n.port), `username=${n.uuid}`); + const verdict = clientSupports('surge', n); + if (!verdict.ok) throw new Error(verdict.reason); + parts.push('vmess', quoteName(n.server), String(n.port), `username=${n.uuid}`); if (n.network === 'ws') { - parts.push('ws=true', `ws-path=${n.wsPath}`); - if (n.wsHost) parts.push(`ws-headers=Host:"${n.wsHost}"`); + parts.push('ws=true', `ws-path=${quoteName(n.wsPath || '/')}`); + if (n.wsHost) parts.push(`ws-headers=${quoteName(`Host:${n.wsHost}`)}`); } if (n.tls) { parts.push('tls=true'); - if (n.sni) parts.push(`sni=${n.sni}`); + if (n.sni) parts.push(`sni=${quoteName(n.sni)}`); + if (n.skipCertVerify) parts.push('skip-cert-verify=true'); } // alterId=0 的 AEAD 头必须显式打开,否则 Surge 连不上且症状隐晦 parts.push('vmess-aead=true'); + if (n.security !== 'auto') parts.push(`encrypt-method=${normalizeSsCipher(n.security)}`); break; } case 'trojan': { - parts.push('trojan', n.server, String(n.port), `password=${n.password}`); + parts.push('trojan', quoteName(n.server), String(n.port), `password=${quoteName(n.password || '')}`); if (n.network === 'ws') { - parts.push('ws=true', `ws-path=${n.wsPath}`); - if (n.wsHost) parts.push(`ws-headers=Host:"${n.wsHost}"`); + parts.push('ws=true', `ws-path=${quoteName(n.wsPath || '/')}`); + if (n.wsHost) parts.push(`ws-headers=${quoteName(`Host:${n.wsHost}`)}`); } - if (n.sni) parts.push(`sni=${n.sni}`); + if (n.sni) parts.push(`sni=${quoteName(n.sni)}`); if (n.skipCertVerify) parts.push('skip-cert-verify=true'); break; } case 'shadowsocks': { - parts.push('ss', n.server, String(n.port), `encrypt-method=${normalizeSsCipher(n.method || '')}`, `password=${n.password}`); + parts.push('ss', quoteName(n.server), String(n.port), `encrypt-method=${quoteName(normalizeSsCipher(n.method || ''))}`, `password=${quoteName(n.password || '')}`); break; } case 'hysteria2': { - parts.push('hysteria2', n.server, String(n.port), `password=${n.password}`); - if (n.sni) parts.push(`sni=${n.sni}`); + parts.push('hysteria2', quoteName(n.server), String(n.port), `password=${quoteName(n.password || '')}`); + if (n.sni) parts.push(`sni=${quoteName(n.sni)}`); // Surge 的 salamander 混淆参数名(sub-router 生产验证过) - if (n.obfsType === 'salamander' && n.obfsPassword) parts.push(`salamander-password=${n.obfsPassword}`); + if (n.obfsType === 'salamander' && n.obfsPassword) parts.push(`salamander-password=${quoteName(n.obfsPassword)}`); if (n.downMbps) parts.push(`download-bandwidth=${n.downMbps}`); if (n.skipCertVerify) parts.push('skip-cert-verify=true'); break; } } - return `${n.name} = ${parts.join(', ')}`; + return `${surgeDeclaration(n.name)} = ${parts.join(', ')}`; } export interface SurgeOutput { @@ -83,39 +83,38 @@ export function generateSurge(ir: ProfileIR, opts: { selfUrl: string }): SurgeOu .filter((m) => m.kind !== 'node' || usableNames.has(m.name)) .map((m) => quoteName(m.name)); if (g.filterRegex) { + parts.push(...g.members.filter((m) => m.kind !== 'group' && (m.kind !== 'node' || usableNames.has(m.name))).map((m) => quoteName(m.name))); // 按名称正则动态筛选:节点来源可以是全部节点,也可以是另一个组 if (g.includeAllNodes) { parts.push('include-all-proxies=true'); - } else { - const source = g.members.find((m) => m.kind === 'group'); - if (source) parts.push(`include-other-group=${quoteName(source.name)}`); } - parts.push(`policy-regex-filter=${g.filterRegex}`); + const sources = g.members.filter((m) => m.kind === 'group').map((m) => m.name); + if (sources.length) parts.push(`include-other-group=${quoteName(sources.join(','))}`); + parts.push(`policy-regex-filter=${quoteName(g.filterRegex)}`); } else if (g.includeAllNodes) { // 「全部节点」组:显式展开当前启用且 Surge 支持的节点 - parts.push(...usable.map((n) => quoteName(n.name)), ...memberNames.filter((m) => !usableNames.has(m))); + parts.push(...[...new Set([...g.members.filter((m) => m.kind !== 'node' || usableNames.has(m.name)).map((m) => m.name), ...usable.map((n) => n.name)])].map(quoteName)); if (usable.length === 0) parts.push('DIRECT'); } else { parts.push(...(memberNames.length ? memberNames : ['DIRECT'])); } if (g.type !== 'select') { - parts.push(`url=${g.testUrl || 'http://cp.cloudflare.com/generate_204'}`); + // Surge uses per-node test-url or General.proxy-test-url, never group url=. if (g.interval) parts.push(`interval=${g.interval}`); - if (g.tolerance) parts.push(`tolerance=${g.tolerance}`); + if (g.type === 'url-test' && g.tolerance !== null) parts.push(`tolerance=${g.tolerance}`); } - lines.push(`${g.name} = ${parts.join(', ')}`); + lines.push(`${surgeDeclaration(g.name)} = ${parts.join(', ')}`); } 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(',')); - continue; - } + for (const r of ir.rules.filter((r) => r.type !== 'FINAL')) { const value = r.ruleset ? r.ruleset.surgeUrl : r.value; - lines.push([r.type, value, quoteName(r.policy), ...r.params].join(',')); + const encoded = ['AND', 'OR', 'NOT'].includes(r.type) ? value : quoteName(value || ''); + lines.push([r.type, encoded, quoteName(r.policy), ...r.params.map(surgeParam)].join(',')); } + const final = ir.rules.find((r) => r.type === 'FINAL'); + lines.push(['FINAL', quoteName(final?.policy ?? 'DIRECT'), ...(final?.params ?? []).map(surgeParam)].join(',')); for (const section of ['Host', 'URL Rewrite', 'Header Rewrite', 'MITM', 'Script']) { const content = section === 'Host' ? dns.host : ir.sections[section]; diff --git a/server/src/services/group-validation.ts b/server/src/services/group-validation.ts new file mode 100644 index 0000000..d251b77 --- /dev/null +++ b/server/src/services/group-validation.ts @@ -0,0 +1,28 @@ +import { BUILTIN_POLICIES, type GroupInput } from '@proxy-station/shared'; + +interface ExistingGroup { id: string; name: string; members: { kind: string; refName: string | null }[] } + +/** Validate the proposed graph, including references rewritten by a rename. */ +export function groupError(input: GroupInput, groups: ExistingGroup[], nodes: { id: string; name: string }[], id?: string): string | null { + if ((BUILTIN_POLICIES as readonly string[]).includes(input.name) || nodes.some((n) => n.name === input.name)) return '策略组名称与节点或内置策略冲突'; + if (groups.some((g) => g.id !== id && g.name === input.name)) return '策略组名称已存在'; + const oldName = groups.find((g) => g.id === id)?.name; + const rename = (name: string) => name === oldName ? input.name : name; + const graph = new Map(groups.filter((g) => g.id !== id).map((g) => [g.name, g.members.filter((m) => m.kind === 'group' && m.refName).map((m) => rename(m.refName!))])); + graph.set(input.name, (input.members ?? []).filter((m) => m.kind === 'group' && m.refName).map((m) => rename(m.refName!))); + for (const m of input.members ?? []) { + if (m.kind === 'node' && !nodes.some((n) => n.id === m.nodeId)) return '成员节点不存在'; + if (m.kind === 'group' && (!m.refName || !graph.has(rename(m.refName)))) return '成员策略组不存在'; + if (m.kind === 'builtin' && !(BUILTIN_POLICIES as readonly (string | null | undefined)[]).includes(m.refName)) return '内置策略无效'; + } + const visiting = new Set(); + const done = new Set(); + function cyclic(name: string): boolean { + if (visiting.has(name)) return true; + if (done.has(name)) return false; + visiting.add(name); + if ((graph.get(name) ?? []).some(cyclic)) return true; + visiting.delete(name); done.add(name); return false; + } + return [...graph.keys()].some(cyclic) ? '策略组不能循环引用' : null; +} diff --git a/server/src/services/ir.ts b/server/src/services/ir.ts index 6b2f423..226dcd2 100644 --- a/server/src/services/ir.ts +++ b/server/src/services/ir.ts @@ -99,13 +99,15 @@ export function buildProfileIR(opts: { nodeIds?: string[] } = {}): ProfileIR { const ruleRows = db .select() .from(rules) - .where(eq(rules.enabled, true)) .orderBy(asc(rules.seq)) - .all() - .filter((r) => r.type === 'FINAL' || !isDeadRef(r.policy)) - // FINAL 必须保留:其策略组被停用或删除时兜底 DIRECT - .map((r) => (r.type === 'FINAL' && isDeadRef(r.policy) ? { ...r, policy: 'DIRECT' } : r)); - const ruleIRs: RuleIR[] = ruleRows.map((r) => { + .all(); + // Defensive repair of legacy/corrupt states: exactly one FINAL, always last. + // Management writes separately reject edits that would break this invariant. + const storedFinal = ruleRows.find((r) => r.type === 'FINAL' && r.enabled) + ?? ruleRows.find((r) => r.type === 'FINAL'); + const activeRows = ruleRows.filter((r) => r.enabled && r.type !== 'FINAL') + .filter((r) => !isDeadRef(r.policy)); + const ruleIRs: RuleIR[] = activeRows.map((r) => { const rs = r.value ? setRows.get(r.value) : undefined; return { type: r.type, @@ -115,6 +117,11 @@ export function buildProfileIR(opts: { nodeIds?: string[] } = {}): ProfileIR { ruleset: rs ?? null, }; }); + ruleIRs.push({ + type: 'FINAL', value: null, + policy: storedFinal && !isDeadRef(storedFinal.policy) ? storedFinal.policy : 'DIRECT', + params: storedFinal ? JSON.parse(storedFinal.params) : [], ruleset: null, + }); const sections: Record = {}; for (const key of ['General', 'Host', 'URL Rewrite', 'Header Rewrite', 'MITM', 'Script']) { diff --git a/shared/src/index.ts b/shared/src/index.ts index f55423f..5a28e44 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -1,3 +1,4 @@ export * from './protocols.js'; export * from './schemas.js'; export * from './dto.js'; +export { splitRuleExpression } from './rule-syntax.js'; diff --git a/shared/src/protocols.ts b/shared/src/protocols.ts index 2efffad..d3e367f 100644 --- a/shared/src/protocols.ts +++ b/shared/src/protocols.ts @@ -25,6 +25,8 @@ export function normalizeSsCipher(method: string): string { export interface NodeCapabilityInput { protocol: Protocol; network: 'tcp' | 'ws'; + alterId?: number; + security?: string; } export interface CapabilityVerdict { @@ -40,5 +42,15 @@ export function clientSupports(client: ClientType, node: NodeCapabilityInput): C if (client === 'surge' && node.protocol === 'shadowsocks' && node.network === 'ws') { return { ok: false, reason: 'Surge 不支持 Shadowsocks over WebSocket' }; } + if (client === 'surge' && node.protocol === 'vmess') { + if ((node.alterId ?? 0) !== 0) return { ok: false, reason: 'Surge 下发仅支持 alterId=0 的 VMess AEAD 节点' }; + if (!['auto', 'aes-128-gcm', 'chacha20-poly1305', 'chacha20-ietf-poly1305'].includes(node.security ?? 'auto')) { + return { ok: false, reason: `Surge 不支持该 VMess 加密方式:${node.security}` }; + } + } + if (client === 'clash' && node.protocol === 'vmess' + && !['auto', 'none', 'zero', 'aes-128-gcm', 'chacha20-poly1305', 'chacha20-ietf-poly1305'].includes(node.security ?? 'auto')) { + return { ok: false, reason: `Clash 不支持该 VMess 加密方式:${node.security}` }; + } return { ok: true }; } diff --git a/shared/src/rule-syntax.ts b/shared/src/rule-syntax.ts new file mode 100644 index 0000000..fafc006 --- /dev/null +++ b/shared/src/rule-syntax.ts @@ -0,0 +1,40 @@ +/** Split structural commas while preserving quoted values and nested expressions. */ +export function splitRuleExpression(value: string): string[] | null { + const parts: string[] = []; + let start = 0, depth = 0, quote = '', escaped = false; + for (let i = 0; i < value.length; i++) { + const ch = value[i]; + if (escaped) { escaped = false; continue; } + if (quote) { + if (ch === '\\') escaped = true; + else if (ch === quote) quote = ''; + continue; + } + if (ch === '"' || ch === "'") { quote = ch; continue; } + if (ch === '#' || ch === ';') return null; + if (ch === '(') depth++; + if (ch === ')' && --depth < 0) return null; + if (ch === ',' && depth === 0) { parts.push(value.slice(start, i).trim()); start = i + 1; } + } + if (depth || quote || escaped) return null; + parts.push(value.slice(start).trim()); + return parts; +} + +const leafTypes = new Set(['DOMAIN', 'DOMAIN-SUFFIX', 'DOMAIN-KEYWORD', 'DOMAIN-WILDCARD', 'RULE-SET', 'DOMAIN-SET', 'IP-CIDR', 'IP-CIDR6', 'IP-ASN', 'GEOIP', 'PROCESS-NAME', 'PROTOCOL', 'USER-AGENT', 'URL-REGEX', 'SRC-IP', 'DEST-PORT', 'SRC-PORT', 'SUBNET', 'SCRIPT', 'HOSTNAME-TYPE', 'CELLULAR-CARRIER', 'CELLULAR-RADIO']); +export const SURGE_PROTOCOLS = ['HTTP', 'HTTPS', 'TCP', 'UDP', 'QUIC', 'STUN', 'MTProto', 'DOH', 'DOH3', 'DOQ', 'DOT', 'DNS']; + +/** Surge logical sub-rules have no policy; NOT has one child, nesting at most 10. */ +export function logicalValueValid(operator: string, value: string, depth = 1): boolean { + value = value.trim(); + if (depth > 10 || !value.startsWith('(') || !value.endsWith(')')) return false; + const children = splitRuleExpression(value.slice(1, -1)); + if (!children?.length || (operator === 'NOT' && children.length !== 1)) return false; + return children.every((child) => { + if (!child.startsWith('(') || !child.endsWith(')')) return false; + const parts = splitRuleExpression(child.slice(1, -1)); + if (!parts || parts.length < 2 || !parts[1] || parts.slice(2).some((flag) => !['no-resolve', 'extended-matching'].includes(flag))) return false; + if (['AND', 'OR', 'NOT'].includes(parts[0])) return logicalValueValid(parts[0], parts[1], depth + 1); + return leafTypes.has(parts[0]) && (parts[0] !== 'PROTOCOL' || SURGE_PROTOCOLS.includes(parts[1])); + }); +} diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 16afcb4..0912012 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -1,25 +1,32 @@ import { z } from 'zod'; import { PROTOCOLS } from './protocols.js'; +import { logicalValueValid, SURGE_PROTOCOLS } from './rule-syntax.js'; + +// These names are shared by Surge's declaration syntax and ShadowRocket's +// unquoted policy syntax. Reject delimiters rather than silently renaming them. +const singleLine = z.string().refine((v) => !/[\x00-\x1f\x7f\u2028\u2029]/.test(v), '不能包含换行或控制字符'); +const policyName = singleLine.pipe(z.string().min(1).max(64)) + .refine((v) => v === v.trim() && !/[=,"'\\#;\[\]]/.test(v), '名称不能包含配置分隔符、引号或首尾空格'); export const nodeInputSchema = z .object({ - name: z.string().min(1).max(64), + name: policyName, protocol: z.enum(PROTOCOLS), - server: z.string().min(1), + server: singleLine.pipe(z.string().min(1)), port: z.number().int().min(1).max(65535), network: z.enum(['tcp', 'ws']).default('tcp'), - wsPath: z.string().startsWith('/').optional().nullable(), - wsHost: z.string().optional().nullable(), + wsPath: singleLine.pipe(z.string().startsWith('/')).optional().nullable(), + wsHost: singleLine.optional().nullable(), tls: z.boolean().default(false), - sni: z.string().optional().nullable(), + sni: singleLine.optional().nullable(), skipCertVerify: z.boolean().default(false), uuid: z.string().uuid().optional().nullable(), alterId: z.number().int().min(0).default(0), - security: z.string().default('auto'), - password: z.string().optional().nullable(), - method: z.string().optional().nullable(), + security: singleLine.default('auto'), + password: singleLine.optional().nullable(), + method: singleLine.optional().nullable(), obfsType: z.enum(['salamander']).optional().nullable(), - obfsPassword: z.string().optional().nullable(), + obfsPassword: singleLine.optional().nullable(), upMbps: z.number().int().positive().optional().nullable(), downMbps: z.number().int().positive().optional().nullable(), region: z.string().max(8).optional().nullable(), @@ -46,12 +53,12 @@ export type NodeInput = z.input; export type NodeParsed = z.output; export const groupInputSchema = z.object({ - name: z.string().min(1).max(64), + name: policyName, type: z.enum(['select', 'url-test', 'fallback']), testUrl: z.string().url().optional().nullable(), interval: z.number().int().positive().optional().nullable(), - tolerance: z.number().int().positive().optional().nullable(), - filterRegex: z.string().optional().nullable(), + tolerance: z.number().int().nonnegative().optional().nullable(), + filterRegex: singleLine.refine((v) => { try { new RegExp(v); return true; } catch { return false; } }, '筛选正则无效').optional().nullable(), includeAllNodes: z.boolean().default(false), sortOrder: z.number().int().default(0), members: z @@ -59,7 +66,7 @@ export const groupInputSchema = z.object({ z.object({ kind: z.enum(['node', 'group', 'builtin']), nodeId: z.string().optional().nullable(), - refName: z.string().optional().nullable(), + refName: policyName.optional().nullable(), }) ) .default([]), @@ -91,10 +98,28 @@ export const BUILTIN_POLICIES = ['DIRECT', 'REJECT', 'REJECT-DROP', 'REJECT-NO-D export const ruleInputSchema = z.object({ type: z.enum(RULE_TYPES), - value: z.string().optional().nullable(), - policy: z.string().min(1), - params: z.array(z.string()).default([]), + value: singleLine.optional().nullable(), + policy: policyName, + params: z.array(singleLine.refine((v) => /^[a-z][a-z0-9-]*(?:=.+)?$/i.test(v), '规则参数需要有效的名称')).default([]), enabled: z.boolean().default(true), +}).superRefine((v, ctx) => { + if (v.type !== 'FINAL' && !v.value?.trim()) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['value'], message: '规则需要匹配值' }); + } + if (v.type === 'FINAL' && (!v.enabled || v.value)) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'FINAL 必须启用且不包含匹配值' }); + } + if (v.type !== 'URL-REGEX' && !['AND', 'OR', 'NOT'].includes(v.type) && /[,"'\\#;]/.test(v.value ?? '')) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['value'], message: '该规则类型不接受配置分隔符' }); + } + if (['AND', 'OR', 'NOT'].includes(v.type)) { + if (!logicalValueValid(v.type, v.value ?? '')) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['value'], message: '逻辑规则需要有效的无策略子规则与完整括号表达式' }); + } + } + if (v.type === 'PROTOCOL' && !SURGE_PROTOCOLS.includes(v.value ?? '')) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['value'], message: 'PROTOCOL 关键字无效,注意大小写' }); + } }); export type RuleInput = z.input; diff --git a/web/src/pages/GroupsPage.vue b/web/src/pages/GroupsPage.vue index 253344a..bc75f0a 100644 --- a/web/src/pages/GroupsPage.vue +++ b/web/src/pages/GroupsPage.vue @@ -224,9 +224,10 @@ function memberLabel(m: GroupDto['members'][number]): string {