Files
awg-gus-bot/awg_gus/bot.py
T

486 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""A native GusVoice UI for an already installed AWG Toolza server."""
from __future__ import annotations
import asyncio
import contextlib
from dataclasses import dataclass
import logging
from pathlib import Path
import re
import secrets
import shutil
import time
from gusvoice_bot import GusVoiceApiError, InputFile
from . import __version__
from .core import AwgError, EXPIRE, LIMIT, NAME, SPEED, esc, redacted, size
log = logging.getLogger("awg-gus")
PAGE = 10
FILE_MAX = 25 * 1024 * 1024
def button(text, data):
if len(data.encode()) > 64:
raise ValueError("callback_data too long")
return {"text": text, "callback_data": data}
HOME = [button("🏠 Меню", "home")]
def client_status(c):
if c.get("blocked"):
return "🚫 заблокирован"
if c.get("online"):
return "🟢 онлайн"
return "⚪ офлайн" if c.get("handshake") else "⚪ ещё не подключался"
@dataclass
class Event:
uid: str
chat: str
update_id: int
message: str | None = None
text: str = ""
data: str = ""
class Bot:
def __init__(self, config, api, delivery, state):
self.config, self.api, self.io, self.state = config, api, delivery, state
self.owners = set(config["owners"])
self.steps = {}
self.confirmations = {}
self.tasks = {}
self.interrupted = {row[0] for row in state.db.execute("SELECT uid FROM pending")}
with state.db:
state.db.execute("DELETE FROM pending")
self.observed = {}
async def screen(self, e, text, rows=None):
out = await self.io.show(e.chat, text, rows if rows is not None else [HOME], e.message)
if isinstance(out, dict):
e.message = out.get("message_id")
return out
async def clients(self):
data = (await self.api.call("clients", "list")).get("data")
if not isinstance(data, list):
raise AwgError("Неверный ответ clients list")
return [c for c in data if isinstance(c, dict) and isinstance(c.get("name"), str) and NAME.fullmatch(c["name"])]
async def client(self, name):
if not NAME.fullmatch(name):
raise ValueError("Неверное имя клиента")
c = next((c for c in await self.clients() if c["name"] == name), None)
if c is None:
raise ValueError("Клиент уже удалён. Обновите список.")
return c
async def process(self, update):
n = update.get("update_id")
if not isinstance(n, int) or isinstance(n, bool) or n < self.state.get("offset", 0):
return
cb = update.get("callback_query")
msg = cb.get("message") if isinstance(cb, dict) else update.get("message")
author = cb.get("from") if isinstance(cb, dict) else (msg or {}).get("from")
msg, author = msg or {}, author or {}
chat = msg.get("chat") or {}
uid, cid = author.get("id"), chat.get("id")
allowed = (chat.get("type") == "dm" and uid in self.owners and isinstance(cid, str))
if not allowed:
self.state.claim(n)
if isinstance(cb, dict):
await self.io.ack(cb["id"], "Доступ только владельцу в личных сообщениях")
elif chat.get("type") == "dm" and (msg.get("text") or "").strip() == "/whoami":
await self.io.show(cid, f"Ваш ID: <code>{esc(uid)}</code>\nДоступ к управлению не выдан.")
return
key = (uid, cid)
busy = key in self.tasks and not self.tasks[key].done()
# Persist deduplication before ack or starting a privileged operation.
self.state.claim(n, uid, cid)
if isinstance(cb, dict):
try:
await self.io.ack(cb["id"], "Предыдущая операция ещё выполняется" if busy else None)
except Exception:
self.state.finish(n)
raise
if busy:
self.state.finish(n)
if not cb:
await self.io.show(cid, "⏳ Предыдущая операция ещё выполняется. Дождитесь результата.")
return
e = Event(uid, cid, n, msg.get("message_id") if cb else None,
msg.get("text") or "", cb.get("data") or "" if cb else "")
self.tasks[key] = asyncio.create_task(self._handle(e), name="awg-gus-action")
async def _handle(self, e):
completed = True
try:
if e.uid in self.interrupted:
self.interrupted.remove(e.uid)
await self.io.show(e.chat, "⚠️ Предыдущий запуск прервался во время обработки. "
"Проверьте состояние: незавершённые действия автоматически не повторяются.")
if e.data:
await self.action(e)
else:
await self.message(e)
except asyncio.CancelledError:
completed = False
raise
except (AwgError, ValueError) as exc:
await self._error(e, str(exc))
except Exception as exc:
# API/transport exceptions may contain a sensitive URL. Log class only.
log.error("Handler failed: %s (update %s)", type(exc).__name__, e.update_id)
await self._error(e, "Ошибка связи или обработки. Если действие изменяло сервер, проверьте результат перед повтором.")
finally:
if completed:
self.state.finish(e.update_id)
def cancel_confirmations(self, e):
self.confirmations = {k: v for k, v in self.confirmations.items() if v[:2] != (e.uid, e.chat)}
async def _error(self, e, text):
with contextlib.suppress(Exception):
await self.screen(e, "❌ " + esc(redacted(text)[:650]))
async def message(self, e):
text = e.text.strip()
command = text.split(maxsplit=1)[0].split("@", 1)[0] if text else ""
if command.startswith("/"):
self.cancel_confirmations(e)
if command in {"/start", "/menu", "/status", "/cancel"}:
self.steps.pop((e.uid, e.chat), None)
await self.home(e)
elif command == "/clients":
await self.list_clients(e)
elif command == "/whoami":
await self.screen(e, f"Ваш ID: <code>{esc(e.uid)}</code>\nДоступ: владелец.")
elif command == "/help":
await self.screen(e, "<b>AWG для Гуся</b>\n/menu — главное меню\n/clients — клиенты\n"
"/cancel — отменить ввод\n/whoami — ваш ID\n\n"
"Изменения клиентов общие с Telegram-ботом: оба управляют одним awg2.")
elif command == "/new":
await self.prompt(e, "add", "", "Имя нового клиента: латиница, цифры, _ и -, до 32 символов.")
elif (e.uid, e.chat) in self.steps:
await self.input(e, text)
else:
await self.home(e)
async def home(self, e):
d = (await self.api.call("status")).get("data") or {}
s = d.get("server") or {}
state = "🟢 работает" if s.get("up") else "🔴 не поднят"
text = (f"<b>🪿 AWG для Гуся · {__version__}</b>\n"
+ (f"Подключение: {esc(self.config['label'])}\n" if self.config.get("label") else "") +
f"Toolza: {esc(d.get('version', '?'))}\n"
f"Сервер: {esc(d.get('host', '?'))}\nAWG: {state}\n"
f"Клиенты: {s.get('clients', 0)} · онлайн {s.get('online', 0)}\n"
f"Протокол: {esc(s.get('proto', '?'))} · порт {esc(s.get('port', '?'))}")
await self.screen(e, text, [
[button("👥 Клиенты", "list:0"), button("➕ Создать", "new")],
[button("🖥 Сервер", "server"), button("🩺 Диагностика", "diag")],
[button("💾 Резервная копия", "backup"), button("📦 Экспорт клиентов", "export")],
[button("🔔 Уведомления", "notices"), button("ℹ️ О боте", "about")],
[button("🔄 Обновить", "home")]])
async def list_clients(self, e, page=0):
rows = sorted(await self.clients(), key=lambda c: (not c.get("online"), c["name"].lower()))
page = min(max(0, page), max(0, (len(rows) - 1) // PAGE))
buttons = [[button(f"{'🚫' if c.get('blocked') else '🟢' if c.get('online') else '⚪'} {c['name']}",
"client:" + c["name"])] for c in rows[page * PAGE:(page + 1) * PAGE]]
nav = []
if page:
nav.append(button("←", f"list:{page-1}"))
if (page + 1) * PAGE < len(rows):
nav.append(button("→", f"list:{page+1}"))
if nav:
buttons.append(nav)
buttons += [[button("➕ Создать", "new"), button("🔄 Обновить", f"list:{page}")], HOME]
await self.screen(e, f"<b>👥 Клиенты: {len(rows)}</b>\nСтраница {page+1}. Выберите клиента.", buttons)
async def card(self, e, name):
c = await self.client(name)
exp = c.get("expires")
expiry = time.strftime("%Y-%m-%d %H:%M", time.localtime(exp)) if exp else "без ограничения"
text = (f"<b>👤 {esc(name)}</b>\n{client_status(c)}\nIP: <code>{esc(c.get('ip', ''))}</code>\n"
f"Трафик: ↓ {size(c.get('rx'))} · ↑ {size(c.get('tx'))}\nСрок: {expiry} (время сервера)\n")
text += (f"Лимит: {size(c.get('used'))} / {size(c['limit'])} ({esc(c.get('period', 'month'))})\n"
if c.get("limit") else "Лимит: нет\n")
text += f"Скорость: {esc(c.get('speed') or 'не ограничена')}" + (" Мбит/с" if c.get("speed") else "")
watching = self.state.watched(e.uid, name)
await self.screen(e, text, [
[button("📄 Конфиг + QR", f"conf:{name}"), button("🔄 Обновить", f"client:{name}")],
[button("📅 Срок", f"expiry:{name}"), button("📊 Лимит", f"limit:{name}")],
[button("🚀 Скорость", f"speed:{name}"), button("✏️ Имя", f"rename:{name}")],
[button("🔑 Новый ключ", f"reissue:{name}"), button("🗑 Удалить", f"delete:{name}")],
[button("🔕 Не следить" if watching else "🔔 Следить", f"watch:{name}"),
button("♻️ Сбросить трафик", f"reset:{name}")],
[button("← Клиенты", "list:0")], HOME])
async def action(self, e):
op, _, arg = e.data.partition(":")
if op != "yes":
self.steps.pop((e.uid, e.chat), None)
self.cancel_confirmations(e)
if op == "home":
await self.home(e)
elif op == "list":
await self.list_clients(e, int(arg) if arg.isdigit() else 0)
elif op == "new":
await self.prompt(e, "add", "", "Имя нового клиента: латиница, цифры, _ и -, до 32 символов.")
elif op == "client":
await self.card(e, arg)
elif op == "conf":
await self.client(arg)
await self.send_config(e, arg)
elif op == "expiry":
await self.client(arg)
await self.screen(e, f"<b>Срок: {esc(arg)}</b>\nОтсчитывается от момента применения.", [
[button("1 день", f"exp:{arg}:+1d"), button("7 дней", f"exp:{arg}:+7d")],
[button("30 дней", f"exp:{arg}:+30d"), button("Без срока", f"exp:{arg}:off")],
[button("← Клиент", f"client:{arg}")]])
elif op == "exp":
name, _, value = arg.partition(":")
await self.client(name)
args = ["client", "unexpire", name] if value == "off" else ["client", "expire", name, value]
await self.confirm(e, f"Изменить срок {esc(name)}: {esc(value)}?", args, name)
elif op in {"rename", "limit", "speed"}:
await self.client(arg)
prompt = {"rename": "Новое имя: латиница, цифры, _ и -, до 32 символов.",
"limit": "Лимит: например 50G или 500M. Добавьте total для общего лимита: 50G total.\n"
"По умолчанию — за месяц. off — снять ограничение.",
"speed": "Скорость в Мбит/с: целое число, например 20. off — снять ограничение."}[op]
await self.prompt(e, op, arg, prompt)
elif op in {"delete", "reissue", "reset"}:
await self.client(arg)
verb, label = {"delete": ("del", "Удалить клиента и его доступ"),
"reissue": ("reissue", "Перевыпустить ключ: старый конфиг перестанет работать"),
"reset": ("limit-reset", "Сбросить счётчик лимита трафика")}[op]
await self.confirm(e, f"{label}: <b>{esc(arg)}</b>?", ["client", verb, arg], arg)
elif op == "watch":
await self.client(arg)
self.state.watch(e.uid, arg)
await self.card(e, arg)
elif op == "newexp":
draft = self.state.get(f"draft:{e.uid}:{e.chat}")
if not draft or time.time() - draft["time"] > 600:
raise ValueError("Ввод устарел. Создайте клиента заново.")
value = arg
args = ["client", "add", draft["name"], "mimicry=server"]
if value != "off":
args.append("expire=" + value)
await self.confirm(e, f"Создать <b>{esc(draft['name'])}</b>?\nСрок: {esc(value)}; мимикрия сервера.", args, draft["name"])
elif op == "yes":
await self.execute(e, arg)
elif op == "server":
d = (await self.api.call("server", "info")).get("data") or {}
text = "<b>🖥 Сервер AWG</b>\n" + "\n".join(
f"{label}: {esc(d.get(key, '—'))}" for key, label in
[("up", "Работает"), ("proto", "Протокол"), ("profile", "Профиль"),
("endpoint", "Endpoint"), ("net", "Подсеть"), ("mtu", "MTU"), ("clients", "Клиенты")])
await self.screen(e, text, [[button("🔄 Перезапустить AWG", "restart")], HOME])
elif op == "restart":
await self.confirm(e, "Перезапустить интерфейс AWG? Соединения VPN кратковременно прервутся.", ["server", "restart"])
elif op == "diag":
r = await self.api.call("diag", "status")
text = redacted(r.get("log") or "Диагностика завершена")
text = re.sub(r"\x1b\[[0-9;]*[A-Za-z]", "", text)
await self.screen(e, "<b>🩺 Диагностика</b>\n<pre>" + esc(text[-1800:]) + "</pre>")
elif op in {"backup", "export"}:
args = ["backup", "create"] if op == "backup" else ["clients", "export"]
await self.confirm(e, "Создать и прислать архив в эту личную беседу? Архив содержит ключи VPN.", args)
elif op == "notices":
on = self.state.get(f"notify:{e.uid}", False)
watches = [r[0] for r in self.state.db.execute("SELECT name FROM watches WHERE uid=?", (e.uid,))]
await self.screen(e, "<b>🔔 Уведомления</b>\nСостояние сервера: " + ("включены" if on else "выключены") +
"\nНаблюдение за клиентами: " + esc(", ".join(watches) or "нет") +
"\nПроверка раз в минуту. Клиент офлайн после 5 минут без рукопожатия.\n"
"При старте бот запоминает состояние без рассылки.",
[[button("Выключить серверные" if on else "Включить серверные", "notify")], HOME])
elif op == "notify":
key = f"notify:{e.uid}"
self.state.set(key, not self.state.get(key, False))
e.data = "notices"
await self.action(e)
elif op == "about":
await self.screen(e, f"<b>AWG для Гуся {__version__}</b>\n"
"Отдельная служба: awg-gus-bot. Управление тем же awg2, что и в Telegram.\n"
"Обновление на сервере: <code>sudo awg-gus-bot update</code>\n"
"Проверка: <code>sudo awg-gus-bot check</code>\n"
"В этой версии: клиенты, ключи/QR, сроки, трафик, скорость, диагностика, "
"бэкап и экспорт, перезапуск AWG, уведомления.\n"
"Установка AWG, туннели, обновление ядра и восстановление бэкапа — через меню awg2.")
else:
raise ValueError("Кнопка устарела. Откройте /menu.")
async def prompt(self, e, kind, name, text):
self.steps[(e.uid, e.chat)] = (kind, name, time.monotonic() + 600)
await self.screen(e, text + "\n\nОтмена: /cancel", [HOME])
async def input(self, e, text):
key = (e.uid, e.chat)
kind, name, expires = self.steps[key]
if time.monotonic() > expires:
self.steps.pop(key, None)
raise ValueError("Ввод устарел. Откройте меню заново.")
if kind in {"add", "rename"}:
if not NAME.fullmatch(text):
raise ValueError("Имя: латиница, цифры, _ и -, от 1 до 32 символов. Повторите ввод.")
if any(c["name"] == text for c in await self.clients()):
raise ValueError("Такое имя уже существует. Введите другое.")
if kind == "add":
self.state.set(f"draft:{e.uid}:{e.chat}", {"name": text, "time": time.time()})
await self.screen(e, f"Срок нового клиента <b>{esc(text)}</b>:", [
[button("1 день", "newexp:+1d"), button("7 дней", "newexp:+7d")],
[button("30 дней", "newexp:+30d"), button("Без срока", "newexp:off")], HOME])
else:
await self.confirm(e, f"Переименовать {esc(name)} → {esc(text)}?", ["client", "rename", name, text], text)
elif kind == "limit":
parts = text.split()
value = parts[0] if parts else ""
value = value if value == "off" else value.upper()
period = parts[1] if len(parts) == 2 else "month"
if len(parts) > 2 or not LIMIT.fullmatch(value) or period not in {"month", "total"}:
raise ValueError("Введите 50G, 500M, 50G total или off.")
await self.confirm(e, f"Лимит {esc(name)}: {esc(value)} ({period})?", ["client", "limit", name, value, period], name)
elif kind == "speed":
if not SPEED.fullmatch(text):
raise ValueError("Введите целое число Мбит/с или off.")
await self.confirm(e, f"Скорость {esc(name)}: {esc(text)} Мбит/с?", ["client", "speed", name, text], name)
self.steps.pop(key, None)
async def confirm(self, e, text, args, name=""):
from .core import validate_command
validate_command(args)
now = time.monotonic()
self.confirmations = {k: v for k, v in self.confirmations.items()
if v[0:2] != (e.uid, e.chat) and v[2] > now}
token = secrets.token_hex(8)
self.confirmations[token] = (e.uid, e.chat, now + 120, args, name)
await self.screen(e, text + "\n\nПодтверждение действует 2 минуты.",
[[button("✅ Подтвердить", "yes:" + token), button("Отмена", "home")]])
async def execute(self, e, token):
pending = self.confirmations.get(token)
if not pending or pending[:2] != (e.uid, e.chat) or pending[2] < time.monotonic():
raise ValueError("Подтверждение устарело или уже использовано. Повторите действие из меню.")
self.confirmations.pop(token)
args, name = pending[3:]
await self.screen(e, "⏳ Выполняю…")
r = await self.api.call(*args)
await self.screen(e, "✅ Выполнено", [[button("👥 Клиенты", "list:0")], HOME])
if args[:2] in (["client", "add"], ["client", "reissue"]):
await self.send_config(e, name)
elif args[:2] in (["backup", "create"], ["clients", "export"]):
data = r.get("data") or {}
path = Path(data.get("path") or data.get("file") or "")
if not path.is_absolute() or not path.is_file():
raise AwgError("Архив создан, но awg2 не вернул путь к файлу.")
if path.stat().st_size > FILE_MAX:
await self.io.show(e.chat, "Архив больше 25 МиБ и сохранён на сервере. Заберите его через SSH.")
else:
await self.io.call("send_message", chat_id=e.chat, text="Архив AWG: содержит ключи VPN.", files=[InputFile(path)])
async def send_config(self, e, name):
data = (await self.api.call("client", "conf", name)).get("data") or {}
text = data.get("text")
if not isinstance(text, str) or not text or len(text.encode()) > FILE_MAX:
raise AwgError("Конфиг отсутствует или превышает допустимый размер.")
files = [InputFile(text.encode(), filename=name + ".conf", content_type="text/plain")]
note = ""
if len(text.encode()) <= 2800 and shutil.which("qrencode"):
proc = await asyncio.create_subprocess_exec("qrencode", "-t", "PNG", "-o", "-", "-s", "8", "-m", "2",
stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL)
try:
png, _ = await asyncio.wait_for(proc.communicate(text.encode()), 10)
if proc.returncode == 0 and png.startswith(b"\x89PNG\r\n\x1a\n"):
files.append(InputFile(png, filename=name + ".png", content_type="image/png"))
else:
note = " QR не сформирован; импортируйте файл."
except (asyncio.TimeoutError, asyncio.CancelledError) as exc:
with contextlib.suppress(ProcessLookupError):
proc.kill()
await proc.wait()
if isinstance(exc, asyncio.CancelledError):
raise
note = " QR не сформирован; импортируйте файл."
else:
note = " QR недоступен или конфиг слишком длинный; импортируйте файл."
await self.io.call("send_message", chat_id=e.chat, text=f"{name} — импорт в AmneziaVPN / AmneziaWG.{note}", files=files)
async def monitor_tick(self):
watches = list(self.state.db.execute("SELECT uid,name FROM watches"))
notify = [uid for uid in self.owners if self.state.get(f"notify:{uid}", False)]
if not watches and not notify:
return
clients = {c["name"]: c for c in await self.clients()} if watches else {}
current = {}
for uid, name in watches:
if uid not in self.owners:
continue
c = clients.get(name)
status = "удалён" if c is None else "заблокирован" if c.get("blocked") else (
"онлайн" if c.get("handshake") and float(c.get("ago") or 0) < 300 else "офлайн")
current[(uid, name)] = status
if notify:
d = (await self.api.call("server", "info")).get("data") or {}
for uid in notify:
current[(uid, "Сервер AWG")] = "работает" if d.get("up") else "не поднят"
for key, status in current.items():
previous = self.observed.get(key)
if previous is not None and previous != status:
row = self.state.db.execute("SELECT chat FROM chats WHERE uid=?", (key[0],)).fetchone()
if row:
await self.io.show(row[0], f"🔔 <b>{esc(key[1])}</b>: {esc(previous)} → {esc(status)}")
self.observed = current
async def monitor(self):
while True:
try:
await self.monitor_tick()
except Exception as exc:
log.warning("Monitor failed: %s", type(exc).__name__)
await asyncio.sleep(60)
async def run(self):
webhook = await self.io.call("get_webhook_info")
if webhook.get("url"):
raise ValueError("У этого бота уже настроен webhook. Используйте отдельного бота Гуся.")
await self.io.call("set_my_commands", commands=[
{"command": "menu", "description": "Управление AWG"},
{"command": "clients", "description": "Клиенты VPN"},
{"command": "new", "description": "Создать клиента"},
{"command": "cancel", "description": "Отменить ввод"},
{"command": "help", "description": "Помощь"}])
monitor = asyncio.create_task(self.monitor())
delay = 1
try:
while True:
try:
updates = await self.io.call("get_updates", offset=self.state.get("offset", 0), timeout=30,
allowed_updates=["message", "callback_query"])
for update in updates:
await self.process(update)
delay = 1
except GusVoiceApiError as exc:
if exc.code in {401, 403, 404, 409}:
log.error("Polling stopped: HTTP %s. Check token, enabled bots, webhook and duplicate processes.", exc.code)
raise
log.warning("Polling retry: HTTP %s", exc.code)
await asyncio.sleep(delay)
delay = min(30, delay * 2)
except (OSError, TimeoutError) as exc:
log.warning("Network retry: %s", type(exc).__name__)
await asyncio.sleep(delay)
delay = min(30, delay * 2)
finally:
monitor.cancel()
for task in self.tasks.values():
task.cancel()
await asyncio.gather(monitor, *self.tasks.values(), return_exceptions=True)