Compare commits

..

14 Commits

Author SHA1 Message Date
Anatoliy Esherkin
498ffdf82a Merge da1d762c51 into 2ce4ac64e2 2026-03-13 18:41:42 +03:00
artemchik12
2ce4ac64e2 Что делать на случай ошибки при сборке (#20)
* Что делать на случай ошибки при сборке

* Update patch_apk.md

* Update patch_apk.md

---------

Co-authored-by: Alexey Polyakov <starwear3000@mail.ru>
2026-03-13 18:28:21 +03:00
whymequestion
e5c7a7baac Security + minor fixes (#16)
* implement ip rate limiting

* fix: secure генерация кода для входа

* fix: possible slowloris and dos attacks

* fix: убрать лишний импорт, не давать сообщения из чата незнакомцам, географически верные названия в дб...

* fix device name не использовался

* refactor: убрал лишние импорты

* refactor: вернул dotenv

* убрал импорт после c642434
2026-03-12 19:22:58 +03:00
Anatoliy Esherkin
da1d762c51 Update config.py 2026-03-12 11:35:46 +03:00
Anatoliy Esherkin
f627cdc785 ЧС, возможность отключения белых и чёрных списков 2026-03-12 10:50:52 +03:00
Anatoliy Esherkin
dfd1954795 ЧС 2026-03-12 10:49:42 +03:00
Anatoliy Esherkin
c3c9be3a30 ЧС, возможность отключения белых и чёрных списков 2026-03-12 10:41:50 +03:00
BetaAcccc
582c0f571c Добавление faq (#6)
* Create patch_apk.md

* Create install.md

* Update patch_apk.md

* remove space

* Create readme.md

* Update readme.md

* Update readme.md

* Update patch_apk.md

* Подправил инструкцию по патчу

Сделал чище и постарался сделать грамотнее

---------

Co-authored-by: Alexey Polyakov <starwear3000@mail.ru>
Co-authored-by: WowInceptionGood <143893762+WowInceptionGood@users.noreply.github.com>
2026-03-11 01:00:25 +03:00
WowInceptionGood
fb46d06aab Ладно последнее исправление 2026-03-10 21:43:25 +00:00
WowInceptionGood
fbb451cd39 Исправление 2026-03-10 21:41:57 +00:00
WowInceptionGood
573825e195 Начал писать документацию прото ТамТама 2026-03-10 21:40:49 +00:00
relyay
4d82f55b79 Fix (#11)
* Улучшена генерация кода, пояснения в некоторых участках, очистка номера телефона через регулярные выражения :>

* Именовать переменные snake_case стоит везде, даже если ты достаешь заголовки в такомСтиле

if not object использовать предпочтительнее, т.к. он обрабатывает более широкие случаи, когда достать данные не получилось
2026-03-10 21:59:44 +03:00
devreal95
917db80460 Update config.py (#10) 2026-03-10 21:42:11 +03:00
relyay
cab75a58f8 Merge pull request #9 from relyay/fix
Some fixes
2026-03-10 21:19:54 +03:00
23 changed files with 446 additions and 252 deletions

View File

@@ -24,4 +24,3 @@ avatar_base_url = "http://127.0.0.1/avatar/"
telegram_bot_token = "123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZ" telegram_bot_token = "123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZ"
telegram_bot_enabled = "1" telegram_bot_enabled = "1"
telegram_whitelist_ids = "1,2,3" telegram_whitelist_ids = "1,2,3"
origins="http://127.0.0.1,https://web.openmax.su"

29
docs/proto/tamtam_ws.md Normal file
View File

@@ -0,0 +1,29 @@
# Описание протокола TamTam по Websocket
## Основная информация
В веб версии мессенджера ТамТам используется протокол, работающий поверх Websocket.
Пакеты в этом протоколе являются текстовыми JSON данными.
Структура пакета:
```
{
ver: int,
cmd: int,
seq: int,
opcode: int,
payload: {}
}
```
* ver - версия протокола
* cmd - определяет, от кого отправлен пакет. клиент - 0, сервер - 1
* seq - порядковый номер пакета (сервер дублирует его из запроса клиента)
* opcode - команда
* payload - полезная нагрузка команды
## Команды протокола
### PING (1)
Клиент периодически отправляет пакет с командой PING и пустой нагрузкой серверу.
Сервер отвечает ему тем же.

18
faq/install.md Normal file
View File

@@ -0,0 +1,18 @@
# Установка
1. Склонируйте репозиторий
2. Установите зависимости
```bash
pip install -r requirements.txt
```
3. Настройте сервер (пример в `.env.example`)
4. Импортируйте схему таблиц в свою базу данных из `tables.sql`
5. Запустите сервер
```bash
python3 main.py
```
6. Создайте пользователя
7. Зайдите со своего любимого клиента

27
faq/patch_apk.md Normal file
View File

@@ -0,0 +1,27 @@
# Смена сервера в мобильном клиенте
> [!Caution]
> Инструкция может быть недостаточной, если вы используете самоподписанный сертификат или сертификат, которому система не доверяет. Вам, возможно, потребуется выполнить дополнительные действия в модификации клиента для успешного входа.
# MT Manager
1. Открываем apk файл клиента, который желаете пропатчить
2. Нажимаем на любой dex файл
3. Выбираем в качестве редактора "Редактор dex+"
4. Выбираем все dex файлы при появлении окна выбора "MultiDex"
5. В поиске выбираем тип Smali, а в поле поиска пишем "api.oneme.ru"
6. Проходимся по каждому результату и заменяем сервер на свой
# ApkTool M
1. Декомпилируем приложение, обязательно поставьте галочку у пункта "Декомпилировать classes*.dex"
2. В папке проекта нажимаем на "лупу"
3. Ставим поиск по содержимому с заменой
4. В поле поиска пишем "api.oneme.ru", а в поле замены ваш адрес сервера
5. После замены нажимаем на "Собрать проект"
# ApkTool
1. Помещаем apk в рабочую директорию
2. Открываем консоль в той же директории и производим декомпиляцию: `apktool d <имя apk> -o max`
3. Заходим в папку проекта и заменяем во всех классах "api.oneme.ru" на свой адрес сервера
4. Производим повторную сборку с помощью команды: `apktool b max -o max_modified.apk`
> [!WARNING]
> Если у вас возникает ошибка при при повторной сборке, попробуйте декомпилировать клиент с параметром -r

7
faq/readme.md Normal file
View File

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

View File

@@ -3,7 +3,7 @@
> Проект находится на ранней стадии разработки и вероятно полон багов. > Проект находится на ранней стадии разработки и вероятно полон багов.
> >
> Использование в профессиональных средах не рекомендовано. > Использование в профессиональных средах не рекомендовано.
>
# OpenMAX # OpenMAX
Эмулятор сервера MAX и ТамТам Эмулятор сервера MAX и ТамТам
@@ -21,23 +21,5 @@ https://t.me/openmax_alerts
Клиент может быть практически любым, главное условие - чтобы он был совместим с официальным сервером (`api.oneme.ru` / `api.tamtam.chat`). Клиент может быть практически любым, главное условие - чтобы он был совместим с официальным сервером (`api.oneme.ru` / `api.tamtam.chat`).
На данный момент с сервером может работать последняя версия MAX (26.7.1), однако все тесты проходят на версии 26.5.0. # Дополнительная информация
[Faq](faq/readme.md)
# Установка
1. Склонируйте репозиторий
2. Установите зависимости
```bash
pip install -r requirements.txt
```
3. Настройте сервер (пример в `.env.example`)
4. Импортируйте схему таблиц в свою базу данных из `tables.sql`
5. Запустите сервер
```bash
python3 main.py
```
6. Создайте пользователя
7. Зайдите со своего любимого клиента

View File

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

View File

@@ -1,5 +1,6 @@
import os import os
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv() load_dotenv()
class ServerConfig: class ServerConfig:
@@ -43,7 +44,8 @@ class ServerConfig:
### Telegram bot ### Telegram bot
telegram_bot_token = os.getenv("telegram_bot_token") or "123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZ" telegram_bot_token = os.getenv("telegram_bot_token") or "123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZ"
telegram_bot_enabled = bool(os.getenv("telegram_bot_enabled")) or True telegram_bot_enabled = bool(os.getenv("telegram_bot_enabled")) or True
telegram_whitelist_ids = [x.strip() for x in os.getenv("telegram_whitelist_ids", "").split(",") if x.strip()] telegram_whitelist_enabled = bool(os.getenv("telegram_whitelist_enabled")) or False
telegram_blacklist_enabled = bool(os.getenv("telegram_blacklist_enabled")) or False
### origins telegram_whitelist_ids = [x.strip() for x in os.getenv("telegram_whitelist_ids", "").split(",") if x.strip()]
origins = [x.strip() for x in os.getenv("origins", "").split(",") if x.strip()] if os.getenv("origins") else None telegram_blacklist_ids = [x.strip() for x in os.getenv("telegram_blacklist_ids", "").split(",") if x.strip()]

View File

@@ -0,0 +1,51 @@
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,6 +12,7 @@ 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"
@@ -22,6 +23,7 @@ class Static:
REGISTRATION_SUCCESS = "registration_success" REGISTRATION_SUCCESS = "registration_success"
ACCOUNT_ALREADY_EXISTS = "account_already_exists" ACCOUNT_ALREADY_EXISTS = "account_already_exists"
ID_NOT_WHITELISTED = "id_not_whitelisted" ID_NOT_WHITELISTED = "id_not_whitelisted"
ID_BLACKLISTED = "id_blacklisted"
INTERNAL_ERROR = "internal_error" INTERNAL_ERROR = "internal_error"
INCOMING_CODE = "incoming_code" INCOMING_CODE = "incoming_code"
@@ -73,6 +75,12 @@ 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": "Слишком много попыток"
} }
} }
@@ -97,6 +105,9 @@ class Static:
"id_not_whitelisted": """ "id_not_whitelisted": """
❌ Ваш ID не находится в белом списке. ❌ Ваш ID не находится в белом списке.
""", """,
"id_blacklisted": """
❌ Ваш ID заблокирован.
""",
"internal_error": """ "internal_error": """
❌ Ошибка при регистрации аккаунта. ❌ Ошибка при регистрации аккаунта.
""", """,

View File

@@ -47,38 +47,6 @@ class Tools:
else: else:
return contact return contact
def generate_profile_tt(
self, id=1, phone=70000000000, avatarUrl=None,
photoId=None, updateTime=0,
firstName="Test", lastName="Account", options=[],
description=None, username=None
):
contact = {
"id": id,
"updateTime": updateTime,
"phone": phone,
"names": [
{
"name": f"{firstName} {lastName}",
"type": "TT"
}
],
"options": options
}
if avatarUrl:
contact["photoId"] = photoId
contact["baseUrl"] = avatarUrl
contact["baseRawUrl"] = avatarUrl
if description:
contact["description"] = description
if username:
contact["link"] = "https://tamtam.chat/" + username
return contact
def generate_chat(self, id, owner, type, participants, lastMessage, lastEventTime): def generate_chat(self, id, owner, type, participants, lastMessage, lastEventTime):
"""Генерация чата""" """Генерация чата"""
# Генерируем список участников # Генерируем список участников
@@ -215,7 +183,3 @@ class Tools:
# Возвращаем # Возвращаем
return message, int(row.get("time")) return message, int(row.get("time"))
async def auth_required(self, userPhone, coro, *args):
if userPhone:
await coro(*args)

View File

@@ -68,8 +68,7 @@ async def main():
"db": db, "db": db,
"ssl": ssl_context, "ssl": ssl_context,
"clients": clients, "clients": clients,
"event": api_event, "event": api_event
"origins": server_config.origins
} }
controllers = { controllers = {

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": True, "mytracker-enabled": False,
"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, random, secrets, hashlib, time, logging import json, 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
@@ -48,9 +48,8 @@ class Processors:
# Валидируем данные пакета # Валидируем данные пакета
try: try:
HelloPayloadModel.model_validate(payload) HelloPayloadModel.model_validate(payload)
except pydantic.ValidationError as error: except Exception as e:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}") await self._send_error(seq, self.proto.HELLO, self.error_types.INVALID_PAYLOAD, writer)
await self._send_error(seq, self.proto.SESSION_INIT, self.error_types.INVALID_PAYLOAD, writer)
return None, None return None, None
# Получаем данные из пакета # Получаем данные из пакета
@@ -87,8 +86,7 @@ class Processors:
# Валидируем данные пакета # Валидируем данные пакета
try: try:
PingPayloadModel.model_validate(payload) PingPayloadModel.model_validate(payload)
except pydantic.ValidationError as error: except Exception as e:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.proto.PING, self.error_types.INVALID_PAYLOAD, writer) await self._send_error(seq, self.proto.PING, self.error_types.INVALID_PAYLOAD, writer)
return return
@@ -117,16 +115,15 @@ class Processors:
# Валидируем данные пакета # Валидируем данные пакета
try: try:
RequestCodePayloadModel.model_validate(payload) RequestCodePayloadModel.model_validate(payload)
except pydantic.ValidationError as error: except Exception as e:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.proto.AUTH_REQUEST, self.error_types.INVALID_PAYLOAD, writer) await self._send_error(seq, self.proto.AUTH_REQUEST, self.error_types.INVALID_PAYLOAD, writer)
return return
# Извлекаем телефон из пакета # Извлекаем телефон из пакета
phone = payload.get("phone").replace("+", "").replace(" ", "").replace("-", "") phone = payload.get("phone").replace("+", "").replace(" ", "").replace("-", "")
# Генерируем токен с кодом # Генерируем токен с кодом (безопасность прежде всего)
code = str(random.randint(100000, 999999)) code = str(secrets.randbelow(900000) + 100000)
token = secrets.token_urlsafe(128) token = secrets.token_urlsafe(128)
# Хешируем # Хешируем
@@ -142,14 +139,17 @@ 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 user: if user is None:
# Сохраняем токен await self._send_error(seq, self.proto.AUTH_REQUEST, self.error_types.USER_NOT_FOUND, writer)
await cursor.execute("INSERT INTO auth_tokens (phone, token_hash, code_hash, expires) VALUES (%s, %s, %s, %s)", (phone, token_hash, code_hash, expires,)) return
# Если тг бот включен, и тг привязан к аккаунту - отправляем туда сообщение # Сохраняем токен
if self.telegram_bot and user.get("telegram_id"): await cursor.execute("INSERT INTO auth_tokens (phone, token_hash, code_hash, expires) VALUES (%s, %s, %s, %s)", (phone, token_hash, code_hash, expires,))
await self.telegram_bot.send_code(chat_id=int(user.get("telegram_id")), phone=phone, code=code)
# Если тг бот включен, и тг привязан к аккаунту - отправляем туда сообщение
if self.telegram_bot and user.get("telegram_id"):
await self.telegram_bot.send_code(chat_id=int(user.get("telegram_id")), phone=phone, code=code)
# Данные пакета # Данные пакета
payload = { payload = {
@@ -174,8 +174,7 @@ class Processors:
# Валидируем данные пакета # Валидируем данные пакета
try: try:
VerifyCodePayloadModel.model_validate(payload) VerifyCodePayloadModel.model_validate(payload)
except pydantic.ValidationError as error: except Exception as e:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.proto.AUTH, self.error_types.INVALID_PAYLOAD, writer) await self._send_error(seq, self.proto.AUTH, self.error_types.INVALID_PAYLOAD, writer)
return return
@@ -218,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, "Epstein Island", int(time.time()),) (stored_token.get("phone"), hashed_login, deviceType, deviceName, "Little Saint James Island", int(time.time()),) # весь покрытый зеленью, абсолютно весь, остров невезения в океане есть
) )
# Генерируем профиль # Генерируем профиль
@@ -264,8 +263,7 @@ class Processors:
# Валидируем данные пакета # Валидируем данные пакета
try: try:
LoginPayloadModel.model_validate(payload) LoginPayloadModel.model_validate(payload)
except pydantic.ValidationError as error: except Exception as e:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.proto.LOGIN, self.error_types.INVALID_PAYLOAD, writer) await self._send_error(seq, self.proto.LOGIN, self.error_types.INVALID_PAYLOAD, writer)
return return
@@ -367,8 +365,7 @@ class Processors:
# Валидируем данные пакета # Валидируем данные пакета
try: try:
AssetsPayloadModel.model_validate(payload) AssetsPayloadModel.model_validate(payload)
except pydantic.ValidationError as error: except Exception as e:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.proto.ASSETS_UPDATE, self.error_types.INVALID_PAYLOAD, writer) await self._send_error(seq, self.proto.ASSETS_UPDATE, self.error_types.INVALID_PAYLOAD, writer)
return return
@@ -393,8 +390,7 @@ class Processors:
# Валидируем данные пакета # Валидируем данные пакета
try: try:
GetCallHistoryPayloadModel.model_validate(payload) GetCallHistoryPayloadModel.model_validate(payload)
except pydantic.ValidationError as error: except Exception as e:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.proto.VIDEO_CHAT_HISTORY, self.error_types.INVALID_PAYLOAD, writer) await self._send_error(seq, self.proto.VIDEO_CHAT_HISTORY, self.error_types.INVALID_PAYLOAD, writer)
return return
@@ -421,8 +417,7 @@ class Processors:
# Валидируем данные пакета # Валидируем данные пакета
try: try:
SendMessagePayloadModel.model_validate(payload) SendMessagePayloadModel.model_validate(payload)
except pydantic.ValidationError as error: except Exception as e:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.proto.MSG_SEND, self.error_types.INVALID_PAYLOAD, writer) await self._send_error(seq, self.proto.MSG_SEND, self.error_types.INVALID_PAYLOAD, writer)
return return
@@ -534,8 +529,7 @@ class Processors:
# Валидируем данные пакета # Валидируем данные пакета
try: try:
SyncFoldersPayloadModel.model_validate(payload) SyncFoldersPayloadModel.model_validate(payload)
except pydantic.ValidationError as error: except Exception as e:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.proto.FOLDERS_GET, self.error_types.INVALID_PAYLOAD, writer) await self._send_error(seq, self.proto.FOLDERS_GET, self.error_types.INVALID_PAYLOAD, writer)
return return
@@ -603,8 +597,7 @@ class Processors:
# Валидируем данные пакета # Валидируем данные пакета
try: try:
SearchUsersPayloadModel.model_validate(payload) SearchUsersPayloadModel.model_validate(payload)
except pydantic.ValidationError as error: except Exception as e:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.proto.CONTACT_INFO, self.error_types.INVALID_PAYLOAD, writer) await self._send_error(seq, self.proto.CONTACT_INFO, self.error_types.INVALID_PAYLOAD, writer)
return return
@@ -665,8 +658,7 @@ class Processors:
# Валидируем данные пакета # Валидируем данные пакета
try: try:
SearchChatsPayloadModel.model_validate(payload) SearchChatsPayloadModel.model_validate(payload)
except pydantic.ValidationError as error: except Exception as e:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.proto.CHAT_INFO, self.error_types.INVALID_PAYLOAD, writer) await self._send_error(seq, self.proto.CHAT_INFO, self.error_types.INVALID_PAYLOAD, writer)
return return
@@ -685,9 +677,11 @@ class Processors:
chat = await cursor.fetchone() chat = await cursor.fetchone()
if chat: if chat:
# Если чат - диалог, и пользователь в нем не состоит, # Проверяем, является ли пользователь участником чата
# то продолжаем без добавления результата
if chat.get("type") == self.chat_types.DIALOG and senderId not in json.loads(chat.get("participants")): # (в max нельзя смотреть и отправлять сообщения в чат, в котором ты не участник, в отличие от tg (например, комментарии в каналах),
# так что надо тоже так делать)
if senderId not in json.loads(chat.get("participants")):
continue continue
# Получаем последнее сообщение из чата # Получаем последнее сообщение из чата
@@ -739,8 +733,7 @@ class Processors:
# Валидируем данные пакета # Валидируем данные пакета
try: try:
SearchByPhonePayloadModel.model_validate(payload) SearchByPhonePayloadModel.model_validate(payload)
except pydantic.ValidationError as error: except Exception as e:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.proto.CONTACT_INFO_BY_PHONE, self.error_types.INVALID_PAYLOAD, writer) await self._send_error(seq, self.proto.CONTACT_INFO_BY_PHONE, self.error_types.INVALID_PAYLOAD, writer)
return return
@@ -809,8 +802,7 @@ class Processors:
# Валидируем данные пакета # Валидируем данные пакета
try: try:
GetCallTokenPayloadModel.model_validate(payload) GetCallTokenPayloadModel.model_validate(payload)
except pydantic.ValidationError as error: except Exception as e:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.proto.OK_TOKEN, self.error_types.INVALID_PAYLOAD, writer) await self._send_error(seq, self.proto.OK_TOKEN, self.error_types.INVALID_PAYLOAD, writer)
return return
@@ -823,8 +815,7 @@ class Processors:
# Валидируем данные пакета # Валидируем данные пакета
try: try:
TypingPayloadModel.model_validate(payload) TypingPayloadModel.model_validate(payload)
except pydantic.ValidationError as error: except Exception as e:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.proto.MSG_TYPING, self.error_types.INVALID_PAYLOAD, writer) await self._send_error(seq, self.proto.MSG_TYPING, self.error_types.INVALID_PAYLOAD, writer)
return return
@@ -878,8 +869,7 @@ class Processors:
# Валидируем данные пакета # Валидируем данные пакета
try: try:
ComplainReasonsGetPayloadModel.model_validate(payload) ComplainReasonsGetPayloadModel.model_validate(payload)
except pydantic.ValidationError as error: except Exception as e:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.proto.COMPLAIN_REASONS_GET, self.error_types.INVALID_PAYLOAD, writer) await self._send_error(seq, self.proto.COMPLAIN_REASONS_GET, self.error_types.INVALID_PAYLOAD, writer)
return return

