Compare commits

..

9 Commits

Author SHA1 Message Date
Alexey Polyakov
b7ac608300 Подправил инструкцию по патчу
Сделал чище и постарался сделать грамотнее
2026-03-10 17:57:52 +03:00
BetaAcccc
fa422d515d Update patch_apk.md 2026-03-10 21:30:26 +07:00
BetaAcccc
ca0b23fd6d Update readme.md 2026-03-10 21:29:02 +07:00
BetaAcccc
4008c61368 Update readme.md 2026-03-10 21:14:19 +07:00
BetaAcccc
6b422ed0e3 Create readme.md 2026-03-10 21:13:24 +07:00
BetaAcccc
d9a0e45f8e remove space 2026-03-10 21:10:25 +07:00
BetaAcccc
db7a4b9c70 Update patch_apk.md 2026-03-10 21:10:06 +07:00
BetaAcccc
ac4cc0f481 Create install.md 2026-03-10 21:07:59 +07:00
BetaAcccc
4995bfd304 Create patch_apk.md 2026-03-10 21:07:05 +07:00
19 changed files with 105 additions and 323 deletions

View File

@@ -22,6 +22,3 @@
2. Открываем консоль в той же директории и производим декомпиляцию: `apktool d <имя apk> -o max` 2. Открываем консоль в той же директории и производим декомпиляцию: `apktool d <имя apk> -o max`
3. Заходим в папку проекта и заменяем во всех классах "api.oneme.ru" на свой адрес сервера 3. Заходим в папку проекта и заменяем во всех классах "api.oneme.ru" на свой адрес сервера
4. Производим повторную сборку с помощью команды: `apktool b max -o max_modified.apk` 4. Производим повторную сборку с помощью команды: `apktool b max -o max_modified.apk`
> [!WARNING]
> Если у вас возникает ошибка при при повторной сборке, попробуйте декомпилировать клиент с параметром -r

View File

@@ -1,8 +0,0 @@
# Патч MAX для IOS
1) Распаковываете IPA
2) Находите файл MAX в Payload/Max.app
3) Открываете hex-редактор и находите api.oneme.ru и меняете на свой
При желании можете поменять другие URL
URL не должен привышать количество символов которое было у изначального URL!
4) Открываете IPA как архив и добавляете патченный файл MAX в Payload/Max.app

View File

@@ -1,8 +1,7 @@
# Навигация по faq # Навигация по faq
## Работа с сервером ## Работа с сервером
- [Установка сервера](install.md) [Установка сервера](install.md)
## Патчинг клиентов ## Патчинг клиентов
- [Патч мобильного клиента для Android](patch_apk.md) [Патч apk](patch_apk.md)
- [Патч мобильного клиента для IOS](patch_ipa.md)

View File

