feat: 合并边缘节点运维并完善 Surge 配置安全
迁入通用节点部署工具、离线交付页生成器和共用技能,隔离真实配置与交付产物。统一 Codex 与 Claude Code 开发约定,补充节点迁移与运维文档。 完善 FINAL、策略组引用、输入校验、Surge 字段编码与 AI DNS 例外保护,增加隔离测试和订阅验证工具。 验证:全量类型检查、44 项单测、前端构建、脚本语法、技能入口与凭据隔离检查均通过。
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
|
name: deploy
|
||||||
description: 把 proxy-station 部署到 LA 生产服务器(ps.la.njcqtechaicoding.com)。可以只部署前端、只部署后端,或前后端一起。凡是用户提到「部署」「发布」「上线」「推到服务器」「更新线上」「让线上生效」「同步到 la」,或改完代码想在生产环境看效果,都用这个 Skill,即使没有明说「部署」二字。
|
description: 将 proxy-station 前端、后端或两者部署到 LA 生产服务器。用于用户要求部署、发布、上线、更新线上或同步到 la;生产数据维护使用 update-db。
|
||||||
---
|
---
|
||||||
|
|
||||||
# 部署 proxy-station 到 LA
|
# 部署 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 只负责日常的代码更新。
|
线上是一台 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
|
```bash
|
||||||
@@ -35,6 +37,7 @@ bash .claude/skills/deploy/scripts/deploy.sh server # 只推后端源码,
|
|||||||
|
|
||||||
1. **预检**:拒绝大写 ssh 别名(大写会被当作域名走 fake-IP 解析,连不上);确认远端目录与 systemd 单元存在,否则提示先做首次部署。
|
1. **预检**:拒绝大写 ssh 别名(大写会被当作域名走 fake-IP 解析,连不上);确认远端目录与 systemd 单元存在,否则提示先做首次部署。
|
||||||
2. **本地检查**:后端目标先跑 typecheck 与单测。线上没有测试环境,这是最后一道拦截。
|
2. **本地检查**:后端目标先跑 typecheck 与单测。线上没有测试环境,这是最后一道拦截。
|
||||||
|
节点/组/规则/DNS/生成器变更另按 [Surge 变更验证](../../../docs/SURGE-CHANGES.md) 验证候选订阅,使用本机官方 `surge-cli`,涉及规则集时通过 `scripts/check-surge.py --fetch-rulesets` 下载快照验证。单测中本地 Surge CLI 用例在未安装时会跳过,不能据此声称已通过官方校验。
|
||||||
3. **推送**:用 tar 管道走 ssh,不依赖远端 rsync。先解到暂存目录再整体换入,旧版本保留为 `*.old`,中途失败不会留下半新半旧的目录。
|
3. **推送**:用 tar 管道走 ssh,不依赖远端 rsync。先解到暂存目录再整体换入,旧版本保留为 `*.old`,中途失败不会留下半新半旧的目录。
|
||||||
4. **后端**:`pnpm install --frozen-lockfile --prod` 只装 server 及其 workspace 依赖,然后重启服务,等 `/api/health` 最多 20 秒。不通过就打印日志、把源码换回 `.old` 并重启,让线上先恢复。
|
4. **后端**:`pnpm install --frozen-lockfile --prod` 只装 server 及其 workspace 依赖,然后重启服务,等 `/api/health` 最多 20 秒。不通过就打印日志、把源码换回 `.old` 并重启,让线上先恢复。
|
||||||
5. **校验**:不用「命令没报错」当成功。前端比对本地与远端 `web/dist` 的文件树指纹,并检查公网首页是否已引用新的入口脚本;后端比对 `server/src` 与 `shared/src` 的指纹并确认服务 active;最后从本机访问公网健康检查,并确认管理接口不带 token 时返回 401。任何一项不过,脚本以失败退出。
|
5. **校验**:不用「命令没报错」当成功。前端比对本地与远端 `web/dist` 的文件树指纹,并检查公网首页是否已引用新的入口脚本;后端比对 `server/src` 与 `shared/src` 的指纹并确认服务 active;最后从本机访问公网健康检查,并确认管理接口不带 token 时返回 401。任何一项不过,脚本以失败退出。
|
||||||
@@ -45,6 +48,7 @@ bash .claude/skills/deploy/scripts/deploy.sh server # 只推后端源码,
|
|||||||
|
|
||||||
- 最后一行 `部署完成:<目标> @ <git rev>` 才算成功。把这一行连同校验段落转述给用户。
|
- 最后一行 `部署完成:<目标> @ <git rev>` 才算成功。把这一行连同校验段落转述给用户。
|
||||||
- 输出里出现 `(工作区有未提交改动)` 时要提醒用户:线上跑的是未提交的代码,之后应当提交或有意识地保持。
|
- 输出里出现 `(工作区有未提交改动)` 时要提醒用户:线上跑的是未提交的代码,之后应当提交或有意识地保持。
|
||||||
|
- 生成器或 shared 变更部署后,从实际 HTTPS 入口取订阅,用 `python3 scripts/check-surge.py <私有临时文件> --fetch-rulesets` 再验收;健康检查和源码指纹不能证明规则行为正确。受影响的 Clash 用独立目录下的 `mihomo -t` 验证。
|
||||||
- `前端产物指纹不一致` 几乎只有一种原因:推送中断。重跑一次 `web` 即可。
|
- `前端产物指纹不一致` 几乎只有一种原因:推送中断。重跑一次 `web` 即可。
|
||||||
- `公网首页未引用 <入口脚本>` 而指纹一致:浏览器或中间缓存,稍等再试;aaPanel 的反代模板对 js 只缓存 1 分钟。
|
- `公网首页未引用 <入口脚本>` 而指纹一致:浏览器或中间缓存,稍等再试;aaPanel 的反代模板对 js 只缓存 1 分钟。
|
||||||
- `pnpm install 失败`:多半是本地 `package.json` 改了但锁文件没更新。本地 `pnpm install` 后连锁文件一起提交,再部署。
|
- `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 提交。部署与提交是两件事,用户没要求就不要顺手提交。
|
- 不做 git 提交。部署与提交是两件事,用户没要求就不要顺手提交。
|
||||||
- 如果用户要部署到 LA 之外的机器,先确认那台机器已按 `docs/DEPLOY.md` 完成首次部署,再用 `--host` 与环境变量指向它。
|
- 如果用户要部署到 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,42 @@
|
|||||||
|
# DNS records this node needs (any provider)
|
||||||
|
|
||||||
|
The wildcard cert (`03-sign-wildcard-cert.sh`) proves domain control via **DNS-01** using
|
||||||
|
whatever `ACME_DNS_PLUGIN` you set — acme.sh supports Cloudflare, DNSPod, Aliyun, Route53,
|
||||||
|
GoDaddy, and dozens more. That part is provider-agnostic.
|
||||||
|
|
||||||
|
The only thing that is *not* automated for non-Cloudflare providers is creating the host
|
||||||
|
**A/AAAA records**. That's just two names. Create them in your DNS provider's console/API:
|
||||||
|
|
||||||
|
| Name | Type | Value | Proxy/CDN |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `<DOMAIN_ROOT>` (e.g. `nodeA.example.com`) | A | `SERVER_IP` | **off** (DNS-only / grey cloud) |
|
||||||
|
| `*.<DOMAIN_ROOT>` | A | `SERVER_IP` | **off** |
|
||||||
|
| `<DOMAIN_ROOT>` | AAAA | `SERVER_IPV6` | off (skip if no IPv6) |
|
||||||
|
| `*.<DOMAIN_ROOT>` | AAAA | `SERVER_IPV6` | off |
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Keep them **unproxied** — the node terminates its own TLS and needs the real client IP path; a proxy/CDN in front would break the WS cover and the panel anti-scan logic.
|
||||||
|
- TTL 300 is fine.
|
||||||
|
- The wildcard `*.<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 all four
|
||||||
|
records for you via the API (`CF_API_TOKEN` with `Zone:DNS:Edit`). Run it from wherever the
|
||||||
|
token lives; it never touches the VPS.
|
||||||
|
|
||||||
|
## Picking the acme.sh plugin + creds
|
||||||
|
|
||||||
|
Set `ACME_DNS_PLUGIN` and the matching credential vars in `config.env` (acme.sh reads them
|
||||||
|
from the environment; `03-sign-wildcard-cert.sh` forwards whichever are set):
|
||||||
|
|
||||||
|
| Provider | `ACME_DNS_PLUGIN` | env vars |
|
||||||
|
|---|---|---|
|
||||||
|
| Cloudflare | `dns_cf` | `CF_API_TOKEN` (forwarded as `CF_Token`) |
|
||||||
|
| DNSPod | `dns_dp` | `DP_Id`, `DP_Key` |
|
||||||
|
| Aliyun | `dns_ali` | `Ali_Key`, `Ali_Secret` |
|
||||||
|
| AWS Route53 | `dns_aws` | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` |
|
||||||
|
| GoDaddy | `dns_gd` | `GD_Key`, `GD_Secret` |
|
||||||
|
| others | see acme.sh dnsapi | per its README |
|
||||||
|
|
||||||
|
Full list: <https://github.com/acmesh-official/acme.sh/wiki/dnsapi>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Pitfalls (read before you run)
|
||||||
|
|
||||||
|
Every item here cost real debugging time on a live deployment. They are ordered by how likely they are to bite you.
|
||||||
|
|
||||||
|
## aaPanel
|
||||||
|
|
||||||
|
- **Never `systemctl restart btpanel`.** `btpanel.service` is `enabled` but its `ExecStart` is `/etc/init.d/bt start` (SysV); the systemd unit shows `active: inactive (dead)` while the panel process (`webserver`) runs fine. `systemctl restart btpanel` stops the panel and it does **not** come back → your reverse-proxied panel returns **502**. To restart use `bt 1` or `/etc/init.d/bt start`. Same for nginx (`/etc/init.d/nginx`, not `systemctl`).
|
||||||
|
- **Reverse-proxy needs `CDN_PROXY=True`** in `/www/server/panel/config/cdn.conf`, or the panel rejects proxied requests. Restart the panel after setting it.
|
||||||
|
- **Anti-scan fake 404/403 — the UA has to look like a REAL browser, not just contain the word "Mozilla".** aaPanel returns a *fake* nginx 404 to requests it judges to be scanners. On aaPanel 7.x this check got noticeably stricter: a minimal UA like `Mozilla/5.0 (Macintosh)` (which used to be enough) now gets judged as a scanner and served the fake 404 — even hitting `/login` or `/` directly on the box over the unix socket gives the same fake 404 with that UA. Use a full, realistic UA string, e.g. `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/605.1.15`, and it works immediately. **If the entrance/domain/path all test as 404 no matter what you try, check the UA string completeness FIRST** — before you start doubting `CDN_PROXY`, domain binding, or the cert. This one costs the most debugging time of anything in this file. A real browser was never affected either way.
|
||||||
|
- Also **403** on direct `IP:port` access once a panel domain is bound — that one's real and expected, not a bug.
|
||||||
|
- **aaPanel 7.x moved several backend modules into a new `class_v2` directory** (e.g. `theme_config.py`). Scripts that do `sys.path.insert(0, "/www/server/panel/class")` and then `import panelSite` (or anything that transitively imports `common.py` → `theme_config`) will fail with `ModuleNotFoundError: theme_config` unless `/www/server/panel/class_v2` is *also* on `sys.path`. `09-aapanel-sites.py` already does this; if you hand-write a new script that touches panel internals, add the same `sys.path.insert(0, "/www/server/panel/class_v2")` line.
|
||||||
|
- **Build sites through the panel, never hand-write vhosts.** A hand-written `/www/server/panel/vhost/nginx/*.conf` works in nginx but is invisible/unmanageable in the panel UI. Use the panel classes (`AddSite`/`SetSSL`/`CreateProxy`/`HttpToHttps`) — that's what `09-aapanel-sites.py` does. The panel HTTP API can't be driven by curl (CSRF + anti-scan); drive the classes with the panel's bundled `pyenv` python.
|
||||||
|
- **nginx 1.24 uses `listen 443 ssl http2;`** — it does **not** support the newer `http2 on;` directive; that syntax fails `nginx -t`.
|
||||||
|
- **The aaPanel install-wait loop must not use `pgrep -f install_7.0` to detect "still installing".** A command like `until run 'test -f BT-Panel && ! pgrep -f install_7.0'; do sleep 15; done` has a self-match bug: the check command's own argv literally contains the string `"install_7.0"`, so `pgrep -f` matches the check itself every time, and the condition can never go true — the loop spins forever even though the install finished in ~1 minute. `04-install-aapanel.sh` instead waits for the literal string `Congratulations` in the install log plus the `BT-Panel` binary existing. If you ever add a similar poll-by-pgrep check, make sure the pattern you grep for doesn't appear in the checking command's own command line (the classic fix elsewhere is `pgrep -f '[i]nstall_7.0'` — the bracket trick — but matching a log string is simpler and was chosen here).
|
||||||
|
|
||||||
|
## 3x-ui (newer versions, Xray 26.x) — multi-protocol notes
|
||||||
|
|
||||||
|
- **Keep `inbounds.settings.clients` populated.** The normalized `clients` and `client_inbounds` tables are projections. Upgrades/migrations rebuild them from settings JSON; a table-only deployment can lose all credentials. The migrated `08-xui-bootstrap.py` includes this fix; repair older nodes with `08b-embed-settings-clients.py` on the VPS after a database backup.
|
||||||
|
|
||||||
|
- **One client owns all six inbounds — don't create one client per protocol.** The whole point of the single-client design is that the panel's Clients page shows ONE row ("node-main") whose Info dialog lists all six share links + QR codes and a single subscription covering all six. That works because the `clients` table is protocol-generic (next bullet): one row carrying both a `uuid` and a `password`, linked via `client_inbounds` to all six inbounds. `08-xui-bootstrap.py` does exactly this. (An earlier iteration created three separate clients — node-main / trojan-main / ss-main — which "worked" for traffic but split the links across three panel rows and made the single-subscription view impossible; consolidating them later meant deleting two clients and re-pointing credentials. Build it as one client from the start.)
|
||||||
|
- **The `clients` table is protocol-generic, not protocol-specific.** It has both a `uuid` column and a `password` column (plus VMess/VLESS-only fields like `security`/`flow`). VMess/VLESS inbounds read `uuid`; Trojan/Shadowsocks inbounds read `password`. There's no "protocol" column on `clients` itself — which field 3x-ui's backend actually uses depends on the *inbound's* `protocol` value at the point it builds the live xray config, not on anything stored on the client row. This isn't documented anywhere obvious; it was confirmed by reading the live generated `/usr/local/x-ui/bin/config.json` after inserting rows and restarting `x-ui`, and seeing the right credential land in the right field for each protocol. **Consequence:** with one client, Trojan and Shadowsocks share ONE password (that client's `password` column); VMess uses its `uuid`. That's intended for a single-user node — it's what `NODE_PASSWORD` in `config.env` is.
|
||||||
|
- **`user_id` on an inbound must be set (1), or the inbound is INVISIBLE in the panel.** The panel's Inbounds and Clients lists filter by the logged-in admin's `user_id`. A DB-inserted inbound with a NULL/empty `user_id` exists in the DB and proxies traffic perfectly (xray loads it, smoke test passes), but the panel UI silently filters it out — you'll see "only the vmess inbounds show up" even though all six are there. `08-xui-bootstrap.py` sets `user_id=1` on every inserted inbound. If you hand-insert an inbound later, set it too (or `UPDATE inbounds SET user_id=1 WHERE user_id IS NULL OR user_id=''`).
|
||||||
|
- **Every inbound needs an `externalProxy` in streamSettings, or the generated share links are useless.** These inbounds listen on `127.0.0.1` with `security:"none"` (nginx terminates TLS at the edge on `<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
|
||||||
|
|
||||||
|
- **`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'"
|
||||||
280
.claude/skills/edge-node-multi-protocol/scripts/08-xui-bootstrap.py
Executable file
280
.claude/skills/edge-node-multi-protocol/scripts/08-xui-bootstrap.py
Executable file
@@ -0,0 +1,280 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Runs ON the box, editing 3x-ui's own sqlite DB directly (the panel HTTP API
|
||||||
|
403s scripted requests on the builds this targets -- see references/pitfalls.md).
|
||||||
|
|
||||||
|
Builds the standard target of this skill in ONE pass: SIX inbounds (vmess,
|
||||||
|
trojan, shadowsocks x direct-egress + WARP-clean-exit) all owned by ONE client
|
||||||
|
"node-main", so the panel's Clients page shows a single row whose Info dialog
|
||||||
|
lists all six share links / QR codes and one subscription that carries all six.
|
||||||
|
|
||||||
|
Why a single client instead of one-per-protocol
|
||||||
|
------------------------------------------------
|
||||||
|
3x-ui's `clients` table is protocol-generic: one client row carries BOTH a
|
||||||
|
`uuid` (used by vmess/vless inbounds) AND a `password` (used by trojan/
|
||||||
|
shadowsocks inbounds). Which field an inbound reads is decided by that
|
||||||
|
*inbound's* protocol when 3x-ui builds the live xray config -- not by anything
|
||||||
|
on the client row. So one node-main row linked (via client_inbounds) to all six
|
||||||
|
inbounds authenticates vmess by its uuid and trojan+ss by its (shared)
|
||||||
|
password. Verified by reading the generated /usr/local/x-ui/bin/config.json.
|
||||||
|
Trojan and Shadowsocks therefore share ONE password (node-main's); vmess uses
|
||||||
|
the uuid. That's intended -- a single-user self node -- and is what makes the
|
||||||
|
"one client, six links" panel view work.
|
||||||
|
|
||||||
|
Four version-compatibility fixes are baked in here (all cost real debugging on
|
||||||
|
a live v3.5.0 box; see references/pitfalls.md for the full story):
|
||||||
|
|
||||||
|
1. user_id=1 on every inbound. The panel Clients/Inbounds lists filter by the
|
||||||
|
logged-in admin's user_id; an inbound with a NULL/empty user_id exists and
|
||||||
|
proxies traffic fine but is INVISIBLE in the UI.
|
||||||
|
|
||||||
|
2. externalProxy on every inbound's streamSettings. These inbounds listen on
|
||||||
|
127.0.0.1 with security:none (nginx terminates TLS at the edge), so without
|
||||||
|
a hint 3x-ui generates useless share links pointing at 127.0.0.1:<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": {}},
|
||||||
|
{"tag": "warp-outbound", "protocol": "freedom",
|
||||||
|
"settings": {"domainStrategy": "UseIPv6v4"}, "proxySettings": {"tag": "warp-socks"}},
|
||||||
|
{"tag": "warp-socks", "protocol": "socks",
|
||||||
|
"settings": {"servers": [{"address": "127.0.0.1", "port": WARP_SOCKS_PORT}]}},
|
||||||
|
{"protocol": "blackhole", "settings": {}, "tag": "blocked"},
|
||||||
|
],
|
||||||
|
"policy": {"system": {"statsInboundDownlink": True, "statsInboundUplink": True}},
|
||||||
|
"routing": {"rules": [
|
||||||
|
{"inboundTag": ["api"], "outboundTag": "api", "type": "field"},
|
||||||
|
{"inboundTag": [], "outboundTag": "warp-outbound", "type": "field"}, # tags filled in below
|
||||||
|
{"ip": ["geoip:private"], "outboundTag": "blocked", "type": "field"},
|
||||||
|
{"protocol": ["bittorrent"], "outboundTag": "blocked", "type": "field"},
|
||||||
|
]},
|
||||||
|
"stats": {},
|
||||||
|
"dns": {"servers": ["1.1.1.1"], "queryStrategy": "UseIP"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def cols(cur, table):
|
||||||
|
return {r[1] for r in cur.execute("PRAGMA table_info(%s)" % table).fetchall()}
|
||||||
|
|
||||||
|
|
||||||
|
def has_table(cur, name):
|
||||||
|
return cur.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (name,)).fetchone() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def stream_settings(path, remark):
|
||||||
|
# security:"none" -- nginx already terminates TLS at the edge and reverse-proxies
|
||||||
|
# plain HTTP to this inbound; don't handshake TLS a second time here.
|
||||||
|
# externalProxy -> share links point at the public cover host:443 over TLS (fix #2).
|
||||||
|
return json.dumps({
|
||||||
|
"network": "ws", "security": "none",
|
||||||
|
"wsSettings": {"path": path, "headers": {}},
|
||||||
|
"externalProxy": [{"forceTls": "tls", "dest": COVER, "port": 443, "sni": COVER, "remark": remark}],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def embedded_client(protocol, now):
|
||||||
|
"""One settings.clients entry (fix #5).
|
||||||
|
|
||||||
|
ALWAYS embed, on every schema. settings.clients is 3x-ui's authoritative copy
|
||||||
|
of the credentials; clients/client_inbounds are a derived projection of it.
|
||||||
|
See fix #5 in the module docstring for why leaving this empty bricks the node
|
||||||
|
on the next upgrade.
|
||||||
|
|
||||||
|
Field semantics (client_link.go applyClientRecordMerge):
|
||||||
|
* non-empty-only overwrite: id / password / security -> a vmess entry may
|
||||||
|
carry just `id` and a trojan/ss entry just `password`; merging the six
|
||||||
|
inbounds for one email yields the union rather than clobbering.
|
||||||
|
* unconditional overwrite: enable / subId / limitIp / totalGB / expiryTime
|
||||||
|
/ tgId / comment / reset / flow -> must be IDENTICAL in all six entries.
|
||||||
|
* `enable` must be explicit: omitting it unmarshals to false, and xray.go
|
||||||
|
then skips the client on EVERY inbound.
|
||||||
|
"""
|
||||||
|
c = {
|
||||||
|
"email": EMAIL, "enable": True, "subId": XUI_SUBID, "flow": "",
|
||||||
|
"limitIp": 0, "totalGB": 0, "expiryTime": 0, "tgId": 0,
|
||||||
|
"comment": "", "reset": 0, "created_at": now, "updated_at": now,
|
||||||
|
}
|
||||||
|
if protocol == "vmess":
|
||||||
|
c.update({"id": XUI_UUID, "security": "auto", "alterId": 0})
|
||||||
|
elif protocol == "trojan":
|
||||||
|
c["password"] = NODE_PASSWORD
|
||||||
|
elif protocol == "shadowsocks":
|
||||||
|
c.update({"password": NODE_PASSWORD, "method": SS_METHOD})
|
||||||
|
else:
|
||||||
|
raise ValueError(protocol)
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
def inbound_settings(protocol, now):
|
||||||
|
clients = [embedded_client(protocol, now)]
|
||||||
|
if protocol == "vmess":
|
||||||
|
return json.dumps({"clients": clients, "disableInsecureEncryption": False})
|
||||||
|
if protocol == "trojan":
|
||||||
|
return json.dumps({"clients": clients, "fallbacks": []})
|
||||||
|
if protocol == "shadowsocks":
|
||||||
|
# top-level "password" prevents the v3.5.0 link-gen panic (fix #3); auth is per-client.
|
||||||
|
return json.dumps({"clients": clients, "method": SS_METHOD, "password": NODE_PASSWORD, "network": "tcp,udp"})
|
||||||
|
raise ValueError(protocol)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
conn = sqlite3.connect(DB)
|
||||||
|
cur = conn.cursor()
|
||||||
|
ic = cols(cur, "inbounds")
|
||||||
|
new_schema = has_table(cur, "clients") and has_table(cur, "client_inbounds")
|
||||||
|
existing_ports = {r[0] for r in cur.execute("SELECT port FROM inbounds").fetchall()}
|
||||||
|
now = int(time.time() * 1000)
|
||||||
|
|
||||||
|
warp_tags = []
|
||||||
|
inbound_ids = {}
|
||||||
|
|
||||||
|
for protocol, port_direct, port_warp, path_direct, path_warp in PROTOCOLS:
|
||||||
|
for suffix, port, path in (("direct", port_direct, path_direct), ("warp", port_warp, path_warp)):
|
||||||
|
tag = "inbound-%s-%s" % (protocol, suffix)
|
||||||
|
remark = "%s-%s" % (protocol, suffix)
|
||||||
|
if port in existing_ports:
|
||||||
|
print(" inbound :%d (%s-%s) exists, skip" % (port, protocol, suffix))
|
||||||
|
inbound_ids[tag] = cur.execute("SELECT id FROM inbounds WHERE port=?", (port,)).fetchone()[0]
|
||||||
|
else:
|
||||||
|
row = {
|
||||||
|
"user_id": 1, # fix #1: NULL user_id -> invisible in panel lists
|
||||||
|
"up": 0, "down": 0, "total": 0, "remark": remark,
|
||||||
|
"enable": 1, "expiry_time": 0, "listen": "127.0.0.1", "port": port, "protocol": protocol,
|
||||||
|
"settings": inbound_settings(protocol, now),
|
||||||
|
"stream_settings": stream_settings(path, remark),
|
||||||
|
"sniffing": json.dumps({"enabled": True, "destOverride": ["http", "tls"]}),
|
||||||
|
"tag": tag,
|
||||||
|
}
|
||||||
|
keys = [k for k in row if k in ic]
|
||||||
|
cur.execute("INSERT INTO inbounds (%s) VALUES (%s)" % (",".join(keys), ",".join("?" * len(keys))),
|
||||||
|
[row[k] for k in keys])
|
||||||
|
inbound_ids[tag] = cur.lastrowid
|
||||||
|
print(" inbound :%d (%s-%s) %s added" % (port, protocol, suffix, path))
|
||||||
|
if suffix == "warp":
|
||||||
|
warp_tags += [tag, "inbound-127.0.0.1:%d" % port]
|
||||||
|
|
||||||
|
if new_schema:
|
||||||
|
# One node-main client carrying BOTH uuid (vmess) and password (trojan+ss),
|
||||||
|
# linked to all six inbounds. 3x-ui picks the right credential per inbound.
|
||||||
|
row = cur.execute("SELECT id FROM clients WHERE email=?", (EMAIL,)).fetchone()
|
||||||
|
if row:
|
||||||
|
cid = row[0]
|
||||||
|
cur.execute("UPDATE clients SET uuid=?, password=?, sub_id=?, security='auto', enable=1, updated_at=? "
|
||||||
|
"WHERE id=?", (XUI_UUID, NODE_PASSWORD, XUI_SUBID, now, cid))
|
||||||
|
else:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO clients (email,sub_id,uuid,password,auth,flow,security,enable,group_name,total_gb,"
|
||||||
|
"expiry_time,limit_ip,reset,created_at,updated_at) VALUES (?,?,?,?,'','','auto',1,'',0,0,0,0,?,?)",
|
||||||
|
(EMAIL, XUI_SUBID, XUI_UUID, NODE_PASSWORD, now, now),
|
||||||
|
)
|
||||||
|
cid = cur.lastrowid
|
||||||
|
|
||||||
|
for tag, iid in inbound_ids.items():
|
||||||
|
if not cur.execute("SELECT 1 FROM client_inbounds WHERE client_id=? AND inbound_id=?",
|
||||||
|
(cid, iid)).fetchone():
|
||||||
|
cur.execute("INSERT INTO client_inbounds (client_id,inbound_id,flow_override,created_at) "
|
||||||
|
"VALUES (?,?,?,?)", (cid, iid, "", now))
|
||||||
|
|
||||||
|
# client_traffics.email is GLOBALLY UNIQUE -> exactly one row for node-main.
|
||||||
|
if not cur.execute("SELECT 1 FROM client_traffics WHERE email=?", (EMAIL,)).fetchone():
|
||||||
|
any_iid = inbound_ids["inbound-vmess-direct"]
|
||||||
|
cur.execute("INSERT INTO client_traffics (inbound_id,enable,email,up,down,expiry_time,total,reset) "
|
||||||
|
"VALUES (?,1,?,0,0,0,0,0)", (any_iid, EMAIL))
|
||||||
|
print(" node-main client linked to all 6 inbounds (uuid=vmess, password=trojan+ss)")
|
||||||
|
|
||||||
|
# WARP routing rule: read-modify-write (fix #4) -- append, never clobber.
|
||||||
|
row = cur.execute("SELECT value FROM settings WHERE key='xrayTemplateConfig'").fetchone()
|
||||||
|
tmpl = json.loads(row[0]) if row else TEMPLATE
|
||||||
|
for rule in tmpl["routing"]["rules"]:
|
||||||
|
if rule.get("outboundTag") == "warp-outbound":
|
||||||
|
for t in warp_tags:
|
||||||
|
if t not in rule["inboundTag"]:
|
||||||
|
rule["inboundTag"].append(t)
|
||||||
|
|
||||||
|
cur.execute("UPDATE settings SET value=? WHERE key='xrayTemplateConfig'", (json.dumps(tmpl, ensure_ascii=False),))
|
||||||
|
if cur.rowcount == 0:
|
||||||
|
cur.execute("INSERT INTO settings (key, value) VALUES ('xrayTemplateConfig', ?)", (json.dumps(tmpl, ensure_ascii=False),))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print(" xrayTemplateConfig written, warp rule tags:", tmpl["routing"]["rules"][1]["inboundTag"])
|
||||||
|
|
||||||
|
subprocess.run(["systemctl", "restart", "x-ui"], check=True)
|
||||||
|
time.sleep(6)
|
||||||
|
print("OK — 3x-ui bootstrap complete (6 inbounds under one node-main client: vmess/trojan/shadowsocks x direct/warp)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
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"
|
||||||
105
.claude/skills/edge-node-multi-protocol/scripts/13-singbox-config.py
Executable file
105
.claude/skills/edge-node-multi-protocol/scripts/13-singbox-config.py
Executable file
@@ -0,0 +1,105 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Runs ON the box. Emits /etc/sing-box/config.json for the QUIC plane.
|
||||||
|
|
||||||
|
Which inbounds get built is driven purely by which port variables are set, so the
|
||||||
|
same script serves a Hysteria2-only node, a TUIC-only node, or both -- no editing
|
||||||
|
required to add or drop a protocol.
|
||||||
|
|
||||||
|
Why a second daemon at all: the TCP inbounds (VMess/Trojan/Shadowsocks) hide
|
||||||
|
behind nginx:443 as WebSocket paths. Hysteria2 and TUIC are QUIC over UDP -- they
|
||||||
|
terminate TLS themselves and cannot be reverse-proxied, so each needs a real
|
||||||
|
public UDP port. Xray-core (what 3x-ui drives) implements neither, hence sing-box
|
||||||
|
running alongside 3x-ui. Nothing here touches 3x-ui, nginx, or the existing
|
||||||
|
inbounds: separate service, separate ports, separate config file.
|
||||||
|
|
||||||
|
WARP egress reuses the cloudflare-warp SOCKS5 proxy the Xray plane already uses,
|
||||||
|
routed by inbound tag -- one WARP install serves both planes.
|
||||||
|
|
||||||
|
Env in: COVER, CERT_DIR, WARP_SOCKS_PORT, NODE_PASSWORD, XUI_UUID,
|
||||||
|
PORT_HY2_DIRECT / PORT_HY2_WARP / HY2_OBFS_PASSWORD / HY2_UP_MBPS / HY2_DOWN_MBPS,
|
||||||
|
PORT_TUIC_DIRECT / PORT_TUIC_WARP / TUIC_CC
|
||||||
|
Unset port pairs are simply skipped.
|
||||||
|
"""
|
||||||
|
import json, os, sys
|
||||||
|
|
||||||
|
COVER = os.environ["COVER"]
|
||||||
|
CERT_DIR = os.environ.get("CERT_DIR", "/etc/nginx/ssl/" + COVER.split(".", 1)[1])
|
||||||
|
WARP_PORT = int(os.environ.get("WARP_SOCKS_PORT", "40000"))
|
||||||
|
OUT = os.environ.get("SINGBOX_CONFIG", "/etc/sing-box/config.json")
|
||||||
|
|
||||||
|
|
||||||
|
def tls():
|
||||||
|
return {"enabled": True, "server_name": COVER, "alpn": ["h3"],
|
||||||
|
"certificate_path": os.path.join(CERT_DIR, "fullchain.pem"),
|
||||||
|
"key_path": os.path.join(CERT_DIR, "privkey.pem")}
|
||||||
|
|
||||||
|
|
||||||
|
def port(name):
|
||||||
|
v = os.environ.get(name, "").strip()
|
||||||
|
return int(v) if v else None
|
||||||
|
|
||||||
|
|
||||||
|
inbounds = []
|
||||||
|
|
||||||
|
# --- Hysteria2 -------------------------------------------------------------
|
||||||
|
# Brutal congestion control needs a DECLARED bandwidth; it ignores loss as a
|
||||||
|
# congestion signal, so an over-stated figure causes a loss storm rather than
|
||||||
|
# speed. up/down are the server's view of the link.
|
||||||
|
up = int(os.environ.get("HY2_UP_MBPS", "500"))
|
||||||
|
down = int(os.environ.get("HY2_DOWN_MBPS", "500"))
|
||||||
|
obfs_pw = os.environ.get("HY2_OBFS_PASSWORD", "").strip()
|
||||||
|
for tag, pvar in (("hy2-direct", "PORT_HY2_DIRECT"), ("hy2-warp", "PORT_HY2_WARP")):
|
||||||
|
p = port(pvar)
|
||||||
|
if not p:
|
||||||
|
continue
|
||||||
|
ib = {"type": "hysteria2", "tag": tag, "listen": "::", "listen_port": p,
|
||||||
|
"up_mbps": up, "down_mbps": down,
|
||||||
|
"users": [{"name": "node-main", "password": os.environ["NODE_PASSWORD"]}],
|
||||||
|
"tls": tls()}
|
||||||
|
if obfs_pw:
|
||||||
|
ib["obfs"] = {"type": "salamander", "password": obfs_pw}
|
||||||
|
inbounds.append(ib)
|
||||||
|
|
||||||
|
# --- TUIC v5 --------------------------------------------------------------
|
||||||
|
tuic_cc = os.environ.get("TUIC_CC", "bbr")
|
||||||
|
for tag, pvar in (("tuic-direct", "PORT_TUIC_DIRECT"), ("tuic-warp", "PORT_TUIC_WARP")):
|
||||||
|
p = port(pvar)
|
||||||
|
if not p:
|
||||||
|
continue
|
||||||
|
inbounds.append({
|
||||||
|
"type": "tuic", "tag": tag, "listen": "::", "listen_port": p,
|
||||||
|
"users": [{"name": "node-main", "uuid": os.environ["XUI_UUID"],
|
||||||
|
"password": os.environ["NODE_PASSWORD"]}],
|
||||||
|
"congestion_control": tuic_cc, "auth_timeout": "3s",
|
||||||
|
"zero_rtt_handshake": False, "heartbeat": "10s", "tls": tls()})
|
||||||
|
|
||||||
|
if not inbounds:
|
||||||
|
sys.exit("ERROR: no QUIC ports configured (set PORT_HY2_* and/or PORT_TUIC_*)")
|
||||||
|
|
||||||
|
warp_tags = [i["tag"] for i in inbounds if i["tag"].endswith("-warp")]
|
||||||
|
direct_tags = [i["tag"] for i in inbounds if i["tag"].endswith("-direct")]
|
||||||
|
|
||||||
|
rules = []
|
||||||
|
if warp_tags:
|
||||||
|
rules.append({"inbound": warp_tags, "outbound": "warp"})
|
||||||
|
if direct_tags:
|
||||||
|
rules.append({"inbound": direct_tags, "outbound": "direct"})
|
||||||
|
rules.append({"protocol": "bittorrent", "outbound": "block"})
|
||||||
|
|
||||||
|
cfg = {
|
||||||
|
"log": {"level": "warn", "timestamp": True},
|
||||||
|
"inbounds": inbounds,
|
||||||
|
"outbounds": [
|
||||||
|
{"type": "direct", "tag": "direct"},
|
||||||
|
{"type": "socks", "tag": "warp", "server": "127.0.0.1",
|
||||||
|
"server_port": WARP_PORT, "version": "5"},
|
||||||
|
{"type": "block", "tag": "block"},
|
||||||
|
],
|
||||||
|
"route": {"rules": rules, "final": "direct"},
|
||||||
|
}
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(OUT), exist_ok=True)
|
||||||
|
with open(OUT, "w") as f:
|
||||||
|
json.dump(cfg, f, indent=2)
|
||||||
|
os.chmod(OUT, 0o600)
|
||||||
|
print("wrote %s with %d inbound(s): %s" % (OUT, len(inbounds), ", ".join(i["tag"] for i in inbounds)))
|
||||||
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="2001:db8::10" # public IPv6 (optional; leave empty to skip AAAA)
|
||||||
|
SSH_USER="root" # root, or a sudo user (scripts escalate via sudo)
|
||||||
|
SSH_KEY="$HOME/.ssh/your-vps-key.pem" # private key for the VPS
|
||||||
|
TZ="UTC" # server timezone (e.g. UTC, Asia/Shanghai, America/Los_Angeles)
|
||||||
|
|
||||||
|
# --- domain ---
|
||||||
|
PARENT_ZONE="example.com" # your DNS zone
|
||||||
|
DOMAIN_ROOT="nodeA.example.com" # this node owns *.nodeA.example.com
|
||||||
|
|
||||||
|
# --- DNS provider ---
|
||||||
|
# Cloudflare is the default & has a turnkey record-creation script (02-cf-dns.py).
|
||||||
|
# Any acme.sh-supported DNS provider works for the wildcard cert — set ACME_DNS_PLUGIN and
|
||||||
|
# the matching credential vars below. For A/AAAA records on a non-CF provider, create them
|
||||||
|
# yourself (see references/dns-records.md — it's two names, A + AAAA, DNS-only).
|
||||||
|
DNS_PROVIDER="cloudflare" # informational: cloudflare | dnspod | aliyun | route53 | other
|
||||||
|
ACME_DNS_PLUGIN="dns_cf" # acme.sh plugin: dns_cf | dns_dp | dns_ali | dns_aws | dns_gd | ...
|
||||||
|
|
||||||
|
# Cloudflare (default). Token needs Zone:DNS:Edit. NEVER commit.
|
||||||
|
CF_API_TOKEN="cf-token-with-Zone-DNS-Edit"
|
||||||
|
CF_ZONE_ID="" # optional; 02-cf-dns.py looks it up if empty
|
||||||
|
|
||||||
|
# Other providers — uncomment the pair your ACME_DNS_PLUGIN needs (acme.sh reads these env names):
|
||||||
|
# DP_Id=""; DP_Key="" # dns_dp (DNSPod)
|
||||||
|
# Ali_Key=""; Ali_Secret="" # dns_ali (Aliyun)
|
||||||
|
# AWS_ACCESS_KEY_ID=""; AWS_SECRET_ACCESS_KEY="" # dns_aws (Route53)
|
||||||
|
# GD_Key=""; GD_Secret="" # dns_gd (GoDaddy)
|
||||||
|
|
||||||
|
# --- subdomain labels (CHOOSE YOUR OWN, NON-OBVIOUS ONES) ---
|
||||||
|
# Your panels sit at <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
|
name: update-db
|
||||||
description: 对 LA 生产机上的 proxy-station 数据做规范化变更——增删节点、导入分享链接、调整策略组与成员、规则、规则集引用、订阅 token、Surge 设置段落。凡是用户要动线上数据的(「线上加/删个节点」「把这个组改成 url-test」「调一下规则」「建个订阅 token」「删掉那个 token」「改下生产上的设置」),都用这个 Skill,即使没提到「数据库」三个字。它只改数据不碰代码:代码更新与部署归 deploy Skill。
|
description: 经管理 API 查询或维护 LA 生产环境的节点、策略组、规则、订阅 token 与 Surge 设置,写入前在线备份。用于线上数据盘点、导入预览与数据变更;代码部署使用 deploy。
|
||||||
---
|
---
|
||||||
|
|
||||||
# 变更 proxy-station 生产数据
|
# 变更 proxy-station 生产数据
|
||||||
|
|
||||||
线上数据(节点、策略组、规则、token、设置)住在 la 机的 SQLite(`/opt/proxy-station/data/proxy-station.db`),由常驻的 `proxy-station` 服务直连。本 Skill 的做法是:**走服务自己的管理 API 做变更,变更前用 sqlite3 在线备份做快照**,不直接写库、不重启服务。
|
线上数据(节点、策略组、规则、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,不能直写 SQLite
|
||||||
|
|
||||||
API 不是简单转发,直写会绕过它的全部防护:
|
API 不是简单转发,直写会绕过它的全部防护:
|
||||||
@@ -23,6 +25,7 @@ API 不是简单转发,直写会绕过它的全部防护:
|
|||||||
```bash
|
```bash
|
||||||
bash .claude/skills/update-db/scripts/api.sh api GET /api/nodes
|
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 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 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 backup <context> # context 用小写连字符 slug,如 add-jp-hy2
|
||||||
bash .claude/skills/update-db/scripts/api.sh counts
|
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,无需转义
|
- `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 …`
|
- 响应末尾带 `__HTTP_STATUS:200__` 一行。**非 2xx 时先读响应体里的错误信息再决定下一步**,不要盲目重试;要管道给 jq 时先剥掉这行:`| sed '$d' | jq …`
|
||||||
- 连不上 la(解析出 198.18.x.x)是本地 fake-IP 劫持了 ssh,与远端无关,等网络恢复再试
|
- 连不上 la(解析出 198.18.x.x)是本地 fake-IP 劫持了 ssh,与远端无关,等网络恢复再试
|
||||||
- 环境变量可覆盖 `DEPLOY_HOST`/`PS_DATA_DIR`/`PS_ENV_FILE`,默认即生产值
|
- 环境变量可覆盖 `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`,把要动的对象当前值拿到手。改组成员、调规则顺序这类「全量替换」操作,不先拿全量必出错。
|
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 属主),不要清理。
|
2. **备份**:`api.sh backup <context>`。一个变更主题一个备份,同主题内的多条变更共用一份。备份落在远端 `data/backups/`(root 属主),不要清理。
|
||||||
3. **执行**:逐条调 API,每条核对 HTTP_STATUS 与返回体。某条失败就停下排查,不要跳过继续。
|
3. **执行**:逐条调 API,每条核对 HTTP_STATUS 与返回体。某条失败就停下排查,不要跳过继续。
|
||||||
4. **校验**:
|
4. **校验**:
|
||||||
- `counts` 与变更前对比,行数增减要能对上意图
|
- `counts` 与变更前对比,行数增减要能对上意图
|
||||||
- `api GET` 变更对象,确认落库的是新值
|
- `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`
|
- `excluded`/`skippedRules` 只存在于生成器内部、不在 HTTP 响应里;要核对静默排除时,用 `/api/nodes` 列表对比 yaml 里的 `proxies`
|
||||||
5. **汇报**:向用户复述三件事——变更清单、备份文件名、校验结果。三者齐了才算完成。
|
5. **汇报**:向用户复述三件事——变更清单、备份文件名、校验结果。三者齐了才算完成。
|
||||||
|
|
||||||
## 各实体的操作要点
|
## 各实体的操作要点
|
||||||
|
|
||||||
### 节点 `/api/nodes`
|
### 节点 `/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`
|
- 节点名是分流的关键:必须保留出口词尾缀(客户端里没有标签,用户靠名字区分直连/WARP 线路);地区组按名称正则收编,涉及新地区先读 `docs/ADDING-REGIONS.md`
|
||||||
|
- 名称不能含配置分隔符、引号、反斜线、控制字符或首尾空格,不能与组或内置策略重名;导入预览与 commit 使用同一字段验证。Surge 当前排除 SS over WS、非零 alterId 及不支持加密方式的 VMess,核对实际输出而非只看节点总数
|
||||||
- 临时下线用 `PATCH /:id/enabled {"enabled":false}`,不要删——IR 层会自动剔除停用节点,删了凭据就没了
|
- 临时下线用 `PATCH /:id/enabled {"enabled":false}`,不要删——IR 层会自动剔除停用节点,删了凭据就没了
|
||||||
- 真要删除:先 GET 全量字段向用户展示将消失的内容,确认后 DELETE
|
- 真要删除:先 GET 全量字段核对对象,向用户展示名称及影响(不展示密码/UUID);删除范围已有明确授权时按流程 DELETE,否则先确认
|
||||||
|
|
||||||
### 策略组 `/api/groups`
|
### 策略组 `/api/groups`
|
||||||
- `members` 是**整体替换**:GET 全量 → 改数组 → PUT 回去,别只发改动项
|
- `members` 是**整体替换**:GET 全量 → 改数组 → PUT 回去,别只发改动项
|
||||||
- 成员三态:`{kind:"node", nodeId}`/`{kind:"group", refName}`/`{kind:"builtin", refName}`
|
- 成员三态:`{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`
|
### 规则 `/api/rules`
|
||||||
- 顺序由 `PUT /api/rules/reorder` 的**全量 ids 数组**决定(GET 拿现有顺序 → 调整 → 全量提交);自定义规则置顶是既有约定
|
- 顺序由 `PUT /api/rules/reorder` 的**全量、无重复 ids 数组**决定,启用的唯一 FINAL 必须末尾。自定义创建置顶,但有明确顺序意图时按完整列表调整;业务例外在覆盖它的集合之前,特定 UDP 拦截在服务域名规则之前
|
||||||
- FINAL 规则永远保留;其策略组失效时 IR 层降级 DIRECT——兜底存在,但别依赖
|
- FINAL 不可停用、删除、改类型或重复创建;失效策略由 IR 降级 DIRECT。不要借防御性兜底制造无效数据库状态
|
||||||
- 新规则的 policy 必须已存在(内置 DIRECT/REJECT… 或现有组名),后端会校验,报错就先建组
|
- 新规则的 policy 必须已存在(内置 DIRECT/REJECT… 或现有组名),后端会校验,报错就先建组
|
||||||
- `POST /api/rules/reset` 会重置回模板初版并**清掉全部自定义规则**——除非用户明说「重置」,否则永远不调用
|
- `POST /api/rules/reset` 事务内恢复模板并**保留自定义规则**;不是定向更新生产规则的方法,除非用户明确要求重置,否则不调用
|
||||||
|
- 靠前 IP 集是否加 `no-resolve` 按意图决定;主动解析可能绕过后续业务规则,no-resolve 又会改变对未解析域名的覆盖。URL-REGEX 值只检查 HTTP URL,不能拿 SMTP 文字当邮件协议匹配
|
||||||
|
|
||||||
### 规则集 `/api/rulesets`(只读)
|
### 规则集 `/api/rulesets`(只读)
|
||||||
- `GET /api/rulesets` 看清单,`GET /:id/content` 看自托管转换结果
|
- `GET /api/rulesets` 看清单,`GET /:id/content` 看自托管转换结果
|
||||||
@@ -78,6 +87,8 @@ bash .claude/skills/update-db/scripts/api.sh health
|
|||||||
|
|
||||||
### 设置 `/api/settings`
|
### 设置 `/api/settings`
|
||||||
- `surgeSections` 是 Surge 原文段落透传,改错直接毁掉 Surge 配置;动它之前先 GET 存档 + backup,改完拉 `/sub/<token>/surge` 全文核对
|
- `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 单元
|
- 不改代码、不做部署(那是 deploy Skill 的事);不动 nginx/证书/`ADMIN_TOKEN`/systemd 单元
|
||||||
- 删除类操作与 reset:先列出将删除/重置的完整清单,用户确认后再执行
|
- 删除类操作与 reset:先核对并说明对象清单与影响;未明确授权时先确认,已有明确授权且对象一致时直接按流程执行
|
||||||
- 节点凭据是真凭据:汇报里不要粘贴密码/UUID 全文,用节点名指代
|
- 节点凭据是真凭据:汇报里不要粘贴密码/UUID 全文,用节点名指代
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#
|
#
|
||||||
# 用法:
|
# 用法:
|
||||||
# api.sh api METHOD PATH [JSON] # 调管理接口;PATH 以 /api/ 或 /sub/ 开头;JSON 作第三参
|
# 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 backup <context> # 变更前快照 → data/backups/before-<context>-<时间戳>.db
|
||||||
# api.sh backups # 列出最近 20 个备份
|
# api.sh backups # 列出最近 20 个备份
|
||||||
# api.sh counts # 各表行数,变更前后对比用
|
# api.sh counts # 各表行数,变更前后对比用
|
||||||
@@ -33,12 +34,19 @@ cmd="$1"; shift
|
|||||||
case "$cmd" in
|
case "$cmd" in
|
||||||
api)
|
api)
|
||||||
[ $# -ge 2 ] || die "用法:api.sh api METHOD PATH [JSON]"
|
[ $# -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 "$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 /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、不落本地磁盘
|
# 单引号段内的 $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"
|
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
|
if [ -n "$BODY_FILE" ]; then
|
||||||
|
"${SSH[@]}" "$RCMD --data-binary @-" < "$BODY_FILE"
|
||||||
|
elif [ -n "$BODY" ]; then
|
||||||
printf '%s' "$BODY" | "${SSH[@]}" "$RCMD --data-binary @-"
|
printf '%s' "$BODY" | "${SSH[@]}" "$RCMD --data-binary @-"
|
||||||
else
|
else
|
||||||
"${SSH[@]}" "$RCMD"
|
"${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
|
.env.local
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.log
|
*.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
|
||||||
|
|||||||
173
AGENTS.md
Normal file
173
AGENTS.md
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
本文件是 Codex 与 Claude Code 共用的项目开发约定。`CLAUDE.md` 用 `@AGENTS.md` 导入本文件;项目规则只在这里维护。双工具入口、技能发现和使用方法见 `docs/AI-COLLABORATION.md`。
|
||||||
|
|
||||||
|
## 协作方式
|
||||||
|
|
||||||
|
- 默认用中文沟通,代码标识符沿用项目现有英文命名。
|
||||||
|
- 按任务读取相关代码与文档;以代码、测试及用户最新要求为准,发现说明过时就同步修正。
|
||||||
|
- 本地阅读、编辑、类型检查、单测和构建按任务自主完成。部署或生产数据变更须属于用户授权的任务;已有明确授权不重复确认。普通代码修改不隐含上线或修改生产数据。
|
||||||
|
- 保留用户已有改动。提交、推送与部署分别按用户要求执行。
|
||||||
|
- 项目技能唯一源文件在 `.claude/skills/`;`.agents/skills/` 通过相对符号链接供 Codex 发现。修改技能时编辑源文件,避免维护两份副本。
|
||||||
|
|
||||||
|
## 这是什么
|
||||||
|
|
||||||
|
本仓库统一维护边缘 VPS 节点的部署管理与 Proxy Station 代理节点、分流规则管理平台。Web 界面管理多协议节点(VMess/Trojan/Shadowsocks/Hysteria2)与策略组、规则,向 macOS Surge、iOS ShadowRocket、Android ClashMeta 下发托管订阅。规则模板来自 [ClashConnectRules/Surge](https://github.com/ClashConnectRules/Surge),边缘舰队的工具与私有交付记录已从原 `../edge-nodes/` 迁入本项目。
|
||||||
|
|
||||||
|
### 节点部署管理
|
||||||
|
|
||||||
|
- `edge-nodes/README.md` 是节点工作区入口;`edge-nodes/build-delivery-doc.py` 从 Git 忽略的 `edge-nodes/private/fleet.json` 读取清单并离线生成汇总交付页,不在源码里保存真实站点信息。
|
||||||
|
- 通用部署脚本、参考资料与占位交付模板唯一源码在 `.claude/skills/edge-node-multi-protocol/`,Codex 从 `.agents/skills/edge-node-multi-protocol` 相对链接加载。部署/重建/维护 VPS 节点使用此技能;发布 Proxy Station 应用用 `deploy`,生产订阅数据查询与修改用 `update-db`。
|
||||||
|
- 真实配置、面板入口、分享链接、二维码、交付文档和各站点脚本工作副本在 `edge-nodes/private/<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/` 是历史资料,不作为当前配置来源;原兄弟目录仅保留为迁移前备份,后续在本仓库维护。
|
||||||
|
- 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 配置(最有价值的一条端到端验证)
|
||||||
|
curl -s "http://localhost:3000/sub/<token>/clash" > /tmp/c.yaml && mihomo -t -f /tmp/c.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
本机开发及当前生产部署使用 Node 22。依赖使用 pnpm 与已提交的 `pnpm-lock.yaml`;不要混用 npm/yarn 或无故更新锁文件。pnpm 在无 TTY 时安装需要 `CI=true`,原生模块(better-sqlite3、esbuild、vue-demi)的构建许可写在 `pnpm-workspace.yaml` 的 `allowBuilds`。
|
||||||
|
|
||||||
|
本机会话注入的代理曾造成 git/安装/网络检查挂起;遇到此问题,在单条命令作用域内用 `env -u HTTPS_PROXY -u https_proxy -u HTTP_PROXY -u http_proxy -u ALL_PROXY -u all_proxy <命令>` 直连。现有运维脚本已自行处理代理。不依赖其他工具的全局指令文件,也不修改用户全局代理配置。
|
||||||
|
|
||||||
|
## 架构
|
||||||
|
|
||||||
|
应用部分为 pnpm monorepo:`shared/`(zod schema 与 DTO,前后端共用源码,非构建产物)+ `server/`(Hono + better-sqlite3 + Drizzle)+ `web/`(Vue 3 + Naive UI + Pinia)。生产模式由 server 静态托管 web 产物,单端口。`edge-nodes/` 与节点部署技能是 Bash/Python 运维工具,不加入 pnpm 包,也不随应用部署上传;Docker 构建上下文排除它们。
|
||||||
|
|
||||||
|
### 配置生成流水线(核心)
|
||||||
|
|
||||||
|
```
|
||||||
|
SQLite → buildProfileIR({nodeIds}) → ProfileIR → 四个序列化器
|
||||||
|
├─ generateSurge(ir, {selfUrl})
|
||||||
|
├─ generateShadowrocket(ir) 节点订阅
|
||||||
|
├─ generateShadowrocketConf(ir) 策略组+规则
|
||||||
|
└─ generateClash(ir, {convertBaseUrl})
|
||||||
|
```
|
||||||
|
|
||||||
|
`server/src/services/ir.ts` 是配置生成流水线的统一读库入口,把节点、策略组、规则、Surge 原文段落收成与客户端无关的 `ProfileIR`;四个生成器只读 IR,互不依赖。**新增客户端就是加一个序列化器**,不要在生成器里再查数据库。
|
||||||
|
|
||||||
|
IR 层承担三件防护,改动时不要绕过:
|
||||||
|
|
||||||
|
- 停用或已删除的策略组会被剔除,引用它的成员与规则一并跳过
|
||||||
|
- `FINAL` 规则永远保留,其策略组失效时降级为 `DIRECT`
|
||||||
|
- `nodeIds` 为空数组表示「不限制」,非空则按订阅分配的节点范围过滤
|
||||||
|
|
||||||
|
### 客户端能力矩阵
|
||||||
|
|
||||||
|
`shared/src/protocols.ts` 的 `clientSupports()` 决定某节点能否下发给某客户端,生成器据此排除并回报 `excluded`。**Surge 不支持 Shadowsocks over WebSocket**;本项目对 Surge 的 VMess 仅下发 `alterId=0`,加密方式只支持 auto/aes-128-gcm/chacha20-poly1305(转换为 IETF 名称)。其他客户端的能力独立判断。不要把无法表达的输入静默改成另一个协议组合。
|
||||||
|
|
||||||
|
### 协议字段差异
|
||||||
|
|
||||||
|
同一协议在三端写法不同,映射集中在 `shared/src/protocols.ts` 与各生成器:
|
||||||
|
|
||||||
|
- SS cipher:分享链接用 Xray 命名 `chacha20-poly1305`,Surge/Clash 要 `chacha20-ietf-poly1305`(`normalizeSsCipher()`)
|
||||||
|
- VMess `alterId=0` 必须输出 Surge 的 `vmess-aead=true`,否则连不上且症状隐晦
|
||||||
|
- Hysteria2 的 salamander 混淆:Surge 用 `salamander-password=`,Clash 用 `obfs`/`obfs-password`
|
||||||
|
- SS over WS 在 Clash 侧唯一可行方案是 `plugin: v2ray-plugin`
|
||||||
|
|
||||||
|
这些写法参考了 `../sub-router` 项目(用户已在生产使用的同类系统),遇到新的客户端适配问题优先查它。
|
||||||
|
|
||||||
|
### 策略组的两种取节点方式
|
||||||
|
|
||||||
|
`includeAllNodes` 与 `filterRegex` 可以共存,生成器里 **filterRegex 分支优先**:
|
||||||
|
|
||||||
|
- 有 `filterRegex` → 动态筛选:Surge 保留显式节点/内置成员,全部成员组作为 `include-other-group` 来源,并可同时启用 `include-all-proxies=true`;正则只筛动态来源,不筛显式成员。Clash 用 `include-all-proxies: true` + `filter`;ShadowRocket 用 `policy-regex-filter`。新增同地区节点会自动进组
|
||||||
|
- 仅 `includeAllNodes` → Surge 静态展开全部节点名,Clash/ShadowRocket 仍用动态引用
|
||||||
|
|
||||||
|
**地区组靠节点名称正则匹配,不看 `region` 字段**。节点命名规范与新增地区的完整步骤见 `docs/ADDING-REGIONS.md`——命名不对就进不了地区组。
|
||||||
|
|
||||||
|
节点与组共用客户端的策略名称空间:禁止名称中的配置分隔符、引号、反斜线、控制字符和首尾空格;不能与内置策略或另一类实体重名。导入预览与实际写入都走同一 schema。组成员必须存在且类型正确,创建/更新(包括改名)校验完整引用图无环,并在事务内写组、替换成员与级联引用。
|
||||||
|
|
||||||
|
Surge 不支持组级 `url=`;组的 `testUrl` 仅用于 Clash/ShadowRocket。Surge 使用 General 的 `proxy-test-url` 或节点 `test-url`,fallback 不输出 tolerance。协议、规则和参数规范以 [Surge 官方手册](https://manual.nssurge.com/) 与本机 `surge-cli --check` 为依据,不能凭其他客户端语法推断。
|
||||||
|
|
||||||
|
### 规则集:映射与兜底转换
|
||||||
|
|
||||||
|
模板里的 `RULE-SET`/`DOMAIN-SET` 指向外部 URL。Surge/ShadowRocket 直接透传;Clash 需要 rule-providers,`services/rulesets/mapping.ts` 按序尝试映射到 skk.moe 的 Clash 版与 blackmatrix7 的 Clash 目录,映射不到的标记 `needsConvert`,provider URL 指向自托管端点 `/sub/:token/ruleset/:id.yaml`——该端点实时拉取 Surge 版并用 `convert.ts` 转成 classical provider。规则集拉取带 24h 缓存 + etag,失败回落陈旧缓存。
|
||||||
|
|
||||||
|
### ShadowRocket 分两步导入
|
||||||
|
|
||||||
|
ShadowRocket 的原生订阅格式只承载节点,因此拆成两个端点:`/shadowrocket`(节点 base64 URI 列表)与 `/shadowrocket-conf`(策略组与规则的 .conf)。**ShadowRocket 没有 Surge 的 `use=` 订阅引用语法**(真机验证过:`use=true` 会被当成解析不了的成员残留),策略组收节点只能靠 `policy-regex-filter` 从 App 内全部节点里筛(「收纳全部节点」输出 `policy-regex-filter=.*`),因此订阅名随意,但用户在 App 里挂的其他订阅的节点也会被筛进来。ShadowRocket 不支持 `DOMAIN-SET`/`AND`/`OR`/`NOT`/`URL-REGEX`/`PROTOCOL`,生成时跳过并回报 `skippedRules`。**ShadowRocket 的 conf 语法没有引号机制**:策略名(含空格/emoji)一律裸写,加引号会被当作名字的一部分,导致规则指向不存在的策略组而整条失效、流量落到默认行为走代理——Surge 生成器可以加引号,ShadowRocket 生成器绝不可以。
|
||||||
|
|
||||||
|
### 鉴权与订阅
|
||||||
|
|
||||||
|
除公开的 `/api/health` 外,`/api/*` 走 `adminAuth`:设置了 `ADMIN_TOKEN` 则要求 Bearer,未设置时仅放行 localhost 与 RFC1918 私有网段(公网 IP 一律拒绝)。`/sub/:token/*` 公开,token 用常量时间比对。**订阅 URL 完全由请求来源决定**——后端只返回路径,前端用 `window.location.origin` 拼接,`#!MANAGED-CONFIG` 与 Clash provider URL 取本次请求的 origin,所以没有也不需要「订阅基址」配置项。
|
||||||
|
|
||||||
|
未匹配的 `/api/*` 显式返回 404,不落到 SPA 回退(否则会拿到一页 HTML,调接口时极难排查)。
|
||||||
|
|
||||||
|
## 前端约定
|
||||||
|
|
||||||
|
UI 走「站牌导视」设计方向(轨道交通导视系统语言):白底 `#f7f7f4`、深藏青重点色 `#2b3f5c`、**琥珀 `#b26a00` = 直连出口、青 `#007a72` = WARP 净出口**,协议各有线路色圆标。颜色只在承载信息时出现——避免发光、亮边条、装饰性渐变。四个方向的设计对比留在 `docs/design-demos.html`。
|
||||||
|
|
||||||
|
两个共用工具:
|
||||||
|
|
||||||
|
- `utils/nodeName.ts` 的 `nodeDisplayName()`:与出口标签并排显示时剥掉名字末尾的出口词。**下发给客户端的名字必须保留出口词**,客户端里没有标签,用户只能靠名字区分同协议的两条线路
|
||||||
|
- `utils/clipboard.ts` 的 `copyText()`:`navigator.clipboard` 只在 HTTPS/localhost 可用,局域网 HTTP 访问时是 `undefined`,直接调用会静默失败。必须走这个带 `execCommand` 降级的封装
|
||||||
|
|
||||||
|
## 数据与安全
|
||||||
|
|
||||||
|
SQLite 落在 `server/data/`,存有节点真实凭据。`data/`、`*.db`、`.env`、`edge-nodes/private/`、`config.env` 及其备份、`node-links/` 已在 `.gitignore`——**绝不入 git**。可入库的示例、测试、模板一律用占位凭据;填写后的交付副本只放私有目录。节点通过 Web 界面粘贴分享链接导入,应用不直接读 `edge-nodes/private/`(见 `docs/SEEDING.md`)。
|
||||||
|
|
||||||
|
schema 变更同时更新 `shared/src/schemas.ts`/DTO(涉及对外字段时)、`server/src/db/schema.ts` 的 Drizzle 定义和 `server/src/db/index.ts` 的启动 SQL。新表在 `BOOTSTRAP_SQL` 中加 `CREATE TABLE IF NOT EXISTS`,旧库补列用同文件下方的轻量 `ALTER TABLE` 循环(吞掉「列已存在」错误)——没有引入 migration 工具。
|
||||||
|
|
||||||
|
公网部署必须设 `ADMIN_TOKEN` 并用反代提供 HTTPS:订阅 token 就在 URL 里,明文 HTTP 会泄露。
|
||||||
|
|
||||||
|
## 初始化与已有数据
|
||||||
|
|
||||||
|
`server/src/db/seed.ts` 只在策略组为空时从 `server/assets/surge-template.conf` 初始化策略组、规则和 Surge 段落。修改模板不会更新已有数据库;既有订阅仍以数据库为准。生产数据变更使用 `update-db` 技能,经管理 API 修改并先做 SQLite 在线备份,避免绕过校验和级联更新。组成员与排序 API 有全量替换语义,修改前先读取完整当前值。
|
||||||
|
|
||||||
|
`server/src/env.ts` 直接读 `process.env`,没有 dotenv 加载;仅复制 `.env.example` 成 `.env` 并不会让 `pnpm dev` 自动读取它。变量须由启动环境提供(Docker Compose 与 systemd 有各自注入方式)。相对 `DATA_DIR` 始终基于 `server/` 解析。
|
||||||
|
|
||||||
|
## Surge AI DNS 与协议规则
|
||||||
|
|
||||||
|
改 DNS/隐私行为先读 `docs/SURGE-PRIVACY.md`、`server/src/services/generators/surge-ai-dns.ts` 及对应测试。AI 域名 DNS 映射来自 IR 内启用的 AI 路由规则;不要把全局 DNS 或所有端口 53 请求改送 AI。Surge 的 `PROTOCOL` 应用协议规则不能直接复制到其他客户端:Clash 只把 TCP/UDP 转成 `NETWORK`,其他应用协议跳过;ShadowRocket 跳过 `PROTOCOL`。生成配置校验不能替代设备 DNS/WebRTC 真机验证。
|
||||||
|
|
||||||
|
手写 Host 项优先;生成的域名 DNS 映射按连接规则顺序保留 AI 之前的非 AI 域名/规则集例外,不把 Host 当成能表达进程、协议或 IP 条件的路由系统。宽泛 AI 域名规则仍须审阅覆盖范围。
|
||||||
|
|
||||||
|
规则变更先读 `docs/SURGE-CHANGES.md`。API 必须保住一条启用且末尾的 FINAL;禁止停用、删除、改类型或创建重复 FINAL,排序必须提交完整且无重复的 ids。IR 对遗留坏数据防御性补齐 FINAL,其失效策略降级 DIRECT。重置规则在事务内保留自定义规则,不重复插入原主键,也不能用 reset 代替定向调整生产规则。
|
||||||
|
|
||||||
|
规则按首条匹配执行(另留意 pre-matching)。业务例外须在覆盖它的广告/通用规则集之前,特定 UDP 拦截须在对应服务域名规则之前。靠前的 IP 规则集是否加 `no-resolve` 必须按意图决定:它能避免主动解析,但会改变未解析域名的 IP 拦截覆盖。普通规则值与策略引用用 `surge-format.ts` 编码,逻辑表达式保留结构;不要手拼含逗号的正则或含换行的字段。
|
||||||
|
|
||||||
|
共享订阅不附公开默认控制口令;默认关闭 Remote Controller/HTTP API,确需使用时按设备配置独立凭据与访问限制。MITM 不关闭上游服务器证书验证;需要 MITM 时另做设备 CA、开关与 HTTPS 行为验证。
|
||||||
|
|
||||||
|
## 验证与完成标准
|
||||||
|
|
||||||
|
| 改动 | 建议验证 |
|
||||||
|
|---|---|
|
||||||
|
| 服务端逻辑/解析器/生成器 | `pnpm --filter @proxy-station/server typecheck` + 相关 Vitest;新增行为用占位凭据构造 IR/分享链接用例 |
|
||||||
|
| shared schema/DTO/协议能力 | `pnpm -r typecheck` + `pnpm test`,兼顾前后端消费者与各客户端产物 |
|
||||||
|
| Vue 页面/样式/状态 | `pnpm --filter @proxy-station/web typecheck` + `pnpm --filter @proxy-station/web build`;页面交互与局域网 HTTP 复制按需验证 |
|
||||||
|
| 数据库结构/启动行为 | 临时 `DATA_DIR` 或 `createDb(':memory:')` 验证新库与旧库升级;注意下述导入副作用 |
|
||||||
|
| 文档/协作入口/技能 | 检查路径、导入、符号链接、技能 frontmatter 与脚本语法;不为纯文档修改触发部署 |
|
||||||
|
| 节点部署工具/交付页生成器 | Bash 语法与 Python 编译检查、占位配置离线验证;迁移核对原文件、私有路径 Git 忽略及技能链接,不为迁移执行远端脚本 |
|
||||||
|
|
||||||
|
涉及节点、组、规则、DNS 或 Surge 段落时,在上述检查之外对实际订阅运行 `scripts/check-surge.py`;涉及规则集或顺序用 `--fetch-rulesets`,审阅域名遮蔽提示。脚本调用官方 Surge CLI,不更换活动配置;输出 OK 不表示测速、业务连接或设备隐私已验收。敏感配置用权限 600 的临时文件,不进入仓库或对话。生产写入先在线备份,再 API 更新、重读、HTTPS 拉订阅与 CLI 验证;必要时同时用 mihomo 校验受影响的 Clash 产物。
|
||||||
|
|
||||||
|
`db/index.ts` 在模块加载时就执行 `export const db = createDb()`,包括只导入 `createDb` 的情形;临时数据库测试/启动验证应在加载模块前设置绝对路径的临时 `DATA_DIR`,不要拿本地真实 `server/data/` 做试验。现有生成器单测使用内存中的 IR;运行服务还会执行初始化。
|
||||||
|
|
||||||
|
改生成器时检查节点过滤、组成员引用、规则顺序和 FINAL 兜底;Clash 可用 mihomo 校验,Surge/ShadowRocket 的客户端语法与实际连接仍需真机验收。`excluded`/`skippedRules` 是生成器返回字段,订阅 HTTP 响应只返回配置正文。
|
||||||
|
|
||||||
|
完成后说明改动、已通过的验证与尚未完成的实际验证。新增业务约束或修正旧认识时同步更新这里或相应 `docs/`,让后续 Codex/Claude Code 会话能接续。
|
||||||
105
CLAUDE.md
105
CLAUDE.md
@@ -1,102 +1,7 @@
|
|||||||
# CLAUDE.md
|
@AGENTS.md
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
# Claude Code 兼容入口
|
||||||
|
|
||||||
## 这是什么
|
项目开发约定统一维护在根目录 `AGENTS.md`,不要在本文件重复复制。
|
||||||
|
项目技能仍从 `.claude/skills/` 加载;双工具使用说明见 `docs/AI-COLLABORATION.md`。
|
||||||
自托管的代理节点与分流规则管理平台:Web 界面管理多协议节点(VMess/Trojan/Shadowsocks/Hysteria2)与策略组、规则,向 macOS Surge、iOS ShadowRocket、Android ClashMeta 下发托管订阅。规则模板来自 [ClashConnectRules/Surge](https://github.com/ClashConnectRules/Surge),节点来自 `~/Development/Fusion/Projects/edge-nodes/` 交付的边缘节点舰队。
|
节点部署管理已合入本项目,工作区入口见 `edge-nodes/README.md`,使用 `edge-node-multi-protocol` 技能。
|
||||||
|
|
||||||
## 命令
|
|
||||||
|
|
||||||
```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,调接口时极难排查)。
|
|
||||||
|
|
||||||
## 前端约定
|
|
||||||
|
|
||||||
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 会泄露。
|
|
||||||
|
|||||||
21
README.md
21
README.md
@@ -1,6 +1,6 @@
|
|||||||
# Proxy Station
|
# Proxy Station
|
||||||
|
|
||||||
自托管的代理节点与分流规则管理平台。在 Web 界面里管理多协议节点与策略组、规则,向 macOS、iOS、Android 三端下发托管订阅——客户端填一次 URL,之后配置自动更新。
|
统一维护边缘 VPS 节点部署管理与自托管的代理节点、分流规则管理平台。在 Web 界面里管理多协议节点与策略组、规则,向 macOS、iOS、Android 三端下发托管订阅——客户端填一次 URL,之后配置自动更新。
|
||||||
|
|
||||||
```
|
```
|
||||||
节点 ──┐
|
节点 ──┐
|
||||||
@@ -11,6 +11,8 @@
|
|||||||
|
|
||||||
## 能做什么
|
## 能做什么
|
||||||
|
|
||||||
|
**边缘节点部署管理**——节点部署脚本、版本兼容参考、交付模板与离线汇总页生成器已合入本仓库,使用项目技能 `edge-node-multi-protocol` 维护 nginx、3x-ui/Xray、WARP 与可选 sing-box 线路。真实配置与交付记录位于 Git 忽略的 `edge-nodes/private/`;入口见 [边缘节点工作区](edge-nodes/README.md)。
|
||||||
|
|
||||||
**多协议节点管理**——VMess、Trojan、Shadowsocks、Hysteria2,支持粘贴分享链接批量导入(`vmess://`、`trojan://`、`ss://` 的 SIP002 与 Xray 两种写法、`hysteria2://`),也支持手动填写与导出。节点按站点分段展示。
|
**多协议节点管理**——VMess、Trojan、Shadowsocks、Hysteria2,支持粘贴分享链接批量导入(`vmess://`、`trojan://`、`ss://` 的 SIP002 与 Xray 两种写法、`hysteria2://`),也支持手动填写与导出。节点按站点分段展示。
|
||||||
|
|
||||||
**分流规则与策略组**——首次启动自动从 [ClashConnectRules/Surge](https://github.com/ClashConnectRules/Surge) 模板导入(26 个策略组 + 88 条规则,含广告拦截、AI 服务、流媒体、国内直连等)。规则可排序、改策略、启停、增删;策略组可启停并调整顺序,顺序即客户端里的排列顺序。外部规则集可在界面上实时预览内容。
|
**分流规则与策略组**——首次启动自动从 [ClashConnectRules/Surge](https://github.com/ClashConnectRules/Surge) 模板导入(26 个策略组 + 88 条规则,含广告拦截、AI 服务、流媒体、国内直连等)。规则可排序、改策略、启停、增删;策略组可启停并调整顺序,顺序即客户端里的排列顺序。外部规则集可在界面上实时预览内容。
|
||||||
@@ -72,14 +74,23 @@ Surge 与 ShadowRocket 需真机导入验收。
|
|||||||
|
|
||||||
| 文件 | 内容 |
|
| 文件 | 内容 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `docs/SEEDING.md` | 从 edge-nodes 交付文档导入真实节点、命名建议 |
|
| `edge-nodes/README.md` | 节点部署工作区、当前舰队、私有资料与工程迁移记录 |
|
||||||
|
| `.claude/skills/edge-node-multi-protocol/SKILL.md` | 节点部署、重建、维护与交付的共用技能 |
|
||||||
|
| `docs/SEEDING.md` | 从本仓库私有交付文档导入真实节点、命名建议 |
|
||||||
| `docs/ADDING-REGIONS.md` | 新增地区(香港、日本等)时的节点命名与策略组配套步骤 |
|
| `docs/ADDING-REGIONS.md` | 新增地区(香港、日本等)时的节点命名与策略组配套步骤 |
|
||||||
| `CLAUDE.md` | 架构要点与开发约定 |
|
| `AGENTS.md` | Codex/Claude Code 共用的架构要点与开发约定 |
|
||||||
|
| `CLAUDE.md` | 导入 `AGENTS.md` 的 Claude Code 兼容入口 |
|
||||||
|
| `docs/AI-COLLABORATION.md` | 双工具加载方式、共用技能与协作维护方法 |
|
||||||
|
| `docs/DEPLOY.md` | LA 生产环境部署与验证 |
|
||||||
|
| `docs/SURGE-PRIVACY.md` | Surge AI 域名 DNS 分流与验证边界 |
|
||||||
|
| `docs/SURGE-AUDIT-2026-10-03.md` | macOS Surge 生产订阅体检、复现结果与修复建议 |
|
||||||
|
| `docs/SURGE-FIX-2026-10-03.md` | 体检问题修复、生产备份与实际订阅验证结果 |
|
||||||
|
| `docs/SURGE-CHANGES.md` | 节点、组、规则与 DNS 变更的 Surge 官方 CLI 验证流程 |
|
||||||
|
|
||||||
## 安全
|
## 安全
|
||||||
|
|
||||||
数据库存有节点真实凭据。`data/`、`*.db`、`.env` 已在 `.gitignore` 中,**绝不入 git**;仓库内所有示例、测试、模板一律使用占位凭据。订阅 token 可随时在「订阅」页禁用或删除。
|
数据库和节点私有工作区存有真实凭据。`data/`、`*.db`、`.env`、`edge-nodes/private/`、`config.env` 及其备份、`node-links/` 已在 `.gitignore` 中,**绝不入 git**;可入库的示例、测试、模板一律使用占位凭据。节点私有资料通过受控备份恢复,Docker 构建上下文排除节点工具与私有资料。订阅 token 可随时在「订阅」页禁用或删除。
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
pnpm monorepo:`server/`(Hono + better-sqlite3 + Drizzle)+ `web/`(Vue 3 + Vite + Naive UI + Pinia)+ `shared/`(zod schema 与 DTO,前后端共用)。生产模式由 server 静态托管前端产物,单端口运行。
|
应用为 pnpm monorepo:`server/`(Hono + better-sqlite3 + Drizzle)+ `web/`(Vue 3 + Vite + Naive UI + Pinia)+ `shared/`(zod schema 与 DTO,前后端共用)。生产模式由 server 静态托管前端产物,单端口运行。节点部署管理使用独立 Bash/Python 工具,不加入应用运行包。
|
||||||
|
|||||||
62
docs/AI-COLLABORATION.md
Normal file
62
docs/AI-COLLABORATION.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# Codex 与 Claude Code 协作
|
||||||
|
|
||||||
|
项目开发约定以根目录 `AGENTS.md` 为唯一来源。原 `CLAUDE.md` 中的架构、协议适配、前端设计、数据与部署约定已迁入,并根据当前代码补充了初始化、环境变量、AI DNS 与验证说明。
|
||||||
|
|
||||||
|
## 指令入口
|
||||||
|
|
||||||
|
| 工具 | 项目指令 | 项目技能 |
|
||||||
|
|---|---|---|
|
||||||
|
| 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` 或复制一份独立的 Codex 规则。Claude 的 `@path` 是其导入语法,Codex 项目入口本身包含完整规则,无须依赖此语法。
|
||||||
|
|
||||||
|
新开会话时从仓库根目录启动工具。Claude Code 可用 `/memory` 或 `/context` 检查项目指令;Codex 可询问当前项目约定,并用 `/skills` 查看发现的技能。若当前会话未发现新技能,重开会话;不需要额外安装项目技能。全局个人指令与机器设置继续由各工具管理,不写入本仓库。
|
||||||
|
|
||||||
|
## 共用技能
|
||||||
|
|
||||||
|
保留现有 `.claude/skills/` 作为技能源目录,Codex 入口使用相对目录符号链接。Codex 官方支持扫描符号链接指向的技能目录,脚本及评估样例因此也共用一份:
|
||||||
|
|
||||||
|
```text
|
||||||
|
.claude/skills/
|
||||||
|
├── deploy/ # SKILL.md 与 scripts/deploy.sh
|
||||||
|
├── update-db/ # SKILL.md、scripts/api.sh 与 evals/
|
||||||
|
└── edge-node-multi-protocol/ # 节点部署脚本、参考资料与交付模板
|
||||||
|
.agents/skills/
|
||||||
|
├── deploy -> ../../.claude/skills/deploy
|
||||||
|
├── update-db -> ../../.claude/skills/update-db
|
||||||
|
└── edge-node-multi-protocol -> ../../.claude/skills/edge-node-multi-protocol
|
||||||
|
```
|
||||||
|
|
||||||
|
| 任务 | 技能 | Codex 显式调用 | Claude Code 显式调用 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 部署前端/后端到 LA | `deploy` | `$deploy` | `/deploy` |
|
||||||
|
| 线上数据盘点、导入预览或修改 | `update-db` | `$update-db` | `/update-db` |
|
||||||
|
| 边缘 VPS 部署、重建、协议维护与交付 | `edge-node-multi-protocol` | `$edge-node-multi-protocol` | `/edge-node-multi-protocol` |
|
||||||
|
|
||||||
|
自然语言请求也可以匹配技能。技能提供操作方法,具体外部操作范围仍以用户任务授权为准;阅读部署文档或修改代码不会自动触发上线。
|
||||||
|
|
||||||
|
脚本示例统一保留已有 `.claude/skills/` 路径,两种工具都可在仓库根目录执行。同一脚本也可从 `.agents/skills/` 链接路径执行。新增技能时只写一份源目录,并在 `.agents/skills/` 增加相对链接。
|
||||||
|
|
||||||
|
Git 应保存这些目录链接;在 Windows 上克隆时需要启用符号链接支持,推荐 WSL。若 checkout 将链接变成文本文件,Codex 无法自动发现技能,应恢复目录链接;不要复制出第二份技能。
|
||||||
|
|
||||||
|
## 维护与接续
|
||||||
|
|
||||||
|
- 架构或业务约束改动更新 `AGENTS.md`;较长的专题说明放入 `docs/` 并按任务引用。
|
||||||
|
- 运维流程改动更新 `.claude/skills/<name>/SKILL.md` 及其脚本;`.agents/skills/` 只维护链接。
|
||||||
|
- 节点与应用在本仓库统一维护,节点目录与当前舰队见 `edge-nodes/README.md`。通用工具可入库,真实配置/交付产物放 `edge-nodes/private/` 并用受控备份转交;原 `../edge-nodes/` 仅保留迁移前备份。旧工程指令保存在私有归档中,不作为当前约定。
|
||||||
|
- 普通开发从 `shared` 的类型/校验、`server` 的 API 与 IR、`web` 的状态与页面定位改动。新增客户端先扩展客户端类型与能力矩阵,再增加独立生成器和订阅端点。
|
||||||
|
- 修改规则模板只影响初始化,已有库的配置更新走管理 API;代码部署与数据更新分别执行。
|
||||||
|
- 本地验证按改动范围选择类型检查、单测或构建。临时启动与数据库验证在进程启动前设置临时 `DATA_DIR`,避免触碰真实节点数据。
|
||||||
|
- 交接说明记录已完成改动、验证结果与未完成项;可复用的认识写入项目文件,不依赖某个工具的私有会话记忆。
|
||||||
|
|
||||||
|
## 官方依据
|
||||||
|
|
||||||
|
- [Codex:AGENTS.md 加载规则](https://developers.openai.com/codex/guides/agents-md)
|
||||||
|
- [Codex:技能目录与符号链接支持](https://developers.openai.com/codex/skills)
|
||||||
|
- [Claude Code:AGENTS.md 支持与共用导入](https://code.claude.com/docs/en/memory#agentsmd)
|
||||||
|
- [Claude Code:项目技能](https://code.claude.com/docs/en/skills)
|
||||||
|
|
||||||
|
支持行为核对于 2026-10-03;若工具升级后入口行为变化,按上述官方文档重新核对。
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
服务器资源有限(1 核/1.9G),采用 Node 直跑 + systemd 守护,前端在本机构建后由服务端静态托管,反向代理与证书由 aaPanel 管理。
|
服务器资源有限(1 核/1.9G),采用 Node 直跑 + systemd 守护,前端在本机构建后由服务端静态托管,反向代理与证书由 aaPanel 管理。
|
||||||
|
|
||||||
|
本文描述 Proxy Station 应用发布。边缘 VPS 的部署、重建与协议维护已合入本仓库,见 [节点工作区](../edge-nodes/README.md) 与 `edge-node-multi-protocol` 技能;其私有资料不随应用部署上传。LA 同时承载节点与本应用,维护 nginx、证书或 WARP 时需核对共享服务影响。
|
||||||
|
|
||||||
## 拓扑
|
## 拓扑
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -22,7 +24,7 @@
|
|||||||
|
|
||||||
## 更新代码
|
## 更新代码
|
||||||
|
|
||||||
日常更新用项目级 Skill `deploy`(对 Claude 说「部署」即可触发),也可直接跑脚本:
|
日常更新用 Codex/Claude Code 共用的项目级技能 `deploy`(说「部署」即可匹配;也可分别用 `$deploy`/`/deploy` 显式调用),也可在仓库根目录直接跑脚本:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash .claude/skills/deploy/scripts/deploy.sh all # 前后端一起(默认)
|
bash .claude/skills/deploy/scripts/deploy.sh all # 前后端一起(默认)
|
||||||
@@ -47,7 +49,7 @@ ssh la 'systemctl stop proxy-station && install -m 644 -o proxy-station -g proxy
|
|||||||
## 反向代理注意点
|
## 反向代理注意点
|
||||||
|
|
||||||
- aaPanel 的反代模板不带 `X-Forwarded-Proto`/`X-Forwarded-Host`,已手工补进反代配置。服务端 `routes/sub.ts` 的 `baseUrl()` 依赖这两个头还原外部地址,缺失时 Surge 的 `#!MANAGED-CONFIG` 与 Clash 的 provider URL 会变成 `http://`。
|
- 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 已指向服务器,新子域无需单独加记录。
|
- 泛域名 `*.la.njcqtechaicoding.com` 的 DNS 已指向服务器,新子域无需单独加记录。
|
||||||
- acme.sh 的 `--reloadcmd` 要把新站点加进复制循环,且证书源路径必须写死(原先引用未定义的 `$SRC`,续期后不会重新部署证书)。
|
- acme.sh 的 `--reloadcmd` 要把新站点加进复制循环,且证书源路径必须写死(原先引用未定义的 `$SRC`,续期后不会重新部署证书)。
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,34 @@
|
|||||||
# 节点导入指引
|
# 节点导入指引
|
||||||
|
|
||||||
proxy-station 不直接读取 edge-nodes 工作区(那里全是真实凭据,保持解耦),节点通过 Web 界面批量粘贴分享链接导入。
|
节点部署管理已迁入本仓库的 `edge-nodes/`,真实配置与交付产物位于 Git 忽略的 `edge-nodes/private/`。Proxy Station 应用运行时不自动读取该目录,节点仍通过 Web 界面批量粘贴分享链接导入。工作区说明见 [edge-nodes/README.md](../edge-nodes/README.md)。
|
||||||
|
|
||||||
## 从 edge-nodes 交付文档导入
|
## 从本仓库私有交付文档导入
|
||||||
|
|
||||||
每个节点目录 `<id>-edge-node-docs/` 已生成好分享链接:
|
每个节点目录 `edge-nodes/private/<id>-edge-node-docs/` 已生成好分享链接;以当前部署的 `scripts/node-links/` 为准,避免同时混入旧交付链接与重复副本。RN 当前使用 `rn2-edge-node-docs/`,旧 `rn-edge-node-docs/` 只作历史记录。
|
||||||
|
|
||||||
- `<proto>-<egress>.link`——VMess/Trojan 的分享链接文件(`vmess://`、`trojan://`)
|
- `<proto>-<egress>.link`——VMess/Trojan/Shadowsocks 的分享链接文件(`vmess://`、`trojan://`、`ss://`);当前生成器会生成本系统可解析的 Xray 风格 SS over WS 链接
|
||||||
- Shadowsocks 线路没有通用分享链接,用 sing-box JSON(`ss-*-singbox.json`)里的参数手动添加,或按 SIP002 + v2ray-plugin 格式自拼:
|
- 旧交付若只有 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路径#名称`
|
`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「节点」页 →「批量导入」→ 粘贴(每行一条)→「解析预览」确认字段 →「导入」
|
2. 打开 proxy-station「节点」页 →「批量导入」→ 粘贴(每行一条)→「解析预览」确认字段 →「导入」
|
||||||
3. SOCKS5 线路为内部使用,不要导入本系统
|
3. TUIC/SOCKS5 线路不属于本系统当前支持的类型,单独使用,不导入。线上数据任务按 `update-db` 技能先备份再走管理 API
|
||||||
|
|
||||||
## 命名建议(影响地区组归类)
|
## 命名建议(影响地区组归类)
|
||||||
|
|
||||||
|
|||||||
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)。
|
||||||
|
|
||||||
|
实际订阅通过 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 路径更新生产规则。
|
||||||
33
docs/SURGE-CHANGES.md
Normal file
33
docs/SURGE-CHANGES.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Surge 订阅变更与验证
|
||||||
|
|
||||||
|
2026-10-03 的缺陷与证据见 [体检报告](SURGE-AUDIT-2026-10-03.md)。后续节点、组、规则和设置变更使用本文件的检查;具体 API 与生产备份操作见 update-db 技能。
|
||||||
|
|
||||||
|
## 写入前
|
||||||
|
|
||||||
|
- 获取最新全量节点、组、规则和设置,明确改动对象及受影响订阅范围。保留策略选择和出口语义;名称正则、节点范围、客户端排除不能只按数据库行数检查。
|
||||||
|
- 节点/组名称不能包含配置分隔符、控制字符或与内置策略及另一类实体冲突。导入预览先验证字段;组更新替换完整 members,校验存在性、成员类型及完整图无环。
|
||||||
|
- 保住唯一启用、末尾 FINAL;完整排序,不通过停用/删除 FINAL 处理兜底。不用 reset 调整生产;reset 的真实含义是事务内恢复模板并保留自定义规则。
|
||||||
|
- 用官方规则顺序检查业务例外与广告/服务规则集的遮蔽,协议规则不放在提前覆盖其目标的域名规则后。IP 黑名单的 `no-resolve` 会改变未解析目标的拦截覆盖,要记录取舍。
|
||||||
|
- DNS 对照连接规则、手写 Host、加密 DNS 出口和 FINAL 的 dns-failed 行为。Host 不能表达进程/协议/IP 路由,远端代理解析和浏览器自带 DoH 也不是同一条路径。
|
||||||
|
- Surge 测速用节点 `test-url` 或 General 的 `proxy-test-url`,不用组级 `url=`。共享订阅不传播默认控制口令,MITM 保留上游证书验证。
|
||||||
|
|
||||||
|
## 本地与生产验证
|
||||||
|
|
||||||
|
代码修改运行相关单测与类型检查;共享字段或 IR 修改运行全量类型检查及单测,页面修改再构建。数据库/管理 API 测试必须在导入 db 模块之前设置绝对临时 DATA_DIR。
|
||||||
|
|
||||||
|
将候选配置存到私有临时目录,文件权限 600,然后运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/check-surge.py /private/tmp/<profile>.conf
|
||||||
|
python3 scripts/check-surge.py /private/tmp/<profile>.conf --fetch-rulesets
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本先调用本机官方 `surge-cli --check`,再检查 FINAL、无效组级 url、公开控制口令和 MITM 参数。第二条下载当前规则集,以临时本地快照再次调用官方 CLI,并提示更早不同策略对后面域名规则样例的覆盖。提示需要结合业务审阅,不能自动将所有广告域名放行。它不模拟 IP/协议/进程或客户端缓存,规则集非法行也可能被 Surge 跳过;有疑点时查看设备资源加载警告及请求匹配日志。
|
||||||
|
|
||||||
|
生产变更先在线备份,经管理 API 逐条核对状态,失败停止;在新代码防护部署后再写需要新防护的数据。写入后重读变更对象、核对数量与完整顺序,从实际 HTTPS 入口拉订阅并核对 MANAGED-CONFIG 的 self URL,然后重复以上校验。通过回环 API 拉的订阅可能带内网 self URL,不能拿它替代实际 HTTPS 验收。配置中的 token、密码、UUID、Host IP 不落仓库或共享日志。
|
||||||
|
|
||||||
|
Clash 受影响时,用独立临时目录运行 `mihomo -t -d <临时目录> -f <配置>`;不要覆盖正在使用的配置或启动代理服务。没有客户端 CLI 的产物用对应单测和结构检查,明确尚缺的真机验收。
|
||||||
|
|
||||||
|
不主动切换活动 Surge 配置、修改保存的组选择或导入 CA。CLI 成功只证明解析;节点连接、测速结果、DNS/WebRTC 和业务连接仍需设备侧验证。
|
||||||
|
|
||||||
|
依据:[匹配与 DNS](https://manual.nssurge.com/rules/overview.html)、[FINAL](https://manual.nssurge.com/rules/final.html)、[成员导入](https://manual.nssurge.com/policy-groups/policy-including.html)、[测速](https://manual.nssurge.com/policy-groups/url-test.html)、[Host](https://manual.nssurge.com/dns/local-dns-mapping.html)、[引用格式](https://manual.nssurge.com/profile/format.html)、[CLI](https://manual.nssurge.com/tools/cli.html)。
|
||||||
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) 中已确认缺陷的代码修复和生产定向调整。原体检报告保留为修复前证据,后续变更流程见 [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
|
||||||
|
}
|
||||||
@@ -12,13 +12,13 @@ DOMAIN,doh.pub,DIRECT
|
|||||||
DOMAIN,dns.alidns.com,DIRECT
|
DOMAIN,dns.alidns.com,DIRECT
|
||||||
```
|
```
|
||||||
|
|
||||||
AI Host 映射使用 `server:https://1.1.1.1/dns-query`,放在普通 Host 映射之前。选择其他 AI 线路后 Cloudflare 查询也跟随同一策略组。该 IP 规则也会将访问 1.1.1.1 的其他连接交给 AI 组。
|
AI Host 映射使用 `server:https://1.1.1.1/dns-query`。手写 Host 项在前,保留精确 DNS 例外;随后按连接规则顺序生成至最后一条 AI 域名规则的映射,先前非 AI 域名/规则集使用 General 的普通 DNS。这样 Unbreak 等先前例外不会被后面的宽泛 AI 映射覆盖。选择其他 AI 线路后 Cloudflare 查询也跟随同一策略组。该 IP 规则也会将访问 1.1.1.1 的其他连接交给 AI 组。
|
||||||
|
|
||||||
本功能控制 Surge 自己执行的 DNS 查询。代理目标域名可能由远端代理解析;官方说明 use-local-host-item-for-proxy 仅适用于 IP 映射,不适用于 server: 映射,因此不能声称远端代理的解析器被改为 Cloudflare。浏览器自带 DoH 也不自动按 AI 域名匹配这些 Host 项。
|
本功能控制 Surge 自己执行的 DNS 查询。代理目标域名可能由远端代理解析;官方说明 use-local-host-item-for-proxy 仅适用于 IP 映射,不适用于 server: 映射,因此不能声称远端代理的解析器被改为 Cloudflare。浏览器自带 DoH 也不自动按 AI 域名匹配这些 Host 项。
|
||||||
|
|
||||||
代理服务器域名的解析可能触发加密 DNS 循环依赖,需核对 Surge 日志。线上保留已验证的节点 Host IP 映射供引导使用;节点 IP 变化后更新映射,私有节点 IP 不提交仓库。
|
代理服务器域名的解析可能触发加密 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 请求日志;生成配置验证不是设备泄漏测试。
|
导入时关联 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` 技能。
|
||||||
80
edge-nodes/README.md
Normal file
80
edge-nodes/README.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# 边缘节点工作区
|
||||||
|
|
||||||
|
节点部署管理与 Proxy Station 开发现在统一在本仓库进行。通用工具可随 Git 维护,真实配置与交付产物保存在本机的 `private/` 中。
|
||||||
|
|
||||||
|
```text
|
||||||
|
edge-nodes/
|
||||||
|
├── build-delivery-doc.py # 离线汇总页生成器,不含站点凭据
|
||||||
|
├── fleet.example.json # 占位清单,供新工作区参考
|
||||||
|
└── private/ # Git 忽略,目录权限 700
|
||||||
|
├── fleet.json # 当前舰队清单,含凭据,权限 600
|
||||||
|
├── sg-edge-node-docs/
|
||||||
|
├── la-edge-node-docs/
|
||||||
|
├── rn2-edge-node-docs/ # RN 当前部署(2026-08-01 重建)
|
||||||
|
├── jp-edge-node-docs/
|
||||||
|
├── rn-edge-node-docs/ # RN 历史交付
|
||||||
|
├── 边缘节点交付文档.html # 内嵌链接与二维码的私有离线页
|
||||||
|
└── archive/ # 原 CLAUDE.md、生成器与 .skill 包的迁移快照
|
||||||
|
.claude/skills/edge-node-multi-protocol/
|
||||||
|
├── SKILL.md
|
||||||
|
├── scripts/ # 通用 Bash/Python 部署、检测与链接生成工具
|
||||||
|
├── references/ # 部署流程、版本兼容、证书、DNS、3x-ui 结构
|
||||||
|
└── templates/ # 未填入真实值的交付模板与门面页
|
||||||
|
.agents/skills/edge-node-multi-protocol -> ../../.claude/skills/edge-node-multi-protocol
|
||||||
|
```
|
||||||
|
|
||||||
|
## 当前舰队
|
||||||
|
|
||||||
|
| 站点 | 服务商/地区 | 私有工作目录 |
|
||||||
|
|---|---|---|
|
||||||
|
| sg | AWS/新加坡 | `private/sg-edge-node-docs/` |
|
||||||
|
| la | DMIT/洛杉矶,承载 Proxy Station 生产服务 | `private/la-edge-node-docs/` |
|
||||||
|
| rn | RackNerd/洛杉矶 | `private/rn2-edge-node-docs/` |
|
||||||
|
| jp | VMISS/东京,裸 nginx | `private/jp-edge-node-docs/` |
|
||||||
|
|
||||||
|
站点信息是迁移时的工作记录;维护前仍需读取私有交付文档并核对实际机器。`rn-edge-node-docs/` 的旧链接不作为当前 RN 节点导入来源。
|
||||||
|
|
||||||
|
## 日常工作
|
||||||
|
|
||||||
|
在仓库根目录启动 Codex/Claude Code。节点部署或维护使用 `edge-node-multi-protocol` 技能,应用发布使用 `deploy`,线上订阅数据使用 `update-db`。技能只维护一份源码,Codex 通过 `.agents/skills/` 相对链接发现。
|
||||||
|
|
||||||
|
已有节点使用其私有 `scripts/` 工作副本,以保留站点适配;修改通用能力时同步整理到技能源目录,按目标节点审阅后更新工作副本。原始副本不会在代码更新时自动覆盖。
|
||||||
|
|
||||||
|
新增节点可在仓库根目录准备私有目录:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
umask 077
|
||||||
|
mkdir -p edge-nodes/private/<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
|
udp-priority = false
|
||||||
# Latency Benchmark / 延迟基准测试
|
# Latency Benchmark / 延迟基准测试
|
||||||
internet-test-url = http://connectivitycheck.gstatic.com/generate_204
|
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
|
test-timeout = 3
|
||||||
# GeoIP Database / GeoIP 数据库
|
# GeoIP Database / GeoIP 数据库
|
||||||
geoip-maxmind-url = https://github.com/Hackl0us/GeoIP2-CN/raw/release/Country.mmdb
|
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
|
socks5-listen = 0.0.0.0
|
||||||
allow-hotspot-access = true
|
allow-hotspot-access = true
|
||||||
# --- REMOTE CONTROLLER / 远程控制器 ---
|
# --- 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 / 兼容性 ---
|
||||||
compatibility-mode = 1
|
compatibility-mode = 1
|
||||||
# --- NETWORK / 网络 ---
|
# --- NETWORK / 网络 ---
|
||||||
@@ -47,7 +43,7 @@ force-http-engine-hosts = *.ott.cibntv.net, 123.59.31.1, 119.18.193.135, 122.14.
|
|||||||
always-real-ip = link-ip.nextdns.io, *.msftconnecttest.com, *.msftncsi.com, *.srv.nintendo.net, *.stun.playstation.net, xbox.*.microsoft.com, *.xboxlive.com, *.logon.battlenet.com.cn, *.logon.battle.net, stun.l.google.com
|
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
|
hijack-dns = 8.8.8.8:53, 8.8.4.4:53
|
||||||
udp-policy-not-supported-behaviour = REJECT
|
udp-policy-not-supported-behaviour = REJECT
|
||||||
proxy-restricted-to-lan = false
|
proxy-restricted-to-lan = true
|
||||||
|
|
||||||
[Proxy]
|
[Proxy]
|
||||||
|
|
||||||
@@ -113,6 +109,7 @@ Speedtest = select, Mainland, Automatic, AllServer, no-alert=0, hidden=0, includ
|
|||||||
[Rule]
|
[Rule]
|
||||||
# 阻断 STUN,避免未匹配请求落到直连兜底
|
# 阻断 STUN,避免未匹配请求落到直连兜底
|
||||||
PROTOCOL,STUN,REJECT
|
PROTOCOL,STUN,REJECT
|
||||||
|
AND,((PROTOCOL,UDP), (DOMAIN-SUFFIX,googlevideo.com)),REJECT-NO-DROP
|
||||||
|
|
||||||
# >> 规则修正 (Unbreak Rules)
|
# >> 规则修正 (Unbreak Rules)
|
||||||
RULE-SET,https://raw.githubusercontent.com/zxfccmm4/Profiles/refs/heads/main/Surge/Ruleset/Unbreak.list,DIRECT
|
RULE-SET,https://raw.githubusercontent.com/zxfccmm4/Profiles/refs/heads/main/Surge/Ruleset/Unbreak.list,DIRECT
|
||||||
@@ -121,7 +118,7 @@ RULE-SET,https://raw.githubusercontent.com/zxfccmm4/Profiles/refs/heads/main/Sur
|
|||||||
DOMAIN-SET,https://ruleset.skk.moe/List/domainset/reject.conf,REJECT
|
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-drop.conf,REJECT-DROP
|
||||||
RULE-SET,https://ruleset.skk.moe/List/non_ip/reject.conf,REJECT
|
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)
|
# >> 隐私保护 (Privacy Protection)
|
||||||
DOMAIN,app-site-association.cdn-apple.com,REJECT
|
DOMAIN,app-site-association.cdn-apple.com,REJECT
|
||||||
@@ -222,7 +219,6 @@ RULE-SET,https://ruleset.skk.moe/List/non_ip/global_plus.conf,Automatic
|
|||||||
RULE-SET,https://ruleset.skk.moe/List/non_ip/global.conf,Automatic
|
RULE-SET,https://ruleset.skk.moe/List/non_ip/global.conf,Automatic
|
||||||
|
|
||||||
# >> 网络优化与特殊规则 (Network Optimization)
|
# >> 网络优化与特殊规则 (Network Optimization)
|
||||||
AND,((PROTOCOL,UDP), (DOMAIN-SUFFIX,googlevideo.com)),REJECT-NO-DROP
|
|
||||||
DOMAIN-SUFFIX,smtp,DIRECT
|
DOMAIN-SUFFIX,smtp,DIRECT
|
||||||
URL-REGEX,(Subject|HELO|SMTP),DIRECT
|
URL-REGEX,(Subject|HELO|SMTP),DIRECT
|
||||||
|
|
||||||
@@ -298,7 +294,7 @@ http-request ^https:\/\/github\.com\/ header-replace Accept-Language en-us
|
|||||||
http-request ^https:\/\/(raw|gist)\.githubusercontent\.com\/ header-replace Accept-Language en-us
|
http-request ^https:\/\/(raw|gist)\.githubusercontent\.com\/ header-replace Accept-Language en-us
|
||||||
|
|
||||||
[MITM]
|
[MITM]
|
||||||
skip-server-cert-verify = true
|
skip-server-cert-verify = false
|
||||||
tcp-connection = true
|
tcp-connection = true
|
||||||
h2 = true
|
h2 = true
|
||||||
hostname = www.google.cn, api.abema.io, *.zhihu.com, -CUSTOMMitM, sub.store
|
hostname = www.google.cn, api.abema.io, *.zhihu.com, -CUSTOMMitM, sub.store
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ function seedGroups(db: Db, groups: ParsedGroup[], source: 'template' | 'custom'
|
|||||||
export function seedRulesFromTemplate(db: Db, opts: { keepCustom: boolean }) {
|
export function seedRulesFromTemplate(db: Db, opts: { keepCustom: boolean }) {
|
||||||
const text = fs.readFileSync(TEMPLATE_PATH, 'utf8');
|
const text = fs.readFileSync(TEMPLATE_PATH, 'utf8');
|
||||||
const parsed = parseSurgeConf(text);
|
const parsed = parseSurgeConf(text);
|
||||||
|
db.transaction(() => {
|
||||||
const custom = opts.keepCustom
|
const custom = opts.keepCustom
|
||||||
? db.select().from(rules).where(eq(rules.source, 'custom')).all()
|
? db.select().from(rules).where(eq(rules.source, 'custom')).all()
|
||||||
: [];
|
: [];
|
||||||
@@ -60,12 +60,11 @@ export function seedRulesFromTemplate(db: Db, opts: { keepCustom: boolean }) {
|
|||||||
|
|
||||||
let seq = 0;
|
let seq = 0;
|
||||||
// 规则首条匹配即生效:自定义规则保持原相对顺序,整体排在全部模板规则之前
|
// 规则首条匹配即生效:自定义规则保持原相对顺序,整体排在全部模板规则之前
|
||||||
for (const c of custom.sort((a, b) => a.seq - b.seq)) {
|
for (const c of custom.filter((r) => r.type !== 'FINAL').sort((a, b) => a.seq - b.seq)) {
|
||||||
db.insert(rules)
|
db.update(rules).set({ seq: seq++ }).where(eq(rules.id, c.id)).run();
|
||||||
.values({ ...c, seq: seq++ })
|
|
||||||
.run();
|
|
||||||
}
|
}
|
||||||
for (const r of parsed.rules) {
|
const customFinal = custom.find((r) => r.type === 'FINAL');
|
||||||
|
for (const r of parsed.rules.filter((r) => !customFinal || r.type !== 'FINAL')) {
|
||||||
let value = r.value;
|
let value = r.value;
|
||||||
if ((r.type === 'RULE-SET' || r.type === 'DOMAIN-SET') && value && /^https?:\/\//.test(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();
|
const existing = db.select().from(rulesets).where(eq(rulesets.surgeUrl, value)).get();
|
||||||
@@ -101,6 +100,13 @@ export function seedRulesFromTemplate(db: Db, opts: { keepCustom: boolean }) {
|
|||||||
})
|
})
|
||||||
.run();
|
.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) {
|
export function seedIfEmpty(db: Db) {
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ import { nanoid } from 'nanoid';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { groupInputSchema, type GroupDto } from '@proxy-station/shared';
|
import { groupInputSchema, type GroupDto } from '@proxy-station/shared';
|
||||||
import { db } from '../db/index.js';
|
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[] {
|
function loadGroups(): GroupDto[] {
|
||||||
const groups = db.select().from(policyGroups).orderBy(asc(policyGroups.sortOrder)).all();
|
const groups = db.select().from(policyGroups).orderBy(asc(policyGroups.sortOrder)).all();
|
||||||
@@ -37,8 +42,11 @@ export const groupsRoute = new Hono()
|
|||||||
.get('/', (c) => c.json(loadGroups()))
|
.get('/', (c) => c.json(loadGroups()))
|
||||||
.post('/', zValidator('json', groupInputSchema), (c) => {
|
.post('/', zValidator('json', groupInputSchema), (c) => {
|
||||||
const input = c.req.valid('json');
|
const input = c.req.valid('json');
|
||||||
|
const error = validate(input);
|
||||||
|
if (error) return c.json({ error }, error === '策略组名称已存在' ? 409 : 400);
|
||||||
const id = nanoid(10);
|
const id = nanoid(10);
|
||||||
try {
|
try {
|
||||||
|
db.transaction(() => {
|
||||||
db.insert(policyGroups)
|
db.insert(policyGroups)
|
||||||
.values({
|
.values({
|
||||||
id,
|
id,
|
||||||
@@ -53,11 +61,12 @@ export const groupsRoute = new Hono()
|
|||||||
source: 'custom',
|
source: 'custom',
|
||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
|
replaceMembers(id, input.members);
|
||||||
|
});
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (String(e.message).includes('UNIQUE')) return c.json({ error: `策略组「${input.name}」已存在` }, 409);
|
if (String(e.message).includes('UNIQUE')) return c.json({ error: `策略组「${input.name}」已存在` }, 409);
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
replaceMembers(id, input.members);
|
|
||||||
return c.json({ id }, 201);
|
return c.json({ id }, 201);
|
||||||
})
|
})
|
||||||
.put('/reorder', zValidator('json', z.object({ ids: z.array(z.string()) })), (c) => {
|
.put('/reorder', zValidator('json', z.object({ ids: z.array(z.string()) })), (c) => {
|
||||||
@@ -79,7 +88,10 @@ export const groupsRoute = new Hono()
|
|||||||
const existing = db.select().from(policyGroups).where(eq(policyGroups.id, id)).get();
|
const existing = db.select().from(policyGroups).where(eq(policyGroups.id, id)).get();
|
||||||
if (!existing) return c.json({ error: '策略组不存在' }, 404);
|
if (!existing) return c.json({ error: '策略组不存在' }, 404);
|
||||||
const input = c.req.valid('json');
|
const input = c.req.valid('json');
|
||||||
|
const error = validate(input, id);
|
||||||
|
if (error) return c.json({ error }, error === '策略组名称已存在' ? 409 : 400);
|
||||||
const oldName = existing.name;
|
const oldName = existing.name;
|
||||||
|
db.transaction(() => {
|
||||||
db.update(policyGroups)
|
db.update(policyGroups)
|
||||||
.set({
|
.set({
|
||||||
name: input.name,
|
name: input.name,
|
||||||
@@ -92,12 +104,13 @@ export const groupsRoute = new Hono()
|
|||||||
})
|
})
|
||||||
.where(eq(policyGroups.id, id))
|
.where(eq(policyGroups.id, id))
|
||||||
.run();
|
.run();
|
||||||
replaceMembers(id, input.members);
|
replaceMembers(id, input.members.map((m) => m.kind === 'group' && m.refName === oldName ? { ...m, refName: input.name } : m));
|
||||||
// 组名变更时同步引用它的成员与规则策略
|
// 组名变更时同步引用它的成员与规则策略
|
||||||
if (oldName !== input.name) {
|
if (oldName !== input.name) {
|
||||||
db.update(groupMembers).set({ refName: input.name }).where(eq(groupMembers.refName, oldName)).run();
|
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.update(rules).set({ policy: input.name }).where(eq(rules.policy, oldName)).run();
|
||||||
}
|
}
|
||||||
|
});
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
})
|
})
|
||||||
.delete('/:id', (c) => {
|
.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 { asc, eq } from 'drizzle-orm';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import { z } from 'zod';
|
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 { 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';
|
import { encodeShareLink, parseShareLinkBatch } from '../services/sharelink/parse.js';
|
||||||
|
|
||||||
type NodeRow = typeof nodes.$inferSelect;
|
type NodeRow = typeof nodes.$inferSelect;
|
||||||
@@ -58,6 +62,7 @@ export const nodesRoute = new Hono()
|
|||||||
})
|
})
|
||||||
.post('/', zValidator('json', nodeInputSchema), (c) => {
|
.post('/', zValidator('json', nodeInputSchema), (c) => {
|
||||||
const input = c.req.valid('json');
|
const input = c.req.valid('json');
|
||||||
|
if (reservedName(input.name)) return c.json({ error: '节点名称与策略组或内置策略冲突' }, 400);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const row = { id: nanoid(10), ...dtoToRowPatch(input), extra: '{}', createdAt: now, updatedAt: now };
|
const row = { id: nanoid(10), ...dtoToRowPatch(input), extra: '{}', createdAt: now, updatedAt: now };
|
||||||
try {
|
try {
|
||||||
@@ -76,18 +81,20 @@ export const nodesRoute = new Hono()
|
|||||||
const parsed = parseShareLinkBatch(text);
|
const parsed = parseShareLinkBatch(text);
|
||||||
const taken = new Set(db.select({ name: nodes.name }).from(nodes).all().map((r) => r.name));
|
const taken = new Set(db.select({ name: nodes.name }).from(nodes).all().map((r) => r.name));
|
||||||
const now = Date.now();
|
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 };
|
if (!item.node) return { ok: false as const, uri: item.uri, error: item.error };
|
||||||
const name = uniqueName(item.node.name, taken);
|
const name = uniqueName(item.node.name, taken);
|
||||||
taken.add(name);
|
taken.add(name);
|
||||||
const node = { ...item.node, 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) {
|
if (commit) {
|
||||||
const validated = nodeInputSchema.parse(node);
|
const validated = validation.data;
|
||||||
const row = { id: nanoid(10), ...dtoToRowPatch(validated), extra: '{}', createdAt: now, updatedAt: now };
|
const row = { id: nanoid(10), ...dtoToRowPatch(validated), extra: '{}', createdAt: now, updatedAt: now };
|
||||||
db.insert(nodes).values(row).run();
|
db.insert(nodes).values(row).run();
|
||||||
}
|
}
|
||||||
return { ok: true as const, uri: item.uri, node };
|
return { ok: true as const, uri: item.uri, node };
|
||||||
});
|
}));
|
||||||
return c.json({
|
return c.json({
|
||||||
committed: commit,
|
committed: commit,
|
||||||
total: results.length,
|
total: results.length,
|
||||||
@@ -107,6 +114,7 @@ export const nodesRoute = new Hono()
|
|||||||
const existing = db.select().from(nodes).where(eq(nodes.id, id)).get();
|
const existing = db.select().from(nodes).where(eq(nodes.id, id)).get();
|
||||||
if (!existing) return c.json({ error: '节点不存在' }, 404);
|
if (!existing) return c.json({ error: '节点不存在' }, 404);
|
||||||
const input = c.req.valid('json');
|
const input = c.req.valid('json');
|
||||||
|
if (reservedName(input.name)) return c.json({ error: '节点名称与策略组或内置策略冲突' }, 400);
|
||||||
try {
|
try {
|
||||||
db.update(nodes)
|
db.update(nodes)
|
||||||
.set({ ...dtoToRowPatch(input), updatedAt: Date.now() })
|
.set({ ...dtoToRowPatch(input), updatedAt: Date.now() })
|
||||||
|
|||||||
@@ -25,23 +25,33 @@ function loadRules(): RuleDto[] {
|
|||||||
/** 引用不存在的策略组会被 IR 静默剔除,必须在写入前拦下 */
|
/** 引用不存在的策略组会被 IR 静默剔除,必须在写入前拦下 */
|
||||||
function policyError(policy: string): string | null {
|
function policyError(policy: string): string | null {
|
||||||
if ((BUILTIN_POLICIES as readonly string[]).includes(policy)) return 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();
|
const group = db.select().from(policyGroups).where(eq(policyGroups.name, policy)).get();
|
||||||
return exists ? null : `策略「${policy}」不存在:请选择内置策略或已有策略组`;
|
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()
|
export const rulesRoute = new Hono()
|
||||||
.get('/', (c) => c.json(loadRules()))
|
.get('/', (c) => c.json(loadRules()))
|
||||||
.post('/', zValidator('json', ruleInputSchema), (c) => {
|
.post('/', zValidator('json', ruleInputSchema), (c) => {
|
||||||
const input = c.req.valid('json');
|
const input = c.req.valid('json');
|
||||||
const err = policyError(input.policy);
|
const err = inputError(input);
|
||||||
if (err) return c.json({ error: err }, 400);
|
if (err) return c.json({ error: err }, 400);
|
||||||
// 规则首条匹配即生效,自定义规则必须压过模板里的宽泛规则集(如 download.conf),插到最前
|
// 规则首条匹配即生效,自定义规则必须压过模板里的宽泛规则集(如 download.conf),插到最前
|
||||||
db.update(rules).set({ seq: sql`${rules.seq} + 1` }).run();
|
|
||||||
const id = nanoid(10);
|
const id = nanoid(10);
|
||||||
|
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)
|
db.insert(rules)
|
||||||
.values({
|
.values({
|
||||||
id,
|
id,
|
||||||
seq: 0,
|
seq,
|
||||||
type: input.type,
|
type: input.type,
|
||||||
value: input.value ?? null,
|
value: input.value ?? null,
|
||||||
policy: input.policy,
|
policy: input.policy,
|
||||||
@@ -50,11 +60,17 @@ export const rulesRoute = new Hono()
|
|||||||
source: 'custom',
|
source: 'custom',
|
||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
|
});
|
||||||
return c.json({ id }, 201);
|
return c.json({ id }, 201);
|
||||||
})
|
})
|
||||||
.put('/reorder', zValidator('json', z.object({ ids: z.array(z.string()) })), (c) => {
|
.put('/reorder', zValidator('json', z.object({ ids: z.array(z.string()) })), (c) => {
|
||||||
const { ids } = c.req.valid('json');
|
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 });
|
return c.json({ ok: true });
|
||||||
})
|
})
|
||||||
.post('/reset', (c) => {
|
.post('/reset', (c) => {
|
||||||
@@ -66,7 +82,9 @@ export const rulesRoute = new Hono()
|
|||||||
const existing = db.select().from(rules).where(eq(rules.id, id)).get();
|
const existing = db.select().from(rules).where(eq(rules.id, id)).get();
|
||||||
if (!existing) return c.json({ error: '规则不存在' }, 404);
|
if (!existing) return c.json({ error: '规则不存在' }, 404);
|
||||||
const input = c.req.valid('json');
|
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);
|
if (err) return c.json({ error: err }, 400);
|
||||||
db.update(rules)
|
db.update(rules)
|
||||||
.set({
|
.set({
|
||||||
@@ -81,6 +99,9 @@ export const rulesRoute = new Hono()
|
|||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
})
|
})
|
||||||
.patch('/:id/enabled', zValidator('json', z.object({ enabled: z.boolean() })), (c) => {
|
.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
|
const res = db
|
||||||
.update(rules)
|
.update(rules)
|
||||||
.set({ enabled: c.req.valid('json').enabled })
|
.set({ enabled: c.req.valid('json').enabled })
|
||||||
@@ -90,6 +111,9 @@ export const rulesRoute = new Hono()
|
|||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
})
|
})
|
||||||
.delete('/:id', (c) => {
|
.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();
|
const res = db.delete(rules).where(eq(rules.id, c.req.param('id'))).run();
|
||||||
if (res.changes === 0) return c.json({ error: '规则不存在' }, 404);
|
if (res.changes === 0) return c.json({ error: '规则不存在' }, 404);
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
|
|||||||
@@ -2,9 +2,19 @@ import { Hono } from 'hono';
|
|||||||
import { zValidator } from '@hono/zod-validator';
|
import { zValidator } from '@hono/zod-validator';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { getSetting, setSetting } from '../services/ir.js';
|
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'];
|
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()
|
export const settingsRoute = new Hono()
|
||||||
.get('/', (c) => {
|
.get('/', (c) => {
|
||||||
const surgeSections: Record<string, string> = {};
|
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) => {
|
.put('/', zValidator('json', z.object({ surgeSections: z.record(z.string()).optional() })), (c) => {
|
||||||
const input = c.req.valid('json');
|
const input = c.req.valid('json');
|
||||||
if (input.surgeSections) {
|
if (input.surgeSections) {
|
||||||
for (const [key, value] of Object.entries(input.surgeSections)) {
|
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);
|
if (SURGE_SECTION_KEYS.includes(key)) setSetting(`surge:${key}`, value);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -48,4 +48,14 @@ describe('AI-only Surge DNS', () => {
|
|||||||
expect(dns.host.match(/new\.example =/g)).toHaveLength(1);
|
expect(dns.host.match(/new\.example =/g)).toHaveLength(1);
|
||||||
expect(dns.host).not.toContain('old.example');
|
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 type { ProfileIR } from '../ir.js';
|
||||||
|
import { surgeValue } from './surge-format.js';
|
||||||
|
|
||||||
const AI_DNS = 'https://1.1.1.1/dns-query';
|
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));
|
const ai = ir.groups.find((g) => /^[^\p{L}\p{N}]*AI$/u.test(g.name));
|
||||||
if (!ai) return { general, host, rules: [] };
|
if (!ai) return { general, host, rules: [] };
|
||||||
|
|
||||||
const mappings = new Set<string>();
|
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);
|
||||||
for (const rule of ir.rules) {
|
if (!aiRules.length) return { general, host, rules: [] };
|
||||||
if (rule.policy !== ai.name || !rule.value) continue;
|
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 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) {
|
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.
|
// *.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-SUFFIX': add(value); add(`*.${value}`); break;
|
||||||
case 'DOMAIN-KEYWORD': mappings.add(`*${value}*`); break;
|
case 'DOMAIN-KEYWORD': add(`*${value}*`); break;
|
||||||
case 'RULE-SET':
|
case 'RULE-SET':
|
||||||
case 'DOMAIN-SET':
|
case 'DOMAIN-SET':
|
||||||
if (/^https?:\/\//.test(value)) mappings.add(`${rule.type}:${value}`);
|
if (/^https?:\/\//.test(value)) add(`${rule.type}:${value}`);
|
||||||
break;
|
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)
|
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.replace(/^encrypted-dns-follow-outbound-mode\s*=.*$/gmi, 'encrypted-dns-follow-outbound-mode = true')
|
||||||
: `${general}\nencrypted-dns-follow-outbound-mode = true`;
|
: `${general}\nencrypted-dns-follow-outbound-mode = true`;
|
||||||
const generatedHost = [...mappings].map((pattern) => `${pattern} = server:${AI_DNS}`).join('\n');
|
const generatedHost = [...mappings].map(([pattern, resolver]) => `${pattern} = server:${resolver}`).join('\n');
|
||||||
const name = /[\s,]/.test(ai.name) ? `"${ai.name}"` : ai.name;
|
const name = surgeValue(ai.name);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
general: updatedGeneral,
|
general: updatedGeneral,
|
||||||
// AI mappings take precedence over the template's ordinary per-domain DNS mappings.
|
// Deliberate Host overrides (including precise DNS exceptions) stay first.
|
||||||
host: `# AI DNS: generated from enabled AI routing rules\n${generatedHost}\n${host}`.trim(),
|
host: `${host}\n# DNS: domain rule order through the last AI rule\n${generatedHost}`.trim(),
|
||||||
rules: [
|
rules: [
|
||||||
`IP-CIDR,1.1.1.1/32,${name},no-resolve`,
|
`IP-CIDR,1.1.1.1/32,${name},no-resolve`,
|
||||||
// Keep the upstream template's default DoH on the local path.
|
// 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 { clientSupports, normalizeSsCipher, type ExcludedNode, type NodeDto } from '@proxy-station/shared';
|
||||||
import type { ProfileIR } from '../ir.js';
|
import type { ProfileIR } from '../ir.js';
|
||||||
import { surgeAiDns } from './surge-ai-dns.js';
|
import { surgeAiDns } from './surge-ai-dns.js';
|
||||||
|
import { surgeDeclaration, surgeParam, surgeValue as quoteName } from './surge-format.js';
|
||||||
/** 成员名含空格/逗号时按 Surge 习惯加引号 */
|
|
||||||
function quoteName(name: string): string {
|
|
||||||
return /[\s,]/.test(name) ? `"${name}"` : name;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function surgeNodeLine(n: NodeDto): string {
|
export function surgeNodeLine(n: NodeDto): string {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
switch (n.protocol) {
|
switch (n.protocol) {
|
||||||
case 'vmess': {
|
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') {
|
if (n.network === 'ws') {
|
||||||
parts.push('ws=true', `ws-path=${n.wsPath}`);
|
parts.push('ws=true', `ws-path=${quoteName(n.wsPath || '/')}`);
|
||||||
if (n.wsHost) parts.push(`ws-headers=Host:"${n.wsHost}"`);
|
if (n.wsHost) parts.push(`ws-headers=${quoteName(`Host:${n.wsHost}`)}`);
|
||||||
}
|
}
|
||||||
if (n.tls) {
|
if (n.tls) {
|
||||||
parts.push('tls=true');
|
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 连不上且症状隐晦
|
// alterId=0 的 AEAD 头必须显式打开,否则 Surge 连不上且症状隐晦
|
||||||
parts.push('vmess-aead=true');
|
parts.push('vmess-aead=true');
|
||||||
|
if (n.security !== 'auto') parts.push(`encrypt-method=${normalizeSsCipher(n.security)}`);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'trojan': {
|
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') {
|
if (n.network === 'ws') {
|
||||||
parts.push('ws=true', `ws-path=${n.wsPath}`);
|
parts.push('ws=true', `ws-path=${quoteName(n.wsPath || '/')}`);
|
||||||
if (n.wsHost) parts.push(`ws-headers=Host:"${n.wsHost}"`);
|
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');
|
if (n.skipCertVerify) parts.push('skip-cert-verify=true');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'shadowsocks': {
|
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;
|
break;
|
||||||
}
|
}
|
||||||
case 'hysteria2': {
|
case 'hysteria2': {
|
||||||
parts.push('hysteria2', n.server, String(n.port), `password=${n.password}`);
|
parts.push('hysteria2', quoteName(n.server), String(n.port), `password=${quoteName(n.password || '')}`);
|
||||||
if (n.sni) parts.push(`sni=${n.sni}`);
|
if (n.sni) parts.push(`sni=${quoteName(n.sni)}`);
|
||||||
// Surge 的 salamander 混淆参数名(sub-router 生产验证过)
|
// 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.downMbps) parts.push(`download-bandwidth=${n.downMbps}`);
|
||||||
if (n.skipCertVerify) parts.push('skip-cert-verify=true');
|
if (n.skipCertVerify) parts.push('skip-cert-verify=true');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return `${n.name} = ${parts.join(', ')}`;
|
return `${surgeDeclaration(n.name)} = ${parts.join(', ')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SurgeOutput {
|
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))
|
.filter((m) => m.kind !== 'node' || usableNames.has(m.name))
|
||||||
.map((m) => quoteName(m.name));
|
.map((m) => quoteName(m.name));
|
||||||
if (g.filterRegex) {
|
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) {
|
if (g.includeAllNodes) {
|
||||||
parts.push('include-all-proxies=true');
|
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) {
|
} else if (g.includeAllNodes) {
|
||||||
// 「全部节点」组:显式展开当前启用且 Surge 支持的节点
|
// 「全部节点」组:显式展开当前启用且 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');
|
if (usable.length === 0) parts.push('DIRECT');
|
||||||
} else {
|
} else {
|
||||||
parts.push(...(memberNames.length ? memberNames : ['DIRECT']));
|
parts.push(...(memberNames.length ? memberNames : ['DIRECT']));
|
||||||
}
|
}
|
||||||
if (g.type !== 'select') {
|
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.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('', '[Rule]');
|
||||||
lines.push(...dns.rules);
|
lines.push(...dns.rules);
|
||||||
for (const r of ir.rules) {
|
for (const r of ir.rules.filter((r) => r.type !== 'FINAL')) {
|
||||||
if (r.type === 'FINAL') {
|
|
||||||
lines.push(['FINAL', quoteName(r.policy), ...r.params].join(','));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const value = r.ruleset ? r.ruleset.surgeUrl : r.value;
|
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']) {
|
for (const section of ['Host', 'URL Rewrite', 'Header Rewrite', 'MITM', 'Script']) {
|
||||||
const content = section === 'Host' ? dns.host : ir.sections[section];
|
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;
|
||||||
|
}
|
||||||
@@ -99,13 +99,15 @@ export function buildProfileIR(opts: { nodeIds?: string[] } = {}): ProfileIR {
|
|||||||
const ruleRows = db
|
const ruleRows = db
|
||||||
.select()
|
.select()
|
||||||
.from(rules)
|
.from(rules)
|
||||||
.where(eq(rules.enabled, true))
|
|
||||||
.orderBy(asc(rules.seq))
|
.orderBy(asc(rules.seq))
|
||||||
.all()
|
.all();
|
||||||
.filter((r) => r.type === 'FINAL' || !isDeadRef(r.policy))
|
// Defensive repair of legacy/corrupt states: exactly one FINAL, always last.
|
||||||
// FINAL 必须保留:其策略组被停用或删除时兜底 DIRECT
|
// Management writes separately reject edits that would break this invariant.
|
||||||
.map((r) => (r.type === 'FINAL' && isDeadRef(r.policy) ? { ...r, policy: 'DIRECT' } : r));
|
const storedFinal = ruleRows.find((r) => r.type === 'FINAL' && r.enabled)
|
||||||
const ruleIRs: RuleIR[] = ruleRows.map((r) => {
|
?? ruleRows.find((r) => r.type === 'FINAL');
|
||||||
|
const activeRows = ruleRows.filter((r) => r.enabled && r.type !== 'FINAL')
|
||||||
|
.filter((r) => !isDeadRef(r.policy));
|
||||||
|
const ruleIRs: RuleIR[] = activeRows.map((r) => {
|
||||||
const rs = r.value ? setRows.get(r.value) : undefined;
|
const rs = r.value ? setRows.get(r.value) : undefined;
|
||||||
return {
|
return {
|
||||||
type: r.type,
|
type: r.type,
|
||||||
@@ -115,6 +117,11 @@ export function buildProfileIR(opts: { nodeIds?: string[] } = {}): ProfileIR {
|
|||||||
ruleset: rs ?? null,
|
ruleset: rs ?? null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
ruleIRs.push({
|
||||||
|
type: 'FINAL', value: null,
|
||||||
|
policy: storedFinal && !isDeadRef(storedFinal.policy) ? storedFinal.policy : 'DIRECT',
|
||||||
|
params: storedFinal ? JSON.parse(storedFinal.params) : [], ruleset: null,
|
||||||
|
});
|
||||||
|
|
||||||
const sections: Record<string, string> = {};
|
const sections: Record<string, string> = {};
|
||||||
for (const key of ['General', 'Host', 'URL Rewrite', 'Header Rewrite', 'MITM', 'Script']) {
|
for (const key of ['General', 'Host', 'URL Rewrite', 'Header Rewrite', 'MITM', 'Script']) {
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export function normalizeSsCipher(method: string): string {
|
|||||||
export interface NodeCapabilityInput {
|
export interface NodeCapabilityInput {
|
||||||
protocol: Protocol;
|
protocol: Protocol;
|
||||||
network: 'tcp' | 'ws';
|
network: 'tcp' | 'ws';
|
||||||
|
alterId?: number;
|
||||||
|
security?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CapabilityVerdict {
|
export interface CapabilityVerdict {
|
||||||
@@ -40,5 +42,11 @@ export function clientSupports(client: ClientType, node: NodeCapabilityInput): C
|
|||||||
if (client === 'surge' && node.protocol === 'shadowsocks' && node.network === 'ws') {
|
if (client === 'surge' && node.protocol === 'shadowsocks' && node.network === 'ws') {
|
||||||
return { ok: false, reason: 'Surge 不支持 Shadowsocks over WebSocket' };
|
return { ok: false, reason: 'Surge 不支持 Shadowsocks over WebSocket' };
|
||||||
}
|
}
|
||||||
|
if (client === 'surge' && node.protocol === 'vmess') {
|
||||||
|
if ((node.alterId ?? 0) !== 0) return { ok: false, reason: 'Surge 下发仅支持 alterId=0 的 VMess AEAD 节点' };
|
||||||
|
if (!['auto', 'aes-128-gcm', 'chacha20-poly1305', 'chacha20-ietf-poly1305'].includes(node.security ?? 'auto')) {
|
||||||
|
return { ok: false, reason: `Surge 不支持该 VMess 加密方式:${node.security}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|||||||
40
shared/src/rule-syntax.ts
Normal file
40
shared/src/rule-syntax.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/** Split structural commas while preserving quoted values and nested expressions. */
|
||||||
|
function split(value: string): string[] | null {
|
||||||
|
const parts: string[] = [];
|
||||||
|
let start = 0, depth = 0, quote = '', escaped = false;
|
||||||
|
for (let i = 0; i < value.length; i++) {
|
||||||
|
const ch = value[i];
|
||||||
|
if (escaped) { escaped = false; continue; }
|
||||||
|
if (quote) {
|
||||||
|
if (ch === '\\') escaped = true;
|
||||||
|
else if (ch === quote) quote = '';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '"' || ch === "'") { quote = ch; continue; }
|
||||||
|
if (ch === '#' || ch === ';') return null;
|
||||||
|
if (ch === '(') depth++;
|
||||||
|
if (ch === ')' && --depth < 0) return null;
|
||||||
|
if (ch === ',' && depth === 0) { parts.push(value.slice(start, i).trim()); start = i + 1; }
|
||||||
|
}
|
||||||
|
if (depth || quote || escaped) return null;
|
||||||
|
parts.push(value.slice(start).trim());
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
const leafTypes = new Set(['DOMAIN', 'DOMAIN-SUFFIX', 'DOMAIN-KEYWORD', 'DOMAIN-WILDCARD', 'RULE-SET', 'DOMAIN-SET', 'IP-CIDR', 'IP-CIDR6', 'IP-ASN', 'GEOIP', 'PROCESS-NAME', 'PROTOCOL', 'USER-AGENT', 'URL-REGEX', 'SRC-IP', 'DEST-PORT', 'SRC-PORT', 'SUBNET', 'SCRIPT', 'HOSTNAME-TYPE', 'CELLULAR-CARRIER', 'CELLULAR-RADIO']);
|
||||||
|
export const SURGE_PROTOCOLS = ['HTTP', 'HTTPS', 'TCP', 'UDP', 'QUIC', 'STUN', 'MTProto', 'DOH', 'DOH3', 'DOQ', 'DOT', 'DNS'];
|
||||||
|
|
||||||
|
/** Surge logical sub-rules have no policy; NOT has one child, nesting at most 10. */
|
||||||
|
export function logicalValueValid(operator: string, value: string, depth = 1): boolean {
|
||||||
|
value = value.trim();
|
||||||
|
if (depth > 10 || !value.startsWith('(') || !value.endsWith(')')) return false;
|
||||||
|
const children = split(value.slice(1, -1));
|
||||||
|
if (!children?.length || (operator === 'NOT' && children.length !== 1)) return false;
|
||||||
|
return children.every((child) => {
|
||||||
|
if (!child.startsWith('(') || !child.endsWith(')')) return false;
|
||||||
|
const parts = split(child.slice(1, -1));
|
||||||
|
if (!parts || parts.length < 2 || !parts[1] || parts.slice(2).some((flag) => !['no-resolve', 'extended-matching'].includes(flag))) return false;
|
||||||
|
if (['AND', 'OR', 'NOT'].includes(parts[0])) return logicalValueValid(parts[0], parts[1], depth + 1);
|
||||||
|
return leafTypes.has(parts[0]) && (parts[0] !== 'PROTOCOL' || SURGE_PROTOCOLS.includes(parts[1]));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,25 +1,32 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { PROTOCOLS } from './protocols.js';
|
import { PROTOCOLS } from './protocols.js';
|
||||||
|
import { logicalValueValid, SURGE_PROTOCOLS } from './rule-syntax.js';
|
||||||
|
|
||||||
|
// These names are shared by Surge's declaration syntax and ShadowRocket's
|
||||||
|
// unquoted policy syntax. Reject delimiters rather than silently renaming them.
|
||||||
|
const singleLine = z.string().refine((v) => !/[\x00-\x1f\x7f\u2028\u2029]/.test(v), '不能包含换行或控制字符');
|
||||||
|
const policyName = singleLine.pipe(z.string().min(1).max(64))
|
||||||
|
.refine((v) => v === v.trim() && !/[=,"'\\#;\[\]]/.test(v), '名称不能包含配置分隔符、引号或首尾空格');
|
||||||
|
|
||||||
export const nodeInputSchema = z
|
export const nodeInputSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().min(1).max(64),
|
name: policyName,
|
||||||
protocol: z.enum(PROTOCOLS),
|
protocol: z.enum(PROTOCOLS),
|
||||||
server: z.string().min(1),
|
server: singleLine.pipe(z.string().min(1)),
|
||||||
port: z.number().int().min(1).max(65535),
|
port: z.number().int().min(1).max(65535),
|
||||||
network: z.enum(['tcp', 'ws']).default('tcp'),
|
network: z.enum(['tcp', 'ws']).default('tcp'),
|
||||||
wsPath: z.string().startsWith('/').optional().nullable(),
|
wsPath: singleLine.pipe(z.string().startsWith('/')).optional().nullable(),
|
||||||
wsHost: z.string().optional().nullable(),
|
wsHost: singleLine.optional().nullable(),
|
||||||
tls: z.boolean().default(false),
|
tls: z.boolean().default(false),
|
||||||
sni: z.string().optional().nullable(),
|
sni: singleLine.optional().nullable(),
|
||||||
skipCertVerify: z.boolean().default(false),
|
skipCertVerify: z.boolean().default(false),
|
||||||
uuid: z.string().uuid().optional().nullable(),
|
uuid: z.string().uuid().optional().nullable(),
|
||||||
alterId: z.number().int().min(0).default(0),
|
alterId: z.number().int().min(0).default(0),
|
||||||
security: z.string().default('auto'),
|
security: singleLine.default('auto'),
|
||||||
password: z.string().optional().nullable(),
|
password: singleLine.optional().nullable(),
|
||||||
method: z.string().optional().nullable(),
|
method: singleLine.optional().nullable(),
|
||||||
obfsType: z.enum(['salamander']).optional().nullable(),
|
obfsType: z.enum(['salamander']).optional().nullable(),
|
||||||
obfsPassword: z.string().optional().nullable(),
|
obfsPassword: singleLine.optional().nullable(),
|
||||||
upMbps: z.number().int().positive().optional().nullable(),
|
upMbps: z.number().int().positive().optional().nullable(),
|
||||||
downMbps: z.number().int().positive().optional().nullable(),
|
downMbps: z.number().int().positive().optional().nullable(),
|
||||||
region: z.string().max(8).optional().nullable(),
|
region: z.string().max(8).optional().nullable(),
|
||||||
@@ -46,12 +53,12 @@ export type NodeInput = z.input<typeof nodeInputSchema>;
|
|||||||
export type NodeParsed = z.output<typeof nodeInputSchema>;
|
export type NodeParsed = z.output<typeof nodeInputSchema>;
|
||||||
|
|
||||||
export const groupInputSchema = z.object({
|
export const groupInputSchema = z.object({
|
||||||
name: z.string().min(1).max(64),
|
name: policyName,
|
||||||
type: z.enum(['select', 'url-test', 'fallback']),
|
type: z.enum(['select', 'url-test', 'fallback']),
|
||||||
testUrl: z.string().url().optional().nullable(),
|
testUrl: z.string().url().optional().nullable(),
|
||||||
interval: z.number().int().positive().optional().nullable(),
|
interval: z.number().int().positive().optional().nullable(),
|
||||||
tolerance: z.number().int().positive().optional().nullable(),
|
tolerance: z.number().int().nonnegative().optional().nullable(),
|
||||||
filterRegex: z.string().optional().nullable(),
|
filterRegex: singleLine.refine((v) => { try { new RegExp(v); return true; } catch { return false; } }, '筛选正则无效').optional().nullable(),
|
||||||
includeAllNodes: z.boolean().default(false),
|
includeAllNodes: z.boolean().default(false),
|
||||||
sortOrder: z.number().int().default(0),
|
sortOrder: z.number().int().default(0),
|
||||||
members: z
|
members: z
|
||||||
@@ -59,7 +66,7 @@ export const groupInputSchema = z.object({
|
|||||||
z.object({
|
z.object({
|
||||||
kind: z.enum(['node', 'group', 'builtin']),
|
kind: z.enum(['node', 'group', 'builtin']),
|
||||||
nodeId: z.string().optional().nullable(),
|
nodeId: z.string().optional().nullable(),
|
||||||
refName: z.string().optional().nullable(),
|
refName: policyName.optional().nullable(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.default([]),
|
.default([]),
|
||||||
@@ -91,10 +98,28 @@ export const BUILTIN_POLICIES = ['DIRECT', 'REJECT', 'REJECT-DROP', 'REJECT-NO-D
|
|||||||
|
|
||||||
export const ruleInputSchema = z.object({
|
export const ruleInputSchema = z.object({
|
||||||
type: z.enum(RULE_TYPES),
|
type: z.enum(RULE_TYPES),
|
||||||
value: z.string().optional().nullable(),
|
value: singleLine.optional().nullable(),
|
||||||
policy: z.string().min(1),
|
policy: policyName,
|
||||||
params: z.array(z.string()).default([]),
|
params: z.array(singleLine.refine((v) => /^[a-z][a-z0-9-]*(?:=.+)?$/i.test(v), '规则参数需要有效的名称')).default([]),
|
||||||
enabled: z.boolean().default(true),
|
enabled: z.boolean().default(true),
|
||||||
|
}).superRefine((v, ctx) => {
|
||||||
|
if (v.type !== 'FINAL' && !v.value?.trim()) {
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['value'], message: '规则需要匹配值' });
|
||||||
|
}
|
||||||
|
if (v.type === 'FINAL' && (!v.enabled || v.value)) {
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'FINAL 必须启用且不包含匹配值' });
|
||||||
|
}
|
||||||
|
if (v.type !== 'URL-REGEX' && !['AND', 'OR', 'NOT'].includes(v.type) && /[,"'\\#;]/.test(v.value ?? '')) {
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['value'], message: '该规则类型不接受配置分隔符' });
|
||||||
|
}
|
||||||
|
if (['AND', 'OR', 'NOT'].includes(v.type)) {
|
||||||
|
if (!logicalValueValid(v.type, v.value ?? '')) {
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['value'], message: '逻辑规则需要有效的无策略子规则与完整括号表达式' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (v.type === 'PROTOCOL' && !SURGE_PROTOCOLS.includes(v.value ?? '')) {
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['value'], message: 'PROTOCOL 关键字无效,注意大小写' });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
export type RuleInput = z.input<typeof ruleInputSchema>;
|
export type RuleInput = z.input<typeof ruleInputSchema>;
|
||||||
|
|
||||||
|
|||||||
@@ -224,9 +224,10 @@ function memberLabel(m: GroupDto['members'][number]): string {
|
|||||||
<NInput v-model:value="form.filterRegex" placeholder="如 SG|Singapore(配合收纳全部节点或成员组)" class="mono" />
|
<NInput v-model:value="form.filterRegex" placeholder="如 SG|Singapore(配合收纳全部节点或成员组)" class="mono" />
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
<template v-if="form.type !== 'select'">
|
<template v-if="form.type !== 'select'">
|
||||||
<NFormItem label="测速 URL">
|
<NFormItem label="测速 URL(Clash/ShadowRocket)">
|
||||||
<NInput v-model:value="form.testUrl" placeholder="http://cp.cloudflare.com/generate_204" class="mono" />
|
<NInput v-model:value="form.testUrl" placeholder="http://cp.cloudflare.com/generate_204" class="mono" />
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
|
<p class="form-hint">Surge 使用设置页 General 中的 proxy-test-url;不支持组级测速 URL。</p>
|
||||||
<NFormItem label="间隔(秒)">
|
<NFormItem label="间隔(秒)">
|
||||||
<NInputNumber v-model:value="form.interval" :min="10" style="width: 100%" />
|
<NInputNumber v-model:value="form.interval" :min="10" style="width: 100%" />
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ const form = ref<RuleInput>({ type: 'DOMAIN-SUFFIX', value: '', policy: 'DIRECT'
|
|||||||
|
|
||||||
const policyOptions = computed(() => [
|
const policyOptions = computed(() => [
|
||||||
...['DIRECT', 'REJECT', 'REJECT-DROP', 'REJECT-NO-DROP'].map((p) => ({ label: p, value: p })),
|
...['DIRECT', 'REJECT', 'REJECT-DROP', 'REJECT-NO-DROP'].map((p) => ({ label: p, value: p })),
|
||||||
...groupsStore.groups.map((g) => ({ label: g.name, value: g.name })),
|
...groupsStore.groups.filter((g) => g.enabled).map((g) => ({ label: g.name, value: g.name })),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const typeOptions = RULE_TYPES.filter((t) => t !== 'RULE-SET' && t !== 'DOMAIN-SET' && t !== 'FINAL').map((t) => ({
|
const typeOptions = RULE_TYPES.filter((t) => t !== 'RULE-SET' && t !== 'DOMAIN-SET' && t !== 'FINAL').map((t) => ({
|
||||||
@@ -170,7 +170,7 @@ const columns = computed<DataTableColumns<RuleDto>>(() => [
|
|||||||
title: '启用',
|
title: '启用',
|
||||||
key: 'enabled',
|
key: 'enabled',
|
||||||
width: 70,
|
width: 70,
|
||||||
render: (row) => h(NSwitch, { size: 'small', value: row.enabled, onUpdateValue: (v: boolean) => store.toggle(row.id, v) }),
|
render: (row) => h(NSwitch, { size: 'small', value: row.enabled, disabled: row.type === 'FINAL', onUpdateValue: (v: boolean) => store.toggle(row.id, v) }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '',
|
title: '',
|
||||||
@@ -178,12 +178,12 @@ const columns = computed<DataTableColumns<RuleDto>>(() => [
|
|||||||
width: 170,
|
width: 170,
|
||||||
render: (row, i) =>
|
render: (row, i) =>
|
||||||
h(NSpace, { size: 2 }, () => [
|
h(NSpace, { size: 2 }, () => [
|
||||||
h(NButton, { size: 'tiny', quaternary: true, disabled: i === 0, onClick: () => move(row, -1) }, () => '↑'),
|
h(NButton, { size: 'tiny', quaternary: true, disabled: i === 0 || row.type === 'FINAL', onClick: () => move(row, -1) }, () => '↑'),
|
||||||
h(NButton, { size: 'tiny', quaternary: true, disabled: i === store.rules.length - 1, onClick: () => move(row, 1) }, () => '↓'),
|
h(NButton, { size: 'tiny', quaternary: true, disabled: i >= store.rules.length - 2 || row.type === 'FINAL', onClick: () => move(row, 1) }, () => '↓'),
|
||||||
...(row.type !== 'RULE-SET' && row.type !== 'DOMAIN-SET' && row.type !== 'FINAL'
|
...(row.type !== 'RULE-SET' && row.type !== 'DOMAIN-SET' && row.type !== 'FINAL'
|
||||||
? [h(NButton, { size: 'tiny', quaternary: true, onClick: () => openEdit(row) }, () => '编辑')]
|
? [h(NButton, { size: 'tiny', quaternary: true, onClick: () => openEdit(row) }, () => '编辑')]
|
||||||
: []),
|
: []),
|
||||||
...(row.source === 'custom'
|
...(row.source === 'custom' && row.type !== 'FINAL'
|
||||||
? [h(NButton, { size: 'tiny', quaternary: true, type: 'error', onClick: () => confirmDelete(row) }, () => '删除')]
|
? [h(NButton, { size: 'tiny', quaternary: true, type: 'error', onClick: () => confirmDelete(row) }, () => '删除')]
|
||||||
: []),
|
: []),
|
||||||
]),
|
]),
|
||||||
|
|||||||
Reference in New Issue
Block a user