Compare commits

..
3 Commits
Author SHA1 Message Date
ik c45b4511b7 feat: serialize official record imports
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s
2026-09-07 07:54:07 +07:00
ik 687b4c9cb5 feat: harden production security boundaries 2026-09-07 07:51:25 +07:00
ik 2fa6b68279 feat: automate production maintenance 2026-09-07 07:44:11 +07:00
22 changed files with 304 additions and 27 deletions
+5 -3
View File
@@ -13,10 +13,12 @@ ADMIN_TOKEN=replace-with-at-least-32-random-characters
RATE_LIMIT_SECRET=replace-with-at-least-32-random-characters RATE_LIMIT_SECRET=replace-with-at-least-32-random-characters
ADMIN_BASIC_USER=rf4admin ADMIN_BASIC_USER=rf4admin
# Generate with: docker run --rm caddy:2.10.2-alpine caddy hash-password --plaintext 'YOUR PASSWORD' # 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 MINIO_ROOT_USER=replace-with-private-root-access-key
S3_SECRET_KEY=replace-with-at-least-32-random-characters 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 S3_BUCKET=catch-screenshots
OFFICIAL_RECORDS_URL=https://rf4game.de/records/region/RU/ OFFICIAL_RECORDS_URL=https://rf4game.de/records/region/RU/
+1
View File
@@ -3,6 +3,7 @@ __pycache__/
.pytest_cache/ .pytest_cache/
.cache/ .cache/
.env.production .env.production
.maintenance.lock
.venv/ .venv/
node_modules/ node_modules/
dist/ dist/
+3 -1
View File
@@ -19,8 +19,10 @@ Production-контур для домена `rf4spotter.ru`, TLS, секреты
Политика минимизации данных и ежедневная dry-run-first очистка описаны в [`docs/data-retention.md`](docs/data-retention.md). Политика минимизации данных и ежедневная dry-run-first очистка описаны в [`docs/data-retention.md`](docs/data-retention.md).
Host-side мониторинг контейнеров, readiness, диска, резервных копий и TLS описан в [`docs/production-monitoring.md`](docs/production-monitoring.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). Фактическое состояние 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-артефакты. 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 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`, когда источник их предоставляет. Ручной административный запуск также доступен через `POST /api/v1/admin/imports/official-records`, журнал — через `GET /api/v1/admin/imports`. Импорт сохраняет HTTP-метаданные и использует `ETag`/`Last-Modified`, когда источник их предоставляет.
+4 -2
View File
@@ -3,7 +3,9 @@ WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
COPY apps/api/requirements.txt . COPY apps/api/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY apps/api . RUN useradd --create-home --uid 10001 rf4
COPY rf4_research ./rf4_research COPY --chown=rf4:rf4 apps/api .
COPY --chown=rf4:rf4 rf4_research ./rf4_research
USER rf4
EXPOSE 8000 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"] CMD ["sh", "-c", "alembic upgrade head && python -m app.seed && uvicorn app.main:app --host 0.0.0.0 --port 8000 --no-access-log"]
+41 -1
View File
@@ -3,11 +3,12 @@ from __future__ import annotations
import hashlib import hashlib
import re import re
import time as time_module import time as time_module
from contextlib import contextmanager
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
from datetime import date, datetime, time, timezone from datetime import date, datetime, time, timezone
import httpx import httpx
from sqlalchemy import select from sqlalchemy import select, text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from rf4_research.official_parser import RecordsContractError, parse_official_records from rf4_research.official_parser import RecordsContractError, parse_official_records
@@ -24,6 +25,10 @@ class ImportSourceError(ValueError):
pass pass
class ImportAlreadyRunning(RuntimeError):
pass
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class RawRecord: class RawRecord:
region: str region: str
@@ -105,7 +110,42 @@ def fetch_records(
raise AssertionError("unreachable") 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: 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) 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.add(run)
session.commit() session.commit()
+9 -2
View File
@@ -22,7 +22,7 @@ from .activity import activity_rows
from .database import get_session from .database import get_session
from .config import settings from .config import settings
from .community_review import ExternalReviewError, map_observation, publish_observation, reject_observation 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 .logging_config import configure_logging
from .models import Bait, BaitKind, CatchReport, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody from .models import Bait, BaitKind, CatchReport, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody
from .readiness import readiness_report 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["X-Content-Type-Options"] = "nosniff"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()" 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": if settings.deployment_environment == "production":
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
return response return response
@@ -163,7 +167,8 @@ def records(
def _admin(authorization: Annotated[str | None, Header()] = None) -> str: 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"}) raise HTTPException(status_code=401, detail="invalid admin token", headers={"WWW-Authenticate": "Bearer"})
return "admin" return "admin"
@@ -195,6 +200,8 @@ def admin_start_official_import(db: Db, _: Annotated[str, Depends(_admin)]) -> O
region=settings.official_records_region, region=settings.official_records_region,
category=settings.official_records_category, 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: except (ImportSourceError, httpx.HTTPError) as exc:
raise HTTPException(status_code=502, detail=f"official records import failed: {exc}") from exc raise HTTPException(status_code=502, detail=f"official records import failed: {exc}") from exc
+11 -7
View File
@@ -9,7 +9,7 @@ from sqlalchemy.orm import Session
from .config import settings from .config import settings
from .database import SessionLocal from .database import SessionLocal
from .importer import import_records from .importer import ImportAlreadyRunning, import_records
from .logging_config import configure_logging from .logging_config import configure_logging
from .models import OfficialRecordImport from .models import OfficialRecordImport
@@ -36,12 +36,16 @@ def run_due_import() -> bool:
with SessionLocal() as session: with SessionLocal() as session:
if not import_is_due(session): if not import_is_due(session):
return False return False
run = import_records( try:
session, run = import_records(
url=settings.official_records_url, session,
region=settings.official_records_region, url=settings.official_records_url,
category=settings.official_records_category, 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( logger.info(
"official import completed", "official import completed",
extra={ extra={
+11
View File
@@ -10,6 +10,7 @@ from sqlalchemy.pool import StaticPool
from app.database import Base, get_session from app.database import Base, get_session
from app.community_importer import stage_observations from app.community_importer import stage_observations
from app.importer import ImportAlreadyRunning
from app.main import app from app.main import app
from app.models import Bait, BaitKind, CatchReport, ExternalEntityAlias, ExternalObservation, Fish, ImportStatus, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, Waterbody 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") response = client.get("/health?token=must-not-be-logged")
assert response.json() == {"status": "ok"} assert response.json() == {"status": "ok"}
assert len(response.headers["X-Request-ID"]) == 32 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: 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: 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"}) 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.status_code == 201
assert created.headers["Cache-Control"] == "no-store"
assert created.json()["moderation_status"] == "pending" assert created.json()["moderation_status"] == "pending"
report_id = created.json()["id"] report_id = created.json()["id"]
headers = {"Authorization": "Bearer change-me-in-production"} headers = {"Authorization": "Bearer change-me-in-production"}
pending = client.get("/api/v1/admin/catch-reports", headers=headers) pending = client.get("/api/v1/admin/catch-reports", headers=headers)
assert pending.status_code == 200 assert pending.status_code == 200
assert pending.headers["Cache-Control"] == "no-store"
assert any(item["id"] == report_id for item in pending.json()) 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"}) approved = client.patch(f"/api/v1/admin/catch-reports/{report_id}", headers=headers, json={"status": "approved", "reason": "fixture verified"})
assert approved.status_code == 200 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) listed = client.get("/api/v1/admin/imports?limit=1&offset=0", headers=headers)
assert listed.status_code == 200 assert listed.status_code == 200
assert listed.json()[0]["id"] == started.json()["id"] 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: 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()
+21 -1
View File
@@ -7,7 +7,7 @@ from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import Base 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 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" 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: def test_parser_and_import_are_idempotent() -> None:
html = FIXTURE.read_text(encoding="utf-8") html = FIXTURE.read_text(encoding="utf-8")
parsed = parse_html(html, region="RU", category="records") parsed = parse_html(html, region="RU", category="records")
+2
View File
@@ -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/package*.json ./
COPY --from=build /app/node_modules ./node_modules COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist COPY --from=build /app/dist ./dist
RUN chown -R node:node /app
USER node
EXPOSE 4321 EXPOSE 4321
CMD ["node", "./dist/server/entry.mjs"] CMD ["node", "./dist/server/entry.mjs"]
+37 -2
View File
@@ -1,5 +1,11 @@
name: rf4-spotter name: rf4-spotter
x-logging: &default-logging
driver: json-file
options:
max-size: "10m"
max-file: "5"
services: services:
proxy: proxy:
image: caddy:2.10.2-alpine image: caddy:2.10.2-alpine
@@ -23,6 +29,7 @@ services:
condition: service_healthy condition: service_healthy
networks: [edge, backend] networks: [edge, backend]
security_opt: [no-new-privileges:true] security_opt: [no-new-privileges:true]
logging: *default-logging
deploy: deploy:
resources: resources:
limits: {cpus: "0.50", memory: 256M} limits: {cpus: "0.50", memory: 256M}
@@ -43,6 +50,7 @@ services:
retries: 10 retries: 10
networks: [backend] networks: [backend]
security_opt: [no-new-privileges:true] security_opt: [no-new-privileges:true]
logging: *default-logging
deploy: deploy:
resources: resources:
limits: {cpus: "1.00", memory: 1G} limits: {cpus: "1.00", memory: 1G}
@@ -52,8 +60,8 @@ services:
command: server /data command: server /data
restart: unless-stopped restart: unless-stopped
environment: environment:
MINIO_ROOT_USER: ${S3_ACCESS_KEY:?Set S3_ACCESS_KEY} MINIO_ROOT_USER: ${MINIO_ROOT_USER:?Set MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY:?Set S3_SECRET_KEY} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?Set MINIO_ROOT_PASSWORD}
volumes: volumes:
- minio_data:/data - minio_data:/data
healthcheck: healthcheck:
@@ -63,10 +71,33 @@ services:
retries: 10 retries: 10
networks: [backend] networks: [backend]
security_opt: [no-new-privileges:true] security_opt: [no-new-privileges:true]
logging: *default-logging
deploy: deploy:
resources: resources:
limits: {cpus: "0.75", memory: 1G} 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: api:
build: build:
context: . context: .
@@ -99,6 +130,7 @@ services:
depends_on: depends_on:
db: {condition: service_healthy} db: {condition: service_healthy}
minio: {condition: service_healthy} minio: {condition: service_healthy}
minio-init: {condition: service_completed_successfully}
healthcheck: healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/ready')"] test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/ready')"]
interval: 10s interval: 10s
@@ -106,6 +138,7 @@ services:
retries: 12 retries: 12
networks: [backend, edge] networks: [backend, edge]
security_opt: [no-new-privileges:true] security_opt: [no-new-privileges:true]
logging: *default-logging
deploy: deploy:
resources: resources:
limits: {cpus: "1.00", memory: 1G} limits: {cpus: "1.00", memory: 1G}
@@ -128,6 +161,7 @@ services:
retries: 10 retries: 10
networks: [backend] networks: [backend]
security_opt: [no-new-privileges:true] security_opt: [no-new-privileges:true]
logging: *default-logging
deploy: deploy:
resources: resources:
limits: {cpus: "0.75", memory: 512M} limits: {cpus: "0.75", memory: 512M}
@@ -143,6 +177,7 @@ services:
- ${BACKUP_DIRECTORY:-./backups}:/backup - ${BACKUP_DIRECTORY:-./backups}:/backup
networks: [backend] networks: [backend]
security_opt: [no-new-privileges:true] security_opt: [no-new-privileges:true]
logging: *default-logging
networks: networks:
edge: edge:
+13
View File
@@ -14,6 +14,14 @@
reverse_proxy web:4321 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 @api path /api/* /health /ready
handle @api { handle @api {
reverse_proxy api:8000 reverse_proxy api:8000
@@ -29,6 +37,9 @@
X-Content-Type-Options "nosniff" X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin" Referrer-Policy "strict-origin-when-cross-origin"
Permissions-Policy "camera=(), microphone=(), geolocation=()" 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" Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff" X-Content-Type-Options "nosniff"
Referrer-Policy "no-referrer" 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
View File
@@ -17,11 +17,11 @@ Production-контур рассчитан на один Linux-сервер с D
```bash ```bash
cp .env.production.example .env.production cp .env.production.example .env.production
chmod 600 .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-ПАРОЛЬ' 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. Проверка и первый запуск ## 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. Автоматический scheduler не входит в production-файл. RF4MAP и RF4 Posts нельзя опрашивать чаще одного раза в 30 минут; до отдельной эксплуатационной задачи используйте только контролируемые ручные запуски и staging.
Официальный импорт защищён PostgreSQL advisory lock на комбинацию source/region/category. Параллельный admin-запрос получает `409`, а scheduler записывает безопасный skip и не делает второй HTTP-запрос к источнику.
## 5. Обновление ## 5. Обновление
```bash ```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 на рабочем наборе. Сроки и состав данных описаны в [`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. Мониторинг ## 8. Мониторинг
После настройки DNS, TLS и первого backup выполните: После настройки DNS, TLS и первого backup выполните:
+29
View File
@@ -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
+1
View File
@@ -17,6 +17,7 @@ cleanup() {
trap cleanup EXIT INT TERM trap cleanup EXIT INT TERM
$compose up -d --wait db minio $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 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 $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
+3
View File
@@ -24,9 +24,12 @@ trap cleanup EXIT INT TERM
$compose up --build -d --wait db minio api web $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_API_PORT/ready" >/dev/null
curl -fsS "http://127.0.0.1:$BOOTSTRAP_WEB_PORT/" >/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 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 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 waterbody')" = "2"
test "$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select count(*) from catch_report')" = "0" 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 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" echo "Production bootstrap passed from empty volumes"
+4 -3
View File
@@ -2,7 +2,7 @@
Этот файл — рабочий источник правды по развитию проекта. После завершения задачи её чекбокс меняется с `[ ]` на `[x]`, рядом добавляется ссылка на коммит или короткое подтверждение проверки. Новые задачи добавляются в соответствующий этап, а не хранятся только в переписке. Этот файл — рабочий источник правды по развитию проекта. После завершения задачи её чекбокс меняется с `[ ]` на `[x]`, рядом добавляется ссылка на коммит или короткое подтверждение проверки. Новые задачи добавляются в соответствующий этап, а не хранятся только в переписке.
Последняя сверка плана со спецификацией, кодом и UI/UX-аудитом: 6 сентября 2026 года. Последняя сверка плана со спецификацией, кодом и UI/UX-аудитом: 7 сентября 2026 года.
Обозначения: Обозначения:
@@ -24,7 +24,7 @@
- [x] Привести журнал импорта к административному контракту `GET /api/v1/admin/imports` с авторизацией, пагинацией и стабильной сортировкой (проверено API-тестом). - [x] Привести журнал импорта к административному контракту `GET /api/v1/admin/imports` с авторизацией, пагинацией и стабильной сортировкой (проверено API-тестом).
- [x] Добавить HTTP-кэширование источника (`ETag`/`Last-Modified`, если источник их отдаёт) и сохранить диагностические метаданные ответа (миграция `0005`, тест условного запроса и `304`). - [x] Добавить HTTP-кэширование источника (`ETag`/`Last-Modified`, если источник их отдаёт) и сохранить диагностические метаданные ответа (миграция `0005`, тест условного запроса и `304`).
- [x] Добавить планировщик импорта с безопасной частотой по умолчанию один раз в 60 минут; отдельный opt-in контейнер/процесс (профиль `scheduler`, обычным запуском не активируется). - [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] Проверить актуальные `robots.txt` и условия использования перед включением расписания; результат записать в `docs/data-sources.md` (`robots.txt` вернул `404`; автоматический профиль оставлен выключенным до явного разрешения).
- [x] Добавить интеграционные тесты: повторный импорт не создаёт дубликаты, сбой источника не удаляет данные, изменение DOM завершается понятной ошибкой. - [x] Добавить интеграционные тесты: повторный импорт не создаёт дубликаты, сбой источника не удаляет данные, изменение DOM завершается понятной ошибкой.
@@ -58,13 +58,14 @@
- [x] Добавить health/readiness-проверки PostgreSQL, MinIO, API и импорта; отразить их в Compose (`/health` без зависимостей, `/ready` с компонентами и режимом обязательного импорта). - [x] Добавить health/readiness-проверки PostgreSQL, MinIO, API и импорта; отразить их в Compose (`/health` без зависимостей, `/ready` с компонентами и режимом обязательного импорта).
- [x] Добавить host-side production monitor контейнеров, `/ready`, диска, свежести/целостности backup и срока TLS; подготовлены systemd timer и runbook, реальный канал уведомлений подключается на сервере. - [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] Добавить структурированные 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] Добавить 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] Добавить отдельный тест полного bootstrap: пустые production volumes → миграции `0010` → seed без демо-уловов → readiness → браузерная отправка и проверка moderation API (`deploy/test-production-bootstrap.sh`, 6 сентября 2026).
- [x] Сделать seed устойчивым к частично заполненной БД: справочники досеиваются независимо, демо-уловы идемпотентны и принудительно отключены в production; повторный/частичный запуск покрыт конфигурационными и интеграционными проверками. - [x] Сделать seed устойчивым к частично заполненной БД: справочники досеиваются независимо, демо-уловы идемпотентны и принудительно отключены в production; повторный/частичный запуск покрыт конфигурационными и интеграционными проверками.
- [ ] Проверить списочные API по требованию раздела 12: пагинация, предсказуемая сортировка и валидация фильтров для справочников, импортов, модерации и внешнего staging. - [ ] Проверить списочные API по требованию раздела 12: пагинация, предсказуемая сортировка и валидация фильтров для справочников, импортов, модерации и внешнего staging.
- [ ] Проверить необходимые индексы PostgreSQL и планы запросов для activity, модерации, дедупликации и очистки rate limit; зафиксировать допустимый бюджет запросов пилота. - [ ] Проверить необходимые индексы 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] Проверить авторизацию повторной загрузки скриншота: используется отдельный одноразовый случайный токен, в БД хранится только SHA-256, UUID заявки недостаточно.
- [x] Определить сроки хранения ников, исходных payload, staging-наблюдений, moderation events и submission attempts; добавлены настраиваемая dry-run-first очистка, тест и `docs/data-retention.md`. - [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). - [x] Добавить резервное копирование и документированное восстановление PostgreSQL и MinIO: консистентные `pg_dump` и MinIO API mirror, контрольные суммы, runbook и успешный изолированный drill с намеренным удалением данных (6 сентября 2026).
+5 -3
View File
@@ -28,13 +28,15 @@ echo $?
```bash ```bash
sudo cp deploy/systemd/rf4spotter-monitor.service /etc/systemd/system/ 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-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 daemon-reload
sudo systemctl enable --now rf4spotter-monitor.timer sudo systemctl enable --now rf4spotter-monitor.timer rf4spotter-maintenance.timer
systemctl list-timers rf4spotter-monitor.timer systemctl list-timers 'rf4spotter-*'
journalctl -u rf4spotter-monitor.service -n 20 --no-pager 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` внешнему сервису.
## Реакция ## Реакция
+38
View File
@@ -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.