mbs-panel/chains.py

234 lines
7 KiB
Python
Raw Permalink Normal View History

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"