feat: 合并边缘节点运维并完善 Surge 配置安全
迁入通用节点部署工具、离线交付页生成器和共用技能,隔离真实配置与交付产物。统一 Codex 与 Claude Code 开发约定,补充节点迁移与运维文档。 完善 FINAL、策略组引用、输入校验、Surge 字段编码与 AI DNS 例外保护,增加隔离测试和订阅验证工具。 验证:全量类型检查、44 项单测、前端构建、脚本语法、技能入口与凭据隔离检查均通过。
This commit is contained in:
@@ -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).
|
||||
Reference in New Issue
Block a user