feat: 合并边缘节点运维并完善 Surge 配置安全
迁入通用节点部署工具、离线交付页生成器和共用技能,隔离真实配置与交付产物。统一 Codex 与 Claude Code 开发约定,补充节点迁移与运维文档。 完善 FINAL、策略组引用、输入校验、Surge 字段编码与 AI DNS 例外保护,增加隔离测试和订阅验证工具。 验证:全量类型检查、44 项单测、前端构建、脚本语法、技能入口与凭据隔离检查均通过。
This commit is contained in:
6
.claude/skills/edge-node-multi-protocol/.gitignore
vendored
Normal file
6
.claude/skills/edge-node-multi-protocol/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
scripts/config.env
|
||||
scripts/config.env.*
|
||||
!scripts/config.env.example
|
||||
scripts/node-links/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
35
.claude/skills/edge-node-multi-protocol/SKILL.md
Normal file
35
.claude/skills/edge-node-multi-protocol/SKILL.md
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
name: edge-node-multi-protocol
|
||||
description: 部署、重建或维护边缘 VPS 节点,管理 nginx、3x-ui/Xray、Cloudflare WARP、证书及可选 sing-box QUIC 线路,生成离线分享链接与交付文档。用于节点部署管理;Proxy Station 应用发布用 deploy,订阅数据库维护用 update-db。
|
||||
---
|
||||
|
||||
# 边缘节点部署与维护
|
||||
|
||||
本项目统一管理边缘节点与 Proxy Station。目录、站点清单和迁移说明见仓库根目录的 `edge-nodes/README.md`;项目约定以根目录 `AGENTS.md` 为准。
|
||||
|
||||
## 工作目录与输入
|
||||
|
||||
- 通用脚本、参考资料、交付模板在本技能的 `scripts/`、`references/`、`templates/`,只保留占位配置。
|
||||
- 当前舰队的真实配置、分享链接、二维码和交付文档在 `edge-nodes/private/<id>-edge-node-docs/`;该目录被 Git 忽略。rn 当前部署使用 `rn2-edge-node-docs/`,`rn-edge-node-docs/` 留作历史交付。
|
||||
- 既有节点先读其交付文档与 `scripts/config.env`,确认实际服务、证书路径和脚本差异。新增节点把通用资源复制到新的私有节点目录,再从 `config.env.example` 填写 `config.env`。不要在技能源目录填写真实配置。
|
||||
- `.sh` 是本机执行的 SSH 编排脚本;`08-xui-bootstrap.py`、`08b-embed-settings-clients.py`、`13-singbox-config.py`、`16-add-socks5.py` 在 VPS 上运行;`14-*`、`15-*` 链接生成器在本机运行。不要按文件编号盲目执行所有文件。
|
||||
|
||||
## 按任务读取流程
|
||||
|
||||
执行前读 `references/pitfalls.md`。完整 aaPanel 部署步骤见 `references/workflow.md`;裸 nginx、QUIC、升级与脚本选择见 `references/standalone-and-quic.md`。3x-ui 数据结构见 `references/3x-ui-schema.md`,DNS 与集中签证书分别见 `references/dns-records.md`、`references/centralized-certs.md`。
|
||||
|
||||
部署或调整先明确目标站点与用户授权范围;已授权的步骤直接完成。现役节点修改前备份受影响的配置、3x-ui 数据库与证书续期钩子,只执行任务需要的步骤。不要因修改本地代码或迁移文件而自动上线。
|
||||
|
||||
## 关键约束
|
||||
|
||||
- TCP 线路是 VMess/Trojan/Shadowsocks × direct/WARP,3x-ui/Xray 承载,nginx 在 TCP 443 按 WS 路径反代。Hysteria2/TUIC 由 sing-box 独立承载 UDP,不写入 3x-ui;WARP 共用本机 SOCKS5 出口。
|
||||
- 3x-ui 的 `settings.clients` 必须保存真实客户端记录;不能只写 `clients`/`client_inbounds` 投影表,否则升级迁移会丢掉认证。新 bootstrap 已包含此修复,旧节点补齐使用 `08b-embed-settings-clients.py`。
|
||||
- aaPanel 使用自身的 `/etc/init.d/bt restart` 与 `/etc/init.d/nginx reload`;裸 nginx 才使用 systemd。更新证书续期动作时保留已有站点复制与 reload 动作,再增加 sing-box restart,不能覆盖整个旧钩子。
|
||||
- Surge 不支持 SS over WS;客户端下发能力以 `shared/src/protocols.ts` 为准。Proxy Station 当前仅导入 VMess/Trojan/Shadowsocks/Hysteria2,TUIC/SOCKS5 不能导入。
|
||||
- 凭据、真实域名与控制面路径不放入通用代码、示例、对话或在线二维码服务。生成链接前设 `umask 077`,二维码用本机 `qrencode`。
|
||||
|
||||
## 交付与验证
|
||||
|
||||
按实际服务跑对应 smoke test,分别核对 WS 101、面板与门面、直连 IP/WARP 出口;QUIC 另做客户端连接和出口验证,并核对云安全组。脚本语法或 `sing-box check` 通过不等于实际连接验收。
|
||||
|
||||
完成后更新私有节点文档及 `edge-nodes/private/fleet.json`,在仓库根目录执行 `python3 edge-nodes/build-delivery-doc.py`。分享链接经 Web 导入 Proxy Station;若任务包含生产订阅数据更新,继续使用 `update-db` 的备份与管理 API 流程,并按 `docs/SURGE-CHANGES.md` 校验实际订阅。报告已验证结果与尚未做的真机验证。
|
||||
@@ -0,0 +1,142 @@
|
||||
# 3x-ui database schema notes
|
||||
|
||||
Read this before writing a script that adds a new protocol or inbound by hand.
|
||||
Everything here was confirmed by reading the live sqlite DB (`/etc/x-ui/x-ui.db`)
|
||||
and the generated runtime config (`/usr/local/x-ui/bin/config.json`) on a real
|
||||
box — not from 3x-ui's own documentation, which doesn't cover this.
|
||||
|
||||
## Why DB-direct instead of the panel API
|
||||
|
||||
The panel HTTP API 403s unauthenticated/scripted requests on the builds this
|
||||
skill has been tested against (security middleware). Every inbound this skill
|
||||
adds is written straight into the sqlite DB, followed by `systemctl restart
|
||||
x-ui` to pick up the change. `08-xui-bootstrap.py` is the reference
|
||||
implementation — read it before writing a new one-off script.
|
||||
|
||||
## Relevant tables
|
||||
|
||||
## The single-client model (why one `node-main` owns all six inbounds)
|
||||
|
||||
This skill deliberately creates **one** client, `node-main`, linked to all six
|
||||
inbounds — not one client per protocol. Because the `clients` row is
|
||||
protocol-generic (it carries both a `uuid` and a `password`), that single row
|
||||
authenticates VMess (by its `uuid`) *and* Trojan+Shadowsocks (by its
|
||||
`password`) depending on which inbound the traffic hits. The payoff is entirely
|
||||
in the panel/subscription UX: the Clients page shows one row, its Info dialog
|
||||
lists all six share links + QR codes, and its one subscription (by `sub_id`)
|
||||
carries all six nodes. Trojan and Shadowsocks therefore **share one password**
|
||||
(`NODE_PASSWORD`) — intended for a single-user node.
|
||||
|
||||
**`inbounds`** — one row per inbound (listener). Columns that matter:
|
||||
`user_id` (**set to 1** — a NULL/empty user_id makes the inbound invisible in
|
||||
the panel even though it works; see pitfalls.md), `port`, `protocol`
|
||||
(`vmess`/`trojan`/`shadowsocks`/...), `listen` (`127.0.0.1` here — nginx does
|
||||
the public-facing TLS), `settings` (protocol-specific JSON, see below),
|
||||
`stream_settings` (transport JSON — this skill uses
|
||||
`{"network":"ws","security":"none","wsSettings":{...},"externalProxy":[{...}]}`;
|
||||
`security` is `"none"` because nginx already terminated TLS upstream, and the
|
||||
`externalProxy` entry makes generated share links point at `<cover>:443` over
|
||||
TLS instead of the useless internal `127.0.0.1:<port>` — see pitfalls.md),
|
||||
`tag` (used to target this inbound in routing rules).
|
||||
|
||||
**`clients`** — a *protocol-generic* credential table, not one table per
|
||||
protocol. Relevant columns: `email` (must be unique — this skill uses ONE email,
|
||||
`node-main`, for all six inbounds), `uuid` (used by vmess/vless), `password`
|
||||
(used by trojan/shadowsocks), `sub_id` (groups this client's inbounds into one
|
||||
subscription), `security`/`flow` (vmess/vless-only). There is no column that
|
||||
says which protocol a client belongs to — that's implied entirely by which
|
||||
inbound(s) it's linked to via `client_inbounds`, and 3x-ui's backend picks
|
||||
`uuid` vs `password` based on *that inbound's* `protocol` field when it builds
|
||||
the live xray config. So node-main sets both `uuid` and `password`.
|
||||
|
||||
**`client_inbounds`** — links a client to an inbound: `(client_id, inbound_id,
|
||||
flow_override, created_at)`. One row per (client, inbound) pair — node-main gets
|
||||
six rows (one per inbound).
|
||||
|
||||
**`client_traffics`** — traffic-stat tracking, **one row per client `email`,
|
||||
not per inbound** (email is globally unique in this table). Insert exactly one
|
||||
row for `node-main`, pointing `inbound_id` at any one of its linked inbounds
|
||||
(which one doesn't matter — it's just where the panel attributes the stats row).
|
||||
|
||||
**`settings`** (key/value store) — the `xrayTemplateConfig` key holds the JSON
|
||||
that 3x-ui actually feeds to xray-core (merged with the DB-derived inbounds at
|
||||
runtime). This skill's WARP outbound + routing rule live here. **Read-modify-write
|
||||
this, never overwrite it wholesale** — the WARP routing rule's `inboundTag`
|
||||
array needs the new protocol's warp-inbound tag *appended*, not replaced,
|
||||
or a previous protocol's warp routing silently stops working.
|
||||
|
||||
## `settings` JSON shape per protocol (in the `inbounds.settings` column)
|
||||
|
||||
```jsonc
|
||||
// vmess
|
||||
{"clients": [{"id": "<uuid>", "email": "node-main", "enable": true, "subId": "<sub-id>", "alterId": 0}], "disableInsecureEncryption": false}
|
||||
// trojan
|
||||
{"clients": [{"password": "<NODE_PASSWORD>", "email": "node-main", "enable": true, "subId": "<sub-id>"}], "fallbacks": []}
|
||||
// shadowsocks — note the TOP-LEVEL "password" (prevents the v3.5.0 link-gen panic, see pitfalls.md)
|
||||
{"clients": [{"password": "<NODE_PASSWORD>", "method": "chacha20-poly1305", "email": "node-main", "enable": true, "subId": "<sub-id>"}], "method": "chacha20-poly1305", "password": "<NODE_PASSWORD>", "network": "tcp,udp"}
|
||||
```
|
||||
|
||||
Keep the client embedded in `settings.clients` on BOTH schema generations.
|
||||
The normalized `clients` and `client_inbounds` tables are derived projections;
|
||||
upgrade/import migration rebuilds them from this JSON. An empty array may
|
||||
appear to work until migration removes the projected credentials. The migrated
|
||||
`08-xui-bootstrap.py` writes both representations; existing table-only nodes
|
||||
are repaired by `08b-embed-settings-clients.py` after a database backup.
|
||||
Use the full client shape in those scripts, including explicit `enable: true`
|
||||
and consistent subscription/quota fields across all six inbounds. Verify the
|
||||
generated `/usr/local/x-ui/bin/config.json` after restarting. Minimal protocol
|
||||
credential fields (the common fields above are also required):
|
||||
|
||||
```jsonc
|
||||
// vmess client — all three use the SAME email "node-main" (one client)
|
||||
{"id": "<uuid>", "email": "node-main", "alterId": 0}
|
||||
// trojan client
|
||||
{"password": "<NODE_PASSWORD>", "email": "node-main"}
|
||||
// shadowsocks client
|
||||
{"password": "<NODE_PASSWORD>", "method": "chacha20-poly1305", "email": "node-main"}
|
||||
```
|
||||
|
||||
## Adding a fourth protocol later (e.g. VLESS+REALITY)
|
||||
|
||||
1. Add its ports/paths/credential to `config.env` (follow the `PORT_<PROTO>_{DIRECT,WARP}` /
|
||||
`WS_PATH_<PROTO>_{DIRECT,WARP}` naming already used for vmess/trojan/ss).
|
||||
2. Add a `(protocol, port_direct, port_warp, path_direct, path_warp)` tuple to
|
||||
the `PROTOCOLS` list in `08-xui-bootstrap.py`, and its client-shape branch
|
||||
to `inbound_settings()` (REALITY additionally needs `realitySettings` in
|
||||
`stream_settings` with a real "borrowed" target site — this is per-inbound
|
||||
TLS-mimicry config the outer nginx doesn't touch, so `security` in
|
||||
`stream_settings` would need to be `"reality"` for that inbound specifically
|
||||
instead of `"none"`, and you'd route it directly rather than through the
|
||||
nginx WS+TLS cover this skill uses for the other three, since REALITY does
|
||||
its own TLS handshake and can't be terminated by an upstream nginx first).
|
||||
3. Add the new WS paths to `PROXIES` in `09-aapanel-sites.py` (skip this for
|
||||
REALITY specifically, since it bypasses nginx as noted above and would need
|
||||
its own firewall port opened instead).
|
||||
4. Add the new protocol's outbound block to the smoke-test client generator in
|
||||
`12-smoke-test.sh`.
|
||||
5. Re-run `08-xui-bootstrap.sh` (idempotent — existing inbounds are skipped by
|
||||
port) then `09-aapanel-sites.sh`, then `12-smoke-test.sh`. `08-xui-bootstrap.py`
|
||||
already sets `user_id=1`, adds the `externalProxy` link hint, links the new
|
||||
inbounds to the existing `node-main` client, and (for shadowsocks-family
|
||||
protocols) writes the top-level `password` — so a new protocol inherits all
|
||||
version-compat fixes as long as you extend `PROTOCOLS` and its client-shape
|
||||
logic rather than hand-inserting rows. Keep `settings.clients` populated
|
||||
for each added protocol as well as maintaining the projection tables.
|
||||
|
||||
## Subscription (optional, not enabled by this skill)
|
||||
|
||||
3x-ui's subscription server (`/sub/<sub_id>`) returns every share link for a
|
||||
client's `sub_id` — since node-main's `sub_id` covers all six inbounds, one
|
||||
subscription URL delivers all six nodes and stays in sync as you add/remove
|
||||
inbounds. It listens on its own port (2096 by default, HTTP) and is **off by
|
||||
default** in this skill. Two gotchas if you turn it on:
|
||||
- The sub port is **not** opened by `10-ufw.sh` (only 443 + panel ports are).
|
||||
Don't just `ufw allow 2096` — that exposes a plaintext-HTTP endpoint carrying
|
||||
every node's credentials on a guessable `IP:port`. Instead reverse-proxy a
|
||||
non-obvious subdomain (e.g. `sub.<root>`) → `127.0.0.1:2096` through nginx on
|
||||
443 with the wildcard cert, same pattern as the panels, so the subscription
|
||||
is HTTPS and hidden behind 443.
|
||||
- "Empty reply from server" on the sub port is almost always the firewall
|
||||
(connection reached UFW's default drop), not 3x-ui — verify by curling
|
||||
`http://127.0.0.1:2096/sub/<id>` **on the box** first (200 there = the
|
||||
service and links are fine, the problem is purely how it's exposed).
|
||||
@@ -0,0 +1,58 @@
|
||||
# Centralized wildcard certs (fleet option)
|
||||
|
||||
`03-sign-wildcard-cert.sh` signs the wildcard **on the box** — simplest for a single node.
|
||||
For a fleet you may prefer to sign every wildcard on **one** host and push to the rest, so
|
||||
the Cloudflare DNS token lives in exactly one place and renewals are centralized.
|
||||
|
||||
## Model
|
||||
|
||||
```
|
||||
one signer host (acme.sh + CF token)
|
||||
┌───────────────────────────────────┐
|
||||
│ acme.sh --dns dns_cf │ issues *.nodeA.example.com,
|
||||
│ --reloadcmd distribute.sh │ *.nodeB.example.com, …
|
||||
└───────────────┬───────────────────┘
|
||||
│ scp fullchain+key on every renew
|
||||
┌─────────────┼─────────────┐
|
||||
nodeA nodeB nodeC (BT cert dirs + nginx reload)
|
||||
```
|
||||
|
||||
## Signer setup (once)
|
||||
|
||||
```bash
|
||||
export CF_Token='<token>'
|
||||
acme.sh --set-default-ca --server letsencrypt
|
||||
acme.sh --issue --dns dns_cf -d nodeA.example.com -d '*.nodeA.example.com' --keylength ec-256
|
||||
```
|
||||
|
||||
## Distribution reloadcmd
|
||||
|
||||
A small script the signer runs after each renewal — for each target it scp's the cert into
|
||||
the aaPanel cert dirs and reloads nginx. Register it once:
|
||||
|
||||
```bash
|
||||
acme.sh --install-cert -d nodeA.example.com --ecc --reloadcmd "/root/acme-deploy/distribute.sh"
|
||||
```
|
||||
|
||||
`distribute.sh` (sketch — adapt hosts/sites to yours; **no real hosts here**):
|
||||
|
||||
```bash
|
||||
TARGETS=(
|
||||
"<nodeA_ip>:root:nodeA.example.com,<cover>.nodeA.example.com,<xui>.nodeA.example.com,<panel>.nodeA.example.com"
|
||||
)
|
||||
SRC=/root/.acme.sh/nodeA.example.com_ecc
|
||||
for entry in "${TARGETS[@]}"; do
|
||||
host="${entry%%:*}"; rest="${entry#*:}"; user="${rest%%:*}"; sites="${rest#*:}"
|
||||
scp -o BatchMode=yes "$SRC/fullchain.cer" "$user@$host:/tmp/.fc.pem"
|
||||
scp -o BatchMode=yes "$SRC/nodeA.example.com.key" "$user@$host:/tmp/.pk.pem"
|
||||
ssh "$user@$host" "IFS=,; for s in \$sites; do d=/www/server/panel/vhost/cert/\$s; mkdir -p \$d;
|
||||
install -m 644 /tmp/.fc.pem \$d/fullchain.pem; install -m 600 /tmp/.pk.pem \$d/privkey.pem; done;
|
||||
rm -f /tmp/.fc.pem /tmp/.pk.pem; /www/server/nginx/sbin/nginx -s reload"
|
||||
done
|
||||
```
|
||||
|
||||
## Trust + notes
|
||||
|
||||
- Put the signer's SSH **public** key in each target's `authorized_keys`; the signer needs write to the BT cert dirs + nginx reload only.
|
||||
- To add a region to an existing wildcard, `--issue` again with the extra `-d` names (SAN change requires a re-issue, not `--renew`), then extend `TARGETS`.
|
||||
- Keep the CF token on the signer only; targets never see it.
|
||||
@@ -0,0 +1,42 @@
|
||||
# DNS records this node needs (any provider)
|
||||
|
||||
The wildcard cert (`03-sign-wildcard-cert.sh`) proves domain control via **DNS-01** using
|
||||
whatever `ACME_DNS_PLUGIN` you set — acme.sh supports Cloudflare, DNSPod, Aliyun, Route53,
|
||||
GoDaddy, and dozens more. That part is provider-agnostic.
|
||||
|
||||
The only thing that is *not* automated for non-Cloudflare providers is creating the host
|
||||
**A/AAAA records**. That's just two names. Create them in your DNS provider's console/API:
|
||||
|
||||
| Name | Type | Value | Proxy/CDN |
|
||||
|---|---|---|---|
|
||||
| `<DOMAIN_ROOT>` (e.g. `nodeA.example.com`) | A | `SERVER_IP` | **off** (DNS-only / grey cloud) |
|
||||
| `*.<DOMAIN_ROOT>` | A | `SERVER_IP` | **off** |
|
||||
| `<DOMAIN_ROOT>` | AAAA | `SERVER_IPV6` | off (skip if no IPv6) |
|
||||
| `*.<DOMAIN_ROOT>` | AAAA | `SERVER_IPV6` | off |
|
||||
|
||||
Notes:
|
||||
- Keep them **unproxied** — the node terminates its own TLS and needs the real client IP path; a proxy/CDN in front would break the WS cover and the panel anti-scan logic.
|
||||
- TTL 300 is fine.
|
||||
- The wildcard `*.<DOMAIN_ROOT>` covers the landing / cover / panel subdomains, so you don't add per-subdomain records.
|
||||
|
||||
## Cloudflare shortcut
|
||||
|
||||
If your zone is on Cloudflare, skip the manual step — `02-cf-dns.py` upserts all four
|
||||
records for you via the API (`CF_API_TOKEN` with `Zone:DNS:Edit`). Run it from wherever the
|
||||
token lives; it never touches the VPS.
|
||||
|
||||
## Picking the acme.sh plugin + creds
|
||||
|
||||
Set `ACME_DNS_PLUGIN` and the matching credential vars in `config.env` (acme.sh reads them
|
||||
from the environment; `03-sign-wildcard-cert.sh` forwards whichever are set):
|
||||
|
||||
| Provider | `ACME_DNS_PLUGIN` | env vars |
|
||||
|---|---|---|
|
||||
| Cloudflare | `dns_cf` | `CF_API_TOKEN` (forwarded as `CF_Token`) |
|
||||
| DNSPod | `dns_dp` | `DP_Id`, `DP_Key` |
|
||||
| Aliyun | `dns_ali` | `Ali_Key`, `Ali_Secret` |
|
||||
| AWS Route53 | `dns_aws` | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` |
|
||||
| GoDaddy | `dns_gd` | `GD_Key`, `GD_Secret` |
|
||||
| others | see acme.sh dnsapi | per its README |
|
||||
|
||||
Full list: <https://github.com/acmesh-official/acme.sh/wiki/dnsapi>
|
||||
@@ -0,0 +1,52 @@
|
||||
# Pitfalls (read before you run)
|
||||
|
||||
Every item here cost real debugging time on a live deployment. They are ordered by how likely they are to bite you.
|
||||
|
||||
## aaPanel
|
||||
|
||||
- **Never `systemctl restart btpanel`.** `btpanel.service` is `enabled` but its `ExecStart` is `/etc/init.d/bt start` (SysV); the systemd unit shows `active: inactive (dead)` while the panel process (`webserver`) runs fine. `systemctl restart btpanel` stops the panel and it does **not** come back → your reverse-proxied panel returns **502**. To restart use `bt 1` or `/etc/init.d/bt start`. Same for nginx (`/etc/init.d/nginx`, not `systemctl`).
|
||||
- **Reverse-proxy needs `CDN_PROXY=True`** in `/www/server/panel/config/cdn.conf`, or the panel rejects proxied requests. Restart the panel after setting it.
|
||||
- **Anti-scan fake 404/403 — the UA has to look like a REAL browser, not just contain the word "Mozilla".** aaPanel returns a *fake* nginx 404 to requests it judges to be scanners. On aaPanel 7.x this check got noticeably stricter: a minimal UA like `Mozilla/5.0 (Macintosh)` (which used to be enough) now gets judged as a scanner and served the fake 404 — even hitting `/login` or `/` directly on the box over the unix socket gives the same fake 404 with that UA. Use a full, realistic UA string, e.g. `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/605.1.15`, and it works immediately. **If the entrance/domain/path all test as 404 no matter what you try, check the UA string completeness FIRST** — before you start doubting `CDN_PROXY`, domain binding, or the cert. This one costs the most debugging time of anything in this file. A real browser was never affected either way.
|
||||
- Also **403** on direct `IP:port` access once a panel domain is bound — that one's real and expected, not a bug.
|
||||
- **aaPanel 7.x moved several backend modules into a new `class_v2` directory** (e.g. `theme_config.py`). Scripts that do `sys.path.insert(0, "/www/server/panel/class")` and then `import panelSite` (or anything that transitively imports `common.py` → `theme_config`) will fail with `ModuleNotFoundError: theme_config` unless `/www/server/panel/class_v2` is *also* on `sys.path`. `09-aapanel-sites.py` already does this; if you hand-write a new script that touches panel internals, add the same `sys.path.insert(0, "/www/server/panel/class_v2")` line.
|
||||
- **Build sites through the panel, never hand-write vhosts.** A hand-written `/www/server/panel/vhost/nginx/*.conf` works in nginx but is invisible/unmanageable in the panel UI. Use the panel classes (`AddSite`/`SetSSL`/`CreateProxy`/`HttpToHttps`) — that's what `09-aapanel-sites.py` does. The panel HTTP API can't be driven by curl (CSRF + anti-scan); drive the classes with the panel's bundled `pyenv` python.
|
||||
- **nginx 1.24 uses `listen 443 ssl http2;`** — it does **not** support the newer `http2 on;` directive; that syntax fails `nginx -t`.
|
||||
- **The aaPanel install-wait loop must not use `pgrep -f install_7.0` to detect "still installing".** A command like `until run 'test -f BT-Panel && ! pgrep -f install_7.0'; do sleep 15; done` has a self-match bug: the check command's own argv literally contains the string `"install_7.0"`, so `pgrep -f` matches the check itself every time, and the condition can never go true — the loop spins forever even though the install finished in ~1 minute. `04-install-aapanel.sh` instead waits for the literal string `Congratulations` in the install log plus the `BT-Panel` binary existing. If you ever add a similar poll-by-pgrep check, make sure the pattern you grep for doesn't appear in the checking command's own command line (the classic fix elsewhere is `pgrep -f '[i]nstall_7.0'` — the bracket trick — but matching a log string is simpler and was chosen here).
|
||||
|
||||
## 3x-ui (newer versions, Xray 26.x) — multi-protocol notes
|
||||
|
||||
- **Keep `inbounds.settings.clients` populated.** The normalized `clients` and `client_inbounds` tables are projections. Upgrades/migrations rebuild them from settings JSON; a table-only deployment can lose all credentials. The migrated `08-xui-bootstrap.py` includes this fix; repair older nodes with `08b-embed-settings-clients.py` on the VPS after a database backup.
|
||||
|
||||
- **One client owns all six inbounds — don't create one client per protocol.** The whole point of the single-client design is that the panel's Clients page shows ONE row ("node-main") whose Info dialog lists all six share links + QR codes and a single subscription covering all six. That works because the `clients` table is protocol-generic (next bullet): one row carrying both a `uuid` and a `password`, linked via `client_inbounds` to all six inbounds. `08-xui-bootstrap.py` does exactly this. (An earlier iteration created three separate clients — node-main / trojan-main / ss-main — which "worked" for traffic but split the links across three panel rows and made the single-subscription view impossible; consolidating them later meant deleting two clients and re-pointing credentials. Build it as one client from the start.)
|
||||
- **The `clients` table is protocol-generic, not protocol-specific.** It has both a `uuid` column and a `password` column (plus VMess/VLESS-only fields like `security`/`flow`). VMess/VLESS inbounds read `uuid`; Trojan/Shadowsocks inbounds read `password`. There's no "protocol" column on `clients` itself — which field 3x-ui's backend actually uses depends on the *inbound's* `protocol` value at the point it builds the live xray config, not on anything stored on the client row. This isn't documented anywhere obvious; it was confirmed by reading the live generated `/usr/local/x-ui/bin/config.json` after inserting rows and restarting `x-ui`, and seeing the right credential land in the right field for each protocol. **Consequence:** with one client, Trojan and Shadowsocks share ONE password (that client's `password` column); VMess uses its `uuid`. That's intended for a single-user node — it's what `NODE_PASSWORD` in `config.env` is.
|
||||
- **`user_id` on an inbound must be set (1), or the inbound is INVISIBLE in the panel.** The panel's Inbounds and Clients lists filter by the logged-in admin's `user_id`. A DB-inserted inbound with a NULL/empty `user_id` exists in the DB and proxies traffic perfectly (xray loads it, smoke test passes), but the panel UI silently filters it out — you'll see "only the vmess inbounds show up" even though all six are there. `08-xui-bootstrap.py` sets `user_id=1` on every inserted inbound. If you hand-insert an inbound later, set it too (or `UPDATE inbounds SET user_id=1 WHERE user_id IS NULL OR user_id=''`).
|
||||
- **Every inbound needs an `externalProxy` in streamSettings, or the generated share links are useless.** These inbounds listen on `127.0.0.1` with `security:"none"` (nginx terminates TLS at the edge on `<cover>:443`). 3x-ui doesn't know the public address, so by default it generates links pointing at `127.0.0.1:<internal port>` with no TLS — which no client can use. Add `"externalProxy":[{"forceTls":"tls","dest":"<cover host>","port":443,"sni":"<cover host>","remark":"<name>"}]` to each inbound's streamSettings and every generated link/QR/subscription instead points at `<cover>:443` over TLS. `08-xui-bootstrap.py` does this. (This is a *link-generation* hint only — it's a 3x-ui-specific field that's stripped when building the live xray config, so it never affects the data path.)
|
||||
- **Shadowsocks inbound `settings` needs a TOP-LEVEL `password` field, or the panel's "copy links" endpoint returns an empty 200 and the UI shows "No response data".** 3x-ui v3.5.0's share-link generator does `settings["password"].(string)` — an *unchecked* Go type assertion. A normal (non-2022) SS inbound has no top-level password (the real per-client password lives on the client), so that assertion hits `nil` and **panics**. gin's recovery swallows the panic (its error writer is `io.Discard`, so nothing reaches `journalctl`), the `/panel/api/clients/subLinks/<subId>` endpoint returns HTTP 200 with an EMPTY body, and the frontend — seeing no parseable JSON — reports "No response data" and drops *all* the links (not just SS). The subscription server returns 500 for the same reason. Fix: write a top-level `"password"` into the SS inbound's `settings` (auth still goes through the client table; for chacha20 ciphers this field doesn't even appear in the emitted `ss://` link — it exists only to keep the assertion from panicking). `08-xui-bootstrap.py` sets it. Debugging note: this presents as "200 empty body", not an error status — so watch for empty-but-200 API responses, and remember the sub server's Go logs are discarded.
|
||||
- **`client_traffics.email` is GLOBALLY UNIQUE.** One client shared across two inbounds (e.g. a protocol's direct + warp inbound) gets **one** `client_traffics` row, not one per inbound — inserting two rows with the same email throws `UNIQUE constraint failed`.
|
||||
- **`client_inbounds` has a `flow_override` column** (client_id, inbound_id, flow_override, created_at) — include it in the INSERT even though it's usually empty; some 3x-ui builds may reject a 3-column insert if the schema doesn't line up with what an older reference implementation expected.
|
||||
- **Extend `xrayTemplateConfig`'s WARP routing rule, never overwrite the whole thing.** The WARP-egress inbounds' tags (and their `inbound-127.0.0.1:<port>` fallback form) need to be appended to the *existing* rule's `inboundTag` array. Re-generating and writing the whole `xrayTemplateConfig` from scratch (instead of read-modify-write) silently deletes whatever protocols were added in a previous run.
|
||||
- **Auth is by UUID/password**, so a mismatch between the traffic-stat email label and the running config email is cosmetic, not a fault.
|
||||
- **Protocol mismatch when testing.** A VLESS test client against a `vmess+ws` inbound is correctly rejected (`websocket close 1000`) — that's not a server bug, it's the wrong protocol. Match the test client's protocol (vmess/trojan/shadowsocks) exactly to the inbound you're testing, with the right `security`/`method`/`password` for that protocol.
|
||||
- **Panel API `/login` may 403** on newer builds (security middleware). Do DB-direct edits + `systemctl restart x-ui` instead of the HTTP API — confirmed true both for the original VMess bootstrap and again when adding Trojan/Shadowsocks later.
|
||||
- **Shadowsocks 2022-blake3-\* ciphers need an exact-byte-length base64 password/PSK** — get the length wrong and xray fails to start with no obvious error pointing at the cipher. `chacha20-poly1305` (or `aes-256-gcm`) takes a plain password string, is much harder to misconfigure, and is still a modern AEAD cipher — prefer it unless you have a specific reason to use the 2022 series.
|
||||
|
||||
## Cloudflare WARP
|
||||
|
||||
- **`gpg` must be installed first.** On a minimal Debian, `gpg --dearmor` for the repo keyring fails with `gpg: command not found`, and the later `apt update` then errors `Failed to parse keyring … sqv returned an error code`. `apt-get install -y gnupg` before adding the repo.
|
||||
- Use the **`bookworm`** repo line even on Debian 13 (trixie) — the bookworm package works.
|
||||
- **WARP can lie about being connected.** The control plane shows `Connected` while the SOCKS proxy stops egressing. The watchdog (`warp-healthcheck.*`) checks `warp=on` *through the proxy* and restarts `warp-svc` when it isn't.
|
||||
|
||||
## nginx / cover
|
||||
|
||||
- WS upgrade must return **101** with `--http1.1` + the `Upgrade`/`Connection` headers. If you test with HTTP/2 you'll get 400 (HTTP/2 doesn't carry the Upgrade frame).
|
||||
- Default server for unmatched `server_name` should `return 444` so scanners hitting the bare IP / wrong host get dropped.
|
||||
|
||||
## Monitoring
|
||||
|
||||
- **Komari** auto-discovery registers the node with an auto-generated name (`Auto-<hostname>`). Rename/group it in your panel afterward (panel-specific; often a small DB update or a UI action). The agent falls back to a v1 WebSocket if the panel has no v2 API — that's normal.
|
||||
- **Nezha** agent install one-liners change over time. If the scripted install fails, drop the `nezha-agent` binary + a `config.yml` (server, `tls`, `client_secret`, `uuid`) and a small systemd unit — the agent connects out to `SERVER:PORT` (gRPC), so only outbound is needed.
|
||||
|
||||
## SSH / access
|
||||
|
||||
- Some providers ship a `.pem`/`.ppk` bundle; use the `.pem`. `chmod 600` it or ssh refuses it.
|
||||
- Fresh boxes may run fail2ban; repeated failed auth from a dynamic IP can lock you out. Get your key in early (step 0) so you're not relying on password auth.
|
||||
@@ -0,0 +1,61 @@
|
||||
# Why VMess + Trojan + Shadowsocks, and when to add a fourth
|
||||
|
||||
This skill's default 6-inbound target (3 protocols × direct/WARP) was chosen for a specific
|
||||
combination of constraints: client compatibility (Surge excludes the Shadowsocks-over-WS lines; see the project capability matrix), transport safety (no UDP, so nothing here is exposed to networks that
|
||||
throttle UDP indiscriminately), and zero extra server software (all three are native to
|
||||
3x-ui/Xray-core — no sing-box, no separate Hysteria2/TUIC binary to maintain).
|
||||
|
||||
If your constraints are different, the right protocol set is different too. This file is the
|
||||
reasoning, not a rule — read it before assuming the default 3 are the "right" choice for a new
|
||||
node.
|
||||
|
||||
## The three defaults
|
||||
|
||||
| Protocol | Transport overhead | GFW-detection posture | Client support |
|
||||
|---|---|---|---|
|
||||
| VMess (+WS+TLS) | Two encryption layers (VMess's own AEAD, on top of the outer TLS) + WS framing — heaviest of the three | Weakest of the three; historically a specific target of active probing, and Xray-core itself now prints a deprecation warning for it | Nearly universal |
|
||||
| Trojan (+WS+TLS) | One layer (just the outer TLS) + WS framing — noticeably lighter than VMess | Stronger: Trojan's whole design is "indistinguishable from a real HTTPS site to anyone without the password" | Nearly universal, including Surge |
|
||||
| Shadowsocks (+WS+TLS) | Similar to Trojan — SS's own cipher is lightweight, WS+TLS is the same cover as the others | Comparable to VMess's cover level, but without VMess's specific deprecated-protocol baggage | Supported by compatible WS clients; Surge does not support SS over WS |
|
||||
|
||||
All three are plain TCP end-to-end between client and server (the WS layer rides on TCP), so
|
||||
none of them are exposed to networks that indiscriminately throttle UDP — that includes the
|
||||
"UDP forwarding" toggle some clients show for VMess/Trojan/SS: it tunnels UDP *payloads* inside
|
||||
the already-established TCP connection, it doesn't open a real UDP socket to the server.
|
||||
|
||||
## What "UDP is faster" actually means (and doesn't)
|
||||
|
||||
QUIC-based protocols (Hysteria2, TUIC) run on top of UDP, and that fact alone doesn't make them
|
||||
faster — raw UDP has no reliability or congestion control built in. Their speed advantage comes
|
||||
from what they build *on top of* UDP: no head-of-line blocking across multiplexed streams,
|
||||
faster connection setup/resumption, connection migration across network changes, and (for
|
||||
Hysteria2 specifically) an aggressive custom congestion-control algorithm designed for lossy
|
||||
links. That advantage is real and large specifically on **high-latency or high-packet-loss**
|
||||
networks. On a clean, low-latency network, well-implemented TCP+TLS (Trojan, VLESS) is often
|
||||
just as fast, sometimes faster (QUIC's per-packet encryption has real per-byte CPU cost that
|
||||
kernel-optimized TCP+TLS doesn't). Don't add a UDP-based protocol to this skill's target list on
|
||||
the assumption that "UDP is just faster" — add it because you specifically need resilience on
|
||||
bad networks or fast network-switching, and you've weighed that against UDP's other real risk:
|
||||
plenty of networks throttle/block UDP indiscriminately, which no amount of protocol-level
|
||||
cleverness fixes.
|
||||
|
||||
## When to add a fourth protocol
|
||||
|
||||
- **Client doesn't support any of the three (rare)** — e.g. something that only speaks VLESS or
|
||||
Hysteria2. Add exactly what that client needs; see `3x-ui-schema.md` for the
|
||||
mechanical steps (VLESS/VLESS+REALITY are native to 3x-ui/Xray-core, no extra software).
|
||||
- **You specifically need the strongest current anti-active-probing posture** — VLESS+REALITY
|
||||
(or Trojan+REALITY; Xray-core supports REALITY on both) borrows a real site's TLS handshake,
|
||||
which is currently the hardest thing for active probing to distinguish from genuinely visiting
|
||||
that site. Native to 3x-ui. Needs picking a "borrowed" target site with real TLS 1.3, decent
|
||||
traffic, and no active blocking of its own in your target region.
|
||||
- **You specifically need next-generation traffic-pattern resistance** — AnyTLS defends against
|
||||
statistical traffic-shape analysis (packet size/timing), which REALITY's handshake-mimicry
|
||||
doesn't address by itself. Not native to 3x-ui/Xray-core — needs sing-box installed
|
||||
server-side, as a separate service alongside 3x-ui (different ports, no conflict).
|
||||
- **You specifically need resilience on bad/mobile networks and already have a UDP fallback
|
||||
elsewhere** — Hysteria2 (preferred over TUIC: larger community, more obfuscation options,
|
||||
more battle-tested) if you don't already have a node covering this case. Also needs sing-box
|
||||
or a standalone Hysteria2 binary, not native to 3x-ui.
|
||||
- **Don't add a protocol "just in case."** Every inbound is one more thing to keep working
|
||||
through aaPanel/3x-ui/Xray version upgrades — see `pitfalls.md` for how much
|
||||
version drift alone (not malicious blocking) can break a working inbound.
|
||||
@@ -0,0 +1,47 @@
|
||||
# 裸 nginx、QUIC 与既有节点维护
|
||||
|
||||
脚本从各节点原工作副本迁入,历史 `-sg`/`-rn` 后缀保留;这些后缀表示流程分支,不表示脚本只能在该站点使用。所有 `.sh` 从本机通过 SSH 修改 VPS。命令中的 `scripts/` 指私有节点工作目录里的副本。
|
||||
|
||||
## 选择部署分支
|
||||
|
||||
| 步骤 | aaPanel 管理 nginx | 裸 nginx |
|
||||
|---|---|---|
|
||||
| 准备机器 | `00-preflight.sh` | `00-preflight-sg.sh`(含 swap、BBR、apt nginx) |
|
||||
| DNS | `02-cf-dns.py` | 同左 |
|
||||
| 证书 | `03-sign-wildcard-cert.sh` | `03-sign-cert-sg.sh`,证书在 `/etc/nginx/ssl/<root>/` |
|
||||
| nginx | `04-install-aapanel.sh`、`04b-install-nginx.sh`、`05-harden-aapanel.sh` | 已由 preflight 安装,跳过 aaPanel 步骤 |
|
||||
| WARP | `06-install-warp.sh`、`06b-warp-watchdog.sh` | 同左 |
|
||||
| TCP 节点 | `07-install-3xui.sh`、`08-xui-bootstrap.sh` | 同左 |
|
||||
| 站点 | `09-aapanel-sites.sh`、`09b-default-444.sh` | `09-nginx-sites-sg.sh` |
|
||||
| 防火墙 | `10-ufw.sh`(核对实际面板端口) | `10-ufw-sg.sh`(TCP 22/80/443) |
|
||||
| TCP 验收 | `12-smoke-test.sh` | `12-smoke-test-sg.sh` |
|
||||
|
||||
`11-monitoring.sh` 是可选监控安装。既有站点以自己的实际配置为准;不要把完整部署流水线当成日常维护命令。
|
||||
|
||||
## QUIC
|
||||
|
||||
在私有 `scripts/config.env` 设置所需的 `PORT_HY2_DIRECT`/`PORT_HY2_WARP` 或 `PORT_TUIC_DIRECT`/`PORT_TUIC_WARP`,未设置的线路跳过。Hysteria2 使用 `NODE_PASSWORD`,可设置 `HY2_OBFS_PASSWORD`(salamander)及带宽;TUIC 使用 `XUI_UUID`+`NODE_PASSWORD`。
|
||||
|
||||
裸 nginx 分支执行 `bash scripts/13-install-singbox-quic.sh`,它推送 `13-singbox-config.py`、生成 sing-box 配置、做 `sing-box check`、开放配置的 UDP 端口并启动服务。aaPanel 节点需要先按实际证书目录适配,不能直接采用默认 `/etc/nginx/ssl/<root>/`。
|
||||
|
||||
该脚本的续期动作面向裸 nginx,会设为 `systemctl reload nginx; systemctl restart sing-box`。既有机器若已有集中证书复制或其他服务动作,先读取并保留旧钩子,再合并;不能直接用此步骤替换旧配置。UFW 之外还要核对供应商云安全组。
|
||||
|
||||
`10-ufw-rn.sh` 是四条 QUIC 线路都存在时的历史防火墙分支,要求两条 HY2 与两条 TUIC 端口,不用于仅 HY2 的机器。
|
||||
|
||||
生成链接在本机执行:
|
||||
|
||||
```bash
|
||||
umask 077
|
||||
python3 scripts/14-generate-links-and-qr.py
|
||||
python3 scripts/15-generate-quic-links.py
|
||||
```
|
||||
|
||||
第二个脚本读取 `HY2_OBFS_PASSWORD`;仅 TUIC 时也保留该字段为空。新增占位配置默认 QUIC 端口为空,不会隐式部署 QUIC。配置生成器能省略混淆,但现有链接生成器始终写 salamander;生成 HY2 链接时应提供非空混淆密码,或先同步修改链接与服务端逻辑。
|
||||
|
||||
## 3x-ui 升级与 SOCKS5
|
||||
|
||||
新 `08-xui-bootstrap.py` 已写入 `settings.clients`,旧部署用 `08b-embed-settings-clients.py` 补齐。该 Python 文件在 VPS 上执行,默认会重启 x-ui;`XUI_DB` 可指定数据库,`RESTART_XUI=0` 可由任务安排重启。修改前备份数据库,完成后重新核对六条 TCP 线路与 WARP 规则。
|
||||
|
||||
`16-add-socks5.py` 是在 VPS 上运行的可选内部 SOCKS5 入站工具,通过 `SOCKS_PORT`、`SOCKS_USER`、`SOCKS_PASS`、`SERVER_IP` 等环境变量配置,默认监听公网且会重启 x-ui。按访问意图设置 `LISTEN`;用户名密码认证本身不加密。它不是标准六条线路部署步骤,也不是 Proxy Station 可导入类型。
|
||||
|
||||
这里保存的是已有部署工具的流程说明。涉及新版本安装或升级时,先核对官方版本兼容性,再在授权范围执行;本次工程迁移没有对生产节点执行这些脚本。
|
||||
175
.claude/skills/edge-node-multi-protocol/references/workflow.md
Normal file
175
.claude/skills/edge-node-multi-protocol/references/workflow.md
Normal file
@@ -0,0 +1,175 @@
|
||||
# aaPanel deployment workflow
|
||||
|
||||
This is the migrated aaPanel workflow. Run shell commands from the LOCAL private node workspace: Bash scripts invoke SSH and their Python companions run on the VPS. Standalone nginx, QUIC and upgrade repairs are documented in `standalone-and-quic.md`. Keep live config out of the skill source directory.
|
||||
|
||||
Turn a bare Debian/Ubuntu VPS into a fully managed edge node running **three proxy protocols side by side, each with a direct-egress and a Cloudflare-WARP clean-exit inbound — six inbounds in total.** This is the standard target of this skill, not an optional extra: a single protocol is a fragile bet (if it gets fingerprinted or a client stops supporting it, the whole node goes dark), and three protocol families sharing the same domain/cert/firewall costs almost nothing extra to run once the panel infrastructure is up.
|
||||
|
||||
**What it builds**
|
||||
|
||||
| Layer | Component | Result |
|
||||
|---|---|---|
|
||||
| Panel | aaPanel International (7.x) + nginx | `<SUB_PANEL>.<root>` reverse-proxied, secret-path entrance, hardened |
|
||||
| Proxy | 3x-ui (Xray) | `<SUB_XUI>.<root>` panel; **6 inbounds** behind nginx: VMess/Trojan/Shadowsocks × direct/WARP |
|
||||
| Clean exit | cloudflare-warp (proxy mode) | every protocol's WARP inbound egresses through Cloudflare WARP |
|
||||
| TLS | acme.sh DNS-01 (any provider) | wildcard `*.<root>` cert, auto-renew + reload, shared by all 6 inbounds |
|
||||
| DNS | Cloudflare API (or your provider, by hand) | `<root>` + `*.<root>` A/AAAA (DNS-only) |
|
||||
| Cover | nginx vhosts | landing page + node cover (6 WS paths) + default-server `444` |
|
||||
| Watchdog | systemd timer | WARP proxy self-heal every 60s |
|
||||
| Monitoring | Nezha + Komari agents (optional) | node appears on your panels |
|
||||
| Access | SSH key + optional shortcut | passwordless login |
|
||||
|
||||
> **This is a template, not a turnkey binary.** You supply your own domain, Cloudflare token, server IP, and passwords. No secrets, domains, or routing rules are baked in.
|
||||
|
||||
## Why three protocols instead of one
|
||||
|
||||
VMess, Trojan, and Shadowsocks(+WS/TLS) use TCP and are implemented by 3x-ui/Xray-core. Client support depends on the protocol/transport combination: Surge does not support Shadowsocks over WebSocket. Proxy Station filters unsupported combinations using `shared/src/protocols.ts`. Running all three from day one means: if a client only supports a subset (e.g. Surge doesn't do VLESS), you're already covered; if one protocol family gets fingerprinted, you have two independent fallbacks without touching the box again. See `protocol-choice.md` for the fuller reasoning (performance/detection tradeoffs, and how to add a fourth protocol like VLESS+REALITY later).
|
||||
|
||||
## Inputs to collect first
|
||||
|
||||
Fill a working copy of `scripts/config.env.example` → `config.env` (git-ignored by you). Collect these before touching the box:
|
||||
|
||||
- `SERVER_IP` / `SERVER_IPV6` — the new VPS public addresses (amd64 or arm64)
|
||||
- `SSH_USER` / `SSH_KEY` — `root` or a sudo user (scripts escalate via `sudo`), and the private key
|
||||
- `TZ` — server timezone (default `UTC`)
|
||||
- `DOMAIN_ROOT` — the sub-zone this node owns, e.g. `nodeA.example.com` (you get `*.nodeA.example.com`)
|
||||
- `ACME_DNS_PLUGIN` + creds — DNS provider for the wildcard cert. Default `dns_cf` (Cloudflare). Any acme.sh-supported provider works (DNSPod/Aliyun/Route53/GoDaddy/…) — set the plugin + its env vars (`dns-records.md`).
|
||||
- `CF_API_TOKEN` / `CF_ZONE_ID` — Cloudflare only: token with **Zone:DNS:Edit** (enables the turnkey A/AAAA record script). Non-CF users create the A/AAAA records themselves.
|
||||
- `SUB_PANEL` / `SUB_XUI` / `SUB_COVER` — the subdomain labels for the three planes. **If your zone is public, choose non-obvious labels** — don't reuse a guessable convention or people can enumerate your control planes from DNS.
|
||||
- `AAPANEL_USER` / `AAPANEL_ENTRANCE` — desired panel username + secret entrance path (e.g. `/somesecret`)
|
||||
- `XUI_USER` / `XUI_PATH` — 3x-ui username + webBasePath (e.g. `/somesecret/`)
|
||||
- **Six WS cover paths + two ports each** (`PORT_<PROTO>_{DIRECT,WARP}` / `WS_PATH_<PROTO>_{DIRECT,WARP}` for `VMESS`/`TROJAN`/`SS`) — pick your own, non-obvious, don't reuse a naming convention across them
|
||||
- `XUI_UUID` + `NODE_PASSWORD` (+ `XUI_SUBID`, `SS_METHOD` default `chacha20-poly1305`) — the credentials for the **single `node-main` client** that owns all six inbounds: `XUI_UUID` authenticates the two VMess inbounds, `NODE_PASSWORD` authenticates the two Trojan **and** two Shadowsocks inbounds (they share it — it's one client). Generate strong values at runtime; **store them in your password manager**, never in git.
|
||||
- monitoring (optional) — Nezha `SERVER:PORT` + shared key; Komari endpoint + auto-discovery key
|
||||
|
||||
## Workflow
|
||||
|
||||
For a fresh aaPanel node, follow the selected steps in order from the local private workspace. Shell scripts read `scripts/config.env` and invoke SSH; their Python companions run on the VPS. For an existing node, read its handover notes and run only the steps required by the task.
|
||||
|
||||
### 0. Verify access & prep
|
||||
```bash
|
||||
scripts/00-preflight.sh # confirms SSH, records specs, sets timezone, installs base pkgs (curl/gnupg/ufw)
|
||||
```
|
||||
Also drop **your** public key into the box's `~/.ssh/authorized_keys` for passwordless login.
|
||||
|
||||
### 1. Wildcard DNS records
|
||||
```bash
|
||||
python3 scripts/02-cf-dns.py # Cloudflare: upserts <root> + *.<root> A/AAAA -> SERVER_IP/IPV6 (proxied=false)
|
||||
```
|
||||
Non-Cloudflare users: create the same records by hand, see `dns-records.md`.
|
||||
|
||||
### 2. Wildcard TLS cert (acme.sh DNS-01)
|
||||
```bash
|
||||
scripts/03-sign-wildcard-cert.sh # on-box acme.sh --dns dns_cf, issues *.<root> (ECDSA P-256, Let's Encrypt)
|
||||
```
|
||||
One cert, shared by all 6 inbounds via the configured `<SUB_COVER>.<root>` cover domain. Fleet cert signing: `centralized-certs.md`.
|
||||
|
||||
### 3. aaPanel International + nginx
|
||||
```bash
|
||||
scripts/04-install-aapanel.sh # official install_7.0_en.sh (headless); waits for the log's success banner
|
||||
scripts/05-harden-aapanel.sh # sets username/password/entrance, binds panel domain, CDN_PROXY=True
|
||||
scripts/04b-install-nginx.sh # install_soft.sh 0 install nginx 1.24 (aaPanel-managed)
|
||||
```
|
||||
`pitfalls.md` has three aaPanel-7.x-specific fixes baked into these scripts already (install-wait logic, `class_v2` import path, anti-scan UA strictness) — read it if you're editing these rather than just running them.
|
||||
|
||||
### 4. Cloudflare WARP (clean exit)
|
||||
```bash
|
||||
scripts/06-install-warp.sh # cloudflare-warp, proxy mode SOCKS5 127.0.0.1:40000, verify warp=on
|
||||
scripts/06b-warp-watchdog.sh # installs warp-healthcheck.sh + systemd timer (self-heal every 60s)
|
||||
```
|
||||
|
||||
### 5. 3x-ui (Xray) + six inbounds under one client
|
||||
```bash
|
||||
scripts/07-install-3xui.sh # installs 3x-ui, sets panel 127.0.0.1:14533 + XUI_PATH + creds
|
||||
scripts/08-xui-bootstrap.sh # adds ALL SIX inbounds under ONE node-main client, in one pass
|
||||
```
|
||||
`08-xui-bootstrap.py` creates a **single `node-main` client** (carrying both the VMess UUID and the shared
|
||||
Trojan/Shadowsocks password) linked to all six inbounds — so the panel's Clients page shows one row whose
|
||||
Info dialog lists all six share links + QR codes and one subscription. It bakes in four version-compat
|
||||
fixes that are easy to miss and each cost real debugging on a live v3.5.0 box: `user_id=1` (else inbounds
|
||||
are invisible in the panel), an `externalProxy` link hint (else generated links point at the useless
|
||||
internal `127.0.0.1:<port>`), a top-level SS `password` (else the panel's copy-links endpoint panics and
|
||||
returns empty → "No response data"), and read-modify-write of the WARP routing rule (else re-runs wipe it).
|
||||
It writes directly into 3x-ui's sqlite DB rather than using the panel API (which 403s scripted requests on
|
||||
this build) — see `3x-ui-schema.md` and `pitfalls.md` for
|
||||
the table layout if you need to add a 4th protocol or debug a client not authenticating.
|
||||
|
||||
### 6. aaPanel sites (panel-native, UI-manageable)
|
||||
```bash
|
||||
scripts/09-aapanel-sites.sh # AddSite + SetSSL + CreateProxy (x6 for cover) + HttpToHttps, via panel classes
|
||||
scripts/09b-default-444.sh # unmatched server_name -> return 444; drop landing page
|
||||
```
|
||||
|
||||
### 7. Firewall + monitoring (optional)
|
||||
```bash
|
||||
scripts/10-ufw.sh # allow 22/80/443 + panel port + aaPanel 888/39000-40000
|
||||
scripts/11-monitoring.sh # optional: install Nezha + Komari agents (placeholders in config.env)
|
||||
```
|
||||
No extra firewall ports needed for the 6 inbounds — they're all `127.0.0.1`-only, reverse-proxied through nginx on 443.
|
||||
|
||||
### 8. Smoke test — all six inbounds
|
||||
```bash
|
||||
scripts/12-smoke-test.sh
|
||||
```
|
||||
Checks: WS upgrade returns 101 on all 6 cover paths; landing/3x-ui/aaPanel panels return 200 (aaPanel test
|
||||
uses a full realistic UA string, see pitfalls); an unmatched hostname gets dropped (444); and — via a
|
||||
temporary on-box xray client covering all 6 outbounds — every `*-direct` egress IP equals `SERVER_IP` with
|
||||
`warp=off`, and every `*-warp` egress IP is a Cloudflare address with `warp=on`.
|
||||
|
||||
### 9. Generate client links + QR codes
|
||||
```bash
|
||||
python3 scripts/14-generate-links-and-qr.py # run LOCALLY, not on the box
|
||||
```
|
||||
Writes `vmess://`, `trojan://` and Xray-style `ss://` share links + QR codes via local `qrencode`, plus sing-box JSON snippets for Shadowsocks. Proxy Station parses these SS links; other clients may require transport-specific import. Nothing is sent to a third-party service. See
|
||||
`templates/02-node-info-template.md` for how to hand these to a client.
|
||||
|
||||
### 10. Documentation
|
||||
Fill in the three templates in `templates/` (handover doc, node info, ops guide) with this node's real
|
||||
values — see "Documentation templates" below.
|
||||
|
||||
## Domains this creates
|
||||
|
||||
With `DOMAIN_ROOT=nodeA.example.com` and example labels `SUB_COVER=edge`, `SUB_XUI=gateway`, `SUB_PANEL=console` (pick your own):
|
||||
|
||||
| Domain | Role | Backend |
|
||||
|---|---|---|
|
||||
| `nodeA.example.com` | landing page | static |
|
||||
| `<SUB_COVER>.nodeA.example.com` | node cover: 6 WS paths, one per inbound | 127.0.0.1:57707-57712 |
|
||||
| `<SUB_XUI>.nodeA.example.com` | 3x-ui panel | 127.0.0.1:14533 |
|
||||
| `<SUB_PANEL>.nodeA.example.com` | aaPanel | 127.0.0.1:\<panel port\> |
|
||||
| `*.nodeA.example.com` (default) | unmatched → `return 444` | — |
|
||||
|
||||
> The labels above are just examples. **Never publish the real labels/paths you pick** — on a public zone they are the map to your control planes and tunnels.
|
||||
|
||||
## Adding a protocol to an already-provisioned node
|
||||
|
||||
You don't need to redo steps 0-4. Add the new protocol's env vars to `config.env`, extend the `PROTOCOLS`
|
||||
list in `scripts/08-xui-bootstrap.py` and the `PROXIES` loop in `scripts/09-aapanel-sites.py` (both scripts
|
||||
are idempotent — re-running them skips ports/sites that already exist), re-run `08-xui-bootstrap.sh` then
|
||||
`09-aapanel-sites.sh`, then re-run the smoke test with the new outbound added. Full walkthrough with the
|
||||
concrete steps used to add Trojan+Shadowsocks to a VMess-only node: `3x-ui-schema.md`.
|
||||
|
||||
## Documentation templates
|
||||
|
||||
`templates/01-handover-template.md`, `templates/02-node-info-template.md`, `templates/03-ops-guide-template.md`
|
||||
are meant to be copied out and filled in with a real node's values after deployment (they're written with
|
||||
`{{PLACEHOLDER}}` tokens, not a real node's domain/keys). See `../templates/README.md` for the full list of
|
||||
tokens each one needs.
|
||||
|
||||
## References
|
||||
|
||||
- `pitfalls.md` — **read first.** aaPanel 7.x version-compatibility fixes (install-wait logic, `class_v2`, anti-scan UA strictness), 3x-ui multi-protocol schema gotchas, WARP/nginx/monitoring/SSH pitfalls. Skipping it costs hours.
|
||||
- `3x-ui-schema.md` — the sqlite table layout 3x-ui uses, and how to add a 4th protocol.
|
||||
- `protocol-choice.md` — why VMess+Trojan+Shadowsocks, performance/detection tradeoffs vs VLESS+REALITY/AnyTLS/Hysteria2/TUIC, and when you'd want a 4th protocol.
|
||||
- `dns-records.md` — the exact records to create, and how to use a non-Cloudflare DNS provider.
|
||||
- `centralized-certs.md` — sign wildcards on one host and push to a fleet.
|
||||
|
||||
## Project entry points
|
||||
|
||||
Use the project root `AGENTS.md` and the shared `edge-node-multi-protocol` skill. The former standalone agent guides are migration snapshots, not active instructions.
|
||||
|
||||
## Privacy / safety notes
|
||||
|
||||
- Never commit `config.env`, cert keys, panel/xui passwords, or any UUID/password/token. Add them to your password manager.
|
||||
- The scripts contain **no** real domains, IPs, subscription rules, or routing policy — only placeholders. Keep it that way when you adapt them.
|
||||
- WARP mode here is for **your own** clean egress; it is not a bypass for anyone else's controls.
|
||||
- The link/QR-code generator (step 9) never sends anything to a third-party service — QR codes are generated fully offline with local `qrencode`.
|
||||
61
.claude/skills/edge-node-multi-protocol/scripts/00-preflight-sg.sh
Executable file
61
.claude/skills/edge-node-multi-protocol/scripts/00-preflight-sg.sh
Executable file
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env bash
|
||||
# No-aaPanel preflight for the SG node: verify SSH, add swap (low-RAM box),
|
||||
# set timezone, install nginx + acme deps directly from apt, authorize your key.
|
||||
. "$(dirname "$0")/_lib.sh"
|
||||
|
||||
echo "== SSH + specs =="
|
||||
run 'hostname; . /etc/os-release; echo "$PRETTY_NAME"; nproc; free -m | awk "/Mem/{print \$2\"MB\"}"; df -h / | tail -1'
|
||||
|
||||
echo "== swap (2G, low-RAM box needs it for installs) =="
|
||||
run 'if ! swapon --show | grep -q /swapfile; then
|
||||
fallocate -l 2G /swapfile || dd if=/dev/zero of=/swapfile bs=1M count=2048
|
||||
chmod 600 /swapfile; mkswap /swapfile >/dev/null; swapon /swapfile
|
||||
grep -q "/swapfile" /etc/fstab || echo "/swapfile none swap sw 0 0" >> /etc/fstab
|
||||
sysctl -w vm.swappiness=10 >/dev/null
|
||||
grep -q "vm.swappiness" /etc/sysctl.conf || echo "vm.swappiness=10" >> /etc/sysctl.conf
|
||||
fi
|
||||
free -h | awk "/Swap/{print \"swap: \"\$2}"'
|
||||
|
||||
echo "== timezone (${TZ:-UTC}) + base packages (nginx, acme deps) =="
|
||||
run "timedatectl set-timezone '${TZ:-UTC}' 2>/dev/null || true
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
# Freshly-booted cloud images run unattended-upgrades, which holds the dpkg lock
|
||||
# for minutes; without this wait apt fails while the script reports success.
|
||||
for i in \$(seq 1 60); do
|
||||
fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1 || break
|
||||
[ \$i = 1 ] && echo 'waiting for dpkg lock (unattended-upgrades)...'
|
||||
sleep 10
|
||||
done
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq curl wget gnupg ufw ca-certificates socat cron nginx >/dev/null || { echo 'ERROR: apt install failed'; exit 1; }
|
||||
command -v nginx >/dev/null || { echo 'ERROR: nginx missing after install'; exit 1; }
|
||||
systemctl enable --now nginx >/dev/null 2>&1 || true
|
||||
nginx -v 2>&1
|
||||
echo base-packages-ok"
|
||||
|
||||
# BBR + fq. Stock cloud images generally do NOT have it: this AWS Ubuntu 24.04 AMI
|
||||
# came up on cubic + fq_codel with tcp_bbr not even loaded, while the DMIT box ships
|
||||
# BBR pre-enabled in its VM template. Lifts every TCP inbound at once, no client change.
|
||||
echo "== kernel tuning: BBR congestion control =="
|
||||
run 'modprobe tcp_bbr 2>/dev/null || true
|
||||
echo tcp_bbr > /etc/modules-load.d/tcp_bbr.conf
|
||||
cat > /etc/sysctl.d/99-edge-node-bbr.conf <<CONF
|
||||
net.core.default_qdisc = fq
|
||||
net.ipv4.tcp_congestion_control = bbr
|
||||
CONF
|
||||
sysctl --system >/dev/null 2>&1
|
||||
CC=$(sysctl -n net.ipv4.tcp_congestion_control)
|
||||
QD=$(sysctl -n net.core.default_qdisc)
|
||||
echo "congestion=$CC qdisc=$QD"
|
||||
[ "$CC" = bbr ] || echo "WARN: BBR not active — kernel may lack tcp_bbr (available: $(sysctl -n net.ipv4.tcp_available_congestion_control))"'
|
||||
|
||||
# Authorize YOUR public key for passwordless login.
|
||||
MYPUB="$(cat "$HOME/.ssh/id_ed25519.pub" 2>/dev/null || cat "$HOME/.ssh/id_rsa.pub" 2>/dev/null || true)"
|
||||
if [ -n "$MYPUB" ]; then
|
||||
run "mkdir -p ~/.ssh && chmod 700 ~/.ssh
|
||||
grep -qF '$MYPUB' ~/.ssh/authorized_keys 2>/dev/null || echo '$MYPUB' >> ~/.ssh/authorized_keys
|
||||
chmod 600 ~/.ssh/authorized_keys; echo authorized-key-added"
|
||||
else
|
||||
echo "WARN: no local public key found; add yours to the box manually"
|
||||
fi
|
||||
echo "preflight done"
|
||||
24
.claude/skills/edge-node-multi-protocol/scripts/00-preflight.sh
Executable file
24
.claude/skills/edge-node-multi-protocol/scripts/00-preflight.sh
Executable file
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
# Verify SSH, record specs, set timezone, install base packages, authorize your key.
|
||||
. "$(dirname "$0")/_lib.sh"
|
||||
|
||||
echo "== SSH + specs =="
|
||||
run 'hostname; . /etc/os-release; echo "$PRETTY_NAME"; nproc; free -m | awk "/Mem/{print \$2\"MB\"}"; df -h / | tail -1'
|
||||
|
||||
echo "== timezone (${TZ:-UTC}) + base packages =="
|
||||
run "timedatectl set-timezone '${TZ:-UTC}' 2>/dev/null || true
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq curl wget gnupg ufw ca-certificates >/dev/null
|
||||
echo base-packages-ok"
|
||||
|
||||
# Authorize YOUR public key for passwordless login (edit path if needed).
|
||||
MYPUB="$(cat "$HOME/.ssh/id_ed25519.pub" 2>/dev/null || cat "$HOME/.ssh/id_rsa.pub" 2>/dev/null || true)"
|
||||
if [ -n "$MYPUB" ]; then
|
||||
run "mkdir -p ~/.ssh && chmod 700 ~/.ssh
|
||||
grep -qF '$MYPUB' ~/.ssh/authorized_keys 2>/dev/null || echo '$MYPUB' >> ~/.ssh/authorized_keys
|
||||
chmod 600 ~/.ssh/authorized_keys; echo authorized-key-added"
|
||||
else
|
||||
echo "WARN: no local public key found; add yours to the box manually for passwordless login"
|
||||
fi
|
||||
echo "preflight done"
|
||||
72
.claude/skills/edge-node-multi-protocol/scripts/02-cf-dns.py
Executable file
72
.claude/skills/edge-node-multi-protocol/scripts/02-cf-dns.py
Executable file
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Upsert <DOMAIN_ROOT> + *.<DOMAIN_ROOT> A/AAAA records in Cloudflare (DNS-only).
|
||||
Run wherever the CF token lives (your laptop / a bastion) — the token never touches the VPS.
|
||||
Reads config.env from the same dir. No real domains/IPs are baked in.
|
||||
"""
|
||||
import json, os, re, sys, urllib.request
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def load_env():
|
||||
env = {}
|
||||
path = os.path.join(HERE, "config.env")
|
||||
if not os.path.exists(path):
|
||||
sys.exit("ERROR: copy config.env.example -> config.env")
|
||||
for line in open(path):
|
||||
m = re.match(r'\s*([A-Z0-9_]+)\s*=\s*"?([^"#\n]*)"?', line)
|
||||
if m:
|
||||
env[m.group(1)] = m.group(2).strip()
|
||||
return env
|
||||
|
||||
|
||||
E = load_env()
|
||||
API = "https://api.cloudflare.com/client/v4"
|
||||
TOKEN = E["CF_API_TOKEN"]
|
||||
ROOT = E["DOMAIN_ROOT"]
|
||||
IPV4 = E.get("SERVER_IP", "")
|
||||
IPV6 = E.get("SERVER_IPV6", "")
|
||||
NAMES = [ROOT, "*." + ROOT]
|
||||
|
||||
|
||||
def req(method, path, body=None):
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
r = urllib.request.Request(API + path, data=data, method=method)
|
||||
r.add_header("Authorization", "Bearer " + TOKEN)
|
||||
r.add_header("Content-Type", "application/json")
|
||||
with urllib.request.urlopen(r, timeout=20) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def zone_id():
|
||||
zid = E.get("CF_ZONE_ID", "")
|
||||
if zid:
|
||||
return zid
|
||||
parent = E["PARENT_ZONE"]
|
||||
res = req("GET", "/zones?name=%s" % parent)
|
||||
if not res.get("result"):
|
||||
sys.exit("ERROR: zone %s not found for this token" % parent)
|
||||
return res["result"][0]["id"]
|
||||
|
||||
|
||||
ZONE = zone_id()
|
||||
|
||||
|
||||
def upsert(rtype, content):
|
||||
if not content:
|
||||
return
|
||||
existing = req("GET", "/zones/%s/dns_records?per_page=200&type=%s" % (ZONE, rtype))["result"]
|
||||
by_name = {r["name"]: r for r in existing}
|
||||
for name in NAMES:
|
||||
payload = {"type": rtype, "name": name, "content": content, "ttl": 300, "proxied": False}
|
||||
if name in by_name:
|
||||
res = req("PUT", "/zones/%s/dns_records/%s" % (ZONE, by_name[name]["id"]), payload)
|
||||
print("UPDATE %-4s %-28s -> %s %s" % (rtype, name, content, "OK" if res.get("success") else res.get("errors")))
|
||||
else:
|
||||
res = req("POST", "/zones/%s/dns_records" % ZONE, payload)
|
||||
print("CREATE %-4s %-28s -> %s %s" % (rtype, name, content, "OK" if res.get("success") else res.get("errors")))
|
||||
|
||||
|
||||
upsert("A", IPV4)
|
||||
upsert("AAAA", IPV6)
|
||||
print("DNS done for *.%s (DNS-only / grey cloud)" % ROOT)
|
||||
27
.claude/skills/edge-node-multi-protocol/scripts/03-sign-cert-sg.sh
Executable file
27
.claude/skills/edge-node-multi-protocol/scripts/03-sign-cert-sg.sh
Executable file
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
# Issue a wildcard cert for *.<DOMAIN_ROOT> via acme.sh DNS-01 and install it into
|
||||
# a STANDALONE nginx cert dir (/etc/nginx/ssl/<DOMAIN_ROOT>/), with a renew reloadcmd
|
||||
# that reloads the system (apt) nginx. No aaPanel.
|
||||
. "$(dirname "$0")/_lib.sh"
|
||||
|
||||
PLUGIN="${ACME_DNS_PLUGIN:-dns_cf}"
|
||||
CERTDIR="/etc/nginx/ssl/$DOMAIN_ROOT"
|
||||
|
||||
CRED_EXPORTS=""
|
||||
for v in CF_API_TOKEN DP_Id DP_Key Ali_Key Ali_Secret AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY GD_Key GD_Secret; do
|
||||
[ -n "${!v:-}" ] && CRED_EXPORTS+="export $v='${!v}'; "
|
||||
done
|
||||
# acme.sh's Cloudflare plugin expects CF_Token; alias it from CF_API_TOKEN.
|
||||
[ -n "${CF_API_TOKEN:-}" ] && CRED_EXPORTS+="export CF_Token='${CF_API_TOKEN}'; "
|
||||
|
||||
run "$CRED_EXPORTS
|
||||
mkdir -p '$CERTDIR'
|
||||
curl -s https://get.acme.sh | sh -s email=admin@$PARENT_ZONE >/dev/null 2>&1 || true
|
||||
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt >/dev/null 2>&1 || true
|
||||
~/.acme.sh/acme.sh --issue --dns $PLUGIN -d '$DOMAIN_ROOT' -d '*.$DOMAIN_ROOT' --keylength ec-256 --server letsencrypt 2>&1 | tail -5
|
||||
~/.acme.sh/acme.sh --install-cert -d '$DOMAIN_ROOT' --ecc \
|
||||
--key-file '$CERTDIR/privkey.pem' \
|
||||
--fullchain-file '$CERTDIR/fullchain.pem' \
|
||||
--reloadcmd 'systemctl reload nginx' 2>&1 | tail -3
|
||||
ls -l '$CERTDIR'
|
||||
echo cert-done"
|
||||
37
.claude/skills/edge-node-multi-protocol/scripts/03-sign-wildcard-cert.sh
Executable file
37
.claude/skills/edge-node-multi-protocol/scripts/03-sign-wildcard-cert.sh
Executable file
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
# Issue a wildcard cert for *.<DOMAIN_ROOT> on the box via acme.sh DNS-01,
|
||||
# deploy into the aaPanel BT cert dirs, and register a renew --reloadcmd.
|
||||
# DNS provider is set by ACME_DNS_PLUGIN (default dns_cf); creds are passed through from config.env.
|
||||
# Fleet alternative (sign once, scp to many): references/centralized-certs.md
|
||||
. "$(dirname "$0")/_lib.sh"
|
||||
|
||||
PLUGIN="${ACME_DNS_PLUGIN:-dns_cf}"
|
||||
# Export whichever acme.sh DNS credential vars are set in config.env (only non-empty ones).
|
||||
CRED_EXPORTS=""
|
||||
for v in CF_API_TOKEN CF_Zone_ID DP_Id DP_Key Ali_Key Ali_Secret AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY GD_Key GD_Secret; do
|
||||
[ -n "${!v:-}" ] && CRED_EXPORTS+="export $v='${!v}'; "
|
||||
done
|
||||
# acme.sh's Cloudflare plugin expects CF_Token, so alias it from CF_API_TOKEN.
|
||||
[ -n "${CF_API_TOKEN:-}" ] && CRED_EXPORTS+="export CF_Token='${CF_API_TOKEN}'; "
|
||||
|
||||
SITES=("$DOMAIN_ROOT" "$SUB_COVER.$DOMAIN_ROOT" "$SUB_XUI.$DOMAIN_ROOT" "$SUB_PANEL.$DOMAIN_ROOT")
|
||||
SITES_CSV=$(IFS=,; echo "${SITES[*]}")
|
||||
|
||||
run "$CRED_EXPORTS
|
||||
curl -s https://get.acme.sh | sh -s email=admin@$PARENT_ZONE >/dev/null 2>&1 || true
|
||||
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt >/dev/null 2>&1 || true
|
||||
~/.acme.sh/acme.sh --issue --dns $PLUGIN -d '$DOMAIN_ROOT' -d '*.$DOMAIN_ROOT' --keylength ec-256 --server letsencrypt 2>&1 | tail -4
|
||||
|
||||
# deploy into BT cert dirs + reload nginx
|
||||
SRC=~/.acme.sh/${DOMAIN_ROOT}_ecc
|
||||
for s in ${SITES_CSV//,/ }; do
|
||||
d=/www/server/panel/vhost/cert/\$s; mkdir -p \$d
|
||||
install -m 644 \$SRC/fullchain.cer \$d/fullchain.pem
|
||||
install -m 600 \$SRC/${DOMAIN_ROOT}.key \$d/privkey.pem
|
||||
done
|
||||
( /www/server/nginx/sbin/nginx -t && /www/server/nginx/sbin/nginx -s reload ) 2>/dev/null || echo 'nginx not up yet (fine — reload after aaPanel install)'
|
||||
|
||||
# register reloadcmd so renewals re-deploy automatically
|
||||
~/.acme.sh/acme.sh --install-cert -d '$DOMAIN_ROOT' --ecc \
|
||||
--reloadcmd \"for s in ${SITES_CSV//,/ }; do d=/www/server/panel/vhost/cert/\\\$s; install -m 644 \\\$SRC/fullchain.cer \\\$d/fullchain.pem; install -m 600 \\\$SRC/${DOMAIN_ROOT}.key \\\$d/privkey.pem; done; /www/server/nginx/sbin/nginx -s reload\" 2>&1 | tail -2
|
||||
echo cert-done"
|
||||
15
.claude/skills/edge-node-multi-protocol/scripts/04-install-aapanel.sh
Executable file
15
.claude/skills/edge-node-multi-protocol/scripts/04-install-aapanel.sh
Executable file
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install aaPanel International (7.x) headless, then print the default credentials.
|
||||
. "$(dirname "$0")/_lib.sh"
|
||||
|
||||
run 'cd /root && curl -sSO https://www.aapanel.com/script/install_7.0_en.sh && ls -la install_7.0_en.sh'
|
||||
echo "== installing aaPanel in background (5-10 min) =="
|
||||
run 'cd /root && (echo y | bash install_7.0_en.sh aapanel > /root/aapanel-install.log 2>&1 &) && echo started'
|
||||
|
||||
echo "== waiting for panel binary =="
|
||||
# Do NOT check "! pgrep -f install_7.0" here -- the check command's own argv contains
|
||||
# that literal string, so pgrep -f matches the check itself and this never goes false.
|
||||
# The installer's own log prints a completion banner; watch for that instead.
|
||||
until run 'test -f /www/server/panel/BT-Panel && grep -q "Congratulations" /root/aapanel-install.log 2>/dev/null'; do sleep 15; done
|
||||
echo "== aaPanel installed. Default credentials (change in 05-harden): =="
|
||||
run 'grep -iE "Address|username|password" /root/aapanel-install.log | grep -ivE "wget|Resolving|Connecting|HTTP request|Saving|node.aapanel|systemd" | tail -6'
|
||||
8
.claude/skills/edge-node-multi-protocol/scripts/04b-install-nginx.sh
Executable file
8
.claude/skills/edge-node-multi-protocol/scripts/04b-install-nginx.sh
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install nginx 1.24 via aaPanel's install_soft.sh (compile; a few minutes).
|
||||
. "$(dirname "$0")/_lib.sh"
|
||||
|
||||
run 'cd /www/server/panel/install && (bash install_soft.sh 0 install nginx 1.24 > /root/nginx-install.log 2>&1 &) && echo started'
|
||||
echo "== waiting for nginx binary (compile) =="
|
||||
until run 'test -x /www/server/nginx/sbin/nginx'; do sleep 20; done
|
||||
run '/www/server/nginx/sbin/nginx -v 2>&1'
|
||||
20
.claude/skills/edge-node-multi-protocol/scripts/05-harden-aapanel.sh
Executable file
20
.claude/skills/edge-node-multi-protocol/scripts/05-harden-aapanel.sh
Executable file
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
# Harden aaPanel: set username/password, secret entrance, bind panel domain, enable CDN_PROXY
|
||||
# (required so the panel works behind an nginx reverse proxy).
|
||||
. "$(dirname "$0")/_lib.sh"
|
||||
|
||||
[ -n "${AAPANEL_PASS:-}" ] || AAPANEL_PASS="$(gen_pass 20)"
|
||||
echo "AAPANEL PASSWORD (save to your password manager): $AAPANEL_PASS"
|
||||
|
||||
run "cd /www/server/panel
|
||||
/www/server/panel/pyenv/bin/python tools.py panel '$AAPANEL_PASS' >/dev/null 2>&1 && echo password-set
|
||||
# username: set directly in the panel DB (tools.py username is interactive)
|
||||
/www/server/panel/pyenv/bin/python -c \"import sqlite3;c=sqlite3.connect('/www/server/panel/data/default.db');c.execute(\\\"update users set username='$AAPANEL_USER'\\\");c.commit();print('username ->',c.execute('select username from users').fetchall())\"
|
||||
echo '$AAPANEL_ENTRANCE' > data/admin_path.pl && echo entrance-set
|
||||
echo '$SUB_PANEL.$DOMAIN_ROOT' > data/domain.conf && echo domain-bound
|
||||
echo 'CDN_PROXY=True' > config/cdn.conf && echo cdn-proxy-on
|
||||
/etc/init.d/bt restart >/dev/null 2>&1; sleep 3
|
||||
P=\$(cat /www/server/panel/data/port.pl | tr -d '[:space:]')
|
||||
ss -lnt | grep -q \":\$P\" && echo \"panel-listening-\$P\" || echo 'WARN: panel not listening (check with: bt 14)'"
|
||||
echo "NOTE: reverse-proxy access at https://$SUB_PANEL.$DOMAIN_ROOT$AAPANEL_ENTRANCE (after 09-aapanel-sites)."
|
||||
echo "NOTE: direct IP:port returns 403 by design (domain binding). Restart panel with 'bt 1', never 'systemctl restart btpanel'."
|
||||
18
.claude/skills/edge-node-multi-protocol/scripts/06-install-warp.sh
Executable file
18
.claude/skills/edge-node-multi-protocol/scripts/06-install-warp.sh
Executable file
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install cloudflare-warp, register, set proxy (SOCKS5) mode, connect, verify warp=on.
|
||||
. "$(dirname "$0")/_lib.sh"
|
||||
|
||||
run "
|
||||
# keyring needs gnupg present (else 'sqv' keyring-parse error on apt update)
|
||||
apt-get install -y -qq gnupg >/dev/null
|
||||
curl -fsSL https://pkg.cloudflareclient.com/pubkey.gpg | gpg --yes --dearmor --output /usr/share/keyrings/cloudflare-warp-archive-keyring.gpg
|
||||
echo 'deb [signed-by=/usr/share/keyrings/cloudflare-warp-archive-keyring.gpg] https://pkg.cloudflareclient.com/ bookworm main' > /etc/apt/sources.list.d/cloudflare-client.list
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq cloudflare-warp >/dev/null
|
||||
warp-cli --accept-tos registration new >/dev/null 2>&1 || true
|
||||
warp-cli --accept-tos mode proxy >/dev/null 2>&1
|
||||
warp-cli --accept-tos proxy port $WARP_SOCKS_PORT >/dev/null 2>&1
|
||||
warp-cli --accept-tos connect >/dev/null 2>&1
|
||||
sleep 5
|
||||
echo '== verify (expect warp=on) =='
|
||||
curl -s -x socks5h://127.0.0.1:$WARP_SOCKS_PORT --max-time 10 https://1.1.1.1/cdn-cgi/trace | grep -E 'ip=|warp='"
|
||||
13
.claude/skills/edge-node-multi-protocol/scripts/06b-warp-watchdog.sh
Executable file
13
.claude/skills/edge-node-multi-protocol/scripts/06b-warp-watchdog.sh
Executable file
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install the WARP health-check watchdog (systemd timer, every 60s).
|
||||
. "$(dirname "$0")/_lib.sh"
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
# adjust the proxy port in the shipped script to your configured one
|
||||
sed "s#127.0.0.1:40000#127.0.0.1:$WARP_SOCKS_PORT#g" "$HERE/warp-healthcheck.sh" | run "cat > /usr/local/bin/warp-healthcheck.sh"
|
||||
push "$HERE/warp-healthcheck.service" /etc/systemd/system/warp-healthcheck.service
|
||||
push "$HERE/warp-healthcheck.timer" /etc/systemd/system/warp-healthcheck.timer
|
||||
run 'chmod +x /usr/local/bin/warp-healthcheck.sh
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now warp-healthcheck.timer 2>&1 | tail -1
|
||||
systemctl is-active warp-healthcheck.timer'
|
||||
13
.claude/skills/edge-node-multi-protocol/scripts/07-install-3xui.sh
Executable file
13
.claude/skills/edge-node-multi-protocol/scripts/07-install-3xui.sh
Executable file
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install 3x-ui and set the panel to listen on 127.0.0.1:XUI_PORT with your path/creds.
|
||||
. "$(dirname "$0")/_lib.sh"
|
||||
|
||||
[ -n "${XUI_PASS:-}" ] || XUI_PASS="$(gen_pass 18)"
|
||||
echo "3X-UI PASSWORD (save to your password manager): $XUI_PASS"
|
||||
|
||||
run "echo -e 'n' | bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) >/dev/null 2>&1 || true
|
||||
/usr/local/x-ui/x-ui setting -username '$XUI_USER' -password '$XUI_PASS' -port $XUI_PORT -webBasePath '$XUI_PATH' 2>&1 | tail -4
|
||||
/usr/local/x-ui/x-ui setting -listenIP 127.0.0.1 2>&1 | tail -1
|
||||
systemctl restart x-ui; sleep 4
|
||||
echo '== status =='; systemctl is-active x-ui
|
||||
ss -lnt | grep -q ':$XUI_PORT' && echo panel-listening-$XUI_PORT || echo 'WARN: panel not listening'"
|
||||
280
.claude/skills/edge-node-multi-protocol/scripts/08-xui-bootstrap.py
Executable file
280
.claude/skills/edge-node-multi-protocol/scripts/08-xui-bootstrap.py
Executable file
@@ -0,0 +1,280 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Runs ON the box, editing 3x-ui's own sqlite DB directly (the panel HTTP API
|
||||
403s scripted requests on the builds this targets -- see references/pitfalls.md).
|
||||
|
||||
Builds the standard target of this skill in ONE pass: SIX inbounds (vmess,
|
||||
trojan, shadowsocks x direct-egress + WARP-clean-exit) all owned by ONE client
|
||||
"node-main", so the panel's Clients page shows a single row whose Info dialog
|
||||
lists all six share links / QR codes and one subscription that carries all six.
|
||||
|
||||
Why a single client instead of one-per-protocol
|
||||
------------------------------------------------
|
||||
3x-ui's `clients` table is protocol-generic: one client row carries BOTH a
|
||||
`uuid` (used by vmess/vless inbounds) AND a `password` (used by trojan/
|
||||
shadowsocks inbounds). Which field an inbound reads is decided by that
|
||||
*inbound's* protocol when 3x-ui builds the live xray config -- not by anything
|
||||
on the client row. So one node-main row linked (via client_inbounds) to all six
|
||||
inbounds authenticates vmess by its uuid and trojan+ss by its (shared)
|
||||
password. Verified by reading the generated /usr/local/x-ui/bin/config.json.
|
||||
Trojan and Shadowsocks therefore share ONE password (node-main's); vmess uses
|
||||
the uuid. That's intended -- a single-user self node -- and is what makes the
|
||||
"one client, six links" panel view work.
|
||||
|
||||
Four version-compatibility fixes are baked in here (all cost real debugging on
|
||||
a live v3.5.0 box; see references/pitfalls.md for the full story):
|
||||
|
||||
1. user_id=1 on every inbound. The panel Clients/Inbounds lists filter by the
|
||||
logged-in admin's user_id; an inbound with a NULL/empty user_id exists and
|
||||
proxies traffic fine but is INVISIBLE in the UI.
|
||||
|
||||
2. externalProxy on every inbound's streamSettings. These inbounds listen on
|
||||
127.0.0.1 with security:none (nginx terminates TLS at the edge), so without
|
||||
a hint 3x-ui generates useless share links pointing at 127.0.0.1:<internal
|
||||
port> with no TLS. An externalProxy entry {forceTls:"tls", dest:<cover>,
|
||||
port:443, sni:<cover>} makes every generated link/QR/subscription point at
|
||||
<cover>:443 over TLS -- i.e. actually usable.
|
||||
|
||||
3. Shadowsocks inbound settings need a TOP-LEVEL `password` field. v3.5.0's
|
||||
share-link generator does `settings["password"].(string)` -- an UNCHECKED
|
||||
type assertion. A normal (non-2022) SS inbound has no top-level password
|
||||
(the per-client password lives on the client), so that assertion panics,
|
||||
the panic is swallowed by gin's recovery, and the whole subLinks endpoint
|
||||
returns HTTP 200 with an EMPTY body -> the panel shows "No response data"
|
||||
and drops every link. We write a top-level password (auth still goes through
|
||||
the client table; this field only exists to keep the assertion from
|
||||
panicking and does not appear in the emitted ss:// link for chacha20 ciphers).
|
||||
|
||||
4. WARP routing rule is read-modify-write, never clobbered -- so re-running
|
||||
this (e.g. to add a 4th protocol) doesn't delete the existing warp routing.
|
||||
|
||||
5. settings.clients is ALWAYS embedded, on every schema -- never left as `[]`.
|
||||
This one cost a live outage on two nodes. `inbounds.settings.clients` is
|
||||
3x-ui's AUTHORITATIVE copy of the credentials; the clients / client_inbounds
|
||||
tables are a derived projection of that JSON, not an alternative to it:
|
||||
* client_link.go SyncInbound() does an UNCONDITIONAL
|
||||
`DELETE FROM client_inbounds WHERE inbound_id=?` and then rebuilds the
|
||||
rows only from the clients slice handed to it -- empty slice means
|
||||
delete-and-insert-nothing, with no guard distinguishing "this inbound
|
||||
has no clients" from "the caller found no data".
|
||||
* inbound_migration.go MigrationRequirements() feeds SyncInbound the
|
||||
clients from GetClients(inbound), i.e. parsed out of the settings JSON.
|
||||
* The panel UI path (client_inbound_apply.go addInboundClient) always
|
||||
writes the client into the settings JSON first, then projects to the
|
||||
tables -- which is why UI-managed installs never hit this.
|
||||
So a node with `settings.clients == []` loses every client_inbounds row the
|
||||
moment the migration path runs -- and `x-ui migrate` is exactly what
|
||||
install.sh/update.sh invoke on upgrade (DB import/restore and the
|
||||
SQLite->PostgreSQL copy call it too). The generated xray config then has
|
||||
`"clients": []` on every inbound: TLS and the WS upgrade still succeed, so
|
||||
the node looks healthy, but every connection fails authentication.
|
||||
Verified empirically on a live 3.6.0 box: with settings.clients empty,
|
||||
`x-ui migrate` emptied client_inbounds instantly; with it populated, the
|
||||
same command left all six links intact. The logic is unchanged since 3.5.0,
|
||||
so this is not a one-release regression -- it fires on ANY future upgrade.
|
||||
To retrofit an already-broken node, run 08b-embed-settings-clients.py.
|
||||
|
||||
Schema-adaptive: newer 3x-ui stores clients in separate tables (clients /
|
||||
client_inbounds / client_traffics); older builds embed them in
|
||||
inbound.settings.clients. The new-schema path is the live-verified one; the
|
||||
embedded path is kept for older installs.
|
||||
"""
|
||||
import json, os, sqlite3, subprocess, time
|
||||
|
||||
DB = os.environ.get("XUI_DB", "/etc/x-ui/x-ui.db")
|
||||
WARP_SOCKS_PORT = int(os.environ.get("WARP_SOCKS_PORT", "40000"))
|
||||
COVER = os.environ["COVER"] # e.g. cc.<DOMAIN_ROOT> -- the public cover host for every link
|
||||
|
||||
XUI_UUID = os.environ["XUI_UUID"] # vmess credential on node-main
|
||||
XUI_SUBID = os.environ.get("XUI_SUBID", "")
|
||||
NODE_PASSWORD = os.environ["NODE_PASSWORD"] # shared trojan+ss credential on node-main
|
||||
SS_METHOD = os.environ.get("SS_METHOD", "chacha20-poly1305")
|
||||
|
||||
EMAIL = "node-main"
|
||||
|
||||
# (protocol, direct port, warp port, direct path, warp path)
|
||||
PROTOCOLS = [
|
||||
("vmess", int(os.environ["PORT_VMESS_DIRECT"]), int(os.environ["PORT_VMESS_WARP"]),
|
||||
os.environ["WS_PATH_VMESS_DIRECT"], os.environ["WS_PATH_VMESS_WARP"]),
|
||||
("trojan", int(os.environ["PORT_TROJAN_DIRECT"]), int(os.environ["PORT_TROJAN_WARP"]),
|
||||
os.environ["WS_PATH_TROJAN_DIRECT"], os.environ["WS_PATH_TROJAN_WARP"]),
|
||||
("shadowsocks", int(os.environ["PORT_SS_DIRECT"]), int(os.environ["PORT_SS_WARP"]),
|
||||
os.environ["WS_PATH_SS_DIRECT"], os.environ["WS_PATH_SS_WARP"]),
|
||||
]
|
||||
|
||||
TEMPLATE = {
|
||||
"log": {"access": "/var/log/v2ray/access.log", "error": "/var/log/v2ray/error.log", "loglevel": "warning"},
|
||||
"api": {"services": ["HandlerService", "LoggerService", "StatsService"], "tag": "api"},
|
||||
"inbounds": [{"listen": "127.0.0.1", "port": 62789, "protocol": "dokodemo-door",
|
||||
"settings": {"address": "127.0.0.1"}, "tag": "api"}],
|
||||
"outbounds": [
|
||||
{"protocol": "freedom", "settings": {}},
|
||||
{"tag": "warp-outbound", "protocol": "freedom",
|
||||
"settings": {"domainStrategy": "UseIPv6v4"}, "proxySettings": {"tag": "warp-socks"}},
|
||||
{"tag": "warp-socks", "protocol": "socks",
|
||||
"settings": {"servers": [{"address": "127.0.0.1", "port": WARP_SOCKS_PORT}]}},
|
||||
{"protocol": "blackhole", "settings": {}, "tag": "blocked"},
|
||||
],
|
||||
"policy": {"system": {"statsInboundDownlink": True, "statsInboundUplink": True}},
|
||||
"routing": {"rules": [
|
||||
{"inboundTag": ["api"], "outboundTag": "api", "type": "field"},
|
||||
{"inboundTag": [], "outboundTag": "warp-outbound", "type": "field"}, # tags filled in below
|
||||
{"ip": ["geoip:private"], "outboundTag": "blocked", "type": "field"},
|
||||
{"protocol": ["bittorrent"], "outboundTag": "blocked", "type": "field"},
|
||||
]},
|
||||
"stats": {},
|
||||
"dns": {"servers": ["1.1.1.1"], "queryStrategy": "UseIP"},
|
||||
}
|
||||
|
||||
|
||||
def cols(cur, table):
|
||||
return {r[1] for r in cur.execute("PRAGMA table_info(%s)" % table).fetchall()}
|
||||
|
||||
|
||||
def has_table(cur, name):
|
||||
return cur.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (name,)).fetchone() is not None
|
||||
|
||||
|
||||
def stream_settings(path, remark):
|
||||
# security:"none" -- nginx already terminates TLS at the edge and reverse-proxies
|
||||
# plain HTTP to this inbound; don't handshake TLS a second time here.
|
||||
# externalProxy -> share links point at the public cover host:443 over TLS (fix #2).
|
||||
return json.dumps({
|
||||
"network": "ws", "security": "none",
|
||||
"wsSettings": {"path": path, "headers": {}},
|
||||
"externalProxy": [{"forceTls": "tls", "dest": COVER, "port": 443, "sni": COVER, "remark": remark}],
|
||||
})
|
||||
|
||||
|
||||
def embedded_client(protocol, now):
|
||||
"""One settings.clients entry (fix #5).
|
||||
|
||||
ALWAYS embed, on every schema. settings.clients is 3x-ui's authoritative copy
|
||||
of the credentials; clients/client_inbounds are a derived projection of it.
|
||||
See fix #5 in the module docstring for why leaving this empty bricks the node
|
||||
on the next upgrade.
|
||||
|
||||
Field semantics (client_link.go applyClientRecordMerge):
|
||||
* non-empty-only overwrite: id / password / security -> a vmess entry may
|
||||
carry just `id` and a trojan/ss entry just `password`; merging the six
|
||||
inbounds for one email yields the union rather than clobbering.
|
||||
* unconditional overwrite: enable / subId / limitIp / totalGB / expiryTime
|
||||
/ tgId / comment / reset / flow -> must be IDENTICAL in all six entries.
|
||||
* `enable` must be explicit: omitting it unmarshals to false, and xray.go
|
||||
then skips the client on EVERY inbound.
|
||||
"""
|
||||
c = {
|
||||
"email": EMAIL, "enable": True, "subId": XUI_SUBID, "flow": "",
|
||||
"limitIp": 0, "totalGB": 0, "expiryTime": 0, "tgId": 0,
|
||||
"comment": "", "reset": 0, "created_at": now, "updated_at": now,
|
||||
}
|
||||
if protocol == "vmess":
|
||||
c.update({"id": XUI_UUID, "security": "auto", "alterId": 0})
|
||||
elif protocol == "trojan":
|
||||
c["password"] = NODE_PASSWORD
|
||||
elif protocol == "shadowsocks":
|
||||
c.update({"password": NODE_PASSWORD, "method": SS_METHOD})
|
||||
else:
|
||||
raise ValueError(protocol)
|
||||
return c
|
||||
|
||||
|
||||
def inbound_settings(protocol, now):
|
||||
clients = [embedded_client(protocol, now)]
|
||||
if protocol == "vmess":
|
||||
return json.dumps({"clients": clients, "disableInsecureEncryption": False})
|
||||
if protocol == "trojan":
|
||||
return json.dumps({"clients": clients, "fallbacks": []})
|
||||
if protocol == "shadowsocks":
|
||||
# top-level "password" prevents the v3.5.0 link-gen panic (fix #3); auth is per-client.
|
||||
return json.dumps({"clients": clients, "method": SS_METHOD, "password": NODE_PASSWORD, "network": "tcp,udp"})
|
||||
raise ValueError(protocol)
|
||||
|
||||
|
||||
def main():
|
||||
conn = sqlite3.connect(DB)
|
||||
cur = conn.cursor()
|
||||
ic = cols(cur, "inbounds")
|
||||
new_schema = has_table(cur, "clients") and has_table(cur, "client_inbounds")
|
||||
existing_ports = {r[0] for r in cur.execute("SELECT port FROM inbounds").fetchall()}
|
||||
now = int(time.time() * 1000)
|
||||
|
||||
warp_tags = []
|
||||
inbound_ids = {}
|
||||
|
||||
for protocol, port_direct, port_warp, path_direct, path_warp in PROTOCOLS:
|
||||
for suffix, port, path in (("direct", port_direct, path_direct), ("warp", port_warp, path_warp)):
|
||||
tag = "inbound-%s-%s" % (protocol, suffix)
|
||||
remark = "%s-%s" % (protocol, suffix)
|
||||
if port in existing_ports:
|
||||
print(" inbound :%d (%s-%s) exists, skip" % (port, protocol, suffix))
|
||||
inbound_ids[tag] = cur.execute("SELECT id FROM inbounds WHERE port=?", (port,)).fetchone()[0]
|
||||
else:
|
||||
row = {
|
||||
"user_id": 1, # fix #1: NULL user_id -> invisible in panel lists
|
||||
"up": 0, "down": 0, "total": 0, "remark": remark,
|
||||
"enable": 1, "expiry_time": 0, "listen": "127.0.0.1", "port": port, "protocol": protocol,
|
||||
"settings": inbound_settings(protocol, now),
|
||||
"stream_settings": stream_settings(path, remark),
|
||||
"sniffing": json.dumps({"enabled": True, "destOverride": ["http", "tls"]}),
|
||||
"tag": tag,
|
||||
}
|
||||
keys = [k for k in row if k in ic]
|
||||
cur.execute("INSERT INTO inbounds (%s) VALUES (%s)" % (",".join(keys), ",".join("?" * len(keys))),
|
||||
[row[k] for k in keys])
|
||||
inbound_ids[tag] = cur.lastrowid
|
||||
print(" inbound :%d (%s-%s) %s added" % (port, protocol, suffix, path))
|
||||
if suffix == "warp":
|
||||
warp_tags += [tag, "inbound-127.0.0.1:%d" % port]
|
||||
|
||||
if new_schema:
|
||||
# One node-main client carrying BOTH uuid (vmess) and password (trojan+ss),
|
||||
# linked to all six inbounds. 3x-ui picks the right credential per inbound.
|
||||
row = cur.execute("SELECT id FROM clients WHERE email=?", (EMAIL,)).fetchone()
|
||||
if row:
|
||||
cid = row[0]
|
||||
cur.execute("UPDATE clients SET uuid=?, password=?, sub_id=?, security='auto', enable=1, updated_at=? "
|
||||
"WHERE id=?", (XUI_UUID, NODE_PASSWORD, XUI_SUBID, now, cid))
|
||||
else:
|
||||
cur.execute(
|
||||
"INSERT INTO clients (email,sub_id,uuid,password,auth,flow,security,enable,group_name,total_gb,"
|
||||
"expiry_time,limit_ip,reset,created_at,updated_at) VALUES (?,?,?,?,'','','auto',1,'',0,0,0,0,?,?)",
|
||||
(EMAIL, XUI_SUBID, XUI_UUID, NODE_PASSWORD, now, now),
|
||||
)
|
||||
cid = cur.lastrowid
|
||||
|
||||
for tag, iid in inbound_ids.items():
|
||||
if not cur.execute("SELECT 1 FROM client_inbounds WHERE client_id=? AND inbound_id=?",
|
||||
(cid, iid)).fetchone():
|
||||
cur.execute("INSERT INTO client_inbounds (client_id,inbound_id,flow_override,created_at) "
|
||||
"VALUES (?,?,?,?)", (cid, iid, "", now))
|
||||
|
||||
# client_traffics.email is GLOBALLY UNIQUE -> exactly one row for node-main.
|
||||
if not cur.execute("SELECT 1 FROM client_traffics WHERE email=?", (EMAIL,)).fetchone():
|
||||
any_iid = inbound_ids["inbound-vmess-direct"]
|
||||
cur.execute("INSERT INTO client_traffics (inbound_id,enable,email,up,down,expiry_time,total,reset) "
|
||||
"VALUES (?,1,?,0,0,0,0,0)", (any_iid, EMAIL))
|
||||
print(" node-main client linked to all 6 inbounds (uuid=vmess, password=trojan+ss)")
|
||||
|
||||
# WARP routing rule: read-modify-write (fix #4) -- append, never clobber.
|
||||
row = cur.execute("SELECT value FROM settings WHERE key='xrayTemplateConfig'").fetchone()
|
||||
tmpl = json.loads(row[0]) if row else TEMPLATE
|
||||
for rule in tmpl["routing"]["rules"]:
|
||||
if rule.get("outboundTag") == "warp-outbound":
|
||||
for t in warp_tags:
|
||||
if t not in rule["inboundTag"]:
|
||||
rule["inboundTag"].append(t)
|
||||
|
||||
cur.execute("UPDATE settings SET value=? WHERE key='xrayTemplateConfig'", (json.dumps(tmpl, ensure_ascii=False),))
|
||||
if cur.rowcount == 0:
|
||||
cur.execute("INSERT INTO settings (key, value) VALUES ('xrayTemplateConfig', ?)", (json.dumps(tmpl, ensure_ascii=False),))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print(" xrayTemplateConfig written, warp rule tags:", tmpl["routing"]["rules"][1]["inboundTag"])
|
||||
|
||||
subprocess.run(["systemctl", "restart", "x-ui"], check=True)
|
||||
time.sleep(6)
|
||||
print("OK — 3x-ui bootstrap complete (6 inbounds under one node-main client: vmess/trojan/shadowsocks x direct/warp)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
24
.claude/skills/edge-node-multi-protocol/scripts/08-xui-bootstrap.sh
Executable file
24
.claude/skills/edge-node-multi-protocol/scripts/08-xui-bootstrap.sh
Executable file
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
# Push 08-xui-bootstrap.py to the box and run it with your config injected as env vars.
|
||||
# Adds all SIX inbounds in one pass, under ONE node-main client: vmess/trojan/shadowsocks x direct/warp.
|
||||
. "$(dirname "$0")/_lib.sh"
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
require_vars XUI_UUID NODE_PASSWORD SUB_COVER DOMAIN_ROOT \
|
||||
PORT_VMESS_DIRECT PORT_VMESS_WARP WS_PATH_VMESS_DIRECT WS_PATH_VMESS_WARP \
|
||||
PORT_TROJAN_DIRECT PORT_TROJAN_WARP WS_PATH_TROJAN_DIRECT WS_PATH_TROJAN_WARP \
|
||||
PORT_SS_DIRECT PORT_SS_WARP WS_PATH_SS_DIRECT WS_PATH_SS_WARP
|
||||
|
||||
COVER="$SUB_COVER.$DOMAIN_ROOT"
|
||||
|
||||
push "$HERE/08-xui-bootstrap.py" /root/_xui-bootstrap.py
|
||||
run "COVER='$COVER' XUI_UUID='$XUI_UUID' XUI_SUBID='$XUI_SUBID' \
|
||||
NODE_PASSWORD='$NODE_PASSWORD' SS_METHOD='${SS_METHOD:-chacha20-poly1305}' \
|
||||
PORT_VMESS_DIRECT='$PORT_VMESS_DIRECT' PORT_VMESS_WARP='$PORT_VMESS_WARP' \
|
||||
WS_PATH_VMESS_DIRECT='$WS_PATH_VMESS_DIRECT' WS_PATH_VMESS_WARP='$WS_PATH_VMESS_WARP' \
|
||||
PORT_TROJAN_DIRECT='$PORT_TROJAN_DIRECT' PORT_TROJAN_WARP='$PORT_TROJAN_WARP' \
|
||||
WS_PATH_TROJAN_DIRECT='$WS_PATH_TROJAN_DIRECT' WS_PATH_TROJAN_WARP='$WS_PATH_TROJAN_WARP' \
|
||||
PORT_SS_DIRECT='$PORT_SS_DIRECT' PORT_SS_WARP='$PORT_SS_WARP' \
|
||||
WS_PATH_SS_DIRECT='$WS_PATH_SS_DIRECT' WS_PATH_SS_WARP='$WS_PATH_SS_WARP' \
|
||||
WARP_SOCKS_PORT='$WARP_SOCKS_PORT' \
|
||||
python3 /root/_xui-bootstrap.py; rm -f /root/_xui-bootstrap.py"
|
||||
157
.claude/skills/edge-node-multi-protocol/scripts/08b-embed-settings-clients.py
Executable file
157
.claude/skills/edge-node-multi-protocol/scripts/08b-embed-settings-clients.py
Executable file
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Runs ON the box. Makes a DB-provisioned node upgrade-safe.
|
||||
|
||||
WHY THIS EXISTS
|
||||
---------------
|
||||
3x-ui's real source of truth for client credentials is the per-inbound
|
||||
`inbounds.settings.clients` JSON array -- NOT the normalized `clients` /
|
||||
`client_inbounds` tables. Those tables are a *derived projection* of the JSON:
|
||||
|
||||
* client_link.go SyncInbound(tx, inboundId, clients) does an UNCONDITIONAL
|
||||
`DELETE FROM client_inbounds WHERE inbound_id = ?` and then rebuilds the
|
||||
rows only from the `clients` slice it was handed. Empty slice -> delete,
|
||||
insert nothing.
|
||||
* inbound_migration.go MigrationRequirements() hands SyncInbound the clients
|
||||
it got from GetClients(inbound), i.e. parsed from the settings JSON.
|
||||
* The panel UI path (client_inbound_apply.go addInboundClient) ALWAYS writes
|
||||
the client into the settings JSON first, saves the inbound, then projects
|
||||
to the tables via SyncInbound. So a UI-managed install always has a
|
||||
populated settings.clients and migration is a harmless no-op for it.
|
||||
|
||||
A node provisioned by writing the tables directly (settings.clients left as
|
||||
`[]`) is therefore off-model: any run of the migration path -- `x-ui migrate`,
|
||||
which install.sh/update.sh invoke on upgrade, plus DB import/restore and the
|
||||
SQLite->PostgreSQL copy -- wipes every client_inbounds row and silently drops
|
||||
all credentials from the generated xray config. Verified empirically: running
|
||||
`x-ui migrate` on a table-only node emptied client_inbounds immediately.
|
||||
This is NOT specific to any one release; the logic is unchanged since 3.5.0.
|
||||
|
||||
WHAT THIS DOES
|
||||
--------------
|
||||
Backfills `settings.clients` for every proxy inbound from the node-main client
|
||||
row, then re-asserts the client_inbounds links. Idempotent -- safe to re-run,
|
||||
and re-running after any 3x-ui upgrade is the recommended belt-and-braces step.
|
||||
|
||||
FIELD SEMANTICS (client_link.go applyClientRecordMerge) -- get these wrong and
|
||||
you corrupt the merged client row:
|
||||
* Credential-ish fields overwrite ONLY when non-empty: id, password, auth,
|
||||
secret, security, privateKey, publicKey. That's why a vmess entry may carry
|
||||
only `id` and a trojan/ss entry only `password` -- across the six inbounds
|
||||
the same email merges to a union instead of clobbering.
|
||||
* These overwrite UNCONDITIONALLY, so all six entries must agree:
|
||||
enable, subId, limitIp, totalGB, expiryTime, tgId, comment, reset, flow.
|
||||
* `enable` is the dangerous one: omit it and JSON unmarshals it to false,
|
||||
which makes xray.go skip the client on EVERY inbound -> node dead.
|
||||
"""
|
||||
import json, os, sqlite3, subprocess, sys, time
|
||||
|
||||
DB = os.environ.get("XUI_DB", "/etc/x-ui/x-ui.db")
|
||||
EMAIL = os.environ.get("XUI_EMAIL", "node-main")
|
||||
RESTART = os.environ.get("RESTART_XUI", "1") == "1"
|
||||
|
||||
PROXY_PROTOCOLS = ("vmess", "vless", "trojan", "shadowsocks")
|
||||
|
||||
|
||||
def build_entry(protocol, uuid, password, sub_id, method, now):
|
||||
"""One settings.clients entry. Unconditional fields identical across inbounds."""
|
||||
e = {
|
||||
"email": EMAIL,
|
||||
"enable": True, # MUST be explicit -- see module docstring
|
||||
"subId": sub_id or "",
|
||||
"flow": "",
|
||||
"limitIp": 0,
|
||||
"totalGB": 0,
|
||||
"expiryTime": 0,
|
||||
"tgId": 0,
|
||||
"comment": "",
|
||||
"reset": 0,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
if protocol in ("vmess", "vless"):
|
||||
e["id"] = uuid
|
||||
e["security"] = "auto"
|
||||
if protocol == "vmess":
|
||||
e["alterId"] = 0
|
||||
else:
|
||||
e["password"] = password
|
||||
if protocol == "shadowsocks":
|
||||
e["method"] = method
|
||||
return e
|
||||
|
||||
|
||||
def main():
|
||||
con = sqlite3.connect(DB)
|
||||
cur = con.cursor()
|
||||
|
||||
row = cur.execute(
|
||||
"SELECT id, uuid, password, sub_id FROM clients WHERE email=?", (EMAIL,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
sys.exit("ERROR: no client row for email=%r -- nothing to embed" % EMAIL)
|
||||
cid, uuid, password, sub_id = row
|
||||
now = int(time.time() * 1000)
|
||||
|
||||
inbounds = cur.execute(
|
||||
"SELECT id, port, protocol, settings FROM inbounds ORDER BY port"
|
||||
).fetchall()
|
||||
|
||||
changed = 0
|
||||
for iid, port, protocol, settings_raw in inbounds:
|
||||
if protocol not in PROXY_PROTOCOLS:
|
||||
continue
|
||||
try:
|
||||
st = json.loads(settings_raw) if settings_raw else {}
|
||||
except (TypeError, ValueError):
|
||||
print(" :%s %s -- settings not valid JSON, SKIPPED" % (port, protocol))
|
||||
continue
|
||||
|
||||
method = st.get("method", "chacha20-poly1305")
|
||||
entry = build_entry(protocol, uuid, password, sub_id, method, now)
|
||||
|
||||
existing = st.get("clients")
|
||||
if not isinstance(existing, list):
|
||||
existing = []
|
||||
# Replace only our own email's entry; leave any other client untouched.
|
||||
kept = [c for c in existing if isinstance(c, dict) and c.get("email") != EMAIL]
|
||||
new_clients = kept + [entry]
|
||||
|
||||
if existing == new_clients:
|
||||
print(" :%s %-12s settings.clients already correct" % (port, protocol))
|
||||
else:
|
||||
st["clients"] = new_clients
|
||||
cur.execute(
|
||||
"UPDATE inbounds SET settings=? WHERE id=?",
|
||||
(json.dumps(st, indent=2, ensure_ascii=False), iid),
|
||||
)
|
||||
changed += 1
|
||||
print(" :%s %-12s settings.clients embedded" % (port, protocol))
|
||||
|
||||
# Re-assert the derived link (harmless if already there).
|
||||
cur.execute(
|
||||
"INSERT OR IGNORE INTO client_inbounds (client_id,inbound_id,flow_override,created_at) "
|
||||
"VALUES (?,?,?,?)", (cid, iid, "", now),
|
||||
)
|
||||
|
||||
# client_traffics.email is globally unique -> exactly one row for this client.
|
||||
if not cur.execute("SELECT 1 FROM client_traffics WHERE email=?", (EMAIL,)).fetchone():
|
||||
first = cur.execute("SELECT id FROM inbounds ORDER BY port").fetchone()
|
||||
if first:
|
||||
cur.execute(
|
||||
"INSERT INTO client_traffics (inbound_id,enable,email,up,down,expiry_time,total,reset) "
|
||||
"VALUES (?,1,?,0,0,0,0,0)", (first[0], EMAIL),
|
||||
)
|
||||
|
||||
con.commit()
|
||||
links = cur.execute("SELECT count(*) FROM client_inbounds").fetchone()[0]
|
||||
con.close()
|
||||
print(" settings.clients updated on %d inbound(s); client_inbounds rows now: %d" % (changed, links))
|
||||
|
||||
if RESTART:
|
||||
subprocess.run(["systemctl", "restart", "x-ui"], check=True)
|
||||
time.sleep(6)
|
||||
print("OK -- x-ui restarted; node is now upgrade-safe (settings.clients is authoritative)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
77
.claude/skills/edge-node-multi-protocol/scripts/09-aapanel-sites.py
Executable file
77
.claude/skills/edge-node-multi-protocol/scripts/09-aapanel-sites.py
Executable file
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Runs ON the box with aaPanel's bundled python:
|
||||
/www/server/panel/pyenv/bin/python /root/_aapanel-sites.py
|
||||
Registers 4 domains as panel-native sites (AddSite + SetSSL + CreateProxy + HttpToHttps)
|
||||
so they are visible/manageable in the aaPanel UI (never hand-write vhosts — the UI won't
|
||||
see those). Reads subdomain labels + cover paths from environment variables.
|
||||
"""
|
||||
import json, os, sys
|
||||
|
||||
sys.path.insert(0, "/www/server/panel")
|
||||
sys.path.insert(0, "/www/server/panel/class")
|
||||
sys.path.insert(0, "/www/server/panel/class_v2") # aaPanel 7.x moved some modules here (e.g. theme_config)
|
||||
import public # noqa: E402
|
||||
import panelSite # noqa: E402
|
||||
|
||||
ROOT = os.environ["DOMAIN_ROOT"]
|
||||
SUB_PANEL = os.environ["SUB_PANEL"]
|
||||
SUB_XUI = os.environ["SUB_XUI"]
|
||||
SUB_COVER = os.environ["SUB_COVER"]
|
||||
XUI_PORT = os.environ.get("XUI_PORT", "14533")
|
||||
PANEL_PORT = public.readFile("/www/server/panel/data/port.pl").strip()
|
||||
|
||||
D_LANDING = ROOT
|
||||
D_COVER = "%s.%s" % (SUB_COVER, ROOT)
|
||||
D_XUI = "%s.%s" % (SUB_XUI, ROOT)
|
||||
D_PANEL = "%s.%s" % (SUB_PANEL, ROOT)
|
||||
|
||||
site = panelSite.panelSite()
|
||||
|
||||
SITES = [
|
||||
(D_LANDING, "/www/wwwroot/" + D_LANDING, "edge landing"),
|
||||
(D_COVER, "/www/wwwroot/" + D_COVER, "node cover"),
|
||||
(D_XUI, "/www/wwwroot/" + D_XUI, "3x-ui panel proxy"),
|
||||
(D_PANEL, "/www/wwwroot/" + D_PANEL, "aaPanel proxy"),
|
||||
]
|
||||
|
||||
# 3 protocols x 2 egress = 6 cover proxy rules, all on the same D_COVER site/cert.
|
||||
_PROTOCOLS = ["VMESS", "TROJAN", "SS"]
|
||||
PROXIES = [
|
||||
("xui-panel", D_XUI, "/", "http://127.0.0.1:%s" % XUI_PORT, D_XUI),
|
||||
("aapanel", D_PANEL, "/", "https://127.0.0.1:%s" % PANEL_PORT, D_PANEL),
|
||||
]
|
||||
for proto in _PROTOCOLS:
|
||||
for suffix in ("DIRECT", "WARP"):
|
||||
path = os.environ["WS_PATH_%s_%s" % (proto, suffix)]
|
||||
port = os.environ["PORT_%s_%s" % (proto, suffix)]
|
||||
PROXIES.append(("cover-%s-%s" % (proto.lower(), suffix.lower()), D_COVER, path,
|
||||
"http://127.0.0.1:%s" % port, D_COVER))
|
||||
|
||||
|
||||
def obj(d):
|
||||
return public.to_dict_obj(d)
|
||||
|
||||
|
||||
def main():
|
||||
for name, path, ps in SITES:
|
||||
get = obj({"webname": json.dumps({"domain": name, "domainlist": [], "count": 0}),
|
||||
"path": path, "port": "80", "version": "00", "ps": ps,
|
||||
"type_id": "0", "type": "static", "ftp": "false", "sql": "false"})
|
||||
print("AddSite", name, "->", site.AddSite(get).get("siteStatus", "?"))
|
||||
for name, _p, _ps in SITES:
|
||||
cdir = "/www/server/panel/vhost/cert/" + name
|
||||
key = public.readFile(cdir + "/privkey.pem"); csr = public.readFile(cdir + "/fullchain.pem")
|
||||
if key and csr:
|
||||
print("SetSSL", name, "->", site.SetSSL(obj({"siteName": name, "key": key, "csr": csr})).get("status"))
|
||||
for pname, sname, pdir, target, todomain in PROXIES:
|
||||
get = obj({"proxyname": pname, "sitename": sname, "proxydir": pdir, "proxysite": target,
|
||||
"todomain": todomain, "type": "1", "cache": "0", "subfilter": "[]",
|
||||
"advanced": "0", "cachetime": "1", "nocheck": "1"})
|
||||
print("CreateProxy", sname, pdir, "->", site.CreateProxy(get).get("status"))
|
||||
for name, _p, _ps in SITES:
|
||||
print("HttpToHttps", name, "->", site.HttpToHttps(obj({"siteName": name})).get("status"))
|
||||
print("DONE — reload nginx: /etc/init.d/nginx reload")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
16
.claude/skills/edge-node-multi-protocol/scripts/09-aapanel-sites.sh
Executable file
16
.claude/skills/edge-node-multi-protocol/scripts/09-aapanel-sites.sh
Executable file
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
# Push 09-aapanel-sites.py and run it with aaPanel's bundled python + your config.
|
||||
. "$(dirname "$0")/_lib.sh"
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
push "$HERE/09-aapanel-sites.py" /root/_aapanel-sites.py
|
||||
run "DOMAIN_ROOT='$DOMAIN_ROOT' SUB_PANEL='$SUB_PANEL' SUB_XUI='$SUB_XUI' SUB_COVER='$SUB_COVER' XUI_PORT='$XUI_PORT' \
|
||||
WS_PATH_VMESS_DIRECT='$WS_PATH_VMESS_DIRECT' WS_PATH_VMESS_WARP='$WS_PATH_VMESS_WARP' \
|
||||
PORT_VMESS_DIRECT='$PORT_VMESS_DIRECT' PORT_VMESS_WARP='$PORT_VMESS_WARP' \
|
||||
WS_PATH_TROJAN_DIRECT='$WS_PATH_TROJAN_DIRECT' WS_PATH_TROJAN_WARP='$WS_PATH_TROJAN_WARP' \
|
||||
PORT_TROJAN_DIRECT='$PORT_TROJAN_DIRECT' PORT_TROJAN_WARP='$PORT_TROJAN_WARP' \
|
||||
WS_PATH_SS_DIRECT='$WS_PATH_SS_DIRECT' WS_PATH_SS_WARP='$WS_PATH_SS_WARP' \
|
||||
PORT_SS_DIRECT='$PORT_SS_DIRECT' PORT_SS_WARP='$PORT_SS_WARP' \
|
||||
/www/server/panel/pyenv/bin/python /root/_aapanel-sites.py
|
||||
rm -f /root/_aapanel-sites.py
|
||||
/etc/init.d/nginx reload 2>&1 | tail -1"
|
||||
115
.claude/skills/edge-node-multi-protocol/scripts/09-nginx-sites-sg.sh
Executable file
115
.claude/skills/edge-node-multi-protocol/scripts/09-nginx-sites-sg.sh
Executable file
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hand-written nginx vhosts (no aaPanel):
|
||||
# - landing : <DOMAIN_ROOT> -> static page
|
||||
# - cover : <SUB_COVER>.<root> -> 6 WS reverse proxies (one per inbound) + benign root
|
||||
# - xui panel : <SUB_XUI>.<root> -> 127.0.0.1:<XUI_PORT>
|
||||
# - default : unmatched SNI -> return 444
|
||||
# All TLS via the standalone wildcard cert; HTTP :80 -> 301 HTTPS.
|
||||
. "$(dirname "$0")/_lib.sh"
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
COVER="$SUB_COVER.$DOMAIN_ROOT"
|
||||
XUI_HOST="$SUB_XUI.$DOMAIN_ROOT"
|
||||
CERTDIR="/etc/nginx/ssl/$DOMAIN_ROOT"
|
||||
|
||||
# landing page html (shared by landing + cover root so neither looks dead)
|
||||
LANDING_HTML="$(sed "s/__NODE_NAME__/$NODE_NAME/g" "$HERE/../templates/landing.html")"
|
||||
|
||||
# build the 6 WS location blocks
|
||||
WS_BLOCKS=""
|
||||
add_ws() { # $1=path $2=port $3=name
|
||||
WS_BLOCKS+="
|
||||
# $3
|
||||
location = $1 {
|
||||
proxy_pass http://127.0.0.1:$2;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade \\\$http_upgrade;
|
||||
proxy_set_header Connection \\\$connection_upgrade;
|
||||
proxy_set_header Host \\\$host;
|
||||
proxy_set_header X-Real-IP \\\$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \\\$proxy_add_x_forwarded_for;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}"
|
||||
}
|
||||
add_ws "$WS_PATH_VMESS_DIRECT" "$PORT_VMESS_DIRECT" "vmess-direct"
|
||||
add_ws "$WS_PATH_VMESS_WARP" "$PORT_VMESS_WARP" "vmess-warp"
|
||||
add_ws "$WS_PATH_TROJAN_DIRECT" "$PORT_TROJAN_DIRECT" "trojan-direct"
|
||||
add_ws "$WS_PATH_TROJAN_WARP" "$PORT_TROJAN_WARP" "trojan-warp"
|
||||
add_ws "$WS_PATH_SS_DIRECT" "$PORT_SS_DIRECT" "ss-direct"
|
||||
add_ws "$WS_PATH_SS_WARP" "$PORT_SS_WARP" "ss-warp"
|
||||
|
||||
# push landing content to the box
|
||||
printf '%s' "$LANDING_HTML" | run "mkdir -p /var/www/$DOMAIN_ROOT /var/www/$COVER && cat > /var/www/$DOMAIN_ROOT/index.html && cp /var/www/$DOMAIN_ROOT/index.html /var/www/$COVER/index.html"
|
||||
|
||||
# write the vhost config
|
||||
run "cat > /etc/nginx/conf.d/edge.conf <<CONF
|
||||
map \\\$http_upgrade \\\$connection_upgrade { default upgrade; '' close; }
|
||||
|
||||
# HTTP -> HTTPS
|
||||
server {
|
||||
listen 80 default_server;
|
||||
listen [::]:80 default_server;
|
||||
server_name _;
|
||||
return 301 https://\\\$host\\\$request_uri;
|
||||
}
|
||||
|
||||
# default TLS server: unmatched SNI -> drop
|
||||
server {
|
||||
listen 443 ssl default_server;
|
||||
listen [::]:443 ssl default_server;
|
||||
server_name _;
|
||||
ssl_certificate $CERTDIR/fullchain.pem;
|
||||
ssl_certificate_key $CERTDIR/privkey.pem;
|
||||
return 444;
|
||||
}
|
||||
|
||||
# landing
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
server_name $DOMAIN_ROOT;
|
||||
ssl_certificate $CERTDIR/fullchain.pem;
|
||||
ssl_certificate_key $CERTDIR/privkey.pem;
|
||||
root /var/www/$DOMAIN_ROOT;
|
||||
index index.html;
|
||||
location / { try_files \\\$uri \\\$uri/ =404; }
|
||||
}
|
||||
|
||||
# node cover: 6 WS reverse proxies + benign root
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
server_name $COVER;
|
||||
ssl_certificate $CERTDIR/fullchain.pem;
|
||||
ssl_certificate_key $CERTDIR/privkey.pem;
|
||||
root /var/www/$COVER;
|
||||
index index.html;
|
||||
$WS_BLOCKS
|
||||
|
||||
location / { try_files \\\$uri \\\$uri/ =404; }
|
||||
}
|
||||
|
||||
# 3x-ui panel
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
server_name $XUI_HOST;
|
||||
ssl_certificate $CERTDIR/fullchain.pem;
|
||||
ssl_certificate_key $CERTDIR/privkey.pem;
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:$XUI_PORT;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade \\\$http_upgrade;
|
||||
proxy_set_header Connection \\\$connection_upgrade;
|
||||
proxy_set_header Host \\\$host;
|
||||
proxy_set_header X-Real-IP \\\$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \\\$proxy_add_x_forwarded_for;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
}
|
||||
CONF
|
||||
|
||||
# apt nginx ships a default_server on :80 -> remove so ours wins
|
||||
rm -f /etc/nginx/sites-enabled/default
|
||||
nginx -t && systemctl reload nginx && echo nginx-reloaded"
|
||||
18
.claude/skills/edge-node-multi-protocol/scripts/09b-default-444.sh
Executable file
18
.claude/skills/edge-node-multi-protocol/scripts/09b-default-444.sh
Executable file
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
# Unmatched server_name -> return 444 (drop). Also drop the landing page.
|
||||
. "$(dirname "$0")/_lib.sh"
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
# landing page
|
||||
sed "s/__NODE_NAME__/$NODE_NAME/g" "$HERE/../templates/landing.html" | run "mkdir -p /www/wwwroot/$DOMAIN_ROOT && cat > /www/wwwroot/$DOMAIN_ROOT/index.html"
|
||||
|
||||
run "cat > /www/server/panel/vhost/nginx/default_444.conf <<'CONF'
|
||||
server {
|
||||
listen 443 ssl default_server;
|
||||
server_name _;
|
||||
ssl_certificate /www/server/panel/vhost/cert/$DOMAIN_ROOT/fullchain.pem;
|
||||
ssl_certificate_key /www/server/panel/vhost/cert/$DOMAIN_ROOT/privkey.pem;
|
||||
return 444;
|
||||
}
|
||||
CONF
|
||||
/www/server/nginx/sbin/nginx -t && /etc/init.d/nginx reload 2>&1 | tail -1"
|
||||
18
.claude/skills/edge-node-multi-protocol/scripts/10-ufw-rn.sh
Executable file
18
.claude/skills/edge-node-multi-protocol/scripts/10-ufw-rn.sh
Executable file
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
# Firewall for a node that runs BOTH planes:
|
||||
# * TCP 22/80/443 — ssh + nginx (the six WS inbounds and the 3x-ui panel are all
|
||||
# 127.0.0.1-only and reach the world only through nginx:443).
|
||||
# * UDP x4 — Hysteria2 / TUIC terminate QUIC themselves, so unlike the TCP
|
||||
# inbounds they need genuinely public UDP ports. UDP 443 (hy2-direct) coexists
|
||||
# with nginx's TCP 443: different protocol, different socket.
|
||||
# RackNerd KVM has no provider-side firewall, so ufw is the only gate here.
|
||||
. "$(dirname "$0")/_lib.sh"
|
||||
|
||||
require_vars PORT_HY2_DIRECT PORT_HY2_WARP PORT_TUIC_DIRECT PORT_TUIC_WARP
|
||||
|
||||
run "for p in 22 80 443; do ufw allow \$p/tcp >/dev/null 2>&1; done
|
||||
for p in $PORT_HY2_DIRECT $PORT_HY2_WARP $PORT_TUIC_DIRECT $PORT_TUIC_WARP; do
|
||||
ufw allow \$p/udp >/dev/null 2>&1
|
||||
done
|
||||
yes | ufw enable >/dev/null 2>&1 || true
|
||||
ufw status verbose | grep -E 'Status|/tcp|/udp'"
|
||||
9
.claude/skills/edge-node-multi-protocol/scripts/10-ufw-sg.sh
Executable file
9
.claude/skills/edge-node-multi-protocol/scripts/10-ufw-sg.sh
Executable file
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# Firewall: only 22/80/443 need to be public (all 6 inbounds + 3x-ui are 127.0.0.1-only,
|
||||
# reverse-proxied through nginx on 443). NOTE: on AWS the Security Group is the outer gate
|
||||
# and must also allow inbound 22/80/443 — ufw alone is not enough.
|
||||
. "$(dirname "$0")/_lib.sh"
|
||||
|
||||
run 'for p in 22 80 443; do ufw allow "$p"/tcp >/dev/null 2>&1; done
|
||||
yes | ufw enable >/dev/null 2>&1 || true
|
||||
ufw status verbose | grep -E "22|80|443|Status"'
|
||||
9
.claude/skills/edge-node-multi-protocol/scripts/10-ufw.sh
Executable file
9
.claude/skills/edge-node-multi-protocol/scripts/10-ufw.sh
Executable file
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# Allow web + panel + aaPanel FTP/passive ports. Reads the real panel port from the box.
|
||||
. "$(dirname "$0")/_lib.sh"
|
||||
|
||||
run 'P=$(cat /www/server/panel/data/port.pl 2>/dev/null | tr -d "[:space:]"); P=${P:-8888}
|
||||
for p in 22 80 443 "$P" 888; do ufw allow "$p"/tcp >/dev/null 2>&1; done
|
||||
ufw allow 39000:40000/tcp >/dev/null 2>&1
|
||||
yes | ufw enable >/dev/null 2>&1 || true
|
||||
ufw status | grep -E "22|80|443|$P|888|39000"'
|
||||
56
.claude/skills/edge-node-multi-protocol/scripts/11-monitoring.sh
Executable file
56
.claude/skills/edge-node-multi-protocol/scripts/11-monitoring.sh
Executable file
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
# Optional: install Nezha + Komari agents pointing at YOUR panels (placeholders in config.env).
|
||||
# Both are skipped unless the relevant config.env fields are set. No panel URLs/keys ship here.
|
||||
. "$(dirname "$0")/_lib.sh"
|
||||
|
||||
# --- Komari (auto-discovery) ---
|
||||
if [ -n "${KOMARI_ENDPOINT:-}" ] && [ -n "${KOMARI_DISCOVERY_KEY:-}" ]; then
|
||||
run "curl -sSL https://raw.githubusercontent.com/komari-monitor/komari-agent/main/install.sh \
|
||||
| bash -s -- --endpoint '$KOMARI_ENDPOINT' --auto-discovery '$KOMARI_DISCOVERY_KEY' 2>&1 | tail -3
|
||||
systemctl is-active komari-agent 2>/dev/null || true"
|
||||
echo "Komari: agent installed. Rename/group the node in your panel (it auto-registers as 'Auto-<hostname>')."
|
||||
else
|
||||
echo "Komari: skipped (set KOMARI_ENDPOINT + KOMARI_DISCOVERY_KEY to enable)."
|
||||
fi
|
||||
|
||||
# --- Nezha (agent v2): download binary + write config.yml + systemd (robust across arch;
|
||||
# the one-liner installer changes often, so we do it explicitly). ---
|
||||
if [ -n "${NEZHA_SERVER:-}" ] && [ -n "${NEZHA_SECRET:-}" ]; then
|
||||
ARCH="$(box_arch)"
|
||||
[ -n "${NEZHA_UUID:-}" ] || NEZHA_UUID="$(run 'cat /proc/sys/kernel/random/uuid')"
|
||||
run "set -e
|
||||
mkdir -p /opt/nezha/agent
|
||||
ver=\$(curl -sL https://api.github.com/repos/nezhahq/agent/releases/latest | grep -oE '\"tag_name\": *\"[^\"]+' | head -1 | grep -oE 'v[0-9.]+')
|
||||
url=https://github.com/nezhahq/agent/releases/download/\$ver/nezha-agent_linux_${ARCH}.zip
|
||||
curl -fsSL \"\$url\" -o /tmp/nezha-agent.zip
|
||||
(command -v unzip >/dev/null || (apt-get update -qq && apt-get install -y -qq unzip >/dev/null))
|
||||
unzip -oq /tmp/nezha-agent.zip -d /opt/nezha/agent && rm -f /tmp/nezha-agent.zip
|
||||
chmod +x /opt/nezha/agent/nezha-agent
|
||||
cat > /opt/nezha/agent/config.yml <<CFG
|
||||
client_secret: $NEZHA_SECRET
|
||||
server: $NEZHA_SERVER
|
||||
tls: ${NEZHA_TLS:-false}
|
||||
uuid: $NEZHA_UUID
|
||||
disable_auto_update: true
|
||||
disable_force_update: true
|
||||
CFG
|
||||
cat > /etc/systemd/system/nezha-agent.service <<'SVC'
|
||||
[Unit]
|
||||
Description=Nezha Agent
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/opt/nezha/agent/nezha-agent -c /opt/nezha/agent/config.yml
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
SVC
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now nezha-agent
|
||||
sleep 3; systemctl is-active nezha-agent"
|
||||
echo "Nezha: agent installed (uuid $NEZHA_UUID, arch $ARCH). Name/group the node in your panel."
|
||||
else
|
||||
echo "Nezha: skipped (set NEZHA_SERVER + NEZHA_SECRET to enable)."
|
||||
fi
|
||||
128
.claude/skills/edge-node-multi-protocol/scripts/12-smoke-test-sg.sh
Executable file
128
.claude/skills/edge-node-multi-protocol/scripts/12-smoke-test-sg.sh
Executable file
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env bash
|
||||
# End-to-end smoke test (no aaPanel): all 6 inbounds (vmess/trojan/ss x direct/warp),
|
||||
# landing page, 3x-ui panel, and the anti-scan default-444 catch-all.
|
||||
. "$(dirname "$0")/_lib.sh"
|
||||
|
||||
COVER="$SUB_COVER.$DOMAIN_ROOT"
|
||||
|
||||
echo "== credential integrity (catches the post-upgrade client wipe) =="
|
||||
# `x-ui migrate` (run by install.sh/update.sh on every upgrade) rebuilds
|
||||
# client_inbounds from each inbound's settings.clients JSON, deleting the old rows
|
||||
# first. If settings.clients is empty the node keeps answering TLS + WS-101 while
|
||||
# authenticating nobody -- so the transport checks below pass on a dead node.
|
||||
# See fix #5 in 08-xui-bootstrap.py; retrofit with 08b-embed-settings-clients.py.
|
||||
run 'python3 - <<PY
|
||||
import json, sqlite3, sys
|
||||
PROXY = ("vmess", "vless", "trojan", "shadowsocks")
|
||||
bad = []
|
||||
con = sqlite3.connect("/etc/x-ui/x-ui.db")
|
||||
rows = con.execute("SELECT port,protocol,settings FROM inbounds ORDER BY port").fetchall()
|
||||
proxy_rows = [r for r in rows if r[1] in PROXY]
|
||||
for port, proto, s in proxy_rows:
|
||||
try:
|
||||
cl = (json.loads(s) or {}).get("clients")
|
||||
except (TypeError, ValueError):
|
||||
cl = None
|
||||
if not isinstance(cl, list) or not cl:
|
||||
bad.append("inbound :%s (%s) settings.clients is EMPTY" % (port, proto))
|
||||
links = con.execute("SELECT count(*) FROM client_inbounds").fetchone()[0]
|
||||
if links < len(proxy_rows):
|
||||
bad.append("client_inbounds has %d row(s), expected >= %d" % (links, len(proxy_rows)))
|
||||
cfg = json.load(open("/usr/local/x-ui/bin/config.json"))
|
||||
for i in cfg.get("inbounds", []):
|
||||
if i.get("protocol") not in PROXY:
|
||||
continue
|
||||
cl = (i.get("settings") or {}).get("clients")
|
||||
if not isinstance(cl, list) or not cl:
|
||||
bad.append("generated config: inbound :%s (%s) has NO clients" % (i.get("port"), i.get("protocol")))
|
||||
if bad:
|
||||
print("FAIL -- credentials missing; every inbound will reject all traffic:")
|
||||
for b in bad:
|
||||
print(" -", b)
|
||||
print(" fix: run scripts/08b-embed-settings-clients.py on the box")
|
||||
sys.exit(1)
|
||||
print("OK -- %d inbounds have embedded clients, %d client_inbounds links, config has credentials"
|
||||
% (len(proxy_rows), links))
|
||||
PY' || echo "!! credential check FAILED (see above) -- transport checks below may still pass on a dead node"
|
||||
|
||||
echo "== cover WS upgrade, all 6 paths (expect 101 each) =="
|
||||
for P in "$WS_PATH_VMESS_DIRECT" "$WS_PATH_VMESS_WARP" "$WS_PATH_TROJAN_DIRECT" "$WS_PATH_TROJAN_WARP" \
|
||||
"$WS_PATH_SS_DIRECT" "$WS_PATH_SS_WARP"; do
|
||||
curl -sk --http1.1 --max-time 20 -o /dev/null -w "$P -> %{http_code}\n" \
|
||||
-H "Connection: Upgrade" -H "Upgrade: websocket" \
|
||||
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" -H "Sec-WebSocket-Version: 13" \
|
||||
"https://$COVER$P" || true
|
||||
done
|
||||
|
||||
echo "== landing + 3x-ui panel (expect 200) =="
|
||||
curl -sk --max-time 10 -o /dev/null -w "landing -> %{http_code}\n" "https://$DOMAIN_ROOT/" || true
|
||||
curl -sk --max-time 10 -o /dev/null -w "3x-ui -> %{http_code}\n" "https://$SUB_XUI.$DOMAIN_ROOT$XUI_PATH" || true
|
||||
|
||||
echo "== unmatched host (expect 000/444, connection dropped) =="
|
||||
curl -sk --max-time 8 -o /dev/null -w "random -> %{http_code}\n" "https://nope-$RANDOM.$DOMAIN_ROOT/" || true
|
||||
|
||||
echo "== e2e egress via temp xray client, all 6 outbounds (direct vs WARP per protocol) =="
|
||||
python3 - "$COVER" "$XUI_UUID" "$NODE_PASSWORD" "${SS_METHOD:-chacha20-poly1305}" \
|
||||
"$WS_PATH_VMESS_DIRECT" "$WS_PATH_VMESS_WARP" "$WS_PATH_TROJAN_DIRECT" "$WS_PATH_TROJAN_WARP" \
|
||||
"$WS_PATH_SS_DIRECT" "$WS_PATH_SS_WARP" <<'PYEOF'
|
||||
import json, sys
|
||||
(cover, uuid, node_pw, ss_method,
|
||||
vd, vw, td, tw, sd, sw) = sys.argv[1:11]
|
||||
trojan_pw = ss_pw = node_pw
|
||||
|
||||
def ws(path):
|
||||
return {"network": "ws", "security": "tls",
|
||||
"tlsSettings": {"serverName": cover},
|
||||
"wsSettings": {"path": path, "headers": {"Host": cover}}}
|
||||
|
||||
outbounds = [
|
||||
{"tag": "vmess-d", "protocol": "vmess",
|
||||
"settings": {"vnext": [{"address": cover, "port": 443, "users": [{"id": uuid, "security": "auto"}]}]},
|
||||
"streamSettings": ws(vd)},
|
||||
{"tag": "vmess-w", "protocol": "vmess",
|
||||
"settings": {"vnext": [{"address": cover, "port": 443, "users": [{"id": uuid, "security": "auto"}]}]},
|
||||
"streamSettings": ws(vw)},
|
||||
{"tag": "trojan-d", "protocol": "trojan",
|
||||
"settings": {"servers": [{"address": cover, "port": 443, "password": trojan_pw}]},
|
||||
"streamSettings": ws(td)},
|
||||
{"tag": "trojan-w", "protocol": "trojan",
|
||||
"settings": {"servers": [{"address": cover, "port": 443, "password": trojan_pw}]},
|
||||
"streamSettings": ws(tw)},
|
||||
{"tag": "ss-d", "protocol": "shadowsocks",
|
||||
"settings": {"servers": [{"address": cover, "port": 443, "method": ss_method, "password": ss_pw}]},
|
||||
"streamSettings": ws(sd)},
|
||||
{"tag": "ss-w", "protocol": "shadowsocks",
|
||||
"settings": {"servers": [{"address": cover, "port": 443, "method": ss_method, "password": ss_pw}]},
|
||||
"streamSettings": ws(sw)},
|
||||
]
|
||||
inbounds = []
|
||||
rules = []
|
||||
for i, tag in enumerate(("vmess-d", "vmess-w", "trojan-d", "trojan-w", "ss-d", "ss-w")):
|
||||
port = 10801 + i
|
||||
inbounds.append({"port": port, "listen": "127.0.0.1", "protocol": "socks",
|
||||
"tag": "in-%s" % tag, "settings": {"udp": True}})
|
||||
rules.append({"type": "field", "inboundTag": ["in-%s" % tag], "outboundTag": tag})
|
||||
|
||||
cfg = {"inbounds": inbounds, "outbounds": outbounds, "routing": {"rules": rules}}
|
||||
json.dump(cfg, open("/tmp/_smoke_tc.json", "w"))
|
||||
print("local socks ports: vmess-d=10801 vmess-w=10802 trojan-d=10803 trojan-w=10804 ss-d=10805 ss-w=10806")
|
||||
PYEOF
|
||||
|
||||
push /tmp/_smoke_tc.json /tmp/_smoke_tc.json
|
||||
run '
|
||||
cd /usr/local/x-ui/bin
|
||||
XRAY=$(ls xray-linux-* 2>/dev/null | head -1); [ -n "$XRAY" ] || XRAY=$(ls xray* 2>/dev/null | head -1)
|
||||
./$XRAY -c /tmp/_smoke_tc.json >/tmp/_smoke_tc.log 2>&1 &
|
||||
XPID=$!; sleep 4
|
||||
for pair in "vmess-direct:10801" "vmess-warp:10802" "trojan-direct:10803" "trojan-warp:10804" "ss-direct:10805" "ss-warp:10806"; do
|
||||
name="${pair%%:*}"; port="${pair##*:}"
|
||||
printf "%-14s " "$name"
|
||||
curl -s -x socks5h://127.0.0.1:$port --max-time 15 https://1.1.1.1/cdn-cgi/trace | grep -E "ip=|warp=" | tr "\n" " "
|
||||
echo
|
||||
done
|
||||
kill $XPID 2>/dev/null
|
||||
rm -f /tmp/_smoke_tc.json /tmp/_smoke_tc.log
|
||||
'
|
||||
rm -f /tmp/_smoke_tc.json
|
||||
|
||||
echo "Expected: *-direct -> ip=$SERVER_IP warp=off ; *-warp -> ip=<a Cloudflare address> warp=on"
|
||||
142
.claude/skills/edge-node-multi-protocol/scripts/12-smoke-test.sh
Executable file
142
.claude/skills/edge-node-multi-protocol/scripts/12-smoke-test.sh
Executable file
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env bash
|
||||
# End-to-end smoke test for all 6 inbounds (vmess/trojan/shadowsocks x direct/warp),
|
||||
# plus the landing page, both panels, and the anti-scan default-444 catch-all.
|
||||
# Run from anywhere with DNS resolving to the box.
|
||||
. "$(dirname "$0")/_lib.sh"
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
COVER="$SUB_COVER.$DOMAIN_ROOT"
|
||||
|
||||
echo "== credential integrity (catches the post-upgrade client wipe) =="
|
||||
# An upgrade that runs `x-ui migrate` rebuilds client_inbounds from each inbound's
|
||||
# settings.clients JSON, deleting the old rows first. If settings.clients is empty
|
||||
# the node keeps answering TLS + WS-101 while silently authenticating nobody, so the
|
||||
# transport checks below all pass on a completely dead node. Assert the credentials
|
||||
# are actually present, in the DB and in the generated xray config. See fix #5 in
|
||||
# 08-xui-bootstrap.py; retrofit with 08b-embed-settings-clients.py.
|
||||
run 'python3 - <<PY
|
||||
import json, sqlite3, sys
|
||||
PROXY = ("vmess", "vless", "trojan", "shadowsocks")
|
||||
bad = []
|
||||
con = sqlite3.connect("/etc/x-ui/x-ui.db")
|
||||
rows = con.execute("SELECT port,protocol,settings FROM inbounds ORDER BY port").fetchall()
|
||||
proxy_rows = [r for r in rows if r[1] in PROXY]
|
||||
for port, proto, s in proxy_rows:
|
||||
try:
|
||||
cl = (json.loads(s) or {}).get("clients")
|
||||
except (TypeError, ValueError):
|
||||
cl = None
|
||||
if not isinstance(cl, list) or not cl:
|
||||
bad.append("inbound :%s (%s) settings.clients is EMPTY" % (port, proto))
|
||||
links = con.execute("SELECT count(*) FROM client_inbounds").fetchone()[0]
|
||||
if links < len(proxy_rows):
|
||||
bad.append("client_inbounds has %d row(s), expected >= %d" % (links, len(proxy_rows)))
|
||||
cfg = json.load(open("/usr/local/x-ui/bin/config.json"))
|
||||
for i in cfg.get("inbounds", []):
|
||||
if i.get("protocol") not in PROXY:
|
||||
continue
|
||||
cl = (i.get("settings") or {}).get("clients")
|
||||
if not isinstance(cl, list) or not cl:
|
||||
bad.append("generated config: inbound :%s (%s) has NO clients" % (i.get("port"), i.get("protocol")))
|
||||
if bad:
|
||||
print("FAIL -- credentials missing; every inbound will reject all traffic:")
|
||||
for b in bad:
|
||||
print(" -", b)
|
||||
print(" fix: run scripts/08b-embed-settings-clients.py on the box")
|
||||
sys.exit(1)
|
||||
print("OK -- %d inbounds have embedded clients, %d client_inbounds links, config has credentials"
|
||||
% (len(proxy_rows), links))
|
||||
PY' || echo "!! credential check FAILED (see above) -- transport checks below may still pass on a dead node"
|
||||
|
||||
echo "== cover WS upgrade, all 6 paths (expect 101 each) =="
|
||||
# curl reports the upgrade status via -w before it ever exits, but the connection stays
|
||||
# open as a live WS tunnel afterwards, so curl commonly hits --max-time and returns a
|
||||
# non-zero exit even on a successful 101 -- `|| true` so one check's exit code can't
|
||||
# abort the rest of the script under `set -e`.
|
||||
for P in "$WS_PATH_VMESS_DIRECT" "$WS_PATH_VMESS_WARP" "$WS_PATH_TROJAN_DIRECT" "$WS_PATH_TROJAN_WARP" \
|
||||
"$WS_PATH_SS_DIRECT" "$WS_PATH_SS_WARP"; do
|
||||
curl -sk --http1.1 --max-time 20 -o /dev/null -w "$P -> %{http_code}\n" \
|
||||
-H "Connection: Upgrade" -H "Upgrade: websocket" \
|
||||
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" -H "Sec-WebSocket-Version: 13" \
|
||||
"https://$COVER$P" || true
|
||||
done
|
||||
|
||||
echo "== landing + panels (expect 200) =="
|
||||
curl -sk --max-time 10 -o /dev/null -w "landing -> %{http_code}\n" "https://$DOMAIN_ROOT/" || true
|
||||
curl -sk --max-time 10 -o /dev/null -w "3x-ui -> %{http_code}\n" "https://$SUB_XUI.$DOMAIN_ROOT$XUI_PATH" || true
|
||||
# aaPanel's anti-scan check needs a UA that actually looks like a real browser -- a bare
|
||||
# "Mozilla/5.0 (Macintosh)" gets judged as a scanner and served a fake 404 (see
|
||||
# references/pitfalls.md). Don't "fix" this by loosening CDN_PROXY or re-checking the
|
||||
# domain binding; check the UA string first, every time.
|
||||
curl -sk --max-time 10 -A "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/605.1.15" \
|
||||
-o /dev/null -w "aaPanel -> %{http_code}\n" "https://$SUB_PANEL.$DOMAIN_ROOT$AAPANEL_ENTRANCE" || true
|
||||
|
||||
echo "== unmatched host (expect 000/444, connection dropped) =="
|
||||
curl -sk --max-time 8 -o /dev/null -w "random -> %{http_code}\n" "https://nope-$RANDOM.$DOMAIN_ROOT/" || true
|
||||
|
||||
echo "== e2e egress via temp xray client, all 6 outbounds (direct vs WARP per protocol) =="
|
||||
# node-main carries one uuid (vmess) + one shared password (trojan+ss).
|
||||
python3 - "$COVER" "$XUI_UUID" "$NODE_PASSWORD" "${SS_METHOD:-chacha20-poly1305}" \
|
||||
"$WS_PATH_VMESS_DIRECT" "$WS_PATH_VMESS_WARP" "$WS_PATH_TROJAN_DIRECT" "$WS_PATH_TROJAN_WARP" \
|
||||
"$WS_PATH_SS_DIRECT" "$WS_PATH_SS_WARP" <<'PYEOF'
|
||||
import json, sys
|
||||
(cover, uuid, node_pw, ss_method,
|
||||
vd, vw, td, tw, sd, sw) = sys.argv[1:11]
|
||||
trojan_pw = ss_pw = node_pw # single shared credential
|
||||
|
||||
def ws(path):
|
||||
return {"network": "ws", "security": "tls",
|
||||
"tlsSettings": {"serverName": cover},
|
||||
"wsSettings": {"path": path, "headers": {"Host": cover}}}
|
||||
|
||||
outbounds = [
|
||||
{"tag": "vmess-d", "protocol": "vmess",
|
||||
"settings": {"vnext": [{"address": cover, "port": 443, "users": [{"id": uuid, "security": "auto"}]}]},
|
||||
"streamSettings": ws(vd)},
|
||||
{"tag": "vmess-w", "protocol": "vmess",
|
||||
"settings": {"vnext": [{"address": cover, "port": 443, "users": [{"id": uuid, "security": "auto"}]}]},
|
||||
"streamSettings": ws(vw)},
|
||||
{"tag": "trojan-d", "protocol": "trojan",
|
||||
"settings": {"servers": [{"address": cover, "port": 443, "password": trojan_pw}]},
|
||||
"streamSettings": ws(td)},
|
||||
{"tag": "trojan-w", "protocol": "trojan",
|
||||
"settings": {"servers": [{"address": cover, "port": 443, "password": trojan_pw}]},
|
||||
"streamSettings": ws(tw)},
|
||||
{"tag": "ss-d", "protocol": "shadowsocks",
|
||||
"settings": {"servers": [{"address": cover, "port": 443, "method": ss_method, "password": ss_pw}]},
|
||||
"streamSettings": ws(sd)},
|
||||
{"tag": "ss-w", "protocol": "shadowsocks",
|
||||
"settings": {"servers": [{"address": cover, "port": 443, "method": ss_method, "password": ss_pw}]},
|
||||
"streamSettings": ws(sw)},
|
||||
]
|
||||
inbounds = []
|
||||
rules = []
|
||||
for i, tag in enumerate(("vmess-d", "vmess-w", "trojan-d", "trojan-w", "ss-d", "ss-w")):
|
||||
port = 10801 + i
|
||||
inbounds.append({"port": port, "listen": "127.0.0.1", "protocol": "socks",
|
||||
"tag": "in-%s" % tag, "settings": {"udp": True}})
|
||||
rules.append({"type": "field", "inboundTag": ["in-%s" % tag], "outboundTag": tag})
|
||||
|
||||
cfg = {"inbounds": inbounds, "outbounds": outbounds, "routing": {"rules": rules}}
|
||||
json.dump(cfg, open("/tmp/_smoke_tc.json", "w"))
|
||||
print("local socks ports: vmess-d=10801 vmess-w=10802 trojan-d=10803 trojan-w=10804 ss-d=10805 ss-w=10806")
|
||||
PYEOF
|
||||
|
||||
push /tmp/_smoke_tc.json /tmp/_smoke_tc.json
|
||||
run '
|
||||
cd /usr/local/x-ui/bin
|
||||
XRAY=$(ls xray-linux-* 2>/dev/null | head -1); [ -n "$XRAY" ] || XRAY=$(ls xray* 2>/dev/null | head -1)
|
||||
./$XRAY -c /tmp/_smoke_tc.json >/tmp/_smoke_tc.log 2>&1 &
|
||||
XPID=$!; sleep 4
|
||||
for pair in "vmess-direct:10801" "vmess-warp:10802" "trojan-direct:10803" "trojan-warp:10804" "ss-direct:10805" "ss-warp:10806"; do
|
||||
name="${pair%%:*}"; port="${pair##*:}"
|
||||
printf "%-14s " "$name"
|
||||
curl -s -x socks5h://127.0.0.1:$port --max-time 15 https://1.1.1.1/cdn-cgi/trace | grep -E "ip=|warp=" | tr "\n" " "
|
||||
echo
|
||||
done
|
||||
kill $XPID 2>/dev/null
|
||||
rm -f /tmp/_smoke_tc.json /tmp/_smoke_tc.log
|
||||
'
|
||||
rm -f /tmp/_smoke_tc.json
|
||||
|
||||
echo "Expected: *-direct -> ip=$SERVER_IP warp=off ; *-warp -> ip=<a Cloudflare address> warp=on"
|
||||
87
.claude/skills/edge-node-multi-protocol/scripts/13-install-singbox-quic.sh
Executable file
87
.claude/skills/edge-node-multi-protocol/scripts/13-install-singbox-quic.sh
Executable file
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env bash
|
||||
# QUIC plane: install sing-box and serve whichever of Hysteria2 / TUIC has ports
|
||||
# configured in config.env, each with a direct and a WARP-clean-exit inbound.
|
||||
#
|
||||
# Additive by design -- safe to run on a node already serving the six TCP inbounds:
|
||||
# * sing-box is its own service on its own UDP ports; 3x-ui / Xray / nginx are
|
||||
# never touched, and nothing here writes to 3x-ui's database or nginx's config.
|
||||
# * The one shared resource is the acme.sh wildcard cert. sing-box reads it at
|
||||
# startup only, so the renew hook is EXTENDED (nginx reload + sing-box restart)
|
||||
# rather than replaced -- otherwise the QUIC plane quietly serves an expired
|
||||
# cert about 60 days later.
|
||||
#
|
||||
# Ports: set PORT_HY2_DIRECT / PORT_HY2_WARP and/or PORT_TUIC_DIRECT / PORT_TUIC_WARP.
|
||||
# Unset pairs are skipped. Putting a Hysteria2 inbound on UDP 443 is deliberate: it
|
||||
# reads as ordinary HTTP/3 traffic to that host, and it does not collide with nginx,
|
||||
# which holds TCP 443 (different protocol, different socket).
|
||||
#
|
||||
# Remember the firewall has two layers on some providers: this script opens the UDP
|
||||
# ports in ufw, but a cloud security group (AWS/GCP/Azure) must allow them too.
|
||||
. "$(dirname "$0")/_lib.sh"
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
require_vars NODE_PASSWORD SUB_COVER DOMAIN_ROOT
|
||||
[ -n "${PORT_HY2_DIRECT:-}${PORT_HY2_WARP:-}${PORT_TUIC_DIRECT:-}${PORT_TUIC_WARP:-}" ] || {
|
||||
echo "ERROR: no QUIC ports set in config.env (PORT_HY2_* / PORT_TUIC_*)" >&2; exit 1; }
|
||||
# TUIC authenticates with uuid+password, so it needs XUI_UUID.
|
||||
if [ -n "${PORT_TUIC_DIRECT:-}${PORT_TUIC_WARP:-}" ]; then require_vars XUI_UUID; fi
|
||||
|
||||
COVER="$SUB_COVER.$DOMAIN_ROOT"
|
||||
CERTDIR="/etc/nginx/ssl/$DOMAIN_ROOT"
|
||||
|
||||
echo "== install sing-box =="
|
||||
run 'if ! command -v sing-box >/dev/null 2>&1; then
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
for i in $(seq 1 60); do fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1 || break; sleep 10; done
|
||||
mkdir -p /etc/apt/keyrings
|
||||
curl -fsSL https://sing-box.app/gpg.key -o /etc/apt/keyrings/sagernet.asc
|
||||
chmod a+r /etc/apt/keyrings/sagernet.asc
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/sagernet.asc] https://deb.sagernet.org/ * *" > /etc/apt/sources.list.d/sagernet.list
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq sing-box >/dev/null || { echo "ERROR: sing-box install failed"; exit 1; }
|
||||
fi
|
||||
sing-box version | head -1'
|
||||
|
||||
echo "== verify the cert the QUIC plane will serve =="
|
||||
run "[ -s '$CERTDIR/fullchain.pem' ] && [ -s '$CERTDIR/privkey.pem' ] || { echo 'ERROR: wildcard cert missing at $CERTDIR — run 03-sign-*-cert.sh first'; exit 1; }
|
||||
echo cert-present"
|
||||
|
||||
echo "== generate config =="
|
||||
push "$HERE/13-singbox-config.py" /root/_singbox_config.py
|
||||
run "COVER='$COVER' CERT_DIR='$CERTDIR' WARP_SOCKS_PORT='${WARP_SOCKS_PORT:-40000}' \
|
||||
NODE_PASSWORD='$NODE_PASSWORD' XUI_UUID='${XUI_UUID:-}' \
|
||||
PORT_HY2_DIRECT='${PORT_HY2_DIRECT:-}' PORT_HY2_WARP='${PORT_HY2_WARP:-}' \
|
||||
HY2_OBFS_PASSWORD='${HY2_OBFS_PASSWORD:-}' \
|
||||
HY2_UP_MBPS='${HY2_UP_MBPS:-500}' HY2_DOWN_MBPS='${HY2_DOWN_MBPS:-500}' \
|
||||
PORT_TUIC_DIRECT='${PORT_TUIC_DIRECT:-}' PORT_TUIC_WARP='${PORT_TUIC_WARP:-}' \
|
||||
TUIC_CC='${TUIC_CC:-bbr}' \
|
||||
python3 /root/_singbox_config.py
|
||||
rm -f /root/_singbox_config.py
|
||||
sing-box check -c /etc/sing-box/config.json && echo config-valid"
|
||||
|
||||
echo "== open the UDP ports in ufw =="
|
||||
UDP_PORTS="${PORT_HY2_DIRECT:-} ${PORT_HY2_WARP:-} ${PORT_TUIC_DIRECT:-} ${PORT_TUIC_WARP:-}"
|
||||
run "for p in $UDP_PORTS; do [ -n \"\$p\" ] && ufw allow \$p/udp >/dev/null 2>&1; done
|
||||
ufw status | grep -E '/udp' || echo 'no udp rules (is ufw active?)'"
|
||||
|
||||
echo "== enable + start =="
|
||||
run 'systemctl enable sing-box >/dev/null 2>&1 || true
|
||||
systemctl restart sing-box
|
||||
sleep 4
|
||||
systemctl is-active sing-box
|
||||
ss -ulnp | grep sing-box || echo "WARN: sing-box has no UDP sockets"'
|
||||
|
||||
echo "== extend the cert-renew hook to restart sing-box =="
|
||||
# Resolve acme.sh on the box rather than writing `~/.acme.sh`: on a sudo-user login
|
||||
# the tilde expands in the *login* shell (HOME=/home/<user>) before sudo runs, so a
|
||||
# root-installed acme.sh is missed and the hook silently never gets updated.
|
||||
run "ACME=\$(for c in /root/.acme.sh/acme.sh \"\$HOME/.acme.sh/acme.sh\"; do [ -x \"\$c\" ] && echo \"\$c\" && break; done)
|
||||
[ -n \"\$ACME\" ] || { echo 'ERROR: acme.sh not found — cannot update the renew hook'; exit 1; }
|
||||
echo \"using \$ACME\"
|
||||
\$ACME --install-cert -d '$DOMAIN_ROOT' --ecc \
|
||||
--key-file '$CERTDIR/privkey.pem' \
|
||||
--fullchain-file '$CERTDIR/fullchain.pem' \
|
||||
--reloadcmd 'systemctl reload nginx; systemctl restart sing-box' 2>&1 | tail -2"
|
||||
|
||||
echo
|
||||
echo "NOTE: on AWS/GCP/Azure the cloud security group must also allow inbound UDP:$UDP_PORTS"
|
||||
105
.claude/skills/edge-node-multi-protocol/scripts/13-singbox-config.py
Executable file
105
.claude/skills/edge-node-multi-protocol/scripts/13-singbox-config.py
Executable file
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Runs ON the box. Emits /etc/sing-box/config.json for the QUIC plane.
|
||||
|
||||
Which inbounds get built is driven purely by which port variables are set, so the
|
||||
same script serves a Hysteria2-only node, a TUIC-only node, or both -- no editing
|
||||
required to add or drop a protocol.
|
||||
|
||||
Why a second daemon at all: the TCP inbounds (VMess/Trojan/Shadowsocks) hide
|
||||
behind nginx:443 as WebSocket paths. Hysteria2 and TUIC are QUIC over UDP -- they
|
||||
terminate TLS themselves and cannot be reverse-proxied, so each needs a real
|
||||
public UDP port. Xray-core (what 3x-ui drives) implements neither, hence sing-box
|
||||
running alongside 3x-ui. Nothing here touches 3x-ui, nginx, or the existing
|
||||
inbounds: separate service, separate ports, separate config file.
|
||||
|
||||
WARP egress reuses the cloudflare-warp SOCKS5 proxy the Xray plane already uses,
|
||||
routed by inbound tag -- one WARP install serves both planes.
|
||||
|
||||
Env in: COVER, CERT_DIR, WARP_SOCKS_PORT, NODE_PASSWORD, XUI_UUID,
|
||||
PORT_HY2_DIRECT / PORT_HY2_WARP / HY2_OBFS_PASSWORD / HY2_UP_MBPS / HY2_DOWN_MBPS,
|
||||
PORT_TUIC_DIRECT / PORT_TUIC_WARP / TUIC_CC
|
||||
Unset port pairs are simply skipped.
|
||||
"""
|
||||
import json, os, sys
|
||||
|
||||
COVER = os.environ["COVER"]
|
||||
CERT_DIR = os.environ.get("CERT_DIR", "/etc/nginx/ssl/" + COVER.split(".", 1)[1])
|
||||
WARP_PORT = int(os.environ.get("WARP_SOCKS_PORT", "40000"))
|
||||
OUT = os.environ.get("SINGBOX_CONFIG", "/etc/sing-box/config.json")
|
||||
|
||||
|
||||
def tls():
|
||||
return {"enabled": True, "server_name": COVER, "alpn": ["h3"],
|
||||
"certificate_path": os.path.join(CERT_DIR, "fullchain.pem"),
|
||||
"key_path": os.path.join(CERT_DIR, "privkey.pem")}
|
||||
|
||||
|
||||
def port(name):
|
||||
v = os.environ.get(name, "").strip()
|
||||
return int(v) if v else None
|
||||
|
||||
|
||||
inbounds = []
|
||||
|
||||
# --- Hysteria2 -------------------------------------------------------------
|
||||
# Brutal congestion control needs a DECLARED bandwidth; it ignores loss as a
|
||||
# congestion signal, so an over-stated figure causes a loss storm rather than
|
||||
# speed. up/down are the server's view of the link.
|
||||
up = int(os.environ.get("HY2_UP_MBPS", "500"))
|
||||
down = int(os.environ.get("HY2_DOWN_MBPS", "500"))
|
||||
obfs_pw = os.environ.get("HY2_OBFS_PASSWORD", "").strip()
|
||||
for tag, pvar in (("hy2-direct", "PORT_HY2_DIRECT"), ("hy2-warp", "PORT_HY2_WARP")):
|
||||
p = port(pvar)
|
||||
if not p:
|
||||
continue
|
||||
ib = {"type": "hysteria2", "tag": tag, "listen": "::", "listen_port": p,
|
||||
"up_mbps": up, "down_mbps": down,
|
||||
"users": [{"name": "node-main", "password": os.environ["NODE_PASSWORD"]}],
|
||||
"tls": tls()}
|
||||
if obfs_pw:
|
||||
ib["obfs"] = {"type": "salamander", "password": obfs_pw}
|
||||
inbounds.append(ib)
|
||||
|
||||
# --- TUIC v5 --------------------------------------------------------------
|
||||
tuic_cc = os.environ.get("TUIC_CC", "bbr")
|
||||
for tag, pvar in (("tuic-direct", "PORT_TUIC_DIRECT"), ("tuic-warp", "PORT_TUIC_WARP")):
|
||||
p = port(pvar)
|
||||
if not p:
|
||||
continue
|
||||
inbounds.append({
|
||||
"type": "tuic", "tag": tag, "listen": "::", "listen_port": p,
|
||||
"users": [{"name": "node-main", "uuid": os.environ["XUI_UUID"],
|
||||
"password": os.environ["NODE_PASSWORD"]}],
|
||||
"congestion_control": tuic_cc, "auth_timeout": "3s",
|
||||
"zero_rtt_handshake": False, "heartbeat": "10s", "tls": tls()})
|
||||
|
||||
if not inbounds:
|
||||
sys.exit("ERROR: no QUIC ports configured (set PORT_HY2_* and/or PORT_TUIC_*)")
|
||||
|
||||
warp_tags = [i["tag"] for i in inbounds if i["tag"].endswith("-warp")]
|
||||
direct_tags = [i["tag"] for i in inbounds if i["tag"].endswith("-direct")]
|
||||
|
||||
rules = []
|
||||
if warp_tags:
|
||||
rules.append({"inbound": warp_tags, "outbound": "warp"})
|
||||
if direct_tags:
|
||||
rules.append({"inbound": direct_tags, "outbound": "direct"})
|
||||
rules.append({"protocol": "bittorrent", "outbound": "block"})
|
||||
|
||||
cfg = {
|
||||
"log": {"level": "warn", "timestamp": True},
|
||||
"inbounds": inbounds,
|
||||
"outbounds": [
|
||||
{"type": "direct", "tag": "direct"},
|
||||
{"type": "socks", "tag": "warp", "server": "127.0.0.1",
|
||||
"server_port": WARP_PORT, "version": "5"},
|
||||
{"type": "block", "tag": "block"},
|
||||
],
|
||||
"route": {"rules": rules, "final": "direct"},
|
||||
}
|
||||
|
||||
os.makedirs(os.path.dirname(OUT), exist_ok=True)
|
||||
with open(OUT, "w") as f:
|
||||
json.dump(cfg, f, indent=2)
|
||||
os.chmod(OUT, 0o600)
|
||||
print("wrote %s with %d inbound(s): %s" % (OUT, len(inbounds), ", ".join(i["tag"] for i in inbounds)))
|
||||
108
.claude/skills/edge-node-multi-protocol/scripts/14-generate-links-and-qr.py
Executable file
108
.claude/skills/edge-node-multi-protocol/scripts/14-generate-links-and-qr.py
Executable file
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run LOCALLY (not on the box) after 08/09 have succeeded. Reads config.env from
|
||||
the same directory and writes share links + QR codes for all 6 nodes into
|
||||
--out (default: ./node-links/ next to this script). Needs `qrencode` on PATH
|
||||
(brew install qrencode / apt install qrencode) -- QR codes are generated fully
|
||||
offline, nothing here is ever sent to a third-party service, because these
|
||||
links carry live credentials.
|
||||
|
||||
All three protocols get a share link + QR. Shadowsocks over ws+tls uses the
|
||||
SIP002 ss:// URI with Xray-style transport query params -- userinfo is
|
||||
base64(method:password) with padding stripped, and the transport is carried in
|
||||
the query string (type=ws&path=...&security=tls&sni=...) rather than in a SIP003
|
||||
`plugin=` field. That is exactly what 3x-ui's own genShadowsocksLink emits, so
|
||||
the links here match what the panel's subscription endpoint hands out; verified
|
||||
against a live box by decoding /sub/<sub_id>. The sing-box JSON snippet is still
|
||||
written alongside as an unambiguous fallback for clients that reject the URI.
|
||||
"""
|
||||
import base64, json, os, re, subprocess, sys, urllib.parse as up
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
OUT = sys.argv[sys.argv.index("--out") + 1] if "--out" in sys.argv else os.path.join(HERE, "node-links")
|
||||
|
||||
|
||||
def load_env():
|
||||
env = {}
|
||||
path = os.path.join(HERE, "config.env")
|
||||
if not os.path.exists(path):
|
||||
sys.exit("ERROR: copy config.env.example -> config.env and fill it in")
|
||||
for line in open(path):
|
||||
m = re.match(r'\s*([A-Z0-9_]+)\s*=\s*"?([^"#\n]*)"?', line)
|
||||
if m:
|
||||
env[m.group(1)] = m.group(2).strip()
|
||||
return env
|
||||
|
||||
|
||||
E = load_env()
|
||||
COVER = "%s.%s" % (E["SUB_COVER"], E["DOMAIN_ROOT"])
|
||||
NODE_NAME = E.get("NODE_NAME", "edge-node")
|
||||
|
||||
|
||||
def vmess_link(remark, path):
|
||||
cfg = {
|
||||
"v": "2", "ps": remark, "add": COVER, "port": "443", "id": E["XUI_UUID"], "aid": "0",
|
||||
"scy": "auto", "net": "ws", "type": "none", "host": COVER, "path": path,
|
||||
"tls": "tls", "sni": COVER, "alpn": "",
|
||||
}
|
||||
raw = json.dumps(cfg, separators=(",", ":"), ensure_ascii=False)
|
||||
return "vmess://" + base64.b64encode(raw.encode()).decode()
|
||||
|
||||
|
||||
# trojan + shadowsocks share NODE_PASSWORD (one node-main client); vmess uses XUI_UUID.
|
||||
def trojan_link(remark, path):
|
||||
q = up.urlencode({"security": "tls", "type": "ws", "host": COVER, "path": path, "sni": COVER})
|
||||
return "trojan://%s@%s:443?%s#%s" % (E["NODE_PASSWORD"], COVER, q, up.quote(remark))
|
||||
|
||||
|
||||
def ss_link(remark, path):
|
||||
"""SIP002 ss:// with Xray transport params -- byte-identical to what 3x-ui emits.
|
||||
Param order matches the panel's (alphabetical) so the two can be diffed."""
|
||||
method = E.get("SS_METHOD", "chacha20-poly1305")
|
||||
userinfo = base64.b64encode(("%s:%s" % (method, E["NODE_PASSWORD"])).encode()).decode().rstrip("=")
|
||||
q = up.urlencode({"host": "", "path": path, "security": "tls", "sni": COVER, "type": "ws"})
|
||||
return "ss://%s@%s:443?%s#%s" % (userinfo, COVER, q, up.quote(remark))
|
||||
|
||||
|
||||
def ss_singbox(tag, path):
|
||||
return {
|
||||
"type": "shadowsocks", "tag": tag, "server": COVER, "server_port": 443,
|
||||
"method": E.get("SS_METHOD", "chacha20-poly1305"), "password": E["NODE_PASSWORD"],
|
||||
"tls": {"enabled": True, "server_name": COVER},
|
||||
"transport": {"type": "ws", "path": path, "headers": {"Host": COVER}},
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
links = {
|
||||
"vmess-direct.link": vmess_link("%s-vmess-direct" % NODE_NAME, E["WS_PATH_VMESS_DIRECT"]),
|
||||
"vmess-warp.link": vmess_link("%s-vmess-warp-clean-exit" % NODE_NAME, E["WS_PATH_VMESS_WARP"]),
|
||||
"trojan-direct.link": trojan_link("%s-trojan-direct" % NODE_NAME, E["WS_PATH_TROJAN_DIRECT"]),
|
||||
"trojan-warp.link": trojan_link("%s-trojan-warp-clean-exit" % NODE_NAME, E["WS_PATH_TROJAN_WARP"]),
|
||||
"ss-direct.link": ss_link("%s-ss-direct" % NODE_NAME, E["WS_PATH_SS_DIRECT"]),
|
||||
"ss-warp.link": ss_link("%s-ss-warp-clean-exit" % NODE_NAME, E["WS_PATH_SS_WARP"]),
|
||||
}
|
||||
for name, link in links.items():
|
||||
path = os.path.join(OUT, name)
|
||||
open(path, "w").write(link)
|
||||
png = path.replace(".link", "-qr.png")
|
||||
try:
|
||||
subprocess.run(["qrencode", "-o", png, "-s", "8", "-m", "2"], input=link.encode(), check=True)
|
||||
except FileNotFoundError:
|
||||
print(" (qrencode not found on PATH -- skipping QR for %s; link file written)" % name)
|
||||
print(" wrote", name)
|
||||
|
||||
for tag, path, filename in [
|
||||
("%s-ss-direct" % NODE_NAME, E["WS_PATH_SS_DIRECT"], "ss-direct-singbox.json"),
|
||||
("%s-ss-warp-clean-exit" % NODE_NAME, E["WS_PATH_SS_WARP"], "ss-warp-singbox.json"),
|
||||
]:
|
||||
with open(os.path.join(OUT, filename), "w") as f:
|
||||
json.dump(ss_singbox(tag, path), f, indent=2)
|
||||
print(" wrote", filename)
|
||||
|
||||
print("DONE — links + QR codes in", OUT)
|
||||
print("Reminder: never upload these files/links to a third-party QR or paste service — they carry live credentials.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
91
.claude/skills/edge-node-multi-protocol/scripts/15-generate-quic-links.py
Executable file
91
.claude/skills/edge-node-multi-protocol/scripts/15-generate-quic-links.py
Executable file
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run LOCALLY. Emits share links + QR codes + sing-box snippets for the QUIC plane
|
||||
(Hysteria2 / TUIC x direct/WARP). Companion to 14-generate-links-and-qr.py, which
|
||||
covers the six TCP inbounds.
|
||||
|
||||
QR codes are produced offline with the local `qrencode` binary -- these carry live
|
||||
credentials and must never be pasted into an online QR generator.
|
||||
"""
|
||||
import json, os, re, subprocess, sys
|
||||
from urllib.parse import quote
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
OUT = os.path.join(HERE, "node-links")
|
||||
|
||||
|
||||
def load_env():
|
||||
env, path = {}, os.path.join(HERE, "config.env")
|
||||
if not os.path.exists(path):
|
||||
sys.exit("ERROR: config.env not found")
|
||||
for line in open(path):
|
||||
m = re.match(r'\s*([A-Z0-9_]+)\s*=\s*"?([^"#\n]*)"?', line)
|
||||
if m:
|
||||
env[m.group(1)] = m.group(2).strip()
|
||||
return env
|
||||
|
||||
|
||||
E = load_env()
|
||||
COVER = "%s.%s" % (E["SUB_COVER"], E["DOMAIN_ROOT"])
|
||||
NAME = E.get("NODE_NAME", "Edge Node")
|
||||
PW = E["NODE_PASSWORD"]
|
||||
UUID = E["XUI_UUID"]
|
||||
OBFS = E["HY2_OBFS_PASSWORD"]
|
||||
CC = E.get("TUIC_CC", "bbr")
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
|
||||
|
||||
def write(fname, content):
|
||||
with open(os.path.join(OUT, fname), "w") as f:
|
||||
f.write(content + "\n")
|
||||
print(" wrote", fname)
|
||||
|
||||
|
||||
def qr(fname, data):
|
||||
try:
|
||||
subprocess.run(["qrencode", "-o", os.path.join(OUT, fname), "-s", "6", data], check=True)
|
||||
print(" wrote", fname)
|
||||
except (FileNotFoundError, subprocess.CalledProcessError):
|
||||
print(" (skipped %s -- install qrencode for QR codes)" % fname)
|
||||
|
||||
|
||||
def p(name):
|
||||
"""Empty/absent port -> protocol not deployed on this node; skip it."""
|
||||
return (E.get(name) or "").strip()
|
||||
|
||||
|
||||
# --- Hysteria2: hy2:// is the format sing-box / v2rayN / Shadowrocket accept ---
|
||||
for suffix, port in (("direct", p("PORT_HY2_DIRECT")), ("warp", p("PORT_HY2_WARP"))):
|
||||
if not port:
|
||||
continue
|
||||
tag = "%s-hy2-%s" % (NAME, suffix if suffix == "direct" else "warp-clean-exit")
|
||||
link = ("hy2://%s@%s:%s/?sni=%s&alpn=h3&obfs=salamander&obfs-password=%s#%s"
|
||||
% (quote(PW, safe=""), COVER, port, COVER, quote(OBFS, safe=""), quote(tag)))
|
||||
write("hy2-%s.link" % suffix, link)
|
||||
qr("hy2-%s-qr.png" % suffix, link)
|
||||
write("hy2-%s-singbox.json" % suffix, json.dumps({
|
||||
"type": "hysteria2", "tag": tag, "server": COVER, "server_port": int(port),
|
||||
"password": PW,
|
||||
"obfs": {"type": "salamander", "password": OBFS},
|
||||
"up_mbps": int(E.get("HY2_UP_MBPS", 500)), "down_mbps": int(E.get("HY2_DOWN_MBPS", 500)),
|
||||
"tls": {"enabled": True, "server_name": COVER, "alpn": ["h3"]},
|
||||
}, indent=2))
|
||||
|
||||
# --- TUIC v5 ---
|
||||
for suffix, port in (("direct", p("PORT_TUIC_DIRECT")), ("warp", p("PORT_TUIC_WARP"))):
|
||||
if not port:
|
||||
continue
|
||||
tag = "%s-tuic-%s" % (NAME, suffix if suffix == "direct" else "warp-clean-exit")
|
||||
link = ("tuic://%s:%s@%s:%s?sni=%s&alpn=h3&congestion_control=%s&udp_relay_mode=native#%s"
|
||||
% (UUID, quote(PW, safe=""), COVER, port, COVER, CC, quote(tag)))
|
||||
write("tuic-%s.link" % suffix, link)
|
||||
qr("tuic-%s-qr.png" % suffix, link)
|
||||
write("tuic-%s-singbox.json" % suffix, json.dumps({
|
||||
"type": "tuic", "tag": tag, "server": COVER, "server_port": int(port),
|
||||
"uuid": UUID, "password": PW, "congestion_control": CC,
|
||||
"udp_relay_mode": "native", "zero_rtt_handshake": False, "heartbeat": "10s",
|
||||
"tls": {"enabled": True, "server_name": COVER, "alpn": ["h3"]},
|
||||
}, indent=2))
|
||||
|
||||
print("DONE — QUIC links + QR + sing-box snippets in", OUT)
|
||||
print("NOTE: hy2:// and tuic:// share-link formats are client-dependent; the sing-box")
|
||||
print(" JSON snippets are the unambiguous fallback if a client rejects a link.")
|
||||
83
.claude/skills/edge-node-multi-protocol/scripts/16-add-socks5.py
Executable file
83
.claude/skills/edge-node-multi-protocol/scripts/16-add-socks5.py
Executable file
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Runs ON the box. Adds a password-authenticated SOCKS5 inbound to 3x-ui.
|
||||
|
||||
Reuses the existing Xray instance -- no new daemon. Xray implements SOCKS5 with
|
||||
RFC 1929 username/password auth natively, so this is purely a new inbound row.
|
||||
|
||||
Additive and idempotent: skips if the port already exists, touches no other
|
||||
inbound, writes nothing to nginx or sing-box.
|
||||
|
||||
Two things worth knowing about this inbound versus the other ten on this node:
|
||||
|
||||
* It is NOT wrapped in TLS. SOCKS5 auth per RFC 1929 sends the username and
|
||||
password in CLEARTEXT, and the tunnelled traffic is unencrypted too. Anyone
|
||||
on the path can read both. Raw SOCKS5 is also trivially fingerprinted. Treat
|
||||
the credentials as disposable and prefer binding to 127.0.0.1 + an SSH tunnel
|
||||
if the client can be pointed at a local port (see LISTEN below).
|
||||
|
||||
* `socks` is not one of the protocols 3x-ui projects through its clients /
|
||||
client_inbounds tables (that set is vmess/vless/trojan/shadowsocks/hysteria),
|
||||
so the accounts live directly in this inbound's settings JSON and the
|
||||
settings.clients migration hazard documented in 08-xui-bootstrap.py fix #5
|
||||
does not apply here.
|
||||
|
||||
Env in: SOCKS_PORT, SOCKS_USER, SOCKS_PASS, SERVER_IP
|
||||
LISTEN (default 0.0.0.0; set 127.0.0.1 for SSH-tunnel-only access)
|
||||
REMARK (default socks5-direct), XUI_DB
|
||||
"""
|
||||
import json, os, sqlite3, subprocess, sys, time
|
||||
|
||||
DB = os.environ.get("XUI_DB", "/etc/x-ui/x-ui.db")
|
||||
PORT = int(os.environ["SOCKS_PORT"])
|
||||
USER = os.environ["SOCKS_USER"]
|
||||
PASS = os.environ["SOCKS_PASS"]
|
||||
LISTEN = os.environ.get("LISTEN", "0.0.0.0")
|
||||
REMARK = os.environ.get("REMARK", "socks5-direct")
|
||||
# Xray announces this address in the UDP ASSOCIATE reply. For a remote server it
|
||||
# must be the public IP, or UDP-over-SOCKS5 breaks (TCP still works either way).
|
||||
SERVER_IP = os.environ.get("SERVER_IP", "127.0.0.1")
|
||||
TAG = "inbound-" + REMARK
|
||||
|
||||
|
||||
def cols(cur, table):
|
||||
return {r[1] for r in cur.execute("PRAGMA table_info(%s)" % table).fetchall()}
|
||||
|
||||
|
||||
def main():
|
||||
con = sqlite3.connect(DB)
|
||||
cur = con.cursor()
|
||||
|
||||
if cur.execute("SELECT 1 FROM inbounds WHERE port=?", (PORT,)).fetchone():
|
||||
print(" inbound :%d already exists, nothing to do" % PORT)
|
||||
return
|
||||
|
||||
row = {
|
||||
"user_id": 1, # NULL user_id -> proxies fine but INVISIBLE in the panel
|
||||
"up": 0, "down": 0, "total": 0, "remark": REMARK,
|
||||
"enable": 1, "expiry_time": 0,
|
||||
"listen": LISTEN, "port": PORT, "protocol": "socks",
|
||||
"settings": json.dumps({
|
||||
"auth": "password",
|
||||
"accounts": [{"user": USER, "pass": PASS}],
|
||||
"udp": True,
|
||||
"ip": SERVER_IP,
|
||||
}, indent=2),
|
||||
"stream_settings": json.dumps({"network": "tcp", "security": "none"}, indent=2),
|
||||
"sniffing": json.dumps({"enabled": True, "destOverride": ["http", "tls"]}),
|
||||
"tag": TAG,
|
||||
}
|
||||
ic = cols(cur, "inbounds")
|
||||
keys = [k for k in row if k in ic]
|
||||
cur.execute("INSERT INTO inbounds (%s) VALUES (%s)" % (",".join(keys), ",".join("?" * len(keys))),
|
||||
[row[k] for k in keys])
|
||||
con.commit()
|
||||
con.close()
|
||||
print(" inbound :%d socks5 added (listen=%s, auth=password, udp=on)" % (PORT, LISTEN))
|
||||
|
||||
subprocess.run(["systemctl", "restart", "x-ui"], check=True)
|
||||
time.sleep(6)
|
||||
print(" x-ui restarted")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
54
.claude/skills/edge-node-multi-protocol/scripts/_lib.sh
Executable file
54
.claude/skills/edge-node-multi-protocol/scripts/_lib.sh
Executable file
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
# Shared helpers. Every script sources this, which sources config.env.
|
||||
set -euo pipefail
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
[ -f "$HERE/config.env" ] || { echo "ERROR: copy config.env.example -> config.env and fill it in"; exit 1; }
|
||||
# shellcheck disable=SC1091
|
||||
. "$HERE/config.env"
|
||||
|
||||
# --- config sanity (fail fast) ---
|
||||
require_vars() {
|
||||
local miss=()
|
||||
for v in "$@"; do [ -n "${!v:-}" ] || miss+=("$v"); done
|
||||
if [ ${#miss[@]} -gt 0 ]; then echo "ERROR: config.env missing: ${miss[*]}" >&2; exit 1; fi
|
||||
}
|
||||
require_vars SERVER_IP SSH_USER DOMAIN_ROOT
|
||||
if [ -n "${SSH_KEY:-}" ]; then
|
||||
[ -f "$SSH_KEY" ] || { echo "ERROR: SSH_KEY not found: $SSH_KEY" >&2; exit 1; }
|
||||
chmod 600 "$SSH_KEY" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
SSH_OPTS=(-o StrictHostKeyChecking=accept-new -o ConnectTimeout=15)
|
||||
[ -n "${SSH_KEY:-}" ] && SSH_OPTS+=(-i "$SSH_KEY")
|
||||
|
||||
# Non-root login -> escalate on the box via sudo. Redirects/pipes must run as root too,
|
||||
# so wrap the whole command string in `sudo bash -c`.
|
||||
_ROOT_LOGIN=1; [ "$SSH_USER" != "root" ] && _ROOT_LOGIN=0
|
||||
|
||||
# run <remote command...> — execute on the VPS (as root, escalating if needed)
|
||||
run() {
|
||||
if [ "$_ROOT_LOGIN" = 1 ]; then
|
||||
ssh "${SSH_OPTS[@]}" "$SSH_USER@$SERVER_IP" "$@"
|
||||
else
|
||||
ssh "${SSH_OPTS[@]}" "$SSH_USER@$SERVER_IP" "sudo bash -c $(printf '%q' "$*")"
|
||||
fi
|
||||
}
|
||||
|
||||
# push <localfile> <remotepath> — copy to the box (stages through /tmp + sudo install if non-root)
|
||||
push() {
|
||||
if [ "$_ROOT_LOGIN" = 1 ]; then
|
||||
scp "${SSH_OPTS[@]}" "$1" "$SSH_USER@$SERVER_IP:$2"
|
||||
else
|
||||
local stage="/tmp/.stage.$$.$(basename "$2")"
|
||||
scp "${SSH_OPTS[@]}" "$1" "$SSH_USER@$SERVER_IP:$stage"
|
||||
run "install -D -m 644 '$stage' '$2'; rm -f '$stage'"
|
||||
fi
|
||||
}
|
||||
|
||||
gen_pass() { python3 -c "import secrets,string;print(''.join(secrets.choice(string.ascii_letters+string.digits) for _ in range($1)))"; }
|
||||
|
||||
# panel_port — read the aaPanel port the installer actually assigned (do not trust config guesses)
|
||||
panel_port() { run 'cat /www/server/panel/data/port.pl 2>/dev/null | tr -d "[:space:]"'; }
|
||||
|
||||
# box_arch — normalized arch token: amd64 | arm64
|
||||
box_arch() { run 'case "$(uname -m)" in x86_64) echo amd64;; aarch64|arm64) echo arm64;; *) uname -m;; esac'; }
|
||||
@@ -0,0 +1,120 @@
|
||||
# Copy to config.env and fill in. config.env is YOURS — keep it out of git.
|
||||
# Nothing here ships with real values; every field is a placeholder.
|
||||
|
||||
# --- target VPS ---
|
||||
SERVER_IP="203.0.113.10" # public IPv4
|
||||
SERVER_IPV6="2001:db8::10" # public IPv6 (optional; leave empty to skip AAAA)
|
||||
SSH_USER="root" # root, or a sudo user (scripts escalate via sudo)
|
||||
SSH_KEY="$HOME/.ssh/your-vps-key.pem" # private key for the VPS
|
||||
TZ="UTC" # server timezone (e.g. UTC, Asia/Shanghai, America/Los_Angeles)
|
||||
|
||||
# --- domain ---
|
||||
PARENT_ZONE="example.com" # your DNS zone
|
||||
DOMAIN_ROOT="nodeA.example.com" # this node owns *.nodeA.example.com
|
||||
|
||||
# --- DNS provider ---
|
||||
# Cloudflare is the default & has a turnkey record-creation script (02-cf-dns.py).
|
||||
# Any acme.sh-supported DNS provider works for the wildcard cert — set ACME_DNS_PLUGIN and
|
||||
# the matching credential vars below. For A/AAAA records on a non-CF provider, create them
|
||||
# yourself (see references/dns-records.md — it's two names, A + AAAA, DNS-only).
|
||||
DNS_PROVIDER="cloudflare" # informational: cloudflare | dnspod | aliyun | route53 | other
|
||||
ACME_DNS_PLUGIN="dns_cf" # acme.sh plugin: dns_cf | dns_dp | dns_ali | dns_aws | dns_gd | ...
|
||||
|
||||
# Cloudflare (default). Token needs Zone:DNS:Edit. NEVER commit.
|
||||
CF_API_TOKEN="cf-token-with-Zone-DNS-Edit"
|
||||
CF_ZONE_ID="" # optional; 02-cf-dns.py looks it up if empty
|
||||
|
||||
# Other providers — uncomment the pair your ACME_DNS_PLUGIN needs (acme.sh reads these env names):
|
||||
# DP_Id=""; DP_Key="" # dns_dp (DNSPod)
|
||||
# Ali_Key=""; Ali_Secret="" # dns_ali (Aliyun)
|
||||
# AWS_ACCESS_KEY_ID=""; AWS_SECRET_ACCESS_KEY="" # dns_aws (Route53)
|
||||
# GD_Key=""; GD_Secret="" # dns_gd (GoDaddy)
|
||||
|
||||
# --- subdomain labels (CHOOSE YOUR OWN, NON-OBVIOUS ONES) ---
|
||||
# Your panels sit at <SUB_*>.<DOMAIN_ROOT>. If your zone is public, do NOT reuse an
|
||||
# obvious convention (a label that names the service it fronts) — anyone can then
|
||||
# enumerate your control planes from DNS. Pick random-ish labels and keep them to
|
||||
# yourself. These examples are neutral placeholders, deliberately NOT a real scheme.
|
||||
SUB_PANEL="console" # aaPanel -> console.<root>
|
||||
SUB_XUI="gateway" # 3x-ui panel -> gateway.<root>
|
||||
SUB_COVER="edge" # node cover -> edge.<root>
|
||||
# landing page uses the bare <DOMAIN_ROOT>.
|
||||
|
||||
# --- aaPanel ---
|
||||
AAPANEL_USER="admin_you"
|
||||
AAPANEL_ENTRANCE="/change-this-entrance" # secret path, e.g. /8f3a1c
|
||||
AAPANEL_PORT="" # leave EMPTY — the installer assigns a random port;
|
||||
# scripts read it from the box (/www/server/panel/data/port.pl).
|
||||
# Only set this to force a specific value.
|
||||
AAPANEL_PASS="" # generate at runtime; store in password manager
|
||||
|
||||
# --- 3x-ui ---
|
||||
XUI_USER="panel_you"
|
||||
XUI_PATH="/change-this-path/" # webBasePath, trailing slash
|
||||
XUI_PORT="14533" # panel listen 127.0.0.1:XUI_PORT
|
||||
XUI_PASS="" # generate at runtime; store in password manager
|
||||
WARP_SOCKS_PORT="40000" # cloudflare-warp proxy SOCKS5 port
|
||||
|
||||
# --- three protocols x two egress paths = six inbounds, ONE client ---
|
||||
# All six inbounds (vmess/trojan/shadowsocks x direct/warp) are owned by a single
|
||||
# 3x-ui client "node-main", so the panel shows one client whose Info dialog lists
|
||||
# all six share links + one subscription. That one client carries BOTH credentials:
|
||||
# - XUI_UUID -> authenticates the two VMess inbounds
|
||||
# - NODE_PASSWORD -> authenticates the two Trojan AND two Shadowsocks inbounds
|
||||
# (trojan+ss share this one password by design — it's one client row; that's
|
||||
# what makes the single-client panel view work. See references/3x-ui-schema.md.)
|
||||
# Ports are internal (127.0.0.1, behind nginx) — only 443 is ever exposed publicly.
|
||||
# Paths are the WS cover paths: CHOOSE YOUR OWN, NON-OBVIOUS ONES (don't reuse a
|
||||
# convention, don't publish them — on a public zone they map your tunnels). These
|
||||
# examples are neutral placeholders, deliberately NOT a real scheme.
|
||||
|
||||
# Shared client credentials (node-main)
|
||||
XUI_UUID="" # generate at runtime (uuidgen); VMess auth
|
||||
NODE_PASSWORD="" # generate at runtime; Trojan + Shadowsocks auth; store in password manager
|
||||
XUI_SUBID="" # generate at runtime; subscription id
|
||||
SS_METHOD="chacha20-poly1305" # non-2022 cipher: plain-string password, no exact-length base64 PSK to misconfigure
|
||||
|
||||
# VMess
|
||||
PORT_VMESS_DIRECT="57707"
|
||||
PORT_VMESS_WARP="57708"
|
||||
WS_PATH_VMESS_DIRECT="/assets"
|
||||
WS_PATH_VMESS_WARP="/media"
|
||||
|
||||
# Trojan
|
||||
PORT_TROJAN_DIRECT="57709"
|
||||
PORT_TROJAN_WARP="57710"
|
||||
WS_PATH_TROJAN_DIRECT="/static"
|
||||
WS_PATH_TROJAN_WARP="/uploads"
|
||||
|
||||
# Shadowsocks
|
||||
PORT_SS_DIRECT="57711"
|
||||
PORT_SS_WARP="57712"
|
||||
WS_PATH_SS_DIRECT="/files"
|
||||
WS_PATH_SS_WARP="/download"
|
||||
|
||||
# --- monitoring (optional; leave empty to skip) ---
|
||||
NEZHA_SERVER="" # e.g. report.example.com:8966
|
||||
NEZHA_TLS="false"
|
||||
NEZHA_SECRET="" # shared agent key for your Nezha panel
|
||||
NEZHA_UUID="" # generate at runtime
|
||||
KOMARI_ENDPOINT="" # e.g. https://komari.example.com
|
||||
KOMARI_DISCOVERY_KEY="" # auto-discovery key for your Komari panel
|
||||
|
||||
# --- node display name (monitoring / labels) ---
|
||||
NODE_NAME="Edge Node A"
|
||||
|
||||
# --- optional QUIC plane (sing-box; blank ports skip that inbound) ---
|
||||
PORT_HY2_DIRECT=""
|
||||
PORT_HY2_WARP=""
|
||||
HY2_OBFS_PASSWORD="" # generate privately for salamander links
|
||||
HY2_UP_MBPS="500" # server-side bandwidth; match the actual link
|
||||
HY2_DOWN_MBPS="500"
|
||||
PORT_TUIC_DIRECT=""
|
||||
PORT_TUIC_WARP=""
|
||||
TUIC_CC="bbr"
|
||||
|
||||
# --- optional internal SOCKS5 (16-add-socks5.py runs ON the VPS via env) ---
|
||||
SOCKS_PORT=""
|
||||
SOCKS_USER=""
|
||||
SOCKS_PASS=""
|
||||
LISTEN="127.0.0.1" # SSH-tunnel-only example
|
||||
@@ -0,0 +1,8 @@
|
||||
[Unit]
|
||||
Description=Check Cloudflare WARP proxy health and recover if needed
|
||||
After=network-online.target warp-svc.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/bin/warp-healthcheck.sh
|
||||
21
.claude/skills/edge-node-multi-protocol/scripts/warp-healthcheck.sh
Executable file
21
.claude/skills/edge-node-multi-protocol/scripts/warp-healthcheck.sh
Executable file
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
# WARP proxy watchdog: WARP can show "Connected" while the SOCKS proxy stops egressing.
|
||||
# Checks warp=on through the SOCKS proxy; restarts warp-svc + reconnects if not.
|
||||
set -euo pipefail
|
||||
PROXY_ADDR="${PROXY_ADDR:-socks5h://127.0.0.1:40000}"
|
||||
TRACE_URL="${TRACE_URL:-https://www.cloudflare.com/cdn-cgi/trace}"
|
||||
TIMEOUT="${CURL_TIMEOUT:-15}"
|
||||
|
||||
log() { logger -t warp-healthcheck "$*"; echo "warp-healthcheck: $*"; }
|
||||
|
||||
if ! ss -lnt | grep -q '127.0.0.1:40000'; then
|
||||
log "SOCKS listener missing; restarting warp-svc"
|
||||
elif trace="$(curl -k -sS --max-time "$TIMEOUT" --proxy "$PROXY_ADDR" "$TRACE_URL" 2>/dev/null)" && grep -q 'warp=on' <<<"$trace"; then
|
||||
exit 0 # healthy
|
||||
else
|
||||
log "warp not on via proxy; recovering"
|
||||
fi
|
||||
|
||||
systemctl restart warp-svc 2>/dev/null || true
|
||||
sleep 8
|
||||
warp-cli --accept-tos connect 2>/dev/null || true
|
||||
@@ -0,0 +1,11 @@
|
||||
[Unit]
|
||||
Description=Run Cloudflare WARP health check every minute
|
||||
|
||||
[Timer]
|
||||
OnBootSec=90s
|
||||
OnUnitActiveSec=60s
|
||||
AccuracySec=15s
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,50 @@
|
||||
# {{HOST_LABEL}} 边缘节点 · 交付文档
|
||||
|
||||
服务器:`{{SSH_USER}}@{{SERVER_IP}}`(SSH 别名 `{{SSH_ALIAS}}`),{{VPS_PROVIDER_AND_SPECS}}
|
||||
IPv6:`{{SERVER_IPV6}}`
|
||||
根域名:`{{DOMAIN_ROOT}}`(Cloudflare 管理,泛域名 DNS-only / 灰云)
|
||||
部署时间:{{DEPLOY_DATE}}
|
||||
|
||||
---
|
||||
|
||||
## 服务清单
|
||||
|
||||
| 服务 | 作用 | 访问地址 | 用户名 | 密码 |
|
||||
|---|---|---|---|---|
|
||||
| **aaPanel** | 服务器控制面板:文件管理、防火墙、监控、站点管理 | `https://{{SUB_PANEL}}.{{DOMAIN_ROOT}}{{AAPANEL_ENTRANCE}}` | `{{AAPANEL_USER}}` | `{{AAPANEL_PASS}}` |
|
||||
| **3x-ui** | Xray 代理面板:管理 inbound、客户端、流量统计 | `https://{{SUB_XUI}}.{{DOMAIN_ROOT}}{{XUI_PATH}}` | `{{XUI_USER}}` | `{{XUI_PASS}}` |
|
||||
| **节点伪装页** | 承载六条 WS 隧道的域名,本身只是普通网页伪装 | `https://{{SUB_COVER}}.{{DOMAIN_ROOT}}` | — | — |
|
||||
| **落地页** | 根域名首页,静态页面,无实际功能 | `https://{{DOMAIN_ROOT}}` | — | — |
|
||||
|
||||
> ⚠️ **访问 aaPanel 面板务必使用真实浏览器**(Chrome/Safari/Edge 等)。用 curl 等工具测试时如果 UA 不像真实浏览器(比如只写默认 UA 或过于简单的 `Mozilla/5.0 (Macintosh)`),会被反爬虫机制拒绝,返回伪装的 404 页面——这是正常防护行为,不是故障。见 `references/pitfalls.md`。
|
||||
|
||||
## 后台服务(无独立管理界面,通过 aaPanel/3x-ui 或 SSH 管理)
|
||||
|
||||
| 服务 | 作用 |
|
||||
|---|---|
|
||||
| **nginx 1.24** | 反向代理入口,承载所有 HTTPS 域名、TLS 终止、伪装 WS 转发 |
|
||||
| **Cloudflare WARP(proxy 模式)** | 本机 SOCKS5 代理 `127.0.0.1:{{WARP_SOCKS_PORT}}`,给"WARP 出口"的 inbound 做净化出口 |
|
||||
| **warp-healthcheck.timer** | systemd 定时器,每 60 秒检查 WARP 代理是否真的在转发流量,异常时自动重启 warp-svc |
|
||||
| **acme.sh** | 自动续期泛域名证书 `*.{{DOMAIN_ROOT}}`,续期后自动重新部署证书并 reload nginx |
|
||||
| **UFW 防火墙** | 只放行 22 / 80 / 443 / 888 / {{AAPANEL_PORT}}(面板端口)/ 39000-40000(aaPanel 被动端口范围) |
|
||||
|
||||
## 六条代理 inbound(三种协议 × direct/WARP)
|
||||
|
||||
| 协议 | 用途 | 域名 | WS 路径 | 内部端口 | 凭据 |
|
||||
|---|---|---|---|---|---|
|
||||
| VMess · direct | 直连出口 | `{{SUB_COVER}}.{{DOMAIN_ROOT}}` | `{{WS_PATH_VMESS_DIRECT}}` | {{PORT_VMESS_DIRECT}} | UUID `{{XUI_UUID}}` |
|
||||
| VMess · WARP | 净出口 | `{{SUB_COVER}}.{{DOMAIN_ROOT}}` | `{{WS_PATH_VMESS_WARP}}` | {{PORT_VMESS_WARP}} | 同上 |
|
||||
| Trojan · direct | 直连出口 | `{{SUB_COVER}}.{{DOMAIN_ROOT}}` | `{{WS_PATH_TROJAN_DIRECT}}` | {{PORT_TROJAN_DIRECT}} | 密码 `{{NODE_PASSWORD}}` |
|
||||
| Trojan · WARP | 净出口 | `{{SUB_COVER}}.{{DOMAIN_ROOT}}` | `{{WS_PATH_TROJAN_WARP}}` | {{PORT_TROJAN_WARP}} | 同上 |
|
||||
| Shadowsocks · direct | 直连出口 | `{{SUB_COVER}}.{{DOMAIN_ROOT}}` | `{{WS_PATH_SS_DIRECT}}` | {{PORT_SS_DIRECT}} | 密码 `{{NODE_PASSWORD}}`,method `{{SS_METHOD}}` |
|
||||
| Shadowsocks · WARP | 净出口 | `{{SUB_COVER}}.{{DOMAIN_ROOT}}` | `{{WS_PATH_SS_WARP}}` | {{PORT_SS_WARP}} | 同上 |
|
||||
|
||||
六个 inbound 全部归属于**同一个 3x-ui 客户端 `node-main`**:VMess 用 UUID 认证,Trojan 和 Shadowsocks 共用同一个 `NODE_PASSWORD` 认证(因为是同一个客户端行)。所以在 3x-ui"客户端"页只看到 node-main 一行,点开它的信息详情就能看到全部 6 条节点链接/二维码和一个订阅。三个协议共用同一个 `{{SUB_COVER}}.{{DOMAIN_ROOT}}` 站点和泛域名证书,没有新建站点/新申请证书。新增/管理 inbound 是直接写 3x-ui 数据库(`/etc/x-ui/x-ui.db`)+ 重启 x-ui 的方式(面板 HTTP API 在这个版本上会 403,参见 `references/pitfalls.md`)。
|
||||
|
||||
节点二维码 / 订阅链接见《02-节点信息》(`scripts/14-generate-links-and-qr.py` 生成)。
|
||||
|
||||
## 安全提醒
|
||||
|
||||
- `SUB_PANEL={{SUB_PANEL}}` / `SUB_XUI={{SUB_XUI}}` / `SUB_COVER={{SUB_COVER}}` 这三个子域名前缀、六条 WS 伪装路径、两个面板入口路径,都不要公开发布——它们是控制面的地图。
|
||||
- 所有密码、UUID 已生成并写在上表和 `config.env` 里,`config.env` 只保留在你的本机,没有上传到任何 git 仓库。建议尽快把密码转存进密码管理器。
|
||||
- WARP 模式是你自己的干净出口,不是绕过他人网络管控的工具。
|
||||
@@ -0,0 +1,108 @@
|
||||
# {{HOST_LABEL}} 节点信息 · 客户端导入
|
||||
|
||||
用支持对应协议的客户端(v2rayN / v2rayNG / Shadowrocket / Nekobox / sing-box / Surge / Clash Meta 等)扫码或粘贴链接即可导入。运行 `scripts/14-generate-links-and-qr.py` 会自动生成本文档所需的全部 `.link` 文件和 `-qr.png` 二维码图片到同一个输出目录 —— 把对应文件内容/图片贴进下面每一节即可,不需要手抄。
|
||||
|
||||
六个节点使用同一个域名 `{{SUB_COVER}}.{{DOMAIN_ROOT}}`,同一个协议内的 direct/WARP 只有 WS 路径不同。
|
||||
|
||||
---
|
||||
|
||||
## 线路一:VMess 直连出口
|
||||
|
||||
出站走服务器自身公网 IP(`{{SERVER_IP}}`),速度最快,但暴露服务器真实 IP。
|
||||
|
||||

|
||||
|
||||
```
|
||||
{{VMESS_DIRECT_LINK}}
|
||||
```
|
||||
|
||||
## 线路二:VMess WARP 净出口
|
||||
|
||||
出站走 Cloudflare WARP,隐藏服务器真实 IP,出口 IP 是 Cloudflare 的地址,速度略慢于直连。
|
||||
|
||||

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

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

|
||||
|
||||
```
|
||||
{{TROJAN_WARP_LINK}}
|
||||
```
|
||||
|
||||
## 线路五:Shadowsocks 直连出口
|
||||
|
||||
生成器会输出带 Xray 传输参数的 `ss://` 链接与二维码,Proxy Station 可解析导入。其他客户端若不接受该写法,可用下面的参数或 sing-box JSON 片段。Surge 不支持 SS over WS,这两条线路不会下发到 Surge。
|
||||
|
||||
| 参数 | 值 |
|
||||
|---|---|
|
||||
| 地址 | `{{SUB_COVER}}.{{DOMAIN_ROOT}}` |
|
||||
| 端口 | `443` |
|
||||
| 加密方式 (method) | `{{SS_METHOD}}` |
|
||||
| 密码 | `{{NODE_PASSWORD}}`(与 Trojan 共用同一密码) |
|
||||
| 传输 | `ws`,路径 `{{WS_PATH_SS_DIRECT}}` |
|
||||
| TLS / SNI | 开启,`{{SUB_COVER}}.{{DOMAIN_ROOT}}` |
|
||||
|
||||
sing-box outbound(`ss-direct-singbox.json`,由生成脚本自动写出):
|
||||
```json
|
||||
{{SS_DIRECT_SINGBOX_JSON}}
|
||||
```
|
||||
|
||||
Surge 用户使用其他受支持线路;不能把 SS over WS 写成 Surge 的 `obfs=wss`。
|
||||
|
||||
## 线路六:Shadowsocks WARP 净出口
|
||||
|
||||
参数和线路五完全一样,公开端口仍为 443,只有出口与 WS 路径不同(`{{WS_PATH_SS_WARP}}`)。
|
||||
|
||||
sing-box outbound(`ss-warp-singbox.json`):
|
||||
```json
|
||||
{{SS_WARP_SINGBOX_JSON}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 手动填写参数 · VMess(如果客户端不支持扫码/粘贴链接)
|
||||
|
||||
| 参数 | direct | WARP |
|
||||
|---|---|---|
|
||||
| 地址 (address) | `{{SUB_COVER}}.{{DOMAIN_ROOT}}` | 同左 |
|
||||
| 端口 (port) | `443` | 同左 |
|
||||
| 用户ID (UUID) | `{{XUI_UUID}}` | 同左 |
|
||||
| 额外ID (AlterId) | `0` | 同左 |
|
||||
| 加密方式 (security) | `auto` | 同左 |
|
||||
| 传输协议 (network) | `ws` | 同左 |
|
||||
| 伪装类型 (type) | `none` | 同左 |
|
||||
| 路径 (path) | `{{WS_PATH_VMESS_DIRECT}}` | `{{WS_PATH_VMESS_WARP}}` |
|
||||
| Host / SNI | `{{SUB_COVER}}.{{DOMAIN_ROOT}}` | 同左 |
|
||||
| TLS | 开启 | 开启 |
|
||||
|
||||
## 手动填写参数 · Trojan
|
||||
|
||||
| 参数 | direct | WARP |
|
||||
|---|---|---|
|
||||
| 地址 / 端口 | `{{SUB_COVER}}.{{DOMAIN_ROOT}}` / `443` | 同左 |
|
||||
| 密码 | `{{NODE_PASSWORD}}`(与 Shadowsocks 共用同一密码) | 同左 |
|
||||
| 传输协议 (network) | `ws` | 同左 |
|
||||
| 路径 (path) | `{{WS_PATH_TROJAN_DIRECT}}` | `{{WS_PATH_TROJAN_WARP}}` |
|
||||
| Host / SNI | `{{SUB_COVER}}.{{DOMAIN_ROOT}}` | 同左 |
|
||||
| TLS | 开启 | 开启 |
|
||||
|
||||
## 验证方式
|
||||
|
||||
导入后连接,访问 `https://1.1.1.1/cdn-cgi/trace`,看返回的 `ip=` 和 `warp=`:
|
||||
- direct 线路(任意协议)应显示 `ip={{SERVER_IP}}`,`warp=off`
|
||||
- WARP 线路(任意协议)应显示一个 Cloudflare 段的 IP,`warp=on`
|
||||
|
||||
服务器侧的 `scripts/12-smoke-test.sh` 已经对全部六条线路做过 WS-101 + 出口 IP 的实测验证;如果你这边导入后验证结果不对,大概率是客户端参数抄错了(尤其是 path 和 host 要跟表里完全一致,包括大小写),不是服务器端的问题。
|
||||
@@ -0,0 +1,71 @@
|
||||
# {{HOST_LABEL}} 运维指南(edge-node-multi-protocol)
|
||||
|
||||
本指南基于 `edge-node-multi-protocol` skill 部署的真实节点整理,适用于维护这台节点或用同一个 skill 部署新节点。
|
||||
|
||||
---
|
||||
|
||||
## 一、这套系统是什么
|
||||
|
||||
一键把一台裸的 Debian/Ubuntu VPS 变成:
|
||||
|
||||
```
|
||||
用户 ──HTTPS──▶ nginx(aaPanel管理) ──┬─▶ aaPanel 面板(反代到 127.0.0.1:面板端口)
|
||||
├─▶ 3x-ui 面板(反代到 127.0.0.1:14533)
|
||||
└─▶ 节点伪装域名 {{SUB_COVER}}.{{DOMAIN_ROOT}}
|
||||
├─ VMess direct/WARP (2 inbound)
|
||||
├─ Trojan direct/WARP (2 inbound)
|
||||
└─ Shadowsocks direct/WARP (2 inbound)
|
||||
每个 direct 直接出公网
|
||||
每个 WARP 走 WARP SOCKS5(127.0.0.1:{{WARP_SOCKS_PORT}}) 出 Cloudflare
|
||||
```
|
||||
|
||||
外层是泛域名 TLS(acme.sh 自动续期)+ Cloudflare DNS(灰云,不走 CF 代理),三个协议的六个 inbound 共用同一份证书和同一个 cover 域名。
|
||||
|
||||
## 二、部署一台新节点的标准流程
|
||||
|
||||
见 `SKILL.md` → Workflow,简要版:
|
||||
|
||||
```
|
||||
00-preflight → 02-cf-dns → 03-sign-wildcard-cert
|
||||
→ 04-install-aapanel → 05-harden-aapanel → 04b-install-nginx
|
||||
→ 06-install-warp → 06b-warp-watchdog
|
||||
→ 07-install-3xui → 08-xui-bootstrap(一次性建好全部6个inbound)
|
||||
→ 09-aapanel-sites → 09b-default-444
|
||||
→ 10-ufw → 11-monitoring(可选)
|
||||
→ 12-smoke-test(验证全部6条线路)
|
||||
→ 14-generate-links-and-qr(本地运行,出分享链接+二维码)
|
||||
```
|
||||
|
||||
## 三、这套 skill 已修复的版本兼容坑
|
||||
|
||||
来自实战踩坑,脚本里已经改好,这里记录原因方便你判断"要不要重新核实":
|
||||
|
||||
### 坑 1:aaPanel 安装等待循环的 pgrep 自匹配 bug
|
||||
|
||||
早期版本用 `pgrep -f install_7.0` 判断安装进程是否还在跑,但这条检查命令自己的命令行文本里就含有 `"install_7.0"` 字符串,导致 pgrep 把自己也匹配进去,判断条件永远为假,脚本卡死轮询——即使安装其实早就装完了(通常 1 分钟左右)。**已修复**:现在改成检查安装日志里的 `Congratulations! Installed successfully!` 字符串。
|
||||
|
||||
### 坑 2:aaPanel 7.x 把部分内部模块挪到了 `class_v2` 目录
|
||||
|
||||
建站脚本原本只把 `/www/server/panel` 和 `/www/server/panel/class` 加进 `sys.path`,缺了 `/www/server/panel/class_v2`,导致 `ModuleNotFoundError: theme_config`。**已修复**:`09-aapanel-sites.py` 已经加上了这一行。如果你手写新脚本要 import 面板内部模块,记得也加上。
|
||||
|
||||
### 坑 3:aaPanel 反爬虫策略比早期文档写的更严格
|
||||
|
||||
用 curl 测试面板入口时,UA 必须是完整真实的浏览器 UA(如 `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/605.1.15`),过于简单的 UA(哪怕带 "Mozilla" 字样)会被判定为扫描器,返回伪装的 404。**排查经验**:如果面板域名/入口路径怎么测都是 404,**先别怀疑域名绑定、CDN_PROXY、证书配置**——先换一个更真实的浏览器 UA 字符串重新 curl,或者直接用真实浏览器打开看看。`12-smoke-test.sh` 已经用了正确的 UA。
|
||||
|
||||
### 坑 4:3x-ui 的 client 表是"协议通用凭据表"
|
||||
|
||||
VMess/VLESS 用 `uuid` 列,Trojan/Shadowsocks 用 `password` 列;`client_traffics.email` 全局唯一(一个 client 一行,不是一个 inbound 一行);WARP 路由规则要"追加"而不是"整个重写",否则会把已有协议的路由覆盖掉。完整表结构和新增协议的步骤见 `references/3x-ui-schema.md`。
|
||||
|
||||
## 四、日常运维
|
||||
|
||||
- **重启 aaPanel**:只能用 `/etc/init.d/bt restart`(或 `bt 1`),**绝对不能用** `systemctl restart btpanel`——systemd 单元的 `ExecStart` 走的是这条 SysV 脚本,`systemctl restart` 会把面板停掉且不会自动拉起,反代的面板域名直接 502。
|
||||
- **重启 nginx**:同理用 `/etc/init.d/nginx reload/restart`,不要用 systemctl。
|
||||
- **续期证书**:acme.sh 已经注册了 `--reloadcmd`,到期自动续期、自动重新部署到 4 个域名的证书目录、自动 reload nginx,不需要人工干预。可以用 `~/.acme.sh/acme.sh --list` 查看到期时间。
|
||||
- **加新的代理客户端**(同一协议下加一个用户):去 3x-ui 面板对已有 inbound 加 client,或者直接改数据库(面板 HTTP API 在这个版本上会 403,见坑4),参考 `references/3x-ui-schema.md`。
|
||||
- **加新的协议**(第4个协议):见 `SKILL.md` → "Adding a protocol to an already-provisioned node",以及 `references/3x-ui-schema.md` 最后一节和 `references/protocol-choice.md` 关于什么时候值得加。
|
||||
- **WARP 掉线自愈**:`warp-healthcheck.timer` 每 60 秒检查一次,通过 SOCKS5 代理实际发请求确认 `warp=on`,异常会自动重启 `warp-svc`。查看状态:`systemctl status warp-healthcheck.timer`。
|
||||
- **防火墙**:只放行了 22/80/443/888/面板端口/39000-40000,六个协议 inbound 全部是 `127.0.0.1`-only、走 nginx 反代 443,不需要额外开端口。
|
||||
|
||||
## 五、监控(可选)
|
||||
|
||||
`11-monitoring.sh` 支持接入 Nezha / Komari。去 `config.env` 填 `NEZHA_SERVER`/`NEZHA_SECRET` 或 `KOMARI_ENDPOINT`/`KOMARI_DISCOVERY_KEY`,单独跑这一个脚本即可,不影响其他服务。
|
||||
50
.claude/skills/edge-node-multi-protocol/templates/README.md
Normal file
50
.claude/skills/edge-node-multi-protocol/templates/README.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Documentation templates
|
||||
|
||||
Three markdown templates to fill in with a real node's values after deployment, plus
|
||||
`landing.html` (the public landing-page cover, used by `09b-default-444.sh`).
|
||||
|
||||
- `01-handover-template.md` — service inventory: what's running, how to log in, what each thing does.
|
||||
- `02-node-info-template.md` — client-facing: share links + QR codes for all 6 nodes, manual params as fallback.
|
||||
- `03-ops-guide-template.md` — architecture + standard workflow + the version-compat pitfalls + day-2 ops.
|
||||
|
||||
## How to fill them in
|
||||
|
||||
1. Run `scripts/14-generate-links-and-qr.py` first — it writes `vmess-direct.link`,
|
||||
`vmess-warp.link`, `trojan-direct.link`, `trojan-warp.link`, the matching `-qr.png` files, and
|
||||
`ss-direct.link` / `ss-warp.link` with their QR codes plus `ss-direct-singbox.json` /
|
||||
`ss-warp-singbox.json` into an output directory. Put the doc copies
|
||||
in the same directory so the `` image references in
|
||||
`02-node-info-template.md` resolve.
|
||||
2. Copy the three `*-template.md` files into that same output directory (drop the `-template`
|
||||
suffix if you like, e.g. `01-handover.md`), and replace every `{{TOKEN}}` below with the real
|
||||
value from `config.env` (or the node's live state, for things like `AAPANEL_PORT` which the
|
||||
installer randomizes — read it from `/www/server/panel/data/port.pl` on the box).
|
||||
3. For `{{VMESS_DIRECT_LINK}}` / `{{VMESS_WARP_LINK}}` / `{{TROJAN_DIRECT_LINK}}` /
|
||||
`{{TROJAN_WARP_LINK}}`, paste the contents of the matching `.link` file. For
|
||||
`{{SS_DIRECT_SINGBOX_JSON}}` / `{{SS_WARP_SINGBOX_JSON}}`, paste the matching `.json` file's
|
||||
contents inside the fenced code block that's already there.
|
||||
|
||||
## Token reference
|
||||
|
||||
| Token | Source |
|
||||
|---|---|
|
||||
| `{{HOST_LABEL}}` | Whatever short name you use for this node (e.g. an SSH alias or hostname) |
|
||||
| `{{SSH_ALIAS}}` / `{{SSH_USER}}` | Your local SSH config / `config.env` `SSH_USER` |
|
||||
| `{{SERVER_IP}}` / `{{SERVER_IPV6}}` | `config.env` |
|
||||
| `{{VPS_PROVIDER_AND_SPECS}}` | Whatever's useful to note (provider, region, vCPU/RAM) |
|
||||
| `{{DEPLOY_DATE}}` | The date you ran through the workflow |
|
||||
| `{{DOMAIN_ROOT}}` / `{{SUB_PANEL}}` / `{{SUB_XUI}}` / `{{SUB_COVER}}` | `config.env` |
|
||||
| `{{AAPANEL_ENTRANCE}}` / `{{AAPANEL_USER}}` / `{{AAPANEL_PASS}}` / `{{AAPANEL_PORT}}` | `config.env` + live panel port from the box |
|
||||
| `{{XUI_PATH}}` / `{{XUI_USER}}` / `{{XUI_PASS}}` | `config.env` |
|
||||
| `{{XUI_UUID}}` | `config.env` (node-main's VMess credential) |
|
||||
| `{{NODE_PASSWORD}}` | `config.env` (node-main's shared Trojan+Shadowsocks credential) |
|
||||
| `{{SS_METHOD}}` | `config.env` |
|
||||
| `{{PORT_VMESS_DIRECT}}` / `{{PORT_VMESS_WARP}}` / `{{PORT_TROJAN_DIRECT}}` / `{{PORT_TROJAN_WARP}}` / `{{PORT_SS_DIRECT}}` / `{{PORT_SS_WARP}}` | `config.env` |
|
||||
| `{{WS_PATH_VMESS_DIRECT}}` / `{{WS_PATH_VMESS_WARP}}` / `{{WS_PATH_TROJAN_DIRECT}}` / `{{WS_PATH_TROJAN_WARP}}` / `{{WS_PATH_SS_DIRECT}}` / `{{WS_PATH_SS_WARP}}` | `config.env` |
|
||||
| `{{WARP_SOCKS_PORT}}` | `config.env` (default 40000) |
|
||||
| `{{VMESS_DIRECT_LINK}}` / `{{VMESS_WARP_LINK}}` / `{{TROJAN_DIRECT_LINK}}` / `{{TROJAN_WARP_LINK}}` | contents of the matching `.link` file from step 9 |
|
||||
| `{{SS_DIRECT_SINGBOX_JSON}}` / `{{SS_WARP_SINGBOX_JSON}}` | contents of the matching `.json` file from step 9 |
|
||||
|
||||
Never fill these in with placeholder/example values in a copy that leaves your machine — every
|
||||
token above except `{{HOST_LABEL}}`/`{{DEPLOY_DATE}}`/`{{VPS_PROVIDER_AND_SPECS}}` is either a
|
||||
secret or part of your control-plane map.
|
||||
@@ -0,0 +1,19 @@
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>__NODE_NAME__</title>
|
||||
<style>
|
||||
:root{color-scheme:light dark}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;min-height:100vh;display:grid;place-items:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:linear-gradient(135deg,#0a2a43,#1668a8);color:#eaf4ff}
|
||||
.card{max-width:560px;padding:48px 40px;text-align:center}
|
||||
h1{font-size:2.2rem;margin:0 0 .4em;letter-spacing:.04em}
|
||||
p{opacity:.82;line-height:1.7;margin:.3em 0}
|
||||
.dot{display:inline-block;width:9px;height:9px;border-radius:50%;background:#43b0ff;box-shadow:0 0 10px #43b0ff;margin-right:8px;vertical-align:middle}
|
||||
.foot{margin-top:2.4em;font-size:.82rem;opacity:.55}
|
||||
</style></head><body>
|
||||
<div class="card">
|
||||
<h1>__NODE_NAME__</h1>
|
||||
<p><span class="dot"></span>Edge service operational</p>
|
||||
<p>Private research & networking endpoint. No public service is hosted on this page.</p>
|
||||
<p class="foot">for authorized use only</p>
|
||||
</div></body></html>
|
||||
Reference in New Issue
Block a user