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()