# 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 `:443` over TLS instead of the useless internal `127.0.0.1:` — 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": "", "email": "node-main", "enable": true, "subId": "", "alterId": 0}], "disableInsecureEncryption": false} // trojan {"clients": [{"password": "", "email": "node-main", "enable": true, "subId": ""}], "fallbacks": []} // shadowsocks — note the TOP-LEVEL "password" (prevents the v3.5.0 link-gen panic, see pitfalls.md) {"clients": [{"password": "", "method": "chacha20-poly1305", "email": "node-main", "enable": true, "subId": ""}], "method": "chacha20-poly1305", "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": "", "email": "node-main", "alterId": 0} // trojan client {"password": "", "email": "node-main"} // shadowsocks client {"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__{DIRECT,WARP}` / `WS_PATH__{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/`) 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.`) → `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/` **on the box** first (200 there = the service and links are fine, the problem is purely how it's exposed).