feat: 合并边缘节点运维并完善 Surge 配置安全

迁入通用节点部署工具、离线交付页生成器和共用技能,隔离真实配置与交付产物。统一 Codex 与 Claude Code 开发约定,补充节点迁移与运维文档。

完善 FINAL、策略组引用、输入校验、Surge 字段编码与 AI DNS 例外保护,增加隔离测试和订阅验证工具。

验证:全量类型检查、44 项单测、前端构建、脚本语法、技能入口与凭据隔离检查均通过。
This commit is contained in:
2026-10-03 23:19:59 +08:00
parent 7358008936
commit 5326c572a2
93 changed files with 6780 additions and 320 deletions

1
.agents/skills/deploy Symbolic link
View File

@@ -0,0 +1 @@
../../.claude/skills/deploy

View File

@@ -0,0 +1 @@
../../.claude/skills/edge-node-multi-protocol

1
.agents/skills/update-db Symbolic link
View File

@@ -0,0 +1 @@
../../.claude/skills/update-db

View File

@@ -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` 与环境变量指向它。

View File

@@ -0,0 +1,6 @@
scripts/config.env
scripts/config.env.*
!scripts/config.env.example
scripts/node-links/
__pycache__/
*.pyc

View 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` 校验实际订阅。报告已验证结果与尚未做的真机验证。

View File

@@ -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).

View File

@@ -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.

View File

@@ -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>

View File

@@ -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.

View File

@@ -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.

View File

@@ -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 可导入类型。
这里保存的是已有部署工具的流程说明。涉及新版本安装或升级时,先核对官方版本兼容性,再在授权范围执行;本次工程迁移没有对生产节点执行这些脚本。

