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

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

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

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

View File

@@ -0,0 +1,142 @@
# 3x-ui database schema notes
Read this before writing a script that adds a new protocol or inbound by hand.
Everything here was confirmed by reading the live sqlite DB (`/etc/x-ui/x-ui.db`)
and the generated runtime config (`/usr/local/x-ui/bin/config.json`) on a real
box — not from 3x-ui's own documentation, which doesn't cover this.
## Why DB-direct instead of the panel API
The panel HTTP API 403s unauthenticated/scripted requests on the builds this
skill has been tested against (security middleware). Every inbound this skill
adds is written straight into the sqlite DB, followed by `systemctl restart
x-ui` to pick up the change. `08-xui-bootstrap.py` is the reference
implementation — read it before writing a new one-off script.
## Relevant tables
## The single-client model (why one `node-main` owns all six inbounds)
This skill deliberately creates **one** client, `node-main`, linked to all six
inbounds — not one client per protocol. Because the `clients` row is
protocol-generic (it carries both a `uuid` and a `password`), that single row
authenticates VMess (by its `uuid`) *and* Trojan+Shadowsocks (by its
`password`) depending on which inbound the traffic hits. The payoff is entirely
in the panel/subscription UX: the Clients page shows one row, its Info dialog
lists all six share links + QR codes, and its one subscription (by `sub_id`)
carries all six nodes. Trojan and Shadowsocks therefore **share one password**
(`NODE_PASSWORD`) — intended for a single-user node.
**`inbounds`** — one row per inbound (listener). Columns that matter:
`user_id` (**set to 1** — a NULL/empty user_id makes the inbound invisible in
the panel even though it works; see pitfalls.md), `port`, `protocol`
(`vmess`/`trojan`/`shadowsocks`/...), `listen` (`127.0.0.1` here — nginx does
the public-facing TLS), `settings` (protocol-specific JSON, see below),
`stream_settings` (transport JSON — this skill uses
`{"network":"ws","security":"none","wsSettings":{...},"externalProxy":[{...}]}`;
`security` is `"none"` because nginx already terminated TLS upstream, and the
`externalProxy` entry makes generated share links point at `<cover>:443` over
TLS instead of the useless internal `127.0.0.1:<port>` — see pitfalls.md),
`tag` (used to target this inbound in routing rules).
**`clients`** — a *protocol-generic* credential table, not one table per
protocol. Relevant columns: `email` (must be unique — this skill uses ONE email,
`node-main`, for all six inbounds), `uuid` (used by vmess/vless), `password`
(used by trojan/shadowsocks), `sub_id` (groups this client's inbounds into one
subscription), `security`/`flow` (vmess/vless-only). There is no column that
says which protocol a client belongs to — that's implied entirely by which
inbound(s) it's linked to via `client_inbounds`, and 3x-ui's backend picks
`uuid` vs `password` based on *that inbound's* `protocol` field when it builds
the live xray config. So node-main sets both `uuid` and `password`.
**`client_inbounds`** — links a client to an inbound: `(client_id, inbound_id,
flow_override, created_at)`. One row per (client, inbound) pair — node-main gets
six rows (one per inbound).
**`client_traffics`** — traffic-stat tracking, **one row per client `email`,
not per inbound** (email is globally unique in this table). Insert exactly one
row for `node-main`, pointing `inbound_id` at any one of its linked inbounds
(which one doesn't matter — it's just where the panel attributes the stats row).
**`settings`** (key/value store) — the `xrayTemplateConfig` key holds the JSON
that 3x-ui actually feeds to xray-core (merged with the DB-derived inbounds at
runtime). This skill's WARP outbound + routing rule live here. **Read-modify-write
this, never overwrite it wholesale** — the WARP routing rule's `inboundTag`
array needs the new protocol's warp-inbound tag *appended*, not replaced,
or a previous protocol's warp routing silently stops working.
## `settings` JSON shape per protocol (in the `inbounds.settings` column)
```jsonc
// vmess
{"clients": [{"id": "<uuid>", "email": "node-main", "enable": true, "subId": "<sub-id>", "alterId": 0}], "disableInsecureEncryption": false}
// trojan
{"clients": [{"password": "<NODE_PASSWORD>", "email": "node-main", "enable": true, "subId": "<sub-id>"}], "fallbacks": []}
// shadowsocks — note the TOP-LEVEL "password" (prevents the v3.5.0 link-gen panic, see pitfalls.md)
{"clients": [{"password": "<NODE_PASSWORD>", "method": "chacha20-poly1305", "email": "node-main", "enable": true, "subId": "<sub-id>"}], "method": "chacha20-poly1305", "password": "<NODE_PASSWORD>", "network": "tcp,udp"}
```
Keep the client embedded in `settings.clients` on BOTH schema generations.
The normalized `clients` and `client_inbounds` tables are derived projections;
upgrade/import migration rebuilds them from this JSON. An empty array may
appear to work until migration removes the projected credentials. The migrated
`08-xui-bootstrap.py` writes both representations; existing table-only nodes
are repaired by `08b-embed-settings-clients.py` after a database backup.
Use the full client shape in those scripts, including explicit `enable: true`
and consistent subscription/quota fields across all six inbounds. Verify the
generated `/usr/local/x-ui/bin/config.json` after restarting. Minimal protocol
credential fields (the common fields above are also required):
```jsonc
// vmess client — all three use the SAME email "node-main" (one client)
{"id": "<uuid>", "email": "node-main", "alterId": 0}
// trojan client
{"password": "<NODE_PASSWORD>", "email": "node-main"}
// shadowsocks client
{"password": "<NODE_PASSWORD>", "method": "chacha20-poly1305", "email": "node-main"}
```
## Adding a fourth protocol later (e.g. VLESS+REALITY)
1. Add its ports/paths/credential to `config.env` (follow the `PORT_<PROTO>_{DIRECT,WARP}` /
`WS_PATH_<PROTO>_{DIRECT,WARP}` naming already used for vmess/trojan/ss).
2. Add a `(protocol, port_direct, port_warp, path_direct, path_warp)` tuple to
the `PROTOCOLS` list in `08-xui-bootstrap.py`, and its client-shape branch
to `inbound_settings()` (REALITY additionally needs `realitySettings` in
`stream_settings` with a real "borrowed" target site — this is per-inbound
TLS-mimicry config the outer nginx doesn't touch, so `security` in
`stream_settings` would need to be `"reality"` for that inbound specifically
instead of `"none"`, and you'd route it directly rather than through the
nginx WS+TLS cover this skill uses for the other three, since REALITY does
its own TLS handshake and can't be terminated by an upstream nginx first).
3. Add the new WS paths to `PROXIES` in `09-aapanel-sites.py` (skip this for
REALITY specifically, since it bypasses nginx as noted above and would need
its own firewall port opened instead).
4. Add the new protocol's outbound block to the smoke-test client generator in
`12-smoke-test.sh`.
5. Re-run `08-xui-bootstrap.sh` (idempotent — existing inbounds are skipped by
port) then `09-aapanel-sites.sh`, then `12-smoke-test.sh`. `08-xui-bootstrap.py`
already sets `user_id=1`, adds the `externalProxy` link hint, links the new
inbounds to the existing `node-main` client, and (for shadowsocks-family
protocols) writes the top-level `password` — so a new protocol inherits all
version-compat fixes as long as you extend `PROTOCOLS` and its client-shape
logic rather than hand-inserting rows. Keep `settings.clients` populated
for each added protocol as well as maintaining the projection tables.
## Subscription (optional, not enabled by this skill)
3x-ui's subscription server (`/sub/<sub_id>`) returns every share link for a
client's `sub_id` — since node-main's `sub_id` covers all six inbounds, one
subscription URL delivers all six nodes and stays in sync as you add/remove
inbounds. It listens on its own port (2096 by default, HTTP) and is **off by
default** in this skill. Two gotchas if you turn it on:
- The sub port is **not** opened by `10-ufw.sh` (only 443 + panel ports are).
Don't just `ufw allow 2096` — that exposes a plaintext-HTTP endpoint carrying
every node's credentials on a guessable `IP:port`. Instead reverse-proxy a
non-obvious subdomain (e.g. `sub.<root>`) → `127.0.0.1:2096` through nginx on
443 with the wildcard cert, same pattern as the panels, so the subscription
is HTTPS and hidden behind 443.
- "Empty reply from server" on the sub port is almost always the firewall
(connection reached UFW's default drop), not 3x-ui — verify by curling
`http://127.0.0.1:2096/sub/<id>` **on the box** first (200 there = the
service and links are fine, the problem is purely how it's exposed).

View File

@@ -0,0 +1,58 @@
# Centralized wildcard certs (fleet option)
`03-sign-wildcard-cert.sh` signs the wildcard **on the box** — simplest for a single node.
For a fleet you may prefer to sign every wildcard on **one** host and push to the rest, so
the Cloudflare DNS token lives in exactly one place and renewals are centralized.
## Model
```
one signer host (acme.sh + CF token)
┌───────────────────────────────────┐
│ acme.sh --dns dns_cf │ issues *.nodeA.example.com,
│ --reloadcmd distribute.sh │ *.nodeB.example.com, …
└───────────────┬───────────────────┘
│ scp fullchain+key on every renew
┌─────────────┼─────────────┐
nodeA nodeB nodeC (BT cert dirs + nginx reload)
```
## Signer setup (once)
```bash
export CF_Token='<token>'
acme.sh --set-default-ca --server letsencrypt
acme.sh --issue --dns dns_cf -d nodeA.example.com -d '*.nodeA.example.com' --keylength ec-256
```
## Distribution reloadcmd
A small script the signer runs after each renewal — for each target it scp's the cert into
the aaPanel cert dirs and reloads nginx. Register it once:
```bash
acme.sh --install-cert -d nodeA.example.com --ecc --reloadcmd "/root/acme-deploy/distribute.sh"
```
`distribute.sh` (sketch — adapt hosts/sites to yours; **no real hosts here**):
```bash
TARGETS=(
"<nodeA_ip>:root:nodeA.example.com,<cover>.nodeA.example.com,<xui>.nodeA.example.com,<panel>.nodeA.example.com"
)
SRC=/root/.acme.sh/nodeA.example.com_ecc
for entry in "${TARGETS[@]}"; do
host="${entry%%:*}"; rest="${entry#*:}"; user="${rest%%:*}"; sites="${rest#*:}"
scp -o BatchMode=yes "$SRC/fullchain.cer" "$user@$host:/tmp/.fc.pem"
scp -o BatchMode=yes "$SRC/nodeA.example.com.key" "$user@$host:/tmp/.pk.pem"
ssh "$user@$host" "IFS=,; for s in \$sites; do d=/www/server/panel/vhost/cert/\$s; mkdir -p \$d;
install -m 644 /tmp/.fc.pem \$d/fullchain.pem; install -m 600 /tmp/.pk.pem \$d/privkey.pem; done;
rm -f /tmp/.fc.pem /tmp/.pk.pem; /www/server/nginx/sbin/nginx -s reload"
done
```
## Trust + notes
- Put the signer's SSH **public** key in each target's `authorized_keys`; the signer needs write to the BT cert dirs + nginx reload only.
- To add a region to an existing wildcard, `--issue` again with the extra `-d` names (SAN change requires a re-issue, not `--renew`), then extend `TARGETS`.
- Keep the CF token on the signer only; targets never see it.

View File

@@ -0,0 +1,42 @@
# DNS records this node needs (any provider)
The wildcard cert (`03-sign-wildcard-cert.sh`) proves domain control via **DNS-01** using
whatever `ACME_DNS_PLUGIN` you set — acme.sh supports Cloudflare, DNSPod, Aliyun, Route53,
GoDaddy, and dozens more. That part is provider-agnostic.
The only thing that is *not* automated for non-Cloudflare providers is creating the host
**A/AAAA records**. That's just two names. Create them in your DNS provider's console/API:
| Name | Type | Value | Proxy/CDN |
|---|---|---|---|
| `<DOMAIN_ROOT>` (e.g. `nodeA.example.com`) | A | `SERVER_IP` | **off** (DNS-only / grey cloud) |
| `*.<DOMAIN_ROOT>` | A | `SERVER_IP` | **off** |
| `<DOMAIN_ROOT>` | AAAA | `SERVER_IPV6` | off (skip if no IPv6) |
| `*.<DOMAIN_ROOT>` | AAAA | `SERVER_IPV6` | off |
Notes:
- Keep them **unproxied** — the node terminates its own TLS and needs the real client IP path; a proxy/CDN in front would break the WS cover and the panel anti-scan logic.
- TTL 300 is fine.
- The wildcard `*.<DOMAIN_ROOT>` covers the landing / cover / panel subdomains, so you don't add per-subdomain records.
## Cloudflare shortcut
If your zone is on Cloudflare, skip the manual step — `02-cf-dns.py` upserts all four
records for you via the API (`CF_API_TOKEN` with `Zone:DNS:Edit`). Run it from wherever the
token lives; it never touches the VPS.
## Picking the acme.sh plugin + creds
Set `ACME_DNS_PLUGIN` and the matching credential vars in `config.env` (acme.sh reads them
from the environment; `03-sign-wildcard-cert.sh` forwards whichever are set):
| Provider | `ACME_DNS_PLUGIN` | env vars |
|---|---|---|
| Cloudflare | `dns_cf` | `CF_API_TOKEN` (forwarded as `CF_Token`) |
| DNSPod | `dns_dp` | `DP_Id`, `DP_Key` |
| Aliyun | `dns_ali` | `Ali_Key`, `Ali_Secret` |
| AWS Route53 | `dns_aws` | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` |
| GoDaddy | `dns_gd` | `GD_Key`, `GD_Secret` |
| others | see acme.sh dnsapi | per its README |
Full list: <https://github.com/acmesh-official/acme.sh/wiki/dnsapi>

View File

@@ -0,0 +1,52 @@
# Pitfalls (read before you run)
Every item here cost real debugging time on a live deployment. They are ordered by how likely they are to bite you.
## aaPanel
- **Never `systemctl restart btpanel`.** `btpanel.service` is `enabled` but its `ExecStart` is `/etc/init.d/bt start` (SysV); the systemd unit shows `active: inactive (dead)` while the panel process (`webserver`) runs fine. `systemctl restart btpanel` stops the panel and it does **not** come back → your reverse-proxied panel returns **502**. To restart use `bt 1` or `/etc/init.d/bt start`. Same for nginx (`/etc/init.d/nginx`, not `systemctl`).
- **Reverse-proxy needs `CDN_PROXY=True`** in `/www/server/panel/config/cdn.conf`, or the panel rejects proxied requests. Restart the panel after setting it.
- **Anti-scan fake 404/403 — the UA has to look like a REAL browser, not just contain the word "Mozilla".** aaPanel returns a *fake* nginx 404 to requests it judges to be scanners. On aaPanel 7.x this check got noticeably stricter: a minimal UA like `Mozilla/5.0 (Macintosh)` (which used to be enough) now gets judged as a scanner and served the fake 404 — even hitting `/login` or `/` directly on the box over the unix socket gives the same fake 404 with that UA. Use a full, realistic UA string, e.g. `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/605.1.15`, and it works immediately. **If the entrance/domain/path all test as 404 no matter what you try, check the UA string completeness FIRST** — before you start doubting `CDN_PROXY`, domain binding, or the cert. This one costs the most debugging time of anything in this file. A real browser was never affected either way.
- Also **403** on direct `IP:port` access once a panel domain is bound — that one's real and expected, not a bug.
- **aaPanel 7.x moved several backend modules into a new `class_v2` directory** (e.g. `theme_config.py`). Scripts that do `sys.path.insert(0, "/www/server/panel/class")` and then `import panelSite` (or anything that transitively imports `common.py` → `theme_config`) will fail with `ModuleNotFoundError: theme_config` unless `/www/server/panel/class_v2` is *also* on `sys.path`. `09-aapanel-sites.py` already does this; if you hand-write a new script that touches panel internals, add the same `sys.path.insert(0, "/www/server/panel/class_v2")` line.
- **Build sites through the panel, never hand-write vhosts.** A hand-written `/www/server/panel/vhost/nginx/*.conf` works in nginx but is invisible/unmanageable in the panel UI. Use the panel classes (`AddSite`/`SetSSL`/`CreateProxy`/`HttpToHttps`) — that's what `09-aapanel-sites.py` does. The panel HTTP API can't be driven by curl (CSRF + anti-scan); drive the classes with the panel's bundled `pyenv` python.
- **nginx 1.24 uses `listen 443 ssl http2;`** — it does **not** support the newer `http2 on;` directive; that syntax fails `nginx -t`.
- **The aaPanel install-wait loop must not use `pgrep -f install_7.0` to detect "still installing".** A command like `until run 'test -f BT-Panel && ! pgrep -f install_7.0'; do sleep 15; done` has a self-match bug: the check command's own argv literally contains the string `"install_7.0"`, so `pgrep -f` matches the check itself every time, and the condition can never go true — the loop spins forever even though the install finished in ~1 minute. `04-install-aapanel.sh` instead waits for the literal string `Congratulations` in the install log plus the `BT-Panel` binary existing. If you ever add a similar poll-by-pgrep check, make sure the pattern you grep for doesn't appear in the checking command's own command line (the classic fix elsewhere is `pgrep -f '[i]nstall_7.0'` — the bracket trick — but matching a log string is simpler and was chosen here).
## 3x-ui (newer versions, Xray 26.x) — multi-protocol notes
- **Keep `inbounds.settings.clients` populated.** The normalized `clients` and `client_inbounds` tables are projections. Upgrades/migrations rebuild them from settings JSON; a table-only deployment can lose all credentials. The migrated `08-xui-bootstrap.py` includes this fix; repair older nodes with `08b-embed-settings-clients.py` on the VPS after a database backup.
- **One client owns all six inbounds — don't create one client per protocol.** The whole point of the single-client design is that the panel's Clients page shows ONE row ("node-main") whose Info dialog lists all six share links + QR codes and a single subscription covering all six. That works because the `clients` table is protocol-generic (next bullet): one row carrying both a `uuid` and a `password`, linked via `client_inbounds` to all six inbounds. `08-xui-bootstrap.py` does exactly this. (An earlier iteration created three separate clients — node-main / trojan-main / ss-main — which "worked" for traffic but split the links across three panel rows and made the single-subscription view impossible; consolidating them later meant deleting two clients and re-pointing credentials. Build it as one client from the start.)
- **The `clients` table is protocol-generic, not protocol-specific.** It has both a `uuid` column and a `password` column (plus VMess/VLESS-only fields like `security`/`flow`). VMess/VLESS inbounds read `uuid`; Trojan/Shadowsocks inbounds read `password`. There's no "protocol" column on `clients` itself — which field 3x-ui's backend actually uses depends on the *inbound's* `protocol` value at the point it builds the live xray config, not on anything stored on the client row. This isn't documented anywhere obvious; it was confirmed by reading the live generated `/usr/local/x-ui/bin/config.json` after inserting rows and restarting `x-ui`, and seeing the right credential land in the right field for each protocol. **Consequence:** with one client, Trojan and Shadowsocks share ONE password (that client's `password` column); VMess uses its `uuid`. That's intended for a single-user node — it's what `NODE_PASSWORD` in `config.env` is.
- **`user_id` on an inbound must be set (1), or the inbound is INVISIBLE in the panel.** The panel's Inbounds and Clients lists filter by the logged-in admin's `user_id`. A DB-inserted inbound with a NULL/empty `user_id` exists in the DB and proxies traffic perfectly (xray loads it, smoke test passes), but the panel UI silently filters it out — you'll see "only the vmess inbounds show up" even though all six are there. `08-xui-bootstrap.py` sets `user_id=1` on every inserted inbound. If you hand-insert an inbound later, set it too (or `UPDATE inbounds SET user_id=1 WHERE user_id IS NULL OR user_id=''`).
- **Every inbound needs an `externalProxy` in streamSettings, or the generated share links are useless.** These inbounds listen on `127.0.0.1` with `security:"none"` (nginx terminates TLS at the edge on `<cover>:443`). 3x-ui doesn't know the public address, so by default it generates links pointing at `127.0.0.1:<internal port>` with no TLS — which no client can use. Add `"externalProxy":[{"forceTls":"tls","dest":"<cover host>","port":443,"sni":"<cover host>","remark":"<name>"}]` to each inbound's streamSettings and every generated link/QR/subscription instead points at `<cover>:443` over TLS. `08-xui-bootstrap.py` does this. (This is a *link-generation* hint only — it's a 3x-ui-specific field that's stripped when building the live xray config, so it never affects the data path.)
- **Shadowsocks inbound `settings` needs a TOP-LEVEL `password` field, or the panel's "copy links" endpoint returns an empty 200 and the UI shows "No response data".** 3x-ui v3.5.0's share-link generator does `settings["password"].(string)` — an *unchecked* Go type assertion. A normal (non-2022) SS inbound has no top-level password (the real per-client password lives on the client), so that assertion hits `nil` and **panics**. gin's recovery swallows the panic (its error writer is `io.Discard`, so nothing reaches `journalctl`), the `/panel/api/clients/subLinks/<subId>` endpoint returns HTTP 200 with an EMPTY body, and the frontend — seeing no parseable JSON — reports "No response data" and drops *all* the links (not just SS). The subscription server returns 500 for the same reason. Fix: write a top-level `"password"` into the SS inbound's `settings` (auth still goes through the client table; for chacha20 ciphers this field doesn't even appear in the emitted `ss://` link — it exists only to keep the assertion from panicking). `08-xui-bootstrap.py` sets it. Debugging note: this presents as "200 empty body", not an error status — so watch for empty-but-200 API responses, and remember the sub server's Go logs are discarded.
- **`client_traffics.email` is GLOBALLY UNIQUE.** One client shared across two inbounds (e.g. a protocol's direct + warp inbound) gets **one** `client_traffics` row, not one per inbound — inserting two rows with the same email throws `UNIQUE constraint failed`.
- **`client_inbounds` has a `flow_override` column** (client_id, inbound_id, flow_override, created_at) — include it in the INSERT even though it's usually empty; some 3x-ui builds may reject a 3-column insert if the schema doesn't line up with what an older reference implementation expected.
- **Extend `xrayTemplateConfig`'s WARP routing rule, never overwrite the whole thing.** The WARP-egress inbounds' tags (and their `inbound-127.0.0.1:<port>` fallback form) need to be appended to the *existing* rule's `inboundTag` array. Re-generating and writing the whole `xrayTemplateConfig` from scratch (instead of read-modify-write) silently deletes whatever protocols were added in a previous run.
- **Auth is by UUID/password**, so a mismatch between the traffic-stat email label and the running config email is cosmetic, not a fault.
- **Protocol mismatch when testing.** A VLESS test client against a `vmess+ws` inbound is correctly rejected (`websocket close 1000`) — that's not a server bug, it's the wrong protocol. Match the test client's protocol (vmess/trojan/shadowsocks) exactly to the inbound you're testing, with the right `security`/`method`/`password` for that protocol.
- **Panel API `/login` may 403** on newer builds (security middleware). Do DB-direct edits + `systemctl restart x-ui` instead of the HTTP API — confirmed true both for the original VMess bootstrap and again when adding Trojan/Shadowsocks later.
- **Shadowsocks 2022-blake3-\* ciphers need an exact-byte-length base64 password/PSK** — get the length wrong and xray fails to start with no obvious error pointing at the cipher. `chacha20-poly1305` (or `aes-256-gcm`) takes a plain password string, is much harder to misconfigure, and is still a modern AEAD cipher — prefer it unless you have a specific reason to use the 2022 series.
## Cloudflare WARP
- **`gpg` must be installed first.** On a minimal Debian, `gpg --dearmor` for the repo keyring fails with `gpg: command not found`, and the later `apt update` then errors `Failed to parse keyring … sqv returned an error code`. `apt-get install -y gnupg` before adding the repo.
- Use the **`bookworm`** repo line even on Debian 13 (trixie) — the bookworm package works.
- **WARP can lie about being connected.** The control plane shows `Connected` while the SOCKS proxy stops egressing. The watchdog (`warp-healthcheck.*`) checks `warp=on` *through the proxy* and restarts `warp-svc` when it isn't.
## nginx / cover
- WS upgrade must return **101** with `--http1.1` + the `Upgrade`/`Connection` headers. If you test with HTTP/2 you'll get 400 (HTTP/2 doesn't carry the Upgrade frame).
- Default server for unmatched `server_name` should `return 444` so scanners hitting the bare IP / wrong host get dropped.
## Monitoring
- **Komari** auto-discovery registers the node with an auto-generated name (`Auto-<hostname>`). Rename/group it in your panel afterward (panel-specific; often a small DB update or a UI action). The agent falls back to a v1 WebSocket if the panel has no v2 API — that's normal.
- **Nezha** agent install one-liners change over time. If the scripted install fails, drop the `nezha-agent` binary + a `config.yml` (server, `tls`, `client_secret`, `uuid`) and a small systemd unit — the agent connects out to `SERVER:PORT` (gRPC), so only outbound is needed.
## SSH / access
- Some providers ship a `.pem`/`.ppk` bundle; use the `.pem`. `chmod 600` it or ssh refuses it.
- Fresh boxes may run fail2ban; repeated failed auth from a dynamic IP can lock you out. Get your key in early (step 0) so you're not relying on password auth.

View File

@@ -0,0 +1,61 @@
# Why VMess + Trojan + Shadowsocks, and when to add a fourth
This skill's default 6-inbound target (3 protocols × direct/WARP) was chosen for a specific
combination of constraints: client compatibility (Surge excludes the Shadowsocks-over-WS lines; see the project capability matrix), transport safety (no UDP, so nothing here is exposed to networks that
throttle UDP indiscriminately), and zero extra server software (all three are native to
3x-ui/Xray-core — no sing-box, no separate Hysteria2/TUIC binary to maintain).
If your constraints are different, the right protocol set is different too. This file is the
reasoning, not a rule — read it before assuming the default 3 are the "right" choice for a new
node.
## The three defaults
| Protocol | Transport overhead | GFW-detection posture | Client support |
|---|---|---|---|
| VMess (+WS+TLS) | Two encryption layers (VMess's own AEAD, on top of the outer TLS) + WS framing — heaviest of the three | Weakest of the three; historically a specific target of active probing, and Xray-core itself now prints a deprecation warning for it | Nearly universal |
| Trojan (+WS+TLS) | One layer (just the outer TLS) + WS framing — noticeably lighter than VMess | Stronger: Trojan's whole design is "indistinguishable from a real HTTPS site to anyone without the password" | Nearly universal, including Surge |
| Shadowsocks (+WS+TLS) | Similar to Trojan — SS's own cipher is lightweight, WS+TLS is the same cover as the others | Comparable to VMess's cover level, but without VMess's specific deprecated-protocol baggage | Supported by compatible WS clients; Surge does not support SS over WS |
All three are plain TCP end-to-end between client and server (the WS layer rides on TCP), so
none of them are exposed to networks that indiscriminately throttle UDP — that includes the
"UDP forwarding" toggle some clients show for VMess/Trojan/SS: it tunnels UDP *payloads* inside
the already-established TCP connection, it doesn't open a real UDP socket to the server.
## What "UDP is faster" actually means (and doesn't)
QUIC-based protocols (Hysteria2, TUIC) run on top of UDP, and that fact alone doesn't make them
faster — raw UDP has no reliability or congestion control built in. Their speed advantage comes
from what they build *on top of* UDP: no head-of-line blocking across multiplexed streams,
faster connection setup/resumption, connection migration across network changes, and (for
Hysteria2 specifically) an aggressive custom congestion-control algorithm designed for lossy
links. That advantage is real and large specifically on **high-latency or high-packet-loss**
networks. On a clean, low-latency network, well-implemented TCP+TLS (Trojan, VLESS) is often
just as fast, sometimes faster (QUIC's per-packet encryption has real per-byte CPU cost that
kernel-optimized TCP+TLS doesn't). Don't add a UDP-based protocol to this skill's target list on
the assumption that "UDP is just faster" — add it because you specifically need resilience on
bad networks or fast network-switching, and you've weighed that against UDP's other real risk:
plenty of networks throttle/block UDP indiscriminately, which no amount of protocol-level
cleverness fixes.
## When to add a fourth protocol
- **Client doesn't support any of the three (rare)** — e.g. something that only speaks VLESS or
Hysteria2. Add exactly what that client needs; see `3x-ui-schema.md` for the
mechanical steps (VLESS/VLESS+REALITY are native to 3x-ui/Xray-core, no extra software).
- **You specifically need the strongest current anti-active-probing posture** — VLESS+REALITY
(or Trojan+REALITY; Xray-core supports REALITY on both) borrows a real site's TLS handshake,
which is currently the hardest thing for active probing to distinguish from genuinely visiting
that site. Native to 3x-ui. Needs picking a "borrowed" target site with real TLS 1.3, decent
traffic, and no active blocking of its own in your target region.
- **You specifically need next-generation traffic-pattern resistance** — AnyTLS defends against
statistical traffic-shape analysis (packet size/timing), which REALITY's handshake-mimicry
doesn't address by itself. Not native to 3x-ui/Xray-core — needs sing-box installed
server-side, as a separate service alongside 3x-ui (different ports, no conflict).
- **You specifically need resilience on bad/mobile networks and already have a UDP fallback
elsewhere** — Hysteria2 (preferred over TUIC: larger community, more obfuscation options,
more battle-tested) if you don't already have a node covering this case. Also needs sing-box
or a standalone Hysteria2 binary, not native to 3x-ui.
- **Don't add a protocol "just in case."** Every inbound is one more thing to keep working
through aaPanel/3x-ui/Xray version upgrades — see `pitfalls.md` for how much
version drift alone (not malicious blocking) can break a working inbound.

View File

@@ -0,0 +1,47 @@
# 裸 nginx、QUIC 与既有节点维护
脚本从各节点原工作副本迁入,历史 `-sg`/`-rn` 后缀保留;这些后缀表示流程分支,不表示脚本只能在该站点使用。所有 `.sh` 从本机通过 SSH 修改 VPS。命令中的 `scripts/` 指私有节点工作目录里的副本。
## 选择部署分支
| 步骤 | aaPanel 管理 nginx | 裸 nginx |
|---|---|---|
| 准备机器 | `00-preflight.sh` | `00-preflight-sg.sh`(含 swap、BBR、apt nginx) |
| DNS | `02-cf-dns.py` | 同左 |
| 证书 | `03-sign-wildcard-cert.sh` | `03-sign-cert-sg.sh`,证书在 `/etc/nginx/ssl/<root>/` |
| nginx | `04-install-aapanel.sh`、`04b-install-nginx.sh`、`05-harden-aapanel.sh` | 已由 preflight 安装,跳过 aaPanel 步骤 |
| WARP | `06-install-warp.sh`、`06b-warp-watchdog.sh` | 同左 |
| TCP 节点 | `07-install-3xui.sh`、`08-xui-bootstrap.sh` | 同左 |
| 站点 | `09-aapanel-sites.sh`、`09b-default-444.sh` | `09-nginx-sites-sg.sh` |
| 防火墙 | `10-ufw.sh`(核对实际面板端口) | `10-ufw-sg.sh`(TCP 22/80/443) |
| TCP 验收 | `12-smoke-test.sh` | `12-smoke-test-sg.sh` |
`11-monitoring.sh` 是可选监控安装。既有站点以自己的实际配置为准;不要把完整部署流水线当成日常维护命令。
## QUIC
在私有 `scripts/config.env` 设置所需的 `PORT_HY2_DIRECT`/`PORT_HY2_WARP` 或 `PORT_TUIC_DIRECT`/`PORT_TUIC_WARP`,未设置的线路跳过。Hysteria2 使用 `NODE_PASSWORD`,可设置 `HY2_OBFS_PASSWORD`(salamander)及带宽;TUIC 使用 `XUI_UUID`+`NODE_PASSWORD`。
裸 nginx 分支执行 `bash scripts/13-install-singbox-quic.sh`,它推送 `13-singbox-config.py`、生成 sing-box 配置、做 `sing-box check`、开放配置的 UDP 端口并启动服务。aaPanel 节点需要先按实际证书目录适配,不能直接采用默认 `/etc/nginx/ssl/<root>/`。
该脚本的续期动作面向裸 nginx,会设为 `systemctl reload nginx; systemctl restart sing-box`。既有机器若已有集中证书复制或其他服务动作,先读取并保留旧钩子,再合并;不能直接用此步骤替换旧配置。UFW 之外还要核对供应商云安全组。
`10-ufw-rn.sh` 是四条 QUIC 线路都存在时的历史防火墙分支,要求两条 HY2 与两条 TUIC 端口,不用于仅 HY2 的机器。
生成链接在本机执行:
```bash
umask 077
python3 scripts/14-generate-links-and-qr.py
python3 scripts/15-generate-quic-links.py
```
第二个脚本读取 `HY2_OBFS_PASSWORD`;仅 TUIC 时也保留该字段为空。新增占位配置默认 QUIC 端口为空,不会隐式部署 QUIC。配置生成器能省略混淆,但现有链接生成器始终写 salamander;生成 HY2 链接时应提供非空混淆密码,或先同步修改链接与服务端逻辑。
## 3x-ui 升级与 SOCKS5
新 `08-xui-bootstrap.py` 已写入 `settings.clients`,旧部署用 `08b-embed-settings-clients.py` 补齐。该 Python 文件在 VPS 上执行,默认会重启 x-ui;`XUI_DB` 可指定数据库,`RESTART_XUI=0` 可由任务安排重启。修改前备份数据库,完成后重新核对六条 TCP 线路与 WARP 规则。
`16-add-socks5.py` 是在 VPS 上运行的可选内部 SOCKS5 入站工具,通过 `SOCKS_PORT`、`SOCKS_USER`、`SOCKS_PASS`、`SERVER_IP` 等环境变量配置,默认监听公网且会重启 x-ui。按访问意图设置 `LISTEN`;用户名密码认证本身不加密。它不是标准六条线路部署步骤,也不是 Proxy Station 可导入类型。
这里保存的是已有部署工具的流程说明。涉及新版本安装或升级时,先核对官方版本兼容性,再在授权范围执行;本次工程迁移没有对生产节点执行这些脚本。

View File

@@ -0,0 +1,175 @@
# aaPanel deployment workflow
This is the migrated aaPanel workflow. Run shell commands from the LOCAL private node workspace: Bash scripts invoke SSH and their Python companions run on the VPS. Standalone nginx, QUIC and upgrade repairs are documented in `standalone-and-quic.md`. Keep live config out of the skill source directory.
Turn a bare Debian/Ubuntu VPS into a fully managed edge node running **three proxy protocols side by side, each with a direct-egress and a Cloudflare-WARP clean-exit inbound — six inbounds in total.** This is the standard target of this skill, not an optional extra: a single protocol is a fragile bet (if it gets fingerprinted or a client stops supporting it, the whole node goes dark), and three protocol families sharing the same domain/cert/firewall costs almost nothing extra to run once the panel infrastructure is up.
**What it builds**
| Layer | Component | Result |
|---|---|---|
| Panel | aaPanel International (7.x) + nginx | `<SUB_PANEL>.<root>` reverse-proxied, secret-path entrance, hardened |
| Proxy | 3x-ui (Xray) | `<SUB_XUI>.<root>` panel; **6 inbounds** behind nginx: VMess/Trojan/Shadowsocks × direct/WARP |
| Clean exit | cloudflare-warp (proxy mode) | every protocol's WARP inbound egresses through Cloudflare WARP |
| TLS | acme.sh DNS-01 (any provider) | wildcard `*.<root>` cert, auto-renew + reload, shared by all 6 inbounds |
| DNS | Cloudflare API (or your provider, by hand) | `<root>` + `*.<root>` A/AAAA (DNS-only) |
| Cover | nginx vhosts | landing page + node cover (6 WS paths) + default-server `444` |
| Watchdog | systemd timer | WARP proxy self-heal every 60s |
| Monitoring | Nezha + Komari agents (optional) | node appears on your panels |
| Access | SSH key + optional shortcut | passwordless login |
> **This is a template, not a turnkey binary.** You supply your own domain, Cloudflare token, server IP, and passwords. No secrets, domains, or routing rules are baked in.
## Why three protocols instead of one
VMess, Trojan, and Shadowsocks(+WS/TLS) use TCP and are implemented by 3x-ui/Xray-core. Client support depends on the protocol/transport combination: Surge does not support Shadowsocks over WebSocket. Proxy Station filters unsupported combinations using `shared/src/protocols.ts`. Running all three from day one means: if a client only supports a subset (e.g. Surge doesn't do VLESS), you're already covered; if one protocol family gets fingerprinted, you have two independent fallbacks without touching the box again. See `protocol-choice.md` for the fuller reasoning (performance/detection tradeoffs, and how to add a fourth protocol like VLESS+REALITY later).
## Inputs to collect first
Fill a working copy of `scripts/config.env.example` → `config.env` (git-ignored by you). Collect these before touching the box:
- `SERVER_IP` / `SERVER_IPV6` — the new VPS public addresses (amd64 or arm64)
- `SSH_USER` / `SSH_KEY` — `root` or a sudo user (scripts escalate via `sudo`), and the private key
- `TZ` — server timezone (default `UTC`)
- `DOMAIN_ROOT` — the sub-zone this node owns, e.g. `nodeA.example.com` (you get `*.nodeA.example.com`)
- `ACME_DNS_PLUGIN` + creds — DNS provider for the wildcard cert. Default `dns_cf` (Cloudflare). Any acme.sh-supported provider works (DNSPod/Aliyun/Route53/GoDaddy/…) — set the plugin + its env vars (`dns-records.md`).
- `CF_API_TOKEN` / `CF_ZONE_ID` — Cloudflare only: token with **Zone:DNS:Edit** (enables the turnkey A/AAAA record script). Non-CF users create the A/AAAA records themselves.
- `SUB_PANEL` / `SUB_XUI` / `SUB_COVER` — the subdomain labels for the three planes. **If your zone is public, choose non-obvious labels** — don't reuse a guessable convention or people can enumerate your control planes from DNS.
- `AAPANEL_USER` / `AAPANEL_ENTRANCE` — desired panel username + secret entrance path (e.g. `/somesecret`)
- `XUI_USER` / `XUI_PATH` — 3x-ui username + webBasePath (e.g. `/somesecret/`)
- **Six WS cover paths + two ports each** (`PORT_<PROTO>_{DIRECT,WARP}` / `WS_PATH_<PROTO>_{DIRECT,WARP}` for `VMESS`/`TROJAN`/`SS`) — pick your own, non-obvious, don't reuse a naming convention across them
- `XUI_UUID` + `NODE_PASSWORD` (+ `XUI_SUBID`, `SS_METHOD` default `chacha20-poly1305`) — the credentials for the **single `node-main` client** that owns all six inbounds: `XUI_UUID` authenticates the two VMess inbounds, `NODE_PASSWORD` authenticates the two Trojan **and** two Shadowsocks inbounds (they share it — it's one client). Generate strong values at runtime; **store them in your password manager**, never in git.
- monitoring (optional) — Nezha `SERVER:PORT` + shared key; Komari endpoint + auto-discovery key
## Workflow
For a fresh aaPanel node, follow the selected steps in order from the local private workspace. Shell scripts read `scripts/config.env` and invoke SSH; their Python companions run on the VPS. For an existing node, read its handover notes and run only the steps required by the task.
### 0. Verify access & prep
```bash
scripts/00-preflight.sh # confirms SSH, records specs, sets timezone, installs base pkgs (curl/gnupg/ufw)
```
Also drop **your** public key into the box's `~/.ssh/authorized_keys` for passwordless login.
### 1. Wildcard DNS records
```bash
python3 scripts/02-cf-dns.py # Cloudflare: upserts <root> + *.<root> A/AAAA -> SERVER_IP/IPV6 (proxied=false)
```
Non-Cloudflare users: create the same records by hand, see `dns-records.md`.
### 2. Wildcard TLS cert (acme.sh DNS-01)
```bash
scripts/03-sign-wildcard-cert.sh # on-box acme.sh --dns dns_cf, issues *.<root> (ECDSA P-256, Let's Encrypt)
```
One cert, shared by all 6 inbounds via the configured `<SUB_COVER>.<root>` cover domain. Fleet cert signing: `centralized-certs.md`.
### 3. aaPanel International + nginx
```bash
scripts/04-install-aapanel.sh # official install_7.0_en.sh (headless); waits for the log's success banner
scripts/05-harden-aapanel.sh # sets username/password/entrance, binds panel domain, CDN_PROXY=True
scripts/04b-install-nginx.sh # install_soft.sh 0 install nginx 1.24 (aaPanel-managed)
```
`pitfalls.md` has three aaPanel-7.x-specific fixes baked into these scripts already (install-wait logic, `class_v2` import path, anti-scan UA strictness) — read it if you're editing these rather than just running them.
### 4. Cloudflare WARP (clean exit)
```bash
scripts/06-install-warp.sh # cloudflare-warp, proxy mode SOCKS5 127.0.0.1:40000, verify warp=on
scripts/06b-warp-watchdog.sh # installs warp-healthcheck.sh + systemd timer (self-heal every 60s)
```
### 5. 3x-ui (Xray) + six inbounds under one client
```bash
scripts/07-install-3xui.sh # installs 3x-ui, sets panel 127.0.0.1:14533 + XUI_PATH + creds
scripts/08-xui-bootstrap.sh # adds ALL SIX inbounds under ONE node-main client, in one pass
```
`08-xui-bootstrap.py` creates a **single `node-main` client** (carrying both the VMess UUID and the shared
Trojan/Shadowsocks password) linked to all six inbounds — so the panel's Clients page shows one row whose
Info dialog lists all six share links + QR codes and one subscription. It bakes in four version-compat
fixes that are easy to miss and each cost real debugging on a live v3.5.0 box: `user_id=1` (else inbounds
are invisible in the panel), an `externalProxy` link hint (else generated links point at the useless
internal `127.0.0.1:<port>`), a top-level SS `password` (else the panel's copy-links endpoint panics and
returns empty → "No response data"), and read-modify-write of the WARP routing rule (else re-runs wipe it).
It writes directly into 3x-ui's sqlite DB rather than using the panel API (which 403s scripted requests on
this build) — see `3x-ui-schema.md` and `pitfalls.md` for
the table layout if you need to add a 4th protocol or debug a client not authenticating.
### 6. aaPanel sites (panel-native, UI-manageable)
```bash
scripts/09-aapanel-sites.sh # AddSite + SetSSL + CreateProxy (x6 for cover) + HttpToHttps, via panel classes
scripts/09b-default-444.sh # unmatched server_name -> return 444; drop landing page
```
### 7. Firewall + monitoring (optional)
```bash
scripts/10-ufw.sh # allow 22/80/443 + panel port + aaPanel 888/39000-40000
scripts/11-monitoring.sh # optional: install Nezha + Komari agents (placeholders in config.env)
```
No extra firewall ports needed for the 6 inbounds — they're all `127.0.0.1`-only, reverse-proxied through nginx on 443.
### 8. Smoke test — all six inbounds
```bash
scripts/12-smoke-test.sh
```
Checks: WS upgrade returns 101 on all 6 cover paths; landing/3x-ui/aaPanel panels return 200 (aaPanel test
uses a full realistic UA string, see pitfalls); an unmatched hostname gets dropped (444); and — via a
temporary on-box xray client covering all 6 outbounds — every `*-direct` egress IP equals `SERVER_IP` with
`warp=off`, and every `*-warp` egress IP is a Cloudflare address with `warp=on`.
### 9. Generate client links + QR codes
```bash
python3 scripts/14-generate-links-and-qr.py # run LOCALLY, not on the box
```
Writes `vmess://`, `trojan://` and Xray-style `ss://` share links + QR codes via local `qrencode`, plus sing-box JSON snippets for Shadowsocks. Proxy Station parses these SS links; other clients may require transport-specific import. Nothing is sent to a third-party service. See
`templates/02-node-info-template.md` for how to hand these to a client.
### 10. Documentation
Fill in the three templates in `templates/` (handover doc, node info, ops guide) with this node's real
values — see "Documentation templates" below.
## Domains this creates
With `DOMAIN_ROOT=nodeA.example.com` and example labels `SUB_COVER=edge`, `SUB_XUI=gateway`, `SUB_PANEL=console` (pick your own):
| Domain | Role | Backend |
|---|---|---|
| `nodeA.example.com` | landing page | static |
| `<SUB_COVER>.nodeA.example.com` | node cover: 6 WS paths, one per inbound | 127.0.0.1:57707-57712 |
| `<SUB_XUI>.nodeA.example.com` | 3x-ui panel | 127.0.0.1:14533 |
| `<SUB_PANEL>.nodeA.example.com` | aaPanel | 127.0.0.1:\<panel port\> |
| `*.nodeA.example.com` (default) | unmatched → `return 444` | — |
> The labels above are just examples. **Never publish the real labels/paths you pick** — on a public zone they are the map to your control planes and tunnels.
## Adding a protocol to an already-provisioned node
You don't need to redo steps 0-4. Add the new protocol's env vars to `config.env`, extend the `PROTOCOLS`
list in `scripts/08-xui-bootstrap.py` and the `PROXIES` loop in `scripts/09-aapanel-sites.py` (both scripts
are idempotent — re-running them skips ports/sites that already exist), re-run `08-xui-bootstrap.sh` then
`09-aapanel-sites.sh`, then re-run the smoke test with the new outbound added. Full walkthrough with the
concrete steps used to add Trojan+Shadowsocks to a VMess-only node: `3x-ui-schema.md`.
## Documentation templates
`templates/01-handover-template.md`, `templates/02-node-info-template.md`, `templates/03-ops-guide-template.md`
are meant to be copied out and filled in with a real node's values after deployment (they're written with
`{{PLACEHOLDER}}` tokens, not a real node's domain/keys). See `../templates/README.md` for the full list of
tokens each one needs.
## References
- `pitfalls.md` — **read first.** aaPanel 7.x version-compatibility fixes (install-wait logic, `class_v2`, anti-scan UA strictness), 3x-ui multi-protocol schema gotchas, WARP/nginx/monitoring/SSH pitfalls. Skipping it costs hours.
- `3x-ui-schema.md` — the sqlite table layout 3x-ui uses, and how to add a 4th protocol.
- `protocol-choice.md` — why VMess+Trojan+Shadowsocks, performance/detection tradeoffs vs VLESS+REALITY/AnyTLS/Hysteria2/TUIC, and when you'd want a 4th protocol.
- `dns-records.md` — the exact records to create, and how to use a non-Cloudflare DNS provider.
- `centralized-certs.md` — sign wildcards on one host and push to a fleet.
## Project entry points
Use the project root `AGENTS.md` and the shared `edge-node-multi-protocol` skill. The former standalone agent guides are migration snapshots, not active instructions.
## Privacy / safety notes
- Never commit `config.env`, cert keys, panel/xui passwords, or any UUID/password/token. Add them to your password manager.
- The scripts contain **no** real domains, IPs, subscription rules, or routing policy — only placeholders. Keep it that way when you adapt them.
- WARP mode here is for **your own** clean egress; it is not a bypass for anyone else's controls.
- The link/QR-code generator (step 9) never sends anything to a third-party service — QR codes are generated fully offline with local `qrencode`.