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