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

25
bin/add-ams-helper.sh Executable file
View File

@@ -0,0 +1,25 @@
#!/bin/bash
# add-ams-helper.sh <bot_pubkey_b64>
# Запускается на НОВОМ ам. сервере. Только устанавливает базу — основные файлы scp-ом.
set -e
exec 2>&1
BOT_KEY_B64=$1
DEBIAN_FRONTEND=noninteractive apt-get update -qq >/dev/null 2>&1 || true
DEBIAN_FRONTEND=noninteractive apt-get install -y wireguard iptables-persistent curl python3 >/dev/null 2>&1
mkdir -p /etc/wireguard /usr/local/bin /var/lib/ru-failover
mkdir -p /root/.ssh && chmod 700 /root/.ssh
BOT_KEY=$(echo "$BOT_KEY_B64" | base64 -d)
grep -qF "$BOT_KEY" /root/.ssh/authorized_keys 2>/dev/null || echo "$BOT_KEY" >> /root/.ssh/authorized_keys
chmod 600 /root/.ssh/authorized_keys
sysctl -w net.ipv4.ip_forward=1 >/dev/null
grep -q '^net.ipv4.ip_forward=1' /etc/sysctl.conf || echo 'net.ipv4.ip_forward=1' >> /etc/sysctl.conf
PUBLIC_IP=$(curl -s --max-time 5 ifconfig.me 2>/dev/null || echo unknown)
IFACE=$(ip route | awk '/^default/{print $5; exit}')
echo "----RESULT----"
echo "PUBLIC_IP=$PUBLIC_IP"
echo "IFACE=$IFACE"
echo "----END----"

66
bin/add-ru-helper.sh Executable file
View File