View 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`.

View 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"

View 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"

View 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)

View 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"

View 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"

View 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'

View 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'

View 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'."

View 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='"

View 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'

View 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'"

View 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()

View 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"

View 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()

View 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()

View 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"

View 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"

View 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"

View 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'"

View 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"'

View 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"'

View 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

View 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"

View 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"

View 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"

View 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)))

View 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()

View 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.")

View 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()

View 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'; }

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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 模式是你自己的干净出口,不是绕过他人网络管控的工具。

View File

@@ -0,0 +1,108 @@
# {{HOST_LABEL}} 节点信息 · 客户端导入
用支持对应协议的客户端(v2rayN / v2rayNG / Shadowrocket / Nekobox / sing-box / Surge / Clash Meta 等)扫码或粘贴链接即可导入。运行 `scripts/14-generate-links-and-qr.py` 会自动生成本文档所需的全部 `.link` 文件和 `-qr.png` 二维码图片到同一个输出目录 —— 把对应文件内容/图片贴进下面每一节即可,不需要手抄。
六个节点使用同一个域名 `{{SUB_COVER}}.{{DOMAIN_ROOT}}`,同一个协议内的 direct/WARP 只有 WS 路径不同。
---
## 线路一:VMess 直连出口
出站走服务器自身公网 IP(`{{SERVER_IP}}`),速度最快,但暴露服务器真实 IP。
![VMess direct 节点二维码](vmess-direct-qr.png)
```
{{VMESS_DIRECT_LINK}}
```
## 线路二:VMess WARP 净出口
出站走 Cloudflare WARP,隐藏服务器真实 IP,出口 IP 是 Cloudflare 的地址,速度略慢于直连。
![VMess WARP 节点二维码](vmess-warp-qr.png)
```
{{VMESS_WARP_LINK}}
```
## 线路三:Trojan 直连出口
![Trojan direct 节点二维码](trojan-direct-qr.png)
```
{{TROJAN_DIRECT_LINK}}
```
## 线路四:Trojan WARP 净出口
![Trojan WARP 节点二维码](trojan-warp-qr.png)
```
{{TROJAN_WARP_LINK}}
```
## 线路五:Shadowsocks 直连出口
生成器会输出带 Xray 传输参数的 `ss://` 链接与二维码,Proxy Station 可解析导入。其他客户端若不接受该写法,可用下面的参数或 sing-box JSON 片段。Surge 不支持 SS over WS,这两条线路不会下发到 Surge。
| 参数 | 值 |
|---|---|
| 地址 | `{{SUB_COVER}}.{{DOMAIN_ROOT}}` |
| 端口 | `443` |
| 加密方式 (method) | `{{SS_METHOD}}` |
| 密码 | `{{NODE_PASSWORD}}`(与 Trojan 共用同一密码) |
| 传输 | `ws`,路径 `{{WS_PATH_SS_DIRECT}}` |
| TLS / SNI | 开启,`{{SUB_COVER}}.{{DOMAIN_ROOT}}` |
sing-box outbound(`ss-direct-singbox.json`,由生成脚本自动写出):
```json
{{SS_DIRECT_SINGBOX_JSON}}
```
Surge 用户使用其他受支持线路;不能把 SS over WS 写成 Surge 的 `obfs=wss`。
## 线路六:Shadowsocks WARP 净出口
参数和线路五完全一样,公开端口仍为 443,只有出口与 WS 路径不同(`{{WS_PATH_SS_WARP}}`)。
sing-box outbound(`ss-warp-singbox.json`):
```json
{{SS_WARP_SINGBOX_JSON}}
```
---
## 手动填写参数 · VMess(如果客户端不支持扫码/粘贴链接)
| 参数 | direct | WARP |
|---|---|---|
| 地址 (address) | `{{SUB_COVER}}.{{DOMAIN_ROOT}}` | 同左 |
| 端口 (port) | `443` | 同左 |
| 用户ID (UUID) | `{{XUI_UUID}}` | 同左 |
| 额外ID (AlterId) | `0` | 同左 |
| 加密方式 (security) | `auto` | 同左 |
| 传输协议 (network) | `ws` | 同左 |
| 伪装类型 (type) | `none` | 同左 |
| 路径 (path) | `{{WS_PATH_VMESS_DIRECT}}` | `{{WS_PATH_VMESS_WARP}}` |
| Host / SNI | `{{SUB_COVER}}.{{DOMAIN_ROOT}}` | 同左 |
| TLS | 开启 | 开启 |
## 手动填写参数 · Trojan
| 参数 | direct | WARP |
|---|---|---|
| 地址 / 端口 | `{{SUB_COVER}}.{{DOMAIN_ROOT}}` / `443` | 同左 |
| 密码 | `{{NODE_PASSWORD}}`(与 Shadowsocks 共用同一密码) | 同左 |
| 传输协议 (network) | `ws` | 同左 |
| 路径 (path) | `{{WS_PATH_TROJAN_DIRECT}}` | `{{WS_PATH_TROJAN_WARP}}` |
| Host / SNI | `{{SUB_COVER}}.{{DOMAIN_ROOT}}` | 同左 |
| TLS | 开启 | 开启 |
## 验证方式
导入后连接,访问 `https://1.1.1.1/cdn-cgi/trace`,看返回的 `ip=` 和 `warp=`:
- direct 线路(任意协议)应显示 `ip={{SERVER_IP}}`,`warp=off`
- WARP 线路(任意协议)应显示一个 Cloudflare 段的 IP,`warp=on`
服务器侧的 `scripts/12-smoke-test.sh` 已经对全部六条线路做过 WS-101 + 出口 IP 的实测验证;如果你这边导入后验证结果不对,大概率是客户端参数抄错了(尤其是 path 和 host 要跟表里完全一致,包括大小写),不是服务器端的问题。

View File

