Files
amnezia-bot/awg/awg_decode.py
T
viktor 11b52cf5ad Обновление зависимостей, устранение хардкода, оплата через 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

144 строки
4.5 KiB
Python

import sys
import struct
import zlib
import base64
import argparse
import socket
import ipaddress
import re
def qCompress(data, level=-1):
compressed = zlib.compress(data, level)
header = struct.pack('>I', len(data))
return header + compressed
def qUncompress(data):
if len(data) < 4:
return b''
uncompressed_size = struct.unpack('>I', data[:4])[0]
compressed_data = data[4:]
try:
uncompressed_data = zlib.decompress(compressed_data)
except zlib.error:
return b''
if len(uncompressed_data) != uncompressed_size:
return b''
return uncompressed_data
def base64url_encode(data):
encoded = base64.urlsafe_b64encode(data)
return encoded.rstrip(b'=')
def base64url_decode(data):
padding_needed = (4 - len(data) % 4) % 4
data += b'=' * padding_needed
return base64.urlsafe_b64decode(data)
def is_ip_address(address):
try:
ipaddress.ip_address(address)
return True
except ValueError:
return False
def resolve_dns_to_ip(dns_name):
try:
ip_address = socket.gethostbyname(dns_name)
return ip_address
except socket.gaierror:
return None
def process_conf_data(data):
def replace_endpoint(match):
full_line = match.group(0)
prefix = match.group(1)
address = match.group(2)
port = match.group(3)
suffix = match.group(4)
if not is_ip_address(address):
resolved_ip = resolve_dns_to_ip(address)
if resolved_ip:
print(f"Resolved DNS '{address}' to IP '{resolved_ip}'", file=sys.stderr)
return f"{prefix}{resolved_ip}:{port}{suffix}"
else:
raise ValueError(f"Не удалось разрешить DNS-имя '{address}'")
else:
return full_line
pattern = r'^(.*Endpoint\s*=\s*)([^\s:]+)(?::(\d+))(.*)$'
return re.sub(pattern, replace_endpoint, data, flags=re.MULTILINE)
def encode(data):
data_bytes = data.encode('utf-8')
compressed = qCompress(data_bytes, level=8)
base64_encoded = base64url_encode(compressed)
s = 'vpn://' + base64_encoded.decode('ascii')
return s
def decode(s):
data = s.replace('vpn://', '')
data_bytes = data.encode('ascii')
compressed = base64url_decode(data_bytes)
uncompressed = qUncompress(compressed)
if uncompressed:
result = uncompressed
else:
result = compressed
return result.decode('utf-8')
def main():
parser = argparse.ArgumentParser(description='Encode and decode VPN configuration files to/from vpn:// format.')
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument('-e', '--encode', action='store_true', help='Encode a .conf file to vpn:// format.')
group.add_argument('-d', '--decode', action='store_true', help='Decode a vpn:// string to configuration data.')
parser.add_argument('input', help='Input file for encoding or vpn:// string for decoding.')
parser.add_argument('-o', '--output', help='Output file. If not specified, output will be printed to console.')
args = parser.parse_args()
if args.encode:
try:
with open(args.input, 'r', encoding='utf-8') as f:
data = f.read()
except FileNotFoundError:
print(f'Error: File {args.input} not found.')
sys.exit(1)
except Exception as e:
print(f'Error reading file {args.input}: {e}')
sys.exit(1)
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)
if args.output:
try:
with open(args.output, 'w', encoding='utf-8') as f:
f.write(encoded_string)
print(f'Encoded vpn:// string written to {args.output}')
except Exception as e:
print(f'Error writing to file {args.output}: {e}')
else:
print(encoded_string)
elif args.decode:
vpn_string = args.input
decoded_data = decode(vpn_string)
if args.output:
try:
with open(args.output, 'w', encoding='utf-8') as f:
f.write(decoded_data)
print(f'Decoded configuration data written to {args.output}')
except Exception as e:
print(f'Error writing to file {args.output}: {e}')
else:
print(decoded_data)
if __name__ == '__main__':
main()