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

View File

@@ -1,12 +1,14 @@
---
name: deploy
description: 把 proxy-station 部署到 LA 生产服务器(ps.la.njcqtechaicoding.com)。可以只部署前端、只部署后端,或前后端一起。凡是用户提到「部署」「发布」「上线」「推到服务器」「更新线上」「让线上生效」「同步到 la」,或改完代码想在生产环境看效果,都用这个 Skill,即使没有明说「部署」二字。
description: 将 proxy-station 前端、后端或两者部署到 LA 生产服务器。用于用户要求部署、发布、上线、更新线上或同步到 la;生产数据维护使用 update-db。
---
# 部署 proxy-station 到 LA
线上是一台 1 核 1.9G 的机器,没有 Docker:后端用 Node 22 直跑、systemd 守护(服务名 `proxy-station`,代码在 `/opt/proxy-station`),前端在本机构建后由后端静态托管,HTTPS 与反代由 aaPanel 的 nginx 负责。首次部署与一次性配置(systemd 单元、`/etc/proxy-station/env`、nginx 站点、证书、数据库迁移)见 `docs/DEPLOY.md`,本 Skill 只负责日常的代码更新。
本技能由 Codex 与 Claude Code 共用,命令在仓库根目录运行;`.agents/skills/deploy` 链接到本目录。阅读或加载技能不代表用户授权上线;用户已明确要求本次部署时直接按授权执行,不重复确认。
## 用法
```bash
@@ -35,6 +37,7 @@ bash .claude/skills/deploy/scripts/deploy.sh server # 只推后端源码,
1. **预检**:拒绝大写 ssh 别名(大写会被当作域名走 fake-IP 解析,连不上);确认远端目录与 systemd 单元存在,否则提示先做首次部署。
2. **本地检查**:后端目标先跑 typecheck 与单测。线上没有测试环境,这是最后一道拦截。
节点/组/规则/DNS/生成器变更另按 [Surge 变更验证](../../../docs/SURGE-CHANGES.md) 验证候选订阅,使用本机官方 `surge-cli`,涉及规则集时通过 `scripts/check-surge.py --fetch-rulesets` 下载快照验证。单测中本地 Surge CLI 用例在未安装时会跳过,不能据此声称已通过官方校验。
3. **推送**:用 tar 管道走 ssh,不依赖远端 rsync。先解到暂存目录再整体换入,旧版本保留为 `*.old`,中途失败不会留下半新半旧的目录。
4. **后端**:`pnpm install --frozen-lockfile --prod` 只装 server 及其 workspace 依赖,然后重启服务,等 `/api/health` 最多 20 秒。不通过就打印日志、把源码换回 `.old` 并重启,让线上先恢复。
5. **校验**:不用「命令没报错」当成功。前端比对本地与远端 `web/dist` 的文件树指纹,并检查公网首页是否已引用新的入口脚本;后端比对 `server/src` 与 `shared/src` 的指纹并确认服务 active;最后从本机访问公网健康检查,并确认管理接口不带 token 时返回 401。任何一项不过,脚本以失败退出。
@@ -45,6 +48,7 @@ bash .claude/skills/deploy/scripts/deploy.sh server # 只推后端源码,
- 最后一行 `部署完成:<目标> @ <git rev>` 才算成功。把这一行连同校验段落转述给用户。
- 输出里出现 `(工作区有未提交改动)` 时要提醒用户:线上跑的是未提交的代码,之后应当提交或有意识地保持。
- 生成器或 shared 变更部署后,从实际 HTTPS 入口取订阅,用 `python3 scripts/check-surge.py <私有临时文件> --fetch-rulesets` 再验收;健康检查和源码指纹不能证明规则行为正确。受影响的 Clash 用独立目录下的 `mihomo -t` 验证。
- `前端产物指纹不一致` 几乎只有一种原因:推送中断。重跑一次 `web` 即可。
- `公网首页未引用 <入口脚本>` 而指纹一致:浏览器或中间缓存,稍等再试;aaPanel 的反代模板对 js 只缓存 1 分钟。
- `pnpm install 失败`:多半是本地 `package.json` 改了但锁文件没更新。本地 `pnpm install` 后连锁文件一起提交,再部署。
@@ -52,6 +56,6 @@ bash .claude/skills/deploy/scripts/deploy.sh server # 只推后端源码,
## 边界
- 不迁移数据库、不改 `ADMIN_TOKEN`、不碰 nginx 与证书。这些操作见 `docs/DEPLOY.md`,且每一项都应先跟用户确认。
- 数据库迁移、`ADMIN_TOKEN`、nginx 与证书属于另行授权的首次配置或维护任务,见 `docs/DEPLOY.md`;本次代码部署授权不包含这些操作。已有明确授权时不重复确认。
- 不做 git 提交。部署与提交是两件事,用户没要求就不要顺手提交。
- 如果用户要部署到 LA 之外的机器,先确认那台机器已按 `docs/DEPLOY.md` 完成首次部署,再用 `--host` 与环境变量指向它。

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
description: 对 LA 生产机上的 proxy-station 数据做规范化变更——增删节点、导入分享链接、调整策略组与成员、规则、规则集引用、订阅 token、Surge 设置段落。凡是用户要动线上数据的(「线上加/删个节点」「把这个组改成 url-test」「调一下规则」「建个订阅 token」「删掉那个 token」「改下生产上的设置」),都用这个 Skill,即使没提到「数据库」三个字。它只改数据不碰代码:代码更新与部署归 deploy Skill。
description: 经管理 API 查询或维护 LA 生产环境的节点、策略组、规则、订阅 token 与 Surge 设置,写入前在线备份。用于线上数据盘点、导入预览与数据变更;代码部署使用 deploy。
---
# 变更 proxy-station 生产数据
线上数据(节点、策略组、规则、token、设置)住在 la 机的 SQLite(`/opt/proxy-station/data/proxy-station.db`),由常驻的 `proxy-station` 服务直连。本 Skill 的做法是:**走服务自己的管理 API 做变更,变更前用 sqlite3 在线备份做快照**,不直接写库、不重启服务。
本技能由 Codex 与 Claude Code 共用,命令在仓库根目录运行;`.agents/skills/update-db` 链接到本目录。先区分查询、导入预览与实际变更;纯查询/`commit:false` 预览无需备份,也不能借此执行写入。用户已明确授权的具体变更不重复确认,范围不清时先查询现状并明确目标。
## 为什么必须走 API,不能直写 SQLite
API 不是简单转发,直写会绕过它的全部防护:
@@ -23,6 +25,7 @@ API 不是简单转发,直写会绕过它的全部防护:
```bash
bash .claude/skills/update-db/scripts/api.sh api GET /api/nodes
bash .claude/skills/update-db/scripts/api.sh api POST /api/nodes '{"name":"…","protocol":"…",…}'
bash .claude/skills/update-db/scripts/api.sh api PUT /api/settings --body-file /private/tmp/<private-body>.json
bash .claude/skills/update-db/scripts/api.sh api GET /sub/<token>/clash # 拉订阅做校验(公开端点)
bash .claude/skills/update-db/scripts/api.sh backup <context> # context 用小写连字符 slug,如 add-jp-hy2
bash .claude/skills/update-db/scripts/api.sh counts
@@ -30,6 +33,7 @@ bash .claude/skills/update-db/scripts/api.sh health
```
- `api` 经 ssh 在**远端本机回环**调接口:`ADMIN_TOKEN` 只在远端 `/etc/proxy-station/env` 里展开,不经本地 shell、不落对话记录;JSON 走 stdin,无需转义
- 含凭据、私有 Host 或复杂 JSON 时使用权限 600 的临时文件及 `--body-file`,不把正文放到 shell 字符串或进程参数;响应也保存到私有文件并只汇报脱敏摘要
- 响应末尾带 `__HTTP_STATUS:200__` 一行。**非 2xx 时先读响应体里的错误信息再决定下一步**,不要盲目重试;要管道给 jq 时先剥掉这行:`| sed '$d' | jq …`
- 连不上 la(解析出 198.18.x.x)是本地 fake-IP 劫持了 ssh,与远端无关,等网络恢复再试
- 环境变量可覆盖 `DEPLOY_HOST`/`PS_DATA_DIR`/`PS_ENV_FILE`,默认即生产值
@@ -37,35 +41,40 @@ bash .claude/skills/update-db/scripts/api.sh health
## 标准流程
1. **摸现状**(只读):`api GET /api/groups`、`/api/rules`、`/api/nodes`、`/api/tokens`,把要动的对象当前值拿到手。改组成员、调规则顺序这类「全量替换」操作,不先拿全量必出错。
涉及下发行为先读 [Surge 变更验证](../../../docs/SURGE-CHANGES.md)。检查业务例外与广告/服务集合的顺序、Host 的 DNS 例外、FINAL 和客户端节点范围;新防护依赖代码修复时先完成并验证代码部署。
2. **备份**:`api.sh backup <context>`。一个变更主题一个备份,同主题内的多条变更共用一份。备份落在远端 `data/backups/`(root 属主),不要清理。
3. **执行**:逐条调 API,每条核对 HTTP_STATUS 与返回体。某条失败就停下排查,不要跳过继续。
4. **校验**:
- `counts` 与变更前对比,行数增减要能对上意图
- `api GET` 变更对象,确认落库的是新值
- 涉及下发面的变更(节点/组/规则/token 范围):拉一次 `api GET /sub/<token>/clash` 确认 200;本地装了 mihomo 的话,`mihomo -t -f <文件>` 是最有说服力的端到端验证
- 涉及节点/组/规则/token 范围/Surge 设置:从实际 HTTPS 入口拉 Surge 订阅到私有临时文件,核对 self URL,运行 `python3 scripts/check-surge.py <文件> --fetch-rulesets`;审阅遮蔽提示。回环 API 拉取的内网 self URL 不能替代 HTTPS 验收,CLI 不等于节点连接或泄漏测试
- 影响 Clash 时拉其产物并在独立临时目录运行 `mihomo -t -d <临时目录> -f <文件>`;不启动代理或覆盖活动配置
- `excluded`/`skippedRules` 只存在于生成器内部、不在 HTTP 响应里;要核对静默排除时,用 `/api/nodes` 列表对比 yaml 里的 `proxies`
5. **汇报**:向用户复述三件事——变更清单、备份文件名、校验结果。三者齐了才算完成。
## 各实体的操作要点
### 节点 `/api/nodes`
- 新节点来自分享链接:先 `POST /api/nodes/import` 带 `{"text":"…","commit":false}` 预览(返回每条的解析结果与自动去重后的名字),确认后再 `commit:true` 真正入库
- 新节点来自分享链接:先 `POST /api/nodes/import` 带 `{"text":"…","commit":false}` 预览(返回每条的解析结果与自动去重后的名字);用户授权导入且结果符合目标时,备份后再 `commit:true` 入库。只要求预览时到预览为止
- 节点名是分流的关键:必须保留出口词尾缀(客户端里没有标签,用户靠名字区分直连/WARP 线路);地区组按名称正则收编,涉及新地区先读 `docs/ADDING-REGIONS.md`
- 名称不能含配置分隔符、引号、反斜线、控制字符或首尾空格,不能与组或内置策略重名;导入预览与 commit 使用同一字段验证。Surge 当前排除 SS over WS、非零 alterId 及不支持加密方式的 VMess,核对实际输出而非只看节点总数
- 临时下线用 `PATCH /:id/enabled {"enabled":false}`,不要删——IR 层会自动剔除停用节点,删了凭据就没了
- 真要删除:先 GET 全量字段向用户展示将消失的内容,确认后 DELETE
- 真要删除:先 GET 全量字段核对对象,向用户展示名称及影响(不展示密码/UUID);删除范围已有明确授权时按流程 DELETE,否则先确认
### 策略组 `/api/groups`
- `members` 是**整体替换**:GET 全量 → 改数组 → PUT 回去,别只发改动项
- 成员三态:`{kind:"node", nodeId}`/`{kind:"group", refName}`/`{kind:"builtin", refName}`
- `filterRegex` 与 `includeAllNodes` 可共存,**filterRegex 优先**(动态筛选用它,新增同地区节点自动进组)
- `filterRegex` 与 `includeAllNodes` 可共存;Surge 保留显式节点/内置成员,全部成员组成为动态来源,正则只筛动态来源。新增同地区节点按名称进入组
- 成员必须存在且类型正确,图无环;禁用组在 IR 中被剔除,不要新写指向停用组的规则
- 改名后端会级联引用,但已导入客户端的旧组名会失配——改名前先向用户点明
- url-test/fallback 组别忘了 `testUrl`/`interval`/`tolerance`
- `testUrl` 用于 Clash/ShadowRocket;Surge 用 General 的 `proxy-test-url`,组级 `url=` 无效。fallback 不使用 tolerance,url-test 可显式设 0
### 规则 `/api/rules`
- 顺序由 `PUT /api/rules/reorder` 的**全量 ids 数组**决定(GET 拿现有顺序 → 调整 → 全量提交);自定义规则置顶是既有约定
- FINAL 规则永远保留;其策略组失效时 IR 层降级 DIRECT——兜底存在,但别依赖
- 顺序由 `PUT /api/rules/reorder` 的**全量、无重复 ids 数组**决定,启用的唯一 FINAL 必须末尾。自定义创建置顶,但有明确顺序意图时按完整列表调整;业务例外在覆盖它的集合之前,特定 UDP 拦截在服务域名规则之前
- FINAL 不可停用、删除、改类型或重复创建;失效策略由 IR 降级 DIRECT。不要借防御性兜底制造无效数据库状态
- 新规则的 policy 必须已存在(内置 DIRECT/REJECT… 或现有组名),后端会校验,报错就先建组
- `POST /api/rules/reset` 会重置回模板初版并**清掉全部自定义规则**——除非用户明说「重置」,否则永远不调用
- `POST /api/rules/reset` 事务内恢复模板并**保留自定义规则**;不是定向更新生产规则的方法,除非用户明确要求重置,否则不调用
- 靠前 IP 集是否加 `no-resolve` 按意图决定;主动解析可能绕过后续业务规则,no-resolve 又会改变对未解析域名的覆盖。URL-REGEX 值只检查 HTTP URL,不能拿 SMTP 文字当邮件协议匹配
### 规则集 `/api/rulesets`(只读)
- `GET /api/rulesets` 看清单,`GET /:id/content` 看自托管转换结果
@@ -78,6 +87,8 @@ bash .claude/skills/update-db/scripts/api.sh health
### 设置 `/api/settings`
- `surgeSections` 是 Surge 原文段落透传,改错直接毁掉 Surge 配置;动它之前先 GET 存档 + backup,改完拉 `/sub/<token>/surge` 全文核对
- 保留手写 Host 的精确 DNS 例外,不将全局 DNS 或全部端口 53 送到 AI;设置变化也须实际 HTTPS 订阅与官方 CLI 验证
- 共享订阅默认不启用控制接口,不使用公开模板口令;确需远程控制时使用设备独立凭据与访问限制。MITM 不关闭上游证书验证,启用效果另核对设备 CA 与开关
## 回滚
@@ -87,5 +98,5 @@ bash .claude/skills/update-db/scripts/api.sh health
## 边界
- 不改代码、不做部署(那是 deploy Skill 的事);不动 nginx/证书/`ADMIN_TOKEN`/systemd 单元
- 删除类操作与 reset:先列出将删除/重置的完整清单,用户确认后再执行
- 删除类操作与 reset:先核对并说明对象清单与影响;未明确授权时先确认,已有明确授权且对象一致时直接按流程执行
- 节点凭据是真凭据:汇报里不要粘贴密码/UUID 全文,用节点名指代

View File

@@ -3,6 +3,7 @@
#
# 用法:
# api.sh api METHOD PATH [JSON] # 调管理接口;PATH 以 /api/ 或 /sub/ 开头;JSON 作第三参
# api.sh api METHOD PATH --body-file FILE # 含凭据或复杂 JSON 优先用权限 600 的文件,避免放入 argv
# api.sh backup <context> # 变更前快照 → data/backups/before-<context>-<时间戳>.db
# api.sh backups # 列出最近 20 个备份
# api.sh counts # 各表行数,变更前后对比用
@@ -33,12 +34,19 @@ cmd="$1"; shift
case "$cmd" in
api)
[ $# -ge 2 ] || die "用法:api.sh api METHOD PATH [JSON]"
METHOD="$1"; APIPATH="$2"; BODY="${3:-}"
METHOD="$1"; APIPATH="$2"; BODY="${3:-}"; BODY_FILE=""
if [ "$BODY" = "--body-file" ]; then
BODY_FILE="${4:-}"; BODY=""
[ -n "$BODY_FILE" ] && [ -f "$BODY_FILE" ] || die "--body-file 需要可读的 JSON 文件"
fi
case "$METHOD" in GET|POST|PUT|PATCH|DELETE) ;; *) die "METHOD 只能是 GET/POST/PUT/PATCH/DELETE";; esac
case "$APIPATH" in /api/*|/sub/*) ;; *) die "PATH 必须以 /api/ 或 /sub/ 开头";; esac
case "$APIPATH" in *[!a-zA-Z0-9_./?=%\&~:+-]*) die "PATH 含不安全字符,请先做 URL 编码";; esac
# 单引号段内的 $ADMIN_TOKEN 与 ${PORT:-3000} 留给远端 shell 展开:token 不经本地 shell、不落本地磁盘
RCMD='. '"$ENV_FILE"' 2>/dev/null; curl -sS -m 60 -X '"$METHOD"' -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" -w "\n__HTTP_STATUS:%{http_code}__" http://127.0.0.1:${PORT:-3000}'"$APIPATH"
if [ -n "$BODY" ]; then
RCMD='. '"$ENV_FILE"' 2>/dev/null; curl -sS -m 60 -X '"$METHOD"' -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" -w "\n__HTTP_STATUS:%{http_code}__" "http://127.0.0.1:${PORT:-3000}'"$APIPATH"'"'
if [ -n "$BODY_FILE" ]; then
"${SSH[@]}" "$RCMD --data-binary @-" < "$BODY_FILE"
elif [ -n "$BODY" ]; then
printf '%s' "$BODY" | "${SSH[@]}" "$RCMD --data-binary @-"
else
"${SSH[@]}" "$RCMD"