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

28
.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
# Sensitive: real tokens, keys, server data
notify.env
webui.env
ru-servers.json
ru-extra.list
ru-base.aips
ru-domains.json
*_private.key
*.key
authorized_keys
# Python
__pycache__/
*.pyc
*.pyo
.pytest_cache/
*.egg-info/
# Editor
.vscode/
.idea/
*.swp
*~
.DS_Store
# Backups
*.bak
*.bak.*

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 andrey271192
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

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

652
bot/ru-tg-bot.py Executable file
View File

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

18
bot/ru-tg-bot.service Normal file
View File

@@ -0,0 +1,18 @@
[Unit]
Description=RU tunnel Telegram bot
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
EnvironmentFile=/etc/wireguard/notify.env
Environment=LOCAL_HOST=ams1
Environment=LOCAL_IP=127.0.0.1
ExecStart=/usr/bin/python3 /usr/local/bin/ru-tg-bot.py
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

106
docs/architecture.md Normal file
View File

@@ -0,0 +1,106 @@
# Архитектура
## Обзор
Каждый ам. сервер — это **WG-клиент**, держит ОДИН активный туннель к одному из RU-серверов. RU-серверы — это **WG-серверы**, у каждого N пиров (по числу ам.).
```
┌─────────────┐
│ RU primary│
│ (priority 1)│
└──┬──┬──┬──┬──┘
│ │ │ │ WG туннели
┌─────┘ │ │ └─────┐
▼ ▼ ▼ ▼
┌──────┐ ┌──────┐ ┌──────┐
│ ams1 │ │ ams2 │ │ ams3 │
│X-ray │ │X-ray │ │X-ray │
└──────┘ └──────┘ └──────┘
▲ ▲ ▲
│ │ │ failover при падении primary
│ ┌────┘ │
│ │ │
┌─▼─────────────▼──┐
│ RU backup │
│ (priority 2) │
└──────────────────┘
```
## Компоненты
### На RU-серверах
- `wg_ru` интерфейс, listen UDP/1939, peers = все ам. серверы (`tunnel_ip``pubkey`)
- `iptables -t nat -A POSTROUTING -o <wan_iface> -j MASQUERADE` — выходящий трафик от ам. серверов наружу
- `iptables -I FORWARD -i wg_ru -j ACCEPT`, `-o wg_ru -j ACCEPT` — пропуск форварда
### На ам. серверах
- `ru` интерфейс, peer = один из RU-серверов (тот что активен сейчас)
- `/etc/wireguard/ru.conf` — текущая конфигурация туннеля
- `/etc/wireguard/ru-servers.json` — общий список всех RU и ам. серверов (синхронизируется ботом/WebUI)
- `/etc/wireguard/ru-base.aips` — базовый набор подсетей в `AllowedIPs` (создаётся при первой установке)
- `/etc/wireguard/ru-extra.list` — дополнительные IP/CIDR, добавленные через бот
- `/etc/wireguard/ru-domains.json` — карта домен → список IP (резолвится через `dig`)
### Скрипты на ам. серверах (`/usr/local/bin/`)
| Скрипт | Что делает |
|---|---|
| `ru-failover.py` | Cron каждую минуту. Проверяет handshake/probe текущего peer'а, переключает на другой при падении. Trial-failback при возврате primary. |
| `ru-set.sh <id>` | Принудительная установка peer'а из JSON по `id` |
| `ru-routes.sh add\|remove\|clear\|list\|apply` | Управление `ru-extra.list` + live `ip route` + iptables mangle + sync allowed-ips через `wg syncconf` |
| `ru-domains.py add\|remove\|list\|show\|refresh` | Резолв доменов и проксирование результатов в `ru-routes.sh` |
## Маршрутизация трафика
```
Запрос с телефона на gosuslugi.ru:
1. X-ray на ам. сервере получает пакет на amn0 интерфейсе
2. iptables -t mangle -A PREROUTING -i amn0 -d 95.163.0.0/16 -j MARK --set-mark 100
3. ip rule fwmark 100 lookup 200
4. table 200: default dev ru
5. WG проверяет AllowedIPs пира (95.163.0.0/16 ∈ allowed) → шифрует
6. Пакет идёт в туннель к RU-серверу
7. На RU: pакет приходит на wg_ru, MASQUERADE на wan, идёт в интернет с RU IP
8. Ответ возвращается обратно через NAT → wg_ru → ам. сервер → телефон
```
Для НЕ-российских сайтов: пакет в mangle не получает MARK 100, идёт по обычному маршруту через провайдера ам. сервера.
## Failover
`ru-failover.py` запускается из cron каждую минуту:
```
1. Прочитать /etc/wireguard/ru-servers.json (отсортированный по priority)
2. Определить current = peer чей endpoint в /etc/wireguard/ru.conf
3. age = now - last_handshake (от wg show)
4. Если current dead (age > 180 && TCP probe не отвечает && cooldown 5min прошёл):
→ переключиться на ближайший живой
5. Если current не highest-priority И есть более приоритетный живой И cooldown && fail_backoff прошли:
→ переключиться на него (mode = failback test)
6. Если в режиме failback test:
- handshake появился за <60s → success
- не появился → откат на запасной + 30min backoff на повторный failback
```
Переключение через `wg syncconf` — без рестарта интерфейса. Маршруты, mangle, расширенные allowed-ips восстанавливаются через `ru-routes.sh apply` (вызывается из PostUp и сразу после switch).
## Синхронизация конфига
`ru-servers.json` — единый источник правды. Хранится на каждом ам. сервере. Изменения вносятся ТОЛЬКО через бот/WebUI на «локальном» ам. сервере (где бот живёт), затем `save_and_distribute()` SCP-ит файл на остальные через root SSH-ключ.
Конфиг WG-серверов (`/etc/wireguard/wg_ru.conf` на RU) бот редактирует напрямую при `/ams-add` / `/ams-remove` — добавляет/удаляет [Peer] секции и делает `wg syncconf wg_ru`.
## SSH между серверами
Бот (на одном из ам. серверов) генерирует ed25519 ключ при первом запуске. Этот ключ:
- автоматически прописывается в `/root/.ssh/authorized_keys` на всех остальных ам. серверах при `/ams-add` (или вручную при онбординге)
- автоматически прописывается на новых RU при `/server-add` (через add-ru-helper.sh)
- для существующих RU — добавляется один раз вручную при первичной настройке
После этого бот работает только по ключу, никаких сохранённых паролей.
## Уведомления
`ru-failover.py` каждый switch/success/fail логирует через `logger -t ru-failover` И шлёт в Telegram если есть `/etc/wireguard/notify.env` с `TG_BOT_TOKEN` и `TG_CHAT_ID`.
С RU-серверов в России TG API часто недоступен, поэтому бот живёт на одном из ам. серверов.

88
docs/bot.md Normal file
View File

