296 lines
16 KiB
Python
Executable File
296 lines
16 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Runs ON the box, editing 3x-ui's own sqlite DB directly (the panel HTTP API
|
|
403s scripted requests on the builds this targets -- see references/pitfalls.md).
|
|
|
|
Builds the standard target of this skill in ONE pass: SIX inbounds (vmess,
|
|
trojan, shadowsocks x direct-egress + WARP-clean-exit) all owned by ONE client
|
|
"node-main", so the panel's Clients page shows a single row whose Info dialog
|
|
lists all six share links / QR codes and one subscription that carries all six.
|
|
|
|
Why a single client instead of one-per-protocol
|
|
------------------------------------------------
|
|
3x-ui's `clients` table is protocol-generic: one client row carries BOTH a
|
|
`uuid` (used by vmess/vless inbounds) AND a `password` (used by trojan/
|
|
shadowsocks inbounds). Which field an inbound reads is decided by that
|
|
*inbound's* protocol when 3x-ui builds the live xray config -- not by anything
|
|
on the client row. So one node-main row linked (via client_inbounds) to all six
|
|
inbounds authenticates vmess by its uuid and trojan+ss by its (shared)
|
|
password. Verified by reading the generated /usr/local/x-ui/bin/config.json.
|
|
Trojan and Shadowsocks therefore share ONE password (node-main's); vmess uses
|
|
the uuid. That's intended -- a single-user self node -- and is what makes the
|
|
"one client, six links" panel view work.
|
|
|
|
Four version-compatibility fixes are baked in here (all cost real debugging on
|
|
a live v3.5.0 box; see references/pitfalls.md for the full story):
|
|
|
|
1. user_id=1 on every inbound. The panel Clients/Inbounds lists filter by the
|
|
logged-in admin's user_id; an inbound with a NULL/empty user_id exists and
|
|
proxies traffic fine but is INVISIBLE in the UI.
|
|
|
|
2. externalProxy on every inbound's streamSettings. These inbounds listen on
|
|
127.0.0.1 with security:none (nginx terminates TLS at the edge), so without
|
|
a hint 3x-ui generates useless share links pointing at 127.0.0.1:<internal
|
|
port> with no TLS. An externalProxy entry {forceTls:"tls", dest:<cover>,
|
|
port:443, sni:<cover>} makes every generated link/QR/subscription point at
|
|
<cover>:443 over TLS -- i.e. actually usable.
|
|
|
|
3. Shadowsocks inbound settings need a TOP-LEVEL `password` field. v3.5.0's
|
|
share-link generator does `settings["password"].(string)` -- an UNCHECKED
|
|
type assertion. A normal (non-2022) SS inbound has no top-level password
|
|
(the per-client password lives on the client), so that assertion panics,
|
|
the panic is swallowed by gin's recovery, and the whole subLinks endpoint
|
|
returns HTTP 200 with an EMPTY body -> the panel shows "No response data"
|
|
and drops every link. We write a top-level password (auth still goes through
|
|
the client table; this field only exists to keep the assertion from
|
|
panicking and does not appear in the emitted ss:// link for chacha20 ciphers).
|
|
|
|
4. WARP routing rule is read-modify-write, never clobbered -- so re-running
|
|
this (e.g. to add a 4th protocol) doesn't delete the existing warp routing.
|
|
|
|
5. settings.clients is ALWAYS embedded, on every schema -- never left as `[]`.
|
|
This one cost a live outage on two nodes. `inbounds.settings.clients` is
|
|
3x-ui's AUTHORITATIVE copy of the credentials; the clients / client_inbounds
|
|
tables are a derived projection of that JSON, not an alternative to it:
|
|
* client_link.go SyncInbound() does an UNCONDITIONAL
|
|
`DELETE FROM client_inbounds WHERE inbound_id=?` and then rebuilds the
|
|
rows only from the clients slice handed to it -- empty slice means
|
|
delete-and-insert-nothing, with no guard distinguishing "this inbound
|
|
has no clients" from "the caller found no data".
|
|
* inbound_migration.go MigrationRequirements() feeds SyncInbound the
|
|
clients from GetClients(inbound), i.e. parsed out of the settings JSON.
|
|
* The panel UI path (client_inbound_apply.go addInboundClient) always
|
|
writes the client into the settings JSON first, then projects to the
|
|
tables -- which is why UI-managed installs never hit this.
|
|
So a node with `settings.clients == []` loses every client_inbounds row the
|
|
moment the migration path runs -- and `x-ui migrate` is exactly what
|
|
install.sh/update.sh invoke on upgrade (DB import/restore and the
|
|
SQLite->PostgreSQL copy call it too). The generated xray config then has
|
|
`"clients": []` on every inbound: TLS and the WS upgrade still succeed, so
|
|
the node looks healthy, but every connection fails authentication.
|
|
Verified empirically on a live 3.6.0 box: with settings.clients empty,
|
|
`x-ui migrate` emptied client_inbounds instantly; with it populated, the
|
|
same command left all six links intact. The logic is unchanged since 3.5.0,
|
|
so this is not a one-release regression -- it fires on ANY future upgrade.
|
|
To retrofit an already-broken node, run 08b-embed-settings-clients.py.
|
|
|
|
Schema-adaptive: newer 3x-ui stores clients in separate tables (clients /
|
|
client_inbounds / client_traffics); older builds embed them in
|
|
inbound.settings.clients. The new-schema path is the live-verified one; the
|
|
embedded path is kept for older installs.
|
|
"""
|
|
import json, os, sqlite3, subprocess, time
|
|
|
|
DB = os.environ.get("XUI_DB", "/etc/x-ui/x-ui.db")
|
|
WARP_SOCKS_PORT = int(os.environ.get("WARP_SOCKS_PORT", "40000"))
|
|
COVER = os.environ["COVER"] # e.g. cc.<DOMAIN_ROOT> -- the public cover host for every link
|
|
|
|
XUI_UUID = os.environ["XUI_UUID"] # vmess credential on node-main
|
|
XUI_SUBID = os.environ.get("XUI_SUBID", "")
|
|
NODE_PASSWORD = os.environ["NODE_PASSWORD"] # shared trojan+ss credential on node-main
|
|
SS_METHOD = os.environ.get("SS_METHOD", "chacha20-poly1305")
|
|
|
|
EMAIL = "node-main"
|
|
|
|
# (protocol, direct port, warp port, direct path, warp path)
|
|
PROTOCOLS = [
|
|
("vmess", int(os.environ["PORT_VMESS_DIRECT"]), int(os.environ["PORT_VMESS_WARP"]),
|
|
os.environ["WS_PATH_VMESS_DIRECT"], os.environ["WS_PATH_VMESS_WARP"]),
|
|
("trojan", int(os.environ["PORT_TROJAN_DIRECT"]), int(os.environ["PORT_TROJAN_WARP"]),
|
|
os.environ["WS_PATH_TROJAN_DIRECT"], os.environ["WS_PATH_TROJAN_WARP"]),
|
|
("shadowsocks", int(os.environ["PORT_SS_DIRECT"]), int(os.environ["PORT_SS_WARP"]),
|
|
os.environ["WS_PATH_SS_DIRECT"], os.environ["WS_PATH_SS_WARP"]),
|
|
]
|
|
|
|
TEMPLATE = {
|
|
"log": {"access": "/var/log/v2ray/access.log", "error": "/var/log/v2ray/error.log", "loglevel": "warning"},
|
|
"api": {"services": ["HandlerService", "LoggerService", "StatsService"], "tag": "api"},
|
|
"inbounds": [{"listen": "127.0.0.1", "port": 62789, "protocol": "dokodemo-door",
|
|
"settings": {"address": "127.0.0.1"}, "tag": "api"}],
|
|
"outbounds": [
|
|
{"protocol": "freedom", "settings": {"domainStrategy": "ForceIPv4"},
|
|
"streamSettings": {"sockopt": {"domainStrategy": "ForceIPv4"}}},
|
|
{"tag": "warp-outbound", "protocol": "freedom",
|
|
"settings": {"domainStrategy": "ForceIPv4"},
|
|
"streamSettings": {"sockopt": {"domainStrategy": "ForceIPv4"}},
|
|
"proxySettings": {"tag": "warp-socks"}},
|
|
{"tag": "warp-socks", "protocol": "socks",
|
|
"settings": {"servers": [{"address": "127.0.0.1", "port": WARP_SOCKS_PORT}]}},
|
|
{"protocol": "blackhole", "settings": {}, "tag": "blocked"},
|
|
],
|
|
"policy": {"system": {"statsInboundDownlink": True, "statsInboundUplink": True}},
|
|
"routing": {"rules": [
|
|
{"ip": ["::/0"], "outboundTag": "blocked", "type": "field"},
|
|
{"inboundTag": ["api"], "outboundTag": "api", "type": "field"},
|
|
{"inboundTag": [], "outboundTag": "warp-outbound", "type": "field"}, # tags filled in below
|
|
{"ip": ["geoip:private"], "outboundTag": "blocked", "type": "field"},
|
|
{"protocol": ["bittorrent"], "outboundTag": "blocked", "type": "field"},
|
|
]},
|
|
"stats": {},
|
|
"dns": {"servers": ["1.1.1.1"], "queryStrategy": "UseIPv4"},
|
|
}
|
|
|
|
|
|
def cols(cur, table):
|
|
return {r[1] for r in cur.execute("PRAGMA table_info(%s)" % table).fetchall()}
|
|
|
|
|
|
def has_table(cur, name):
|
|
return cur.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (name,)).fetchone() is not None
|
|
|
|
|
|
def stream_settings(path, remark):
|
|
# security:"none" -- nginx already terminates TLS at the edge and reverse-proxies
|
|
# plain HTTP to this inbound; don't handshake TLS a second time here.
|
|
# externalProxy -> share links point at the public cover host:443 over TLS (fix #2).
|
|
return json.dumps({
|
|
"network": "ws", "security": "none",
|
|
"wsSettings": {"path": path, "headers": {}},
|
|
"externalProxy": [{"forceTls": "tls", "dest": COVER, "port": 443, "sni": COVER, "remark": remark}],
|
|
})
|
|
|
|
|
|
def embedded_client(protocol, now):
|
|
"""One settings.clients entry (fix #5).
|
|
|
|
ALWAYS embed, on every schema. settings.clients is 3x-ui's authoritative copy
|
|
of the credentials; clients/client_inbounds are a derived projection of it.
|
|
See fix #5 in the module docstring for why leaving this empty bricks the node
|
|
on the next upgrade.
|
|
|
|
Field semantics (client_link.go applyClientRecordMerge):
|
|
* non-empty-only overwrite: id / password / security -> a vmess entry may
|
|
carry just `id` and a trojan/ss entry just `password`; merging the six
|
|
inbounds for one email yields the union rather than clobbering.
|
|
* unconditional overwrite: enable / subId / limitIp / totalGB / expiryTime
|
|
/ tgId / comment / reset / flow -> must be IDENTICAL in all six entries.
|
|
* `enable` must be explicit: omitting it unmarshals to false, and xray.go
|
|
then skips the client on EVERY inbound.
|
|
"""
|
|
c = {
|
|
"email": EMAIL, "enable": True, "subId": XUI_SUBID, "flow": "",
|
|
"limitIp": 0, "totalGB": 0, "expiryTime": 0, "tgId": 0,
|
|
"comment": "", "reset": 0, "created_at": now, "updated_at": now,
|
|
}
|
|
if protocol == "vmess":
|
|
c.update({"id": XUI_UUID, "security": "auto", "alterId": 0})
|
|
elif protocol == "trojan":
|
|
c["password"] = NODE_PASSWORD
|
|
elif protocol == "shadowsocks":
|
|
c.update({"password": NODE_PASSWORD, "method": SS_METHOD})
|
|
else:
|
|
raise ValueError(protocol)
|
|
return c
|
|
|
|
|
|
def inbound_settings(protocol, now):
|
|
clients = [embedded_client(protocol, now)]
|
|
if protocol == "vmess":
|
|
return json.dumps({"clients": clients, "disableInsecureEncryption": False})
|
|
if protocol == "trojan":
|
|
return json.dumps({"clients": clients, "fallbacks": []})
|
|
if protocol == "shadowsocks":
|
|
# top-level "password" prevents the v3.5.0 link-gen panic (fix #3); auth is per-client.
|
|
return json.dumps({"clients": clients, "method": SS_METHOD, "password": NODE_PASSWORD, "network": "tcp,udp"})
|
|
raise ValueError(protocol)
|
|
|
|
|
|
def main():
|
|
conn = sqlite3.connect(DB)
|
|
cur = conn.cursor()
|
|
ic = cols(cur, "inbounds")
|
|
new_schema = has_table(cur, "clients") and has_table(cur, "client_inbounds")
|
|
existing_ports = {r[0] for r in cur.execute("SELECT port FROM inbounds").fetchall()}
|
|
now = int(time.time() * 1000)
|
|
|
|
warp_tags = []
|
|
inbound_ids = {}
|
|
|
|
for protocol, port_direct, port_warp, path_direct, path_warp in PROTOCOLS:
|
|
for suffix, port, path in (("direct", port_direct, path_direct), ("warp", port_warp, path_warp)):
|
|
tag = "inbound-%s-%s" % (protocol, suffix)
|
|
remark = "%s-%s" % (protocol, suffix)
|
|
if port in existing_ports:
|
|
print(" inbound :%d (%s-%s) exists, skip" % (port, protocol, suffix))
|
|
inbound_ids[tag] = cur.execute("SELECT id FROM inbounds WHERE port=?", (port,)).fetchone()[0]
|
|
else:
|
|
row = {
|
|
"user_id": 1, # fix #1: NULL user_id -> invisible in panel lists
|
|
"up": 0, "down": 0, "total": 0, "remark": remark,
|
|
"enable": 1, "expiry_time": 0, "listen": "127.0.0.1", "port": port, "protocol": protocol,
|
|
"settings": inbound_settings(protocol, now),
|
|
"stream_settings": stream_settings(path, remark),
|
|
"sniffing": json.dumps({"enabled": True, "destOverride": ["http", "tls"]}),
|
|
"tag": tag,
|
|
}
|
|
keys = [k for k in row if k in ic]
|
|
cur.execute("INSERT INTO inbounds (%s) VALUES (%s)" % (",".join(keys), ",".join("?" * len(keys))),
|
|
[row[k] for k in keys])
|
|
inbound_ids[tag] = cur.lastrowid
|
|
print(" inbound :%d (%s-%s) %s added" % (port, protocol, suffix, path))
|
|
if suffix == "warp":
|
|
warp_tags += [tag, "inbound-127.0.0.1:%d" % port]
|
|
|
|
if new_schema:
|
|
# One node-main client carrying BOTH uuid (vmess) and password (trojan+ss),
|
|
# linked to all six inbounds. 3x-ui picks the right credential per inbound.
|
|
row = cur.execute("SELECT id FROM clients WHERE email=?", (EMAIL,)).fetchone()
|
|
if row:
|
|
cid = row[0]
|
|
cur.execute("UPDATE clients SET uuid=?, password=?, sub_id=?, security='auto', enable=1, updated_at=? "
|
|
"WHERE id=?", (XUI_UUID, NODE_PASSWORD, XUI_SUBID, now, cid))
|
|
else:
|
|
cur.execute(
|
|
"INSERT INTO clients (email,sub_id,uuid,password,auth,flow,security,enable,group_name,total_gb,"
|
|
"expiry_time,limit_ip,reset,created_at,updated_at) VALUES (?,?,?,?,'','','auto',1,'',0,0,0,0,?,?)",
|
|
(EMAIL, XUI_SUBID, XUI_UUID, NODE_PASSWORD, now, now),
|
|
)
|
|
cid = cur.lastrowid
|
|
|
|
for tag, iid in inbound_ids.items():
|
|
if not cur.execute("SELECT 1 FROM client_inbounds WHERE client_id=? AND inbound_id=?",
|
|
(cid, iid)).fetchone():
|
|
cur.execute("INSERT INTO client_inbounds (client_id,inbound_id,flow_override,created_at) "
|
|
"VALUES (?,?,?,?)", (cid, iid, "", now))
|
|
|
|
# client_traffics.email is GLOBALLY UNIQUE -> exactly one row for node-main.
|
|
if not cur.execute("SELECT 1 FROM client_traffics WHERE email=?", (EMAIL,)).fetchone():
|
|
any_iid = inbound_ids["inbound-vmess-direct"]
|
|
cur.execute("INSERT INTO client_traffics (inbound_id,enable,email,up,down,expiry_time,total,reset) "
|
|
"VALUES (?,1,?,0,0,0,0,0)", (any_iid, EMAIL))
|
|
print(" node-main client linked to all 6 inbounds (uuid=vmess, password=trojan+ss)")
|
|
|
|
# WARP routing rule: read-modify-write (fix #4) -- append, never clobber.
|
|
row = cur.execute("SELECT value FROM settings WHERE key='xrayTemplateConfig'").fetchone()
|
|
tmpl = json.loads(row[0]) if row else TEMPLATE
|
|
# Enforce the fleet's IPv4-only policy when extending an existing template too.
|
|
for outbound in tmpl["outbounds"]:
|
|
if outbound.get("protocol") == "freedom":
|
|
outbound.setdefault("settings", {})["domainStrategy"] = "ForceIPv4"
|
|
outbound.setdefault("streamSettings", {}).setdefault("sockopt", {})["domainStrategy"] = "ForceIPv4"
|
|
tmpl.setdefault("dns", {})["queryStrategy"] = "UseIPv4"
|
|
blocked_tag = next(o["tag"] for o in tmpl["outbounds"] if o.get("protocol") == "blackhole")
|
|
ipv6_rule = {"ip": ["::/0"], "outboundTag": blocked_tag, "type": "field"}
|
|
if ipv6_rule not in tmpl["routing"]["rules"]:
|
|
tmpl["routing"]["rules"].insert(0, ipv6_rule)
|
|
for rule in tmpl["routing"]["rules"]:
|
|
if rule.get("outboundTag") == "warp-outbound":
|
|
for t in warp_tags:
|
|
if t not in rule["inboundTag"]:
|
|
rule["inboundTag"].append(t)
|
|
|
|
cur.execute("UPDATE settings SET value=? WHERE key='xrayTemplateConfig'", (json.dumps(tmpl, ensure_ascii=False),))
|
|
if cur.rowcount == 0:
|
|
cur.execute("INSERT INTO settings (key, value) VALUES ('xrayTemplateConfig', ?)", (json.dumps(tmpl, ensure_ascii=False),))
|
|
conn.commit()
|
|
conn.close()
|
|
print(" xrayTemplateConfig written, warp rule tags:",
|
|
next(r["inboundTag"] for r in tmpl["routing"]["rules"] if r.get("outboundTag") == "warp-outbound"))
|
|
|
|
subprocess.run(["systemctl", "restart", "x-ui"], check=True)
|
|
time.sleep(6)
|
|
print("OK — 3x-ui bootstrap complete (6 inbounds under one node-main client: vmess/trojan/shadowsocks x direct/warp)")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|