Add v2 support for independent GusVoice connections to one AWG server

This commit is contained in:
wenil committed 2026-10-30 15:38:31 +03:00
1 parent 38c6a1a682
commit 0e62fe20ed
9 files changed
+759 -148

No files matched your search

+74 -26
View File
@@ -4,7 +4,39 @@
Работает рядом с Telegram-ботом: своя служба, настройки и состояние; оба интерфейса управляют одними VPN-клиентами. Работает рядом с Telegram-ботом: своя служба, настройки и состояние; оба интерфейса управляют одними VPN-клиентами.
Telegram Mini App не используется. Telegram Mini App не используется.
Версия **0.1.0 — первая версия для проверки на реальном сервере**. Версия **0.2.0 — v2, несколько Гусей → один AWG**.
## Добавить второго Гуся к существующей установке
Обновите программу, затем добавьте подключение:
```bash
sudo awg-gus-bot update
sudo awg-gus-bot add
```
Или повторно запустите скачанный **новый** установщик командой из раздела ниже и выберите
**«2. Добавить ещё одного Гуся»**. Он сначала обновит общую программу, затем откроет настройку нового подключения.
1. На втором сервере GusVoice создайте бота в «Настройки → Мои боты».
2. Введите короткое имя подключения, например `gus2`, адрес второго Гуся и **его токен**.
3. Отправьте показанный `/start <код>` этому боту в личку на втором Гусе.
4. После установки отправьте `/menu` — будут доступны те же AWG-клиенты.
Для третьего и следующих Гусей снова выполните `sudo awg-gus-bot add`.
Имя подключения: до 32 символов, строчная латиница, цифры, `_`, `-`; первая — буква.
Можно сразу задать его: `sudo awg-gus-bot add gus2`.
На каждом независимом Гусе нужен свой аккаунт бота и токен: боты GusVoice принадлежат своему инстансу.
Программа и возможности одинаковые; один и тот же токен не нужно запускать несколькими процессами.
Повторное добавление уже настроенного аккаунта отклоняется **до начала polling**.
У каждого подключения отдельные владелец, события, подтверждения, уведомления, SQLite и служба systemd.
Падение/отключение одного Гуся не останавливает остальные. `add` запускает только новую службу;
обновление общего кода кратковременно перезапускает работавшие службы ботов.
**Первая установка 0.1.0 сохраняется как `default`**: её токен, владелец, настройки, служба и база остаются на прежних местах.
Повторная привязка первого бота при обновлении не требуется. Telegram продолжает управлять тем же AWG.
## Установка ## Установка
@@ -41,7 +73,7 @@ sudo bash awg-gus-install.sh
При отсутствии зависимостей установщик использует apt для `python3`, `curl`, `tar`, `qrencode`, `ca-certificates`. При отсутствии зависимостей установщик использует apt для `python3`, `curl`, `tar`, `qrencode`, `ca-certificates`.
На других дистрибутивах установите эти программы самостоятельно. Python SDK включён в репозиторий; pip/PyPI для установки не нужны. На других дистрибутивах установите эти программы самостоятельно. Python SDK включён в репозиторий; pip/PyPI для установки не нужны.
## Что умеет 0.1.0 ## Возможности бота
- Главное меню со статусом сервера и клиента: работает/офлайн, IP, трафик. - Главное меню со статусом сервера и клиента: работает/офлайн, IP, трафик.
- Список клиентов с перелистыванием, создание, переименование и удаление. - Список клиентов с перелистыванием, создание, переименование и удаление.
@@ -61,22 +93,23 @@ sudo bash awg-gus-install.sh
Включите «Следить» в карточке нужного клиента. Отдельный переключатель состояния сервера — в «Уведомлениях». Включите «Следить» в карточке нужного клиента. Отдельный переключатель состояния сервера — в «Уведомлениях».
После запуска бот запоминает текущее состояние без рассылки и пишет только при изменении. После запуска бот запоминает текущее состояние без рассылки и пишет только при изменении.
В первой версии через меню `awg2` остаются: установка/обновление AWG и модулей ядра, создание сервера, Через меню `awg2` остаются: установка/обновление AWG и модулей ядра, создание сервера,
настройка туннелей/DNS/обфускаторов, массовое создание, восстановление бэкапа и удаление всей установки. настройка туннелей/DNS/обфускаторов, массовое создание, восстановление бэкапа и удаление всей установки.
Это отдельный нативный клиент основных функций, а не полная копия всех экранов Telegram-бота. Это отдельный нативный клиент основных функций, а не полная копия всех экранов Telegram-бота.
## Сосуществование с Telegram ## Сосуществование с Telegram
| Компонент | Этот бот | | Компонент | Первое подключение `default` | Дополнительное `gus2` |
|---|---| |---|---|---|
| Служба | `awg-gus-bot.service` | | Служба | `awg-gus-bot.service` | `awg-gus-bot@gus2.service` |
| Программа | `/opt/awg-gus-bot/current` → отдельная версия в `releases/` | | Настройки (права `600`) | `/etc/awg-gus-bot.json` | `/etc/awg-gus-bot/instances/gus2.json` |
| Настройки | `/etc/awg-gus-bot.json`, права `600` | | Состояние | `/var/lib/awg-gus-bot/state.sqlite3` | `/var/lib/awg-gus-bot/instances/gus2/state.sqlite3` |
| Состояние | `/var/lib/awg-gus-bot`, отдельная SQLite-база |
| Управление | `/usr/local/bin/awg-gus-bot` | Общий код: `/opt/awg-gus-bot/current` → отдельная версия в `releases/`.
Команда управления: `/usr/local/bin/awg-gus-bot`.
Установщик не меняет файлы и службу `awg-bot`, не обновляет `awg2` и не перезапускает VPN. Установщик не меняет файлы и службу `awg-bot`, не обновляет `awg2` и не перезапускает VPN.
**Данные VPN общие:** если удалить клиента через Гуся, он исчезнет и в Telegram. **Данные VPN общие:** если удалить клиента через любого Гуся, он исчезнет во всех Гусях и в Telegram.
В проверенном upstream `awg2 api` изменяющие команды сериализованы общим `flock`. В проверенном upstream `awg2 api` изменяющие команды сериализованы общим `flock`.
Если другой интерфейс занят длительной операцией, AWG может попросить повторить действие позже. Если другой интерфейс занят длительной операцией, AWG может попросить повторить действие позже.
@@ -87,24 +120,35 @@ sudo bash awg-gus-install.sh
## Обновление и управление ## Обновление и управление
```bash ```bash
sudo awg-gus-bot update # загрузить новую версию из Gitea sudo awg-gus-bot # меню управления подключениями
sudo awg-gus-bot check # проверить токен, возможности Гуся и awg2 api sudo awg-gus-bot add # добавить ещё одного Гуся
sudo awg-gus-bot status sudo awg-gus-bot list # имена, адреса, состояния всех подключений
sudo awg-gus-bot logs sudo awg-gus-bot update # обновить общий код из Gitea
sudo awg-gus-bot restart sudo awg-gus-bot check # проверить все подключения, включая недоступные
sudo awg-gus-bot stop sudo awg-gus-bot check gus2 # проверить только второго Гуся
sudo awg-gus-bot start sudo awg-gus-bot status gus2
sudo awg-gus-bot setup # повторная настройка/привязка того же бота sudo awg-gus-bot logs gus2
sudo awg-gus-bot restart gus2
sudo awg-gus-bot stop gus2
sudo awg-gus-bot start gus2
sudo awg-gus-bot disable gus2 # остановить и убрать из автозапуска; настройки сохраняются
sudo awg-gus-bot enable gus2 # вернуть в автозапуск и запустить
sudo awg-gus-bot setup gus2 # повторная привязка того же аккаунта / замена его токена
``` ```
Повторная установка тоже обновляет программу. Настройки сохраняются без повторного запроса токена. Без имени `logs/start/stop/restart/setup` обращаются к `default`, а `status/list/check` — ко всем подключениям.
Если `default` отсутствует и подключений несколько, имя нужно указывать явно.
Повторный запуск нового установщика предлагает обновить код или добавить подключение.
Для обновления без меню: `sudo bash install.sh --update`. Настройки сохраняются без повторного запроса токена.
Новая версия сначала скачивается и проверяется, затем переключается `current`. Новая версия сначала скачивается и проверяется, затем переключается `current`.
Если служба не запустилась, установщик возвращает прежние программу/службу/настройки и запускает прежнюю службу, Если после обновления служба не запустилась, установщик возвращает прежний код и службы, затем запускает ранее работавшие подключения.
если она работала до обновления. Старые версии автоматически не удаляются. Остановленные подключения обновляются вместе со всеми, но автоматически не включаются.
Если не удалось добавить/перенастроить одного Гуся, восстанавливаются настройки и состояние только этого подключения.
Старые версии программы автоматически не удаляются.
При смене на другой аккаунт бота база специально не переиспользуется: старые chat UUID и курсор событий принадлежат прежнему боту. При смене на другой аккаунт бота база специально не переиспользуется: старые chat UUID и курсор событий принадлежат прежнему боту.
Для такого переноса сначала сохраните текущую установку и подготовьте новое состояние; `setup` рассчитан на перепривязку того же аккаунта Для другого аккаунта/инстанса используйте `add`; `setup` рассчитан на перепривязку того же аккаунта или обновление его токена.
или обновление его токена.
## Ограничения и проверка на сервере ## Ограничения и проверка на сервере
@@ -125,7 +169,9 @@ sudo awg-gus-bot setup # повторная настройка/привяз
3. Проверить скачивание `.conf` и отображение QR; импортировать конфиг и проверить соединение. 3. Проверить скачивание `.conf` и отображение QR; импортировать конфиг и проверить соединение.
4. Убедиться, что этот же клиент появился в Telegram-боте. 4. Убедиться, что этот же клиент появился в Telegram-боте.
5. Изменить срок/лимит, перезапустить только `awg-gus-bot`, повторно открыть `/menu`. 5. Изменить срок/лимит, перезапустить только `awg-gus-bot`, повторно открыть `/menu`.
6. Удалить временного клиента с подтверждением; проверить, что Telegram-бот продолжает работать. 6. Добавить второго Гуся, проверить тот же список клиентов. Уведомления включаются отдельно в каждом боте.
7. Выполнить `sudo awg-gus-bot stop gus2`, проверить работу первого Гуся и Telegram, затем `start gus2`.
8. Удалить временного клиента с подтверждением; проверить, что он исчез во всех интерфейсах.
## Разработка и происхождение ## Разработка и происхождение
@@ -136,7 +182,9 @@ bash -n awg-gus-bot
``` ```
Тесты проверяют права владельца, личку, подтверждения/отмену, повторные события после перезапуска, Тесты проверяют права владельца, личку, подтверждения/отмену, повторные события после перезапуска,
создание клиента, файлы и QR, лимиты, мониторинг. Транспортные тесты используют настоящий включённый SDK создание клиента, файлы и QR, лимиты, мониторинг, параллельные подключения с одинаковыми update ID,
защиту от повторного polling, сохранение v1, добавление отдельной службы и откат настройки/базы при ошибке.
Транспортные тесты используют настоящий включённый SDK
и локальный HTTP-стенд. Вызовы AWG имитируются: эти тесты не меняют VPN и не подтверждают установку на реальном systemd-сервере. и локальный HTTP-стенд. Вызовы AWG имитируются: эти тесты не меняют VPN и не подтверждают установку на реальном systemd-сервере.
- GusVoice Python SDK **0.1.0** включён без изменений в `vendor/gusvoice_bot/` (MIT). - GusVoice Python SDK **0.1.0** включён без изменений в `vendor/gusvoice_bot/` (MIT).
+22 -15
View File
@@ -1,18 +1,25 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -Eeuo pipefail set -Eeuo pipefail
BASE=/opt/awg-gus-bot BASE=/opt/awg-gus-bot
case "${1:-status}" in if [[ "${1:-menu}" == update ]]; then
status) systemctl status awg-gus-bot --no-pager ;; [[ $EUID -eq 0 ]] || { echo 'Запустите через sudo'; exit 1; }
logs) journalctl -u awg-gus-bot -n 80 --no-pager ;; export AWG_GUS_REPO_URL AWG_GUS_REF
restart|stop|start) [[ $EUID -eq 0 ]] || { echo 'Запустите через sudo'; exit 1; }; systemctl "$1" awg-gus-bot ;; AWG_GUS_REPO_URL=$(cat "$BASE/current/source-url")
check) exec python3 "$BASE/current/run.py" check ;; AWG_GUS_REF=$(cat "$BASE/current/source-ref")
setup) exec bash "$BASE/current/install.sh" --src "$BASE/current" --configure ;; [[ "$AWG_GUS_REPO_URL" == https://* && "$AWG_GUS_REPO_URL" != *'@'* ]] || exit 2
update) [[ "$AWG_GUS_REF" =~ ^[A-Za-z0-9._-]+$ ]] || exit 2
[[ $EUID -eq 0 ]] || { echo 'Запустите через sudo'; exit 1; } installer=$(mktemp)
export AWG_GUS_REPO_URL AWG_GUS_REF trap 'rm -f -- "$installer"' EXIT
AWG_GUS_REPO_URL=$(cat "$BASE/current/source-url") downloaded=0
AWG_GUS_REF=$(cat "$BASE/current/source-ref") for kind in branch tag commit; do
exec bash "$BASE/current/install.sh" if curl --proto '=https' --proto-redir '=https' --tlsv1.2 -fsSL --retry 3 --connect-timeout 15 --max-time 180 \
;; "${AWG_GUS_REPO_URL%.git}/raw/$kind/$AWG_GUS_REF/install.sh" -o "$installer"; then
*) echo 'Использование: awg-gus-bot status|logs|check|restart|stop|start|setup|update'; exit 2 ;; downloaded=1
esac break
fi
done
((downloaded)) || { echo 'Не удалось скачать установщик'; exit 1; }
bash "$installer" --update
else
exec python3 "$BASE/current/run.py" manage "$@"
fi
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.1.0" __version__ = "0.2.0"
+1
View File
@@ -176,6 +176,7 @@ class Bot:
s = d.get("server") or {} s = d.get("server") or {}
state = "🟢 работает" if s.get("up") else "🔴 не поднят" state = "🟢 работает" if s.get("up") else "🔴 не поднят"
text = (f"<b>🪿 AWG для Гуся · {__version__}</b>\n" 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"Toolza: {esc(d.get('version', '?'))}\n"
f"Сервер: {esc(d.get('host', '?'))}\nAWG: {state}\n" f"Сервер: {esc(d.get('host', '?'))}\nAWG: {state}\n"
f"Клиенты: {s.get('clients', 0)} · онлайн {s.get('online', 0)}\n" f"Клиенты: {s.get('clients', 0)} · онлайн {s.get('online', 0)}\n"
+11
View File
@@ -21,6 +21,7 @@ from gusvoice_bot import Client, GusVoiceApiError
from . import __version__ from . import __version__
from .bot import Bot from .bot import Bot
from .core import Awg, Delivery, State, client_for from .core import Awg, Delivery, State, client_for
from .profiles import assert_unique_bot
DEFAULT_CONFIG = "/etc/awg-gus-bot.json" DEFAULT_CONFIG = "/etc/awg-gus-bot.json"
DEFAULT_STATE = "/var/lib/awg-gus-bot" DEFAULT_STATE = "/var/lib/awg-gus-bot"
@@ -47,6 +48,9 @@ def validate_config(d):
for key in ("awg2", "state_dir"): for key in ("awg2", "state_dir"):
if not Path(d[key]).is_absolute(): if not Path(d[key]).is_absolute():
raise ValueError(f"{key}: нужен абсолютный путь") raise ValueError(f"{key}: нужен абсолютный путь")
if "label" in d and (not isinstance(d["label"], str) or len(d["label"]) > 80
or any(ord(c) < 32 for c in d["label"])):
raise ValueError("Название подключения должно быть одной строкой до 80 символов")
return d return d
@@ -122,6 +126,8 @@ def configure(args):
if client.get_webhook_info().get("url"): if client.get_webhook_info().get("url"):
raise ValueError("У этого бота настроен webhook. Создайте отдельного бота для AWG.") raise ValueError("У этого бота настроен webhook. Создайте отдельного бота для AWG.")
resolved = validate_url(client.api_url()) resolved = validate_url(client.api_url())
candidate = {"url": resolved, "token": token, "bot_id": me["id"]}
assert_unique_bot(args.config, candidate, getattr(args, "layout", None))
code = secrets.token_urlsafe(18) code = secrets.token_urlsafe(18)
print(f"\nОткройте личную беседу с @{me.get('username', '')} в Гусе.") print(f"\nОткройте личную беседу с @{me.get('username', '')} в Гусе.")
print("Отправьте следующее сообщение (код действует 10 минут):\n") print("Отправьте следующее сообщение (код действует 10 минут):\n")
@@ -130,6 +136,8 @@ def configure(args):
owner, chat, offset = pair(client, code) owner, chat, offset = pair(client, code)
d = {"url": resolved, "token": token, "owners": [owner], "bot_id": me["id"], d = {"url": resolved, "token": token, "owners": [owner], "bot_id": me["id"],
"awg2": args.awg2, "state_dir": args.state_dir} "awg2": args.awg2, "state_dir": args.state_dir}
if getattr(args, "label", None):
d["label"] = args.label
validate_config(d) validate_config(d)
state = State(Path(args.state_dir) / "state.sqlite3", resolved + "|" + me["id"]) state = State(Path(args.state_dir) / "state.sqlite3", resolved + "|" + me["id"])
state.set("offset", offset) state.set("offset", offset)
@@ -186,6 +194,9 @@ async def run(args):
def main(): def main():
if len(sys.argv) > 1 and sys.argv[1] == "manage":
from .manage import main as manage_main
return manage_main(sys.argv[2:])
parser = argparse.ArgumentParser(description="AWG Toolza bot for GusVoice") parser = argparse.ArgumentParser(description="AWG Toolza bot for GusVoice")
parser.add_argument("command", choices=["run", "setup", "check"]) parser.add_argument("command", choices=["run", "setup", "check"])
parser.add_argument("--config", default=DEFAULT_CONFIG) parser.add_argument("--config", default=DEFAULT_CONFIG)
+259
View File
@@ -0,0 +1,259 @@
"""Local CLI for independently managed systemd connections to GusVoice."""
from __future__ import annotations
import argparse
import asyncio
import contextlib
import os
from pathlib import Path
import shutil
import sqlite3
import subprocess
import sys
import tempfile
import time
from types import SimpleNamespace
from .core import Awg
from .main import check, configure, load_config
from .profiles import Layout, profile_name
def systemctl(*args, check=True):
result = subprocess.run(["systemctl", *args], capture_output=True, text=True)
if check and result.returncode:
raise RuntimeError(f"systemctl {args[0]} завершился с кодом {result.returncode}")
return result
@contextlib.contextmanager
def install_lock(layout):
import fcntl
path = layout.root / "run/lock/awg-gus-install.lock"
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a") as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError as exc:
raise ValueError("Другая установка или настройка уже выполняется") from exc
yield
def install_units(layout):
folder = layout.root / "etc/systemd/system"
folder.mkdir(parents=True, exist_ok=True)
for unit, config in [
("awg-gus-bot.service", "/etc/awg-gus-bot.json"),
("awg-gus-bot@.service", "/etc/awg-gus-bot/instances/%i.json"),
]:
text = f"""[Unit]
Description=AWG Toolza bot for GusVoice ({'default' if '%' not in config else '%i'})
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=5
[Service]
Type=simple
User=root
WorkingDirectory=/opt/awg-gus-bot/current
ExecStart={sys.executable} /opt/awg-gus-bot/current/run.py run --config {config}
Environment=PYTHONUNBUFFERED=1
UMask=0077
Restart=on-failure
RestartSec=10
TimeoutStopSec=20
KillMode=control-group
[Install]
WantedBy=multi-user.target
"""
path = folder / unit
if not path.exists() or path.read_text(encoding="utf-8") != text:
temporary = path.with_suffix(path.suffix + ".new")
temporary.write_text(text, encoding="utf-8")
temporary.chmod(0o644)
temporary.replace(path)
def database_copy(source, destination):
with contextlib.closing(sqlite3.connect(source)) as src:
with contextlib.closing(sqlite3.connect(destination)) as dst:
src.backup(dst)
def setup_profile(name, *, reconfigure=False, layout=None, setup_fn=configure, check_fn=check):
layout = layout or Layout()
name = profile_name(name)
path, state, unit = layout.config(name), layout.state(name), layout.unit(name)
exists = path.is_file()
if exists and not reconfigure:
raise ValueError(f"Подключение {name} уже существует. Для него используйте setup {name}.")
if reconfigure and not exists:
raise ValueError(f"Подключение {name} не найдено")
if exists:
previous = load_config(path)
awg2, state = previous["awg2"], Path(previous["state_dir"])
else:
awg2 = shutil.which("awg2")
if not awg2:
raise ValueError("Команда awg2 не найдена. Сначала установите AWG Toolza.")
# This is read-only and happens before stopping the selected connection.
asyncio.run(Awg(awg2).call("version", timeout=30))
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
state.mkdir(parents=True, exist_ok=True, mode=0o700)
state.chmod(0o700)
database = state / "state.sqlite3"
was_active = systemctl("is-active", "--quiet", unit, check=False).returncode == 0
was_enabled = systemctl("is-enabled", "--quiet", unit, check=False).returncode == 0
with tempfile.TemporaryDirectory(prefix=".setup-", dir=state) as temporary:
backup = Path(temporary)
if exists:
shutil.copy2(path, backup / "config")
had_database = database.exists()
database_saved = False
if was_active:
systemctl("stop", unit)
try:
if had_database:
database_copy(database, backup / "state")
database_saved = True
args = SimpleNamespace(config=str(path), awg2=awg2, state_dir=str(state),
label=name, layout=layout)
setup_fn(args)
asyncio.run(check_fn(args))
install_units(layout)
systemctl("daemon-reload")
systemctl("enable", unit)
systemctl("reset-failed", unit, check=False)
systemctl("restart", unit)
time.sleep(2)
if systemctl("is-active", "--quiet", unit, check=False).returncode:
raise RuntimeError(f"Служба {unit} не запустилась; откатываю это подключение")
except BaseException:
systemctl("stop", unit, check=False)
if exists:
shutil.copy2(backup / "config", path)
else:
path.unlink(missing_ok=True)
if database_saved:
database_copy(backup / "state", database)
elif not had_database:
for suffix in ("", "-journal", "-wal", "-shm"):
database.with_name(database.name + suffix).unlink(missing_ok=True)
if not was_enabled:
systemctl("disable", unit, check=False)
if was_active:
systemctl("reset-failed", unit, check=False)
systemctl("start", unit, check=False)
raise
print(f"Подключение {name} готово. Отправьте /menu боту на этом сервере Гуся.")
def choose_existing(layout, name):
if name:
profile_name(name)
if name not in layout.names():
raise ValueError(f"Подключение {name} не найдено")
return name
if "default" in layout.names():
return "default"
if len(layout.names()) == 1:
return layout.names()[0]
raise ValueError("Укажите имя подключения; список: awg-gus-bot list")
def list_profiles(layout):
if not layout.names():
print("Подключений пока нет.")
for name in layout.names():
try:
data = load_config(layout.config(name))
status = systemctl("is-active", layout.unit(name), check=False).stdout.strip()
print(f"{name}: {data['url']} — {status or 'inactive'}")
except (ValueError, KeyError):
print(f"{name}: ошибка настроек; проверьте это подключение отдельно")
def dispatch(command, name, layout):
if command == "menu":
list_profiles(layout)
print("\n1. Добавить ещё одного Гуся\n2. Обновить программу для всех подключений\n"
"3. Список подключений\n4. Перенастроить подключение\n0. Выход")
choice = input("Выберите действие: ").strip()
if choice == "0":
return
if choice == "2":
subprocess.run(["/usr/local/bin/awg-gus-bot", "update"], check=True)
return
command = {"1": "add", "3": "list", "4": "setup"}.get(choice)
if not command:
raise ValueError("Неизвестный пункт меню")
if command == "setup":
name = input("Имя подключения [default]: ").strip() or "default"
if command in {"list", "status"} and not name:
list_profiles(layout)
elif command in {"add", "setup"}:
if not sys.stdin.isatty():
raise ValueError("Для привязки нужен терминал")
if command == "add":
name = name or input("Имя нового подключения (например gus2): ").strip()
else:
name = choose_existing(layout, name)
with install_lock(layout):
setup_profile(name, reconfigure=command == "setup", layout=layout)
elif command == "services":
for name in layout.names():
print(layout.unit(name))
elif command == "validate":
for name in layout.names():
load_config(layout.config(name))
elif command == "install-units":
install_units(layout)
elif command == "check":
failures = []
names = [choose_existing(layout, name)] if name else layout.names()
for item in names:
try:
print(f"Проверяю {item}…", flush=True)
asyncio.run(check(SimpleNamespace(config=str(layout.config(item)))))
except Exception as exc:
print(f"{item}: {type(exc).__name__}; проверьте адрес, токен и доступность узла.")
failures.append(item)
if failures:
raise ValueError("Не прошли проверку: " + ", ".join(failures))
elif command in {"start", "stop", "restart", "enable", "disable", "status", "logs"}:
name = choose_existing(layout, name)
unit = layout.unit(name)
if command == "logs":
subprocess.run(["journalctl", "-u", unit, "-n", "80", "--no-pager"], check=True)
elif command == "status":
subprocess.run(["systemctl", "status", unit, "--no-pager"], check=True)
else:
with install_lock(layout):
systemctl(command, *(["--now"] if command in {"enable", "disable"} else []), unit)
else:
raise ValueError("Неизвестная команда")
def main(argv=None):
parser = argparse.ArgumentParser(description="Несколько Гусей → один AWG-сервер")
parser.add_argument("command", nargs="?", default="menu", choices=[
"menu", "add", "setup", "list", "check", "status", "logs", "start", "stop", "restart",
"enable", "disable", "services", "validate", "install-units"])
parser.add_argument("name", nargs="?")
args = parser.parse_args(argv)
os.umask(0o077)
try:
if os.name != "posix" or os.geteuid() != 0:
raise ValueError("Запустите на AWG-сервере через sudo awg-gus-bot")
dispatch(args.command, args.name, Layout())
except (ValueError, RuntimeError) as exc:
print(f"Ошибка: {exc}", file=sys.stderr)
sys.exit(1)
except KeyboardInterrupt:
print("Настройка отменена.", file=sys.stderr)
sys.exit(130)
except Exception as exc:
print(f"Ошибка: {type(exc).__name__}. Проверьте сеть и журнал выбранной службы.", file=sys.stderr)
sys.exit(1)
+60
View File
@@ -0,0 +1,60 @@
"""One local connection per GusVoice bot; legacy v1 paths remain valid."""
from dataclasses import dataclass
import json
from pathlib import Path
import re
import uuid
def profile_name(value):
if not isinstance(value, str) or not re.fullmatch(r"[a-z][a-z0-9_-]{0,31}", value):
raise ValueError("Имя подключения: строчная латиница, цифры, _ и -, до 32 символов; первая — буква.")
return value
@dataclass(frozen=True)
class Layout:
root: Path = Path("/")
def config(self, name):
profile_name(name)
return self.root / ("etc/awg-gus-bot.json" if name == "default"
else f"etc/awg-gus-bot/instances/{name}.json")
def state(self, name):
profile_name(name)
base = self.root / "var/lib/awg-gus-bot"
return base if name == "default" else base / "instances" / name
def names(self):
names = ["default"] if self.config("default").is_file() else []
for path in sorted((self.root / "etc/awg-gus-bot/instances").glob("*.json")):
name = profile_name(path.stem)
if name == "default":
raise ValueError("Имя default зарезервировано для первого подключения.")
names.append(name)
return names
@staticmethod
def unit(name):
profile_name(name)
return "awg-gus-bot.service" if name == "default" else f"awg-gus-bot@{name}.service"
def assert_unique_bot(target, candidate, layout=None):
"""Before polling: aliases/rotated tokens must not steal another bot's updates."""
layout = layout or Layout()
target = Path(target).resolve()
for name in layout.names():
path = layout.config(name)
try:
existing = json.loads(path.read_text(encoding="utf-8"))
same_bot = uuid.UUID(existing["bot_id"]) == uuid.UUID(candidate["bot_id"])
same_token = existing["token"] == candidate["token"]
except (ValueError, KeyError, TypeError, AttributeError) as exc:
raise ValueError(f"Проверьте настройки подключения {name}: неверный формат.") from exc
if path.resolve() == target:
if not same_bot or existing["url"] != candidate["url"]:
raise ValueError("Это другой бот/сервер. Добавьте новое подключение вместо перенастройки старого.")
elif same_bot or same_token:
raise ValueError(f"Этот бот уже подключён как {name}. Повторный polling отключит его работу.")
+91 -106
View File
@@ -1,51 +1,63 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# AWG Gus bot: install/update only this service; existing awg2 and Telegram bot stay in place. # Install shared code, preserving the independent configuration/state of every Gus.
set -Eeuo pipefail set -Eeuo pipefail
umask 077 umask 077
REPO_URL="${AWG_GUS_REPO_URL:-https://gitea.bvrdo.online/wenil/awg-gus-bot}" REPO_URL="${AWG_GUS_REPO_URL:-https://gitea.bvrdo.online/wenil/awg-gus-bot}"
REF="${AWG_GUS_REF:-main}" REF="${AWG_GUS_REF:-main}"
BASE=/opt/awg-gus-bot BASE=/opt/awg-gus-bot
CONF=/etc/awg-gus-bot.json
STATE=/var/lib/awg-gus-bot
SERVICE=awg-gus-bot.service
SRC="" SRC=""
RECONFIGURE=0 ACTION=""
INSTANCE=""
die() { printf 'Ошибка: %s\n' "$*" >&2; exit 1; } die() { printf 'Ошибка: %s\n' "$*" >&2; exit 1; }
usage() { usage() {
cat <<'EOF' cat <<'EOF'
Установка/обновление отдельного AWG-бота для GusVoice: AWG для Гуся v2: несколько серверов Гуся → один AWG.
sudo bash install.sh sudo bash install.sh # установка / меню обновления и добавления
sudo bash install.sh --src /path/to/awg-gus-bot sudo bash install.sh --update # обновить код всех подключений
sudo bash install.sh --configure sudo bash install.sh --add [--instance gus2]
sudo bash install.sh --configure [--instance default]
sudo bash install.sh --src /path/to/source --update
Нужны: Linux с systemd, Python 3.10+, установленный awg2 с JSON API. Для каждого Гуся нужны его адрес, собственный токен бота и привязка владельца.
При первой установке запросит HTTPS-адрес Гуся и токен, затем покажет код привязки. Текущее подключение v1 сохраняется под именем default. VPN-клиенты общие.
Существующая служба awg-bot не меняется. VPN-клиенты у обоих ботов общие.
EOF EOF
} }
while (($#)); do while (($#)); do
case "$1" in case "$1" in
--src) [[ $# -ge 2 ]] || die 'После --src нужен каталог'; SRC="$2"; shift 2 ;; --src) [[ $# -ge 2 ]] || die 'После --src нужен каталог'; SRC="$2"; shift 2 ;;
--configure) RECONFIGURE=1; shift ;; --instance) [[ $# -ge 2 ]] || die 'После --instance нужно имя'; INSTANCE="$2"; shift 2 ;;
--add|--configure|--update)
[[ -z "$ACTION" ]] || die 'Выберите одно действие'; ACTION="${1#--}"; shift ;;
-h|--help) usage; exit 0 ;; -h|--help) usage; exit 0 ;;
*) die "Неизвестный параметр: $1" ;; *) die "Неизвестный параметр: $1" ;;
esac esac
done done
[[ -z "$INSTANCE" || "$INSTANCE" =~ ^[a-z][a-z0-9_-]{0,31}$ ]] || die 'Некорректное имя подключения'
[[ $EUID -eq 0 ]] || die 'Запустите через sudo bash install.sh' [[ $EUID -eq 0 ]] || die 'Запустите через sudo bash install.sh'
[[ $(uname -s) == Linux ]] || die 'Устанавливается на Linux-сервер с awg2' [[ $(uname -s) == Linux ]] || die 'Устанавливается на Linux-сервер с awg2'
command -v systemctl >/dev/null || die 'Нужен systemd' command -v systemctl >/dev/null && [[ -d /run/systemd/system ]] || die 'Нужен запущенный systemd'
[[ -d /run/systemd/system ]] || die 'systemd не запущен' command -v flock >/dev/null || die 'Нужен flock (util-linux)'
command -v flock >/dev/null || die 'Нужен flock (пакет util-linux)' shopt -s nullglob
configs=(/etc/awg-gus-bot/instances/*.json)
[[ ! -f /etc/awg-gus-bot.json ]] || configs+=(/etc/awg-gus-bot.json)
if [[ -z "$ACTION" ]]; then
if ((${#configs[@]} == 0)); then
ACTION=add
INSTANCE="${INSTANCE:-default}"
elif [[ -t 0 ]]; then
printf 'AWG для Гуся v2\n1. Обновить все подключения\n2. Добавить ещё одного Гуся\n0. Выход\n'
read -r -p 'Выберите действие: ' choice
case "$choice" in 1) ACTION=update ;; 2) ACTION=add ;; 0) exit 0 ;; *) die 'Неизвестный пункт' ;; esac
else
ACTION=update
fi
fi
if [[ "$ACTION" != update ]]; then [[ -t 0 ]] || die 'Для привязки нужен терминал'; fi
mkdir -p /run/lock mkdir -p /run/lock
exec 9>/run/lock/awg-gus-install.lock exec 9>/run/lock/awg-gus-install.lock
flock -n 9 || die 'Другой установщик уже работает' flock -n 9 || die 'Другая установка или настройка уже выполняется'
AWG2="$(command -v awg2 || true)" AWG2=$(command -v awg2 || true)
[[ -n "$AWG2" ]] || die 'Сначала установите AWG Toolza: команда awg2 не найдена' [[ -n "$AWG2" ]] || die 'Сначала установите AWG Toolza: команда awg2 не найдена'
missing=() missing=()
for pair in 'python3:python3' 'curl:curl' 'tar:tar' 'qrencode:qrencode'; do for pair in 'python3:python3' 'curl:curl' 'tar:tar' 'qrencode:qrencode'; do
command -v "${pair%%:*}" >/dev/null || missing+=("${pair#*:}") command -v "${pair%%:*}" >/dev/null || missing+=("${pair#*:}")
@@ -55,52 +67,44 @@ if ((${#missing[@]})); then
apt-get update -qq apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "${missing[@]}" ca-certificates DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "${missing[@]}" ca-certificates
fi fi
PYTHON=$(command -v python3)
python3 -c 'import sys; assert sys.version_info >= (3,10), "Нужен Python 3.10+"' python3 -c 'import sys; assert sys.version_info >= (3,10), "Нужен Python 3.10+"'
"$AWG2" api version | python3 -c 'import sys,json; d=json.loads(sys.stdin.read().strip().splitlines()[-1]); sys.exit(0 if d.get("ok") is True else 1)' \ "$AWG2" api version | python3 -c 'import sys,json; d=json.loads(sys.stdin.read().strip().splitlines()[-1]); sys.exit(0 if d.get("ok") is True else 1)' \
|| die 'awg2 api version не отвечает. Обновите AWG Toolza штатным способом' || die 'awg2 api version не отвечает'
TMP=$(mktemp -d /tmp/awg-gus-install.XXXXXXXX) TMP=$(mktemp -d /tmp/awg-gus-install.XXXXXXXX)
old="" old=""
activated=0 activated=0
service_stopped=0 active=()
configuring=0 artifacts=(/etc/systemd/system/awg-gus-bot.service /etc/systemd/system/awg-gus-bot@.service /usr/local/bin/awg-gus-bot)
was_active=0
systemctl is-active --quiet "$SERVICE" && was_active=1
cleanup() { cleanup() {
local rc=$? local rc=$? i
trap - EXIT trap - EXIT
if ((rc != 0 && (activated || service_stopped || configuring))); then if ((rc != 0 && activated)); then
printf 'Запуск новой версии не удался. Откатываю только службу Гуся.\n' >&2 printf 'Обновление не удалось. Возвращаю прежний код и службы Гуся.\n' >&2
systemctl stop "$SERVICE" || true for unit in "${active[@]}"; do systemctl stop "$unit" || true; done
if ((activated)) && [[ -n "$old" ]]; then if [[ -n "$old" ]]; then
ln -sfn "$old" "$BASE/.current-rollback" ln -sfn "$old" "$BASE/.current-rollback"
mv -Tf "$BASE/.current-rollback" "$BASE/current" mv -Tf "$BASE/.current-rollback" "$BASE/current"
elif ((activated)); then else
rm -f "$BASE/current" rm -f "$BASE/current"
fi fi
if [[ -f "$TMP/config.before" ]]; then for i in "${!artifacts[@]}"; do
install -m 600 "$TMP/config.before" "$CONF" if [[ -f "$TMP/artifact.$i" ]]; then
fi cp -p "$TMP/artifact.$i" "${artifacts[$i]}"
if [[ -f "$TMP/unit.before" ]]; then else
install -m 644 "$TMP/unit.before" "/etc/systemd/system/$SERVICE" rm -f "${artifacts[$i]}"
elif ((activated)) && [[ -z "$old" ]]; then fi
systemctl disable "$SERVICE" >/dev/null 2>&1 || true done
rm -f "/etc/systemd/system/$SERVICE"
fi
if [[ -f "$TMP/command.before" ]]; then
install -m 755 "$TMP/command.before" /usr/local/bin/awg-gus-bot
elif ((activated)) && [[ -z "$old" ]]; then
rm -f /usr/local/bin/awg-gus-bot
fi
systemctl daemon-reload systemctl daemon-reload
if ((was_active)) && [[ -n "$old" ]]; then systemctl start "$SERVICE" || true; fi for unit in "${active[@]}"; do
systemctl reset-failed "$unit" || true
systemctl start "$unit" || true
done
fi fi
[[ "$TMP" == /tmp/awg-gus-install.* && -d "$TMP" ]] && rm -rf -- "$TMP" [[ "$TMP" == /tmp/awg-gus-install.* && -d "$TMP" ]] && rm -rf -- "$TMP"
exit "$rc" exit "$rc"
} }
trap cleanup EXIT trap cleanup EXIT
if [[ -z "$SRC" ]]; then if [[ -z "$SRC" ]]; then
[[ "$REPO_URL" == https://* && "$REPO_URL" != *'@'* ]] || die 'Источник должен быть HTTPS без учётных данных в URL' [[ "$REPO_URL" == https://* && "$REPO_URL" != *'@'* ]] || die 'Источник должен быть HTTPS без учётных данных в URL'
[[ "$REF" =~ ^[A-Za-z0-9._-]+$ ]] || die 'Некорректная ветка/версия' [[ "$REF" =~ ^[A-Za-z0-9._-]+$ ]] || die 'Некорректная ветка/версия'
@@ -108,14 +112,12 @@ if [[ -z "$SRC" ]]; then
curl --proto '=https' --proto-redir '=https' --tlsv1.2 -fSL --retry 3 --connect-timeout 15 --max-time 180 \ curl --proto '=https' --proto-redir '=https' --tlsv1.2 -fSL --retry 3 --connect-timeout 15 --max-time 180 \
"${REPO_URL%.git}/archive/$REF.tar.gz" -o "$TMP/source.tar.gz" "${REPO_URL%.git}/archive/$REF.tar.gz" -o "$TMP/source.tar.gz"
mkdir "$TMP/source" mkdir "$TMP/source"
# Reject paths escaping extraction root and any links/devices in the source archive.
python3 - "$TMP/source.tar.gz" "$TMP/source" <<'PY' python3 - "$TMP/source.tar.gz" "$TMP/source" <<'PY'
import pathlib, sys, tarfile import pathlib, sys, tarfile
root = pathlib.Path(sys.argv[2]).resolve() root = pathlib.Path(sys.argv[2]).resolve()
with tarfile.open(sys.argv[1]) as archive: with tarfile.open(sys.argv[1]) as archive:
for m in archive.getmembers(): for m in archive.getmembers():
p = (root / m.name).resolve() if not (root / m.name).resolve().is_relative_to(root) or not (m.isfile() or m.isdir()):
if not p.is_relative_to(root) or not (m.isfile() or m.isdir()):
raise SystemExit('Небезопасный путь/тип в архиве') raise SystemExit('Небезопасный путь/тип в архиве')
archive.extractall(root) archive.extractall(root)
PY PY
@@ -124,68 +126,51 @@ PY
SRC="${dirs[0]}" SRC="${dirs[0]}"
fi fi
SRC=$(cd "$SRC" && pwd -P) SRC=$(cd "$SRC" && pwd -P)
[[ -f "$SRC/run.py" && -f "$SRC/awg_gus/bot.py" && -f "$SRC/vendor/gusvoice_bot/client.py" && -f "$SRC/install.sh" ]] \ [[ -f "$SRC/run.py" && -f "$SRC/awg_gus/manage.py" && -f "$SRC/vendor/gusvoice_bot/client.py" ]] || die 'Неполный исходный код v2'
|| die 'В источнике нет файлов AWG Gus bot'
python3 -m compileall -q "$SRC/awg_gus" "$SRC/vendor/gusvoice_bot" "$SRC/run.py" python3 -m compileall -q "$SRC/awg_gus" "$SRC/vendor/gusvoice_bot" "$SRC/run.py"
mkdir -p "$BASE/releases" "$STATE" # Validate locally: an unavailable Gus must not prevent updating the other connections.
chmod 700 "$BASE" "$BASE/releases" "$STATE" python3 "$SRC/run.py" manage validate
python3 "$SRC/run.py" manage services > "$TMP/services"
while IFS= read -r unit; do
if systemctl is-active --quiet "$unit"; then active+=("$unit"); fi
done < "$TMP/services"
mkdir -p "$BASE/releases"
chmod 700 "$BASE" "$BASE/releases"
old=$(readlink "$BASE/current" || true)
if [[ -n "$old" && "$old" != "$BASE/releases/"* ]]; then die 'current указывает вне каталога версий'; fi
release="$BASE/releases/$(date -u +%Y%m%dT%H%M%SZ)-$$" release="$BASE/releases/$(date -u +%Y%m%dT%H%M%SZ)-$$"
mkdir "$release" mkdir "$release"
cp -a "$SRC/awg_gus" "$SRC/vendor" "$SRC/run.py" "$SRC/install.sh" "$SRC/awg-gus-bot" "$SRC/README.md" "$SRC/LICENSE" "$SRC/licenses" "$release/" cp -a "$SRC/awg_gus" "$SRC/vendor" "$SRC/run.py" "$SRC/install.sh" "$SRC/awg-gus-bot" "$SRC/README.md" "$SRC/LICENSE" "$SRC/licenses" "$release/"
chmod -R go-rwx "$release" chmod -R go-rwx "$release"
printf '%s\n' "$REPO_URL" > "$release/source-url" printf '%s\n' "$REPO_URL" > "$release/source-url"
printf '%s\n' "$REF" > "$release/source-ref" printf '%s\n' "$REF" > "$release/source-ref"
[[ ! -f "$CONF" ]] || cp -p "$CONF" "$TMP/config.before" for i in "${!artifacts[@]}"; do
[[ ! -f "/etc/systemd/system/$SERVICE" ]] || cp -p "/etc/systemd/system/$SERVICE" "$TMP/unit.before" [[ ! -f "${artifacts[$i]}" ]] || cp -p "${artifacts[$i]}" "$TMP/artifact.$i"
[[ ! -f /usr/local/bin/awg-gus-bot ]] || cp -p /usr/local/bin/awg-gus-bot "$TMP/command.before" done
old=$(readlink "$BASE/current" || true)
if [[ -n "$old" && "$old" != "$BASE/releases/"* ]]; then die 'current указывает вне каталога версий'; fi
if [[ ! -s "$CONF" ]] || ((RECONFIGURE)); then
[[ -t 0 ]] || die 'Для первого запуска нужен терминал: скачайте скрипт в файл, затем sudo bash install.sh'
# Stop only our bot to release its polling connection while pairing.
if ((was_active)); then systemctl stop "$SERVICE"; service_stopped=1; fi
configuring=1
if ! python3 "$release/run.py" setup --config "$CONF" --awg2 "$AWG2" --state-dir "$STATE"; then
die 'Настройка не завершена'
fi
fi
chmod 600 "$CONF"
python3 "$release/run.py" check --config "$CONF"
activated=1 activated=1
cat > "/etc/systemd/system/$SERVICE" <<EOF for unit in "${active[@]}"; do systemctl stop "$unit"; done
[Unit] python3 "$release/run.py" manage install-units
Description=AWG Toolza bot for GusVoice
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=5
[Service]
Type=simple
User=root
WorkingDirectory=$BASE/current
ExecStart=$PYTHON $BASE/current/run.py run --config $CONF
Environment=PYTHONUNBUFFERED=1
UMask=0077
Restart=on-failure
RestartSec=10
TimeoutStopSec=20
KillMode=control-group
[Install]
WantedBy=multi-user.target
EOF
ln -sfn "$release" "$BASE/.current-next" ln -sfn "$release" "$BASE/.current-next"
mv -Tf "$BASE/.current-next" "$BASE/current" mv -Tf "$BASE/.current-next" "$BASE/current"
install -m 755 "$release/awg-gus-bot" /usr/local/bin/awg-gus-bot install -m 755 "$release/awg-gus-bot" /usr/local/bin/awg-gus-bot
systemctl daemon-reload systemctl daemon-reload
systemctl enable "$SERVICE" >/dev/null for unit in "${active[@]}"; do
systemctl restart "$SERVICE" systemctl reset-failed "$unit" || true
sleep 3 systemctl start "$unit"
systemctl is-active --quiet "$SERVICE" || die 'Служба не запустилась. journalctl -u awg-gus-bot -n 30' done
if ((${#active[@]})); then
sleep 3
for unit in "${active[@]}"; do
systemctl is-active --quiet "$unit" || die "Не запустилась $unit; откатываю обновление"
done
fi
activated=0 activated=0
printf '\nГотово. В личной беседе с ботом Гуся отправьте /menu.\n' printf 'Код обновлён. Существующие настройки и состояния сохранены.\n'
printf 'Служба: awg-gus-bot. Обновление: sudo awg-gus-bot update\n' # Pairing has its own lock and rollback, limited to the chosen connection.
printf 'Telegram-бот и awg2 не заменялись. Оба бота управляют общими VPN-клиентами.\n' flock -u 9
exec 9>&-
case "$ACTION" in
add) args=(manage add); [[ -z "$INSTANCE" ]] || args+=("$INSTANCE"); python3 "$BASE/current/run.py" "${args[@]}" ;;
configure) python3 "$BASE/current/run.py" manage setup "${INSTANCE:-default}" ;;
update) printf 'Добавить ещё одного Гуся: sudo awg-gus-bot add\n' ;;
esac
+240
View File
@@ -0,0 +1,240 @@
import asyncio
import contextlib
import io
import json
from pathlib import Path
import sys
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import AsyncMock, Mock, patch
ROOT = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(ROOT), str(ROOT / "vendor")]
from awg_gus import manage
from awg_gus.bot import Bot
from awg_gus.core import State
from awg_gus.main import configure, load_config, save_config
from awg_gus.profiles import Layout, assert_unique_bot, profile_name
from test_bot import FakeAwg, FakeDelivery, OWNER, OTHER, callback, message
BOT_A = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
BOT_B = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
def config(layout, name, bot=BOT_A):
return dict(url=f"https://{name}.example.test", token="gvb_test_" + bot,
bot_id=bot, owners=[OWNER], awg2=str(layout.root / "bin/awg2"),
state_dir=str(layout.state(name)))
class FakeSystemd:
def __init__(self):
self.calls = []
self.active = {Layout.unit("default")}
self.enabled = set(self.active)
self.fail_restart = False
def __call__(self, *args, check=True):
self.calls.append(args)
action, unit = args[0], args[-1]
rc = 0
if action == "is-active":
rc = 0 if unit in self.active else 3
elif action == "is-enabled":
rc = 0 if unit in self.enabled else 1
elif action == "stop":
self.active.discard(unit)
elif action == "disable":
self.enabled.discard(unit)
elif action == "enable":
self.enabled.add(unit)
elif action in {"start", "restart"}:
if action == "restart" and self.fail_restart:
raise RuntimeError("simulated startup failure")
self.active.add(unit)
return SimpleNamespace(returncode=rc, stdout="active" if rc == 0 else "inactive")
class ProfileTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.layout = Layout(Path(self.tmp.name))
save_config(self.layout.config("default"), config(self.layout, "default"))
def tearDown(self):
self.tmp.cleanup()
def test_legacy_paths_preserved_and_new_paths_isolated(self):
self.assertEqual(self.layout.names(), ["default"])
self.assertEqual(self.layout.config("default"), self.layout.root / "etc/awg-gus-bot.json")
self.assertNotEqual(self.layout.state("gus2"), self.layout.state("default"))
for name in ["../bad", "x/y", "bad;id", "a.service", "-name", "A", "a"*33]:
with self.subTest(name=name), self.assertRaises(ValueError):
profile_name(name)
def test_duplicate_bot_rejected_even_with_alias_url_and_rotated_token(self):
candidate = config(self.layout, "gus2")
candidate["token"] = "gvb_rotated"
with self.assertRaisesRegex(ValueError, "default"):
assert_unique_bot(self.layout.config("gus2"), candidate, self.layout)
def test_second_bot_and_existing_token_rotation_are_allowed(self):
candidate = config(self.layout, "gus2", BOT_B)
assert_unique_bot(self.layout.config("gus2"), candidate, self.layout)
original = config(self.layout, "default")
original["token"] = "gvb_rotated"
assert_unique_bot(self.layout.config("default"), original, self.layout)
def test_existing_profile_cannot_silently_switch_identity(self):
candidate = config(self.layout, "default", BOT_B)
with self.assertRaisesRegex(ValueError, "другой бот"):
assert_unique_bot(self.layout.config("default"), candidate, self.layout)
def test_duplicate_is_rejected_before_pairing_or_polling(self):
client = Mock()
client.get_me.return_value = dict(id=BOT_A, api_version=3,
features=["commands", "keyboards", "parse_mode"])
client.get_webhook_info.return_value = {}
client.api_url.return_value = "https://alias.example.test"
args = SimpleNamespace(config=str(self.layout.config("gus2")), layout=self.layout)
with patch("awg_gus.main.sys.stdin.isatty", return_value=True), \
patch("builtins.input", return_value="https://alias.example.test"), \
patch("awg_gus.main.getpass.getpass", return_value="gvb_rotated"), \
patch("awg_gus.main.Client", return_value=client), \
patch("awg_gus.main.pair") as pair, contextlib.redirect_stdout(io.StringIO()):
with self.assertRaisesRegex(ValueError, "default"):
configure(args)
pair.assert_not_called()
client.get_updates.assert_not_called()
self.assertFalse(self.layout.config("gus2").exists())
def test_add_starts_only_new_unit_and_preserves_legacy_config(self):
original = self.layout.config("default").read_bytes()
systemd = FakeSystemd()
def setup(args):
save_config(args.config, config(self.layout, "gus2", BOT_B))
with patch("awg_gus.manage.systemctl", systemd), \
patch("awg_gus.manage.shutil.which", return_value=str(self.layout.root / "bin/awg2")), \
patch("awg_gus.manage.Awg.call", new_callable=AsyncMock), \
patch("awg_gus.manage.time.sleep"), contextlib.redirect_stdout(io.StringIO()):
manage.setup_profile("gus2", layout=self.layout, setup_fn=setup, check_fn=AsyncMock())
self.assertEqual(self.layout.config("default").read_bytes(), original)
self.assertEqual(self.layout.names(), ["default", "gus2"])
self.assertEqual(systemd.active, {Layout.unit("default"), Layout.unit("gus2")})
self.assertFalse(any(c[-1] == Layout.unit("default") for c in systemd.calls))
template = (self.layout.root / "etc/systemd/system/awg-gus-bot@.service").read_text()
self.assertIn("/instances/%i.json", template)
self.assertIn("Restart=on-failure", template)
def test_failed_add_rolls_back_only_new_profile(self):
original = self.layout.config("default").read_bytes()
systemd = FakeSystemd()
systemd.fail_restart = True
def setup(args):
save_config(args.config, config(self.layout, "gus2", BOT_B))
s = State(Path(args.state_dir) / "state.sqlite3", "new")
s.set("offset", 22)
s.db.close()
with patch("awg_gus.manage.systemctl", systemd), \
patch("awg_gus.manage.shutil.which", return_value=str(self.layout.root / "bin/awg2")), \
patch("awg_gus.manage.Awg.call", new_callable=AsyncMock):
with self.assertRaises(RuntimeError):
manage.setup_profile("gus2", layout=self.layout, setup_fn=setup, check_fn=AsyncMock())
self.assertEqual(self.layout.config("default").read_bytes(), original)
self.assertFalse(self.layout.config("gus2").exists())
self.assertFalse((self.layout.state("gus2") / "state.sqlite3").exists())
self.assertEqual(systemd.active, {Layout.unit("default")})
self.assertNotIn(Layout.unit("gus2"), systemd.enabled)
def test_failed_reconfigure_restores_cursor_config_and_service(self):
original = self.layout.config("default").read_bytes()
database = self.layout.state("default") / "state.sqlite3"
s = State(database, "original")
s.set("offset", 91)
s.db.close()
systemd = FakeSystemd()
def setup(args):
changed = config(self.layout, "default")
changed["token"] = "gvb_changed"
save_config(args.config, changed)
s = State(database, "original")
s.set("offset", 999)
s.db.close()
raise ValueError("pairing failed")
with patch("awg_gus.manage.systemctl", systemd), \
patch("awg_gus.manage.Awg.call", new_callable=AsyncMock):
with self.assertRaisesRegex(ValueError, "pairing failed"):
manage.setup_profile("default", reconfigure=True, layout=self.layout,
setup_fn=setup, check_fn=AsyncMock())
self.assertEqual(self.layout.config("default").read_bytes(), original)
s = State(database, "original")
self.assertEqual(s.get("offset"), 91)
s.db.close()
self.assertIn(Layout.unit("default"), systemd.active)
def test_check_continues_when_one_gus_is_unreachable(self):
save_config(self.layout.config("gus2"), config(self.layout, "gus2", BOT_B))
seen = []
async def fake_check(args):
seen.append(args.config)
if args.config == str(self.layout.config("default")):
raise OSError("offline")
with patch("awg_gus.manage.check", fake_check), contextlib.redirect_stdout(io.StringIO()):
with self.assertRaisesRegex(ValueError, "default"):
manage.dispatch("check", None, self.layout)
self.assertEqual(len(seen), 2)
def test_failed_database_snapshot_does_not_restore_partial_backup(self):
database = self.layout.state("default") / "state.sqlite3"
s = State(database, "original")
s.set("offset", 91)
s.db.close()
systemd = FakeSystemd()
def bad_copy(source, destination):
Path(destination).write_bytes(b"partial backup")
raise OSError("disk full")
with patch("awg_gus.manage.systemctl", systemd), \
patch("awg_gus.manage.Awg.call", new_callable=AsyncMock), \
patch("awg_gus.manage.database_copy", side_effect=bad_copy) as copy:
with self.assertRaises(OSError):
manage.setup_profile("default", reconfigure=True, layout=self.layout)
self.assertEqual(copy.call_count, 1)
s = State(database, "original")
self.assertEqual(s.get("offset"), 91)
s.db.close()
self.assertIn(Layout.unit("default"), systemd.active)
class ParallelBotsTests(unittest.IsolatedAsyncioTestCase):
async def test_parallel_gus_updates_confirmations_and_owners_are_isolated(self):
with tempfile.TemporaryDirectory() as directory:
states = [State(Path(directory)/f"{i}.sqlite3", f"gus{i}") for i in range(2)]
api = FakeAwg()
deliveries = [FakeDelivery(), FakeDelivery()]
bots = [Bot({"owners": [owner]}, api, delivery, state)
for owner, delivery, state in zip([OWNER, OTHER], deliveries, states)]
async def feed(bot, event):
await bot.process(event)
await asyncio.gather(*bot.tasks.values())
try:
await asyncio.gather(feed(bots[0], callback(1, "delete:alice")),
feed(bots[1], message(1, "/menu", OTHER)))
token = next(iter(bots[0].confirmations))
await feed(bots[1], callback(2, "yes:"+token, OTHER))
self.assertNotIn(("client", "del", "alice"), api.calls)
await feed(bots[0], callback(2, "yes:"+token))
await feed(bots[1], message(3, "/clients", OTHER))
self.assertEqual(api.rows, []) # mutation visible from both connections
before = len(api.calls)
await feed(bots[1], callback(4, "conf:alice", OWNER))
self.assertEqual(len(api.calls), before)
self.assertEqual(states[0].get("offset"), 3)
self.assertEqual(states[1].get("offset"), 5)
finally:
for state in states:
state.db.close()
if __name__ == "__main__":
unittest.main()