mirror of
https://github.com/andrey271192/kaskad.git
synced 2026-09-20 13:49:56 +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/ — шаблоны конфигов
139 lines
5.2 KiB
Python
Executable File
139 lines
5.2 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""N-серверный failover с приоритетами. Конфиг — /etc/wireguard/ru-servers.json."""
|
||
import json, os, re, socket, subprocess, time
|
||
from pathlib import Path
|
||
from urllib import parse, request, error as urlerror
|
||
|
||
CONF = Path("/etc/wireguard/ru.conf")
|
||
SERVERS = Path("/etc/wireguard/ru-servers.json")
|
||
STATE_DIR = Path("/var/lib/ru-failover")
|
||
NOTIFY_ENV = Path("/etc/wireguard/notify.env")
|
||
|
||
HS_THRESHOLD = 180
|
||
COOLDOWN = 300
|
||
FAIL_BACKOFF = 1800
|
||
TEST_TIMEOUT = 60
|
||
|
||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||
|
||
def state(name, val=None):
|
||
p = STATE_DIR / name
|
||
if val is None:
|
||
try: return int((p.read_text().strip() or "0"))
|
||
except Exception: return 0
|
||
p.write_text(str(val))
|
||
|
||
def load_servers():
|
||
return sorted(json.loads(SERVERS.read_text())["servers"], key=lambda s: s["priority"])
|
||
|
||
def cur_endpoint():
|
||
for line in CONF.read_text().splitlines():
|
||
if line.lstrip().startswith("Endpoint"):
|
||
return line.split("=", 1)[1].strip()
|
||
return ""
|
||
|
||
def hs_age(now):
|
||
try:
|
||
out = subprocess.run(["wg","show","ru","latest-handshakes"],
|
||
capture_output=True, text=True, timeout=5).stdout.strip()
|
||
if not out: return 999999
|
||
hs = int(out.split()[1])
|
||
return 999999 if hs == 0 else now - hs
|
||
except Exception:
|
||
return 999999
|
||
|
||
def probe(host, port, timeout=3):
|
||
try:
|
||
with socket.create_connection((host, int(port)), timeout=timeout):
|
||
return True
|
||
except (socket.error, OSError, ValueError):
|
||
return False
|
||
|
||
def apply_server(s):
|
||
t = CONF.read_text()
|
||
t = re.sub(r'(?m)^[ \t]*PublicKey *=.*$', f'PublicKey = {s["pubkey"]}', t, count=1)
|
||
t = re.sub(r'(?m)^[ \t]*Endpoint *=.*$', f'Endpoint = {s["endpoint"]}', t, count=1)
|
||
CONF.write_text(t)
|
||
subprocess.run(["bash","-c","wg syncconf ru <(wg-quick strip ru)"], check=False)
|
||
subprocess.run(["/usr/local/bin/ru-routes.sh","apply"], capture_output=True)
|
||
state("last_switch", int(time.time()))
|
||
|
||
def find_by_endpoint(servers, ep):
|
||
for s in servers:
|
||
if s["endpoint"] == ep: return s
|
||
return None
|
||
|
||
def notify(msg):
|
||
subprocess.run(["logger","-t","ru-failover", msg], check=False)
|
||
if not NOTIFY_ENV.exists(): return
|
||
env = {}
|
||
for line in NOTIFY_ENV.read_text().splitlines():
|
||
if "=" in line:
|
||
k, v = line.split("=", 1)
|
||
env[k.strip()] = v.strip().strip('"').strip("'")
|
||
token = env.get("TG_BOT_TOKEN", "")
|
||
chat = env.get("TG_CHAT_ID", "")
|
||
if not (token and chat): return
|
||
try:
|
||
host = subprocess.check_output(["hostname"], text=True).strip()
|
||
body = parse.urlencode({"chat_id": chat, "text": f"[ru-failover @ {host}] {msg}"}).encode()
|
||
request.urlopen(f"https://api.telegram.org/bot{token}/sendMessage", data=body, timeout=5).read()
|
||
except (urlerror.URLError, OSError):
|
||
pass
|
||
|
||
def main():
|
||
servers = load_servers()
|
||
if not servers: return
|
||
now = int(time.time())
|
||
cur = find_by_endpoint(servers, cur_endpoint())
|
||
if not cur:
|
||
# Текущий endpoint не в списке — выставить highest-priority alive
|
||
for s in servers:
|
||
if probe(s["host"], s["probe_port"]):
|
||
apply_server(s)
|
||
notify(f"endpoint {cur_endpoint()} не найден в списке — выставил {s['label']}")
|
||
return
|
||
return
|
||
|
||
age = hs_age(now)
|
||
last_switch = state("last_switch")
|
||
test_started = state("test_started")
|
||
last_fail = state("last_fail")
|
||
|
||
# Phase 1: failback test
|
||
if test_started:
|
||
elapsed = now - test_started
|
||
if age < 60:
|
||
state("test_started", 0); state("last_fail", 0)
|
||
notify(f"failback на {cur['label']} успешен за {elapsed}s")
|
||
elif elapsed > TEST_TIMEOUT:
|
||
others = [s for s in servers if s["id"] != cur["id"] and probe(s["host"], s["probe_port"])]
|
||
if others:
|
||
apply_server(others[0])
|
||
notify(f"failback на {cur['label']} провалился — откат на {others[0]['label']}")
|
||
state("test_started", 0); state("last_fail", now)
|
||
return
|
||
|
||
since_switch = now - last_switch
|
||
since_fail = now - last_fail
|
||
|
||
# Failover: текущий мёртв
|
||
if age > HS_THRESHOLD and not probe(cur["host"], cur["probe_port"]) and since_switch > COOLDOWN:
|
||
others = [s for s in servers if s["id"] != cur["id"] and probe(s["host"], s["probe_port"])]
|
||
if others:
|
||
t = others[0]
|
||
apply_server(t)
|
||
notify(f"{cur['label']} упал (hs {age}s, probe fail) — переключился на {t['label']}")
|
||
return
|
||
|
||
# Failback: есть live сервер с приоритетом выше
|
||
higher = [s for s in servers if s["priority"] < cur["priority"] and probe(s["host"], s["probe_port"])]
|
||
if higher and since_switch > COOLDOWN and since_fail > FAIL_BACKOFF:
|
||
t = higher[0]
|
||
apply_server(t)
|
||
state("test_started", now)
|
||
notify(f"{t['label']} поднялся — пробую failback (тест {TEST_TIMEOUT}s)")
|
||
|
||
if __name__ == "__main__":
|
||
main()
|