View File

@@ -4,8 +4,19 @@ 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")
@@ -18,6 +29,17 @@ 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
@@ -27,12 +49,12 @@ 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=99999, uncompressed_size=self.MAX_DECOMPRESSED_SIZE,
) )
except lz4.block.LZ4BlockError: except lz4.block.LZ4BlockError:
self.logger.warning("Ошибка декомпрессии LZ4")
return None return None
# Распаковываем msgpack # Распаковываем msgpack

View File

@@ -1,7 +1,7 @@
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.tools import Tools 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):
@@ -14,9 +14,14 @@ class OnemeMobileServer:
self.clients = clients self.clients = clients
self.proto = Proto() self.proto = Proto()
self.auth_required = Tools().auth_required
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-адрес клиента
@@ -32,16 +37,33 @@ class OnemeMobileServer:
try: try:
while True: while True:
# Читаем новые данные из сокета # Читаем новые данные из сокета с таймаутом
data = await reader.read(4098) try:
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")
@@ -50,15 +72,23 @@ 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:
await self.processors.process_request_code(payload, seq, writer) if not self.auth_rate_limiter.is_allowed(address[0]):
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:
await self.processors.process_verify_code(payload, seq, writer, deviceType, deviceName) if not self.auth_rate_limiter.is_allowed(address[0]):
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:
userPhone, userId, hashedToken = await self.processors.process_login(payload, seq, writer) if not self.auth_rate_limiter.is_allowed(address[0]):
await self.processors._send_error(seq, self.proto.LOGIN, self.processors.error_types.RATE_LIMITED, writer)
else:
userPhone, userId, hashedToken = await self.processors.process_login(payload, seq, writer)
# Если авторизация на сервере успешная - можем завершить авторизацию if userPhone:
if userPhone: await self._finish_auth(writer, address, userPhone, userId)
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
@@ -67,49 +97,27 @@ class OnemeMobileServer:
case self.proto.LOG: case self.proto.LOG:
await self.processors.process_telemetry(payload, seq, writer) await self.processors.process_telemetry(payload, seq, writer)
case self.proto.ASSETS_UPDATE: case self.proto.ASSETS_UPDATE:
await self.auth_required( await self.processors.process_get_assets(payload, seq, writer)
userPhone, self.processors.process_get_assets, payload, seq, writer
)
case self.proto.VIDEO_CHAT_HISTORY: case self.proto.VIDEO_CHAT_HISTORY:
await self.auth_required( await self.processors.process_get_call_history(payload, seq, writer)
userPhone, self.processors.process_get_call_history, payload, seq, writer
)
case self.proto.MSG_SEND: case self.proto.MSG_SEND:
await self.auth_required( await self.processors.process_send_message(payload, seq, writer, senderId=userId, db_pool=self.db_pool)
userPhone, self.processors.process_send_message, payload, seq, writer, senderId=userId, db_pool=self.db_pool
)
case self.proto.FOLDERS_GET: case self.proto.FOLDERS_GET:
await self.auth_required( await self.processors.process_get_folders(payload, seq, writer, senderPhone=userPhone)
userPhone, self.processors.process_get_folders, payload, seq, writer, senderPhone=userPhone
)
case self.proto.SESSIONS_INFO: case self.proto.SESSIONS_INFO:
await self.auth_required( await self.processors.process_get_sessions(payload, seq, writer, senderPhone=userPhone, hashedToken=hashedToken)
userPhone, self.processors.process_get_sessions, payload, seq, writer, senderPhone=userPhone, hashedToken=hashedToken
)
case self.proto.CHAT_INFO: case self.proto.CHAT_INFO:
await self.auth_required( await self.processors.process_search_chats(payload, seq, writer, senderId=userId)
userPhone, self.processors.process_search_chats, payload, seq, writer, senderId=userId
)
case self.proto.CONTACT_INFO_BY_PHONE: case self.proto.CONTACT_INFO_BY_PHONE:
await self.auth_required( await self.processors.process_search_by_phone(payload, seq, writer, senderId=userId)
userPhone, self.processors.process_search_by_phone, payload, seq, writer, senderId=userId
)
case self.proto.OK_TOKEN: case self.proto.OK_TOKEN:
await self.auth_required( await self.processors.process_get_call_token(payload, seq, writer)
userPhone, self.processors.process_get_call_token, payload, seq, writer
)
case self.proto.MSG_TYPING: case self.proto.MSG_TYPING:
await self.auth_required( await self.processors.process_typing(payload, seq, writer, senderId=userId)
userPhone, self.processors.process_typing, payload, seq, writer, senderId=userId
)
case self.proto.CONTACT_INFO: case self.proto.CONTACT_INFO:
await self.auth_required( await self.processors.process_search_users(payload, seq, writer)
userPhone, self.processors.process_search_users, payload, seq, writer
)
case self.proto.COMPLAIN_REASONS_GET: case self.proto.COMPLAIN_REASONS_GET:
await self.auth_required( await self.processors.process_complain_reasons_get(payload, seq, writer)
userPhone, self.processors.process_complain_reasons_get, payload, seq, writer
)
case _: case _:
self.logger.warning(f"Неизвестный опкод {opcode}") self.logger.warning(f"Неизвестный опкод {opcode}")
except Exception as e: except Exception as e:

