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