feat: add telegram rules
This commit is contained in:
1
.agents/skills/deploy
Symbolic link
1
.agents/skills/deploy
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.claude/skills/deploy
|
||||
1
.agents/skills/edge-node-multi-protocol
Symbolic link
1
.agents/skills/edge-node-multi-protocol
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.claude/skills/edge-node-multi-protocol
|
||||
1
.agents/skills/update-db
Symbolic link
1
.agents/skills/update-db
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.claude/skills/update-db
|
||||
@@ -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 # 只推后端源码,
|
||||
|
||||
- 最后一行 `部署完成:<目标> @ <git rev>` 才算成功。把这一行连同校验段落转述给用户。
|
||||
- 输出里出现 `(工作区有未提交改动)` 时要提醒用户:线上跑的是未提交的代码,之后应当提交或有意识地保持。
|
||||
- 生成器或 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` 与环境变量指向它。
|
||||
|
||||
6
.claude/skills/edge-node-multi-protocol/.gitignore
vendored
Normal file
6
.claude/skills/edge-node-multi-protocol/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
scripts/config.env
|
||||
scripts/config.env.*
|
||||
!scripts/config.env.example
|
||||
scripts/node-links/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
35
.claude/skills/edge-node-multi-protocol/SKILL.md
Normal file
35
.claude/skills/edge-node-multi-protocol/SKILL.md
Normal file
@@ -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/<id>-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` 校验实际订阅。报告已验证结果与尚未做的真机验证。
|
||||
@@ -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 `<cover>:443` over
|
||||
TLS instead of the useless internal `127.0.0.1:<port>` — 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": "<uuid>", "email": "node-main", "enable": true, "subId": "<sub-id>", "alterId": 0}], "disableInsecureEncryption": false}
|
||||
// trojan
|
||||
{"clients": [{"password": "<NODE_PASSWORD>", "email": "node-main", "enable": true, "subId": "<sub-id>"}], "fallbacks": []}
|
||||
// shadowsocks — note the TOP-LEVEL "password" (prevents the v3.5.0 link-gen panic, see pitfalls.md)
|
||||
{"clients": [{"password": "<NODE_PASSWORD>", "method": "chacha20-poly1305", "email": "node-main", "enable": true, "subId": "<sub-id>"}], "method": "chacha20-poly1305", "password": "<NODE_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": "<uuid>", "email": "node-main", "alterId": 0}
|
||||
// trojan client
|
||||
{"password": "<NODE_PASSWORD>", "email": "node-main"}
|
||||
// shadowsocks client
|
||||
{"password": "<NODE_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_<PROTO>_{DIRECT,WARP}` /
|
||||
`WS_PATH_<PROTO>_{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/<sub_id>`) 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.<root>`) → `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/<id>` **on the box** first (200 there = the
|
||||
service and links are fine, the problem is purely how it's exposed).
|
||||
@@ -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='<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=(
|
||||
"<nodeA_ip>:root:nodeA.example.com,<cover>.nodeA.example.com,<xui>.nodeA.example.com,<panel>.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.
|
||||
@@ -0,0 +1,45 @@
|
||||
# DNS records this node needs (any provider)
|
||||
|
||||
The wildcard cert (`03-sign-wildcard-cert.sh`) proves domain control via **DNS-01** using
|
||||
whatever `ACME_DNS_PLUGIN` you set — acme.sh supports Cloudflare, DNSPod, Aliyun, Route53,
|
||||
GoDaddy, and dozens more. That part is provider-agnostic.
|
||||
|
||||
The only thing that is *not* automated for non-Cloudflare providers is creating the host
|
||||
**A records**. The current fleet is IPv4-only: keep `SERVER_IPV6` empty and do not create
|
||||
AAAA records. Create these two names in your DNS provider's console/API:
|
||||
|
||||
| Name | Type | Value | Proxy/CDN |
|
||||
|---|---|---|---|
|
||||
| `<DOMAIN_ROOT>` (e.g. `nodeA.example.com`) | A | `SERVER_IP` | **off** (DNS-only / grey cloud) |
|
||||
| `*.<DOMAIN_ROOT>` | A | `SERVER_IP` | **off** |
|
||||
|
||||
Notes:
|
||||
- Keep them **unproxied** — the node terminates its own TLS and needs the real client IP path; a proxy/CDN in front would break the WS cover and the panel anti-scan logic.
|
||||
- TTL 300 is fine.
|
||||
- The wildcard `*.<DOMAIN_ROOT>` covers the landing / cover / panel subdomains, so you don't add per-subdomain records.
|
||||
|
||||
## Cloudflare shortcut
|
||||
|
||||
If your zone is on Cloudflare, skip the manual step — `02-cf-dns.py` upserts the two A
|
||||
records when `SERVER_IPV6` is empty (`CF_API_TOKEN` with `Zone:DNS:Edit`). Run it from wherever the
|
||||
token lives; it never touches the VPS.
|
||||
|
||||
Leaving `SERVER_IPV6` empty does **not** delete existing AAAA records. For an existing
|
||||
node, back up DNS records, remove the AAAA records under that node's domain, then check
|
||||
the API and public DNS. See [IPv4-only fleet policy](../../../../docs/EDGE-IPV4-ONLY.md).
|
||||
|
||||
## Picking the acme.sh plugin + creds
|
||||
|
||||
Set `ACME_DNS_PLUGIN` and the matching credential vars in `config.env` (acme.sh reads them
|
||||
from the environment; `03-sign-wildcard-cert.sh` forwards whichever are set):
|
||||
|
||||
| Provider | `ACME_DNS_PLUGIN` | env vars |
|
||||
|---|---|---|
|
||||
| Cloudflare | `dns_cf` | `CF_API_TOKEN` (forwarded as `CF_Token`) |
|
||||
| DNSPod | `dns_dp` | `DP_Id`, `DP_Key` |
|
||||
| Aliyun | `dns_ali` | `Ali_Key`, `Ali_Secret` |
|
||||
| AWS Route53 | `dns_aws` | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` |
|
||||
| GoDaddy | `dns_gd` | `GD_Key`, `GD_Secret` |
|
||||
| others | see acme.sh dnsapi | per its README |
|
||||
|
||||
Full list: <https://github.com/acmesh-official/acme.sh/wiki/dnsapi>
|
||||
@@ -0,0 +1,53 @@
|
||||
# Pitfalls (read before you run)
|
||||
|
||||
Every item here cost real debugging time on a live deployment. They are ordered by how likely they are to bite you.
|
||||
|
||||
## aaPanel
|
||||
|
||||
- **Never `systemctl restart btpanel`.** `btpanel.service` is `enabled` but its `ExecStart` is `/etc/init.d/bt start` (SysV); the systemd unit shows `active: inactive (dead)` while the panel process (`webserver`) runs fine. `systemctl restart btpanel` stops the panel and it does **not** come back → your reverse-proxied panel returns **502**. To restart use `bt 1` or `/etc/init.d/bt start`. Same for nginx (`/etc/init.d/nginx`, not `systemctl`).
|
||||
- **Reverse-proxy needs `CDN_PROXY=True`** in `/www/server/panel/config/cdn.conf`, or the panel rejects proxied requests. Restart the panel after setting it.
|
||||
- **Anti-scan fake 404/403 — the UA has to look like a REAL browser, not just contain the word "Mozilla".** aaPanel returns a *fake* nginx 404 to requests it judges to be scanners. On aaPanel 7.x this check got noticeably stricter: a minimal UA like `Mozilla/5.0 (Macintosh)` (which used to be enough) now gets judged as a scanner and served the fake 404 — even hitting `/login` or `/` directly on the box over the unix socket gives the same fake 404 with that UA. Use a full, realistic UA string, e.g. `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/605.1.15`, and it works immediately. **If the entrance/domain/path all test as 404 no matter what you try, check the UA string completeness FIRST** — before you start doubting `CDN_PROXY`, domain binding, or the cert. This one costs the most debugging time of anything in this file. A real browser was never affected either way.
|
||||
- Also **403** on direct `IP:port` access once a panel domain is bound — that one's real and expected, not a bug.
|
||||
- **aaPanel 7.x moved several backend modules into a new `class_v2` directory** (e.g. `theme_config.py`). Scripts that do `sys.path.insert(0, "/www/server/panel/class")` and then `import panelSite` (or anything that transitively imports `common.py` → `theme_config`) will fail with `ModuleNotFoundError: theme_config` unless `/www/server/panel/class_v2` is *also* on `sys.path`. `09-aapanel-sites.py` already does this; if you hand-write a new script that touches panel internals, add the same `sys.path.insert(0, "/www/server/panel/class_v2")` line.
|
||||
- **Build sites through the panel, never hand-write vhosts.** A hand-written `/www/server/panel/vhost/nginx/*.conf` works in nginx but is invisible/unmanageable in the panel UI. Use the panel classes (`AddSite`/`SetSSL`/`CreateProxy`/`HttpToHttps`) — that's what `09-aapanel-sites.py` does. The panel HTTP API can't be driven by curl (CSRF + anti-scan); drive the classes with the panel's bundled `pyenv` python.
|
||||
- **nginx 1.24 uses `listen 443 ssl http2;`** — it does **not** support the newer `http2 on;` directive; that syntax fails `nginx -t`.
|
||||
- **The aaPanel install-wait loop must not use `pgrep -f install_7.0` to detect "still installing".** A command like `until run 'test -f BT-Panel && ! pgrep -f install_7.0'; do sleep 15; done` has a self-match bug: the check command's own argv literally contains the string `"install_7.0"`, so `pgrep -f` matches the check itself every time, and the condition can never go true — the loop spins forever even though the install finished in ~1 minute. `04-install-aapanel.sh` instead waits for the literal string `Congratulations` in the install log plus the `BT-Panel` binary existing. If you ever add a similar poll-by-pgrep check, make sure the pattern you grep for doesn't appear in the checking command's own command line (the classic fix elsewhere is `pgrep -f '[i]nstall_7.0'` — the bracket trick — but matching a log string is simpler and was chosen here).
|
||||
|
||||
## 3x-ui (newer versions, Xray 26.x) — multi-protocol notes
|
||||
|
||||
- **Keep `inbounds.settings.clients` populated.** The normalized `clients` and `client_inbounds` tables are projections. Upgrades/migrations rebuild them from settings JSON; a table-only deployment can lose all credentials. The migrated `08-xui-bootstrap.py` includes this fix; repair older nodes with `08b-embed-settings-clients.py` on the VPS after a database backup.
|
||||
|
||||
- **One client owns all six inbounds — don't create one client per protocol.** The whole point of the single-client design is that the panel's Clients page shows ONE row ("node-main") whose Info dialog lists all six share links + QR codes and a single subscription covering all six. That works because the `clients` table is protocol-generic (next bullet): one row carrying both a `uuid` and a `password`, linked via `client_inbounds` to all six inbounds. `08-xui-bootstrap.py` does exactly this. (An earlier iteration created three separate clients — node-main / trojan-main / ss-main — which "worked" for traffic but split the links across three panel rows and made the single-subscription view impossible; consolidating them later meant deleting two clients and re-pointing credentials. Build it as one client from the start.)
|
||||
- **The `clients` table is protocol-generic, not protocol-specific.** It has both a `uuid` column and a `password` column (plus VMess/VLESS-only fields like `security`/`flow`). VMess/VLESS inbounds read `uuid`; Trojan/Shadowsocks inbounds read `password`. There's no "protocol" column on `clients` itself — which field 3x-ui's backend actually uses depends on the *inbound's* `protocol` value at the point it builds the live xray config, not on anything stored on the client row. This isn't documented anywhere obvious; it was confirmed by reading the live generated `/usr/local/x-ui/bin/config.json` after inserting rows and restarting `x-ui`, and seeing the right credential land in the right field for each protocol. **Consequence:** with one client, Trojan and Shadowsocks share ONE password (that client's `password` column); VMess uses its `uuid`. That's intended for a single-user node — it's what `NODE_PASSWORD` in `config.env` is.
|
||||
- **`user_id` on an inbound must be set (1), or the inbound is INVISIBLE in the panel.** The panel's Inbounds and Clients lists filter by the logged-in admin's `user_id`. A DB-inserted inbound with a NULL/empty `user_id` exists in the DB and proxies traffic perfectly (xray loads it, smoke test passes), but the panel UI silently filters it out — you'll see "only the vmess inbounds show up" even though all six are there. `08-xui-bootstrap.py` sets `user_id=1` on every inserted inbound. If you hand-insert an inbound later, set it too (or `UPDATE inbounds SET user_id=1 WHERE user_id IS NULL OR user_id=''`).
|
||||
- **Every inbound needs an `externalProxy` in streamSettings, or the generated share links are useless.** These inbounds listen on `127.0.0.1` with `security:"none"` (nginx terminates TLS at the edge on `<cover>:443`). 3x-ui doesn't know the public address, so by default it generates links pointing at `127.0.0.1:<internal port>` with no TLS — which no client can use. Add `"externalProxy":[{"forceTls":"tls","dest":"<cover host>","port":443,"sni":"<cover host>","remark":"<name>"}]` to each inbound's streamSettings and every generated link/QR/subscription instead points at `<cover>: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/<subId>` 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:<port>` fallback form) need to be appended to the *existing* rule's `inboundTag` array. Re-generating and writing the whole `xrayTemplateConfig` from scratch (instead of read-modify-write) silently deletes whatever protocols were added in a previous run.
|
||||
- **Auth is by UUID/password**, so a mismatch between the traffic-stat email label and the running config email is cosmetic, not a fault.
|
||||
- **Protocol mismatch when testing.** A VLESS test client against a `vmess+ws` inbound is correctly rejected (`websocket close 1000`) — that's not a server bug, it's the wrong protocol. Match the test client's protocol (vmess/trojan/shadowsocks) exactly to the inbound you're testing, with the right `security`/`method`/`password` for that protocol.
|
||||
- **Panel API `/login` may 403** on newer builds (security middleware). Do DB-direct edits + `systemctl restart x-ui` instead of the HTTP API — confirmed true both for the original VMess bootstrap and again when adding Trojan/Shadowsocks later.
|
||||
- **Shadowsocks 2022-blake3-\* ciphers need an exact-byte-length base64 password/PSK** — get the length wrong and xray fails to start with no obvious error pointing at the cipher. `chacha20-poly1305` (or `aes-256-gcm`) takes a plain password string, is much harder to misconfigure, and is still a modern AEAD cipher — prefer it unless you have a specific reason to use the 2022 series.
|
||||
|
||||
## Cloudflare WARP
|
||||
|
||||
- **Disabling host IPv6 does not disable IPv6 inside WARP's proxy tunnel.** Keep the IPv4-only guards in Xray and sing-box: force IPv4 domain resolution and reject `::/0` before WARP routing. `UseIPv4` can fall back to system resolution in Xray; use `ForceIPv4`. Do not pass unresolved target domains to WARP. See [fleet policy](../../../../docs/EDGE-IPV4-ONLY.md).
|
||||
- **`gpg` must be installed first.** On a minimal Debian, `gpg --dearmor` for the repo keyring fails with `gpg: command not found`, and the later `apt update` then errors `Failed to parse keyring … sqv returned an error code`. `apt-get install -y gnupg` before adding the repo.
|
||||
- Use the **`bookworm`** repo line even on Debian 13 (trixie) — the bookworm package works.
|
||||
- **WARP can lie about being connected.** The control plane shows `Connected` while the SOCKS proxy stops egressing. The watchdog (`warp-healthcheck.*`) checks `warp=on` *through the proxy* and restarts `warp-svc` when it isn't.
|
||||
|
||||
## nginx / cover
|
||||
|
||||
- WS upgrade must return **101** with `--http1.1` + the `Upgrade`/`Connection` headers. If you test with HTTP/2 you'll get 400 (HTTP/2 doesn't carry the Upgrade frame).
|
||||
- Default server for unmatched `server_name` should `return 444` so scanners hitting the bare IP / wrong host get dropped.
|
||||
|
||||
## Monitoring
|
||||
|
||||
- **Komari** auto-discovery registers the node with an auto-generated name (`Auto-<hostname>`). 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.
|
||||
@@ -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.
|
||||
@@ -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/<root>/` |
|
||||
| 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/<root>/`。
|
||||
|
||||
该脚本的续期动作面向裸 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 可导入类型。
|
||||
|
||||
这里保存的是已有部署工具的流程说明。涉及新版本安装或升级时,先核对官方版本兼容性,再在授权范围执行;本次工程迁移没有对生产节点执行这些脚本。
|
||||
175
.claude/skills/edge-node-multi-protocol/references/workflow.md
Normal file
175
.claude/skills/edge-node-multi-protocol/references/workflow.md
Normal file
@@ -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 | `<SUB_PANEL>.<root>` reverse-proxied, secret-path entrance, hardened |
|
||||
| Proxy | 3x-ui (Xray) | `<SUB_XUI>.<root>` 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 `*.<root>` cert, auto-renew + reload, shared by all 6 inbounds |
|
||||
| DNS | Cloudflare API (or your provider, by hand) | `<root>` + `*.<root>` 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_<PROTO>_{DIRECT,WARP}` / `WS_PATH_<PROTO>_{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 <root> + *.<root> 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 *.<root> (ECDSA P-256, Let's Encrypt)
|
||||
```
|
||||
One cert, shared by all 6 inbounds via the configured `<SUB_COVER>.<root>` 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:<port>`), 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 |
|
||||
| `<SUB_COVER>.nodeA.example.com` | node cover: 6 WS paths, one per inbound | 127.0.0.1:57707-57712 |
|
||||
| `<SUB_XUI>.nodeA.example.com` | 3x-ui panel | 127.0.0.1:14533 |
|
||||
| `<SUB_PANEL>.nodeA.example.com` | aaPanel | 127.0.0.1:\<panel port\> |
|
||||
| `*.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`.
|
||||
61
.claude/skills/edge-node-multi-protocol/scripts/00-preflight-sg.sh
Executable file
61
.claude/skills/edge-node-multi-protocol/scripts/00-preflight-sg.sh
Executable file
@@ -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 <<CONF
|
||||
net.core.default_qdisc = fq
|
||||
net.ipv4.tcp_congestion_control = bbr
|
||||
CONF
|
||||
sysctl --system >/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"
|
||||
24
.claude/skills/edge-node-multi-protocol/scripts/00-preflight.sh
Executable file
24
.claude/skills/edge-node-multi-protocol/scripts/00-preflight.sh
Executable file
@@ -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"
|
||||
72
.claude/skills/edge-node-multi-protocol/scripts/02-cf-dns.py
Executable file
72
.claude/skills/edge-node-multi-protocol/scripts/02-cf-dns.py
Executable file
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Upsert <DOMAIN_ROOT> + *.<DOMAIN_ROOT> 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)
|
||||
27
.claude/skills/edge-node-multi-protocol/scripts/03-sign-cert-sg.sh
Executable file
27
.claude/skills/edge-node-multi-protocol/scripts/03-sign-cert-sg.sh
Executable file
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
# Issue a wildcard cert for *.<DOMAIN_ROOT> via acme.sh DNS-01 and install it into
|
||||
# a STANDALONE nginx cert dir (/etc/nginx/ssl/<DOMAIN_ROOT>/), 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"
|
||||
37
.claude/skills/edge-node-multi-protocol/scripts/03-sign-wildcard-cert.sh
Executable file
37
.claude/skills/edge-node-multi-protocol/scripts/03-sign-wildcard-cert.sh
Executable file
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
# Issue a wildcard cert for *.<DOMAIN_ROOT> 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"
|
||||
15
.claude/skills/edge-node-multi-protocol/scripts/04-install-aapanel.sh
Executable file
15
.claude/skills/edge-node-multi-protocol/scripts/04-install-aapanel.sh
Executable file
@@ -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'
|
||||
8
.claude/skills/edge-node-multi-protocol/scripts/04b-install-nginx.sh
Executable file
8
.claude/skills/edge-node-multi-protocol/scripts/04b-install-nginx.sh
Executable file
@@ -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'
|
||||
20
.claude/skills/edge-node-multi-protocol/scripts/05-harden-aapanel.sh
Executable file
20
.claude/skills/edge-node-multi-protocol/scripts/05-harden-aapanel.sh
Executable file
@@ -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'."
|
||||
18
.claude/skills/edge-node-multi-protocol/scripts/06-install-warp.sh
Executable file
18
.claude/skills/edge-node-multi-protocol/scripts/06-install-warp.sh
Executable file
@@ -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='"
|
||||
13
.claude/skills/edge-node-multi-protocol/scripts/06b-warp-watchdog.sh
Executable file
13
.claude/skills/edge-node-multi-protocol/scripts/06b-warp-watchdog.sh
Executable file
@@ -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'
|
||||
13
.claude/skills/edge-node-multi-protocol/scripts/07-install-3xui.sh
Executable file
13
.claude/skills/edge-node-multi-protocol/scripts/07-install-3xui.sh
Executable file
@@ -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'"
|
||||
295
.claude/skills/edge-node-multi-protocol/scripts/08-xui-bootstrap.py
Executable file
295
.claude/skills/edge-node-multi-protocol/scripts/08-xui-bootstrap.py
Executable file
@@ -0,0 +1,295 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Runs ON the box, editing 3x-ui's own sqlite DB directly (the panel HTTP API
|
||||
403s scripted requests on the builds this targets -- see references/pitfalls.md).
|
||||
|
||||
Builds the standard target of this skill in ONE pass: SIX inbounds (vmess,
|
||||
trojan, shadowsocks x direct-egress + WARP-clean-exit) all owned by ONE client
|
||||
"node-main", so the panel's Clients page shows a single row whose Info dialog
|
||||
lists all six share links / QR codes and one subscription that carries all six.
|
||||
|
||||
Why a single client instead of one-per-protocol
|
||||
------------------------------------------------
|
||||
3x-ui's `clients` table is protocol-generic: one client row carries BOTH a
|
||||
`uuid` (used by vmess/vless inbounds) AND a `password` (used by trojan/
|
||||
shadowsocks inbounds). Which field an inbound reads is decided by that
|
||||
*inbound's* protocol when 3x-ui builds the live xray config -- not by anything
|
||||
on the client row. So one node-main row linked (via client_inbounds) to all six
|
||||
inbounds authenticates vmess by its uuid and trojan+ss by its (shared)
|
||||
password. Verified by reading the generated /usr/local/x-ui/bin/config.json.
|
||||
Trojan and Shadowsocks therefore share ONE password (node-main's); vmess uses
|
||||
the uuid. That's intended -- a single-user self node -- and is what makes the
|
||||
"one client, six links" panel view work.
|
||||
|
||||
Four version-compatibility fixes are baked in here (all cost real debugging on
|
||||
a live v3.5.0 box; see references/pitfalls.md for the full story):
|
||||
|
||||
1. user_id=1 on every inbound. The panel Clients/Inbounds lists filter by the
|
||||
logged-in admin's user_id; an inbound with a NULL/empty user_id exists and
|
||||
proxies traffic fine but is INVISIBLE in the UI.
|
||||
|
||||
2. externalProxy on every inbound's streamSettings. These inbounds listen on
|
||||
127.0.0.1 with security:none (nginx terminates TLS at the edge), so without
|
||||
a hint 3x-ui generates useless share links pointing at 127.0.0.1:<internal
|
||||
port> with no TLS. An externalProxy entry {forceTls:"tls", dest:<cover>,
|
||||
port:443, sni:<cover>} makes every generated link/QR/subscription point at
|
||||
<cover>: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.<DOMAIN_ROOT> -- the public cover host for every link
|
||||
|
||||
XUI_UUID = os.environ["XUI_UUID"] # vmess credential on node-main
|
||||
XUI_SUBID = os.environ.get("XUI_SUBID", "")
|
||||
NODE_PASSWORD = os.environ["NODE_PASSWORD"] # shared trojan+ss credential on node-main
|
||||
SS_METHOD = os.environ.get("SS_METHOD", "chacha20-poly1305")
|
||||
|
||||
EMAIL = "node-main"
|
||||
|
||||
# (protocol, direct port, warp port, direct path, warp path)
|
||||
PROTOCOLS = [
|
||||
("vmess", int(os.environ["PORT_VMESS_DIRECT"]), int(os.environ["PORT_VMESS_WARP"]),
|
||||
os.environ["WS_PATH_VMESS_DIRECT"], os.environ["WS_PATH_VMESS_WARP"]),
|
||||
("trojan", int(os.environ["PORT_TROJAN_DIRECT"]), int(os.environ["PORT_TROJAN_WARP"]),
|
||||
os.environ["WS_PATH_TROJAN_DIRECT"], os.environ["WS_PATH_TROJAN_WARP"]),
|
||||
("shadowsocks", int(os.environ["PORT_SS_DIRECT"]), int(os.environ["PORT_SS_WARP"]),
|
||||
os.environ["WS_PATH_SS_DIRECT"], os.environ["WS_PATH_SS_WARP"]),
|
||||
]
|
||||
|
||||
TEMPLATE = {
|
||||
"log": {"access": "/var/log/v2ray/access.log", "error": "/var/log/v2ray/error.log", "loglevel": "warning"},
|
||||
"api": {"services": ["HandlerService", "LoggerService", "StatsService"], "tag": "api"},
|
||||
"inbounds": [{"listen": "127.0.0.1", "port": 62789, "protocol": "dokodemo-door",
|
||||
"settings": {"address": "127.0.0.1"}, "tag": "api"}],
|
||||
"outbounds": [
|
||||
{"protocol": "freedom", "settings": {"domainStrategy": "ForceIPv4"},
|
||||
"streamSettings": {"sockopt": {"domainStrategy": "ForceIPv4"}}},
|
||||
{"tag": "warp-outbound", "protocol": "freedom",
|
||||
"settings": {"domainStrategy": "ForceIPv4"},
|
||||
"streamSettings": {"sockopt": {"domainStrategy": "ForceIPv4"}},
|
||||
"proxySettings": {"tag": "warp-socks"}},
|
||||
{"tag": "warp-socks", "protocol": "socks",
|
||||
"settings": {"servers": [{"address": "127.0.0.1", "port": WARP_SOCKS_PORT}]}},
|
||||
{"protocol": "blackhole", "settings": {}, "tag": "blocked"},
|
||||
],
|
||||
"policy": {"system": {"statsInboundDownlink": True, "statsInboundUplink": True}},
|
||||
"routing": {"rules": [
|
||||
{"ip": ["::/0"], "outboundTag": "blocked", "type": "field"},
|
||||
{"inboundTag": ["api"], "outboundTag": "api", "type": "field"},
|
||||
{"inboundTag": [], "outboundTag": "warp-outbound", "type": "field"}, # tags filled in below
|
||||
{"ip": ["geoip:private"], "outboundTag": "blocked", "type": "field"},
|
||||
{"protocol": ["bittorrent"], "outboundTag": "blocked", "type": "field"},
|
||||
]},
|
||||
"stats": {},
|
||||
"dns": {"servers": ["1.1.1.1"], "queryStrategy": "UseIPv4"},
|
||||
}
|
||||
|
||||
|
||||
def cols(cur, table):
|
||||
return {r[1] for r in cur.execute("PRAGMA table_info(%s)" % table).fetchall()}
|
||||
|
||||
|
||||
def has_table(cur, name):
|
||||
return cur.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (name,)).fetchone() is not None
|
||||
|
||||
|
||||
def stream_settings(path, remark):
|
||||
# security:"none" -- nginx already terminates TLS at the edge and reverse-proxies
|
||||
# plain HTTP to this inbound; don't handshake TLS a second time here.
|
||||
# externalProxy -> share links point at the public cover host:443 over TLS (fix #2).
|
||||
return json.dumps({
|
||||
"network": "ws", "security": "none",
|
||||
"wsSettings": {"path": path, "headers": {}},
|
||||
"externalProxy": [{"forceTls": "tls", "dest": COVER, "port": 443, "sni": COVER, "remark": remark}],
|
||||
})
|
||||
|
||||
|
||||
def embedded_client(protocol, now):
|
||||
"""One settings.clients entry (fix #5).
|
||||
|
||||
ALWAYS embed, on every schema. settings.clients is 3x-ui's authoritative copy
|
||||
of the credentials; clients/client_inbounds are a derived projection of it.
|
||||
See fix #5 in the module docstring for why leaving this empty bricks the node
|
||||
on the next upgrade.
|
||||
|
||||
Field semantics (client_link.go applyClientRecordMerge):
|
||||
* non-empty-only overwrite: id / password / security -> a vmess entry may
|
||||
carry just `id` and a trojan/ss entry just `password`; merging the six
|
||||
inbounds for one email yields the union rather than clobbering.
|
||||
* unconditional overwrite: enable / subId / limitIp / totalGB / expiryTime
|
||||
/ tgId / comment / reset / flow -> must be IDENTICAL in all six entries.
|
||||
* `enable` must be explicit: omitting it unmarshals to false, and xray.go
|
||||
then skips the client on EVERY inbound.
|
||||
"""
|
||||
c = {
|
||||
"email": EMAIL, "enable": True, "subId": XUI_SUBID, "flow": "",
|
||||
"limitIp": 0, "totalGB": 0, "expiryTime": 0, "tgId": 0,
|
||||
"comment": "", "reset": 0, "created_at": now, "updated_at": now,
|
||||
}
|
||||
if protocol == "vmess":
|
||||
c.update({"id": XUI_UUID, "security": "auto", "alterId": 0})
|
||||
elif protocol == "trojan":
|
||||
c["password"] = NODE_PASSWORD
|
||||
elif protocol == "shadowsocks":
|
||||
c.update({"password": NODE_PASSWORD, "method": SS_METHOD})
|
||||
else:
|
||||
raise ValueError(protocol)
|
||||
return c
|
||||
|
||||
|
||||
def inbound_settings(protocol, now):
|
||||
clients = [embedded_client(protocol, now)]
|
||||
if protocol == "vmess":
|
||||
return json.dumps({"clients": clients, "disableInsecureEncryption": False})
|
||||
if protocol == "trojan":
|
||||
return json.dumps({"clients": clients, "fallbacks": []})
|
||||
if protocol == "shadowsocks":
|
||||
# top-level "password" prevents the v3.5.0 link-gen panic (fix #3); auth is per-client.
|
||||
return json.dumps({"clients": clients, "method": SS_METHOD, "password": NODE_PASSWORD, "network": "tcp,udp"})
|
||||
raise ValueError(protocol)
|
||||
|
||||
|
||||
def main():
|
||||
conn = sqlite3.connect(DB)
|
||||
cur = conn.cursor()
|
||||
ic = cols(cur, "inbounds")
|
||||
new_schema = has_table(cur, "clients") and has_table(cur, "client_inbounds")
|
||||
existing_ports = {r[0] for r in cur.execute("SELECT port FROM inbounds").fetchall()}
|
||||
now = int(time.time() * 1000)
|
||||
|
||||
warp_tags = []
|
||||
inbound_ids = {}
|
||||
|
||||
for protocol, port_direct, port_warp, path_direct, path_warp in PROTOCOLS:
|
||||
for suffix, port, path in (("direct", port_direct, path_direct), ("warp", port_warp, path_warp)):
|
||||
tag = "inbound-%s-%s" % (protocol, suffix)
|
||||
remark = "%s-%s" % (protocol, suffix)
|
||||
if port in existing_ports:
|
||||
print(" inbound :%d (%s-%s) exists, skip" % (port, protocol, suffix))
|
||||
inbound_ids[tag] = cur.execute("SELECT id FROM inbounds WHERE port=?", (port,)).fetchone()[0]
|
||||
else:
|
||||
row = {
|
||||
"user_id": 1, # fix #1: NULL user_id -> invisible in panel lists
|
||||
"up": 0, "down": 0, "total": 0, "remark": remark,
|
||||
"enable": 1, "expiry_time": 0, "listen": "127.0.0.1", "port": port, "protocol": protocol,
|
||||
"settings": inbound_settings(protocol, now),
|
||||
"stream_settings": stream_settings(path, remark),
|
||||
"sniffing": json.dumps({"enabled": True, "destOverride": ["http", "tls"]}),
|
||||
"tag": tag,
|
||||
}
|
||||
keys = [k for k in row if k in ic]
|
||||
cur.execute("INSERT INTO inbounds (%s) VALUES (%s)" % (",".join(keys), ",".join("?" * len(keys))),
|
||||
[row[k] for k in keys])
|
||||
inbound_ids[tag] = cur.lastrowid
|
||||
print(" inbound :%d (%s-%s) %s added" % (port, protocol, suffix, path))
|
||||
if suffix == "warp":
|
||||
warp_tags += [tag, "inbound-127.0.0.1:%d" % port]
|
||||
|
||||
if new_schema:
|
||||
# One node-main client carrying BOTH uuid (vmess) and password (trojan+ss),
|
||||
# linked to all six inbounds. 3x-ui picks the right credential per inbound.
|
||||
row = cur.execute("SELECT id FROM clients WHERE email=?", (EMAIL,)).fetchone()
|
||||
if row:
|
||||
cid = row[0]
|
||||
cur.execute("UPDATE clients SET uuid=?, password=?, sub_id=?, security='auto', enable=1, updated_at=? "
|
||||
"WHERE id=?", (XUI_UUID, NODE_PASSWORD, XUI_SUBID, now, cid))
|
||||
else:
|
||||
cur.execute(
|
||||
"INSERT INTO clients (email,sub_id,uuid,password,auth,flow,security,enable,group_name,total_gb,"
|
||||
"expiry_time,limit_ip,reset,created_at,updated_at) VALUES (?,?,?,?,'','','auto',1,'',0,0,0,0,?,?)",
|
||||
(EMAIL, XUI_SUBID, XUI_UUID, NODE_PASSWORD, now, now),
|
||||
)
|
||||
cid = cur.lastrowid
|
||||
|
||||
for tag, iid in inbound_ids.items():
|
||||
if not cur.execute("SELECT 1 FROM client_inbounds WHERE client_id=? AND inbound_id=?",
|
||||
(cid, iid)).fetchone():
|
||||
cur.execute("INSERT INTO client_inbounds (client_id,inbound_id,flow_override,created_at) "
|
||||
"VALUES (?,?,?,?)", (cid, iid, "", now))
|
||||
|
||||
# client_traffics.email is GLOBALLY UNIQUE -> exactly one row for node-main.
|
||||
if not cur.execute("SELECT 1 FROM client_traffics WHERE email=?", (EMAIL,)).fetchone():
|
||||
any_iid = inbound_ids["inbound-vmess-direct"]
|
||||
cur.execute("INSERT INTO client_traffics (inbound_id,enable,email,up,down,expiry_time,total,reset) "
|
||||
"VALUES (?,1,?,0,0,0,0,0)", (any_iid, EMAIL))
|
||||
print(" node-main client linked to all 6 inbounds (uuid=vmess, password=trojan+ss)")
|
||||
|
||||
# WARP routing rule: read-modify-write (fix #4) -- append, never clobber.
|
||||
row = cur.execute("SELECT value FROM settings WHERE key='xrayTemplateConfig'").fetchone()
|
||||
tmpl = json.loads(row[0]) if row else TEMPLATE
|
||||
# Enforce the fleet's IPv4-only policy when extending an existing template too.
|
||||
for outbound in tmpl["outbounds"]:
|
||||
if outbound.get("protocol") == "freedom":
|
||||
outbound.setdefault("settings", {})["domainStrategy"] = "ForceIPv4"
|
||||
outbound.setdefault("streamSettings", {}).setdefault("sockopt", {})["domainStrategy"] = "ForceIPv4"
|
||||
tmpl.setdefault("dns", {})["queryStrategy"] = "UseIPv4"
|
||||
blocked_tag = next(o["tag"] for o in tmpl["outbounds"] if o.get("protocol") == "blackhole")
|
||||
ipv6_rule = {"ip": ["::/0"], "outboundTag": blocked_tag, "type": "field"}
|
||||
if ipv6_rule not in tmpl["routing"]["rules"]:
|
||||
tmpl["routing"]["rules"].insert(0, ipv6_rule)
|
||||
for rule in tmpl["routing"]["rules"]:
|
||||
if rule.get("outboundTag") == "warp-outbound":
|
||||
for t in warp_tags:
|
||||
if t not in rule["inboundTag"]:
|
||||
rule["inboundTag"].append(t)
|
||||
|
||||
cur.execute("UPDATE settings SET value=? WHERE key='xrayTemplateConfig'", (json.dumps(tmpl, ensure_ascii=False),))
|
||||
if cur.rowcount == 0:
|
||||
cur.execute("INSERT INTO settings (key, value) VALUES ('xrayTemplateConfig', ?)", (json.dumps(tmpl, ensure_ascii=False),))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print(" xrayTemplateConfig written, warp rule tags:",
|
||||
next(r["inboundTag"] for r in tmpl["routing"]["rules"] if r.get("outboundTag") == "warp-outbound"))
|
||||
|
||||
subprocess.run(["systemctl", "restart", "x-ui"], check=True)
|
||||
time.sleep(6)
|
||||
print("OK — 3x-ui bootstrap complete (6 inbounds under one node-main client: vmess/trojan/shadowsocks x direct/warp)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
24
.claude/skills/edge-node-multi-protocol/scripts/08-xui-bootstrap.sh
Executable file
24
.claude/skills/edge-node-multi-protocol/scripts/08-xui-bootstrap.sh
Executable file
@@ -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"
|
||||
157
.claude/skills/edge-node-multi-protocol/scripts/08b-embed-settings-clients.py
Executable file
157
.claude/skills/edge-node-multi-protocol/scripts/08b-embed-settings-clients.py
Executable file
@@ -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()
|
||||
77
.claude/skills/edge-node-multi-protocol/scripts/09-aapanel-sites.py
Executable file
77
.claude/skills/edge-node-multi-protocol/scripts/09-aapanel-sites.py
Executable file
@@ -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()
|
||||
16
.claude/skills/edge-node-multi-protocol/scripts/09-aapanel-sites.sh
Executable file
16
.claude/skills/edge-node-multi-protocol/scripts/09-aapanel-sites.sh
Executable file
@@ -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"
|
||||
115
.claude/skills/edge-node-multi-protocol/scripts/09-nginx-sites-sg.sh
Executable file
115
.claude/skills/edge-node-multi-protocol/scripts/09-nginx-sites-sg.sh
Executable file
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hand-written nginx vhosts (no aaPanel):
|
||||
# - landing : <DOMAIN_ROOT> -> static page
|
||||
# - cover : <SUB_COVER>.<root> -> 6 WS reverse proxies (one per inbound) + benign root
|
||||
# - xui panel : <SUB_XUI>.<root> -> 127.0.0.1:<XUI_PORT>
|
||||
# - 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 <<CONF
|
||||
map \\\$http_upgrade \\\$connection_upgrade { default upgrade; '' close; }
|
||||
|
||||
# HTTP -> 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"
|
||||
18
.claude/skills/edge-node-multi-protocol/scripts/09b-default-444.sh
Executable file
18
.claude/skills/edge-node-multi-protocol/scripts/09b-default-444.sh
Executable file
@@ -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"
|
||||
18
.claude/skills/edge-node-multi-protocol/scripts/10-ufw-rn.sh
Executable file
18
.claude/skills/edge-node-multi-protocol/scripts/10-ufw-rn.sh
Executable file
@@ -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'"
|
||||
9
.claude/skills/edge-node-multi-protocol/scripts/10-ufw-sg.sh
Executable file
9
.claude/skills/edge-node-multi-protocol/scripts/10-ufw-sg.sh
Executable file
@@ -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"'
|
||||
9
.claude/skills/edge-node-multi-protocol/scripts/10-ufw.sh
Executable file
9
.claude/skills/edge-node-multi-protocol/scripts/10-ufw.sh
Executable file
@@ -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"'
|
||||
56
.claude/skills/edge-node-multi-protocol/scripts/11-monitoring.sh
Executable file
56
.claude/skills/edge-node-multi-protocol/scripts/11-monitoring.sh
Executable file
@@ -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-<hostname>')."
|
||||
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 <<CFG
|
||||
client_secret: $NEZHA_SECRET
|
||||
server: $NEZHA_SERVER
|
||||
tls: ${NEZHA_TLS:-false}
|
||||
uuid: $NEZHA_UUID
|
||||
disable_auto_update: true
|
||||
disable_force_update: true
|
||||
CFG
|
||||
cat > /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
|
||||
128
.claude/skills/edge-node-multi-protocol/scripts/12-smoke-test-sg.sh
Executable file
128
.claude/skills/edge-node-multi-protocol/scripts/12-smoke-test-sg.sh
Executable file
@@ -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 - <<PY
|
||||
import json, sqlite3, sys
|
||||
PROXY = ("vmess", "vless", "trojan", "shadowsocks")
|
||||
bad = []
|
||||
con = sqlite3.connect("/etc/x-ui/x-ui.db")
|
||||
rows = con.execute("SELECT port,protocol,settings FROM inbounds ORDER BY port").fetchall()
|
||||
proxy_rows = [r for r in rows if r[1] in PROXY]
|
||||
for port, proto, s in proxy_rows:
|
||||
try:
|
||||
cl = (json.loads(s) or {}).get("clients")
|
||||
except (TypeError, ValueError):
|
||||
cl = None
|
||||
if not isinstance(cl, list) or not cl:
|
||||
bad.append("inbound :%s (%s) settings.clients is EMPTY" % (port, proto))
|
||||
links = con.execute("SELECT count(*) FROM client_inbounds").fetchone()[0]
|
||||
if links < len(proxy_rows):
|
||||
bad.append("client_inbounds has %d row(s), expected >= %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=<a Cloudflare address> warp=on"
|
||||
142
.claude/skills/edge-node-multi-protocol/scripts/12-smoke-test.sh
Executable file
142
.claude/skills/edge-node-multi-protocol/scripts/12-smoke-test.sh
Executable file
@@ -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 - <<PY
|
||||
import json, sqlite3, sys
|
||||
PROXY = ("vmess", "vless", "trojan", "shadowsocks")
|
||||
bad = []
|
||||
con = sqlite3.connect("/etc/x-ui/x-ui.db")
|
||||
rows = con.execute("SELECT port,protocol,settings FROM inbounds ORDER BY port").fetchall()
|
||||
proxy_rows = [r for r in rows if r[1] in PROXY]
|
||||
for port, proto, s in proxy_rows:
|
||||
try:
|
||||
cl = (json.loads(s) or {}).get("clients")
|
||||
except (TypeError, ValueError):
|
||||
cl = None
|
||||
if not isinstance(cl, list) or not cl:
|
||||
bad.append("inbound :%s (%s) settings.clients is EMPTY" % (port, proto))
|
||||
links = con.execute("SELECT count(*) FROM client_inbounds").fetchone()[0]
|
||||
if links < len(proxy_rows):
|
||||
bad.append("client_inbounds has %d row(s), expected >= %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=<a Cloudflare address> warp=on"
|
||||
87
.claude/skills/edge-node-multi-protocol/scripts/13-install-singbox-quic.sh
Executable file
87
.claude/skills/edge-node-multi-protocol/scripts/13-install-singbox-quic.sh
Executable file
@@ -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/<user>) 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"
|
||||
110
.claude/skills/edge-node-multi-protocol/scripts/13-singbox-config.py
Executable file
110
.claude/skills/edge-node-multi-protocol/scripts/13-singbox-config.py
Executable file
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Runs ON the box. Emits /etc/sing-box/config.json for the QUIC plane.
|
||||
|
||||
Which inbounds get built is driven purely by which port variables are set, so the
|
||||
same script serves a Hysteria2-only node, a TUIC-only node, or both -- no editing
|
||||
required to add or drop a protocol.
|
||||
|
||||
Why a second daemon at all: the TCP inbounds (VMess/Trojan/Shadowsocks) hide
|
||||
behind nginx:443 as WebSocket paths. Hysteria2 and TUIC are QUIC over UDP -- they
|
||||
terminate TLS themselves and cannot be reverse-proxied, so each needs a real
|
||||
public UDP port. Xray-core (what 3x-ui drives) implements neither, hence sing-box
|
||||
running alongside 3x-ui. Nothing here touches 3x-ui, nginx, or the existing
|
||||
inbounds: separate service, separate ports, separate config file.
|
||||
|
||||
WARP egress reuses the cloudflare-warp SOCKS5 proxy the Xray plane already uses,
|
||||
routed by inbound tag -- one WARP install serves both planes.
|
||||
|
||||
Env in: COVER, CERT_DIR, WARP_SOCKS_PORT, NODE_PASSWORD, XUI_UUID,
|
||||
PORT_HY2_DIRECT / PORT_HY2_WARP / HY2_OBFS_PASSWORD / HY2_UP_MBPS / HY2_DOWN_MBPS,
|
||||
PORT_TUIC_DIRECT / PORT_TUIC_WARP / TUIC_CC
|
||||
Unset port pairs are simply skipped.
|
||||
"""
|
||||
import json, os, sys
|
||||
|
||||
COVER = os.environ["COVER"]
|
||||
CERT_DIR = os.environ.get("CERT_DIR", "/etc/nginx/ssl/" + COVER.split(".", 1)[1])
|
||||
WARP_PORT = int(os.environ.get("WARP_SOCKS_PORT", "40000"))
|
||||
OUT = os.environ.get("SINGBOX_CONFIG", "/etc/sing-box/config.json")
|
||||
|
||||
|
||||
def tls():
|
||||
return {"enabled": True, "server_name": COVER, "alpn": ["h3"],
|
||||
"certificate_path": os.path.join(CERT_DIR, "fullchain.pem"),
|
||||
"key_path": os.path.join(CERT_DIR, "privkey.pem")}
|
||||
|
||||
|
||||
def port(name):
|
||||
v = os.environ.get(name, "").strip()
|
||||
return int(v) if v else None
|
||||
|
||||
|
||||
inbounds = []
|
||||
|
||||
# --- Hysteria2 -------------------------------------------------------------
|
||||
# Brutal congestion control needs a DECLARED bandwidth; it ignores loss as a
|
||||
# congestion signal, so an over-stated figure causes a loss storm rather than
|
||||
# speed. up/down are the server's view of the link.
|
||||
up = int(os.environ.get("HY2_UP_MBPS", "500"))
|
||||
down = int(os.environ.get("HY2_DOWN_MBPS", "500"))
|
||||
obfs_pw = os.environ.get("HY2_OBFS_PASSWORD", "").strip()
|
||||
for tag, pvar in (("hy2-direct", "PORT_HY2_DIRECT"), ("hy2-warp", "PORT_HY2_WARP")):
|
||||
p = port(pvar)
|
||||
if not p:
|
||||
continue
|
||||
ib = {"type": "hysteria2", "tag": tag, "listen": "0.0.0.0", "listen_port": p,
|
||||
"up_mbps": up, "down_mbps": down,
|
||||
"users": [{"name": "node-main", "password": os.environ["NODE_PASSWORD"]}],
|
||||
"tls": tls()}
|
||||
if obfs_pw:
|
||||
ib["obfs"] = {"type": "salamander", "password": obfs_pw}
|
||||
inbounds.append(ib)
|
||||
|
||||
# --- TUIC v5 --------------------------------------------------------------
|
||||
tuic_cc = os.environ.get("TUIC_CC", "bbr")
|
||||
for tag, pvar in (("tuic-direct", "PORT_TUIC_DIRECT"), ("tuic-warp", "PORT_TUIC_WARP")):
|
||||
p = port(pvar)
|
||||
if not p:
|
||||
continue
|
||||
inbounds.append({
|
||||
"type": "tuic", "tag": tag, "listen": "0.0.0.0", "listen_port": p,
|
||||
"users": [{"name": "node-main", "uuid": os.environ["XUI_UUID"],
|
||||
"password": os.environ["NODE_PASSWORD"]}],
|
||||
"congestion_control": tuic_cc, "auth_timeout": "3s",
|
||||
"zero_rtt_handshake": False, "heartbeat": "10s", "tls": tls()})
|
||||
|
||||
if not inbounds:
|
||||
sys.exit("ERROR: no QUIC ports configured (set PORT_HY2_* and/or PORT_TUIC_*)")
|
||||
|
||||
warp_tags = [i["tag"] for i in inbounds if i["tag"].endswith("-warp")]
|
||||
direct_tags = [i["tag"] for i in inbounds if i["tag"].endswith("-direct")]
|
||||
|
||||
rules = [
|
||||
{"action": "resolve", "strategy": "ipv4_only", "server": "ipv4-dns"},
|
||||
{"ip_cidr": ["::/0"], "action": "reject"},
|
||||
]
|
||||
if warp_tags:
|
||||
rules.append({"inbound": warp_tags, "outbound": "warp"})
|
||||
if direct_tags:
|
||||
rules.append({"inbound": direct_tags, "outbound": "direct"})
|
||||
rules.append({"protocol": "bittorrent", "outbound": "block"})
|
||||
|
||||
cfg = {
|
||||
"log": {"level": "warn", "timestamp": True},
|
||||
"dns": {"servers": [{"type": "udp", "tag": "ipv4-dns", "server": "1.1.1.1",
|
||||
"server_port": 53}], "strategy": "ipv4_only", "final": "ipv4-dns"},
|
||||
"inbounds": inbounds,
|
||||
"outbounds": [
|
||||
{"type": "direct", "tag": "direct"},
|
||||
{"type": "socks", "tag": "warp", "server": "127.0.0.1",
|
||||
"server_port": WARP_PORT, "version": "5"},
|
||||
{"type": "block", "tag": "block"},
|
||||
],
|
||||
"route": {"rules": rules, "final": "direct"},
|
||||
}
|
||||
|
||||
os.makedirs(os.path.dirname(OUT), exist_ok=True)
|
||||
with open(OUT, "w") as f:
|
||||
json.dump(cfg, f, indent=2)
|
||||
os.chmod(OUT, 0o600)
|
||||
print("wrote %s with %d inbound(s): %s" % (OUT, len(inbounds), ", ".join(i["tag"] for i in inbounds)))
|
||||
108
.claude/skills/edge-node-multi-protocol/scripts/14-generate-links-and-qr.py
Executable file
108
.claude/skills/edge-node-multi-protocol/scripts/14-generate-links-and-qr.py
Executable file
@@ -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/<sub_id>. 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()
|
||||
91
.claude/skills/edge-node-multi-protocol/scripts/15-generate-quic-links.py
Executable file
91
.claude/skills/edge-node-multi-protocol/scripts/15-generate-quic-links.py
Executable file
@@ -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.")
|
||||
83
.claude/skills/edge-node-multi-protocol/scripts/16-add-socks5.py
Executable file
83
.claude/skills/edge-node-multi-protocol/scripts/16-add-socks5.py
Executable file
@@ -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()
|
||||
54
.claude/skills/edge-node-multi-protocol/scripts/_lib.sh
Executable file
54
.claude/skills/edge-node-multi-protocol/scripts/_lib.sh
Executable file
@@ -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 <remote command...> — 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 <localfile> <remotepath> — 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'; }
|
||||
@@ -0,0 +1,120 @@
|
||||
# Copy to config.env and fill in. config.env is YOURS — keep it out of git.
|
||||
# Nothing here ships with real values; every field is a placeholder.
|
||||
|
||||
# --- target VPS ---
|
||||
SERVER_IP="203.0.113.10" # public IPv4
|
||||
SERVER_IPV6="" # fleet is IPv4-only; do not create AAAA
|
||||
SSH_USER="root" # root, or a sudo user (scripts escalate via sudo)
|
||||
SSH_KEY="$HOME/.ssh/your-vps-key.pem" # private key for the VPS
|
||||
TZ="UTC" # server timezone (e.g. UTC, Asia/Shanghai, America/Los_Angeles)
|
||||
|
||||
# --- domain ---
|
||||
PARENT_ZONE="example.com" # your DNS zone
|
||||
DOMAIN_ROOT="nodeA.example.com" # this node owns *.nodeA.example.com
|
||||
|
||||
# --- DNS provider ---
|
||||
# Cloudflare is the default & has a turnkey record-creation script (02-cf-dns.py).
|
||||
# Any acme.sh-supported DNS provider works for the wildcard cert — set ACME_DNS_PLUGIN and
|
||||
# the matching credential vars below. For A/AAAA records on a non-CF provider, create them
|
||||
# yourself (see references/dns-records.md — two names with A records, DNS-only).
|
||||
DNS_PROVIDER="cloudflare" # informational: cloudflare | dnspod | aliyun | route53 | other
|
||||
ACME_DNS_PLUGIN="dns_cf" # acme.sh plugin: dns_cf | dns_dp | dns_ali | dns_aws | dns_gd | ...
|
||||
|
||||
# Cloudflare (default). Token needs Zone:DNS:Edit. NEVER commit.
|
||||
CF_API_TOKEN="cf-token-with-Zone-DNS-Edit"
|
||||
CF_ZONE_ID="" # optional; 02-cf-dns.py looks it up if empty
|
||||
|
||||
# Other providers — uncomment the pair your ACME_DNS_PLUGIN needs (acme.sh reads these env names):
|
||||
# DP_Id=""; DP_Key="" # dns_dp (DNSPod)
|
||||
# Ali_Key=""; Ali_Secret="" # dns_ali (Aliyun)
|
||||
# AWS_ACCESS_KEY_ID=""; AWS_SECRET_ACCESS_KEY="" # dns_aws (Route53)
|
||||
# GD_Key=""; GD_Secret="" # dns_gd (GoDaddy)
|
||||
|
||||
# --- subdomain labels (CHOOSE YOUR OWN, NON-OBVIOUS ONES) ---
|
||||
# Your panels sit at <SUB_*>.<DOMAIN_ROOT>. 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.<root>
|
||||
SUB_XUI="gateway" # 3x-ui panel -> gateway.<root>
|
||||
SUB_COVER="edge" # node cover -> edge.<root>
|
||||
# landing page uses the bare <DOMAIN_ROOT>.
|
||||
|
||||
# --- 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
|
||||
@@ -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
|
||||
21
.claude/skills/edge-node-multi-protocol/scripts/warp-healthcheck.sh
Executable file
21
.claude/skills/edge-node-multi-protocol/scripts/warp-healthcheck.sh
Executable file
@@ -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
|
||||
@@ -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
|
||||
@@ -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 模式是你自己的干净出口,不是绕过他人网络管控的工具。
|
||||
@@ -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_LINK}}
|
||||
```
|
||||
|
||||
## 线路二:VMess WARP 净出口
|
||||
|
||||
出站走 Cloudflare WARP,隐藏服务器真实 IP,出口 IP 是 Cloudflare 的地址,速度略慢于直连。
|
||||
|
||||

|
||||
|
||||
```
|
||||
{{VMESS_WARP_LINK}}
|
||||
```
|
||||
|
||||
## 线路三:Trojan 直连出口
|
||||
|
||||

|
||||
|
||||
```
|
||||
{{TROJAN_DIRECT_LINK}}
|
||||
```
|
||||
|
||||
## 线路四:Trojan WARP 净出口
|
||||
|
||||

|
||||
|
||||
```
|
||||
{{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 要跟表里完全一致,包括大小写),不是服务器端的问题。
|
||||
@@ -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`,单独跑这一个脚本即可,不影响其他服务。
|
||||
50
.claude/skills/edge-node-multi-protocol/templates/README.md
Normal file
50
.claude/skills/edge-node-multi-protocol/templates/README.md
Normal file
@@ -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 `` 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.
|
||||
@@ -0,0 +1,19 @@
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>__NODE_NAME__</title>
|
||||
<style>
|
||||
:root{color-scheme:light dark}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;min-height:100vh;display:grid;place-items:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:linear-gradient(135deg,#0a2a43,#1668a8);color:#eaf4ff}
|
||||
.card{max-width:560px;padding:48px 40px;text-align:center}
|
||||
h1{font-size:2.2rem;margin:0 0 .4em;letter-spacing:.04em}
|
||||
p{opacity:.82;line-height:1.7;margin:.3em 0}
|
||||
.dot{display:inline-block;width:9px;height:9px;border-radius:50%;background:#43b0ff;box-shadow:0 0 10px #43b0ff;margin-right:8px;vertical-align:middle}
|
||||
.foot{margin-top:2.4em;font-size:.82rem;opacity:.55}
|
||||
</style></head><body>
|
||||
<div class="card">
|
||||
<h1>__NODE_NAME__</h1>
|
||||
<p><span class="dot"></span>Edge service operational</p>
|
||||
<p>Private research & networking endpoint. No public service is hosted on this page.</p>
|
||||
<p class="foot">for authorized use only</p>
|
||||
</div></body></html>
|
||||
@@ -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/<private-body>.json
|
||||
bash .claude/skills/update-db/scripts/api.sh api GET /sub/<token>/clash # 拉订阅做校验(公开端点)
|
||||
bash .claude/skills/update-db/scripts/api.sh backup <context> # 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 <context>`。一个变更主题一个备份,同主题内的多条变更共用一份。备份落在远端 `data/backups/`(root 属主),不要清理。
|
||||
3. **执行**:逐条调 API,每条核对 HTTP_STATUS 与返回体。某条失败就停下排查,不要跳过继续。
|
||||
4. **校验**:
|
||||
- `counts` 与变更前对比,行数增减要能对上意图
|
||||
- `api GET` 变更对象,确认落库的是新值
|
||||
- 涉及下发面的变更(节点/组/规则/token 范围):拉一次 `api GET /sub/<token>/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/<token>/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 全文,用节点名指代
|
||||
|
||||
@@ -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 <context> # 变更前快照 → data/backups/before-<context>-<时间戳>.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"
|
||||
|
||||
17
.dockerignore
Normal file
17
.dockerignore
Normal file
@@ -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/
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -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
|
||||
|
||||
215
AGENTS.md
Normal file
215
AGENTS.md
Normal file
@@ -0,0 +1,215 @@
|
||||
# AGENTS.md
|
||||
|
||||
本文件是 Codex 与 Claude Code 共用的项目开发约定。`CLAUDE.md` 用 `@AGENTS.md` 导入本文件;项目规则只在这里维护。双工具入口、技能发现和使用方法见 `docs/AI-COLLABORATION.md`。
|
||||
|
||||
## 协作方式
|
||||
|
||||
- 默认用中文沟通,代码标识符沿用项目现有英文命名。
|
||||
- 按任务读取相关代码与文档;以代码、测试及用户最新要求为准,发现说明过时就同步修正。
|
||||
- 本地阅读、编辑、类型检查、单测和构建按任务自主完成。部署或生产数据变更须属于用户授权的任务;已有明确授权不重复确认。普通代码修改不隐含上线或修改生产数据。
|
||||
- 保留用户已有改动。提交、推送与部署分别按用户要求执行。
|
||||
- 项目技能唯一源文件在 `.claude/skills/`;`.agents/skills/` 通过相对符号链接供 Codex 发现。修改技能时编辑源文件,避免维护两份副本。
|
||||
|
||||
## 这是什么
|
||||
|
||||
本仓库统一维护边缘 VPS 节点的部署管理与 Proxy Station 代理节点、分流规则管理平台。Web 界面管理多协议节点(VMess/Trojan/Shadowsocks/Hysteria2)与策略组、规则,向 macOS Surge、iOS ShadowRocket、Android ClashMeta 下发托管订阅。规则模板来自 [ClashConnectRules/Surge](https://github.com/ClashConnectRules/Surge),边缘舰队的工具与私有交付记录已从原 `../edge-nodes/` 迁入本项目。
|
||||
|
||||
### 当前生产约定(2026-10-04)
|
||||
|
||||
最新盘点、完整组顺序与成员、节点启用范围和 Session 变更索引见 [当前生产状态](docs/CURRENT-STATE.md)。日期化体检/变更文档保留当时的数量与验证结果;维护前经管理 API 重读,不能用历史快照或初始化模板覆盖最新生产数据。
|
||||
|
||||
- 当前登记 30 个节点,12 个启用、18 个停用;12 个启用组、21 个显式成员、130 条规则(128 启用)。停用节点保留凭据,不因文档整理、排序、部署或导入清单核对而自动启用。应用的节点启用状态不等于边缘 VPS 的运行状态。
|
||||
- 普通代理流量交给 `🚀 Proxy`,只有它引用 `⚡️ Automatic`。Automatic 为 url-test,成员依次为新加坡、美国、日本;规则不直接指向 Automatic。`🤖 AI` 为 select,成员依次为美国、新加坡、日本、`🧠 AI Nodes`,不引用 Automatic。
|
||||
- `📋 All Nodes` 与 `🧠 AI Nodes` 都是独立 select 组,开启 includeAllNodes、没有筛选正则或显式成员;AI Nodes 紧接 AI 显示。两组收纳相同的订阅范围内可用节点,各自保存客户端选择,不能用引用 All Nodes 代替独立的 AI Nodes。
|
||||
- 两组按完整节点名称字符串升序输出,保留 Direct/Warp 出口词。现有节点 sortOrder 全为默认 0,IR 按 sortOrder、name 读取;新增/导入节点保持默认值。非零权重会先于名称排序,调整它影响节点声明及其他动态列表,不只是这两个组。Web 节点页仍按站点、协议与出口展示。
|
||||
- Streaming 当前承接 Disney、Netflix、TikTok、YouTube、GoogleSearch 及欧洲/韩国/香港/台湾流媒体集合;美国/日本流媒体集合直接交给对应地区组,Emby 交给 Proxy。组显示顺序不决定规则匹配顺序。
|
||||
- 规则优先级保持:STUN 与 googlevideo UDP 防护 → Unbreak DIRECT → 启用的 AI 规则 → 业务例外/广告与其余分流 → 唯一 FINAL。FINAL 仍为 `🎯 NoAuto,dns-failed`,NoAuto → Mainland → DIRECT。保留 Google 更新/翻译直连例外;全局 PROTOCOL DOH 与 Dia 关键词规则继续停用。
|
||||
- Apple/Global Services/Speedtest/Other 及无节点的香港/台湾/韩国/英国组已删除,不因模板仍存在而恢复。App Store DIRECT、Apple News 美国、Apple Relay AI 例外继续保留。
|
||||
|
||||
### 节点部署管理
|
||||
|
||||
- `edge-nodes/README.md` 是节点工作区入口;`edge-nodes/build-delivery-doc.py` 从 Git 忽略的 `edge-nodes/private/fleet.json` 读取清单并离线生成汇总交付页,不在源码里保存真实站点信息。
|
||||
- 通用部署脚本、参考资料与占位交付模板唯一源码在 `.claude/skills/edge-node-multi-protocol/`,Codex 从 `.agents/skills/edge-node-multi-protocol` 相对链接加载。部署/重建/维护 VPS 节点使用此技能;发布 Proxy Station 应用用 `deploy`,生产订阅数据查询与修改用 `update-db`。
|
||||
- 真实配置、面板入口、分享链接、二维码、交付文档和各站点脚本工作副本在 `edge-nodes/private/<id>-edge-node-docs/`,全部禁止入 Git 或公开静态托管,也不发送到在线二维码服务。新增配置/产物用 `umask 077`,私有目录用受控备份转交,新克隆不会携带这些文件。
|
||||
- 当前舰队为 sg(AWS 新加坡)、la(DMIT 洛杉矶,同时承载 Proxy Station)、rn(RackNerd 洛杉矶,当前工作副本为 `rn2-edge-node-docs/`)、jp(VMISS 东京,裸 nginx)。`rn-edge-node-docs/` 与 `private/archive/` 是历史资料,不作为当前配置来源;原兄弟目录仅保留为迁移前备份,后续在本仓库维护。
|
||||
- 舰队自 2026-10-03 统一使用 IPv4:系统接口禁用 IPv6,Xray/sing-box 仅解析 IPv4 并拒绝 IPv6 目标(含 WARP),节点域名不保留 AAAA。重建或扩展线路须保留这些约束,详见 `docs/EDGE-IPV4-ONLY.md`;仅禁用网卡或删除 AAAA 不能阻止 WARP 隧道内的 IPv6 转发。
|
||||
- TCP 基础线路由 nginx+3x-ui/Xray 承载 VMess/Trojan/SS × direct/WARP,QUIC(HY2/TUIC)由 sing-box 独立承载。3x-ui 客户端不能只写投影表,必须保留 `settings.clients`,否则升级迁移会丢失认证。改远端前读取节点交付记录与技能参考,备份受影响配置和数据库,并保留已有证书续期动作。
|
||||
- 迁移工程、修改脚本或复制文件不会授权远端部署;节点与应用的部署各自按任务执行。应用运行时仍通过管理 API/Web 导入节点,不自动读取私有目录。TUIC/SOCKS5 交付线路不属于本系统当前支持的导入类型。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
CI=true pnpm install --frozen-lockfile # 按锁文件安装依赖
|
||||
pnpm dev # server :3000 + web :5173(都监听 0.0.0.0,局域网可访问)
|
||||
pnpm -r typecheck # 三个包全量类型检查
|
||||
pnpm test # 服务端单测(vitest)
|
||||
pnpm --filter @proxy-station/web build
|
||||
|
||||
# macOS Surge:先取实际 HTTPS 订阅到私有临时文件,再用官方 CLI 与规则集快照验证
|
||||
python3 scripts/check-surge.py /private/tmp/<profile>.conf --fetch-rulesets
|
||||
|
||||
# 单测过滤(在仓库根目录下)
|
||||
pnpm --filter @proxy-station/server exec vitest run parse
|
||||
pnpm --filter @proxy-station/server exec vitest run -t "round-trip"
|
||||
pnpm --filter @proxy-station/server exec vitest # watch 模式
|
||||
|
||||
# 部署到 LA 生产机(web|server|all),详见 .claude/skills/deploy/SKILL.md 与 docs/DEPLOY.md
|
||||
bash .claude/skills/deploy/scripts/deploy.sh all
|
||||
|
||||
# 本机离线生成边缘舰队交付页(清单与产物均在 Git 忽略的私有目录)
|
||||
python3 edge-nodes/build-delivery-doc.py
|
||||
|
||||
# 校验实际 Clash 配置;生产验收改用实际 HTTPS 入口,真实 URL 仅在本机使用
|
||||
umask 077
|
||||
check_dir="$(mktemp -d /private/tmp/proxy-station-check.XXXXXX)"
|
||||
curl -fsSL --max-time 45 "http://localhost:3000/sub/<token>/clash" -o "$check_dir/clash.yaml" && \
|
||||
test -s "$check_dir/clash.yaml" && mihomo -t -d "$check_dir" -f "$check_dir/clash.yaml"
|
||||
```
|
||||
|
||||
本机开发及当前生产部署使用 Node 22。依赖使用 pnpm 与已提交的 `pnpm-lock.yaml`;不要混用 npm/yarn 或无故更新锁文件。pnpm 在无 TTY 时安装需要 `CI=true`,原生模块(better-sqlite3、esbuild、vue-demi)的构建许可写在 `pnpm-workspace.yaml` 的 `allowBuilds`。
|
||||
|
||||
本机会话注入的代理曾造成 git/安装/网络检查挂起;遇到此问题,在单条命令作用域内用 `env -u HTTPS_PROXY -u https_proxy -u HTTP_PROXY -u http_proxy -u ALL_PROXY -u all_proxy <命令>` 直连。现有运维脚本已自行处理代理。不依赖其他工具的全局指令文件,也不修改用户全局代理配置。
|
||||
|
||||
## 架构
|
||||
|
||||
应用部分为 pnpm monorepo:`shared/`(zod schema 与 DTO,前后端共用源码,非构建产物)+ `server/`(Hono + better-sqlite3 + Drizzle)+ `web/`(Vue 3 + Naive UI + Pinia)。生产模式由 server 静态托管 web 产物,单端口。`edge-nodes/` 与节点部署技能是 Bash/Python 运维工具,不加入 pnpm 包,也不随应用部署上传;Docker 构建上下文排除它们。
|
||||
|
||||
### 配置生成流水线(核心)
|
||||
|
||||
```
|
||||
SQLite → buildProfileIR({nodeIds}) → ProfileIR → 四个序列化器
|
||||
├─ generateSurge(ir, {selfUrl})
|
||||
├─ generateShadowrocket(ir) 节点订阅
|
||||
├─ generateShadowrocketConf(ir) 策略组+规则
|
||||
└─ generateClash(ir, {convertBaseUrl})
|
||||
```
|
||||
|
||||
`server/src/services/ir.ts` 是配置生成流水线的统一读库入口,把节点、策略组、规则、Surge 原文段落收成与客户端无关的 `ProfileIR`;四个生成器只读 IR,互不依赖。**新增客户端就是加一个序列化器**,不要在生成器里再查数据库。
|
||||
|
||||
IR 层承担三件防护,改动时不要绕过:
|
||||
|
||||
- 停用或已删除的策略组会被剔除,引用它的成员与规则一并跳过
|
||||
- `FINAL` 规则永远保留,其策略组失效时降级为 `DIRECT`
|
||||
- `nodeIds` 为空数组表示「不限制」,非空则按订阅分配的节点范围过滤
|
||||
|
||||
### 客户端能力矩阵
|
||||
|
||||
`shared/src/protocols.ts` 的 `clientSupports()` 决定某节点能否下发给某客户端,生成器据此排除并回报 `excluded`。**Surge 不支持 Shadowsocks over WebSocket**;本项目对 Surge 的 VMess 仅下发 `alterId=0`,加密方式只支持 auto/aes-128-gcm/chacha20-poly1305(转换为 IETF 名称)。其他客户端的能力独立判断。不要把无法表达的输入静默改成另一个协议组合。
|
||||
|
||||
### 协议字段差异
|
||||
|
||||
同一协议在三端写法不同,映射集中在 `shared/src/protocols.ts` 与各生成器:
|
||||
|
||||
- SS cipher:分享链接用 Xray 命名 `chacha20-poly1305`,Surge/Clash 要 `chacha20-ietf-poly1305`(`normalizeSsCipher()`)
|
||||
- VMess `alterId=0` 必须输出 Surge 的 `vmess-aead=true`,否则连不上且症状隐晦
|
||||
- Hysteria2 的 salamander 混淆:Surge 用 `salamander-password=`,Clash 用 `obfs`/`obfs-password`
|
||||
- SS over WS 在 Clash 侧唯一可行方案是 `plugin: v2ray-plugin`
|
||||
|
||||
这些写法参考了 `../sub-router` 项目(用户已在生产使用的同类系统),遇到新的客户端适配问题优先查它。
|
||||
|
||||
### 策略组的两种取节点方式
|
||||
|
||||
`includeAllNodes` 与 `filterRegex` 可以共存,生成器里 **filterRegex 分支优先**:
|
||||
|
||||
- 有 `filterRegex` → 动态筛选:Surge 保留显式节点/内置成员,全部成员组作为 `include-other-group` 来源,并可同时启用 `include-all-proxies=true`;正则只筛动态来源,不筛显式成员。Clash 用 `include-all-proxies: true` + `filter` 并保留 `proxies` 显式成员;ShadowRocket 保留显式成员并用 `policy-regex-filter` 动态收节点。新增同地区节点会自动进组
|
||||
- 仅 `includeAllNodes` → Surge 静态展开全部节点名,Clash/ShadowRocket 仍用动态引用
|
||||
|
||||
**地区组靠节点名称正则匹配,不看 `region` 字段**。节点命名规范与新增地区的完整步骤见 `docs/ADDING-REGIONS.md`——命名不对就进不了地区组。
|
||||
|
||||
节点与组共用客户端的策略名称空间:禁止名称中的配置分隔符、引号、反斜线、控制字符和首尾空格;不能与内置策略或另一类实体重名。导入预览与实际写入都走同一 schema。组成员必须存在且类型正确,创建/更新(包括改名)校验完整引用图无环,并在事务内写组、替换成员与级联引用。
|
||||
|
||||
Surge 不支持组级 `url=`;组的 `testUrl` 仅用于 Clash/ShadowRocket。Surge 使用 General 的 `proxy-test-url` 或节点 `test-url`,fallback 不输出 tolerance。协议、规则和参数规范以 [Surge 官方手册](https://manual.nssurge.com/) 与本机 `surge-cli --check` 为依据,不能凭其他客户端语法推断。
|
||||
|
||||
### 规则集:映射与兜底转换
|
||||
|
||||
模板里的 `RULE-SET`/`DOMAIN-SET` 指向外部 URL。Surge/ShadowRocket 直接透传;Clash 需要 rule-providers,`services/rulesets/mapping.ts` 按序尝试映射到 skk.moe 的 Clash 版与 blackmatrix7 的 Clash 目录,映射不到的标记 `needsConvert`,provider URL 指向自托管端点 `/sub/:token/ruleset/:id.yaml`——该端点实时拉取 Surge 版并用 `convert.ts` 转成 classical provider。规则集拉取带 24h 缓存 + etag,失败回落陈旧缓存。
|
||||
|
||||
### ShadowRocket 分两步导入
|
||||
|
||||
ShadowRocket 的原生订阅格式只承载节点,因此拆成两个端点:`/shadowrocket`(节点 base64 URI 列表)与 `/shadowrocket-conf`(策略组与规则的 .conf)。节点订阅按完整名称字符串升序排列,保留出口词;该序列化器对副本排序,不修改 IR/数据库,跨客户端名称排序由上述节点权重约定保证。**ShadowRocket 没有 Surge 的 `use=` 订阅引用语法**(真机验证过:`use=true` 会被当成解析不了的成员残留),策略组动态收节点靠 `policy-regex-filter` 从 App 内全部节点里筛(「收纳全部节点」输出 `policy-regex-filter=.*`),显式成员也要保留。因此订阅名随意,但用户在 App 里挂的其他订阅的节点也会被筛进来;这些节点及 App 自定义排序不由本服务控制。本生成器当前保守跳过 `DOMAIN-SET`/`AND`/`OR`/`NOT`/`URL-REGEX`/`PROTOCOL`,并回报 `skippedRules`;这不是客户端不支持这些类型的断言。官方更新记录已确认存在逻辑规则与 QUIC 匹配,具体配置语法仍须以 App 内帮助和真机编译验证,详见 `docs/MOBILE-CLIENT-AUDIT-2026-10-04.md`。**ShadowRocket 的 conf 策略名不可用 Surge 引号机制**:策略名(含空格/emoji)一律裸写,加引号会被当作名字的一部分,导致规则指向不存在的策略组而整条失效、流量落到默认行为走代理——Surge 生成器可以加引号,ShadowRocket 生成器绝不可以。
|
||||
|
||||
Clash 节点必须明确下发 UDP 能力:VMess/Trojan/纯 TCP SS/HY2 输出 `udp: true`,SS WS 插件仅转发 TCP,输出 false,不能假设 nginx 的同端口提供 SS UDP。VMess cipher 按 mihomo 命名映射、未知 cipher 明确排除;SS plugin 的 TLS 参数在 `plugin-opts` 内。RULE-SET 保留 no-resolve;逻辑规则逐个转换子类型,仅把 PROTOCOL TCP/UDP 映射 NETWORK,任一子条件无法表达时整条跳过。两端 url-test 保留零 tolerance,fallback 不输出 tolerance。完整体检需分别核对客户端能力与生成器范围,原生解析成功不等于设备连接或隐私验收。
|
||||
|
||||
### 鉴权与订阅
|
||||
|
||||
除公开的 `/api/health` 外,`/api/*` 走 `adminAuth`:设置了 `ADMIN_TOKEN` 则要求 Bearer,未设置时仅放行 localhost 与 RFC1918 私有网段(公网 IP 一律拒绝)。`/sub/:token/*` 公开,token 用常量时间比对。**订阅 URL 完全由请求来源决定**——后端只返回路径,前端用 `window.location.origin` 拼接,`#!MANAGED-CONFIG` 与 Clash provider URL 取本次请求的 origin,所以没有也不需要「订阅基址」配置项。
|
||||
|
||||
未匹配的 `/api/*` 显式返回 404,不落到 SPA 回退(否则会拿到一页 HTML,调接口时极难排查)。
|
||||
|
||||
### Surge Mac 与 Tailscale 共存
|
||||
|
||||
修改增强模式、DNS、STUN 或 VPN 路由前,先读 [docs/SURGE-TAILSCALE.md](docs/SURGE-TAILSCALE.md)。2026-10-06 已实测:独立 Tailscale 客户端设置 `--accept-dns=false`,Surge 仅将具体 tailnet 域名交给 `100.100.100.100`,配合 always-real-ip 和实际 Tailscale 网络扩展/CLI 的优先 DIRECT 规则。保留原有公网代理、AI DNS 和其他应用的 STUN 阻断;不使用 Exit Node,不需要向项目保存 Tailscale auth key。
|
||||
|
||||
**不能机械套用 `tun-excluded-routes = 100.64.0.0/10`**:本机实测该项创建指向物理网关的路由,覆盖 Tailscale 路由。最终配置不添加这些排除项。skip-proxy、DIRECT 和 VIF 路由排除是不同层次,必须检查实际路由表及普通应用连接。
|
||||
|
||||
tailscale ping 可通不代表 curl/SSH 可通;客户端显示 Running 也不代表公网未受影响。测试必须同时覆盖原代理、AI DNS、MagicDNS、普通 tailnet 服务和外网入站 SSH。使用 Tailscale CLI 做 netcheck 时也需进程例外,避免把自身 STUN 被阻断误判为网络不支持 UDP。测试 VPN 重连前设置失败自动 tailscale down 的恢复措施,并确认用户未同时手动切换。
|
||||
|
||||
现有订阅的持久来源是生产 settings/rules,模板仅供首次初始化;通过 update-db 技能先备份再走 API,不直接改托管文件或重置规则。结论仅限实测环境与场景:两种启动顺序、IPv4/IPv6 服务和蜂窝网络入站 SSH 已通过;睡眠唤醒、长期运行及网络切换尚未验证,不宣称“完美兼容”。
|
||||
|
||||
同账号/同 Surge 订阅不会同步 Tailscale 的本机 `accept-dns` 与 Exit Node 设置。新 Mac 必须逐台检查;MacBook Air 使用相同配置时仍曾保留 accept-dns=true,需在该机单独关闭。不要仅凭 Surge 自带探测成功认定普通应用正常,须另外测不使用显式代理的 curl。
|
||||
|
||||
### Telegram 跨平台分流(2026-10-11)
|
||||
|
||||
最新修复记录见 [docs/TELEGRAM-RULES.md](docs/TELEGRAM-RULES.md),规则快照见 [rules/telegram.list](rules/telegram.list)。生产已通过管理 API 添加 37 条高优先级自定义规则,Telegram 域名及 IPv4/IPv6 地址统一交给 `🚀 Proxy`;本机更新订阅后登录二维码恢复显示。仅配置 `osx.telegram.org` 不足以覆盖数据中心连接。此变更晚于上面的 2026-10-04 生产盘点,历史数量与规则顺序不能视为当前值;维护前仍须经 API 重读。规则快照不会自动跟随上游更新。
|
||||
|
||||
## 前端约定
|
||||
|
||||
UI 走「站牌导视」设计方向(轨道交通导视系统语言):白底 `#f7f7f4`、深藏青重点色 `#2b3f5c`、**琥珀 `#b26a00` = 直连出口、青 `#007a72` = WARP 净出口**,协议各有线路色圆标。颜色只在承载信息时出现——避免发光、亮边条、装饰性渐变。四个方向的设计对比留在 `docs/design-demos.html`。
|
||||
|
||||
两个共用工具:
|
||||
|
||||
- `utils/nodeName.ts` 的 `nodeDisplayName()`:与出口标签并排显示时剥掉名字末尾的出口词。**下发给客户端的名字必须保留出口词**,客户端里没有标签,用户只能靠名字区分同协议的两条线路
|
||||
- `utils/clipboard.ts` 的 `copyText()`:`navigator.clipboard` 只在 HTTPS/localhost 可用,局域网 HTTP 访问时是 `undefined`,直接调用会静默失败。必须走这个带 `execCommand` 降级的封装
|
||||
|
||||
## 数据与安全
|
||||
|
||||
SQLite 落在 `server/data/`,存有节点真实凭据。`data/`、`*.db`、`.env`、`edge-nodes/private/`、`config.env` 及其备份、`node-links/` 已在 `.gitignore`——**绝不入 git**。可入库的示例、测试、模板一律用占位凭据;填写后的交付副本只放私有目录。节点通过 Web 界面粘贴分享链接导入,应用不直接读 `edge-nodes/private/`(见 `docs/SEEDING.md`)。
|
||||
|
||||
schema 变更同时更新 `shared/src/schemas.ts`/DTO(涉及对外字段时)、`server/src/db/schema.ts` 的 Drizzle 定义和 `server/src/db/index.ts` 的启动 SQL。新表在 `BOOTSTRAP_SQL` 中加 `CREATE TABLE IF NOT EXISTS`,旧库补列用同文件下方的轻量 `ALTER TABLE` 循环(吞掉「列已存在」错误)——没有引入 migration 工具。
|
||||
|
||||
公网部署必须设 `ADMIN_TOKEN` 并用反代提供 HTTPS:订阅 token 就在 URL 里,明文 HTTP 会泄露。
|
||||
|
||||
## 初始化与已有数据
|
||||
|
||||
`server/src/db/seed.ts` 只在策略组为空时从 `server/assets/surge-template.conf` 初始化策略组、规则和 Surge 段落。修改模板不会更新已有数据库;既有订阅仍以数据库为准。生产数据变更使用 `update-db` 技能,经管理 API 修改并先做 SQLite 在线备份,确认备份 root 属主、600 权限,避免绕过校验和级联更新。
|
||||
|
||||
组的 members 是整体替换;排序请求始终先读完整列表并提交全量、无重复 ids。规则 reorder API 强制校验完整性与末尾 FINAL;当前组 reorder API 只逐条写顺序,调用方须自行核对完整性。组 PUT 不更新 sortOrder,显示排序使用独立 `/api/groups/reorder`。节点 PUT 使用私有文件提交完整原字段,排序调整后重读确认除 sortOrder/updatedAt 外没有变化。
|
||||
|
||||
`server/src/env.ts` 直接读 `process.env`,没有 dotenv 加载;仅复制 `.env.example` 成 `.env` 并不会让 `pnpm dev` 自动读取它。变量须由启动环境提供(Docker Compose 与 systemd 有各自注入方式)。相对 `DATA_DIR` 始终基于 `server/` 解析。
|
||||
|
||||
## Surge AI DNS 与协议规则
|
||||
|
||||
改 DNS/隐私行为先读 `docs/SURGE-PRIVACY.md`、`server/src/services/generators/surge-ai-dns.ts` 及对应测试。AI 域名 DNS 映射来自 IR 内启用的 AI 路由规则;不要把全局 DNS 或所有端口 53 请求改送 AI。Surge 的 `PROTOCOL` 应用协议规则不能直接复制到其他客户端:Clash 只把 TCP/UDP 转成 `NETWORK`,其他应用协议跳过;ShadowRocket 跳过 `PROTOCOL`。生成配置校验不能替代设备 DNS/WebRTC 真机验证。
|
||||
|
||||
手写 Host 项优先;生成的域名 DNS 映射按连接规则顺序保留 AI 之前的非 AI 域名/规则集例外,不把 Host 当成能表达进程、协议或 IP 条件的路由系统。宽泛 AI 域名规则仍须审阅覆盖范围。
|
||||
|
||||
Net.Coffee 的 Claude 页通过随机 `*.d.ip.net.coffee` 探测递归 DNS 出口,不直接查询 Claude 域名;使用 `DOMAIN-SUFFIX,ip.net.coffee,🤖 AI` 同时覆盖页面和探测子域。Surge 派生根域/通配 Host 到 Cloudflare DoH,并随 AI 出口。本机 Chrome+Surge 已复测为美国 Cloudflare「未检测到泄露」,仅代表该探测路径的实际结果,不能推广为所有客户端/域名/远端解析器已验收,见 `docs/NET-COFFEE-DNS-2026-10-04.md`。
|
||||
|
||||
规则变更先读 `docs/SURGE-CHANGES.md`。API 必须保住一条启用且末尾的 FINAL;禁止停用、删除、改类型或创建重复 FINAL,排序必须提交完整且无重复的 ids。IR 对遗留坏数据防御性补齐 FINAL,其失效策略降级 DIRECT。重置规则在事务内保留自定义规则,不重复插入原主键,也不能用 reset 代替定向调整生产规则。
|
||||
|
||||
规则按首条匹配执行(另留意 pre-matching)。业务例外须在覆盖它的广告/通用规则集之前,特定 UDP 拦截须在对应服务域名规则之前。靠前的 IP 规则集是否加 `no-resolve` 必须按意图决定:它能避免主动解析,但会改变未解析域名的 IP 拦截覆盖。普通规则值与策略引用用 `surge-format.ts` 编码,逻辑表达式保留结构;不要手拼含逗号的正则或含换行的字段。
|
||||
|
||||
共享订阅不附公开默认控制口令;默认关闭 Remote Controller/HTTP API,确需使用时按设备配置独立凭据与访问限制。MITM 不关闭上游服务器证书验证;需要 MITM 时另做设备 CA、开关与 HTTPS 行为验证。
|
||||
|
||||
## 验证与完成标准
|
||||
|
||||
| 改动 | 建议验证 |
|
||||
|---|---|
|
||||
| 服务端逻辑/解析器/生成器 | `pnpm --filter @proxy-station/server typecheck` + 相关 Vitest;新增行为用占位凭据构造 IR/分享链接用例 |
|
||||
| shared schema/DTO/协议能力 | `pnpm -r typecheck` + `pnpm test`,兼顾前后端消费者与各客户端产物 |
|
||||
| Vue 页面/样式/状态 | `pnpm --filter @proxy-station/web typecheck` + `pnpm --filter @proxy-station/web build`;页面交互与局域网 HTTP 复制按需验证 |
|
||||
| 数据库结构/启动行为 | 临时 `DATA_DIR` 或 `createDb(':memory:')` 验证新库与旧库升级;注意下述导入副作用 |
|
||||
| 文档/协作入口/技能 | 检查路径、导入、符号链接、技能 frontmatter 与脚本语法;不为纯文档修改触发部署 |
|
||||
| 节点部署工具/交付页生成器 | Bash 语法与 Python 编译检查、占位配置离线验证;迁移核对原文件、私有路径 Git 忽略及技能链接,不为迁移执行远端脚本 |
|
||||
|
||||
涉及节点、组、规则、DNS 或 Surge 段落时,在上述检查之外对实际订阅运行 `scripts/check-surge.py`;涉及规则集或顺序用 `--fetch-rulesets`,审阅域名遮蔽提示。脚本调用官方 Surge CLI,不更换活动配置;输出 OK 不表示测速、业务连接或设备隐私已验收。敏感配置用权限 600 的临时文件,不进入仓库或对话。生产写入先在线备份,再 API 更新、重读、HTTPS 拉订阅与 CLI 验证;必要时同时用 mihomo 校验受影响的 Clash 产物。
|
||||
|
||||
取订阅后先核对下载成功、非空及预期结构,再交给原生解析器。mihomo 在目标配置不存在时可能创建默认配置并报告成功,不能用这个结果验收订阅;所有检查遇到失败即停止,避免前一步失败而最后一条命令成功掩盖错误。
|
||||
|
||||
区分数据库、实际 HTTPS 产物、客户端活动配置与设备网络结果。用户授权的同机复测需刷新既有托管配置时,先核对托管 URL、备份并读取最新组选择,用 `surge-cli managed-profile update` 后检查 effective 配置与实际选项。组改名会改变客户端保存选择的身份;必要时仅将原选定节点及父组路径迁移到新名称,保留 All Nodes/AI Nodes 的独立选择,不擅自切换出口或活动配置。客户端模块可覆盖生成的组类型与参数,不能把本机模块效果写回生产数据库。
|
||||
|
||||
`db/index.ts` 在模块加载时就执行 `export const db = createDb()`,包括只导入 `createDb` 的情形;临时数据库测试/启动验证应在加载模块前设置绝对路径的临时 `DATA_DIR`,不要拿本地真实 `server/data/` 做试验。现有生成器单测使用内存中的 IR;运行服务还会执行初始化。
|
||||
|
||||
改生成器时检查节点过滤、组成员引用、规则顺序和 FINAL 兜底;Clash 可用 mihomo 校验,Surge/ShadowRocket 的客户端语法与实际连接仍需真机验收。`excluded`/`skippedRules` 是生成器返回字段,订阅 HTTP 响应只返回配置正文。
|
||||
|
||||
完成后说明改动、已通过的验证与尚未完成的实际验证。新增业务约束或修正旧认识时同步更新这里或相应 `docs/`,让后续 Codex/Claude Code 会话能接续。
|
||||
117
CLAUDE.md
117
CLAUDE.md
@@ -1,114 +1,7 @@
|
||||
# CLAUDE.md
|
||||
@AGENTS.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
# Claude Code 兼容入口
|
||||
|
||||
## 这是什么
|
||||
|
||||
自托管的代理节点与分流规则管理平台:Web 界面管理多协议节点(VMess/Trojan/Shadowsocks/Hysteria2)与策略组、规则,向 macOS Surge、iOS ShadowRocket、Android ClashMeta 下发托管订阅。规则模板来自 [ClashConnectRules/Surge](https://github.com/ClashConnectRules/Surge),节点来自 `~/Development/Fusion/Projects/edge-nodes/` 交付的边缘节点舰队。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
pnpm dev # server :3000 + web :5173(都监听 0.0.0.0,局域网可访问)
|
||||
pnpm -r typecheck # 三个包全量类型检查
|
||||
pnpm test # 服务端单测(vitest)
|
||||
pnpm --filter @proxy-station/web build
|
||||
|
||||
# 单测过滤(在 server/ 目录下)
|
||||
npx vitest run parse # 按文件名
|
||||
npx vitest run -t "round-trip" # 按用例名
|
||||
npx vitest # watch 模式
|
||||
|
||||
# 部署到 LA 生产机(web|server|all),详见 .claude/skills/deploy/SKILL.md 与 docs/DEPLOY.md
|
||||
bash .claude/skills/deploy/scripts/deploy.sh all
|
||||
|
||||
# 校验生成的 Clash 配置(最有价值的一条端到端验证)
|
||||
curl -s "http://localhost:3000/sub/<token>/clash" > /tmp/c.yaml && mihomo -t -f /tmp/c.yaml
|
||||
```
|
||||
|
||||
**网络与安装的两个坑**:本机会话注入的 `HTTPS_PROXY` 会让 git/构建挂死,跑任何命令前先剥掉代理(见用户全局 CLAUDE.md)。pnpm 在无 TTY 时执行 `pnpm install` 需要 `CI=true`,原生模块(better-sqlite3、esbuild、vue-demi)的构建许可写在 `pnpm-workspace.yaml` 的 `allowBuilds`。
|
||||
|
||||
## 架构
|
||||
|
||||
pnpm monorepo:`shared/`(zod schema 与 DTO,前后端共用源码,非构建产物)+ `server/`(Hono + better-sqlite3 + Drizzle)+ `web/`(Vue 3 + Naive UI + Pinia)。生产模式由 server 静态托管 web 产物,单端口。
|
||||
|
||||
### 配置生成流水线(核心)
|
||||
|
||||
```
|
||||
SQLite → buildProfileIR({nodeIds}) → ProfileIR → 四个序列化器
|
||||
├─ generateSurge(ir, {selfUrl})
|
||||
├─ generateShadowrocket(ir) 节点订阅
|
||||
├─ generateShadowrocketConf(ir, …) 策略组+规则
|
||||
└─ generateClash(ir, {convertBaseUrl})
|
||||
```
|
||||
|
||||
`services/ir.ts` 是唯一的读库出口,把节点、策略组、规则、Surge 原文段落收成与客户端无关的 `ProfileIR`;四个生成器只读 IR,互不依赖。**新增客户端就是加一个序列化器**,不要在生成器里再查数据库。
|
||||
|
||||
IR 层承担三件防护,改动时不要绕过:
|
||||
- 停用或已删除的策略组会被剔除,引用它的成员与规则一并跳过
|
||||
- `FINAL` 规则永远保留,其策略组失效时降级为 `DIRECT`
|
||||
- `nodeIds` 为空数组表示「不限制」,非空则按订阅分配的节点范围过滤
|
||||
|
||||
### 客户端能力矩阵
|
||||
|
||||
`shared/src/protocols.ts` 的 `clientSupports()` 决定某节点能否下发给某客户端,生成器据此静默排除并回报 `excluded`。当前唯一规则:**Surge 不支持 Shadowsocks over WebSocket**(Surge 的 ss 类型只有 simple-obfs,没有 WS 传输)。这不是 bug,同机的 VMess/Trojan 线路凭据相同、覆盖等价。
|
||||
|
||||
### 协议字段差异
|
||||
|
||||
同一协议在三端写法不同,映射集中在 `shared/src/protocols.ts` 与各生成器:
|
||||
- SS cipher:分享链接用 Xray 命名 `chacha20-poly1305`,Surge/Clash 要 `chacha20-ietf-poly1305`(`normalizeSsCipher()`)
|
||||
- VMess `alterId=0` 必须输出 Surge 的 `vmess-aead=true`,否则连不上且症状隐晦
|
||||
- Hysteria2 的 salamander 混淆:Surge 用 `salamander-password=`,Clash 用 `obfs`/`obfs-password`
|
||||
- SS over WS 在 Clash 侧唯一可行方案是 `plugin: v2ray-plugin`
|
||||
|
||||
这些写法参考了 `../sub-router` 项目(用户已在生产使用的同类系统),遇到新的客户端适配问题优先查它。
|
||||
|
||||
### 策略组的两种取节点方式
|
||||
|
||||
`includeAllNodes` 与 `filterRegex` 可以共存,生成器里 **filterRegex 分支优先**:
|
||||
- 有 `filterRegex` → 动态筛选:Surge `include-all-proxies=true, policy-regex-filter=…`/Clash `filter:`/ShadowRocket `use=true, policy-regex-filter=…`。新增同地区节点会自动进组
|
||||
- 仅 `includeAllNodes` → Surge 静态展开全部节点名,Clash/ShadowRocket 仍用动态引用
|
||||
|
||||
**地区组靠节点名称正则匹配,不看 `region` 字段**。节点命名规范与新增地区的完整步骤见 `docs/ADDING-REGIONS.md`——命名不对就进不了地区组。
|
||||
|
||||
### 规则集:映射与兜底转换
|
||||
|
||||
模板里的 `RULE-SET`/`DOMAIN-SET` 指向外部 URL。Surge/ShadowRocket 直接透传;Clash 需要 rule-providers,`services/rulesets/mapping.ts` 按序尝试映射到 skk.moe 的 Clash 版与 blackmatrix7 的 Clash 目录,映射不到的标记 `needsConvert`,provider URL 指向自托管端点 `/sub/:token/ruleset/:id.yaml`——该端点实时拉取 Surge 版并用 `convert.ts` 转成 classical provider。规则集拉取带 24h 缓存 + etag,失败回落陈旧缓存。
|
||||
|
||||
### ShadowRocket 分两步导入
|
||||
|
||||
ShadowRocket 的原生订阅格式只承载节点,因此拆成两个端点:`/shadowrocket`(节点 base64 URI 列表)与 `/shadowrocket-conf`(策略组与规则的 .conf)。**ShadowRocket 没有 Surge 的 `use=` 订阅引用语法**(真机验证过:`use=true` 会被当成解析不了的成员残留),策略组收节点只能靠 `policy-regex-filter` 从 App 内全部节点里筛(「收纳全部节点」输出 `policy-regex-filter=.*`),因此订阅名随意,但用户在 App 里挂的其他订阅的节点也会被筛进来。ShadowRocket 不支持 `DOMAIN-SET`/`AND`/`URL-REGEX`,生成时跳过并回报 `skippedRules`。**ShadowRocket 的 conf 语法没有引号机制**:策略名(含空格/emoji)一律裸写,加引号会被当作名字的一部分,导致规则指向不存在的策略组而整条失效、流量落到默认行为走代理——Surge 生成器可以加引号,ShadowRocket 生成器绝不可以。
|
||||
|
||||
### 鉴权与订阅
|
||||
|
||||
`/api/*` 走 `adminAuth`:设置了 `ADMIN_TOKEN` 则要求 Bearer,未设置时仅放行 localhost 与 RFC1918 私有网段(公网 IP 一律拒绝)。`/sub/:token/*` 公开,token 用常量时间比对。**订阅 URL 完全由请求来源决定**——后端只返回路径,前端用 `window.location.origin` 拼接,`#!MANAGED-CONFIG` 与 Clash provider URL 取本次请求的 origin,所以没有也不需要「订阅基址」配置项。
|
||||
|
||||
未匹配的 `/api/*` 显式返回 404,不落到 SPA 回退(否则会拿到一页 HTML,调接口时极难排查)。
|
||||
|
||||
### Surge Mac 与 Tailscale 共存
|
||||
|
||||
修改增强模式、DNS、STUN 或 VPN 路由前,先读 [docs/SURGE-TAILSCALE.md](docs/SURGE-TAILSCALE.md)。2026-10-06 已实测:独立 Tailscale 客户端设置 `--accept-dns=false`,Surge 仅将具体 tailnet 域名交给 `100.100.100.100`,配合 always-real-ip 和实际 Tailscale 网络扩展/CLI 的优先 DIRECT 规则。保留原有公网代理、AI DNS 和其他应用的 STUN 阻断;不使用 Exit Node,不需要向项目保存 Tailscale auth key。
|
||||
|
||||
**不能机械套用 `tun-excluded-routes = 100.64.0.0/10`**:本机实测该项创建指向物理网关的路由,覆盖 Tailscale 路由。最终配置不添加这些排除项。skip-proxy、DIRECT 和 VIF 路由排除是不同层次,必须检查实际路由表及普通应用连接。
|
||||
|
||||
tailscale ping 可通不代表 curl/SSH 可通;客户端显示 Running 也不代表公网未受影响。测试必须同时覆盖原代理、AI DNS、MagicDNS、普通 tailnet 服务和外网入站 SSH。使用 Tailscale CLI 做 netcheck 时也需进程例外,避免把自身 STUN 被阻断误判为网络不支持 UDP。测试 VPN 重连前设置失败自动 tailscale down 的恢复措施,并确认用户未同时手动切换。
|
||||
|
||||
现有订阅的持久来源是生产 settings/rules,模板仅供首次初始化;通过 update-db 技能先备份再走 API,不直接改托管文件或重置规则。结论仅限实测环境与场景:两种启动顺序、IPv4/IPv6 服务和蜂窝网络入站 SSH 已通过;睡眠唤醒、长期运行及网络切换尚未验证,不宣称“完美兼容”。
|
||||
|
||||
同账号/同 Surge 订阅不会同步 Tailscale 的本机 `accept-dns` 与 Exit Node 设置。新 Mac 必须逐台检查;MacBook Air 使用相同配置时仍曾保留 accept-dns=true,需在该机单独关闭。不要仅凭 Surge 自带探测成功认定普通应用正常,须另外测不使用显式代理的 curl。
|
||||
|
||||
## 前端约定
|
||||
|
||||
UI 走「站牌导视」设计方向(轨道交通导视系统语言):白底 `#f7f7f4`、深藏青重点色 `#2b3f5c`、**琥珀 `#b26a00` = 直连出口、青 `#007a72` = WARP 净出口**,协议各有线路色圆标。颜色只在承载信息时出现——避免发光、亮边条、装饰性渐变。四个方向的设计对比留在 `docs/design-demos.html`。
|
||||
|
||||
两个共用工具:
|
||||
- `utils/nodeName.ts` 的 `nodeDisplayName()`:与出口标签并排显示时剥掉名字末尾的出口词。**下发给客户端的名字必须保留出口词**,客户端里没有标签,用户只能靠名字区分同协议的两条线路
|
||||
- `utils/clipboard.ts` 的 `copyText()`:`navigator.clipboard` 只在 HTTPS/localhost 可用,局域网 HTTP 访问时是 `undefined`,直接调用会静默失败。必须走这个带 `execCommand` 降级的封装
|
||||
|
||||
## 数据与安全
|
||||
|
||||
SQLite 落在 `server/data/`,存有节点真实凭据。`data/`、`*.db`、`.env` 已在 `.gitignore`——**绝不入 git**。仓库内所有示例、测试、模板一律用占位凭据。节点通过 Web 界面粘贴分享链接导入,不直接读 edge-nodes 目录(见 `docs/SEEDING.md`)。
|
||||
|
||||
schema 变更走 `db/index.ts` 里的 `BOOTSTRAP_SQL` 加 `CREATE TABLE IF NOT EXISTS`,旧库补列用同文件下方的轻量 `ALTER TABLE` 循环(吞掉「列已存在」错误)——没有引入 migration 工具。
|
||||
|
||||
公网部署必须设 `ADMIN_TOKEN` 并用反代提供 HTTPS:订阅 token 就在 URL 里,明文 HTTP 会泄露。
|
||||
项目开发约定统一维护在根目录 `AGENTS.md`,不要在本文件重复复制。
|
||||
项目技能仍从 `.claude/skills/` 加载;双工具使用说明见 `docs/AI-COLLABORATION.md`。
|
||||
节点部署管理已合入本项目,工作区入口见 `edge-nodes/README.md`,使用 `edge-node-multi-protocol` 技能。
|
||||
|
||||
64
README.md
64
README.md
@@ -1,6 +1,6 @@
|
||||
# Proxy Station
|
||||
|
||||
自托管的代理节点与分流规则管理平台。在 Web 界面里管理多协议节点与策略组、规则,向 macOS、iOS、Android 三端下发托管订阅——客户端填一次 URL,之后配置自动更新。
|
||||
统一维护边缘 VPS 节点部署管理与自托管的代理节点、分流规则管理平台。在 Web 界面里管理多协议节点与策略组、规则,向 macOS、iOS、Android 三端下发托管订阅——客户端填一次 URL,之后配置自动更新。
|
||||
|
||||
```
|
||||
节点 ──┐
|
||||
@@ -11,9 +11,11 @@
|
||||
|
||||
## 能做什么
|
||||
|
||||
**边缘节点部署管理**——节点部署脚本、版本兼容参考、交付模板与离线汇总页生成器已合入本仓库,使用项目技能 `edge-node-multi-protocol` 维护 nginx、3x-ui/Xray、WARP 与可选 sing-box 线路。真实配置与交付记录位于 Git 忽略的 `edge-nodes/private/`;入口见 [边缘节点工作区](edge-nodes/README.md)。
|
||||
|
||||
**多协议节点管理**——VMess、Trojan、Shadowsocks、Hysteria2,支持粘贴分享链接批量导入(`vmess://`、`trojan://`、`ss://` 的 SIP002 与 Xray 两种写法、`hysteria2://`),也支持手动填写与导出。节点按站点分段展示。
|
||||
|
||||
**分流规则与策略组**——首次启动自动从 [ClashConnectRules/Surge](https://github.com/ClashConnectRules/Surge) 模板导入(26 个策略组 + 88 条规则,含广告拦截、AI 服务、流媒体、国内直连等)。规则可排序、改策略、启停、增删;策略组可启停并调整顺序,顺序即客户端里的排列顺序。外部规则集可在界面上实时预览内容。
|
||||
**分流规则与策略组**——空库首次启动从内置 Surge 模板初始化广告拦截、AI 服务、流媒体、国内直连等配置。模板参考 [ClashConnectRules/Surge](https://github.com/ClashConnectRules/Surge),已有数据库以管理 API 的实际数据为准,修改模板不会同步更新。规则可排序、改策略、启停、增删;唯一启用的 FINAL 保持末尾,不可停用或删除。策略组可启停、调整成员与显示顺序;显示顺序不决定规则优先级。外部规则集可在界面上预览内容。
|
||||
|
||||
**按客户端自动适配**——同一份数据生成三端配置,客户端不支持的协议在下发时自动屏蔽(例如 Surge 的 ss 类型没有 WebSocket 传输,SS over WS 节点会被排除),字段差异自动映射(cipher 命名、VMess AEAD、Hysteria2 混淆参数等)。
|
||||
|
||||
@@ -31,14 +33,26 @@ ShadowRocket 的原生订阅格式只承载节点,因此拆成两步:先在
|
||||
|
||||
> ClashMeta 侧无 Clash 版本的规则集,由本服务实时拉取 Surge 版并转换后提供,无需外部转换服务。
|
||||
|
||||
三端的规则与 DNS 表达范围有所不同。Shadowrocket 部分规则仍保守跳过;Surge 的 AI Host DNS 映射不自动等同于手机端 DNS 设置。能力、已验证范围与待验项见 [移动端体检](docs/MOBILE-CLIENT-AUDIT-2026-10-04.md)。
|
||||
|
||||
## 当前配置入口
|
||||
|
||||
最新节点启用范围、完整组成员、规则服务归属与验证范围集中在 [当前生产状态](docs/CURRENT-STATE.md)。目前普通代理由 **🚀 Proxy** 控制,只有它引用 **⚡️ Automatic**;**🤖 AI** 可选美国、新加坡、日本或 **🧠 AI Nodes**。
|
||||
|
||||
**📋 All Nodes** 与 **🧠 AI Nodes** 收纳相同的可用节点,按完整名称升序排列,分别保存手动选择。AI Nodes 紧接 AI 显示;在 AI 中选 AI Nodes,再在 AI Nodes 中选择具体节点,即可为 AI 保存独立出口。详见 [独立节点选择](docs/AI-NODES-2026-10-04.md)。
|
||||
|
||||
AI 规则位于广告与普通分流之前,保留更早的协议防护与 Unbreak 直连例外。Net.Coffee 的随机 DNS 探测子域已纳入 AI;本机 Chrome+Surge 复测显示美国 Cloudflare「未检测到泄露」,验证边界见 [DNS 复测](docs/NET-COFFEE-DNS-2026-10-04.md)。
|
||||
|
||||
## 快速开始
|
||||
|
||||
使用 Node 22 与 pnpm,按已提交的锁文件安装:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
CI=true pnpm install --frozen-lockfile
|
||||
pnpm dev # server :3000 + web :5173
|
||||
```
|
||||
|
||||
打开 http://localhost:3000 (或开发时的 :5173),然后:
|
||||
开发时打开 http://localhost:5173,然后:
|
||||
|
||||
1. **节点** → 批量导入,粘贴分享链接。命名建议带地区词(如 `🇺🇸 US LA VMess Direct`),地区策略组靠节点名正则匹配
|
||||
2. **订阅** → 新建订阅,勾选要开放的节点
|
||||
@@ -48,6 +62,10 @@ pnpm dev # server :3000 + web :5173
|
||||
|
||||
## 部署
|
||||
|
||||
当前 LA 生产服务使用 Node 22+systemd,由 nginx 提供 HTTPS;日常应用发布使用 [deploy 技能](.claude/skills/deploy/SKILL.md),线上节点/组/规则数据通过 [update-db 技能](.claude/skills/update-db/SKILL.md)管理。部署代码不自动覆盖已有数据。详细步骤见 [生产部署](docs/DEPLOY.md)。
|
||||
|
||||
另提供 Docker Compose 部署方式:
|
||||
|
||||
```bash
|
||||
ADMIN_TOKEN=<随机长字符串> docker compose up -d
|
||||
```
|
||||
@@ -59,27 +77,49 @@ SQLite 落在 `./data` 卷。订阅 URL 由请求来源自动推断,无需配
|
||||
## 校验
|
||||
|
||||
```bash
|
||||
# Clash 产物(最有价值的一条端到端验证)
|
||||
curl -s "http://localhost:3000/sub/<token>/clash" > /tmp/c.yaml && mihomo -t -f /tmp/c.yaml
|
||||
|
||||
pnpm -r typecheck
|
||||
pnpm test
|
||||
|
||||
# 下载成功后再校验真实产物;生产验收使用实际 HTTPS 订阅
|
||||
umask 077
|
||||
check_dir="$(mktemp -d /private/tmp/proxy-station-check.XXXXXX)"
|
||||
curl -fsSL --max-time 45 "http://localhost:3000/sub/<token>/clash" -o "$check_dir/clash.yaml" && \
|
||||
test -s "$check_dir/clash.yaml" && mihomo -t -d "$check_dir" -f "$check_dir/clash.yaml"
|
||||
|
||||
# 在安装 Surge 的 macOS 上检查实际配置与远程规则快照
|
||||
python3 scripts/check-surge.py /private/tmp/<profile>.conf --fetch-rulesets
|
||||
```
|
||||
|
||||
Surge 与 ShadowRocket 需真机导入验收。
|
||||
真实配置、URL 与凭据仅保存在私有文件。原生解析成功不等于节点连接或隐私验收;本机 Surge 的组与网站 DNS 复测已有记录,iOS/Android 业务、UDP 与完整 DNS/WebRTC 测试仍需真机完成。完整流程见 [订阅变更与验证](docs/SURGE-CHANGES.md)。
|
||||
|
||||
## 文档
|
||||
|
||||
| 文件 | 内容 |
|
||||
|---|---|
|
||||
| `docs/SEEDING.md` | 从 edge-nodes 交付文档导入真实节点、命名建议 |
|
||||
| [docs/CURRENT-STATE.md](docs/CURRENT-STATE.md) | 最新生产节点、策略组、规则、客户端下发数量与 Session 接续入口 |
|
||||
| `edge-nodes/README.md` | 节点部署工作区、当前舰队、私有资料与工程迁移记录 |
|
||||
| `.claude/skills/edge-node-multi-protocol/SKILL.md` | 节点部署、重建、维护与交付的共用技能 |
|
||||
| `docs/SEEDING.md` | 从本仓库私有交付文档导入真实节点、命名建议 |
|
||||
| `docs/ADDING-REGIONS.md` | 新增地区(香港、日本等)时的节点命名与策略组配套步骤 |
|
||||
| `CLAUDE.md` | 架构要点与开发约定 |
|
||||
| `AGENTS.md` | Codex/Claude Code 共用的架构要点与开发约定 |
|
||||
| `CLAUDE.md` | 导入 `AGENTS.md` 的 Claude Code 兼容入口 |
|
||||
| `docs/AI-COLLABORATION.md` | 双工具加载方式、共用技能与协作维护方法 |
|
||||
| `docs/DEPLOY.md` | LA 生产环境部署与验证 |
|
||||
| `docs/SURGE-PRIVACY.md` | Surge AI 域名 DNS 分流与验证边界 |
|
||||
| `docs/SURGE-AUDIT-2026-10-03.md` | macOS Surge 生产订阅体检、复现结果与修复建议 |
|
||||
| `docs/SURGE-FIX-2026-10-03.md` | 体检问题修复、生产备份与实际订阅验证结果 |
|
||||
| `docs/SURGE-CHANGES.md` | 节点、组、规则与 DNS 变更的 Surge 官方 CLI 验证流程 |
|
||||
| [docs/AI-NODES-2026-10-04.md](docs/AI-NODES-2026-10-04.md) | 独立 AI Nodes、emoji、名称排序与客户端选择保留 |
|
||||
| [docs/PROXY-AUTOMATIC-2026-10-04.md](docs/PROXY-AUTOMATIC-2026-10-04.md) | 普通代理入口与 Automatic 的选择链 |
|
||||
| [docs/AI-RULE-REVIEW-2026-10-04.md](docs/AI-RULE-REVIEW-2026-10-04.md) | AI 精确补充及 Apple/Global/Bing 专用分流调整 |
|
||||
| [docs/AI-PRIORITY-2026-10-04.md](docs/AI-PRIORITY-2026-10-04.md) | AI、协议防护、直连例外与广告规则优先级 |
|
||||
| [docs/NET-COFFEE-DNS-2026-10-04.md](docs/NET-COFFEE-DNS-2026-10-04.md) | 检测域名实现、AI 后缀规则及本机 DNS 复测 |
|
||||
| [docs/MOBILE-CLIENT-AUDIT-2026-10-04.md](docs/MOBILE-CLIENT-AUDIT-2026-10-04.md) | Shadowrocket/ClashMeta 生成器差异、已部署修复与手机待验项 |
|
||||
|
||||
## 安全
|
||||
|
||||
数据库存有节点真实凭据。`data/`、`*.db`、`.env` 已在 `.gitignore` 中,**绝不入 git**;仓库内所有示例、测试、模板一律使用占位凭据。订阅 token 可随时在「订阅」页禁用或删除。
|
||||
数据库和节点私有工作区存有真实凭据。`data/`、`*.db`、`.env`、`edge-nodes/private/`、`config.env` 及其备份、`node-links/` 已在 `.gitignore` 中,**绝不入 git**;可入库的示例、测试、模板一律使用占位凭据。节点私有资料通过受控备份恢复,Docker 构建上下文排除节点工具与私有资料。订阅 token 可随时在「订阅」页禁用或删除。
|
||||
|
||||
## 技术栈
|
||||
|
||||
pnpm monorepo:`server/`(Hono + better-sqlite3 + Drizzle)+ `web/`(Vue 3 + Vite + Naive UI + Pinia)+ `shared/`(zod schema 与 DTO,前后端共用)。生产模式由 server 静态托管前端产物,单端口运行。
|
||||
应用为 pnpm monorepo:`server/`(Hono + better-sqlite3 + Drizzle)+ `web/`(Vue 3 + Vite + Naive UI + Pinia)+ `shared/`(zod schema 与 DTO,前后端共用)。生产模式由 server 静态托管前端产物,单端口运行。节点部署管理使用独立 Bash/Python 工具,不加入应用运行包。
|
||||
|
||||
@@ -4,12 +4,16 @@
|
||||
|
||||
> 背景:策略组的地区筛选靠**节点名称正则**,不靠节点的 `region` 字段。所以节点命名是一切的前提。
|
||||
|
||||
## 当前状态(2026-09-14)
|
||||
## 当前状态(2026-10-04)
|
||||
|
||||
节点登记/启用数量、完整策略组矩阵和服务规则归属集中见 [当前生产状态](CURRENT-STATE.md)。下面维护地区命名与扩展方法;新增站点不自动启用已有停用线路。
|
||||
|
||||
有美国、新加坡、日本三个地区的节点,策略组里对应 `🇺🇸 United States`、`🇸🇬 Singapore`、`🇯🇵 Japan` 三个地区组。
|
||||
日本组于 2026-09-14 随 jp 站点(VMISS 东京,八条线路)按本文步骤重建,站点码 `JP`,已加入所有成员里含美国或新加坡的服务组(Proxy/Automatic/Streaming/Apple/AI/Bybit/Global Services)。约定:**凡是成员含美国或新加坡的组,都应同时含日本**;Speedtest/NoAuto 只含大陆与 Automatic,不在此列。
|
||||
日本组于 2026-09-14 随 jp 站点(VMISS 东京,八条线路)按本文步骤重建,站点码 `JP`,当前加入的服务组为 Proxy/Automatic/Streaming/AI/Bybit。约定:**凡是成员含美国或新加坡的组,都应同时含日本**;NoAuto 当前只含大陆,不在此列。Speedtest 组及其专用规则已删除。
|
||||
2026-10-04 已删除线上 Apple/Global Services 组及指向它们的专用规则,并删除 AI 的 Bing 规则;不再给这两个已删除的服务组添加地区。同日进一步将所有原先直接指向 Automatic 的 20 条规则改为 Proxy,包括 `global`/`global_plus`、CDN 及 Facebook 等;x.com/bing.com 当前自然匹配 `global` → Proxy,由用户在 Proxy 中选择出口。Automatic 改为 `url-test`,仅 Proxy 保留其成员引用;AI/Speedtest/NoAuto 已移除 Automatic。具体约束与验证见 [Proxy 与 Automatic 选择链](PROXY-AUTOMATIC-2026-10-04.md)。App Store DIRECT、Apple News 美国及 Apple Relay AI 例外继续保留。原始初始化模板仍有旧组和旧引用;已有生产数据以管理 API 为准,不通过规则 reset 恢复旧分流。
|
||||
香港、台湾、英国、韩国的地区组**仍处于删除状态**——留着会是空组,在客户端里显示为无可用节点。
|
||||
兜底的 `🌍 Other` 组也已删除:所有节点都能被现有三个地区组收纳,它恒为空。
|
||||
当前组显示顺序为 Proxy、Automatic、All Nodes、美国、新加坡、日本、AI、🧠 AI Nodes、Streaming、Bybit、Mainland、NoAuto。🧠 AI Nodes 为独立收纳全部节点的 select 组,紧接 AI;其选择与 All Nodes 独立保存,见 [AI Nodes](AI-NODES-2026-10-04.md)。两组的节点按完整名称字符串升序输出;当前节点 sortOrder 统一为默认 0,新增节点保持默认值即可沿用名称排序。组显示顺序不决定分流优先级;实际规则保留协议防护与 Unbreak 例外在前,启用的 AI 规则集中置于广告及普通分流之前,见 [AI 与广告优先级](AI-PRIORITY-2026-10-04.md)。
|
||||
|
||||
## 一、给新节点命名(决定它能被哪个组收纳)
|
||||
|
||||
@@ -57,7 +61,7 @@ const STATION_LABELS: Record<string, string> = {
|
||||
| 间隔 | `300` |
|
||||
| 容差 | `50` |
|
||||
|
||||
「收纳全部节点」+「筛选正则」的组合会生成 `include-all-proxies=true, policy-regex-filter=…`(Surge)、`include-all-proxies: true, filter: …`(Clash)、`use=true, policy-regex-filter=…`(ShadowRocket),三端都是**动态筛选**——之后再加同地区节点会自动进组,不用改配置。
|
||||
「收纳全部节点」+「筛选正则」的组合会生成 `include-all-proxies=true, policy-regex-filter=…`(Surge)、`include-all-proxies: true, filter: …`(Clash)、`policy-regex-filter=…`(ShadowRocket),三端都是**动态筛选**——之后再加同地区节点会自动进组,不用改配置。ShadowRocket 不使用 `use=true`,其正则从 App 内全部节点筛选。
|
||||
|
||||
各地区的正则(取自原模板,可直接复制):
|
||||
|
||||
@@ -73,9 +77,9 @@ const STATION_LABELS: Record<string, string> = {
|
||||
|
||||
## 四、兜底组 `🌍 Other`(当前不存在)
|
||||
|
||||
原模板有一个 `🌍 Other` 组,用否定前瞻正则收纳「不属于任何已建地区组」的节点。**当前已删除**,因为现有节点都能被美/新两个组收纳,它恒为空。
|
||||
原模板有一个 `🌍 Other` 组,用否定前瞻正则收纳「不属于任何已建地区组」的节点。**当前已删除**,因为现有节点都能被美/新/日三个组收纳,它恒为空。
|
||||
|
||||
只有当你加入的节点**不属于任何已建地区组**(比如加了德国节点但没建德国组)时,才需要重建它:
|
||||
加入尚无地区组的节点时,优先按本文新建对应地区组。若确需恢复 Other,以下仅是历史模板示例,必须先核对目标客户端的正则能力与实际产物:
|
||||
|
||||
| 字段 | 值 |
|
||||
|---|---|
|
||||
@@ -87,6 +91,8 @@ const STATION_LABELS: Record<string, string> = {
|
||||
^((?!港|🇭🇰|香港|HK|Hong|日|🇯🇵|日本|JP|Japan|坡|🇸🇬|新加坡|狮城|SG|Singapore|美|🇺🇸|美国|US|States|American|Remain|Expired|官网|如需|套餐|去除|剩余|距离|Reset|重置|流量).)+$
|
||||
```
|
||||
|
||||
否定前瞻不能视为三个客户端都支持的正则;本系统会把 filterRegex 透传给 Clash 的 filter,不自动改写成等价的排除条件。当前简单地区正则的验证结果不覆盖这个历史示例,跨端恢复需先验证,必要时调整数据模型与生成器。
|
||||
|
||||
**如果重建了 Other,每次新增地区组都必须往这个否定前瞻里追加对应关键字**,否则新地区节点会同时出现在自己的组和 Other 里。
|
||||
|
||||
## 五、把新地区加进服务组的可选项
|
||||
@@ -95,36 +101,33 @@ const STATION_LABELS: Record<string, string> = {
|
||||
|
||||
| 组 | 当前成员 | 建议 |
|
||||
|---|---|---|
|
||||
| `⚡️ Automatic` | SG、US | 加入所有地区组 |
|
||||
| `🚀 Proxy` | Automatic、SG、US | 加入所有地区组 |
|
||||
| `🎬 Streaming` | SG、US | 加入解锁能力强的地区(HK/TW/JP) |
|
||||
| `🤖 AI` | Automatic、US、SG | 加入 JP、US 等 AI 服务可用地区 |
|
||||
| `🌐 Global Services` | Automatic、US、SG | 同 AI |
|
||||
| `🍎 Apple` | Mainland、US、JP | 按需加入 HK |
|
||||
| `⚡️ Automatic`(url-test) | SG、US、JP | 加入所有地区组;仅由 Proxy 引用,不让规则直接指向它 |
|
||||
| `🚀 Proxy` | Automatic、All Nodes、SG、US、JP | 加入所有地区组 |
|
||||
| `🎬 Streaming` | SG、US、JP | 加入解锁能力强的地区(HK/TW/JP) |
|
||||
| `🤖 AI` | US、SG、JP、🧠 AI Nodes | 加入 AI 服务可用地区;不加入 Automatic |
|
||||
| `🧠 AI Nodes`(select) | 全部节点 | 保持 includeAllNodes,与 All Nodes 独立选择;新增节点自动进入,sortOrder 保持默认 0 |
|
||||
| `🪙 Bybit` | DIRECT、SG、US、JP | 同 Proxy |
|
||||
| `🎯 NoAuto` | Mainland | 不加入 Automatic |
|
||||
|
||||
成员的顺序就是客户端里的排列顺序,**第一个成员是该组的默认选中项**。
|
||||
成员的顺序就是客户端里的排列顺序。对于 `select` 组,客户端没有有效的已保存选择时,**第一个成员是默认选中项**;Automatic 为 `url-test`,按测速结果与容差选择成员。
|
||||
|
||||
## 六、地区流媒体解锁规则(可选)
|
||||
|
||||
模板原有 `stream_hk`、`stream_jp`、`stream_tw`、`stream_kr`、`stream_eu` 等按地区解锁的规则集。删除地区组时,这些规则被改指向了 `🎬 Streaming`。
|
||||
模板原有按地区解锁的规则集。当前 `stream_us` 的域名/IP 两条已指向美国,`stream_jp` 的两条已指向日本;`stream_hk`、`stream_tw`、`stream_kr`、`stream_eu` 的八条规则仍指向 `🎬 Streaming`,相关地区组当前不存在。
|
||||
|
||||
如果新建了对应地区组,可以在「规则」页把这些规则的策略改回该地区组,解锁效果更精准:
|
||||
|
||||
| 规则集 | 建议策略 |
|
||||
|---|---|
|
||||
| `stream_hk`(non_ip + ip 两条) | `🇭🇰 Hong Kong` |
|
||||
| `stream_jp`(两条) | `🇯🇵 Japan` |
|
||||
| `stream_jp`(两条) | `🇯🇵 Japan`,当前已应用 |
|
||||
| `stream_tw`、`stream_kr`、`stream_eu` | 对应地区组 |
|
||||
|
||||
## 七、验证
|
||||
|
||||
1. 「策略组」页:新地区组的**命中**一栏应列出该地区的所有节点
|
||||
2. 「节点」页:新站点应有独立分段,标题显示国旗
|
||||
3. 命令行校验 Clash 产物:
|
||||
```bash
|
||||
curl -s "http://<host>/sub/<token>/clash" > /tmp/c.yaml && mihomo -t -f /tmp/c.yaml
|
||||
```
|
||||
3. 从真实 HTTPS 订阅入口获取各端产物,先确认下载成功及配置结构,再按 [订阅变更与验证](SURGE-CHANGES.md)运行 Surge CLI/规则集快照与独立目录的 `mihomo -t`;结构通过后仍需客户端连接验收。
|
||||
4. 若重建过 `🌍 Other`,确认它的命中里**不再包含**新地区节点
|
||||
|
||||
## 删除某个地区(反向操作)
|
||||
|
||||
67
docs/AI-COLLABORATION.md
Normal file
67
docs/AI-COLLABORATION.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Codex 与 Claude Code 协作
|
||||
|
||||
项目开发约定以根目录 `AGENTS.md` 为唯一来源。原 `CLAUDE.md` 中的架构、协议适配、前端设计、数据与部署约定已迁入,并根据当前代码补充了初始化、环境变量、AI DNS 与验证说明。
|
||||
|
||||
最新生产盘点统一维护在 [CURRENT-STATE.md](CURRENT-STATE.md),较长的 Session 变更证据保存在其链接的专题文档。AGENTS.md 维护持续适用的约束;专题记录保留原阶段数量,不能用旧快照替代新的 API 盘点。
|
||||
|
||||
## 指令入口
|
||||
|
||||
| 工具 | 项目指令 | 项目技能 |
|
||||
|---|---|---|
|
||||
| Codex | 原生读取根目录 `AGENTS.md` | `.agents/skills/<name>/SKILL.md` |
|
||||
| Claude Code | `CLAUDE.md` 中的 `@AGENTS.md` 导入 | `.claude/skills/<name>/SKILL.md` |
|
||||
|
||||
Claude Code v2.1.277 起支持直接读取 `AGENTS.md`,但默认在当前目录或祖先目录已有 `CLAUDE.md`/`CLAUDE.local.md` 时优先读取 Claude 文件。保留 `CLAUDE.md` 的显式导入,使支持导入的旧版本与新版本都能读取同一份规则;无需修改用户全局配置。原生支持不可用的部分旧版本会话也可通过导入加载。
|
||||
|
||||
文件名使用大写、复数的 `AGENTS.md`。不要新增 `agent.md`、`AGENT.md`、`agents.jmd` 或复制一份独立的 Codex 规则。Claude 的 `@path` 是其导入语法,Codex 项目入口本身包含完整规则,无须依赖此语法。
|
||||
|
||||
新开会话时从仓库根目录启动工具。Claude Code 可用 `/memory` 或 `/context` 检查项目指令;Codex 可询问当前项目约定,并用 `/skills` 查看发现的技能。若当前会话未发现新技能,重开会话;不需要额外安装项目技能。全局个人指令与机器设置继续由各工具管理,不写入本仓库。
|
||||
|
||||
## 共用技能
|
||||
|
||||
保留现有 `.claude/skills/` 作为技能源目录,Codex 入口使用相对目录符号链接。Codex 官方支持扫描符号链接指向的技能目录,脚本及评估样例因此也共用一份:
|
||||
|
||||
```text
|
||||
.claude/skills/
|
||||
├── deploy/ # SKILL.md 与 scripts/deploy.sh
|
||||
├── update-db/ # SKILL.md、scripts/api.sh 与 evals/
|
||||
└── edge-node-multi-protocol/ # 节点部署脚本、参考资料与交付模板
|
||||
.agents/skills/
|
||||
├── deploy -> ../../.claude/skills/deploy
|
||||
├── update-db -> ../../.claude/skills/update-db
|
||||
└── edge-node-multi-protocol -> ../../.claude/skills/edge-node-multi-protocol
|
||||
```
|
||||
|
||||
| 任务 | 技能 | Codex 显式调用 | Claude Code 显式调用 |
|
||||
|---|---|---|---|
|
||||
| 部署前端/后端到 LA | `deploy` | `$deploy` | `/deploy` |
|
||||
| 线上数据盘点、导入预览或修改 | `update-db` | `$update-db` | `/update-db` |
|
||||
| 边缘 VPS 部署、重建、协议维护与交付 | `edge-node-multi-protocol` | `$edge-node-multi-protocol` | `/edge-node-multi-protocol` |
|
||||
|
||||
自然语言请求也可以匹配技能。技能提供操作方法,具体外部操作范围仍以用户任务授权为准;阅读部署文档或修改代码不会自动触发上线。
|
||||
|
||||
脚本示例统一保留已有 `.claude/skills/` 路径,两种工具都可在仓库根目录执行。同一脚本也可从 `.agents/skills/` 链接路径执行。新增技能时只写一份源目录,并在 `.agents/skills/` 增加相对链接。
|
||||
|
||||
Git 应保存这些目录链接;在 Windows 上克隆时需要启用符号链接支持,推荐 WSL。若 checkout 将链接变成文本文件,Codex 无法自动发现技能,应恢复目录链接;不要复制出第二份技能。
|
||||
|
||||
## 维护与接续
|
||||
|
||||
- 架构或业务约束改动更新 `AGENTS.md`;较长的专题说明放入 `docs/` 并按任务引用。
|
||||
- 生产任务先经 update-db 只读盘点,区分登记总量、启用数量、token 范围和客户端排除;当前节点、组成员与显示顺序集中更新 CURRENT-STATE.md,阶段性的数量/备份/验收仍保留在原专题文档。
|
||||
- 运维流程改动更新 `.claude/skills/<name>/SKILL.md` 及其脚本;`.agents/skills/` 只维护链接。
|
||||
- 节点与应用在本仓库统一维护,节点目录与当前舰队见 `edge-nodes/README.md`。通用工具可入库,真实配置/交付产物放 `edge-nodes/private/` 并用受控备份转交;原 `../edge-nodes/` 仅保留迁移前备份。旧工程指令保存在私有归档中,不作为当前约定。
|
||||
- 普通开发从 `shared` 的类型/校验、`server` 的 API 与 IR、`web` 的状态与页面定位改动。新增客户端先扩展客户端类型与能力矩阵,再增加独立生成器和订阅端点。
|
||||
- 修改规则模板只影响初始化,已有库的配置更新走管理 API;代码部署与数据更新分别执行。
|
||||
- 本地验证按改动范围选择类型检查、单测或构建。临时启动与数据库验证在进程启动前设置临时 `DATA_DIR`,避免触碰真实节点数据。
|
||||
- 接续时区分本地源码、已部署源码、生产数据库、客户端活动配置/模块及实际网络结果。未提交源码可能已经在用户授权下部署,部署记录不是新一轮上线授权;文档修改也不触发业务写入、订阅刷新或节点启用。
|
||||
- All Nodes/AI Nodes 独立选择、零权重名称排序、Proxy 为唯一 Automatic 父组及 AI 规则优先级属于当前业务约定。改组名需跟踪 API 级联引用和客户端保存选择的迁移,不能仅看生成文本通过校验。
|
||||
- 交接说明记录已完成改动、验证结果与未完成项;可复用的认识写入项目文件,不依赖某个工具的私有会话记忆。
|
||||
|
||||
## 官方依据
|
||||
|
||||
- [Codex:AGENTS.md 加载规则](https://developers.openai.com/codex/guides/agents-md)
|
||||
- [Codex:技能目录与符号链接支持](https://developers.openai.com/codex/skills)
|
||||
- [Claude Code:AGENTS.md 支持与共用导入](https://code.claude.com/docs/en/memory#agentsmd)
|
||||
- [Claude Code:项目技能](https://code.claude.com/docs/en/skills)
|
||||
|
||||
支持行为核对于 2026-10-03;若工具升级后入口行为变化,按上述官方文档重新核对。
|
||||
33
docs/AI-NODES-2026-10-04.md
Normal file
33
docs/AI-NODES-2026-10-04.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# 🧠 AI Nodes 独立节点选择(2026-10-04)
|
||||
|
||||
本记录包含创建、改名及排序两个阶段;完整最新盘点见 [当前生产状态](CURRENT-STATE.md)。登记节点为 30 个,其中 12 个启用、18 个停用;全节点组仍受启用状态、订阅范围与客户端能力过滤。
|
||||
|
||||
按用户要求,经生产管理 API 新增名为 `AI Nodes` 的启用 select 组,同日后续添加 emoji 改名为 `🧠 AI Nodes`,显示在 `🤖 AI` 下方。它与 `📋 All Nodes` 一样启用 `includeAllNodes`,没有正则和显式成员,直接收纳订阅范围内、该客户端支持的全部启用节点。
|
||||
|
||||
`🤖 AI` 的成员顺序现在为美国、新加坡、日本、🧠 AI Nodes;原 All Nodes 引用已替换为该独立组,改名时 API 级联更新引用。All Nodes 继续作为 Proxy 的选项。
|
||||
|
||||
两个组的节点列表相同,但组名和客户端策略身份独立,分别保存手动选择。用户在 AI 中选择 🧠 AI Nodes 后,再在 🧠 AI Nodes 中选择具体节点,即可让 AI 流量使用独立于 All Nodes 的节点选择。新增或停用节点时,两组由同一订阅生成流程更新,不维护两份静态节点清单。
|
||||
|
||||
当前组显示顺序:Proxy → Automatic → All Nodes → United States → Singapore → Japan → AI → 🧠 AI Nodes → Streaming → Bybit → Mainland → NoAuto。
|
||||
|
||||
## 名称排序与 emoji 优化
|
||||
|
||||
用户进一步要求给 AI Nodes 添加合适的 emoji,并将它与 All Nodes 中的节点按名称字符串排列。`🧠` 表示 AI 专用节点选择,和 `🤖 AI` 主服务策略组区分。
|
||||
|
||||
已有 IR 按节点 `sortOrder`、完整 `name` 升序读取。原排序权重偏向协议顺序,导致日本、新加坡节点交错;本次经节点管理 API 将 28 个非零权重改为默认 0,现有 30 个节点统一同权重,由完整名称决定顺序。节点名称、凭据、协议与开关不变,更新只影响 sortOrder 和 updatedAt。这也使节点声明及依赖同一节点来源的动态地区列表按名称排列。Web 节点页仍使用其既有站点、协议与出口排序。
|
||||
|
||||
Surge 的全部节点组继续静态展开支持的节点;Clash 从按名称排列的 proxies 动态收纳;Shadowrocket 的节点订阅原本已经按完整名称排序,conf 继续用 `policy-regex-filter=.*` 收纳 App 内节点。Shadowrocket 中其他订阅或 App 自定义排序的显示次序不由此服务控制。
|
||||
|
||||
新建或导入节点的 schema 默认 sortOrder 为 0,保持默认即可沿用名称排序;显式设置非零权重会先按权重排列。本次使用既有数据字段和生成流程,未修改或部署生成器代码。优化前另有在线备份:`/opt/proxy-station/data/backups/before-polish-ai-nodes-name-order-20261004-113612.db`,root 属主、600 权限。
|
||||
|
||||
优化后重读确认实体数量不变,规则与设置完全一致;组只发生名称及引用级联更新,节点只发生权重和更新时间调整。真实 HTTPS Surge 两组各 12 个支持的节点均按完整名称升序排列,Clash 的 proxies 来源与 Shadowrocket 节点订阅同样通过名称排序核对。Surge 官方 CLI、49/49 规则集快照与 `mihomo -t` 再次通过,域名遮蔽提示为 0。刷新本机同一托管配置后,有效配置及两个组的运行时选项都已确认顺序;为改名后的新客户端组身份恢复了之前选择的同一节点,原 All Nodes 选择与 AI 实际路径保持不变。
|
||||
|
||||
## 创建阶段的备份与验证
|
||||
|
||||
- 写入前 SQLite 在线备份:`/opt/proxy-station/data/backups/before-add-ai-nodes-selector-20261004-112540.db`,root 属主、600 权限。
|
||||
- 重读确认 12 个组、21 个成员、30 个节点、130 条规则;仅新增 AI Nodes、替换 AI 的该项引用及调整显示顺序。其他原组内容保持一致,原节点、规则和设置与变更前相同。
|
||||
- 从真实 HTTPS 入口读取 Surge、Clash、Shadowrocket conf,逐端确认 AI Nodes 与 All Nodes 的内容完全一致、AI 只引用 AI Nodes,且新组紧接 AI。Surge 当前两组各有 12 个客户端支持的节点,节点数不是数据库总节点数。
|
||||
- Surge 官方 CLI、49/49 远程规则集快照校验及 Clash `mihomo -t` 通过;静态域名遮蔽提示为 0。
|
||||
- 本机同一托管配置已确认生效,有效配置与运行时均提供独立 AI Nodes 手动选择组;其运行时节点选项与 All Nodes 完全一致,既有 AI 与 All Nodes 的已保存选择保持不变。没有通过切换节点来做独立性测试。
|
||||
|
||||
本次只修改生产数据与文档,未部署代码。Shadowrocket 仅检查订阅结构,未做手机真机编译或连接测试;其他设备刷新订阅后取得新组。
|
||||
46
docs/AI-PRIORITY-2026-10-04.md
Normal file
46
docs/AI-PRIORITY-2026-10-04.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# AI、广告与其他规则的优先级(2026-10-04)
|
||||
|
||||
本文保留删除 Speedtest、集中排列 AI 规则时的阶段记录。后续加入 Net.Coffee 规则及独立 `🧠 AI Nodes` 后,现状为 12 组、130 条规则、31 条启用 AI 规则;完整盘点见 [当前生产状态](CURRENT-STATE.md)。
|
||||
|
||||
用户要求删除 Speedtest 策略组及其规则,调整组显示顺序,并让 AI 优先于广告和普通分流。审阅现有宽泛 googleapis.com AI 规则与协议防护后,用户明确选择保留 STUN 阻断及 Google 更新/翻译直连例外。本次通过管理 API 删除与排序,未修改规则匹配范围、策略、参数或启用状态,未部署代码。
|
||||
|
||||
## 本阶段策略组显示顺序
|
||||
|
||||
Proxy → Automatic → All Nodes → United States → Singapore → Japan → AI → Streaming → Bybit → Mainland → NoAuto。
|
||||
|
||||
前六组相对顺序保持原状。Speedtest 组与其一条 Speedtest RULE-SET 已删除,测速服务继续匹配后续规则。组显示顺序不决定规则优先级;规则从上到下首条匹配生效,pre-matching 另有更高优先级,依据 [Surge 规则说明](https://manual.nssurge.com/rules/overview.html)。此次启用的顶层规则与已下载规则集都没有 pre-matching 参数。
|
||||
|
||||
## 本阶段实际连接规则顺序
|
||||
|
||||
1. STUN REJECT,以及 googlevideo.com 的 UDP REJECT-NO-DROP。
|
||||
2. Unbreak DIRECT;保留包括 update.googleapis.com、translate.googleapis.com 等 Google 例外。当前规则集与 AI 域名样例的交叉检查没有发现其他 AI 服务域名被 Unbreak 覆盖。
|
||||
3. 全部 30 条启用的 AI 规则集中排列,保留原有相对顺序。两条已停用的 AI 规则仍停用,全局 DOH 接管没有启用。
|
||||
4. 原有业务例外,如 PayPal 指定美国、Bybit 的 inapps.appsflyer.com、api.amplitude.com 的 Proxy 等。
|
||||
5. 广告与隐私规则。四条广告列表的相对顺序保持原状;IP 拦截集原有 no-resolve 参数保留,不新增泛化遥测放行。
|
||||
6. 其余服务、地区、国内、CDN 和 global 等规则,彼此相对顺序保持原状。
|
||||
7. 唯一启用 FINAL 末尾兜底,仍指向 NoAuto,并保留 dns-failed。
|
||||
|
||||
这是现有 AI 匹配范围的优先级调整,不将整个共享服务平台扩展为 AI。googleapis.com、api.github.com 及原有共享域名的范围沿用既有配置。外部规则集仍会动态更新,后续业务规则变更需重做覆盖审阅。
|
||||
|
||||
## DNS 与客户端范围
|
||||
|
||||
Surge 的 AI DNS 映射仍由连接规则派生;Unbreak 的默认 DNS 映射在 AI 映射之前,AI 域名使用 Cloudflare DoH 并随 AI 出口。手写 Host、节点引导 IP 和 General 均未改动,不改变全局 DNS 或端口 53 的路由。
|
||||
|
||||
STUN 的 PROTOCOL 规则仍仅在能表达它的客户端下发生效;现有 Clash/Shadowrocket 生成器跳过该应用协议类型,此次没有新增其他客户端的 STUN 防护。Host 不能表达协议/进程/IP 条件,生成配置与静态域名匹配也不能证明设备 DNS/WebRTC 没有泄漏。
|
||||
|
||||
## 本阶段备份与验证
|
||||
|
||||
写入前在线备份:`/opt/proxy-station/data/backups/before-remove-speedtest-ai-priority-20261004-105853.db`,root 属主、600 权限。敏感 API 快照和订阅仅保存在私有临时目录。
|
||||
|
||||
重读确认 11 个组、129 条规则、21 个组成员、30 个节点;除 Speedtest 删除与两个排序外,其余实体字段保持原状。当前没有直接指向 Automatic 的规则,只有 Proxy 引用它,Automatic 仍为 url-test。
|
||||
|
||||
候选 Surge 配置先通过官方 CLI,写入后从实际 HTTPS 入口读取三个客户端的订阅:
|
||||
|
||||
- 三端组显示顺序均与上述一致,Speedtest 组及引用已消失,AI 均位于广告规则之前。
|
||||
- Surge/Shadowrocket 以当前规则集快照检查 126 个 AI 域名样例,全部首条域名匹配 AI;包括 Claude/OpenAI、附件/MCP、Statsig、已确认的 Datadog/Sentry/LiveKit 主机及 api.github.com。Google 更新/翻译例外为 DIRECT,x.com/bing.com 为 Proxy,doubleclick.net 仍 REJECT,Bybit 与 Amplitude 业务例外保持原策略。
|
||||
- Surge self URL 正确,Host 中 Unbreak 在 AI 集之前,Statsig 的 AI DNS 映射正确;官方 CLI 与 49/49 规则集快照再次通过,静态域名遮蔽提示为 0。
|
||||
- Clash 结构与 `mihomo -t` 通过,FINAL/MATCH 末尾兜底保留。
|
||||
|
||||
样例检查不模拟 IP、进程、协议、CNAME 或设备缓存。本阶段 Shadowrocket 未做真机编译,所有客户端均未做业务连接、测速或 DNS/WebRTC 实测;未切换本机活动配置或修改设备已保存选择。
|
||||
|
||||
同日后续已完成本机 Net.Coffee DNS 复测,见 [检测机制与结果](NET-COFFEE-DNS-2026-10-04.md);随后创建独立 `🧠 AI Nodes`、统一节点默认权重为 0,并验证两组完整名称排序与保存选择,见 [AI Nodes](AI-NODES-2026-10-04.md)。这些后续验证不构成手机或其他业务路径的验收。
|
||||
86
docs/AI-RULE-REVIEW-2026-10-04.md
Normal file
86
docs/AI-RULE-REVIEW-2026-10-04.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# 🤖 AI 规则审阅与精确补齐(2026-10-04)
|
||||
|
||||
本文按阶段保留规则补齐及后续删组、调整优先级的证据,数量与选择链均以各段发生时为准。最新规则、组与客户端产物见 [当前生产状态](CURRENT-STATE.md),不按历史阶段恢复旧配置。
|
||||
|
||||
本次按用户提供的 [Claude 清单](https://ip.net.coffee/claude/site.html) 与 [ChatGPT 清单](https://ip.net.coffee/gpt/site.html) 审阅线上规则。用户选择「按 AI 专用域名精确补齐;共享服务只加入已确认的主机」。已有远程规则集继续自动更新,缺项通过管理 API 添加到同一 `🤖 AI` 策略。规则集 API 只读,不能修改第三方托管文件,也不通过直接写库替换它们。
|
||||
|
||||
## 原有覆盖
|
||||
|
||||
| 原有规则 | 审阅结果 |
|
||||
|---|---|
|
||||
| Sukka `non_ip/ai.conf` | 实际下载版本为 2026-10-03、53 条;已有 Anthropic、Claude、clau.de、ChatGPT、chat.com、Sora、oaistatic 等域名。`DOMAIN-KEYWORD,openai` 已覆盖所列 OpenAI/Azure/CDN 中包含 openai 的主机,但缺少 oaiusercontent、oaistatsig、MCP 和语音等域名。 |
|
||||
| Blackmatrix7 `Bing.list` | 9 条,包含 Bing 搜索与相关服务;精确补齐时保留,随后按用户要求移除,见文末后续调整。不是 Claude/OpenAI 专项规则集。 |
|
||||
| `DOMAIN-SUFFIX,googleapis.com` | 范围包括非 AI Google APIs;保留原业务行为。Unbreak 中靠前的 Google 更新、翻译等 DIRECT 例外继续优先。 |
|
||||
| Apple Relay 两条后缀及 `apple-relay` 关键词 | 后缀与关键词重叠;保留现有 Apple Intelligence/Relay 行为。 |
|
||||
| Dia 后缀与关键词 | 停用 `DOMAIN-KEYWORD,diabrowser.engineering`,保留 `DOMAIN-SUFFIX,diabrowser.engineering`,避免关键词匹配域名之外的同名片段。 |
|
||||
| 已停用的全局 `PROTOCOL,DOH` | 继续停用;仅从 AI 域名规则生成 Surge DNS 映射。 |
|
||||
|
||||
原有 Sukka 集还包含 `api.github.com`、多种其他 AI 服务及历史域名。此次保持原引用,精确原则用于新增规则,不声称整个原有 AI 组已收敛为 Claude/OpenAI 专属。
|
||||
|
||||
## 新增的 24 条规则
|
||||
|
||||
以下全部指向 `🤖 AI`。22 条域名规则放在现有 `api.amplitude.com` 之后、第一条广告拦截规则集之前;保留更早的协议阻断、业务例外和 Unbreak。2 条 IP 兜底放在原有 AI/Bing 域名规则之后,均使用 `no-resolve`。其他原有规则的相对顺序保持不变,唯一启用的 FINAL 保持末尾。
|
||||
|
||||
| 类型 | 匹配值 |
|
||||
|---|---|
|
||||
| DOMAIN-SUFFIX | claudemcpclient.com |
|
||||
| DOMAIN-SUFFIX | claudemcpcontent.com |
|
||||
| DOMAIN-SUFFIX | claudeusercontent.com |
|
||||
| DOMAIN-SUFFIX | oaiusercontent.com |
|
||||
| DOMAIN-SUFFIX | crixet.com |
|
||||
| DOMAIN-SUFFIX | oaistatsig.com |
|
||||
| DOMAIN-SUFFIX | chatgpt.livekit.cloud |
|
||||
| DOMAIN | servd-anthropic-website.b-cdn.net |
|
||||
| DOMAIN | anthropic.com.cdn.cloudflare.net |
|
||||
| DOMAIN | anthropic.auth0.com |
|
||||
| DOMAIN | anthropic-com.ghost.io |
|
||||
| DOMAIN | browser-intake-us5-datadoghq.com |
|
||||
| DOMAIN | http-intake.logs.us5.datadoghq.com |
|
||||
| DOMAIN | client-api.arkoselabs.com |
|
||||
| DOMAIN | host.livekit.cloud |
|
||||
| DOMAIN | turn.livekit.cloud |
|
||||
| DOMAIN | o33249.ingest.sentry.io |
|
||||
| DOMAIN | o207216.ingest.sentry.io |
|
||||
| DOMAIN | browser-intake-datadoghq.com |
|
||||
| DOMAIN | rum.browser-intake-datadoghq.com |
|
||||
| DOMAIN-SUFFIX | statsig.anthropic.com |
|
||||
| DOMAIN-KEYWORD | chatgpt-async-webps-prod- |
|
||||
| IP-CIDR | 160.79.104.0/23 |
|
||||
| IP-CIDR6 | 2607:6bc0::/48 |
|
||||
|
||||
`statsig.anthropic.com` 原本已经被 Anthropic 后缀覆盖,但更早的广告域名集包含它;这条是优先级例外,而非新增域名覆盖。`browser-intake-datadoghq.com` 及其 `rum` 主机也原先被广告域名集拦截,补充后优先交给 AI。用户选择接受这些已确认 AI 遥测主机,不据此放行所有遥测服务。
|
||||
|
||||
ChatGPT 动态 Azure 主机的范围参考 [v2fly OpenAI 原始清单](https://raw.githubusercontent.com/v2fly/domain-list-community/master/data/openai)。当前数据模型没有顶层 DOMAIN-WILDCARD;本次使用 ChatGPT 服务专用前缀关键词,避免添加整个 `webpubsub.azure.com` 后缀。关键词匹配不是锚定的完整域名正则,也会匹配其他包含相同字符串的主机;这是一项明确的表达能力限制。它可以在三个现有客户端产物中下发,并触发 Surge 域名 DNS 映射。
|
||||
|
||||
## 没有照搬的项目
|
||||
|
||||
- 不新增整个 `sentry.io`、`statsigapi.net`、`intercom.io`、`intercomcdn.com` 或泛化 `datadog`/`sentry`/`sift` 关键词;不将通用 `cdn.usefathom.com` 当作 AI 专属。这些会改变其他应用的流量。只添加上表确认的遥测主机;Claude CLI 的两个 Datadog 主机由 [官方网络要求](https://code.claude.com/docs/en/network-config#network-access-requirements) 确认,OpenAI 的两个 Sentry 与 rum 主机由 [官方网络建议](https://help.openai.com/en/articles/9247338-network-recommendations-for-chatgpt-errors-on-web-and-apps) 确认。
|
||||
- Claude 页面提供的 `/21` 和 IPv6 `/32` 比 API 接收请求的网段宽。[Anthropic 官方 IP 文档](https://platform.claude.com/docs/en/api/ip-addresses) 将 inbound 标为 `160.79.104.0/23`、`2607:6bc0::/48`,而 `/21` 是服务端向外发起工具请求的出口范围。本次使用 inbound 范围,不加扩大覆盖的 ASN 兜底。
|
||||
- 不添加全局 NTP 分流。页面称 NTP 返回代理所在地时区,这不符合 [RFC 5905](https://www.rfc-editor.org/rfc/rfc5905.html) 的 UTC 时间模型;转发 NTP 也不会修改设备时区。
|
||||
- IPv6 `/48` 是路由兜底,不启用客户端或边缘 IPv6。边缘继续遵守 IPv4-only,命中规则不保证 IPv6 字面目标可以连接。
|
||||
|
||||
## 验证与限制
|
||||
|
||||
变更前、候选配置与写入后的实际 HTTPS Surge 订阅均通过官方 Surge CLI;每次 60/60 远程规则集快照下载成功并再次通过 CLI,静态域名遮蔽提示为 0。写入前在线备份,新增规则先以停用状态创建、提交全量顺序后逐条启用,再停用 Dia 关键词,逐条核对 HTTP 状态。
|
||||
|
||||
线上已重读确认规则从 116 增至 140,其中启用 138 条、启用的 AI 规则 31 条;其他原有规则字段与相对顺序、节点、组、设置及规则集引用均未改变。实际 HTTPS 的 Surge、Clash、Shadowrocket conf 均包含全部 24 条新增规则,节点与组产物与变更前一致,FINAL/MATCH 末尾兜底验证通过;Surge 的 self URL 和新增域名 DNS 映射正确。38 个 AI 域名样例的首条域名匹配均为 AI,3 个 Google Unbreak DIRECT 例外仍保留。Clash 实际订阅另通过独立临时目录的 `mihomo -t`;Shadowrocket 为结构验证,未做真机编译。文档变更通过 `git diff --check`。
|
||||
|
||||
备份:`/opt/proxy-station/data/backups/before-ai-claude-chatgpt-precision-20261004-102138.db`,已收紧为 root 属主、600 权限。含凭据的 API 快照及订阅仅保存在权限受控的私有临时目录,不写入仓库。本次只有生产数据与文档变更,没有代码部署。
|
||||
|
||||
本次候选校验还发现:`AND,((DOMAIN-SUFFIX,webpubsub.azure.com),(DOMAIN-KEYWORD,chatgpt-async-webps-prod-)),"🤖 AI"` 被本机 Surge CLI 拒绝,同一规则将末尾策略裸写为 `🤖 AI` 则通过。当前生成器为含空格的策略输出引号,因此本次没有写入这条 AND。以后修改逻辑规则生成器时,应单独补原生 CLI 用例并核实带空格策略的处理;本次不部署代码。
|
||||
|
||||
DNS 映射随新域名规则生成,手写 Host 例外继续优先;IP 规则不生成域名 DNS 映射。现有全局 STUN 阻断可能影响语音 WebRTC/TURN,添加语音域名不表示高级语音已经可用。CLI 和静态覆盖验证不能替代设备上的登录、附件、MCP、语音与 DNS/WebRTC 验收。策略组成员和设备已保存的出口选择均保持现状。
|
||||
|
||||
## 后续调整:移除 Apple/Global Services 与 Bing 专用分流
|
||||
|
||||
2026-10-04 按用户要求,经管理 API 删除 `🍎 Apple`、`🌐 Global Services` 两个组,以及指向它们的 9 条规则(apple_cdn、AppleTV、Apple、Twitter、Telegram 域名/IP/ASN、OneDrive、microsoft);另删除 `🤖 AI` 的 Bing RULE-SET。没有将这些专用规则改挂其他策略,后续规则自然接管。用户明确选择保留 `global`/`global_plus` 的 Automatic 策略;当前规则集静态域名匹配确认 x.com、twitter.com、bing.com、www.bing.com 均由 `global` → Automatic。当前 Sukka AI 集不含 Bing/Copilot 名称规则。App Store DIRECT、Apple News 美国、Apple Relay AI 例外继续保留。
|
||||
|
||||
重读确认组从 14 减至 12、规则从 140 减至 130,组成员剩 25;其余组、规则字段与相对顺序、节点和设置均保持原状,唯一启用 FINAL 仍在末尾。实际 HTTPS Surge、Clash、Shadowrocket conf 均已无这两个组和 Bing 规则引用;Surge self URL 正确,官方 CLI 与 50/50 远程规则集快照校验通过,静态域名遮蔽提示为 0;Clash 的 `mihomo -t` 通过。Shadowrocket 仅验证产物结构,未进行真机编译或业务连接验证。
|
||||
|
||||
本次写入前在线备份为 `/opt/proxy-station/data/backups/before-remove-apple-global-bing-20261004-103623.db`。只调整生产数据与相关文档,未部署代码或修改初始化模板。
|
||||
|
||||
同日后续按用户明确要求统一代理入口:原先指向 Automatic 的 20 条规则(包括 global/global_plus)全部改为 Proxy,Automatic 从 select 改为 url-test;AI/Speedtest/NoAuto 去掉 Automatic 成员。因此 x.com/bing.com 的已验证路径变为 global → Proxy;当时 AI 的可选成员为美国、新加坡、日本、All Nodes。该调整覆盖上文“保留 Automatic”的阶段性状态,详见 [Proxy 与 Automatic 选择链](PROXY-AUTOMATIC-2026-10-04.md)。
|
||||
|
||||
同日再按用户要求删除 Speedtest 组与其一条专用规则,并调整组显示顺序。当时 30 条启用 AI 规则集中排在广告与普通分流之前;用户明确选择保留 STUN 阻断及 Google 更新/翻译直连例外,因此两条协议防护及 Unbreak 在 AI 之前。实际 HTTPS Surge/Shadowrocket 的 126 个 AI 域名样例均首条匹配 AI,Google 例外仍为 DIRECT,49/49 规则集快照与 Surge CLI、Clash 原生校验通过。该阶段为 11 组、129 条规则、21 个成员,详见 [AI 与广告优先级](AI-PRIORITY-2026-10-04.md)。
|
||||
|
||||
随后新增 `DOMAIN-SUFFIX,ip.net.coffee → AI`,本机 DNS 探测从中国电信变为美国 Cloudflare;再将 AI 中的 All Nodes 替换为独立 `🧠 AI Nodes`,紧接 AI 显示,两个全节点组按完整名称排列且各自保存选择。最新为 12 组、130 条规则、31 条启用 AI 规则,见 [DNS 复测](NET-COFFEE-DNS-2026-10-04.md)与 [AI Nodes](AI-NODES-2026-10-04.md)。
|
||||
105
docs/CURRENT-STATE.md
Normal file
105
docs/CURRENT-STATE.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# 当前生产状态与接续入口
|
||||
|
||||
核对日期:2026-10-04(UTC+8;管理 API 盘点时间 11:46)。来源为 LA 管理 API 与四类实际 HTTPS 订阅;本次仅更新文档,未执行部署或生产业务数据变更。完整响应、token、凭据与配置仅保存在权限受限的本机临时目录。
|
||||
|
||||
本文件集中维护最新生产盘点;日期化体检与变更报告保留各阶段证据。后续任务先重读 API,再更新这里,不能把模板、历史数量或本机模块当作生产现状。项目协作约定见 [AGENTS.md](../AGENTS.md),技能入口见 [双工具协作](AI-COLLABORATION.md)。
|
||||
|
||||
## 节点与订阅范围
|
||||
|
||||
| 站点 | 登记节点 | 启用 | 停用 | 当前启用协议 |
|
||||
|---|---:|---:|---:|---|
|
||||
| JP(日本) | 8 | 4 | 4 | VMess/Trojan × Direct/Warp |
|
||||
| SG(新加坡) | 8 | 4 | 4 | VMess/Trojan × Direct/Warp |
|
||||
| LA(美国) | 6 | 4 | 2 | VMess/Trojan × Direct/Warp |
|
||||
| RN(美国) | 8 | 0 | 8 | 无 |
|
||||
| 合计 | 30 | 12 | 18 | VMess 6、Trojan 6,均为 WS |
|
||||
|
||||
全部 8 个 SS、6 个 HY2 以及 RN 的 4 个 VMess/Trojan 当前停用。停用是应用数据库状态,不代表 VPS 或远端线路已经移除/故障;不因整理文档、部署代码或重新生成交付页而启用。边缘舰队仍为 SG/LA/RN/JP,部署资料与 IPv4-only 约束见 [节点工作区](../edge-nodes/README.md)及 [IPv4 约定](EDGE-IPV4-ONLY.md)。舰队交付线路数与本应用登记/启用数不是同一指标。
|
||||
|
||||
现有 1 个启用订阅,nodeIds 为空数组,即下发全部启用且客户端支持的节点;没有公开默认 token。Surge、Clash、Shadowrocket 节点订阅当前均输出 12 个节点;这不是客户端只能支持这两种协议的断言。将来启用 SS/HY2 后仍须按 [客户端能力矩阵](../shared/src/protocols.ts)重新核对过滤与连接。
|
||||
|
||||
所有节点 sortOrder 现为默认 0。[IR](../server/src/services/ir.ts)按 sortOrder、name 升序读取,当前完整名称顺序为日本 → 新加坡 → 美国,同地区内 Trojan → VMess,同协议 Direct → Warp。新增节点保持默认权重即可按名称自然插入;非零权重会改变该全局来源的排序。Web 节点页另按站点、协议与出口展示。
|
||||
|
||||
## 策略组:显示顺序与有序成员
|
||||
|
||||
12 个组全部启用,共 21 个显式成员。下表顺序就是数据库 sortOrder,也是生成配置的组显示顺序。
|
||||
|
||||
| 顺序 | 名称 | 类型 | 成员/动态节点来源 |
|
||||
|---:|---|---|---|
|
||||
| 1 | 🚀 Proxy | select | Automatic、All Nodes、新加坡、美国、日本 |
|
||||
| 2 | ⚡️ Automatic | url-test | 新加坡、美国、日本 |
|
||||
| 3 | 📋 All Nodes | select | includeAllNodes,全部可用节点 |
|
||||
| 4 | 🇺🇸 United States | url-test | includeAllNodes+美国名称正则 |
|
||||
| 5 | 🇸🇬 Singapore | url-test | includeAllNodes+新加坡名称正则 |
|
||||
| 6 | 🇯🇵 Japan | url-test | includeAllNodes+日本名称正则 |
|
||||
| 7 | 🤖 AI | select | 美国、新加坡、日本、🧠 AI Nodes |
|
||||
| 8 | 🧠 AI Nodes | select | includeAllNodes,全部可用节点 |
|
||||
| 9 | 🎬 Streaming | select | 新加坡、美国、日本 |
|
||||
| 10 | 🪙 Bybit | select | DIRECT、新加坡、美国、日本 |
|
||||
| 11 | 🇨🇳 Mainland | select | DIRECT |
|
||||
| 12 | 🎯 NoAuto | select | Mainland |
|
||||
|
||||
- 普通代理规则指向 Proxy,只有 Proxy 包含 Automatic;没有直接指向 Automatic 的规则。
|
||||
- AI 不引用 Automatic。All Nodes 与 AI Nodes 使用独立组身份保存节点选择;两组无正则、无显式成员,节点来源与名称升序列表相同。AI Nodes 紧接 AI,不能通过引用 All Nodes 来实现独立选择。
|
||||
- Automatic 和三个地区组的数据库类型均为 url-test,interval=300、tolerance=50。组 testUrl 为 `http://cp.cloudflare.com/generate_204`,仅 Clash/Shadowrocket 使用;Surge 使用 General/节点测速地址。客户端自身模块可能覆盖组类型或参数。
|
||||
- select 在没有有效保存选择时使用首个成员;名称排序和改名可能影响这个默认值。已保存的实际选择需在授权的客户端更新任务中保留。
|
||||
- FINAL → NoAuto → Mainland → DIRECT。Bybit 的首个候选同为 DIRECT,实际使用仍由客户端保存的选择决定。
|
||||
|
||||
Apple、Global Services、Speedtest、Other,以及香港/台湾/韩国/英国地区组当前不存在。App Store DIRECT、Apple News 美国、Apple Relay AI 例外保留。原初始化模板还有历史组,修改模板或 reset 规则不能代替定向更新生产数据。
|
||||
|
||||
## 规则与服务归属
|
||||
|
||||
数据库有 130 条规则:128 启用、2 停用;登记 62 个规则集,实际配置引用 49 个不同远程 URL。唯一启用 FINAL 位于末尾,保留 dns-failed。
|
||||
|
||||
连接规则按首条匹配,当前顺序为:
|
||||
|
||||
1. STUN REJECT,以及 googlevideo.com 的 UDP REJECT-NO-DROP。
|
||||
2. Unbreak DIRECT,保留已授权的 Google 更新/翻译直连例外。
|
||||
3. 31 条启用的 AI 规则集中排列,包含原 AI 集、精确补充域名、Anthropic IP 兜底及 `ip.net.coffee` 后缀。
|
||||
4. 业务例外、广告/隐私规则及其余服务、地区、国内、CDN、global 分流。
|
||||
5. FINAL → NoAuto。
|
||||
|
||||
两条停用规则为全局 `PROTOCOL,DOH → AI` 和 `DOMAIN-KEYWORD,diabrowser.engineering → AI`;Dia 的 DOMAIN-SUFFIX 规则仍启用。普通代理入口统一到 Proxy;x.com/bing.com 的已验证路径是 global → Proxy,Bing 专用 AI RULE-SET 已删除。具体域名仍受更早规则及动态外部规则集影响,后续变更须重审遮蔽。
|
||||
|
||||
直接指向 Streaming 的启用 RULE-SET 共 13 条;相关服务的地区分流与 Emby 归属一并列在下表:
|
||||
|
||||
| 来源 | 策略 |
|
||||
|---|---|
|
||||
| Disney、Netflix、TikTok、YouTube、GoogleSearch | 🎬 Streaming,各 1 条 |
|
||||
| stream_eu、stream_kr、stream_hk、stream_tw | 🎬 Streaming,各域名/IP 两条 |
|
||||
| stream_us | 🇺🇸 United States,域名/IP 两条 |
|
||||
| stream_jp | 🇯🇵 Japan,域名/IP 两条 |
|
||||
| Emby | 🚀 Proxy,1 条 |
|
||||
|
||||
该表表示规则指定策略,不保证某服务的所有请求都走该组;例如先匹配 AI 的 Google API 请求或被协议防护拦截的请求仍执行先前规则。
|
||||
|
||||
## DNS、客户端差异与验收范围
|
||||
|
||||
Surge AI DNS 从启用 AI 域名规则派生 Host 到 `https://1.1.1.1/dns-query`,并生成 DoH 跟随出口开关与 1.1.1.1/32 → AI 规则。手写 Host、Unbreak 和默认国内 DNS 例外继续保持顺序。数据库 General 中原始开关为 false,生成配置派生为 true;原文设置与最终产物须分别核对。没有启用全局 DOH/全端口 53 AI 接管,边缘 IPv4-only 约束继续保留。
|
||||
|
||||
Net.Coffee 的 Claude 页通过随机 `*.d.ip.net.coffee` 记录递归解析器,使用 `DOMAIN-SUFFIX,ip.net.coffee → AI` 覆盖页面及子域。前面 Session 的本机 Chrome+Surge 实测已从中国电信「可能泄露」变为美国 Cloudflare「未检测到泄露」;`dns trace` 确认 Cloudflare DoH。此结果限于该探测路径,不代表其他设备、域名或远端代理解析器已验收。
|
||||
|
||||
| 实际 HTTPS 产物 | 节点 | 组 | 规则 | 说明 |
|
||||
|---|---:|---:|---:|---|
|
||||
| Surge | 12 | 12 | 131 | 128 条启用数据库规则+3 条派生 DNS 出口规则 |
|
||||
| Clash | 12 | 12 | 126 | 49 个 provider;跳过 STUN 与 SMTP 文本 URL-REGEX |
|
||||
| Shadowrocket 节点订阅 | 12 | — | — | 完整名称升序的 base64 URI 列表 |
|
||||
| Shadowrocket conf | — | 12 | 122 | 保守跳过 6 条:STUN、googlevideo AND、reject/download/cdn DOMAIN-SET、SMTP 文本 URL-REGEX |
|
||||
|
||||
HTTP 订阅只有正文,没有 excluded/skippedRules 字段;总节点数与实际节点数不同,要区分停用、token 范围与客户端过滤。Shadowrocket 的全节点正则会收纳 App 内其他订阅,App 排序和其他节点不由本服务控制。
|
||||
|
||||
前面 Session 最近一次验证已通过 Surge 官方 CLI、49/49 规则集快照及 Clash `mihomo -t`,静态域名遮蔽提示为 0;本机 Surge 已验证新组名、两个列表的名称顺序和独立选择,改名后保留原节点及 AI 路径。本轮仅重读最新数据/产物并验证文档一致性,没有重复进行网络泄漏测试。iOS/Android 真机编译、节点连接、UDP、完整业务与隐私验收仍有未完成项。
|
||||
|
||||
## Session 变更记录与接续
|
||||
|
||||
| 主题 | 记录 | 后续维护要点 |
|
||||
|---|---|---|
|
||||
| Surge 体检与防护修复 | [体检](SURGE-AUDIT-2026-10-03.md)、[修复](SURGE-FIX-2026-10-03.md) | 保留 API 校验、FINAL、引用图、配置编码及 Host 顺序防护 |
|
||||
| 移动端生成器体检与已授权后端部署 | [移动端体检](MOBILE-CLIENT-AUDIT-2026-10-04.md) | 能力矩阵与生成器范围独立检查,保留手机真机待验项 |
|
||||
| AI 精确补充与删除 Apple/Global/Bing 专用分流 | [AI 规则审阅](AI-RULE-REVIEW-2026-10-04.md) | 精确补充主机;历史阶段保留 Automatic 的记录已被后续 Proxy 入口调整覆盖 |
|
||||
| Proxy 统一普通代理入口、Automatic 测速 | [选择链](PROXY-AUTOMATIC-2026-10-04.md) | Proxy 为唯一 Automatic 父组,AI/NoAuto 独立 |
|
||||
| 删除 Speedtest、AI 优先于广告 | [AI 优先级](AI-PRIORITY-2026-10-04.md) | 该阶段为 11 组/129 规则,后续新增项以本文件为准 |
|
||||
| Net.Coffee 探测域名与 DNS 复测 | [DNS 复测](NET-COFFEE-DNS-2026-10-04.md) | 覆盖随机子域,区分网站探测与 Claude 实际域名 |
|
||||
| 独立 AI Nodes、emoji 与名称排序 | [AI Nodes](AI-NODES-2026-10-04.md) | 独立 select、同节点来源、默认零权重、保留客户端选择 |
|
||||
|
||||
备份文件名与原阶段验收保留在各专题记录中,不清理远端备份。新增业务约束更新 AGENTS.md;当前盘点更新本文件;操作方法更新对应专题。代码部署、生产数据更新和设备配置更新分别核对授权,纯文档整理不触发这些动作。
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
服务器资源有限(1 核/1.9G),采用 Node 直跑 + systemd 守护,前端在本机构建后由服务端静态托管,反向代理与证书由 aaPanel 管理。
|
||||
|
||||
本文描述 Proxy Station 应用发布。边缘 VPS 的部署、重建与协议维护已合入本仓库,见 [节点工作区](../edge-nodes/README.md) 与 `edge-node-multi-protocol` 技能;其私有资料不随应用部署上传。LA 同时承载节点与本应用,维护 nginx、证书或 WARP 时需核对共享服务影响。
|
||||
|
||||
当前配置与变更接续入口见 [生产状态](CURRENT-STATE.md)。应用部署只更新代码/产物,不重置生产数据库,不恢复已停用节点,也不自动修改客户端的活动配置与保存选择。
|
||||
|
||||
## 拓扑
|
||||
|
||||
```
|
||||
@@ -22,7 +26,7 @@
|
||||
|
||||
## 更新代码
|
||||
|
||||
日常更新用项目级 Skill `deploy`(对 Claude 说「部署」即可触发),也可直接跑脚本:
|
||||
日常更新用 Codex/Claude Code 共用的项目级技能 `deploy`(说「部署」即可匹配;也可分别用 `$deploy`/`/deploy` 显式调用),也可在仓库根目录直接跑脚本:
|
||||
|
||||
```bash
|
||||
bash .claude/skills/deploy/scripts/deploy.sh all # 前后端一起(默认)
|
||||
@@ -36,24 +40,30 @@ SSH 别名必须用小写 `la`,大写会被当作域名解析。
|
||||
|
||||
## 迁移数据库
|
||||
|
||||
用 sqlite3 在线备份导出一致副本,避免直接拷贝带 WAL 的库文件:
|
||||
普通节点、组、规则与设置调整使用 update-db 管理 API,并先做生产 SQLite 在线备份。整库迁移/恢复只属于用户明确授权的相应任务;按技能恢复流程安排停服窗口、回滚备份和后续 HTTPS 验收,不用替库代替定向业务修改。
|
||||
|
||||
需要从本机导出时,使用在线备份取得一致副本,文件保存在私有临时目录:
|
||||
|
||||
```bash
|
||||
sqlite3 server/data/proxy-station.db ".backup /tmp/ps.db"
|
||||
scp /tmp/ps.db la:/tmp/ps.db
|
||||
ssh la 'systemctl stop proxy-station && install -m 644 -o proxy-station -g proxy-station /tmp/ps.db /opt/proxy-station/data/proxy-station.db && rm -f /opt/proxy-station/data/proxy-station.db-wal /opt/proxy-station/data/proxy-station.db-shm /tmp/ps.db && systemctl start proxy-station'
|
||||
umask 077
|
||||
migration_dir="$(mktemp -d /private/tmp/proxy-station-migration.XXXXXX)"
|
||||
sqlite3 server/data/proxy-station.db ".backup $migration_dir/proxy-station.db"
|
||||
chmod 600 "$migration_dir/proxy-station.db"
|
||||
```
|
||||
|
||||
远端备份确认 root 属主、600 权限;恢复后的活动数据库属运行用户,保留仅必要的访问权限。不要直接拷贝运行中的主库而漏掉 WAL,也不把含真实凭据的库放进 Git 或共享日志。
|
||||
|
||||
## 反向代理注意点
|
||||
|
||||
- aaPanel 的反代模板不带 `X-Forwarded-Proto`/`X-Forwarded-Host`,已手工补进反代配置。服务端 `routes/sub.ts` 的 `baseUrl()` 依赖这两个头还原外部地址,缺失时 Surge 的 `#!MANAGED-CONFIG` 与 Clash 的 provider URL 会变成 `http://`。
|
||||
- 站点通过面板类 `AddSite`/`SetSSL`/`CreateProxy`/`HttpToHttps` 创建(脚本模式见 edge-nodes 的 `09-aapanel-sites.py`),不要手写 vhost,否则面板里看不到。
|
||||
- 站点通过面板类 `AddSite`/`SetSSL`/`CreateProxy`/`HttpToHttps` 创建(脚本模式见本仓库 `.claude/skills/edge-node-multi-protocol/scripts/09-aapanel-sites.py`),不要手写 vhost,否则面板里看不到。
|
||||
- 泛域名 `*.la.njcqtechaicoding.com` 的 DNS 已指向服务器,新子域无需单独加记录。
|
||||
- acme.sh 的 `--reloadcmd` 要把新站点加进复制循环,且证书源路径必须写死(原先引用未定义的 `$SRC`,续期后不会重新部署证书)。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
curl -s https://ps.la.njcqtechaicoding.com/api/health
|
||||
curl -s "https://ps.la.njcqtechaicoding.com/sub/<token>/clash" > /tmp/c.yaml && mihomo -t -f /tmp/c.yaml
|
||||
curl -fsS --max-time 10 https://ps.la.njcqtechaicoding.com/api/health
|
||||
```
|
||||
|
||||
部署脚本核对服务状态、源码/前端指纹、公网健康及管理接口鉴权。涉及生成器或 shared 后,还要读取最新节点范围与真实 HTTPS 订阅,按 [订阅验证](SURGE-CHANGES.md)检查 Surge/Clash 及受影响的 Shadowrocket 产物;先确保下载成功及文件非空,不能把 mihomo 为缺失文件创建的默认配置当成验收结果。与部署前候选比对时使用同一最新生产快照。
|
||||
|
||||
28
docs/EDGE-IPV4-ONLY.md
Normal file
28
docs/EDGE-IPV4-ONLY.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# 边缘舰队 IPv4-only
|
||||
|
||||
2026-10-03 按用户授权对 SG、LA、RN、JP 四台现役节点统一禁用 IPv6。目标是经任何代理线路访问目标时,直连与 WARP 均只使用 IPv4;IPv6 地址及仅有 AAAA 的目标直接失败,不回退到 IPv6。
|
||||
|
||||
## 三层约束
|
||||
|
||||
1. 系统:`/etc/sysctl.d/99-z-proxy-station-ipv4-only.conf` 将 `net.ipv6.conf.all.disable_ipv6`、`default.disable_ipv6`、`lo.disable_ipv6` 设为 `1`。`proxy-station-ipv4-only.service` 已启用,在 `network-online.target` 之后、Xray/sing-box/WARP 之前重新应用。检查每个接口,不只检查 `all`;`ip -6 addr` 与 `ip -6 route` 应无地址/路由。
|
||||
2. 代理:Xray 的每个 freedom 出站使用 `settings.domainStrategy=ForceIPv4` 与 `streamSettings.sockopt.domainStrategy=ForceIPv4`,内置 DNS 使用 `queryStrategy=UseIPv4`;`::/0` 拒绝规则在 WARP 分流之前。持久配置写在 3x-ui 数据库的 `xrayTemplateConfig`,不能只改生成的 `bin/config.json`。sing-box 在路由开始先 `resolve`(`ipv4_only`),再拒绝 `::/0`,之后才进入原有 direct/WARP 规则;DNS 经 IPv4 UDP 服务器,QUIC 入站监听 `0.0.0.0`。
|
||||
3. DNS:节点根域、通配符及其他节点子域不保留 AAAA,A 记录为 DNS-only。私有 `config.env` 的 `SERVER_IPV6` 清空,防止重新创建 AAAA;清空变量不会删除已存在的记录。
|
||||
|
||||
WARP 在用户态代理隧道中仍有双栈能力,因此主机没有公网 IPv6 也不能替代代理层限制。Xray 的 `UseIPv4` 允许解析失败后回退;当前策略使用 `ForceIPv4`。依据:[Linux 内核 sysctl](https://www.kernel.org/doc/html/latest/networking/ip-sysctl.html)、[Xray sockopt](https://xtls.github.io/config/transports/sockopt.html)、[sing-box resolve](https://sing-box.sagernet.org/configuration/route/rule_action/#resolve)。
|
||||
|
||||
## 维护与回滚
|
||||
|
||||
通用 `08-xui-bootstrap.py` 与 `13-singbox-config.py` 已保留代理层防护;前者扩展已有模板时也应用 IPv4-only,后者需要 sing-box 1.12+。新建/重建服务器还须配置上述系统 sysctl 与开机服务,不要只依赖生成器。当前私有脚本工作副本已同步相关修改。
|
||||
|
||||
本次每台机器先通过 SQLite backup API 在线备份 3x-ui 数据库,并打包受影响配置、系统网络设置、systemd 单元、nginx、acme.sh 与 WARP 状态。备份在远端 `/root/ipv4-only-backup-<UTC时间>/`,具体路径、Cloudflare 删除前的记录及验收结果在 Git 忽略的 `edge-nodes/private/ipv4-only-20261003/`。不要将这些内容公开托管或入 Git。证书续期动作未修改。
|
||||
|
||||
回滚应读取本机的私有维护记录和该服务器快照,仅恢复受影响的 3x-ui 模板、sing-box 配置及 sysctl/开机单元;不要用整个目录快照覆盖后来新增的配置。恢复 IPv6 或 AAAA 须有用户授权。
|
||||
|
||||
## 本次验证与边界
|
||||
|
||||
- 四台服务器全部接口的 `disable_ipv6=1`,无 IPv6 地址;持久文件与开机服务均已配置。未进行整机重启验收。
|
||||
- 服务器侧真实协议客户端验证 33 条线路(SG 8、LA 6、RN 11、JP 8):IPv4 出口正常,direct 的 `warp=off`、WARP 的 `warp=on`;IPv6 字面地址与仅 IPv6 域名均失败。
|
||||
- 从本机经公网验证线路:Trojan/Hysteria2/TUIC/SOCKS5 使用 sing-box,VMess/SS over WS 使用 mihomo。临时客户端只监听本机回环,不修改活动代理配置。具体结果见私有记录。
|
||||
- Cloudflare 删除 SG、LA 的 4 条 AAAA;RN、JP 原本无 AAAA。实际 HTTPS Surge 订阅通过官方 CLI 校验。
|
||||
|
||||
代理入站使用的 QUIC UDP 已做真实连接验证;未逐条进行被代理 UDP 负载、手机真机或本地直连 IPv6 泄漏验收。这项策略限制服务器代理转发,不改变客户端绕过代理的本地网络行为。当前不需要通过 VPS 厂商面板才能达到代理仅 IPv4 的目标。
|
||||
73
docs/MOBILE-CLIENT-AUDIT-2026-10-04.md
Normal file
73
docs/MOBILE-CLIENT-AUDIT-2026-10-04.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# Shadowrocket/ClashMeta 体检:2026-10-04
|
||||
|
||||
本文保留体检开始时的快照、代码修复及随后已授权部署的证据。下文 18 个启用节点、14 个组等数量属于历史阶段;最新为 12 个启用节点、12 个组,完整盘点见 [当前生产状态](CURRENT-STATE.md)。
|
||||
|
||||
体检阶段只读盘点生产数据、拉取实际 HTTPS 订阅,对照官方资料审阅生成器,并在本地修复已确认的序列化问题。当时没有部署、修改生产业务数据、提交或推送;订阅访问会正常更新访问统计,自托管规则资源拉取可更新既有缓存。用户后续授权的部署及验收见末尾记录。
|
||||
|
||||
## 之前检查到哪里
|
||||
|
||||
[2026-10-03 验证记录](SURGE-FIX-2026-10-03.md) 已包含实际 Clash 订阅的 `mihomo -t`,以及 Shadowrocket conf 的 HTTPS 响应与候选一致性。已有测试覆盖两端的 PROTOCOL 处理、Surge AI DNS 不误传给其他端,以及 Shadowrocket 策略名不加引号;还没有两端节点、组、规则的系统体检或 iOS/Android 真机验收记录。
|
||||
|
||||
## 官方依据及边界
|
||||
|
||||
- Android 的目标是 **ClashMeta/mihomo**,不是早期 Clash 内核。依据 [mihomo 节点通用字段](https://wiki.metacubex.one/config/proxies/)、[VMess](https://wiki.metacubex.one/config/proxies/vmess/)、[SS](https://wiki.metacubex.one/en/config/proxies/ss/)、[TLS](https://wiki.metacubex.one/config/proxies/tls/)、[Hysteria2](https://wiki.metacubex.one/config/proxies/hysteria2/)、[策略组](https://wiki.metacubex.one/config/proxy-groups/)、[url-test](https://wiki.metacubex.one/config/proxy-groups/url-test/)、[fallback](https://wiki.metacubex.one/config/proxy-groups/fallback/)、[规则](https://wiki.metacubex.one/config/rules/)、[规则资源](https://wiki.metacubex.one/config/rule-providers/content/),必要时核对 [官方规则解析源码](https://github.com/MetaCubeX/mihomo/blob/Meta/rules/parser.go)。本机验证内核为 mihomo 1.19.32;它不能代替用户 Android App 内核版本及 VPN 接管测试。
|
||||
- Shadowrocket 官方来源从 [App Store 开发者页面](https://apps.apple.com/us/app/shadowrocket/id932747118) 指向 [shadowlaunch.com](https://shadowlaunch.com/)。开发者站只提供产品与联系入口,本次没有找到可核实的完整官方配置语法手册。`help.shadowrocket.net` 本次不可访问,也不能从开发者入口验证其归属;第三方手册不冒充官方依据。官方更新记录明确提到逻辑规则、QUIC 匹配和 Hysteria2,足以纠正“客户端不支持逻辑规则”的绝对断言,但不足以证明某一 Surge 写法可以直接透传。
|
||||
- SS 插件传输的依据为 [Shadowsocks 官方 SIP003](https://shadowsocks.org/doc/sip003.html):插件只转发 TCP。不能因客户端支持 UDP 就宣称 nginx WS 同端口也提供 SS UDP 服务。
|
||||
|
||||
## 体检开始时生产快照(历史)
|
||||
|
||||
| 项目 | 结果 |
|
||||
|---|---|
|
||||
| 节点 | 30 个,18 个启用,12 个停用 |
|
||||
| 实际订阅节点范围 | 日本、新加坡、美国各 6 个;VMess/Trojan/SS 各 6 个,均为 WS;当时 HY2 与 RN 节点停用 |
|
||||
| 组与规则 | 14 个启用组;116 条规则,其中 115 启用;62 个规则集记录 |
|
||||
| Shadowrocket 节点订阅 | 18 个,排除 0 个;原顺序中 JP/SG 按协议交错 |
|
||||
| Shadowrocket conf | 14 个组,108 条规则,跳过 7 条 |
|
||||
| Clash | 18 个节点,14 个组,113 条规则,60 个 provider;跳过 2 条 |
|
||||
|
||||
四类实际 HTTPS 产物均成功拉取;修复前本地生成的 Surge/Clash/Shadowrocket 节点订阅/conf 与线上逐字节一致,确认审阅的是当前下发逻辑。真实密码、UUID、域名、Host IP、token、配置和快照只保存在权限受限的临时目录,不进入本报告或 Git。
|
||||
|
||||
## 已修复的问题
|
||||
|
||||
| 问题 | 修复及实际影响 |
|
||||
|---|---|
|
||||
| Shadowrocket JP/SG 混排 | 节点订阅对完整名称做确定性的字符串升序排序,不改变 IR、数据库排序或其他客户端顺序。当前顺序为 JP → SG → US;每国内 SS → Trojan → VMess,同协议 Direct → Warp。未来启用 HY2 时按名称自然排入 |
|
||||
| Clash 未启用 UDP | 原产物 18 个节点均省略 `udp`,VMess/Trojan 默认不能承载 UDP。现在 12 个 VMess/Trojan 显式 `udp: true`;6 个 SS WS 显式 `false`。纯 TCP SS 和 HY2 的占位用例也覆盖;UDP 是否成功穿过远端仍需连接验证 |
|
||||
| Clash 丢失 provider 的 no-resolve | 当前 IP reject RULE-SET 的 `no-resolve` 被丢弃,可能提前解析。现在保留这个参数,不改变规则顺序或业务策略 |
|
||||
| Clash 逻辑规则只做文字替换 | 原来任意 PROTOCOL 都变成 NETWORK,遇到 STUN/DOH 子条件会产生无效规则,还可能改写匹配值中的文字。现在按结构递归映射 TCP/UDP,支持明确的等价子类型,任一子条件无法表达时跳过整个表达式;保留当前 googlevideo UDP AND 规则 |
|
||||
| 动态组丢失显式成员 | Clash 正则分支、Shadowrocket 正则/全节点分支原来会丢节点、组或内置成员;现在保留完整有序显式成员,并继续动态收节点。当前生产动态组没有显式成员,所以这是潜在缺陷修复 |
|
||||
| 测速容差 | 两端 url-test 显式零容差不再被省略;fallback 不输出 url-test 专用 tolerance。当前生产容差为 50,不受零值修复影响 |
|
||||
| Clash TLS/VMess 字段 | 补上 VMess TLS 与 SS plugin 内层的 skip-cert-verify 映射;VMess IETF cipher 别名转回 mihomo 的名称,未知 cipher 明确排除并过滤组引用。当前节点均 auto、skipCertVerify=false,因此这些修复不额外排除生产节点 |
|
||||
|
||||
Shadowrocket conf 的动态筛选仍以 App 内全部节点为来源;显式成员保留的文本和结构经过测试,但没有 iOS 编译器的实际验证。Clash 正则仅作用于动态引入来源,不能把显式成员误当作需要筛掉的动态节点。
|
||||
|
||||
## 仍存在的客户端差异与待验项
|
||||
|
||||
1. **体检时 Shadowrocket 的 7 条规则缺口**:本生成器保守跳过 `PROTOCOL,STUN`、googlevideo 的 UDP AND、4 个 DOMAIN-SET(reject/apple_cdn/download/cdn)和 SMTP 文本 URL-REGEX。同日后续删除 apple_cdn 规则后,最新产物跳过 6 条,生成器范围没有因此扩大。对应广告拦截、CDN 策略和 UDP 防护不能声称与 Surge 等价。官方版本记录表明逻辑规则支持实际存在;后续应以 App 内帮助和 iOS 编译/匹配日志核实具体语法,再缩小跳过范围。本次已修正源码注释和 AGENTS.md 的错误能力断言。
|
||||
2. **Shadowrocket RULE-SET,LAN 与 PROCESS-NAME**:当前产物保留一个 LAN 内置引用及一个 macOS 进程名规则,尚无官方语法证据或 iOS 匹配结果。56 个远程规则集 URL 可达并不证明这两个本地规则有效;iOS 也不能据 macOS 进程名期待相同匹配。
|
||||
3. **Clash 的 2 条缺口**:STUN 应用协议识别没有 NETWORK 等价物;SMTP 文本 URL-REGEX 被跳过。当前 googlevideo UDP AND 已正确转换。不能用全局拒绝 UDP 代替 STUN 规则,否则会改变业务。
|
||||
4. **SS WS 没有插件 UDP 转发**:选到这类节点时,UDP 无法由 WS 线路承载。mihomo 会继续向后匹配;可能走其他策略或 FINAL。未擅自新增全局 UDP 阻断、UDP-over-TCP 或改变默认选择。需在设备侧分别核对 VMess/Trojan 与 SS 候选的 UDP 行为。
|
||||
5. **DNS 与原文段落不等价**:Clash 使用自己的 fake-ip/DoH 配置,不读取 Surge Host、重写、MITM、Script;Shadowrocket 仅复制 General 白名单及 Host/URL Rewrite/MITM,不下发 Header Rewrite/Script。Surge 自动 AI DNS 映射不会应用到两端。当前保留的 Host/MITM 语法、证书安装、DNS/WebRTC 行为仍需设备侧检查。
|
||||
6. **节点 URI 的扩展字段**:VMess/Trojan 分享链接编码未完整保留 skipCertVerify;HY2 URI 未表达 up/down 限速;SS plugin 的独立 SNI/WS Host 不一定能同时表达。HY2 解析目前还把 pinSHA256 当成跳过证书验证,却没有保存指纹,应在后续协议字段修复中拒绝或完整保留这类输入。当前启用节点 skipCertVerify 全 false、HY2 停用,18 个节点的实际连接字段往返核对通过;这不构成这些潜在输入的支持证明。
|
||||
7. **特殊字符与资源语义**:Shadowrocket 正则内逗号、规则扩展参数及复用 Surge 原文段落没有完整官方语法验证;Clash 的 Go 正则也不完全等同 JS 正则。当前地区正则只是普通或分支,可以正常解析。映射到不同维护者的规则列表存在内容差异;资源可达/语法通过不保证逐条业务语义与 Surge 相同。
|
||||
|
||||
## 体检阶段验证
|
||||
|
||||
- 全仓类型检查通过,51 项 Vitest 通过;新增测试覆盖四种节点协议、完整名称排序与 IR 不变、动态组显式成员、零容差、provider 命名冲突/no-resolve、逻辑子规则映射/整条跳过、TLS/cipher、排除后的组引用,并调用本机 mihomo 原生解析器。
|
||||
- 线上旧 Clash 配置与本地修复候选都通过 `mihomo -t`。60/60 Clash provider 下载成功;把当前资源展开为 152,295 条辅助规则后也通过原生解析,覆盖资源内的 IP-ASN 等类型。展开检查用于核对子规则语法,不替代 Android provider 初始化、域名匹配语义或规则更新行为。
|
||||
- Shadowrocket 18 个 URI 与原始节点的相关协议字段逐项核对,名称顺序正确;14 个组、108 条规则引用完整,FINAL 唯一且末尾,没有 `use=`。56 个远程规则集属于 Surge 检查已下载的资源。没有官方 iOS CLI,所以这是结构和序列化检查,不能称为 iOS 编译通过。
|
||||
- 实际 HTTPS Surge 运行 `scripts/check-surge.py --fetch-rulesets`:官方 CLI OK,60/60 资源下载,本地快照 CLI OK,静态域名遮蔽提示 0。候选 Surge 与实际产物一致。
|
||||
- `git diff --check` 通过。未切换活动配置、启动代理服务、导入 CA、测速或测试节点连接;iOS/Android 的订阅更新、编译、默认选择、规则命中、DNS/WebRTC、TCP/UDP 连接仍需真机验收。
|
||||
|
||||
后续本地变更至少执行 `pnpm -r typecheck`、`pnpm test`;Clash 使用独立临时目录 `mihomo -t -d <目录> -f <配置>`。涉及真实订阅仍按 [Surge 变更验证](SURGE-CHANGES.md) 检查实际 HTTPS 产物,生产部署和数据变更分别遵循 deploy/update-db 技能。
|
||||
|
||||
## 后续部署与验收:2026-10-04
|
||||
|
||||
用户明确授权后,通过 deploy 技能执行 `deploy.sh server --skip-checks`。跳过脚本内重复检查前,已重新完成全仓类型检查、51 项测试、最新生产快照的候选 Surge/Clash 解析与规则集快照校验。
|
||||
|
||||
- 部署完成:`server @ 349ab83`,包含工作区未提交的修复;后端源码指纹前缀 `b3aaeb92cba2` 与远端一致,旧源码保留为 `src.old`。systemd active、公网健康及未带 token 的管理接口返回 401 均正常。
|
||||
- 部署前最新生产节点范围已变为 12 个:日本/新加坡/美国各 4 个 VMess/Trojan 节点。本次保留当前启用状态,没有恢复体检快照里的其他节点或修改业务数据。
|
||||
- 部署后四类实际 HTTPS 订阅与最新已验证候选逐字节一致。Shadowrocket 的 12 个节点按完整名称升序排列,国家顺序 JP → SG → US,国内 Trojan → VMess,同协议 Direct → Warp。
|
||||
- 实际 Surge:官方 CLI OK、60/60 规则资源下载成功、本地快照 CLI OK、静态域名遮蔽提示 0。实际 Clash:mihomo 解析通过。
|
||||
|
||||
设备需要重新更新节点订阅;此次确认的是线上响应顺序,尚未操作 iOS/Android 客户端。前述客户端能力缺口与真机验收边界仍然适用。没有创建 Git 提交或推送。
|
||||
48
docs/NET-COFFEE-DNS-2026-10-04.md
Normal file
48
docs/NET-COFFEE-DNS-2026-10-04.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Net.Coffee Claude 页面 DNS 检测与复测(2026-10-04)
|
||||
|
||||
本文保留当日页面脚本和本机实测证据;后续独立 AI Nodes 与当前订阅范围见 [当前生产状态](CURRENT-STATE.md)。页面实现及解析器出口可能变化,后续复测仍需重新核对。
|
||||
|
||||
用户要求检查 [Claude 检测页](https://ip.net.coffee/claude/) 的 DNS 检测机制,若使用该站域名,则将它加入 AI 分流并在本机复测。本次仅新增一条生产规则并刷新本机既有托管订阅,未部署应用代码。
|
||||
|
||||
## 实际检测机制
|
||||
|
||||
检查当日页面引用的 [claude-page.js](https://ip.net.coffee/claude/claude-page.js?v=20260916a),`detectDNSLeak()` 的实现为:
|
||||
|
||||
1. 用 `Math.random()` 生成本次测试 token。
|
||||
2. 依次加载 `https://<token>-1.d.ip.net.coffee/pixel.gif` 与 `https://<token>-2.d.ip.net.coffee/pixel.gif`,通过新主机名触发 DNS 查询。
|
||||
3. 等待后查询同源 `/api/dns/result/<token>`,读取 `dns_servers` 数组,即该站权威 DNS 观察到的递归解析器 IP。
|
||||
4. 查询这些解析器的地理信息;若其中有中国解析器,而页面认定的 Claude 出口不在中国,则显示「可能泄露」。此判断不要求 DNS 解析器 IP 等于 Claude 连接出口 IP,也不要求两者来自同一家运营商。
|
||||
|
||||
源码前面的注释仍提到 ipleak.net,但实际请求已使用该站自托管的 `d.ip.net.coffee`。检测没有查询 Claude 域名本身;在按域名分流的环境里,探测域名使用普通国内 DNS,不能单凭此结果认定 Claude 域名也使用国内 DNS。若接口未返回解析器,该页面会显示「DNS 加密或未暴露出口」;空结果也不能证明没有泄漏。
|
||||
|
||||
## 生产规则
|
||||
|
||||
经管理 API 新增启用的自定义规则:
|
||||
|
||||
```ini
|
||||
DOMAIN-SUFFIX,ip.net.coffee,"🤖 AI"
|
||||
```
|
||||
|
||||
后缀匹配同时覆盖页面根域和随机探测子域,精确 `DOMAIN,ip.net.coffee` 无法覆盖后者。新规则放在现有 AI 规则段末尾、广告和普通业务分流之前;既有协议防护、Unbreak、其他规则的相对顺序及唯一启用的末尾 FINAL 保持原状。
|
||||
|
||||
Surge 生成器由该规则自动派生:
|
||||
|
||||
```ini
|
||||
ip.net.coffee = server:https://1.1.1.1/dns-query
|
||||
*.ip.net.coffee = server:https://1.1.1.1/dns-query
|
||||
```
|
||||
|
||||
已有 `encrypted-dns-follow-outbound-mode` 与 `1.1.1.1/32 → AI` 规则使该 DoH 查询随 AI 出口。没有修改全局 DNS、手写 Host、策略组成员或节点。
|
||||
|
||||
## 备份与验收
|
||||
|
||||
写入前在线备份:`/opt/proxy-station/data/backups/before-add-ai-net-coffee-dns-20261004-111830.db`,已收紧为 root 属主、600 权限。敏感 API 快照、实际订阅和活动配置备份仅保存在权限受限的本机临时目录。
|
||||
|
||||
- 重读确认规则从 129 条变为 130 条,仅新增上述规则;原规则字段和相对顺序保持一致,组、节点及设置重读与变更前相同。
|
||||
- 从实际 HTTPS 入口拉取 Surge 与 Clash 订阅,核对托管 self URL、新规则和两条派生 Host 映射。
|
||||
- `scripts/check-surge.py --fetch-rulesets` 通过官方 Surge CLI 和 49/49 规则集快照校验,静态域名遮蔽提示为 0;Clash 的 `mihomo -t` 通过。
|
||||
- 本机原活动配置的托管 URL 与此次生产订阅一致。备份后用 `surge-cli managed-profile update` 更新同一托管配置,并在重载生效后确认有效配置包含新规则及 DNS 映射,原 AI 选择保持不变。
|
||||
- 本机 `rule explain` 确认根域与探测子域命中 AI;`dns trace` 确认探测子域使用 `https://1.1.1.1/dns-query`。
|
||||
- 同一个 Chrome 检测页修改前刷新显示中国电信 DNS「可能泄露」;修改后刷新显示美国 Cloudflare DNS「未检测到泄露」,Claude 连接出口保持原美国线路。WebRTC 显示「已禁用或无泄露」。
|
||||
|
||||
这是该网站、该浏览器和本机 Surge 的实际 DNS 复测;其他设备、其他客户端及远端代理解析器没有在此次测试中验收。网站测试实现与外部规则集后续可能变化,维护时应重新检查探测主机名。
|
||||
41
docs/PROXY-AUTOMATIC-2026-10-04.md
Normal file
41
docs/PROXY-AUTOMATIC-2026-10-04.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Proxy 与 Automatic 选择链(2026-10-04)
|
||||
|
||||
最新节点范围、组成员及规则数量集中在 [当前生产状态](CURRENT-STATE.md)。下文业务约束已衔接后续修改;操作与备份记录保留统一代理入口时的阶段证据。
|
||||
|
||||
当前生产配置按用户要求由 Proxy 控制普通代理流量的出口。已有数据库通过管理 API 调整,原始初始化模板未修改,不用规则 reset 恢复旧引用。
|
||||
|
||||
## 业务约束
|
||||
|
||||
- 不允许规则直接指向 `⚡️ Automatic`;普通代理规则指向 `🚀 Proxy`。
|
||||
- 只有 Proxy 的成员列表包含 Automatic。AI、NoAuto 和其他组不独立引用 Automatic。Speedtest 组与其专用规则在同日后续调整中已删除。
|
||||
- Proxy 保持 `select`,成员顺序为 Automatic、All Nodes、新加坡、美国、日本。用户在客户端选择 Automatic 时,匹配 Proxy 的业务流量才沿 Automatic 选出口;选择其他成员则使用对应成员。
|
||||
- Automatic 为 `url-test`,保留新加坡、美国、日本三个地区组;这三个地区组本身也为 url-test。沿用 `interval=300`、`tolerance=50`。Surge 用 General 的 `proxy-test-url` 或节点 `test-url`,不输出组级 `url=`;Clash/Shadowrocket 使用组的 `http://cp.cloudflare.com/generate_204`。
|
||||
- AI 保持 select,成员为美国、新加坡、日本、🧠 AI Nodes。🧠 AI Nodes 是同日后续新增的独立 select 组,直接收纳全部节点,节点列表与 All Nodes 相同,按完整名称升序输出,两个组各自保存选择;见 [AI Nodes](AI-NODES-2026-10-04.md)。NoAuto 保持 select,只含 Mainland。
|
||||
- AI、Streaming、Bybit、直连、拦截和 FINAL 等原有分流继续独立生效;此次没有把全部流量统一改到 Proxy。FINAL 仍指向 NoAuto。
|
||||
|
||||
以上约束针对业务请求的策略路径,不等于关闭客户端测速或手动测试。
|
||||
|
||||
当前组显示顺序为 Proxy、Automatic、All Nodes、美国、新加坡、日本、AI、🧠 AI Nodes、Streaming、Bybit、Mainland、NoAuto。同日后续删除 Speedtest、集中排列 AI 规则、加入 Net.Coffee 检测域名并新增 AI Nodes 后,现状为 12 组、130 条规则、21 个成员;详情见 [AI 与广告优先级](AI-PRIORITY-2026-10-04.md)、[DNS 复测](NET-COFFEE-DNS-2026-10-04.md)与 [AI Nodes](AI-NODES-2026-10-04.md)。下文备份与数量记录的是本次统一代理入口时的阶段性状态。
|
||||
|
||||
## 统一代理入口阶段的数据变更
|
||||
|
||||
将 20 条原先指向 Automatic 的规则改到 Proxy:
|
||||
|
||||
| 类型/规则集 | 范围 |
|
||||
|---|---|
|
||||
| RULE-SET/DOMAIN-SET | Emby、Facebook、Instagram、Threads、Discord、GitHub、substore、CDN 两条、global_plus、global |
|
||||
| DOMAIN/DOMAIN-SUFFIX | api.amplitude.com、vercel.app、nssurge.com、surgee.me、www.torrentmac.net、cloudflare.com、jable.tv、m.cmx.im、api.themoviedb.org |
|
||||
|
||||
另将 Automatic 类型改为 url-test,从 AI/Speedtest/NoAuto 各移除一个 Automatic 成员。重读确认 12 个组、130 条规则、22 个组成员;没有直接指向 Automatic 的规则,唯一父组为 Proxy。其他规则字段与相对顺序、其他组配置、节点和设置均保持原状,唯一启用 FINAL 位于末尾。
|
||||
|
||||
x.com、twitter.com、bing.com、www.bing.com 原先由 global 接到 Automatic,当前 global 策略已改为 Proxy;后续出口由 Proxy 的客户端选择决定。Apple/Global Services 组及其专用规则、AI 的 Bing 专用规则仍维持已删除状态。
|
||||
|
||||
## 统一代理入口阶段的备份与验证
|
||||
|
||||
在线备份:`/opt/proxy-station/data/backups/before-proxy-gates-automatic-20261004-104820.db`,root 属主、600 权限。敏感 API 快照和实际订阅只存放在权限受控的私有临时目录。
|
||||
|
||||
实际 HTTPS Surge、Clash、Shadowrocket conf 均确认 Automatic 为 url-test、没有规则直接指向 Automatic、Proxy 为唯一父组、AI/Speedtest/NoAuto 已移除该选项,global 指向 Proxy。Surge self URL 正确;官方 Surge CLI 与 50/50 远程规则集快照校验通过,静态域名遮蔽提示为 0;Clash 结构与 `mihomo -t` 通过,FINAL/MATCH 末尾兜底保留。
|
||||
|
||||
Shadowrocket 仅做产物结构检查,没有真机编译或连接验收。未切换本机活动配置或修改设备已保存的策略选择;设备需要刷新订阅后获取新配置。此次只修改生产数据与文档,未部署代码。
|
||||
|
||||
测速参数含义以 [Surge url-test 官方说明](https://manual.nssurge.com/policy-groups/url-test.html) 为准:interval 是结果有效期,Surge 在组使用且结果过期等条件下重测,不表示固定每 300 秒执行;tolerance 用于减少相近延迟引起的反复切换。
|
||||
@@ -1,33 +1,53 @@
|
||||
# 节点导入指引
|
||||
|
||||
proxy-station 不直接读取 edge-nodes 工作区(那里全是真实凭据,保持解耦),节点通过 Web 界面批量粘贴分享链接导入。
|
||||
节点部署管理已迁入本仓库的 `edge-nodes/`,真实配置与交付产物位于 Git 忽略的 `edge-nodes/private/`。Proxy Station 应用运行时不自动读取该目录,节点仍通过 Web 界面批量粘贴分享链接导入。工作区说明见 [edge-nodes/README.md](../edge-nodes/README.md)。
|
||||
|
||||
## 从 edge-nodes 交付文档导入
|
||||
最新登记、启用与订阅范围见 [当前生产状态](CURRENT-STATE.md)。已有库导入新节点前先读当前节点并预览,避免把整份交付清单重复导入;部署脚本、分享链接存在或重新生成交付页都不表示该节点应被启用。
|
||||
|
||||
每个节点目录 `<id>-edge-node-docs/` 已生成好分享链接:
|
||||
## 从本仓库私有交付文档导入
|
||||
|
||||
- `<proto>-<egress>.link`——VMess/Trojan 的分享链接文件(`vmess://`、`trojan://`)
|
||||
- Shadowsocks 线路没有通用分享链接,用 sing-box JSON(`ss-*-singbox.json`)里的参数手动添加,或按 SIP002 + v2ray-plugin 格式自拼:
|
||||
每个节点目录 `edge-nodes/private/<id>-edge-node-docs/` 已生成好分享链接;以当前部署的 `scripts/node-links/` 为准,避免同时混入旧交付链接与重复副本。RN 当前使用 `rn2-edge-node-docs/`,旧 `rn-edge-node-docs/` 只作历史记录。
|
||||
|
||||
- `<proto>-<egress>.link`——VMess/Trojan/Shadowsocks 的分享链接文件(`vmess://`、`trojan://`、`ss://`);当前生成器会生成本系统可解析的 Xray 风格 SS over WS 链接
|
||||
- 旧交付若只有 Shadowsocks sing-box JSON(`ss-*-singbox.json`),用其中参数手动添加,或按 SIP002 + v2ray-plugin 格式自拼:
|
||||
`ss://base64url(chacha20-poly1305:密码)@域名:443?plugin=v2ray-plugin%3Btls%3Bmode%3Dwebsocket%3Bhost%3D域名%3Bpath%3D%2F路径#名称`
|
||||
- Hysteria2 线路在 `scripts/node-links/` 下有 `hysteria2://` 链接(含 `obfs=salamander&obfs-password=…`)
|
||||
- Hysteria2 线路在 `scripts/node-links/` 下有 `hy2://` 或 `hysteria2://` 链接(含 `obfs=salamander&obfs-password=…`),两种 scheme 均可解析
|
||||
|
||||
操作:
|
||||
|
||||
1. 在 edge-nodes 侧汇总链接:`cat ~/Development/Fusion/Projects/edge-nodes/*-edge-node-docs/*.link`
|
||||
1. 在仓库根目录收集当前舰队支持的链接(输出含凭据,只在本机使用):
|
||||
|
||||
```bash
|
||||
for site in sg la rn2 jp; do
|
||||
for proto in vmess trojan ss hy2; do
|
||||
for egress in direct warp; do
|
||||
link="edge-nodes/private/$site-edge-node-docs/scripts/node-links/$proto-$egress.link"
|
||||
[ ! -f "$link" ] || cat "$link"
|
||||
done
|
||||
done
|
||||
done
|
||||
```
|
||||
|
||||
也可从 `edge-nodes/private/边缘节点交付文档.html` 挑选对应线路。
|
||||
2. 打开 proxy-station「节点」页 →「批量导入」→ 粘贴(每行一条)→「解析预览」确认字段 →「导入」
|
||||
3. SOCKS5 线路为内部使用,不要导入本系统
|
||||
3. TUIC/SOCKS5 线路不属于本系统当前支持的类型,单独使用,不导入。线上数据任务按 `update-db` 技能先备份再走管理 API
|
||||
|
||||
## 命名建议(影响地区组归类)
|
||||
|
||||
模板的地区策略组按**节点名正则**筛选(如 `坡|🇸🇬|新加坡|狮城|SG|Singapore`、`美|🇺🇸|美国|US`)。命名时带上地区词与出口词,例如:
|
||||
|
||||
- `SG-vmess-direct`、`SG-trojan-warp`(命中 Singapore 组)
|
||||
- `US-LA-trojan-direct`、`US-RN-vmess-warp`(命中 United States 组;只写 LA/RN 不会命中任何地区组,会落入 Other)
|
||||
- `🇸🇬 SG VMess Direct`、`🇸🇬 SG Trojan Warp`(命中 Singapore 组)
|
||||
- `🇯🇵 JP VMess Direct`(命中 Japan 组)
|
||||
- `🇺🇸 US LA Trojan Direct`、`🇺🇸 US RN VMess Warp`(命中 United States 组;只写 LA/RN 不会命中地区正则,当前也没有 Other 兜底组)
|
||||
|
||||
名称含 `direct`/`warp` 会自动打出口标签,线路矩阵按此归位(琥珀 = 直连,青 = WARP)。
|
||||
|
||||
保留完整出口词;客户端没有 Web 的出口标签。新节点 sortOrder 保持默认 0,All Nodes/🧠 AI Nodes 从同一来源按完整名称升序收纳。名字决定地区正则,region 填站点码用于 Web 分段,详见 [新增地区](ADDING-REGIONS.md)。
|
||||
|
||||
## 导入后核对
|
||||
|
||||
- 「节点」页线路矩阵:每个站点的协议 × 出口格子是否齐全
|
||||
- 「策略组」页:地区组的「命中」行是否圈中了预期节点
|
||||
- 「订阅」页预览:Surge 端确认 SS over WS 节点已被自动屏蔽(预期行为,Surge 不支持该组合)
|
||||
- 保留已有停用状态;当前 RN、SS、HY2 的停用记录不通过导入或整理清单恢复。明确授权启用后再分别核对各端节点范围、UDP 能力、动态组引用与实际连接。
|
||||
- 生产写入前在线备份,完成后 API 重读及真实 HTTPS 订阅校验,按 [Surge 变更验证](SURGE-CHANGES.md)执行;解析/导入成功不能代替设备连接验证。
|
||||
|
||||
1453
docs/SURGE-AUDIT-2026-10-03.evidence.json
Normal file
1453
docs/SURGE-AUDIT-2026-10-03.evidence.json
Normal file
File diff suppressed because it is too large
Load Diff
224
docs/SURGE-AUDIT-2026-10-03.md
Normal file
224
docs/SURGE-AUDIT-2026-10-03.md
Normal file
@@ -0,0 +1,224 @@
|
||||
# Surge macOS 订阅体检:2026-10-03
|
||||
|
||||
本文保留修复前的体检结论;其中行号、缺陷与数量指体检时的源码与订阅快照。已实施的代码/生产修复和后续验证见 [修复记录](SURGE-FIX-2026-10-03.md),最新盘点见 [当前生产状态](CURRENT-STATE.md)。不要把下文历史缺陷与快照当作当前状态。
|
||||
|
||||
实际订阅通过 Surge 6.9.1 的原生语法校验,生产数据与当前生成器一致。但存在规则被遮蔽、DNS 与连接策略不一致,以及管理 API 能接受最终导致无效配置或丢失规则的数据操作。
|
||||
|
||||
本次只查询生产管理 API、盘点数据库行数、读取 HTTPS 订阅和公开规则集;没有修改生产节点、规则、设置,没有部署、重启或切换本机活动配置。订阅 GET 按现有业务逻辑正常更新访问计数和访问时间(`server/src/routes/sub.ts:30`)。所有 API 写入复现都使用 `/private/tmp/` 下的隔离数据库与占位凭据。
|
||||
|
||||
## 范围与证据
|
||||
|
||||
| 项目 | 实际结果 |
|
||||
|---|---|
|
||||
| 生产节点 | 30 个,均启用;Surge 输出 22 个,排除 8 个 SS over WS |
|
||||
| 生产策略组 | 14 个,均启用;无循环引用;地区组有效候选为美国 10、新加坡 6、日本 6 |
|
||||
| 生产规则 | 116 条,其中 115 条启用;旧 `PROTOCOL,DOH,AI` 已停用 |
|
||||
| 最终订阅规则 | 118 条:115 条数据库规则 + 3 条生成的 DNS 出口规则 |
|
||||
| 生产规则集 | 数据库登记 62 个;当前配置引用 60 个不同远程 URL |
|
||||
| 生产订阅 | 1 个启用订阅,节点范围为空数组,即全部启用节点 |
|
||||
| 原生语法校验 | Surge Mac 6.9.1(12290),`surge-cli --check` 返回 `OK`、退出码 0 |
|
||||
| 代码与生产一致性 | 用生产 API 快照重建 IR,由本地实际生成器输出;与 HTTPS 订阅逐字节相同 |
|
||||
| 远程依赖 | 60/60 URL 本次返回 HTTP 200,合计 166,028 个非空、非注释条目,无 FINAL 条目 |
|
||||
| 使用本次规则集快照的校验 | 将远程 URL 替换为临时本地文件,再用官方 CLI 校验:`OK` |
|
||||
|
||||
配置指纹、规则集指纹和复现结果见 [脱敏证据](SURGE-AUDIT-2026-10-03.evidence.json)。报告中的 `seq` 是数据库从 0 起算的顺序;“配置第 N 行”对应本次实际 HTTPS 响应。报告和证据不包含订阅 token、真实节点密码、UUID、节点地址或私有 Host IP。
|
||||
|
||||
校验能力以 [Surge 官方 CLI 文档](https://manual.nssurge.com/tools/cli.html) 为依据。CLI 返回 OK 证明配置可解析,不能证明每个规则都会被命中,也不能代替节点连接、解锁或泄漏测试。
|
||||
|
||||
P1 表示可导致配置不可用、规则丢失或重要控制失效;P2 表示行为偏差或需要修正的兼容性问题。静态推导的网络影响和依赖设备开关的风险明确另行说明。
|
||||
|
||||
## 已确认的业务代码缺陷
|
||||
|
||||
### C1 · P1:重置规则在存在自定义规则时失败,并先删除模板规则
|
||||
|
||||
`server/src/routes/rules.ts:60` 调用 `seedRulesFromTemplate(db, { keepCustom: true })`。`server/src/db/seed.ts:52` 读取自定义规则后只删除模板规则,随后在第 63 行把仍在表中的自定义规则再次 INSERT。自定义规则主键重复,且这些操作没有事务包裹。
|
||||
|
||||
隔离数据库复现结果:
|
||||
|
||||
```text
|
||||
首次初始化:89 条模板规则
|
||||
新增自定义规则:HTTP 201,合计 90 条
|
||||
POST /api/rules/reset:HTTP 500
|
||||
错误:UNIQUE constraint failed: rules.id
|
||||
操作后:模板规则 0 条,自定义规则 1 条
|
||||
```
|
||||
|
||||
生产快照中存在自定义规则,具备触发条件。**本次没有在生产调用 reset**,没有认定生产发生过此故障。
|
||||
|
||||
修复建议:把整个重置过程放入事务;保留自定义规则时更新其顺序,或在事务内明确删除后重插,避免重复主键;同时明确“重置”究竟是否保留自定义规则。现有 update-db 技能描述为清掉自定义规则,与当前代码的 `keepCustom: true` 意图不一致,需要一起纠正。
|
||||
|
||||
### C2 · P1:FINAL 并未得到完整保护,普通操作可使全部订阅无效
|
||||
|
||||
`server/src/services/ir.ts:102` 先过滤 `enabled=true`;后面的 FINAL 特判只处理策略引用失效,不能保住已被停用、删除或改成其他类型的 FINAL。`server/src/routes/rules.ts:83` 和第 92 行允许停用及删除唯一 FINAL,排序接口也没有校验末尾位置或完整 ids。
|
||||
|
||||
隔离 API 复现:创建 FINAL 返回 201,停用返回 200,生成器输出中 FINAL 数量变为 0;官方 CLI 返回:
|
||||
|
||||
```text
|
||||
Rules must end with FINAL
|
||||
```
|
||||
|
||||
当前生产恰有一条启用的 FINAL,位于末尾,因此当前配置通过校验。问题在后续编辑入口。官方要求规则以启用的 FINAL 结束;`dns-failed` 只是 DNS 失败时选用其策略,不会补回缺失的 FINAL。[FINAL 规范](https://manual.nssurge.com/rules/final.html)
|
||||
|
||||
修复建议:在写入时保护唯一末尾 FINAL,完整校验排序,拒绝产生无效状态的修改;生成侧再验证,不要仅靠注释声称“永远保留”。
|
||||
|
||||
### C3 · P1:输入与序列化允许合法 API 请求生成无效 Surge 配置
|
||||
|
||||
`shared/src/schemas.ts:6` 对节点名只校验长度;`server/src/services/generators/surge.ts:51` 直接拼接声明名。规则值在第 117 行直接拼接;`quoteName()` 只为策略引用加引号,没有完整处理引号/反斜线。
|
||||
|
||||
实际路由的隔离复现:
|
||||
|
||||
| 请求 | API 结果 | 官方 CLI 结果 |
|
||||
|---|---|---|
|
||||
| 新建名为 `Bad = Name` 的 VMess 节点 | 201 | `Unknown proxy type: Name = vmess` |
|
||||
| 新建 URL-REGEX,值为 `^https://example.com/(a\|b),?c` | 201 | 把 `?c` 误当策略名,拒绝配置 |
|
||||
|
||||
表中正则的 `\|` 仅为 Markdown 表格转义;实际测试值使用普通 `|`。
|
||||
|
||||
Surge 对含逗号的正则值要求引用;新版本还有明确的引号、反斜线转义规则。这是已用官方解析器复现的错误,不是按自建解析器猜测。[HTTP 规则](https://manual.nssurge.com/rules/http.html)、[配置引用格式](https://manual.nssurge.com/profile/format.html)
|
||||
|
||||
修复建议:区分声明名、策略引用、普通规则值、逻辑规则表达式的编码规则;拒绝不能安全表达的名称和换行输入。逻辑规则内部的逗号不能简单按普通字符串一律引用。
|
||||
|
||||
### C4 · P1:策略组循环可通过 API 写入,导致整个配置被拒绝
|
||||
|
||||
`server/src/routes/groups.ts:21`、第 38 行和第 77 行没有图循环检查。隔离测试建立 `A → B → A`,两次新建均返回 201,IR 保留这些引用;官方 CLI 拒绝:
|
||||
|
||||
```text
|
||||
Loop reference is found in the policy group: A.
|
||||
```
|
||||
|
||||
当前生产 14 个组没有循环。修复建议:新建、修改成员与改名时,在事务内校验引用存在、类型正确及图无环;不能把存在性检查当成循环检查。
|
||||
|
||||
### C5 · P2:filterRegex 分支丢弃显式成员,只保留第一个来源组
|
||||
|
||||
`server/src/services/generators/surge.ts:85` 在存在正则时不输出 `memberNames`,非 includeAllNodes 情形也只取第一个 group 成员。
|
||||
|
||||
占位 IR 复现:输入组同时有显式 `DIRECT`、`includeAllNodes=true` 和正则,输出只有动态来源与正则,DIRECT 被丢弃。官方规定正则只作用于动态导入的成员,显式成员不受过滤;`include-other-group` 可包含多个来源组。[策略成员导入](https://manual.nssurge.com/policy-groups/policy-including.html)
|
||||
|
||||
当前生产的三个地区组没有显式成员,暂不触发。修复建议:完整保留显式成员和多个动态来源,分别应用 Surge 规定的语义。
|
||||
|
||||
### C6 · P2:VMess 参数映射不完整
|
||||
|
||||
`server/src/services/generators/surge.ts:13` 没有输出 VMess 的 `skipCertVerify`,忽略 `security`,并无条件输出 `vmess-aead=true`。占位测试确认 `skipCertVerify=true` 不出现在结果中;`alterId=64` 也仍输出 AEAD。
|
||||
|
||||
官方要求 AEAD 设置与服务端匹配,并支持 TLS 证书校验参数及指定的加密方式。当前生产 VMess 均为 `alterId=0`、`security=auto`、`skipCertVerify=false`,没有证据表明现有节点因此连接失败。[VMess 规范](https://manual.nssurge.com/policies/vmess.html)、[TLS 参数](https://manual.nssurge.com/policies/tls.html)
|
||||
|
||||
修复建议:在能力层拒绝无法表达的旧协议组合,或按官方参数转换;不能让用户输入被静默忽略。所有输入字段都应有明确的支持/转换/拒绝行为。
|
||||
|
||||
## 体检时生产配置中的行为问题
|
||||
|
||||
### D1 · P1:两条例外规则被更早的拦截集覆盖
|
||||
|
||||
本次下载的 `https://ruleset.skk.moe/List/non_ip/reject.conf` 含有对 `amplitude.com` 的 DOMAIN-SUFFIX,以及对 `appsflyer` 的 DOMAIN-KEYWORD。
|
||||
|
||||
| 域名 | 更早匹配 | 后面的例外规则 |
|
||||
|---|---|---|
|
||||
| `api.amplitude.com` | seq 11,REJECT | seq 94,Automatic |
|
||||
| `inapps.appsflyer.com` | seq 11,REJECT | seq 66,Bybit |
|
||||
|
||||
现有例外不会覆盖早先的匹配。结论针对 Rule-Based 模式及本次规则集快照中的域名匹配;生产规则没有 pre-matching 标记,客户端资源可能存在缓存差异,未宣称已观察到设备上的请求失败。官方规则顺序是首条匹配决定策略。[规则顺序](https://manual.nssurge.com/rules/overview.html)
|
||||
|
||||
修复建议:先确定这两项是需要放行的业务例外,还是需要保持阻断的跟踪服务,再决定是否提前。不能自动为全部跟踪域名放行。
|
||||
|
||||
### D2 · P2:YouTube UDP 拦截被提前的 YouTube 规则集遮蔽
|
||||
|
||||
数据库 seq 37/配置第 135 行的 YouTube 规则集含 `DOMAIN-SUFFIX,googlevideo.com`,策略为 Streaming;seq 110/配置第 208 行才是:
|
||||
|
||||
```ini
|
||||
AND,((PROTOCOL,UDP), (DOMAIN-SUFFIX,googlevideo.com)),REJECT-NO-DROP
|
||||
```
|
||||
|
||||
对携带 googlevideo.com 主机名的普通 UDP 请求,先前的 DOMAIN-SUFFIX 已匹配,后面的 AND 不会生效。STUN 请求另有更前的拦截,所以不能由此认定 STUN 保护失效。逻辑表达式本身通过官方语法校验。[逻辑规则](https://manual.nssurge.com/rules/logical.html)、[规则顺序](https://manual.nssurge.com/rules/overview.html)
|
||||
|
||||
修复建议:若确实要让这类视频流量回落 TCP,把特定 UDP 规则放到相关服务域名规则之前;不要无依据地扩大为全局 UDP 拦截。
|
||||
|
||||
### D3 · P2:AI DNS 映射忽略更早的连接例外,且 googleapis 覆盖整个服务族
|
||||
|
||||
`server/src/services/generators/surge-ai-dns.ts:13` 收集所有指向 AI 的规则,并把结果整体放在数据库 Host 原文之前,不计算其他策略的更早例外。
|
||||
|
||||
本次可确认的例子:
|
||||
|
||||
```text
|
||||
连接策略:seq 8 的 Unbreak 集匹配 update.googleapis.com → DIRECT
|
||||
AI 域名规则:seq 30,DOMAIN-SUFFIX,googleapis.com → AI
|
||||
Host 第 224 行:*.googleapis.com → Cloudflare DoH
|
||||
Host 第 253 行:update.googleapis.com → 原来的国内 DNS
|
||||
```
|
||||
|
||||
Host 首条匹配生效,所以后面的精确 DNS 设置被前面的通配覆盖;连接仍明确直连,而 Surge 自己解析该域名时按 AI Host 项处理。`youtube.googleapis.com` 也被 seq 30 送到 AI,而不会使用后面的 YouTube Streaming 策略。后一项是否符合业务意图需要确认。
|
||||
|
||||
官方允许 Host 绑定规则集,当前语法没有问题;问题是两个独立的有序匹配系统没有同样的例外优先级。[Host 映射](https://manual.nssurge.com/dns/local-dns-mapping.html)
|
||||
|
||||
修复建议:明确 Google AI 与其他 Google API 的范围,并在生成 Host 时保留需要优先的精确例外。对于连接规则中的 IP、进程或复杂表达式,不能承诺 Host 能完全复刻。
|
||||
|
||||
### D4 · P2:靠前的 IP 拦截在业务域名规则前触发 DNS
|
||||
|
||||
seq 12/配置第 110 行加载 `https://ruleset.skk.moe/List/ip/reject.conf`,位于 AI、YouTube、Bybit 等规则之前。本次文件含 778 条 IP/ASN 条目,其中 736 条没有 `no-resolve`。
|
||||
|
||||
按官方执行语义,没有在更前规则被决定、且目标还是域名的请求会在这些 IP 条目处触发解析;DNS 失败时转到 FINAL 的策略,不继续匹配后面的业务域名规则。这是规范推导的控制流影响,未测量延迟或宣称已抓到泄漏。[IP 与 DNS 规则](https://manual.nssurge.com/rules/ip.html)
|
||||
|
||||
修复建议:先确定 IP 黑名单是否应当主动解析所有域名;可考虑在该 RULE-SET 上加 `no-resolve`,或调整域名与 IP 规则顺序。前者会改变对未解析域名的 IP 拦截覆盖,不能当成无代价优化。
|
||||
|
||||
### D5 · P2:三个地区测速地址在 Surge 中实际未生效
|
||||
|
||||
数据库三个 url-test 组的 `testUrl` 为 Cloudflare 的 HTTP 204 地址,配置第 83–85 行输出为组参数 `url=`。全局 `proxy-test-url` 却为 Apple 的地址,节点没有 `test-url`。
|
||||
|
||||
当前官方规范明确组上的旧 `url=` 无效,测速以节点 `test-url` 或全局地址为准。因此 UI 中组级测速地址与 Surge 实际测试依据不一致。[自动测速组](https://manual.nssurge.com/policy-groups/url-test.html)
|
||||
|
||||
修复建议:把可用的设置模型改为官方支持的节点/全局测试参数;不要仅在组编辑界面保留一个看似可用但不会生效的字段。
|
||||
|
||||
### D6 · P1(取决于设备可达性):控制接口使用全接口监听和模板默认口令
|
||||
|
||||
生产 General 同时配置 Remote Controller 和 HTTP API,均绑定 `0.0.0.0`,认证值与公开模板一致,并开启 Web Dashboard。模板本身也在仓库中,因此这些认证值不能视为私密、独立的设备凭据。订阅生成器原样透传 General。
|
||||
|
||||
这表示网络可达的控制接口存在风险;没有检查设备防火墙或公网端口,**不认定当前已暴露互联网**。官方说明这两个入口具有控制 Surge 的能力,HTTPS 不能弥补已知认证值的问题。[控制接口规范](https://manual.nssurge.com/profile/general.html)
|
||||
|
||||
修复建议:无远程控制需求时移除;本机需求绑定回环;有局域网控制需求时使用独立凭据与访问限制,避免所有订阅设备共享模板口令。
|
||||
|
||||
### D7 · P2(取决于设备开关):MITM 关闭服务端证书验证
|
||||
|
||||
实际订阅有 `skip-server-cert-verify=true` 和 5 个 MITM hostname,但没有 CA 内容或 Keystore 引用。官方说明此项会在 MITM 时关闭真实目标服务器的证书验证;HTTPS 重写需要 MITM 开启和受信任证书。[MITM 规范](https://manual.nssurge.com/http/mitm.html)
|
||||
|
||||
未检查设备是否关联其他包含 CA 的本地配置、模块或证书设置,因此不能宣称现在的 HTTPS 重写有效,也不能宣称已经发生 MITM 安全事件。修复建议:若需要 MITM,保留上游验证并在设备侧核实证书与开关;若仅需要代理分流,评估是否应移除不需要的 MITM 与重写配置。
|
||||
|
||||
## 需要明确业务意图的配置
|
||||
|
||||
这些项目有官方支持,不直接判为错误:
|
||||
|
||||
| 项目 | 当前实际行为 | 需要明确的意图 |
|
||||
|---|---|---|
|
||||
| `⚡️ Automatic` | 实际类型是 select,默认第一项 Singapore,不跨地区自动选最快 | 名称是否应改,或者确实需要跨地区自动选择 |
|
||||
| `🎯 NoAuto` | 首项 Mainland → DIRECT;FINAL 指向它并带 dns-failed | 新设备/无保存选择时,未知流量与 DNS 失败是否应默认直连 |
|
||||
| `🪙 Bybit` | 首项 DIRECT | 是否希望交易业务默认直连,还是应固定所选出口 |
|
||||
| `📋 All Nodes` 与地区组 | Direct 和 Warp 节点混在一起,地区 url-test 可切换出口 | AI/交易业务是否需要限制出口类型及切换范围 |
|
||||
| FFSB 的进程直连 | App Bundle 写法有效,但规则在所有服务/通用集合之后 | 是只对未被前面规则匹配的请求直连,还是要整个 App 强制直连 |
|
||||
| `DOMAIN-KEYWORD,byb` | 对任何包含该短字符串的域名匹配 | 是否可以缩小为已知域名,降低误收范围 |
|
||||
| SMTP 相关直连 | `DOMAIN-SUFFIX,smtp` 匹配域名后缀;`URL-REGEX,(Subject\|HELO\|SMTP)` 检查 HTTP URL | 是否原本想匹配邮件协议;这两条不会识别邮件正文或 SMTP 命令 |
|
||||
|
||||
select 的首项是未选过或保存的选择已失效时的默认;不能据此断言用户当前选中的出口。[手动选择组](https://manual.nssurge.com/policy-groups/select.html)
|
||||
|
||||
`PROCESS-NAME,/Applications/FFSB.app/` 的首尾斜线属于官方 App Bundle 前缀匹配模式,不应误报为非法目录规则。[进程规则](https://manual.nssurge.com/rules/process.html)
|
||||
|
||||
URL-REGEX 的作用对象是经过 HTTP 引擎处理的 URL,HTTPS 还取决于 MITM;正则中出现 SMTP/HELO 不会使它变成邮件协议匹配规则。[HTTP 规则范围](https://manual.nssurge.com/rules/http.html)
|
||||
|
||||
## 已核实正常与验证边界
|
||||
|
||||
- HTTPS 订阅的 `#!MANAGED-CONFIG` self URL 与实际请求完全一致;没有回落到内网 URL。当前 `interval=86400, strict=true` 受官方支持,但更新间隔不是推送保证,strict 也意味着更新失败时的可用性取舍。[托管配置](https://manual.nssurge.com/profile/managed-profile.html)
|
||||
- 生产三个地区正则能收纳当前 22 个可用节点,组引用无环,没有悬空规则策略,FINAL 位于末尾。
|
||||
- 8 个 SS over WS 排除符合 Surge 的能力范围;不要为了使节点数相同强行输出。[Shadowsocks](https://manual.nssurge.com/policies/shadowsocks.html)
|
||||
- Hysteria2 的 `salamander-password` 受支持,但官方注明需要 Mac 6.4.3+;实际检查使用 6.9.1。Host 规则集映射需要 Mac 5.10.0+。[Hysteria2](https://manual.nssurge.com/policies/hysteria2.html)
|
||||
- 60 个外部资源本次均可下载。`global_plus.conf` 只有注释,没有有效条目;这是零覆盖,不是失效 HTTP 响应。资源可动态更新,设备缓存内容可能不同。
|
||||
- 官方允许规则集中的非法行被跳过并记录警告。因此本次快照检查和 CLI 返回 OK,不能证明全部 166,028 个条目均已被客户端接受;逐条加载警告仍需设备侧检查。[规则集规范](https://manual.nssurge.com/rules/ruleset.html)
|
||||
- 现有 STUN 拦截的类型、关键字、顺序可用。生产没有全局 PROTOCOL,UDP,REJECT;`udp-policy-not-supported-behaviour=REJECT` 仅是匹配到不支持 UDP 的策略后的回落,不是全局禁用 UDP。[协议规则](https://manual.nssurge.com/rules/protocol-and-network.html)
|
||||
- Host 的 `server:` 映射只管 Surge 自己的 DNS 客户端,不会因 `use-local-host-item-for-proxy=true` 而改变远端代理的解析器;该开关只作用于 IP 映射。[Host 代理行为](https://manual.nssurge.com/dns/local-dns-mapping.html)
|
||||
- 当前四个站点的节点域名都有显式 Host IP 映射。没有因此假设永远不会出现 DNS 引导回退;仍需核对所选节点与设备日志。[加密 DNS 引导与回退](https://manual.nssurge.com/dns/encrypted-dns.html)
|
||||
- 没有切换本机配置、修改保存的策略选择、发起节点测速/带宽测试、导入 MITM CA 或抓取设备业务流量。
|
||||
|
||||
## 建议处理顺序
|
||||
|
||||
1. 修复 C1–C4:事务、FINAL、字段编码与循环校验,防止普通管理操作毁掉订阅。
|
||||
2. 明确业务例外后修正规则顺序:D1、D2;同时处理 D3 的 DNS 例外、D4 的主动 DNS 与 FINAL 失败策略。
|
||||
3. 收紧实际需要的控制入口与 MITM 参数:D6、D7。
|
||||
4. 对齐测速与节点映射:D5、C5、C6,再明确跨地区/出口选择策略。
|
||||
5. 对修复后的配置重复官方校验,然后在保留用户选择的前提下进行设备侧请求匹配、DNS、节点连接和业务回归。
|
||||
|
||||
本报告完成体检与复现,未实施以上修复。后续修改应分别列出业务代码变更和生产 API 数据变更;当前不要通过 reset 路径更新生产规则。
|
||||
44
docs/SURGE-CHANGES.md
Normal file
44
docs/SURGE-CHANGES.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Surge 订阅变更与验证
|
||||
|
||||
2026-10-03 的缺陷与证据见 [体检报告](SURGE-AUDIT-2026-10-03.md)。后续节点、组、规则和设置变更使用本文件的检查;具体 API 与生产备份操作见 update-db 技能。
|
||||
|
||||
最新节点启用范围、组成员、名称排序与规则优先级见 [当前生产状态](CURRENT-STATE.md)。历史验收成功不能替代新一轮 API 重读;本机模块和已保存策略选择也不等同于数据库配置。
|
||||
|
||||
## 写入前
|
||||
|
||||
- 获取最新全量节点、组、规则和设置,明确改动对象及受影响订阅范围。保留策略选择和出口语义;名称正则、节点范围、客户端排除不能只按数据库行数检查。
|
||||
- 分别核对节点登记总量、enabled、订阅 nodeIds 和客户端能力过滤。当前停用记录继续保留;All Nodes/AI Nodes 使用同一节点来源和独立选择,排序调整先核对 sortOrder,再看完整名称。
|
||||
- 节点/组名称不能包含配置分隔符、控制字符或与内置策略及另一类实体冲突。导入预览先验证字段;组更新替换完整 members,校验存在性、成员类型及完整图无环。
|
||||
- 保住唯一启用、末尾 FINAL;完整排序,不通过停用/删除 FINAL 处理兜底。不用 reset 调整生产;reset 的真实含义是事务内恢复模板并保留自定义规则。
|
||||
- 用官方规则顺序检查业务例外与广告/服务规则集的遮蔽,协议规则不放在提前覆盖其目标的域名规则后。IP 黑名单的 `no-resolve` 会改变未解析目标的拦截覆盖,要记录取舍。
|
||||
- DNS 对照连接规则、手写 Host、加密 DNS 出口和 FINAL 的 dns-failed 行为。Host 不能表达进程/协议/IP 路由,远端代理解析和浏览器自带 DoH 也不是同一条路径。
|
||||
- Surge 测速用节点 `test-url` 或 General 的 `proxy-test-url`,不用组级 `url=`。共享订阅不传播默认控制口令,MITM 保留上游证书验证。
|
||||
|
||||
## 本地与生产验证
|
||||
|
||||
代码修改运行相关单测与类型检查;共享字段或 IR 修改运行全量类型检查及单测,页面修改再构建。数据库/管理 API 测试必须在导入 db 模块之前设置绝对临时 DATA_DIR。
|
||||
|
||||
将候选配置存到私有临时目录,文件权限 600,然后运行:
|
||||
|
||||
```bash
|
||||
python3 scripts/check-surge.py /private/tmp/<profile>.conf
|
||||
python3 scripts/check-surge.py /private/tmp/<profile>.conf --fetch-rulesets
|
||||
```
|
||||
|
||||
脚本先调用本机官方 `surge-cli --check`,再检查 FINAL、无效组级 url、公开控制口令和 MITM 参数。第二条下载当前规则集,以临时本地快照再次调用官方 CLI,并提示更早不同策略对后面域名规则样例的覆盖。提示需要结合业务审阅,不能自动将所有广告域名放行。它不模拟 IP/协议/进程或客户端缓存,规则集非法行也可能被 Surge 跳过;有疑点时查看设备资源加载警告及请求匹配日志。
|
||||
|
||||
生产变更先在线备份,确认本次文件为 root 属主、600 权限;不要仅因脚本退出成功就假定权限正确。经管理 API 逐条核对状态,失败停止;在新代码防护部署后再写需要新防护的数据。组成员整体替换,排序使用完整且无重复的 ids;节点权重更新以私有文件保留全部原字段,重读排除排序/更新时间后比对,避免改变协议或凭据。写入后重读变更对象、核对数量与完整顺序,从实际 HTTPS 入口拉订阅并核对 MANAGED-CONFIG 的 self URL,然后重复以上校验。通过回环 API 拉的订阅可能带内网 self URL,不能拿它替代实际 HTTPS 验收。配置中的 token、密码、UUID、Host IP 不落仓库或共享日志。
|
||||
|
||||
下载使用 `curl -fsSL --max-time ...`,完成后检查文件存在、非空、预期段落或 YAML 结构,再运行原生校验。串行验证遇错停止;如果管道会隐藏前一步失败,启用 pipefail 或分别读取返回码。mihomo 在配置路径不存在时可能创建默认配置并报告成功,这种输出不能算真实订阅通过。
|
||||
|
||||
Clash 受影响时,用独立临时目录运行 `mihomo -t -d <临时目录> -f <配置>`;不要覆盖正在使用的配置或启动代理服务。没有客户端 CLI 的产物用对应单测和结构检查,明确尚缺的真机验收。HTTP 正文不包含 excluded/skippedRules,需将启用节点与实际产物逐项比对;配置规则条数也会受派生规则和客户端跳过类型影响。
|
||||
|
||||
Shadowrocket/Clash 的完整差异与待验项见 [2026-10-04 体检](MOBILE-CLIENT-AUDIT-2026-10-04.md)。除解析外还要检查节点完整名称顺序、UDP 能力、动态组显式成员、RULE-SET 的 no-resolve 和实际跳过规则。Shadowrocket 官方更新记录确认存在逻辑规则,不能把生成器的保守跳过范围当成客户端能力断言。
|
||||
|
||||
默认不切换活动 Surge 配置、修改保存的组选择或导入 CA。用户授权的本机复测需要更新既有托管配置时,先核对托管 URL 与当前配置,备份并读取最新组选择,再执行 `surge-cli managed-profile update`。该命令可能仅报告已安排重载;须随后读取 `dump profile effective` 和 `policy-group get` 确认真正生效,不把紧接更新的旧规则查询当作最终结果。
|
||||
|
||||
组改名时 API 会级联生产引用,但客户端保存选择按组身份存储。核对新组、父组路径和两个独立节点组的最新实际选择;仅迁移原选定节点到新组名,不借验证更换线路。用 `profile diff` 区分原订阅与模块覆盖;数据库 url-test 与设备有效 Smart 等类型可能不同,不直接用客户端覆盖修改数据库。
|
||||
|
||||
CLI 成功只证明解析;节点连接、测速结果、DNS/WebRTC 和业务连接仍需设备侧验证。Net.Coffee 的已完成复测限定于本机 Chrome+Surge 的随机探测域名路径,详见 [DNS 复测](NET-COFFEE-DNS-2026-10-04.md),不能代替其他设备或 Claude 全部业务请求的验收。
|
||||
|
||||
依据:[匹配与 DNS](https://manual.nssurge.com/rules/overview.html)、[FINAL](https://manual.nssurge.com/rules/final.html)、[成员导入](https://manual.nssurge.com/policy-groups/policy-including.html)、[测速](https://manual.nssurge.com/policy-groups/url-test.html)、[Host](https://manual.nssurge.com/dns/local-dns-mapping.html)、[引用格式](https://manual.nssurge.com/profile/format.html)、[CLI](https://manual.nssurge.com/tools/cli.html)。
|
||||
54
docs/SURGE-FIX-2026-10-03.md
Normal file
54
docs/SURGE-FIX-2026-10-03.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Surge 订阅修复与验证:2026-10-03
|
||||
|
||||
本次完成 [体检报告](SURGE-AUDIT-2026-10-03.md) 中已确认缺陷的代码修复和生产定向调整。原体检报告保留为修复前证据,本文数量与验收属于修复当日;最新盘点见 [当前生产状态](CURRENT-STATE.md),后续变更流程见 [Surge 变更验证](SURGE-CHANGES.md)。
|
||||
|
||||
## 修复结果
|
||||
|
||||
| 原问题 | 已实施的处理 |
|
||||
|---|---|
|
||||
| C1 重置丢失规则 | 事务内恢复模板,更新保留自定义规则的顺序,不重新 INSERT 原主键;自定义 FINAL 不与模板重复;故障注入证明失败整体回滚 |
|
||||
| C2 FINAL 缺乏保护 | API 拒绝停用、删除、改类型、重复创建、缺失/重复/不完整排序;IR 防御性保证唯一末尾 FINAL;页面关闭无效操作 |
|
||||
| C3 配置字段编码 | 节点/组名称与控制字符校验,导入预览和写入一致;Surge 值、规则参数转义;逻辑表达式校验结构、无策略子规则和嵌套深度 |
|
||||
| C4 组循环引用 | 写入前验证完整图、引用存在性与成员类型;组与成员替换、改名级联使用事务;节点/组/内置策略禁止名称冲突 |
|
||||
| C5 丢成员及单来源 | 保留显式节点和内置成员;包含全部成员组来源;全节点展开去重且显式成员先于自动补充成员 |
|
||||
| C6 VMess 映射 | 输出 TLS skip-cert-verify 与受支持 cipher;Surge 排除非零 alterId 和不支持 cipher 的 VMess,回报原因;当前生产节点不受额外排除 |
|
||||
| D1 业务例外遮蔽 | 将 AppsFlyer/Amplitude 既有例外放到广告集合之前,保留原策略 |
|
||||
| D2 视频 UDP 遮蔽 | googlevideo UDP 拦截移到 STUN 后、业务域名集合前,未扩大为全局 UDP 阻断 |
|
||||
| D3 DNS 例外冲突 | 手写 Host 优先;按规则顺序保留 AI 之前的非 AI 域名/规则集 DNS 例外;update.googleapis.com 的原精确 DNS 映射不再被覆盖 |
|
||||
| D4 提前主动解析 | 生产 IP reject RULE-SET 加 no-resolve;对尚未解析域名不再主动查 IP,已解析/IP 字面量仍可按黑名单处理 |
|
||||
| D5 测速地址无效 | 删除 Surge 无效组级 url=;生产 General 的 proxy-test-url 改为 Cloudflare 204;UI 标明组级地址仅用于 Clash/ShadowRocket;fallback 不输出 tolerance |
|
||||
| D6 默认控制口令 | 生产与初始化模板移除默认控制接口、Dashboard 及相关 TLS 参数;API/CLI 检查拒绝公开默认口令(包括带引号的写法);恢复 LAN 访问限制 |
|
||||
| D7 MITM 上游验证 | 生产与模板改为 skip-server-cert-verify=false;API/CLI 检查阻止重新关闭上游验证 |
|
||||
|
||||
`scripts/check-surge.py` 是新增的只读本地验证入口:官方 Surge CLI、FINAL、测速与安全参数、当前规则集快照,以及静态域名遮蔽提示。update-db helper 新增 `--body-file`,敏感 JSON 不进入命令参数,并校验/引用请求路径。它通过 mock SSH 验证 stdin 正文、URL 引用及危险路径拒绝。
|
||||
|
||||
## 生产变更
|
||||
|
||||
在线备份:`/opt/proxy-station/data/backups/before-surge-audit-fix-20261003-223218.db`,SQLite `PRAGMA quick_check` 返回 `ok`,备份含 116 条规则。
|
||||
|
||||
在最新快照上预演三项管理 API 操作,执行前再次确认节点、组、规则、规则集与设置未被其他修改。三个 PUT 均返回 200:IP 黑名单参数、完整规则排序、Surge 设置。没有调用生产 reset、直接写 SQLite 或改动节点凭据、组成员和订阅范围。
|
||||
|
||||
变更后盘点保持:30 个节点、14 个组、32 个成员、116 条规则(115 启用)、62 个规则集、1 个订阅、6 个设置。Surge 仍输出 22 个节点,8 个 SS over WS 按能力排除。
|
||||
|
||||
前后端已部署;前端/源码指纹、systemd active、公网健康和无 token 管理接口 401 均通过。工作区尚未提交,没有创建 git 提交或推送。
|
||||
|
||||
## 验证证据
|
||||
|
||||
- 全量类型检查通过,44 项 Vitest 通过;隔离 SQLite 验证真实 API、事务回滚、重置与 FINAL/循环/非法输入保护。
|
||||
- 本机 Surge Mac 6.9.1(12290)实际校验六组占位配置,覆盖带逗号正则与密码、引号/反斜线、动态成员、TLS 与 cipher、逻辑规则及通知参数、缺失 FINAL 防御。
|
||||
- 前端 production build 通过。
|
||||
- 生产 API 快照中的 160 个节点/组/规则对象通过新 schema;候选操作在隔离数据库通过实际 API 路由执行。
|
||||
- 真实 HTTPS 的 Surge、Clash 和 ShadowRocket conf 均返回 200,并与已验证候选逐字节一致。
|
||||
- Surge 实际配置官方 CLI:OK;60/60 当前远程资源下载成功,替换为本地快照后官方 CLI:OK,静态域名遮蔽提示 0。
|
||||
- Clash 实际配置在独立临时目录下 `mihomo -t` 通过;没有启动代理或覆盖活动配置。
|
||||
- 技能 frontmatter、相对引用、符号链接、脚本语法及 git diff 空白检查通过。技能校验器缺少 PyYAML,使用项目现有 YAML 库适配其解析接口运行原校验规则,没有安装全局依赖。
|
||||
|
||||
Surge HTTPS 正文 SHA-256:`0c12a56f72bbc0e1b116570bbcccd2208cdd1ca06568391a7769728ae5499caf`。脱敏校验结果见 [验证数据](SURGE-FIX-2026-10-03.verification.json)。原始 API 快照和含凭据配置仅在权限受限的临时目录,不进入仓库或报告。
|
||||
|
||||
## 保留的业务选择与验收边界
|
||||
|
||||
Automatic 仍为手动 select,Bybit 与 FINAL 的原默认直连语义、googleapis 的 AI 路由范围、Direct/Warp 混合候选、FFSB 和 SMTP 相关规则未擅自改成其他业务策略。googleapis 范围若要缩小,应另以业务域名范围为依据调整;本次修复其 DNS 例外优先级。
|
||||
|
||||
no-resolve 会降低对未解析域名的 IP 黑名单覆盖,这是避免提前主动 DNS 的明确取舍。控制接口默认移除;需要远程控制的设备应在设备侧配置独立凭据与访问限制。设备需更新订阅后应用配置;托管订阅并非即时推送。
|
||||
|
||||
本次没有切换设备活动配置、改变保存的组选择、测速连接、导入 CA 或抓取业务流量。CLI 成功不证明实际节点连通、测速结果、业务解锁、MITM 开关/CA 或 DNS/WebRTC 无泄漏;这些仍属于设备侧验收。
|
||||
68
docs/SURGE-FIX-2026-10-03.verification.json
Normal file
68
docs/SURGE-FIX-2026-10-03.verification.json
Normal file
@@ -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
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
# Surge AI 域名 DNS 分流
|
||||
|
||||
当前数据库与实际 HTTPS 产物、节点/组选择链见 [生产状态](CURRENT-STATE.md)。AI DNS 由生成器派生,检查时区分数据库 Surge 原文、最终订阅及设备模块后的有效配置。
|
||||
|
||||
Claude/ChatGPT 的精确补充域名、广告拦截优先级例外、官方 inbound IP 兜底与不采用全局 NTP 分流的理由,见 [2026-10-04 AI 规则审阅](AI-RULE-REVIEW-2026-10-04.md)。该次补充保留已有 AI 规则集;新增域名同样由下述流水线派生 DNS 映射。
|
||||
|
||||
同日后续已将全部启用的 AI 规则集中排到广告及普通分流之前,按用户选择保留 STUN 阻断和 Google 更新/翻译直连例外。Unbreak 仍在 AI 之前,生成的 Host 映射也保持该顺序;手写 Host 未改动。具体排序、验证及限制见 [AI 与广告优先级](AI-PRIORITY-2026-10-04.md)。策略组显示顺序不决定连接规则或 DNS 匹配优先级。
|
||||
|
||||
Net.Coffee 的 Claude 页面通过随机 `*.d.ip.net.coffee` 子域名检测 DNS,而不是查询 Claude 域名。线上已按用户要求加入 `DOMAIN-SUFFIX,ip.net.coffee,🤖 AI`;精确 DOMAIN 根域规则无法覆盖这些探测子域。页面与探测子域名采用 AI 连接及派生 DNS 映射,本机更新同一托管订阅后,由中国电信 DNS「可能泄露」变为美国 Cloudflare「未检测到泄露」。页面检测权威 DNS 观察到的递归解析器,并按地区判断,不要求解析器 IP 等于代理出口 IP;无返回结果也不能证明无泄漏。检测机制、备份与验收边界见 [Net.Coffee DNS 复测](NET-COFFEE-DNS-2026-10-04.md)。
|
||||
|
||||
默认 DNS 基于 https://github.com/ClashConnectRules/Surge 的 Surge.conf:阿里、腾讯 DoH 与原来的国内 Host 映射保持直连;不再将全局 DNS 送到 AI,也不再新增全端口 53 接管。
|
||||
|
||||
Surge 生成器从启用的 AI 策略规则自动生成 Host 映射。组名支持 `AI` 和 `🤖 AI`。DOMAIN 精确匹配;DOMAIN-SUFFIX 同时生成根域与子域匹配;DOMAIN-KEYWORD 生成通配符;RULE-SET / DOMAIN-SET 直接绑定相同远程列表,由 Surge 更新,无需维护国内域名集合。IP、进程及复合规则无法直接转换为域名 DNS 映射,不生成。Host 与连接规则是独立匹配系统,复杂优先级或例外可能不同。
|
||||
@@ -12,13 +20,15 @@ DOMAIN,doh.pub,DIRECT
|
||||
DOMAIN,dns.alidns.com,DIRECT
|
||||
```
|
||||
|
||||
AI Host 映射使用 `server:https://1.1.1.1/dns-query`,放在普通 Host 映射之前。选择其他 AI 线路后 Cloudflare 查询也跟随同一策略组。该 IP 规则也会将访问 1.1.1.1 的其他连接交给 AI 组。
|
||||
AI Host 映射使用 `server:https://1.1.1.1/dns-query`。手写 Host 项在前,保留精确 DNS 例外;随后按连接规则顺序生成至最后一条 AI 域名规则的映射,先前非 AI 域名/规则集使用 General 的普通 DNS。这样 Unbreak 等先前例外不会被后面的宽泛 AI 映射覆盖。选择其他 AI 线路后 Cloudflare 查询也跟随同一策略组。该 IP 规则也会将访问 1.1.1.1 的其他连接交给 AI 组。
|
||||
|
||||
AI 可以选择地区组或独立的 `🧠 AI Nodes`;后者与 All Nodes 的节点来源相同、保存选择独立,详见 [AI Nodes](AI-NODES-2026-10-04.md)。2026-10-04 数据库 General 原文中的 encrypted-dns-follow-outbound-mode 仍为 false,生成器将最终配置派生为 true;不需要为这个差异重复修改全局 DNS 设置。
|
||||
|
||||
本功能控制 Surge 自己执行的 DNS 查询。代理目标域名可能由远端代理解析;官方说明 use-local-host-item-for-proxy 仅适用于 IP 映射,不适用于 server: 映射,因此不能声称远端代理的解析器被改为 Cloudflare。浏览器自带 DoH 也不自动按 AI 域名匹配这些 Host 项。
|
||||
|
||||
代理服务器域名的解析可能触发加密 DNS 循环依赖,需核对 Surge 日志。线上保留已验证的节点 Host IP 映射供引导使用;节点 IP 变化后更新映射,私有节点 IP 不提交仓库。
|
||||
|
||||
数据库 settings/rules 是已有订阅的来源,模板仅首次初始化导入。修改默认 DNS 时使用 update-db 技能备份并经管理 API 更新;不重置规则。移除上一版全局 PROTOCOL,DOH,AI;保留 STUN 阻断及 UDP REJECT,不影响已授权的 WebRTC 防护。
|
||||
数据库 settings/rules 是已有订阅的来源,模板仅首次初始化导入。修改默认 DNS 时使用 update-db 技能备份并经管理 API 更新;不重置规则。全局 PROTOCOL,DOH,AI 已停用;保留 STUN 阻断,特定 googlevideo UDP 拦截放在服务域名规则之前。生产没有全局 UDP 拦截,不把 UDP 回落参数误认为全局阻断。
|
||||
|
||||
导入时关联 General、Proxy、Proxy Group、Rule、Host,并开启增强模式。无需 MITM 证书。更新后清除 DNS 缓存并复测 Surge DNS 请求日志;生成配置验证不是设备泄漏测试。
|
||||
|
||||
|
||||
9
edge-nodes/AGENTS.md
Normal file
9
edge-nodes/AGENTS.md
Normal file
@@ -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 应用发布、生产订阅数据更新分别按用户任务执行。本地迁移与文档更新不会授权远端变更。
|
||||
4
edge-nodes/CLAUDE.md
Normal file
4
edge-nodes/CLAUDE.md
Normal file
@@ -0,0 +1,4 @@
|
||||
@../AGENTS.md
|
||||
@AGENTS.md
|
||||
|
||||
节点目录补充约定见本目录 `AGENTS.md`,部署流程使用项目级 `edge-node-multi-protocol` 技能。
|
||||
84
edge-nodes/README.md
Normal file
84
edge-nodes/README.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# 边缘节点工作区
|
||||
|
||||
节点部署管理与 Proxy Station 开发现在统一在本仓库进行。通用工具可随 Git 维护,真实配置与交付产物保存在本机的 `private/` 中。
|
||||
|
||||
```text
|
||||
edge-nodes/
|
||||
├── build-delivery-doc.py # 离线汇总页生成器,不含站点凭据
|
||||
├── fleet.example.json # 占位清单,供新工作区参考
|
||||
└── private/ # Git 忽略,目录权限 700
|
||||
├── fleet.json # 当前舰队清单,含凭据,权限 600
|
||||
├── sg-edge-node-docs/
|
||||
├── la-edge-node-docs/
|
||||
├── rn2-edge-node-docs/ # RN 当前部署(2026-08-01 重建)
|
||||
├── jp-edge-node-docs/
|
||||
├── rn-edge-node-docs/ # RN 历史交付
|
||||
├── 边缘节点交付文档.html # 内嵌链接与二维码的私有离线页
|
||||
└── archive/ # 原 CLAUDE.md、生成器与 .skill 包的迁移快照
|
||||
.claude/skills/edge-node-multi-protocol/
|
||||
├── SKILL.md
|
||||
├── scripts/ # 通用 Bash/Python 部署、检测与链接生成工具
|
||||
├── references/ # 部署流程、版本兼容、证书、DNS、3x-ui 结构
|
||||
└── templates/ # 未填入真实值的交付模板与门面页
|
||||
.agents/skills/edge-node-multi-protocol -> ../../.claude/skills/edge-node-multi-protocol
|
||||
```
|
||||
|
||||
## 当前舰队
|
||||
|
||||
| 站点 | 服务商/地区 | 私有工作目录 |
|
||||
|---|---|---|
|
||||
| sg | AWS/新加坡 | `private/sg-edge-node-docs/` |
|
||||
| la | DMIT/洛杉矶,承载 Proxy Station 生产服务 | `private/la-edge-node-docs/` |
|
||||
| rn | RackNerd/洛杉矶 | `private/rn2-edge-node-docs/` |
|
||||
| jp | VMISS/东京,裸 nginx | `private/jp-edge-node-docs/` |
|
||||
|
||||
站点信息是迁移时的工作记录;维护前仍需读取私有交付文档并核对实际机器。`rn-edge-node-docs/` 的旧链接不作为当前 RN 节点导入来源。
|
||||
|
||||
应用内的登记/启用节点与舰队交付线路是不同统计范围。最新应用盘点为 30 个登记节点、12 个启用、18 个停用,见 [当前生产状态](../docs/CURRENT-STATE.md);停用不表示远端线路故障,维护节点或生成交付页不自动恢复应用开关。
|
||||
|
||||
2026-10-03 四台现役节点已统一使用 IPv4:系统接口禁用 IPv6,代理层拒绝 IPv6 目标(含 WARP),Cloudflare 不保留节点 AAAA。后续重建与扩展遵守 [IPv4-only 维护约定](../docs/EDGE-IPV4-ONLY.md);具体备份与验收在各站点的私有 `04-IPv4-only.md`。
|
||||
|
||||
## 日常工作
|
||||
|
||||
在仓库根目录启动 Codex/Claude Code。节点部署或维护使用 `edge-node-multi-protocol` 技能,应用发布使用 `deploy`,线上订阅数据使用 `update-db`。技能只维护一份源码,Codex 通过 `.agents/skills/` 相对链接发现。
|
||||
|
||||
已有节点使用其私有 `scripts/` 工作副本,以保留站点适配;修改通用能力时同步整理到技能源目录,按目标节点审阅后更新工作副本。原始副本不会在代码更新时自动覆盖。
|
||||
|
||||
新增节点可在仓库根目录准备私有目录:
|
||||
|
||||
```bash
|
||||
umask 077
|
||||
mkdir -p edge-nodes/private/<id>-edge-node-docs
|
||||
cp -R .claude/skills/edge-node-multi-protocol/{scripts,references,templates} \
|
||||
edge-nodes/private/<id>-edge-node-docs/
|
||||
cp edge-nodes/private/<id>-edge-node-docs/scripts/config.env.example \
|
||||
edge-nodes/private/<id>-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` 读取 `<slug>.link`、`<slug>-qr.png` 与可选 `<slug>-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、在线二维码或公开交付页同步。不要将填写过的真实值放回技能源目录的脚本、参考资料或模板。
|
||||
764
edge-nodes/build-delivery-doc.py
Normal file
764
edge-nodes/build-delivery-doc.py
Normal file
@@ -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(
|
||||
' <div class="kv"><dt>%s</dt><dd%s>%s</dd></div>' % (e(k), cls, e(v))
|
||||
for k, v in pairs)
|
||||
|
||||
|
||||
def copy_btn(value, label="复制"):
|
||||
return ('<button class="copy" type="button" data-copy="%s" aria-label="复制%s">%s</button>'
|
||||
% (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 = ('<button class="qr" type="button" data-qr="%s" data-title="%s" data-link="%s" '
|
||||
'aria-label="放大查看 %s 的二维码">'
|
||||
'<img src="data:image/png;base64,%s" alt=""></button>'
|
||||
% (ln["qr"], e(ln["proto"] + " · " + egtxt), e(ln["link"] or ""),
|
||||
e(ln["proto"]), ln["qr"]))
|
||||
else:
|
||||
qr_html = '<div class="qr noqr" aria-hidden="true"><span>无标准<br>分享链接</span></div>'
|
||||
|
||||
body = []
|
||||
body.append('<div class="ln-head">'
|
||||
'<span class="proto">%s</span>'
|
||||
'<span class="pill %s">%s</span>'
|
||||
'</div>' % (e(ln["proto"]), eg, e(egtxt)))
|
||||
body.append('<div class="ln-meta"><span>%s</span><span class="sep">·</span><span class="mono">%s</span></div>'
|
||||
% (e(ln["transport"]), e(ln["endpoint"])))
|
||||
if ln.get("plaintext"):
|
||||
body.append('<p class="ln-warn">认证与流量均为明文,链路上可被读取,且特征易被识别。'
|
||||
'仅适合可信网络内使用,不要用于穿越审查。</p>')
|
||||
|
||||
if ln["link"]:
|
||||
body.append('<div class="ln-link"><code>%s</code>%s</div>'
|
||||
% (e(ln["link"]), copy_btn(ln["link"], "复制链接")))
|
||||
if ln["json"]:
|
||||
body.append('<details class="ln-json"><summary>sing-box 配置片段</summary>'
|
||||
'<pre>%s</pre>%s</details>'
|
||||
% (e(ln["json"]), copy_btn(ln["json"], "复制配置")))
|
||||
cls = eg + (" plain" if ln.get("plaintext") else "")
|
||||
return (' <article class="line %s">\n %s\n <div class="ln-body">%s</div>\n </article>'
|
||||
% (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('<span class="cell %s" title="%s · %s">%s</span>'
|
||||
% (cls, e(ln["proto"]), label, e(ln["proto"][:2])))
|
||||
return '<div class="matrix">%s</div>' % "".join(cells)
|
||||
|
||||
|
||||
def render_site(site):
|
||||
p = []
|
||||
p.append(' <section class="panel" id="panel-%s" role="tabpanel" aria-labelledby="tab-%s" tabindex="0">' % (site["id"], site["id"]))
|
||||
|
||||
# identity
|
||||
p.append(' <div class="ident">')
|
||||
p.append(' <div class="ident-main"><span class="flag">%s</span>'
|
||||
'<h2>%s<span class="city">%s</span></h2></div>' % (site["flag"], e(site["code"]), e(site["city"])))
|
||||
p.append(' <div class="ident-sub"><code>%s</code><span class="tagline">%s</span></div>' % (e(site["domain"]), e(site["tagline"])))
|
||||
p.append(' %s' % matrix(site))
|
||||
p.append(' </div>')
|
||||
|
||||
# 01 站点
|
||||
p.append(' <section class="block"><h3><i>01</i>站点</h3>')
|
||||
p.append(' <dl class="grid">')
|
||||
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(' <div class="kv full"><dt>拥塞控制</dt><dd class="v">%s</dd></div>' % e(site["bbr"]))
|
||||
p.append(' </dl>')
|
||||
if site["notes"]:
|
||||
p.append(' <ul class="notes">%s</ul>' % "".join("<li>%s</li>" % e(n) for n in site["notes"]))
|
||||
p.append(' </section>')
|
||||
|
||||
# 02 账号
|
||||
p.append(' <section class="block"><h3><i>02</i>账号</h3>')
|
||||
for pan in site["panels"]:
|
||||
p.append(' <div class="acct">')
|
||||
p.append(' <div class="acct-top"><span class="acct-name">%s</span>'
|
||||
'<a href="%s" target="_blank" rel="noopener">%s</a></div>' % (e(pan["name"]), e(pan["url"]), e(pan["url"])))
|
||||
p.append(' <div class="acct-cred">'
|
||||
'<span class="lbl">用户名</span><code>%s</code>%s'
|
||||
'<span class="lbl">密码</span><code>%s</code>%s</div>'
|
||||
% (e(pan["user"]), copy_btn(pan["user"], "复制用户名"),
|
||||
e(pan["pass"]), copy_btn(pan["pass"], "复制密码")))
|
||||
if pan.get("note"):
|
||||
p.append(' <p class="acct-note">%s</p>' % e(pan["note"]))
|
||||
p.append(' </div>')
|
||||
p.append(' <dl class="grid plain">')
|
||||
p.append("\n".join(
|
||||
' <div class="kv"><dt>%s</dt><dd class="v"><a href="%s" target="_blank" rel="noopener">%s</a>'
|
||||
'<span class="hint">%s</span></dd></div>' % (e(n), e(u), e(u), e(d))
|
||||
for n, u, d in site["sites_plain"]))
|
||||
p.append(' </dl>')
|
||||
p.append(' </section>')
|
||||
|
||||
# 03 节点
|
||||
p.append(' <section class="block"><h3><i>03</i>节点</h3>')
|
||||
p.append(' <div class="shared"><div class="shared-hd">共用凭据 · 全部 %d 条线路同一套身份</div>' % len(site["lines"]))
|
||||
for label, val, scope in site["creds"]:
|
||||
p.append(' <div class="cred"><span class="lbl">%s</span><code>%s</code>%s'
|
||||
'<span class="scope">%s</span></div>' % (e(label), e(val), copy_btn(val, "复制"+label), e(scope)))
|
||||
p.append(' <div class="cred"><span class="lbl">实测出口</span>'
|
||||
'<span class="pill direct">直连 %s</span>'
|
||||
'<span class="pill warp">WARP Cloudflare</span></div>' % e(site["egress_direct"]))
|
||||
p.append(' </div>')
|
||||
p.append(' <div class="lines">')
|
||||
for i, ln in enumerate(site["lines"]):
|
||||
p.append(line_card(ln, i))
|
||||
p.append(' </div>')
|
||||
p.append(' </section>')
|
||||
|
||||
p.append(' </section>')
|
||||
return "\n".join(p)
|
||||
|
||||
|
||||
def render_subs():
|
||||
chips = "\n".join(
|
||||
' <button class="chip" type="button" data-group="%s" aria-pressed="%s">'
|
||||
'%s<span class="chip-n">%d</span></button>'
|
||||
% (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 """
|
||||
<section class="subs" id="subs">
|
||||
<div class="subs-hd">
|
||||
<h2>订阅</h2>
|
||||
<p>把全部线路打成一份订阅,节点名统一为 <code>[国家码] [站点码] [协议] [Direct/Warp]</code>,
|
||||
导入后在客户端里自行挑线路。内容由本页离线生成,不访问任何服务器。</p>
|
||||
</div>
|
||||
|
||||
<div class="chips" role="group" aria-label="订阅范围">
|
||||
%(chips)s
|
||||
</div>
|
||||
|
||||
<div class="subs-act">
|
||||
<button class="act primary" type="button" id="sub-dl">下载订阅文件</button>
|
||||
<button class="act copy" type="button" id="sub-b64" data-copy="">复制订阅内容(Base64)</button>
|
||||
<button class="act copy" type="button" id="sub-plain" data-copy="">复制明文链接</button>
|
||||
<span class="subs-count" id="sub-count"></span>
|
||||
</div>
|
||||
|
||||
<ol class="sub-list" id="sub-list"></ol>
|
||||
|
||||
<details class="subs-how">
|
||||
<summary>怎么导入</summary>
|
||||
<div class="how-body">
|
||||
<p><b>v2rayN / v2rayNG / NekoBox</b> 点「复制明文链接」,在客户端里选「从剪贴板导入批量 URL」。
|
||||
也可先「下载订阅文件」,再用本地文件导入。</p>
|
||||
<p><b>Shadowrocket / Stash</b> 点「复制订阅内容(Base64)」,回到 App 会提示识别到订阅内容;
|
||||
或把下载的 .txt 通过 AirDrop / 文件 App 传到手机后用「打开方式」导入。</p>
|
||||
<p><b>Clash 系(Verge / Meta)</b> 不吃这种 Base64 订阅,需要先转成 Clash 配置;
|
||||
建议改用 sing-box 或 Xray 内核的客户端,或直接使用上方各线路的 sing-box 配置片段。</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="subs-how">
|
||||
<summary>使用 Proxy Station 托管订阅</summary>
|
||||
<div class="how-body">
|
||||
<p>在 Proxy Station 的「节点」页粘贴本页对应的 VMess、Trojan、Shadowsocks、Hysteria2
|
||||
分享链接,解析预览并导入,再到「订阅」页创建订阅并复制客户端 URL。</p>
|
||||
<p>SOCKS5 和 TUIC 线路目前不属于 Proxy Station 支持的节点类型,需单独使用。
|
||||
Shadowsocks over WebSocket 在 Surge 订阅中会被排除。</p>
|
||||
<p>本页含真实凭据,请只在本机或通过受控文件交付使用。</p>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<script type="application/json" id="sub-data">%(data)s</script>
|
||||
""" % {"chips": chips, "data": data}
|
||||
|
||||
|
||||
subs = render_subs()
|
||||
|
||||
tabs = "\n".join(
|
||||
' <button class="tab" id="tab-%s" role="tab" aria-controls="panel-%s" aria-selected="%s" tabindex="%s">'
|
||||
'<span class="t-flag">%s</span><span class="t-code">%s</span>'
|
||||
'<span class="t-city">%s</span><span class="t-n">%d</span></button>'
|
||||
% (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 = '<span class="dot"></span><span class="nm"></span><span class="tr"></span>';
|
||||
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 = """<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
<title>边缘节点 · 交付文档</title>
|
||||
<style>%(css)s</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="masthead">
|
||||
<div class="wrap mh-in">
|
||||
<div class="brand">边缘节点<span>/</span>交付文档</div>
|
||||
<div class="mh-meta"><b>%(nsite)d</b> 站点 <b>%(nline)d</b> 条线路 更新于 %(updated)s
|
||||
<a class="mh-jump" href="#subs">订阅 ↓</a></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="wrap">
|
||||
<div class="tabs" role="tablist" aria-label="站点">
|
||||
%(tabs)s
|
||||
</div>
|
||||
|
||||
%(panels)s
|
||||
|
||||
%(subs)s
|
||||
|
||||
<div class="warn">
|
||||
<b>凭据安全</b> 本页含全部站点的明文密码、UUID 与可直接导入的节点链接。子域前缀、面板入口路径、WS 路径与 QUIC 端口共同构成控制面的地图 —— 请勿提交到 git 或上传至任何在线服务;需要转交时直接发文件,不要粘贴内容。<br>
|
||||
<b>出口区分</b> <span class="pill direct">直连</span> 走服务器本机 IP,速度最快但暴露真实 IP;<span class="pill warp">WARP</span> 经 Cloudflare 净出口,隐藏真实 IP。连上后访问 <code>https://1.1.1.1/cdn-cgi/trace</code> 看 <code>ip=</code> 与 <code>warp=</code> 即可验证。
|
||||
</div>
|
||||
<p class="foot">全部线路已在服务器侧实测通过(WS 握手 / QUIC 出口 / 直连与 WARP 分流)。二维码为本地离线生成,未经过任何第三方服务。</p>
|
||||
</div>
|
||||
|
||||
<div class="lb" id="lb" role="dialog" aria-modal="true" aria-labelledby="lb-title">
|
||||
<div class="lb-card">
|
||||
<img id="lb-img" alt="节点二维码">
|
||||
<div class="lb-title" id="lb-title"></div>
|
||||
<div class="lb-link" id="lb-link"></div>
|
||||
<div class="lb-act">
|
||||
<button class="copy" id="lb-copy" type="button" data-copy="">复制链接</button>
|
||||
<button class="lb-close" id="lb-close" type="button">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>%(js)s</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
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))
|
||||
45
edge-nodes/fleet.example.json
Normal file
45
edge-nodes/fleet.example.json
Normal file
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
141
scripts/check-surge.py
Normal file
141
scripts/check-surge.py
Normal file
@@ -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))
|
||||
@@ -8,7 +8,7 @@ all-hybrid = false
|
||||
udp-priority = false
|
||||
# Latency Benchmark / 延迟基准测试
|
||||
internet-test-url = http://connectivitycheck.gstatic.com/generate_204
|
||||
proxy-test-url = http://captive.apple.com/generate_204
|
||||
proxy-test-url = http://cp.cloudflare.com/generate_204
|
||||
test-timeout = 3
|
||||
# GeoIP Database / GeoIP 数据库
|
||||
geoip-maxmind-url = https://github.com/Hackl0us/GeoIP2-CN/raw/release/Country.mmdb
|
||||
@@ -23,10 +23,6 @@ http-listen = 0.0.0.0
|
||||
socks5-listen = 0.0.0.0
|
||||
allow-hotspot-access = true
|
||||
# --- REMOTE CONTROLLER / 远程控制器 ---
|
||||
external-controller-access = surge@0.0.0.0:6160
|
||||
http-api = clashconnectrules@0.0.0.0:6166
|
||||
http-api-tls = true
|
||||
http-api-web-dashboard = true
|
||||
# --- COMPATIBILITY / 兼容性 ---
|
||||
compatibility-mode = 1
|
||||
# --- NETWORK / 网络 ---
|
||||
@@ -49,7 +45,7 @@ force-http-engine-hosts = *.ott.cibntv.net, 123.59.31.1, 119.18.193.135, 122.14.
|
||||
always-real-ip = link-ip.nextdns.io, *.msftconnecttest.com, *.msftncsi.com, *.srv.nintendo.net, *.stun.playstation.net, xbox.*.microsoft.com, *.xboxlive.com, *.logon.battlenet.com.cn, *.logon.battle.net, stun.l.google.com
|
||||
hijack-dns = 8.8.8.8:53, 8.8.4.4:53
|
||||
udp-policy-not-supported-behaviour = REJECT
|
||||
proxy-restricted-to-lan = false
|
||||
proxy-restricted-to-lan = true
|
||||
|
||||
[Proxy]
|
||||
|
||||
@@ -122,6 +118,7 @@ IP-CIDR,100.64.0.0/10,DIRECT,no-resolve
|
||||
IP-CIDR6,fd7a:115c:a1e0::/48,DIRECT,no-resolve
|
||||
# 阻断 STUN,避免未匹配请求落到直连兜底
|
||||
PROTOCOL,STUN,REJECT
|
||||
AND,((PROTOCOL,UDP), (DOMAIN-SUFFIX,googlevideo.com)),REJECT-NO-DROP
|
||||
|
||||
# >> 规则修正 (Unbreak Rules)
|
||||
RULE-SET,https://raw.githubusercontent.com/zxfccmm4/Profiles/refs/heads/main/Surge/Ruleset/Unbreak.list,DIRECT
|
||||
@@ -130,7 +127,7 @@ RULE-SET,https://raw.githubusercontent.com/zxfccmm4/Profiles/refs/heads/main/Sur
|
||||
DOMAIN-SET,https://ruleset.skk.moe/List/domainset/reject.conf,REJECT
|
||||
RULE-SET,https://ruleset.skk.moe/List/non_ip/reject-drop.conf,REJECT-DROP
|
||||
RULE-SET,https://ruleset.skk.moe/List/non_ip/reject.conf,REJECT
|
||||
RULE-SET,https://ruleset.skk.moe/List/ip/reject.conf,REJECT-DROP
|
||||
RULE-SET,https://ruleset.skk.moe/List/ip/reject.conf,REJECT-DROP,no-resolve
|
||||
|
||||
# >> 隐私保护 (Privacy Protection)
|
||||
DOMAIN,app-site-association.cdn-apple.com,REJECT
|
||||
@@ -231,7 +228,6 @@ RULE-SET,https://ruleset.skk.moe/List/non_ip/global_plus.conf,Automatic
|
||||
RULE-SET,https://ruleset.skk.moe/List/non_ip/global.conf,Automatic
|
||||
|
||||
# >> 网络优化与特殊规则 (Network Optimization)
|
||||
AND,((PROTOCOL,UDP), (DOMAIN-SUFFIX,googlevideo.com)),REJECT-NO-DROP
|
||||
DOMAIN-SUFFIX,smtp,DIRECT
|
||||
URL-REGEX,(Subject|HELO|SMTP),DIRECT
|
||||
|
||||
@@ -307,7 +303,7 @@ http-request ^https:\/\/github\.com\/ header-replace Accept-Language en-us
|
||||
http-request ^https:\/\/(raw|gist)\.githubusercontent\.com\/ header-replace Accept-Language en-us
|
||||
|
||||
[MITM]
|
||||
skip-server-cert-verify = true
|
||||
skip-server-cert-verify = false
|
||||
tcp-connection = true
|
||||
h2 = true
|
||||
hostname = www.google.cn, api.abema.io, *.zhihu.com, -CUSTOMMitM, sub.store
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<typeof groupInputSchema>, 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) => {
|
||||
|
||||
130
server/src/routes/management-safety.test.ts
Normal file
130
server/src/routes/management-safety.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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() })
|
||||
|
||||
@@ -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<typeof ruleInputSchema>, 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 });
|
||||
|
||||
@@ -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, string>): 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<string, string> = {};
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import YAML from 'yaml';
|
||||
import { clientSupports, normalizeSsCipher, type ExcludedNode, type NodeDto } from '@proxy-station/shared';
|
||||
import type { ProfileIR, RuleIR } from '../ir.js';
|
||||
import { clientSupports, normalizeSsCipher, splitRuleExpression, type ExcludedNode, type NodeDto } from '@proxy-station/shared';
|
||||
import type { ProfileIR } from '../ir.js';
|
||||
|
||||
export function clashProxy(n: NodeDto): Record<string, unknown> {
|
||||
const base: Record<string, unknown> = { name: n.name, server: n.server, port: n.port };
|
||||
const base: Record<string, unknown> = {
|
||||
name: n.name, server: n.server, port: n.port,
|
||||
// SIP003 的 WS 插件只转发 TCP,不能把 UDP 发往 nginx 的同端口。
|
||||
udp: !(n.protocol === 'shadowsocks' && n.network === 'ws'),
|
||||
};
|
||||
switch (n.protocol) {
|
||||
case 'vmess': {
|
||||
Object.assign(base, { type: 'vmess', uuid: n.uuid, alterId: n.alterId, cipher: n.security || 'auto' });
|
||||
const cipher = n.security === 'chacha20-ietf-poly1305' ? 'chacha20-poly1305' : n.security || 'auto';
|
||||
Object.assign(base, { type: 'vmess', uuid: n.uuid, alterId: n.alterId, cipher });
|
||||
if (n.tls) {
|
||||
base.tls = true;
|
||||
if (n.sni) base.servername = n.sni;
|
||||
if (n.skipCertVerify) base['skip-cert-verify'] = true;
|
||||
}
|
||||
if (n.network === 'ws') {
|
||||
base.network = 'ws';
|
||||
@@ -42,6 +48,7 @@ export function clashProxy(n: NodeDto): Record<string, unknown> {
|
||||
tls: n.tls,
|
||||
host: n.wsHost || n.sni || n.server,
|
||||
path: n.wsPath,
|
||||
...(n.skipCertVerify ? { 'skip-cert-verify': true } : {}),
|
||||
};
|
||||
}
|
||||
break;
|
||||
@@ -68,9 +75,39 @@ function mapPolicy(policy: string): string {
|
||||
return policy;
|
||||
}
|
||||
|
||||
/** Surge 逻辑规则里的子类型 → mihomo 命名 */
|
||||
function mapLogicValue(value: string): string {
|
||||
return value.replace(/\bPROTOCOL\b/g, 'NETWORK');
|
||||
/** 逐个转换逻辑子规则;任何不可表达的子条件使整条规则跳过,避免扩大匹配范围。 */
|
||||
function mapLogicValue(value: string): string | null {
|
||||
value = value.trim();
|
||||
if (!value.startsWith('(') || !value.endsWith(')')) return null;
|
||||
const children = splitRuleExpression(value.slice(1, -1));
|
||||
if (!children?.length) return null;
|
||||
const mapped: string[] = [];
|
||||
for (const child of children) {
|
||||
if (!child.startsWith('(') || !child.endsWith(')')) return null;
|
||||
const parts = splitRuleExpression(child.slice(1, -1));
|
||||
if (!parts || parts.length < 2) return null;
|
||||
let [type, payload] = parts;
|
||||
if (['AND', 'OR', 'NOT'].includes(type)) {
|
||||
const nested = mapLogicValue(payload);
|
||||
if (!nested) return null;
|
||||
payload = nested;
|
||||
} else if (type === 'PROTOCOL') {
|
||||
if (payload !== 'TCP' && payload !== 'UDP') return null;
|
||||
type = 'NETWORK';
|
||||
} else {
|
||||
const types: Record<string, string> = {
|
||||
DOMAIN: 'DOMAIN', 'DOMAIN-SUFFIX': 'DOMAIN-SUFFIX', 'DOMAIN-KEYWORD': 'DOMAIN-KEYWORD',
|
||||
'DOMAIN-WILDCARD': 'DOMAIN-WILDCARD', 'IP-CIDR': 'IP-CIDR', 'IP-CIDR6': 'IP-CIDR6',
|
||||
'IP-ASN': 'IP-ASN', GEOIP: 'GEOIP', 'PROCESS-NAME': 'PROCESS-NAME',
|
||||
'SRC-IP': 'SRC-IP-CIDR', 'DEST-PORT': 'DST-PORT', 'SRC-PORT': 'SRC-PORT',
|
||||
};
|
||||
if (!types[type] || /["'\\]/.test(payload)) return null;
|
||||
type = types[type];
|
||||
}
|
||||
if (parts.slice(2).some((p) => p !== 'no-resolve')) return null;
|
||||
mapped.push(`(${[type, payload, ...parts.slice(2)].join(',')})`);
|
||||
}
|
||||
return `(${mapped.join(',')})`;
|
||||
}
|
||||
|
||||
const CLASH_UNSUPPORTED_RULE_TYPES = new Set(['URL-REGEX', 'USER-AGENT']);
|
||||
@@ -100,17 +137,17 @@ export function generateClash(ir: ProfileIR, opts: { convertBaseUrl: string }):
|
||||
if (g.filterRegex) {
|
||||
group['include-all-proxies'] = true;
|
||||
group.filter = g.filterRegex;
|
||||
if (members.length) group.proxies = [...new Set(members)];
|
||||
} else if (g.includeAllNodes) {
|
||||
group['include-all-proxies'] = true;
|
||||
const extra = members.filter((m) => !usableNames.has(m));
|
||||
if (extra.length) group.proxies = extra;
|
||||
if (members.length) group.proxies = [...new Set(members)];
|
||||
} else {
|
||||
group.proxies = members.length ? members : ['DIRECT'];
|
||||
}
|
||||
if (g.type !== 'select') {
|
||||
group.url = g.testUrl || 'http://cp.cloudflare.com/generate_204';
|
||||
group.interval = g.interval ?? 300;
|
||||
if (g.tolerance) group.tolerance = g.tolerance;
|
||||
if (g.type === 'url-test' && g.tolerance !== null) group.tolerance = g.tolerance;
|
||||
}
|
||||
return group;
|
||||
});
|
||||
@@ -158,7 +195,7 @@ export function generateClash(ir: ProfileIR, opts: { convertBaseUrl: string }):
|
||||
}
|
||||
const policy = mapPolicy(r.policy);
|
||||
if (r.ruleset) {
|
||||
ruleLines.push(`RULE-SET,${providerNameByRulesetId.get(r.ruleset.id)},${policy}`);
|
||||
ruleLines.push(['RULE-SET', providerNameByRulesetId.get(r.ruleset.id), policy, ...r.params.filter((p) => p === 'no-resolve')].join(','));
|
||||
continue;
|
||||
}
|
||||
// Surge 内置规则集(非 URL):LAN → 私网 GEOIP;SYSTEM 无对应物,跳过
|
||||
@@ -171,7 +208,9 @@ export function generateClash(ir: ProfileIR, opts: { convertBaseUrl: string }):
|
||||
continue;
|
||||
}
|
||||
if (r.type === 'AND' || r.type === 'OR' || r.type === 'NOT') {
|
||||
ruleLines.push(`${r.type},${mapLogicValue(r.value || '')},${policy}`);
|
||||
const value = mapLogicValue(r.value || '');
|
||||
if (value) ruleLines.push([r.type, value, policy, ...r.params.filter((p) => p === 'no-resolve')].join(','));
|
||||
else skippedRules.push(`${r.type},${r.value},${r.policy}`);
|
||||
continue;
|
||||
}
|
||||
const params = r.params.filter((p) => p === 'no-resolve');
|
||||
|
||||
148
server/src/services/generators/mobile-clients.test.ts
Normal file
148
server/src/services/generators/mobile-clients.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { afterAll, describe, expect, it } from 'vitest';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import YAML from 'yaml';
|
||||
import type { NodeDto } from '@proxy-station/shared';
|
||||
import type { GroupIR, ProfileIR, RuleIR } from '../ir.js';
|
||||
import { generateClash, clashProxy } from './clash.js';
|
||||
import { generateShadowrocket } from './shadowrocket.js';
|
||||
import { generateShadowrocketConf } from './shadowrocket-conf.js';
|
||||
import { parseShareLink } from '../sharelink/parse.js';
|
||||
|
||||
const node: NodeDto = {
|
||||
id: 'placeholder', name: '🇯🇵 JP VMess Direct', protocol: 'vmess',
|
||||
server: 'node.example.com', port: 443, uuid: '00000000-1111-2222-3333-444444444444',
|
||||
password: 'placeholder:password', method: 'chacha20-poly1305', network: 'ws',
|
||||
tls: true, sni: 'node.example.com', skipCertVerify: false, wsHost: 'node.example.com', wsPath: '/ws',
|
||||
alterId: 0, security: 'auto', obfsType: null, obfsPassword: null, upMbps: null, downMbps: null,
|
||||
region: null, tags: [], enabled: true, sortOrder: 0, createdAt: 0, updatedAt: 0,
|
||||
};
|
||||
const group: GroupIR = {
|
||||
name: 'Filtered', type: 'url-test', testUrl: 'https://example.com/generate_204',
|
||||
interval: 300, tolerance: 0, filterRegex: '^🇯🇵', includeAllNodes: true,
|
||||
members: [{ kind: 'builtin', name: 'DIRECT' }, { kind: 'node', name: node.name }, { kind: 'group', name: 'Source' }],
|
||||
};
|
||||
const final: RuleIR = { type: 'FINAL', value: null, policy: 'DIRECT', params: ['dns-failed'], ruleset: null };
|
||||
const base: ProfileIR = { nodes: [node], groups: [], rules: [final], sections: {} };
|
||||
const generate = (ir: ProfileIR) => generateClash(ir, { convertBaseUrl: 'https://example.com/sub/placeholder' });
|
||||
const directory = mkdtempSync(path.join(tmpdir(), 'proxy-station-mobile-'));
|
||||
const cli = process.env.MIHOMO_CLI ?? 'mihomo';
|
||||
const nativeAvailable = spawnSync(cli, ['-v'], { timeout: 10000 }).status === 0;
|
||||
afterAll(() => rmSync(directory, { recursive: true, force: true }));
|
||||
|
||||
describe('mobile client node subscriptions', () => {
|
||||
it('sorts complete Shadowrocket names by string, retains all four protocols and does not mutate IR', () => {
|
||||
const names = ['🇸🇬 SG VMess Warp', '🇯🇵 JP Trojan Warp', '🇺🇸 US LA SS Direct',
|
||||
'🇸🇬 SG VMess Direct', '🇯🇵 JP HY2 Direct', '🇯🇵 JP Trojan Direct'];
|
||||
const protocols: NodeDto['protocol'][] = ['vmess', 'trojan', 'shadowsocks', 'vmess', 'hysteria2', 'trojan'];
|
||||
const nodes = names.map((name, i) => ({ ...node, name, protocol: protocols[i],
|
||||
...(protocols[i] === 'hysteria2' ? { network: 'tcp' as const, wsHost: null, wsPath: null,
|
||||
obfsType: 'salamander', obfsPassword: 'placeholder-obfs' } : {}) }));
|
||||
const output = generateShadowrocket({ ...base, nodes });
|
||||
const uris = Buffer.from(output.content, 'base64').toString('utf8').split('\n');
|
||||
expect(uris).toEqual(output.uris);
|
||||
expect(uris.map((uri) => parseShareLink(uri).name)).toEqual([...names].sort());
|
||||
expect(nodes.map((n) => n.name)).toEqual(names);
|
||||
expect(output.excluded).toEqual([]);
|
||||
for (const uri of uris) {
|
||||
const parsed = parseShareLink(uri);
|
||||
const original = nodes.find((n) => n.name === parsed.name)!;
|
||||
expect(parsed).toMatchObject({ name: original.name, protocol: original.protocol,
|
||||
server: original.server, port: original.port, network: original.network,
|
||||
tls: original.tls, sni: original.sni });
|
||||
if (original.protocol === 'vmess') expect(parsed.uuid).toBe(original.uuid);
|
||||
else expect(parsed.password).toBe(original.password);
|
||||
if (original.protocol === 'hysteria2') expect(parsed.obfsPassword).toBe(original.obfsPassword);
|
||||
}
|
||||
});
|
||||
|
||||
it('enables supported Clash UDP transports, keeps SS WS TCP-only, and preserves TLS flags', () => {
|
||||
expect(clashProxy({ ...node, skipCertVerify: true })).toMatchObject({ type: 'vmess', udp: true,
|
||||
tls: true, servername: node.sni, 'skip-cert-verify': true, 'ws-opts': { headers: { Host: node.wsHost } } });
|
||||
expect(clashProxy({ ...node, protocol: 'trojan' })).toMatchObject({ type: 'trojan', udp: true, sni: node.sni });
|
||||
expect(clashProxy({ ...node, protocol: 'shadowsocks', skipCertVerify: true })).toMatchObject({
|
||||
type: 'ss', udp: false, cipher: 'chacha20-ietf-poly1305', plugin: 'v2ray-plugin',
|
||||
'plugin-opts': { mode: 'websocket', tls: true, path: '/ws', 'skip-cert-verify': true },
|
||||
});
|
||||
expect(clashProxy({ ...node, protocol: 'shadowsocks', network: 'tcp' }).udp).toBe(true);
|
||||
expect(clashProxy({ ...node, protocol: 'hysteria2', network: 'tcp', obfsType: 'salamander',
|
||||
obfsPassword: 'placeholder-obfs', upMbps: 20, downMbps: 100 })).toMatchObject({ type: 'hysteria2',
|
||||
udp: true, obfs: 'salamander', 'obfs-password': 'placeholder-obfs', up: '20 Mbps', down: '100 Mbps' });
|
||||
});
|
||||
|
||||
it('maps the VMess cipher alias and reports unsupported nodes without leaving dangling group references', () => {
|
||||
expect(clashProxy({ ...node, security: 'chacha20-ietf-poly1305' }).cipher).toBe('chacha20-poly1305');
|
||||
const result = generate({ ...base, nodes: [{ ...node, security: 'unsupported-placeholder' }],
|
||||
groups: [{ ...group, filterRegex: null, includeAllNodes: false, members: [{ kind: 'node', name: node.name }] }] });
|
||||
const doc = YAML.parse(result.content);
|
||||
expect(result.excluded).toEqual([{ name: node.name, reason: 'Clash 不支持该 VMess 加密方式:unsupported-placeholder' }]);
|
||||
expect(doc.proxies).toEqual([]);
|
||||
expect(doc['proxy-groups'][0].proxies).toEqual(['DIRECT']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mobile client groups and rule semantics', () => {
|
||||
it('preserves explicit members in dynamic groups, zero tolerance, and omits fallback tolerance', () => {
|
||||
const source = { ...group, name: 'Source', filterRegex: null, includeAllNodes: false,
|
||||
members: [{ kind: 'node' as const, name: node.name }] };
|
||||
const all = { ...group, name: 'All', filterRegex: null };
|
||||
const fallback = { ...group, name: 'Fallback', type: 'fallback' as const, tolerance: 50 };
|
||||
const ir = { ...base, groups: [source, group, all, fallback] };
|
||||
const doc = YAML.parse(generate(ir).content);
|
||||
expect(doc['proxy-groups'][1]).toMatchObject({ proxies: ['DIRECT', node.name, 'Source'],
|
||||
'include-all-proxies': true, filter: group.filterRegex, tolerance: 0, url: group.testUrl });
|
||||
expect(doc['proxy-groups'][2].proxies).toEqual(['DIRECT', node.name, 'Source']);
|
||||
expect(doc['proxy-groups'][3]).not.toHaveProperty('tolerance');
|
||||
const sr = generateShadowrocketConf(ir).content;
|
||||
expect(sr).toContain(`Filtered = url-test, DIRECT, ${node.name}, Source, url=${group.testUrl}, interval=300, tolerance=0, policy-regex-filter=^🇯🇵`);
|
||||
expect(sr.split('\n').find((l) => l.startsWith('Fallback ='))).not.toContain('tolerance=');
|
||||
expect(sr).not.toContain('use=');
|
||||
});
|
||||
|
||||
it('preserves provider no-resolve, handles duplicate provider names, keeps rule order and FINAL mapping', () => {
|
||||
const ruleset = { id: 'first', name: 'same', surgeUrl: 'https://example.com/ip.list', clashUrl: null,
|
||||
clashBehavior: 'classical', clashFormat: 'yaml', needsConvert: true };
|
||||
const rules: RuleIR[] = [
|
||||
{ type: 'RULE-SET', value: 'first', policy: 'REJECT-DROP', params: ['no-resolve'], ruleset },
|
||||
{ type: 'DOMAIN', value: 'example.com', policy: 'DIRECT', params: [], ruleset: null },
|
||||
{ type: 'RULE-SET', value: 'second', policy: 'DIRECT', params: [], ruleset: { ...ruleset, id: 'second' } }, final,
|
||||
];
|
||||
const doc = YAML.parse(generate({ ...base, rules }).content);
|
||||
expect(doc.rules).toEqual(['RULE-SET,same,REJECT-DROP,no-resolve', 'DOMAIN,example.com,DIRECT',
|
||||
'RULE-SET,same-2,DIRECT', 'MATCH,DIRECT']);
|
||||
expect(doc['rule-providers']['same-2'].url).toBe('https://example.com/sub/placeholder/ruleset/second.yaml');
|
||||
});
|
||||
|
||||
it('converts only transport subrules, preserves payload literals and skips whole unsupported logical expressions', () => {
|
||||
const values = ['((PROTOCOL,UDP),(DOMAIN-SUFFIX,PROTOCOL.example.com))',
|
||||
'((OR,((PROTOCOL,TCP),(PROTOCOL,UDP))),(DEST-PORT,443))',
|
||||
'((PROTOCOL,STUN),(DOMAIN,example.com))', '((PROTOCOL,DOH),(DOMAIN,example.com))',
|
||||
'((URL-REGEX,^https://example.com),(DOMAIN,example.com))',
|
||||
'((DOMAIN,example.com,extended-matching),(PROTOCOL,UDP))'];
|
||||
const result = generate({ ...base, rules: [...values.map((value): RuleIR => ({
|
||||
type: 'AND', value, policy: 'REJECT-NO-DROP', params: [], ruleset: null,
|
||||
})), final] });
|
||||
expect(YAML.parse(result.content).rules).toEqual([
|
||||
'AND,((NETWORK,UDP),(DOMAIN-SUFFIX,PROTOCOL.example.com)),REJECT',
|
||||
'AND,((OR,((NETWORK,TCP),(NETWORK,UDP))),(DST-PORT,443)),REJECT', 'MATCH,DIRECT',
|
||||
]);
|
||||
expect(result.skippedRules).toHaveLength(4);
|
||||
});
|
||||
|
||||
it.skipIf(!nativeAvailable)('passes the native mihomo parser with all node protocols, dynamic groups and nested logic', () => {
|
||||
const nodes: NodeDto[] = [node, { ...node, name: 'Trojan', protocol: 'trojan' },
|
||||
{ ...node, name: 'SS', protocol: 'shadowsocks' },
|
||||
{ ...node, name: 'HY2', protocol: 'hysteria2', network: 'tcp', obfsType: 'salamander', obfsPassword: 'placeholder-obfs' }];
|
||||
const ir: ProfileIR = { ...base, nodes, groups: [
|
||||
{ ...group, name: 'Source', filterRegex: null, includeAllNodes: false, members: [{ kind: 'node', name: node.name }] }, group,
|
||||
], rules: [{ type: 'AND', value: '((OR,((PROTOCOL,TCP),(PROTOCOL,UDP))),(DOMAIN,example.com))',
|
||||
policy: 'REJECT', params: [], ruleset: null }, final] };
|
||||
const file = path.join(directory, 'fixture.yaml');
|
||||
writeFileSync(file, generate(ir).content, { mode: 0o600 });
|
||||
const result = spawnSync(cli, ['-t', '-d', directory, '-f', file], { encoding: 'utf8', timeout: 15000 });
|
||||
expect(result.stdout + result.stderr).toContain('test is successful');
|
||||
expect(result.status).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -44,7 +44,7 @@ function shadowrocketGeneral(surgeGeneral: string): string {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/** ShadowRocket 不认识的规则类型 */
|
||||
/** 尚未验证 Shadowrocket 配置语法的类型;这是生成器的保守范围,不是客户端能力断言。 */
|
||||
const SR_UNSUPPORTED_RULE_TYPES = new Set(['DOMAIN-SET', 'AND', 'OR', 'NOT', 'URL-REGEX', 'PROTOCOL']);
|
||||
|
||||
export interface ShadowrocketConfOutput {
|
||||
@@ -70,14 +70,12 @@ export function generateShadowrocketConf(ir: ProfileIR): ShadowrocketConfOutput
|
||||
lines.push('', '[Proxy Group]');
|
||||
for (const g of ir.groups) {
|
||||
const parts: string[] = [g.type];
|
||||
if (!g.filterRegex && !g.includeAllNodes) {
|
||||
const members = g.members.map((m) => policyName(m.name));
|
||||
parts.push(...(members.length ? members : ['DIRECT']));
|
||||
}
|
||||
const members = [...new Set(g.members.map((m) => policyName(m.name)))];
|
||||
parts.push(...(members.length ? members : !g.filterRegex && !g.includeAllNodes ? ['DIRECT'] : []));
|
||||
if (g.type !== 'select') {
|
||||
parts.push(`url=${g.testUrl || 'http://cp.cloudflare.com/generate_204'}`);
|
||||
if (g.interval) parts.push(`interval=${g.interval}`);
|
||||
if (g.tolerance) parts.push(`tolerance=${g.tolerance}`);
|
||||
if (g.type === 'url-test' && g.tolerance !== null) parts.push(`tolerance=${g.tolerance}`);
|
||||
}
|
||||
// policy-regex-filter 从 App 内全部节点里筛选;「收纳全部节点」用全匹配正则
|
||||
if (g.filterRegex) parts.push(`policy-regex-filter=${g.filterRegex}`);
|
||||
|
||||
@@ -11,7 +11,9 @@ export interface ShadowrocketOutput {
|
||||
export function generateShadowrocket(ir: ProfileIR): ShadowrocketOutput {
|
||||
const excluded: ExcludedNode[] = [];
|
||||
const uris: string[] = [];
|
||||
for (const n of ir.nodes) {
|
||||
// 按完整名称的字符串顺序排列,保留国家、协议和出口后缀;不改 IR 的数据库顺序。
|
||||
const sortedNodes = [...ir.nodes].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
||||
for (const n of sortedNodes) {
|
||||
const verdict = clientSupports('shadowrocket', n);
|
||||
if (!verdict.ok) {
|
||||
excluded.push({ name: n.name, reason: verdict.reason! });
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string>();
|
||||
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<string, string>();
|
||||
// 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.
|
||||
|
||||
21
server/src/services/generators/surge-format.ts
Normal file
21
server/src/services/generators/surge-format.ts
Normal file
@@ -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))}`;
|
||||
}
|
||||
67
server/src/services/generators/surge-safety.test.ts
Normal file
67
server/src/services/generators/surge-safety.test.ts
Normal file
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
|
||||
28
server/src/services/group-validation.ts
Normal file
28
server/src/services/group-validation.ts
Normal file
@@ -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<string>();
|
||||
const done = new Set<string>();
|
||||
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;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user