@@ -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`,单独跑这一个脚本即可,不影响其他服务。

View 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 `![...](foo-qr.png)` image references in
`02-node-info-template.md` resolve.
2. Copy the three `*-template.md` files into that same output directory (drop the `-template`
suffix if you like, e.g. `01-handover.md`), and replace every `{{TOKEN}}` below with the real
value from `config.env` (or the node's live state, for things like `AAPANEL_PORT` which the
installer randomizes — read it from `/www/server/panel/data/port.pl` on the box).
3. For `{{VMESS_DIRECT_LINK}}` / `{{VMESS_WARP_LINK}}` / `{{TROJAN_DIRECT_LINK}}` /
`{{TROJAN_WARP_LINK}}`, paste the contents of the matching `.link` file. For
`{{SS_DIRECT_SINGBOX_JSON}}` / `{{SS_WARP_SINGBOX_JSON}}`, paste the matching `.json` file's
contents inside the fenced code block that's already there.
## Token reference
| Token | Source |
|---|---|
| `{{HOST_LABEL}}` | Whatever short name you use for this node (e.g. an SSH alias or hostname) |
| `{{SSH_ALIAS}}` / `{{SSH_USER}}` | Your local SSH config / `config.env` `SSH_USER` |
| `{{SERVER_IP}}` / `{{SERVER_IPV6}}` | `config.env` |
| `{{VPS_PROVIDER_AND_SPECS}}` | Whatever's useful to note (provider, region, vCPU/RAM) |
| `{{DEPLOY_DATE}}` | The date you ran through the workflow |
| `{{DOMAIN_ROOT}}` / `{{SUB_PANEL}}` / `{{SUB_XUI}}` / `{{SUB_COVER}}` | `config.env` |
| `{{AAPANEL_ENTRANCE}}` / `{{AAPANEL_USER}}` / `{{AAPANEL_PASS}}` / `{{AAPANEL_PORT}}` | `config.env` + live panel port from the box |
| `{{XUI_PATH}}` / `{{XUI_USER}}` / `{{XUI_PASS}}` | `config.env` |
| `{{XUI_UUID}}` | `config.env` (node-main's VMess credential) |
| `{{NODE_PASSWORD}}` | `config.env` (node-main's shared Trojan+Shadowsocks credential) |
| `{{SS_METHOD}}` | `config.env` |
| `{{PORT_VMESS_DIRECT}}` / `{{PORT_VMESS_WARP}}` / `{{PORT_TROJAN_DIRECT}}` / `{{PORT_TROJAN_WARP}}` / `{{PORT_SS_DIRECT}}` / `{{PORT_SS_WARP}}` | `config.env` |
| `{{WS_PATH_VMESS_DIRECT}}` / `{{WS_PATH_VMESS_WARP}}` / `{{WS_PATH_TROJAN_DIRECT}}` / `{{WS_PATH_TROJAN_WARP}}` / `{{WS_PATH_SS_DIRECT}}` / `{{WS_PATH_SS_WARP}}` | `config.env` |
| `{{WARP_SOCKS_PORT}}` | `config.env` (default 40000) |
| `{{VMESS_DIRECT_LINK}}` / `{{VMESS_WARP_LINK}}` / `{{TROJAN_DIRECT_LINK}}` / `{{TROJAN_WARP_LINK}}` | contents of the matching `.link` file from step 9 |
| `{{SS_DIRECT_SINGBOX_JSON}}` / `{{SS_WARP_SINGBOX_JSON}}` | contents of the matching `.json` file from step 9 |
Never fill these in with placeholder/example values in a copy that leaves your machine — every
token above except `{{HOST_LABEL}}`/`{{DEPLOY_DATE}}`/`{{VPS_PROVIDER_AND_SPECS}}` is either a
secret or part of your control-plane map.

View File

@@ -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 &amp; networking endpoint. No public service is hosted on this page.</p>
<p class="foot">for authorized use only</p>
</div></body></html>

View File

@@ -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 全文,用节点名指代

View File

@@ -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
View 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
View File

@@ -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
View 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
View File

@@ -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 会泄露。

View File

@@ -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
View 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;若工具升级后入口行为变化,按上述官方文档重新核对。

View File

@@ -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`,续期后不会重新部署证书)。

View File

@@ -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
## 命名建议(影响地区组归类) ## 命名建议(影响地区组归类)

File diff suppressed because it is too large Load Diff

View 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
View 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)。