@@ -1,11 +0,0 @@
> [!Caution]
>
> На данной странице представлены инстансы от сторонних разработчиков.
>
> Используйте на свой страх и риск
>
# Открытые сервера
* [JustMAX](https://t.me/justmax_official)
* [mox.nyako.tk](https://t.me/opengrame/296), порт 443 (регистрация по реальному номеру)

View File

@@ -1,6 +1,6 @@
> [!Caution] > [!Caution]
> >
> Проект находится на ранней стадии разработки и, вероятно, полон багов. > Проект находится на ранней стадии разработки и вероятно полон багов.
> >
> Использование в профессиональных средах не рекомендовано. > Использование в профессиональных средах не рекомендовано.
> >
@@ -13,7 +13,7 @@ https://t.me/openmax_alerts
# Требования # Требования
- Python 3.12+ (поддержка версий ниже не гарантирована) - Python 3.12+ (поддержка версий ниже не гарантирована)
- MariaDB, MySQL или SQLite (использование последнего не рекомендуется, так как поддержка ещё в разработке) - MariaDB, MySQL или SQLite
- Уметь патчить клиент MAX или собирать Komet из исходного кода (естественно с заменой сервера) - Уметь патчить клиент MAX или собирать Komet из исходного кода (естественно с заменой сервера)
- Сертификат и приватный ключ X.509 (для тестирования сервера можно сгенерировать самоподписанный: ```openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem -days 365```) - Сертификат и приватный ключ X.509 (для тестирования сервера можно сгенерировать самоподписанный: ```openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem -days 365```)
@@ -22,6 +22,4 @@ https://t.me/openmax_alerts
Клиент может быть практически любым, главное условие - чтобы он был совместим с официальным сервером (`api.oneme.ru` / `api.tamtam.chat`). Клиент может быть практически любым, главное условие - чтобы он был совместим с официальным сервером (`api.oneme.ru` / `api.tamtam.chat`).
# Дополнительная информация # Дополнительная информация
- [FAQ](faq/readme.md) [Faq](faq/readme.md)
- [Документация проекта](https://github.com/openmax-server/docs)
- [Публичные сервера](faq/servers.md)

View File

@@ -1,8 +1,9 @@
pyTelegramBotAPI pyTelegramBotAPI
aiomysql aiomysql
python-dotenv
msgpack msgpack
lz4 lz4
websockets websockets
pydantic pydantic
aiosqlite aiohttp
python-dotenv aiosqlite

View File

@@ -1,6 +1,5 @@
import os import os
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv() load_dotenv()
class ServerConfig: class ServerConfig:

View File

@@ -1,51 +0,0 @@
import time, logging
class RateLimiter:
"""
ip rate limiter using sliding window algorithm
"""
def __init__(self, max_attempts=5, window_seconds=60):
self.max_attempts = max_attempts
self.window_seconds = window_seconds
self.attempts = {} # {ip: [timestamp, ...]}
self.logger = logging.getLogger(__name__)
def is_allowed(self, ip: str) -> bool:
now = time.monotonic()
cutoff = now - self.window_seconds
if ip not in self.attempts:
self.attempts[ip] = []
self.attempts[ip] = [t for t in self.attempts[ip] if t > cutoff]
if len(self.attempts[ip]) >= self.max_attempts:
self.logger.warning(f"request limit exceeded for {ip}: {len(self.attempts[ip])}/{self.max_attempts}")
return False
self.attempts[ip].append(now)
return True
def remaining(self, ip: str) -> int:
now = time.monotonic()
cutoff = now - self.window_seconds
entries = self.attempts.get(ip, [])
active = [t for t in entries if t > cutoff]
return max(0, self.max_attempts - len(active))
def retry_after(self, ip: str) -> int:
entries = self.attempts.get(ip, [])
if not entries:
return 0
now = time.monotonic()
cutoff = now - self.window_seconds
active = [t for t in entries if t > cutoff]
if len(active) < self.max_attempts:
return 0
oldest = min(active)
return max(0, int(oldest + self.window_seconds - now) + 1)

View File

@@ -12,7 +12,6 @@ class Static:
INVALID_TOKEN = "invalid_token" INVALID_TOKEN = "invalid_token"
CHAT_NOT_FOUND = "chat_not_found" CHAT_NOT_FOUND = "chat_not_found"
CHAT_NOT_ACCESS = "chat_not_access" CHAT_NOT_ACCESS = "chat_not_access"
RATE_LIMITED = "rate_limited"
class ChatTypes: class ChatTypes:
DIALOG = "DIALOG" DIALOG = "DIALOG"
@@ -74,12 +73,6 @@ class Static:
"error": "chat.not.access", "error": "chat.not.access",
"message": "Chat not access", "message": "Chat not access",
"title": "Нет доступа к чату" "title": "Нет доступа к чату"
},
"rate_limited": {
"localizedMessage": "Слишком много попыток. Повторите позже",
"error": "error.rate_limited",
"message": "Too many attempts. Please try again later",
"title": "Слишком много попыток"
} }
} }

View File

@@ -102,14 +102,20 @@ class Tools:
# Выносим результат в лист # Выносим результат в лист
chats.append( chats.append(
self.generate_chat( {
row.get("id"), "id": row.get("id"),
row.get("owner"), "type": row.get("type"),
row.get("type"), "status": "ACTIVE",
participants, "owner": row.get("owner"),
message, "participants": participants,
messageTime "lastMessage": message,
) "lastEventTime": messageTime,
"lastDelayedUpdateTime": 0,
"lastFireDelayedErrorTime": 0,
"created": 1,
"joinTime": 1,
"modified": messageTime
}
) )
# Получаем последнее сообщение из избранного # Получаем последнее сообщение из избранного
@@ -117,19 +123,24 @@ class Tools:
senderId, db_pool senderId, db_pool
) )
# ID избранного
chatId = senderId ^ senderId
# Хардкодим в лист чатов избранное # Хардкодим в лист чатов избранное
chats.append( chats.append(
self.generate_chat( {
chatId, "id": 0,
senderId, "type": "DIALOG",
"DIALOG", "status": "ACTIVE",
[senderId], "owner": senderId,
message, "participants": {
messageTime str(senderId): 0 # if not messageTime else messageTime
) },
"lastMessage": message,
"lastEventTime": messageTime,
"lastDelayedUpdateTime": 0,
"lastFireDelayedErrorTime": 0,
"created": 1,
"joinTime": 1,
"modified": messageTime
}
) )
return chats return chats
@@ -174,11 +185,9 @@ class Tools:
"time": int(row.get("time")), "time": int(row.get("time")),
"type": row.get("type"), "type": row.get("type"),
"sender": row.get("sender"), "sender": row.get("sender"),
"cid": int(row.get("cid")),
"text": row.get("text"), "text": row.get("text"),
"attaches": json.loads(row.get("attaches")), "attaches": json.loads(row.get("attaches")),
"elements": json.loads(row.get("elements")), # "reactionInfo": {}
"reactionInfo": {}
} }
# Возвращаем # Возвращаем

View File

