feat: 合并边缘节点运维并完善 Surge 配置安全
迁入通用节点部署工具、离线交付页生成器和共用技能,隔离真实配置与交付产物。统一 Codex 与 Claude Code 开发约定,补充节点迁移与运维文档。 完善 FINAL、策略组引用、输入校验、Surge 字段编码与 AI DNS 例外保护,增加隔离测试和订阅验证工具。 验证:全量类型检查、44 项单测、前端构建、脚本语法、技能入口与凭据隔离检查均通过。
This commit is contained in:
157
.claude/skills/edge-node-multi-protocol/scripts/08b-embed-settings-clients.py
Executable file
157
.claude/skills/edge-node-multi-protocol/scripts/08b-embed-settings-clients.py
Executable file
@@ -0,0 +1,157 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user