@@ -0,0 +1,88 @@
# Telegram-бот
Бот живёт на одном из ам. серверов (там, где есть доступ к api.telegram.org). Управляет всеми остальными по SSH через свой ed25519 ключ.
## Доступ
Бот реагирует ТОЛЬКО на сообщения из чата с `chat_id == TG_CHAT_ID` (см. `/etc/wireguard/notify.env`). Остальные сообщения игнорирует.
## Команды
### Туннели
| Команда | Описание |
|---|---|
| `/status` | Endpoint и handshake age для каждого ам. сервера |
| `/use <id>` | Принудительно переключить все ам. на RU-сервер с этим id |
| `/primary` | Алиас `/use primary` |
| `/backup` | Алиас `/use backup` |
| `/failover` | Алиас `/use backup` |
| `/failback` | Алиас `/use primary` |
### RU-серверы
| Команда | Описание |
|---|---|
| `/server-list` | Список RU-серверов с приоритетами |
| `/server-add <host> <user> <ssh_port> <id> [prio] [password=PW] [label=...] [listen_port=1939] [probe_port=ssh_port]` | Бот SSHит на новый RU, ставит wireguard, генерит ключ, добавляет 4 ам. как peer'ы, открывает 1939/UDP, кладёт свой ключ для будущего управления |
| `/server-remove <id\|host>` | Удалить RU из ротации (сам сервер не выключается, просто перестаёт использоваться) |
| `/bot-key` | Показать публичный SSH-ключ бота — для добавления в `authorized_keys` на новом сервере (если не хочется передавать пароль через TG) |
**Безопасность**: при `password=PW` бот после обработки автоматически удаляет своё сообщение через TG API. Но в моменте оно всё равно попадает в логи TG, поэтому надёжнее — использовать ключевую авторизацию (см. `/bot-key`).
### Ам. серверы
| Команда | Описание |
|---|---|
| `/ams-list` | Список ам. серверов |
| `/ams-add <host> <user> <ssh_port> <id> [tunnel_ip=auto] [xray_iface=amn0] [password=PW]` | Бот: ставит wireguard, кладёт свой ключ, генерит WG-ключ ам., выдаёт следующий свободный 10.0.0.X, копирует все скрипты и шаблон ru.conf со sga1, поднимает туннель на текущий primary, ставит cron, и добавляет [Peer] на ВСЕХ RU-серверах |
| `/ams-remove <id\|host>` | Снять [Peer] со всех RU; обновить JSON. Сам сервер остаётся работать (X-ray, OS), просто перестаёт быть в туннеле |
X-ray на новый ам. сервер бот НЕ ставит — это вне его компетенции.
### Маршруты (IP/CIDR)
| Команда | Описание |
|---|---|
| `/ips` | Все маршруты сразу (база + доп.) |
| `/list` | Только дополнительные (добавленные через бот) |
| `/add <IP/CIDR ...>` | Добавить (через пробел или с новой строки; `/32` дописывается сам) |
| `/remove <IP/CIDR ...>` | Удалить |
| `/clear` | Удалить ВСЕ доп. маршруты |
Базовые подсети (10 крупных сервисов: Яндекс, ВК, Mail.ru, Госуслуги, Ozon) трогать через бот нельзя — они в `/etc/wireguard/ru-base.aips`. Если нужно изменить — править вручную и `/usr/local/bin/ru-routes.sh apply`.
### Домены
| Команда | Описание |
|---|---|
| `/list-domains` | Список доменов с количеством IP |
| `/add-domain <domain ...>` | Резолвит каждый домен через `dig +short A`, добавляет полученные IP как /32 в маршруты на всех ам. серверах. Формат: пробел или с новой строки. |
| `/remove-domain <domain ...>` | Убирает домен и его IP (если их не использует другой домен). |
| `/show-domain <domain>` | Текущие IP конкретного домена |
| `/refresh-domains` | Принудительно перерезолвить все домены |
Cron `17 */6 * * * /usr/local/bin/ru-domains.py refresh` — автоматический refresh каждые 6 часов. Лог: `/var/log/ru-domains.log`.
### Прочее
| Команда | Описание |
|---|---|
| `/bot-key` | Публичный SSH-ключ бота |
| `/help` | Полная справка |
## Уведомления
При каждом автоматическом переключении (failover/failback успех/failback провал) бот шлёт сообщение в `TG_CHAT_ID` с тэгом `[ru-failover @ <hostname>]`. См. `journalctl -t ru-failover` для истории.
## Логи
```bash
# логи бота
journalctl -u ru-tg-bot.service -f
# логи failover
journalctl -t ru-failover -f
# или
grep ru-failover /var/log/syslog | tail
```

216
docs/install.md Normal file
View File

