Обновление зависимостей, устранение хардкода, оплата через 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 удалений
+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"])