View 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 无泄漏;这些仍属于设备侧验收。

View 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
}

View File

@@ -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
View 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
View File

@@ -0,0 +1,4 @@
@../AGENTS.md
@AGENTS.md
节点目录补充约定见本目录 `AGENTS.md`,部署流程使用项目级 `edge-node-multi-protocol` 技能。

80
edge-nodes/README.md Normal file
View 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、在线二维码或公开交付页同步。不要将填写过的真实值放回技能源目录的脚本、参考资料或模板。

View 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))

View 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
View 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))

View File

@@ -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

View File

@@ -51,56 +51,62 @@ 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
? db.select().from(rules).where(eq(rules.source, 'custom')).all()
: [];
db.delete(rules).where(eq(rules.source, 'template')).run();
if (!opts.keepCustom) db.delete(rules).where(eq(rules.source, 'custom')).run();
const custom = opts.keepCustom let seq = 0;
? db.select().from(rules).where(eq(rules.source, 'custom')).all() // 规则首条匹配即生效:自定义规则保持原相对顺序,整体排在全部模板规则之前
: []; for (const c of custom.filter((r) => r.type !== 'FINAL').sort((a, b) => a.seq - b.seq)) {
db.delete(rules).where(eq(rules.source, 'template')).run(); db.update(rules).set({ seq: seq++ }).where(eq(rules.id, c.id)).run();
if (!opts.keepCustom) db.delete(rules).where(eq(rules.source, 'custom')).run();
let seq = 0;
// 规则首条匹配即生效:自定义规则保持原相对顺序,整体排在全部模板规则之前
for (const c of custom.sort((a, b) => a.seq - b.seq)) {
db.insert(rules)
.values({ ...c, seq: seq++ })
.run();
}
for (const r of parsed.rules) {
let value = r.value;
if ((r.type === 'RULE-SET' || r.type === 'DOMAIN-SET') && value && /^https?:\/\//.test(value)) {
const existing = db.select().from(rulesets).where(eq(rulesets.surgeUrl, value)).get();
if (existing) {
value = existing.id;
} else {
const mapping = mapRulesetUrl(value, r.type);
const rulesetId = nanoid(10);
db.insert(rulesets)
.values({
id: rulesetId,
name: rulesetNameFromUrl(value),
surgeUrl: value,
clashUrl: mapping.clashUrl,
clashBehavior: mapping.clashBehavior,
clashFormat: mapping.clashFormat,
needsConvert: mapping.needsConvert,
})
.run();
value = rulesetId;
}
} }
db.insert(rules) const customFinal = custom.find((r) => r.type === 'FINAL');
.values({ for (const r of parsed.rules.filter((r) => !customFinal || r.type !== 'FINAL')) {
id: nanoid(10), let value = r.value;
seq: seq++, if ((r.type === 'RULE-SET' || r.type === 'DOMAIN-SET') && value && /^https?:\/\//.test(value)) {
type: r.type, const existing = db.select().from(rulesets).where(eq(rulesets.surgeUrl, value)).get();
value, if (existing) {
policy: r.policy, value = existing.id;
params: JSON.stringify(r.params), } else {
enabled: true, const mapping = mapRulesetUrl(value, r.type);
source: 'template', const rulesetId = nanoid(10);
}) db.insert(rulesets)
.run(); .values({
} id: rulesetId,
name: rulesetNameFromUrl(value),
surgeUrl: value,
clashUrl: mapping.clashUrl,
clashBehavior: mapping.clashBehavior,
clashFormat: mapping.clashFormat,
needsConvert: mapping.needsConvert,
})
.run();
value = rulesetId;
}
}
db.insert(rules)
.values({
id: nanoid(10),
seq: seq++,
type: r.type,
value,
policy: r.policy,
params: JSON.stringify(r.params),
enabled: true,
source: 'template',
})
.run();
}
if (customFinal) {
for (const extra of custom.filter((r) => r.type === 'FINAL' && r.id !== customFinal.id)) {
db.delete(rules).where(eq(rules.id, extra.id)).run();
}
db.update(rules).set({ seq, enabled: true }).where(eq(rules.id, customFinal.id)).run();
}
});
} }
export function seedIfEmpty(db: Db) { export function seedIfEmpty(db: Db) {

View File

@@ -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,27 +42,31 @@ 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.insert(policyGroups) db.transaction(() => {
.values({ db.insert(policyGroups)
id, .values({
name: input.name, id,
type: input.type, name: input.name,
testUrl: input.testUrl ?? null, type: input.type,
interval: input.interval ?? null, testUrl: input.testUrl ?? null,
tolerance: input.tolerance ?? null, interval: input.interval ?? null,
filterRegex: input.filterRegex || null, tolerance: input.tolerance ?? null,
includeAllNodes: input.includeAllNodes, filterRegex: input.filterRegex || null,
sortOrder: input.sortOrder, includeAllNodes: input.includeAllNodes,
source: 'custom', sortOrder: input.sortOrder,
}) 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,25 +88,29 @@ 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.update(policyGroups) db.transaction(() => {
.set({ db.update(policyGroups)
name: input.name, .set({
type: input.type, name: input.name,
testUrl: input.testUrl ?? null, type: input.type,
interval: input.interval ?? null, testUrl: input.testUrl ?? null,
tolerance: input.tolerance ?? null, interval: input.interval ?? null,
filterRegex: input.filterRegex || null, tolerance: input.tolerance ?? null,
includeAllNodes: input.includeAllNodes, filterRegex: input.filterRegex || null,
}) includeAllNodes: input.includeAllNodes,
.where(eq(policyGroups.id, id)) })
.run(); .where(eq(policyGroups.id, id))
replaceMembers(id, input.members); .run();
// 组名变更时同步引用它的成员与规则策略 replaceMembers(id, input.members.map((m) => m.kind === 'group' && m.refName === oldName ? { ...m, refName: input.name } : m));
if (oldName !== input.name) { // 组名变更时同步引用它的成员与规则策略
db.update(groupMembers).set({ refName: input.name }).where(eq(groupMembers.refName, oldName)).run(); if (oldName !== input.name) {
db.update(rules).set({ policy: input.name }).where(eq(rules.policy, 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();
}
});
return c.json({ ok: true }); return c.json({ ok: true });
}) })
.delete('/:id', (c) => { .delete('/:id', (c) => {

View 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);
});
});

View File

@@ -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() })

View File

@@ -25,36 +25,52 @@ 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.insert(rules) db.transaction(() => {
.values({ if (input.type !== 'FINAL') db.update(rules).set({ seq: sql`${rules.seq} + 1` }).run();
id, const seq = input.type === 'FINAL' ? Math.max(-1, ...db.select().from(rules).all().map((r) => r.seq)) + 1 : 0;
seq: 0, db.insert(rules)
type: input.type, .values({
value: input.value ?? null, id,
policy: input.policy, seq,
params: JSON.stringify(input.params), type: input.type,
enabled: input.enabled, value: input.value ?? null,
source: 'custom', policy: input.policy,
}) params: JSON.stringify(input.params),
.run(); enabled: input.enabled,
source: 'custom',
})
.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 });

View File

@@ -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 (SURGE_SECTION_KEYS.includes(key)) setSetting(`surge:${key}`, value); if (error) return c.json({ error }, 400);
} db.transaction(() => {
for (const [key, value] of Object.entries(input.surgeSections!)) {
if (SURGE_SECTION_KEYS.includes(key)) setSetting(`surge:${key}`, value);
}
});
} }
return c.json({ ok: true }); return c.json({ ok: true });
}); });

View File

@@ -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');
});
}); });

View File

@@ -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.

View 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))}`;
}

View 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);
}
});
});

View File

@@ -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];

View 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;
}

View File

@@ -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']) {

View File

@@ -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
View 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]));
});
}

View File

@@ -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>;

View File

@@ -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>

View File

@@ -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) }, () => '删除')]
: []), : []),
]), ]),