kin: BATON-ARCH-012 Добавить WEBHOOK_ENABLED флаг для локальной разработки

This commit is contained in:
Gros Frumos 2026-03-20 21:03:45 +02:00
parent 69d01ac3a6
commit 0f8ecdfc49
5 changed files with 434 additions and 0 deletions

131
README.md Normal file
View file

@ -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": <unix_ts>}` |
| `POST` | `/api/register` | Регистрация пользователя |
| `POST` | `/api/signal` | Отправка экстренного сигнала |
| `POST` | `/api/webhook/telegram` | Telegram webhook |
## Hosting & Keep-Alive
### Проблема cold start
На бесплатных хостингах (Render, fly.io free tier, Railway и подобных) приложение **засыпает** после периода неактивности (обычно 1530 минут). Следующий входящий запрос ждёт пока процесс поднимется заново — **cold start занимает 35 секунд**. Для экстренного приложения это критично.
### Решения по вариантам хостинга
| Вариант | Стоимость | Cold start | Рекомендация |
|---|---|---|---|
| **fly.io Hobby** | $5/мес | Нет (всегда активен) | Оптимально для прода |
| **fly.io free tier** | Бесплатно | 35 сек | Только для разработки |
| **Render free** | Бесплатно | 35 сек | Только для разработки |
| **Самохостинг (VPS)** | От $35/мес | Нет | Полный контроль |
> **Финальный выбор хостинга зависит от решения по 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
```

View file

@ -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

View file

@ -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)

204
tests/test_arch_003.py Normal file
View file

@ -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

61
tests/test_arch_012.py Normal file
View file

@ -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