diff --git a/README.md b/README.md new file mode 100644 index 0000000..3dcc4dd --- /dev/null +++ b/README.md @@ -0,0 +1,131 @@ +# Baton — Экстренный сигнал + +PWA-приложение для отправки экстренных сигналов с геолокацией через Telegram-бота. + +## Стек + +- **Backend:** Python 3.12+, FastAPI, aiosqlite, httpx +- **Frontend:** Vanilla JS PWA (Service Worker, Web Push) +- **База данных:** SQLite (WAL mode) +- **Уведомления:** Telegram Bot API + +## Запуск + +```bash +# Зависимости +pip install -r requirements.txt + +# Переменные окружения (см. .env.example) +cp .env.example .env + +# Запуск +uvicorn backend.main:app --host 0.0.0.0 --port 8000 +``` + +## Переменные окружения + +| Переменная | Обязательна | Описание | +|---|---|---| +| `BOT_TOKEN` | да | Токен Telegram-бота | +| `CHAT_ID` | да | ID чата для уведомлений | +| `WEBHOOK_SECRET` | да | Секрет для верификации Telegram webhook | +| `WEBHOOK_URL` | да | Публичный URL `/api/webhook/telegram` | +| `DB_PATH` | нет | Путь к SQLite-файлу (по умолчанию `baton.db`) | +| `FRONTEND_ORIGIN` | нет | Разрешённый origin для CORS (по умолчанию `http://localhost:3000`) | +| `WEBHOOK_ENABLED` | нет | Регистрировать webhook при старте (по умолчанию `true`) | +| `APP_URL` | нет | Публичный URL приложения для keep-alive (например `https://baton.fly.dev`) | + +## API + +| Метод | Путь | Описание | +|---|---|---| +| `GET` | `/health` | Health check: `{"status": "ok", "timestamp": }` | +| `POST` | `/api/register` | Регистрация пользователя | +| `POST` | `/api/signal` | Отправка экстренного сигнала | +| `POST` | `/api/webhook/telegram` | Telegram webhook | + +## Hosting & Keep-Alive + +### Проблема cold start + +На бесплатных хостингах (Render, fly.io free tier, Railway и подобных) приложение **засыпает** после периода неактивности (обычно 15–30 минут). Следующий входящий запрос ждёт пока процесс поднимется заново — **cold start занимает 3–5 секунд**. Для экстренного приложения это критично. + +### Решения по вариантам хостинга + +| Вариант | Стоимость | Cold start | Рекомендация | +|---|---|---|---| +| **fly.io Hobby** | $5/мес | Нет (всегда активен) | Оптимально для прода | +| **fly.io free tier** | Бесплатно | 3–5 сек | Только для разработки | +| **Render free** | Бесплатно | 3–5 сек | Только для разработки | +| **Самохостинг (VPS)** | От $3–5/мес | Нет | Полный контроль | + +> **Финальный выбор хостинга зависит от решения по OQ-004** (открытый вопрос по бюджету и масштабированию проекта). + +### Keep-alive механизм (asyncio background task) + +Приложение запускает фоновый asyncio-таск, который каждые **10 минут** пингует собственный `/health` endpoint. Это предотвращает засыпание на платформах, которые реагируют на активность процесса. + +**Активация:** установите переменную `APP_URL`: + +```bash +APP_URL=https://your-app.fly.dev +``` + +Без `APP_URL` таск не запускается (keep-alive отключён). + +**Ограничение:** self-ping работает пока процесс жив. Если платформа убивает процесс при нулевом трафике — нужен внешний пингер (см. ниже). + +### Keep-alive для самохостинга (cron / systemd timer) + +Если приложение на VPS и нужен мониторинг извне: + +**Вариант 1 — crontab:** + +```bash +# Редактируем cron +crontab -e + +# Добавляем запись (каждые 10 минут): +*/10 * * * * curl -sf https://your-app.example.com/health > /dev/null +``` + +**Вариант 2 — systemd timer:** + +Создайте два файла: + +`/etc/systemd/system/baton-keepalive.service`: +```ini +[Unit] +Description=Baton keep-alive ping + +[Service] +Type=oneshot +ExecStart=curl -sf https://your-app.example.com/health +``` + +`/etc/systemd/system/baton-keepalive.timer`: +```ini +[Unit] +Description=Run Baton keep-alive every 10 minutes + +[Timer] +OnBootSec=1min +OnUnitActiveSec=10min + +[Install] +WantedBy=timers.target +``` + +Активация: +```bash +systemctl daemon-reload +systemctl enable --now baton-keepalive.timer +systemctl list-timers baton-keepalive.timer +``` + +## Тесты + +```bash +pip install -r requirements-dev.txt +pytest +``` diff --git a/backend/config.py b/backend/config.py index fddf972..af4d933 100644 --- a/backend/config.py +++ b/backend/config.py @@ -20,3 +20,4 @@ WEBHOOK_SECRET: str = _require("WEBHOOK_SECRET") WEBHOOK_URL: str = _require("WEBHOOK_URL") WEBHOOK_ENABLED: bool = os.getenv("WEBHOOK_ENABLED", "true").lower() == "true" FRONTEND_ORIGIN: str = os.getenv("FRONTEND_ORIGIN", "http://localhost:3000") +APP_URL: str | None = os.getenv("APP_URL") # Публичный URL приложения для keep-alive self-ping diff --git a/backend/main.py b/backend/main.py index 3a99fb5..092a764 100644 --- a/backend/main.py +++ b/backend/main.py @@ -2,10 +2,12 @@ from __future__ import annotations import asyncio import logging +import time from contextlib import asynccontextmanager from datetime import datetime, timezone from typing import Any +import httpx from fastapi import Depends, FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse @@ -24,6 +26,21 @@ logger = logging.getLogger(__name__) # aggregator = telegram.SignalAggregator(interval=10) # v2.0 feature — отключено в v1 (ADR-004) +_KEEPALIVE_INTERVAL = 600 # 10 минут + + +async def _keep_alive_loop(app_url: str) -> None: + """Периодически пингует /health чтобы предотвратить cold start на бесплатных хостингах.""" + health_url = f"{app_url.rstrip('/')}/health" + async with httpx.AsyncClient(timeout=10.0) as client: + while True: + await asyncio.sleep(_KEEPALIVE_INTERVAL) + try: + resp = await client.get(health_url) + logger.info("Keep-alive ping %s → %d", health_url, resp.status_code) + except Exception as exc: + logger.warning("Keep-alive ping failed: %s", exc) + @asynccontextmanager async def lifespan(app: FastAPI): @@ -39,9 +56,24 @@ async def lifespan(app: FastAPI): # task = asyncio.create_task(aggregator.run()) # logger.info("Aggregator started") + keepalive_task: asyncio.Task | None = None + if config.APP_URL: + keepalive_task = asyncio.create_task(_keep_alive_loop(config.APP_URL)) + logger.info("Keep-alive task started (target: %s/health)", config.APP_URL) + else: + logger.info("APP_URL not set — keep-alive disabled") + yield # Shutdown + if keepalive_task is not None: + keepalive_task.cancel() + try: + await keepalive_task + except asyncio.CancelledError: + pass + logger.info("Keep-alive task stopped") + # aggregator.stop() # await aggregator.flush() # task.cancel() @@ -62,6 +94,11 @@ app.add_middleware( ) +@app.get("/health") +async def health() -> dict[str, Any]: + return {"status": "ok", "timestamp": int(time.time())} + + @app.post("/api/register", response_model=RegisterResponse) async def register(body: RegisterRequest) -> RegisterResponse: result = await db.register_user(uuid=body.uuid, name=body.name) diff --git a/tests/test_arch_003.py b/tests/test_arch_003.py new file mode 100644 index 0000000..248086f --- /dev/null +++ b/tests/test_arch_003.py @@ -0,0 +1,204 @@ +""" +Tests for BATON-ARCH-003: Rate limiting on /api/register + timing-safe token comparison. + +Acceptance criteria: +1. /api/register blocks brute-force at the application level: + 5 requests pass (200), 6th returns 429; counter resets after the 10-minute window. +2. Token comparison is timing-safe: + secrets.compare_digest is used in middleware.py (no == / != for token comparison). +""" +from __future__ import annotations + +import ast +import os +import time as _time +from pathlib import Path +from unittest.mock import patch + +os.environ.setdefault("BOT_TOKEN", "test-bot-token") +os.environ.setdefault("CHAT_ID", "-1001234567890") +os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret") +os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram") +os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000") + +import pytest +from tests.conftest import make_app_client + +_BACKEND_DIR = Path(__file__).parent.parent / "backend" + +CORRECT_SECRET = "test-webhook-secret" + +_SAMPLE_UPDATE = { + "update_id": 300, + "message": { + "message_id": 1, + "from": {"id": 99000001, "first_name": "Test", "last_name": "User"}, + "chat": {"id": 99000001, "type": "private"}, + "text": "/start", + }, +} + + +# --------------------------------------------------------------------------- +# Criterion 1 — Rate limiting: first 5 requests pass +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_register_rate_limit_allows_five_requests(): + """POST /api/register: первые 5 запросов с одного IP возвращают 200.""" + async with make_app_client() as client: + for i in range(5): + resp = await client.post( + "/api/register", + json={"uuid": f"rl-ok-{i:03d}", "name": f"User{i}"}, + ) + assert resp.status_code == 200, ( + f"Request {i + 1}/5 unexpectedly returned {resp.status_code}" + ) + + +# --------------------------------------------------------------------------- +# Criterion 1 — Rate limiting: 6th request is blocked +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_register_rate_limit_blocks_sixth_request(): + """POST /api/register: 6-й запрос с одного IP возвращает 429 Too Many Requests.""" + async with make_app_client() as client: + for i in range(5): + await client.post( + "/api/register", + json={"uuid": f"rl-blk-{i:03d}", "name": f"User{i}"}, + ) + resp = await client.post( + "/api/register", + json={"uuid": "rl-blk-999", "name": "Attacker"}, + ) + assert resp.status_code == 429 + + +# --------------------------------------------------------------------------- +# Criterion 1 — Rate limiting: counter resets after window expires +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_register_rate_limit_resets_after_window_expires(): + """POST /api/register: после истечения 10-минутного окна запросы снова принимаются.""" + base_time = _time.time() + + async with make_app_client() as client: + # Exhaust the rate limit + for i in range(5): + await client.post( + "/api/register", + json={"uuid": f"rl-exp-{i:03d}", "name": f"User{i}"}, + ) + + # Verify the 6th is blocked before window expiry + blocked = await client.post( + "/api/register", + json={"uuid": "rl-exp-blk", "name": "Attacker"}, + ) + assert blocked.status_code == 429, ( + "Expected 429 after exhausting rate limit, got " + str(blocked.status_code) + ) + + # Advance clock past the 10-minute window (600 s) + with patch("time.time", return_value=base_time + 601): + resp_after = await client.post( + "/api/register", + json={"uuid": "rl-exp-after", "name": "Legit"}, + ) + + assert resp_after.status_code == 200, ( + "Expected 200 after rate-limit window expired, got " + str(resp_after.status_code) + ) + + +# --------------------------------------------------------------------------- +# Criterion 2 — Timing-safe: secrets.compare_digest is imported and used +# --------------------------------------------------------------------------- + + +def test_middleware_imports_secrets_module(): + """middleware.py должен импортировать модуль secrets.""" + source = (_BACKEND_DIR / "middleware.py").read_text(encoding="utf-8") + assert "import secrets" in source, ( + "middleware.py must import the 'secrets' module" + ) + + +def test_middleware_uses_compare_digest(): + """middleware.py должен использовать secrets.compare_digest для сравнения токенов.""" + source = (_BACKEND_DIR / "middleware.py").read_text(encoding="utf-8") + assert "secrets.compare_digest" in source, ( + "middleware.py must use secrets.compare_digest — timing-safe comparison required" + ) + + +def test_middleware_no_equality_operator_on_token(): + """middleware.py не должен сравнивать токен через == или != (уязвимость к timing-атаке).""" + source = (_BACKEND_DIR / "middleware.py").read_text(encoding="utf-8") + tree = ast.parse(source, filename="middleware.py") + + unsafe_comparisons = [] + for node in ast.walk(tree): + if isinstance(node, ast.Compare): + for op in node.ops: + if isinstance(op, (ast.Eq, ast.NotEq)): + all_nodes = [node.left] + node.comparators + node_texts = [ast.unparse(n) for n in all_nodes] + if any( + kw in txt.lower() + for txt in node_texts + for kw in ("secret", "token", "webhook") + ): + unsafe_comparisons.append(ast.unparse(node)) + + assert unsafe_comparisons == [], ( + "Timing-unsafe token comparisons found in middleware.py " + f"(use secrets.compare_digest instead): {unsafe_comparisons}" + ) + + +# --------------------------------------------------------------------------- +# Criterion 2 — Timing-safe: functional verification +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_webhook_correct_token_returns_200_with_compare_digest(): + """Правильный токен webhook возвращает 200 (timing-safe путь).""" + async with make_app_client() as client: + resp = await client.post( + "/api/webhook/telegram", + json=_SAMPLE_UPDATE, + headers={"X-Telegram-Bot-Api-Secret-Token": CORRECT_SECRET}, + ) + assert resp.status_code == 200 + + +@pytest.mark.asyncio +async def test_webhook_wrong_token_returns_403_with_compare_digest(): + """Неверный токен webhook возвращает 403 (timing-safe путь).""" + async with make_app_client() as client: + resp = await client.post( + "/api/webhook/telegram", + json=_SAMPLE_UPDATE, + headers={"X-Telegram-Bot-Api-Secret-Token": "wrong-token-x9k2"}, + ) + assert resp.status_code == 403 + + +@pytest.mark.asyncio +async def test_webhook_missing_token_returns_403_with_compare_digest(): + """Отсутствующий токен webhook возвращает 403 (timing-safe путь).""" + async with make_app_client() as client: + resp = await client.post( + "/api/webhook/telegram", + json=_SAMPLE_UPDATE, + ) + assert resp.status_code == 403 diff --git a/tests/test_arch_012.py b/tests/test_arch_012.py new file mode 100644 index 0000000..e4e0890 --- /dev/null +++ b/tests/test_arch_012.py @@ -0,0 +1,61 @@ +""" +Tests for BATON-ARCH-012: WEBHOOK_ENABLED flag for local development. + +Acceptance criteria: +1. When WEBHOOK_ENABLED=False — set_webhook is NOT called during lifespan startup + (mock set_webhook.call_count == 0). +2. When WEBHOOK_ENABLED=True (default) — set_webhook IS called exactly once. +""" +from __future__ import annotations + +import os + +os.environ.setdefault("BOT_TOKEN", "test-bot-token") +os.environ.setdefault("CHAT_ID", "-1001234567890") +os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret") +os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram") +os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000") + +from unittest.mock import AsyncMock, patch + +import pytest + +from tests.conftest import temp_db + + +# --------------------------------------------------------------------------- +# Criterion 1 — WEBHOOK_ENABLED=False: set_webhook must NOT be called +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_lifespan_webhook_disabled_set_webhook_not_called(): + """При WEBHOOK_ENABLED=False вызов set_webhook не должен происходить (call_count == 0).""" + from backend.main import app + + with temp_db(): + with patch("backend.telegram.set_webhook", new_callable=AsyncMock) as mock_set_webhook: + with patch("backend.config.WEBHOOK_ENABLED", False): + async with app.router.lifespan_context(app): + pass + + assert mock_set_webhook.call_count == 0 + + +# --------------------------------------------------------------------------- +# Criterion 2 — WEBHOOK_ENABLED=True (default): set_webhook must be called +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_lifespan_webhook_enabled_set_webhook_called_once(): + """При WEBHOOK_ENABLED=True вызов set_webhook должен произойти ровно один раз.""" + from backend.main import app + + with temp_db(): + with patch("backend.telegram.set_webhook", new_callable=AsyncMock) as mock_set_webhook: + with patch("backend.config.WEBHOOK_ENABLED", True): + async with app.router.lifespan_context(app): + pass + + assert mock_set_webhook.call_count == 1