@@ -0,0 +1,216 @@
# Установка
## Требования
- Минимум 1 RU-сервер (Ubuntu 20.04+) с публичным IP или пробросом UDP/1939 на роутере
- Минимум 1 ам. сервер (Ubuntu 20.04+) с установленной X-ray панелью (3x-ui)
- Telegram-бот (создать в @BotFather) и chat ID для уведомлений (узнать у @userinfobot)
- Root доступ на оба сервера
## Шаг 1. Первый RU-сервер
На RU-сервере под root:
```bash
apt update && apt install -y wireguard iptables-persistent
# 1. Генерация ключа
cd /etc/wireguard
umask 077
wg genkey | tee ru_private.key | wg pubkey > ru_public.key
PRIVKEY=$(cat ru_private.key)
# 2. ip_forward
sysctl -w net.ipv4.ip_forward=1
echo 'net.ipv4.ip_forward=1' >> /etc/sysctl.conf
# 3. Конфиг — пока без peer'ов, добавятся когда подключим первый ам.
IFACE=$(ip route | awk '/^default/{print $5; exit}')
cat > /etc/wireguard/wg_ru.conf <<EOF
[Interface]
Address = 10.0.0.1/24
PrivateKey = $PRIVKEY
ListenPort = 1939
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
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
EOF
chmod 600 /etc/wireguard/wg_ru.conf
# 4. Открыть порт 1939/UDP
iptables -A INPUT -p udp --dport 1939 -j ACCEPT
netfilter-persistent save
# 5. Запуск + автостарт
wg-quick up wg_ru
systemctl enable wg-quick@wg_ru
# 6. Сохранить публичный ключ — пригодится
cat ru_public.key
```
Если RU за NAT (Keenetic и т.п.) — пробрось UDP/1939 на роутере на этот сервер.
## Шаг 2. Первый ам. сервер
На ам. сервере под root:
```bash
apt update && apt install -y wireguard iptables-persistent dnsutils
# 1. Ключ
cd /etc/wireguard
umask 077
wg genkey | tee ru_private.key | wg pubkey > ru_public.key
cat ru_public.key # записать — добавим в peer'ы RU
# 2. Узнать имя интерфейса X-ray
ip a | grep -E 'amn|tun' | grep -v '@'
# обычно amn0 — используем дальше
# 3. Конфиг ru.conf — IP в туннеле = 10.0.0.2 (первый ам.)
PRIVKEY=$(cat ru_private.key)
cat > /etc/wireguard/ru.conf <<EOF
[Interface]
Address = 10.0.0.2/32
PrivateKey = $PRIVKEY
Table = off
PostUp = ip route add 95.163.0.0/16 dev ru; ip route add 185.73.192.0/22 dev ru; ip route add 213.59.0.0/16 dev ru; ip route add 77.88.0.0/18 dev ru; ip route add 93.158.128.0/18 dev ru; ip route add 188.40.167.0/24 dev ru; ip route add 176.114.120.0/22 dev ru; ip route add 178.248.232.0/22 dev ru; ip route add 213.180.192.0/20 dev ru; ip route add 87.240.128.0/18 dev ru; ip rule add fwmark 100 table 200; ip route add default dev ru table 200; iptables -t nat -A POSTROUTING -o ru -j MASQUERADE
PostDown = ip rule del fwmark 100 table 200; ip route flush table 200; iptables -t nat -D POSTROUTING -o ru -j MASQUERADE
[Peer]
PublicKey = ПУБЛИЧНЫЙ_КЛЮЧ_RU_СЕРВЕРА
Endpoint = ВНЕШНИЙ_IP_RU:1939
AllowedIPs = 10.0.0.0/24, 95.163.0.0/16, 185.73.192.0/22, 213.59.0.0/16, 77.88.0.0/18, 93.158.128.0/18, 188.40.167.0/24, 176.114.120.0/22, 178.248.232.0/22, 213.180.192.0/20, 87.240.128.0/18
PersistentKeepalive = 25
EOF
chmod 600 /etc/wireguard/ru.conf
# 4. Mangle для X-ray трафика — за каждой подсетью
for net in 95.163.0.0/16 185.73.192.0/22 213.59.0.0/16 77.88.0.0/18 93.158.128.0/18 188.40.167.0/24 176.114.120.0/22 178.248.232.0/22 213.180.192.0/20 87.240.128.0/18; do
iptables -t mangle -A PREROUTING -i amn0 -d $net -j MARK --set-mark 100
done
netfilter-persistent save
# 5. Запуск
wg-quick up ru
systemctl enable wg-quick@ru
wg show ru # должен быть handshake
```
На RU-сервере добавить этот ам. как peer:
```bash
wg set wg_ru peer ПУБЛИЧНЫЙ_КЛЮЧ_АМ allowed-ips 10.0.0.2/32
cat >> /etc/wireguard/wg_ru.conf <<EOF
[Peer]
PublicKey = ПУБЛИЧНЫЙ_КЛЮЧ_АМ
AllowedIPs = 10.0.0.2/32
EOF
```
Проверить что трафик идёт:
```bash
# на ам. сервере
curl --interface ru https://gosuslugi.ru -I
# должен ответить HTTP/... 200
```
## Шаг 3. Установка скриптов и бота
На ам. сервере где будет жить бот (в Амстердаме, Telegram должен быть доступен!):
```bash
git clone https://github.com/andrey271192/kaskad.git /opt/kaskad
cd /opt/kaskad
# 1. Скопировать скрипты
install -m 755 bin/ru-failover.py /usr/local/bin/
install -m 755 bin/ru-set.sh /usr/local/bin/
install -m 755 bin/ru-routes.sh /usr/local/bin/
install -m 755 bin/ru-domains.py /usr/local/bin/
install -m 755 bin/add-ru-helper.sh /usr/local/bin/
install -m 755 bin/add-ams-helper.sh /usr/local/bin/
# 2. Создать notify.env
cp examples/notify.env.example /etc/wireguard/notify.env
chmod 600 /etc/wireguard/notify.env
# ВПИСАТЬ TG_BOT_TOKEN и TG_CHAT_ID
# 3. Создать ru-servers.json
cp examples/ru-servers.example.json /etc/wireguard/ru-servers.json
chmod 600 /etc/wireguard/ru-servers.json
# ОТРЕДАКТИРОВАТЬ — вписать host, pubkey, ssh_user/port для каждого RU; для каждого ам. — host, pubkey, tunnel_ip
# 4. Базовый список allowed-ips (берётся из ru.conf при первом apply)
# создаст ru-base.aips автоматически
/usr/local/bin/ru-routes.sh apply
# 5. Cron на failover и refresh доменов
( crontab -l 2>/dev/null; \
echo '* * * * * /usr/local/bin/ru-failover.py'; \
echo '17 */6 * * * /usr/local/bin/ru-domains.py refresh >> /var/log/ru-domains.log 2>&1' \
) | crontab -
# 6. SSH-ключ бота (нужен для управления остальными серверами через ssh-key auth)
[ -f /root/.ssh/id_ed25519 ] || ssh-keygen -t ed25519 -N '' -f /root/.ssh/id_ed25519
cat /root/.ssh/id_ed25519.pub
# скопировать в /root/.ssh/authorized_keys на всех остальных ам. серверах
# и на всех RU-серверах
# 7. Установить бот
install -m 755 bot/ru-tg-bot.py /usr/local/bin/
install -m 644 bot/ru-tg-bot.service /etc/systemd/system/
apt install -y python3 sshpass
systemctl daemon-reload
systemctl enable --now ru-tg-bot.service
journalctl -u ru-tg-bot.service -f
```
Послать боту в Telegram `/status` — должно прийти текущее состояние.
## Шаг 4. Веб-интерфейс (опционально)
На том же ам. сервере, где бот:
```bash
cd /opt/kaskad
apt install -y python3-flask
install -m 755 webui/app.py /usr/local/bin/ru-webui.py
mkdir -p /usr/local/share/kaskad
cp -r webui/templates webui/static /usr/local/share/kaskad/
# в app.py указать template_folder и static_folder если нужно;
# по умолчанию работает из текущей директории, поэтому проще:
ln -s /usr/local/share/kaskad/templates /usr/local/bin/templates
ln -s /usr/local/share/kaskad/static /usr/local/bin/static
mkdir -p /etc/kaskad
cp webui/webui.env.example /etc/kaskad/webui.env
chmod 600 /etc/kaskad/webui.env
# ВПИСАТЬ KASKAD_WEB_USER и KASKAD_WEB_PASS
install -m 644 webui/ru-webui.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now ru-webui.service
# Открыть порт 8088 (по желанию)
iptables -I INPUT -p tcp --dport 8088 -j ACCEPT
netfilter-persistent save
# Открыть в браузере: http://AMS-IP:8088
```
Рекомендуется поставить за HTTPS reverse-proxy (nginx, caddy, traefik) с Let's Encrypt.
## Шаг 5. Дальше
Через бот или WebUI:
- `/server-add` — добавить новый RU-сервер
- `/ams-add` — добавить новый ам. сервер
- `/add-domain vk.com ozon.ru` — добавить русские сайты по доменам
- `/add 5.45.192.1/32` — добавить конкретные IP/CIDR
Failover-скрипт каждую минуту проверяет здоровье текущего peer'а и переключает при необходимости.
## Если что-то не работает
См. [docs/troubleshooting.md](troubleshooting.md).

104
docs/troubleshooting.md Normal file
View File

@@ -0,0 +1,104 @@
# Troubleshooting
## Туннель не устанавливается (нет handshake)
```bash
# на ам. сервере
wg show ru
# если нет endpoint вообще — что-то с конфигом
# если есть, но нет handshake:
nc -uvz <RU_HOST> 1939 # проверить что 1939/UDP проброшен
journalctl -u wg-quick@ru -n 30
```
На RU:
```bash
iptables -L INPUT -n | grep 1939 # должно быть ACCEPT
ss -ulnp | grep 1939 # WG слушает
wg show wg_ru # должны быть peer'ы
```
Если за NAT (Keenetic) — пробрось 1939/UDP на роутере.
## Handshake есть, curl через туннель не идёт (HTTP 000)
Скорее всего `FORWARD` policy = DROP на RU (часто из-за установленного Docker):
```bash
iptables -L FORWARD -n | head -1
# если DROP — добавить:
iptables -I FORWARD -i wg_ru -j ACCEPT
iptables -I FORWARD -o wg_ru -j ACCEPT
iptables -I INPUT -i wg_ru -j ACCEPT
netfilter-persistent save
```
И прописать в `PostUp` `wg_ru.conf` чтобы пережили рестарт.
Также проверить `ip_forward`:
```bash
sysctl net.ipv4.ip_forward # должно быть = 1
```
## Failover не срабатывает / срабатывает зря
Логи:
```bash
journalctl -t ru-failover -n 50
cat /var/lib/ru-failover/last_switch # timestamp последнего switch
cat /var/lib/ru-failover/test_started # 0 если не в режиме failback test
cat /var/lib/ru-failover/last_fail # timestamp последнего failed failback
```
Health-check использует TCP probe (по `probe_port` из JSON). Если хост жив, но probe_port закрыт — будет ложное срабатывание. Проверь:
```bash
nc -zv <RU_HOST> <probe_port>
```
Если ICMP блокируется на RU — это нормально, мы не используем ping.
## /add-domain ничего не добавляет
```bash
# на ам. сервере
which dig # если нет — apt install dnsutils
dig +short A vk.com # должны быть IP
/usr/local/bin/ru-domains.py add vk.com # ручной тест
```
## Бот не отвечает
```bash
systemctl status ru-tg-bot.service
journalctl -u ru-tg-bot.service -n 30
# проверка доступности TG:
curl -s -m 5 -o /dev/null -w '%{http_code}\n' https://api.telegram.org
# должно быть 302; 000 = заблокирован, бота нужно перенести на сервер вне РФ
```
Проверь, что `TG_CHAT_ID` в `notify.env` совпадает с твоим chat_id (узнать у `@userinfobot` или просто ничего боту не пиши — он молчит для всех кроме разрешённого chat_id).
## WebUI не открывается
```bash
systemctl status ru-webui.service
journalctl -u ru-webui.service -n 30
ss -tlnp | grep 8088
iptables -L INPUT -n | grep 8088 # порт должен быть ACCEPT
```
## Сменился pubkey ам. сервера, но JSON не обновился
Если ты руками регенерил ключи WG на ам. сервере — нужно:
1. Обновить `pubkey` этого ам. в `ru-servers.json` через бот: `/ams-remove <id>` + `/ams-add ...`
2. Или вручную в JSON и `wg syncconf wg_ru` на каждом RU
## Конфиги разъехались между серверами
Бот считает `ru-servers.json` на «локальном» ам. сервере источником правды и синхронит на остальные при каждом изменении. Если ты вручную менял JSON на не-локальном — изменения потеряются.
Принудительная синхронизация: на локальном ам.
```bash
/usr/local/bin/ru-tg-bot.py --resync # (если нет — через любую команду /add /remove /server-add т.п.)
```
Или просто пересохрани JSON через WebUI (любая операция запишет и распространит).

