Compare commits

...

18 Commits

Author SHA1 Message Date
WowInceptionGood
d5ea45cb96 SQLite: Ошибка
И вообще не юзайте sqlite пожалуйста, он баганный
2026-03-16 00:11:50 +03:00
zavolo
2d09f52c2e feat: 23 опкод для регистрации, смс шлюз, докер 2026-03-15 13:25:40 -04:00
zavolo
d4d5dd5530 feat: 16 опкод для обновления профиля 2026-03-15 11:39:32 +03:00
Alexey Polyakov
81f5fb762f Update deviceId description in tamtam_ws.md 2026-03-12 19:52:38 +03:00
WowInceptionGood
301e55be05 Документация: описал про SESSION_INIT 2026-03-12 16:49:52 +00:00
WowInceptionGood
db3b7323d9 Merge branch 'dev/0.1.0' of https://github.com/openmax-server/server into dev/0.1.0 2026-03-12 16:34:45 +00:00
WowInceptionGood
66fb40a1fd Merge branch 'master' into dev/0.1.0 2026-03-12 16:33:29 +00:00
Alexey Polyakov
9004566652 Merge branch 'master' into dev/0.1.0 2026-03-12 19:30:37 +03:00
Alexey Polyakov
07dd71b0ad Теперь все должно работать исправно 2026-03-12 19:25:09 +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
Alexey Polyakov
1ec1d49424 Merge branch 'master' into dev/0.1.0 2026-03-11 20:44:19 +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
43 changed files with 1545 additions and 126 deletions

View File

@@ -17,11 +17,13 @@ db_name = "openmax"
db_file = "" db_file = ""
certfile = "cert.pem" certfile = "/certs/cert.pem"
keyfile = "key.pem" keyfile = "/certs/key.pem"
domain = "openmax.su"
avatar_base_url = "http://127.0.0.1/avatar/" 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" origins="http://127.0.0.1,https://web.openmax.su"
sms_gateway_url = "http://127.0.0.1:8100/sms-gateway"

16
Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY src/ ./src/
WORKDIR /app/src
CMD ["python", "main.py"]

39
docker-compose.yml Normal file
View File

@@ -0,0 +1,39 @@
services:
app:
build: .
restart: unless-stopped
ports:
- "${oneme_tcp_port:-443}:443"
- "${tamtam_tcp_port:-4433}:4433"
- "${oneme_ws_port:-81}:81"
- "${tamtam_ws_port:-82}:82"
volumes:
- /etc/letsencrypt/live/${domain}/fullchain.pem:/certs/cert.pem:ro
- /etc/letsencrypt/live/${domain}/privkey.pem:/certs/key.pem:ro
env_file:
- .env
environment:
- db_host=db
depends_on:
db:
condition: service_healthy
db:
image: mysql:8.0
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${db_password:-openmax}
MYSQL_DATABASE: ${db_name:-openmax}
MYSQL_USER: ${db_user:-openmax}
MYSQL_PASSWORD: ${db_password:-openmax}
volumes:
- mysql_data:/var/lib/mysql
- ./tables.sql:/docker-entrypoint-initdb.d/tables.sql:ro
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
volumes:
mysql_data:

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

@@ -0,0 +1,69 @@
# Описание протокола TamTam по Websocket
## Основная информация
В веб версии мессенджера ТамТам используется протокол, работающий поверх Websocket.
Пакеты в этом протоколе являются текстовыми JSON данными.
Структура пакета:
```
{
ver: int,
cmd: int,
seq: int,
opcode: int,
payload: {}
}
```
* ver - версия протокола
* cmd - определяет, от кого отправлен пакет. клиент - 0, сервер - 1
* seq - порядковый номер пакета (сервер дублирует его из запроса клиента)
* opcode - команда
* payload - полезная нагрузка команды
## Команды протокола
### PING (1)
Клиент периодически отправляет пакет с командой PING и нагрузкой "{"interactive": true}".
Сервер отвечает ему тем же.
### SESSION_INIT (6)
Первый пакет, который клиент отправляет на сервер после подключения. Полезная нагрузка:
```
{
"userAgent": {
"deviceType": "WEB",
"appVersion": "версия приложения",
"osVersion": "операционная система",
"locale": "язык приложения",
"deviceLocale": "язык устройства",
"deviceName": "название устройства",
"screen": "размер экрана..?",
"headerUserAgent": "юзерагент устройства",
"timezone": "часовой пояс"
},
"deviceId": "ID устройства"
}
```
Сервер отвечает ему пакетом с тем же опкодом, но другой нагрузкой:
```
{
"proxy": "msgproxy.okcdn.ru",
"logs-enabled": false,
"proxy-domains": [
"okcdn.ru",
"mycdn.me",
"ok.ru",
"odnoklassniki.ru",
"odkl.ru",
"vk.com",
"userapi.com",
"vkuser.net",
"vkusercdn.ru"
],
"location": "RU",
"libh-enabled": true
}
```

80
faq/install.md Normal file
View File

