迁入通用节点部署工具、离线交付页生成器和共用技能,隔离真实配置与交付产物。统一 Codex 与 Claude Code 开发约定,补充节点迁移与运维文档。 完善 FINAL、策略组引用、输入校验、Surge 字段编码与 AI DNS 例外保护,增加隔离测试和订阅验证工具。 验证:全量类型检查、44 项单测、前端构建、脚本语法、技能入口与凭据隔离检查均通过。
8.5 KiB
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)
// 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):
// 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)
- Add its ports/paths/credential to
config.env(follow thePORT_<PROTO>_{DIRECT,WARP}/WS_PATH_<PROTO>_{DIRECT,WARP}naming already used for vmess/trojan/ss). - Add a
(protocol, port_direct, port_warp, path_direct, path_warp)tuple to thePROTOCOLSlist in08-xui-bootstrap.py, and its client-shape branch toinbound_settings()(REALITY additionally needsrealitySettingsinstream_settingswith a real "borrowed" target site — this is per-inbound TLS-mimicry config the outer nginx doesn't touch, sosecurityinstream_settingswould 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). - Add the new WS paths to
PROXIESin09-aapanel-sites.py(skip this for REALITY specifically, since it bypasses nginx as noted above and would need its own firewall port opened instead). - Add the new protocol's outbound block to the smoke-test client generator in
12-smoke-test.sh. - Re-run
08-xui-bootstrap.sh(idempotent — existing inbounds are skipped by port) then09-aapanel-sites.sh, then12-smoke-test.sh.08-xui-bootstrap.pyalready setsuser_id=1, adds theexternalProxylink hint, links the new inbounds to the existingnode-mainclient, and (for shadowsocks-family protocols) writes the top-levelpassword— so a new protocol inherits all version-compat fixes as long as you extendPROTOCOLSand its client-shape logic rather than hand-inserting rows. Keepsettings.clientspopulated 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 justufw allow 2096— that exposes a plaintext-HTTP endpoint carrying every node's credentials on a guessableIP:port. Instead reverse-proxy a non-obvious subdomain (e.g.sub.<root>) →127.0.0.1:2096through 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).