@@ -179,7 +179,7 @@ class OnemeConfig:
"moscow-theme-enabled": True, "moscow-theme-enabled": True,
"msg-get-reactions-page-size": 40, "msg-get-reactions-page-size": 40,
"music-files-enabled": False, "music-files-enabled": False,
"mytracker-enabled": False, "mytracker-enabled": True,
"net-client-dns-enabled": True, "net-client-dns-enabled": True,
"net-session-suppress-bad-disconnected-state": True, "net-session-suppress-bad-disconnected-state": True,
"net-stat-config": [ "net-stat-config": [

View File

@@ -1,4 +1,4 @@
import json, secrets, hashlib, time, logging import json, random, secrets, hashlib, time, logging
from oneme_tcp.models import * from oneme_tcp.models import *
from oneme_tcp.proto import Proto from oneme_tcp.proto import Proto
from oneme_tcp.config import OnemeConfig from oneme_tcp.config import OnemeConfig
@@ -122,8 +122,8 @@ class Processors:
# Извлекаем телефон из пакета # Извлекаем телефон из пакета
phone = payload.get("phone").replace("+", "").replace(" ", "").replace("-", "") phone = payload.get("phone").replace("+", "").replace(" ", "").replace("-", "")
# Генерируем токен с кодом (безопасность прежде всего) # Генерируем токен с кодом
code = str(secrets.randbelow(900000) + 100000) code = str(random.randint(100000, 999999))
token = secrets.token_urlsafe(128) token = secrets.token_urlsafe(128)
# Хешируем # Хешируем
@@ -217,7 +217,7 @@ class Processors:
# Создаем сессию # Создаем сессию
await cursor.execute( await cursor.execute(
"INSERT INTO tokens (phone, token_hash, device_type, device_name, location, time) VALUES (%s, %s, %s, %s, %s, %s)", "INSERT INTO tokens (phone, token_hash, device_type, device_name, location, time) VALUES (%s, %s, %s, %s, %s, %s)",
(stored_token.get("phone"), hashed_login, deviceType, deviceName, "Little Saint James Island", int(time.time()),) # весь покрытый зеленью, абсолютно весь, остров невезения в океане есть (stored_token.get("phone"), hashed_login, deviceType, deviceName, "Epstein Island", int(time.time()),)
) )
# Генерируем профиль # Генерируем профиль
@@ -445,7 +445,7 @@ class Processors:
chatId = userId ^ senderId chatId = userId ^ senderId
# Если клиент хочет отправить сообщение в избранное, # Если клиент хочет отправить сообщение в избранное,
# то выставляем в качестве ID чата ID отправителя # то выставляем ID чата 0
# (А ещё используем это, если клиент вообще ничего не указал) # (А ещё используем это, если клиент вообще ничего не указал)
if chatId == 0 or not chatId: if chatId == 0 or not chatId:
chatId = senderId chatId = senderId
@@ -491,8 +491,7 @@ class Processors:
"sender": senderId, "sender": senderId,
"cid": cid, "cid": cid,
"text": text, "text": text,
"attaches": attaches, "attaches": attaches
"elements": elements
} }
# Отправляем событие всем участникам чата # Отправляем событие всем участникам чата
@@ -677,12 +676,10 @@ class Processors:
chat = await cursor.fetchone() chat = await cursor.fetchone()
if chat: if chat:
# Проверяем, является ли пользователь участником чата # Если чат - диалог, и пользователь в нем не состоит,
# то продолжаем без добавления результата
# (в max нельзя смотреть и отправлять сообщения в чат, в котором ты не участник, в отличие от tg (например, комментарии в каналах), if chat.get("type") == self.chat_types.DIALOG and senderId not in json.loads(chat.get("participants")):
# так что надо тоже так делать) continue
if senderId not in json.loads(chat.get("participants")):
continue
# Получаем последнее сообщение из чата # Получаем последнее сообщение из чата
message, messageTime = await self.tools.get_last_message( message, messageTime = await self.tools.get_last_message(
@@ -703,9 +700,6 @@ class Processors:
senderId, self.db_pool senderId, self.db_pool
) )
# ID избранного
chatId = senderId ^ senderId
# Добавляем чат в список # Добавляем чат в список
chats.append( chats.append(
self.tools.generate_chat( self.tools.generate_chat(

View File

@@ -4,19 +4,8 @@ class Proto:
def __init__(self) -> None: def __init__(self) -> None:
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
# TODO узнать какие должны быть лимиты и поменять,
# сейчас это больше заглушка
MAX_PAYLOAD_SIZE = 1048576 # 1 MB
MAX_DECOMPRESSED_SIZE = 1048576 # 1 MB
HEADER_SIZE = 10 # 1+2+1+2+4
### Работа с протоколом ### Работа с протоколом
def unpack_packet(self, data: bytes) -> dict | None: def unpack_packet(self, data: bytes) -> dict | None:
# Проверяем минимальный размер пакета
if len(data) < self.HEADER_SIZE:
self.logger.warning(f"Пакет слишком маленький: {len(data)} байт")
return None
# Распаковываем заголовок # Распаковываем заголовок
ver = int.from_bytes(data[0:1], "big") ver = int.from_bytes(data[0:1], "big")
cmd = int.from_bytes(data[1:3], "big") cmd = int.from_bytes(data[1:3], "big")
@@ -29,17 +18,6 @@ class Proto:
# Парсим данные пакета # Парсим данные пакета
payload_length = packed_len & 0xFFFFFF payload_length = packed_len & 0xFFFFFF
# Проверяем размер payload
if payload_length > self.MAX_PAYLOAD_SIZE:
self.logger.warning(f"Payload слишком большой: {payload_length} B (лимит {self.MAX_PAYLOAD_SIZE})")
return None
# Проверяем длину пакета
if len(data) < self.HEADER_SIZE + payload_length:
self.logger.warning(f"Пакет неполный: требуется {self.HEADER_SIZE + payload_length} B, получено {len(data)}")
return None
payload_bytes = data[10 : 10 + payload_length] payload_bytes = data[10 : 10 + payload_length]
payload = None payload = None
@@ -49,14 +27,14 @@ class Proto:
if comp_flag != 0: if comp_flag != 0:
compressed_data = payload_bytes compressed_data = payload_bytes
try: try:
payload_bytes = lz4.block.decompress( payload_bytes = lz4.block.decompress(
compressed_data, compressed_data,
uncompressed_size=self.MAX_DECOMPRESSED_SIZE, uncompressed_size=99999,
) )
except lz4.block.LZ4BlockError: except lz4.block.LZ4BlockError:
self.logger.warning("Ошибка декомпрессии LZ4")
return None return None
# Распаковываем msgpack # Распаковываем msgpack
payload = msgpack.unpackb(payload_bytes, raw=False, strict_map_key=False) payload = msgpack.unpackb(payload_bytes, raw=False, strict_map_key=False)

View File

@@ -1,7 +1,6 @@
import asyncio, logging, traceback import asyncio, logging, traceback
from oneme_tcp.proto import Proto from oneme_tcp.proto import Proto
from oneme_tcp.processors import Processors from oneme_tcp.processors import Processors
from common.rate_limiter import RateLimiter
class OnemeMobileServer: class OnemeMobileServer:
def __init__(self, host="0.0.0.0", port=443, ssl_context=None, db_pool=None, clients={}, send_event=None, telegram_bot=None): def __init__(self, host="0.0.0.0", port=443, ssl_context=None, db_pool=None, clients={}, send_event=None, telegram_bot=None):
@@ -16,12 +15,6 @@ class OnemeMobileServer:
self.proto = Proto() self.proto = Proto()
self.processors = Processors(db_pool=db_pool, clients=clients, send_event=send_event, telegram_bot=telegram_bot) self.processors = Processors(db_pool=db_pool, clients=clients, send_event=send_event, telegram_bot=telegram_bot)
# rate limiter anti ddos brute force protection
self.auth_rate_limiter = RateLimiter(max_attempts=5, window_seconds=60)
self.read_timeout = 300 # Таймаут чтения из сокета (секунды)
self.max_read_size = 65536 # Максимальный размер данных из сокета
async def handle_client(self, reader, writer): async def handle_client(self, reader, writer):
"""Функция для обработки подключений""" """Функция для обработки подключений"""
# IP-адрес клиента # IP-адрес клиента
@@ -37,33 +30,16 @@ class OnemeMobileServer:
try: try:
while True: while True:
# Читаем новые данные из сокета с таймаутом # Читаем новые данные из сокета
try: data = await reader.read(4098)
data = await asyncio.wait_for(
reader.read(self.max_read_size),
timeout=self.read_timeout
)
except asyncio.TimeoutError:
self.logger.info(f"Таймаут соединения для {address[0]}:{address[1]}")
break
# Если сокет закрыт - выходим из цикла # Если сокет закрыт - выходим из цикла
if not data: if not data:
break break
if len(data) > self.max_read_size:
self.logger.warning(f"Пакет от {address[0]}:{address[1]} превышает лимит ({len(data)} байт)")
break
# Распаковываем данные # Распаковываем данные
packet = self.proto.unpack_packet(data) packet = self.proto.unpack_packet(data)
# Скип если пакет невалидный
if packet is None:
self.logger.warning(f"Невалидный пакет от {address[0]}:{address[1]}")
continue
opcode = packet.get("opcode") opcode = packet.get("opcode")
seq = packet.get("seq") seq = packet.get("seq")
payload = packet.get("payload") payload = packet.get("payload")
@@ -72,23 +48,14 @@ class OnemeMobileServer:
case self.proto.SESSION_INIT: case self.proto.SESSION_INIT:
deviceType, deviceName = await self.processors.process_hello(payload, seq, writer) deviceType, deviceName = await self.processors.process_hello(payload, seq, writer)
case self.proto.AUTH_REQUEST: case self.proto.AUTH_REQUEST:
if not self.auth_rate_limiter.is_allowed(address[0]): await self.processors.process_request_code(payload, seq, writer)
await self.processors._send_error(seq, self.proto.AUTH_REQUEST, self.processors.error_types.RATE_LIMITED, writer)
else:
await self.processors.process_request_code(payload, seq, writer)
case self.proto.AUTH: case self.proto.AUTH:
if not self.auth_rate_limiter.is_allowed(address[0]): await self.processors.process_verify_code(payload, seq, writer, deviceType, deviceName)
await self.processors._send_error(seq, self.proto.AUTH, self.processors.error_types.RATE_LIMITED, writer)
else:
await self.processors.process_verify_code(payload, seq, writer, deviceType, deviceName)
case self.proto.LOGIN: case self.proto.LOGIN:
if not self.auth_rate_limiter.is_allowed(address[0]): userPhone, userId, hashedToken = await self.processors.process_login(payload, seq, writer)
await self.processors._send_error(seq, self.proto.LOGIN, self.processors.error_types.RATE_LIMITED, writer)
else: if userPhone:
userPhone, userId, hashedToken = await self.processors.process_login(payload, seq, writer) await self._finish_auth(writer, address, userPhone, userId)
if userPhone:
await self._finish_auth(writer, address, userPhone, userId)
case self.proto.LOGOUT: case self.proto.LOGOUT:
await self.processors.process_logout(seq, writer, hashedToken=hashedToken) await self.processors.process_logout(seq, writer, hashedToken=hashedToken)
break break

View File

@@ -1,19 +1,11 @@
import hashlib import hashlib, secrets, random, time, logging, json
import secrets
import time
import logging
import json
import re
from common.static import Static from common.static import Static
from common.tools import Tools from common.tools import Tools
from tamtam_tcp.proto import Proto from tamtam_tcp.proto import Proto
from tamtam_tcp.models import * from tamtam_tcp.models import *
class Processors: class Processors:
def __init__(self, db_pool=None, clients=None, send_event=None): def __init__(self, db_pool=None, clients={}, send_event=None):
if clients is None:
clients = {} # Более правильная логика
self.static = Static() self.static = Static()
self.proto = Proto() self.proto = Proto()
self.tools = Tools() self.tools = Tools()
@@ -35,11 +27,11 @@ class Processors:
"message": "Unknown error", "message": "Unknown error",
"title": "Неизвестная ошибка" "title": "Неизвестная ошибка"
}) })
packet = self.proto.pack_packet( packet = self.proto.pack_packet(
cmd=self.proto.CMD_ERR, seq=seq, opcode=opcode, payload=payload cmd=self.proto.CMD_ERR, seq=seq, opcode=opcode, payload=payload
) )
await self._send(writer, packet) await self._send(writer, packet)
async def process_hello(self, payload, seq, writer): async def process_hello(self, payload, seq, writer):
@@ -50,10 +42,10 @@ class Processors:
except Exception as e: except Exception as e:
await self._send_error(seq, self.proto.HELLO, self.error_types.INVALID_PAYLOAD, writer) await self._send_error(seq, self.proto.HELLO, self.error_types.INVALID_PAYLOAD, writer)
return None, None return None, None
# Получаем данные из пакета # Получаем данные из пакета
device_type = payload.get("userAgent").get("deviceType") deviceType = payload.get("userAgent").get("deviceType")
device_name = payload.get("userAgent").get("deviceName") deviceName = payload.get("userAgent").get("deviceName")
# Данные пакета # Данные пакета
payload = { payload = {
@@ -72,8 +64,8 @@ class Processors:
# Отправляем # Отправляем
await self._send(writer, packet) await self._send(writer, packet)
return device_type, device_name return deviceType, deviceName
async def process_request_code(self, payload, seq, writer): async def process_request_code(self, payload, seq, writer):
"""Обработчик запроса кода""" """Обработчик запроса кода"""
# Валидируем данные пакета # Валидируем данные пакета
@@ -84,17 +76,17 @@ class Processors:
return return
# Извлекаем телефон из пакета # Извлекаем телефон из пакета
phone = re.sub(r'\D', '', payload.get("phone", "")) # Не хардкодим, через регулярки phone = payload.get("phone").replace("+", "").replace(" ", "").replace("-", "")
# Генерируем токен с кодом # Генерируем токен с кодом
code = f"{secrets.randbelow(1_000_000):06d}" # Старая версия ненадежна, могла отбросить ведущие нули или вообще интерпритировать как систему счисления с основанием 8 code = str(random.randint(000000, 999999))
token = secrets.token_urlsafe(128) token = secrets.token_urlsafe(128)
# Хешируем # Хешируем
code_hash = hashlib.sha256(code.encode()).hexdigest() code_hash = hashlib.sha256(code.encode()).hexdigest()
token_hash = hashlib.sha256(token.encode()).hexdigest() token_hash = hashlib.sha256(token.encode()).hexdigest()
# Срок жизни токена (5 минут) # Время истечения токена
expires = int(time.time()) + 300 expires = int(time.time()) + 300
# Ищем пользователя, и если он существует, сохраняем токен # Ищем пользователя, и если он существует, сохраняем токен
@@ -103,14 +95,12 @@ class Processors:
await cursor.execute("SELECT * FROM users WHERE phone = %s", (phone,)) await cursor.execute("SELECT * FROM users WHERE phone = %s", (phone,))
user = await cursor.fetchone() user = await cursor.fetchone()
if not user: if user is None:
await self._send_error(seq, self.proto.REQUEST_CODE, self.error_types.USER_NOT_FOUND, writer) await self._send_error(seq, self.proto.REQUEST_CODE, self.error_types.USER_NOT_FOUND, writer)
return return
# Сохраняем токен # Сохраняем токен
await cursor.execute( await cursor.execute("INSERT INTO auth_tokens (phone, token_hash, code_hash, expires, state) VALUES (%s, %s, %s, %s, %s)", (phone, token_hash, code_hash, expires, "started",))
"INSERT INTO auth_tokens (phone, token_hash, code_hash, expires, state) VALUES (%s, %s, %s, %s, %s)",
(phone, token_hash, code_hash, expires, "started",))
# Данные пакета # Данные пакета
payload = { payload = {
@@ -153,11 +143,10 @@ class Processors:
async with self.db_pool.acquire() as conn: async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor: async with conn.cursor() as cursor:
# Ищем токен # Ищем токен
await cursor.execute("SELECT * FROM auth_tokens WHERE token_hash = %s AND expires > UNIX_TIMESTAMP()", await cursor.execute("SELECT * FROM auth_tokens WHERE token_hash = %s AND expires > UNIX_TIMESTAMP()", (hashed_token,))
(hashed_token,))
stored_token = await cursor.fetchone() stored_token = await cursor.fetchone()
if not stored_token: if stored_token is None:
await self._send_error(seq, self.proto.VERIFY_CODE, self.error_types.CODE_EXPIRED, writer) await self._send_error(seq, self.proto.VERIFY_CODE, self.error_types.CODE_EXPIRED, writer)
return return
@@ -165,14 +154,13 @@ class Processors:
if stored_token.get("code_hash") != hashed_code: if stored_token.get("code_hash") != hashed_code:
await self._send_error(seq, self.proto.VERIFY_CODE, self.error_types.INVALID_CODE, writer) await self._send_error(seq, self.proto.VERIFY_CODE, self.error_types.INVALID_CODE, writer)
return return
# Ищем аккаунт # Ищем аккаунт
await cursor.execute("SELECT * FROM users WHERE phone = %s", (stored_token.get("phone"),)) await cursor.execute("SELECT * FROM users WHERE phone = %s", (stored_token.get("phone"),))
account = await cursor.fetchone() account = await cursor.fetchone()
# Обновляем состояние токена # Обновляем состояние токена
await cursor.execute("UPDATE auth_tokens set state = %s WHERE token_hash = %s", await cursor.execute("UPDATE auth_tokens set state = %s WHERE token_hash = %s", ("verified", hashed_token,))
("verified", hashed_token,))
# # Создаем сессию # # Создаем сессию
# await cursor.execute( # await cursor.execute(
@@ -182,9 +170,9 @@ class Processors:
# Генерируем профиль # Генерируем профиль
# Аватарка с биографией # Аватарка с биографией
photo_id = int(account["avatar_id"]) if account.get("avatar_id") else None photoId = None if not account.get("avatar_id") else int(account.get("avatar_id"))
avatar_url = f"{self.config.avatar_base_url}{photo_id}" if photo_id else None avatar_url = None if not photoId else self.config.avatar_base_url + photoId
description = account.get("description") description = None if not account.get("description") else account.get("description")
# Собираем данные пакета # Собираем данные пакета
payload = { payload = {
@@ -192,7 +180,7 @@ class Processors:
id=account.get("id"), id=account.get("id"),
phone=int(account.get("phone")), phone=int(account.get("phone")),
avatarUrl=avatar_url, avatarUrl=avatar_url,
photoId=photo_id, photoId=photoId,
updateTime=int(account.get("updatetime")), updateTime=int(account.get("updatetime")),
firstName=account.get("firstname"), firstName=account.get("firstname"),
lastName=account.get("lastname"), lastName=account.get("lastname"),
@@ -246,8 +234,7 @@ class Processors:
async with self.db_pool.acquire() as conn: async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor: async with conn.cursor() as cursor:
# Ищем токен # Ищем токен
await cursor.execute("SELECT * FROM auth_tokens WHERE token_hash = %s AND expires > UNIX_TIMESTAMP()", await cursor.execute("SELECT * FROM auth_tokens WHERE token_hash = %s AND expires > UNIX_TIMESTAMP()", (hashed_token,))
(hashed_token,))
stored_token = await cursor.fetchone() stored_token = await cursor.fetchone()
if stored_token is None: if stored_token is None:
@@ -268,13 +255,12 @@ class Processors:
# Создаем сессию # Создаем сессию
await cursor.execute( await cursor.execute(
"INSERT INTO tokens (phone, token_hash, device_type, device_name, location, time) VALUES (%s, %s, %s, %s, %s, %s)", "INSERT INTO tokens (phone, token_hash, device_type, device_name, location, time) VALUES (%s, %s, %s, %s, %s, %s)",
(stored_token.get("phone"), hashed_login, deviceType, deviceName, "Epstein Island", (stored_token.get("phone"), hashed_login, deviceType, deviceName, "Epstein Island", int(time.time()),)
int(time.time()),)
) )
# Аватарка с биографией # Аватарка с биографией
photo_id = None if not account.get("avatar_id") else int(account.get("avatar_id")) photoId = None if not account.get("avatar_id") else int(account.get("avatar_id"))
avatar_url = None if not photo_id else self.config.avatar_base_url + photo_id avatar_url = None if not photoId else self.config.avatar_base_url + photoId
description = None if not account.get("description") else account.get("description") description = None if not account.get("description") else account.get("description")
# Собираем данные пакета # Собираем данные пакета
@@ -284,7 +270,7 @@ class Processors:
id=account.get("id"), id=account.get("id"),
phone=int(account.get("phone")), phone=int(account.get("phone")),
avatarUrl=avatar_url, avatarUrl=avatar_url,
photoId=photo_id, photoId=photoId,
updateTime=int(account.get("updatetime")), updateTime=int(account.get("updatetime")),
firstName=account.get("firstname"), firstName=account.get("firstname"),
lastName=account.get("lastname"), lastName=account.get("lastname"),
@@ -304,4 +290,4 @@ class Processors:
cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.FINAL_AUTH, payload=payload cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.FINAL_AUTH, payload=payload
) )
await self._send(writer, packet) await self._send(writer, packet)

View File

@@ -4,19 +4,8 @@ class Proto:
def __init__(self) -> None: def __init__(self) -> None:
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
# TODO узнать какие должны быть лимиты и поменять,
# сейчас это больше заглушка
MAX_PAYLOAD_SIZE = 1048576 # 1 MB
MAX_DECOMPRESSED_SIZE = 1048576 # 1 MB
HEADER_SIZE = 10 # 1+2+1+2+4
### Работа с протоколом ### Работа с протоколом
def unpack_packet(self, data: bytes) -> dict | None: def unpack_packet(self, data: bytes) -> dict | None:
# Проверяем минимальный размер пакета
if len(data) < self.HEADER_SIZE:
self.logger.warning(f"Пакет слишком маленький: {len(data)} байт")
return None
# Распаковываем заголовок # Распаковываем заголовок
ver = int.from_bytes(data[0:1], "big") ver = int.from_bytes(data[0:1], "big")
cmd = int.from_bytes(data[1:3], "big") cmd = int.from_bytes(data[1:3], "big")
@@ -29,17 +18,6 @@ class Proto:
# Парсим данные пакета # Парсим данные пакета
payload_length = packed_len & 0xFFFFFF payload_length = packed_len & 0xFFFFFF
# Проверяем размер payload
if payload_length > self.MAX_PAYLOAD_SIZE:
self.logger.warning(f"Payload слишком большой: {payload_length} B (лимит {self.MAX_PAYLOAD_SIZE})")
return None
# Проверяем длину пакета
if len(data) < self.HEADER_SIZE + payload_length:
self.logger.warning(f"Пакет неполный: требуется {self.HEADER_SIZE + payload_length} B, получено {len(data)}")
return None
payload_bytes = data[10 : 10 + payload_length] payload_bytes = data[10 : 10 + payload_length]
payload = None payload = None
@@ -49,14 +27,14 @@ class Proto:
if comp_flag != 0: if comp_flag != 0:
compressed_data = payload_bytes compressed_data = payload_bytes
try: try:
payload_bytes = lz4.block.decompress( payload_bytes = lz4.block.decompress(
compressed_data, compressed_data,
uncompressed_size=self.MAX_DECOMPRESSED_SIZE, uncompressed_size=99999,
) )
except lz4.block.LZ4BlockError: except lz4.block.LZ4BlockError:
self.logger.warning("Ошибка декомпрессии LZ4")
return None return None
# Распаковываем msgpack # Распаковываем msgpack
payload = msgpack.unpackb(payload_bytes, raw=False, strict_map_key=False) payload = msgpack.unpackb(payload_bytes, raw=False, strict_map_key=False)

View File

@@ -1,7 +1,6 @@
import asyncio, logging, traceback import asyncio, logging, traceback
from tamtam_tcp.proto import Proto from tamtam_tcp.proto import Proto
from tamtam_tcp.processors import Processors from tamtam_tcp.processors import Processors
from common.rate_limiter import RateLimiter
class TTMobileServer: class TTMobileServer:
def __init__(self, host="0.0.0.0", port=443, ssl_context=None, db_pool=None, clients={}, send_event=None): def __init__(self, host="0.0.0.0", port=443, ssl_context=None, db_pool=None, clients={}, send_event=None):
@@ -16,12 +15,6 @@ class TTMobileServer:
self.proto = Proto() self.proto = Proto()
self.processors = Processors(db_pool=db_pool, clients=clients, send_event=send_event) self.processors = Processors(db_pool=db_pool, clients=clients, send_event=send_event)
# rate limiter
self.auth_rate_limiter = RateLimiter(max_attempts=5, window_seconds=60)
self.read_timeout = 300 # Таймаут чтения из сокета (секунды)
self.max_read_size = 65536 # Максимальный размер данных из сокета
async def handle_client(self, reader, writer): async def handle_client(self, reader, writer):
"""Функция для обработки подключений""" """Функция для обработки подключений"""
# IP-адрес клиента # IP-адрес клиента
@@ -37,33 +30,16 @@ class TTMobileServer:
try: try:
while True: while True:
# Читаем новые данные из сокета (с таймаутом!) # Читаем новые данные из сокета
try: data = await reader.read(4098)
data = await asyncio.wait_for(
reader.read(self.max_read_size),
timeout=self.read_timeout
)
except asyncio.TimeoutError:
self.logger.info(f"Таймаут соединения для {address[0]}:{address[1]}")
break
# Если сокет закрыт - выходим из цикла # Если сокет закрыт - выходим из цикла
if not data: if not data:
break break
# Проверяем размер данных
if len(data) > self.max_read_size:
self.logger.warning(f"Пакет от {address[0]}:{address[1]} превышает лимит ({len(data)} байт)")
break
# Распаковываем данные # Распаковываем данные
packet = self.proto.unpack_packet(data) packet = self.proto.unpack_packet(data)
# Если пакет невалидный — пропускаем
if packet is None:
self.logger.warning(f"Невалидный пакет от {address[0]}:{address[1]}")
continue
opcode = packet.get("opcode") opcode = packet.get("opcode")
seq = packet.get("seq") seq = packet.get("seq")
payload = packet.get("payload") payload = packet.get("payload")
@@ -72,20 +48,11 @@ class TTMobileServer:
case self.proto.HELLO: case self.proto.HELLO:
deviceType, deviceName = await self.processors.process_hello(payload, seq, writer) deviceType, deviceName = await self.processors.process_hello(payload, seq, writer)
case self.proto.REQUEST_CODE: case self.proto.REQUEST_CODE:
if not self.auth_rate_limiter.is_allowed(address[0]): await self.processors.process_request_code(payload, seq, writer)
await self.processors._send_error(seq, self.proto.REQUEST_CODE, self.processors.error_types.RATE_LIMITED, writer)
else:
await self.processors.process_request_code(payload, seq, writer)
case self.proto.VERIFY_CODE: case self.proto.VERIFY_CODE:
if not self.auth_rate_limiter.is_allowed(address[0]): await self.processors.process_verify_code(payload, seq, writer)
await self.processors._send_error(seq, self.proto.VERIFY_CODE, self.processors.error_types.RATE_LIMITED, writer)
else:
await self.processors.process_verify_code(payload, seq, writer)
case self.proto.FINAL_AUTH: case self.proto.FINAL_AUTH:
if not self.auth_rate_limiter.is_allowed(address[0]): await self.processors.process_final_auth(payload, seq, writer, deviceType, deviceName)
await self.processors._send_error(seq, self.proto.FINAL_AUTH, self.processors.error_types.RATE_LIMITED, writer)
else:
await self.processors.process_final_auth(payload, seq, writer, deviceType, deviceName)
case _: case _:
self.logger.warning(f"Неизвестный опкод {opcode}") self.logger.warning(f"Неизвестный опкод {opcode}")
except Exception as e: except Exception as e:

View File

@@ -12,18 +12,14 @@ class Proto:
"payload": payload "payload": payload
}) })
MAX_PACKET_SIZE = 65536 # 64 KB, заглушка, нужно узнать реальные лимиты и поменять, хотя кто будет это делать...
def unpack_packet(self, packet): def unpack_packet(self, packet):
# try catch чтобы не сыпалось всё при неверных пакетах # нужно try catch сделать
if isinstance(packet, (str, bytes)) and len(packet) > self.MAX_PACKET_SIZE: # чтобы не сыпалось всё при неверных пакетах
return {}
try: try:
parsed_packet = json.loads(packet) parsed_packet = json.loads(packet)
except (json.JSONDecodeError, TypeError, ValueError): except:
return {} return {}
return parsed_packet return parsed_packet
# мне кажется долго вручную всё писать # мне кажется долго вручную всё писать
# а как еще # а как еще

