mbs-panel/xray_manager.py
savsis da6200ae4a feat: server chains (client -> A -> B -> internet), audit log, users list, csv export
Chains are real Xray hops: a chain-<code> inbound + outbound + routing rule on the
entry node and a relay-<code> client on the exit node, reconciled by sync_node with
config test, port check, rollback on a failed restart and a per-node lock.
Admin API gets chains CRUD/probe/check, node latency, audit log (middleware, no request
bodies), users list with subscription counters and a csv export that neutralises formulas.
2026-10-04 03:51:16 +05:00

390 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import json
import os
import socket
import subprocess
import fcntl
import contextlib
import time
import chains
from config import XRAY_CONFIG_PATH
_LOCK_PATH = XRAY_CONFIG_PATH + ".lock"
@contextlib.contextmanager
def _locked():
with open(_LOCK_PATH, "w") as lf:
fcntl.flock(lf, fcntl.LOCK_EX)
try:
yield
finally:
fcntl.flock(lf, fcntl.LOCK_UN)
def _load():
with open(XRAY_CONFIG_PATH, "r", encoding="utf-8") as f:
return json.load(f)
class ConfigValidationError(Exception):
pass
def _readable_by(path, uid, gid) -> bool:
try:
st = os.stat(path)
except OSError:
return False
mode = st.st_mode
if st.st_uid == uid and mode & 0o400:
return True
if st.st_gid == gid and mode & 0o040:
return True
if mode & 0o004:
return True
return False
def check_cert_permissions(cfg, uid=65534, gid=65534) -> list:
problems = []
for ib in cfg.get("inbounds", []):
stream = ib.get("streamSettings") or {}
tls = stream.get("tlsSettings")
if not tls:
continue
for cert in tls.get("certificates") or []:
for key in ("certificateFile", "keyFile"):
path = cert.get(key)
if path and not _readable_by(path, uid, gid):
problems.append(f"{ib.get('tag', '?')}: {key}={path} not readable by the xray service user")
return problems
def validate_config(cfg, xray_bin="/usr/local/bin/xray") -> tuple:
tmp = XRAY_CONFIG_PATH + ".validate.tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(cfg, f, indent=2)
try:
result = subprocess.run(
[xray_bin, "run", "-test", "-format=json", "-config", tmp],
capture_output=True, text=True, timeout=15,
)
ok = result.returncode == 0
detail = (result.stdout + result.stderr).strip()
except Exception as e:
return False, str(e)
finally:
try:
os.remove(tmp)
except OSError:
pass
if not ok:
return False, detail
perm_problems = check_cert_permissions(cfg)
if perm_problems:
return False, "cert permission problem(s): " + "; ".join(perm_problems)
return True, detail
def _save(cfg):
ok, detail = validate_config(cfg)
if not ok:
raise ConfigValidationError(detail)
tmp = XRAY_CONFIG_PATH + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(cfg, f, indent=2)
os.replace(tmp, XRAY_CONFIG_PATH)
def _reload_xray():
subprocess.run(["systemctl", "restart", "xray"], check=True, timeout=20)
def _local_inbounds(cfg):
return [ib for ib in cfg["inbounds"] if chains.is_user_tag(ib.get("tag"))]
def add_client(client_uuid: str, email: str):
with _locked():
cfg = _load()
changed = False
for ib in _local_inbounds(cfg):
clients = ib["settings"]["clients"]
if any(c["id"] == client_uuid for c in clients):
continue
entry = {"id": client_uuid, "email": email}
flow = chains.flow_for_tag(ib["tag"])
if flow:
entry["flow"] = flow
clients.append(entry)
changed = True
if changed:
_save(cfg)
_reload_xray()
def remove_client(client_uuid: str):
with _locked():
cfg = _load()
changed = False
for ib in _local_inbounds(cfg):
clients = ib["settings"]["clients"]
new_clients = [c for c in clients if c["id"] != client_uuid]
if len(new_clients) != len(clients):
ib["settings"]["clients"] = new_clients
changed = True
if changed:
_save(cfg)
_reload_xray()
def _node_usable(node):
return bool(node["enabled"]) and node["status"] == "active"
def desired_state(node):
import db as dbmod
active = dbmod.list_active_subscriptions(node=node["code"])
wanted = {s["uuid"]: s["uuid"] for s in active}
nodes_by_code = {n["code"]: n for n in dbmod.list_nodes()}
entry_chains = []
exit_nodes = {}
relay_wanted = {}
for chain in dbmod.list_chains(enabled_only=True):
entry = nodes_by_code.get(chain["entry_node"])
exit_node = nodes_by_code.get(chain["exit_node"])
if not entry or not exit_node:
continue
if not _node_usable(entry) or not _node_usable(exit_node):
continue
if chain["entry_node"] == node["code"]:
entry_chains.append(chain)
exit_nodes[chain["exit_node"]] = exit_node
if chain["exit_node"] == node["code"] and node["kind"] in ("local", "managed") and chain.get("relay_uuid"):
relay_wanted[chain["relay_uuid"]] = chains.relay_email(chain["code"])
return wanted, relay_wanted, entry_chains, exit_nodes
def _read_config_text():
with open(XRAY_CONFIG_PATH, "r", encoding="utf-8") as f:
return f.read()
def _restore_config_text(text):
tmp = XRAY_CONFIG_PATH + ".restore.tmp"
with open(tmp, "w", encoding="utf-8") as f:
f.write(text)
os.replace(tmp, XRAY_CONFIG_PATH)
subprocess.run(["systemctl", "restart", "xray"], timeout=20)
def _reload_and_verify():
subprocess.run(["systemctl", "restart", "xray"], check=True, timeout=20)
time.sleep(1)
state = subprocess.run(["systemctl", "is-active", "xray"], capture_output=True, text=True).stdout.strip()
if state != "active":
raise ConfigValidationError("xray не поднялся после применения конфига, вернули старый")
def _port_busy(port):
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
sock.bind(("0.0.0.0", int(port)))
return False
except OSError:
return True
finally:
sock.close()
def _open_firewall(port):
subprocess.run(
["sh", "-c", f"command -v ufw >/dev/null 2>&1 && ufw allow {int(port)}/tcp || true"],
timeout=20,
)
def _reconcile_local(wanted, relay_wanted, entry_chains, exit_nodes, apply_chains=True):
with _locked():
before_text = _read_config_text()
cfg = json.loads(before_text)
usable = entry_chains
skipped = []
new_ports = []
if apply_chains:
busy = {c["port"] for c in entry_chains if _port_busy(c["port"])}
usable, skipped = chains.split_busy_chains(cfg, entry_chains, busy)
new_ports = chains.new_ports_needed(cfg, usable)
changed, problems = chains.sync_config(cfg, wanted, relay_wanted, usable, exit_nodes, apply_chains=apply_chains)
if changed:
_save(cfg)
try:
_reload_and_verify()
except Exception:
_restore_config_text(before_text)
raise
for port in new_ports:
_open_firewall(port)
return {"changed": changed, "new_ports": new_ports, "problems": skipped + problems}
def sync_node(node):
wanted, relay_wanted, entry_chains, exit_nodes = desired_state(node)
if node["kind"] == "managed":
import nodeprov
reconcile = nodeprov.remote_reconcile
args = (node, wanted, relay_wanted, entry_chains, exit_nodes)
elif node["kind"] == "local":
reconcile = _reconcile_local
args = (wanted, relay_wanted, entry_chains, exit_nodes)
else:
return {"changed": False, "new_ports": [], "problems": []}
try:
return reconcile(*args)
except Exception as first_error:
if not entry_chains:
raise
result = reconcile(*args, apply_chains=False)
result["problems"].append(f"цепочки не применились, клиенты синхронизированы: {first_error}")
return result
def sync_from_db():
import db as dbmod
expired = dbmod.deactivate_expired()
node = dbmod.get_node("de1")
result = sync_node(node)
wanted = desired_state(node)[0]
return {
"removed_expired": len(expired), "active_now": len(wanted),
"reloaded": result["changed"], "problems": result["problems"],
}
def probe_from_node(node: dict, host: str, port: int):
if node["kind"] == "local":
return chains.tcp_connect_ms(host, port)
if node["kind"] == "managed":
import nodeprov
return nodeprov.remote_probe(node, host, port)
raise ValueError("нода не под управлением панели, замерить с неё нельзя")
def add_client_to_node(node: dict, client_uuid: str, email: str):
if node["kind"] == "local":
add_client(client_uuid, email)
elif node["kind"] == "managed":
import nodeprov
nodeprov.remote_add_client(node, client_uuid, email)
def remove_client_from_node(node: dict, client_uuid: str):
if node["kind"] == "local":
remove_client(client_uuid)
elif node["kind"] == "managed":
import nodeprov
nodeprov.remote_remove_client(node, client_uuid)
def query_stats() -> dict:
try:
out = subprocess.run(
["/usr/local/bin/xray", "api", "statsquery", "--server=127.0.0.1:10085", "-pattern", "user>>>"],
capture_output=True, text=True, timeout=10,
).stdout
data = json.loads(out) if out.strip() else {"stat": []}
except Exception:
return {}
result = {}
for entry in data.get("stat") or []:
name = entry.get("name", "")
value = entry.get("value", 0)
parts = name.split(">>>")
if len(parts) != 4 or parts[0] != "user":
continue
_, email, _, direction = parts
row = result.setdefault(email, {"up": 0, "down": 0})
if direction == "uplink":
row["up"] += value
elif direction == "downlink":
row["down"] += value
return result
def reset_stats(email: str) -> bool:
try:
subprocess.run(
["/usr/local/bin/xray", "api", "statsquery", "--server=127.0.0.1:10085",
"-pattern", f"user>>>{email}>>>traffic", "-reset"],
capture_output=True, text=True, timeout=10,
)
return True
except Exception:
return False
def reset_stats_for_node(node: dict, email: str) -> bool:
if node["kind"] == "local":
return reset_stats(email)
if node["kind"] == "managed":
import nodeprov
return nodeprov.remote_reset_stats(node, email)
return False
def local_node_status() -> dict:
try:
with open("/proc/loadavg") as f:
load1 = float(f.read().split()[0])
except Exception:
load1 = None
mem_total = mem_avail = None
try:
with open("/proc/meminfo") as f:
for line in f:
if line.startswith("MemTotal:"):
mem_total = int(line.split()[1]) // 1024
elif line.startswith("MemAvailable:"):
mem_avail = int(line.split()[1]) // 1024
except Exception:
pass
uptime_s = None
try:
with open("/proc/uptime") as f:
uptime_s = int(float(f.read().split()[0]))
except Exception:
pass
xray_active = subprocess.run(["systemctl", "is-active", "xray"], capture_output=True, text=True).stdout.strip()
return {
"ok": True, "load1": load1,
"mem_used_mb": (mem_total - mem_avail) if (mem_total and mem_avail is not None) else None,
"mem_total_mb": mem_total, "uptime_s": uptime_s, "xray_active": xray_active == "active",
}
def sync_all():
import db as dbmod
expired = dbmod.deactivate_expired()
results = {}
for node in dbmod.list_nodes():
if node["kind"] == "local":
results[node["code"]] = sync_from_db()
elif node["kind"] == "managed":
active = dbmod.list_active_subscriptions(node=node["code"])
try:
res = sync_node(node)
results[node["code"]] = {
"active_now": len(active), "ok": True,
"changed": res["changed"], "problems": res["problems"],
}
except Exception as e:
results[node["code"]] = {"active_now": len(active), "ok": False, "error": str(e)}
reloaded = any(r.get("changed") or r.get("reloaded") for r in results.values())
return {
"removed_expired": len(expired), "active_now": len(dbmod.list_active_subscriptions()),
"reloaded": reloaded, "nodes": results,
}