From 2d09f52c2ee5ae9b300492e837b3ebe8e4e372d4 Mon Sep 17 00:00:00 2001 From: zavolo Date: Sun, 15 Mar 2026 13:25:40 -0400 Subject: [PATCH] =?UTF-8?q?feat:=2023=20=D0=BE=D0=BF=D0=BA=D0=BE=D0=B4=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D1=80=D0=B5=D0=B3=D0=B8=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B8,=20=D1=81=D0=BC=D1=81=20=D1=88=D0=BB?= =?UTF-8?q?=D1=8E=D0=B7,=20=D0=B4=D0=BE=D0=BA=D0=B5=D1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 8 +- Dockerfile | 16 ++ docker-compose.yml | 39 +++++ faq/install.md | 74 +++++++++- requirements.txt | 4 +- sms-gateway/Dockerfile | 12 ++ sms-gateway/README.md | 90 ++++++++++++ sms-gateway/app/config.py | 79 ++++++++++ sms-gateway/app/deps.py | 20 +++ sms-gateway/app/main.py | 41 ++++++ sms-gateway/app/providers/__init__.py | 10 ++ sms-gateway/app/providers/base.py | 10 ++ sms-gateway/app/providers/lk_api.py | 36 +++++ sms-gateway/app/providers/registry.py | 34 +++++ sms-gateway/app/providers/sms_api.py | 52 +++++++ sms-gateway/app/redis_client.py | 25 ++++ sms-gateway/app/routers/__init__.py | 0 sms-gateway/app/routers/admin.py | 51 +++++++ sms-gateway/app/routers/lk.py | 43 ++++++ sms-gateway/app/routers/sms.py | 41 ++++++ sms-gateway/app/service.py | 90 ++++++++++++ sms-gateway/config.yaml | 34 +++++ sms-gateway/docker-compose.yml | 28 ++++ sms-gateway/requirements.txt | 6 + src/common/config.py | 5 +- src/common/sms.py | 34 +++++ src/oneme_tcp/models.py | 26 +++- src/oneme_tcp/processors.py | 202 +++++++++++++++++++++++--- src/oneme_tcp/server.py | 7 + 29 files changed, 1088 insertions(+), 29 deletions(-) create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 sms-gateway/Dockerfile create mode 100644 sms-gateway/README.md create mode 100644 sms-gateway/app/config.py create mode 100644 sms-gateway/app/deps.py create mode 100644 sms-gateway/app/main.py create mode 100644 sms-gateway/app/providers/__init__.py create mode 100644 sms-gateway/app/providers/base.py create mode 100644 sms-gateway/app/providers/lk_api.py create mode 100644 sms-gateway/app/providers/registry.py create mode 100644 sms-gateway/app/providers/sms_api.py create mode 100644 sms-gateway/app/redis_client.py create mode 100644 sms-gateway/app/routers/__init__.py create mode 100644 sms-gateway/app/routers/admin.py create mode 100644 sms-gateway/app/routers/lk.py create mode 100644 sms-gateway/app/routers/sms.py create mode 100644 sms-gateway/app/service.py create mode 100644 sms-gateway/config.yaml create mode 100644 sms-gateway/docker-compose.yml create mode 100644 sms-gateway/requirements.txt create mode 100644 src/common/sms.py diff --git a/.env.example b/.env.example index 051cdd4..4e9911a 100644 --- a/.env.example +++ b/.env.example @@ -17,11 +17,13 @@ db_name = "openmax" db_file = "" -certfile = "cert.pem" -keyfile = "key.pem" +certfile = "/certs/cert.pem" +keyfile = "/certs/key.pem" +domain = "openmax.su" avatar_base_url = "http://127.0.0.1/avatar/" telegram_bot_token = "123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZ" telegram_bot_enabled = "1" telegram_whitelist_ids = "1,2,3" -origins="http://127.0.0.1,https://web.openmax.su" \ No newline at end of file +origins="http://127.0.0.1,https://web.openmax.su" +sms_gateway_url = "http://127.0.0.1:8100/sms-gateway" \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..172113f --- /dev/null +++ b/Dockerfile @@ -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"] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f6abed6 --- /dev/null +++ b/docker-compose.yml @@ -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: \ No newline at end of file diff --git a/faq/install.md b/faq/install.md index 32e30ac..c503bda 100644 --- a/faq/install.md +++ b/faq/install.md @@ -1,18 +1,80 @@ # Установка +## Вручную + 1. Склонируйте репозиторий 2. Установите зависимости - ```bash pip install -r requirements.txt ``` -3. Настройте сервер (пример в `.env.example`) -4. Импортируйте схему таблиц в свою базу данных из `tables.sql` -5. Запустите сервер +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 ``` -6. Создайте пользователя -7. Зайдите со своего любимого клиента +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" +``` \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 6f985ec..b29d6f9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,4 +5,6 @@ lz4 websockets pydantic aiosqlite -python-dotenv \ No newline at end of file +aiohttp +python-dotenv +cryptography \ No newline at end of file diff --git a/sms-gateway/Dockerfile b/sms-gateway/Dockerfile new file mode 100644 index 0000000..95c9554 --- /dev/null +++ b/sms-gateway/Dockerfile @@ -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"] \ No newline at end of file diff --git a/sms-gateway/README.md b/sms-gateway/README.md new file mode 100644 index 0000000..a8f82e2 --- /dev/null +++ b/sms-gateway/README.md @@ -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` \ No newline at end of file diff --git a/sms-gateway/app/config.py b/sms-gateway/app/config.py new file mode 100644 index 0000000..ce64301 --- /dev/null +++ b/sms-gateway/app/config.py @@ -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() \ No newline at end of file diff --git a/sms-gateway/app/deps.py b/sms-gateway/app/deps.py new file mode 100644 index 0000000..c2e7ccf --- /dev/null +++ b/sms-gateway/app/deps.py @@ -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 \ No newline at end of file diff --git a/sms-gateway/app/main.py b/sms-gateway/app/main.py new file mode 100644 index 0000000..6c32b20 --- /dev/null +++ b/sms-gateway/app/main.py @@ -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"} \ No newline at end of file diff --git a/sms-gateway/app/providers/__init__.py b/sms-gateway/app/providers/__init__.py new file mode 100644 index 0000000..41e6e5b --- /dev/null +++ b/sms-gateway/app/providers/__init__.py @@ -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 \ No newline at end of file diff --git a/sms-gateway/app/providers/base.py b/sms-gateway/app/providers/base.py new file mode 100644 index 0000000..3f9a169 --- /dev/null +++ b/sms-gateway/app/providers/base.py @@ -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 \ No newline at end of file diff --git a/sms-gateway/app/providers/lk_api.py b/sms-gateway/app/providers/lk_api.py new file mode 100644 index 0000000..b7d9453 --- /dev/null +++ b/sms-gateway/app/providers/lk_api.py @@ -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"}, + ) \ No newline at end of file diff --git a/sms-gateway/app/providers/registry.py b/sms-gateway/app/providers/registry.py new file mode 100644 index 0000000..3be572f --- /dev/null +++ b/sms-gateway/app/providers/registry.py @@ -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 \ No newline at end of file diff --git a/sms-gateway/app/providers/sms_api.py b/sms-gateway/app/providers/sms_api.py new file mode 100644 index 0000000..180d01e --- /dev/null +++ b/sms-gateway/app/providers/sms_api.py @@ -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)) \ No newline at end of file diff --git a/sms-gateway/app/redis_client.py b/sms-gateway/app/redis_client.py new file mode 100644 index 0000000..280aed8 --- /dev/null +++ b/sms-gateway/app/redis_client.py @@ -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 \ No newline at end of file diff --git a/sms-gateway/app/routers/__init__.py b/sms-gateway/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sms-gateway/app/routers/admin.py b/sms-gateway/app/routers/admin.py new file mode 100644 index 0000000..32a7a1d --- /dev/null +++ b/sms-gateway/app/routers/admin.py @@ -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() + ] \ No newline at end of file diff --git a/sms-gateway/app/routers/lk.py b/sms-gateway/app/routers/lk.py new file mode 100644 index 0000000..0b47823 --- /dev/null +++ b/sms-gateway/app/routers/lk.py @@ -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} \ No newline at end of file diff --git a/sms-gateway/app/routers/sms.py b/sms-gateway/app/routers/sms.py new file mode 100644 index 0000000..c88018b --- /dev/null +++ b/sms-gateway/app/routers/sms.py @@ -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, + ) \ No newline at end of file diff --git a/sms-gateway/app/service.py b/sms-gateway/app/service.py new file mode 100644 index 0000000..ccfadd5 --- /dev/null +++ b/sms-gateway/app/service.py @@ -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 \ No newline at end of file diff --git a/sms-gateway/config.yaml b/sms-gateway/config.yaml new file mode 100644 index 0000000..851953c --- /dev/null +++ b/sms-gateway/config.yaml @@ -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 \ No newline at end of file diff --git a/sms-gateway/docker-compose.yml b/sms-gateway/docker-compose.yml new file mode 100644 index 0000000..78492fa --- /dev/null +++ b/sms-gateway/docker-compose.yml @@ -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: \ No newline at end of file diff --git a/sms-gateway/requirements.txt b/sms-gateway/requirements.txt new file mode 100644 index 0000000..e5da733 --- /dev/null +++ b/sms-gateway/requirements.txt @@ -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 \ No newline at end of file diff --git a/src/common/config.py b/src/common/config.py index bbe0af1..dc14fa9 100644 --- a/src/common/config.py +++ b/src/common/config.py @@ -47,4 +47,7 @@ class ServerConfig: telegram_whitelist_ids = [x.strip() for x in os.getenv("telegram_whitelist_ids", "").split(",") if x.strip()] ### origins - origins = [x.strip() for x in os.getenv("origins", "").split(",") if x.strip()] if os.getenv("origins") else None \ No newline at end of file + 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" \ No newline at end of file diff --git a/src/common/sms.py b/src/common/sms.py new file mode 100644 index 0000000..64f29e9 --- /dev/null +++ b/src/common/sms.py @@ -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 \ No newline at end of file diff --git a/src/oneme_tcp/models.py b/src/oneme_tcp/models.py index 34ff0bd..f8aecdd 100644 --- a/src/oneme_tcp/models.py +++ b/src/oneme_tcp/models.py @@ -98,4 +98,28 @@ class ComplainReasonsGetPayloadModel(pydantic.BaseModel): class UpdateProfilePayloadModel(pydantic.BaseModel): description: str = None firstName: str = None - lastName: str = None \ No newline at end of file + 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 \ No newline at end of file diff --git a/src/oneme_tcp/processors.py b/src/oneme_tcp/processors.py index 85974b5..a0f94d0 100644 --- a/src/oneme_tcp/processors.py +++ b/src/oneme_tcp/processors.py @@ -5,6 +5,7 @@ from oneme_tcp.config import OnemeConfig from common.tools import Tools from common.config import ServerConfig from common.static import Static +from common.sms import send_sms_code class Processors: 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): """Обработчик запроса кода""" - # Валидируем данные пакета try: RequestCodePayloadModel.model_validate(payload) except pydantic.ValidationError as error: @@ -125,32 +125,55 @@ class Processors: # Извлекаем телефон из пакета phone = payload.get("phone").replace("+", "").replace(" ", "").replace("-", "") - # Генерируем токен с кодом (безопасность прежде всего) - code = str(secrets.randbelow(900000) + 100000) + # Генерируем токен token = secrets.token_urlsafe(128) - - # Хешируем - code_hash = hashlib.sha256(code.encode()).hexdigest() token_hash = hashlib.sha256(token.encode()).hexdigest() # Время истечения токена expires = int(time.time()) + 300 - # Ищем пользователя, и если он существует, сохраняем токен + user_exists = False + + # Ищем пользователя async with self.db_pool.acquire() as conn: async with conn.cursor() as cursor: await cursor.execute("SELECT * FROM users WHERE phone = %s", (phone,)) 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: + 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) - + 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 = { "requestMaxDuration": 60000, @@ -167,11 +190,10 @@ class Processors: # Отправляем 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): """Обработчик проверки кода""" - # Валидируем данные пакета try: VerifyCodePayloadModel.model_validate(payload) except pydantic.ValidationError as error: @@ -195,7 +217,10 @@ class Processors: async with self.db_pool.acquire() as conn: 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() # Если токен просрочен, или его нет - отправляем ошибку @@ -207,7 +232,28 @@ class Processors: if stored_token.get("code_hash") != hashed_code: await self._send_error(seq, self.proto.AUTH, self.error_types.INVALID_CODE, writer) 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"),)) account = await cursor.fetchone() @@ -218,7 +264,7 @@ class Processors: # Создаем сессию 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, "Little Saint James 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) + 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): """Обработчик авторизации клиента на сервере""" # Валидируем данные пакета @@ -894,7 +1063,6 @@ class Processors: # Отправляем пакет await self._send(writer, packet) - async def process_update_profile(self, payload, seq, writer, userId, userPhone): # Валидируем входные данные try: diff --git a/src/oneme_tcp/server.py b/src/oneme_tcp/server.py index 7ab720e..bc06e78 100644 --- a/src/oneme_tcp/server.py +++ b/src/oneme_tcp/server.py @@ -83,6 +83,13 @@ class OnemeMobileServer: 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.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: 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)