@@ -0,0 +1,80 @@
# Установка
## Вручную
1. Склонируйте репозиторий
2. Установите зависимости
```bash
pip install -r requirements.txt
```
3. Сгенерируйте сертификат
Для тестирования (самоподписанный):
```bash
openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem -days 365
```
Для прода — [Let's Encrypt](https://certbot.eff.org/):
```bash
apt install certbot
certbot certonly --standalone -d openmax.su
```
4. Настройте сервер (пример в `.env.example`)
5. Импортируйте схему таблиц в свою базу данных из `tables.sql`
6. Запустите сервер
```bash
python3 main.py
```
7. Создайте пользователя через Telegram бот (`/register`)
8. Зайдите со своего любимого клиента
---
## Docker
1. Склонируйте репозиторий
2. Настройте `.env` (пример в `.env.example`), укажите `db_user` отличный от `root`
3. Получите сертификат Let's Encrypt:
```bash
apt install certbot
certbot certonly --standalone -d openmax.su
```
Укажите домен и пути в `.env`:
```
certfile=/certs/cert.pem
keyfile=/certs/key.pem
domain=openmax.su
```
4. Запустите
```bash
docker compose up -d
```
База данных инициализируется автоматически из `tables.sql`.
5. Создайте пользователя через Telegram бот (`/register`)
6. Зайдите со своего любимого клиента
---
## SMS-шлюз
По умолчанию коды авторизации доставляются через Telegram бот. Если вы хотите принимать пользователей с произвольными номерами без привязки к Telegram — поднимите [SMS Gateway](https://github.com/openmax-server/server/sms-gateway), укажите его адрес в `.env` и отключите Telegram бот:
```
telegram_bot_enabled=false
sms_gateway_url=http://localhost:8100/sms-gateway
```
Клиент MAX ожидает 6-значный код. Если ваш SMS-провайдер отправляет 5-значные коды и не поддерживает настройку длины — сервер автоматически дублирует последнюю цифру: `26541``265411`. Пользователь получает SMS с 5 цифрами и вводит их дважды последнюю: `2-6-5-4-1-1`.
---
## Автопродление сертификата
```bash
certbot renew --deploy-hook "docker compose -f /opt/server/docker-compose.yml restart app"
```

24
faq/patch_apk.md Normal file
View File

@@ -0,0 +1,24 @@
# Смена сервера в мобильном клиенте
> [!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`

7
faq/readme.md Normal file
View File

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

View File

@@ -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,10 @@
pyTelegramBotAPI pyTelegramBotAPI
aiomysql aiomysql
python-dotenv
msgpack msgpack
lz4 lz4
websockets websockets
pydantic pydantic
aiohttp
aiosqlite aiosqlite
aiohttp
python-dotenv
cryptography

12
sms-gateway/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

90
sms-gateway/README.md Normal file
View File

@@ -0,0 +1,90 @@
# Смс шлюз
Микросервис для отправки SMS-кодов с маршрутизацией по провайдерам в зависимости от страны.
## Требования
- Docker и Docker Compose
## Запуск
```bash
docker compose up -d
```
Сервис доступен на порту `8100`, API монтируется по префиксу `/sms-gateway`.
## Конфигурация
Все настройки находятся в `config.yaml`. Перезагрузка конфига без перезапуска:
```bash
curl -X POST http://localhost:8100/sms-gateway/admin/reload
```
### Провайдеры
Два типа провайдеров:
**`sms_api`** — внешний HTTP-сервис, отправляет реальное SMS. Параметры:
- `base_url` — базовый адрес сервиса
- `send_endpoint` — эндпоинт отправки (по умолчанию `/auth/code`)
- `timeout` — таймаут запроса в секундах
**`lk_api`** — внутренний провайдер, SMS не отправляет. Генерирует код и сохраняет его в Redis для отображения в личном кабинете.
### Маршрутизация
Правила задаются в `routing.rules`. Для каждого правила указываются префиксы номеров, основной провайдер и опциональный fallback. Если ни одно правило не совпало — используется `default_provider`.
Пример: номера `+7` идут через `sms_api`, при недоступности — через `lk_api`. Все остальные номера сразу через `lk_api`.
### Rate limiting
Настраивается в `settings.rate_limit`:
- `max_attempts` — максимум запросов с одного номера
- `window_seconds` — окно в секундах
## API
### Отправка кода
```
POST /sms-gateway/sms/send
{"phone_number": "+79001234567"}
```
### Личный кабинет
Получить все ожидающие коды:
```
GET /sms-gateway/lk/codes
```
Получить код по номеру:
```
GET /sms-gateway/lk/code?phone=+79001234567
```
Получить и удалить код (разовое считывание):
```
DELETE /sms-gateway/lk/code?phone=+79001234567
```
### Администрирование
Проверить, какой провайдер выберется для номера:
```
GET /sms-gateway/admin/routing/resolve?phone=+79001234567
```
Список правил маршрутизации:
```
GET /sms-gateway/admin/routing/rules
```
Список активных провайдеров:
```
GET /sms-gateway/admin/providers
```
## Swagger
Документация доступна по адресу: `http://localhost:8100/sms-gateway/docs`

79
sms-gateway/app/config.py Normal file
View File

@@ -0,0 +1,79 @@
from __future__ import annotations
import os
from functools import lru_cache
from pathlib import Path
from typing import Any
import yaml
from pydantic import BaseModel
class ProviderConfig(BaseModel):
type: str
enabled: bool = True
model_config = {"extra": "allow"}
def extra(self) -> dict[str, Any]:
return dict(self.__pydantic_extra__) if self.__pydantic_extra__ else {}
class RoutingRule(BaseModel):
name: str
prefixes: list[str]
provider: str
fallback: str | None = None
def matches(self, phone: str) -> bool:
normalized = phone if phone.startswith("+") else f"+{phone}"
for prefix in sorted(self.prefixes, key=len, reverse=True):
if normalized.startswith(prefix):
return True
return False
class RoutingConfig(BaseModel):
rules: list[RoutingRule] = []
default_provider: str = "lk_api"
default_fallback: str | None = None
class RateLimitSettings(BaseModel):
enabled: bool = True
max_attempts: int = 3
window_seconds: int = 600
class AppSettings(BaseModel):
log_codes: bool = True
code_ttl_seconds: int = 300
rate_limit: RateLimitSettings = RateLimitSettings()
class RedisConfig(BaseModel):
host: str = "redis"
port: int = 6379
db: int = 0
password: str | None = None
def url(self) -> str:
if self.password:
return f"redis://:{self.password}@{self.host}:{self.port}/{self.db}"
return f"redis://{self.host}:{self.port}/{self.db}"
class Config(BaseModel):
providers: dict[str, ProviderConfig]
routing: RoutingConfig
settings: AppSettings = AppSettings()
redis: RedisConfig = RedisConfig()
def resolve_provider(self, phone: str) -> tuple[str, str | None]:
for rule in self.routing.rules:
if rule.matches(phone):
return rule.provider, rule.fallback
return self.routing.default_provider, self.routing.default_fallback
@lru_cache(maxsize=1)
def load_config() -> Config:
path = Path(os.getenv("CONFIG_PATH", "config.yaml"))
if not path.exists():
raise FileNotFoundError(f"Конфиг не найден: {path}")
with open(path, encoding="utf-8") as f:
raw = yaml.safe_load(f)
return Config.model_validate(raw)
def reload_config() -> Config:
load_config.cache_clear()
return load_config()

20
sms-gateway/app/deps.py Normal file
View File

@@ -0,0 +1,20 @@
from __future__ import annotations
from app.config import Config, load_config
from app.providers.registry import build_all_providers
from app.redis_client import get_redis
from app.service import SmsService
_service: SmsService | None = None
def init_service() -> None:
global _service
config = load_config()
providers = build_all_providers(config)
redis = get_redis()
_service = SmsService(config, providers, redis)
def get_sms_service() -> SmsService:
global _service
if _service is None:
init_service()
return _service

41
sms-gateway/app/main.py Normal file
View File

@@ -0,0 +1,41 @@
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from app.config import load_config
from app.redis_client import close_redis, init_redis
from app.routers import admin, lk, sms
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
config = load_config()
await init_redis(config.redis)
logger.info("Redis подключён: %s", config.redis.url())
logger.info(
"Провайдеры: %s | Правил маршрутизации: %d",
list(config.providers.keys()),
len(config.routing.rules),
)
yield
await close_redis()
logger.info("SMS Gateway остановлен")
app = FastAPI(
title="SMS Gateway",
description="Маршрутизация SMS по провайдерам в зависимости от страны",
version="1.0.0",
lifespan=lifespan,
root_path="/sms-gateway",
)
app.include_router(sms.router)
app.include_router(lk.router)
app.include_router(admin.router)
@app.get("/health")
async def health() -> dict:
return {"status": "ok"}

View File

@@ -0,0 +1,10 @@
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass
class SendResult:
success: bool
provider: str
code: str | None = None
raw_response: dict = field(default_factory=dict)
error: str | None = None

View File

@@ -0,0 +1,10 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from app.providers import SendResult
class BaseProvider(ABC):
name: str = "base"
@abstractmethod
async def send(self, phone_number: str, code: str | None = None) -> SendResult:
pass

View File

@@ -0,0 +1,36 @@
from __future__ import annotations
import logging
import random
import uuid
from app.config import ProviderConfig
from app.providers import SendResult
from app.providers.base import BaseProvider
logger = logging.getLogger(__name__)
class LkApiProvider(BaseProvider):
"""
Внутренний провайдер — SMS не шлёт.
Генерирует код, который отображается в личном кабинете.
Используется для всех стран кроме России.
"""
name = "lk_api"
def __init__(self, config: ProviderConfig | None = None) -> None:
pass
async def send(self, phone_number: str, code: str | None = None) -> SendResult:
normalized = phone_number if phone_number.startswith("+") else f"+{phone_number}"
if not code:
code = str(random.randint(10000, 99999))
request_uuid = str(uuid.uuid4())
logger.info(
"lk_api: код для ЛК | phone=%s code=%s uuid=%s",
normalized, code, request_uuid,
)
return SendResult(
success=True,
provider=self.name,
code=code,
raw_response={"code": int(code), "uuid": request_uuid, "note": "displayed in personal cabinet"},
)

View File

@@ -0,0 +1,34 @@
from __future__ import annotations
import logging
from app.config import Config, ProviderConfig
from app.providers.base import BaseProvider
from app.providers.lk_api import LkApiProvider
from app.providers.sms_api import SmsApiProvider
logger = logging.getLogger(__name__)
PROVIDER_REGISTRY: dict[str, type[BaseProvider]] = {
"sms_api": SmsApiProvider,
"lk_api": LkApiProvider,
}
def build_provider(name: str, config: ProviderConfig) -> BaseProvider | None:
cls = PROVIDER_REGISTRY.get(config.type)
if cls is None:
logger.error("Неизвестный тип провайдера: %s", config.type)
return None
if not config.enabled:
logger.debug("Провайдер %s отключён", name)
return None
return cls(config)
def build_all_providers(config: Config) -> dict[str, BaseProvider]:
result: dict[str, BaseProvider] = {}
for name, provider_cfg in config.providers.items():
provider = build_provider(name, provider_cfg)
if provider is not None:
result[name] = provider
logger.info("Провайдер загружен: %s (тип: %s)", name, provider_cfg.type)
if "lk_api" not in result:
result["lk_api"] = LkApiProvider()
logger.info("lk_api добавлен как fallback по умолчанию")
return result

View File

@@ -0,0 +1,52 @@
from __future__ import annotations
import logging
import httpx
from app.config import ProviderConfig
from app.providers import SendResult
from app.providers.base import BaseProvider
logger = logging.getLogger(__name__)
class SmsApiProvider(BaseProvider):
"""
Внешний SMS-сервис.
Отправляет реальное SMS, возвращает код и uuid.
Используется для России (+7).
"""
name = "sms_api"
def __init__(self, config: ProviderConfig) -> None:
extra = config.extra()
self.base_url: str = extra.get("base_url", "").rstrip("/")
self.send_endpoint: str = extra.get("send_endpoint", "/auth/code")
self.timeout: int = int(extra.get("timeout", 10))
async def send(self, phone_number: str, code: str | None = None) -> SendResult:
normalized = phone_number if phone_number.startswith("+") else f"+{phone_number}"
url = f"{self.base_url}{self.send_endpoint}"
payload: dict = {"phone_number": normalized}
if code:
payload["code"] = code
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
url,
json=payload,
headers={"accept": "application/json", "Content-Type": "application/json"},
)
response.raise_for_status()
data = response.json()
code = str(data.get("code", ""))
logger.info("sms_api: SMS отправлен на %s | uuid=%s code=%s", normalized, data.get("uuid"), code)
return SendResult(
success=True,
provider=self.name,
code=code,
raw_response=data,
)
except httpx.HTTPStatusError as e:
logger.error("sms_api HTTP %s для %s: %s", e.response.status_code, normalized, e)
return SendResult(success=False, provider=self.name, error=str(e))
except Exception as e:
logger.error("sms_api ошибка для %s: %s", normalized, e)
return SendResult(success=False, provider=self.name, error=str(e))

View File

@@ -0,0 +1,25 @@
from __future__ import annotations
import redis.asyncio as aioredis
from app.config import RedisConfig
_redis: aioredis.Redis | None = None
async def init_redis(cfg: RedisConfig) -> aioredis.Redis:
global _redis
_redis = aioredis.from_url(
cfg.url(),
encoding="utf-8",
decode_responses=True,
)
await _redis.ping()
return _redis
async def close_redis() -> None:
global _redis
if _redis:
await _redis.aclose()
_redis = None
def get_redis() -> aioredis.Redis:
if _redis is None:
raise RuntimeError("Redis не инициализирован")
return _redis

View File

View File

@@ -0,0 +1,51 @@
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from app.config import reload_config
from app.deps import get_sms_service, init_service
from app.service import SmsService
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin", tags=["Admin"])
class RoutingInfo(BaseModel):
phone: str
primary_provider: str
fallback_provider: str | None
@router.post("/reload", response_model=dict)
async def reload() -> dict:
"""Перечитать config.yaml без перезапуска сервиса."""
new_config = reload_config()
init_service()
providers = list(new_config.providers.keys())
rules_count = len(new_config.routing.rules)
logger.info("Конфиг перезагружен: провайдеры=%s правил=%d", providers, rules_count)
return {"success": True, "providers": providers, "routing_rules": rules_count}
@router.get("/routing/resolve", response_model=RoutingInfo)
async def resolve_routing(
phone: str,
service: SmsService = Depends(get_sms_service),
) -> RoutingInfo:
"""Проверить, какой провайдер будет выбран для номера."""
primary, fallback = service.config.resolve_provider(phone)
return RoutingInfo(phone=phone, primary_provider=primary, fallback_provider=fallback)
@router.get("/routing/rules", response_model=list[dict])
async def list_rules(
service: SmsService = Depends(get_sms_service),
) -> list[dict]:
"""Список всех правил маршрутизации."""
return [rule.model_dump() for rule in service.config.routing.rules]
@router.get("/providers", response_model=list[dict])
async def list_providers(
service: SmsService = Depends(get_sms_service),
) -> list[dict]:
"""Список активных провайдеров."""
return [
{"name": name, "type": name, "enabled": True}
for name in service.providers.keys()
]

View File

@@ -0,0 +1,43 @@
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from app.deps import get_sms_service
from app.service import SmsService
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/lk", tags=["Личный кабинет"])
class PendingCode(BaseModel):
phone: str
code: str
expires_in: int
@router.get("/codes", response_model=list[PendingCode])
async def list_codes(
service: SmsService = Depends(get_sms_service),
) -> list[PendingCode]:
items = await service.list_pending_codes()
return [PendingCode(**item) for item in items]
@router.get("/code", response_model=PendingCode)
async def get_code(
phone: str = Query(..., description="Номер телефона"),
service: SmsService = Depends(get_sms_service),
) -> PendingCode:
items = await service.list_pending_codes()
normalized = phone if phone.startswith("+") else f"+{phone}"
for item in items:
if item["phone"] == normalized:
return PendingCode(**item)
raise HTTPException(status_code=404, detail="Код не найден или истёк")
@router.delete("/code", response_model=dict)
async def consume_code(
phone: str = Query(..., description="Номер телефона"),
service: SmsService = Depends(get_sms_service),
) -> dict:
code = await service.consume_code(phone)
if code is None:
raise HTTPException(status_code=404, detail="Код не найден или истёк")
return {"success": True, "phone": phone, "consumed_code": code}

View File

@@ -0,0 +1,41 @@
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from app.deps import get_sms_service
from app.service import RateLimitExceeded, SmsService
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/sms", tags=["SMS"])
class SendCodeRequest(BaseModel):
phone_number: str
class SendCodeResponse(BaseModel):
success: bool
provider: str
phone_number: str
code: str | None = None
error: str | None = None
@router.post("/send", response_model=SendCodeResponse)
async def send_code(
request: SendCodeRequest,
service: SmsService = Depends(get_sms_service),
) -> SendCodeResponse:
try:
result = await service.send_code(request.phone_number)
except RateLimitExceeded as e:
raise HTTPException(
status_code=429,
detail={"error": "Слишком много запросов для этого номера", "retry_after": e.retry_after},
headers={"Retry-After": str(e.retry_after)},
)
if not result.success:
raise HTTPException(status_code=502, detail=result.error or "Ошибка отправки SMS")
return SendCodeResponse(
success=True,
provider=result.provider,
phone_number=request.phone_number,
code=result.code,
)

View File

@@ -0,0 +1,90 @@
from __future__ import annotations
import logging
import redis.asyncio as aioredis
from app.config import Config
from app.providers import SendResult
from app.providers.base import BaseProvider
logger = logging.getLogger(__name__)
RATE_KEY = "sms:rate:{phone}"
CODE_KEY = "sms:code:{phone}"
class RateLimitExceeded(Exception):
def __init__(self, retry_after: int) -> None:
self.retry_after = retry_after
super().__init__(f"Rate limit exceeded, retry after {retry_after}s")
class SmsService:
def __init__(self, config: Config, providers: dict[str, BaseProvider], redis: aioredis.Redis) -> None:
self.config = config
self.providers = providers
self.redis = redis
async def send_code(self, phone_number: str, code: str | None = None) -> SendResult:
normalized = phone_number if phone_number.startswith("+") else f"+{phone_number}"
await self._check_rate_limit(normalized)
primary_name, fallback_name = self.config.resolve_provider(normalized)
result = await self._try_send(primary_name, normalized, code=code)
if not result.success and fallback_name:
logger.warning(
"Провайдер %s недоступен для %s, пробуем fallback: %s",
primary_name, normalized, fallback_name,
)
result = await self._try_send(fallback_name, normalized, code=code)
if result.success and result.code:
ttl = self.config.settings.code_ttl_seconds
key = CODE_KEY.format(phone=normalized)
await self.redis.set(key, result.code, ex=ttl)
if self.config.settings.log_codes:
logger.info("Код сохранён: phone=%s code=%s provider=%s", normalized, result.code, result.provider)
return result
async def _check_rate_limit(self, phone: str) -> None:
rl = self.config.settings.rate_limit
if not rl.enabled:
return
key = RATE_KEY.format(phone=phone)
pipe = self.redis.pipeline()
pipe.incr(key)
pipe.ttl(key)
count, ttl = await pipe.execute()
if count == 1:
await self.redis.expire(key, rl.window_seconds)
ttl = rl.window_seconds
if count > rl.max_attempts:
retry_after = ttl if ttl > 0 else rl.window_seconds
logger.warning("Rate limit для %s: попытка %d/%d, retry_after=%ds", phone, count, rl.max_attempts, retry_after)
raise RateLimitExceeded(retry_after=retry_after)
async def _try_send(self, provider_name: str, phone: str, code: str | None = None) -> SendResult:
provider = self.providers.get(provider_name)
if provider is None:
logger.error("Провайдер не найден: %s", provider_name)
return SendResult(success=False, provider=provider_name, error=f"Provider '{provider_name}' not found")
return await provider.send(phone, code=code)
async def get_pending_code(self, phone_number: str) -> str | None:
normalized = phone_number if phone_number.startswith("+") else f"+{phone_number}"
key = CODE_KEY.format(phone=normalized)
return await self.redis.get(key)
async def consume_code(self, phone_number: str) -> str | None:
normalized = phone_number if phone_number.startswith("+") else f"+{phone_number}"
key = CODE_KEY.format(phone=normalized)
pipe = self.redis.pipeline()
pipe.get(key)
pipe.delete(key)
code, _ = await pipe.execute()
return code
async def list_pending_codes(self) -> list[dict]:
pattern = CODE_KEY.format(phone="*")
result = []
async for key in self.redis.scan_iter(pattern):
pipe = self.redis.pipeline()
pipe.get(key)
pipe.ttl(key)
code, ttl = await pipe.execute()
if code:
phone = key.replace("sms:code:", "")
result.append({"phone": phone, "code": code, "expires_in": max(ttl, 0)})
return result

34
sms-gateway/config.yaml Normal file
View File

@@ -0,0 +1,34 @@
providers:
sms_api:
type: sms_api
enabled: false
base_url: "http://localhost:8000"
send_endpoint: "/auth/code"
timeout: 10
lk_api:
type: lk_api
enabled: true
routing:
rules:
- name: "Russia"
prefixes: ["+7"]
provider: "sms_api"
fallback: "lk_api"
default_provider: "lk_api"
default_fallback: null
settings:
log_codes: true
code_ttl_seconds: 300
rate_limit:
enabled: true
max_attempts: 3
window_seconds: 600
redis:
host: "redis"
port: 6379
db: 0

View File

@@ -0,0 +1,28 @@
services:
sms-gateway:
build: .
ports:
- "8100:8000"
volumes:
- ./config.yaml:/app/config.yaml:ro
environment:
- CONFIG_PATH=/app/config.yaml
depends_on:
redis:
condition: service_healthy
restart: unless-stopped
redis:
image: redis:7-alpine
command: redis-server --save 60 1 --loglevel warning
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
restart: unless-stopped
volumes:
redis_data:

View File

@@ -0,0 +1,6 @@
fastapi>=0.115.0
uvicorn[standard]>=0.30.0
httpx>=0.27.0
pydantic>=2.7.0
pyyaml>=6.0.1
redis>=5.0.0

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:
@@ -47,3 +48,6 @@ class ServerConfig:
### origins ### origins
origins = [x.strip() for x in os.getenv("origins", "").split(",") if x.strip()] if os.getenv("origins") else None origins = [x.strip() for x in os.getenv("origins", "").split(",") if x.strip()] if os.getenv("origins") else None
### sms шлюз
sms_gateway_url = os.getenv("sms_gateway_url") or "http://127.0.0.1/sms-gateway"

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)

34
src/common/sms.py Normal file
View File

@@ -0,0 +1,34 @@
import aiohttp
import ssl
import logging
logger = logging.getLogger(__name__)
async def send_sms_code(gateway_url: str, phone: str) -> str | None:
url = f"{gateway_url}/sms/send"
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
connector = aiohttp.TCPConnector(ssl=ssl_context)
async with aiohttp.ClientSession(connector=connector) as session:
try:
async with session.post(url, json={"phone_number": phone}) as resp:
data = await resp.json()
except Exception as e:
logger.error(f"Ошибка подключения к SMS шлюзу: {e}")
return None
if not data.get("success"):
logger.error(f"SMS шлюз вернул ошибку: {data.get('error')}")
return None
code = data.get("code")
if not code:
logger.error("SMS шлюз не вернул код")
return None
code = str(code)
# Если шлюз вернул 5-значный код — повторяем последнюю цифру.
# Пример: 26541 -> 265411, 26542 -> 265422
# Пользователь получает SMS с 5 цифрами и дописывает последнюю (такую же).
if len(code) == 5:
code = code + code[-1]
logger.debug(f"Код дополнен до 6 цифр: {code}")
return code

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"
@@ -73,6 +74,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": "Слишком много попыток"
} }
} }

