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.
233 lines
7 KiB
Python
233 lines
7 KiB
Python
import copy
|
||
import json
|
||
import re
|
||
import socket
|
||
import time
|
||
|
||
BASE_TAGS = ("vless-tcp-reality", "vless-grpc-reality", "vless-xhttp-reality", "vless-ws-tls")
|
||
TCP_TAG = "vless-tcp-reality"
|
||
VISION = "xtls-rprx-vision"
|
||
CHAIN_PREFIX = "chain-"
|
||
RELAY_EMAIL_PREFIX = "relay-"
|
||
MAX_SERVERS = 2
|
||
PORT_MIN = 10443
|
||
PORT_MAX = 10999
|
||
CODE_RE = re.compile(r"^[a-z0-9]{1,16}$")
|
||
HOST_RE = re.compile(r"^[A-Za-z0-9.-]{1,253}$")
|
||
CHAIN_KINDS_ENTRY = ("local", "managed")
|
||
CHAIN_KINDS_EXIT = ("local", "managed", "external")
|
||
|
||
|
||
class ChainConfigError(Exception):
|
||
pass
|
||
|
||
|
||
def inbound_tag(code):
|
||
return CHAIN_PREFIX + code
|
||
|
||
|
||
def outbound_tag(code):
|
||
return CHAIN_PREFIX + code + "-out"
|
||
|
||
|
||
def relay_email(code):
|
||
return RELAY_EMAIL_PREFIX + code
|
||
|
||
|
||
def is_chain_inbound_tag(tag):
|
||
return bool(tag) and tag.startswith(CHAIN_PREFIX) and not tag.endswith("-out")
|
||
|
||
|
||
def is_user_tag(tag):
|
||
return tag in BASE_TAGS or is_chain_inbound_tag(tag)
|
||
|
||
|
||
def flow_for_tag(tag):
|
||
if tag == TCP_TAG or is_chain_inbound_tag(tag):
|
||
return VISION
|
||
return None
|
||
|
||
|
||
def sync_clients(clients, wanted, flow):
|
||
kept = []
|
||
seen = set()
|
||
for c in clients:
|
||
cid = c.get("id")
|
||
if cid in wanted and cid not in seen:
|
||
kept.append(c)
|
||
seen.add(cid)
|
||
for cid in wanted:
|
||
if cid in seen:
|
||
continue
|
||
entry = {"id": cid, "email": wanted[cid]}
|
||
if flow:
|
||
entry["flow"] = flow
|
||
kept.append(entry)
|
||
return kept
|
||
|
||
|
||
def find_inbound(cfg, tag):
|
||
for ib in cfg.get("inbounds", []):
|
||
if ib.get("tag") == tag:
|
||
return ib
|
||
return None
|
||
|
||
|
||
def build_chain_inbound(template, chain, wanted, old_clients):
|
||
reality = (template.get("streamSettings") or {}).get("realitySettings")
|
||
if not reality:
|
||
raise ChainConfigError("у входной ноды нет TCP+Reality inbound — цепочку строить не из чего")
|
||
ib = copy.deepcopy(template)
|
||
ib["tag"] = inbound_tag(chain["code"])
|
||
ib["port"] = chain["port"]
|
||
ib["streamSettings"]["realitySettings"]["shortIds"] = [chain["short_id"]]
|
||
ib["settings"]["clients"] = sync_clients(old_clients, wanted, VISION)
|
||
return ib
|
||
|
||
|
||
def build_chain_outbound(chain, exit_node, relay_uuid):
|
||
return {
|
||
"tag": outbound_tag(chain["code"]),
|
||
"protocol": "vless",
|
||
"settings": {
|
||
"vnext": [{
|
||
"address": exit_node["address"],
|
||
"port": int(exit_node["port"]),
|
||
"users": [{"id": relay_uuid, "encryption": "none", "flow": VISION}],
|
||
}],
|
||
},
|
||
"streamSettings": {
|
||
"network": "tcp",
|
||
"security": "reality",
|
||
"realitySettings": {
|
||
"serverName": exit_node["sni"],
|
||
"fingerprint": "chrome",
|
||
"publicKey": exit_node["public_key"],
|
||
"shortId": exit_node["short_id"],
|
||
"spiderX": "",
|
||
},
|
||
},
|
||
}
|
||
|
||
|
||
def build_chain_rule(chain):
|
||
return {
|
||
"type": "field",
|
||
"inboundTag": [inbound_tag(chain["code"])],
|
||
"outboundTag": outbound_tag(chain["code"]),
|
||
}
|
||
|
||
|
||
def relay_for_chain(chain, exit_node):
|
||
if exit_node["kind"] == "external":
|
||
return exit_node.get("shared_uuid")
|
||
return chain.get("relay_uuid")
|
||
|
||
|
||
def split_busy_chains(cfg, entry_chains, busy_ports):
|
||
new_ports = set(new_ports_needed(cfg, entry_chains))
|
||
usable = []
|
||
problems = []
|
||
for chain in entry_chains:
|
||
if chain["port"] in new_ports and chain["port"] in busy_ports:
|
||
problems.append(f"{chain['code']}: порт {chain['port']} уже занят другим процессом, цепочка не применена")
|
||
continue
|
||
usable.append(chain)
|
||
return usable, problems
|
||
|
||
|
||
def sync_config(cfg, wanted, relay_wanted, entry_chains, exit_nodes, apply_chains=True):
|
||
before = json.dumps(cfg, sort_keys=True)
|
||
problems = []
|
||
|
||
template = find_inbound(cfg, TCP_TAG)
|
||
|
||
for ib in cfg["inbounds"]:
|
||
tag = ib.get("tag")
|
||
if not is_user_tag(tag):
|
||
continue
|
||
want = dict(wanted)
|
||
if tag == TCP_TAG:
|
||
want.update(relay_wanted)
|
||
ib["settings"]["clients"] = sync_clients(ib["settings"]["clients"], want, flow_for_tag(tag))
|
||
|
||
if not apply_chains:
|
||
changed = json.dumps(cfg, sort_keys=True) != before
|
||
return changed, problems
|
||
|
||
old_chain_inbounds = {}
|
||
for ib in cfg["inbounds"]:
|
||
if is_chain_inbound_tag(ib.get("tag")):
|
||
old_chain_inbounds[ib["tag"]] = ib
|
||
|
||
kept_inbounds = [ib for ib in cfg["inbounds"] if not is_chain_inbound_tag(ib.get("tag"))]
|
||
kept_outbounds = [ob for ob in cfg.get("outbounds", []) if not (ob.get("tag") or "").startswith(CHAIN_PREFIX)]
|
||
routing = cfg.setdefault("routing", {})
|
||
kept_rules = [r for r in routing.get("rules", []) if not (r.get("outboundTag") or "").startswith(CHAIN_PREFIX)]
|
||
|
||
for chain in entry_chains:
|
||
exit_node = exit_nodes.get(chain["exit_node"])
|
||
if template is None:
|
||
problems.append(f"{chain['code']}: нет TCP+Reality inbound на входной ноде")
|
||
continue
|
||
if not exit_node:
|
||
problems.append(f"{chain['code']}: выходная нода не найдена")
|
||
continue
|
||
relay_uuid = relay_for_chain(chain, exit_node)
|
||
if not relay_uuid:
|
||
problems.append(f"{chain['code']}: у выходной ноды нет ключа для цепочки")
|
||
continue
|
||
old = old_chain_inbounds.get(inbound_tag(chain["code"]))
|
||
old_clients = old["settings"]["clients"] if old else []
|
||
kept_inbounds.append(build_chain_inbound(template, chain, wanted, old_clients))
|
||
kept_outbounds.append(build_chain_outbound(chain, exit_node, relay_uuid))
|
||
kept_rules.append(build_chain_rule(chain))
|
||
|
||
cfg["inbounds"] = kept_inbounds
|
||
cfg["outbounds"] = kept_outbounds
|
||
routing["rules"] = kept_rules
|
||
|
||
changed = json.dumps(cfg, sort_keys=True) != before
|
||
return changed, problems
|
||
|
||
|
||
def new_ports_needed(cfg, entry_chains):
|
||
existing = set()
|
||
for ib in cfg.get("inbounds", []):
|
||
if is_chain_inbound_tag(ib.get("tag")):
|
||
existing.add(ib["tag"])
|
||
ports = []
|
||
for chain in entry_chains:
|
||
if inbound_tag(chain["code"]) not in existing:
|
||
ports.append(chain["port"])
|
||
return ports
|
||
|
||
|
||
def tcp_connect_ms(host, port, samples=3, timeout=3.0):
|
||
results = []
|
||
for _ in range(samples):
|
||
start = time.perf_counter()
|
||
try:
|
||
with socket.create_connection((host, int(port)), timeout=timeout):
|
||
pass
|
||
results.append(round((time.perf_counter() - start) * 1000))
|
||
except OSError:
|
||
results.append(-1)
|
||
return results
|
||
|
||
|
||
def median_ms(samples):
|
||
good = sorted(s for s in samples if s >= 0)
|
||
if not good:
|
||
return None
|
||
return good[len(good) // 2]
|
||
|
||
|
||
def latency_level(rtt_ms):
|
||
if rtt_ms is None:
|
||
return "unknown"
|
||
if rtt_ms < 40:
|
||
return "low"
|
||
if rtt_ms < 120:
|
||
return "medium"
|
||
return "high"
|