80
docs/webui.md Normal file
View File

@@ -0,0 +1,80 @@
# Web UI
Flask-приложение, бежит рядом с TG-ботом (на одном из ам. серверов). Использует **те же** скрипты и тот же `ru-servers.json`, что и бот.
## Возможности
- **Дашборд** — состояние туннелей всех ам. серверов: куда подключены, возраст handshake
- **Force-переключение** на любой RU-сервер одной кнопкой
- **CRUD RU-серверов** — добавление/удаление с веб-формы (бот сам пробрасывает SSH ключи и поднимает WG)
- **CRUD ам. серверов** — то же
- **CRUD доменов** — добавить/удалить с автоматическим резолвом
- **CRUD IP/CIDR** — добавить/удалить любые подсети
- **Просмотр базовых подсетей** — read-only
- **Фильтры по доменам и IP** — для удобства поиска
## API
Все endpoint'ы под `/api/`, все требуют HTTP basic auth.
| Метод | Путь | Тело | Описание |
|---|---|---|---|
| GET | `/api/state` | — | Полное состояние JSON |
| POST | `/api/use` | `{"id":"primary"}` | Force-переключить все ам. на этот RU |
| POST | `/api/server` | `{host,id,user,ssh_port,priority,...}` | Добавить новый RU |
| DELETE | `/api/server/<id>` | — | Удалить RU |
| POST | `/api/ams` | `{host,id,user,ssh_port,xray_iface,...}` | Добавить новый ам. |
| DELETE | `/api/ams/<id>` | — | Удалить ам. |
| POST | `/api/domains` | `{"domains":["vk.com",...]}` | Добавить домены |
| DELETE | `/api/domains` | `{"domains":[...]}` | Удалить домены |
| POST | `/api/domains/refresh` | — | Перерезолвить |
| POST | `/api/ips` | `{"ips":["1.2.3.4/32",...]}` или `{"ips":"text with IPs"}` | Добавить |
| DELETE | `/api/ips` | то же | Удалить |
| POST | `/api/ips/clear` | — | Очистить все доп. IP |
Пример:
```bash
curl -u admin:PASS https://your-host/api/state | jq
curl -u admin:PASS -X POST https://your-host/api/use -d '{"id":"primary"}' -H 'Content-Type: application/json'
```
## Безопасность
- Basic auth обязателен. `KASKAD_WEB_PASS` должен быть длинным и случайным (см. `webui.env.example`)
- По умолчанию слушает на `0.0.0.0:8088`. **Рекомендуется** поставить за HTTPS reverse-proxy (nginx/caddy) с Let's Encrypt
- Если хочется привязать только к localhost — `KASKAD_HOST=127.0.0.1` и пользоваться через SSH-туннель: `ssh -L 8088:localhost:8088 root@ams1`
- Пароли SSH (`password=` при `/server-add`, `/ams-add`) передаются по HTTPS только если веб за reverse-proxy. Без HTTPS не передавай пароли через WebUI — используй ключи (см. `/bot-key`)
## Конфиг (env-переменные)
| Переменная | Дефолт | Описание |
|---|---|---|
| `KASKAD_WEB_USER` | `admin` | Логин для basic auth |
| `KASKAD_WEB_PASS` | (нет) | Пароль; ОБЯЗАТЕЛЬНО задать |
| `LOCAL_HOST` | `ams1` | Имя локального ам. сервера |
| `LOCAL_IP` | `127.0.0.1` | Локальный IP — определяет, какой ам. читать локально без SSH |
| `KASKAD_HOST` | `0.0.0.0` | Адрес для bind |
| `KASKAD_PORT` | `8088` | Порт |
| `KASKAD_SSH_KEY` | `/root/.ssh/id_ed25519` | SSH-ключ для управления другими серверами |
| `KASKAD_SERVERS_JSON` | `/etc/wireguard/ru-servers.json` | Путь к конфигу |
## nginx reverse-proxy (пример)
```nginx
server {
listen 443 ssl http2;
server_name kaskad.example.com;
ssl_certificate /etc/letsencrypt/live/kaskad.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/kaskad.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8088;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 600s; # для долгих /add-domain пачкой
}
}
```
И в WebUI поставить `KASKAD_HOST=127.0.0.1`, не открывать 8088 наружу.

View File

@@ -0,0 +1,5 @@
# Telegram-токен от @BotFather
TG_BOT_TOKEN=123456789:ABCdef...
# Chat ID, куда слать (узнать у @userinfobot)
TG_CHAT_ID=12345678

View File

@@ -0,0 +1,32 @@
{
"servers": [
{
"id": "primary",
"host": "RU_PRIMARY_HOST_OR_IP",
"endpoint": "RU_PRIMARY_HOST_OR_IP:1939",
"pubkey": "BASE64_WG_PUBKEY_OF_PRIMARY",
"probe_port": 22,
"priority": 1,
"label": "primary.example.ru",
"ssh_user": "root",
"ssh_port": 22,
"wg_iface": "ens18"
},
{
"id": "backup",
"host": "RU_BACKUP_HOST_OR_IP",
"endpoint": "RU_BACKUP_HOST_OR_IP:1939",
"pubkey": "BASE64_WG_PUBKEY_OF_BACKUP",
"probe_port": 22,
"priority": 2,
"label": "backup.example.ru",
"ssh_user": "root",
"ssh_port": 22,
"wg_iface": "enp3s0"
}
],
"ams_servers": [
{"id":"ams1","host":"AMS1_PUBLIC_IP","ssh_port":22,"tunnel_ip":"10.0.0.2","pubkey":"BASE64_WG_PUBKEY_AMS1","xray_iface":"amn0","is_local":true},
{"id":"ams2","host":"AMS2_PUBLIC_IP","ssh_port":22,"tunnel_ip":"10.0.0.3","pubkey":"BASE64_WG_PUBKEY_AMS2","xray_iface":"amn0"}
]
}

536
webui/app.py Normal file
View File