View File

@@ -27,7 +27,7 @@ async def init_db():
elif server_config.db_type == "sqlite": elif server_config.db_type == "sqlite":
import aiosqlite import aiosqlite
raw_db = await aiosqlite.connect(server_config.db_file) raw_db = await aiosqlite.connect(server_config.db_file)
db["acquire"] = raw_db db["acquire"] = lambda: raw_db
# Возвращаем # Возвращаем
return db return db

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

@@ -94,3 +94,32 @@ class SearchUsersPayloadModel(pydantic.BaseModel):
class ComplainReasonsGetPayloadModel(pydantic.BaseModel): class ComplainReasonsGetPayloadModel(pydantic.BaseModel):
complainSync: int complainSync: int
class UpdateProfilePayloadModel(pydantic.BaseModel):
description: str = None
firstName: str = None
lastName: str = None
class AuthConfirmRegisterPayloadModel(pydantic.BaseModel):
token: str
firstName: str
lastName: str = None
tokenType: str
@pydantic.field_validator('firstName')
def validate_first_name(cls, v):
v = v.strip()
if not v:
raise ValueError('firstName must not be empty')
if len(v) > 59:
raise ValueError('firstName too long')
return v
@pydantic.field_validator('lastName')
def validate_last_name(cls, v):
if v is None:
return v
v = v.strip()
if len(v) > 59:
raise ValueError('lastName too long')
return v

