mirror of
https://github.com/andrey271192/kaskad.git
synced 2026-09-20 13:49:56 +00:00
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:
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal 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
21
LICENSE
Normal 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
25
bin/add-ams-helper.sh
Executable 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
66
bin/add-ru-helper.sh
Executable 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
117
bin/ru-domains.py
Executable 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
138
bin/ru-failover.py
Executable 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
120
bin/ru-routes.sh
Executable 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
25
bin/ru-set.sh
Executable 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
652
bot/ru-tg-bot.py
Executable 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
18
bot/ru-tg-bot.service
Normal 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
106
docs/architecture.md
Normal 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
88
docs/bot.md
Normal 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
216
docs/install.md
Normal 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
104
docs/troubleshooting.md
Normal 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
80
docs/webui.md
Normal 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 наружу.
|
||||
5
examples/notify.env.example
Normal file
5
examples/notify.env.example
Normal file
@@ -0,0 +1,5 @@
|
||||
# Telegram-токен от @BotFather
|
||||
TG_BOT_TOKEN=123456789:ABCdef...
|
||||
|
||||
# Chat ID, куда слать (узнать у @userinfobot)
|
||||
TG_CHAT_ID=12345678
|
||||
32
examples/ru-servers.example.json
Normal file
32
examples/ru-servers.example.json
Normal 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
536
webui/app.py
Normal 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
17
webui/ru-webui.service
Normal 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
113
webui/static/style.css
Normal 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
278
webui/templates/index.html
Normal 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 ozon.ru 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 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
17
webui/webui.env.example
Normal 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
|
||||
Reference in New Issue
Block a user