@@ -0,0 +1,536 @@
#!/usr/bin/env python3
"""
Kaskad Web UI - dashboard для управления RU/ам. серверами, доменами и IP.
Шарит логику с TG-ботом: читает /etc/wireguard/ru-servers.json и шеллит
те же скрипты (ru-set.sh, ru-routes.sh, ru-domains.py).
"""
import base64, functools, json, os, re, shlex, socket, subprocess
from pathlib import Path
from flask import Flask, jsonify, render_template, request, Response
SERVERS_JSON = Path(os.environ.get("KASKAD_SERVERS_JSON", "/etc/wireguard/ru-servers.json"))
LOCAL_HOST = os.environ.get("LOCAL_HOST", "ams1")
LOCAL_IP = os.environ.get("LOCAL_IP", "127.0.0.1")
BOT_KEY = os.environ.get("KASKAD_SSH_KEY", "/root/.ssh/id_ed25519")
WEB_USER = os.environ.get("KASKAD_WEB_USER", "admin")
WEB_PASS = os.environ.get("KASKAD_WEB_PASS", "") # обязательно задать в проде!
app = Flask(__name__, template_folder="templates", static_folder="static")
CIDR_RX = re.compile(r"\b(\d{1,3}(?:\.\d{1,3}){3}(?:/\d{1,2})?)\b")
# --- auth ---
def require_auth(fn):
@functools.wraps(fn)
def w(*a, **kw):
if not WEB_PASS:
return Response("KASKAD_WEB_PASS не задан", 500)
auth = request.authorization
if not auth or auth.username != WEB_USER or auth.password != WEB_PASS:
return Response("Auth required", 401, {"WWW-Authenticate": 'Basic realm="kaskad"'})
return fn(*a, **kw)
return w
# --- shell + ssh ---
def shell(cmd, timeout=30, 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"):
if host == LOCAL_IP:
out, rc, _ = shell(cmd, timeout=timeout)
return out, rc
args = ["ssh", "-i", BOT_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)
return r.stdout.strip(), r.returncode
def ssh_pw(host, port, user, password, cmd, timeout=240):
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):
args = ["scp", "-P", str(port), "-o", "StrictHostKeyChecking=no",
"-o", "ConnectTimeout=15", "-i", BOT_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()
# --- config ---
def load_data():
try:
return json.loads(SERVERS_JSON.read_text())
except Exception:
return {"servers": [], "ams_servers": []}
def save_and_distribute(data):
js = json.dumps(data, indent=2, ensure_ascii=False)
SERVERS_JSON.write_text(js)
fail = []
for a in data.get("ams_servers", []):
if a.get("is_local"): continue
proc = subprocess.run(
["ssh", "-i", BOT_KEY, "-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"))
# --- queries ---
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 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
def get_status():
out = []
for a in ams_list_data():
line = {"id": a["id"], "host": a["host"], "tunnel_ip": a["tunnel_ip"]}
o, rc = ssh_ams(a, QUERY_CMD)
if rc != 0:
line["state"] = "unreachable"
else:
try:
ep, age = o.split("|")
line["endpoint"] = ep
line["label"] = label_for(ep)
line["handshake_age"] = int(age) if int(age) < 99999 else None
line["state"] = "ok"
except Exception:
line["state"] = "parse_error"
line["raw"] = o
out.append(line)
return out
# --- API ---
@app.route("/api/state")
@require_auth
def api_state():
data = load_data()
extra_ips = []
out, _, _ = shell("/usr/local/bin/ru-routes.sh list")
if out and out.strip() != "(пусто)":
extra_ips = [l.strip() for l in out.splitlines() if l.strip()]
base_aips, _, _ = shell("cat /etc/wireguard/ru-base.aips 2>/dev/null || true")
base = [x.strip() for x in (base_aips or "").split(",") if x.strip()]
domains = {}
out, rc, _ = shell("/usr/local/bin/ru-domains.py list")
if rc == 0 and out.strip() != "(пусто)":
for line in out.splitlines():
if ":" in line:
k, v = line.split(":", 1)
try: domains[k.strip()] = int(v.strip().split()[0])
except: pass
return jsonify({
"ru_servers": ru_list_data(),
"ams_servers": ams_list_data(),
"status": get_status(),
"extra_ips": extra_ips,
"base_ips": base,
"domains": domains,
})
@app.route("/api/use", methods=["POST"])
@require_auth
def api_use():
sid = (request.json or {}).get("id", "").strip()
if not sid: return jsonify(error="id required"), 400
if not any(s["id"] == sid for s in ru_list_data()):
return jsonify(error=f"unknown server: {sid}"), 404
results = []
for a in ams_list_data():
out, rc = ssh_ams(a, f"/usr/local/bin/ru-set.sh {shlex.quote(sid)}")
results.append({"ams": a["id"], "ok": rc == 0, "msg": out})
return jsonify(results=results)
@app.route("/api/server", methods=["POST"])
@require_auth
def api_server_add():
body = request.json or {}
host = body.get("host"); user = body.get("user", "root"); ssh_port = body.get("ssh_port", 22)
sid = body.get("id"); priority = int(body.get("priority", 99))
label = body.get("label", host); listen_port = int(body.get("listen_port", 1939))
probe_port = int(body.get("probe_port", ssh_port)); password = body.get("password")
if not (host and sid): return jsonify(error="host и id обязательны"), 400
data = load_data()
if any(s["id"] == sid for s in data["servers"]):
return jsonify(error=f"id '{sid}' уже есть"), 409
bot_key_pub, _, _ = shell(f"cat {BOT_KEY}.pub")
bot_key_b64 = base64.b64encode(bot_key_pub.encode()).decode()
helper = "/usr/local/bin/add-ru-helper.sh"
if not Path(helper).exists():
return jsonify(error=f"нет {helper}"), 500
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 jsonify(error=f"scp: {err[:500]}"), 500
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 jsonify(error=f"helper не отработал: {full[-1000:]}"), 500
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 jsonify(error="pubkey не получен"), 500
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 jsonify(error=f"sync: {err}"), 500
return jsonify(server=new, public_ip=res.get("PUBLIC_IP"))
@app.route("/api/server/<sid>", methods=["DELETE"])
@require_auth
def api_server_remove(sid):
data = load_data()
before = len(data["servers"])
data["servers"] = [s for s in data["servers"] if s["id"] != sid and s["host"] != sid]
if len(data["servers"]) == before: return jsonify(error="не найден"), 404
if not data["servers"]: return jsonify(error="последний сервер, отказ"), 400
save_and_distribute(data)
return jsonify(ok=True)
@app.route("/api/ams", methods=["POST"])
@require_auth
def api_ams_add():
body = request.json or {}
host = body.get("host"); user = body.get("user", "root")
ssh_port = int(body.get("ssh_port", 22)); sid = body.get("id")
xray_iface = body.get("xray_iface", "amn0"); password = body.get("password")
requested_tunnel = body.get("tunnel_ip")
if not (host and sid): return jsonify(error="host и id обязательны"), 400
data = load_data()
if any(a["id"] == sid or a["host"] == host for a in data.get("ams_servers", [])):
return jsonify(error=f"id или host уже есть"), 409
used = {a["tunnel_ip"] for a in data.get("ams_servers", [])} | {"10.0.0.1"}
if requested_tunnel:
if requested_tunnel in used: return jsonify(error=f"{requested_tunnel} занят"), 409
tunnel_ip = requested_tunnel
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 jsonify(error="нет свободных tunnel IP"), 500
rus = ru_list_data()
if not rus: return jsonify(error="нет RU-серверов в конфиге"), 500
primary = rus[0]
bot_key_pub, _, _ = shell(f"cat {BOT_KEY}.pub")
bot_key_b64 = base64.b64encode(bot_key_pub.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 jsonify(error=f"scp helper: {err[:500]}"), 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 jsonify(error=f"helper failed: {full[-1000:]}"), 500
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 jsonify(error=f"key gen: {out_pk}"), 500
new_pubkey = out_pk.strip()
files = ["/usr/local/bin/ru-failover.py", "/usr/local/bin/ru-set.sh",
"/usr/local/bin/ru-routes.sh", "/usr/local/bin/ru-domains.py",
"/etc/wireguard/notify.env", str(SERVERS_JSON)]
ok, err = scp_key(host, ssh_port, files, "/tmp/", timeout=30)
if not ok: return jsonify(error=f"scp scripts: {err[:500]}"), 500
sga1_conf, _, _ = shell("cat /etc/wireguard/ru.conf")
sga1_base, _, _ = shell("cat /etc/wireguard/ru-base.aips 2>/dev/null || true")
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)
if xray_iface != "amn0":
new_conf = new_conf.replace("amn0", xray_iface)
proc = subprocess.run(
["ssh", "-i", BOT_KEY, "-p", str(ssh_port),
"-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=10", "-o", "BatchMode=yes",
f"root@{host}", "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 jsonify(error=f"write ru.conf: {proc.stderr.strip()}"), 500
if sga1_base:
subprocess.run(
["ssh", "-i", BOT_KEY, "-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)
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 755 /tmp/ru-domains.py /usr/local/bin/ru-domains.py
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
touch /etc/wireguard/ru-domains.json && chmod 600 /etc/wireguard/ru-domains.json
[ -s /etc/wireguard/ru-domains.json ] || echo '{}' > /etc/wireguard/ru-domains.json
( crontab -l 2>/dev/null | grep -v 'ru-failover\\|ru-domains' ; echo '* * * * * /usr/local/bin/ru-failover.py' ; echo '17 */6 * * * /usr/local/bin/ru-domains.py refresh >> /var/log/ru-domains.log 2>&1' ) | 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 jsonify(error=f"install: {out_inst[-500:]}"), 500
peer_block = f"\n[Peer]\n# {sid}\nPublicKey = {new_pubkey}\nAllowedIPs = {tunnel_ip}/32\n"
peer_results = []
for ru in rus:
cmd = (f"if grep -qF '{new_pubkey}' /etc/wireguard/wg_ru.conf; then echo 'already'; "
f"else printf '%s' {shlex.quote(peer_block)} >> /etc/wireguard/wg_ru.conf; fi; "
f"wg syncconf wg_ru <(wg-quick strip wg_ru) 2>&1")
out_pr, rc_pr = ssh_ru(ru, cmd, timeout=20)
peer_results.append({"ru": ru["id"], "ok": rc_pr == 0, "msg": (out_pr.splitlines()[-1] if out_pr else "")})
data["ams_servers"].append({
"id": sid, "host": host, "ssh_port": int(ssh_port),
"tunnel_ip": tunnel_ip, "pubkey": new_pubkey, "xray_iface": xray_iface,
})
save_and_distribute(data)
return jsonify(ams={"id": sid, "host": host, "tunnel_ip": tunnel_ip, "pubkey": new_pubkey},
peers=peer_results)
@app.route("/api/ams/<sid>", methods=["DELETE"])
@require_auth
def api_ams_remove(sid):
data = load_data()
target = next((a for a in data.get("ams_servers", []) if a["id"] == sid or a["host"] == sid), None)
if not target: return jsonify(error="не найден"), 404
if target.get("is_local"): return jsonify(error="нельзя удалить local"), 400
rus = ru_list_data()
peer_results = []
for ru in rus:
cmd = f"""
python3 - <<'PY'
import pathlib, re
p = pathlib.Path('/etc/wireguard/wg_ru.conf')
t = p.read_text()
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 {target['pubkey']!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
"""
out_pr, rc_pr = ssh_ru(ru, cmd, timeout=20)
peer_results.append({"ru": ru["id"], "ok": rc_pr == 0})
data["ams_servers"] = [a for a in data["ams_servers"] if a["id"] != target["id"]]
save_and_distribute(data)
return jsonify(removed=target["id"], peers=peer_results)
@app.route("/api/domains", methods=["POST"])
@require_auth
def api_domains_add():
domains = (request.json or {}).get("domains", [])
if not domains: return jsonify(error="domains обязателен"), 400
args = " ".join(shlex.quote(d) for d in domains)
cmd = f"/usr/local/bin/ru-domains.py add {args}"
timeout = max(60, len(domains) * 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.append({"ams": aid, "ok": rc == 0, "added": ok, "failed": bad, "raw": out})
except Exception as e:
results.append({"ams": "?", "ok": False, "msg": str(e)})
return jsonify(results=results)
@app.route("/api/domains", methods=["DELETE"])
@require_auth
def api_domains_remove():
domains = (request.json or {}).get("domains", [])
if not domains: return jsonify(error="domains обязателен"), 400
args = " ".join(shlex.quote(d) for d in domains)
cmd = f"/usr/local/bin/ru-domains.py remove {args}"
results = []
for a in ams_list_data():
out, rc = ssh_ams(a, cmd, timeout=30)
results.append({"ams": a["id"], "ok": rc == 0, "msg": out})
return jsonify(results=results)
@app.route("/api/domains/refresh", methods=["POST"])
@require_auth
def api_domains_refresh():
results = []
for a in ams_list_data():
out, rc = ssh_ams(a, "/usr/local/bin/ru-domains.py refresh", timeout=600)
results.append({"ams": a["id"], "ok": rc == 0, "msg": out})
return jsonify(results=results)
@app.route("/api/ips", methods=["POST"])
@require_auth
def api_ips_add():
raw = (request.json or {}).get("ips", [])
if isinstance(raw, str):
ips = CIDR_RX.findall(raw)
else:
ips = []
for x in raw: ips += CIDR_RX.findall(x)
if not ips: return jsonify(error="не нашёл IP/CIDR"), 400
args = " ".join(shlex.quote(i) for i in ips)
cmd = f"/usr/local/bin/ru-routes.sh add {args}"
results = []
for a in ams_list_data():
out, rc = ssh_ams(a, cmd, timeout=20)
results.append({"ams": a["id"], "ok": rc == 0, "msg": out})
return jsonify(parsed=ips, results=results)
@app.route("/api/ips", methods=["DELETE"])
@require_auth
def api_ips_remove():
raw = (request.json or {}).get("ips", [])
if isinstance(raw, str):
ips = CIDR_RX.findall(raw)
else:
ips = []
for x in raw: ips += CIDR_RX.findall(x)
if not ips: return jsonify(error="не нашёл IP/CIDR"), 400
args = " ".join(shlex.quote(i) for i in ips)
cmd = f"/usr/local/bin/ru-routes.sh remove {args}"
results = []
for a in ams_list_data():
out, rc = ssh_ams(a, cmd, timeout=20)
results.append({"ams": a["id"], "ok": rc == 0, "msg": out})
return jsonify(results=results)
@app.route("/api/ips/clear", methods=["POST"])
@require_auth
def api_ips_clear():
results = []
for a in ams_list_data():
out, rc = ssh_ams(a, "/usr/local/bin/ru-routes.sh clear", timeout=20)
results.append({"ams": a["id"], "ok": rc == 0, "msg": out})
return jsonify(results=results)
@app.route("/")
@require_auth
def index():
return render_template("index.html")
if __name__ == "__main__":
app.run(host=os.environ.get("KASKAD_HOST", "0.0.0.0"),
port=int(os.environ.get("KASKAD_PORT", "8088")),
debug=False)

17
webui/ru-webui.service Normal file
View File

@@ -0,0 +1,17 @@
[Unit]
Description=Kaskad Web UI
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
EnvironmentFile=/etc/wireguard/notify.env
EnvironmentFile=/etc/kaskad/webui.env
ExecStart=/usr/bin/python3 /usr/local/bin/ru-webui.py
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

113
webui/static/style.css Normal file
View File

@@ -0,0 +1,113 @@
* { box-sizing: border-box; }
body {
background: #0f1419;
color: #c9d1d9;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
font-size: 14px;
margin: 0;
padding: 0 24px 80px;
max-width: 1200px;
margin-left: auto;
margin-right: auto;
}
header {
position: sticky; top: 0;
background: #0f1419;
display: flex; align-items: center; gap: 16px;
padding: 16px 0;
border-bottom: 1px solid #21262d;
z-index: 10;
}
h1 { margin: 0; font-size: 20px; color: #58a6ff; }
h2 { font-size: 16px; color: #79c0ff; margin: 24px 0 8px; }
section { margin: 24px 0; }
.muted { color: #6e7681; font-size: 12px; }
.mono { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; }
code { background: #161b22; padding: 1px 5px; border-radius: 3px; }
table {
width: 100%;
border-collapse: collapse;
background: #161b22;
border-radius: 6px;
overflow: hidden;
}
th, td { padding: 8px 12px; text-align: left; border-bottom: 1px solid #21262d; }
th { background: #1c2128; color: #8b949e; font-weight: 500; font-size: 12px; text-transform: uppercase; }
tr:last-child td { border: 0; }
tr.bad { background: rgba(248, 81, 73, 0.07); }
tr:hover { background: #1c2128; }
button {
background: #21262d;
color: #c9d1d9;
border: 1px solid #30363d;
padding: 6px 12px;
border-radius: 5px;
font-size: 13px;
cursor: pointer;
}
button:hover:not(:disabled) { background: #30363d; border-color: #58a6ff; color: #58a6ff; }
button:disabled { opacity: 0.4; cursor: not-allowed; }
button.danger { color: #f85149; border-color: #4a1f23; }
button.danger:hover:not(:disabled) { background: #4a1f23; color: #fff; border-color: #f85149; }
button.sm { padding: 2px 8px; font-size: 11px; }
.actions {
display: flex; gap: 8px; align-items: center;
margin: 12px 0;
}
.actions input[type=text] { padding: 6px 10px; }
input, textarea {
background: #0d1117;
color: #c9d1d9;
border: 1px solid #30363d;
padding: 6px 10px;
border-radius: 5px;
font-size: 13px;
font-family: inherit;
}
input:focus, textarea:focus { outline: none; border-color: #58a6ff; }
textarea { width: 100%; font-family: ui-monospace, monospace; resize: vertical; }
details {
margin: 12px 0;
border: 1px dashed #30363d;
border-radius: 5px;
padding: 0 12px;
}
details[open] { padding: 12px; }
summary { cursor: pointer; padding: 8px 0; color: #58a6ff; }
form { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; align-items: end; }
form label { display: flex; flex-direction: column; gap: 3px; font-size: 12px; color: #8b949e; }
form button { grid-column: span 2; justify-self: start; padding: 8px 16px; }
form textarea { grid-column: span 2; }
.ip-list { list-style: none; padding: 0; display: flex; flex-wrap: wrap; gap: 4px; }
.ip-list li {
background: #161b22; border: 1px solid #21262d;
padding: 4px 8px; border-radius: 4px;
font-family: ui-monospace, monospace; font-size: 12px;
display: flex; align-items: center; gap: 6px;
}
.ip-list.compact li { font-size: 11px; padding: 2px 6px; opacity: 0.7; }
.status { color: #8b949e; font-size: 12px; }
.status.err { color: #f85149; }
.toast {
position: fixed; bottom: 24px; right: 24px;
background: #238636; color: #fff;
padding: 10px 18px; border-radius: 6px;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
z-index: 100;
}
footer {
margin-top: 60px; padding: 20px 0;
border-top: 1px solid #21262d;
color: #6e7681; font-size: 12px;
text-align: center;
}
footer a { color: #58a6ff; text-decoration: none; }

278
webui/templates/index.html Normal file
View File

@@ -0,0 +1,278 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kaskad</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<script defer src="https://unpkg.com/alpinejs@3.13.0/dist/cdn.min.js"></script>
</head>
<body x-data="kaskad()" x-init="load()">
<header>
<h1>Каскад</h1>
<button @click="load()" :disabled="loading">⟳ Обновить</button>
<span class="status" x-show="loading">загружаю…</span>
<span class="status err" x-show="error" x-text="error"></span>
</header>
<!-- Status: ам. серверы и куда ходят -->
<section>
<h2>Состояние туннелей</h2>
<table>
<thead><tr><th>Ам. сервер</th><th>Tunnel IP</th><th>Через RU</th><th>Handshake</th><th>State</th></tr></thead>
<tbody>
<template x-for="s in state.status" :key="s.id">
<tr :class="s.state !== 'ok' ? 'bad' : ''">
<td><strong x-text="s.id"></strong> <span class="muted" x-text="s.host"></span></td>
<td x-text="s.tunnel_ip"></td>
<td x-text="s.label || '-'"></td>
<td x-text="s.handshake_age != null ? fmtAge(s.handshake_age) : '—'"></td>
<td x-text="s.state"></td>
</tr>
</template>
</tbody>
</table>
<div class="actions">
Force переключить все на:
<template x-for="ru in state.ru_servers" :key="ru.id">
<button @click="useServer(ru.id)" x-text="ru.id"></button>
</template>
</div>
</section>
<!-- RU-серверы -->
<section>
<h2>RU-серверы (по приоритету)</h2>
<table>
<thead><tr><th>Prio</th><th>ID</th><th>Label</th><th>Endpoint</th><th>Probe</th><th>SSH</th><th></th></tr></thead>
<tbody>
<template x-for="ru in state.ru_servers" :key="ru.id">
<tr>
<td x-text="ru.priority"></td>
<td><strong x-text="ru.id"></strong></td>
<td x-text="ru.label"></td>
<td x-text="ru.endpoint"></td>
<td x-text="'TCP ' + ru.probe_port"></td>
<td class="muted" x-text="(ru.ssh_user||'?') + '@' + ru.host + ':' + (ru.ssh_port||'?')"></td>
<td><button class="danger" @click="removeServer(ru.id)">удалить</button></td>
</tr>
</template>
</tbody>
</table>
<details>
<summary>+ добавить RU-сервер</summary>
<form @submit.prevent="addServer()">
<label>host <input x-model="forms.ru.host" placeholder="1.2.3.4 или domain.ru" required></label>
<label>id <input x-model="forms.ru.id" placeholder="newru" required></label>
<label>SSH user <input x-model="forms.ru.user" value="root"></label>
<label>SSH port <input type="number" x-model.number="forms.ru.ssh_port" value="22"></label>
<label>label <input x-model="forms.ru.label" placeholder="my-ru.example"></label>
<label>priority <input type="number" x-model.number="forms.ru.priority" value="3"></label>
<label>listen_port <input type="number" x-model.number="forms.ru.listen_port" value="1939"></label>
<label>probe_port <input type="number" x-model.number="forms.ru.probe_port" placeholder="по умолч. ssh_port"></label>
<label>SSH password <input type="password" x-model="forms.ru.password" placeholder="оставь пустым если ключ бота уже там"></label>
<button type="submit" :disabled="loading">добавить</button>
</form>
<p class="muted">Если оставишь пароль пустым — на новом RU должен лежать SSH-ключ бота: <code>cat /root/.ssh/id_ed25519.pub</code></p>
</details>
</section>
<!-- Ам. серверы -->
<section>
<h2>Ам. серверы</h2>
<table>
<thead><tr><th>ID</th><th>Host</th><th>SSH</th><th>Tunnel</th><th>Pubkey</th><th></th></tr></thead>
<tbody>
<template x-for="a in state.ams_servers" :key="a.id">
<tr>
<td><strong x-text="a.id"></strong> <span class="muted" x-show="a.is_local">(local)</span></td>
<td x-text="a.host"></td>
<td x-text="a.ssh_port"></td>
<td x-text="a.tunnel_ip"></td>
<td class="muted mono" x-text="a.pubkey.slice(0, 20) + '...'"></td>
<td><button class="danger" @click="removeAms(a.id)" :disabled="a.is_local">удалить</button></td>
</tr>
</template>
</tbody>
</table>
<details>
<summary>+ добавить ам. сервер</summary>
<form @submit.prevent="addAms()">
<label>host <input x-model="forms.ams.host" required></label>
<label>id <input x-model="forms.ams.id" required></label>
<label>SSH user <input x-model="forms.ams.user" value="root"></label>
<label>SSH port <input type="number" x-model.number="forms.ams.ssh_port" value="22"></label>
<label>tunnel_ip <input x-model="forms.ams.tunnel_ip" placeholder="auto = следующий свободный"></label>
<label>xray_iface <input x-model="forms.ams.xray_iface" value="amn0"></label>
<label>SSH password <input type="password" x-model="forms.ams.password"></label>
<button type="submit" :disabled="loading">добавить</button>
</form>
</details>
</section>
<!-- Домены -->
<section>
<h2>Домены (<span x-text="Object.keys(state.domains||{}).length"></span>)</h2>
<div class="actions">
<button @click="refreshDomains()" :disabled="loading">🔄 Refresh DNS</button>
<input type="text" x-model="domainFilter" placeholder="фильтр…" style="flex:1">
</div>
<table>
<thead><tr><th>Домен</th><th>IP</th><th></th></tr></thead>
<tbody>
<template x-for="(cnt, dom) in filteredDomains()" :key="dom">
<tr>
<td x-text="dom"></td>
<td x-text="cnt + ' IP'"></td>
<td><button class="danger" @click="removeDomain(dom)">удалить</button></td>
</tr>
</template>
</tbody>
</table>
<details>
<summary>+ добавить домены</summary>
<form @submit.prevent="addDomains()">
<textarea x-model="forms.domains" rows="6" placeholder="vk.com&#10;ozon.ru&#10;gosuslugi.ru" required></textarea>
<button type="submit" :disabled="loading">добавить</button>
</form>
<p class="muted">Бот резолвит каждый домен и кладёт IP в маршруты на всех ам. серверах. Cron каждые 6ч обновляет резолвы.</p>
</details>
</section>
<!-- IP / CIDR -->
<section>
<h2>Доп. IP/CIDR (<span x-text="(state.extra_ips||[]).length"></span>)</h2>
<div class="actions">
<input type="text" x-model="ipFilter" placeholder="фильтр…" style="flex:1">
<button class="danger" @click="clearIps()" :disabled="loading">🧹 Очистить все</button>
</div>
<ul class="ip-list">
<template x-for="ip in filteredIps()" :key="ip">
<li>
<span x-text="ip"></span>
<button class="danger sm" @click="removeIp(ip)">×</button>
</li>
</template>
</ul>
<details>
<summary>+ добавить IP/CIDR</summary>
<form @submit.prevent="addIps()">
<textarea x-model="forms.ips" rows="5" placeholder="5.45.192.1/32&#10;1.2.3.0/24" required></textarea>
<button type="submit" :disabled="loading">добавить</button>
</form>
</details>
</section>
<!-- Базовые подсети -->
<section>
<h2>Базовые подсети (read-only)</h2>
<p class="muted">Эти подсети живут в <code>/etc/wireguard/ru-base.aips</code>, прописаны при первоначальной установке. Дополнительные адреса/домены идут отдельным списком.</p>
<ul class="ip-list compact">
<template x-for="ip in state.base_ips" :key="ip"><li x-text="ip"></li></template>
</ul>
</section>
<footer>
<span>Kaskad WebUI · <a href="https://github.com/andrey271192/kaskad" target="_blank">GitHub</a></span>
</footer>
<div class="toast" x-show="toast" x-text="toast"></div>
<script>
function kaskad() {
return {
state: {ru_servers:[], ams_servers:[], status:[], extra_ips:[], base_ips:[], domains:{}},
loading: false, error: '', toast: '',
domainFilter: '', ipFilter: '',
forms: {
ru: {user:'root', ssh_port:22, listen_port:1939, priority:3},
ams: {user:'root', ssh_port:22, xray_iface:'amn0'},
domains: '', ips: '',
},
async req(method, url, body) {
this.loading = true; this.error = '';
try {
const r = await fetch(url, {
method, headers: {'Content-Type':'application/json'},
body: body ? JSON.stringify(body) : undefined
});
const j = await r.json().catch(()=>({}));
if (!r.ok) throw new Error(j.error || r.statusText);
return j;
} catch (e) { this.error = e.message; throw e; }
finally { this.loading = false; }
},
flash(msg) { this.toast = msg; setTimeout(()=>this.toast='', 3500); },
async load() { try { this.state = await this.req('GET','/api/state'); } catch{} },
fmtAge(s) {
if (s == null) return '—';
if (s >= 86400) return Math.floor(s/86400)+'д';
if (s >= 3600) return Math.floor(s/3600)+'ч';
if (s >= 60) return Math.floor(s/60)+'м';
return s+'с';
},
filteredDomains() {
const f = this.domainFilter.toLowerCase();
const o = {};
for (const k of Object.keys(this.state.domains||{}).sort())
if (!f || k.includes(f)) o[k] = this.state.domains[k];
return o;
},
filteredIps() {
const f = this.ipFilter.toLowerCase();
return (this.state.extra_ips||[]).filter(ip => !f || ip.includes(f));
},
async useServer(id) {
if (!confirm(`Переключить все ам. на ${id}?`)) return;
await this.req('POST','/api/use',{id}); this.flash('переключено'); this.load();
},
async addServer() {
await this.req('POST','/api/server', this.forms.ru);
this.flash('RU добавлен'); this.forms.ru = {user:'root', ssh_port:22, listen_port:1939, priority:3};
this.load();
},
async removeServer(id) {
if (!confirm(`Удалить RU '${id}'?`)) return;
await this.req('DELETE',`/api/server/${id}`); this.flash('удалено'); this.load();
},
async addAms() {
await this.req('POST','/api/ams', this.forms.ams);
this.flash('Ам. добавлен'); this.forms.ams = {user:'root', ssh_port:22, xray_iface:'amn0'};
this.load();
},
async removeAms(id) {
if (!confirm(`Удалить ам. '${id}'? Snimet peer на всех RU.`)) return;
await this.req('DELETE',`/api/ams/${id}`); this.flash('удалено'); this.load();
},
async addDomains() {
const doms = this.forms.domains.split(/[\s,]+/).map(s=>s.trim()).filter(Boolean);
await this.req('POST','/api/domains',{domains:doms});
this.flash(`добавлено ${doms.length}`); this.forms.domains=''; this.load();
},
async removeDomain(dom) {
if (!confirm(`Убрать ${dom}?`)) return;
await this.req('DELETE','/api/domains',{domains:[dom]}); this.flash('убран'); this.load();
},
async refreshDomains() {
await this.req('POST','/api/domains/refresh',{}); this.flash('refresh запущен'); this.load();
},
async addIps() {
await this.req('POST','/api/ips',{ips: this.forms.ips});
this.flash('добавлено'); this.forms.ips=''; this.load();
},
async removeIp(ip) {
await this.req('DELETE','/api/ips',{ips:[ip]}); this.flash('убран'); this.load();
},
async clearIps() {
if (!confirm('Очистить ВСЕ доп. IP? (домены тоже потеряют свои IP до следующего refresh)')) return;
await this.req('POST','/api/ips/clear',{}); this.flash('очищено'); this.load();
},
};
}
</script>
</body>
</html>

17
webui/webui.env.example Normal file
View File

@@ -0,0 +1,17 @@
# Креды для входа в веб-интерфейс
KASKAD_WEB_USER=admin
KASKAD_WEB_PASS=ОБЯЗАТЕЛЬНО_СМЕНИТЬ
# Локальный ам. сервер (где живёт WebUI)
LOCAL_HOST=ams1
LOCAL_IP=127.0.0.1
# Порт и интерфейс веб-интерфейса
KASKAD_HOST=0.0.0.0
KASKAD_PORT=8088
# SSH-ключ для доступа к остальным серверам
KASKAD_SSH_KEY=/root/.ssh/id_ed25519
# Путь к JSON конфигу
KASKAD_SERVERS_JSON=/etc/wireguard/ru-servers.json