diff --git a/README.md b/README.md index e657357..ec29711 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,39 @@ Работает рядом с Telegram-ботом: своя служба, настройки и состояние; оба интерфейса управляют одними VPN-клиентами. 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`. На других дистрибутивах установите эти программы самостоятельно. Python SDK включён в репозиторий; pip/PyPI для установки не нужны. -## Что умеет 0.1.0 +## Возможности бота - Главное меню со статусом сервера и клиента: работает/офлайн, IP, трафик. - Список клиентов с перелистыванием, создание, переименование и удаление. @@ -61,22 +93,23 @@ sudo bash awg-gus-install.sh Включите «Следить» в карточке нужного клиента. Отдельный переключатель состояния сервера — в «Уведомлениях». После запуска бот запоминает текущее состояние без рассылки и пишет только при изменении. -В первой версии через меню `awg2` остаются: установка/обновление AWG и модулей ядра, создание сервера, +Через меню `awg2` остаются: установка/обновление AWG и модулей ядра, создание сервера, настройка туннелей/DNS/обфускаторов, массовое создание, восстановление бэкапа и удаление всей установки. Это отдельный нативный клиент основных функций, а не полная копия всех экранов Telegram-бота. ## Сосуществование с Telegram -| Компонент | Этот бот | -|---|---| -| Служба | `awg-gus-bot.service` | -| Программа | `/opt/awg-gus-bot/current` → отдельная версия в `releases/` | -| Настройки | `/etc/awg-gus-bot.json`, права `600` | -| Состояние | `/var/lib/awg-gus-bot`, отдельная SQLite-база | -| Управление | `/usr/local/bin/awg-gus-bot` | +| Компонент | Первое подключение `default` | Дополнительное `gus2` | +|---|---|---| +| Служба | `awg-gus-bot.service` | `awg-gus-bot@gus2.service` | +| Настройки (права `600`) | `/etc/awg-gus-bot.json` | `/etc/awg-gus-bot/instances/gus2.json` | +| Состояние | `/var/lib/awg-gus-bot/state.sqlite3` | `/var/lib/awg-gus-bot/instances/gus2/state.sqlite3` | + +Общий код: `/opt/awg-gus-bot/current` → отдельная версия в `releases/`. +Команда управления: `/usr/local/bin/awg-gus-bot`. Установщик не меняет файлы и службу `awg-bot`, не обновляет `awg2` и не перезапускает VPN. -**Данные VPN общие:** если удалить клиента через Гуся, он исчезнет и в Telegram. +**Данные VPN общие:** если удалить клиента через любого Гуся, он исчезнет во всех Гусях и в Telegram. В проверенном upstream `awg2 api` изменяющие команды сериализованы общим `flock`. Если другой интерфейс занят длительной операцией, AWG может попросить повторить действие позже. @@ -87,24 +120,35 @@ sudo bash awg-gus-install.sh ## Обновление и управление ```bash -sudo awg-gus-bot update # загрузить новую версию из Gitea -sudo awg-gus-bot check # проверить токен, возможности Гуся и awg2 api -sudo awg-gus-bot status -sudo awg-gus-bot logs -sudo awg-gus-bot restart -sudo awg-gus-bot stop -sudo awg-gus-bot start -sudo awg-gus-bot setup # повторная настройка/привязка того же бота +sudo awg-gus-bot # меню управления подключениями +sudo awg-gus-bot add # добавить ещё одного Гуся +sudo awg-gus-bot list # имена, адреса, состояния всех подключений +sudo awg-gus-bot update # обновить общий код из Gitea +sudo awg-gus-bot check # проверить все подключения, включая недоступные +sudo awg-gus-bot check gus2 # проверить только второго Гуся +sudo awg-gus-bot status gus2 +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`. -Если служба не запустилась, установщик возвращает прежние программу/службу/настройки и запускает прежнюю службу, -если она работала до обновления. Старые версии автоматически не удаляются. +Если после обновления служба не запустилась, установщик возвращает прежний код и службы, затем запускает ранее работавшие подключения. +Остановленные подключения обновляются вместе со всеми, но автоматически не включаются. +Если не удалось добавить/перенастроить одного Гуся, восстанавливаются настройки и состояние только этого подключения. +Старые версии программы автоматически не удаляются. При смене на другой аккаунт бота база специально не переиспользуется: старые chat UUID и курсор событий принадлежат прежнему боту. -Для такого переноса сначала сохраните текущую установку и подготовьте новое состояние; `setup` рассчитан на перепривязку того же аккаунта -или обновление его токена. +Для другого аккаунта/инстанса используйте `add`; `setup` рассчитан на перепривязку того же аккаунта или обновление его токена. ## Ограничения и проверка на сервере @@ -125,7 +169,9 @@ sudo awg-gus-bot setup # повторная настройка/привяз 3. Проверить скачивание `.conf` и отображение QR; импортировать конфиг и проверить соединение. 4. Убедиться, что этот же клиент появился в Telegram-боте. 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-сервере. - GusVoice Python SDK **0.1.0** включён без изменений в `vendor/gusvoice_bot/` (MIT). diff --git a/awg-gus-bot b/awg-gus-bot index 5c4f503..595cfa4 100755 --- a/awg-gus-bot +++ b/awg-gus-bot @@ -1,18 +1,25 @@ #!/usr/bin/env bash set -Eeuo pipefail BASE=/opt/awg-gus-bot -case "${1:-status}" in - status) systemctl status awg-gus-bot --no-pager ;; - logs) journalctl -u awg-gus-bot -n 80 --no-pager ;; - restart|stop|start) [[ $EUID -eq 0 ]] || { echo 'Запустите через sudo'; exit 1; }; systemctl "$1" awg-gus-bot ;; - check) exec python3 "$BASE/current/run.py" check ;; - setup) exec bash "$BASE/current/install.sh" --src "$BASE/current" --configure ;; - update) - [[ $EUID -eq 0 ]] || { echo 'Запустите через sudo'; exit 1; } - export AWG_GUS_REPO_URL AWG_GUS_REF - AWG_GUS_REPO_URL=$(cat "$BASE/current/source-url") - AWG_GUS_REF=$(cat "$BASE/current/source-ref") - exec bash "$BASE/current/install.sh" - ;; - *) echo 'Использование: awg-gus-bot status|logs|check|restart|stop|start|setup|update'; exit 2 ;; -esac +if [[ "${1:-menu}" == update ]]; then + [[ $EUID -eq 0 ]] || { echo 'Запустите через sudo'; exit 1; } + export AWG_GUS_REPO_URL AWG_GUS_REF + AWG_GUS_REPO_URL=$(cat "$BASE/current/source-url") + AWG_GUS_REF=$(cat "$BASE/current/source-ref") + [[ "$AWG_GUS_REPO_URL" == https://* && "$AWG_GUS_REPO_URL" != *'@'* ]] || exit 2 + [[ "$AWG_GUS_REF" =~ ^[A-Za-z0-9._-]+$ ]] || exit 2 + installer=$(mktemp) + trap 'rm -f -- "$installer"' EXIT + downloaded=0 + for kind in branch tag commit; do + 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 + downloaded=1 + break + fi + done + ((downloaded)) || { echo 'Не удалось скачать установщик'; exit 1; } + bash "$installer" --update +else + exec python3 "$BASE/current/run.py" manage "$@" +fi diff --git a/awg_gus/__init__.py b/awg_gus/__init__.py index 3dc1f76..d3ec452 100644 --- a/awg_gus/__init__.py +++ b/awg_gus/__init__.py @@ -1 +1 @@ -__version__ = "0.1.0" +__version__ = "0.2.0" diff --git a/awg_gus/bot.py b/awg_gus/bot.py index 382ffe8..c0a3d27 100644 --- a/awg_gus/bot.py +++ b/awg_gus/bot.py @@ -176,6 +176,7 @@ class Bot: s = d.get("server") or {} state = "🟢 работает" if s.get("up") else "🔴 не поднят" text = (f"🪿 AWG для Гуся · {__version__}\n" + + (f"Подключение: {esc(self.config['label'])}\n" if self.config.get("label") else "") + f"Toolza: {esc(d.get('version', '?'))}\n" f"Сервер: {esc(d.get('host', '?'))}\nAWG: {state}\n" f"Клиенты: {s.get('clients', 0)} · онлайн {s.get('online', 0)}\n" diff --git a/awg_gus/main.py b/awg_gus/main.py index c6e7ce7..36112aa 100644 --- a/awg_gus/main.py +++ b/awg_gus/main.py @@ -21,6 +21,7 @@ from gusvoice_bot import Client, GusVoiceApiError from . import __version__ from .bot import Bot from .core import Awg, Delivery, State, client_for +from .profiles import assert_unique_bot DEFAULT_CONFIG = "/etc/awg-gus-bot.json" DEFAULT_STATE = "/var/lib/awg-gus-bot" @@ -47,6 +48,9 @@ def validate_config(d): for key in ("awg2", "state_dir"): if not Path(d[key]).is_absolute(): 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 @@ -122,6 +126,8 @@ def configure(args): if client.get_webhook_info().get("url"): raise ValueError("У этого бота настроен webhook. Создайте отдельного бота для AWG.") 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) print(f"\nОткройте личную беседу с @{me.get('username', '')} в Гусе.") print("Отправьте следующее сообщение (код действует 10 минут):\n") @@ -130,6 +136,8 @@ def configure(args): 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} + if getattr(args, "label", None): + d["label"] = args.label validate_config(d) state = State(Path(args.state_dir) / "state.sqlite3", resolved + "|" + me["id"]) state.set("offset", offset) @@ -186,6 +194,9 @@ async def run(args): 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.add_argument("command", choices=["run", "setup", "check"]) parser.add_argument("--config", default=DEFAULT_CONFIG) diff --git a/awg_gus/manage.py b/awg_gus/manage.py new file mode 100644 index 0000000..5d8682e --- /dev/null +++ b/awg_gus/manage.py @@ -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) diff --git a/awg_gus/profiles.py b/awg_gus/profiles.py new file mode 100644 index 0000000..47695f5 --- /dev/null +++ b/awg_gus/profiles.py @@ -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 отключит его работу.") diff --git a/install.sh b/install.sh index 24c94ee..cadfb22 100755 --- a/install.sh +++ b/install.sh @@ -1,51 +1,63 @@ #!/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 umask 077 - REPO_URL="${AWG_GUS_REPO_URL:-https://gitea.bvrdo.online/wenil/awg-gus-bot}" REF="${AWG_GUS_REF:-main}" BASE=/opt/awg-gus-bot -CONF=/etc/awg-gus-bot.json -STATE=/var/lib/awg-gus-bot -SERVICE=awg-gus-bot.service SRC="" -RECONFIGURE=0 - +ACTION="" +INSTANCE="" die() { printf 'Ошибка: %s\n' "$*" >&2; exit 1; } usage() { cat <<'EOF' -Установка/обновление отдельного AWG-бота для GusVoice: - sudo bash install.sh - sudo bash install.sh --src /path/to/awg-gus-bot - sudo bash install.sh --configure +AWG для Гуся v2: несколько серверов Гуся → один AWG. + sudo bash install.sh # установка / меню обновления и добавления + sudo bash install.sh --update # обновить код всех подключений + 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-адрес Гуся и токен, затем покажет код привязки. -Существующая служба awg-bot не меняется. VPN-клиенты у обоих ботов общие. +Для каждого Гуся нужны его адрес, собственный токен бота и привязка владельца. +Текущее подключение v1 сохраняется под именем default. VPN-клиенты общие. EOF } - while (($#)); do case "$1" in --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 ;; *) die "Неизвестный параметр: $1" ;; esac done - +[[ -z "$INSTANCE" || "$INSTANCE" =~ ^[a-z][a-z0-9_-]{0,31}$ ]] || die 'Некорректное имя подключения' [[ $EUID -eq 0 ]] || die 'Запустите через sudo bash install.sh' [[ $(uname -s) == Linux ]] || die 'Устанавливается на Linux-сервер с awg2' -command -v systemctl >/dev/null || die 'Нужен systemd' -[[ -d /run/systemd/system ]] || die 'systemd не запущен' -command -v flock >/dev/null || die 'Нужен flock (пакет util-linux)' +command -v systemctl >/dev/null && [[ -d /run/systemd/system ]] || die 'Нужен запущенный systemd' +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 exec 9>/run/lock/awg-gus-install.lock -flock -n 9 || die 'Другой установщик уже работает' -AWG2="$(command -v awg2 || true)" +flock -n 9 || die 'Другая установка или настройка уже выполняется' +AWG2=$(command -v awg2 || true) [[ -n "$AWG2" ]] || die 'Сначала установите AWG Toolza: команда awg2 не найдена' - missing=() for pair in 'python3:python3' 'curl:curl' 'tar:tar' 'qrencode:qrencode'; do command -v "${pair%%:*}" >/dev/null || missing+=("${pair#*:}") @@ -55,52 +67,44 @@ if ((${#missing[@]})); then apt-get update -qq DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "${missing[@]}" ca-certificates fi -PYTHON=$(command -v python3) 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)' \ - || die 'awg2 api version не отвечает. Обновите AWG Toolza штатным способом' + || die 'awg2 api version не отвечает' TMP=$(mktemp -d /tmp/awg-gus-install.XXXXXXXX) old="" activated=0 -service_stopped=0 -configuring=0 -was_active=0 -systemctl is-active --quiet "$SERVICE" && was_active=1 +active=() +artifacts=(/etc/systemd/system/awg-gus-bot.service /etc/systemd/system/awg-gus-bot@.service /usr/local/bin/awg-gus-bot) cleanup() { - local rc=$? + local rc=$? i trap - EXIT - if ((rc != 0 && (activated || service_stopped || configuring))); then - printf 'Запуск новой версии не удался. Откатываю только службу Гуся.\n' >&2 - systemctl stop "$SERVICE" || true - if ((activated)) && [[ -n "$old" ]]; then + if ((rc != 0 && activated)); then + printf 'Обновление не удалось. Возвращаю прежний код и службы Гуся.\n' >&2 + for unit in "${active[@]}"; do systemctl stop "$unit" || true; done + if [[ -n "$old" ]]; then ln -sfn "$old" "$BASE/.current-rollback" mv -Tf "$BASE/.current-rollback" "$BASE/current" - elif ((activated)); then + else rm -f "$BASE/current" fi - if [[ -f "$TMP/config.before" ]]; then - install -m 600 "$TMP/config.before" "$CONF" - fi - if [[ -f "$TMP/unit.before" ]]; then - install -m 644 "$TMP/unit.before" "/etc/systemd/system/$SERVICE" - elif ((activated)) && [[ -z "$old" ]]; then - systemctl disable "$SERVICE" >/dev/null 2>&1 || true - 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 + for i in "${!artifacts[@]}"; do + if [[ -f "$TMP/artifact.$i" ]]; then + cp -p "$TMP/artifact.$i" "${artifacts[$i]}" + else + rm -f "${artifacts[$i]}" + fi + done 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 [[ "$TMP" == /tmp/awg-gus-install.* && -d "$TMP" ]] && rm -rf -- "$TMP" exit "$rc" } trap cleanup EXIT - if [[ -z "$SRC" ]]; then [[ "$REPO_URL" == https://* && "$REPO_URL" != *'@'* ]] || die 'Источник должен быть HTTPS без учётных данных в URL' [[ "$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 \ "${REPO_URL%.git}/archive/$REF.tar.gz" -o "$TMP/source.tar.gz" mkdir "$TMP/source" - # Reject paths escaping extraction root and any links/devices in the source archive. python3 - "$TMP/source.tar.gz" "$TMP/source" <<'PY' import pathlib, sys, tarfile root = pathlib.Path(sys.argv[2]).resolve() with tarfile.open(sys.argv[1]) as archive: for m in archive.getmembers(): - p = (root / m.name).resolve() - if not p.is_relative_to(root) or not (m.isfile() or m.isdir()): + if not (root / m.name).resolve().is_relative_to(root) or not (m.isfile() or m.isdir()): raise SystemExit('Небезопасный путь/тип в архиве') archive.extractall(root) PY @@ -124,68 +126,51 @@ PY SRC="${dirs[0]}" fi 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" ]] \ - || die 'В источнике нет файлов AWG Gus bot' +[[ -f "$SRC/run.py" && -f "$SRC/awg_gus/manage.py" && -f "$SRC/vendor/gusvoice_bot/client.py" ]] || die 'Неполный исходный код v2' python3 -m compileall -q "$SRC/awg_gus" "$SRC/vendor/gusvoice_bot" "$SRC/run.py" -mkdir -p "$BASE/releases" "$STATE" -chmod 700 "$BASE" "$BASE/releases" "$STATE" +# Validate locally: an unavailable Gus must not prevent updating the other connections. +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)-$$" 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/" chmod -R go-rwx "$release" printf '%s\n' "$REPO_URL" > "$release/source-url" printf '%s\n' "$REF" > "$release/source-ref" -[[ ! -f "$CONF" ]] || cp -p "$CONF" "$TMP/config.before" -[[ ! -f "/etc/systemd/system/$SERVICE" ]] || cp -p "/etc/systemd/system/$SERVICE" "$TMP/unit.before" -[[ ! -f /usr/local/bin/awg-gus-bot ]] || cp -p /usr/local/bin/awg-gus-bot "$TMP/command.before" -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" - +for i in "${!artifacts[@]}"; do + [[ ! -f "${artifacts[$i]}" ]] || cp -p "${artifacts[$i]}" "$TMP/artifact.$i" +done activated=1 -cat > "/etc/systemd/system/$SERVICE" </dev/null -systemctl restart "$SERVICE" -sleep 3 -systemctl is-active --quiet "$SERVICE" || die 'Служба не запустилась. journalctl -u awg-gus-bot -n 30' +for unit in "${active[@]}"; do + systemctl reset-failed "$unit" || true + systemctl start "$unit" +done +if ((${#active[@]})); then + sleep 3 + for unit in "${active[@]}"; do + systemctl is-active --quiet "$unit" || die "Не запустилась $unit; откатываю обновление" + done +fi activated=0 -printf '\nГотово. В личной беседе с ботом Гуся отправьте /menu.\n' -printf 'Служба: awg-gus-bot. Обновление: sudo awg-gus-bot update\n' -printf 'Telegram-бот и awg2 не заменялись. Оба бота управляют общими VPN-клиентами.\n' +printf 'Код обновлён. Существующие настройки и состояния сохранены.\n' +# Pairing has its own lock and rollback, limited to the chosen connection. +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 diff --git a/tests/test_profiles.py b/tests/test_profiles.py new file mode 100644 index 0000000..e1500c1 --- /dev/null +++ b/tests/test_profiles.py @@ -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()