View File

@@ -1,10 +1,11 @@
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
from common.tools import Tools from common.tools import Tools
from common.config import ServerConfig from common.config import ServerConfig
from common.static import Static from common.static import Static
from common.sms import send_sms_code
class Processors: class Processors:
def __init__(self, db_pool=None, clients={}, send_event=None, telegram_bot=None): def __init__(self, db_pool=None, clients={}, send_event=None, telegram_bot=None):
@@ -114,7 +115,6 @@ class Processors:
async def process_request_code(self, payload, seq, writer): async def process_request_code(self, payload, seq, writer):
"""Обработчик запроса кода""" """Обработчик запроса кода"""
# Валидируем данные пакета
try: try:
RequestCodePayloadModel.model_validate(payload) RequestCodePayloadModel.model_validate(payload)
except pydantic.ValidationError as error: except pydantic.ValidationError as error:
@@ -125,31 +125,54 @@ class Processors:
# Извлекаем телефон из пакета # Извлекаем телефон из пакета
phone = payload.get("phone").replace("+", "").replace(" ", "").replace("-", "") phone = payload.get("phone").replace("+", "").replace(" ", "").replace("-", "")
# Генерируем токен с кодом # Генерируем токен
code = str(random.randint(100000, 999999))
token = secrets.token_urlsafe(128) token = secrets.token_urlsafe(128)
# Хешируем
code_hash = hashlib.sha256(code.encode()).hexdigest()
token_hash = hashlib.sha256(token.encode()).hexdigest() token_hash = hashlib.sha256(token.encode()).hexdigest()
# Время истечения токена # Время истечения токена
expires = int(time.time()) + 300 expires = int(time.time()) + 300
# Ищем пользователя, и если он существует, сохраняем токен user_exists = False
# Ищем пользователя
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 users WHERE phone = %s", (phone,)) await cursor.execute("SELECT * FROM users WHERE phone = %s", (phone,))
user = await cursor.fetchone() user = await cursor.fetchone()
# Если пользователя найден - сохраняем токен и отправляем код # Получаем код через SMS шлюз или генерируем локально (безопасность прежде всего)
if self.config.sms_gateway_url:
code = await send_sms_code(self.config.sms_gateway_url, phone)
if code is None:
await self._send_error(seq, self.proto.AUTH_REQUEST, self.error_types.INVALID_PAYLOAD, writer)
return
else:
code = str(secrets.randbelow(900000) + 100000)
# Хешируем
code_hash = hashlib.sha256(code.encode()).hexdigest()
# Сохраняем токен и если нужно отправляем код через тг
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
if user: if user:
user_exists = True
# Сохраняем токен # Сохраняем токен
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 cursor.execute(
"INSERT INTO auth_tokens (phone, token_hash, code_hash, expires) VALUES (%s, %s, %s, %s)",
(phone, token_hash, code_hash, expires,)
)
# Если тг бот включен, и тг привязан к аккаунту - отправляем туда сообщение # Если тг бот включен, и тг привязан к аккаунту - отправляем туда сообщение
if self.telegram_bot and user.get("telegram_id"): if not self.config.sms_gateway_url and 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) await self.telegram_bot.send_code(chat_id=int(user.get("telegram_id")), phone=phone, code=code)
else:
# Пользователь не найден - сохраняем токен со state='register'
# чтобы после верификации кода направить на экран регистрации
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, "register",)
)
# Данные пакета # Данные пакета
payload = { payload = {
@@ -167,11 +190,10 @@ class Processors:
# Отправляем # Отправляем
await self._send(writer, packet) await self._send(writer, packet)
self.logger.debug(f"Код для {phone}: {code}") self.logger.debug(f"Код для {phone}: {code} (существующий={user_exists})")
async def process_verify_code(self, payload, seq, writer, deviceType, deviceName): async def process_verify_code(self, payload, seq, writer, deviceType, deviceName):
"""Обработчик проверки кода""" """Обработчик проверки кода"""
# Валидируем данные пакета
try: try:
VerifyCodePayloadModel.model_validate(payload) VerifyCodePayloadModel.model_validate(payload)
except pydantic.ValidationError as error: except pydantic.ValidationError as error:
@@ -195,7 +217,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()", (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()
# Если токен просрочен, или его нет - отправляем ошибку # Если токен просрочен, или его нет - отправляем ошибку
@@ -208,6 +233,27 @@ class Processors:
await self._send_error(seq, self.proto.AUTH, self.error_types.INVALID_CODE, writer) await self._send_error(seq, self.proto.AUTH, self.error_types.INVALID_CODE, writer)
return return
# Если это новый пользователь - переводим токен в state='verified'
# и отдаём клиенту REGISTER токен, чтобы он показал экран ввода имени
if stored_token.get("state") == "register":
await cursor.execute(
"UPDATE auth_tokens SET state = %s WHERE token_hash = %s",
("verified", hashed_token,)
)
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.AUTH,
payload={
"tokenAttrs": {
"REGISTER": {
"token": token
}
},
"presetAvatars": []
}
)
await self._send(writer, packet)
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()
@@ -218,7 +264,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()),) # весь покрытый зеленью, абсолютно весь, остров невезения в океане есть
) )
# Генерируем профиль # Генерируем профиль
@@ -259,6 +305,129 @@ class Processors:
# Отправляем # Отправляем
await self._send(writer, packet) await self._send(writer, packet)
async def process_auth_confirm(self, payload, seq, writer, deviceType, deviceName):
"""Обработчик подтверждения регистрации нового пользователя"""
# Валидируем данные пакета
try:
AuthConfirmRegisterPayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.proto.AUTH_CONFIRM, self.error_types.INVALID_PAYLOAD, writer)
return
# Извлекаем данные из пакета
token = payload.get("token")
first_name = payload.get("firstName").strip()
last_name = (payload.get("lastName") or "").strip()
# Хешируем токен
hashed_token = hashlib.sha256(token.encode()).hexdigest()
# Генерируем постоянный логин-токен
login = secrets.token_urlsafe(128)
hashed_login = hashlib.sha256(login.encode()).hexdigest()
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
# Ищем токен - он должен быть в state='verified'
await cursor.execute(
"SELECT * FROM auth_tokens WHERE token_hash = %s AND expires > UNIX_TIMESTAMP() AND state = %s",
(hashed_token, "verified",)
)
stored_token = await cursor.fetchone()
# Если токен не найден или просрочен - отправляем ошибку
if stored_token is None:
await self._send_error(seq, self.proto.AUTH_CONFIRM, self.error_types.CODE_EXPIRED, writer)
return
phone = stored_token.get("phone")
# Проверяем что пользователь с таким телефоном ещё не существует
await cursor.execute("SELECT id FROM users WHERE phone = %s", (phone,))
if await cursor.fetchone():
await self._send_error(seq, self.proto.AUTH_CONFIRM, self.error_types.INVALID_PAYLOAD, writer)
return
now_ms = int(time.time() * 1000)
now_s = int(time.time())
# Создаем пользователя
await cursor.execute(
"""
INSERT INTO users
(phone, telegram_id, firstname, lastname, username,
profileoptions, options, accountstatus, updatetime, lastseen)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
phone, None, first_name, last_name, None,
json.dumps([]), json.dumps(["ONEME"]),
0, str(now_ms), str(now_s),
)
)
user_id = cursor.lastrowid
# Добавляем данные аккаунта
await cursor.execute(
"""
INSERT INTO user_data
(phone, chats, contacts, folders, user_config, chat_config)
VALUES (%s, %s, %s, %s, %s, %s)
""",
(
phone,
json.dumps([]), json.dumps([]),
json.dumps(self.static.USER_FOLDERS),
json.dumps(self.static.USER_SETTINGS),
json.dumps({}),
)
)
# Удаляем токен
await cursor.execute("DELETE FROM auth_tokens WHERE token_hash = %s", (hashed_token,))
# Создаем сессию
await cursor.execute(
"INSERT INTO tokens (phone, token_hash, device_type, device_name, location, time) VALUES (%s, %s, %s, %s, %s, %s)",
(phone, hashed_login, deviceType or "ANDROID", deviceName or "Unknown", "Little Saint James Island", now_s,)
)
# Генерируем профиль
profile = self.tools.generate_profile(
id=user_id,
phone=int(phone),
avatarUrl=None,
photoId=None,
updateTime=now_ms,
firstName=first_name,
lastName=last_name,
options=["ONEME"],
description=None,
accountStatus=0,
profileOptions=[],
includeProfileOptions=True,
username=None
)
# Собираем данные пакета
payload = {
"userToken": "0",
"profile": profile,
"tokenType": "LOGIN",
"token": login
}
# Создаем пакет
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.AUTH_CONFIRM, payload=payload
)
# Отправляем
await self._send(writer, packet)
self.logger.info(f"Новый пользователь зарегистрирован: phone={phone} id={user_id} name={first_name} {last_name}")
async def process_login(self, payload, seq, writer): async def process_login(self, payload, seq, writer):
"""Обработчик авторизации клиента на сервере""" """Обработчик авторизации клиента на сервере"""
# Валидируем данные пакета # Валидируем данные пакета
@@ -436,11 +605,6 @@ class Processors:
cid = message.get("cid") or 0 cid = message.get("cid") or 0
text = message.get("text") or "" text = message.get("text") or ""
# Если клиент вообще ничего не указал в пакете, то выбрасываем ошибку
if not all([userId, chatId, elements, attaches, cid, text]):
await self._send_error(seq, self.proto.MSG_SEND, self.error_types.INVALID_PAYLOAD, writer)
return
# Время отправки сообщения # Время отправки сообщения
messageTime = int(time.time() * 1000) messageTime = int(time.time() * 1000)
@@ -685,9 +849,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
# Получаем последнее сообщение из чата # Получаем последнее сообщение из чата
@@ -896,3 +1062,73 @@ class Processors:
# Отправляем пакет # Отправляем пакет
await self._send(writer, packet) await self._send(writer, packet)
async def process_update_profile(self, payload, seq, writer, userId, userPhone):
# Валидируем входные данные
try:
UpdateProfilePayloadModel.model_validate(payload)
except Exception as e:
await self._send_error(seq, self.proto.PROFILE, self.error_types.INVALID_PAYLOAD, writer)
return
# Извлекаем поля из пакета (каждое может быть None)
description = payload.get("description")
firstName = payload.get("firstName")
lastName = payload.get("lastName")
# Обновляем только те поля, которые пришли в запросе
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
if description is not None:
# При изменении описания также обновляем время последнего изменения профиля
await cursor.execute(
"UPDATE users SET description = %s, updatetime = %s WHERE id = %s",
(description, int(time.time() * 1000), userId)
)
if firstName is not None:
await cursor.execute(
"UPDATE users SET firstname = %s WHERE id = %s",
(firstName, userId)
)
if lastName is not None:
await cursor.execute(
"UPDATE users SET lastname = %s WHERE id = %s",
(lastName, userId)
)
# Получаем актуальные данные пользователя после обновления
await cursor.execute("SELECT * FROM users WHERE id = %s", (userId,))
user = await cursor.fetchone()
# Формируем URL аватарки если она есть
photoId = None if not user.get("avatar_id") else int(user.get("avatar_id"))
avatar_url = None if not photoId else self.config.avatar_base_url + str(photoId)
# Генерируем профиль для отправки клиенту
profile = self.tools.generate_profile(
id=user.get("id"),
phone=int(user.get("phone")),
avatarUrl=avatar_url,
photoId=photoId,
updateTime=int(user.get("updatetime")),
firstName=user.get("firstname"),
lastName=user.get("lastname"),
options=json.loads(user.get("options")),
description=user.get("description"),
accountStatus=int(user.get("accountstatus")),
profileOptions=json.loads(user.get("profileoptions")),
includeProfileOptions=True,
username=user.get("username")
)
# Отправляем ответ на запрос (CMD_OK)
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.PROFILE, payload=profile
)
await self._send(writer, packet)
# Отправляем уведомление об изменении профиля (CMD_NOF)
notif_packet = self.proto.pack_packet(
cmd=self.proto.CMD_NOF, seq=0, opcode=self.proto.NOTIF_PROFILE, payload=profile
)
await self._send(writer, notif_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 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
from common.tools import Tools from common.tools import Tools
class OnemeMobileServer: class OnemeMobileServer:
@@ -17,6 +18,12 @@ class OnemeMobileServer:
self.auth_required = Tools().auth_required 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 +39,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,13 +74,28 @@ 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._send_error(seq, self.proto.AUTH_REQUEST, self.processors.error_types.RATE_LIMITED, writer)
else:
await self.processors.process_request_code(payload, seq, writer) 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._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) await self.processors.process_verify_code(payload, seq, writer, deviceType, deviceName)
case self.proto.AUTH_CONFIRM:
if not self.auth_rate_limiter.is_allowed(address[0]):
await self.processors._send_error(seq, self.proto.AUTH_CONFIRM, self.processors.error_types.RATE_LIMITED, writer)
elif payload and payload.get("tokenType") == "REGISTER":
await self.processors.process_auth_confirm(payload, seq, writer, deviceType, deviceName)
else:
self.logger.warning(f"AUTH_CONFIRM с неизвестным tokenType: {payload}")
case self.proto.LOGIN: case self.proto.LOGIN:
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) 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:
@@ -76,23 +115,23 @@ class OnemeMobileServer:
) )
case self.proto.MSG_SEND: case self.proto.MSG_SEND:
await self.auth_required( await self.auth_required(
userPhone, self.processors.process_send_message, payload, seq, writer, senderId=userId, db_pool=self.db_pool userPhone, self.processors.process_send_message, payload, seq, writer, userId, self.db_pool
) )
case self.proto.FOLDERS_GET: case self.proto.FOLDERS_GET:
await self.auth_required( await self.auth_required(
userPhone, self.processors.process_get_folders, payload, seq, writer, senderPhone=userPhone userPhone, self.processors.process_get_folders, payload, seq, writer, userPhone
) )
case self.proto.SESSIONS_INFO: case self.proto.SESSIONS_INFO:
await self.auth_required( await self.auth_required(
userPhone, self.processors.process_get_sessions, payload, seq, writer, senderPhone=userPhone, hashedToken=hashedToken userPhone, self.processors.process_get_sessions, payload, seq, writer, userPhone, hashedToken
) )
case self.proto.CHAT_INFO: case self.proto.CHAT_INFO:
await self.auth_required( await self.auth_required(
userPhone, self.processors.process_search_chats, payload, seq, writer, senderId=userId userPhone, self.processors.process_search_chats, payload, seq, writer, userId
) )
case self.proto.CONTACT_INFO_BY_PHONE: case self.proto.CONTACT_INFO_BY_PHONE:
await self.auth_required( await self.auth_required(
userPhone, self.processors.process_search_by_phone, payload, seq, writer, senderId=userId userPhone, self.processors.process_search_by_phone, payload, seq, writer, userId
) )
case self.proto.OK_TOKEN: case self.proto.OK_TOKEN:
await self.auth_required( await self.auth_required(
@@ -100,7 +139,7 @@ class OnemeMobileServer:
) )
case self.proto.MSG_TYPING: case self.proto.MSG_TYPING:
await self.auth_required( await self.auth_required(
userPhone, self.processors.process_typing, payload, seq, writer, senderId=userId userPhone, self.processors.process_typing, payload, seq, writer, userId
) )
case self.proto.CONTACT_INFO: case self.proto.CONTACT_INFO:
await self.auth_required( await self.auth_required(
@@ -110,6 +149,10 @@ class OnemeMobileServer:
await self.auth_required( await self.auth_required(
userPhone, self.processors.process_complain_reasons_get, payload, seq, writer userPhone, self.processors.process_complain_reasons_get, payload, seq, writer
) )
case self.proto.PROFILE:
await self.processors.process_update_profile(
payload, seq, writer, userId=userId, userPhone=userPhone
)
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
# Ищем пользователя, и если он существует, сохраняем токен # Ищем пользователя, и если он существует, сохраняем токен
@@ -141,10 +149,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
@@ -162,9 +171,9 @@ class Processors:
# Генерируем профиль # Генерируем профиль
# Аватарка с биографией # Аватарка с биографией
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 = {
@@ -172,7 +181,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=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"),
@@ -222,7 +231,8 @@ 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:
@@ -244,12 +254,13 @@ 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, "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")
# Собираем данные пакета # Собираем данные пакета
@@ -259,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=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"),

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,10 +72,19 @@ 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._send_error(seq, self.proto.REQUEST_CODE, self.processors.error_types.RATE_LIMITED, writer)
else:
await self.processors.process_request_code(payload, seq, writer) 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._send_error(seq, self.proto.VERIFY_CODE, self.processors.error_types.RATE_LIMITED, writer)
else:
await self.processors.process_verify_code(payload, seq, writer) 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._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) await self.processors.process_final_auth(payload, seq, writer, deviceType, deviceName)
case _: case _:
self.logger.warning(f"Неизвестный опкод {opcode}") self.logger.warning(f"Неизвестный опкод {opcode}")

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
# мне кажется долго вручную всё писать # мне кажется долго вручную всё писать

View File

@@ -22,17 +22,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 +42,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:
@@ -58,8 +57,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()