Compare commits
2
Commits
bb5f2a1121
...
03834c6855
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03834c6855 | ||
|
|
cff48f7cc7 |
@@ -14,6 +14,9 @@ APP_ENV=development
|
|||||||
# ─── ПУБЛИКАЦИЯ ЧЕРЕЗ ДОМЕН (VPS-туннель) ─────────────────────────────────────
|
# ─── ПУБЛИКАЦИЯ ЧЕРЕЗ ДОМЕН (VPS-туннель) ─────────────────────────────────────
|
||||||
# LOCAL_PUBLIC — только для DEV на твоём ПК: local = приложение лишь на localhost;
|
# LOCAL_PUBLIC — только для DEV на твоём ПК: local = приложение лишь на localhost;
|
||||||
# vps = лаунчер (run.ps1) дополнительно поднимает SSH-туннель → дев на forbidden-stars.ru.
|
# vps = лаунчер (run.ps1) дополнительно поднимает SSH-туннель → дев на forbidden-stars.ru.
|
||||||
|
# Тогда любому посетителю домена открыты dev-инструменты: вход по нику без пароля,
|
||||||
|
# список/создание игроков, жёсткое удаление аккаунтов, Swagger. Дефолтные SECRET_KEY
|
||||||
|
# и ADMIN_PASSWORD при vps не дают стартовать — задайте свои (#69).
|
||||||
# PROD выставляет себя сам через туннель-КОНТЕЙНЕР (docker-compose*.yml) — ему
|
# PROD выставляет себя сам через туннель-КОНТЕЙНЕР (docker-compose*.yml) — ему
|
||||||
# LOCAL_PUBLIC не нужен, но VPS_TUNNEL_HOST/USER ниже он тоже читает.
|
# LOCAL_PUBLIC не нужен, но VPS_TUNNEL_HOST/USER ниже он тоже читает.
|
||||||
LOCAL_PUBLIC=local
|
LOCAL_PUBLIC=local
|
||||||
@@ -54,6 +57,7 @@ PUBLIC_BASE_URL=
|
|||||||
# Сгенерировать: python -c "import secrets;print(secrets.token_urlsafe(48))"
|
# Сгенерировать: python -c "import secrets;print(secrets.token_urlsafe(48))"
|
||||||
# ВАЖНО: в секретах НЕ используйте символ '$' — docker compose трактует его как
|
# ВАЖНО: в секретах НЕ используйте символ '$' — docker compose трактует его как
|
||||||
# подстановку переменной (token_urlsafe даёт только [A-Za-z0-9_-], это безопасно).
|
# подстановку переменной (token_urlsafe даёт только [A-Za-z0-9_-], это безопасно).
|
||||||
|
# У dev и prod ключи должны быть РАЗНЫМИ: иначе токен, подписанный на dev, примет прод.
|
||||||
SECRET_KEY=change-me-dev-secret-not-for-production
|
SECRET_KEY=change-me-dev-secret-not-for-production
|
||||||
JWT_ALGORITHM=HS256
|
JWT_ALGORITHM=HS256
|
||||||
JWT_USER_TTL_MINUTES=10080
|
JWT_USER_TTL_MINUTES=10080
|
||||||
|
|||||||
+26
-13
@@ -12,8 +12,8 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
|||||||
# (исключён из образа) — там настройки приходят переменными от docker compose.
|
# (исключён из образа) — там настройки приходят переменными от docker compose.
|
||||||
_ROOT_ENV = str(Path(__file__).resolve().parents[3] / ".env")
|
_ROOT_ENV = str(Path(__file__).resolve().parents[3] / ".env")
|
||||||
|
|
||||||
# Небезопасные значения по умолчанию (годятся только для dev). В production приложение
|
# Небезопасные значения по умолчанию (годятся только для dev на localhost). Опубликованное
|
||||||
# с ними не стартует — см. валидатор _forbid_default_secrets_in_prod (#59).
|
# приложение с ними не стартует — см. валидатор _forbid_default_secrets_when_published.
|
||||||
_DEFAULT_SECRET_KEY = "change-me-dev-secret-not-for-production"
|
_DEFAULT_SECRET_KEY = "change-me-dev-secret-not-for-production"
|
||||||
_DEFAULT_ADMIN_PASSWORD = "change-me-admin-password"
|
_DEFAULT_ADMIN_PASSWORD = "change-me-admin-password"
|
||||||
_MIN_SECRET_KEY_LENGTH = 32
|
_MIN_SECRET_KEY_LENGTH = 32
|
||||||
@@ -38,7 +38,8 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# Публикация локального dev-окружения наружу через VPS-туннель.
|
# Публикация локального dev-окружения наружу через VPS-туннель.
|
||||||
# Читает ЛАУНЧЕР (run.ps1/run.sh): local — только localhost; vps — плюс SSH-туннель
|
# Читает ЛАУНЧЕР (run.ps1/run.sh): local — только localhost; vps — плюс SSH-туннель
|
||||||
# на forbidden-stars.ru. Влияет на cookie_secure (vps ⇒ снаружи HTTPS ⇒ Secure-cookie).
|
# на forbidden-stars.ru. Приложению значение говорит, опубликовано ли оно (is_published):
|
||||||
|
# от этого зависят Secure-cookie и проверка секретов при старте.
|
||||||
local_public: str = "local"
|
local_public: str = "local"
|
||||||
|
|
||||||
# Часовой пояс приложения (фиксированное смещение, по умолчанию МСК +3).
|
# Часовой пояс приложения (фиксированное смещение, по умолчанию МСК +3).
|
||||||
@@ -111,11 +112,18 @@ class Settings(BaseSettings):
|
|||||||
return self.dev_achievements_dir if self.is_development else self.prod_achievements_dir
|
return self.dev_achievements_dir if self.is_development else self.prod_achievements_dir
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cookie_secure(self) -> bool:
|
def is_published(self) -> bool:
|
||||||
"""Secure-cookie нужен везде, где снаружи HTTPS (домен). Исключение —
|
"""Приложение доступно снаружи по домену: production или dev, выставленный через
|
||||||
нативный dev на localhost по HTTP (development + local_public=local)."""
|
VPS-туннель. Не опубликован только нативный dev на localhost
|
||||||
|
(development + local_public=local)."""
|
||||||
return not (self.is_development and self.local_public.lower() == "local")
|
return not (self.is_development and self.local_public.lower() == "local")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cookie_secure(self) -> bool:
|
||||||
|
"""Secure-cookie нужен везде, где снаружи HTTPS (домен), — у опубликованного
|
||||||
|
приложения. На localhost по HTTP браузер Secure-cookie не вернул бы."""
|
||||||
|
return self.is_published
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cookie_domain_value(self) -> str | None:
|
def cookie_domain_value(self) -> str | None:
|
||||||
return self.cookie_domain or None
|
return self.cookie_domain or None
|
||||||
@@ -132,13 +140,13 @@ class Settings(BaseSettings):
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def _forbid_default_secrets_in_prod(self) -> "Settings":
|
def _forbid_default_secrets_when_published(self) -> "Settings":
|
||||||
"""Fail-fast: в production не стартуем с дефолтными/слабыми секретами (#59).
|
"""Fail-fast: опубликованное приложение не стартует с дефолтными/слабыми
|
||||||
|
секретами (#59, #69) — и прод, и dev, выставленный на домен (LOCAL_PUBLIC=vps).
|
||||||
|
|
||||||
Деплой, скопировавший .env.example дословно (или забывший поле), иначе поднялся бы
|
Иначе снаружи оказались бы общеизвестный ключ подписи JWT (подделка любого токена,
|
||||||
с общеизвестным ключом подписи JWT (подделка любого токена, включая админский) и
|
включая админский) и известный пароль администратора. На localhost дефолты — норма."""
|
||||||
известным паролем администратора. В dev проверка не мешает — там дефолты норма."""
|
if not self.is_published:
|
||||||
if self.app_env.lower() != "production":
|
|
||||||
return self
|
return self
|
||||||
problems: list[str] = []
|
problems: list[str] = []
|
||||||
if self.secret_key == _DEFAULT_SECRET_KEY or len(self.secret_key) < _MIN_SECRET_KEY_LENGTH:
|
if self.secret_key == _DEFAULT_SECRET_KEY or len(self.secret_key) < _MIN_SECRET_KEY_LENGTH:
|
||||||
@@ -150,8 +158,13 @@ class Settings(BaseSettings):
|
|||||||
if not password or password == _DEFAULT_ADMIN_PASSWORD:
|
if not password or password == _DEFAULT_ADMIN_PASSWORD:
|
||||||
problems.append("ADMIN_PASSWORD не задан или дефолтный")
|
problems.append("ADMIN_PASSWORD не задан или дефолтный")
|
||||||
if problems:
|
if problems:
|
||||||
|
where = (
|
||||||
|
"production"
|
||||||
|
if self.is_production
|
||||||
|
else f"dev, опубликованного наружу (LOCAL_PUBLIC={self.local_public})"
|
||||||
|
)
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"Небезопасная конфигурация production — задайте секреты в .env: "
|
f"Небезопасная конфигурация {where} — задайте секреты в .env: "
|
||||||
+ "; ".join(problems)
|
+ "; ".join(problems)
|
||||||
)
|
)
|
||||||
return self
|
return self
|
||||||
|
|||||||
@@ -132,6 +132,16 @@ async def _lifespan(_app: FastAPI):
|
|||||||
|
|
||||||
hub.bind_loop(asyncio.get_running_loop())
|
hub.bind_loop(asyncio.get_running_loop())
|
||||||
|
|
||||||
|
if settings.is_development and settings.is_published:
|
||||||
|
# Решение владельца (#69): dev-инструменты остаются и на опубликованном dev —
|
||||||
|
# но о том, что они открыты любому посетителю домена, нужно сказать громко.
|
||||||
|
logging.getLogger("fs").warning(
|
||||||
|
"DEV ОПУБЛИКОВАН НАРУЖУ (LOCAL_PUBLIC=%s): любому посетителю домена открыты "
|
||||||
|
"вход по нику без пароля, список и создание игроков, жёсткое удаление аккаунтов "
|
||||||
|
"и Swagger. Не держите в dev-базе копию прод-данных.",
|
||||||
|
settings.local_public,
|
||||||
|
)
|
||||||
|
|
||||||
# В DEV приложение само подтягивает справочники и админа из .env при старте
|
# В DEV приложение само подтягивает справочники и админа из .env при старте
|
||||||
# (в prod это делает entrypoint.sh; в pytest отключено FS_STARTUP_BOOTSTRAP=0).
|
# (в prod это делает entrypoint.sh; в pytest отключено FS_STARTUP_BOOTSTRAP=0).
|
||||||
if settings.is_development and os.getenv("FS_STARTUP_BOOTSTRAP", "1") != "0":
|
if settings.is_development and os.getenv("FS_STARTUP_BOOTSTRAP", "1") != "0":
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
"""Fail-fast конфигурации: production не стартует с дефолтными секретами (#59, F4)."""
|
"""Fail-fast конфигурации: опубликованное приложение (production и dev на домене) не
|
||||||
|
стартует с дефолтными секретами (#59, F4, #69)."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
@@ -61,10 +64,70 @@ def test_production_skips_admin_check_when_bootstrap_disabled():
|
|||||||
def test_development_allows_defaults():
|
def test_development_allows_defaults():
|
||||||
s = config.Settings(
|
s = config.Settings(
|
||||||
app_env="development",
|
app_env="development",
|
||||||
|
local_public="local",
|
||||||
secret_key=config._DEFAULT_SECRET_KEY,
|
secret_key=config._DEFAULT_SECRET_KEY,
|
||||||
admin_password=config._DEFAULT_ADMIN_PASSWORD,
|
admin_password=config._DEFAULT_ADMIN_PASSWORD,
|
||||||
)
|
)
|
||||||
assert s.is_development
|
assert s.is_development
|
||||||
|
assert not s.is_published and not s.cookie_secure
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Dev, опубликованный на домен (LOCAL_PUBLIC=vps, #69) ─────────────────────
|
||||||
|
# Снаружи он так же доступен, как прод: общеизвестный ключ JWT и пароль админа там
|
||||||
|
# открывают админку и подделку любого токена.
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"secret_key,admin_password",
|
||||||
|
[
|
||||||
|
(config._DEFAULT_SECRET_KEY, _STRONG_ADMIN_PW),
|
||||||
|
("too-short", _STRONG_ADMIN_PW),
|
||||||
|
(_STRONG_SECRET, config._DEFAULT_ADMIN_PASSWORD),
|
||||||
|
],
|
||||||
|
ids=["default-secret", "short-secret", "default-admin-password"],
|
||||||
|
)
|
||||||
|
def test_published_dev_rejects_weak_secrets(secret_key, admin_password):
|
||||||
|
with pytest.raises(ValidationError, match="LOCAL_PUBLIC=vps"):
|
||||||
|
config.Settings(
|
||||||
|
app_env="development",
|
||||||
|
local_public="vps",
|
||||||
|
secret_key=secret_key,
|
||||||
|
admin_password=admin_password,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_published_dev_accepts_strong_secrets():
|
||||||
|
s = config.Settings(
|
||||||
|
app_env="development",
|
||||||
|
local_public="vps",
|
||||||
|
secret_key=_STRONG_SECRET,
|
||||||
|
admin_password=_STRONG_ADMIN_PW,
|
||||||
|
)
|
||||||
|
assert s.is_development and s.is_published and s.cookie_secure
|
||||||
|
|
||||||
|
|
||||||
|
def test_published_dev_warns_on_startup(monkeypatch, caplog):
|
||||||
|
"""Dev-инструменты на опубликованном dev остаются (решение владельца) — но старт
|
||||||
|
громко перечисляет, что открыто любому посетителю домена."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app import main
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.settings, "local_public", "vps")
|
||||||
|
with caplog.at_level(logging.WARNING, logger="fs"), TestClient(main.create_app()):
|
||||||
|
pass
|
||||||
|
assert "DEV ОПУБЛИКОВАН НАРУЖУ" in caplog.text
|
||||||
|
assert "вход по нику без пароля" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_dev_starts_quietly(caplog):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app import main
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING, logger="fs"), TestClient(main.create_app()):
|
||||||
|
pass
|
||||||
|
assert "DEV ОПУБЛИКОВАН НАРУЖУ" not in caplog.text
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("app_env", ["test", "staging", ""])
|
@pytest.mark.parametrize("app_env", ["test", "staging", ""])
|
||||||
|
|||||||
@@ -128,6 +128,9 @@ switch ($appEnv) {
|
|||||||
if ($localPublic -eq "vps") {
|
if ($localPublic -eq "vps") {
|
||||||
Start-VpsTunnel 5173
|
Start-VpsTunnel 5173
|
||||||
Write-Host " Public: https://forbidden-stars.ru" -ForegroundColor Green
|
Write-Host " Public: https://forbidden-stars.ru" -ForegroundColor Green
|
||||||
|
Write-Host " WARNING: dev is public. Anyone can log in by nickname without a password," -ForegroundColor Yellow
|
||||||
|
Write-Host " list/create players, hard-delete accounts and read Swagger." -ForegroundColor Yellow
|
||||||
|
Write-Host " Do not keep a copy of production data in the dev database." -ForegroundColor Yellow
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"production" {
|
"production" {
|
||||||
|
|||||||
@@ -75,7 +75,12 @@ case "$app_env" in
|
|||||||
trap 'kill "$back" 2>/dev/null || true' EXIT INT TERM
|
trap 'kill "$back" 2>/dev/null || true' EXIT INT TERM
|
||||||
echo " Бэк: http://127.0.0.1:8000 (Swagger: /api/docs)"
|
echo " Бэк: http://127.0.0.1:8000 (Swagger: /api/docs)"
|
||||||
echo " Фронт: http://127.0.0.1:5173"
|
echo " Фронт: http://127.0.0.1:5173"
|
||||||
[ "$local_public" = "vps" ] && start_tunnel 5173
|
if [ "$local_public" = "vps" ]; then
|
||||||
|
start_tunnel 5173
|
||||||
|
echo " ВНИМАНИЕ: dev опубликован. Любой посетитель может войти по нику без пароля,"
|
||||||
|
echo " смотреть и создавать игроков, жёстко удалять аккаунты и читать Swagger."
|
||||||
|
echo " Не держите в dev-базе копию прод-данных."
|
||||||
|
fi
|
||||||
( cd "$root/frontend" && npm run dev )
|
( cd "$root/frontend" && npm run dev )
|
||||||
;;
|
;;
|
||||||
production)
|
production)
|
||||||
|
|||||||
Reference in New Issue
Block a user