Add standalone GusVoice AWG bot and Linux installer
This commit is contained in:
commit
38c6a1a682
23 files changed
+2924
No files matched your search
@@ -0,0 +1 @@
|
||||
__version__ = "0.1.0"
|
||||
+484
@@ -0,0 +1,484 @@
|
||||
"""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"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)
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
"""Local AWG API, private state, and GusVoice delivery. No shell commands from chat."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import signal
|
||||
import sqlite3
|
||||
import time
|
||||
|
||||
from gusvoice_bot import AsyncClient, GusVoiceApiError
|
||||
|
||||
log = logging.getLogger("awg-gus")
|
||||
NAME = re.compile(r"[A-Za-z0-9_-]{1,32}\Z")
|
||||
LIMIT = re.compile(r"(?:[1-9][0-9]{0,5}[MG]|off)\Z")
|
||||
SPEED = re.compile(r"(?:[1-9][0-9]{0,5}|off)\Z")
|
||||
EXPIRE = re.compile(r"\+[1-9][0-9]{0,3}[dh]\Z")
|
||||
|
||||
|
||||
def esc(value):
|
||||
return html.escape(str(value if value is not None else ""))
|
||||
|
||||
|
||||
def redacted(value):
|
||||
text = str(value)
|
||||
text = re.sub(r"(?im)(PrivateKey|PresharedKey|BOT_TOKEN|password)\s*[:=]\s*\S+", r"\1=[скрыто]", text)
|
||||
return re.sub(r"gvb_[A-Za-z0-9_-]+", "[токен скрыт]", text)
|
||||
|
||||
|
||||
def size(value):
|
||||
n = float(value or 0)
|
||||
for unit in ("Б", "КиБ", "МиБ", "ГиБ", "ТиБ"):
|
||||
if abs(n) < 1024 or unit == "ТиБ":
|
||||
return f"{n:.1f} {unit}"
|
||||
n /= 1024
|
||||
|
||||
|
||||
class AwgError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def validate_command(args):
|
||||
"""Second allow-list immediately before a privileged subprocess starts."""
|
||||
a = tuple(args)
|
||||
if a in {("version",), ("status",), ("server", "info"), ("server", "restart"),
|
||||
("clients", "list"), ("clients", "export"), ("diag", "status"),
|
||||
("backup", "create"), ("backup", "list"), ("tunnels", "status")}:
|
||||
return
|
||||
if len(a) >= 3 and a[0] == "client" and NAME.fullmatch(a[2]):
|
||||
op = a[1]
|
||||
if len(a) == 3 and op in {"conf", "del", "reissue", "unexpire", "limit-reset"}:
|
||||
return
|
||||
if len(a) == 4:
|
||||
if op == "rename" and NAME.fullmatch(a[3]):
|
||||
return
|
||||
if op == "expire" and EXPIRE.fullmatch(a[3]):
|
||||
return
|
||||
if op == "speed" and SPEED.fullmatch(a[3]):
|
||||
return
|
||||
if len(a) == 5 and op == "limit" and LIMIT.fullmatch(a[3]) and a[4] in {"month", "total"}:
|
||||
return
|
||||
if op == "add" and len(a) in {4, 5} and a[3] == "mimicry=server":
|
||||
if len(a) == 4 or (a[4].startswith("expire=") and EXPIRE.fullmatch(a[4][7:])):
|
||||
return
|
||||
if len(a) == 4 and a[:2] == ("traffic", "daily") and (a[2] == "all" or NAME.fullmatch(a[2])) and a[3] == "7":
|
||||
return
|
||||
raise ValueError("Недопустимая операция awg2")
|
||||
|
||||
|
||||
class Awg:
|
||||
def __init__(self, binary="/usr/local/bin/awg2"):
|
||||
self.binary = binary
|
||||
self.lock = asyncio.Lock()
|
||||
|
||||
async def call(self, *args, timeout=120):
|
||||
validate_command(args)
|
||||
# Upstream awg2 also uses a flock shared with the Telegram bot.
|
||||
async with self.lock:
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
self.binary, "api", *args, stdin=asyncio.subprocess.DEVNULL,
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
||||
start_new_session=(os.name == "posix"))
|
||||
except OSError as e:
|
||||
raise AwgError("Не удалось запустить awg2. Проверьте установку и путь к программе.") from e
|
||||
try:
|
||||
out, _ = await asyncio.wait_for(proc.communicate(), timeout)
|
||||
except (asyncio.TimeoutError, asyncio.CancelledError) as e:
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
if os.name == "posix":
|
||||
os.killpg(proc.pid, signal.SIGTERM)
|
||||
else:
|
||||
proc.kill()
|
||||
try:
|
||||
await asyncio.wait_for(proc.communicate(), 5)
|
||||
except asyncio.TimeoutError:
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
if os.name == "posix":
|
||||
os.killpg(proc.pid, signal.SIGKILL)
|
||||
else:
|
||||
proc.kill()
|
||||
await proc.communicate()
|
||||
if isinstance(e, asyncio.CancelledError):
|
||||
raise
|
||||
raise AwgError("awg2 не ответил вовремя. Проверьте результат в меню: операция могла выполниться.") from e
|
||||
try:
|
||||
result = json.loads(out.decode("utf-8", errors="replace").strip().splitlines()[-1])
|
||||
if not isinstance(result, dict) or not isinstance(result.get("ok"), bool):
|
||||
raise ValueError
|
||||
except (ValueError, IndexError) as e:
|
||||
raise AwgError("awg2 вернул ответ без JSON API. Нужна версия AWG Toolza с командой awg2 api.") from e
|
||||
if not result["ok"] or proc.returncode:
|
||||
raise AwgError(redacted(result.get("error") or "awg2 завершился с ошибкой")[:500])
|
||||
return result
|
||||
|
||||
|
||||
class State:
|
||||
def __init__(self, path, identity):
|
||||
path = Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
self.db = sqlite3.connect(path)
|
||||
if os.name == "posix":
|
||||
path.chmod(0o600)
|
||||
self.db.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS kv(key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS chats(uid TEXT PRIMARY KEY, chat TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS watches(uid TEXT, name TEXT, PRIMARY KEY(uid,name));
|
||||
CREATE TABLE IF NOT EXISTS pending(id INTEGER PRIMARY KEY, uid TEXT, chat TEXT);
|
||||
""")
|
||||
old = self.get("identity")
|
||||
if old is not None and old != identity:
|
||||
self.db.close()
|
||||
raise ValueError("Состояние принадлежит другому боту/инстансу. Укажите отдельный state_dir.")
|
||||
self.set("identity", identity)
|
||||
|
||||
def get(self, key, default=None):
|
||||
row = self.db.execute("SELECT value FROM kv WHERE key=?", (key,)).fetchone()
|
||||
return json.loads(row[0]) if row else default
|
||||
|
||||
def set(self, key, value):
|
||||
with self.db:
|
||||
self.db.execute("INSERT OR REPLACE INTO kv VALUES (?,?)", (key, json.dumps(value)))
|
||||
|
||||
def claim(self, update_id, uid=None, chat=None):
|
||||
if update_id < self.get("offset", 0):
|
||||
return False
|
||||
# Persist BEFORE any mutation. Interrupted operations are reported, never replayed.
|
||||
with self.db:
|
||||
self.db.execute("INSERT OR REPLACE INTO kv VALUES ('offset',?)", (json.dumps(update_id + 1),))
|
||||
if uid and chat:
|
||||
self.db.execute("INSERT INTO pending VALUES (?,?,?)", (update_id, uid, chat))
|
||||
self.db.execute("INSERT OR REPLACE INTO chats VALUES (?,?)", (uid, chat))
|
||||
return True
|
||||
|
||||
def finish(self, update_id):
|
||||
with self.db:
|
||||
self.db.execute("DELETE FROM pending WHERE id=?", (update_id,))
|
||||
|
||||
def watch(self, uid, name):
|
||||
with self.db:
|
||||
if self.watched(uid, name):
|
||||
self.db.execute("DELETE FROM watches WHERE uid=? AND name=?", (uid, name))
|
||||
return False
|
||||
self.db.execute("INSERT INTO watches VALUES (?,?)", (uid, name))
|
||||
return True
|
||||
|
||||
def watched(self, uid, name):
|
||||
return bool(self.db.execute("SELECT 1 FROM watches WHERE uid=? AND name=?", (uid, name)).fetchone())
|
||||
|
||||
|
||||
class Delivery:
|
||||
"""Throttle sends/edits together; acknowledge callbacks independently."""
|
||||
def __init__(self, client):
|
||||
self.client = client
|
||||
self.locks = {}
|
||||
self.next_send = {}
|
||||
|
||||
async def call(self, method, **params):
|
||||
chat = params.get("chat_id")
|
||||
if chat and method in {"send_message", "edit_message_text", "edit_message_reply_markup"}:
|
||||
lock = self.locks.setdefault(chat, asyncio.Lock())
|
||||
async with lock:
|
||||
await asyncio.sleep(max(0, self.next_send.get(chat, 0) - time.monotonic()))
|
||||
try:
|
||||
return await getattr(self.client, method)(**params)
|
||||
finally:
|
||||
self.next_send[chat] = time.monotonic() + 1.1
|
||||
return await getattr(self.client, method)(**params)
|
||||
|
||||
async def ack(self, callback_id, text=None):
|
||||
try:
|
||||
await self.call("answer_callback_query", callback_query_id=callback_id, text=text)
|
||||
except GusVoiceApiError as e:
|
||||
if e.code != 400:
|
||||
raise
|
||||
|
||||
async def show(self, chat, text, buttons=None, message=None):
|
||||
params = dict(chat_id=chat, text=text, parse_mode="HTML",
|
||||
reply_markup={"inline_keyboard": buttons} if buttons else None)
|
||||
if message:
|
||||
try:
|
||||
return await self.call("edit_message_text", message_id=message, **params)
|
||||
except GusVoiceApiError as e:
|
||||
if e.code != 400:
|
||||
raise
|
||||
return await self.call("send_message", **params)
|
||||
|
||||
|
||||
def client_for(config):
|
||||
return AsyncClient(config["token"], config["url"], discover=False, max_retries=3)
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import contextlib
|
||||
import getpass
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
from urllib.parse import urlsplit
|
||||
import uuid
|
||||
|
||||
from gusvoice_bot import Client, GusVoiceApiError
|
||||
|
||||
from . import __version__
|
||||
from .bot import Bot
|
||||
from .core import Awg, Delivery, State, client_for
|
||||
|
||||
DEFAULT_CONFIG = "/etc/awg-gus-bot.json"
|
||||
DEFAULT_STATE = "/var/lib/awg-gus-bot"
|
||||
|
||||
|
||||
def validate_url(url):
|
||||
u = urlsplit(url.strip())
|
||||
if u.scheme != "https" or not u.hostname or u.username or u.password or u.query or u.fragment:
|
||||
raise ValueError("Укажите HTTPS-адрес Гуся без логина, пароля и параметров.")
|
||||
if u.path.rstrip("/") not in {"", "/api"}:
|
||||
raise ValueError("Нужен адрес сайта/сервера API, без /api/bot/v1.")
|
||||
return url.strip().rstrip("/").removesuffix("/api")
|
||||
|
||||
|
||||
def validate_config(d):
|
||||
validate_url(d["url"])
|
||||
if not isinstance(d.get("token"), str) or not d["token"].startswith("gvb_"):
|
||||
raise ValueError("Требуется токен бота Гуся gvb_…")
|
||||
if not isinstance(d.get("owners"), list) or not d["owners"]:
|
||||
raise ValueError("Не задан владелец")
|
||||
for owner in d["owners"]:
|
||||
uuid.UUID(owner)
|
||||
uuid.UUID(d["bot_id"])
|
||||
for key in ("awg2", "state_dir"):
|
||||
if not Path(d[key]).is_absolute():
|
||||
raise ValueError(f"{key}: нужен абсолютный путь")
|
||||
return d
|
||||
|
||||
|
||||
def load_config(path):
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return validate_config(json.load(f))
|
||||
|
||||
|
||||
def save_config(path, data):
|
||||
path = Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_name(path.name + ".new")
|
||||
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
f.write("\n")
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
if os.name == "posix":
|
||||
tmp.chmod(0o600)
|
||||
os.replace(tmp, path)
|
||||
finally:
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
tmp.unlink()
|
||||
|
||||
|
||||
def check_features(me):
|
||||
uuid.UUID(me["id"])
|
||||
# HTML rendering was introduced with API 3; do not silently send HTML to an older server.
|
||||
if int(me.get("api_version") or 0) < 3:
|
||||
raise ValueError("Нужен GusVoice Bot API 3 или новее (кнопки, команды и HTML). Обновите Гуся.")
|
||||
features = set(me.get("features") or [])
|
||||
if not {"keyboards", "commands", "parse_mode"}.issubset(features):
|
||||
raise ValueError("Инстанс не объявляет необходимые возможности: keyboards, commands, parse_mode.")
|
||||
|
||||
|
||||
def pair(client, code, *, duration=600, now=time.monotonic):
|
||||
deadline = now() + duration
|
||||
offset = 0
|
||||
while now() < deadline:
|
||||
updates = client.get_updates(offset=offset, timeout=10, allowed_updates=["message"])
|
||||
for u in updates:
|
||||
offset = max(offset, int(u["update_id"]) + 1)
|
||||
msg = u.get("message") or {}
|
||||
chat, author = msg.get("chat") or {}, msg.get("from") or {}
|
||||
text = (msg.get("text") or "").strip()
|
||||
if chat.get("type") != "dm" or author.get("bot"):
|
||||
continue
|
||||
if not hmac.compare_digest(text.encode("utf-8"), ("/start " + code).encode("utf-8")):
|
||||
continue
|
||||
try:
|
||||
uuid.UUID(author["id"])
|
||||
uuid.UUID(chat["id"])
|
||||
except (KeyError, ValueError, TypeError, AttributeError):
|
||||
continue
|
||||
client.get_updates(offset=offset, timeout=0, allowed_updates=[])
|
||||
return author["id"], chat["id"], offset
|
||||
raise ValueError("Время привязки истекло. Запустите настройку ещё раз.")
|
||||
|
||||
|
||||
def configure(args):
|
||||
if not sys.stdin.isatty():
|
||||
raise ValueError("Настройка требует терминала. Скачайте install.sh в файл и запустите sudo bash install.sh.")
|
||||
print("AWG для Гуся: настройка отдельного бота. Mini App не используется.")
|
||||
url = validate_url(input("HTTPS-адрес Гуся: "))
|
||||
token = getpass.getpass("Токен нового бота gvb_… (ввод скрыт): ").strip()
|
||||
if not token.startswith("gvb_"):
|
||||
raise ValueError("Токен должен начинаться с gvb_")
|
||||
client = Client(token, url)
|
||||
me = client.get_me()
|
||||
check_features(me)
|
||||
if client.get_webhook_info().get("url"):
|
||||
raise ValueError("У этого бота настроен webhook. Создайте отдельного бота для AWG.")
|
||||
resolved = validate_url(client.api_url())
|
||||
code = secrets.token_urlsafe(18)
|
||||
print(f"\nОткройте личную беседу с @{me.get('username', '')} в Гусе.")
|
||||
print("Отправьте следующее сообщение (код действует 10 минут):\n")
|
||||
print("/start " + code)
|
||||
print("\nЖду сообщение…", flush=True)
|
||||
owner, chat, offset = pair(client, code)
|
||||
d = {"url": resolved, "token": token, "owners": [owner], "bot_id": me["id"],
|
||||
"awg2": args.awg2, "state_dir": args.state_dir}
|
||||
validate_config(d)
|
||||
state = State(Path(args.state_dir) / "state.sqlite3", resolved + "|" + me["id"])
|
||||
state.set("offset", offset)
|
||||
with state.db:
|
||||
state.db.execute("INSERT OR REPLACE INTO chats VALUES (?,?)", (owner, chat))
|
||||
state.db.close()
|
||||
save_config(args.config, d)
|
||||
# Pairing is complete at this point; a delivery failure must not discard the valid config.
|
||||
with contextlib.suppress(Exception):
|
||||
client.send_message(chat, "✅ Аккаунт привязан. После завершения установки отправьте /menu.")
|
||||
print("Владелец привязан. Конфигурация сохранена.")
|
||||
|
||||
|
||||
async def check(args):
|
||||
d = load_config(args.config)
|
||||
me = await client_for(d).get_me()
|
||||
check_features(me)
|
||||
if me["id"] != d["bot_id"]:
|
||||
raise ValueError("Токен принадлежит другому боту. Повторите настройку с отдельным state_dir.")
|
||||
if (await client_for(d).get_webhook_info()).get("url"):
|
||||
raise ValueError("Задан webhook: нужен отдельный бот для polling.")
|
||||
await Awg(d["awg2"]).call("version", timeout=30)
|
||||
print(f"OK: AWG для Гуся {__version__}; GusVoice API {me['api_version']}; awg2 api отвечает.")
|
||||
|
||||
|
||||
async def run(args):
|
||||
d = load_config(args.config)
|
||||
client = client_for(d)
|
||||
me = await client.get_me()
|
||||
check_features(me)
|
||||
if me["id"] != d["bot_id"]:
|
||||
raise ValueError("Неверная привязка бота. Выполните setup.")
|
||||
state = State(Path(d["state_dir"]) / "state.sqlite3", d["url"] + "|" + d["bot_id"])
|
||||
# flock prevents accidental second local process; the API prevents remote duplicate consumers.
|
||||
import fcntl
|
||||
lock = open(Path(d["state_dir"]) / "process.lock", "a")
|
||||
try:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError as e:
|
||||
raise ValueError("Этот бот уже запущен") from e
|
||||
bot = Bot(d, Awg(d["awg2"]), Delivery(client), state)
|
||||
task = asyncio.create_task(bot.run())
|
||||
loop = asyncio.get_running_loop()
|
||||
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||
loop.add_signal_handler(sig, task.cancel)
|
||||
logging.info("AWG Gus bot %s started", __version__)
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
finally:
|
||||
state.db.close()
|
||||
lock.close()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="AWG Toolza bot for GusVoice")
|
||||
parser.add_argument("command", choices=["run", "setup", "check"])
|
||||
parser.add_argument("--config", default=DEFAULT_CONFIG)
|
||||
parser.add_argument("--awg2", default="/usr/local/bin/awg2")
|
||||
parser.add_argument("--state-dir", default=DEFAULT_STATE)
|
||||
args = parser.parse_args()
|
||||
os.umask(0o077)
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
try:
|
||||
if args.command == "setup":
|
||||
configure(args)
|
||||
else:
|
||||
asyncio.run(check(args) if args.command == "check" else run(args))
|
||||
except GusVoiceApiError as e:
|
||||
print(f"Ошибка GusVoice API: HTTP {e.code}. Проверьте адрес, токен и включение ботов.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except (ValueError, FileNotFoundError, KeyError) as e:
|
||||
# No raw request URLs or token-bearing exception repr in logs.
|
||||
print(str(e) if isinstance(e, ValueError) else "Не найден или неверно заполнен конфигурационный файл.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except KeyboardInterrupt:
|
||||
sys.exit(130)
|
||||
except Exception as e:
|
||||
print(f"Ошибка: {type(e).__name__}. Проверьте сеть, сертификаты и доступность awg2.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
Reference in new issue
Block a user