diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..57fc68f --- /dev/null +++ b/.gitignore @@ -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.* diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c9b26b0 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/bin/add-ams-helper.sh b/bin/add-ams-helper.sh new file mode 100755 index 0000000..5ce00d3 --- /dev/null +++ b/bin/add-ams-helper.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# add-ams-helper.sh +# Запускается на НОВОМ ам. сервере. Только устанавливает базу — основные файлы 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----" diff --git a/bin/add-ru-helper.sh b/bin/add-ru-helper.sh new file mode 100755 index 0000000..0c11ee3 --- /dev/null +++ b/bin/add-ru-helper.sh @@ -0,0 +1,66 @@ +#!/bin/bash +# add-ru-helper.sh ... +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----" diff --git a/bin/ru-domains.py b/bin/ru-domains.py new file mode 100755 index 0000000..7d122ca --- /dev/null +++ b/bin/ru-domains.py @@ -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) diff --git a/bin/ru-failover.py b/bin/ru-failover.py new file mode 100755 index 0000000..1c9d39d --- /dev/null +++ b/bin/ru-failover.py @@ -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() diff --git a/bin/ru-routes.sh b/bin/ru-routes.sh new file mode 100755 index 0000000..cce5449 --- /dev/null +++ b/bin/ru-routes.sh @@ -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 diff --git a/bin/ru-set.sh b/bin/ru-set.sh new file mode 100755 index 0000000..cf626bc --- /dev/null +++ b/bin/ru-set.sh @@ -0,0 +1,25 @@ +#!/bin/bash +set -u +ID="${1:-}" +[ -z "$ID" ] && { echo "usage: $0 "; 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)" diff --git a/bot/ru-tg-bot.py b/bot/ru-tg-bot.py new file mode 100755 index 0000000..31ff59f --- /dev/null +++ b/bot/ru-tg-bot.py @@ -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 [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 " + 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 [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 " + 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 " + 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\n" + " /primary, /backup — алиасы\n" + "\n" + "🌐 *RU-серверы:*\n" + " /server-list\n" + " /server-add [prio] [password=PW]\n" + " /server-remove \n" + "\n" + "🛰 *Ам. серверы:*\n" + " /ams-list\n" + " /ams-add [tunnel_ip=auto] [xray_iface=amn0] [password=PW]\n" + " /ams-remove \n" + "\n" + "🛣 *Доп. маршруты:*\n" + " /ips — все маршруты (база + доп)\n" + " /list, /add | /remove | /clear\n" + " /list-domains, /add-domain , /remove-domain , /refresh-domains\n" + " /show-domain — IP конкретного домена\n" + " /list, /add , /remove , /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() diff --git a/bot/ru-tg-bot.service b/bot/ru-tg-bot.service new file mode 100644 index 0000000..77d03c1 --- /dev/null +++ b/bot/ru-tg-bot.service @@ -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 diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..2138d7f --- /dev/null +++ b/docs/architecture.md @@ -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 -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 ` | Принудительная установка 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 часто недоступен, поэтому бот живёт на одном из ам. серверов. diff --git a/docs/bot.md b/docs/bot.md new file mode 100644 index 0000000..6aebb0d --- /dev/null +++ b/docs/bot.md @@ -0,0 +1,88 @@ +# Telegram-бот + +Бот живёт на одном из ам. серверов (там, где есть доступ к api.telegram.org). Управляет всеми остальными по SSH через свой ed25519 ключ. + +## Доступ + +Бот реагирует ТОЛЬКО на сообщения из чата с `chat_id == TG_CHAT_ID` (см. `/etc/wireguard/notify.env`). Остальные сообщения игнорирует. + +## Команды + +### Туннели + +| Команда | Описание | +|---|---| +| `/status` | Endpoint и handshake age для каждого ам. сервера | +| `/use ` | Принудительно переключить все ам. на RU-сервер с этим id | +| `/primary` | Алиас `/use primary` | +| `/backup` | Алиас `/use backup` | +| `/failover` | Алиас `/use backup` | +| `/failback` | Алиас `/use primary` | + +### RU-серверы + +| Команда | Описание | +|---|---| +| `/server-list` | Список RU-серверов с приоритетами | +| `/server-add [prio] [password=PW] [label=...] [listen_port=1939] [probe_port=ssh_port]` | Бот SSHит на новый RU, ставит wireguard, генерит ключ, добавляет 4 ам. как peer'ы, открывает 1939/UDP, кладёт свой ключ для будущего управления | +| `/server-remove ` | Удалить RU из ротации (сам сервер не выключается, просто перестаёт использоваться) | +| `/bot-key` | Показать публичный SSH-ключ бота — для добавления в `authorized_keys` на новом сервере (если не хочется передавать пароль через TG) | + +**Безопасность**: при `password=PW` бот после обработки автоматически удаляет своё сообщение через TG API. Но в моменте оно всё равно попадает в логи TG, поэтому надёжнее — использовать ключевую авторизацию (см. `/bot-key`). + +### Ам. серверы + +| Команда | Описание | +|---|---| +| `/ams-list` | Список ам. серверов | +| `/ams-add [tunnel_ip=auto] [xray_iface=amn0] [password=PW]` | Бот: ставит wireguard, кладёт свой ключ, генерит WG-ключ ам., выдаёт следующий свободный 10.0.0.X, копирует все скрипты и шаблон ru.conf со sga1, поднимает туннель на текущий primary, ставит cron, и добавляет [Peer] на ВСЕХ RU-серверах | +| `/ams-remove ` | Снять [Peer] со всех RU; обновить JSON. Сам сервер остаётся работать (X-ray, OS), просто перестаёт быть в туннеле | + +X-ray на новый ам. сервер бот НЕ ставит — это вне его компетенции. + +### Маршруты (IP/CIDR) + +| Команда | Описание | +|---|---| +| `/ips` | Все маршруты сразу (база + доп.) | +| `/list` | Только дополнительные (добавленные через бот) | +| `/add ` | Добавить (через пробел или с новой строки; `/32` дописывается сам) | +| `/remove ` | Удалить | +| `/clear` | Удалить ВСЕ доп. маршруты | + +Базовые подсети (10 крупных сервисов: Яндекс, ВК, Mail.ru, Госуслуги, Ozon) трогать через бот нельзя — они в `/etc/wireguard/ru-base.aips`. Если нужно изменить — править вручную и `/usr/local/bin/ru-routes.sh apply`. + +### Домены + +| Команда | Описание | +|---|---| +| `/list-domains` | Список доменов с количеством IP | +| `/add-domain ` | Резолвит каждый домен через `dig +short A`, добавляет полученные IP как /32 в маршруты на всех ам. серверах. Формат: пробел или с новой строки. | +| `/remove-domain ` | Убирает домен и его IP (если их не использует другой домен). | +| `/show-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 @ ]`. См. `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 +``` diff --git a/docs/install.md b/docs/install.md new file mode 100644 index 0000000..719ff9e --- /dev/null +++ b/docs/install.md @@ -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 < 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 <> /etc/wireguard/wg_ru.conf </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). diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..4f8eebe --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,104 @@ +# Troubleshooting + +## Туннель не устанавливается (нет handshake) + +```bash +# на ам. сервере +wg show ru +# если нет endpoint вообще — что-то с конфигом +# если есть, но нет handshake: +nc -uvz 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 +``` + +Если 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 ` + `/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 (любая операция запишет и распространит). diff --git a/docs/webui.md b/docs/webui.md new file mode 100644 index 0000000..8163675 --- /dev/null +++ b/docs/webui.md @@ -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/` | — | Удалить RU | +| POST | `/api/ams` | `{host,id,user,ssh_port,xray_iface,...}` | Добавить новый ам. | +| DELETE | `/api/ams/` | — | Удалить ам. | +| 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 наружу. diff --git a/examples/notify.env.example b/examples/notify.env.example new file mode 100644 index 0000000..bce722b --- /dev/null +++ b/examples/notify.env.example @@ -0,0 +1,5 @@ +# Telegram-токен от @BotFather +TG_BOT_TOKEN=123456789:ABCdef... + +# Chat ID, куда слать (узнать у @userinfobot) +TG_CHAT_ID=12345678 diff --git a/examples/ru-servers.example.json b/examples/ru-servers.example.json new file mode 100644 index 0000000..685449b --- /dev/null +++ b/examples/ru-servers.example.json @@ -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"} + ] +} diff --git a/webui/app.py b/webui/app.py new file mode 100644 index 0000000..ee3363f --- /dev/null +++ b/webui/app.py @@ -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/", 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/", 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) diff --git a/webui/ru-webui.service b/webui/ru-webui.service new file mode 100644 index 0000000..0df5773 --- /dev/null +++ b/webui/ru-webui.service @@ -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 diff --git a/webui/static/style.css b/webui/static/style.css new file mode 100644 index 0000000..8b826bf --- /dev/null +++ b/webui/static/style.css @@ -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; } diff --git a/webui/templates/index.html b/webui/templates/index.html new file mode 100644 index 0000000..a02e033 --- /dev/null +++ b/webui/templates/index.html @@ -0,0 +1,278 @@ + + + + + + Kaskad + + + + +
+