@@ -0,0 +1,66 @@
#!/bin/bash
# add-ru-helper.sh <bot_pubkey_b64> <listen_port> <peer_pk> <peer_ip> ...
set -e
exec 2>&1
BOT_KEY_B64=$1; shift
LISTEN_PORT=$1; shift
declare -a PEERS_PK PEERS_IP
while [ $# -ge 2 ]; do
PEERS_PK+=("$1"); shift
PEERS_IP+=("$1"); shift
done
DEBIAN_FRONTEND=noninteractive apt-get update -qq >/dev/null 2>&1 || true
DEBIAN_FRONTEND=noninteractive apt-get install -y wireguard iptables-persistent curl >/dev/null 2>&1
mkdir -p /etc/wireguard
cd /etc/wireguard
if [ ! -f ru_private.key ]; then
umask 077
wg genkey | tee ru_private.key | wg pubkey > ru_public.key
fi
PRIVKEY=$(cat ru_private.key)
PUBKEY=$(cat ru_public.key)
IFACE=$(ip route | awk '/^default/{print $5; exit}')
PUBLIC_IP=$(curl -s --max-time 5 ifconfig.me 2>/dev/null || echo unknown)
CONF=/etc/wireguard/wg_ru.conf
{
echo "[Interface]"
echo "Address = 10.0.0.1/24"
echo "PrivateKey = $PRIVKEY"
echo "ListenPort = $LISTEN_PORT"
echo "PostUp = iptables -t nat -A POSTROUTING -o $IFACE -j MASQUERADE; iptables -I FORWARD 1 -i wg_ru -j ACCEPT; iptables -I FORWARD 1 -o wg_ru -j ACCEPT; iptables -I INPUT -i wg_ru -j ACCEPT"
echo "PostDown = iptables -t nat -D POSTROUTING -o $IFACE -j MASQUERADE; iptables -D FORWARD -i wg_ru -j ACCEPT; iptables -D FORWARD -o wg_ru -j ACCEPT; iptables -D INPUT -i wg_ru -j ACCEPT"
for i in "${!PEERS_PK[@]}"; do
echo
echo "[Peer]"
echo "PublicKey = ${PEERS_PK[$i]}"
echo "AllowedIPs = ${PEERS_IP[$i]}/32"
done
} > "$CONF"
chmod 600 "$CONF"
sysctl -w net.ipv4.ip_forward=1 >/dev/null
grep -q '^net.ipv4.ip_forward=1' /etc/sysctl.conf || echo 'net.ipv4.ip_forward=1' >> /etc/sysctl.conf
iptables -C INPUT -p udp --dport "$LISTEN_PORT" -j ACCEPT 2>/dev/null \
|| iptables -I INPUT -p udp --dport "$LISTEN_PORT" -j ACCEPT
netfilter-persistent save >/dev/null 2>&1 || iptables-save > /etc/iptables/rules.v4 2>/dev/null
wg-quick down wg_ru 2>/dev/null || true
wg-quick up wg_ru
systemctl enable wg-quick@wg_ru >/dev/null 2>&1
# bot pubkey в root authorized_keys для будущего управления
mkdir -p /root/.ssh && chmod 700 /root/.ssh
BOT_KEY=$(echo "$BOT_KEY_B64" | base64 -d)
grep -qF "$BOT_KEY" /root/.ssh/authorized_keys 2>/dev/null || echo "$BOT_KEY" >> /root/.ssh/authorized_keys
chmod 600 /root/.ssh/authorized_keys
echo "----RESULT----"
echo "PUBKEY=$PUBKEY"
echo "IFACE=$IFACE"
echo "PUBLIC_IP=$PUBLIC_IP"
echo "----END----"

117
bin/ru-domains.py Executable file
View File

@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""Управление доменами для маршрутизации через ru-туннель."""
import json, subprocess, sys
from pathlib import Path
JSON = Path("/etc/wireguard/ru-domains.json")
ROUTES = "/usr/local/bin/ru-routes.sh"
def load():
if not JSON.exists():
JSON.write_text("{}")
JSON.chmod(0o600)
return json.loads(JSON.read_text())
def save(d):
JSON.write_text(json.dumps(d, indent=2, ensure_ascii=False))
JSON.chmod(0o600)
def resolve(dom):
try:
r = subprocess.run(["dig","+short","+time=3","+tries=2","A", dom],
capture_output=True, text=True, timeout=10)
except Exception:
return set()
out = set()
for line in r.stdout.split("\n"):
line = line.strip()
if line and line.count(".") == 3 and line.replace(".","").isdigit():
out.add(line)
return out
def routes_call(verb, ips):
if not ips: return
args = [ROUTES, verb] + [ip + "/32" for ip in ips]
subprocess.run(args, capture_output=True)
def cmd_list():
d = load()
if not d: return "(пусто)"
lines = []
for dom, ips in sorted(d.items()):
lines.append(f"{dom}: {len(ips)} IP")
return "\n".join(lines)
def cmd_show(dom):
d = load()
if dom not in d: return f"{dom}: нет в списке"
return f"{dom}:\n" + "\n".join(d[dom])
def cmd_add(domains):
d = load()
results = []
for dom in domains:
ips = resolve(dom)
if not ips:
results.append(f"{dom}: не резолвится")
continue
old = set(d.get(dom, []))
new = old | ips
d[dom] = sorted(new)
added = ips - old
routes_call("add", sorted(added))
results.append(f"{dom}: {len(ips)} IP (+{len(added)} новых)")
save(d)
return "\n".join(results)
def cmd_remove(domains):
d = load()
results = []
for dom in domains:
if dom not in d:
results.append(f"{dom}: нет в списке")
continue
ips_to_check = set(d[dom])
del d[dom]
still_owned = set()
for v in d.values(): still_owned.update(v)
to_remove = ips_to_check - still_owned
routes_call("remove", sorted(to_remove))
results.append(f"{dom}: убрано {len(to_remove)} IP из extra")
save(d)
return "\n".join(results)
def cmd_refresh():
d = load()
total_add = 0; total_rm = 0; failed = []
for dom in list(d.keys()):
new_ips = resolve(dom)
if not new_ips:
failed.append(dom); continue
old = set(d[dom])
added = new_ips - old
removed = old - new_ips
d[dom] = sorted(new_ips)
# после обновления — пересобрать карту владельцев
owners = set()
for v in d.values(): owners.update(v)
to_rm = sorted(removed - owners)
routes_call("add", sorted(added))
routes_call("remove", to_rm)
total_add += len(added)
total_rm += len(to_rm)
save(d)
msg = f"обновлено: +{total_add} / -{total_rm} IP"
if failed: msg += f"\nне резолвятся: {', '.join(failed)}"
return msg
if __name__ == "__main__":
if len(sys.argv) < 2:
print("usage: ru-domains {list|show DOM|add DOM...|remove DOM...|refresh}"); sys.exit(1)
cmd = sys.argv[1]; args = sys.argv[2:]
if cmd == "list": print(cmd_list())
elif cmd == "show": print(cmd_show(args[0]) if args else "show DOM")
elif cmd == "add": print(cmd_add(args))
elif cmd in ("remove","del"): print(cmd_remove(args))
elif cmd == "refresh": print(cmd_refresh())
else: print(f"unknown: {cmd}"); sys.exit(1)

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()

120
bin/ru-routes.sh Executable file
View File

@@ -0,0 +1,120 @@
#!/bin/bash
# /usr/local/bin/ru-routes.sh — управление extra-маршрутами через ru-туннель
set -u
LIST=/etc/wireguard/ru-extra.list
BASE=/etc/wireguard/ru-base.aips
CONF=/etc/wireguard/ru.conf
XRAY_IF="${XRAY_IF:-amn0}"
MARK=100
touch "$LIST"; chmod 600 "$LIST"
valid_cidr() {
[[ "$1" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}(/[0-9]{1,2})?$ ]] || return 1
IFS=/ read -r ip mask <<< "$1"
IFS=. read -r a b c d <<< "$ip"
for o in $a $b $c $d; do (( o < 0 || o > 255 )) && return 1; done
if [[ -n "${mask:-}" ]]; then (( mask < 0 || mask > 32 )) && return 1; fi
return 0
}
normalize() { local n="$1"; [[ "$n" == */* ]] || n="$n/32"; echo "$n"; }
peer_pk() { awk '/^\[Peer\]/{p=1} p && /^PublicKey *= /{sub(/^PublicKey *= */,""); print; exit}' "$CONF"; }
current_aips() { awk '/^\[Peer\]/{p=1} p && /^AllowedIPs *= /{sub(/^AllowedIPs *= */,""); print; exit}' "$CONF" | tr -d ' '; }
ensure_base() {
if [ ! -f "$BASE" ]; then
current_aips > "$BASE"
chmod 600 "$BASE"
fi
}
sync_wg() {
ensure_base
local pk base extra all
pk=$(peer_pk)
base=$(cat "$BASE")
extra=$(grep -vE '^[[:space:]]*(#|$)' "$LIST" | tr -d ' ' | tr '\n' ',' | sed 's/,$//')
if [ -n "$extra" ]; then all="$base,$extra"; else all="$base"; fi
python3 - "$CONF" "$all" <<'PY'
import sys, re, pathlib
p = pathlib.Path(sys.argv[1])
t = p.read_text()
t = re.sub(r'^AllowedIPs *= .*$', 'AllowedIPs = ' + sys.argv[2], t, count=1, flags=re.M)
p.write_text(t)
PY
wg set ru peer "$pk" allowed-ips "$all" 2>&1
}
apply_route() {
local net="$1"
ip route replace "$net" dev ru 2>/dev/null || true
iptables -t mangle -C PREROUTING -i "$XRAY_IF" -d "$net" -j MARK --set-mark $MARK 2>/dev/null \
|| iptables -t mangle -A PREROUTING -i "$XRAY_IF" -d "$net" -j MARK --set-mark $MARK 2>/dev/null || true
}
remove_route() {
local net="$1"
ip route del "$net" dev ru 2>/dev/null || true
iptables -t mangle -D PREROUTING -i "$XRAY_IF" -d "$net" -j MARK --set-mark $MARK 2>/dev/null || true
}
cmd_list() { if [ -s "$LIST" ]; then cat "$LIST"; else echo "(пусто)"; fi; }
cmd_add() {
local added=0 skipped=0 invalid=()
for raw in "$@"; do
if ! valid_cidr "$raw"; then invalid+=("$raw"); continue; fi
local net; net=$(normalize "$raw")
if grep -qxF "$net" "$LIST"; then ((skipped++)); else echo "$net" >> "$LIST"; ((added++)); fi
apply_route "$net"
done
sync_wg >/dev/null
printf "added=%d skipped=%d invalid=%d" "$added" "$skipped" "${#invalid[@]}"
((${#invalid[@]})) && printf " (%s)" "${invalid[*]}"
echo
}
cmd_remove() {
local removed=0 missing=0
for raw in "$@"; do
valid_cidr "$raw" || continue
local net; net=$(normalize "$raw")
if grep -qxF "$net" "$LIST"; then
grep -vxF "$net" "$LIST" > "$LIST.tmp"; mv "$LIST.tmp" "$LIST"
((removed++))
else ((missing++)); fi
remove_route "$net"
done
sync_wg >/dev/null
echo "removed=$removed missing=$missing"
}
cmd_clear() {
local n=0
while IFS= read -r net; do
[[ -z "$net" || "$net" == \#* ]] && continue
remove_route "$net"; ((n++))
done < "$LIST"
: > "$LIST"
sync_wg >/dev/null
echo "cleared=$n"
}
cmd_apply() {
local n=0
while IFS= read -r net; do
[[ -z "$net" || "$net" == \#* ]] && continue
apply_route "$net"; ((n++))
done < "$LIST"
sync_wg >/dev/null
echo "applied=$n"
}
case "${1:-}" in
list) cmd_list ;;
add) shift; cmd_add "$@" ;;
remove|del) shift; cmd_remove "$@" ;;
clear) cmd_clear ;;
apply|post-up) cmd_apply ;;
*) echo "usage: $0 {list|add NET...|remove NET...|clear|apply}"; exit 1 ;;
esac

25
bin/ru-set.sh Executable file
View File

@@ -0,0 +1,25 @@
#!/bin/bash
set -u
ID="${1:-}"
[ -z "$ID" ] && { echo "usage: $0 <server_id>"; exit 1; }
DATA=$(python3 -c "
import json, sys
try:
data = json.load(open('/etc/wireguard/ru-servers.json'))
except Exception as e:
print('ERR: '+str(e), file=sys.stderr); sys.exit(2)
for s in data['servers']:
if s['id'] == sys.argv[1]:
print(s['pubkey'] + '|' + s['endpoint'] + '|' + s['label']); sys.exit(0)
sys.exit(1)
" "$ID")
[ -z "$DATA" ] && { echo "unknown server: $ID"; exit 1; }
PK="${DATA%%|*}"; rest="${DATA#*|}"; EP="${rest%%|*}"; LABEL="${rest#*|}"
sed -i "s|^PublicKey = .*|PublicKey = $PK|" /etc/wireguard/ru.conf
sed -i "s|^Endpoint = .*|Endpoint = $EP|" /etc/wireguard/ru.conf
wg syncconf ru <(wg-quick strip ru)
/usr/local/bin/ru-routes.sh apply >/dev/null 2>&1 || true
mkdir -p /var/lib/ru-failover
date +%s > /var/lib/ru-failover/last_switch
echo 0 > /var/lib/ru-failover/test_started
echo "OK: ru → $LABEL ($EP)"