View File

@@ -1,11 +1,19 @@
import hashlib, secrets, random, time, logging, json import hashlib
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={}, send_event=None): def __init__(self, db_pool=None, clients=None, 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()
@@ -44,8 +52,8 @@ class Processors:
return None, None return None, None
# Получаем данные из пакета # Получаем данные из пакета
deviceType = payload.get("userAgent").get("deviceType") device_type = payload.get("userAgent").get("deviceType")
deviceName = payload.get("userAgent").get("deviceName") device_name = payload.get("userAgent").get("deviceName")
# Данные пакета # Данные пакета
payload = { payload = {
@@ -64,7 +72,7 @@ class Processors:
# Отправляем # Отправляем
await self._send(writer, packet) await self._send(writer, packet)
return deviceType, deviceName return device_type, device_name
async def process_request_code(self, payload, seq, writer): async def process_request_code(self, payload, seq, writer):
"""Обработчик запроса кода""" """Обработчик запроса кода"""
@@ -76,17 +84,17 @@ class Processors:
return return
# Извлекаем телефон из пакета # Извлекаем телефон из пакета
phone = payload.get("phone").replace("+", "").replace(" ", "").replace("-", "") phone = re.sub(r'\D', '', payload.get("phone", "")) # Не хардкодим, через регулярки
# Генерируем токен с кодом # Генерируем токен с кодом
code = str(random.randint(000000, 999999)) code = f"{secrets.randbelow(1_000_000):06d}" # Старая версия ненадежна, могла отбросить ведущие нули или вообще интерпритировать как систему счисления с основанием 8
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
# Ищем пользователя, и если он существует, сохраняем токен # Ищем пользователя, и если он существует, сохраняем токен
@@ -95,10 +103,14 @@ 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: await self._send_error(seq, self.proto.REQUEST_CODE, self.error_types.USER_NOT_FOUND, writer)
# Сохраняем токен return
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",))
# Сохраняем токен
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",))
# Данные пакета # Данные пакета
payload = { payload = {
@@ -141,10 +153,11 @@ 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()", (hashed_token,)) await cursor.execute("SELECT * FROM auth_tokens WHERE token_hash = %s AND expires > UNIX_TIMESTAMP()",
(hashed_token,))
stored_token = await cursor.fetchone() stored_token = await cursor.fetchone()
if stored_token is None: if not stored_token:
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
@@ -158,28 +171,39 @@ class Processors:
account = await cursor.fetchone() account = await cursor.fetchone()
# Обновляем состояние токена # Обновляем состояние токена
await cursor.execute("UPDATE auth_tokens set state = %s WHERE token_hash = %s", ("verified", hashed_token,)) await cursor.execute("UPDATE auth_tokens set state = %s WHERE token_hash = %s",
("verified", hashed_token,))
# # Создаем сессию
# await cursor.execute(
# "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", int(time.time()),)
# )
# Генерируем профиль # Генерируем профиль
# Аватарка с биографией # Аватарка с биографией
photoId = None if not account.get("avatar_id") else int(account.get("avatar_id")) photo_id = int(account["avatar_id"]) if account.get("avatar_id") else None
avatar_url = None if not photoId else self.config.avatar_base_url + photoId avatar_url = f"{self.config.avatar_base_url}{photo_id}" if photo_id else None
description = None if not account.get("description") else account.get("description") description = account.get("description")
# Собираем данные пакета # Собираем данные пакета
payload = { payload = {
"profile": self.tools.generate_profile_tt( "profile": self.tools.generate_profile(
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=photoId, photoId=photo_id,
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"),
options=json.loads(account.get("options")), options=json.loads(account.get("options")),
description=description, description=description,
username=account.get("username") accountStatus=int(account.get("accountstatus")),
), profileOptions=json.loads(account.get("profileoptions")),
includeProfileOptions=False,
username=account.get("username"),
type="TT"
).get("contact"),
"tokenAttrs": { "tokenAttrs": {
"AUTH": { "AUTH": {
"token": token "token": token
@@ -222,14 +246,14 @@ 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()", (hashed_token,)) await cursor.execute("SELECT * FROM auth_tokens WHERE token_hash = %s AND expires > UNIX_TIMESTAMP()",
(hashed_token,))
stored_token = await cursor.fetchone() stored_token = await cursor.fetchone()
if stored_token is None: if stored_token is None:
await self._send_error(seq, self.proto.VERIFY_CODE, self.error_types.INVALID_TOKEN, writer) await self._send_error(seq, self.proto.VERIFY_CODE, self.error_types.INVALID_TOKEN, writer)
return return
# Если авторизация только началась - отдаем ошибку
if stored_token.get("state") == "started": if stored_token.get("state") == "started":
await self._send_error(seq, self.proto.VERIFY_CODE, self.error_types.INVALID_TOKEN, writer) await self._send_error(seq, self.proto.VERIFY_CODE, self.error_types.INVALID_TOKEN, writer)
return return
@@ -238,43 +262,46 @@ class Processors:
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("DELETE FROM auth_tokens WHERE token_hash = %s", (hashed_token,)) await cursor.execute("DELETE FROM auth_tokens WHERE token_hash = %s", (hashed_token,))
# Создаем сессию # Создаем сессию
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", int(time.time()),) (stored_token.get("phone"), hashed_login, deviceType, deviceName, "Epstein Island",
int(time.time()),)
) )
# Аватарка с биографией # Аватарка с биографией
photoId = None if not account.get("avatar_id") else int(account.get("avatar_id")) photo_id = None if not account.get("avatar_id") else int(account.get("avatar_id"))
avatar_url = None if not photoId else self.config.avatar_base_url + photoId avatar_url = None if not photo_id else self.config.avatar_base_url + photo_id
description = None if not account.get("description") else account.get("description") description = None if not account.get("description") else account.get("description")
# Собираем данные пакета # Собираем данные пакета
payload = { payload = {
"userToken": "0", # Пока как заглушка "userToken": "0",
"profile": self.tools.generate_profile_tt( "profile": self.tools.generate_profile(
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=photoId, photoId=photo_id,
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"),
options=json.loads(account.get("options")), options=json.loads(account.get("options")),
description=description, description=description,
username=account.get("username") accountStatus=int(account.get("accountstatus")),
), profileOptions=json.loads(account.get("profileoptions")),
includeProfileOptions=False,
username=account.get("username"),
type="TT"
).get("contact"),
"tokenType": "LOGIN", "tokenType": "LOGIN",
"token": login "token": login
} }
# Создаем пакет
packet = self.proto.pack_packet( packet = self.proto.pack_packet(
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,8 +4,19 @@ 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")
@@ -18,6 +29,17 @@ 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
@@ -27,12 +49,12 @@ 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=99999, uncompressed_size=self.MAX_DECOMPRESSED_SIZE,
) )
except lz4.block.LZ4BlockError: except lz4.block.LZ4BlockError:
self.logger.warning("Ошибка декомпрессии LZ4")
return None return None
# Распаковываем msgpack # Распаковываем msgpack

View File

@@ -1,6 +1,7 @@
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):
@@ -15,6 +16,12 @@ 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-адрес клиента
@@ -30,16 +37,33 @@ class TTMobileServer:
try: try:
while True: while True:
# Читаем новые данные из сокета # Читаем новые данные из сокета (с таймаутом!)
data = await reader.read(4098) try:
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")
@@ -48,11 +72,20 @@ 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:
await self.processors.process_request_code(payload, seq, writer) if not self.auth_rate_limiter.is_allowed(address[0]):
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:
await self.processors.process_verify_code(payload, seq, writer) if not self.auth_rate_limiter.is_allowed(address[0]):
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:
await self.processors.process_final_auth(payload, seq, writer, deviceType, deviceName) if not self.auth_rate_limiter.is_allowed(address[0]):
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

@@ -15,10 +15,7 @@ class Processors:
async def _send(self, writer, packet): async def _send(self, writer, packet):
"""Отправка пакета""" """Отправка пакета"""
try: await writer.send(packet)
await writer.send(packet)
except Exception as error:
self.logger.error(f"Ошибка при отправке пакета - {error}")
async def _send_error(self, seq, opcode, type, writer): async def _send_error(self, seq, opcode, type, writer):
payload = self.static.ERROR_TYPES.get(type, { payload = self.static.ERROR_TYPES.get(type, {
@@ -39,8 +36,7 @@ class Processors:
# Валидируем данные пакета # Валидируем данные пакета
try: try:
HelloPayloadModel.model_validate(payload) HelloPayloadModel.model_validate(payload)
except pydantic.ValidationError as error: except Exception as e:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.proto.SESSION_INIT, self.error_types.INVALID_PAYLOAD, writer) await self._send_error(seq, self.proto.SESSION_INIT, self.error_types.INVALID_PAYLOAD, writer)
return None, None return None, None

View File

@@ -12,13 +12,17 @@ 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: except (json.JSONDecodeError, TypeError, ValueError):
return None return {}
return parsed_packet return parsed_packet
# мне кажется долго вручную всё писать # мне кажется долго вручную всё писать
@@ -36,14 +40,6 @@ class Proto:
# мб найдем че. она без обфускации # мб найдем че. она без обфускации
# а ты ее видишь? # а ты ее видишь?
# пошли # пошли
### Констаты протокола
CMD_OK = 1
CMD_NOF = 2
CMD_ERR = 3
PROTO_VER = 10
### Команды
PING = 1 PING = 1
LOG = 5 LOG = 5
SESSION_INIT = 6 SESSION_INIT = 6

View File

@@ -6,13 +6,12 @@ from tamtam_ws.proto import Proto
from tamtam_ws.processors import Processors from tamtam_ws.processors import Processors
class TTWSServer: class TTWSServer:
def __init__(self, host, port, db_pool=None, clients={}, send_event=None, origins=None): def __init__(self, host, port, db_pool=None, clients={}, send_event=None):
self.host = host self.host = host
self.port = port self.port = port
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)
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
self.origins = origins
async def handle_client(self, websocket): async def handle_client(self, websocket):
deviceType = None deviceType = None
@@ -22,17 +21,16 @@ class TTWSServer:
# Распаковываем пакет # Распаковываем пакет
packet = self.proto.unpack_packet(message) packet = self.proto.unpack_packet(message)
# Если ничего не извлекли if not packet:
if packet is None: self.logger.warning("Невалидный пакет от ws клиента")
self.logger.error(f"Не удалось распаковать пакет - {message}") continue
return
# Валидируем структуру пакета # Валидируем структуру пакета
try: try:
MessageModel.model_validate(packet) MessageModel.model_validate(packet)
except ValidationError as error: except ValidationError as e:
self.logger.error(f"Произошла ошибка при валидации структуры пакета: {error}") self.logger.warning(f"Ошибка валидации пакета: {e}")
return continue
# Извлекаем данные из пакета # Извлекаем данные из пакета
seq = packet['seq'] seq = packet['seq']
@@ -43,7 +41,7 @@ class TTWSServer:
case self.proto.SESSION_INIT: case self.proto.SESSION_INIT:
# ПРИВЕТ АНДРЕЙ МАЛАХОВ # ПРИВЕТ АНДРЕЙ МАЛАХОВ
# не не удаляй этот коммент. пусть останется на релизе аххахаха # не не удаляй этот коммент. пусть останется на релизе аххахаха
deviceType, deviceType = await self.processors.process_hello(payload, seq, websocket) deviceType, deviceName = 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:
@@ -51,6 +49,12 @@ class TTWSServer:
# УДАЛЯЕМ MYTRACKER ИЗ TAMTAM ТАМ ВИРУС # УДАЛЯЕМ MYTRACKER ИЗ TAMTAM ТАМ ВИРУС
# майтрекер отправляет все ваши сообщения на сервер барака обамы. немедленно удаляем!!! # майтрекер отправляет все ваши сообщения на сервер барака обамы. немедленно удаляем!!!
await self.processors.process_telemetry(payload, seq, websocket) await self.processors.process_telemetry(payload, seq, websocket)
# case self.proto.AUTH_REQUEST:
# await self.processors.process_auth_request(payload, seq, websocket)
# case self.proto.VERIFY_CODE:
# await self.processors.process_verify_code(payload, seq, websocket)
# case self.proto.FINAL_AUTH:
# await self.processors.process_final_auth(payload, seq, websocket, deviceType, deviceName)
# лан я пойду. пока # лан я пойду. пока
# а ок # а ок
@@ -58,8 +62,10 @@ 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(handler=self.handle_client, async with serve(
host=self.host, self.handle_client, self.host, self.port,
port=self.port, max_size=65536,
origins=self.origins): open_timeout=10,
close_timeout=10,
):
await asyncio.Future() await asyncio.Future()

View File

@@ -13,6 +13,7 @@ class TelegramBot:
self.enabled = enabled self.enabled = enabled
self.db_pool = db_pool self.db_pool = db_pool
self.whitelist_ids = whitelist_ids if whitelist_ids is not None else [] self.whitelist_ids = whitelist_ids if whitelist_ids is not None else []
self.blacklist_ids = blacklist_ids if blacklist_ids is not None else []
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
self.msg_types = Static().BotMessageTypes() self.msg_types = Static().BotMessageTypes()
@@ -46,10 +47,15 @@ class TelegramBot:
async def handle_register(message): async def handle_register(message):
tg_id = str(message.from_user.id) tg_id = str(message.from_user.id)
# Проверка ID на наличие в белом списке # Проверка ID на наличие в белом списке и в чёрном списке
if tg_id not in self.whitelist_ids: if whitelist_enabled:
await self.bot.send_message(message.chat.id, self.get_bot_message(self.msg_types.ID_NOT_WHITELISTED)) if tg_id not in self.whitelist_ids:
return await self.bot.send_message(message.chat.id, self.get_bot_message(self.msg_types.ID_NOT_WHITELISTED))
return
elif blacklist_enabled:
if tg_id in self.blacklist_ids:
await self.bot.send_message(message.chat.id, self.get_bot_message(self.msg_types.ID_BLACKLISTED))
return
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: