Обновление зависимостей, устранение хардкода, оплата через Telegram

Библиотеки:
- миграция с aiogram 2.25.2 (EOL) на aiogram 3.31: aiohttp обновился
  с 3.8.6 до 3.14.3, так как aiogram 2 жёстко требовал aiohttp<3.9
- самодельный словарь состояний заменён на FSM aiogram (MemoryStorage);
  это убирает падение на user_state.startswith(), когда состояния нет
- из requirements убраны неиспользуемые пакеты (Babel 2.9.1, humanize,
  aiofiles, yookassa, YooMoney); оставлены только прямые зависимости

Хардкод:
- пути вычисляются от расположения файлов бота (awg/config.py), больше
  нет привязки к /root/amnezia-bot и к запуску из конкретного каталога
- вместо 'python3.11' используется sys.executable; awg-decode.py
  переименован в awg_decode.py и импортируется напрямую вместо запуска
  через subprocess
- newclient.sh и removeclient.sh определяют каталог по своему
  расположению, а не по cwd
- install.sh: каталог, репозиторий, ветка и Python настраиваются
  переменными окружения, зависимости ставятся из requirements.txt
- секреты можно задавать переменными окружения вместо config.json

Оплата (встроенные платежи Telegram, провайдер ЮKassa):
- счёт, pre_checkout_query и successful_payment; публичный HTTPS-адрес
  и вебхук не нужны
- обработчик платежей вынесен в отдельный роутер и подключён первым:
  successful_payment приходит без текста и иначе перехватывался бы
  обработчиком, ожидающим ввода в FSM
- выдача ключа идемпотентна по id платежа, повторная доставка апдейта
  не создаёт второй ключ
- если после оплаты ключ создать не удалось, уведомляются администраторы
- промокоды разделены на validate/consume: использование засчитывается
  только после фактической выдачи ключа, а не при проверке кода
- поддержаны скидочные промокоды, уменьшающие сумму счёта
- опциональные чеки по 54-ФЗ через provider_data

Исправленные ошибки:
- db.root_add и db.deactive_user_db передавали скриптам 2 аргумента
  вместо 4, из-за чего создание и удаление клиентов всегда падало;
  публичный ключ для удаления теперь вычисляется из конфига клиента
- в create_backup не был импортирован zipfile (NameError при бэкапе)
- планировщик запускался без задач, подписки никогда не истекали;
  добавлена ежечасная проверка с отключением доступа и уведомлением
- callback_data вида renew_period_<имя>_<период> разбирался неверно для
  имён с подчёркиваниями; разделителем стало двоеточие
- статус клиента брался из status.json, который никто не создавал;
  теперь используется wg show latest-handshakes
- продление подписки сбрасывало остаток, теперь период добавляется к нему
- Markdown-разметка ломалась на именах с подчёркиваниями; используется
  HTML с экранированием
- vpn:// ключ уходит отдельным сообщением, а не в подписи к файлу,
  где мог не поместиться в лимит 1024 символа
- блокирующие вызовы docker и скриптов вынесены в отдельные потоки
- при удалении клиента связь с Telegram ID удаляется, а не пишется None

Удалён мёртвый код: awg/wg.py (нигде не импортировался, содержал
подсеть 10.0.0.0/24 вместо используемой 10.8.1.0/24) и
handlers/add_client.py (импортировал несуществующий модуль loader).
Этот коммит содержится в:
2026-09-04 14:44:26 +00:00
родитель 90b00d971d
Коммит 11b52cf5ad
17 изменённых файлов: 1904 добавлений и 1277 удалений
+11
Просмотреть файл
@@ -0,0 +1,11 @@
__pycache__/
*.py[cod]
myenv/
venv/
.version
# Рабочие данные и секреты бота
awg/files/*
!awg/files/config.json.example
awg/users/
backup_*.zip
+71 -16
Просмотреть файл
@@ -1,27 +1,32 @@
# AmneziaVPN Telegram Bot
Телеграм-бот на Python для управления [AmneziaVPN](https://github.com/amnezia-vpn/amnezia-client). Этот бот позволяет легко управлять клиентами.
Телеграм-бот на Python для управления [AmneziaVPN](https://github.com/amnezia-vpn/amnezia-client). Этот бот позволяет легко управлять клиентами и продавать доступ через встроенные платежи Telegram.
Используется библиотека `aiogram` версии 2.25.2.
Протестировано на Ubuntu, версии 20.04, 22.04, 24.04.
Используется библиотека `aiogram` версии 3.x.
Требуется Python 3.10 или новее. Протестировано на Ubuntu 20.04, 22.04, 24.04.
## Оглавление
- [Возможности](#возможности)
- [Установка](#установка)
- [Настройка оплаты](#настройка-оплаты)
- [Конфигурация](#конфигурация)
- [Запуск](#запуск)
- [Заметки](#заметки)
- [Поддержка](#поддержка)
## Возможности
- Добавление администраторов и модераторов для упралением VPN
- Добавление клиентов
- Удаление клиентов
- Получение информации об IP-адресе клиента (берется из Endpoint, используется API ресурса [ip-api.com](http://ip-api.com))
- Создание ключа в формате `vpn://` при генерации нового клиента (так же, при получении конфигурации клиента), для использования в [AmneziaVPN](https://github.com/amnezia-vpn/amnezia-client)
- Роли: администраторы, модераторы и обычные пользователи
- Добавление и удаление клиентов
- Создание ключа в формате `vpn://` при генерации нового клиента (и при получении конфигурации), для использования в [AmneziaVPN](https://github.com/amnezia-vpn/amnezia-client)
- Статус клиента онлайн/офлайн по реальному времени последнего handshake
- Подписки на 1, 3, 6 и 12 месяцев с автоматическим отключением по истечении срока
- Продление подписки на период или до произвольной даты; остаток активной подписки не сгорает
- **Оплата подписки прямо в Telegram** (провайдер — ЮKassa), с чеками по 54-ФЗ при необходимости
- Промокоды двух видов: выдающие ключ бесплатно и дающие скидку при оплате
- Настраиваемые цены из интерфейса бота
- Создание резервной копии
- Инструкции по работе с VPN
## Установка
@@ -38,14 +43,65 @@
- Придумать уникальное имя пользователя для бота (например, `AmneziaWGManagerBot_bot`). Оно должно оканчиваться на `_bot`.
- После создания бота BotFather отправит вам токен для доступа к API. Его запросит бот во время первоначальной инициализации.
4. Получите ваш Telegram ID с помощью [Get My ID](https://t.me/getmyid_bot), просто написав боту.
4. Получите ваш Telegram ID с помощью [Get My ID](https://t.me/getmyid_bot), просто написав боту.
5. Загрузите и запустите скрипт `install.sh`, с помощью которого будет автоматически установлен бот, со всеми зависимостями, в том числе, в качестве системной службы (автозапуск):
```bash
curl -O https://raw.githubusercontent.com/stevefoxru/amnezia-bot/main/install.sh && chmod +x install.sh && ./install.sh
curl -O https://raw.githubusercontent.com/viktor138irk/amnezia-bot/main/install.sh && chmod +x install.sh && ./install.sh
```
## Настройка оплаты
Оплата работает на встроенных платежах Telegram, поэтому боту **не нужен публичный
HTTPS-адрес, домен или сертификат** — Telegram сам подтверждает платёж.
1. Откройте [BotFather](https://t.me/BotFather) → `/mybots` → ваш бот → **Payments**.
2. Выберите провайдера **ЮKassa** и подключите свой магазин.
3. BotFather выдаст **токен провайдера** (для тестов — с пометкой `TEST`).
4. Положите его в `awg/files/config.json` как `payment_provider_token` либо задайте
переменной окружения `AWG_PAYMENT_PROVIDER_TOKEN`.
Если токен не задан, бот работает без оплаты: кнопка «Купить ключ» скрывается, ключи
выдаются только по промокодам.
Минимальная сумма счёта в Telegram — 60 ₽, бот не даст выставить счёт меньше.
Для чеков по 54-ФЗ включите блок `payment_receipt` в конфигурации — тогда бот будет
запрашивать email покупателя и передавать данные чека в ЮKassa.
## Конфигурация
Настройки лежат в `awg/files/config.json`, образец — в
[`awg/files/config.json.example`](awg/files/config.json.example).
Любую настройку можно переопределить переменной окружения, чтобы не хранить секреты
в файле:
| Переменная | Назначение |
| --- | --- |
| `AWG_BOT_HOME` | Каталог с данными бота (по умолчанию — каталог `awg/`) |
| `AWG_BOT_TOKEN` | Токен бота |
| `AWG_PAYMENT_PROVIDER_TOKEN` | Токен платёжного провайдера |
| `AWG_PAYMENT_CURRENCY` | Валюта счетов (по умолчанию `RUB`) |
| `AWG_ENDPOINT` | Адрес VPN-сервера |
| `AWG_DOCKER_CONTAINER` | Имя контейнера Amnezia |
| `AWG_WG_CONFIG_FILE` | Путь к конфигу WireGuard внутри контейнера |
| `AWG_LOG_LEVEL` | Уровень логирования (по умолчанию `INFO`) |
Каталог установки больше не зашит в код: пути вычисляются относительно расположения
файлов бота. `install.sh` тоже понимает `AWG_BOT_HOME`, `AWG_PYTHON_BIN`,
`AWG_REPO_URL` и `AWG_REPO_BRANCH`.
### Промокоды
Формат добавления: `<код> <скидка%> <дней_действия> <макс_использований|none> <период|none>`
- `FREE1M 100 30 10 1_month` — бесплатный ключ на месяц, 10 активаций, промокод живёт 30 дней
- `SALE20 20 30 none none` — скидка 20% при оплате, без ограничения по количеству
Использование промокода засчитывается только после фактической выдачи ключа.
## Запуск
1. Добавьте бота в Telegram и отправьте команду `/start` или `/help` для начала работы.
@@ -54,11 +110,10 @@
Для обновления бота, необходимо запустить скрипт `install.sh`. В меню, необходимо выбрать пункт `Проверить обновления`.
При создании резервной копии, в архив добавляется директория connections (создается и содержит в себе логи подключений клиентов), conf, png, и сам конфигурационный файл.
При создании резервной копии, в архив добавляется директория `users` (конфигурации клиентов), `files` (настройки, промокоды, платежи, сроки подписок) и скрипты бота.
Подписки проверяются раз в час: по истечении срока клиент удаляется с сервера, а его владелец получает уведомление.
## Поддержка
Поддержать разработчика можете следующими способами:
Если у вас возникли вопросы или проблемы с установкой и использованием бота, создайте [issue](https://github.com/stevefoxru/amnezia-bot/issues) в этом репозитории или обратитесь к разработчику.
Если у вас возникли вопросы или проблемы с установкой и использованием бота, создайте [issue](https://github.com/viktor138irk/amnezia-bot/issues) в этом репозитории или обратитесь к разработчику.
+6 -3
Просмотреть файл
@@ -61,8 +61,7 @@ def process_conf_data(data):
print(f"Resolved DNS '{address}' to IP '{resolved_ip}'", file=sys.stderr)
return f"{prefix}{resolved_ip}:{port}{suffix}"
else:
print(f"Error: Could not resolve DNS name '{address}'", file=sys.stderr)
sys.exit(1)
raise ValueError(f"Не удалось разрешить DNS-имя '{address}'")
else:
return full_line
pattern = r'^(.*Endpoint\s*=\s*)([^\s:]+)(?::(\d+))(.*)$'
@@ -107,7 +106,11 @@ def main():
print(f'Error reading file {args.input}: {e}')
sys.exit(1)
processed_data = process_conf_data(data)
try:
processed_data = process_conf_data(data)
except ValueError as e:
print(f'Error: {e}', file=sys.stderr)
sys.exit(1)
encoded_string = encode(processed_data)
Разница между файлами не показана из-за своего большого размера Загрузить разницу
+65
Просмотреть файл
@@ -0,0 +1,65 @@
"""Пути и настройки бота.
Все пути вычисляются относительно расположения этого файла, поэтому бот
не привязан к конкретному каталогу установки и не зависит от текущего cwd.
Каталог можно переопределить переменной окружения AWG_BOT_HOME.
"""
import os
import re
import sys
from pathlib import Path
BASE_DIR = Path(os.environ.get('AWG_BOT_HOME') or Path(__file__).resolve().parent)
FILES_DIR = BASE_DIR / 'files'
USERS_DIR = BASE_DIR / 'users'
CONFIG_FILE = FILES_DIR / 'config.json'
USER_EXPIRATION_FILE = FILES_DIR / 'user_expiration.json'
USER_TELEGRAM_FILE = FILES_DIR / 'user_telegram.json'
PROMOCODES_FILE = FILES_DIR / 'promocodes.json'
PAYMENTS_FILE = FILES_DIR / 'payments.json'
SERVER_CONF_FILE = FILES_DIR / 'server.conf'
CLIENTS_TABLE_FILE = FILES_DIR / 'clientsTable'
NEWCLIENT_SCRIPT = BASE_DIR / 'newclient.sh'
REMOVECLIENT_SCRIPT = BASE_DIR / 'removeclient.sh'
# Интерпретатор, которым запущен бот, — не захардкоженная версия Python.
PYTHON_EXECUTABLE = sys.executable or 'python3'
# Имя клиента должно быть безопасным для файловой системы и для newclient.sh.
USERNAME_RE = re.compile(r'^[a-zA-Z0-9_-]{1,32}$')
DEFAULT_PRICING = {
'1_month': 1000.0,
'3_months': 2500.0,
'6_months': 4500.0,
'12_months': 8000.0,
}
# Периоды подписки и их длительность в месяцах.
PERIODS = {
'1_month': 1,
'3_months': 3,
'6_months': 6,
'12_months': 12,
}
PERIOD_LABELS = {
'1_month': '1 месяц',
'3_months': '3 месяца',
'6_months': '6 месяцев',
'12_months': '12 месяцев',
}
def period_label(period):
"""Человекочитаемое название периода подписки."""
return PERIOD_LABELS.get(period, str(period).replace('_', ' '))
def ensure_dirs():
"""Создаёт рабочие каталоги, если их ещё нет."""
FILES_DIR.mkdir(parents=True, exist_ok=True)
USERS_DIR.mkdir(parents=True, exist_ok=True)
+262 -107
Просмотреть файл
@@ -1,165 +1,273 @@
"""Хранилище данных бота (JSON-файлы) и операции с клиентами WireGuard."""
import json
import os
import subprocess
import logging
import re
import subprocess
from datetime import datetime
import pytz
import shutil
logging.basicConfig(level=logging.INFO)
import pytz
import config
logger = logging.getLogger(__name__)
CONFIG_FILE = 'files/config.json'
USER_EXPIRATION_FILE = 'files/user_expiration.json'
USER_TELEGRAM_FILE = 'files/user_telegram.json'
PROMOCODES_FILE = 'files/promocodes.json'
def load_json(file_path, default=None):
"""Загружает JSON-файл, возвращает default при ошибке или отсутствии файла."""
try:
if os.path.exists(file_path):
with open(file_path, 'r') as f:
if file_path.exists():
with open(file_path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
logger.error(f"Ошибка загрузки {file_path}: {str(e)}")
logger.error(f"Ошибка загрузки {file_path}: {e}")
return default if default is not None else {}
def save_json(file_path, data):
"""Сохраняет данные в JSON-файл."""
"""Сохраняет данные в JSON-файл (атомарно, через временный файл)."""
try:
os.makedirs(os.path.dirname(file_path), exist_ok=True)
with open(file_path, 'w') as f:
json.dump(data, f, indent=4, default=str)
file_path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = file_path.with_suffix(file_path.suffix + '.tmp')
with open(tmp_path, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=4, ensure_ascii=False, default=str)
tmp_path.replace(file_path)
return True
except Exception as e:
logger.error(f"Ошибка сохранения {file_path}: {str(e)}")
logger.error(f"Ошибка сохранения {file_path}: {e}")
return False
# --- Конфигурация -----------------------------------------------------------
def get_config():
"""Возвращает конфигурацию из config.json."""
return load_json(CONFIG_FILE, {})
return load_json(config.CONFIG_FILE, {})
def save_config(cfg):
"""Сохраняет конфигурацию в config.json."""
return save_json(config.CONFIG_FILE, cfg)
def add_admin(admin_id):
"""Добавляет ID администратора в конфигурацию."""
config = get_config()
admin_ids = config.get('admin_ids', [])
cfg = get_config()
admin_ids = [str(a) for a in cfg.get('admin_ids', [])]
if str(admin_id) not in admin_ids:
admin_ids.append(str(admin_id))
config['admin_ids'] = admin_ids
save_json(CONFIG_FILE, config)
cfg['admin_ids'] = admin_ids
save_config(cfg)
def remove_admin(admin_id):
"""Удаляет ID администратора из конфигурации."""
config = get_config()
admin_ids = config.get('admin_ids', [])
admin_id_str = str(admin_id)
if admin_id_str in admin_ids:
admin_ids.remove(admin_id_str)
config['admin_ids'] = admin_ids
save_json(CONFIG_FILE, config)
cfg = get_config()
admin_ids = [str(a) for a in cfg.get('admin_ids', [])]
if str(admin_id) in admin_ids:
admin_ids.remove(str(admin_id))
cfg['admin_ids'] = admin_ids
save_config(cfg)
def set_pricing(period, price):
"""Устанавливает цену для указанного периода подписки."""
config = get_config()
config['pricing'] = config.get('pricing', {})
config['pricing'][period] = price
save_json(CONFIG_FILE, config)
cfg = get_config()
pricing = cfg.get('pricing', dict(config.DEFAULT_PRICING))
pricing[period] = price
cfg['pricing'] = pricing
save_config(cfg)
def root_add(name, ipv6=False):
"""Добавляет нового пользователя через newclient.sh."""
# --- Клиенты WireGuard ------------------------------------------------------
def _client_conf_path(name):
return config.USERS_DIR / name / f'{name}.conf'
def get_client_public_key(name, docker_container):
"""Возвращает публичный ключ клиента.
Ключ выводится из приватного ключа в конфиге клиента; если конфига нет,
используется clientsTable, который ведёт newclient.sh.
"""
conf_path = _client_conf_path(name)
if conf_path.exists():
try:
conf = conf_path.read_text(encoding='utf-8')
match = re.search(r'^PrivateKey\s*=\s*(\S+)', conf, re.MULTILINE)
if match:
result = subprocess.run(
['docker', 'exec', '-i', docker_container, 'wg', 'pubkey'],
input=match.group(1), capture_output=True, text=True, timeout=30,
)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip()
logger.error(f"wg pubkey для {name} завершился с ошибкой: {result.stderr.strip()}")
except Exception as e:
logger.error(f"Не удалось вычислить публичный ключ для {name}: {e}")
for entry in load_json(config.CLIENTS_TABLE_FILE, []):
if entry.get('userData', {}).get('clientName') == name:
return entry.get('clientId')
return None
def root_add(name, endpoint, wg_config_file, docker_container):
"""Создаёт нового клиента через newclient.sh."""
try:
cmd = ['./newclient.sh', name]
if not ipv6:
cmd.append('--no-ipv6')
process = subprocess.run(cmd, capture_output=True, text=True)
process = subprocess.run(
[str(config.NEWCLIENT_SCRIPT), name, endpoint, wg_config_file, docker_container],
capture_output=True, text=True, cwd=str(config.BASE_DIR), timeout=120,
)
if process.returncode == 0:
return True
logger.error(f"Ошибка добавления пользователя {name}: {process.stderr}")
logger.error(f"Ошибка добавления клиента {name}: {process.stderr.strip() or process.stdout.strip()}")
return False
except Exception as e:
logger.error(f"Исключение при добавлении пользователя {name}: {str(e)}")
logger.error(f"Исключение при добавлении клиента {name}: {e}")
return False
def deactive_user_db(name):
"""Деактивирует пользователя через removeclient.sh."""
def deactive_user_db(name, wg_config_file, docker_container):
"""Удаляет клиента через removeclient.sh."""
public_key = get_client_public_key(name, docker_container)
if not public_key:
logger.error(f"Не найден публичный ключ клиента {name} — удаление невозможно.")
return False
try:
process = subprocess.run(['./removeclient.sh', name], capture_output=True, text=True)
process = subprocess.run(
[str(config.REMOVECLIENT_SCRIPT), name, public_key, wg_config_file, docker_container],
capture_output=True, text=True, cwd=str(config.BASE_DIR), timeout=120,
)
if process.returncode == 0:
return True
logger.error(f"Ошибка удаления пользователя {name}: {process.stderr}")
logger.error(f"Ошибка удаления клиента {name}: {process.stderr.strip() or process.stdout.strip()}")
return False
except Exception as e:
logger.error(f"Исключение при удалении пользователя {name}: {str(e)}")
logger.error(f"Исключение при удалении клиента {name}: {e}")
return False
def get_client_list():
"""Возвращает список клиентов (имя и конфигурация)."""
"""Возвращает список клиентов в виде пар (имя, конфигурация)."""
clients = []
users_dir = 'users'
if os.path.exists(users_dir):
for user_dir in os.listdir(users_dir):
user_path = os.path.join(users_dir, user_dir)
if os.path.isdir(user_path):
conf_file = os.path.join(user_path, f"{user_dir}.conf")
if os.path.exists(conf_file):
with open(conf_file, 'r') as f:
config = f.read()
clients.append((user_dir, config))
if not config.USERS_DIR.exists():
return clients
for user_dir in sorted(config.USERS_DIR.iterdir()):
if not user_dir.is_dir():
continue
conf_file = user_dir / f'{user_dir.name}.conf'
if conf_file.exists():
clients.append((user_dir.name, conf_file.read_text(encoding='utf-8')))
return clients
def get_active_list():
"""Возвращает список активных клиентов с последним handshake."""
active = []
users_dir = 'users'
if os.path.exists(users_dir):
for user_dir in os.listdir(users_dir):
user_path = os.path.join(users_dir, user_dir)
if os.path.isdir(user_path):
status_file = os.path.join(user_path, 'status.json')
if os.path.exists(status_file):
with open(status_file, 'r') as f:
status = json.load(f)
last_handshake = status.get('last_handshake', 'never')
active.append((user_dir, last_handshake))
def get_active_list(wg_config_file, docker_container):
"""Возвращает {имя клиента: время последнего handshake (UTC) или None}.
Данные берутся из `wg show latest-handshakes` внутри контейнера Amnezia.
"""
interface = wg_config_file.rsplit('/', 1)[-1].rsplit('.', 1)[0]
handshakes = {}
try:
result = subprocess.run(
['docker', 'exec', '-i', docker_container, 'wg', 'show', interface, 'latest-handshakes'],
capture_output=True, text=True, timeout=30,
)
if result.returncode != 0:
logger.error(f"wg show завершился с ошибкой: {result.stderr.strip()}")
return {}
for line in result.stdout.splitlines():
parts = line.split()
if len(parts) != 2:
continue
timestamp = int(parts[1])
handshakes[parts[0]] = (
datetime.fromtimestamp(timestamp, pytz.utc) if timestamp > 0 else None
)
except Exception as e:
logger.error(f"Не удалось получить статусы клиентов: {e}")
return {}
active = {}
for entry in load_json(config.CLIENTS_TABLE_FILE, []):
name = entry.get('userData', {}).get('clientName')
if name and entry.get('clientId') in handshakes:
active[name] = handshakes[entry['clientId']]
return active
def set_user_expiration(username, expiration, transfer_limit):
"""Устанавливает срок действия и лимит трафика для пользователя."""
data = load_json(USER_EXPIRATION_FILE, {})
# --- Подписки ---------------------------------------------------------------
def set_user_expiration(username, expiration, transfer_limit='Неограниченно'):
"""Устанавливает срок действия подписки пользователя."""
data = load_json(config.USER_EXPIRATION_FILE, {})
data[username] = {
'expiration': expiration.isoformat() if expiration else None,
'transfer_limit': transfer_limit
'transfer_limit': transfer_limit,
}
save_json(USER_EXPIRATION_FILE, data)
save_json(config.USER_EXPIRATION_FILE, data)
def get_user_expiration(username):
"""Получает срок действия подписки пользователя."""
data = load_json(USER_EXPIRATION_FILE, {})
user_data = data.get(username, {})
expiration = user_data.get('expiration')
return datetime.fromisoformat(expiration) if expiration else None
"""Возвращает срок действия подписки пользователя (aware datetime в UTC)."""
data = load_json(config.USER_EXPIRATION_FILE, {})
expiration = data.get(username, {}).get('expiration')
if not expiration:
return None
try:
parsed = datetime.fromisoformat(expiration)
except ValueError:
logger.error(f"Некорректная дата окончания подписки у {username}: {expiration}")
return None
return parsed if parsed.tzinfo else pytz.utc.localize(parsed)
def get_all_expirations():
"""Возвращает {имя клиента: дата окончания подписки} для всех клиентов."""
data = load_json(config.USER_EXPIRATION_FILE, {})
return {username: get_user_expiration(username) for username in data}
def remove_user_expiration(username):
"""Удаляет информацию о сроке действия подписки пользователя."""
data = load_json(USER_EXPIRATION_FILE, {})
data = load_json(config.USER_EXPIRATION_FILE, {})
if username in data:
del data[username]
save_json(USER_EXPIRATION_FILE, data)
save_json(config.USER_EXPIRATION_FILE, data)
def set_user_telegram_id(username, telegram_id):
"""Связывает имя пользователя с Telegram ID."""
data = load_json(USER_TELEGRAM_FILE, {})
"""Связывает имя клиента с Telegram ID."""
data = load_json(config.USER_TELEGRAM_FILE, {})
data[username] = telegram_id
save_json(USER_TELEGRAM_FILE, data)
save_json(config.USER_TELEGRAM_FILE, data)
def get_user_telegram_id(username):
"""Получает Telegram ID пользователя по имени."""
data = load_json(USER_TELEGRAM_FILE, {})
return data.get(username)
"""Возвращает Telegram ID владельца клиента."""
return load_json(config.USER_TELEGRAM_FILE, {}).get(username)
def remove_user_telegram_id(username):
"""Удаляет связь клиента с Telegram ID."""
data = load_json(config.USER_TELEGRAM_FILE, {})
if username in data:
del data[username]
save_json(config.USER_TELEGRAM_FILE, data)
def get_usernames_by_telegram_id(telegram_id):
"""Возвращает имена клиентов, принадлежащих пользователю Telegram."""
data = load_json(config.USER_TELEGRAM_FILE, {})
return [name for name, owner in data.items() if owner == telegram_id]
# --- Промокоды --------------------------------------------------------------
def add_promocode(code, discount, expires_at, max_uses, subscription_period):
"""Добавляет новый промокод."""
promocodes = load_json(PROMOCODES_FILE, {})
promocodes = load_json(config.PROMOCODES_FILE, {})
if code in promocodes:
return False
promocodes[code] = {
@@ -167,48 +275,95 @@ def add_promocode(code, discount, expires_at, max_uses, subscription_period):
'expires_at': expires_at.isoformat() if expires_at else None,
'max_uses': max_uses,
'uses': 0,
'subscription_period': subscription_period
'subscription_period': subscription_period,
}
save_json(PROMOCODES_FILE, promocodes)
save_json(config.PROMOCODES_FILE, promocodes)
return True
def apply_promocode(code):
"""Применяет промокод, увеличивает счетчик использований."""
promocodes = load_json(PROMOCODES_FILE, {})
now = datetime.now(pytz.utc)
promo = promocodes.get(code)
def validate_promocode(code):
"""Проверяет промокод, НЕ расходуя использование.
Возвращает {'discount': ..., 'subscription_period': ...} или None.
Счётчик использований увеличивает только consume_promocode().
"""
promo = load_json(config.PROMOCODES_FILE, {}).get(code)
if not promo:
return None
if promo['expires_at'] and datetime.fromisoformat(promo['expires_at']) < now:
if promo['expires_at'] and datetime.fromisoformat(promo['expires_at']) < datetime.now(pytz.utc):
return None
if promo['max_uses'] is not None and promo['uses'] >= promo['max_uses']:
return None
promo['uses'] += 1
save_json(PROMOCODES_FILE, promocodes)
return {
'discount': promo['discount'],
'subscription_period': promo['subscription_period']
'subscription_period': promo['subscription_period'],
}
def consume_promocode(code):
"""Расходует одно использование промокода после успешной выдачи ключа."""
promocodes = load_json(config.PROMOCODES_FILE, {})
promo = promocodes.get(code)
if not promo:
return False
promo['uses'] += 1
save_json(config.PROMOCODES_FILE, promocodes)
return True
def get_promocodes():
"""Возвращает список всех промокодов."""
promocodes = load_json(PROMOCODES_FILE, {})
"""Возвращает все промокоды с разобранными датами."""
result = {}
for code, info in promocodes.items():
for code, info in load_json(config.PROMOCODES_FILE, {}).items():
result[code] = {
'discount': info['discount'],
'expires_at': datetime.fromisoformat(info['expires_at']) if info['expires_at'] else None,
'max_uses': info['max_uses'],
'uses': info['uses'],
'subscription_period': info['subscription_period']
'subscription_period': info['subscription_period'],
}
return result
def remove_promocode(code):
"""Удаляет промокод."""
promocodes = load_json(PROMOCODES_FILE, {})
promocodes = load_json(config.PROMOCODES_FILE, {})
if code in promocodes:
del promocodes[code]
save_json(PROMOCODES_FILE, promocodes)
save_json(config.PROMOCODES_FILE, promocodes)
return True
return False
# --- Платежи ----------------------------------------------------------------
def record_payment(charge_id, telegram_id, username, period, amount, currency, promocode=None):
"""Сохраняет успешный платёж. Возвращает False, если платёж уже записан.
Telegram может доставить successful_payment повторно, поэтому запись
идемпотентна по идентификатору платежа провайдера.
"""
payments = load_json(config.PAYMENTS_FILE, {})
if charge_id in payments:
return False
payments[charge_id] = {
'telegram_id': telegram_id,
'username': username,
'period': period,
'amount': amount,
'currency': currency,
'promocode': promocode,
'paid_at': datetime.now(pytz.utc).isoformat(),
}
save_json(config.PAYMENTS_FILE, payments)
return True
def is_payment_recorded(charge_id):
"""Проверяет, обработан ли уже платёж с таким идентификатором."""
return charge_id in load_json(config.PAYMENTS_FILE, {})
def get_payments():
"""Возвращает все записанные платежи."""
return load_json(config.PAYMENTS_FILE, {})
+26
Просмотреть файл
@@ -0,0 +1,26 @@
{
"bot_token": "123456789:AA...ваш токен от @BotFather",
"admin_ids": ["123456789"],
"moderator_ids": [],
"wg_config_file": "/opt/amnezia/awg/wg0.conf",
"docker_container": "amnezia-awg",
"endpoint": "203.0.113.10",
"payment_provider_token": "381764678:TEST:0000000000",
"payment_currency": "RUB",
"pricing": {
"1_month": 1000.0,
"3_months": 2500.0,
"6_months": 4500.0,
"12_months": 8000.0
},
"payment_receipt": {
"enabled": false,
"vat_code": 1,
"tax_system_code": 1
}
}
+177
Просмотреть файл
@@ -0,0 +1,177 @@
"""Инлайн-клавиатуры бота.
В callback_data используется двоеточие как разделитель: имена клиентов
состоят из [a-zA-Z0-9_-], поэтому двоеточие гарантированно не встречается
внутри значения и разбор не ломается на именах с подчёркиваниями.
"""
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
import config
HOME = InlineKeyboardButton(text='🏠 Домой', callback_data='home')
def _finish(builder, *rows):
for row in rows:
builder.row(*row)
return builder.as_markup()
def main_menu(user_id, settings):
"""Главное меню, состав зависит от роли пользователя."""
builder = InlineKeyboardBuilder()
if settings.is_admin(user_id):
builder.row(
InlineKeyboardButton(text='➕ Добавить пользователя', callback_data='add_user'),
InlineKeyboardButton(text='📋 Список клиентов', callback_data='list_users'),
)
builder.row(
InlineKeyboardButton(text='🔑 Получить конфиг', callback_data='get_config'),
InlineKeyboardButton(text='🎟️ Управление промокодами', callback_data='manage_promocodes'),
)
builder.row(
InlineKeyboardButton(text='⚙️ Настройки', callback_data='settings'),
HOME,
)
elif settings.is_staff(user_id):
builder.row(
InlineKeyboardButton(text='➕ Добавить пользователя', callback_data='add_user'),
InlineKeyboardButton(text='📋 Список клиентов', callback_data='list_users'),
)
builder.row(
InlineKeyboardButton(text='🔑 Получить конфиг', callback_data='get_config'),
HOME,
)
else:
if settings.payments_enabled:
builder.row(InlineKeyboardButton(text='💳 Купить ключ', callback_data='buy_key'))
builder.row(InlineKeyboardButton(text='🎟️ Получить ключ по промокоду', callback_data='use_promocode'))
builder.row(InlineKeyboardButton(text='🔑 Мои ключи', callback_data='my_keys'))
return builder.as_markup()
def buy_menu(settings, discount=0.0):
"""Выбор периода подписки с ценами (при наличии — со скидкой)."""
builder = InlineKeyboardBuilder()
for period in config.PERIODS:
price = settings.price(period) * (1 - discount / 100)
builder.row(InlineKeyboardButton(
text=f'{config.period_label(period)} — {price:,.0f} ₽'.replace(',', ' '),
callback_data=f'buy:{period}',
))
builder.row(InlineKeyboardButton(text='🎟️ Ввести промокод', callback_data='use_promocode'))
builder.row(HOME)
return builder.as_markup()
def settings_menu():
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text='💾 Создать бэкап', callback_data='create_backup'),
InlineKeyboardButton(text='👥 Список админов', callback_data='list_admins'),
)
builder.row(
InlineKeyboardButton(text='👤 Добавить админа', callback_data='add_admin'),
InlineKeyboardButton(text='💰 Настройки цен', callback_data='pricing_settings'),
)
builder.row(InlineKeyboardButton(text='⬅️ Назад', callback_data='home'))
return builder.as_markup()
def pricing_settings_menu(settings):
builder = InlineKeyboardBuilder()
for period in config.PERIODS:
builder.row(InlineKeyboardButton(
text=f'{config.period_label(period)}: {settings.price(period):,.0f} ₽'.replace(',', ' '),
callback_data=f'setprice:{period}',
))
builder.row(InlineKeyboardButton(text='⬅️ Назад', callback_data='settings'))
return builder.as_markup()
def renewal_periods(username):
builder = InlineKeyboardBuilder()
for period in config.PERIODS:
builder.add(InlineKeyboardButton(
text=config.period_label(period),
callback_data=f'renewp:{username}:{period}',
))
builder.adjust(2)
builder.row(InlineKeyboardButton(text='📅 Указать дату', callback_data=f'renewp:{username}:custom'))
builder.row(
InlineKeyboardButton(text='⬅️ Назад', callback_data=f'client:{username}'),
HOME,
)
return builder.as_markup()
def client_actions(username):
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text='🗑️ Удалить', callback_data=f'delete:{username}'),
InlineKeyboardButton(text='🔄 Продлить', callback_data=f'renew:{username}'),
)
builder.row(
InlineKeyboardButton(text='🔑 Конфиг', callback_data=f'sendconf:{username}'),
)
builder.row(
InlineKeyboardButton(text='⬅️ Назад', callback_data='list_users'),
HOME,
)
return builder.as_markup()
def client_list(clients, callback_prefix, back_callback='home'):
"""Список клиентов; clients — пары (имя, префикс статуса)."""
builder = InlineKeyboardBuilder()
for username, status in clients:
builder.add(InlineKeyboardButton(
text=f'{status} {username}'.strip(),
callback_data=f'{callback_prefix}:{username}',
))
builder.adjust(2)
builder.row(InlineKeyboardButton(text='🏠 Домой', callback_data=back_callback))
return builder.as_markup()
def admin_list(admin_ids):
builder = InlineKeyboardBuilder()
for admin_id in sorted(admin_ids):
builder.add(InlineKeyboardButton(
text=f'🗑️ Удалить {admin_id}',
callback_data=f'rmadmin:{admin_id}',
))
builder.adjust(2)
builder.row(InlineKeyboardButton(text='⬅️ Назад', callback_data='settings'))
return builder.as_markup()
def promocodes_menu():
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text='➕ Добавить промокод', callback_data='add_promocode'),
InlineKeyboardButton(text='🗑️ Удалить промокод', callback_data='delete_promocode'),
)
builder.row(HOME)
return builder.as_markup()
def promocode_list(codes):
builder = InlineKeyboardBuilder()
for code in codes:
builder.add(InlineKeyboardButton(text=f'🗑️ {code}', callback_data=f'rmpromo:{code}'))
builder.adjust(2)
builder.row(InlineKeyboardButton(text='⬅️ Назад', callback_data='manage_promocodes'))
return builder.as_markup()
def single(text, callback_data):
"""Клавиатура из одной кнопки."""
return InlineKeyboardMarkup(inline_keyboard=[[
InlineKeyboardButton(text=text, callback_data=callback_data),
]])
def home_only():
return InlineKeyboardMarkup(inline_keyboard=[[HOME]])
Regular → Executable
+2 -1
Просмотреть файл
@@ -32,7 +32,8 @@ if [[ ! "$CLIENT_NAME" =~ ^[a-zA-Z0-9_-]+$ ]]; then
exit 1
fi
pwd=$(pwd)
# Каталог скрипта, а не текущий каталог: бот может быть запущен откуда угодно.
pwd="$(cd "$(dirname "$0")" && pwd)"
mkdir -p "$pwd/users/$CLIENT_NAME"
mkdir -p "$pwd/files"
+94
Просмотреть файл
@@ -0,0 +1,94 @@
"""Приём оплаты через встроенные платежи Telegram (провайдер — ЮKassa).
Токен провайдера выдаёт @BotFather (Payments -> ЮKassa) и кладётся в
files/config.json как payment_provider_token либо в переменную окружения
AWG_PAYMENT_PROVIDER_TOKEN. Никакой публичный webhook при этом не нужен:
Telegram сам подтверждает оплату апдейтами pre_checkout_query и
successful_payment.
"""
import json
import logging
from aiogram.types import LabeledPrice
import config
logger = logging.getLogger(__name__)
PAYLOAD_PREFIX = 'sub'
NO_PROMOCODE = '-'
def to_minor_units(amount):
"""Переводит рубли в копейки — Telegram принимает только целые единицы."""
return int(round(float(amount) * 100))
def apply_discount(price, discount):
"""Применяет скидку в процентах, не опускаясь ниже нуля."""
discounted = float(price) * (1 - float(discount or 0) / 100)
return max(discounted, 0.0)
def final_price(settings, period, discount=0.0):
"""Итоговая цена периода подписки с учётом скидки."""
return apply_discount(settings.price(period), discount)
def build_payload(period, promocode=None):
"""Собирает payload счёта (Telegram ограничивает его 128 байтами)."""
return f'{PAYLOAD_PREFIX}:{period}:{promocode or NO_PROMOCODE}'
def parse_payload(payload):
"""Разбирает payload счёта. Возвращает (период, промокод) или (None, None)."""
parts = (payload or '').split(':')
if len(parts) != 3 or parts[0] != PAYLOAD_PREFIX:
return None, None
period = parts[1] if parts[1] in config.PERIODS else None
promocode = None if parts[2] == NO_PROMOCODE else parts[2]
return period, promocode
def _receipt_provider_data(settings, price, description):
"""Чек для 54-ФЗ, если он включён в настройках."""
receipt = getattr(settings, 'receipt', None)
if not receipt or not receipt.get('enabled'):
return None
return {
'receipt': {
'items': [{
'description': description[:128],
'quantity': '1.00',
'amount': {'value': f'{price:.2f}', 'currency': settings.currency},
'vat_code': receipt.get('vat_code', 1),
}],
'tax_system_code': receipt.get('tax_system_code', 1),
}
}
def build_invoice(settings, period, discount=0.0, promocode=None):
"""Готовит именованные аргументы для bot.send_invoice."""
price = final_price(settings, period, discount)
label = config.period_label(period)
title = f'VPN-ключ на {label}'
description = f'Доступ к AmneziaVPN на {label}.'
if discount:
description += f' Промокод {promocode}: скидка {discount:g}%.'
invoice = {
'title': title,
'description': description,
'payload': build_payload(period, promocode),
'provider_token': settings.provider_token,
'currency': settings.currency,
'prices': [LabeledPrice(label=label, amount=to_minor_units(price))],
}
provider_data = _receipt_provider_data(settings, price, title)
if provider_data:
invoice['provider_data'] = json.dumps(provider_data, ensure_ascii=False)
invoice['need_email'] = True
invoice['send_email_to_provider'] = True
return invoice
Regular → Executable
+4 -3
Просмотреть файл
@@ -12,7 +12,8 @@ CLIENT_PUBLIC_KEY="$2"
WG_CONFIG_FILE="$3"
DOCKER_CONTAINER="$4"
pwd=$(pwd)
# Каталог скрипта, а не текущий каталог: бот может быть запущен откуда угодно.
pwd="$(cd "$(dirname "$0")" && pwd)"
mkdir -p "$pwd/files"
SERVER_CONF_PATH="$pwd/files/server.conf"
@@ -74,8 +75,8 @@ docker cp "$SERVER_CONF_PATH" "$DOCKER_CONTAINER":"$WG_CONFIG_FILE"
docker exec -i "$DOCKER_CONTAINER" sh -c "wg-quick down '$WG_CONFIG_FILE' && wg-quick up '$WG_CONFIG_FILE'"
rm -f "users/$CLIENT_NAME/$CLIENT_NAME.conf"
rmdir "users/$CLIENT_NAME" 2>/dev/null || true
rm -f "$pwd/users/$CLIENT_NAME/$CLIENT_NAME.conf"
rmdir "$pwd/users/$CLIENT_NAME" 2>/dev/null || true
CLIENTS_TABLE_PATH="$pwd/files/clientsTable"
docker exec -i "$DOCKER_CONTAINER" cat /opt/amnezia/awg/clientsTable > "$CLIENTS_TABLE_PATH" || echo "[]" > "$CLIENTS_TABLE_PATH"
+103
Просмотреть файл
@@ -0,0 +1,103 @@
"""Загрузка и валидация настроек бота.
Значения берутся из files/config.json, любое из них можно переопределить
переменной окружения — так секреты (токен бота, токен платёжного провайдера)
не обязаны лежать в репозитории.
"""
import logging
from dataclasses import dataclass, field
import os
import config
import db
logger = logging.getLogger(__name__)
# Telegram не принимает счета меньше этой суммы в рублях.
MIN_INVOICE_RUB = 60.0
def _env_or(cfg, key, env_name):
value = os.environ.get(env_name)
if value:
return value
return cfg.get(key)
def _as_id_set(raw):
result = set()
for item in raw or []:
try:
result.add(int(item))
except (TypeError, ValueError):
logger.warning(f"Некорректный Telegram ID в конфигурации: {item!r} — пропущен.")
return result
@dataclass
class Settings:
bot_token: str
wg_config_file: str
docker_container: str
endpoint: str
admin_ids: set = field(default_factory=set)
moderator_ids: set = field(default_factory=set)
pricing: dict = field(default_factory=lambda: dict(config.DEFAULT_PRICING))
provider_token: str = ''
currency: str = 'RUB'
receipt: dict = field(default_factory=dict)
@property
def payments_enabled(self):
"""Оплата доступна, только если задан токен платёжного провайдера."""
return bool(self.provider_token)
def price(self, period):
"""Цена периода подписки с запасным значением по умолчанию."""
return float(self.pricing.get(period, config.DEFAULT_PRICING.get(period, 0.0)))
def is_admin(self, user_id):
return user_id in self.admin_ids
def is_staff(self, user_id):
return user_id in self.admin_ids or user_id in self.moderator_ids
def load():
"""Читает настройки, дополняет их окружением и проверяет обязательные поля."""
cfg = db.get_config()
settings = Settings(
bot_token=_env_or(cfg, 'bot_token', 'AWG_BOT_TOKEN') or '',
wg_config_file=_env_or(cfg, 'wg_config_file', 'AWG_WG_CONFIG_FILE') or '',
docker_container=_env_or(cfg, 'docker_container', 'AWG_DOCKER_CONTAINER') or '',
endpoint=_env_or(cfg, 'endpoint', 'AWG_ENDPOINT') or '',
admin_ids=_as_id_set(cfg.get('admin_ids')),
moderator_ids=_as_id_set(cfg.get('moderator_ids')),
pricing={**config.DEFAULT_PRICING, **(cfg.get('pricing') or {})},
provider_token=_env_or(cfg, 'payment_provider_token', 'AWG_PAYMENT_PROVIDER_TOKEN') or '',
currency=_env_or(cfg, 'payment_currency', 'AWG_PAYMENT_CURRENCY') or 'RUB',
receipt=cfg.get('payment_receipt') or {},
)
missing = [
name for name, value in (
('bot_token', settings.bot_token),
('wg_config_file', settings.wg_config_file),
('docker_container', settings.docker_container),
('endpoint', settings.endpoint),
) if not value
]
if missing:
raise ValueError(
f"В {config.CONFIG_FILE} не заданы обязательные настройки: {', '.join(missing)}."
)
if not settings.admin_ids:
raise ValueError(f"В {config.CONFIG_FILE} не задан ни один admin_ids.")
if not settings.payments_enabled:
logger.warning(
"payment_provider_token не задан — оплата отключена, ключи выдаются только "
"по промокодам. Получите токен у @BotFather (Payments -> ЮKassa)."
)
return settings
+102
Просмотреть файл
@@ -0,0 +1,102 @@
"""Операции с клиентами VPN: создание, удаление, выдача подписок.
Все блокирующие вызовы (docker, скрипты, DNS) выполняются в отдельном потоке,
чтобы не блокировать цикл событий бота.
"""
import asyncio
import logging
import shutil
import uuid
from datetime import datetime, timedelta
import pytz
import awg_decode
import config
import db
logger = logging.getLogger(__name__)
def client_conf_path(username):
"""Путь к файлу конфигурации клиента."""
return config.USERS_DIR / username / f'{username}.conf'
def _encode_conf(conf_text):
return awg_decode.encode(awg_decode.process_conf_data(conf_text))
async def generate_vpn_key(conf_path):
"""Преобразует конфиг клиента в ссылку vpn:// для приложения AmneziaVPN."""
try:
conf_text = conf_path.read_text(encoding='utf-8')
# process_conf_data резолвит DNS, поэтому выносим в поток.
return await asyncio.to_thread(_encode_conf, conf_text)
except Exception as e:
logger.error(f"Ошибка генерации vpn:// для {conf_path}: {e}")
return ''
def make_username(telegram_id):
"""Генерирует уникальное имя клиента для пользователя Telegram."""
return f'user_{telegram_id}_{uuid.uuid4().hex[:8]}'
async def create_client(username, settings):
"""Создаёт клиента WireGuard."""
return await asyncio.to_thread(
db.root_add, username, settings.endpoint,
settings.wg_config_file, settings.docker_container,
)
async def delete_client(username, settings):
"""Удаляет клиента WireGuard и все связанные с ним данные."""
removed = await asyncio.to_thread(
db.deactive_user_db, username,
settings.wg_config_file, settings.docker_container,
)
if not removed:
return False
shutil.rmtree(config.USERS_DIR / username, ignore_errors=True)
db.remove_user_expiration(username)
db.remove_user_telegram_id(username)
return True
async def active_clients(settings):
"""Возвращает {имя клиента: время последнего handshake}."""
return await asyncio.to_thread(
db.get_active_list, settings.wg_config_file, settings.docker_container,
)
def expiration_for(period, since=None):
"""Вычисляет дату окончания подписки для периода."""
months = config.PERIODS.get(period, 1)
return (since or datetime.now(pytz.utc)) + timedelta(days=30 * months)
async def issue_subscription(telegram_id, period, settings):
"""Создаёт клиента и выдаёт ему подписку. Возвращает имя клиента или None."""
username = make_username(telegram_id)
if not await create_client(username, settings):
return None
db.set_user_expiration(username, expiration_for(period))
db.set_user_telegram_id(username, telegram_id)
return username
def extend_subscription(username, period):
"""Продлевает подписку клиента, отсчитывая от текущей даты окончания.
Если подписка ещё активна, время не сгорает — новый период добавляется
к остатку.
"""
current = db.get_user_expiration(username)
now = datetime.now(pytz.utc)
since = current if current and current > now else now
expiration = expiration_for(period, since=since)
db.set_user_expiration(username, expiration)
return expiration
-49
Просмотреть файл
@@ -1,49 +0,0 @@
import subprocess
import asyncio
import os
def generate_key():
"""Генерирует пару ключей WireGuard (приватный и публичный)."""
private_key = subprocess.check_output(["wg", "genkey"]).decode().strip()
public_key = subprocess.check_output(["wg", "pubkey"], input=private_key.encode()).decode().strip()
return private_key, public_key
def allocate_ip():
"""Выделяет уникальный IP-адрес из подсети 10.0.0.0/24."""
conf_path = "/root/amnezia-bot/awg/files/wg0.conf"
used_ips = []
try:
with open(conf_path, "r") as f:
for line in f:
if "AllowedIPs" in line:
ip = line.split("=")[1].strip().split("/")[0]
used_ips.append(ip)
except FileNotFoundError:
pass
for i in range(2, 255): # Начинаем с 10.0.0.2
ip = f"10.0.0.{i}"
if ip not in used_ips:
return ip
raise Exception("Нет доступных IP-адресов.")
async def generate_vpn_key(conf_path: str) -> str:
"""Преобразует .conf в формат vpn:// с помощью awg-decode.py."""
process = await asyncio.create_subprocess_exec(
'python3.11', '/root/amnezia-bot/awg/awg-decode.py', '--encode', conf_path,
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
if process.returncode == 0 and stdout.decode().startswith('vpn://'):
return stdout.decode().strip()
else:
print(f"Ошибка генерации vpn://: {stderr.decode()}")
return ""
def add_peer(public_key, ip_address):
"""Добавляет новый пир в wg0.conf и синхронизирует с Docker."""
conf_path = "/root/amnezia-bot/awg/files/wg0.conf"
with open(conf_path, "a") as f:
f.write(f"\n[Peer]\nPublicKey = {public_key}\nAllowedIPs = {ip_address}/32\n")
subprocess.run(["docker", "exec", "amnezia-awg", "wg", "syncconf", "wg0", "/config/wg0.conf"])
-44
Просмотреть файл
@@ -1,44 +0,0 @@
from aiogram import types
from aiogram.dispatcher import FSMContext
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from aiogram.dispatcher.filters.state import State, StatesGroup
import subprocess
from loader import dp, bot # Убедись, что loader.py существует
class AddClient(StatesGroup):
protocol = State()
username = State()
@dp.message_handler(commands=["add_client"])
async def cmd_add_client(message: types.Message):
keyboard = InlineKeyboardMarkup()
keyboard.add(
InlineKeyboardButton("WireGuard", callback_data="proto_wg"),
InlineKeyboardButton("XRay", callback_data="proto_xray")
)
await message.answer("Выберите протокол для нового клиента:", reply_markup=keyboard)
await AddClient.protocol.set()
@dp.callback_query_handler(lambda c: c.data.startswith("proto_"), state=AddClient.protocol)
async def process_protocol_choice(callback_query: types.CallbackQuery, state: FSMContext):
protocol = callback_query.data.replace("proto_", "")
await state.update_data(protocol=protocol)
await bot.send_message(callback_query.from_user.id, f"Выбран протокол: {protocol.upper()}\nВведите имя клиента:")
await AddClient.username.set()
@dp.message_handler(state=AddClient.username)
async def process_username(message: types.Message, state: FSMContext):
data = await state.get_data()
username = message.text.strip()
protocol = data.get("protocol", "wg")
try:
subprocess.run(
["amnezia", "client", "add", "--proto", protocol, "--name", username],
check=True
)
await message.answer(f"✅ Клиент `{username}` с протоколом `{protocol.upper()}` успешно создан.", parse_mode="Markdown")
except subprocess.CalledProcessError as e:
await message.answer(f"❌ Ошибка при создании клиента:\n```\n{str(e)}\n```", parse_mode="Markdown")
await state.finish()
+56 -30
Просмотреть файл
@@ -1,10 +1,31 @@
#!/bin/bash
# Конфигурация
SERVICE_NAME="awg_bot"
REPO_URL="https://github.com/stevefoxru/amnezia-bot.git"
REPO_API="https://api.github.com/repos/stevefoxru/amnezia-bot"
LOCAL_VERSION_FILE="/root/amnezia-bot/.version"
# Конфигурация. Любое значение можно переопределить переменной окружения.
SERVICE_NAME="${AWG_SERVICE_NAME:-awg_bot}"
# Каталог установки бота (по умолчанию — как в прежних версиях).
BASE_DIR="${AWG_BOT_HOME:-/root/amnezia-bot}"
PARENT_DIR="$(dirname "$BASE_DIR")"
BOT_DIRNAME="$(basename "$BASE_DIR")"
# Репозиторий: берём origin уже установленной копии, иначе значение по умолчанию.
DEFAULT_REPO_URL="https://github.com/viktor138irk/amnezia-bot.git"
if [[ -z "$AWG_REPO_URL" && -d "$BASE_DIR/.git" ]]; then
AWG_REPO_URL="$(git -C "$BASE_DIR" remote get-url origin 2>/dev/null)"
fi
REPO_URL="${AWG_REPO_URL:-$DEFAULT_REPO_URL}"
REPO_BRANCH="${AWG_REPO_BRANCH:-main}"
# https://github.com/<owner>/<repo>.git -> https://api.github.com/repos/<owner>/<repo>
REPO_API="https://api.github.com/repos/$(echo "$REPO_URL" | sed -E 's#^.*github\.com[:/]##; s#\.git$##')"
LOCAL_VERSION_FILE="$BASE_DIR/.version"
# Python: нужен 3.10+, конкретная минорная версия не важна.
PYTHON_BIN="${AWG_PYTHON_BIN:-}"
if [[ -z "$PYTHON_BIN" ]]; then
for candidate in python3.13 python3.12 python3.11 python3.10 python3; do
if command -v "$candidate" &>/dev/null; then PYTHON_BIN="$candidate"; break; fi
done
fi
# Цвета для вывода
GREEN=$'\033[0;32m'
@@ -53,10 +74,10 @@ run_with_spinner() {
# Функция проверки обновлений на GitHub
check_github_updates() {
local current_sha local_sha latest_sha auto_mode="$1"
cd /root/amnezia-bot || { echo -e "${RED}Каталог amnezia-bot не найден${NC}"; return 1; }
cd "$BASE_DIR" || { echo -e "${RED}Каталог $BASE_DIR не найден${NC}"; return 1; }
echo -e "${YELLOW}Текущая директория: $(pwd)${NC}"
echo -e "${YELLOW}Содержимое /root/amnezia-bot:${NC}"
echo -e "${YELLOW}Содержимое $BASE_DIR:${NC}"
ls -la
# Получение текущего SHA коммита
@@ -64,7 +85,7 @@ check_github_updates() {
# Получение последнего коммита через GitHub API
if command -v curl &>/dev/null; then
latest_sha=$(curl -s "$REPO_API/commits/main" | jq -r '.sha' 2>/dev/null)
latest_sha=$(curl -s "$REPO_API/commits/$REPO_BRANCH" | jq -r '.sha' 2>/dev/null)
[[ -z "$latest_sha" ]] && { echo -e "${RED}Не удалось получить данные с GitHub${NC}"; cd ..; return 1; }
else
echo -e "${RED}curl не установлен${NC}"; cd ..; return 1
@@ -83,14 +104,14 @@ check_github_updates() {
echo -e "${YELLOW}Обнаружены локальные изменения. Сбрасываем их...${NC}"
run_with_spinner "Сброс локальных изменений" "git reset --hard && git clean -fd"
fi
run_with_spinner "Обновление репозитория" "git pull"
run_with_spinner "Обновление репозитория" "git pull origin $REPO_BRANCH"
echo "$latest_sha" > "$LOCAL_VERSION_FILE"
check_script_update
# Проверка и создание виртуального окружения, если отсутствует
if [[ ! -d "myenv" ]]; then
run_with_spinner "Создание виртуального окружения" "python3.11 -m venv myenv"
run_with_spinner "Создание виртуального окружения" "$PYTHON_BIN -m venv myenv"
fi
run_with_spinner "Обновление Python-зависимостей" "source myenv/bin/activate && pip install --upgrade pip && pip install aiogram==2.25.1 aiohttp==3.8.6 apscheduler==3.10.4 humanize==4.9.0 pytz==2023.3.post1 && deactivate"
run_with_spinner "Обновление Python-зависимостей" "source myenv/bin/activate && pip install --upgrade pip && pip install -r requirements.txt && deactivate"
run_with_spinner "Перезапуск службы" "systemctl restart $SERVICE_NAME"
else
echo -ne "${BLUE}1) Установить 2) Отменить: ${NC}"; read choice
@@ -99,14 +120,14 @@ check_github_updates() {
echo -e "${YELLOW}Обнаружены локальные изменения. Сбрасываем их...${NC}"
run_with_spinner "Сброс локальных изменений" "git reset --hard && git clean -fd"
fi
run_with_spinner "Обновление репозитория" "git pull"
run_with_spinner "Обновление репозитория" "git pull origin $REPO_BRANCH"
echo "$latest_sha" > "$LOCAL_VERSION_FILE"
check_script_update
# Проверка и создание виртуального окружения, если отсутствует
if [[ ! -d "myenv" ]]; then
run_with_spinner "Создание виртуального окружения" "python3.11 -m venv myenv"
run_with_spinner "Создание виртуального окружения" "$PYTHON_BIN -m venv myenv"
fi
run_with_spinner "Обновление Python-зависимостей" "source myenv/bin/activate && pip install --upgrade pip && pip install aiogram==2.25.1 aiohttp==3.8.6 apscheduler==3.10.4 humanize==4.9.0 pytz==2023.3.post1 && deactivate"
run_with_spinner "Обновление Python-зависимостей" "source myenv/bin/activate && pip install --upgrade pip && pip install -r requirements.txt && deactivate"
run_with_spinner "Перезапуск службы" "systemctl restart $SERVICE_NAME"
else
echo -e "${YELLOW}Обновление отменено${NC}"
@@ -118,8 +139,8 @@ check_github_updates() {
# Проверка обновления самого скрипта
check_script_update() {
local temp_script=$(mktemp)
if [[ -f "/root/amnezia-bot/install.sh" ]]; then
cp "/root/amnezia-bot/install.sh" "$temp_script"
if [[ -f "$BASE_DIR/install.sh" ]]; then
cp "$BASE_DIR/install.sh" "$temp_script"
if ! cmp -s "$SCRIPT_PATH" "$temp_script"; then
echo -e "${YELLOW}Обнаружено обновление скрипта install.sh${NC}"
run_with_spinner "Обновление скрипта" "mv $temp_script $SCRIPT_PATH && chmod +x $SCRIPT_PATH"
@@ -130,8 +151,8 @@ check_script_update() {
fi
else
echo -e "${YELLOW}Скрипт install.sh не найден в репозитории, копируем локальную версию${NC}"
cp "$SCRIPT_PATH" "/root/amnezia-bot/install.sh"
chmod +x "/root/amnezia-bot/install.sh"
cp "$SCRIPT_PATH" "$BASE_DIR/install.sh"
chmod +x "$BASE_DIR/install.sh"
fi
}
@@ -147,7 +168,11 @@ check_updates() {
fi
# Проверка остальных зависимостей
for cmd in git curl python3.11; do
if [[ -z "$PYTHON_BIN" ]]; then
echo -e "${RED}Python 3 не найден. Установите python3 (3.10 или новее).${NC}"
exit 1
fi
for cmd in git curl "$PYTHON_BIN"; do
if ! command -v "$cmd" &>/dev/null; then
echo -e "${RED}$cmd не установлен. Установите $cmd для продолжения.${NC}"
exit 1
@@ -161,22 +186,23 @@ check_updates() {
fi
# Проверка и клонирование репозитория, если он отсутствует
if [[ ! -d "/root/amnezia-bot/.git" ]]; then
echo -e "${YELLOW}Репозиторий не найден. Проверяем /root/amnezia-bot...${NC}"
echo -e "${YELLOW}Содержимое /root:${NC}"
ls -la /root
if [[ -d "/root/amnezia-bot" ]]; then
echo -e "${YELLOW}Директория /root/amnezia-bot существует, но не является git-репозиторием. Удаляем её...${NC}"
rm -rf /root/amnezia-bot || error_exit "Не удалось удалить поврежденную директорию /root/amnezia-bot"
if [[ ! -d "$BASE_DIR/.git" ]]; then
echo -e "${YELLOW}Репозиторий не найден. Проверяем $BASE_DIR...${NC}"
echo -e "${YELLOW}Содержимое $PARENT_DIR:${NC}"
ls -la "$PARENT_DIR"
if [[ -d "$BASE_DIR" ]]; then
echo -e "${YELLOW}Директория $BASE_DIR существует, но не является git-репозиторием. Удаляем её...${NC}"
rm -rf $BASE_DIR || error_exit "Не удалось удалить поврежденную директорию $BASE_DIR"
fi
echo -e "${YELLOW}Клонируем репозиторий...${NC}"
cd /root || error_exit "Не удалось перейти в /root"
run_with_spinner "Клонирование репозитория" "git clone $REPO_URL"
cd amnezia-bot || error_exit "Не удалось перейти в каталог amnezia-bot"
mkdir -p "$PARENT_DIR" || error_exit "Не удалось создать $PARENT_DIR"
cd "$PARENT_DIR" || error_exit "Не удалось перейти в $PARENT_DIR"
run_with_spinner "Клонирование репозитория" "git clone -b $REPO_BRANCH $REPO_URL $BOT_DIRNAME"
cd "$BOT_DIRNAME" || error_exit "Не удалось перейти в каталог $BOT_DIRNAME"
if [[ ! -d ".git" ]]; then
error_exit "Клонирование репозитория не удалось. Проверьте доступ к $REPO_URL"
fi
echo -e "${YELLOW}Содержимое /root/amnezia-bot после клонирования:${NC}"
echo -e "${YELLOW}Содержимое $BASE_DIR после клонирования:${NC}"
ls -la
fi
check_github_updates "$1"
+4 -22
Просмотреть файл
@@ -1,22 +1,4 @@
aiofiles==24.1.0
aiogram==2.25.2
aiohttp==3.8.6
aiosignal==1.3.1
APScheduler==3.10.4
async-timeout==4.0.3
attrs==24.2.0
Babel==2.9.1
certifi==2024.8.30
charset-normalizer==3.4.0
frozenlist==1.5.0
humanize==4.11.0
idna==3.10
magic-filter==1.0.12
multidict==6.1.0
propcache==0.2.0
pytz==2024.2
six==1.16.0
tzlocal==5.2
yarl==1.17.1
yookassa==2.4.0
YooMoney==0.1.0
# Прямые зависимости бота. Транзитивные (aiohttp и др.) подтягивает pip.
aiogram==3.31.0
APScheduler==3.11.3
pytz==2026.3.post1