Каскад

+ + загружаю… + +
+ + +
+

Состояние туннелей

+ + + + + +
Ам. серверTunnel IPЧерез RUHandshakeState
+
+ Force переключить все на: + +
+
+ + +
+

RU-серверы (по приоритету)

+ + + + + +
PrioIDLabelEndpointProbeSSH
+
+ + добавить RU-сервер +
+ + + + + + + + + + +
+

Если оставишь пароль пустым — на новом RU должен лежать SSH-ключ бота: cat /root/.ssh/id_ed25519.pub

+
+
+ + +
+

Ам. серверы

+ + + + + +
IDHostSSHTunnelPubkey
+
+ + добавить ам. сервер +
+ + + + + + + + +
+
+
+ + +
+

Домены ()

+
+ + +
+ + + + + +
ДоменIP
+
+ + добавить домены +
+ + +
+

Бот резолвит каждый домен и кладёт IP в маршруты на всех ам. серверах. Cron каждые 6ч обновляет резолвы.

+
+
+ + +
+

Доп. IP/CIDR ()

+
+ + +
+
    + +
+
+ + добавить IP/CIDR +
+ + +
+
+
+ + +
+

Базовые подсети (read-only)

+

Эти подсети живут в /etc/wireguard/ru-base.aips, прописаны при первоначальной установке. Дополнительные адреса/домены идут отдельным списком.

+
    + +
+
+ + + +
+ + + + diff --git a/webui/webui.env.example b/webui/webui.env.example new file mode 100644 index 0000000..beec122 --- /dev/null +++ b/webui/webui.env.example @@ -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