View File

@@ -21,17 +21,12 @@ class TTWSServer:
# Распаковываем пакет # Распаковываем пакет
packet = self.proto.unpack_packet(message) packet = self.proto.unpack_packet(message)
if not packet:
self.logger.warning("Невалидный пакет от ws клиента")
continue
# Валидируем структуру пакета # Валидируем структуру пакета
try: try:
MessageModel.model_validate(packet) MessageModel.model_validate(packet)
except ValidationError as e: except ValidationError as e:
self.logger.warning(f"Ошибка валидации пакета: {e}") self.logger.error(e)
continue
# Извлекаем данные из пакета # Извлекаем данные из пакета
seq = packet['seq'] seq = packet['seq']
opcode = packet['opcode'] opcode = packet['opcode']
@@ -41,7 +36,7 @@ class TTWSServer:
case self.proto.SESSION_INIT: case self.proto.SESSION_INIT:
# ПРИВЕТ АНДРЕЙ МАЛАХОВ # ПРИВЕТ АНДРЕЙ МАЛАХОВ
# не не удаляй этот коммент. пусть останется на релизе аххахаха # не не удаляй этот коммент. пусть останется на релизе аххахаха
deviceType, deviceName = await self.processors.process_hello(payload, seq, websocket) deviceType, deviceType = await self.processors.process_hello(payload, seq, websocket)
case self.proto.PING: case self.proto.PING:
await self.processors.process_ping(payload, seq, websocket) await self.processors.process_ping(payload, seq, websocket)
case self.proto.LOG: case self.proto.LOG:
@@ -62,10 +57,5 @@ class TTWSServer:
async def start(self): async def start(self):
self.logger.info(f"Вебсокет запущен на порту {self.port}") self.logger.info(f"Вебсокет запущен на порту {self.port}")
async with serve( async with serve(self.handle_client, self.host, self.port):
self.handle_client, self.host, self.port,
max_size=65536,
open_timeout=10,
close_timeout=10,
):
await asyncio.Future() await asyncio.Future()