mirror of
https://github.com/andrey271192/kaskad.git
synced 2026-09-21 13:51:57 +00:00
Каскадная маршрутизация русских сайтов через свой набор RU-серверов с failover, Telegram-ботом и веб-интерфейсом. - bin/ — failover-script, route/domain helpers, ams/ru bootstrap - bot/ — Telegram-бот (Python, long-poll) - webui/ — Flask single-page dashboard - docs/ — установка, архитектура, API бота и WebUI - examples/ — шаблоны конфигов
653 lines
29 KiB
Python
Executable File
653 lines
29 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""TG-бот: управляет N RU-серверов и M ам. серверов через ru-servers.json."""
|
||
import base64, json, os, re, shlex, socket, subprocess, time
|
||
from pathlib import Path
|
||
from urllib import parse, request, error as urlerror
|
||
|
||
TOKEN = os.environ["TG_BOT_TOKEN"]
|
||
ALLOWED = os.environ.get("TG_CHAT_ID", "").strip()
|
||
API = f"https://api.telegram.org/bot{TOKEN}"
|
||
|
||
LOCAL_HOST = os.environ.get("LOCAL_HOST", "sga1")
|
||
LOCAL_IP = os.environ.get("LOCAL_IP", "127.0.0.1")
|
||
SERVERS_JSON = "/etc/wireguard/ru-servers.json"
|
||
|
||
CIDR_RX = re.compile(r"\b(\d{1,3}(?:\.\d{1,3}){3}(?:/\d{1,2})?)\b")
|
||
|
||
# --- TG ---
|
||
def tg_post(method, params):
|
||
body = parse.urlencode(params).encode()
|
||
try:
|
||
with request.urlopen(request.Request(f"{API}/{method}", data=body), timeout=10) as r:
|
||
return json.loads(r.read())
|
||
except Exception as e:
|
||
return {"ok": False, "err": str(e)}
|
||
|
||
def tg_get(method, params):
|
||
url = f"{API}/{method}?{parse.urlencode(params)}"
|
||
with request.urlopen(url, timeout=40) as r:
|
||
return json.loads(r.read())
|
||
|
||
# --- shell ---
|
||
def shell(cmd, timeout=15, input=None):
|
||
r = subprocess.run(["bash","-c",cmd], capture_output=True, text=True, timeout=timeout, input=input)
|
||
return r.stdout.strip(), r.returncode, r.stderr.strip()
|
||
|
||
def ssh_run(host, cmd, timeout=15, port=22, user="root", key="/root/.ssh/id_ed25519", input=None):
|
||
if host == LOCAL_IP:
|
||
out, rc, _ = shell(cmd, timeout=timeout, input=input)
|
||
return out, rc
|
||
args = ["ssh","-i",key,"-p",str(port),
|
||
"-o","StrictHostKeyChecking=no","-o","ConnectTimeout=5","-o","BatchMode=yes",
|
||
f"{user}@{host}", cmd]
|
||
r = subprocess.run(args, capture_output=True, text=True, timeout=timeout, input=input)
|
||
return r.stdout.strip(), r.returncode
|
||
|
||
def ssh_pw(host, port, user, password, cmd, timeout=180, sudo_pass=None):
|
||
args = ["sshpass","-p",password,"ssh",
|
||
"-o","StrictHostKeyChecking=accept-new","-o","ConnectTimeout=15",
|
||
"-o","PreferredAuthentications=password","-o","PubkeyAuthentication=no",
|
||
"-p",str(port), f"{user}@{host}", cmd]
|
||
r = subprocess.run(args, capture_output=True, text=True, timeout=timeout)
|
||
return r.stdout, r.returncode, r.stderr
|
||
|
||
def scp_pw(host, port, user, password, files, dest, timeout=60):
|
||
args = ["sshpass","-p",password,"scp","-P",str(port),
|
||
"-o","StrictHostKeyChecking=accept-new","-o","ConnectTimeout=15",
|
||
"-o","PreferredAuthentications=password","-o","PubkeyAuthentication=no"]
|
||
if isinstance(files, str): files = [files]
|
||
args += files + [f"{user}@{host}:{dest}"]
|
||
r = subprocess.run(args, capture_output=True, text=True, timeout=timeout)
|
||
return r.returncode == 0, (r.stderr or r.stdout).strip()
|
||
|
||
def scp_key(host, port, files, dest, timeout=60, key="/root/.ssh/id_ed25519"):
|
||
args = ["scp","-P",str(port),
|
||
"-o","StrictHostKeyChecking=no","-o","ConnectTimeout=15",
|
||
"-i",key,"-o","BatchMode=yes"]
|
||
if isinstance(files, str): files = [files]
|
||
args += files + [f"root@{host}:{dest}"]
|
||
r = subprocess.run(args, capture_output=True, text=True, timeout=timeout)
|
||
return r.returncode == 0, (r.stderr or r.stdout).strip()
|
||
|
||
# --- helpers ---
|
||
def fmt_age(s):
|
||
try: s = int(s)
|
||
except: return "?"
|
||
if s >= 86400: return f"{s//86400}d"
|
||
if s >= 3600: return f"{s//3600}h"
|
||
if s >= 60: return f"{s//60}m"
|
||
return f"{s}s"
|
||
|
||
def load_data():
|
||
try: return json.loads(Path(SERVERS_JSON).read_text())
|
||
except: return {"servers": [], "ams_servers": []}
|
||
|
||
def save_and_distribute(data):
|
||
js = json.dumps(data, indent=2, ensure_ascii=False)
|
||
Path(SERVERS_JSON).write_text(js)
|
||
fail = []
|
||
for a in data.get("ams_servers", []):
|
||
if a.get("is_local"): continue
|
||
proc = subprocess.run(
|
||
["ssh","-i","/root/.ssh/id_ed25519","-p",str(a.get("ssh_port",22)),
|
||
"-o","StrictHostKeyChecking=no","-o","ConnectTimeout=5","-o","BatchMode=yes",
|
||
f"root@{a['host']}", f"cat > {SERVERS_JSON} && chmod 600 {SERVERS_JSON}"],
|
||
input=js, text=True, capture_output=True, timeout=10)
|
||
if proc.returncode != 0: fail.append(f"{a['id']}: {proc.stderr.strip()}")
|
||
return (len(fail) == 0), ("; ".join(fail) if fail else "OK")
|
||
|
||
def ams_list_data(): return load_data().get("ams_servers", [])
|
||
def ru_list_data(): return sorted(load_data().get("servers", []), key=lambda x: x["priority"])
|
||
|
||
def ssh_ams(a, cmd, timeout=15):
|
||
if a.get("is_local") or a["host"] == LOCAL_IP:
|
||
return shell(cmd, timeout=timeout)[:2]
|
||
return ssh_run(a["host"], cmd, timeout=timeout, port=a.get("ssh_port",22))
|
||
|
||
def ssh_ru(s, cmd, timeout=15):
|
||
return ssh_run(s["host"], cmd, timeout=timeout,
|
||
port=s.get("ssh_port", 22), user=s.get("ssh_user", "root"))
|
||
|
||
def label_for(ep):
|
||
for s in ru_list_data():
|
||
if s["endpoint"] == ep or s["host"] in ep:
|
||
return f"{s['id']} ({s['label']})"
|
||
return ep
|
||
|
||
QUERY_CMD = (
|
||
"ep=$(grep ^Endpoint /etc/wireguard/ru.conf | awk '{print $3}'); "
|
||
"hs=$(wg show ru latest-handshakes 2>/dev/null | head -1 | awk '{print $2}'); "
|
||
"now=$(date +%s); age=$((now-${hs:-0})); "
|
||
"[ \"${hs:-0}\" -eq 0 ] && age=999999; "
|
||
"echo \"$ep|$age\""
|
||
)
|
||
|
||
def status():
|
||
ams = ams_list_data()
|
||
if not ams: return "Нет ам. серверов в конфиге"
|
||
lines = ["📊 Туннели:"]
|
||
for a in ams:
|
||
out, rc = ssh_ams(a, QUERY_CMD)
|
||
if rc != 0:
|
||
lines.append(f"❌ {a['id']}: недоступен")
|
||
continue
|
||
try:
|
||
ep, age = out.split("|")
|
||
label = label_for(ep)
|
||
icon = "🟢" if "primary" in label else ("🟡" if "backup" in label else "🔵")
|
||
lines.append(f"{icon} {a['id']}: {label}, hs {fmt_age(age)}")
|
||
except Exception:
|
||
lines.append(f"• {a['id']}: {out}")
|
||
return "\n".join(lines)
|
||
|
||
def force_all(server_id):
|
||
ru = next((s for s in ru_list_data() if s["id"] == server_id), None)
|
||
if not ru:
|
||
ids = ", ".join(s["id"] for s in ru_list_data())
|
||
return f"❌ нет RU-сервера '{server_id}'. Доступны: {ids}"
|
||
lines = [f"⚙️ Все ам. на {ru['id']} ({ru['label']}):"]
|
||
for a in ams_list_data():
|
||
out, rc = ssh_ams(a, f"/usr/local/bin/ru-set.sh {shlex.quote(server_id)}")
|
||
lines.append(f"• {a['id']}: {'✅' if rc == 0 else '❌'} {out}")
|
||
return "\n".join(lines)
|
||
|
||
# --- RU servers ---
|
||
def server_list():
|
||
rs = ru_list_data()
|
||
if not rs: return "❌ нет RU"
|
||
lines = ["🌐 RU-серверы (по приоритету):"]
|
||
for s in rs:
|
||
lines.append(f" [{s['priority']}] {s['id']} — {s['label']} {s['endpoint']} probe:{s['probe_port']} ssh:{s.get('ssh_user','?')}@{s['host']}:{s.get('ssh_port','?')}")
|
||
return "\n".join(lines)
|
||
|
||
def parse_kv(parts):
|
||
out = {}; positional = []
|
||
for p in parts:
|
||
if "=" in p:
|
||
k, v = p.split("=", 1); out[k] = v
|
||
else:
|
||
positional.append(p)
|
||
return positional, out
|
||
|
||
def get_bot_pubkey():
|
||
out, _, _ = shell("cat /root/.ssh/id_ed25519.pub")
|
||
return out
|
||
|
||
def server_add(body):
|
||
parts = body.split()
|
||
pos, kv = parse_kv(parts)
|
||
if len(pos) < 4:
|
||
return ("Использование:\n"
|
||
"/server-add <host> <user> <ssh_port> <id> [prio] [password=PW] [label=...] [listen_port=1939] [probe_port=ssh_port]")
|
||
host, user, ssh_port, sid = pos[:4]
|
||
priority = int(pos[4]) if len(pos) > 4 and pos[4].isdigit() else 99
|
||
label = kv.get("label", host)
|
||
listen_port = int(kv.get("listen_port", 1939))
|
||
probe_port = int(kv.get("probe_port", ssh_port))
|
||
password = kv.get("password")
|
||
|
||
data = load_data()
|
||
if any(s["id"] == sid for s in data["servers"]):
|
||
return f"❌ id '{sid}' уже есть"
|
||
|
||
bot_key_b64 = base64.b64encode(get_bot_pubkey().encode()).decode()
|
||
helper = "/usr/local/bin/add-ru-helper.sh"
|
||
if not Path(helper).exists(): return f"❌ нет {helper}"
|
||
|
||
# scp
|
||
if password:
|
||
ok, err = scp_pw(host, ssh_port, user, password, helper, "/tmp/add-ru-helper.sh")
|
||
else:
|
||
ok, err = scp_key(host, ssh_port, helper, "/tmp/add-ru-helper.sh")
|
||
if not ok: return f"❌ scp: {err[:500]}"
|
||
|
||
# подготовить аргументы хелпера: ams pubkeys + tunnel IPs
|
||
args = [bot_key_b64, str(listen_port)]
|
||
for a in sorted(data.get("ams_servers", []), key=lambda x: x["tunnel_ip"]):
|
||
args += [a["pubkey"], a["tunnel_ip"]]
|
||
arg_str = " ".join(shlex.quote(x) for x in args)
|
||
sudo_p = ""
|
||
if user != "root":
|
||
sudo_p = f"echo {shlex.quote(password or '')} | sudo -S -p '' " if password else "sudo -n "
|
||
cmd = f"chmod +x /tmp/add-ru-helper.sh && {sudo_p}bash /tmp/add-ru-helper.sh {arg_str}"
|
||
|
||
if password:
|
||
out, rc, err = ssh_pw(host, ssh_port, user, password, cmd, timeout=240)
|
||
else:
|
||
out, rc = ssh_run(host, cmd, timeout=240, port=ssh_port, user=user); err = ""
|
||
full = (out or "") + (err or "")
|
||
if "----RESULT----" not in full:
|
||
return f"❌ helper не отработал:\n{full[-1500:]}"
|
||
res = {}
|
||
in_b = False
|
||
for line in full.splitlines():
|
||
if line == "----RESULT----": in_b = True; continue
|
||
if line == "----END----": in_b = False; continue
|
||
if in_b and "=" in line:
|
||
k, v = line.split("=", 1); res[k] = v.strip()
|
||
pubkey = res.get("PUBKEY")
|
||
if not pubkey: return f"❌ pubkey не получен:\n{full[-800:]}"
|
||
|
||
new = {
|
||
"id": sid, "host": host, "endpoint": f"{host}:{listen_port}",
|
||
"pubkey": pubkey, "probe_port": probe_port, "priority": priority, "label": label,
|
||
"ssh_user": "root", "ssh_port": int(ssh_port),
|
||
"wg_iface": res.get("IFACE", "ens18"),
|
||
}
|
||
data["servers"].append(new)
|
||
ok, err = save_and_distribute(data)
|
||
if not ok: return f"❌ JSON sync: {err}"
|
||
|
||
warn = "" if probe_tcp(host, probe_port) else f"\n⚠ TCP {host}:{probe_port} закрыт — failover не сможет проверять"
|
||
return (f"✅ RU '{sid}' ({label}) добавлен\n"
|
||
f" endpoint: {host}:{listen_port}\n"
|
||
f" pubkey: {pubkey}\n"
|
||
f" priority: {priority}{warn}")
|
||
|
||
def server_remove(arg):
|
||
arg = arg.strip()
|
||
if not arg: return "Использование: /server-remove <id|host>"
|
||
data = load_data()
|
||
before = len(data["servers"])
|
||
data["servers"] = [s for s in data["servers"] if s["id"] != arg and s["host"] != arg]
|
||
if len(data["servers"]) == before: return f"❌ '{arg}' не найден"
|
||
if not data["servers"]: return "❌ это последний RU, отказ"
|
||
ok, err = save_and_distribute(data)
|
||
return f"✅ '{arg}' удалён" if ok else f"❌ sync: {err}"
|
||
|
||
# --- AMS servers ---
|
||
def ams_list():
|
||
a = ams_list_data()
|
||
if not a: return "❌ нет ам. серверов"
|
||
lines = ["🛰 Ам. серверы:"]
|
||
for x in a:
|
||
local = " (local)" if x.get("is_local") else ""
|
||
lines.append(f" {x['id']}{local} — {x['host']}:{x.get('ssh_port',22)} tunnel:{x['tunnel_ip']}")
|
||
return "\n".join(lines)
|
||
|
||
def probe_tcp(host, port, timeout=3):
|
||
try:
|
||
with socket.create_connection((host, int(port)), timeout=timeout): return True
|
||
except Exception: return False
|
||
|
||
def add_peer_to_ru(ru, peer_id, peer_pk, tunnel_ip):
|
||
"""SSH в RU, дописать [Peer] в wg_ru.conf и syncconf."""
|
||
block = f"\n[Peer]\n# {peer_id}\nPublicKey = {peer_pk}\nAllowedIPs = {tunnel_ip}/32\n"
|
||
cmd = f"""
|
||
if grep -qF '{peer_pk}' /etc/wireguard/wg_ru.conf; then
|
||
echo 'peer already present'
|
||
else
|
||
printf '%s' {shlex.quote(block)} >> /etc/wireguard/wg_ru.conf
|
||
fi
|
||
wg syncconf wg_ru <(wg-quick strip wg_ru) 2>&1
|
||
"""
|
||
return ssh_ru(ru, cmd, timeout=20)
|
||
|
||
def remove_peer_from_ru(ru, peer_pk):
|
||
"""Удалить [Peer] секцию по pubkey."""
|
||
cmd = f"""
|
||
python3 - <<'PY'
|
||
import pathlib, re
|
||
p = pathlib.Path('/etc/wireguard/wg_ru.conf')
|
||
t = p.read_text()
|
||
# делим на блоки по [Peer], удаляем тот, где совпадает pubkey
|
||
parts = re.split(r'(\\[Peer\\])', t)
|
||
result = parts[0]
|
||
i = 1
|
||
while i < len(parts):
|
||
block = parts[i] + (parts[i+1] if i+1 < len(parts) else '')
|
||
if {peer_pk!r} in block:
|
||
i += 2; continue
|
||
result += block
|
||
i += 2
|
||
p.write_text(result)
|
||
PY
|
||
wg syncconf wg_ru <(wg-quick strip wg_ru) 2>&1
|
||
"""
|
||
return ssh_ru(ru, cmd, timeout=20)
|
||
|
||
def ams_add(body):
|
||
parts = body.split()
|
||
pos, kv = parse_kv(parts)
|
||
if len(pos) < 4:
|
||
return ("Использование:\n"
|
||
"/ams-add <host> <user> <ssh_port> <id> [tunnel_ip=auto] [xray_iface=amn0] [password=PW]\n\n"
|
||
"Что делает: ставит WG-клиент, копирует скрипты failover/routes, добавляет пира на ВСЕ RU.")
|
||
host, user, ssh_port, sid = pos[:4]
|
||
xray_iface = kv.get("xray_iface", "amn0")
|
||
password = kv.get("password")
|
||
|
||
data = load_data()
|
||
if any(a["id"] == sid or a["host"] == host for a in data.get("ams_servers", [])):
|
||
return f"❌ id '{sid}' или host '{host}' уже есть"
|
||
|
||
used = {a["tunnel_ip"] for a in data.get("ams_servers", [])} | {"10.0.0.1"}
|
||
if "tunnel_ip" in kv:
|
||
tunnel_ip = kv["tunnel_ip"]
|
||
if tunnel_ip in used: return f"❌ {tunnel_ip} занят"
|
||
else:
|
||
tunnel_ip = next((f"10.0.0.{i}" for i in range(2, 255) if f"10.0.0.{i}" not in used), None)
|
||
if not tunnel_ip: return "❌ нет свободных tunnel IP"
|
||
|
||
rus = ru_list_data()
|
||
if not rus: return "❌ нет RU-серверов"
|
||
primary = rus[0]
|
||
|
||
# 1. scp + run helper
|
||
bot_key_b64 = base64.b64encode(get_bot_pubkey().encode()).decode()
|
||
helper_local = "/usr/local/bin/add-ams-helper.sh"
|
||
if password:
|
||
ok, err = scp_pw(host, ssh_port, user, password, helper_local, "/tmp/add-ams-helper.sh")
|
||
else:
|
||
ok, err = scp_key(host, ssh_port, helper_local, "/tmp/add-ams-helper.sh")
|
||
if not ok: return f"❌ scp helper: {err[:500]}"
|
||
|
||
sudo_p = ""
|
||
if user != "root":
|
||
sudo_p = f"echo {shlex.quote(password or '')} | sudo -S -p '' " if password else "sudo -n "
|
||
helper_cmd = f"chmod +x /tmp/add-ams-helper.sh && {sudo_p}bash /tmp/add-ams-helper.sh {shlex.quote(bot_key_b64)}"
|
||
if password:
|
||
out, rc, err = ssh_pw(host, ssh_port, user, password, helper_cmd, timeout=180)
|
||
else:
|
||
out, rc = ssh_run(host, helper_cmd, timeout=180, port=ssh_port, user=user); err = ""
|
||
full = (out or "") + (err or "")
|
||
if "----RESULT----" not in full:
|
||
return f"❌ helper не отработал:\n{full[-1200:]}"
|
||
|
||
# 2. Сгенерировать ключи на новом ам. через ssh с key auth (теперь должно работать)
|
||
out_pk, rc = ssh_run(host, "test -f /etc/wireguard/ru_private.key || (umask 077 && wg genkey | tee /etc/wireguard/ru_private.key | wg pubkey > /etc/wireguard/ru_public.key); cat /etc/wireguard/ru_public.key", timeout=15, port=ssh_port)
|
||
if rc != 0: return f"❌ key gen: {out_pk}"
|
||
new_pubkey = out_pk.strip()
|
||
|
||
# 3. Скопировать скрипты + конфиг
|
||
files = ["/usr/local/bin/ru-failover.py", "/usr/local/bin/ru-set.sh", "/usr/local/bin/ru-routes.sh", "/etc/wireguard/notify.env", "/etc/wireguard/ru-servers.json"]
|
||
# JSON ещё без нового ам. — обновим в конце
|
||
ok, err = scp_key(host, ssh_port, files, "/tmp/", timeout=30)
|
||
if not ok: return f"❌ scp scripts: {err[:500]}"
|
||
|
||
# 4. Готовим ru.conf на новом ам. (берём sga1 как шаблон, меняем Address/PublicKey/Endpoint и pubkey пира)
|
||
sga1_conf, _, _ = shell("cat /etc/wireguard/ru.conf")
|
||
sga1_base, _, _ = shell("cat /etc/wireguard/ru-base.aips 2>/dev/null || true")
|
||
|
||
# шаблон: заменим Address и Peer-секцию (Endpoint, PublicKey)
|
||
new_conf = re.sub(r'(?m)^Address *=.*$', f'Address = {tunnel_ip}/32', sga1_conf, count=1)
|
||
new_conf = re.sub(r'(?m)^PublicKey *=.*$', f'PublicKey = {primary["pubkey"]}', new_conf, count=1)
|
||
new_conf = re.sub(r'(?m)^Endpoint *=.*$', f'Endpoint = {primary["endpoint"]}', new_conf, count=1)
|
||
# PostUp использует amn0 — заменим на xray_iface если другой
|
||
if xray_iface != "amn0":
|
||
new_conf = new_conf.replace("amn0", xray_iface)
|
||
|
||
# Положить конфиг и активировать на новом ам.
|
||
proc = subprocess.run(
|
||
["ssh","-i","/root/.ssh/id_ed25519","-p",str(ssh_port),
|
||
"-o","StrictHostKeyChecking=no","-o","ConnectTimeout=10","-o","BatchMode=yes",
|
||
f"root@{host}",
|
||
f"cat > /etc/wireguard/ru.conf && chmod 600 /etc/wireguard/ru.conf"],
|
||
input=new_conf, text=True, capture_output=True, timeout=15)
|
||
if proc.returncode != 0: return f"❌ write ru.conf: {proc.stderr.strip()}"
|
||
|
||
# base.aips
|
||
if sga1_base:
|
||
proc = subprocess.run(
|
||
["ssh","-i","/root/.ssh/id_ed25519","-p",str(ssh_port),
|
||
"-o","StrictHostKeyChecking=no","-o","ConnectTimeout=10","-o","BatchMode=yes",
|
||
f"root@{host}",
|
||
"cat > /etc/wireguard/ru-base.aips && chmod 600 /etc/wireguard/ru-base.aips"],
|
||
input=sga1_base, text=True, capture_output=True, timeout=10)
|
||
|
||
# установить скрипты, ru-extra, cron, поднять туннель
|
||
install_cmd = """
|
||
install -m 755 /tmp/ru-failover.py /usr/local/bin/ru-failover.py
|
||
install -m 755 /tmp/ru-set.sh /usr/local/bin/ru-set.sh
|
||
install -m 755 /tmp/ru-routes.sh /usr/local/bin/ru-routes.sh
|
||
install -m 600 /tmp/notify.env /etc/wireguard/notify.env
|
||
install -m 600 /tmp/ru-servers.json /etc/wireguard/ru-servers.json
|
||
touch /etc/wireguard/ru-extra.list && chmod 600 /etc/wireguard/ru-extra.list
|
||
( crontab -l 2>/dev/null | grep -v ru-failover ; echo '* * * * * /usr/local/bin/ru-failover.py' ) | crontab -
|
||
wg-quick down ru 2>/dev/null || true
|
||
wg-quick up ru 2>&1 | tail -5
|
||
systemctl enable wg-quick@ru 2>&1 | tail -1
|
||
"""
|
||
out_inst, rc = ssh_run(host, install_cmd, timeout=60, port=ssh_port)
|
||
if rc != 0: return f"❌ install: {out_inst[-500:]}"
|
||
|
||
# 5. Добавить пира на КАЖДОМ RU
|
||
peer_results = []
|
||
for ru in rus:
|
||
out_pr, rc_pr = add_peer_to_ru(ru, sid, new_pubkey, tunnel_ip)
|
||
peer_results.append(f" {ru['id']}: {'✅' if rc_pr == 0 else '❌'} {out_pr.splitlines()[-1] if out_pr else ''}")
|
||
|
||
# 6. Обновить JSON ams_servers
|
||
data["ams_servers"].append({
|
||
"id": sid, "host": host, "ssh_port": int(ssh_port),
|
||
"tunnel_ip": tunnel_ip, "pubkey": new_pubkey, "xray_iface": xray_iface,
|
||
})
|
||
ok, err = save_and_distribute(data)
|
||
|
||
return (f"✅ ам. сервер '{sid}' добавлен\n"
|
||
f" host: {host}, tunnel: {tunnel_ip}\n"
|
||
f" pubkey: {new_pubkey}\n"
|
||
f"Пиры на RU:\n" + "\n".join(peer_results) + "\n\n"
|
||
f"X-ray на этом сервере настраивай сам (туннель уже работает на {primary['label']}).")
|
||
|
||
def ams_remove(body):
|
||
arg = body.strip()
|
||
if not arg: return "Использование: /ams-remove <id|host>"
|
||
data = load_data()
|
||
target = next((a for a in data.get("ams_servers", []) if a["id"] == arg or a["host"] == arg), None)
|
||
if not target: return f"❌ '{arg}' не найден"
|
||
if target.get("is_local"): return f"❌ '{arg}' — local (бот сам тут живёт), удалить нельзя"
|
||
|
||
rus = ru_list_data()
|
||
peer_results = []
|
||
for ru in rus:
|
||
out_pr, rc_pr = remove_peer_from_ru(ru, target["pubkey"])
|
||
peer_results.append(f" {ru['id']}: {'✅' if rc_pr == 0 else '❌'} {out_pr.splitlines()[-1] if out_pr else ''}")
|
||
|
||
data["ams_servers"] = [a for a in data["ams_servers"] if a["id"] != target["id"]]
|
||
save_and_distribute(data)
|
||
return (f"✅ '{target['id']}' ({target['host']}) удалён.\n"
|
||
f"Пиры сняты с RU:\n" + "\n".join(peer_results) + "\n\n"
|
||
f"⚠ Сам сервер не выключен. Если он больше не нужен — отключи руками.")
|
||
|
||
# --- routes ---
|
||
def routes_list():
|
||
out, rc, _ = shell("/usr/local/bin/ru-routes.sh list")
|
||
if rc != 0: return f"❌ {out}"
|
||
return "📜 Доп. маршруты:\n" + out if out.strip() != "(пусто)" else "📜 Доп. маршрутов нет."
|
||
|
||
def routes_run_all(verb, nets):
|
||
args = " ".join(shlex.quote(n) for n in nets)
|
||
cmd = f"/usr/local/bin/ru-routes.sh {verb} {args}"
|
||
lines = []
|
||
for a in ams_list_data():
|
||
out, rc = ssh_ams(a, cmd, timeout=20)
|
||
lines.append(f"• {a['id']}: {'✅' if rc == 0 else '❌'} {out}")
|
||
return "\n".join(lines)
|
||
|
||
def cmd_add_routes(body):
|
||
nets = CIDR_RX.findall(body)
|
||
if not nets: return "Не нашёл IP/CIDR"
|
||
return f"➕ Добавляю {len(nets)}:\n" + "\n".join(nets) + "\n\n" + routes_run_all("add", nets)
|
||
|
||
def cmd_remove_routes(body):
|
||
nets = CIDR_RX.findall(body)
|
||
if not nets: return "Не нашёл IP/CIDR"
|
||
return f"➖ Удаляю {len(nets)}:\n" + "\n".join(nets) + "\n\n" + routes_run_all("remove", nets)
|
||
|
||
def cmd_clear_routes():
|
||
lines = ["🧹 Очищаю доп. маршруты:"]
|
||
for a in ams_list_data():
|
||
out, rc = ssh_ams(a, "/usr/local/bin/ru-routes.sh clear")
|
||
lines.append(f"• {a['id']}: {'✅' if rc == 0 else '❌'} {out}")
|
||
return "\n".join(lines)
|
||
|
||
def bot_key():
|
||
out, _, _ = shell("cat /root/.ssh/id_ed25519.pub")
|
||
return ("🔑 Публичный SSH-ключ бота. Добавь в `/root/.ssh/authorized_keys` на новом сервере, чтобы /server-add или /ams-add не требовали пароля:\n\n"
|
||
f"```\nmkdir -p ~/.ssh && echo '{out}' >> ~/.ssh/authorized_keys && chmod 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keys\n```")
|
||
|
||
def domains_list():
|
||
out, rc, _ = shell("/usr/local/bin/ru-domains.py list")
|
||
if rc != 0: return f"❌ {out}"
|
||
return "🌐 Домены:\n" + out if out.strip() != "(пусто)" else "🌐 Доменов нет."
|
||
|
||
def domains_show(arg):
|
||
arg = arg.strip()
|
||
if not arg: return "Использование: /show-domain <domain>"
|
||
out, rc, _ = shell(f"/usr/local/bin/ru-domains.py show {shlex.quote(arg)}")
|
||
return out
|
||
|
||
DOMAIN_RX = re.compile(r"\b[a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)+\b")
|
||
|
||
def parse_domains(body):
|
||
res = []
|
||
for m in DOMAIN_RX.finditer(body):
|
||
d = m.group(0).lower().strip(".")
|
||
# filter pure-IP
|
||
if all(p.isdigit() for p in d.split(".")): continue
|
||
if d not in res: res.append(d)
|
||
return res
|
||
|
||
def domains_run_all(verb, doms):
|
||
args = " ".join(shlex.quote(d) for d in doms)
|
||
cmd = f"/usr/local/bin/ru-domains.py {verb} {args}"
|
||
lines = []
|
||
for a in ams_list_data():
|
||
out, rc = ssh_ams(a, cmd, timeout=30)
|
||
first_line = out.split("\n", 1)[0] if out else ""
|
||
lines.append(f"• {a[chr(39)+'id'+chr(39)] if False else a['id']}: {'✅' if rc == 0 else '❌'} {first_line}")
|
||
# переходим в простую версию ниже
|
||
return "\n".join(lines)
|
||
|
||
def cmd_add_domains(body):
|
||
doms = parse_domains(body)
|
||
if not doms: return "Не нашёл домены. Пример: /add-domain vk.com ozon.ru"
|
||
head = f"➕ Добавляю {len(doms)} доменов параллельно на 4 ам. (~3 сек/домен)..."
|
||
args = " ".join(shlex.quote(d) for d in doms)
|
||
cmd = f"/usr/local/bin/ru-domains.py add {args}"
|
||
timeout = max(60, len(doms) * 5)
|
||
import concurrent.futures as cf
|
||
results = {}
|
||
def run(a): return a["id"], ssh_ams(a, cmd, timeout=timeout)
|
||
with cf.ThreadPoolExecutor(max_workers=8) as ex:
|
||
for fut in cf.as_completed([ex.submit(run, a) for a in ams_list_data()]):
|
||
try:
|
||
aid, (out, rc) = fut.result()
|
||
ok = sum(1 for l in (out or "").splitlines() if l.startswith("✅"))
|
||
bad = sum(1 for l in (out or "").splitlines() if l.startswith("❌"))
|
||
results[aid] = f"{aid}: ✅{ok} ❌{bad}" + (("\n не резолв: " + ", ".join(l.split(":",1)[0].replace("❌","").strip() for l in (out or "").splitlines() if l.startswith("❌"))) if bad else "")
|
||
except Exception as e:
|
||
results[aid] = f"{aid}: ERR {e}"
|
||
return head + "\n\n" + "\n".join(results.values())
|
||
|
||
def cmd_remove_domains(body):
|
||
doms = parse_domains(body)
|
||
if not doms: return "Нет доменов для удаления"
|
||
args = " ".join(shlex.quote(d) for d in doms)
|
||
cmd = f"/usr/local/bin/ru-domains.py remove {args}"
|
||
lines = [f"➖ Удаляю {len(doms)} доменов:"]
|
||
for a in ams_list_data():
|
||
out, rc = ssh_ams(a, cmd, timeout=30)
|
||
lines.append(f"--- {a['id']} ---\n" + (out or "..."))
|
||
return "\n\n".join(lines)
|
||
|
||
def cmd_refresh_domains():
|
||
lines = ["🔄 Refresh доменов:"]
|
||
for a in ams_list_data():
|
||
out, rc = ssh_ams(a, "/usr/local/bin/ru-domains.py refresh", timeout=600)
|
||
lines.append(f"• {a['id']}: {'✅' if rc == 0 else '❌'} {out}")
|
||
return "\n".join(lines)
|
||
|
||
def cmd_all_ips():
|
||
base, _, _ = shell("cat /etc/wireguard/ru-base.aips 2>/dev/null || true")
|
||
extra, _, _ = shell("/usr/local/bin/ru-routes.sh list")
|
||
base_lines = [x.strip() for x in (base or "").split(",") if x.strip()]
|
||
extra_lines = [] if extra.strip() == "(пусто)" else [x.strip() for x in extra.splitlines() if x.strip()]
|
||
msg = "📋 Все маршруты через ru:\n\n"
|
||
msg += f"🔹 Базовые ({len(base_lines)}):\n" + ("\n".join(base_lines) or "(нет)") + "\n\n"
|
||
msg += f"🔸 Доп. ({len(extra_lines)}):\n" + ("\n".join(extra_lines) or "(нет)")
|
||
return msg
|
||
|
||
HELP = (
|
||
"📡 *Туннели:*\n"
|
||
" /status — состояние\n"
|
||
" /use <id> — все ам. на сервер id\n"
|
||
" /primary, /backup — алиасы\n"
|
||
"\n"
|
||
"🌐 *RU-серверы:*\n"
|
||
" /server-list\n"
|
||
" /server-add <host> <user> <port> <id> [prio] [password=PW]\n"
|
||
" /server-remove <id|host>\n"
|
||
"\n"
|
||
"🛰 *Ам. серверы:*\n"
|
||
" /ams-list\n"
|
||
" /ams-add <host> <user> <port> <id> [tunnel_ip=auto] [xray_iface=amn0] [password=PW]\n"
|
||
" /ams-remove <id|host>\n"
|
||
"\n"
|
||
"🛣 *Доп. маршруты:*\n"
|
||
" /ips — все маршруты (база + доп)\n"
|
||
" /list, /add <IP> | /remove <IP> | /clear\n"
|
||
" /list-domains, /add-domain <DOM ...>, /remove-domain <DOM ...>, /refresh-domains\n"
|
||
" /show-domain <DOM> — IP конкретного домена\n"
|
||
" /list, /add <IP ...>, /remove <IP ...>, /clear\n"
|
||
"\n"
|
||
"🔑 /bot-key — SSH-ключ бота\n"
|
||
"❓ /help"
|
||
)
|
||
|
||
def handle(msg):
|
||
chat_id = msg.get("chat", {}).get("id")
|
||
if ALLOWED and str(chat_id) != ALLOWED: return
|
||
text = (msg.get("text") or "").strip()
|
||
if not text: return
|
||
parts = text.split(maxsplit=1)
|
||
cmd = parts[0].lower(); body = parts[1] if len(parts) > 1 else ""
|
||
reply = None
|
||
|
||
if cmd in ("/status","/start","статус"): reply = status()
|
||
elif cmd in ("/primary","/failback"): reply = force_all("primary")
|
||
elif cmd in ("/backup","/failover"): reply = force_all("backup")
|
||
elif cmd == "/use": reply = force_all(body.strip())
|
||
elif cmd in ("/server-list","/servers"): reply = server_list()
|
||
elif cmd == "/server-add": reply = server_add(body)
|
||
elif cmd == "/server-remove": reply = server_remove(body)
|
||
elif cmd in ("/ams-list","/ams"): reply = ams_list()
|
||
elif cmd == "/ams-add": reply = ams_add(body)
|
||
elif cmd == "/ams-remove": reply = ams_remove(body)
|
||
elif cmd == "/bot-key": reply = bot_key()
|
||
elif cmd == "/ips": reply = cmd_all_ips()
|
||
elif cmd == "/list": reply = routes_list()
|
||
elif cmd == "/add": reply = cmd_add_routes(body)
|
||
elif cmd in ("/remove","/del","/rm"): reply = cmd_remove_routes(body)
|
||
elif cmd == "/clear": reply = cmd_clear_routes()
|
||
elif cmd in ("/help","помощь"): reply = HELP
|
||
|
||
if reply is not None:
|
||
for i in range(0, len(reply), 4000):
|
||
params = {"chat_id": chat_id, "text": reply[i:i+4000]}
|
||
if "*" in reply or "```" in reply: params["parse_mode"] = "Markdown"
|
||
tg_post("sendMessage", params)
|
||
|
||
if "password=" in text:
|
||
msg_id = msg.get("message_id")
|
||
if msg_id: tg_post("deleteMessage", {"chat_id": chat_id, "message_id": msg_id})
|
||
|
||
def main():
|
||
offset = 0
|
||
while True:
|
||
try:
|
||
data = tg_get("getUpdates", {"offset": offset, "timeout": 30})
|
||
if not data.get("ok"): time.sleep(5); continue
|
||
for upd in data.get("result", []):
|
||
offset = upd["update_id"] + 1
|
||
msg = upd.get("message")
|
||
if msg: handle(msg)
|
||
except (urlerror.URLError, urlerror.HTTPError, TimeoutError, json.JSONDecodeError):
|
||
time.sleep(5)
|
||
except Exception as e:
|
||
print(f"err: {e}", flush=True); time.sleep(5)
|
||
|
||
if __name__ == "__main__":
|
||
main()
|