Библиотеки: - миграция с 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).
144 строки
4.5 KiB
Python
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()
|