Files
proxy-station/.claude/skills/edge-node-multi-protocol/references/3x-ui-schema.md
YANG JIANKUAN 5326c572a2 feat: 合并边缘节点运维并完善 Surge 配置安全
迁入通用节点部署工具、离线交付页生成器和共用技能,隔离真实配置与交付产物。统一 Codex 与 Claude Code 开发约定,补充节点迁移与运维文档。

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

验证:全量类型检查、44 项单测、前端构建、脚本语法、技能入口与凭据隔离检查均通过。
2026-10-03 23:19:59 +08:00

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)

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