迁入通用节点部署工具、离线交付页生成器和共用技能,隔离真实配置与交付产物。统一 Codex 与 Claude Code 开发约定,补充节点迁移与运维文档。 完善 FINAL、策略组引用、输入校验、Surge 字段编码与 AI DNS 例外保护,增加隔离测试和订阅验证工具。 验证:全量类型检查、44 项单测、前端构建、脚本语法、技能入口与凭据隔离检查均通过。
158 lines
6.4 KiB
Python
Executable File
158 lines
6.4 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Runs ON the box. Makes a DB-provisioned node upgrade-safe.
|
|
|
|
WHY THIS EXISTS
|
|
---------------
|
|
3x-ui's real source of truth for client credentials is the per-inbound
|
|
`inbounds.settings.clients` JSON array -- NOT the normalized `clients` /
|
|
`client_inbounds` tables. Those tables are a *derived projection* of the JSON:
|
|
|
|
* client_link.go SyncInbound(tx, inboundId, clients) does an UNCONDITIONAL
|
|
`DELETE FROM client_inbounds WHERE inbound_id = ?` and then rebuilds the
|
|
rows only from the `clients` slice it was handed. Empty slice -> delete,
|
|
insert nothing.
|
|
* inbound_migration.go MigrationRequirements() hands SyncInbound the clients
|
|
it got from GetClients(inbound), i.e. parsed from the settings JSON.
|
|
* The panel UI path (client_inbound_apply.go addInboundClient) ALWAYS writes
|
|
the client into the settings JSON first, saves the inbound, then projects
|
|
to the tables via SyncInbound. So a UI-managed install always has a
|
|
populated settings.clients and migration is a harmless no-op for it.
|
|
|
|
A node provisioned by writing the tables directly (settings.clients left as
|
|
`[]`) is therefore off-model: any run of the migration path -- `x-ui migrate`,
|
|
which install.sh/update.sh invoke on upgrade, plus DB import/restore and the
|
|
SQLite->PostgreSQL copy -- wipes every client_inbounds row and silently drops
|
|
all credentials from the generated xray config. Verified empirically: running
|
|
`x-ui migrate` on a table-only node emptied client_inbounds immediately.
|
|
This is NOT specific to any one release; the logic is unchanged since 3.5.0.
|
|
|
|
WHAT THIS DOES
|
|
--------------
|
|
Backfills `settings.clients` for every proxy inbound from the node-main client
|
|
row, then re-asserts the client_inbounds links. Idempotent -- safe to re-run,
|
|
and re-running after any 3x-ui upgrade is the recommended belt-and-braces step.
|
|
|
|
FIELD SEMANTICS (client_link.go applyClientRecordMerge) -- get these wrong and
|
|
you corrupt the merged client row:
|
|
* Credential-ish fields overwrite ONLY when non-empty: id, password, auth,
|
|
secret, security, privateKey, publicKey. That's why a vmess entry may carry
|
|
only `id` and a trojan/ss entry only `password` -- across the six inbounds
|
|
the same email merges to a union instead of clobbering.
|
|
* These overwrite UNCONDITIONALLY, so all six entries must agree:
|
|
enable, subId, limitIp, totalGB, expiryTime, tgId, comment, reset, flow.
|
|
* `enable` is the dangerous one: omit it and JSON unmarshals it to false,
|
|
which makes xray.go skip the client on EVERY inbound -> node dead.
|
|
"""
|
|
import json, os, sqlite3, subprocess, sys, time
|
|
|
|
DB = os.environ.get("XUI_DB", "/etc/x-ui/x-ui.db")
|
|
EMAIL = os.environ.get("XUI_EMAIL", "node-main")
|
|
RESTART = os.environ.get("RESTART_XUI", "1") == "1"
|
|
|
|
PROXY_PROTOCOLS = ("vmess", "vless", "trojan", "shadowsocks")
|
|
|
|
|
|
def build_entry(protocol, uuid, password, sub_id, method, now):
|
|
"""One settings.clients entry. Unconditional fields identical across inbounds."""
|
|
e = {
|
|
"email": EMAIL,
|
|
"enable": True, # MUST be explicit -- see module docstring
|
|
"subId": sub_id or "",
|
|
"flow": "",
|
|
"limitIp": 0,
|
|
"totalGB": 0,
|
|
"expiryTime": 0,
|
|
"tgId": 0,
|
|
"comment": "",
|
|
"reset": 0,
|
|
"created_at": now,
|
|
"updated_at": now,
|
|
}
|
|
if protocol in ("vmess", "vless"):
|
|
e["id"] = uuid
|
|
e["security"] = "auto"
|
|
if protocol == "vmess":
|
|
e["alterId"] = 0
|
|
else:
|
|
e["password"] = password
|
|
if protocol == "shadowsocks":
|
|
e["method"] = method
|
|
return e
|
|
|
|
|
|
def main():
|
|
con = sqlite3.connect(DB)
|
|
cur = con.cursor()
|
|
|
|
row = cur.execute(
|
|
"SELECT id, uuid, password, sub_id FROM clients WHERE email=?", (EMAIL,)
|
|
).fetchone()
|
|
if not row:
|
|
sys.exit("ERROR: no client row for email=%r -- nothing to embed" % EMAIL)
|
|
cid, uuid, password, sub_id = row
|
|
now = int(time.time() * 1000)
|
|
|
|
inbounds = cur.execute(
|
|
"SELECT id, port, protocol, settings FROM inbounds ORDER BY port"
|
|
).fetchall()
|
|
|
|
changed = 0
|
|
for iid, port, protocol, settings_raw in inbounds:
|
|
if protocol not in PROXY_PROTOCOLS:
|
|
continue
|
|
try:
|
|
st = json.loads(settings_raw) if settings_raw else {}
|
|
except (TypeError, ValueError):
|
|
print(" :%s %s -- settings not valid JSON, SKIPPED" % (port, protocol))
|
|
continue
|
|
|
|
method = st.get("method", "chacha20-poly1305")
|
|
entry = build_entry(protocol, uuid, password, sub_id, method, now)
|
|
|
|
existing = st.get("clients")
|
|
if not isinstance(existing, list):
|
|
existing = []
|
|
# Replace only our own email's entry; leave any other client untouched.
|
|
kept = [c for c in existing if isinstance(c, dict) and c.get("email") != EMAIL]
|
|
new_clients = kept + [entry]
|
|
|
|
if existing == new_clients:
|
|
print(" :%s %-12s settings.clients already correct" % (port, protocol))
|
|
else:
|
|
st["clients"] = new_clients
|
|
cur.execute(
|
|
"UPDATE inbounds SET settings=? WHERE id=?",
|
|
(json.dumps(st, indent=2, ensure_ascii=False), iid),
|
|
)
|
|
changed += 1
|
|
print(" :%s %-12s settings.clients embedded" % (port, protocol))
|
|
|
|
# Re-assert the derived link (harmless if already there).
|
|
cur.execute(
|
|
"INSERT OR IGNORE 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 this client.
|
|
if not cur.execute("SELECT 1 FROM client_traffics WHERE email=?", (EMAIL,)).fetchone():
|
|
first = cur.execute("SELECT id FROM inbounds ORDER BY port").fetchone()
|
|
if first:
|
|
cur.execute(
|
|
"INSERT INTO client_traffics (inbound_id,enable,email,up,down,expiry_time,total,reset) "
|
|
"VALUES (?,1,?,0,0,0,0,0)", (first[0], EMAIL),
|
|
)
|
|
|
|
con.commit()
|
|
links = cur.execute("SELECT count(*) FROM client_inbounds").fetchone()[0]
|
|
con.close()
|
|
print(" settings.clients updated on %d inbound(s); client_inbounds rows now: %d" % (changed, links))
|
|
|
|
if RESTART:
|
|
subprocess.run(["systemctl", "restart", "x-ui"], check=True)
|
|
time.sleep(6)
|
|
print("OK -- x-ui restarted; node is now upgrade-safe (settings.clients is authoritative)")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|