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..ff9add1 --- /dev/null +++ b/.claude/skills/edge-node-multi-protocol/references/dns-records.md @@ -0,0 +1,42 @@ +# 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/AAAA records**. That's just two names. Create them 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** | +| `` | AAAA | `SERVER_IPV6` | off (skip if no IPv6) | +| `*.` | AAAA | `SERVER_IPV6` | 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 all four +records for you via the API (`CF_API_TOKEN` with `Zone:DNS:Edit`). Run it from wherever the +token lives; it never touches the VPS. + +## 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..400dd02 --- /dev/null +++ b/.claude/skills/edge-node-multi-protocol/references/pitfalls.md @@ -0,0 +1,52 @@ +# 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 + +- **`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..51625d9 --- /dev/null +++ b/.claude/skills/edge-node-multi-protocol/scripts/08-xui-bootstrap.py @@ -0,0 +1,280 @@ +#!/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": {}}, + {"tag": "warp-outbound", "protocol": "freedom", + "settings": {"domainStrategy": "UseIPv6v4"}, "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": [ + {"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": "UseIP"}, +} + + +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 + 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:", tmpl["routing"]["rules"][1]["inboundTag"]) + + 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..20b32f8 --- /dev/null +++ b/.claude/skills/edge-node-multi-protocol/scripts/13-singbox-config.py @@ -0,0 +1,105 @@ +#!/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": "::", "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": "::", "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 = [] +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}, + "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..d4cf8fa --- /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="2001:db8::10" # public IPv6 (optional; leave empty to skip 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 — it's two names, A + AAAA, 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..3c82524 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,173 @@ +# 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/` 迁入本项目。 + +### 节点部署管理 + +- `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/` 是历史资料,不作为当前配置来源;原兄弟目录仅保留为迁移前备份,后续在本仓库维护。 +- 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 配置(最有价值的一条端到端验证) +curl -s "http://localhost:3000/sub//clash" > /tmp/c.yaml && mihomo -t -f /tmp/c.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`;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)。**ShadowRocket 没有 Surge 的 `use=` 订阅引用语法**(真机验证过:`use=true` 会被当成解析不了的成员残留),策略组收节点只能靠 `policy-regex-filter` 从 App 内全部节点里筛(「收纳全部节点」输出 `policy-regex-filter=.*`),因此订阅名随意,但用户在 App 里挂的其他订阅的节点也会被筛进来。ShadowRocket 不支持 `DOMAIN-SET`/`AND`/`OR`/`NOT`/`URL-REGEX`/`PROTOCOL`,生成时跳过并回报 `skippedRules`。**ShadowRocket 的 conf 语法没有引号机制**:策略名(含空格/emoji)一律裸写,加引号会被当作名字的一部分,导致规则指向不存在的策略组而整条失效、流量落到默认行为走代理——Surge 生成器可以加引号,ShadowRocket 生成器绝不可以。 + +### 鉴权与订阅 + +除公开的 `/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,调接口时极难排查)。 + +## 前端约定 + +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 在线备份,避免绕过校验和级联更新。组成员与排序 API 有全量替换语义,修改前先读取完整当前值。 + +`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 域名规则仍须审阅覆盖范围。 + +规则变更先读 `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 产物。 + +`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 d3fa6ca..3de74bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,102 +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,调接口时极难排查)。 - -## 前端约定 - -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..c073d8f 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,6 +11,8 @@ ## 能做什么 +**边缘节点部署管理**——节点部署脚本、版本兼容参考、交付模板与离线汇总页生成器已合入本仓库,使用项目技能 `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 服务、流媒体、国内直连等)。规则可排序、改策略、启停、增删;策略组可启停并调整顺序,顺序即客户端里的排列顺序。外部规则集可在界面上实时预览内容。 @@ -72,14 +74,23 @@ Surge 与 ShadowRocket 需真机导入验收。 | 文件 | 内容 | |---|---| -| `docs/SEEDING.md` | 从 edge-nodes 交付文档导入真实节点、命名建议 | +| `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 验证流程 | ## 安全 -数据库存有节点真实凭据。`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/AI-COLLABORATION.md b/docs/AI-COLLABORATION.md new file mode 100644 index 0000000..a54880d --- /dev/null +++ b/docs/AI-COLLABORATION.md @@ -0,0 +1,62 @@ +# Codex 与 Claude Code 协作 + +项目开发约定以根目录 `AGENTS.md` 为唯一来源。原 `CLAUDE.md` 中的架构、协议适配、前端设计、数据与部署约定已迁入,并根据当前代码补充了初始化、环境变量、AI DNS 与验证说明。 + +## 指令入口 + +| 工具 | 项目指令 | 项目技能 | +|---|---|---| +| 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` 或复制一份独立的 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/` 并按任务引用。 +- 运维流程改动更新 `.claude/skills//SKILL.md` 及其脚本;`.agents/skills/` 只维护链接。 +- 节点与应用在本仓库统一维护,节点目录与当前舰队见 `edge-nodes/README.md`。通用工具可入库,真实配置/交付产物放 `edge-nodes/private/` 并用受控备份转交;原 `../edge-nodes/` 仅保留迁移前备份。旧工程指令保存在私有归档中,不作为当前约定。 +- 普通开发从 `shared` 的类型/校验、`server` 的 API 与 IR、`web` 的状态与页面定位改动。新增客户端先扩展客户端类型与能力矩阵,再增加独立生成器和订阅端点。 +- 修改规则模板只影响初始化,已有库的配置更新走管理 API;代码部署与数据更新分别执行。 +- 本地验证按改动范围选择类型检查、单测或构建。临时启动与数据库验证在进程启动前设置临时 `DATA_DIR`,避免触碰真实节点数据。 +- 交接说明记录已完成改动、验证结果与未完成项;可复用的认识写入项目文件,不依赖某个工具的私有会话记忆。 + +## 官方依据 + +- [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/DEPLOY.md b/docs/DEPLOY.md index fbe8d2d..97730d6 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -2,6 +2,8 @@ 服务器资源有限(1 核/1.9G),采用 Node 直跑 + systemd 守护,前端在本机构建后由服务端静态托管,反向代理与证书由 aaPanel 管理。 +本文描述 Proxy Station 应用发布。边缘 VPS 的部署、重建与协议维护已合入本仓库,见 [节点工作区](../edge-nodes/README.md) 与 `edge-node-multi-protocol` 技能;其私有资料不随应用部署上传。LA 同时承载节点与本应用,维护 nginx、证书或 WARP 时需核对共享服务影响。 + ## 拓扑 ``` @@ -22,7 +24,7 @@ ## 更新代码 -日常更新用项目级 Skill `deploy`(对 Claude 说「部署」即可触发),也可直接跑脚本: +日常更新用 Codex/Claude Code 共用的项目级技能 `deploy`(说「部署」即可匹配;也可分别用 `$deploy`/`/deploy` 显式调用),也可在仓库根目录直接跑脚本: ```bash bash .claude/skills/deploy/scripts/deploy.sh all # 前后端一起(默认) @@ -47,7 +49,7 @@ ssh la 'systemctl stop proxy-station && install -m 644 -o proxy-station -g proxy ## 反向代理注意点 - 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`,续期后不会重新部署证书)。 diff --git a/docs/SEEDING.md b/docs/SEEDING.md index d136941..55b1997 100644 --- a/docs/SEEDING.md +++ b/docs/SEEDING.md @@ -1,21 +1,34 @@ # 节点导入指引 -proxy-station 不直接读取 edge-nodes 工作区(那里全是真实凭据,保持解耦),节点通过 Web 界面批量粘贴分享链接导入。 +节点部署管理已迁入本仓库的 `edge-nodes/`,真实配置与交付产物位于 Git 忽略的 `edge-nodes/private/`。Proxy Station 应用运行时不自动读取该目录,节点仍通过 Web 界面批量粘贴分享链接导入。工作区说明见 [edge-nodes/README.md](../edge-nodes/README.md)。 -## 从 edge-nodes 交付文档导入 +## 从本仓库私有交付文档导入 -每个节点目录 `-edge-node-docs/` 已生成好分享链接: +每个节点目录 `edge-nodes/private/-edge-node-docs/` 已生成好分享链接;以当前部署的 `scripts/node-links/` 为准,避免同时混入旧交付链接与重复副本。RN 当前使用 `rn2-edge-node-docs/`,旧 `rn-edge-node-docs/` 只作历史记录。 -- `-.link`——VMess/Trojan 的分享链接文件(`vmess://`、`trojan://`) -- Shadowsocks 线路没有通用分享链接,用 sing-box JSON(`ss-*-singbox.json`)里的参数手动添加,或按 SIP002 + v2ray-plugin 格式自拼: +- `-.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 ## 命名建议(影响地区组归类) 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..31a4cc5 --- /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)。 + +实际订阅通过 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..b477941 --- /dev/null +++ b/docs/SURGE-CHANGES.md @@ -0,0 +1,33 @@ +# Surge 订阅变更与验证 + +2026-10-03 的缺陷与证据见 [体检报告](SURGE-AUDIT-2026-10-03.md)。后续节点、组、规则和设置变更使用本文件的检查;具体 API 与生产备份操作见 update-db 技能。 + +## 写入前 + +- 获取最新全量节点、组、规则和设置,明确改动对象及受影响订阅范围。保留策略选择和出口语义;名称正则、节点范围、客户端排除不能只按数据库行数检查。 +- 节点/组名称不能包含配置分隔符、控制字符或与内置策略及另一类实体冲突。导入预览先验证字段;组更新替换完整 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 跳过;有疑点时查看设备资源加载警告及请求匹配日志。 + +生产变更先在线备份,经管理 API 逐条核对状态,失败停止;在新代码防护部署后再写需要新防护的数据。写入后重读变更对象、核对数量与完整顺序,从实际 HTTPS 入口拉订阅并核对 MANAGED-CONFIG 的 self URL,然后重复以上校验。通过回环 API 拉的订阅可能带内网 self URL,不能拿它替代实际 HTTPS 验收。配置中的 token、密码、UUID、Host IP 不落仓库或共享日志。 + +Clash 受影响时,用独立临时目录运行 `mihomo -t -d <临时目录> -f <配置>`;不要覆盖正在使用的配置或启动代理服务。没有客户端 CLI 的产物用对应单测和结构检查,明确尚缺的真机验收。 + +不主动切换活动 Surge 配置、修改保存的组选择或导入 CA。CLI 成功只证明解析;节点连接、测速结果、DNS/WebRTC 和业务连接仍需设备侧验证。 + +依据:[匹配与 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..060dc8a --- /dev/null +++ b/docs/SURGE-FIX-2026-10-03.md @@ -0,0 +1,54 @@ +# Surge 订阅修复与验证:2026-10-03 + +本次完成 [体检报告](SURGE-AUDIT-2026-10-03.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..c777898 100644 --- a/docs/SURGE-PRIVACY.md +++ b/docs/SURGE-PRIVACY.md @@ -12,13 +12,13 @@ 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 组。 本功能控制 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..b43b1ac --- /dev/null +++ b/edge-nodes/README.md @@ -0,0 +1,80 @@ +# 边缘节点工作区 + +节点部署管理与 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 节点导入来源。 + +## 日常工作 + +在仓库根目录启动 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 6751c6b..f41f4d8 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 / 网络 --- @@ -47,7 +43,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] @@ -113,6 +109,7 @@ Speedtest = select, Mainland, Automatic, AllServer, no-alert=0, hidden=0, includ [Rule] # 阻断 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 @@ -121,7 +118,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 @@ -222,7 +219,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 @@ -298,7 +294,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/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/protocols.ts b/shared/src/protocols.ts index 2efffad..0abd267 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,11 @@ 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}` }; + } + } return { ok: true }; } diff --git a/shared/src/rule-syntax.ts b/shared/src/rule-syntax.ts new file mode 100644 index 0000000..b254c07 --- /dev/null +++ b/shared/src/rule-syntax.ts @@ -0,0 +1,40 @@ +/** Split structural commas while preserving quoted values and nested expressions. */ +function split(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 = split(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 = split(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 {