diff --git a/.env.example b/.env.example index 79a9a98..940b8b1 100644 --- a/.env.example +++ b/.env.example @@ -10,14 +10,16 @@ # Database DATABASE_URL=postgresql+psycopg://gntodo:gntodo@localhost:5432/gntodo -# Ollama (M2) -OLLAMA_BASE_URL=http://localhost:11434 +# Ollama (M2): внешний сервер (в compose контейнера ollama нет) +OLLAMA_BASE_URL=http://192.168.1.130:11434 OLLAMA_MODEL=qwen3.5:2b-q4_K_M # MCP-сервер (M5): bearer-токен для ИИ-агентов; пусто — /mcp закрыт MCP_TOKEN=change-me-mcp-token -# docker-compose (M7) +# docker-compose (M7): TLS терминирует внешний reverse-proxy — +# он пробрасывает трафик на WEB_PORT (контейнер web слушает :80); +# на нём же client_max_body_size >= 12m и без буферизации SSE (/api/, /mcp/) POSTGRES_USER=gntodo POSTGRES_PASSWORD=change-me POSTGRES_DB=gntodo diff --git a/README.md b/README.md index ed0805c..7d63303 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,27 @@ npm run dev # http://localhost:15134, /api и /auth проксируются на :8134 ``` -Прод-сборка: `docker compose up -d --build` (настройки — `.env`, шаблон `.env.example`). +## Деплой (Docker Compose) + +Состав стека: `db` (PostgreSQL 17) + `api` (FastAPI, миграции применяются при старте +контейнера) + `web` (nginx: SPA-статика, прокси `/api/`, `/auth/`, `/mcp/` на api). +TLS терминирует **внешний reverse-proxy** на VPS — контейнер `web` слушает :80. + +1. На сервере: `cp .env.example .env` и заполнить: + - SSO-креды (`GAUTH_CLIENT_ID/SECRET`), `GAUTH_REDIRECT_URI=https://<домен>/auth/callback` + — адрес callback регистрируется в auth.gnexus.space; + - `SESSION_SECRET` (произвольная длинная строка) и `MCP_TOKEN` (пусто — `/mcp` закрыт); + - `OLLAMA_BASE_URL` внешнего сервера Ollama (контейнера ollama в compose нет) + и `OLLAMA_MODEL` (по умолчанию `qwen3.5:2b-q4_K_M`). +2. Запуск: `docker compose up -d --build`. +3. На внешнем reverse-proxy, который пробрасывает на `WEB_PORT` (по умолчанию 8135): + - `client_max_body_size 12m` или больше (вложения до 10 МБ); + - `/api/` и `/mcp/` — без буферизации (backend шлёт `X-Accel-Buffering: no`, + nginx его уважает; на других прокси отключить буферизацию явно) — иначе + SSE-реактивность и MCP-агенты застрянут. + +Заметка: API работает в 1 воркер uvicorn — это осознанно (PKCE/state у gnexus-gauth +хранятся в памяти процесса; несколько воркеров сломают OAuth-флоу). ## Вехи diff --git a/backend/app/mcp_server.py b/backend/app/mcp_server.py index 7a290ef..c331953 100644 --- a/backend/app/mcp_server.py +++ b/backend/app/mcp_server.py @@ -11,11 +11,14 @@ import threading from datetime import date from typing import Annotated, Any, cast +from urllib.parse import urlparse from mcp.server.fastmcp import FastMCP +from mcp.server.transport_security import TransportSecuritySettings from pydantic import Field from sqlalchemy import and_, func, select +from app.config import get_settings from app.db import get_session_factory from app.models import Attachment, CoinEvent, Document, Project, Tag, Task, utcnow from app.realtime import publish @@ -56,9 +59,43 @@ по её id. """ + +def _transport_security() -> TransportSecuritySettings: + """Разрешённый Host для MCP — домен приложения, из redirect URI SSO. + + Дефолт FastMCP пропускает только localhost: в dev это работает + (vite-прокси переписывает Host на localhost:8134), а в проде за nginx + Host — реальный домен, и /mcp отвечал бы 421 Misdirected Request. + nginx $host отдаёт Host без порта — разрешаем обе формы; Origin + перечисляем для https/http (браузерные клиенты; у агентов Origin нет). + """ + host = urlparse(get_settings().gauth_redirect_uri).hostname or "" + if host in ("127.0.0.1", "localhost", "::1"): + return TransportSecuritySettings( + enable_dns_rebinding_protection=True, + allowed_hosts=["127.0.0.1:*", "localhost:*", "[::1]:*"], + allowed_origins=["http://127.0.0.1:*", "http://localhost:*", "http://[::1]:*"], + ) + return TransportSecuritySettings( + enable_dns_rebinding_protection=True, + allowed_hosts=[host, f"{host}:*"], + allowed_origins=[ + f"https://{host}", + f"https://{host}:*", + f"http://{host}", + f"http://{host}:*", + ], + ) + + # streamable_http_path="/" — чтобы под /mcp основного приложения маршрут # FastMCP не дублировался (иначе он оказывается на /mcp/mcp) -mcp = FastMCP("gntodo", streamable_http_path="/", instructions=INSTRUCTIONS) +mcp = FastMCP( + "gntodo", + streamable_http_path="/", + instructions=INSTRUCTIONS, + transport_security=_transport_security(), +) def _compact(task: Task) -> dict[str, Any]: diff --git a/docker-compose.yml b/docker-compose.yml index 8b3610e..73b657a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,11 @@ -# M7: упаковка в docker-compose — API, PostgreSQL, фронт (nginx), Ollama + модель. +# M7: упаковка в docker-compose — API, PostgreSQL, фронт (nginx). +# TLS терминирует внешний reverse-proxy на VPS (контейнер web — :80 за ним). +# Ollama — внешний сервер, адрес в .env (OLLAMA_BASE_URL), модель — OLLAMA_MODEL. # Настройки читаются из .env в корне репо (шаблон — .env.example). services: db: image: postgres:17-alpine + restart: unless-stopped environment: POSTGRES_USER: ${POSTGRES_USER:-gntodo} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-gntodo} @@ -15,35 +18,12 @@ timeout: 3s retries: 12 - ollama: - image: ollama/ollama:latest - volumes: - - ollama:/root/.ollama - healthcheck: - test: ["CMD-SHELL", "ollama ls >/dev/null 2>&1"] - interval: 10s - timeout: 5s - retries: 12 - - # Одноразовая загрузка модели (idempotent: pull обновляет, если уже есть) - ollama-init: - image: ollama/ollama:latest - depends_on: - ollama: - condition: service_healthy - environment: - OLLAMA_HOST: http://ollama:11434 - command: ["ollama", "pull", "${OLLAMA_MODEL:-qwen2.5:3b}"] - restart: "no" - api: build: ./backend restart: unless-stopped depends_on: db: condition: service_healthy - ollama-init: - condition: service_completed_successfully environment: DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-gntodo}:${POSTGRES_PASSWORD:-gntodo}@db:5432/${POSTGRES_DB:-gntodo} GAUTH_BASE_URL: ${GAUTH_BASE_URL} @@ -51,14 +31,21 @@ GAUTH_CLIENT_SECRET: ${GAUTH_CLIENT_SECRET} GAUTH_REDIRECT_URI: ${GAUTH_REDIRECT_URI} SESSION_SECRET: ${SESSION_SECRET} - OLLAMA_BASE_URL: http://ollama:11434 - OLLAMA_MODEL: ${OLLAMA_MODEL:-qwen2.5:3b} + OLLAMA_BASE_URL: ${OLLAMA_BASE_URL} + OLLAMA_MODEL: ${OLLAMA_MODEL:-qwen3.5:2b-q4_K_M} MCP_TOKEN: ${MCP_TOKEN:-} ATTACHMENTS_PATH: /data/attachments volumes: - attachments:/data/attachments expose: - "8134" + healthcheck: + # CMD контейнера сначала гоняет миграции — даём запас на старт + test: ["CMD", "python", "-c", "import urllib.request as u; u.urlopen('http://localhost:8134/api/health')"] + interval: 15s + timeout: 5s + retries: 5 + start_period: 30s web: build: ./frontend @@ -70,5 +57,4 @@ volumes: pgdata: - attachments: - ollama: \ No newline at end of file + attachments: \ No newline at end of file diff --git a/docs/TZ.md b/docs/TZ.md index 0d6a83c..e37d6eb 100644 --- a/docs/TZ.md +++ b/docs/TZ.md @@ -4,7 +4,7 @@ | | | |---|---| -| Версия ТЗ | 0.32 | +| Версия ТЗ | 0.33 | | Дата | 2026-09-21 | | Статус | На обсуждении | @@ -25,7 +25,8 @@ ### 1.3. Развёртывание - Собственный сервер (VPS), доступ из интернета. -- HTTPS, реальный домен. +- HTTPS, реальный домен; TLS терминирует внешний reverse-proxy VPS — + docker-compose (web-контейнер) слушает :80 за ним. - Данные хранятся только на своём сервере. ## 2. Сценарии использования @@ -418,8 +419,8 @@ | СУБД | PostgreSQL (рекомендация для VPS) | | Авторизация | SSO через **auth.gnexus.space**, библиотека **gnexus-gauth** (`https://git.gnexus.space/git/root/gnexus-auth-client-py.git`) | | Хостинг | VPS, HTTPS | -| AI | **Ollama** на сервере; модель задана конфигом | -| Упаковка | В итоге всё пакуется в **Docker** (docker-compose: API, PostgreSQL, фронт, Ollama + модель) | +| AI | **Ollama** на внешнем сервере (адрес и модель — конфигом, `OLLAMA_BASE_URL`/`OLLAMA_MODEL`); контейнера ollama в compose нет | +| Упаковка | В итоге всё пакуется в **Docker** (docker-compose: API, PostgreSQL, фронт; TLS — внешний reverse-proxy) | | Мобильность | На старте — **только PWA**; Android-приложение — вне скоупа стартовой версии (вернуться к нему позже) | | Приватность | Все данные и LLM-вызовы — на собственном сервере | | Реактивность | SSE (`/api/events`): грубозернистые события + refetch, см. 3.14 | @@ -526,7 +527,7 @@ 2. ~~**Прогнозирование времени**~~ — **решено 2026-09-19**: оценка длительности приходит из LLM-детализации (или задаётся вручную); точность не требуется — оценка нужна, чтобы отсекать заведомо большие задачи при малом доступном времени. 3. ~~**Оценка затрат к бюджету**~~ — **решено 2026-09-19**: оба поля (бюджет и оценка затрат) — ручные; валюта — глобальная настройка, выбирается один раз и применяется всюду (UAH, USD, EUR, GBP, PLN). 4. **Оффлайн-режим** в PWA: нужен ли, или всегда онлайн? -5. **Модель для Ollama**: какая конкретно модель и параметры (в конфиг, но нужно выбрать для проверки M2). +5. ~~**Модель для Ollama**~~ — **решено 2026-09-21**: по умолчанию `qwen3.5:2b-q4_K_M` (проверена на M2); меняется конфигом (`OLLAMA_MODEL`) без правок кода. Ollama — внешний сервер, адрес — `OLLAMA_BASE_URL`. 6. **Практическое различие `cancelled` / `deferred`**: определения зафиксированы (3.4), но поведение в интерфейсе почти одинаковое — скрыть из активных списков и оставить доступным для возврата. Уточнить при проектировании представлений, нужны ли оба статуса или их поведение сольётся. 7. ~~**Анти-дюп награды за статус**~~ — **решено 2026-09-21**: закрытие (HTTP и MCP) идёт через общий путь; XP — один раз на задачу (grant_task_xp), спавн регулярной — один раз на цепочку (метка `spawned_at`), выход из done сбрасывает `done_at`. Повторные закрытия и обновления закрытой не дублируют награды и экземпляры — покрыто тестами. 8. ~~**Пиксель-арт сцена сада**~~ — **решено 2026-09-20**: сцена переведена на пиксель-арт (PixiJS 8, WebGL); спрайты генерируются из пиксель-матриц в коде (без внешних ассетов и лицензий), см. 3.13. diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 1fa3b76..130e2c1 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -4,11 +4,26 @@ root /usr/share/nginx/html; index index.html; + # Вложения — до 10 МБ на файл (лимит бэка) + запас на multipart-конверт + client_max_body_size 20m; + + # kit.css/phosphor ~4 МБ текстовых ассетов — сжимаем + gzip on; + gzip_types text/css application/javascript application/json image/svg+xml; + gzip_min_length 1024; + # SPA: любой маршрут отдаёт index.html location / { try_files $uri $uri/ /index.html; } + # Хэшированная статика (Vite + ассеты кита) — кэш на год + location /assets/ { + try_files $uri =404; + expires 1y; + add_header Cache-Control "public, immutable"; + } + # SSO-флоу gnexus-gauth проходит через backend location /auth/ { proxy_pass http://api:8134; @@ -24,15 +39,22 @@ proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Connection ""; proxy_buffering off; proxy_read_timeout 3600s; } + # API: /api/events — долгоживущий SSE (ping каждые 20 с), стрим без буферизации location /api/ { proxy_pass http://api:8134; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_buffering off; + proxy_read_timeout 300s; } -} +} \ No newline at end of file