diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..fb6603f --- /dev/null +++ b/.env.production.example @@ -0,0 +1,27 @@ +# DNS: A/AAAA records for both names must point to the deployment server. +SITE_DOMAIN=rf4spotter.ru +FILES_DOMAIN=files.rf4spotter.ru +ACME_EMAIL=admin@rf4spotter.ru + +POSTGRES_DB=rf4_spotter +POSTGRES_USER=rf4 +POSTGRES_PASSWORD=replace-with-long-random-value +# URL-encode special characters from POSTGRES_PASSWORD in this URL. +DATABASE_URL=postgresql+psycopg://rf4:replace-with-url-encoded-password@db:5432/rf4_spotter + +ADMIN_TOKEN=replace-with-at-least-32-random-characters +RATE_LIMIT_SECRET=replace-with-at-least-32-random-characters +ADMIN_BASIC_USER=rf4admin +# Generate with: docker run --rm caddy:2.10.2-alpine caddy hash-password --plaintext 'YOUR PASSWORD' +ADMIN_BASIC_PASSWORD_HASH=replace-with-caddy-password-hash + +S3_ACCESS_KEY=replace-with-random-access-key +S3_SECRET_KEY=replace-with-at-least-32-random-characters +S3_BUCKET=catch-screenshots + +OFFICIAL_RECORDS_URL=https://rf4game.de/records/region/RU/ +OFFICIAL_RECORDS_REGION=RU +OFFICIAL_RECORDS_CATEGORY=records +OFFICIAL_IMPORT_REQUIRED=false +IMPORT_INTERVAL_SECONDS=3600 +LOG_LEVEL=INFO diff --git a/.gitignore b/.gitignore index a8efa7c..01c0688 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ __pycache__/ *.py[cod] .pytest_cache/ .cache/ +.env.production .venv/ node_modules/ dist/ diff --git a/README.md b/README.md index 47b7438..c72bb36 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ RF4 Spotter — неофициальный сервис свежих точек Подробный план и актуальные чекбоксы находятся в [`docs/ROADMAP.md`](docs/ROADMAP.md). Результаты проверки интерфейса и пять приоритетных UX-пакетов описаны в [`docs/UI_UX_AUDIT.md`](docs/UI_UX_AUDIT.md). +Production-контур для домена `rf4spotter.ru`, TLS, секреты и команды первого запуска описаны в [`deploy/README.md`](deploy/README.md). Он использует отдельный `compose.production.yaml`; локальный `compose.yaml` остаётся средой разработки. + Gitea Actions workflow `.gitea/workflows/ci.yml` на каждый push и pull request проверяет Python, миграции на чистой PostgreSQL, Astro build и полный Compose/Playwright-сценарий. При падении E2E сохраняются логи контейнеров и Playwright-артефакты. Актуальная инвентаризация источников и правила подключения адаптеров находятся в [`docs/data-source-audit.md`](docs/data-source-audit.md). Разрешённый технический пилот RF4DB/RF4-STAT описан в [`docs/community-source-pilot.md`](docs/community-source-pilot.md), а статус разрешений и лимитов — в [`docs/data-permissions.md`](docs/data-permissions.md). Данные сохраняются только в промежуточный staging и не влияют на индекс без явной проверки и публикации администратором. diff --git a/apps/api/app/config.py b/apps/api/app/config.py index afa0693..13b95db 100644 --- a/apps/api/app/config.py +++ b/apps/api/app/config.py @@ -1,8 +1,9 @@ -from pydantic import Field +from pydantic import Field, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): + deployment_environment: str = "development" database_url: str = "postgresql+psycopg://rf4:rf4_local@localhost:5432/rf4_spotter" admin_token: str = "change-me-in-production" s3_endpoint_url: str = "http://localhost:9000" @@ -18,7 +19,27 @@ class Settings(BaseSettings): import_interval_seconds: int = Field(default=3600, ge=3600) rate_limit_secret: str = "change-rate-limit-secret" log_level: str = "INFO" + cors_origins: list[str] = Field(default_factory=lambda: ["http://localhost:4321", "http://127.0.0.1:4321"]) model_config = SettingsConfigDict(env_file=".env", extra="ignore") + @model_validator(mode="after") + def reject_insecure_production_defaults(self) -> "Settings": + if self.deployment_environment != "production": + return self + insecure = { + "ADMIN_TOKEN": self.admin_token == "change-me-in-production" or len(self.admin_token) < 32, + "RATE_LIMIT_SECRET": self.rate_limit_secret == "change-rate-limit-secret" or len(self.rate_limit_secret) < 32, + "S3_ACCESS_KEY": self.s3_access_key == "rf4-local" or len(self.s3_access_key) < 12, + "S3_SECRET_KEY": self.s3_secret_key == "rf4-local-secret" or len(self.s3_secret_key) < 32, + } + invalid = [name for name, failed in insecure.items() if failed] + if invalid: + raise ValueError(f"insecure production settings: {', '.join(invalid)}") + if not self.cors_origins or any(not origin.startswith("https://") for origin in self.cors_origins): + raise ValueError("production CORS_ORIGINS must contain only HTTPS origins") + if not self.s3_public_endpoint_url.startswith("https://"): + raise ValueError("production S3_PUBLIC_ENDPOINT_URL must use HTTPS") + return self + settings = Settings() diff --git a/apps/api/app/main.py b/apps/api/app/main.py index 29a3d11..864bd67 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -34,7 +34,7 @@ logger = logging.getLogger("rf4.api") app = FastAPI(title="RF4 Spotter API", version="0.1.0") app.add_middleware( CORSMiddleware, - allow_origins=["http://localhost:4321", "http://127.0.0.1:4321"], + allow_origins=settings.cors_origins, allow_methods=["GET", "POST", "PATCH", "DELETE"], allow_headers=["Authorization", "Content-Type"], ) @@ -50,6 +50,11 @@ async def structured_request_log(request: Request, call_next): response = await call_next(request) status_code = response.status_code response.headers["X-Request-ID"] = request_id + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()" + if settings.deployment_environment == "production": + response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" return response except Exception as exc: logger.error("request failed", extra={"request_id": request_id, "error_type": type(exc).__name__}) diff --git a/apps/api/tests/test_config.py b/apps/api/tests/test_config.py new file mode 100644 index 0000000..a5b45ac --- /dev/null +++ b/apps/api/tests/test_config.py @@ -0,0 +1,36 @@ +import pytest +from pydantic import ValidationError + +from app.config import Settings + + +def production_settings(**changes) -> Settings: + values = { + "deployment_environment": "production", + "admin_token": "a" * 32, + "rate_limit_secret": "r" * 32, + "s3_access_key": "access-key-1234", + "s3_secret_key": "s" * 32, + "s3_public_endpoint_url": "https://files.rf4spotter.ru", + "cors_origins": ["https://rf4spotter.ru"], + } + return Settings(**(values | changes)) + + +def test_production_settings_accept_real_domains_and_secrets() -> None: + settings = production_settings() + + assert settings.cors_origins == ["https://rf4spotter.ru"] + + +@pytest.mark.parametrize(("field", "value"), [ + ("admin_token", "change-me-in-production"), + ("rate_limit_secret", "short"), + ("s3_access_key", "rf4-local"), + ("s3_secret_key", "rf4-local-secret"), + ("cors_origins", ["http://rf4spotter.ru"]), + ("s3_public_endpoint_url", "http://files.rf4spotter.ru"), +]) +def test_production_settings_reject_insecure_values(field: str, value: object) -> None: + with pytest.raises(ValidationError): + production_settings(**{field: value}) diff --git a/compose.production.yaml b/compose.production.yaml new file mode 100644 index 0000000..c36b105 --- /dev/null +++ b/compose.production.yaml @@ -0,0 +1,134 @@ +name: rf4-spotter + +services: + proxy: + image: caddy:2.10.2-alpine + restart: unless-stopped + environment: + SITE_DOMAIN: ${SITE_DOMAIN:?Set SITE_DOMAIN} + FILES_DOMAIN: ${FILES_DOMAIN:?Set FILES_DOMAIN} + ACME_EMAIL: ${ACME_EMAIL:?Set ACME_EMAIL} + ADMIN_BASIC_USER: ${ADMIN_BASIC_USER:?Set ADMIN_BASIC_USER} + ADMIN_BASIC_PASSWORD_HASH: ${ADMIN_BASIC_PASSWORD_HASH:?Set ADMIN_BASIC_PASSWORD_HASH} + ports: + - "80:80" + - "443:443" + - "443:443/udp" + volumes: + - ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + depends_on: + web: + condition: service_healthy + networks: [edge, backend] + security_opt: [no-new-privileges:true] + deploy: + resources: + limits: {cpus: "0.50", memory: 256M} + + db: + image: postgres:17-alpine + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-rf4_spotter} + POSTGRES_USER: ${POSTGRES_USER:-rf4} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-rf4} -d ${POSTGRES_DB:-rf4_spotter}"] + interval: 10s + timeout: 5s + retries: 10 + networks: [backend] + security_opt: [no-new-privileges:true] + deploy: + resources: + limits: {cpus: "1.00", memory: 1G} + + minio: + image: minio/minio:RELEASE.2025-07-23T15-54-02Z + command: server /data + restart: unless-stopped + environment: + MINIO_ROOT_USER: ${S3_ACCESS_KEY:?Set S3_ACCESS_KEY} + MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY:?Set S3_SECRET_KEY} + volumes: + - minio_data:/data + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost:9000/minio/health/live"] + interval: 10s + timeout: 5s + retries: 10 + networks: [backend] + security_opt: [no-new-privileges:true] + deploy: + resources: + limits: {cpus: "0.75", memory: 1G} + + api: + build: + context: . + dockerfile: apps/api/Dockerfile + restart: unless-stopped + environment: + DEPLOYMENT_ENVIRONMENT: production + DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL} + ADMIN_TOKEN: ${ADMIN_TOKEN:?Set ADMIN_TOKEN} + CORS_ORIGINS: '["https://${SITE_DOMAIN:?Set SITE_DOMAIN}"]' + S3_ENDPOINT_URL: http://minio:9000 + S3_PUBLIC_ENDPOINT_URL: https://${FILES_DOMAIN:?Set FILES_DOMAIN} + S3_ACCESS_KEY: ${S3_ACCESS_KEY:?Set S3_ACCESS_KEY} + S3_SECRET_KEY: ${S3_SECRET_KEY:?Set S3_SECRET_KEY} + S3_BUCKET: ${S3_BUCKET:-catch-screenshots} + OFFICIAL_RECORDS_URL: ${OFFICIAL_RECORDS_URL:-https://rf4game.de/records/region/RU/} + OFFICIAL_RECORDS_REGION: ${OFFICIAL_RECORDS_REGION:-RU} + OFFICIAL_RECORDS_CATEGORY: ${OFFICIAL_RECORDS_CATEGORY:-records} + OFFICIAL_IMPORT_REQUIRED: ${OFFICIAL_IMPORT_REQUIRED:-false} + IMPORT_INTERVAL_SECONDS: ${IMPORT_INTERVAL_SECONDS:-3600} + RATE_LIMIT_SECRET: ${RATE_LIMIT_SECRET:?Set RATE_LIMIT_SECRET} + LOG_LEVEL: ${LOG_LEVEL:-INFO} + depends_on: + db: {condition: service_healthy} + minio: {condition: service_healthy} + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/ready')"] + interval: 10s + timeout: 5s + retries: 12 + networks: [backend, edge] + security_opt: [no-new-privileges:true] + deploy: + resources: + limits: {cpus: "1.00", memory: 1G} + + web: + build: ./apps/web + restart: unless-stopped + environment: + PUBLIC_API_URL: https://${SITE_DOMAIN:?Set SITE_DOMAIN} + API_INTERNAL_URL: http://api:8000 + depends_on: + api: {condition: service_healthy} + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://localhost:4321').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"] + interval: 10s + timeout: 5s + retries: 10 + networks: [backend] + security_opt: [no-new-privileges:true] + deploy: + resources: + limits: {cpus: "0.75", memory: 512M} + +networks: + edge: + backend: + internal: true + +volumes: + postgres_data: + minio_data: + caddy_data: + caddy_config: diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 0000000..59987ba --- /dev/null +++ b/deploy/Caddyfile @@ -0,0 +1,44 @@ +{ + email {$ACME_EMAIL} + admin off +} + +{$SITE_DOMAIN} { + encode zstd gzip + + @admin path /admin/* + handle @admin { + basic_auth { + {$ADMIN_BASIC_USER} {$ADMIN_BASIC_PASSWORD_HASH} + } + reverse_proxy web:4321 + } + + @api path /api/* /health /ready + handle @api { + reverse_proxy api:8000 + } + + handle { + reverse_proxy web:4321 + } + + header { + -Server + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Content-Type-Options "nosniff" + Referrer-Policy "strict-origin-when-cross-origin" + Permissions-Policy "camera=(), microphone=(), geolocation=()" + } +} + +{$FILES_DOMAIN} { + encode zstd gzip + reverse_proxy minio:9000 + header { + -Server + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Content-Type-Options "nosniff" + Referrer-Policy "no-referrer" + } +} diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..2fc70ca --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,77 @@ +# Развёртывание закрытой альфы rf4spotter.ru + +Production-контур рассчитан на один Linux-сервер с Docker Compose. Наружу публикуются только Caddy `80/443`; PostgreSQL, FastAPI и MinIO не имеют host-портов. Административные страницы защищены одновременно Caddy Basic Auth и API bearer token. + +## 1. DNS и сервер + +Создайте A-записи `rf4spotter.ru` и `files.rf4spotter.ru` на публичный IPv4 сервера. При наличии рабочего IPv6 добавьте AAAA для обоих имён. До запуска убедитесь, что извне доступны TCP 80/443 и UDP 443; SSH ограничьте своим IP или VPN. Порты 4321, 8000, 9000, 9001 и 5432 открывать нельзя. + +Минимум для закрытой альфы: 2 vCPU, 4 ГБ RAM и 40 ГБ SSD. Рекомендуется 4 vCPU, 8 ГБ RAM и отдельное внешнее место для резервных копий. + +## 2. Секреты + +На сервере: + +```bash +cp .env.production.example .env.production +chmod 600 .env.production +openssl rand -base64 36 # повторить для пароля БД, ADMIN_TOKEN, RATE_LIMIT_SECRET и S3_SECRET_KEY +docker run --rm caddy:2.10.2-alpine caddy hash-password --plaintext 'ОТДЕЛЬНЫЙ ADMIN-ПАРОЛЬ' +``` + +Заполните `.env.production`. Если пароль PostgreSQL содержит специальные символы, в `DATABASE_URL` нужна URL-кодированная форма того же пароля. `.env.production` нельзя коммитить или пересылать вместе с логами. + +## 3. Проверка и первый запуск + +```bash +docker compose --env-file .env.production -f compose.production.yaml config --quiet +docker compose --env-file .env.production -f compose.production.yaml build +docker compose --env-file .env.production -f compose.production.yaml up -d +docker compose --env-file .env.production -f compose.production.yaml ps +curl -fsS https://rf4spotter.ru/health +curl -fsS https://rf4spotter.ru/ready +``` + +API-контейнер перед стартом применяет Alembic-миграции и запускает seed. До альфы демонстрационные уловы следует убрать отдельной задачей; справочники должны остаться. + +Проверка TLS и маршрутизации: + +```bash +curl -I https://rf4spotter.ru/ +curl -I https://files.rf4spotter.ru/minio/health/live +``` + +MinIO health URL допустим для диагностики, но Console наружу не публикуется. Объекты доступны только по временным подписанным ссылкам. + +## 4. Первичные данные + +Официальный импорт запускается вручную после успешного readiness: + +```bash +docker compose --env-file .env.production -f compose.production.yaml exec api python -m app.cli import-records +``` + +Автоматический scheduler не входит в production-файл. RF4MAP и RF4 Posts нельзя опрашивать чаще одного раза в 30 минут; до отдельной эксплуатационной задачи используйте только контролируемые ручные запуски и staging. + +## 5. Обновление + +```bash +git pull --ff-only +docker compose --env-file .env.production -f compose.production.yaml build +docker compose --env-file .env.production -f compose.production.yaml up -d +docker compose --env-file .env.production -f compose.production.yaml ps +curl -fsS https://rf4spotter.ru/ready +``` + +Перед обновлением со сменой схемы обязателен backup PostgreSQL. Не удаляйте volumes и не используйте `down -v`. + +## 6. Что ещё блокирует приглашение альфа-пользователей + +- проверенный backup/restore PostgreSQL и MinIO; +- production-safe seed без демонстрационных уловов; +- одноразовый секрет для повторной загрузки скриншота вместо одного UUID; +- проверка полного bootstrap на пустых volumes; +- политика хранения и удаления пользовательских данных; +- базовый мониторинг `/ready`, диска и срока TLS-сертификата. + +До закрытия этих пунктов контур можно поднять для технической проверки домена, но не следует открывать форму реальным пользователям. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 90375e7..e6b94fc 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -79,7 +79,7 @@ - [ ] Добавить smoke-проверку административной очереди внешних источников на desktop/mobile без публикации реальных записей. - [ ] Обновить README: архитектура, все переменные окружения, импорт, модерация, backup/restore, эксплуатация логов и известные ограничения. - [ ] Выбрать лицензию кода и политику использования данных. -- [ ] Определить production-профиль Compose/развёртывания: TLS/reverse proxy, домены, CORS, volumes, restart policy, resource limits и порядок обновления миграций. +- [ ] Завершить production-профиль для `rf4spotter.ru`: Compose, Caddy/TLS, закрытые внутренние сервисы, CORS, resource limits, fail-fast секреты и runbook добавлены; остаются backup/restore и проверка на целевом сервере. - [ ] Заменить демонстрационные секреты и определить целевое размещение перед внешней публикацией. ## Источники данных и согласование