Add v2 support for independent GusVoice connections to one AWG server
This commit is contained in:
1 parent
38c6a1a682
commit
0e62fe20ed
9 files changed
+753
-142
No files matched your search
@@ -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).
|
||||||
|
|||||||
+18
-11
@@ -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 ;;
|
|
||||||
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; }
|
[[ $EUID -eq 0 ]] || { echo 'Запустите через sudo'; exit 1; }
|
||||||
export AWG_GUS_REPO_URL AWG_GUS_REF
|
export AWG_GUS_REPO_URL AWG_GUS_REF
|
||||||
AWG_GUS_REPO_URL=$(cat "$BASE/current/source-url")
|
AWG_GUS_REPO_URL=$(cat "$BASE/current/source-url")
|
||||||
AWG_GUS_REF=$(cat "$BASE/current/source-ref")
|
AWG_GUS_REF=$(cat "$BASE/current/source-ref")
|
||||||
exec bash "$BASE/current/install.sh"
|
[[ "$AWG_GUS_REPO_URL" == https://* && "$AWG_GUS_REPO_URL" != *'@'* ]] || exit 2
|
||||||
;;
|
[[ "$AWG_GUS_REF" =~ ^[A-Za-z0-9._-]+$ ]] || exit 2
|
||||||
*) echo 'Использование: awg-gus-bot status|logs|check|restart|stop|start|setup|update'; exit 2 ;;
|
installer=$(mktemp)
|
||||||
esac
|
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
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
__version__ = "0.1.0"
|
__version__ = "0.2.0"
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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 отключит его работу.")
|
||||||
+89
-104
@@ -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
|
|
||||||
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
|
fi
|
||||||
|
done
|
||||||
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
|
||||||
|
systemctl start "$unit"
|
||||||
|
done
|
||||||
|
if ((${#active[@]})); then
|
||||||
sleep 3
|
sleep 3
|
||||||
systemctl is-active --quiet "$SERVICE" || die 'Служба не запустилась. journalctl -u awg-gus-bot -n 30'
|
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
|
||||||
@@ -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()
|
||||||
Reference in new issue
Block a user