Compare commits
3
Commits
6e60988ac9
...
c45b4511b7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c45b4511b7 | ||
|
|
687b4c9cb5 | ||
|
|
2fa6b68279 |
@@ -13,10 +13,12 @@ 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
|
||||
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
|
||||
MINIO_ROOT_USER=replace-with-private-root-access-key
|
||||
MINIO_ROOT_PASSWORD=replace-with-private-root-password-32-chars
|
||||
S3_ACCESS_KEY=replace-with-separate-app-access-key
|
||||
S3_SECRET_KEY=replace-with-separate-app-secret-at-least-32-chars
|
||||
S3_BUCKET=catch-screenshots
|
||||
|
||||
OFFICIAL_RECORDS_URL=https://rf4game.de/records/region/RU/
|
||||
|
||||
@@ -3,6 +3,7 @@ __pycache__/
|
||||
.pytest_cache/
|
||||
.cache/
|
||||
.env.production
|
||||
.maintenance.lock
|
||||
.venv/
|
||||
node_modules/
|
||||
dist/
|
||||
|
||||
@@ -19,8 +19,10 @@ Production-контур для домена `rf4spotter.ru`, TLS, секреты
|
||||
Политика минимизации данных и ежедневная dry-run-first очистка описаны в [`docs/data-retention.md`](docs/data-retention.md).
|
||||
|
||||
Host-side мониторинг контейнеров, readiness, диска, резервных копий и TLS описан в [`docs/production-monitoring.md`](docs/production-monitoring.md).
|
||||
Ежедневный systemd timer создаёт проверяемую копию до retention-очистки, а production Compose ограничивает рост JSON-логов контейнеров.
|
||||
|
||||
Фактическое состояние DNS/TLS домена и серверный чек-лист ведутся в [`docs/deployment-status.md`](docs/deployment-status.md).
|
||||
Результаты security review и остаточные ограничения закрытой альфы записаны в [`docs/security-review.md`](docs/security-review.md).
|
||||
|
||||
Gitea Actions workflow `.gitea/workflows/ci.yml` на каждый push и pull request проверяет Python, миграции на чистой PostgreSQL, Astro build и полный Compose/Playwright-сценарий. При падении E2E сохраняются логи контейнеров и Playwright-артефакты.
|
||||
|
||||
@@ -142,7 +144,7 @@ npm run test:e2e
|
||||
docker compose --profile tools run --rm importer
|
||||
```
|
||||
|
||||
Импорт делает до трёх ограниченных попыток, проверяет DOM-контракт и не удаляет ранее сохранённые данные при сбое. Повторный запуск обновляет совпавшие записи по SHA-256 ключу и не создаёт дубликаты. Расписание реализовано, но намеренно не включается обычным запуском: сначала требуется согласовать допустимость регулярного опроса официального сайта.
|
||||
Импорт делает до трёх ограниченных попыток, проверяет DOM-контракт и не удаляет ранее сохранённые данные при сбое. Повторный запуск обновляет совпавшие записи по SHA-256 ключу и не создаёт дубликаты. PostgreSQL advisory lock не допускает параллельный импорт одной source/region/category через admin и scheduler. Расписание реализовано, но намеренно не включается обычным запуском: сначала требуется согласовать допустимость регулярного опроса официального сайта.
|
||||
|
||||
Ручной административный запуск также доступен через `POST /api/v1/admin/imports/official-records`, журнал — через `GET /api/v1/admin/imports`. Импорт сохраняет HTTP-метаданные и использует `ETag`/`Last-Modified`, когда источник их предоставляет.
|
||||
|
||||
|
||||
+4
-2
@@ -3,7 +3,9 @@ WORKDIR /app
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
||||
COPY apps/api/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY apps/api .
|
||||
COPY rf4_research ./rf4_research
|
||||
RUN useradd --create-home --uid 10001 rf4
|
||||
COPY --chown=rf4:rf4 apps/api .
|
||||
COPY --chown=rf4:rf4 rf4_research ./rf4_research
|
||||
USER rf4
|
||||
EXPOSE 8000
|
||||
CMD ["sh", "-c", "alembic upgrade head && python -m app.seed && uvicorn app.main:app --host 0.0.0.0 --port 8000 --no-access-log"]
|
||||
|
||||
@@ -3,11 +3,12 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import re
|
||||
import time as time_module
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import date, datetime, time, timezone
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.orm import Session
|
||||
from rf4_research.official_parser import RecordsContractError, parse_official_records
|
||||
|
||||
@@ -24,6 +25,10 @@ class ImportSourceError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class ImportAlreadyRunning(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RawRecord:
|
||||
region: str
|
||||
@@ -105,7 +110,42 @@ def fetch_records(
|
||||
raise AssertionError("unreachable")
|
||||
|
||||
|
||||
def _lock_key(url: str, region: str, category: str) -> int:
|
||||
digest = hashlib.sha256(f"{url}|{region.upper()}|{category}".encode()).digest()
|
||||
return int.from_bytes(digest[:8], byteorder="big", signed=True)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _official_import_lock(session: Session, *, url: str, region: str, category: str):
|
||||
bind = session.get_bind()
|
||||
if bind.dialect.name != "postgresql":
|
||||
yield
|
||||
return
|
||||
connection = bind.connect()
|
||||
key = _lock_key(url, region, category)
|
||||
try:
|
||||
acquired = bool(connection.scalar(text("SELECT pg_try_advisory_lock(:key)"), {"key": key}))
|
||||
except Exception:
|
||||
connection.close()
|
||||
raise
|
||||
if not acquired:
|
||||
connection.close()
|
||||
raise ImportAlreadyRunning("official import is already running for this source and category")
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
try:
|
||||
connection.execute(text("SELECT pg_advisory_unlock(:key)"), {"key": key})
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def import_records(session: Session, *, url: str, region: str, category: str, html: str | None = None) -> OfficialRecordImport:
|
||||
with _official_import_lock(session, url=url, region=region, category=category):
|
||||
return _import_records_locked(session, url=url, region=region, category=category, html=html)
|
||||
|
||||
|
||||
def _import_records_locked(session: Session, *, url: str, region: str, category: str, html: str | None = None) -> OfficialRecordImport:
|
||||
run = OfficialRecordImport(started_at=datetime.now(timezone.utc), status=ImportStatus.running, source_url=url, rows_seen=0, rows_created=0, rows_updated=0)
|
||||
session.add(run)
|
||||
session.commit()
|
||||
|
||||
@@ -22,7 +22,7 @@ from .activity import activity_rows
|
||||
from .database import get_session
|
||||
from .config import settings
|
||||
from .community_review import ExternalReviewError, map_observation, publish_observation, reject_observation
|
||||
from .importer import ImportSourceError, import_records, normalize
|
||||
from .importer import ImportAlreadyRunning, ImportSourceError, import_records, normalize
|
||||
from .logging_config import configure_logging
|
||||
from .models import Bait, BaitKind, CatchReport, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody
|
||||
from .readiness import readiness_report
|
||||
@@ -54,6 +54,10 @@ async def structured_request_log(request: Request, call_next):
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
response.headers["Cross-Origin-Opener-Policy"] = "same-origin"
|
||||
if request.url.path.startswith("/api/v1/admin/") or request.url.path == "/api/v1/catch-reports":
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
if settings.deployment_environment == "production":
|
||||
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
||||
return response
|
||||
@@ -163,7 +167,8 @@ def records(
|
||||
|
||||
|
||||
def _admin(authorization: Annotated[str | None, Header()] = None) -> str:
|
||||
if not authorization or authorization != f"Bearer {settings.admin_token}":
|
||||
expected = f"Bearer {settings.admin_token}"
|
||||
if not authorization or not hmac.compare_digest(authorization, expected):
|
||||
raise HTTPException(status_code=401, detail="invalid admin token", headers={"WWW-Authenticate": "Bearer"})
|
||||
return "admin"
|
||||
|
||||
@@ -195,6 +200,8 @@ def admin_start_official_import(db: Db, _: Annotated[str, Depends(_admin)]) -> O
|
||||
region=settings.official_records_region,
|
||||
category=settings.official_records_category,
|
||||
)
|
||||
except ImportAlreadyRunning as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except (ImportSourceError, httpx.HTTPError) as exc:
|
||||
raise HTTPException(status_code=502, detail=f"official records import failed: {exc}") from exc
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from .config import settings
|
||||
from .database import SessionLocal
|
||||
from .importer import import_records
|
||||
from .importer import ImportAlreadyRunning, import_records
|
||||
from .logging_config import configure_logging
|
||||
from .models import OfficialRecordImport
|
||||
|
||||
@@ -36,12 +36,16 @@ def run_due_import() -> bool:
|
||||
with SessionLocal() as session:
|
||||
if not import_is_due(session):
|
||||
return False
|
||||
run = import_records(
|
||||
session,
|
||||
url=settings.official_records_url,
|
||||
region=settings.official_records_region,
|
||||
category=settings.official_records_category,
|
||||
)
|
||||
try:
|
||||
run = import_records(
|
||||
session,
|
||||
url=settings.official_records_url,
|
||||
region=settings.official_records_region,
|
||||
category=settings.official_records_category,
|
||||
)
|
||||
except ImportAlreadyRunning:
|
||||
logger.info("official import skipped because it is already running", extra={"event": "official_import_locked"})
|
||||
return False
|
||||
logger.info(
|
||||
"official import completed",
|
||||
extra={
|
||||
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.database import Base, get_session
|
||||
from app.community_importer import stage_observations
|
||||
from app.importer import ImportAlreadyRunning
|
||||
from app.main import app
|
||||
from app.models import Bait, BaitKind, CatchReport, ExternalEntityAlias, ExternalObservation, Fish, ImportStatus, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, Waterbody
|
||||
|
||||
@@ -59,6 +60,8 @@ def test_liveness_does_not_probe_dependencies() -> None:
|
||||
response = client.get("/health?token=must-not-be-logged")
|
||||
assert response.json() == {"status": "ok"}
|
||||
assert len(response.headers["X-Request-ID"]) == 32
|
||||
assert response.headers["X-Frame-Options"] == "DENY"
|
||||
assert response.headers["Cross-Origin-Opener-Policy"] == "same-origin"
|
||||
|
||||
|
||||
def test_spot_detail_and_catches() -> None:
|
||||
@@ -80,11 +83,13 @@ def test_records_list_is_empty_before_import() -> None:
|
||||
def test_user_report_requires_moderation_before_activity() -> None:
|
||||
created = client.post("/api/v1/catch-reports", json={"fish_slug": "pike", "waterbody_slug": "test-lake", "x": 77, "y": 88, "weight_g": 5500, "bait_name": "Новая приманка", "player_name": "Reporter"})
|
||||
assert created.status_code == 201
|
||||
assert created.headers["Cache-Control"] == "no-store"
|
||||
assert created.json()["moderation_status"] == "pending"
|
||||
report_id = created.json()["id"]
|
||||
headers = {"Authorization": "Bearer change-me-in-production"}
|
||||
pending = client.get("/api/v1/admin/catch-reports", headers=headers)
|
||||
assert pending.status_code == 200
|
||||
assert pending.headers["Cache-Control"] == "no-store"
|
||||
assert any(item["id"] == report_id for item in pending.json())
|
||||
approved = client.patch(f"/api/v1/admin/catch-reports/{report_id}", headers=headers, json={"status": "approved", "reason": "fixture verified"})
|
||||
assert approved.status_code == 200
|
||||
@@ -182,6 +187,12 @@ def test_admin_can_start_and_list_official_import(monkeypatch) -> None:
|
||||
listed = client.get("/api/v1/admin/imports?limit=1&offset=0", headers=headers)
|
||||
assert listed.status_code == 200
|
||||
assert listed.json()[0]["id"] == started.json()["id"]
|
||||
def busy_import(*args, **kwargs):
|
||||
raise ImportAlreadyRunning("official import is already running")
|
||||
|
||||
monkeypatch.setattr("app.main.import_records", busy_import)
|
||||
conflict = client.post("/api/v1/admin/imports/official-records", headers=headers)
|
||||
assert conflict.status_code == 409
|
||||
|
||||
|
||||
def test_pending_report_accepts_one_validated_screenshot(monkeypatch) -> None:
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.importer import ImportAlreadyRunning, _official_import_lock
|
||||
|
||||
|
||||
@pytest.mark.skipif(not os.environ.get("DATABASE_URL", "").startswith("postgresql"), reason="requires PostgreSQL")
|
||||
def test_postgresql_import_lock_blocks_only_same_source_category() -> None:
|
||||
engine = create_engine(os.environ["DATABASE_URL"])
|
||||
first = Session(engine)
|
||||
second = Session(engine)
|
||||
try:
|
||||
with _official_import_lock(first, url="https://example.test/records", region="RU", category="records"):
|
||||
with pytest.raises(ImportAlreadyRunning):
|
||||
with _official_import_lock(second, url="https://example.test/records", region="RU", category="records"):
|
||||
pass
|
||||
with _official_import_lock(second, url="https://example.test/records", region="RU", category="weekly"):
|
||||
pass
|
||||
with _official_import_lock(second, url="https://example.test/records", region="RU", category="records"):
|
||||
pass
|
||||
finally:
|
||||
first.close()
|
||||
second.close()
|
||||
engine.dispose()
|
||||
@@ -7,7 +7,7 @@ from sqlalchemy import create_engine, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import Base
|
||||
from app.importer import FetchResult, ImportSourceError, import_records, parse_html
|
||||
from app.importer import FetchResult, ImportAlreadyRunning, ImportSourceError, _lock_key, _official_import_lock, import_records, parse_html
|
||||
from app.models import CatchReport, ImportStatus, OfficialRecordImport, SourceType
|
||||
|
||||
|
||||
@@ -15,6 +15,26 @@ FIXTURE = Path(__file__).parents[3] / "tests" / "fixtures" / "records_ru_sample.
|
||||
WEEKLY_FIXTURE = Path(__file__).parents[3] / "tests" / "fixtures" / "weekly_records_sample.html"
|
||||
|
||||
|
||||
def test_import_lock_is_stable_and_fails_closed_when_busy() -> None:
|
||||
class Connection:
|
||||
def scalar(self, statement, parameters):
|
||||
assert "pg_try_advisory_lock" in str(statement)
|
||||
assert parameters == {"key": _lock_key("https://example.test", "RU", "records")}
|
||||
return False
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
connection = Connection()
|
||||
bind = type("Bind", (), {"dialect": type("Dialect", (), {"name": "postgresql"})(), "connect": lambda self: connection})()
|
||||
session = type("Session", (), {"get_bind": lambda self: bind})()
|
||||
assert _lock_key("https://example.test", "ru", "records") == _lock_key("https://example.test", "RU", "records")
|
||||
with pytest.raises(ImportAlreadyRunning, match="already running"):
|
||||
with _official_import_lock(session, url="https://example.test", region="RU", category="records"):
|
||||
raise AssertionError("busy lock must not enter import")
|
||||
assert connection.closed is True
|
||||
|
||||
|
||||
def test_parser_and_import_are_idempotent() -> None:
|
||||
html = FIXTURE.read_text(encoding="utf-8")
|
||||
parsed = parse_html(html, region="RU", category="records")
|
||||
|
||||
@@ -13,5 +13,7 @@ ENV HOST=0.0.0.0 PORT=4321 NODE_ENV=production
|
||||
COPY --from=build /app/package*.json ./
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/dist ./dist
|
||||
RUN chown -R node:node /app
|
||||
USER node
|
||||
EXPOSE 4321
|
||||
CMD ["node", "./dist/server/entry.mjs"]
|
||||
|
||||
+37
-2
@@ -1,5 +1,11 @@
|
||||
name: rf4-spotter
|
||||
|
||||
x-logging: &default-logging
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "5"
|
||||
|
||||
services:
|
||||
proxy:
|
||||
image: caddy:2.10.2-alpine
|
||||
@@ -23,6 +29,7 @@ services:
|
||||
condition: service_healthy
|
||||
networks: [edge, backend]
|
||||
security_opt: [no-new-privileges:true]
|
||||
logging: *default-logging
|
||||
deploy:
|
||||
resources:
|
||||
limits: {cpus: "0.50", memory: 256M}
|
||||
@@ -43,6 +50,7 @@ services:
|
||||
retries: 10
|
||||
networks: [backend]
|
||||
security_opt: [no-new-privileges:true]
|
||||
logging: *default-logging
|
||||
deploy:
|
||||
resources:
|
||||
limits: {cpus: "1.00", memory: 1G}
|
||||
@@ -52,8 +60,8 @@ services:
|
||||
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}
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:?Set MINIO_ROOT_USER}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?Set MINIO_ROOT_PASSWORD}
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
healthcheck:
|
||||
@@ -63,10 +71,33 @@ services:
|
||||
retries: 10
|
||||
networks: [backend]
|
||||
security_opt: [no-new-privileges:true]
|
||||
logging: *default-logging
|
||||
deploy:
|
||||
resources:
|
||||
limits: {cpus: "0.75", memory: 1G}
|
||||
|
||||
minio-init:
|
||||
image: minio/mc:RELEASE.2025-07-21T05-28-08Z
|
||||
restart: "no"
|
||||
entrypoint: ["/bin/sh", "-c"]
|
||||
command:
|
||||
- >-
|
||||
test "$$MINIO_ROOT_USER" != "$$S3_ACCESS_KEY" &&
|
||||
test "$$MINIO_ROOT_PASSWORD" != "$$S3_SECRET_KEY" &&
|
||||
mc alias set local http://minio:9000 "$$MINIO_ROOT_USER" "$$MINIO_ROOT_PASSWORD" >/dev/null &&
|
||||
(mc admin user info local "$$S3_ACCESS_KEY" >/dev/null 2>&1 || mc admin user add local "$$S3_ACCESS_KEY" "$$S3_SECRET_KEY") &&
|
||||
mc admin policy attach local readwrite --user "$$S3_ACCESS_KEY" >/dev/null
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:?Set MINIO_ROOT_USER}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?Set MINIO_ROOT_PASSWORD}
|
||||
S3_ACCESS_KEY: ${S3_ACCESS_KEY:?Set S3_ACCESS_KEY}
|
||||
S3_SECRET_KEY: ${S3_SECRET_KEY:?Set S3_SECRET_KEY}
|
||||
depends_on:
|
||||
minio: {condition: service_healthy}
|
||||
networks: [backend]
|
||||
security_opt: [no-new-privileges:true]
|
||||
logging: *default-logging
|
||||
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
@@ -99,6 +130,7 @@ services:
|
||||
depends_on:
|
||||
db: {condition: service_healthy}
|
||||
minio: {condition: service_healthy}
|
||||
minio-init: {condition: service_completed_successfully}
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/ready')"]
|
||||
interval: 10s
|
||||
@@ -106,6 +138,7 @@ services:
|
||||
retries: 12
|
||||
networks: [backend, edge]
|
||||
security_opt: [no-new-privileges:true]
|
||||
logging: *default-logging
|
||||
deploy:
|
||||
resources:
|
||||
limits: {cpus: "1.00", memory: 1G}
|
||||
@@ -128,6 +161,7 @@ services:
|
||||
retries: 10
|
||||
networks: [backend]
|
||||
security_opt: [no-new-privileges:true]
|
||||
logging: *default-logging
|
||||
deploy:
|
||||
resources:
|
||||
limits: {cpus: "0.75", memory: 512M}
|
||||
@@ -143,6 +177,7 @@ services:
|
||||
- ${BACKUP_DIRECTORY:-./backups}:/backup
|
||||
networks: [backend]
|
||||
security_opt: [no-new-privileges:true]
|
||||
logging: *default-logging
|
||||
|
||||
networks:
|
||||
edge:
|
||||
|
||||
@@ -14,6 +14,14 @@
|
||||
reverse_proxy web:4321
|
||||
}
|
||||
|
||||
@adminApi path /api/v1/admin/*
|
||||
handle @adminApi {
|
||||
basic_auth {
|
||||
{$ADMIN_BASIC_USER} {$ADMIN_BASIC_PASSWORD_HASH}
|
||||
}
|
||||
reverse_proxy api:8000
|
||||
}
|
||||
|
||||
@api path /api/* /health /ready
|
||||
handle @api {
|
||||
reverse_proxy api:8000
|
||||
@@ -29,6 +37,9 @@
|
||||
X-Content-Type-Options "nosniff"
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
Permissions-Policy "camera=(), microphone=(), geolocation=()"
|
||||
X-Frame-Options "DENY"
|
||||
Cross-Origin-Opener-Policy "same-origin"
|
||||
Content-Security-Policy "frame-ancestors 'none'; base-uri 'self'; object-src 'none'"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,5 +51,7 @@
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||
X-Content-Type-Options "nosniff"
|
||||
Referrer-Policy "no-referrer"
|
||||
X-Frame-Options "DENY"
|
||||
Content-Security-Policy "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox"
|
||||
}
|
||||
}
|
||||
|
||||
+16
-2
@@ -17,11 +17,11 @@ Production-контур рассчитан на один Linux-сервер с D
|
||||
```bash
|
||||
cp .env.production.example .env.production
|
||||
chmod 600 .env.production
|
||||
openssl rand -base64 36 # повторить для пароля БД, ADMIN_TOKEN, RATE_LIMIT_SECRET и S3_SECRET_KEY
|
||||
openssl rand -base64 36 # отдельные значения для БД, ADMIN_TOKEN, RATE_LIMIT_SECRET, MinIO root и S3 app
|
||||
docker run --rm caddy:2.10.2-alpine caddy hash-password --plaintext 'ОТДЕЛЬНЫЙ ADMIN-ПАРОЛЬ'
|
||||
```
|
||||
|
||||
Заполните `.env.production`. Если пароль PostgreSQL содержит специальные символы, в `DATABASE_URL` нужна URL-кодированная форма того же пароля. `.env.production` нельзя коммитить или пересылать вместе с логами.
|
||||
Заполните `.env.production`. Хеш Caddy содержит символы `$`, поэтому значение `ADMIN_BASIC_PASSWORD_HASH` в env-файле заключите в одинарные кавычки. `MINIO_ROOT_*` и `S3_*` обязаны быть разными: init-контейнер проверяет это, API получает только application credentials. Если пароль PostgreSQL содержит специальные символы, в `DATABASE_URL` нужна URL-кодированная форма того же пароля. `.env.production` нельзя коммитить или пересылать вместе с логами.
|
||||
|
||||
## 3. Проверка и первый запуск
|
||||
|
||||
@@ -63,6 +63,8 @@ docker compose --env-file .env.production -f compose.production.yaml exec api py
|
||||
|
||||
Автоматический scheduler не входит в production-файл. RF4MAP и RF4 Posts нельзя опрашивать чаще одного раза в 30 минут; до отдельной эксплуатационной задачи используйте только контролируемые ручные запуски и staging.
|
||||
|
||||
Официальный импорт защищён PostgreSQL advisory lock на комбинацию source/region/category. Параллельный admin-запрос получает `409`, а scheduler записывает безопасный skip и не делает второй HTTP-запрос к источнику.
|
||||
|
||||
## 5. Обновление
|
||||
|
||||
```bash
|
||||
@@ -109,6 +111,18 @@ docker compose --env-file .env.production -f compose.production.yaml exec -T api
|
||||
|
||||
Сроки и состав данных описаны в [`docs/data-retention.md`](../docs/data-retention.md). Автоматизацию включайте только после проверки dry-run на рабочем наборе.
|
||||
|
||||
После ручной проверки установите ежедневный timer. `maintenance.sh` использует lock от параллельного запуска и применяет retention только после успешного backup:
|
||||
|
||||
```bash
|
||||
sudo cp deploy/systemd/rf4spotter-maintenance.service /etc/systemd/system/
|
||||
sudo cp deploy/systemd/rf4spotter-maintenance.timer /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now rf4spotter-maintenance.timer
|
||||
systemctl list-timers rf4spotter-maintenance.timer
|
||||
```
|
||||
|
||||
Шаблон рассчитан на пользователя `rf4spotter`, каталог `/opt/rf4-spotter` и наличие `flock` из `util-linux`. Пользователь должен иметь доступ к Docker socket и каталогу `BACKUP_ROOT`. Результат каждого запуска хранится в systemd journal. Docker JSON-логи production-сервисов ограничены пятью файлами по 10 МБ на контейнер.
|
||||
|
||||
## 8. Мониторинг
|
||||
|
||||
После настройки DNS, TLS и первого backup выполните:
|
||||
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
repo=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
|
||||
cd "$repo"
|
||||
env_file=${COMPOSE_ENV_FILE:-.env.production}
|
||||
if [ ! -r "$env_file" ]; then
|
||||
echo "Production env file is not readable: $env_file" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
backup_root=${BACKUP_ROOT:-$(sed -n 's/^BACKUP_ROOT=//p' "$env_file" | tail -n 1)}
|
||||
case "$backup_root" in
|
||||
/*) ;;
|
||||
*) echo "BACKUP_ROOT must be an absolute path" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
exec 9>"$repo/.maintenance.lock"
|
||||
if ! flock -n 9; then
|
||||
echo "Another RF4 Spotter maintenance run is active" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export COMPOSE_ENV_FILE="$env_file"
|
||||
./deploy/backup.sh "$backup_root"
|
||||
compose="docker compose --env-file $env_file -f compose.production.yaml"
|
||||
$compose exec -T api python -m app.cli cleanup-retention
|
||||
$compose exec -T api python -m app.cli cleanup-retention --apply
|
||||
echo "Daily maintenance completed"
|
||||
@@ -0,0 +1,13 @@
|
||||
[Unit]
|
||||
Description=RF4 Spotter backup and retention maintenance
|
||||
After=docker.service network-online.target
|
||||
Requires=docker.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=rf4spotter
|
||||
WorkingDirectory=/opt/rf4-spotter
|
||||
ExecStart=/opt/rf4-spotter/deploy/maintenance.sh
|
||||
|
||||
# Backups can take longer on a growing database and object store.
|
||||
TimeoutStartSec=2h
|
||||
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=Run RF4 Spotter backup and retention daily
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 03:10:00
|
||||
RandomizedDelaySec=10min
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -17,6 +17,7 @@ cleanup() {
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
$compose up -d --wait db minio
|
||||
$compose run --rm minio-init
|
||||
$compose exec -T db sh -c 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "create table restore_drill (value text primary key); insert into restore_drill values ('"'"'database-ok'"'"');"' >/dev/null
|
||||
$compose run --rm --entrypoint /bin/sh storage-tool -c 'mc alias set local http://minio:9000 "$S3_ACCESS_KEY" "$S3_SECRET_KEY" >/dev/null; mc mb --ignore-existing "local/$S3_BUCKET" >/dev/null; printf object-ok | mc pipe "local/$S3_BUCKET/restore-drill.txt"' >/dev/null
|
||||
|
||||
|
||||
@@ -24,9 +24,12 @@ trap cleanup EXIT INT TERM
|
||||
$compose up --build -d --wait db minio api web
|
||||
curl -fsS "http://127.0.0.1:$BOOTSTRAP_API_PORT/ready" >/dev/null
|
||||
curl -fsS "http://127.0.0.1:$BOOTSTRAP_WEB_PORT/" >/dev/null
|
||||
curl -fsS -D - -o /dev/null "http://127.0.0.1:$BOOTSTRAP_API_PORT/health" | grep -qi '^x-frame-options: DENY'
|
||||
curl -fsS -D - -o /dev/null "http://127.0.0.1:$BOOTSTRAP_API_PORT/health" | grep -qi '^cross-origin-opener-policy: same-origin'
|
||||
test "$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select version_num from alembic_version')" = "0010"
|
||||
test "$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select count(*) from fish')" = "2"
|
||||
test "$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select count(*) from waterbody')" = "2"
|
||||
test "$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select count(*) from catch_report')" = "0"
|
||||
WEB_URL="http://127.0.0.1:$BOOTSTRAP_WEB_PORT" BOOTSTRAP_API_URL="http://127.0.0.1:$BOOTSTRAP_API_PORT" BOOTSTRAP_ADMIN_TOKEN=replace-with-at-least-32-random-characters npm --prefix apps/web run test:bootstrap
|
||||
curl -fsS -D - -o /dev/null -H 'Authorization: Bearer replace-with-at-least-32-random-characters' "http://127.0.0.1:$BOOTSTRAP_API_PORT/api/v1/admin/catch-reports" | grep -qi '^cache-control: no-store'
|
||||
echo "Production bootstrap passed from empty volumes"
|
||||
|
||||
+4
-3
@@ -2,7 +2,7 @@
|
||||
|
||||
Этот файл — рабочий источник правды по развитию проекта. После завершения задачи её чекбокс меняется с `[ ]` на `[x]`, рядом добавляется ссылка на коммит или короткое подтверждение проверки. Новые задачи добавляются в соответствующий этап, а не хранятся только в переписке.
|
||||
|
||||
Последняя сверка плана со спецификацией, кодом и UI/UX-аудитом: 6 сентября 2026 года.
|
||||
Последняя сверка плана со спецификацией, кодом и UI/UX-аудитом: 7 сентября 2026 года.
|
||||
|
||||
Обозначения:
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
- [x] Привести журнал импорта к административному контракту `GET /api/v1/admin/imports` с авторизацией, пагинацией и стабильной сортировкой (проверено API-тестом).
|
||||
- [x] Добавить HTTP-кэширование источника (`ETag`/`Last-Modified`, если источник их отдаёт) и сохранить диагностические метаданные ответа (миграция `0005`, тест условного запроса и `304`).
|
||||
- [x] Добавить планировщик импорта с безопасной частотой по умолчанию один раз в 60 минут; отдельный opt-in контейнер/процесс (профиль `scheduler`, обычным запуском не активируется).
|
||||
- [ ] Защитить официальный импорт PostgreSQL advisory lock или эквивалентом, чтобы ручной endpoint и несколько scheduler-процессов не импортировали одну категорию одновременно.
|
||||
- [x] Защитить официальный импорт PostgreSQL session advisory lock: одинаковая source/region/category не запускается параллельно, admin получает `409`, scheduler безопасно пропускает цикл; проверено двумя независимыми PostgreSQL-соединениями.
|
||||
- [x] Проверить актуальные `robots.txt` и условия использования перед включением расписания; результат записать в `docs/data-sources.md` (`robots.txt` вернул `404`; автоматический профиль оставлен выключенным до явного разрешения).
|
||||
- [x] Добавить интеграционные тесты: повторный импорт не создаёт дубликаты, сбой источника не удаляет данные, изменение DOM завершается понятной ошибкой.
|
||||
|
||||
@@ -58,13 +58,14 @@
|
||||
|
||||
- [x] Добавить health/readiness-проверки PostgreSQL, MinIO, API и импорта; отразить их в Compose (`/health` без зависимостей, `/ready` с компонентами и режимом обязательного импорта).
|
||||
- [x] Добавить host-side production monitor контейнеров, `/ready`, диска, свежести/целостности backup и срока TLS; подготовлены systemd timer и runbook, реальный канал уведомлений подключается на сервере.
|
||||
- [x] Автоматизировать ежедневную цепочку backup → dry-run → retention с блокировкой параллельного запуска; ограничить Docker JSON-логи пятью файлами по 10 МБ на сервис.
|
||||
- [x] Добавить структурированные JSON-логи без пользовательских секретов и персональных технических данных (whitelist полей, redaction, request ID; Uvicorn access-log отключён).
|
||||
- [x] Добавить Gitea Actions CI: backend tests, Astro check/build, E2E и применение всех миграций на чистой PostgreSQL; сохранять логи Compose и Playwright-артефакты при падении (`.gitea/workflows/ci.yml`).
|
||||
- [x] Добавить отдельный тест полного bootstrap: пустые production volumes → миграции `0010` → seed без демо-уловов → readiness → браузерная отправка и проверка moderation API (`deploy/test-production-bootstrap.sh`, 6 сентября 2026).
|
||||
- [x] Сделать seed устойчивым к частично заполненной БД: справочники досеиваются независимо, демо-уловы идемпотентны и принудительно отключены в production; повторный/частичный запуск покрыт конфигурационными и интеграционными проверками.
|
||||
- [ ] Проверить списочные API по требованию раздела 12: пагинация, предсказуемая сортировка и валидация фильтров для справочников, импортов, модерации и внешнего staging.
|
||||
- [ ] Проверить необходимые индексы PostgreSQL и планы запросов для activity, модерации, дедупликации и очистки rate limit; зафиксировать допустимый бюджет запросов пилота.
|
||||
- [ ] Провести security-проверку admin-аутентификации, CORS, security headers, загрузок и управления секретами; вынести допустимые origins в конфигурацию и исключить демонстрационные секреты в production-режиме.
|
||||
- [x] Провести security-проверку admin-аутентификации, CORS, headers, загрузок, контейнерных пользователей и секретов: двойная защита admin web/API, constant-time token, no-store, non-root API/web и отдельные MinIO root/app credentials; остаточные ограничения записаны в `docs/security-review.md`.
|
||||
- [x] Проверить авторизацию повторной загрузки скриншота: используется отдельный одноразовый случайный токен, в БД хранится только SHA-256, UUID заявки недостаточно.
|
||||
- [x] Определить сроки хранения ников, исходных payload, staging-наблюдений, moderation events и submission attempts; добавлены настраиваемая dry-run-first очистка, тест и `docs/data-retention.md`.
|
||||
- [x] Добавить резервное копирование и документированное восстановление PostgreSQL и MinIO: консистентные `pg_dump` и MinIO API mirror, контрольные суммы, runbook и успешный изолированный drill с намеренным удалением данных (6 сентября 2026).
|
||||
|
||||
@@ -28,13 +28,15 @@ echo $?
|
||||
```bash
|
||||
sudo cp deploy/systemd/rf4spotter-monitor.service /etc/systemd/system/
|
||||
sudo cp deploy/systemd/rf4spotter-monitor.timer /etc/systemd/system/
|
||||
sudo cp deploy/systemd/rf4spotter-maintenance.service /etc/systemd/system/
|
||||
sudo cp deploy/systemd/rf4spotter-maintenance.timer /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now rf4spotter-monitor.timer
|
||||
systemctl list-timers rf4spotter-monitor.timer
|
||||
sudo systemctl enable --now rf4spotter-monitor.timer rf4spotter-maintenance.timer
|
||||
systemctl list-timers 'rf4spotter-*'
|
||||
journalctl -u rf4spotter-monitor.service -n 20 --no-pager
|
||||
```
|
||||
|
||||
Сам timer записывает результат в journal. Для реального оповещения подключите failed unit к существующему серверному мониторингу либо настройте внешний HTTPS-monitor на `/ready`; уведомления должны приходить минимум по состояниям readiness, disk, backup и TLS. Не передавайте `.env.production` или вывод `docker compose config` внешнему сервису.
|
||||
Monitor timer записывает результат в journal. Maintenance timer ежедневно сначала создаёт backup, затем выполняет retention и не допускает параллельных запусков. Для реального оповещения подключите failed unit к существующему серверному мониторингу либо настройте внешний HTTPS-monitor на `/ready`; уведомления должны приходить минимум по состояниям readiness, disk, backup и TLS. Не передавайте `.env.production` или вывод `docker compose config` внешнему сервису.
|
||||
|
||||
## Реакция
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Production security review
|
||||
|
||||
Проверка выполнена 7 сентября 2026 года для закрытой альфы. Она охватывает код и Compose-конфигурацию; firewall, SSH, DNS и фактические права файлов проверяются отдельно на целевом сервере.
|
||||
|
||||
## Закрытые пункты
|
||||
|
||||
| Область | Реализация |
|
||||
|---|---|
|
||||
| Внешняя поверхность | наружу публикуются только Caddy `80/443`; PostgreSQL, API, web и MinIO находятся во внутренних сетях |
|
||||
| Admin web/API | `/admin/*` и `/api/v1/admin/*` требуют Caddy Basic Auth; API дополнительно требует длинный bearer token |
|
||||
| Сравнение токена | constant-time `hmac.compare_digest` |
|
||||
| CORS | production принимает только явно заданные HTTPS origins, wildcard запрещён конфигурацией |
|
||||
| Чувствительные ответы | административные ответы и создание заявки получают `Cache-Control: no-store` |
|
||||
| Browser headers | HSTS, `nosniff`, restrictive referrer/permissions, anti-framing CSP/X-Frame-Options и COOP |
|
||||
| Скриншоты | MIME/расширение/фактический формат, лимит размера и пикселей, повторное кодирование без EXIF, одноразовый upload token |
|
||||
| MinIO | Console не опубликована; root credentials отделены от application credentials и не передаются API |
|
||||
| Секреты | production fail-fast отклоняет короткие/демонстрационные значения; `.env.production` исключён из Git и должен иметь mode `0600` |
|
||||
| Контейнеры | API и web запускаются непривилегированными пользователями; для всех сервисов включён `no-new-privileges` |
|
||||
| Логи | query string, IP, Authorization и payload не журналируются; известные формы секретов редактируются |
|
||||
| Персональные данные | действует документированная retention-очистка, причём backup выполняется раньше удаления |
|
||||
|
||||
## Остаточные ограничения закрытой альфы
|
||||
|
||||
- Basic Auth и bearer token общие для всех модераторов: персональная атрибуция и отзыв одной учётной записи отсутствуют.
|
||||
- Нет MFA и отдельного identity provider; admin credentials следует передавать только ограниченному числу операторов и ротировать при любом подозрении.
|
||||
- Application user MinIO имеет встроенную политику `readwrite`; сейчас в инстансе используется один прикладной bucket. Перед появлением других bucket нужна отдельная bucket-scoped policy.
|
||||
- Пользователь `rf4spotter`, запускающий Docker/systemd, фактически обладает высокими правами через Docker socket. SSH к нему должен быть ограничен ключами и firewall/VPN.
|
||||
- Защита от volumetric DDoS и внешний WAF не входят в single-server Compose.
|
||||
|
||||
## Проверка на сервере
|
||||
|
||||
1. Убедиться, что `ss -lntup` не показывает наружу порты `4321`, `8000`, `9000`, `9001`, `5432`.
|
||||
2. Проверить `stat -c '%a %U:%G' .env.production`: ожидается `600 rf4spotter:rf4spotter`.
|
||||
3. Без Basic Auth запрос к `/admin/moderation` и `/api/v1/admin/catch-reports` должен вернуть `401` от Caddy.
|
||||
4. С Basic Auth, но без bearer token административный API должен вернуть `401` от FastAPI.
|
||||
5. Проверить security headers через `curl -I https://rf4spotter.ru/`.
|
||||
6. Убедиться, что `https://files.rf4spotter.ru/` не открывает Console, list buckets или анонимную запись.
|
||||
7. После проверки удалить команды с секретами из shell history либо вводить их через защищённый password manager.
|
||||
Reference in New Issue
Block a user