Initial release of Kaskad

Каскадная маршрутизация русских сайтов через свой набор 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/   — шаблоны конфигов
This commit is contained in:
andrey271192
2026-05-02 09:13:10 +03:00
parent 7a2a41510c
commit 209caa81a8
22 changed files with 2802 additions and 0 deletions

138
bin/ru-failover.py Executable file
View File

@@ -0,0 +1,138 @@
#!/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()